fix: resolve actions commit status permission per repository (#38977)

Various pages did not display the correct action run list tooltips. Fix
those tooltips like here on the `/pulls` page:

`ctx.Repo.Permission` is the zero value outside a repository route, so
on `/pulls`, `/issues`, `/notifications/subscriptions` and the dashboard
repo list the commit status "Details" link was always stripped. The live
job status is looked up from that target URL, so running checks also
rendered as a static pending dot instead of a spinner.

Resolve the Actions unit permission per repository instead.

Also drops the releases page's gate on *loading* statuses, which hid
external CI results from anyone without Actions read; it now loads them
and hides only the URL, like every other page.

Co-authored-by: bircni <bircni@icloud.com>
This commit is contained in:
silverwind
2026-08-19 20:09:00 +02:00
committed by GitHub
parent e355c39e91
commit 4e813a262f
19 changed files with 98 additions and 97 deletions
+37 -13
View File
@@ -15,8 +15,12 @@ import (
asymkey_model "gitea.dev/models/asymkey"
"gitea.dev/models/db"
access_model "gitea.dev/models/perm/access"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unit"
user_model "gitea.dev/models/user"
"gitea.dev/modules/cache"
"gitea.dev/modules/cachegroup"
"gitea.dev/modules/commitstatus"
"gitea.dev/modules/git"
"gitea.dev/modules/log"
@@ -213,8 +217,8 @@ func (status *CommitStatus) LocaleString(lang translation.Locale) string {
return lang.TrString("repo.commitstatus." + status.State.String())
}
// HideActionsURL set `TargetURL` to an empty string if the status comes from Gitea Actions
func (status *CommitStatus) HideActionsURL(ctx context.Context) {
// hideActionsURL set `TargetURL` to an empty string if the status comes from Gitea Actions
func (status *CommitStatus) hideActionsURL(ctx context.Context) {
if _, ok := status.cutTargetURLGiteaActionsPrefix(ctx); ok {
status.TargetURL = ""
}
@@ -544,18 +548,38 @@ func HashCommitStatusContext(context string) string {
return fmt.Sprintf("%x", sha1.Sum([]byte(context)))
}
// CommitStatusesHideActionsURL hide Gitea Actions urls
func CommitStatusesHideActionsURL(ctx context.Context, statuses []*CommitStatus) {
idToRepos := make(map[int64]*repo_model.Repository)
// CommitStatusesApplyDoerPermission hides the Gitea Actions url of every status whose repository
// the doer cannot read the Actions unit of, so the "Details" link does not lead to a 404.
func CommitStatusesApplyDoerPermission(ctx context.Context, doer *user_model.User, statuses []*CommitStatus) {
for _, status := range statuses {
if status == nil {
continue
if status != nil && !statusRepoCanReadActions(ctx, doer, status) {
status.hideActionsURL(ctx)
}
if status.Repo == nil {
status.Repo = idToRepos[status.RepoID]
}
status.HideActionsURL(ctx)
idToRepos[status.RepoID] = status.Repo
}
}
// SignCommitsApplyDoerPermission is CommitStatusesApplyDoerPermission for a list of commits.
func SignCommitsApplyDoerPermission(ctx context.Context, doer *user_model.User, commits []*SignCommitWithStatuses) {
var statuses []*CommitStatus
for _, commit := range commits {
statuses = append(statuses, commit.Status)
statuses = append(statuses, commit.Statuses...)
}
CommitStatusesApplyDoerPermission(ctx, doer, statuses)
}
func statusRepoCanReadActions(ctx context.Context, doer *user_model.User, status *CommitStatus) bool {
perm, err := cache.GetWithContextCache(ctx, cachegroup.RepoUserPermission, access_model.RepoUserPermissionCacheKey(status.RepoID, doer),
func(ctx context.Context, _ string) (access_model.Permission, error) { // only runs on a cache miss
if err := status.loadRepository(ctx); err != nil {
return access_model.Permission{}, err
}
return access_model.GetDoerRepoPermission(ctx, status.Repo, doer)
},
)
if err != nil {
log.Error("GetDoerRepoPermission[%d]: %v", status.RepoID, err)
return false
}
return perm.CanRead(unit.TypeActions)
}
+14 -9
View File
@@ -4,11 +4,9 @@
package git_test
import (
"fmt"
"testing"
"time"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
git_model "gitea.dev/models/git"
repo_model "gitea.dev/models/repo"
@@ -233,17 +231,23 @@ func TestFindRepoRecentCommitStatusContexts(t *testing.T) {
}
}
func TestCommitStatusesHideActionsURL(t *testing.T) {
func TestCommitStatusesApplyDoerPermission(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
// repo4 is public and has the actions unit, repo2 is private and owned by someone else
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 791, RepoID: repo.ID})
assert.NoError(t, run.LoadAttributes(t.Context()))
otherRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
visibleURL := repo.Link() + "/actions/runs/1/jobs/1"
statuses := []*git_model.CommitStatus{
{
RepoID: repo.ID,
TargetURL: fmt.Sprintf("%s/jobs/%d", run.Link(), run.ID),
TargetURL: visibleURL,
},
{
RepoID: otherRepo.ID,
TargetURL: otherRepo.Link() + "/actions/runs/1/jobs/1",
},
{
RepoID: repo.ID,
@@ -251,9 +255,10 @@ func TestCommitStatusesHideActionsURL(t *testing.T) {
},
}
git_model.CommitStatusesHideActionsURL(t.Context(), statuses)
assert.Empty(t, statuses[0].TargetURL)
assert.Equal(t, "https://mycicd.org/1", statuses[1].TargetURL)
git_model.CommitStatusesApplyDoerPermission(t.Context(), doer, statuses)
assert.Equal(t, visibleURL, statuses[0].TargetURL)
assert.Empty(t, statuses[1].TargetURL)
assert.Equal(t, "https://mycicd.org/1", statuses[2].TargetURL)
}
func TestGetCountLatestCommitStatus(t *testing.T) {
+10
View File
@@ -658,6 +658,16 @@ func PermissionNoAccess() Permission {
return Permission{AccessMode: perm_model.AccessModeNone}
}
// RepoUserPermissionCacheKey is the cachegroup.RepoUserPermission key of a doer's
// permission on a repository. Producers and consumers must agree on it, so it lives here.
func RepoUserPermissionCacheKey(repoID int64, doer *user_model.User) string {
var doerID int64
if doer != nil {
doerID = doer.ID
}
return fmt.Sprintf("%d-%d", repoID, doerID)
}
// CanReadWorkflowCrossRepo checks whether the run can read workflow files from targetRepo.
func CanReadWorkflowCrossRepo(ctx context.Context, targetRepo *repo_model.Repository, run *actions_model.ActionRun) (bool, error) {
if err := run.LoadRepo(ctx); err != nil {