diff --git a/options/locale_next/locale_en-US.json b/options/locale_next/locale_en-US.json
index a707e564a7..edca1072a8 100644
--- a/options/locale_next/locale_en-US.json
+++ b/options/locale_next/locale_en-US.json
@@ -56,6 +56,19 @@
"relativetime.2months": "two months ago",
"relativetime.1year": "last year",
"relativetime.2years": "two years ago",
+ "repo.pulls.poster_manage_approval": "Manage approval",
+ "repo.pulls.poster_requires_approval": "Some workflows are waiting to be reviewed.",
+ "repo.pulls.poster_requires_approval.tooltip": "The author of this pull request is not trusted to run workflows triggered by a pull request created from a forked repository or with AGit. The workflows triggered by a `pull_request` event will not run until they are approved.",
+ "repo.pulls.poster_is_trusted": "The author of this pull request is always trusted to run workflows.",
+ "repo.pulls.poster_is_trusted.tooltip": "The author of this pull request is explicitly trusted to run workflows triggered by `pull_request` events.",
+ "repo.pulls.poster_trust_deny": "Deny",
+ "repo.pulls.poster_trust_deny.tooltip": "The workflows waiting approval will be canceled.",
+ "repo.pulls.poster_trust_once": "Approve once",
+ "repo.pulls.poster_trust_once.tooltip": "The workflows triggered by a `pull_request` event will run on this commit but will need to be approved for all future commits pushed to this pull request.",
+ "repo.pulls.poster_trust_always": "Approve always",
+ "repo.pulls.poster_trust_always.tooltip": "The workflows triggered by a `pull_request` event will run on this commit and there will be no need to approve runs from this pull request or future pull requests authored by the same user.",
+ "repo.pulls.poster_trust_revoke": "Revoke",
+ "repo.pulls.poster_trust_revoke.tooltip": "The author of this pull request will no longer be trusted to run workflows triggered by a `pull_request` event, each run will have to be manually approved.",
"repo.pulls.already_merged": "Merge failed: This pull request has already been merged.",
"repo.pulls.merged_title_desc": {
"one": "merged %[1]d commit from %[2]s into %[3]s %[4]s",
@@ -137,6 +150,7 @@
"admin.auths.allow_username_change.description": "Allow users to change their username in the profile settings",
"admin.dashboard.cleanup_offline_runners": "Cleanup offline runners",
"admin.dashboard.remove_resolved_reports": "Remove resolved reports",
+ "admin.dashboard.actions_action_user": "Revoke Forgejo Actions trust for inactive users",
"admin.config.security": "Security configuration",
"admin.config.global_2fa_requirement.title": "Global two-factor requirement",
"admin.config.global_2fa_requirement.none": "No",
diff --git a/routers/web/repo/pull.go b/routers/web/repo/pull.go
index dee630d96b..453637e7d8 100644
--- a/routers/web/repo/pull.go
+++ b/routers/web/repo/pull.go
@@ -18,6 +18,7 @@ import (
"strings"
"forgejo.org/models"
+ actions_model "forgejo.org/models/actions"
activities_model "forgejo.org/models/activities"
asymkey_model "forgejo.org/models/asymkey"
"forgejo.org/models/db"
@@ -44,6 +45,7 @@ import (
"forgejo.org/modules/util"
"forgejo.org/modules/web"
"forgejo.org/routers/utils"
+ actions_service "forgejo.org/services/actions"
asymkey_service "forgejo.org/services/asymkey"
"forgejo.org/services/automerge"
"forgejo.org/services/context"
@@ -764,6 +766,11 @@ func PrepareViewPullInfo(ctx *context.Context, issue *issues_model.Issue) *git.C
ctx.Data["IsNothingToCompare"] = true
}
+ PrepareViewPullInfoActions(ctx, pull)
+ if ctx.Written() {
+ return nil
+ }
+
if pull.IsWorkInProgress(ctx) {
ctx.Data["IsPullWorkInProgress"] = true
ctx.Data["WorkInProgressPrefix"] = pull.GetWorkInProgressPrefix(ctx)
@@ -1943,3 +1950,67 @@ func SetAllowEdits(ctx *context.Context) {
"allow_maintainer_edit": pr.AllowMaintainerEdit,
})
}
+
+func PrepareViewPullInfoActions(ctx *context.Context, pull *issues_model.PullRequest) {
+ canReadUnitActions := ctx.Repo.CanRead(unit.TypeActions)
+ ctx.Data["CanReadUnitActions"] = canReadUnitActions
+
+ if !canReadUnitActions {
+ return
+ }
+
+ PrepareViewPullInfoActionsTrust(ctx, pull)
+ if ctx.Written() {
+ return
+ }
+}
+
+func PrepareViewPullInfoActionsTrust(ctx *context.Context, pull *issues_model.PullRequest) {
+ trusted, err := actions_service.GetPullRequestPosterIsTrustedWithActions(ctx, pull)
+ if err != nil {
+ ctx.ServerError("GetPullRequestUserIsTrustedWithActions", err)
+ return
+ }
+ ctx.Data["PullRequestPosterIsNotTrustedWithActions"] = trusted == actions_service.UserIsNotTrustedWithActions
+ ctx.Data["PullRequestPosterIsExplicitlyTrustedWithActions"] = trusted == actions_service.UserIsExplicitlyTrustedWithActions
+ ctx.Data["PullRequestPosterIsImplicitlyTrustedWithActions"] = trusted == actions_service.UserIsImplicitlyTrustedWithActions
+
+ someRunsNeedApproval, err := actions_model.HasRunThatNeedApproval(ctx, pull.Issue.RepoID, pull.ID)
+ if err != nil {
+ ctx.ServerError("HasRunThatNeedApproval", err)
+ }
+ ctx.Data["SomePullRequestRunsNeedApproval"] = someRunsNeedApproval
+
+ ctx.Data["UserCanDelegateTrustWithPullRequest"] = context.CheckRepoDelegateActionTrust(ctx)
+}
+
+func UpdateTrustWithPullRequestActions(ctx *context.Context) {
+ pr, err := issues_model.GetPullRequestByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index"))
+ if err != nil {
+ if issues_model.IsErrPullRequestNotExist(err) {
+ ctx.NotFound("GetPullRequestByIndex", err)
+ } else {
+ ctx.ServerError("GetPullRequestByIndex", err)
+ }
+ return
+ }
+
+ trust := ctx.FormString("trust")
+
+ if err := actions_service.UpdateTrustedWithPullRequest(ctx, ctx.Doer.ID, pr, actions_service.TrustUpdate(trust)); err != nil {
+ ctx.Error(http.StatusInternalServerError, err.Error())
+ return
+ }
+
+ if err := pr.LoadIssue(ctx); err != nil {
+ ctx.Error(http.StatusInternalServerError, err.Error())
+ return
+ }
+
+ if err := pr.Issue.LoadRepo(ctx); err != nil {
+ ctx.Error(http.StatusInternalServerError, err.Error())
+ return
+ }
+
+ ctx.Redirect(fmt.Sprintf("%s#pull-request-trust-panel", pr.Issue.Link()))
+}
diff --git a/routers/web/web.go b/routers/web/web.go
index fb0306e6d2..4e064ed40a 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -846,6 +846,7 @@ func registerRoutes(m *web.Route) {
reqRepoProjectsWriter := context.RequireRepoWriter(unit.TypeProjects)
reqRepoActionsReader := context.RequireRepoReader(unit.TypeActions)
reqRepoActionsWriter := context.RequireRepoWriter(unit.TypeActions)
+ reqRepoDelegateActionTrust := context.RequireRepoDelegateActionTrust()
reqPackageAccess := func(accessMode perm.AccessMode) func(ctx *context.Context) {
return func(ctx *context.Context) {
@@ -1215,6 +1216,7 @@ func registerRoutes(m *web.Route) {
m.Group("/{type:issues|pulls}", func() {
m.Group("/{index}", func() {
m.Post("/title", repo.UpdateIssueTitle)
+ m.Post("/action-user-trust", reqRepoActionsReader, actions.MustEnableActions, reqRepoDelegateActionTrust, repo.UpdateTrustWithPullRequestActions)
m.Post("/content", repo.UpdateIssueContent)
m.Post("/deadline", web.Bind(structs.EditDeadlineOption{}), repo.UpdateIssueDeadline)
m.Post("/watch", repo.IssueWatch)
diff --git a/services/context/permission.go b/services/context/permission.go
index b6af87f912..483758141c 100644
--- a/services/context/permission.go
+++ b/services/context/permission.go
@@ -111,6 +111,19 @@ func RequireRepoReaderOr(unitTypes ...unit.Type) func(ctx *Context) {
}
}
+func RequireRepoDelegateActionTrust() func(ctx *Context) {
+ return func(ctx *Context) {
+ if CheckRepoDelegateActionTrust(ctx) {
+ return
+ }
+ ctx.NotFound(ctx.Req.URL.RequestURI(), nil)
+ }
+}
+
+func CheckRepoDelegateActionTrust(ctx *Context) bool {
+ return ctx.Repo.IsAdmin() || (ctx.IsSigned && ctx.Doer.IsAdmin) || ctx.Repo.CanWrite(unit.TypeActions)
+}
+
// CheckRepoScopedToken check whether personal access token has repo scope
func CheckRepoScopedToken(ctx *Context, repo *repo_model.Repository, level auth_model.AccessTokenScopeLevel) {
if !ctx.IsBasicAuth || ctx.Data["IsApiToken"] != true {
diff --git a/templates/repo/actions/view.tmpl b/templates/repo/actions/view.tmpl
index 5b8b44639e..6adc91417c 100644
--- a/templates/repo/actions/view.tmpl
+++ b/templates/repo/actions/view.tmpl
@@ -12,7 +12,7 @@
data-workflow-url="{{.WorkflowURL}}"
data-initial-post-response="{{.InitialData}}"
data-initial-artifacts-response="{{.InitialArtifactsData}}"
- data-locale-approve="{{ctx.Locale.Tr "repo.diff.review.approve"}}"
+ data-locale-approve="{{ctx.Locale.Tr "repo.pulls.poster_manage_approval"}}"
data-locale-cancel="{{ctx.Locale.Tr "cancel"}}"
data-locale-rerun="{{ctx.Locale.Tr "rerun"}}"
data-locale-rerun-all="{{ctx.Locale.Tr "rerun_all"}}"
diff --git a/templates/repo/issue/view_content/pull.tmpl b/templates/repo/issue/view_content/pull.tmpl
index 101474d0ff..21f0d613ed 100644
--- a/templates/repo/issue/view_content/pull.tmpl
+++ b/templates/repo/issue/view_content/pull.tmpl
@@ -94,6 +94,7 @@
{{end}}
+ {{template "repo/pulls/trust" .}}
{{template "repo/issue/view_content/update_branch_by_merge" $}}
{{else if .Issue.PullRequest.IsChecking}}
@@ -187,6 +188,7 @@
{{end}}
{{end}}
+ {{template "repo/pulls/trust" .}}
{{template "repo/issue/view_content/update_branch_by_merge" $}}
{{if .Issue.PullRequest.IsEmpty}}
diff --git a/templates/repo/pulls/trust.tmpl b/templates/repo/pulls/trust.tmpl
new file mode 100644
index 0000000000..f715f27bbf
--- /dev/null
+++ b/templates/repo/pulls/trust.tmpl
@@ -0,0 +1,61 @@
+{{/*
+Template Attributes:
+* CanReadUnitActions: true if the actions unit is active and readable
+* SomePullRequestRunsNeedApproval: true if there is at least one run waiting for approval
+* UserCanDelegateTrustWithPullRequest: true if the user can delegate trust in the context of pull requests
+* PullRequestPosterIsNotTrustedWithActions: true if the poster of the pull request needs to be approved to run actions
+* PullRequestPosterIsExplicitlyTrustedWithActions: true if the poster of the pull request is trusted to run actions (once or always)
+* PullRequestPosterIsImplicitlyTrustedWithActions: true if the poster of the pull request is trusted to run actions because of elevated permissions
+* Link: URL to the pull request
+*/}}
+
+{{if .CanReadUnitActions}}
+ {{if and .UserCanDelegateTrustWithPullRequest .PullRequestPosterIsExplicitlyTrustedWithActions}}
+
+
+
+
+ {{ctx.Locale.Tr "repo.pulls.poster_is_trusted" "https://forgejo.org/docs/latest/user/actions/security-pull-request/"}}
+
+
+
+
+
+
+ {{else if and .PullRequestPosterIsNotTrustedWithActions .SomePullRequestRunsNeedApproval}}
+
+
+
+
+ {{svg "octicon-alert" 16 "text red"}}
+ {{ctx.Locale.Tr "repo.pulls.poster_requires_approval" "https://forgejo.org/docs/latest/user/actions/security-pull-request/"}}
+
+ {{if .UserCanDelegateTrustWithPullRequest}}
+
+ {{end}}
+
+
+ {{end}}
+{{end}}
diff --git a/tests/integration/actions_trust_test.go b/tests/integration/actions_trust_test.go
new file mode 100644
index 0000000000..7a683d5625
--- /dev/null
+++ b/tests/integration/actions_trust_test.go
@@ -0,0 +1,355 @@
+// Copyright 2025 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+
+ actions_model "forgejo.org/models/actions"
+ issues_model "forgejo.org/models/issues"
+ repo_model "forgejo.org/models/repo"
+ unit_model "forgejo.org/models/unit"
+ "forgejo.org/models/unittest"
+ user_model "forgejo.org/models/user"
+ actions_module "forgejo.org/modules/actions"
+ "forgejo.org/modules/git"
+ "forgejo.org/modules/structs"
+ actions_service "forgejo.org/services/actions"
+ pull_service "forgejo.org/services/pull"
+ repo_service "forgejo.org/services/repository"
+ files_service "forgejo.org/services/repository/files"
+ "forgejo.org/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func actionsTrustTestClickTrustPanel(t *testing.T, session *TestSession, url, trust string) {
+ // an admin approves the run once
+ req := NewRequest(t, "GET", url)
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ htmlDoc.AssertElement(t, "#pull-request-trust-panel", true)
+ link, exists := htmlDoc.doc.Find("#pull-request-trust-panel-" + trust).Attr("action")
+ require.True(t, exists)
+ actualTrust, exists := htmlDoc.doc.Find(fmt.Sprintf("#pull-request-trust-panel-%s input[name='trust']", trust)).Attr("value")
+ require.True(t, exists)
+ require.Equal(t, trust, actualTrust)
+ req = NewRequestWithValues(t, "POST", link, map[string]string{
+ "trust": trust,
+ })
+ session.MakeRequest(t, req, http.StatusSeeOther)
+}
+
+func actionsTrustTestAssertTrustPanelPresence(t *testing.T, session *TestSession, url string, present bool) {
+ t.Helper()
+ req := NewRequest(t, "GET", url)
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ htmlDoc.AssertElement(t, ".error-code", false)
+ htmlDoc.AssertElement(t, "#pull-request-trust-panel", present)
+}
+
+func actionsTrustTestAssertTrustPanel(t *testing.T, session *TestSession, url string) {
+ t.Helper()
+ actionsTrustTestAssertTrustPanelPresence(t, session, url, true)
+}
+
+func actionsTrustTestAssertNoTrustPanel(t *testing.T, session *TestSession, url string) {
+ t.Helper()
+ actionsTrustTestAssertTrustPanelPresence(t, session, url, false)
+}
+
+func actionsTrustTestCreateBaseRepo(t *testing.T, owner *user_model.User) (*repo_model.Repository, func()) {
+ t.Helper()
+
+ // create the base repo
+ baseRepo, _, f := tests.CreateDeclarativeRepo(t, owner, "repo-pull-request",
+ []unit_model.Type{unit_model.TypeActions}, nil, nil,
+ )
+
+ // add workflow file to the base repo
+ addWorkflowToBaseResp, err := files_service.ChangeRepoFiles(git.DefaultContext, baseRepo, owner, &files_service.ChangeRepoFilesOptions{
+ Files: []*files_service.ChangeRepoFile{
+ {
+ Operation: "create",
+ TreePath: ".forgejo/workflows/pr.yml",
+ ContentReader: strings.NewReader(`
+on:
+ pull_request:
+
+jobs:
+ test:
+ runs-on: docker
+ steps:
+ - run: echo helloworld
+`),
+ },
+ },
+ Message: "add workflow",
+ OldBranch: "main",
+ NewBranch: "main",
+ Author: &files_service.IdentityOptions{
+ Name: owner.Name,
+ Email: owner.Email,
+ },
+ Committer: &files_service.IdentityOptions{
+ Name: owner.Name,
+ Email: owner.Email,
+ },
+ Dates: &files_service.CommitDateOptions{
+ Author: time.Now(),
+ Committer: time.Now(),
+ },
+ })
+ require.NoError(t, err)
+ require.NotEmpty(t, addWorkflowToBaseResp)
+ return baseRepo, f
+}
+
+func actionsTrustTestRequireRun(t *testing.T, repo *repo_model.Repository, modifiedFiles *structs.FilesResponse) {
+ t.Helper()
+
+ actionRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: repo.ID, CommitSHA: modifiedFiles.Commit.SHA})
+ require.Equal(t, actions_module.GithubEventPullRequest, actionRun.TriggerEvent)
+ require.Equal(t, actions_model.StatusWaiting.String(), actionRun.Status.String())
+ unittest.BeanExists(t, &actions_model.ActionRunJob{RunID: actionRun.ID, RepoID: repo.ID})
+}
+
+func actionsTrustTestRepoCreateBranch(t *testing.T, doer *user_model.User, repo *repo_model.Repository) *structs.FilesResponse {
+ t.Helper()
+
+ return actionsTrustTestModifyRepo(t, doer, repo, "file_in_fork.txt", "main", "fork-branch-1")
+}
+
+func actionsTrustTestRepoModify(t *testing.T, doer *user_model.User, baseRepo, headRepo *repo_model.Repository, filename string) *structs.FilesResponse {
+ t.Helper()
+
+ modified := actionsTrustTestModifyRepo(t, doer, headRepo, filename, "fork-branch-1", "fork-branch-1")
+ // the creation of the run is not synchronous
+ require.Eventually(t, func() bool {
+ return unittest.BeanExists(t, &actions_model.ActionRun{RepoID: baseRepo.ID, CommitSHA: modified.Commit.SHA})
+ }, 60*time.Second, time.Millisecond*100)
+ return modified
+}
+
+func actionsTrustTestModifyRepo(t *testing.T, doer *user_model.User, repo *repo_model.Repository, filename, oldBranch, newBranch string) *structs.FilesResponse {
+ t.Helper()
+
+ // add a new file to the forked repo
+ addFile, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, doer, &files_service.ChangeRepoFilesOptions{
+ Files: []*files_service.ChangeRepoFile{
+ {
+ Operation: "create",
+ TreePath: filename,
+ ContentReader: strings.NewReader("content"),
+ },
+ },
+ Message: "add " + filename,
+ OldBranch: oldBranch,
+ NewBranch: newBranch,
+ Author: &files_service.IdentityOptions{
+ Name: doer.Name,
+ Email: doer.Email,
+ },
+ Committer: &files_service.IdentityOptions{
+ Name: doer.Name,
+ Email: doer.Email,
+ },
+ Dates: &files_service.CommitDateOptions{
+ Author: time.Now(),
+ Committer: time.Now(),
+ },
+ })
+ require.NoError(t, err)
+ require.NotEmpty(t, addFile)
+ return addFile
+}
+
+func actionsTrustTestCreatePullRequestFromForkedRepo(t *testing.T, baseUser *user_model.User, baseRepo *repo_model.Repository, headUser *user_model.User) (*repo_model.Repository, *issues_model.PullRequest, *structs.FilesResponse) {
+ t.Helper()
+
+ forkRepo := func(t *testing.T, baseUser *user_model.User, baseRepo *repo_model.Repository, headUser *user_model.User) *repo_model.Repository {
+ t.Helper()
+
+ // create the forked repo
+ forkedRepo, err := repo_service.ForkRepositoryAndUpdates(git.DefaultContext, baseUser, headUser, repo_service.ForkRepoOptions{
+ BaseRepo: baseRepo,
+ Name: "forked-repo-pull-request",
+ Description: "test pull-request event",
+ })
+ require.NoError(t, err)
+ require.NotEmpty(t, forkedRepo)
+ return forkedRepo
+ }
+
+ forkedRepo := forkRepo(t, baseUser, baseRepo, headUser)
+ addFileToForkedResp := actionsTrustTestRepoCreateBranch(t, headUser, forkedRepo)
+
+ // create Pull
+ pullIssue := &issues_model.Issue{
+ RepoID: baseRepo.ID,
+ Title: "Test pull-request",
+ PosterID: headUser.ID,
+ Poster: headUser,
+ IsPull: true,
+ }
+ pullRequest := &issues_model.PullRequest{
+ HeadRepoID: forkedRepo.ID,
+ BaseRepoID: baseRepo.ID,
+ HeadBranch: "fork-branch-1",
+ BaseBranch: "main",
+ HeadRepo: forkedRepo,
+ BaseRepo: baseRepo,
+ Type: issues_model.PullRequestGitea,
+ }
+ // create the pull request
+ err := pull_service.NewPullRequest(git.DefaultContext, baseRepo, pullIssue, nil, nil, pullRequest, nil)
+ require.NoError(t, err)
+
+ actionsTrustTestRequireRun(t, baseRepo, addFileToForkedResp)
+
+ return forkedRepo, pullRequest, addFileToForkedResp
+}
+
+func TestActionsPullRequestTrustPanel(t *testing.T) {
+ onApplicationRun(t, func(t *testing.T, u *url.URL) {
+ ownerUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) // owner of the repo
+
+ regularUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5}) // a regular user with no specific permission
+ regularSession := loginUser(t, regularUser.Name)
+
+ userAdmin := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) // the instance admin
+ adminSession := loginUser(t, userAdmin.Name)
+
+ baseRepo, f := actionsTrustTestCreateBaseRepo(t, ownerUser)
+ defer f()
+
+ forkedRepo, pullRequest, addFileToForkedResp := actionsTrustTestCreatePullRequestFromForkedRepo(t, ownerUser, baseRepo, regularUser)
+ pullRequestLink := pullRequest.Issue.Link()
+
+ t.Run("Regular user sees a pending approval on a newly created pull request from a fork", func(t *testing.T) {
+ actionsTrustTestAssertTrustPanel(t, regularSession, pullRequestLink)
+ })
+
+ t.Run("Admin approves runs once", func(t *testing.T) {
+ actionRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: baseRepo.ID, CommitSHA: addFileToForkedResp.Commit.SHA})
+
+ {
+ actionRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: baseRepo.ID, CommitSHA: addFileToForkedResp.Commit.SHA})
+ assert.True(t, actionRun.NeedApproval)
+ actionRunJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: actionRun.ID, RepoID: baseRepo.ID})
+ assert.Equal(t, actions_model.StatusBlocked.String(), actionRunJob.Status.String())
+ }
+
+ actionsTrustTestAssertTrustPanel(t, adminSession, pullRequestLink)
+ actionsTrustTestClickTrustPanel(t, adminSession, pullRequestLink, string(actions_service.UserTrustedOnce))
+
+ {
+ actionRunJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: actionRun.ID, RepoID: baseRepo.ID})
+ assert.Equal(t, actions_model.StatusWaiting.String(), actionRunJob.Status.String())
+ actionRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: baseRepo.ID})
+ assert.False(t, actionRun.NeedApproval)
+ }
+ })
+
+ t.Run("All users sees no pending approval because it was approved once", func(t *testing.T) {
+ actionsTrustTestAssertNoTrustPanel(t, regularSession, pullRequestLink)
+ actionsTrustTestAssertNoTrustPanel(t, adminSession, pullRequestLink)
+ })
+
+ modifiedForkedResp := actionsTrustTestRepoModify(t, regularUser, baseRepo, forkedRepo, "add_file_one.txt")
+
+ t.Run("Regular user sees a pending approval on a modified pull request from a fork (2)", func(t *testing.T) {
+ actionsTrustTestAssertTrustPanel(t, regularSession, pullRequestLink)
+ })
+
+ t.Run("Admin denies runs", func(t *testing.T) {
+ actionRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: baseRepo.ID, CommitSHA: modifiedForkedResp.Commit.SHA})
+
+ {
+ actionRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: baseRepo.ID, CommitSHA: modifiedForkedResp.Commit.SHA})
+ assert.True(t, actionRun.NeedApproval)
+ actionRunJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: actionRun.ID, RepoID: baseRepo.ID})
+ assert.Equal(t, actions_model.StatusBlocked.String(), actionRunJob.Status.String())
+ }
+
+ actionsTrustTestAssertTrustPanel(t, adminSession, pullRequestLink)
+ actionsTrustTestClickTrustPanel(t, adminSession, pullRequestLink, string(actions_service.UserTrustDenied))
+
+ {
+ actionRunJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: actionRun.ID, RepoID: baseRepo.ID})
+ assert.Equal(t, actions_model.StatusCancelled.String(), actionRunJob.Status.String())
+ actionRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: baseRepo.ID})
+ assert.False(t, actionRun.NeedApproval)
+ }
+ })
+
+ t.Run("All users sees no pending approval because it was denied", func(t *testing.T) {
+ actionsTrustTestAssertNoTrustPanel(t, regularSession, pullRequestLink)
+ actionsTrustTestAssertNoTrustPanel(t, adminSession, pullRequestLink)
+ })
+
+ modifiedForkedResp = actionsTrustTestRepoModify(t, regularUser, baseRepo, forkedRepo, "add_file_two.txt")
+
+ t.Run("Regular user sees a pending approval on a modified pull request from a fork (2)", func(t *testing.T) {
+ actionsTrustTestAssertTrustPanel(t, regularSession, pullRequestLink)
+ })
+
+ t.Run("Admin always trusts the poster", func(t *testing.T) {
+ actionRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: baseRepo.ID, CommitSHA: modifiedForkedResp.Commit.SHA})
+
+ {
+ actionRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: baseRepo.ID, CommitSHA: modifiedForkedResp.Commit.SHA})
+ assert.True(t, actionRun.NeedApproval)
+ actionRunJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: actionRun.ID, RepoID: baseRepo.ID})
+ assert.Equal(t, actions_model.StatusBlocked.String(), actionRunJob.Status.String())
+ }
+
+ actionsTrustTestAssertTrustPanel(t, adminSession, pullRequestLink)
+ actionsTrustTestClickTrustPanel(t, adminSession, pullRequestLink, string(actions_service.UserAlwaysTrusted))
+
+ {
+ actionRunJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: actionRun.ID, RepoID: baseRepo.ID})
+ assert.Equal(t, actions_model.StatusWaiting.String(), actionRunJob.Status.String())
+ actionRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: baseRepo.ID})
+ assert.False(t, actionRun.NeedApproval)
+ }
+ })
+
+ t.Run("Regular users sees no pending approval because it was approved", func(t *testing.T) {
+ actionsTrustTestAssertNoTrustPanel(t, regularSession, pullRequestLink)
+ })
+
+ modifiedForkedResp = actionsTrustTestRepoModify(t, regularUser, baseRepo, forkedRepo, "add_file_three.txt")
+
+ t.Run("No need for approval because the poster is always trusted", func(t *testing.T) {
+ actionRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: baseRepo.ID, CommitSHA: modifiedForkedResp.Commit.SHA})
+ assert.False(t, actionRun.NeedApproval)
+ actionRunJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: actionRun.ID, RepoID: baseRepo.ID})
+ assert.Equal(t, actions_model.StatusWaiting.String(), actionRunJob.Status.String())
+ })
+
+ t.Run("Admin revokes the trusted poster", func(t *testing.T) {
+ actionsTrustTestAssertTrustPanel(t, adminSession, pullRequestLink)
+ actionsTrustTestClickTrustPanel(t, adminSession, pullRequestLink, string(actions_service.UserTrustRevoked))
+ })
+
+ modifiedForkedResp = actionsTrustTestRepoModify(t, regularUser, baseRepo, forkedRepo, "add_file_four.txt")
+
+ t.Run("There needs to be an approval again because the user is no longer trusted", func(t *testing.T) {
+ actionRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: baseRepo.ID, CommitSHA: modifiedForkedResp.Commit.SHA})
+ assert.True(t, actionRun.NeedApproval)
+ actionRunJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: actionRun.ID, RepoID: baseRepo.ID})
+ assert.Equal(t, actions_model.StatusBlocked.String(), actionRunJob.Status.String())
+ })
+ })
+}
diff --git a/web_src/js/components/RepoActionView.test.js b/web_src/js/components/RepoActionView.test.js
index 6f575ca8da..b06751b8cf 100644
--- a/web_src/js/components/RepoActionView.test.js
+++ b/web_src/js/components/RepoActionView.test.js
@@ -397,6 +397,44 @@ test('historical attempt dropdown interactions', async () => {
expect(window.location.href).toEqual(toAbsoluteUrl('/user1/repo2/actions/runs/123/jobs/1/attempt/2'));
});
+test('run approval interaction', async () => {
+ const pullRequestLink = '/example-org/example-repo/pulls/456';
+ const wrapper = mount(RepoActionView, {
+ props: {
+ ...defaultTestProps,
+ initialJobData: {
+ state: {
+ run: {
+ canApprove: true,
+ status: 'waiting',
+ commit: {
+ pusher: {},
+ branch: {
+ link: toAbsoluteUrl(pullRequestLink),
+ },
+ },
+ },
+ currentJob: {
+ steps: [
+ {
+ summary: 'Test Job',
+ },
+ ],
+ },
+ },
+ logs: {
+ stepsLog: [],
+ },
+ },
+ },
+ });
+ await flushPromises();
+ const approve = wrapper.findAll('button').filter((button) => button.text() === 'Locale Approve');
+ expect(approve.length).toEqual(1);
+ approve[0].trigger('click');
+ expect(window.location.href).toEqual(toAbsoluteUrl(`${pullRequestLink}#pull-request-trust-panel`));
+});
+
test('artifacts download links', async () => {
Object.defineProperty(document.documentElement, 'lang', {value: 'en'});
vi.spyOn(global, 'fetch').mockImplementation((url, opts) => {
diff --git a/web_src/js/components/RepoActionView.vue b/web_src/js/components/RepoActionView.vue
index 097676cac2..9b2ba0166c 100644
--- a/web_src/js/components/RepoActionView.vue
+++ b/web_src/js/components/RepoActionView.vue
@@ -223,7 +223,8 @@ export default {
},
// approve a run
approveRun() {
- POST(`${this.run.link}/approve`);
+ const url = `${this.run.commit.branch.link}#pull-request-trust-panel`;
+ window.location.href = url;
},
// show/hide the step logs for a group
toggleGroupLogs(event) {