Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
28044de
Batch-load related data in actions run, job, and task API endpoints
myers Mar 29, 2026
4c02528
Skip redundant LoadAttributes in convert layer when batch-loaded
myers Mar 29, 2026
0b1ed61
Add LoadJobAttributes to avoid loading steps in list endpoints
myers Apr 12, 2026
1ad4af2
remove over-defensive code
wxiaoguang Apr 12, 2026
6f96faf
fix wrong response, fix wrong link
wxiaoguang Apr 12, 2026
fab9df3
fix more
wxiaoguang Apr 12, 2026
8664e1e
fix
wxiaoguang Apr 12, 2026
809649e
merge tests
wxiaoguang Apr 12, 2026
d3292f3
Set run.Repo before LoadAttributes in ToActionWorkflowRun
myers Apr 12, 2026
7001ad1
Merge remote-tracking branch 'origin/main' into fix/actions-api-n-plus-1
myers Apr 12, 2026
4cd9fe9
Merge remote-tracking branch 'myers/fix/actions-api-n-plus-1' into fi…
myers Apr 12, 2026
63c31cb
Accept repo parameter in ToActionWorkflowRun to avoid redundant load
myers Apr 12, 2026
cc862a6
Fix test race by deferring PrepareTestEnv cleanup
myers Apr 12, 2026
e3efe3e
Revert "Accept repo parameter in ToActionWorkflowRun to avoid redunda…
myers Apr 12, 2026
79afaec
Guard against missing run in ActionJobList.LoadRuns
myers Apr 19, 2026
096ad61
Return 500 not 404 when a job has no run or repository
myers Apr 19, 2026
b681623
Merge branch 'main' into fix/actions-api-n-plus-1
silverwind Apr 19, 2026
8da8495
Skip LoadRepos/LoadOwners when ListRuns is repo-scoped
silverwind Apr 19, 2026
1b87e0d
Merge branch 'main' into fix/actions-api-n-plus-1
wxiaoguang Apr 29, 2026
09e720b
fix merge
wxiaoguang Apr 29, 2026
ff137f4
fix
wxiaoguang Apr 29, 2026
fb0161a
fix
wxiaoguang Apr 29, 2026
285deba
fix
wxiaoguang Apr 29, 2026
838ba0f
fix
wxiaoguang Apr 29, 2026
bebf60a
clean up
wxiaoguang Apr 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 12 additions & 15 deletions models/actions/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,11 @@ func init() {
db.RegisterModel(new(ActionRunIndex))
}

func (run *ActionRun) HTMLURL() string {
func (run *ActionRun) HTMLURL(ctxOpt ...context.Context) string {
if run.Repo == nil {
return ""
}
return fmt.Sprintf("%s/actions/runs/%d", run.Repo.HTMLURL(), run.ID)
return fmt.Sprintf("%s/actions/runs/%d", run.Repo.HTMLURL(ctxOpt...), run.ID)
}

func (run *ActionRun) Link() string {
Expand Down Expand Up @@ -120,11 +120,7 @@ func (run *ActionRun) RefTooltip() string {
}

// LoadAttributes load Repo TriggerUser if not loaded
func (run *ActionRun) LoadAttributes(ctx context.Context) (err error) {
if run == nil {
return nil
}

func (run *ActionRun) LoadAttributes(ctx context.Context) error {
if err := run.LoadRepo(ctx); err != nil {
return err
}
Expand All @@ -133,18 +129,19 @@ func (run *ActionRun) LoadAttributes(ctx context.Context) (err error) {
return err
}

if run.TriggerUser == nil {
run.TriggerUserID, run.TriggerUser, err = user_model.GetPossibleUserByID(ctx, run.TriggerUserID)
if err != nil {
return err
}
}
return run.LoadTriggerUser(ctx)
}

return nil
func (run *ActionRun) LoadTriggerUser(ctx context.Context) (err error) {
if run.TriggerUser != nil {
return nil
}
run.TriggerUserID, run.TriggerUser, err = user_model.GetPossibleUserByID(ctx, run.TriggerUserID)
return err
}

func (run *ActionRun) LoadRepo(ctx context.Context) error {
if run == nil || run.Repo != nil {
if run.Repo != nil {
return nil
}

Expand Down
4 changes: 0 additions & 4 deletions models/actions/run_attempt.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,6 @@ func (attempt *ActionRunAttempt) Duration() time.Duration {
}

func (attempt *ActionRunAttempt) LoadAttributes(ctx context.Context) (err error) {
if attempt == nil {
return nil
}

if attempt.Run == nil {
run, err := GetRunByRepoAndID(ctx, attempt.RepoID, attempt.RunID)
if err != nil {
Expand Down
4 changes: 0 additions & 4 deletions models/actions/run_job.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,10 +120,6 @@ func (job *ActionRunJob) LoadRepo(ctx context.Context) error {

// LoadAttributes load Run if not loaded
func (job *ActionRunJob) LoadAttributes(ctx context.Context) error {
if job == nil {
return nil
}

if err := job.LoadRun(ctx); err != nil {
return err
}
Expand Down
4 changes: 3 additions & 1 deletion models/actions/run_job_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,10 @@ func (jobs ActionJobList) LoadRuns(ctx context.Context, withRepo bool) error {
return err
}
for _, j := range jobs {
if j.RunID > 0 && j.Run == nil {
if j.Run == nil {
j.Run = runs[j.RunID]
}
if j.Run != nil {
j.Run.Repo = j.Repo
}
}
Expand Down
43 changes: 28 additions & 15 deletions models/actions/run_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"context"

"code.gitea.io/gitea/models/db"
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/container"
"code.gitea.io/gitea/modules/translation"
Expand All @@ -17,27 +18,39 @@ import (

type RunList []*ActionRun

// GetUserIDs returns a slice of user's id
func (runs RunList) GetUserIDs() []int64 {
return container.FilterSlice(runs, func(run *ActionRun) (int64, bool) {
return run.TriggerUserID, true
})
}

func (runs RunList) LoadTriggerUser(ctx context.Context) error {
userIDs := runs.GetUserIDs()
userIDs := container.FilterSlice(runs, func(run *ActionRun) (int64, bool) {
return run.TriggerUserID, run.TriggerUser == nil
})
users := make(map[int64]*user_model.User, len(userIDs))
if err := db.GetEngine(ctx).In("id", userIDs).Find(&users); err != nil {
return err
}
for _, run := range runs {
if run.TriggerUserID == user_model.ActionsUserID {
run.TriggerUser = user_model.NewActionsUser()
} else {
run.TriggerUser = users[run.TriggerUserID]
if run.TriggerUser == nil {
run.TriggerUser = user_model.NewGhostUser()
}
if run.TriggerUser != nil {
continue
}
run.TriggerUser = users[run.TriggerUserID]
if run.TriggerUserID < 0 {
run.TriggerUserID, run.TriggerUser, _ = user_model.GetPossibleUserByID(ctx, run.TriggerUserID)
} else if run.TriggerUser == nil {
run.TriggerUserID, run.TriggerUser, _ = user_model.GetPossibleUserByID(ctx, user_model.GhostUserID)
}
}
return nil
}

func (runs RunList) LoadRepos(ctx context.Context) error {
repoIDs := container.FilterSlice(runs, func(run *ActionRun) (int64, bool) {
return run.RepoID, run.Repo == nil
})
repos, err := repo_model.GetRepositoriesMapByIDs(ctx, repoIDs)
if err != nil {
return err
}
for _, run := range runs {
if run.Repo == nil {
run.Repo = repos[run.RepoID]
}
}
return nil
Expand Down
3 changes: 0 additions & 3 deletions models/actions/task.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,9 +125,6 @@ func (task *ActionTask) LoadJob(ctx context.Context) error {

// LoadAttributes load Job Steps if not loaded
func (task *ActionTask) LoadAttributes(ctx context.Context) error {
if task == nil {
return nil
}
if err := task.LoadJob(ctx); err != nil {
return err
}
Expand Down
5 changes: 3 additions & 2 deletions models/repo/repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -376,8 +376,9 @@ func (repo *Repository) CommitLink(commitID string) (result string) {
}

// APIURL returns the repository API URL
func (repo *Repository) APIURL() string {
return setting.AppURL + "api/v1/repos/" + url.PathEscape(repo.OwnerName) + "/" + url.PathEscape(repo.Name)
func (repo *Repository) APIURL(ctxOpt ...context.Context) string {
ctx := util.OptionalArg(ctxOpt, context.TODO())
return httplib.MakeAbsoluteURL(ctx, setting.AppSubURL+"/api/v1/repos/"+url.PathEscape(repo.OwnerName)+"/"+url.PathEscape(repo.Name))
}

// GetCommitsCountCacheKey returns cache key used for commits count caching.
Expand Down
2 changes: 1 addition & 1 deletion routers/api/v1/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -1272,7 +1272,7 @@ func Routes() *web.Router {
m.Delete("", reqRepoWriter(unit.TypeActions), repo.DeleteArtifact)
})
m.Get("/artifacts/{artifact_id}/zip", repo.DownloadArtifact)
}, reqRepoReader(unit.TypeActions), context.ReferencesGitRepo(true))
Comment thread
lunny marked this conversation as resolved.
}, reqRepoReader(unit.TypeActions))
m.Group("/keys", func() {
m.Combo("").Get(repo.ListDeployKeys).
Post(bind(api.CreateKeyOption{}), repo.CreateDeployKey)
Expand Down
15 changes: 11 additions & 4 deletions routers/api/v1/repo/action.go
Original file line number Diff line number Diff line change
Expand Up @@ -848,6 +848,12 @@ func ListActionTasks(ctx *context.APIContext) {
res := new(api.ActionTaskResponse)
res.TotalCount = total

taskList := actions_model.TaskList(tasks)
if err := taskList.LoadAttributes(ctx); err != nil {
ctx.APIErrorInternal(err)
return
}

Comment thread
myers marked this conversation as resolved.
res.Entries = make([]*api.ActionTask, len(tasks))
for i := range tasks {
convertedTask, err := convert.ToActionTask(ctx, tasks[i])
Expand All @@ -859,7 +865,7 @@ func ListActionTasks(ctx *context.APIContext) {
}

ctx.SetLinkHeader(total, listOptions.PageSize)
ctx.SetTotalCountHeader(total) // Duplicates api response field but it's better to set it for consistency
ctx.SetTotalCountHeader(total) // Duplicates api response field, but it's better to set it for consistency
ctx.JSON(http.StatusOK, &res)
}

Expand Down Expand Up @@ -1155,6 +1161,7 @@ func getCurrentRepoActionRunByID(ctx *context.APIContext) *actions_model.ActionR
ctx.APIErrorInternal(err)
return nil
}
run.Repo = ctx.Repo.Repository
return run
}

Expand Down Expand Up @@ -1226,7 +1233,7 @@ func GetWorkflowRun(ctx *context.APIContext) {
return
}

convertedRun, err := convert.ToActionWorkflowRun(ctx, ctx.Repo.Repository, run, nil)
convertedRun, err := convert.ToActionWorkflowRun(ctx, run, nil)
if err != nil {
ctx.APIErrorInternal(err)
return
Expand Down Expand Up @@ -1275,7 +1282,7 @@ func GetWorkflowRunAttempt(ctx *context.APIContext) {
return
}

convertedRun, err := convert.ToActionWorkflowRun(ctx, ctx.Repo.Repository, run, attempt)
convertedRun, err := convert.ToActionWorkflowRun(ctx, run, attempt)
if err != nil {
ctx.APIErrorInternal(err)
return
Expand Down Expand Up @@ -1330,7 +1337,7 @@ func RerunWorkflowRun(ctx *context.APIContext) {
return
}

convertedRun, err := convert.ToActionWorkflowRun(ctx, ctx.Repo.Repository, run, nil)
convertedRun, err := convert.ToActionWorkflowRun(ctx, run, nil)
if err != nil {
ctx.APIErrorInternal(err)
return
Expand Down
46 changes: 30 additions & 16 deletions routers/api/v1/shared/action.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"code.gitea.io/gitea/models/db"
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/container"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/setting"
Expand Down Expand Up @@ -62,6 +63,12 @@ func ListJobs(ctx *context.APIContext, ownerID, repoID, runID int64, runAttemptI
res := new(api.ActionWorkflowJobsResponse)
res.TotalCount = total

jobList := actions_model.ActionJobList(jobs)
if err := jobList.LoadAttributes(ctx, true); err != nil {
ctx.APIErrorInternal(err)
return
}

res.Entries = make([]*api.ActionWorkflowJob, len(jobs))

isRepoLevel := repoID != 0 && ctx.Repo != nil && ctx.Repo.Repository != nil && ctx.Repo.Repository.ID == repoID
Expand All @@ -70,11 +77,11 @@ func ListJobs(ctx *context.APIContext, ownerID, repoID, runID int64, runAttemptI
if isRepoLevel {
repository = ctx.Repo.Repository
} else {
Comment thread
myers marked this conversation as resolved.
repository, err = repo_model.GetRepositoryByID(ctx, jobs[i].RepoID)
if err != nil {
ctx.APIErrorInternal(err)
if jobs[i].Run == nil || jobs[i].Run.Repo == nil {
ctx.APIErrorInternal(fmt.Errorf("job %d is missing its run or repository", jobs[i].ID))
return
}
repository = jobs[i].Run.Repo
}

convertedWorkflowJob, err := convert.ToActionWorkflowJob(ctx, repository, nil, jobs[i])
Expand Down Expand Up @@ -169,21 +176,28 @@ func ListRuns(ctx *context.APIContext, ownerID, repoID int64) {
res := new(api.ActionWorkflowRunsResponse)
res.TotalCount = total

runList := actions_model.RunList(runs)
if err := runList.LoadTriggerUser(ctx); err != nil {
ctx.APIErrorInternal(err)
return
}

if err := runList.LoadRepos(ctx); err != nil {
ctx.APIErrorInternal(err)
return
}
repos := repo_model.RepositoryList(container.FilterSlice(runs, func(r *actions_model.ActionRun) (*repo_model.Repository, bool) {
return r.Repo, r.Repo != nil
}))
if err := repos.LoadOwners(ctx); err != nil {
ctx.APIErrorInternal(err)
return
}

res.Entries = make([]*api.ActionWorkflowRun, len(runs))
isRepoLevel := repoID != 0 && ctx.Repo != nil && ctx.Repo.Repository != nil && ctx.Repo.Repository.ID == repoID
for i := range runs {
var repository *repo_model.Repository
if isRepoLevel {
repository = ctx.Repo.Repository
} else {
repository, err = repo_model.GetRepositoryByID(ctx, runs[i].RepoID)
if err != nil {
ctx.APIErrorInternal(err)
return
}
}

convertedRun, err := convert.ToActionWorkflowRun(ctx, repository, runs[i], nil)
// TODO: load run attempts in batch
convertedRun, err := convert.ToActionWorkflowRun(ctx, runs[i], nil)
if err != nil {
ctx.APIErrorInternal(err)
return
Expand Down
3 changes: 2 additions & 1 deletion services/actions/notifier.go
Original file line number Diff line number Diff line change
Expand Up @@ -815,7 +815,8 @@ func (n *actionsNotifier) WorkflowRunStatusUpdate(ctx context.Context, repo *rep
log.Error("GetActionWorkflow: %v", err)
return
}
convertedRun, err := convert.ToActionWorkflowRun(ctx, repo, run, nil)
run.Repo = repo
convertedRun, err := convert.ToActionWorkflowRun(ctx, run, nil)
if err != nil {
log.Error("ToActionWorkflowRun: %v", err)
return
Expand Down
4 changes: 2 additions & 2 deletions services/convert/action_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,12 +115,12 @@ func TestToActionWorkflowRun_UsesTriggerEvent(t *testing.T) {

repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 803})

run.Repo = repo
// Scheduled runs keep Event as the registration event (push) and use TriggerEvent as the real trigger.
run.Event = "push"
run.TriggerEvent = "schedule"

apiRun, err := ToActionWorkflowRun(t.Context(), repo, run, nil)
apiRun, err := ToActionWorkflowRun(t.Context(), run, nil)
require.NoError(t, err)
assert.Equal(t, "schedule", apiRun.Event)
}
Loading