diff --git a/services/actions/notifier.go b/services/actions/notifier.go index 19d6be94207..5f7ee6fcea0 100644 --- a/services/actions/notifier.go +++ b/services/actions/notifier.go @@ -5,6 +5,7 @@ package actions import ( "context" + "errors" actions_model "code.gitea.io/gitea/models/actions" issues_model "code.gitea.io/gitea/models/issues" @@ -20,6 +21,7 @@ import ( "code.gitea.io/gitea/modules/repository" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/util" webhook_module "code.gitea.io/gitea/modules/webhook" "code.gitea.io/gitea/services/convert" notify_service "code.gitea.io/gitea/services/notify" @@ -805,7 +807,10 @@ func (n *actionsNotifier) WorkflowRunStatusUpdate(ctx context.Context, repo *rep } defer gitRepo.Close() - convertedWorkflow, err := convert.GetActionWorkflow(ctx, gitRepo, repo, run.WorkflowID) + convertedWorkflow, err := convert.GetActionWorkflowByRef(ctx, gitRepo, repo, run.WorkflowID, git.RefName(run.Ref)) + if err != nil && errors.Is(err, util.ErrNotExist) { + convertedWorkflow, err = convert.GetActionWorkflow(ctx, gitRepo, repo, run.WorkflowID) + } if err != nil { log.Error("GetActionWorkflow: %v", err) return diff --git a/services/convert/action_test.go b/services/convert/action_test.go new file mode 100644 index 00000000000..7080fc2f146 --- /dev/null +++ b/services/convert/action_test.go @@ -0,0 +1,109 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package convert + +import ( + "fmt" + "strings" + "testing" + + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/git/gitcmd" + "code.gitea.io/gitea/modules/util" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// buildWorkflowTestRepo creates a temporary git repository for testing GetActionWorkflow. +// The default branch "main" has no workflow files; "feature" and "release-v1" each add their own workflow file. +func buildWorkflowTestRepo(t *testing.T) string { + t.Helper() + ctx := t.Context() + tmpDir := t.TempDir() + + _, _, err := gitcmd.NewCommand("init").WithDir(tmpDir).RunStdString(ctx) + require.NoError(t, err) + + readme := "readme" + featureWF := "on: [push]\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: echo test\n" + releaseWF := "on: [push]\njobs:\n release:\n runs-on: ubuntu-latest\n steps:\n - run: echo release\n" + + // Build a git fast-import stream: + // :4 = initial commit on main (README.md only) + // :5 = feature branch commit (adds feature workflow) + // :6 = release commit from :4 (adds release workflow, tagged release-v1, not on main) + var sb strings.Builder + fmt.Fprintf(&sb, "blob\nmark :1\ndata %d\n%s\n", len(readme), readme) + fmt.Fprintf(&sb, "blob\nmark :2\ndata %d\n%s\n", len(featureWF), featureWF) + fmt.Fprintf(&sb, "blob\nmark :3\ndata %d\n%s\n", len(releaseWF), releaseWF) + fmt.Fprintf(&sb, "commit refs/heads/main\nmark :4\nauthor Test 1000000000 +0000\ncommitter Test 1000000000 +0000\ndata 14\ninitial commit\nM 100644 :1 README.md\n\n") + fmt.Fprintf(&sb, "commit refs/heads/feature\nmark :5\nauthor Test 1000000001 +0000\ncommitter Test 1000000001 +0000\ndata 12\nadd workflow\nfrom :4\nM 100644 :2 .gitea/workflows/my-workflow.yml\n\n") + fmt.Fprintf(&sb, "reset refs/pull/42/merge\nfrom :5\n\n") + fmt.Fprintf(&sb, "commit refs/heads/main\nmark :6\nauthor Test 1000000002 +0000\ncommitter Test 1000000002 +0000\ndata 16\nrelease workflow\nfrom :4\nM 100644 :3 .gitea/workflows/my-workflow.yml\n\n") + fmt.Fprintf(&sb, "reset refs/tags/release-v1\nfrom :6\n\n") + fmt.Fprintf(&sb, "reset refs/heads/main\nfrom :4\n\n") + fmt.Fprintf(&sb, "done\n") + + _, _, err = gitcmd.NewCommand("fast-import").WithDir(tmpDir).WithStdinBytes([]byte(sb.String())).RunStdString(ctx) + require.NoError(t, err) + + return tmpDir +} + +func TestGetActionWorkflow_FallbackRef(t *testing.T) { + ctx := t.Context() + + repoDir := buildWorkflowTestRepo(t) + + gitRepo, err := git.OpenRepository(ctx, repoDir) + require.NoError(t, err) + defer gitRepo.Close() + + repo := &repo_model.Repository{ + DefaultBranch: "main", + OwnerName: "test-owner", + Name: "test-repo", + Units: []*repo_model.RepoUnit{ + { + Type: unit.TypeActions, + Config: &repo_model.ActionsConfig{}, + }, + }, + } + + t.Run("returns error when workflow only on non-default branch", func(t *testing.T) { + _, err := GetActionWorkflow(ctx, gitRepo, repo, "my-workflow.yml") + require.Error(t, err) + assert.ErrorIs(t, err, util.ErrNotExist) + }) + + t.Run("returns workflow when found via ref", func(t *testing.T) { + wf, err := GetActionWorkflowByRef(ctx, gitRepo, repo, "my-workflow.yml", git.RefName("refs/heads/feature")) + require.NoError(t, err) + assert.Equal(t, "my-workflow.yml", wf.ID) + }) + + t.Run("returns workflow when found via pull ref", func(t *testing.T) { + wf, err := GetActionWorkflowByRef(ctx, gitRepo, repo, "my-workflow.yml", git.RefName("refs/pull/42/merge")) + require.NoError(t, err) + assert.Equal(t, "my-workflow.yml", wf.ID) + assert.Contains(t, wf.HTMLURL, "/src/commit/") + }) + + t.Run("returns workflow with tag link when found via tag ref", func(t *testing.T) { + wf, err := GetActionWorkflowByRef(ctx, gitRepo, repo, "my-workflow.yml", git.RefName("refs/tags/release-v1")) + require.NoError(t, err) + assert.Equal(t, "my-workflow.yml", wf.ID) + assert.Contains(t, wf.HTMLURL, "/src/tag/release-v1/") + }) + + t.Run("returns error when workflow missing from ref", func(t *testing.T) { + _, err := GetActionWorkflowByRef(ctx, gitRepo, repo, "nonexistent.yml", git.RefName("refs/heads/feature")) + require.Error(t, err) + assert.ErrorIs(t, err, util.ErrNotExist) + }) +} diff --git a/services/convert/convert.go b/services/convert/convert.go index 71d2ecb3333..f7a207622be 100644 --- a/services/convert/convert.go +++ b/services/convert/convert.go @@ -387,12 +387,15 @@ func ToActionWorkflowJob(ctx context.Context, repo *repo_model.Repository, task }, nil } -func getActionWorkflowEntry(ctx context.Context, repo *repo_model.Repository, commit *git.Commit, branchName, folder string, entry *git.TreeEntry) *api.ActionWorkflow { +func getActionWorkflowEntry(ctx context.Context, repo *repo_model.Repository, commit *git.Commit, refName git.RefName, folder string, entry *git.TreeEntry) *api.ActionWorkflow { cfgUnit := repo.MustGetUnit(ctx, unit.TypeActions) cfg := cfgUnit.ActionsConfig() workflowURL := fmt.Sprintf("%s/actions/workflows/%s", repo.APIURL(), util.PathEscapeSegments(entry.Name())) - workflowRepoURL := fmt.Sprintf("%s/src/branch/%s/%s/%s", repo.HTMLURL(ctx), util.PathEscapeSegments(branchName), util.PathEscapeSegments(folder), util.PathEscapeSegments(entry.Name())) + workflowRepoURL := fmt.Sprintf("%s/src/commit/%s/%s/%s", repo.HTMLURL(ctx), commit.ID.String(), util.PathEscapeSegments(folder), util.PathEscapeSegments(entry.Name())) + if refWebLinkPath := refName.RefWebLinkPath(); refWebLinkPath != "" { + workflowRepoURL = fmt.Sprintf("%s/src/%s/%s/%s", repo.HTMLURL(ctx), refWebLinkPath, util.PathEscapeSegments(folder), util.PathEscapeSegments(entry.Name())) + } badgeURL := fmt.Sprintf("%s/actions/workflows/%s/badge.svg?branch=%s", repo.HTMLURL(ctx), util.PathEscapeSegments(entry.Name()), url.QueryEscape(repo.DefaultBranch)) // See https://docs.github.com/en/rest/actions/workflows?apiVersion=2022-11-28#get-a-workflow @@ -457,7 +460,7 @@ func ListActionWorkflows(ctx context.Context, gitrepo *git.Repository, repo *rep workflows := make([]*api.ActionWorkflow, len(entries)) for i, entry := range entries { - workflows[i] = getActionWorkflowEntry(ctx, repo, defaultBranchCommit, repo.DefaultBranch, folder, entry) + workflows[i] = getActionWorkflowEntry(ctx, repo, defaultBranchCommit, git.RefNameFromBranch(repo.DefaultBranch), folder, entry) } return workflows, nil @@ -469,14 +472,35 @@ func GetActionWorkflow(ctx context.Context, gitrepo *git.Repository, repo *repo_ return nil, err } - folder, entries, err := actions.ListWorkflows(defaultBranchCommit) + return getActionWorkflowFromCommit(ctx, repo, defaultBranchCommit, git.RefNameFromBranch(repo.DefaultBranch), workflowID) +} + +func GetActionWorkflowByRef(ctx context.Context, gitrepo *git.Repository, repo *repo_model.Repository, workflowID string, ref git.RefName) (*api.ActionWorkflow, error) { + if ref == "" { + return nil, util.NewNotExistErrorf("workflow %q not found", workflowID) + } + + refCommitID, err := gitrepo.GetRefCommitID(ref.String()) + if err != nil { + return nil, err + } + refCommit, err := gitrepo.GetCommit(refCommitID) + if err != nil { + return nil, err + } + + return getActionWorkflowFromCommit(ctx, repo, refCommit, ref, workflowID) +} + +func getActionWorkflowFromCommit(ctx context.Context, repo *repo_model.Repository, commit *git.Commit, refName git.RefName, workflowID string) (*api.ActionWorkflow, error) { + folder, entries, err := actions.ListWorkflows(commit) if err != nil { return nil, err } for _, entry := range entries { if entry.Name() == workflowID { - return getActionWorkflowEntry(ctx, repo, defaultBranchCommit, repo.DefaultBranch, folder, entry), nil + return getActionWorkflowEntry(ctx, repo, commit, refName, folder, entry), nil } } diff --git a/services/webhook/notifier.go b/services/webhook/notifier.go index 0a5661009e5..2b301d4d583 100644 --- a/services/webhook/notifier.go +++ b/services/webhook/notifier.go @@ -5,6 +5,7 @@ package webhook import ( "context" + "errors" actions_model "code.gitea.io/gitea/models/actions" git_model "code.gitea.io/gitea/models/git" @@ -22,6 +23,7 @@ import ( "code.gitea.io/gitea/modules/repository" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/util" webhook_module "code.gitea.io/gitea/modules/webhook" "code.gitea.io/gitea/services/convert" notify_service "code.gitea.io/gitea/services/notify" @@ -1032,7 +1034,10 @@ func (*webhookNotifier) WorkflowRunStatusUpdate(ctx context.Context, repo *repo_ } defer gitRepo.Close() - convertedWorkflow, err := convert.GetActionWorkflow(ctx, gitRepo, repo, run.WorkflowID) + convertedWorkflow, err := convert.GetActionWorkflowByRef(ctx, gitRepo, repo, run.WorkflowID, git.RefName(run.Ref)) + if err != nil && errors.Is(err, util.ErrNotExist) { + convertedWorkflow, err = convert.GetActionWorkflow(ctx, gitRepo, repo, run.WorkflowID) + } if err != nil { log.Error("GetActionWorkflow: %v", err) return