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
co-authored by bircni
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 {
+1 -5
View File
@@ -70,14 +70,10 @@ func Branches(ctx *context.Context) {
ctx.ServerError("LoadBranches", err)
return
}
if !ctx.Repo.Permission.CanRead(unit.TypeActions) {
for key := range commitStatuses {
git_model.CommitStatusesHideActionsURL(ctx, commitStatuses[key])
}
}
commitStatus := make(map[string]*git_model.CommitStatus)
for commitID, cs := range commitStatuses {
git_model.CommitStatusesApplyDoerPermission(ctx, ctx.Doer, cs)
commitStatus[commitID] = git_model.CalcCommitStatus(cs)
}
+2 -13
View File
@@ -17,7 +17,6 @@ import (
issues_model "gitea.dev/models/issues"
"gitea.dev/models/renderhelper"
repo_model "gitea.dev/models/repo"
unit_model "gitea.dev/models/unit"
user_model "gitea.dev/models/user"
"gitea.dev/modules/base"
"gitea.dev/modules/fileicon"
@@ -388,9 +387,7 @@ func Diff(ctx *context.Context) {
if err != nil {
log.Error("GetLatestCommitStatus: %v", err)
}
if !ctx.Repo.Permission.CanRead(unit_model.TypeActions) {
git_model.CommitStatusesHideActionsURL(ctx, statuses)
}
git_model.CommitStatusesApplyDoerPermission(ctx, ctx.Doer, statuses)
ctx.Data["CommitStatus"] = git_model.CalcCommitStatus(statuses)
ctx.Data["CommitStatuses"] = statuses
@@ -466,14 +463,6 @@ func processGitCommits(ctx *context.Context, gitCommits []*git.Commit) ([]*git_m
if err != nil {
return nil, err
}
if !ctx.Repo.Permission.CanRead(unit_model.TypeActions) {
for _, commit := range commits {
if commit.Status == nil {
continue
}
commit.Status.HideActionsURL(ctx)
git_model.CommitStatusesHideActionsURL(ctx, commit.Statuses)
}
}
git_model.SignCommitsApplyDoerPermission(ctx, ctx.Doer, commits)
return commits, nil
}
+1 -8
View File
@@ -13,11 +13,9 @@ import (
"strings"
"gitea.dev/models/db"
git_model "gitea.dev/models/git"
issues_model "gitea.dev/models/issues"
"gitea.dev/models/organization"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unit"
user_model "gitea.dev/models/user"
issue_indexer "gitea.dev/modules/indexer/issues"
db_indexer "gitea.dev/modules/indexer/issues/db"
@@ -640,16 +638,11 @@ func prepareIssueFilterAndList(ctx *context.Context, milestoneID int64, projectI
}
}
commitStatuses, lastStatus, err := pull_service.GetIssuesAllCommitStatus(ctx, issues)
commitStatuses, lastStatus, err := pull_service.GetIssuesAllCommitStatus(ctx, ctx.Doer, issues)
if err != nil {
ctx.ServerError("GetIssuesAllCommitStatus", err)
return
}
if !ctx.Repo.Permission.CanRead(unit.TypeActions) {
for key := range commitStatuses {
git_model.CommitStatusesHideActionsURL(ctx, commitStatuses[key])
}
}
if err := issues.LoadAttributes(ctx); err != nil {
ctx.ServerError("issues.LoadAttributes", err)
+1 -9
View File
@@ -794,15 +794,7 @@ func prepareIssueViewCommentsAndSidebarParticipants(ctx *context.Context, issue
ctx.ServerError("LoadCommentPushCommits", err)
return
}
if !ctx.Repo.Permission.CanRead(unit.TypeActions) {
for _, commit := range comment.Commits {
if commit.Status == nil {
continue
}
commit.Status.HideActionsURL(ctx)
git_model.CommitStatusesHideActionsURL(ctx, commit.Statuses)
}
}
git_model.SignCommitsApplyDoerPermission(ctx, ctx.Doer, comment.Commits)
} else if comment.Type == issues_model.CommentTypeAddTimeManual ||
comment.Type == issues_model.CommentTypeStopTracking ||
comment.Type == issues_model.CommentTypeDeleteTimeManual {
+1 -3
View File
@@ -432,9 +432,7 @@ func (prInfo *pullRequestViewInfo) prepareMergeBoxStatusCheckData(ctx *context.C
}
data.hasRequiredStatusContexts = len(requiredContexts) > 0
if !ctx.Repo.Permission.CanRead(unit.TypeActions) {
git_model.CommitStatusesHideActionsURL(ctx, commitStatuses)
}
git_model.CommitStatusesApplyDoerPermission(ctx, ctx.Doer, commitStatuses)
combinedCommitStatus := git_model.CalcCommitStatus(commitStatuses)
statusCheckData.ApproveLink = fmt.Sprintf("%s/actions/approve-all-checks?commit_id=%s", ctx.Repo.Repository.Link(), headCommitID)
statusCheckData.PullCommitStatuses = commitStatuses
+5 -3
View File
@@ -97,11 +97,12 @@ func getReleaseInfos(ctx *context.Context, opts *repo_model.FindReleasesOptions)
}
var ok bool
canReadActions := ctx.Repo.Permission.CanRead(unit.TypeActions)
// statuses describe the tagged code, and unlike the other pages showing them this one is not behind the code unit
canReadCode := ctx.Repo.Permission.CanRead(unit.TypeCode)
// Bulk-load commit statuses for all releases in one query.
var commitStatusMap map[string][]*git_model.CommitStatus
if canReadActions && len(releases) > 0 {
if canReadCode && len(releases) > 0 {
shas := make([]string, 0, len(releases))
for _, r := range releases {
shas = append(shas, r.Sha1)
@@ -140,8 +141,9 @@ func getReleaseInfos(ctx *context.Context, opts *repo_model.FindReleasesOptions)
Release: r,
}
if canReadActions {
if canReadCode {
statuses := commitStatusMap[r.Sha1]
git_model.CommitStatusesApplyDoerPermission(ctx, ctx.Doer, statuses)
info.CommitStatus = git_model.CalcCommitStatus(statuses)
info.CommitStatuses = statuses
}
+1 -3
View File
@@ -526,9 +526,7 @@ func SearchRepo(ctx *context.Context) {
ctx.JSON(http.StatusInternalServerError, nil)
return
}
if !ctx.Repo.Permission.CanRead(unit.TypeActions) {
git_model.CommitStatusesHideActionsURL(ctx, latestCommitStatuses)
}
git_model.CommitStatusesApplyDoerPermission(ctx, ctx.Doer, latestCommitStatuses)
results := make([]*repo_service.WebSearchRepository, len(repos))
for i, repo := range repos {
+1 -3
View File
@@ -143,9 +143,7 @@ func loadLatestCommitData(ctx *context.Context, latestCommit *git.Commit) bool {
if err != nil {
log.Error("GetLatestCommitStatus: %v", err)
}
if !ctx.Repo.Permission.CanRead(unit_model.TypeActions) {
git_model.CommitStatusesHideActionsURL(ctx, statuses)
}
git_model.CommitStatusesApplyDoerPermission(ctx, ctx.Doer, statuses)
ctx.Data["LatestCommitStatus"] = git_model.CalcCommitStatus(statuses)
ctx.Data["LatestCommitStatuses"] = statuses
+1 -7
View File
@@ -17,7 +17,6 @@ import (
activities_model "gitea.dev/models/activities"
asymkey_model "gitea.dev/models/asymkey"
"gitea.dev/models/db"
git_model "gitea.dev/models/git"
issues_model "gitea.dev/models/issues"
"gitea.dev/models/organization"
"gitea.dev/models/renderhelper"
@@ -554,16 +553,11 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
}
}
commitStatuses, lastStatus, err := pull_service.GetIssuesAllCommitStatus(ctx, issues)
commitStatuses, lastStatus, err := pull_service.GetIssuesAllCommitStatus(ctx, ctx.Doer, issues)
if err != nil {
ctx.ServerError("GetIssuesLastCommitStatus", err)
return
}
if !ctx.Repo.Permission.CanRead(unit.TypeActions) {
for key := range commitStatuses {
git_model.CommitStatusesHideActionsURL(ctx, commitStatuses[key])
}
}
// -------------------------------
// Fill stats to post to ctx.Data.
+1 -8
View File
@@ -11,11 +11,9 @@ import (
activities_model "gitea.dev/models/activities"
"gitea.dev/models/db"
git_model "gitea.dev/models/git"
issues_model "gitea.dev/models/issues"
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/base"
"gitea.dev/modules/container"
@@ -268,16 +266,11 @@ func NotificationSubscriptions(ctx *context.Context) {
return
}
commitStatuses, lastStatus, err := pull_service.GetIssuesAllCommitStatus(ctx, issues)
commitStatuses, lastStatus, err := pull_service.GetIssuesAllCommitStatus(ctx, ctx.Doer, issues)
if err != nil {
ctx.ServerError("GetIssuesAllCommitStatus", err)
return
}
if !ctx.Repo.Permission.CanRead(unit.TypeActions) {
for key := range commitStatuses {
git_model.CommitStatusesHideActionsURL(ctx, commitStatuses[key])
}
}
ctx.Data["CommitLastStatus"] = lastStatus
ctx.Data["CommitStatuses"] = commitStatuses
ctx.Data["Issues"] = issues
+5
View File
@@ -24,6 +24,7 @@ import (
user_model "gitea.dev/models/user"
"gitea.dev/modules/base"
"gitea.dev/modules/cache"
"gitea.dev/modules/cachegroup"
"gitea.dev/modules/git"
"gitea.dev/modules/httplib"
code_indexer "gitea.dev/modules/indexer/code"
@@ -427,6 +428,10 @@ func repoAssignmentLegacy(ctx *Context, data *repoAssignmentPrepareDataStruct) {
return
}
}
// publish it so code resolving the same permission later in this request reuses it
if c := cache.GetContextCache(ctx); c != nil {
c.Put(cachegroup.RepoUserPermission, access_model.RepoUserPermissionCacheKey(repo.ID, ctx.Doer), ctx.Repo.Permission)
}
if !ctx.Repo.Permission.HasAnyUnitAccessOrPublicAccess() && !canWriteAsMaintainer(ctx) {
if ctx.FormString("go-get") == "1" {
+1 -7
View File
@@ -5,7 +5,6 @@ package convert
import (
"context"
"fmt"
git_model "gitea.dev/models/git"
issues_model "gitea.dev/models/issues"
@@ -55,12 +54,7 @@ func ToAPIPullRequest(ctx context.Context, pr *issues_model.PullRequest, doer *u
return nil
}
var doerID int64
if doer != nil {
doerID = doer.ID
}
repoUserPerm, err := cache.GetWithContextCache(ctx, cachegroup.RepoUserPermission, fmt.Sprintf("%d-%d", pr.BaseRepoID, doerID),
repoUserPerm, err := cache.GetWithContextCache(ctx, cachegroup.RepoUserPermission, access_model.RepoUserPermissionCacheKey(pr.BaseRepoID, doer),
func(ctx context.Context, _ string) (access_model.Permission, error) {
return access_model.GetDoerRepoPermission(ctx, pr.BaseRepo, doer)
},
+10 -4
View File
@@ -931,8 +931,9 @@ func formatSquashMergeCommitMessages(commits []*git.Commit) string {
return util.UnsafeBytesToString(buf)
}
// GetIssuesAllCommitStatus returns a map of issue ID to a list of all statuses for the most recent commit as well as a map of issue ID to only the commit's latest status
func GetIssuesAllCommitStatus(ctx context.Context, issues issues_model.IssueList) (map[int64][]*git_model.CommitStatus, map[int64]*git_model.CommitStatus, error) {
// GetIssuesAllCommitStatus returns a map of issue ID to a list of all statuses for the most recent commit as well as a map of issue ID to only the commit's latest status.
// The returned statuses are redacted for doer.
func GetIssuesAllCommitStatus(ctx context.Context, doer *user_model.User, issues issues_model.IssueList) (map[int64][]*git_model.CommitStatus, map[int64]*git_model.CommitStatus, error) {
if err := issues.LoadPullRequests(ctx); err != nil {
return nil, nil, err
}
@@ -966,7 +967,7 @@ func GetIssuesAllCommitStatus(ctx context.Context, issues issues_model.IssueList
gitRepos[issue.RepoID] = gitRepo
}
statuses, lastStatus, err := getAllCommitStatus(ctx, gitRepo, issue.PullRequest)
statuses, lastStatus, err := getAllCommitStatus(ctx, doer, gitRepo, issue.PullRequest)
if err != nil {
log.Error("getAllCommitStatus: cant get commit statuses of pull [%d]: %v", issue.PullRequest.ID, err)
continue
@@ -978,13 +979,18 @@ func GetIssuesAllCommitStatus(ctx context.Context, issues issues_model.IssueList
}
// getAllCommitStatus get pr's commit statuses.
func getAllCommitStatus(ctx context.Context, gitRepo *git.Repository, pr *issues_model.PullRequest) (statuses []*git_model.CommitStatus, lastStatus *git_model.CommitStatus, err error) {
func getAllCommitStatus(ctx context.Context, doer *user_model.User, gitRepo *git.Repository, pr *issues_model.PullRequest) (statuses []*git_model.CommitStatus, lastStatus *git_model.CommitStatus, err error) {
sha, shaErr := gitRepo.GetRefCommitID(ctx, pr.GetGitHeadRefName())
if shaErr != nil {
return nil, nil, shaErr
}
statuses, err = git_model.GetLatestCommitStatus(ctx, pr.BaseRepo.ID, sha, db.ListOptionsAll)
for _, status := range statuses {
status.Repo = pr.BaseRepo // the repo is already loaded, spare the permission lookup a query
}
// CalcCommitStatus copies a TargetURL out of the statuses, so hide before combining
git_model.CommitStatusesApplyDoerPermission(ctx, doer, statuses)
lastStatus = git_model.CalcCommitStatus(statuses)
return statuses, lastStatus, err
}
@@ -124,6 +124,8 @@ func FindReposLatestCommitStatuses(ctx context.Context, repos []*repo_model.Repo
for i, repo := range repos {
if cv := getCommitStatusCache(repo.ID, repo.DefaultBranch); cv != nil {
results[i] = &git_model.CommitStatus{
RepoID: repo.ID,
Repo: repo,
State: commitstatus.CommitStatusState(cv.State),
TargetURL: cv.TargetURL,
}
@@ -163,6 +165,7 @@ func FindReposLatestCommitStatuses(ctx context.Context, repos []*repo_model.Repo
for _, summary := range summaryResults {
for i, repo := range repos {
if repo.ID == summary.RepoID {
summary.Repo = repo
results[i] = summary
repoSHAs = slices.DeleteFunc(repoSHAs, func(repoSHA git_model.RepoSHA) bool {
return repoSHA.RepoID == repo.ID
@@ -190,6 +193,7 @@ func FindReposLatestCommitStatuses(ctx context.Context, repos []*repo_model.Repo
if results[i] == nil {
results[i] = git_model.CalcCommitStatus(repoToItsLatestCommitStatuses[repo.ID])
if results[i] != nil {
results[i].Repo = repo
if err := updateCommitStatusCache(repo.ID, repo.DefaultBranch, results[i].State, results[i].TargetURL); err != nil {
log.Error("updateCommitStatusCache[%d:%s] failed: %v", repo.ID, repo.DefaultBranch, err)
}
+1 -1
View File
@@ -9,7 +9,7 @@
</span>
{{end}}
<div class="tippy-target">
<div class="flex-divided-list items-px-default">
<div class="commit-status-list flex-divided-list items-px-default">
{{template "repo/pulls/status_items" (dict "CommitStatuses" .Statuses)}}
</div>
</div>
+1 -1
View File
@@ -1826,7 +1826,7 @@ tbody.commit-list {
width: 100%;
}
.commit-status-item { /* the item can be used at 2 places: PR's merge box (commit-status-list), commit's status popup (no commit-status-list) */
.commit-status-item { /* the item is used in the PR merge box and the commit status popup, both are commit-status-list */
height: 40px;
padding-top: 0 !important; /* use "height" + "align items center", don't use padding-y (from the list container) to layout */
padding-bottom: 0 !important;