diff --git a/services/actions/commit_status.go b/services/actions/commit_status.go index 95b848f4fb1..5a7f8f1f443 100644 --- a/services/actions/commit_status.go +++ b/services/actions/commit_status.go @@ -8,7 +8,6 @@ import ( "errors" "fmt" "path" - "strconv" "strings" actions_model "code.gitea.io/gitea/models/actions" @@ -143,57 +142,59 @@ func createCommitStatus(ctx context.Context, repo *repo_model.Repository, event, if wfs, err := jobparser.Parse(job.WorkflowPayload); err == nil && len(wfs) > 0 { runName = wfs[0].Name } - ctxName := fmt.Sprintf("%s / %s (%s)", runName, job.Name, event) - ctxName = strings.TrimSpace(ctxName) // git_model.NewCommitStatus also trims spaces + ctxName := strings.TrimSpace(fmt.Sprintf("%s / %s (%s)", runName, job.Name, event)) // git_model.NewCommitStatus also trims spaces state := toCommitStatus(job.Status) - if statuses, err := git_model.GetLatestCommitStatus(ctx, repo.ID, commitID, db.ListOptionsAll); err == nil { - for _, v := range statuses { - if v.Context == ctxName { - if v.State == state { - // no need to update - return nil - } - break - } - } - } else { + targetURL := fmt.Sprintf("%s/jobs/%d", run.Link(), job.ID) + description := toCommitStatusDescription(job) + + statuses, err := git_model.GetLatestCommitStatus(ctx, repo.ID, commitID, db.ListOptionsAll) + if err != nil { return fmt.Errorf("GetLatestCommitStatus: %w", err) } - - var description string - switch job.Status { - // TODO: if we want support description in different languages, we need to support i18n placeholders in it - case actions_model.StatusSuccess: - description = fmt.Sprintf("Successful in %s", job.Duration()) - case actions_model.StatusFailure: - description = fmt.Sprintf("Failing after %s", job.Duration()) - case actions_model.StatusCancelled: - description = "Has been cancelled" - case actions_model.StatusSkipped: - description = "Has been skipped" - case actions_model.StatusRunning: - description = "Has started running" - case actions_model.StatusWaiting: - description = "Waiting to run" - case actions_model.StatusBlocked: - description = "Blocked by required conditions" - default: - description = "Unknown status: " + strconv.Itoa(int(job.Status)) + for _, v := range statuses { + if v.Context == ctxName { + if v.State == state && v.TargetURL == targetURL && v.Description == description { + return nil + } + break + } } creator := user_model.NewActionsUser() status := git_model.CommitStatus{ SHA: commitID, - TargetURL: fmt.Sprintf("%s/jobs/%d", run.Link(), job.ID), + TargetURL: targetURL, Description: description, Context: ctxName, - CreatorID: creator.ID, State: state, + CreatorID: creator.ID, } return commitstatus_service.CreateCommitStatus(ctx, repo, creator, commitID, &status) } +func toCommitStatusDescription(job *actions_model.ActionRunJob) string { + switch job.Status { + // TODO: if we want support description in different languages, we need to support i18n placeholders in it + case actions_model.StatusSuccess: + return fmt.Sprintf("Successful in %s", job.Duration()) + case actions_model.StatusFailure: + return fmt.Sprintf("Failing after %s", job.Duration()) + case actions_model.StatusCancelled: + return "Has been cancelled" + case actions_model.StatusSkipped: + return "Has been skipped" + case actions_model.StatusRunning: + return "Has started running" + case actions_model.StatusWaiting: + return "Waiting to run" + case actions_model.StatusBlocked: + return "Blocked by required conditions" + default: + return fmt.Sprintf("Unknown status: %d", job.Status) + } +} + func toCommitStatus(status actions_model.Status) commitstatus.CommitStatusState { switch status { case actions_model.StatusSuccess: diff --git a/services/actions/commit_status_test.go b/services/actions/commit_status_test.go new file mode 100644 index 00000000000..6ff93933189 --- /dev/null +++ b/services/actions/commit_status_test.go @@ -0,0 +1,88 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "testing" + + actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/models/db" + git_model "code.gitea.io/gitea/models/git" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/commitstatus" + "code.gitea.io/gitea/modules/gitrepo" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateCommitStatus_Dedupe(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4}) + gitRepo, err := gitrepo.OpenRepository(t.Context(), repo) + require.NoError(t, err) + defer gitRepo.Close() + + commit, err := gitRepo.GetBranchCommit(repo.DefaultBranch) + require.NoError(t, err) + + run := &actions_model.ActionRun{ + ID: 99001, + RepoID: repo.ID, + Repo: repo, + WorkflowID: "status-dedupe-test.yaml", + } + job := &actions_model.ActionRunJob{ + ID: 99002, + RunID: run.ID, + RepoID: repo.ID, + Name: "status-dedupe-job", + Status: actions_model.StatusWaiting, + } + + expectedContext := "status-dedupe-test.yaml / status-dedupe-job (push)" + expectedTargetURL := run.Link() + "/jobs/99002" + + require.NoError(t, createCommitStatus(t.Context(), repo, "push", commit.ID.String(), run, job)) + + statuses := findCommitStatusesForContext(t, repo.ID, commit.ID.String(), expectedContext) + require.Len(t, statuses, 1) + assert.Equal(t, commitstatus.CommitStatusPending, statuses[0].State) + assert.Equal(t, "Waiting to run", statuses[0].Description) + assert.Equal(t, expectedTargetURL, statuses[0].TargetURL) + + job.Status = actions_model.StatusRunning + require.NoError(t, createCommitStatus(t.Context(), repo, "push", commit.ID.String(), run, job)) + + statuses = findCommitStatusesForContext(t, repo.ID, commit.ID.String(), expectedContext) + require.Len(t, statuses, 2) + assert.Equal(t, "Waiting to run", statuses[0].Description) + assert.Equal(t, commitstatus.CommitStatusPending, statuses[1].State) + assert.Equal(t, "Has started running", statuses[1].Description) + assert.Equal(t, expectedTargetURL, statuses[1].TargetURL) + + require.NoError(t, createCommitStatus(t.Context(), repo, "push", commit.ID.String(), run, job)) + statuses = findCommitStatusesForContext(t, repo.ID, commit.ID.String(), expectedContext) + assert.Len(t, statuses, 2) + + job.Status = actions_model.StatusSuccess + require.NoError(t, createCommitStatus(t.Context(), repo, "push", commit.ID.String(), run, job)) + statuses = findCommitStatusesForContext(t, repo.ID, commit.ID.String(), expectedContext) + require.Len(t, statuses, 3) + assert.Equal(t, commitstatus.CommitStatusSuccess, statuses[2].State) +} + +func findCommitStatusesForContext(t *testing.T, repoID int64, sha, context string) []*git_model.CommitStatus { + t.Helper() + + var statuses []*git_model.CommitStatus + err := db.GetEngine(t.Context()). + Where("repo_id = ? AND sha = ? AND context = ?", repoID, sha, context). + Asc("`index`"). + Find(&statuses) + require.NoError(t, err) + return statuses +}