mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2026-02-03 20:51:07 -05:00
Fixes #11125. When a PR is closed, cancel any action runs associated with the pull request that are not approved so that they do not remain in the Actions list as a blocked action. ## Checklist The [contributor guide](https://forgejo.org/docs/next/contributor/) contains information that will be helpful to first time contributors. There also are a few [conditions for merging Pull Requests in Forgejo repositories](https://codeberg.org/forgejo/governance/src/branch/main/PullRequestsAgreement.md). You are also welcome to join the [Forgejo development chatroom](https://matrix.to/#/#forgejo-development:matrix.org). ### Tests - I added test coverage for Go changes... - [x] in their respective `*_test.go` for unit tests. - [x] in the `tests/integration` directory if it involves interactions with a live Forgejo server. - I added test coverage for JavaScript changes... - [ ] in `web_src/js/*.test.js` if it can be unit tested. - [ ] in `tests/e2e/*.test.e2e.js` if it requires interactions with a live Forgejo server (see also the [developer guide for JavaScript testing](https://codeberg.org/forgejo/forgejo/src/branch/forgejo/tests/e2e/README.md#end-to-end-tests)). ### Documentation - [ ] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change. - [x] I did not document these changes and I do not expect someone else to do it. ### Release notes - [x] This change will be noticed by a Forgejo user or admin (feature, bug fix, performance, etc.). I suggest to include a release note for this change. - [ ] This change is not visible to a Forgejo user or admin (refactor, dependency upgrade, etc.). I think there is no need to add a release note for this change. *The decision if the pull request will be shown in the release notes is up to the mergers / release team.* The content of the `release-notes/<pull request number>.md` file will serve as the basis for the release notes. If the file does not exist, the title of the pull request will be used instead. Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/11134 Reviewed-by: Andreas Ahlenstorf <aahlenst@noreply.codeberg.org> Reviewed-by: Gusted <gusted@noreply.codeberg.org> Co-authored-by: Mathieu Fenniak <mathieu@fenniak.net> Co-committed-by: Mathieu Fenniak <mathieu@fenniak.net>
345 lines
10 KiB
Go
345 lines
10 KiB
Go
// Copyright 2025 The Forgejo Authors. All rights reserved.
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package actions
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
|
|
actions_model "forgejo.org/models/actions"
|
|
issues_model "forgejo.org/models/issues"
|
|
access_model "forgejo.org/models/perm/access"
|
|
repo_model "forgejo.org/models/repo"
|
|
unit_model "forgejo.org/models/unit"
|
|
user_model "forgejo.org/models/user"
|
|
actions_module "forgejo.org/modules/actions"
|
|
"forgejo.org/modules/log"
|
|
webhook_module "forgejo.org/modules/webhook"
|
|
)
|
|
|
|
type TrustUpdate string
|
|
|
|
const (
|
|
UserTrustDenied = TrustUpdate("deny")
|
|
UserAlwaysTrusted = TrustUpdate("always")
|
|
UserTrustedOnce = TrustUpdate("once")
|
|
UserTrustRevoked = TrustUpdate("revoke")
|
|
)
|
|
|
|
func CleanupActionUser(ctx context.Context) error {
|
|
return actions_model.RevokeInactiveActionUser(ctx)
|
|
}
|
|
|
|
func loadPullRequestAttributes(ctx context.Context, pr *issues_model.PullRequest) error {
|
|
if err := pr.LoadIssue(ctx); err != nil {
|
|
return err
|
|
}
|
|
|
|
return pr.Issue.LoadRepo(ctx)
|
|
}
|
|
|
|
func getIssuePoster(ctx context.Context, issue *issues_model.Issue) (*user_model.User, error) {
|
|
if issue.Poster != nil {
|
|
return issue.Poster, nil
|
|
}
|
|
if issue.PosterID == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
poster, err := user_model.GetPossibleUserByID(ctx, issue.PosterID)
|
|
if err != nil {
|
|
if user_model.IsErrUserNotExist(err) {
|
|
return nil, nil
|
|
}
|
|
return nil, fmt.Errorf("getIssuePoster [%d]: %w", issue.PosterID, err)
|
|
}
|
|
issue.Poster = poster
|
|
return poster, nil
|
|
}
|
|
|
|
func mustGetIssuePoster(ctx context.Context, issue *issues_model.Issue) (*user_model.User, error) {
|
|
poster, err := getIssuePoster(ctx, issue)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if poster == nil {
|
|
return nil, user_model.ErrUserNotExist{UID: issue.PosterID}
|
|
}
|
|
return poster, nil
|
|
}
|
|
|
|
type useHeadOrBaseCommit int
|
|
|
|
const (
|
|
useHeadCommit = 1 << iota
|
|
useBaseCommit
|
|
)
|
|
|
|
func getPullRequestCommitAndApproval(ctx context.Context, pr *issues_model.PullRequest, doer *user_model.User, event webhook_module.HookEventType) (useHeadOrBaseCommit, actions_model.ApprovalType, error) {
|
|
if pr == nil || actions_module.IsDefaultBranchWorkflow(event) || !pr.IsForkPullRequest() {
|
|
return useHeadCommit, actions_model.DoesNotNeedApproval, nil
|
|
}
|
|
|
|
posterTrust, err := GetPullRequestPosterIsTrustedWithActions(ctx, pr)
|
|
if err != nil {
|
|
return useHeadCommit, actions_model.UndefinedApproval, err
|
|
}
|
|
|
|
if posterTrust.IsTrusted() {
|
|
return useHeadCommit, actions_model.DoesNotNeedApproval, nil
|
|
}
|
|
|
|
doerTrust, err := getPullRequestUserIsTrustedWithActions(ctx, pr, doer)
|
|
if err != nil {
|
|
return useHeadCommit, actions_model.UndefinedApproval, err
|
|
}
|
|
|
|
if doerTrust.IsTrusted() {
|
|
if event == webhook_module.HookEventPullRequestSync {
|
|
// a synchronized event action (i.e. the doer pushed a commit to the pull request)
|
|
// can run from the head
|
|
return useHeadCommit, actions_model.DoesNotNeedApproval, nil
|
|
}
|
|
// other events run from workflows found in the base, not
|
|
// from possibly modified workflows found in the head
|
|
return useBaseCommit, actions_model.DoesNotNeedApproval, nil
|
|
}
|
|
// the poster and the doer are not trusted, approval is needed
|
|
return useHeadCommit, actions_model.NeedApproval, nil
|
|
}
|
|
|
|
// cancels or approves runs and keep track of posters that are to always be trusted
|
|
func UpdateTrustedWithPullRequest(ctx context.Context, doerID int64, pr *issues_model.PullRequest, trusted TrustUpdate) error {
|
|
if err := loadPullRequestAttributes(ctx, pr); err != nil {
|
|
return err
|
|
}
|
|
|
|
switch trusted {
|
|
case UserAlwaysTrusted:
|
|
poster, err := mustGetIssuePoster(ctx, pr.Issue)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return AlwaysTrust(ctx, doerID, pr.Issue.RepoID, poster.ID)
|
|
case UserTrustedOnce:
|
|
return pullRequestApprove(ctx, doerID, pr.Issue.RepoID, pr.ID)
|
|
case UserTrustRevoked:
|
|
poster, err := mustGetIssuePoster(ctx, pr.Issue)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return RevokeTrust(ctx, pr.Issue.RepoID, poster.ID)
|
|
case UserTrustDenied:
|
|
return pullRequestCancel(ctx, pr.Issue.RepoID, pr.ID)
|
|
default:
|
|
return fmt.Errorf("UpdateTrustedWithPullRequest: unknown trust %v", trusted)
|
|
}
|
|
}
|
|
|
|
func setRunTrustForPullRequest(ctx context.Context, run *actions_model.ActionRun, pr *issues_model.PullRequest, needApproval actions_model.ApprovalType) error {
|
|
if pr == nil {
|
|
return nil
|
|
}
|
|
|
|
if err := loadPullRequestAttributes(ctx, pr); err != nil {
|
|
return err
|
|
}
|
|
|
|
run.IsForkPullRequest = pr.IsForkPullRequest()
|
|
run.PullRequestPosterID = pr.Issue.PosterID
|
|
run.PullRequestID = pr.ID
|
|
run.NeedApproval = bool(needApproval)
|
|
|
|
return nil
|
|
}
|
|
|
|
type UserTrust string
|
|
|
|
const (
|
|
UserTrustIsNotRelevant = UserTrust("irrelevant")
|
|
UserIsNotTrustedWithActions = UserTrust("no")
|
|
UserIsExplicitlyTrustedWithActions = UserTrust("explicitly")
|
|
UserIsImplicitlyTrustedWithActions = UserTrust("implicitly")
|
|
)
|
|
|
|
func (t UserTrust) IsTrusted() bool {
|
|
return t != UserIsNotTrustedWithActions
|
|
}
|
|
|
|
func GetPullRequestPosterIsTrustedWithActions(ctx context.Context, pr *issues_model.PullRequest) (UserTrust, error) {
|
|
if err := loadPullRequestAttributes(ctx, pr); err != nil {
|
|
return "", err
|
|
}
|
|
poster, err := mustGetIssuePoster(ctx, pr.Issue)
|
|
if err != nil {
|
|
return UserIsNotTrustedWithActions, err
|
|
}
|
|
|
|
return getPullRequestUserIsTrustedWithActions(ctx, pr, poster)
|
|
}
|
|
|
|
func getPullRequestUserIsTrustedWithActions(ctx context.Context, pr *issues_model.PullRequest, user *user_model.User) (UserTrust, error) {
|
|
if err := loadPullRequestAttributes(ctx, pr); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return userIsTrustedWithPullRequest(ctx, pr, user)
|
|
}
|
|
|
|
func userIsTrustedWithPullRequest(ctx context.Context, pr *issues_model.PullRequest, user *user_model.User) (UserTrust, error) {
|
|
implicitlyTrusted, err := userIsImplicitlyTrustedWithPullRequest(ctx, pr, user)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if implicitlyTrusted {
|
|
log.Trace("%s is implicitly trusted to run actions in repository %s", user, pr.Issue.Repo)
|
|
return UserIsImplicitlyTrustedWithActions, nil
|
|
}
|
|
|
|
explicitlyTrusted, err := userIsExplicitlyTrustedWithPullRequest(ctx, pr, user)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if explicitlyTrusted {
|
|
log.Trace("%s is explicitly trusted to run actions in repository %s", user, pr.Issue.Repo)
|
|
return UserIsExplicitlyTrustedWithActions, nil
|
|
}
|
|
|
|
log.Trace("%s is not trusted to run actions in repository %s", user, pr.Issue.Repo)
|
|
return UserIsNotTrustedWithActions, nil
|
|
}
|
|
|
|
func userIsImplicitlyTrustedWithPullRequest(ctx context.Context, pr *issues_model.PullRequest, user *user_model.User) (bool, error) {
|
|
// users that are trusted to create a pull request that is not from a fork
|
|
// are also implicitly trusted to run workflows
|
|
if !pr.IsForkPullRequest() {
|
|
log.Trace("a pull request that is not from a fork nor AGit is implicitly trusted to run actions")
|
|
return true, nil
|
|
}
|
|
|
|
return userCanWriteActionsOnRepo(ctx, pr.Issue.Repo, user)
|
|
}
|
|
|
|
func userCanWriteActionsOnRepo(ctx context.Context, repo *repo_model.Repository, user *user_model.User) (bool, error) {
|
|
// users with write permission to the actions unit are trusted to
|
|
// run actions
|
|
permission, err := access_model.GetUserRepoPermission(ctx, repo, user)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if permission.CanWrite(unit_model.TypeActions) {
|
|
log.Trace("%s has write permissions to the Action unit on %s", user, repo)
|
|
return true, nil
|
|
}
|
|
|
|
return false, nil
|
|
}
|
|
|
|
func userIsExplicitlyTrustedWithPullRequest(ctx context.Context, pr *issues_model.PullRequest, user *user_model.User) (bool, error) {
|
|
// there is no need to check if the user is blocked because it is not
|
|
// allowed to create a pull request
|
|
if user.IsRestricted {
|
|
log.Trace("%v is restricted and cannot be trusted with pull requests", user)
|
|
return false, nil
|
|
}
|
|
|
|
actionUser, err := actions_model.GetActionUserByUserIDAndRepoIDAndUpdateAccess(ctx, user.ID, pr.Issue.Repo.ID)
|
|
if err != nil {
|
|
log.Trace("%v is not explicitly trusted with pull requests on repository %v", user, pr.Issue.Repo)
|
|
if actions_model.IsErrUserNotExist(err) {
|
|
return false, nil
|
|
}
|
|
return false, err
|
|
}
|
|
|
|
log.Trace("%v is explicitly trusted with pull requests on repository %v", user, pr.Issue.Repo)
|
|
return actionUser.TrustedWithPullRequests, nil
|
|
}
|
|
|
|
func RevokeTrust(ctx context.Context, repoID, posterID int64) error {
|
|
if err := actions_model.DeleteActionUserByUserIDAndRepoID(ctx, posterID, repoID); err != nil {
|
|
return err
|
|
}
|
|
|
|
runs, err := actions_model.GetRunsNotDoneByRepoIDAndPullRequestPosterID(ctx, repoID, posterID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, run := range runs {
|
|
if err := CancelRun(ctx, run); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func AlwaysTrust(ctx context.Context, doerID, repoID, posterID int64) error {
|
|
if err := actions_model.InsertActionUser(ctx, &actions_model.ActionUser{
|
|
UserID: posterID,
|
|
RepoID: repoID,
|
|
TrustedWithPullRequests: true,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
|
|
runs, err := actions_model.GetRunsNotDoneByRepoIDAndPullRequestPosterID(ctx, repoID, posterID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, run := range runs {
|
|
if err := ApproveRun(ctx, run, doerID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func pullRequestCancel(ctx context.Context, repoID, pullRequestID int64) error {
|
|
runs, err := actions_model.GetRunsNotDoneByRepoIDAndPullRequestID(ctx, repoID, pullRequestID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, run := range runs {
|
|
if err := CancelRun(ctx, run); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func pullRequestApprove(ctx context.Context, doerID, repoID, pullRequestID int64) error {
|
|
runs, err := actions_model.GetRunsThatNeedApprovalByRepoIDAndPullRequestID(ctx, repoID, pullRequestID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, run := range runs {
|
|
if err := ApproveRun(ctx, run, doerID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func cleanupPullRequestUnapprovedRuns(ctx context.Context, repoID, pullRequestID int64) error {
|
|
runs, err := actions_model.GetRunsThatNeedApprovalByRepoIDAndPullRequestID(ctx, repoID, pullRequestID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
errorSlice := []error{}
|
|
for _, run := range runs {
|
|
if err := CancelRun(ctx, run); err != nil {
|
|
errorSlice = append(errorSlice, err)
|
|
}
|
|
}
|
|
if len(errorSlice) > 0 {
|
|
return errors.Join(errorSlice...)
|
|
}
|
|
return nil
|
|
}
|