mirror of
https://github.com/go-gitea/gitea.git
synced 2026-09-08 14:03:24 +09:00
refactor: prepare to decouple the "model migration" package and "models" package (#38533)
Migrations should never use model structs directly, because the model structs can be different in different releases. e.g. if one migration uses "User" model, it works in the early releases, then one day, when the User model changes, the migration breaks because it will use the new (incorrect) User model, it should only use the old User model. The same to "modules/structs". --------- Signed-off-by: wxiaoguang <wxiaoguang@gmail.com> Co-authored-by: delvh <dev.lh@web.de>
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v1_26
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modelmigration/migrationtest"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
migrationtest.MainTest(m)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v1_26
|
||||
|
||||
import (
|
||||
"gitea.dev/modelmigration/base"
|
||||
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
func AddActionsConcurrency(x base.EngineMigration) error {
|
||||
type ActionRun struct {
|
||||
RepoID int64 `xorm:"index(repo_concurrency)"`
|
||||
RawConcurrency string
|
||||
ConcurrencyGroup string `xorm:"index(repo_concurrency) NOT NULL DEFAULT ''"`
|
||||
ConcurrencyCancel bool `xorm:"NOT NULL DEFAULT FALSE"`
|
||||
}
|
||||
|
||||
if _, err := x.SyncWithOptions(xorm.SyncOptions{
|
||||
IgnoreDropIndices: true,
|
||||
}, new(ActionRun)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := x.Sync(new(ActionRun)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
type ActionRunJob struct {
|
||||
RepoID int64 `xorm:"index(repo_concurrency)"`
|
||||
RawConcurrency string
|
||||
IsConcurrencyEvaluated bool
|
||||
ConcurrencyGroup string `xorm:"index(repo_concurrency) NOT NULL DEFAULT ''"`
|
||||
ConcurrencyCancel bool `xorm:"NOT NULL DEFAULT FALSE"`
|
||||
}
|
||||
|
||||
if _, err := x.SyncWithOptions(xorm.SyncOptions{
|
||||
IgnoreDropIndices: true,
|
||||
}, new(ActionRunJob)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v1_26
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gitea.dev/modelmigration/base"
|
||||
)
|
||||
|
||||
func FixClosedMilestoneCompleteness(x base.EngineMigration) error {
|
||||
// Update all milestones to recalculate completeness with the new logic:
|
||||
// - Closed milestones with 0 issues should show 100%
|
||||
// - All other milestones should calculate based on closed/total ratio
|
||||
_, err := x.Exec("UPDATE `milestone` SET completeness=(CASE WHEN is_closed = ? AND num_issues = 0 THEN 100 ELSE 100*num_closed_issues/(CASE WHEN num_issues > 0 THEN num_issues ELSE 1 END) END)",
|
||||
true,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error updating milestone completeness: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v1_26
|
||||
|
||||
import "gitea.dev/modelmigration/base"
|
||||
|
||||
func FixMissedRepoIDWhenMigrateAttachments(x base.EngineMigration) error {
|
||||
_, err := x.Exec("UPDATE `attachment` SET `repo_id` = (SELECT `repo_id` FROM `issue` WHERE `issue`.`id` = `attachment`.`issue_id`) WHERE `issue_id` > 0 AND (`repo_id` IS NULL OR `repo_id` = 0);")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = x.Exec("UPDATE `attachment` SET `repo_id` = (SELECT `repo_id` FROM `release` WHERE `release`.`id` = `attachment`.`release_id`) WHERE `release_id` > 0 AND (`repo_id` IS NULL OR `repo_id` = 0);")
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v1_26
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modelmigration/migrationtest"
|
||||
"gitea.dev/modules/timeutil"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_FixMissedRepoIDWhenMigrateAttachments(t *testing.T) {
|
||||
type Attachment struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
UUID string `xorm:"uuid UNIQUE"`
|
||||
RepoID int64 `xorm:"INDEX"` // this should not be zero
|
||||
IssueID int64 `xorm:"INDEX"` // maybe zero when creating
|
||||
ReleaseID int64 `xorm:"INDEX"` // maybe zero when creating
|
||||
UploaderID int64 `xorm:"INDEX DEFAULT 0"` // Notice: will be zero before this column added
|
||||
CommentID int64 `xorm:"INDEX"`
|
||||
Name string
|
||||
DownloadCount int64 `xorm:"DEFAULT 0"`
|
||||
Size int64 `xorm:"DEFAULT 0"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||
}
|
||||
|
||||
type Issue struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
RepoID int64 `xorm:"INDEX"`
|
||||
}
|
||||
|
||||
type Release struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
RepoID int64 `xorm:"INDEX"`
|
||||
}
|
||||
|
||||
// Prepare and load the testing database
|
||||
x, deferrable := migrationtest.PrepareTestEnv(t, 0, new(Attachment), new(Issue), new(Release))
|
||||
defer deferrable()
|
||||
|
||||
require.NoError(t, FixMissedRepoIDWhenMigrateAttachments(x))
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v1_26
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modelmigration/base"
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
webhook_module "gitea.dev/modules/webhook"
|
||||
)
|
||||
|
||||
const (
|
||||
actionsRunPath = "/actions/runs/"
|
||||
|
||||
// Only commit status target URLs whose resolved run ID is smaller than this threshold are rewritten by this partial migration.
|
||||
// The fixed value 1000 is a conservative cutoff chosen to cover the smaller legacy run indexes that are most likely to be confused with ID-based URLs at runtime.
|
||||
// Larger legacy {run} or {job} numbers are usually easier to disambiguate. For example:
|
||||
// * /actions/runs/1200/jobs/1420 is most likely an ID-based URL, because a run should not contain more than 256 jobs.
|
||||
// * /actions/runs/1500/jobs/3 is most likely an index-based URL, because a job ID cannot be smaller than its run ID.
|
||||
// But URLs with small numbers, such as /actions/runs/5/jobs/6, are much harder to distinguish reliably.
|
||||
// This migration therefore prioritizes rewriting target URLs for runs in that lower range.
|
||||
legacyURLIDThreshold int64 = 1000
|
||||
)
|
||||
|
||||
type migrationRepository struct {
|
||||
ID int64
|
||||
OwnerName string
|
||||
Name string
|
||||
}
|
||||
|
||||
type migrationActionRun struct {
|
||||
ID int64
|
||||
RepoID int64
|
||||
Index int64
|
||||
CommitSHA string `xorm:"commit_sha"`
|
||||
Event webhook_module.HookEventType
|
||||
TriggerEvent string
|
||||
EventPayload string
|
||||
}
|
||||
|
||||
type migrationActionRunJob struct {
|
||||
ID int64
|
||||
RunID int64
|
||||
}
|
||||
|
||||
type migrationCommitStatus struct {
|
||||
ID int64
|
||||
RepoID int64
|
||||
TargetURL string
|
||||
}
|
||||
|
||||
// Frozen subsets of modules/structs payload types, decoded from stored
|
||||
// action_run.event_payload values. Inlined so the migration is insulated
|
||||
// from future field changes in modules/structs.
|
||||
type migrationPayloadCommit struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
type migrationPushPayload struct {
|
||||
HeadCommit *migrationPayloadCommit `json:"head_commit"`
|
||||
}
|
||||
|
||||
type migrationPRBranchInfo struct {
|
||||
Sha string `json:"sha"`
|
||||
}
|
||||
|
||||
type migrationPullRequest struct {
|
||||
Head *migrationPRBranchInfo `json:"head"`
|
||||
}
|
||||
|
||||
type migrationPullRequestPayload struct {
|
||||
PullRequest *migrationPullRequest `json:"pull_request"`
|
||||
}
|
||||
|
||||
type commitSHAAndRuns struct {
|
||||
commitSHA string
|
||||
runs map[int64]*migrationActionRun
|
||||
}
|
||||
|
||||
// FixCommitStatusTargetURLToUseRunAndJobID partially migrates legacy Actions
|
||||
// commit status target URLs to the new run/job ID-based form.
|
||||
//
|
||||
// Only rows whose resolved run ID is below legacyURLIDThreshold are rewritten.
|
||||
// This is because smaller legacy run indexes are more likely to collide with run ID URLs during runtime resolution,
|
||||
// so this migration prioritizes that lower range and leaves the remaining legacy target URLs to the web compatibility logic.
|
||||
func FixCommitStatusTargetURLToUseRunAndJobID(x base.EngineMigration) error {
|
||||
jobsByRunIDCache := make(map[int64][]int64)
|
||||
repoLinkCache := make(map[int64]string)
|
||||
groups, err := loadLegacyMigrationRunGroups(x)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for repoID, groupsBySHA := range groups {
|
||||
for _, group := range groupsBySHA {
|
||||
if err := migrateCommitStatusTargetURLForGroup(x, "commit_status", repoID, group.commitSHA, group.runs, jobsByRunIDCache, repoLinkCache); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := migrateCommitStatusTargetURLForGroup(x, "commit_status_summary", repoID, group.commitSHA, group.runs, jobsByRunIDCache, repoLinkCache); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadLegacyMigrationRunGroups(x base.EngineMigration) (map[int64]map[string]*commitSHAAndRuns, error) {
|
||||
var runs []migrationActionRun
|
||||
if err := x.Table("action_run").
|
||||
Where("id < ?", legacyURLIDThreshold).
|
||||
Cols("id", "repo_id", "`index`", "commit_sha", "event", "trigger_event", "event_payload").
|
||||
Find(&runs); err != nil {
|
||||
return nil, fmt.Errorf("query action_run: %w", err)
|
||||
}
|
||||
|
||||
groups := make(map[int64]map[string]*commitSHAAndRuns)
|
||||
for i := range runs {
|
||||
run := runs[i]
|
||||
commitID, err := getCommitStatusCommitID(&run)
|
||||
if err != nil {
|
||||
log.Warn("skip action_run id=%d when resolving commit status commit SHA: %v", run.ID, err)
|
||||
continue
|
||||
}
|
||||
if commitID == "" {
|
||||
// empty commitID means the run didn't create any commit status records, just skip
|
||||
continue
|
||||
}
|
||||
if groups[run.RepoID] == nil {
|
||||
groups[run.RepoID] = make(map[string]*commitSHAAndRuns)
|
||||
}
|
||||
if groups[run.RepoID][commitID] == nil {
|
||||
groups[run.RepoID][commitID] = &commitSHAAndRuns{
|
||||
commitSHA: commitID,
|
||||
runs: make(map[int64]*migrationActionRun),
|
||||
}
|
||||
}
|
||||
groups[run.RepoID][commitID].runs[run.Index] = &run
|
||||
}
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
func migrateCommitStatusTargetURLForGroup(
|
||||
x base.EngineMigration,
|
||||
table string,
|
||||
repoID int64,
|
||||
sha string,
|
||||
runs map[int64]*migrationActionRun,
|
||||
jobsByRunIDCache map[int64][]int64,
|
||||
repoLinkCache map[int64]string,
|
||||
) error {
|
||||
var rows []migrationCommitStatus
|
||||
if err := x.Table(table).
|
||||
Where("repo_id = ?", repoID).
|
||||
And("sha = ?", sha).
|
||||
Cols("id", "repo_id", "target_url").
|
||||
Find(&rows); err != nil {
|
||||
return fmt.Errorf("query %s for repo_id=%d sha=%s: %w", table, repoID, sha, err)
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
repoLink, err := getRepoLinkCached(x, repoLinkCache, row.RepoID)
|
||||
if err != nil || repoLink == "" {
|
||||
if err != nil {
|
||||
log.Warn("convert %s id=%d getRepoLinkCached: %v", table, row.ID, err)
|
||||
} else {
|
||||
log.Warn("convert %s id=%d: repo=%d not found", table, row.ID, row.RepoID)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
runNum, jobNum, ok := parseTargetURL(row.TargetURL, repoLink)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
run, ok := runs[runNum]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
jobID, ok, err := getJobIDByIndexCached(x, jobsByRunIDCache, run.ID, jobNum)
|
||||
if err != nil || !ok {
|
||||
if err != nil {
|
||||
log.Warn("convert %s id=%d getJobIDByIndexCached: %v", table, row.ID, err)
|
||||
} else {
|
||||
log.Warn("convert %s id=%d: job not found for run_id=%d job_index=%d", table, row.ID, run.ID, jobNum)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
oldURL := row.TargetURL
|
||||
newURL := fmt.Sprintf("%s%s%d/jobs/%d", repoLink, actionsRunPath, run.ID, jobID)
|
||||
if oldURL == newURL {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, err := x.Table(table).ID(row.ID).Cols("target_url").Update(&migrationCommitStatus{TargetURL: newURL}); err != nil {
|
||||
return fmt.Errorf("update %s id=%d target_url from %s to %s: %w", table, row.ID, oldURL, newURL, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getRepoLinkCached(x base.EngineMigration, cache map[int64]string, repoID int64) (string, error) {
|
||||
if link, ok := cache[repoID]; ok {
|
||||
return link, nil
|
||||
}
|
||||
repo := &migrationRepository{}
|
||||
has, err := x.Table("repository").Where("id=?", repoID).Get(repo)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !has {
|
||||
cache[repoID] = ""
|
||||
return "", nil
|
||||
}
|
||||
link := setting.AppSubURL + "/" + url.PathEscape(repo.OwnerName) + "/" + url.PathEscape(repo.Name)
|
||||
cache[repoID] = link
|
||||
return link, nil
|
||||
}
|
||||
|
||||
func getJobIDByIndexCached(x base.EngineMigration, cache map[int64][]int64, runID, jobIndex int64) (int64, bool, error) {
|
||||
jobIDs, ok := cache[runID]
|
||||
if !ok {
|
||||
var jobs []migrationActionRunJob
|
||||
if err := x.Table("action_run_job").Where("run_id=?", runID).Asc("id").Cols("id").Find(&jobs); err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
jobIDs = make([]int64, 0, len(jobs))
|
||||
for _, job := range jobs {
|
||||
jobIDs = append(jobIDs, job.ID)
|
||||
}
|
||||
cache[runID] = jobIDs
|
||||
}
|
||||
if jobIndex < 0 || jobIndex >= int64(len(jobIDs)) {
|
||||
return 0, false, nil
|
||||
}
|
||||
return jobIDs[jobIndex], true, nil
|
||||
}
|
||||
|
||||
func parseTargetURL(targetURL, repoLink string) (runNum, jobNum int64, ok bool) {
|
||||
prefix := repoLink + actionsRunPath
|
||||
if !strings.HasPrefix(targetURL, prefix) {
|
||||
return 0, 0, false
|
||||
}
|
||||
rest := targetURL[len(prefix):]
|
||||
|
||||
parts := strings.Split(rest, "/")
|
||||
if len(parts) == 3 && parts[1] == "jobs" {
|
||||
runNum, err1 := strconv.ParseInt(parts[0], 10, 64)
|
||||
jobNum, err2 := strconv.ParseInt(parts[2], 10, 64)
|
||||
if err1 != nil || err2 != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
return runNum, jobNum, true
|
||||
}
|
||||
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
func getCommitStatusCommitID(run *migrationActionRun) (string, error) {
|
||||
switch run.Event {
|
||||
case webhook_module.HookEventPush:
|
||||
payload, err := getPushEventPayload(run)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("getPushEventPayload: %w", err)
|
||||
}
|
||||
if payload.HeadCommit == nil {
|
||||
return "", errors.New("head commit is missing in event payload")
|
||||
}
|
||||
return payload.HeadCommit.ID, nil
|
||||
case webhook_module.HookEventPullRequest,
|
||||
webhook_module.HookEventPullRequestSync,
|
||||
webhook_module.HookEventPullRequestAssign,
|
||||
webhook_module.HookEventPullRequestLabel,
|
||||
webhook_module.HookEventPullRequestReviewRequest,
|
||||
webhook_module.HookEventPullRequestMilestone:
|
||||
payload, err := getPullRequestEventPayload(run)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("getPullRequestEventPayload: %w", err)
|
||||
}
|
||||
if payload.PullRequest == nil {
|
||||
return "", errors.New("pull request is missing in event payload")
|
||||
} else if payload.PullRequest.Head == nil {
|
||||
return "", errors.New("head of pull request is missing in event payload")
|
||||
}
|
||||
return payload.PullRequest.Head.Sha, nil
|
||||
case webhook_module.HookEventPullRequestReviewApproved,
|
||||
webhook_module.HookEventPullRequestReviewRejected,
|
||||
webhook_module.HookEventPullRequestReviewComment:
|
||||
payload, err := getPullRequestEventPayload(run)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("getPullRequestEventPayload: %w", err)
|
||||
}
|
||||
if payload.PullRequest == nil {
|
||||
return "", errors.New("pull request is missing in event payload")
|
||||
} else if payload.PullRequest.Head == nil {
|
||||
return "", errors.New("head of pull request is missing in event payload")
|
||||
}
|
||||
return payload.PullRequest.Head.Sha, nil
|
||||
case webhook_module.HookEventRelease:
|
||||
return run.CommitSHA, nil
|
||||
default:
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
|
||||
func getPushEventPayload(run *migrationActionRun) (*migrationPushPayload, error) {
|
||||
if run.Event != webhook_module.HookEventPush {
|
||||
return nil, fmt.Errorf("event %s is not a push event", run.Event)
|
||||
}
|
||||
var payload migrationPushPayload
|
||||
if err := json.Unmarshal([]byte(run.EventPayload), &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &payload, nil
|
||||
}
|
||||
|
||||
func getPullRequestEventPayload(run *migrationActionRun) (*migrationPullRequestPayload, error) {
|
||||
if !run.Event.IsPullRequest() && !run.Event.IsPullRequestReview() {
|
||||
return nil, fmt.Errorf("event %s is not a pull request event", run.Event)
|
||||
}
|
||||
var payload migrationPullRequestPayload
|
||||
if err := json.Unmarshal([]byte(run.EventPayload), &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &payload, nil
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v1_26
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modelmigration/base"
|
||||
"gitea.dev/modelmigration/migrationtest"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/test"
|
||||
|
||||
_ "gitea.dev/models/actions"
|
||||
_ "gitea.dev/models/git"
|
||||
_ "gitea.dev/models/repo"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_FixCommitStatusTargetURLToUseRunAndJobID(t *testing.T) {
|
||||
defer test.MockVariableValue(&setting.AppSubURL, "")()
|
||||
|
||||
type Repository struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
OwnerName string
|
||||
Name string
|
||||
}
|
||||
|
||||
type ActionRun struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
RepoID int64 `xorm:"index"`
|
||||
Index int64
|
||||
CommitSHA string `xorm:"commit_sha"`
|
||||
Event string
|
||||
TriggerEvent string
|
||||
EventPayload string `xorm:"LONGTEXT"`
|
||||
}
|
||||
|
||||
type ActionRunJob struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
RunID int64 `xorm:"index"`
|
||||
}
|
||||
|
||||
type CommitStatus struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
RepoID int64 `xorm:"index"`
|
||||
SHA string
|
||||
TargetURL string
|
||||
}
|
||||
|
||||
type CommitStatusSummary struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
RepoID int64 `xorm:"index"`
|
||||
SHA string `xorm:"VARCHAR(64) NOT NULL"`
|
||||
State string `xorm:"VARCHAR(7) NOT NULL"`
|
||||
TargetURL string
|
||||
}
|
||||
|
||||
x, deferable := migrationtest.PrepareTestEnv(t, 0,
|
||||
new(Repository),
|
||||
new(ActionRun),
|
||||
new(ActionRunJob),
|
||||
new(CommitStatus),
|
||||
new(CommitStatusSummary),
|
||||
)
|
||||
defer deferable()
|
||||
|
||||
require.NoError(t, FixCommitStatusTargetURLToUseRunAndJobID(x))
|
||||
|
||||
cases := []struct {
|
||||
table string
|
||||
id int64
|
||||
want string
|
||||
}{
|
||||
// Legacy URLs for runs whose resolved run IDs are below the threshold should be rewritten.
|
||||
{table: "commit_status", id: 10010, want: "/testuser/repo1/actions/runs/990/jobs/997"},
|
||||
{table: "commit_status", id: 10011, want: "/testuser/repo1/actions/runs/990/jobs/998"},
|
||||
{table: "commit_status", id: 10012, want: "/testuser/repo1/actions/runs/991/jobs/1997"},
|
||||
|
||||
// Runs whose resolved IDs are above the threshold are intentionally left unchanged.
|
||||
{table: "commit_status", id: 10013, want: "/testuser/repo1/actions/runs/9/jobs/0"},
|
||||
|
||||
// URLs that do not resolve cleanly as legacy Actions URLs should remain untouched.
|
||||
{table: "commit_status", id: 10014, want: "/otheruser/badrepo/actions/runs/7/jobs/0"},
|
||||
{table: "commit_status", id: 10015, want: "/testuser/repo1/actions/runs/10/jobs/0"},
|
||||
{table: "commit_status", id: 10016, want: "/testuser/repo1/actions/runs/7/jobs/3"},
|
||||
{table: "commit_status", id: 10017, want: "https://ci.example.com/build/123"},
|
||||
|
||||
// Already ID-based URLs are valid inputs and should not be rewritten again.
|
||||
{table: "commit_status", id: 10018, want: "/testuser/repo1/actions/runs/990/jobs/997"},
|
||||
|
||||
// The same rewrite rules apply to commit_status_summary rows.
|
||||
{table: "commit_status_summary", id: 10020, want: "/testuser/repo1/actions/runs/990/jobs/997"},
|
||||
{table: "commit_status_summary", id: 10021, want: "/testuser/repo1/actions/runs/9/jobs/0"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
assertTargetURL(t, x, tc.table, tc.id, tc.want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertTargetURL(t *testing.T, x base.EngineMigration, table string, id int64, want string) {
|
||||
t.Helper()
|
||||
|
||||
var row struct {
|
||||
TargetURL string
|
||||
}
|
||||
has, err := x.Table(table).Where("id=?", id).Cols("target_url").Get(&row)
|
||||
require.NoError(t, err)
|
||||
require.Truef(t, has, "row not found: table=%s id=%d", table, id)
|
||||
require.Equal(t, want, row.TargetURL)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v1_26
|
||||
|
||||
import (
|
||||
"gitea.dev/modelmigration/base"
|
||||
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
func AddDisabledToActionRunner(x base.EngineMigration) error {
|
||||
type ActionRunner struct {
|
||||
IsDisabled bool `xorm:"is_disabled NOT NULL DEFAULT false"`
|
||||
}
|
||||
|
||||
_, err := x.SyncWithOptions(xorm.SyncOptions{
|
||||
IgnoreDropIndices: true,
|
||||
}, new(ActionRunner))
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v1_26
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modelmigration/migrationtest"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_AddDisabledToActionRunner(t *testing.T) {
|
||||
type ActionRunner struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
Name string
|
||||
}
|
||||
|
||||
x, deferable := migrationtest.PrepareTestEnv(t, 0, new(ActionRunner))
|
||||
defer deferable()
|
||||
|
||||
_, err := x.Insert(&ActionRunner{Name: "runner"})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, AddDisabledToActionRunner(x))
|
||||
|
||||
var isDisabled bool
|
||||
has, err := x.SQL("SELECT is_disabled FROM action_runner WHERE id = ?", 1).Get(&isDisabled)
|
||||
require.NoError(t, err)
|
||||
require.True(t, has)
|
||||
require.False(t, isDisabled)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v1_26
|
||||
|
||||
import (
|
||||
"gitea.dev/modelmigration/base"
|
||||
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
func AddTokenPermissionsToActionRunJob(x base.EngineMigration) error {
|
||||
type ActionRunJob struct {
|
||||
TokenPermissions string `xorm:"JSON TEXT"`
|
||||
}
|
||||
_, err := x.SyncWithOptions(xorm.SyncOptions{IgnoreDropIndices: true}, new(ActionRunJob))
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v1_26
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gitea.dev/modelmigration/base"
|
||||
|
||||
"xorm.io/xorm/schemas"
|
||||
)
|
||||
|
||||
type UserBadge struct { //revive:disable-line:exported
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
BadgeID int64
|
||||
UserID int64
|
||||
}
|
||||
|
||||
// TableIndices implements xorm's TableIndices interface
|
||||
func (n *UserBadge) TableIndices() []*schemas.Index {
|
||||
indices := make([]*schemas.Index, 0, 1)
|
||||
ubUnique := schemas.NewIndex("unique_user_badge", schemas.UniqueType)
|
||||
ubUnique.AddColumn("user_id", "badge_id")
|
||||
indices = append(indices, ubUnique)
|
||||
return indices
|
||||
}
|
||||
|
||||
// AddUniqueIndexForUserBadge adds a compound unique indexes for user badge table
|
||||
// and it replaces an old index on user_id
|
||||
func AddUniqueIndexForUserBadge(x base.EngineMigration) error {
|
||||
// remove possible duplicated records in table user_badge
|
||||
type result struct {
|
||||
UserID int64
|
||||
BadgeID int64
|
||||
Cnt int
|
||||
}
|
||||
var results []result
|
||||
if err := x.Select("user_id, badge_id, count(*) as cnt").
|
||||
Table("user_badge").
|
||||
GroupBy("user_id, badge_id").
|
||||
Having("count(*) > 1").
|
||||
Find(&results); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, r := range results {
|
||||
if x.Dialect().URI().DBType == schemas.MSSQL {
|
||||
if _, err := x.Exec(fmt.Sprintf("delete from user_badge where id in (SELECT top %d id FROM user_badge WHERE user_id = ? and badge_id = ?)", r.Cnt-1), r.UserID, r.BadgeID); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
var ids []int64
|
||||
if err := x.SQL("SELECT id FROM user_badge WHERE user_id = ? and badge_id = ? limit ?", r.UserID, r.BadgeID, r.Cnt-1).Find(&ids); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := x.Table("user_badge").In("id", ids).Delete(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return x.Sync(new(UserBadge))
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v1_26
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modelmigration/migrationtest"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
type UserBadgeBefore struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
BadgeID int64
|
||||
UserID int64 `xorm:"INDEX"`
|
||||
}
|
||||
|
||||
func (UserBadgeBefore) TableName() string {
|
||||
return "user_badge"
|
||||
}
|
||||
|
||||
func Test_AddUniqueIndexForUserBadge(t *testing.T) {
|
||||
x, deferable := migrationtest.PrepareTestEnv(t, 0, new(UserBadgeBefore))
|
||||
defer deferable()
|
||||
if x == nil || t.Failed() {
|
||||
return
|
||||
}
|
||||
|
||||
testData := []*UserBadgeBefore{
|
||||
{UserID: 1, BadgeID: 1},
|
||||
{UserID: 1, BadgeID: 1}, // duplicate
|
||||
{UserID: 2, BadgeID: 1},
|
||||
{UserID: 1, BadgeID: 2},
|
||||
{UserID: 3, BadgeID: 3},
|
||||
{UserID: 3, BadgeID: 3}, // duplicate
|
||||
}
|
||||
|
||||
for _, data := range testData {
|
||||
_, err := x.Insert(data)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
// check that we have duplicates
|
||||
count, err := x.Where("user_id = ? AND badge_id = ?", 1, 1).Count(&UserBadgeBefore{})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, int64(2), count)
|
||||
|
||||
count, err = x.Where("user_id = ? AND badge_id = ?", 3, 3).Count(&UserBadgeBefore{})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, int64(2), count)
|
||||
|
||||
totalCount, err := x.Count(&UserBadgeBefore{})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, int64(6), totalCount)
|
||||
|
||||
// run the migration
|
||||
if err := AddUniqueIndexForUserBadge(x); err != nil {
|
||||
assert.NoError(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
// verify the duplicates were removed
|
||||
count, err = x.Where("user_id = ? AND badge_id = ?", 1, 1).Count(&UserBadgeBefore{})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, int64(1), count)
|
||||
|
||||
count, err = x.Where("user_id = ? AND badge_id = ?", 3, 3).Count(&UserBadgeBefore{})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, int64(1), count)
|
||||
|
||||
// check total count
|
||||
totalCount, err = x.Count(&UserBadgeBefore{})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, int64(4), totalCount)
|
||||
|
||||
// fail to insert a duplicate
|
||||
_, err = x.Insert(&UserBadge{UserID: 1, BadgeID: 1})
|
||||
assert.Error(t, err)
|
||||
|
||||
// succeed adding a non-duplicate
|
||||
_, err = x.Insert(&UserBadge{UserID: 4, BadgeID: 1})
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v1_26
|
||||
|
||||
import (
|
||||
"gitea.dev/modelmigration/base"
|
||||
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
func AddNameToWebhook(x base.EngineMigration) error {
|
||||
type Webhook struct {
|
||||
Name string `xorm:"VARCHAR(255) NOT NULL DEFAULT ''"`
|
||||
}
|
||||
_, err := x.SyncWithOptions(xorm.SyncOptions{IgnoreDropIndices: true}, new(Webhook))
|
||||
return err
|
||||
}
|
||||
Reference in New Issue
Block a user