mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2026-05-12 22:10:25 +00:00
feat: allow/disallow users to run workflows when pushing to a pull request from a fork (#9397)
Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/9397 Reviewed-by: Lucas <sclu1034@noreply.codeberg.org> Reviewed-by: Mathieu Fenniak <mfenniak@noreply.codeberg.org>
This commit is contained in:
commit
ce93a16557
52 changed files with 2822 additions and 287 deletions
|
|
@ -41,27 +41,24 @@ const (
|
||||||
|
|
||||||
// ActionRun represents a run of a workflow file
|
// ActionRun represents a run of a workflow file
|
||||||
type ActionRun struct {
|
type ActionRun struct {
|
||||||
ID int64
|
ID int64
|
||||||
Title string
|
Title string
|
||||||
RepoID int64 `xorm:"index unique(repo_index) index(concurrency)"`
|
RepoID int64 `xorm:"index unique(repo_index) index(concurrency)"`
|
||||||
Repo *repo_model.Repository `xorm:"-"`
|
Repo *repo_model.Repository `xorm:"-"`
|
||||||
OwnerID int64 `xorm:"index"`
|
OwnerID int64 `xorm:"index"`
|
||||||
WorkflowID string `xorm:"index"` // the name of workflow file
|
WorkflowID string `xorm:"index"` // the name of workflow file
|
||||||
Index int64 `xorm:"index unique(repo_index)"` // a unique number for each run of a repository
|
Index int64 `xorm:"index unique(repo_index)"` // a unique number for each run of a repository
|
||||||
TriggerUserID int64 `xorm:"index"`
|
TriggerUserID int64 `xorm:"index"`
|
||||||
TriggerUser *user_model.User `xorm:"-"`
|
TriggerUser *user_model.User `xorm:"-"`
|
||||||
ScheduleID int64
|
ScheduleID int64
|
||||||
Ref string `xorm:"index"` // the commit/tag/… that caused the run
|
Ref string `xorm:"index"` // the commit/tag/… that caused the run
|
||||||
IsRefDeleted bool `xorm:"-"`
|
IsRefDeleted bool `xorm:"-"`
|
||||||
CommitSHA string
|
CommitSHA string
|
||||||
IsForkPullRequest bool // If this is triggered by a PR from a forked repository or an untrusted user, we need to check if it is approved and limit permissions when running the workflow.
|
Event webhook_module.HookEventType // the webhook event that causes the workflow to run
|
||||||
NeedApproval bool // may need approval if it's a fork pull request
|
EventPayload string `xorm:"LONGTEXT"`
|
||||||
ApprovedBy int64 `xorm:"index"` // who approved
|
TriggerEvent string // the trigger event defined in the `on` configuration of the triggered workflow
|
||||||
Event webhook_module.HookEventType // the webhook event that causes the workflow to run
|
Status Status `xorm:"index"`
|
||||||
EventPayload string `xorm:"LONGTEXT"`
|
Version int `xorm:"version default 0"` // Status could be updated concomitantly, so an optimistic lock is needed
|
||||||
TriggerEvent string // the trigger event defined in the `on` configuration of the triggered workflow
|
|
||||||
Status Status `xorm:"index"`
|
|
||||||
Version int `xorm:"version default 0"` // Status could be updated concomitantly, so an optimistic lock is needed
|
|
||||||
// Started and Stopped is used for recording last run time, if rerun happened, they will be reset to 0
|
// Started and Stopped is used for recording last run time, if rerun happened, they will be reset to 0
|
||||||
Started timeutil.TimeStamp
|
Started timeutil.TimeStamp
|
||||||
Stopped timeutil.TimeStamp
|
Stopped timeutil.TimeStamp
|
||||||
|
|
@ -71,6 +68,13 @@ type ActionRun struct {
|
||||||
Updated timeutil.TimeStamp `xorm:"updated"`
|
Updated timeutil.TimeStamp `xorm:"updated"`
|
||||||
NotifyEmail bool
|
NotifyEmail bool
|
||||||
|
|
||||||
|
// pull request trust
|
||||||
|
IsForkPullRequest bool
|
||||||
|
PullRequestPosterID int64
|
||||||
|
PullRequestID int64 `xorm:"index"`
|
||||||
|
NeedApproval bool
|
||||||
|
ApprovedBy int64 `xorm:"index"`
|
||||||
|
|
||||||
ConcurrencyGroup string `xorm:"'concurrency_group' index(concurrency)"`
|
ConcurrencyGroup string `xorm:"'concurrency_group' index(concurrency)"`
|
||||||
ConcurrencyType ConcurrencyMode
|
ConcurrencyType ConcurrencyMode
|
||||||
|
|
||||||
|
|
@ -228,6 +232,54 @@ func clearRepoRunCountCache(repo *repo_model.Repository) {
|
||||||
cache.Remove(actionsCountOpenCacheKey(repo.ID))
|
cache.Remove(actionsCountOpenCacheKey(repo.ID))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func condRunsThatNeedApproval(repoID, pullRequestID int64) builder.Cond {
|
||||||
|
// performance relies indexes on repo_id and pull_request_id
|
||||||
|
return builder.Eq{"repo_id": repoID, "pull_request_id": pullRequestID, "need_approval": true}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetRunsThatNeedApprovalByRepoIDAndPullRequestID(ctx context.Context, repoID, pullRequestID int64) ([]*ActionRun, error) {
|
||||||
|
var runs []*ActionRun
|
||||||
|
if err := db.GetEngine(ctx).Where(condRunsThatNeedApproval(repoID, pullRequestID)).Find(&runs); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return runs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func HasRunThatNeedApproval(ctx context.Context, repoID, pullRequestID int64) (bool, error) {
|
||||||
|
return db.GetEngine(ctx).Where(condRunsThatNeedApproval(repoID, pullRequestID)).Exist(&ActionRun{})
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApprovalType bool
|
||||||
|
|
||||||
|
const (
|
||||||
|
NeedApproval = ApprovalType(true)
|
||||||
|
DoesNotNeedApproval = ApprovalType(false)
|
||||||
|
UndefinedApproval = ApprovalType(false)
|
||||||
|
)
|
||||||
|
|
||||||
|
func UpdateRunApprovalByID(ctx context.Context, id int64, approval ApprovalType, approvedBy int64) error {
|
||||||
|
_, err := db.GetEngine(ctx).Exec("UPDATE action_run SET need_approval=?, approved_by=? WHERE id=?", bool(approval), approvedBy, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetRunsNotDoneByRepoIDAndPullRequestPosterID(ctx context.Context, repoID, pullRequestPosterID int64) ([]*ActionRun, error) {
|
||||||
|
var runs []*ActionRun
|
||||||
|
// performance relies on indexes on repo_id and status
|
||||||
|
if err := db.GetEngine(ctx).Where("repo_id=? AND pull_request_poster_id=?", repoID, pullRequestPosterID).And(builder.In("status", []Status{StatusUnknown, StatusWaiting, StatusRunning, StatusBlocked})).Find(&runs); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return runs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetRunsNotDoneByRepoIDAndPullRequestID(ctx context.Context, repoID, pullRequestID int64) ([]*ActionRun, error) {
|
||||||
|
var runs []*ActionRun
|
||||||
|
// performance relies on indexes on repo_id and status
|
||||||
|
if err := db.GetEngine(ctx).Where("repo_id=? AND pull_request_id=?", repoID, pullRequestID).And(builder.In("status", []Status{StatusUnknown, StatusWaiting, StatusRunning, StatusBlocked})).Find(&runs); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return runs, nil
|
||||||
|
}
|
||||||
|
|
||||||
// InsertRun inserts a run
|
// InsertRun inserts a run
|
||||||
// The title will be cut off at 255 characters if it's longer than 255 characters.
|
// The title will be cut off at 255 characters if it's longer than 255 characters.
|
||||||
// We don't have to send the ActionRunNowDone notification here because there are no runs that start in a not done status.
|
// We don't have to send the ActionRunNowDone notification here because there are no runs that start in a not done status.
|
||||||
|
|
@ -298,8 +350,10 @@ func InsertRun(ctx context.Context, run *ActionRun, jobs []*jobparser.SingleWork
|
||||||
Status: status,
|
Status: status,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if err := db.Insert(ctx, runJobs); err != nil {
|
if len(runJobs) > 0 {
|
||||||
return err
|
if err := db.Insert(ctx, runJobs); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// if there is a job in the waiting status, increase tasks version.
|
// if there is a job in the waiting status, increase tasks version.
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
package actions
|
package actions
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"forgejo.org/models/db"
|
"forgejo.org/models/db"
|
||||||
|
|
@ -81,3 +82,121 @@ func TestRepoNumOpenActions(t *testing.T) {
|
||||||
assert.Equal(t, 0, RepoNumOpenActions(t.Context(), repo.ID))
|
assert.Equal(t, 0, RepoNumOpenActions(t.Context(), repo.ID))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestActionRun_GetRunsNotDoneByRepoIDAndPullRequestPosterID(t *testing.T) {
|
||||||
|
require.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
|
||||||
|
repoID := int64(10)
|
||||||
|
pullRequestID := int64(3)
|
||||||
|
pullRequestPosterID := int64(30)
|
||||||
|
|
||||||
|
runDone := &ActionRun{
|
||||||
|
RepoID: repoID,
|
||||||
|
PullRequestID: pullRequestID,
|
||||||
|
PullRequestPosterID: pullRequestPosterID,
|
||||||
|
Status: StatusSuccess,
|
||||||
|
}
|
||||||
|
require.NoError(t, InsertRun(t.Context(), runDone, nil))
|
||||||
|
|
||||||
|
unrelatedUser := int64(5)
|
||||||
|
runNotByPoster := &ActionRun{
|
||||||
|
RepoID: repoID,
|
||||||
|
PullRequestID: pullRequestID,
|
||||||
|
PullRequestPosterID: unrelatedUser,
|
||||||
|
Status: StatusRunning,
|
||||||
|
}
|
||||||
|
require.NoError(t, InsertRun(t.Context(), runNotByPoster, nil))
|
||||||
|
|
||||||
|
unrelatedRepository := int64(6)
|
||||||
|
runNotInTheSameRepository := &ActionRun{
|
||||||
|
RepoID: unrelatedRepository,
|
||||||
|
PullRequestID: pullRequestID,
|
||||||
|
PullRequestPosterID: pullRequestPosterID,
|
||||||
|
Status: StatusSuccess,
|
||||||
|
}
|
||||||
|
require.NoError(t, InsertRun(t.Context(), runNotInTheSameRepository, nil))
|
||||||
|
|
||||||
|
for _, status := range []Status{StatusUnknown, StatusWaiting, StatusRunning} {
|
||||||
|
t.Run(fmt.Sprintf("%s", status), func(t *testing.T) {
|
||||||
|
runNotDone := &ActionRun{
|
||||||
|
RepoID: repoID,
|
||||||
|
PullRequestID: pullRequestID,
|
||||||
|
Status: status,
|
||||||
|
PullRequestPosterID: pullRequestPosterID,
|
||||||
|
}
|
||||||
|
require.NoError(t, InsertRun(t.Context(), runNotDone, nil))
|
||||||
|
runs, err := GetRunsNotDoneByRepoIDAndPullRequestPosterID(t.Context(), repoID, pullRequestPosterID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, runs, 1)
|
||||||
|
run := runs[0]
|
||||||
|
assert.Equal(t, runNotDone.ID, run.ID)
|
||||||
|
assert.Equal(t, runNotDone.Status, run.Status)
|
||||||
|
unittest.AssertSuccessfulDelete(t, run)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActionRun_NeedApproval(t *testing.T) {
|
||||||
|
require.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
|
||||||
|
pullRequestPosterID := int64(4)
|
||||||
|
repoID := int64(10)
|
||||||
|
pullRequestID := int64(2)
|
||||||
|
runDoesNotNeedApproval := &ActionRun{
|
||||||
|
RepoID: repoID,
|
||||||
|
PullRequestID: pullRequestID,
|
||||||
|
PullRequestPosterID: pullRequestPosterID,
|
||||||
|
}
|
||||||
|
require.NoError(t, InsertRun(t.Context(), runDoesNotNeedApproval, nil))
|
||||||
|
unrelatedRepository := int64(6)
|
||||||
|
runNotInTheSameRepository := &ActionRun{
|
||||||
|
RepoID: unrelatedRepository,
|
||||||
|
PullRequestID: pullRequestID,
|
||||||
|
PullRequestPosterID: pullRequestPosterID,
|
||||||
|
NeedApproval: true,
|
||||||
|
}
|
||||||
|
require.NoError(t, InsertRun(t.Context(), runNotInTheSameRepository, nil))
|
||||||
|
unrelatedPullRequest := int64(3)
|
||||||
|
runNotInTheSamePullRequest := &ActionRun{
|
||||||
|
RepoID: repoID,
|
||||||
|
PullRequestID: unrelatedPullRequest,
|
||||||
|
PullRequestPosterID: pullRequestPosterID,
|
||||||
|
NeedApproval: true,
|
||||||
|
}
|
||||||
|
require.NoError(t, InsertRun(t.Context(), runNotInTheSamePullRequest, nil))
|
||||||
|
|
||||||
|
t.Run("HasRunThatNeedApproval is false", func(t *testing.T) {
|
||||||
|
has, err := HasRunThatNeedApproval(t.Context(), repoID, pullRequestID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, has)
|
||||||
|
})
|
||||||
|
|
||||||
|
runNeedApproval := &ActionRun{
|
||||||
|
RepoID: repoID,
|
||||||
|
PullRequestID: pullRequestID,
|
||||||
|
PullRequestPosterID: pullRequestPosterID,
|
||||||
|
NeedApproval: true,
|
||||||
|
}
|
||||||
|
require.NoError(t, InsertRun(t.Context(), runNeedApproval, nil))
|
||||||
|
|
||||||
|
t.Run("HasRunThatNeedApproval is true", func(t *testing.T) {
|
||||||
|
has, err := HasRunThatNeedApproval(t.Context(), repoID, pullRequestID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, has)
|
||||||
|
})
|
||||||
|
|
||||||
|
assertApprovalEqual := func(t *testing.T, expected, actual *ActionRun) {
|
||||||
|
t.Helper()
|
||||||
|
assert.Equal(t, expected.RepoID, actual.RepoID)
|
||||||
|
assert.Equal(t, expected.PullRequestID, actual.PullRequestID)
|
||||||
|
assert.Equal(t, expected.PullRequestPosterID, actual.PullRequestPosterID)
|
||||||
|
assert.Equal(t, expected.NeedApproval, actual.NeedApproval)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("GetRunsThatNeedApproval", func(t *testing.T) {
|
||||||
|
runs, err := GetRunsThatNeedApprovalByRepoIDAndPullRequestID(t.Context(), repoID, pullRequestID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, runs, 1)
|
||||||
|
assertApprovalEqual(t, runNeedApproval, runs[0])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
97
models/actions/user.go
Normal file
97
models/actions/user.go
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
// Copyright 2025 The Forgejo Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package actions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"forgejo.org/models/db"
|
||||||
|
"forgejo.org/modules/timeutil"
|
||||||
|
|
||||||
|
"xorm.io/builder"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ActionUser struct {
|
||||||
|
ID int64 `xorm:"pk autoincr"`
|
||||||
|
UserID int64 `xorm:"INDEX UNIQUE(action_user_index) REFERENCES(user, id)"`
|
||||||
|
RepoID int64 `xorm:"INDEX UNIQUE(action_user_index) REFERENCES(repository, id)"`
|
||||||
|
|
||||||
|
TrustedWithPullRequests bool
|
||||||
|
|
||||||
|
LastAccess timeutil.TimeStamp `xorm:"INDEX"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
db.RegisterModel(new(ActionUser))
|
||||||
|
}
|
||||||
|
|
||||||
|
type ErrUserNotExist struct {
|
||||||
|
UserID int64
|
||||||
|
RepoID int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsErrUserNotExist(err error) bool {
|
||||||
|
_, ok := err.(ErrUserNotExist)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (err ErrUserNotExist) Error() string {
|
||||||
|
return fmt.Sprintf("ActionUser does not exist [user_id: %d, repo_id: %d]", err.UserID, err.RepoID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func InsertActionUser(ctx context.Context, user *ActionUser) error {
|
||||||
|
user.LastAccess = timeutil.TimeStampNow()
|
||||||
|
return db.Insert(ctx, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeleteActionUserByUserIDAndRepoID(ctx context.Context, userID, repoID int64) error {
|
||||||
|
_, err := db.GetEngine(ctx).Table(&ActionUser{}).Where("user_id=? AND repo_id=?", userID, repoID).Delete()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var updateFrequency = 24 * time.Hour
|
||||||
|
|
||||||
|
func MaybeUpdateAccess(ctx context.Context, user *ActionUser) error {
|
||||||
|
// Keep track of the last time the record was accessed to identify which one
|
||||||
|
// are never accessed so they can be removed eventually. But only every updateFrequency
|
||||||
|
// to not stress the underlying database.
|
||||||
|
if timeutil.TimeStampNow() > user.LastAccess.AddDuration(updateFrequency) {
|
||||||
|
user.LastAccess = timeutil.TimeStampNow()
|
||||||
|
if _, err := db.GetEngine(ctx).ID(user.ID).Cols("last_access").Update(user); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetActionUserByUserIDAndRepoID(ctx context.Context, userID, repoID int64) (*ActionUser, error) {
|
||||||
|
user := new(ActionUser)
|
||||||
|
has, err := db.GetEngine(ctx).Where("user_id=? AND repo_id=?", userID, repoID).Get(user)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if !has {
|
||||||
|
return nil, ErrUserNotExist{userID, repoID}
|
||||||
|
}
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetActionUserByUserIDAndRepoIDAndUpdateAccess(ctx context.Context, userID, repoID int64) (*ActionUser, error) {
|
||||||
|
user, err := GetActionUserByUserIDAndRepoID(ctx, userID, repoID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return user, MaybeUpdateAccess(ctx, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
var expire = 3 * 30 * 24 * time.Hour
|
||||||
|
|
||||||
|
func RevokeInactiveActionUser(ctx context.Context) error {
|
||||||
|
olderThan := timeutil.TimeStampNow().AddDuration(-expire)
|
||||||
|
|
||||||
|
_, err := db.GetEngine(ctx).Where(builder.Lt{"last_access": olderThan}).Delete(&ActionUser{})
|
||||||
|
return err
|
||||||
|
}
|
||||||
107
models/actions/user_test.go
Normal file
107
models/actions/user_test.go
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
// Copyright 2025 The Forgejo Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package actions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
repo_model "forgejo.org/models/repo"
|
||||||
|
"forgejo.org/models/unittest"
|
||||||
|
user_model "forgejo.org/models/user"
|
||||||
|
"forgejo.org/modules/test"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestActionUser_CreateDelete(t *testing.T) {
|
||||||
|
require.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
|
||||||
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
|
||||||
|
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||||
|
|
||||||
|
require.ErrorContains(t, InsertActionUser(t.Context(), &ActionUser{
|
||||||
|
UserID: user.ID,
|
||||||
|
}), "FOREIGN KEY")
|
||||||
|
|
||||||
|
require.ErrorContains(t, InsertActionUser(t.Context(), &ActionUser{
|
||||||
|
RepoID: repo.ID,
|
||||||
|
}), "FOREIGN KEY")
|
||||||
|
|
||||||
|
actionUser := &ActionUser{
|
||||||
|
UserID: user.ID,
|
||||||
|
RepoID: repo.ID,
|
||||||
|
}
|
||||||
|
require.NoError(t, InsertActionUser(t.Context(), actionUser))
|
||||||
|
assert.NotZero(t, actionUser.ID)
|
||||||
|
assert.NotZero(t, actionUser.LastAccess)
|
||||||
|
|
||||||
|
otherUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3})
|
||||||
|
actionUserNotSameUser := &ActionUser{
|
||||||
|
UserID: otherUser.ID,
|
||||||
|
RepoID: repo.ID,
|
||||||
|
}
|
||||||
|
require.NoError(t, InsertActionUser(t.Context(), actionUserNotSameUser))
|
||||||
|
|
||||||
|
otherRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3})
|
||||||
|
actionUserNotSameRepo := &ActionUser{
|
||||||
|
UserID: user.ID,
|
||||||
|
RepoID: otherRepo.ID,
|
||||||
|
}
|
||||||
|
require.NoError(t, InsertActionUser(t.Context(), actionUserNotSameRepo))
|
||||||
|
|
||||||
|
unittest.AssertExistsAndLoadBean(t, &ActionUser{ID: actionUser.ID})
|
||||||
|
require.NoError(t, DeleteActionUserByUserIDAndRepoID(t.Context(), user.ID, repo.ID))
|
||||||
|
unittest.AssertNotExistsBean(t, &ActionUser{ID: actionUser.ID})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActionUser_RevokeInactiveActionUser(t *testing.T) {
|
||||||
|
require.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
|
||||||
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
|
||||||
|
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||||
|
|
||||||
|
actionUser := &ActionUser{
|
||||||
|
UserID: user.ID,
|
||||||
|
RepoID: repo.ID,
|
||||||
|
}
|
||||||
|
require.NoError(t, InsertActionUser(t.Context(), actionUser))
|
||||||
|
|
||||||
|
t.Run("not revoked because it was just created", func(t *testing.T) {
|
||||||
|
unittest.AssertExistsAndLoadBean(t, &ActionUser{ID: actionUser.ID})
|
||||||
|
require.NoError(t, RevokeInactiveActionUser(t.Context()))
|
||||||
|
unittest.AssertExistsAndLoadBean(t, &ActionUser{ID: actionUser.ID})
|
||||||
|
})
|
||||||
|
|
||||||
|
// needs to be at least 1 second because unix timestamp resolution is 1 second
|
||||||
|
defer test.MockVariableValue(&expire, 1*time.Second)()
|
||||||
|
|
||||||
|
t.Run("used not updated too frequently", func(t *testing.T) {
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
usedActionUser, err := GetActionUserByUserIDAndRepoIDAndUpdateAccess(t.Context(), user.ID, repo.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, actionUser.ID, usedActionUser.ID)
|
||||||
|
assert.Equal(t, usedActionUser.LastAccess, actionUser.LastAccess)
|
||||||
|
})
|
||||||
|
|
||||||
|
defer test.MockVariableValue(&updateFrequency, 0)()
|
||||||
|
|
||||||
|
t.Run("not revoked because it was recently used", func(t *testing.T) {
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
usedActionUser, err := GetActionUserByUserIDAndRepoIDAndUpdateAccess(t.Context(), user.ID, repo.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, actionUser.ID, usedActionUser.ID)
|
||||||
|
assert.Greater(t, usedActionUser.LastAccess, actionUser.LastAccess)
|
||||||
|
require.NoError(t, RevokeInactiveActionUser(t.Context()))
|
||||||
|
unittest.AssertExistsAndLoadBean(t, &ActionUser{ID: actionUser.ID})
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("revoked because it was not recently used", func(t *testing.T) {
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
unittest.AssertExistsAndLoadBean(t, &ActionUser{ID: actionUser.ID})
|
||||||
|
require.NoError(t, RevokeInactiveActionUser(t.Context()))
|
||||||
|
unittest.AssertNotExistsBean(t, &ActionUser{ID: actionUser.ID})
|
||||||
|
})
|
||||||
|
}
|
||||||
6
models/fixtures/action_user.yml
Normal file
6
models/fixtures/action_user.yml
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
-
|
||||||
|
id: 1
|
||||||
|
repo_id: 1
|
||||||
|
user_id: 2
|
||||||
|
trusted_with_pull_requests: true
|
||||||
|
last_access: 1683636528
|
||||||
104
models/forgejo_migrations/v14a_actions-approval-and-trust.go
Normal file
104
models/forgejo_migrations/v14a_actions-approval-and-trust.go
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
// Copyright 2025 The Forgejo Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package forgejo_migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
actions_model "forgejo.org/models/actions"
|
||||||
|
"forgejo.org/models/db"
|
||||||
|
"forgejo.org/modules/log"
|
||||||
|
"forgejo.org/modules/timeutil"
|
||||||
|
|
||||||
|
"xorm.io/xorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
registerMigration(&Migration{
|
||||||
|
Description: "add actions approval and trust table and fields",
|
||||||
|
Upgrade: v14ActionsApprovalAndTrust,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func v14ActionsApprovalAndTrust(x *xorm.Engine) error {
|
||||||
|
if err := v14ActionsApprovalAndTrustCreateTableActionUser(x); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := v14ActionsApprovalAndTrustAddActionsRunFields(x); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return v14ActionsApprovalAndTrustPopulateTableActionUser(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func v14ActionsApprovalAndTrustCreateTableActionUser(x *xorm.Engine) error {
|
||||||
|
type ActionUser struct {
|
||||||
|
ID int64 `xorm:"pk autoincr"`
|
||||||
|
UserID int64 `xorm:"INDEX UNIQUE(action_user_index) REFERENCES(user, id)"`
|
||||||
|
RepoID int64 `xorm:"INDEX UNIQUE(action_user_index) REFERENCES(repository, id)"`
|
||||||
|
|
||||||
|
TrustedWithPullRequests bool
|
||||||
|
|
||||||
|
LastAccess timeutil.TimeStamp `xorm:"INDEX"`
|
||||||
|
}
|
||||||
|
return x.Sync(new(ActionUser))
|
||||||
|
}
|
||||||
|
|
||||||
|
func v14ActionsApprovalAndTrustAddActionsRunFields(x *xorm.Engine) error {
|
||||||
|
type ActionRun struct {
|
||||||
|
PullRequestPosterID int64
|
||||||
|
PullRequestID int64 `xorm:"index"`
|
||||||
|
}
|
||||||
|
_, err := x.SyncWithOptions(xorm.SyncOptions{IgnoreDropIndices: true}, new(ActionRun))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
type v14ActionsApprovalAndTrustTrusted struct {
|
||||||
|
RepoID int64
|
||||||
|
UserID int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func v14ActionsApprovalAndTrustPopulateTableActionUser(x *xorm.Engine) error {
|
||||||
|
//
|
||||||
|
// Users approved once were trusted before and are trusted now.
|
||||||
|
//
|
||||||
|
// The admin will see they can revoke that trust when the user
|
||||||
|
// submits a new pull request.
|
||||||
|
//
|
||||||
|
// If the user does not submit any pull request, this trust will
|
||||||
|
// eventually be automatically revoked.
|
||||||
|
//
|
||||||
|
// The number of trusted users is assumed to be small enough to not require
|
||||||
|
// pagination, even on large instances.
|
||||||
|
//
|
||||||
|
log.Info("v14a_actions-approval-and-trust: search")
|
||||||
|
var trustedList []*v14ActionsApprovalAndTrustTrusted
|
||||||
|
if err := x.Table("`action_run`").
|
||||||
|
Select("DISTINCT `action_run`.`repo_id`, `action_run`.`trigger_user_id` AS `user_id`").
|
||||||
|
Join("INNER", "`repository`", "`repository`.`id` = `action_run`.`repo_id`").
|
||||||
|
Join("INNER", "`user`", "`user`.`id` = `action_run`.`trigger_user_id`").
|
||||||
|
Where("`action_run`.`approved_by` > 0 AND `action_run`.`trigger_user_id` > 0").
|
||||||
|
OrderBy("`action_run`.`repo_id`, `action_run`.`trigger_user_id`").
|
||||||
|
Find(&trustedList); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info("v14a_actions-approval-and-trust: start adding %d users trusted with workflow runs", len(trustedList))
|
||||||
|
if err := db.WithTx(db.DefaultContext, func(ctx context.Context) error {
|
||||||
|
for _, trusted := range trustedList {
|
||||||
|
log.Debug("v14a_actions-approval-and-trust: repository %d trusts user %d", trusted.RepoID, trusted.UserID)
|
||||||
|
if err := actions_model.InsertActionUser(ctx, &actions_model.ActionUser{
|
||||||
|
RepoID: trusted.RepoID,
|
||||||
|
UserID: trusted.UserID,
|
||||||
|
TrustedWithPullRequests: true,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.Info("v14a_actions-approval-and-trust: done adding %d users", len(trustedList))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
// Copyright 2025 The Forgejo Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package forgejo_migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
actions_model "forgejo.org/models/actions"
|
||||||
|
"forgejo.org/models/db"
|
||||||
|
migration_tests "forgejo.org/models/gitea_migrations/test"
|
||||||
|
repo_model "forgejo.org/models/repo"
|
||||||
|
user_model "forgejo.org/models/user"
|
||||||
|
"forgejo.org/modules/timeutil"
|
||||||
|
webhook_module "forgejo.org/modules/webhook"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test_v14ActionsApprovalAndTrustPopulateTableActionUser(t *testing.T) {
|
||||||
|
type ActionUser struct {
|
||||||
|
ID int64 `xorm:"pk autoincr"`
|
||||||
|
UserID int64 `xorm:"INDEX UNIQUE(action_user_index) REFERENCES(user, id)"`
|
||||||
|
RepoID int64 `xorm:"INDEX UNIQUE(action_user_index) REFERENCES(repository, id)"`
|
||||||
|
|
||||||
|
TrustedWithPullRequests bool
|
||||||
|
|
||||||
|
LastAccess timeutil.TimeStamp `xorm:"INDEX"`
|
||||||
|
}
|
||||||
|
type ActionRun struct {
|
||||||
|
ID int64
|
||||||
|
Title string
|
||||||
|
RepoID int64 `xorm:"index unique(repo_index) index(concurrency)"`
|
||||||
|
Repo *repo_model.Repository `xorm:"-"`
|
||||||
|
OwnerID int64 `xorm:"index"`
|
||||||
|
WorkflowID string `xorm:"index"` // the name of workflow file
|
||||||
|
Index int64 `xorm:"index unique(repo_index)"` // a unique number for each run of a repository
|
||||||
|
TriggerUserID int64 `xorm:"index"`
|
||||||
|
TriggerUser *user_model.User `xorm:"-"`
|
||||||
|
ScheduleID int64
|
||||||
|
Ref string `xorm:"index"` // the commit/tag/… that caused the run
|
||||||
|
IsRefDeleted bool `xorm:"-"`
|
||||||
|
CommitSHA string
|
||||||
|
Event webhook_module.HookEventType // the webhook event that causes the workflow to run
|
||||||
|
EventPayload string `xorm:"LONGTEXT"`
|
||||||
|
TriggerEvent string // the trigger event defined in the `on` configuration of the triggered workflow
|
||||||
|
Status actions_model.Status `xorm:"index"`
|
||||||
|
Version int `xorm:"version default 0"` // Status could be updated concomitantly, so an optimistic lock is needed
|
||||||
|
// Started and Stopped is used for recording last run time, if rerun happened, they will be reset to 0
|
||||||
|
Started timeutil.TimeStamp
|
||||||
|
Stopped timeutil.TimeStamp
|
||||||
|
// PreviousDuration is used for recording previous duration
|
||||||
|
PreviousDuration time.Duration
|
||||||
|
Created timeutil.TimeStamp `xorm:"created"`
|
||||||
|
Updated timeutil.TimeStamp `xorm:"updated"`
|
||||||
|
NotifyEmail bool
|
||||||
|
|
||||||
|
// pull request trust
|
||||||
|
IsForkPullRequest bool
|
||||||
|
PullRequestPosterID int64
|
||||||
|
PullRequestID int64 `xorm:"index"`
|
||||||
|
NeedApproval bool
|
||||||
|
ApprovedBy int64 `xorm:"index"`
|
||||||
|
|
||||||
|
ConcurrencyGroup string `xorm:"'concurrency_group' index(concurrency)"`
|
||||||
|
ConcurrencyType actions_model.ConcurrencyMode
|
||||||
|
|
||||||
|
PreExecutionError string `xorm:"LONGTEXT"` // used to report errors that blocked execution of a workflow
|
||||||
|
}
|
||||||
|
type Repository struct {
|
||||||
|
ID int64 `xorm:"pk autoincr"`
|
||||||
|
}
|
||||||
|
type User struct {
|
||||||
|
ID int64 `xorm:"pk autoincr"`
|
||||||
|
}
|
||||||
|
x, deferable := migration_tests.PrepareTestEnv(t, 0, new(User), new(Repository), new(ActionUser), new(ActionRun))
|
||||||
|
defer deferable()
|
||||||
|
if x == nil || t.Failed() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, v14ActionsApprovalAndTrustPopulateTableActionUser(x))
|
||||||
|
|
||||||
|
var users []*actions_model.ActionUser
|
||||||
|
require.NoError(t, db.GetEngine(t.Context()).Select("`repo_id`, `user_id`").OrderBy("`id`").Find(&users))
|
||||||
|
// See models/gitea_migrations/fixtures/Test_v14ActionsApprovalAndTrustPopulateTableActionUser/action_run.yml
|
||||||
|
assert.Equal(t, []*actions_model.ActionUser{
|
||||||
|
{
|
||||||
|
UserID: 3,
|
||||||
|
RepoID: 15,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
UserID: 3,
|
||||||
|
RepoID: 63,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
UserID: 4,
|
||||||
|
RepoID: 63,
|
||||||
|
},
|
||||||
|
}, users)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,151 @@
|
||||||
|
-
|
||||||
|
id: 1000
|
||||||
|
title: "63, 2 not trusted"
|
||||||
|
repo_id: 63
|
||||||
|
trigger_user_id: 2
|
||||||
|
approved_by: 0
|
||||||
|
owner_id: 2
|
||||||
|
workflow_id: "running.yaml"
|
||||||
|
index: 1000
|
||||||
|
ref: "refs/heads/main"
|
||||||
|
commit_sha: "97f29ee599c373c729132a5c46a046978311e0ee"
|
||||||
|
trigger_event: "workflow_dispatch"
|
||||||
|
is_fork_pull_request: 0
|
||||||
|
status: 1 # success
|
||||||
|
started: 1683636528
|
||||||
|
created: 1683636108
|
||||||
|
updated: 1683636626
|
||||||
|
need_approval: 0
|
||||||
|
|
||||||
|
-
|
||||||
|
id: 1001
|
||||||
|
title: "63, 3 trusted"
|
||||||
|
repo_id: 63
|
||||||
|
trigger_user_id: 3
|
||||||
|
approved_by: 1
|
||||||
|
owner_id: 2
|
||||||
|
workflow_id: "running.yaml"
|
||||||
|
index: 1001
|
||||||
|
ref: "refs/heads/main"
|
||||||
|
commit_sha: "97f29ee599c373c729132a5c46a046978311e0ee"
|
||||||
|
trigger_event: "workflow_dispatch"
|
||||||
|
is_fork_pull_request: 0
|
||||||
|
status: 1 # success
|
||||||
|
started: 1683636528
|
||||||
|
created: 1683636108
|
||||||
|
updated: 1683636626
|
||||||
|
need_approval: 0
|
||||||
|
|
||||||
|
-
|
||||||
|
id: 1002
|
||||||
|
title: "63, 3 trusted (duplicate, ignored)"
|
||||||
|
repo_id: 63
|
||||||
|
trigger_user_id: 3
|
||||||
|
approved_by: 1
|
||||||
|
owner_id: 2
|
||||||
|
workflow_id: "running.yaml"
|
||||||
|
index: 1002
|
||||||
|
ref: "refs/heads/main"
|
||||||
|
commit_sha: "97f29ee599c373c729132a5c46a046978311e0ee"
|
||||||
|
trigger_event: "workflow_dispatch"
|
||||||
|
is_fork_pull_request: 0
|
||||||
|
status: 1 # success
|
||||||
|
started: 1683636528
|
||||||
|
created: 1683636108
|
||||||
|
updated: 1683636626
|
||||||
|
need_approval: 0
|
||||||
|
|
||||||
|
-
|
||||||
|
id: 1003
|
||||||
|
title: "5000, 3 trusted (non existent repo_id, ignored)"
|
||||||
|
repo_id: 5000
|
||||||
|
trigger_user_id: 2
|
||||||
|
approved_by: 1
|
||||||
|
owner_id: 2
|
||||||
|
workflow_id: "running.yaml"
|
||||||
|
index: 1003
|
||||||
|
ref: "refs/heads/main"
|
||||||
|
commit_sha: "97f29ee599c373c729132a5c46a046978311e0ee"
|
||||||
|
trigger_event: "workflow_dispatch"
|
||||||
|
is_fork_pull_request: 0
|
||||||
|
status: 1 # success
|
||||||
|
started: 1683636528
|
||||||
|
created: 1683636108
|
||||||
|
updated: 1683636626
|
||||||
|
need_approval: 0
|
||||||
|
|
||||||
|
-
|
||||||
|
id: 1004
|
||||||
|
title: "63, 3000 trusted (non existent trigger_user, ignored)"
|
||||||
|
repo_id: 63
|
||||||
|
trigger_user_id: 3000
|
||||||
|
approved_by: 1
|
||||||
|
owner_id: 2
|
||||||
|
workflow_id: "running.yaml"
|
||||||
|
index: 1004
|
||||||
|
ref: "refs/heads/main"
|
||||||
|
commit_sha: "97f29ee599c373c729132a5c46a046978311e0ee"
|
||||||
|
trigger_event: "workflow_dispatch"
|
||||||
|
is_fork_pull_request: 0
|
||||||
|
status: 1 # success
|
||||||
|
started: 1683636528
|
||||||
|
created: 1683636108
|
||||||
|
updated: 1683636626
|
||||||
|
need_approval: 0
|
||||||
|
|
||||||
|
-
|
||||||
|
id: 1005
|
||||||
|
title: "63, 4 trusted"
|
||||||
|
repo_id: 63
|
||||||
|
trigger_user_id: 4
|
||||||
|
approved_by: 1
|
||||||
|
owner_id: 2
|
||||||
|
workflow_id: "running.yaml"
|
||||||
|
index: 1005
|
||||||
|
ref: "refs/heads/main"
|
||||||
|
commit_sha: "97f29ee599c373c729132a5c46a046978311e0ee"
|
||||||
|
trigger_event: "workflow_dispatch"
|
||||||
|
is_fork_pull_request: 0
|
||||||
|
status: 1 # success
|
||||||
|
started: 1683636528
|
||||||
|
created: 1683636108
|
||||||
|
updated: 1683636626
|
||||||
|
need_approval: 0
|
||||||
|
|
||||||
|
-
|
||||||
|
id: 1006
|
||||||
|
title: "15, 3 trusted"
|
||||||
|
repo_id: 15
|
||||||
|
trigger_user_id: 3
|
||||||
|
approved_by: 1
|
||||||
|
owner_id: 2
|
||||||
|
workflow_id: "running.yaml"
|
||||||
|
index: 1006
|
||||||
|
ref: "refs/heads/main"
|
||||||
|
commit_sha: "97f29ee599c373c729132a5c46a046978311e0ee"
|
||||||
|
trigger_event: "workflow_dispatch"
|
||||||
|
is_fork_pull_request: 0
|
||||||
|
status: 1 # success
|
||||||
|
started: 1683636528
|
||||||
|
created: 1683636108
|
||||||
|
updated: 1683636626
|
||||||
|
need_approval: 0
|
||||||
|
|
||||||
|
-
|
||||||
|
id: 1007
|
||||||
|
title: "15, 0 trigger user id zero is ignored"
|
||||||
|
repo_id: 15
|
||||||
|
trigger_user_id: 0
|
||||||
|
approved_by: 1
|
||||||
|
owner_id: 2
|
||||||
|
workflow_id: "running.yaml"
|
||||||
|
index: 1007
|
||||||
|
ref: "refs/heads/main"
|
||||||
|
commit_sha: "97f29ee599c373c729132a5c46a046978311e0ee"
|
||||||
|
trigger_event: "workflow_dispatch"
|
||||||
|
is_fork_pull_request: 0
|
||||||
|
status: 1 # success
|
||||||
|
started: 1683636528
|
||||||
|
created: 1683636108
|
||||||
|
updated: 1683636626
|
||||||
|
need_approval: 0
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
-
|
||||||
|
id: 15
|
||||||
|
|
||||||
|
-
|
||||||
|
id: 63
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
- # NOTE: this user (id=1) is the admin
|
||||||
|
id: 1
|
||||||
|
|
||||||
|
-
|
||||||
|
id: 2
|
||||||
|
|
||||||
|
-
|
||||||
|
id: 3
|
||||||
|
|
||||||
|
-
|
||||||
|
id: 4
|
||||||
|
|
@ -470,6 +470,23 @@ func (pr *PullRequest) GetReviewCommentsCount(ctx context.Context) int {
|
||||||
return int(count)
|
return int(count)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (pr *PullRequest) IsForkPullRequest() bool {
|
||||||
|
var isForkPullRequest bool
|
||||||
|
|
||||||
|
switch pr.Flow {
|
||||||
|
case PullRequestFlowGithub:
|
||||||
|
isForkPullRequest = pr.IsFromFork()
|
||||||
|
case PullRequestFlowAGit:
|
||||||
|
// there is no fork concept in AGit flow, anyone with read permission can push refs/for/<target-branch>/<topic-branch> to the repo.
|
||||||
|
// So we must treat it as a fork pull request because it may be from an untrusted user
|
||||||
|
isForkPullRequest = true
|
||||||
|
default:
|
||||||
|
// unknown flow, treat it as it's a fork pull request
|
||||||
|
isForkPullRequest = true
|
||||||
|
}
|
||||||
|
return isForkPullRequest
|
||||||
|
}
|
||||||
|
|
||||||
// IsChecking returns true if this pull request is still checking conflict.
|
// IsChecking returns true if this pull request is still checking conflict.
|
||||||
func (pr *PullRequest) IsChecking() bool {
|
func (pr *PullRequest) IsChecking() bool {
|
||||||
return pr.Status == PullRequestStatusChecking
|
return pr.Status == PullRequestStatusChecking
|
||||||
|
|
|
||||||
|
|
@ -466,6 +466,43 @@ func TestGetPullRequestByMergedCommit(t *testing.T) {
|
||||||
require.ErrorAs(t, err, &issues_model.ErrPullRequestNotExist{})
|
require.ErrorAs(t, err, &issues_model.ErrPullRequestNotExist{})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPullRequest_IsForkPullRequest(t *testing.T) {
|
||||||
|
require.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
|
||||||
|
t.Run("FlowGithub from a fork", func(t *testing.T) {
|
||||||
|
pr := &issues_model.PullRequest{
|
||||||
|
Flow: issues_model.PullRequestFlowGithub,
|
||||||
|
HeadRepoID: 111,
|
||||||
|
BaseRepoID: 222,
|
||||||
|
}
|
||||||
|
assert.True(t, pr.IsForkPullRequest())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("FlowGithub from the same repository", func(t *testing.T) {
|
||||||
|
pr := &issues_model.PullRequest{
|
||||||
|
Flow: issues_model.PullRequestFlowGithub,
|
||||||
|
HeadRepoID: 111,
|
||||||
|
BaseRepoID: 111,
|
||||||
|
}
|
||||||
|
assert.False(t, pr.IsForkPullRequest())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("PullRequestFlowAGit", func(t *testing.T) {
|
||||||
|
pr := &issues_model.PullRequest{
|
||||||
|
Flow: issues_model.PullRequestFlowAGit,
|
||||||
|
}
|
||||||
|
assert.True(t, pr.IsForkPullRequest())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Other", func(t *testing.T) {
|
||||||
|
unknown := issues_model.PullRequestFlow(4854)
|
||||||
|
pr := &issues_model.PullRequest{
|
||||||
|
Flow: unknown,
|
||||||
|
}
|
||||||
|
assert.True(t, pr.IsForkPullRequest())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestMigrate_InsertPullRequests(t *testing.T) {
|
func TestMigrate_InsertPullRequests(t *testing.T) {
|
||||||
require.NoError(t, unittest.PrepareTestDatabase())
|
require.NoError(t, unittest.PrepareTestDatabase())
|
||||||
reponame := "repo1"
|
reponame := "repo1"
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import (
|
||||||
"code.forgejo.org/forgejo/runner/v11/act/jobparser"
|
"code.forgejo.org/forgejo/runner/v11/act/jobparser"
|
||||||
)
|
)
|
||||||
|
|
||||||
func jobParser(workflow []byte, options ...jobparser.ParseOption) ([]*jobparser.SingleWorkflow, error) {
|
func JobParser(workflow []byte, options ...jobparser.ParseOption) ([]*jobparser.SingleWorkflow, error) {
|
||||||
singleWorkflows, err := jobparser.Parse(workflow, false, options...)
|
singleWorkflows, err := jobparser.Parse(workflow, false, options...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -200,7 +200,7 @@ jobs:
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(testCase.name, func(t *testing.T) {
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
sw, err := jobParser([]byte(testCase.workflow))
|
sw, err := JobParser([]byte(testCase.workflow))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
for i, sw := range sw {
|
for i, sw := range sw {
|
||||||
actual, err := sw.Marshal()
|
actual, err := sw.Marshal()
|
||||||
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"io"
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
actions_model "forgejo.org/models/actions"
|
||||||
"forgejo.org/modules/git"
|
"forgejo.org/modules/git"
|
||||||
"forgejo.org/modules/log"
|
"forgejo.org/modules/log"
|
||||||
api "forgejo.org/modules/structs"
|
api "forgejo.org/modules/structs"
|
||||||
|
|
@ -25,6 +26,7 @@ type DetectedWorkflow struct {
|
||||||
TriggerEvent *jobparser.Event
|
TriggerEvent *jobparser.Event
|
||||||
Content []byte
|
Content []byte
|
||||||
EventDetectionError error
|
EventDetectionError error
|
||||||
|
NeedApproval actions_model.ApprovalType
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
|
|
||||||
|
|
@ -2793,8 +2793,8 @@ projects.read = <b>Read:</b> Access repository project boards.
|
||||||
projects.write = <b>Write:</b> Create projects and columns and edit them.
|
projects.write = <b>Write:</b> Create projects and columns and edit them.
|
||||||
packages.read = <b>Read:</b> View and download packages assigned to the repository.
|
packages.read = <b>Read:</b> View and download packages assigned to the repository.
|
||||||
packages.write = <b>Write:</b> Publish and delete packages assigned to the repository.
|
packages.write = <b>Write:</b> Publish and delete packages assigned to the repository.
|
||||||
actions.read = <b>Read:</b> View integrated CI/CD pipelines and their logs.
|
actions.read = <b>Read:</b> View workflow runs and their logs.
|
||||||
actions.write = <b>Write:</b> Manually trigger, restart, cancel or approve pending CI/CD pipelines.
|
actions.write = <b>Write:</b> Trigger, restart, and cancel workflows. Manage trust delegation to pull requests posters.
|
||||||
ext_issues = Access the link to an external issue tracker. The permissions are managed externally.
|
ext_issues = Access the link to an external issue tracker. The permissions are managed externally.
|
||||||
ext_wiki = Access the link to an external wiki. The permissions are managed externally.
|
ext_wiki = Access the link to an external wiki. The permissions are managed externally.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,19 @@
|
||||||
"relativetime.2months": "two months ago",
|
"relativetime.2months": "two months ago",
|
||||||
"relativetime.1year": "last year",
|
"relativetime.1year": "last year",
|
||||||
"relativetime.2years": "two years ago",
|
"relativetime.2years": "two years ago",
|
||||||
|
"repo.pulls.poster_manage_approval": "Manage approval",
|
||||||
|
"repo.pulls.poster_requires_approval": "Some workflows are <a href=\"%[1]s\">waiting to be reviewed.</a>",
|
||||||
|
"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 <a href=\"%[1]s\">always trusted to run workflows.</a>",
|
||||||
|
"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.already_merged": "Merge failed: This pull request has already been merged.",
|
||||||
"repo.pulls.merged_title_desc": {
|
"repo.pulls.merged_title_desc": {
|
||||||
"one": "merged %[1]d commit from <code>%[2]s</code> into <code>%[3]s</code> %[4]s",
|
"one": "merged %[1]d commit from <code>%[2]s</code> into <code>%[3]s</code> %[4]s",
|
||||||
|
|
@ -137,6 +150,7 @@
|
||||||
"admin.auths.allow_username_change.description": "Allow users to change their username in the profile settings",
|
"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.cleanup_offline_runners": "Cleanup offline runners",
|
||||||
"admin.dashboard.remove_resolved_reports": "Remove resolved reports",
|
"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.security": "Security configuration",
|
||||||
"admin.config.global_2fa_requirement.title": "Global two-factor requirement",
|
"admin.config.global_2fa_requirement.title": "Global two-factor requirement",
|
||||||
"admin.config.global_2fa_requirement.none": "No",
|
"admin.config.global_2fa_requirement.none": "No",
|
||||||
|
|
|
||||||
|
|
@ -680,42 +680,6 @@ func Cancel(ctx *context_module.Context) {
|
||||||
ctx.JSON(http.StatusOK, struct{}{})
|
ctx.JSON(http.StatusOK, struct{}{})
|
||||||
}
|
}
|
||||||
|
|
||||||
func Approve(ctx *context_module.Context) {
|
|
||||||
runIndex := ctx.ParamsInt64("run")
|
|
||||||
|
|
||||||
current, jobs := getRunJobs(ctx, runIndex, -1)
|
|
||||||
if ctx.Written() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
run := current.Run
|
|
||||||
doer := ctx.Doer
|
|
||||||
|
|
||||||
if err := db.WithTx(ctx, func(ctx context.Context) error {
|
|
||||||
run.NeedApproval = false
|
|
||||||
run.ApprovedBy = doer.ID
|
|
||||||
if err := actions_service.UpdateRun(ctx, run, "need_approval", "approved_by"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for _, job := range jobs {
|
|
||||||
if len(job.Needs) == 0 && job.Status.IsBlocked() {
|
|
||||||
job.Status = actions_model.StatusWaiting
|
|
||||||
_, err := actions_service.UpdateRunJob(ctx, job, nil, "status")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}); err != nil {
|
|
||||||
ctx.Error(http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
actions_service.CreateCommitStatus(ctx, jobs...)
|
|
||||||
|
|
||||||
ctx.JSON(http.StatusOK, struct{}{})
|
|
||||||
}
|
|
||||||
|
|
||||||
// getRunJobs gets the jobs of runIndex, and returns jobs[jobIndex], jobs.
|
// getRunJobs gets the jobs of runIndex, and returns jobs[jobIndex], jobs.
|
||||||
// Any error will be written to the ctx.
|
// Any error will be written to the ctx.
|
||||||
// It never returns a nil job of an empty jobs, if the jobIndex is out of range, it will be treated as 0.
|
// It never returns a nil job of an empty jobs, if the jobIndex is out of range, it will be treated as 0.
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"forgejo.org/models"
|
"forgejo.org/models"
|
||||||
|
actions_model "forgejo.org/models/actions"
|
||||||
activities_model "forgejo.org/models/activities"
|
activities_model "forgejo.org/models/activities"
|
||||||
asymkey_model "forgejo.org/models/asymkey"
|
asymkey_model "forgejo.org/models/asymkey"
|
||||||
"forgejo.org/models/db"
|
"forgejo.org/models/db"
|
||||||
|
|
@ -44,6 +45,7 @@ import (
|
||||||
"forgejo.org/modules/util"
|
"forgejo.org/modules/util"
|
||||||
"forgejo.org/modules/web"
|
"forgejo.org/modules/web"
|
||||||
"forgejo.org/routers/utils"
|
"forgejo.org/routers/utils"
|
||||||
|
actions_service "forgejo.org/services/actions"
|
||||||
asymkey_service "forgejo.org/services/asymkey"
|
asymkey_service "forgejo.org/services/asymkey"
|
||||||
"forgejo.org/services/automerge"
|
"forgejo.org/services/automerge"
|
||||||
"forgejo.org/services/context"
|
"forgejo.org/services/context"
|
||||||
|
|
@ -764,6 +766,11 @@ func PrepareViewPullInfo(ctx *context.Context, issue *issues_model.Issue) *git.C
|
||||||
ctx.Data["IsNothingToCompare"] = true
|
ctx.Data["IsNothingToCompare"] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PrepareViewPullInfoActions(ctx, pull)
|
||||||
|
if ctx.Written() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
if pull.IsWorkInProgress(ctx) {
|
if pull.IsWorkInProgress(ctx) {
|
||||||
ctx.Data["IsPullWorkInProgress"] = true
|
ctx.Data["IsPullWorkInProgress"] = true
|
||||||
ctx.Data["WorkInProgressPrefix"] = pull.GetWorkInProgressPrefix(ctx)
|
ctx.Data["WorkInProgressPrefix"] = pull.GetWorkInProgressPrefix(ctx)
|
||||||
|
|
@ -1942,3 +1949,67 @@ func SetAllowEdits(ctx *context.Context) {
|
||||||
"allow_maintainer_edit": pr.AllowMaintainerEdit,
|
"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()))
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -848,6 +848,7 @@ func registerRoutes(m *web.Route) {
|
||||||
reqRepoProjectsWriter := context.RequireRepoWriter(unit.TypeProjects)
|
reqRepoProjectsWriter := context.RequireRepoWriter(unit.TypeProjects)
|
||||||
reqRepoActionsReader := context.RequireRepoReader(unit.TypeActions)
|
reqRepoActionsReader := context.RequireRepoReader(unit.TypeActions)
|
||||||
reqRepoActionsWriter := context.RequireRepoWriter(unit.TypeActions)
|
reqRepoActionsWriter := context.RequireRepoWriter(unit.TypeActions)
|
||||||
|
reqRepoDelegateActionTrust := context.RequireRepoDelegateActionTrust()
|
||||||
|
|
||||||
reqPackageAccess := func(accessMode perm.AccessMode) func(ctx *context.Context) {
|
reqPackageAccess := func(accessMode perm.AccessMode) func(ctx *context.Context) {
|
||||||
return func(ctx *context.Context) {
|
return func(ctx *context.Context) {
|
||||||
|
|
@ -1217,6 +1218,7 @@ func registerRoutes(m *web.Route) {
|
||||||
m.Group("/{type:issues|pulls}", func() {
|
m.Group("/{type:issues|pulls}", func() {
|
||||||
m.Group("/{index}", func() {
|
m.Group("/{index}", func() {
|
||||||
m.Post("/title", repo.UpdateIssueTitle)
|
m.Post("/title", repo.UpdateIssueTitle)
|
||||||
|
m.Post("/action-user-trust", reqRepoActionsReader, actions.MustEnableActions, reqRepoDelegateActionTrust, repo.UpdateTrustWithPullRequestActions)
|
||||||
m.Post("/content", repo.UpdateIssueContent)
|
m.Post("/content", repo.UpdateIssueContent)
|
||||||
m.Post("/deadline", web.Bind(structs.EditDeadlineOption{}), repo.UpdateIssueDeadline)
|
m.Post("/deadline", web.Bind(structs.EditDeadlineOption{}), repo.UpdateIssueDeadline)
|
||||||
m.Post("/watch", repo.IssueWatch)
|
m.Post("/watch", repo.IssueWatch)
|
||||||
|
|
@ -1460,7 +1462,6 @@ func registerRoutes(m *web.Route) {
|
||||||
Post(web.Bind(actions.ViewRequest{}), actions.ViewPost)
|
Post(web.Bind(actions.ViewRequest{}), actions.ViewPost)
|
||||||
})
|
})
|
||||||
m.Post("/cancel", reqRepoActionsWriter, actions.Cancel)
|
m.Post("/cancel", reqRepoActionsWriter, actions.Cancel)
|
||||||
m.Post("/approve", reqRepoActionsWriter, actions.Approve)
|
|
||||||
m.Get("/artifacts", actions.ArtifactsView)
|
m.Get("/artifacts", actions.ArtifactsView)
|
||||||
m.Get("/artifacts/{artifact_name_or_id}", actions.ArtifactsDownloadView)
|
m.Get("/artifacts/{artifact_name_or_id}", actions.ArtifactsDownloadView)
|
||||||
m.Delete("/artifacts/{artifact_name}", reqRepoActionsWriter, actions.ArtifactsDeleteView)
|
m.Delete("/artifacts/{artifact_name}", reqRepoActionsWriter, actions.ArtifactsDeleteView)
|
||||||
|
|
|
||||||
16
services/actions/TestActionsNotifier_IsTrusted/issue.yml
Normal file
16
services/actions/TestActionsNotifier_IsTrusted/issue.yml
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
-
|
||||||
|
id: 8000
|
||||||
|
repo_id: 10
|
||||||
|
index: 1000
|
||||||
|
poster_id: 1 # admin
|
||||||
|
original_author_id: 0
|
||||||
|
name: pr8000
|
||||||
|
content: pull request 8000
|
||||||
|
milestone_id: 0
|
||||||
|
priority: 0
|
||||||
|
is_closed: false
|
||||||
|
is_pull: true
|
||||||
|
num_comments: 0
|
||||||
|
created_unix: 946684820
|
||||||
|
updated_unix: 978307180
|
||||||
|
is_locked: false
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
-
|
||||||
|
id: 3000
|
||||||
|
type: 0 # pull request
|
||||||
|
status: 2 # mergeable
|
||||||
|
issue_id: 8000
|
||||||
|
index: 1000
|
||||||
|
head_repo_id: 11
|
||||||
|
base_repo_id: 10
|
||||||
|
head_branch: branch3000
|
||||||
|
base_branch: master
|
||||||
|
merge_base: 0abcb056019adb83
|
||||||
|
has_merged: false
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
-
|
||||||
|
id: 11500
|
||||||
|
repo_id: 10
|
||||||
|
type: 10 # TypeActions
|
||||||
|
config: "{}"
|
||||||
|
created_unix: 946684810
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
-
|
||||||
|
id: 10
|
||||||
|
repo_id: 10
|
||||||
|
user_id: 4
|
||||||
|
trusted_with_pull_requests: true
|
||||||
|
last_access: 1683636528
|
||||||
|
-
|
||||||
|
id: 11
|
||||||
|
repo_id: 10
|
||||||
|
user_id: 29
|
||||||
|
trusted_with_pull_requests: true
|
||||||
|
last_access: 1683636528
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
-
|
||||||
|
id: 81000
|
||||||
|
repo_id: 10
|
||||||
|
index: 1000
|
||||||
|
poster_id: 4 # regular user
|
||||||
|
original_author_id: 0
|
||||||
|
name: pr1000
|
||||||
|
content: pull request
|
||||||
|
milestone_id: 0
|
||||||
|
priority: 0
|
||||||
|
is_closed: false
|
||||||
|
is_pull: true
|
||||||
|
num_comments: 0
|
||||||
|
created_unix: 946684820
|
||||||
|
updated_unix: 978307180
|
||||||
|
is_locked: false
|
||||||
|
|
||||||
|
-
|
||||||
|
id: 82000
|
||||||
|
repo_id: 11
|
||||||
|
index: 2000
|
||||||
|
poster_id: 2 # regular user
|
||||||
|
original_author_id: 0
|
||||||
|
name: pr2000
|
||||||
|
content: pull request
|
||||||
|
milestone_id: 0
|
||||||
|
priority: 0
|
||||||
|
is_closed: false
|
||||||
|
is_pull: true
|
||||||
|
num_comments: 0
|
||||||
|
created_unix: 946684820
|
||||||
|
updated_unix: 978307180
|
||||||
|
is_locked: false
|
||||||
|
|
||||||
|
-
|
||||||
|
id: 83000
|
||||||
|
repo_id: 10
|
||||||
|
index: 3000
|
||||||
|
poster_id: 1 # admin
|
||||||
|
original_author_id: 0
|
||||||
|
name: pr3000
|
||||||
|
content: pull request
|
||||||
|
milestone_id: 0
|
||||||
|
priority: 0
|
||||||
|
is_closed: false
|
||||||
|
is_pull: true
|
||||||
|
num_comments: 0
|
||||||
|
created_unix: 946684820
|
||||||
|
updated_unix: 978307180
|
||||||
|
is_locked: false
|
||||||
|
|
||||||
|
-
|
||||||
|
id: 84000
|
||||||
|
repo_id: 10
|
||||||
|
index: 4000
|
||||||
|
poster_id: 5 # regular user
|
||||||
|
original_author_id: 0
|
||||||
|
name: pr4000
|
||||||
|
content: pull request
|
||||||
|
milestone_id: 0
|
||||||
|
priority: 0
|
||||||
|
is_closed: false
|
||||||
|
is_pull: true
|
||||||
|
num_comments: 0
|
||||||
|
created_unix: 946684820
|
||||||
|
updated_unix: 978307180
|
||||||
|
is_locked: false
|
||||||
|
|
||||||
|
-
|
||||||
|
id: 85000
|
||||||
|
repo_id: 10
|
||||||
|
index: 5000
|
||||||
|
poster_id: 29 # restricted user
|
||||||
|
original_author_id: 0
|
||||||
|
name: pr5000
|
||||||
|
content: pull request
|
||||||
|
milestone_id: 0
|
||||||
|
priority: 0
|
||||||
|
is_closed: false
|
||||||
|
is_pull: true
|
||||||
|
num_comments: 0
|
||||||
|
created_unix: 946684820
|
||||||
|
updated_unix: 978307180
|
||||||
|
is_locked: false
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
-
|
||||||
|
id: 1000
|
||||||
|
type: 0 # pull request
|
||||||
|
status: 2 # mergeable
|
||||||
|
issue_id: 81000
|
||||||
|
index: 1000
|
||||||
|
head_repo_id: 11
|
||||||
|
base_repo_id: 10
|
||||||
|
head_branch: branch2000
|
||||||
|
base_branch: master
|
||||||
|
merge_base: 0abcb056019adb83
|
||||||
|
has_merged: false
|
||||||
|
|
||||||
|
-
|
||||||
|
id: 2000
|
||||||
|
type: 0 # pull request
|
||||||
|
status: 2 # mergeable
|
||||||
|
issue_id: 82000
|
||||||
|
index: 2000
|
||||||
|
head_repo_id: 11
|
||||||
|
base_repo_id: 11
|
||||||
|
head_branch: branch2000
|
||||||
|
base_branch: master
|
||||||
|
merge_base: 0abcb056019adb83
|
||||||
|
has_merged: false
|
||||||
|
|
||||||
|
-
|
||||||
|
id: 3000
|
||||||
|
type: 0 # pull request
|
||||||
|
status: 2 # mergeable
|
||||||
|
issue_id: 83000
|
||||||
|
index: 3000
|
||||||
|
head_repo_id: 11 # different from base_repo
|
||||||
|
base_repo_id: 10
|
||||||
|
head_branch: branch3000
|
||||||
|
base_branch: master
|
||||||
|
merge_base: 0abcb056019adb83
|
||||||
|
has_merged: false
|
||||||
|
|
||||||
|
-
|
||||||
|
id: 4000
|
||||||
|
type: 0 # pull request
|
||||||
|
status: 2 # mergeable
|
||||||
|
issue_id: 84000
|
||||||
|
index: 4000
|
||||||
|
head_repo_id: 11 # different from base_repo
|
||||||
|
base_repo_id: 10
|
||||||
|
head_branch: branch4000
|
||||||
|
base_branch: master
|
||||||
|
merge_base: 0abcb056019adb83
|
||||||
|
has_merged: false
|
||||||
|
|
||||||
|
-
|
||||||
|
id: 5000
|
||||||
|
type: 0 # pull request
|
||||||
|
status: 2 # mergeable
|
||||||
|
issue_id: 85000
|
||||||
|
index: 5000
|
||||||
|
head_repo_id: 11 # different from base_repo
|
||||||
|
base_repo_id: 10
|
||||||
|
head_branch: branch5000
|
||||||
|
base_branch: master
|
||||||
|
merge_base: 0abcb056019adb83
|
||||||
|
has_merged: false
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
-
|
||||||
|
id: 11500
|
||||||
|
repo_id: 10
|
||||||
|
type: 10 # TypeActions
|
||||||
|
config: "{}"
|
||||||
|
created_unix: 946684810
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
-
|
||||||
|
id: 900
|
||||||
|
title: "run running"
|
||||||
|
repo_id: 63
|
||||||
|
owner_id: 2
|
||||||
|
workflow_id: "test.yaml"
|
||||||
|
index: 4
|
||||||
|
trigger_user_id: 2
|
||||||
|
ref: "refs/heads/main"
|
||||||
|
commit_sha: "97f29ee599c373c729132a5c46a046978311e0ee"
|
||||||
|
trigger_event: "synchronized"
|
||||||
|
is_fork_pull_request: 0
|
||||||
|
status: 6 # running
|
||||||
|
started: 1683636528
|
||||||
|
created: 1683636108
|
||||||
|
updated: 1683636626
|
||||||
|
need_approval: 0
|
||||||
|
approved_by: 0
|
||||||
|
concurrency_group: abc123
|
||||||
|
-
|
||||||
|
id: 800
|
||||||
|
title: "run waiting because jobs need approval"
|
||||||
|
repo_id: 63
|
||||||
|
owner_id: 2
|
||||||
|
workflow_id: "test.yaml"
|
||||||
|
index: 5
|
||||||
|
trigger_user_id: 2
|
||||||
|
ref: "refs/heads/main"
|
||||||
|
commit_sha: "97f29ee599c373c729132a5c46a046978311e0ee"
|
||||||
|
trigger_event: "synchronized"
|
||||||
|
is_fork_pull_request: 0
|
||||||
|
status: 5 # waiting
|
||||||
|
started: 1683636528
|
||||||
|
created: 1683636108
|
||||||
|
updated: 1683636626
|
||||||
|
need_approval: 1
|
||||||
|
approved_by: 0
|
||||||
|
concurrency_group: def435
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
-
|
||||||
|
id: 10900
|
||||||
|
run_id: 900
|
||||||
|
repo_id: 63
|
||||||
|
owner_id: 2
|
||||||
|
commit_sha: 97f29ee599c373c729132a5c46a046978311e0ee
|
||||||
|
is_fork_pull_request: 0
|
||||||
|
name: job_1
|
||||||
|
attempt: 0
|
||||||
|
job_id: job_1
|
||||||
|
task_id: 0
|
||||||
|
status: 1 # success
|
||||||
|
runs_on: '["docker"]'
|
||||||
|
started: 1683636528
|
||||||
|
-
|
||||||
|
id: 11900
|
||||||
|
run_id: 900
|
||||||
|
repo_id: 63
|
||||||
|
owner_id: 2
|
||||||
|
commit_sha: 97f29ee599c373c729132a5c46a046978311e0ee
|
||||||
|
is_fork_pull_request: 0
|
||||||
|
name: job_2
|
||||||
|
attempt: 0
|
||||||
|
job_id: job_2
|
||||||
|
task_id: 711900
|
||||||
|
status: 6 # running
|
||||||
|
runs_on: '["docker"]'
|
||||||
|
started: 1683636528
|
||||||
|
-
|
||||||
|
id: 10800
|
||||||
|
run_id: 800
|
||||||
|
repo_id: 63
|
||||||
|
owner_id: 2
|
||||||
|
commit_sha: 97f29ee599c373c729132a5c46a046978311e0ee
|
||||||
|
is_fork_pull_request: 0
|
||||||
|
name: job_1
|
||||||
|
attempt: 0
|
||||||
|
job_id: job_1
|
||||||
|
task_id: 0
|
||||||
|
status: 7 # blocked
|
||||||
|
runs_on: '["docker"]'
|
||||||
|
started: 1683636528
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
-
|
||||||
|
id: 710900
|
||||||
|
job_id: 10900
|
||||||
|
attempt: 0
|
||||||
|
runner_id: 1
|
||||||
|
status: 1 # success
|
||||||
|
started: 1683636528
|
||||||
|
stopped: 1683636626
|
||||||
|
repo_id: 63
|
||||||
|
owner_id: 2
|
||||||
|
commit_sha: c2d72f548424103f01ee1dc02889c1e2bff816b0
|
||||||
|
is_fork_pull_request: 0
|
||||||
|
token_hash: 8d8ef48297195edcc8e22c70b3020eaa06c52976db67d39b4260c64a69a2cc1508825121b7b8394e48e00b1bf8718b2a867e
|
||||||
|
token_salt: jVuKnSPGgy
|
||||||
|
token_last_eight: eeb1a71a
|
||||||
|
log_filename: artifact-test2/2f/47.log
|
||||||
|
log_in_storage: 1
|
||||||
|
log_length: 707
|
||||||
|
log_size: 90179
|
||||||
|
log_expired: 0
|
||||||
|
-
|
||||||
|
id: 711900
|
||||||
|
job_id: 11900
|
||||||
|
attempt: 0
|
||||||
|
runner_id: 1
|
||||||
|
status: 6 # running
|
||||||
|
started: 1683636528
|
||||||
|
stopped: 1683636626
|
||||||
|
repo_id: 63
|
||||||
|
owner_id: 2
|
||||||
|
commit_sha: c2d72f548424103f01ee1dc02889c1e2bff816b0
|
||||||
|
is_fork_pull_request: 0
|
||||||
|
token_hash: 7d8ef48297195edcc8e22c70b3020eaa06c52976db67d39b4260c64a69a2cc1508825121b7b8394e48e00b1bf8718b2a867e
|
||||||
|
token_salt: jVuKnSPGgy
|
||||||
|
token_last_eight: eeb1a71a
|
||||||
|
log_filename: artifact-test2/2f/47.log
|
||||||
|
log_in_storage: 1
|
||||||
|
log_length: 707
|
||||||
|
log_size: 90179
|
||||||
|
log_expired: 0
|
||||||
|
|
@ -157,12 +157,43 @@ func notify(ctx context.Context, input *notifyInput) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
gitRepo, err := gitrepo.OpenRepository(context.Background(), input.Repo)
|
gitRepo, commit, ref, err := getGitRepoAndCommit(ctx, input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("git.OpenRepository: %w", err)
|
return err
|
||||||
|
} else if gitRepo == nil && commit == nil {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
defer gitRepo.Close()
|
defer gitRepo.Close()
|
||||||
|
|
||||||
|
if skipWorkflows(input, commit) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if SkipPullRequestEvent(ctx, input.Event, input.Repo.ID, commit.ID.String()) {
|
||||||
|
log.Trace("repo %s with commit %s skip event %v", input.Repo.RepoPath(), commit.ID, input.Event)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
detectedWorkflows, schedules, err := detectWorkflows(ctx, input, gitRepo, commit, shouldDetectSchedules)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if shouldDetectSchedules {
|
||||||
|
if err := handleSchedules(ctx, schedules, commit, input, ref.String()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return handleWorkflows(ctx, detectedWorkflows, commit, input, ref.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func getGitRepoAndCommit(_ context.Context, input *notifyInput) (*git.Repository, *git.Commit, git.RefName, error) {
|
||||||
|
gitRepo, err := gitrepo.OpenRepository(context.Background(), input.Repo)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, "", fmt.Errorf("git.OpenRepository: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
ref := input.Ref
|
ref := input.Ref
|
||||||
if ref.BranchName() != input.Repo.DefaultBranch && actions_module.IsDefaultBranchWorkflow(input.Event) {
|
if ref.BranchName() != input.Repo.DefaultBranch && actions_module.IsDefaultBranchWorkflow(input.Event) {
|
||||||
if ref != "" {
|
if ref != "" {
|
||||||
|
|
@ -178,24 +209,20 @@ func notify(ctx context.Context, input *notifyInput) error {
|
||||||
|
|
||||||
commitID, err := gitRepo.GetRefCommitID(ref.String())
|
commitID, err := gitRepo.GetRefCommitID(ref.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("gitRepo.GetRefCommitID: %w", err)
|
gitRepo.Close()
|
||||||
|
return nil, nil, "", fmt.Errorf("gitRepo.GetRefCommitID: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the commit object for the ref
|
// Get the commit object for the ref
|
||||||
commit, err := gitRepo.GetCommit(commitID)
|
commit, err := gitRepo.GetCommit(commitID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("gitRepo.GetCommit: %w", err)
|
gitRepo.Close()
|
||||||
}
|
return nil, nil, "", fmt.Errorf("gitRepo.GetCommit: %w", err)
|
||||||
|
|
||||||
if skipWorkflows(input, commit) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if SkipPullRequestEvent(ctx, input.Event, input.Repo.ID, commit.ID.String()) {
|
|
||||||
log.Trace("repo %s with commit %s skip event %v", input.Repo.RepoPath(), commit.ID, input.Event)
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
return gitRepo, commit, ref, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectWorkflows(ctx context.Context, input *notifyInput, gitRepo *git.Repository, commit *git.Commit, shouldDetectSchedules bool) ([]*actions_module.DetectedWorkflow, []*actions_module.DetectedWorkflow, error) {
|
||||||
var detectedWorkflows []*actions_module.DetectedWorkflow
|
var detectedWorkflows []*actions_module.DetectedWorkflow
|
||||||
actionsConfig := input.Repo.MustGetUnit(ctx, unit_model.TypeActions).ActionsConfig()
|
actionsConfig := input.Repo.MustGetUnit(ctx, unit_model.TypeActions).ActionsConfig()
|
||||||
workflows, schedules, err := actions_module.DetectWorkflows(gitRepo, commit,
|
workflows, schedules, err := actions_module.DetectWorkflows(gitRepo, commit,
|
||||||
|
|
@ -204,7 +231,7 @@ func notify(ctx context.Context, input *notifyInput) error {
|
||||||
shouldDetectSchedules,
|
shouldDetectSchedules,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("DetectWorkflows: %w", err)
|
return nil, nil, fmt.Errorf("DetectWorkflows: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Trace("repo %s with commit %s event %s find %d workflows and %d schedules",
|
log.Trace("repo %s with commit %s event %s find %d workflows and %d schedules",
|
||||||
|
|
@ -215,6 +242,47 @@ func notify(ctx context.Context, input *notifyInput) error {
|
||||||
len(schedules),
|
len(schedules),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if input.PullRequest != nil && !actions_module.IsDefaultBranchWorkflow(input.Event) {
|
||||||
|
// detect pull_request_target workflows
|
||||||
|
baseRef := git.BranchPrefix + input.PullRequest.BaseBranch
|
||||||
|
baseCommit, err := gitRepo.GetCommit(baseRef)
|
||||||
|
if err != nil {
|
||||||
|
if prp, ok := input.Payload.(*api.PullRequestPayload); ok && errors.Is(err, util.ErrNotExist) {
|
||||||
|
// the baseBranch was deleted and the PR closed: the action can be skipped
|
||||||
|
if prp.Action == api.HookIssueClosed {
|
||||||
|
return nil, nil, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, nil, fmt.Errorf("gitRepo.GetCommit: %w", err)
|
||||||
|
}
|
||||||
|
baseWorkflows, _, err := actions_module.DetectWorkflows(gitRepo, baseCommit, input.Event, input.Payload, false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("DetectWorkflows: %w", err)
|
||||||
|
}
|
||||||
|
if len(baseWorkflows) == 0 {
|
||||||
|
log.Trace("repo %s with commit %s couldn't find pull_request_target workflows", input.Repo.RepoPath(), baseCommit.ID)
|
||||||
|
} else {
|
||||||
|
for _, wf := range baseWorkflows {
|
||||||
|
if wf.TriggerEvent.Name == actions_module.GithubEventPullRequestTarget {
|
||||||
|
detectedWorkflows = append(detectedWorkflows, wf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useHeadOrBaseCommit, pullRequestNeedApproval, err := getPullRequestCommitAndApproval(ctx, input.PullRequest, input.Doer, input.Event)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("getPullRequestTrust: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if useHeadOrBaseCommit == useBaseCommit {
|
||||||
|
workflows = baseWorkflows
|
||||||
|
} else if pullRequestNeedApproval {
|
||||||
|
for _, wf := range workflows {
|
||||||
|
wf.NeedApproval = pullRequestNeedApproval
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for _, wf := range workflows {
|
for _, wf := range workflows {
|
||||||
if actionsConfig.IsWorkflowDisabled(wf.EntryName) {
|
if actionsConfig.IsWorkflowDisabled(wf.EntryName) {
|
||||||
log.Trace("repo %s has disable workflows %s", input.Repo.RepoPath(), wf.EntryName)
|
log.Trace("repo %s has disable workflows %s", input.Repo.RepoPath(), wf.EntryName)
|
||||||
|
|
@ -226,41 +294,7 @@ func notify(ctx context.Context, input *notifyInput) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if input.PullRequest != nil && !actions_module.IsDefaultBranchWorkflow(input.Event) {
|
return detectedWorkflows, schedules, nil
|
||||||
// detect pull_request_target workflows
|
|
||||||
baseRef := git.BranchPrefix + input.PullRequest.BaseBranch
|
|
||||||
baseCommit, err := gitRepo.GetCommit(baseRef)
|
|
||||||
if err != nil {
|
|
||||||
if prp, ok := input.Payload.(*api.PullRequestPayload); ok && errors.Is(err, util.ErrNotExist) {
|
|
||||||
// the baseBranch was deleted and the PR closed: the action can be skipped
|
|
||||||
if prp.Action == api.HookIssueClosed {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fmt.Errorf("gitRepo.GetCommit: %w", err)
|
|
||||||
}
|
|
||||||
baseWorkflows, _, err := actions_module.DetectWorkflows(gitRepo, baseCommit, input.Event, input.Payload, false)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("DetectWorkflows: %w", err)
|
|
||||||
}
|
|
||||||
if len(baseWorkflows) == 0 {
|
|
||||||
log.Trace("repo %s with commit %s couldn't find pull_request_target workflows", input.Repo.RepoPath(), baseCommit.ID)
|
|
||||||
} else {
|
|
||||||
for _, wf := range baseWorkflows {
|
|
||||||
if wf.TriggerEvent.Name == actions_module.GithubEventPullRequestTarget {
|
|
||||||
detectedWorkflows = append(detectedWorkflows, wf)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if shouldDetectSchedules {
|
|
||||||
if err := handleSchedules(ctx, schedules, commit, input, ref.String()); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return handleWorkflows(ctx, detectedWorkflows, commit, input, ref.String())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func SkipPullRequestEvent(ctx context.Context, event webhook_module.HookEventType, repoID int64, commitSHA string) bool {
|
func SkipPullRequestEvent(ctx context.Context, event webhook_module.HookEventType, repoID int64, commitSHA string) bool {
|
||||||
|
|
@ -321,35 +355,25 @@ func handleWorkflows(
|
||||||
return fmt.Errorf("json.Marshal: %w", err)
|
return fmt.Errorf("json.Marshal: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
isForkPullRequest := false
|
|
||||||
if pr := input.PullRequest; pr != nil && !actions_module.IsDefaultBranchWorkflow(input.Event) {
|
|
||||||
switch pr.Flow {
|
|
||||||
case issues_model.PullRequestFlowGithub:
|
|
||||||
isForkPullRequest = pr.IsFromFork()
|
|
||||||
case issues_model.PullRequestFlowAGit:
|
|
||||||
// There is no fork concept in agit flow, anyone with read permission can push refs/for/<target-branch>/<topic-branch> to the repo.
|
|
||||||
// So we can treat it as a fork pull request because it may be from an untrusted user
|
|
||||||
isForkPullRequest = true
|
|
||||||
default:
|
|
||||||
// unknown flow, assume it's a fork pull request to be safe
|
|
||||||
isForkPullRequest = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, dwf := range detectedWorkflows {
|
for _, dwf := range detectedWorkflows {
|
||||||
run := &actions_model.ActionRun{
|
run := &actions_model.ActionRun{
|
||||||
Title: strings.SplitN(commit.CommitMessage, "\n", 2)[0],
|
Title: strings.SplitN(commit.CommitMessage, "\n", 2)[0],
|
||||||
RepoID: input.Repo.ID,
|
RepoID: input.Repo.ID,
|
||||||
OwnerID: input.Repo.OwnerID,
|
OwnerID: input.Repo.OwnerID,
|
||||||
WorkflowID: dwf.EntryName,
|
WorkflowID: dwf.EntryName,
|
||||||
TriggerUserID: input.Doer.ID,
|
TriggerUserID: input.Doer.ID,
|
||||||
Ref: ref,
|
Ref: ref,
|
||||||
CommitSHA: commit.ID.String(),
|
CommitSHA: commit.ID.String(),
|
||||||
IsForkPullRequest: isForkPullRequest,
|
Event: input.Event,
|
||||||
Event: input.Event,
|
EventPayload: string(p),
|
||||||
EventPayload: string(p),
|
TriggerEvent: dwf.TriggerEvent.Name,
|
||||||
TriggerEvent: dwf.TriggerEvent.Name,
|
Status: actions_model.StatusWaiting,
|
||||||
Status: actions_model.StatusWaiting,
|
}
|
||||||
|
|
||||||
|
if !actions_module.IsDefaultBranchWorkflow(input.Event) {
|
||||||
|
if err := setRunTrustForPullRequest(ctx, run, input.PullRequest, dwf.NeedApproval); err != nil {
|
||||||
|
return fmt.Errorf("setTrustForPullRequest: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
workflow, err := model.ReadWorkflow(bytes.NewReader(dwf.Content), false)
|
workflow, err := model.ReadWorkflow(bytes.NewReader(dwf.Content), false)
|
||||||
|
|
@ -363,14 +387,6 @@ func handleWorkflows(
|
||||||
}
|
}
|
||||||
run.NotifyEmail = notifications
|
run.NotifyEmail = notifications
|
||||||
|
|
||||||
need, err := ifNeedApproval(ctx, run, input.Repo, input.Doer)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("check if need approval for repo %d with user %d: %v", input.Repo.ID, input.Doer.ID, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
run.NeedApproval = need
|
|
||||||
|
|
||||||
if err := run.LoadAttributes(ctx); err != nil {
|
if err := run.LoadAttributes(ctx); err != nil {
|
||||||
log.Error("LoadAttributes: %v", err)
|
log.Error("LoadAttributes: %v", err)
|
||||||
continue
|
continue
|
||||||
|
|
@ -396,7 +412,7 @@ func handleWorkflows(
|
||||||
Name: dwf.EntryName,
|
Name: dwf.EntryName,
|
||||||
}}
|
}}
|
||||||
} else {
|
} else {
|
||||||
jobs, err = jobParser(dwf.Content, jobparser.WithVars(vars))
|
jobs, err = actions_module.JobParser(dwf.Content, jobparser.WithVars(vars))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Info("jobparser.Parse: invalid workflow, setting job status to failed: %v", err)
|
log.Info("jobparser.Parse: invalid workflow, setting job status to failed: %v", err)
|
||||||
tr := translation.NewLocale(input.Doer.Language)
|
tr := translation.NewLocale(input.Doer.Language)
|
||||||
|
|
@ -479,45 +495,6 @@ func notifyPackage(ctx context.Context, sender *user_model.User, pd *packages_mo
|
||||||
Notify(ctx)
|
Notify(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
func ifNeedApproval(ctx context.Context, run *actions_model.ActionRun, repo *repo_model.Repository, user *user_model.User) (bool, error) {
|
|
||||||
// 1. don't need approval if it's not a fork PR
|
|
||||||
// 2. don't need approval if the event is `pull_request_target` since the workflow will run in the context of base branch
|
|
||||||
// see https://docs.github.com/en/actions/managing-workflow-runs/approving-workflow-runs-from-public-forks#about-workflow-runs-from-public-forks
|
|
||||||
if !run.IsForkPullRequest || run.TriggerEvent == actions_module.GithubEventPullRequestTarget {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// always need approval if the user is restricted
|
|
||||||
if user.IsRestricted {
|
|
||||||
log.Trace("need approval because user %d is restricted", user.ID)
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// don't need approval if the user can write
|
|
||||||
if perm, err := access_model.GetUserRepoPermission(ctx, repo, user); err != nil {
|
|
||||||
return false, fmt.Errorf("GetUserRepoPermission: %w", err)
|
|
||||||
} else if perm.CanWrite(unit_model.TypeActions) {
|
|
||||||
log.Trace("do not need approval because user %d can write", user.ID)
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// don't need approval if the user has been approved before
|
|
||||||
if count, err := db.Count[actions_model.ActionRun](ctx, actions_model.FindRunOptions{
|
|
||||||
RepoID: repo.ID,
|
|
||||||
TriggerUserID: user.ID,
|
|
||||||
Approved: true,
|
|
||||||
}); err != nil {
|
|
||||||
return false, fmt.Errorf("CountRuns: %w", err)
|
|
||||||
} else if count > 0 {
|
|
||||||
log.Trace("do not need approval because user %d has been approved before", user.ID)
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// otherwise, need approval
|
|
||||||
log.Trace("need approval because it's the first time user %d triggered actions", user.ID)
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleSchedules(
|
func handleSchedules(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
detectedWorkflows []*actions_module.DetectedWorkflow,
|
detectedWorkflows []*actions_module.DetectedWorkflow,
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ import (
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Test_SkipPullRequestEvent(t *testing.T) {
|
func TestActionsNotifier_SkipPullRequestEvent(t *testing.T) {
|
||||||
require.NoError(t, unittest.PrepareTestDatabase())
|
require.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
|
||||||
repoID := int64(1)
|
repoID := int64(1)
|
||||||
|
|
@ -59,7 +59,7 @@ func Test_SkipPullRequestEvent(t *testing.T) {
|
||||||
assert.True(t, SkipPullRequestEvent(db.DefaultContext, webhook_module.HookEventPullRequestSync, repoID, commitSHA))
|
assert.True(t, SkipPullRequestEvent(db.DefaultContext, webhook_module.HookEventPullRequestSync, repoID, commitSHA))
|
||||||
}
|
}
|
||||||
|
|
||||||
func Test_IssueCommentOnForkPullRequestEvent(t *testing.T) {
|
func TestActionsNotifier_IssueCommentOnForkPullRequestEvent(t *testing.T) {
|
||||||
require.NoError(t, unittest.PrepareTestDatabase())
|
require.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
|
||||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 10})
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 10})
|
||||||
|
|
@ -103,39 +103,47 @@ func Test_IssueCommentOnForkPullRequestEvent(t *testing.T) {
|
||||||
assert.False(t, runs[0].IsForkPullRequest)
|
assert.False(t, runs[0].IsForkPullRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
func Test_OpenForkPullRequestEvent(t *testing.T) {
|
func testActionsNotifierPullRequest(t *testing.T, repo *repo_model.Repository, pr *issues_model.PullRequest, dw *actions_module.DetectedWorkflow, event webhook_module.HookEventType) {
|
||||||
require.NoError(t, unittest.PrepareTestDatabase())
|
t.Helper()
|
||||||
|
|
||||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 10})
|
|
||||||
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
||||||
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 3})
|
|
||||||
require.NoError(t, pr.LoadIssue(db.DefaultContext))
|
require.NoError(t, pr.LoadIssue(db.DefaultContext))
|
||||||
|
|
||||||
require.True(t, pr.IsFromFork())
|
testActionsNotifierPullRequestWithDoer(t, repo, pr, doer, dw, event)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testActionsNotifierPullRequestWithDoer(t *testing.T, repo *repo_model.Repository, pr *issues_model.PullRequest, doer *user_model.User, dw *actions_module.DetectedWorkflow, event webhook_module.HookEventType) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
commit := &git.Commit{
|
commit := &git.Commit{
|
||||||
ID: git.MustIDFromString("0000000000000000000000000000000000000000"),
|
ID: git.MustIDFromString("0000000000000000000000000000000000000000"),
|
||||||
CommitMessage: "test",
|
CommitMessage: "test",
|
||||||
}
|
}
|
||||||
detectedWorkflows := []*actions_module.DetectedWorkflow{
|
dw.EntryName = "test.yml"
|
||||||
{
|
dw.TriggerEvent = &jobparser.Event{
|
||||||
TriggerEvent: &jobparser.Event{
|
Name: "pull_request",
|
||||||
Name: "pull_request",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
detectedWorkflows := []*actions_module.DetectedWorkflow{dw}
|
||||||
input := ¬ifyInput{
|
input := ¬ifyInput{
|
||||||
Repo: repo,
|
Repo: repo,
|
||||||
Doer: doer,
|
Doer: doer,
|
||||||
Event: webhook_module.HookEventPullRequest,
|
Event: event,
|
||||||
PullRequest: pr,
|
PullRequest: pr,
|
||||||
Payload: &api.PullRequestPayload{},
|
Payload: &api.PullRequestPayload{},
|
||||||
}
|
}
|
||||||
|
|
||||||
unittest.AssertSuccessfulDelete(t, &actions_model.ActionRun{RepoID: repo.ID})
|
err := handleWorkflows(db.DefaultContext, detectedWorkflows, commit, input, "refs/head/main")
|
||||||
|
|
||||||
err := handleWorkflows(db.DefaultContext, detectedWorkflows, commit, input, "")
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActionsNotifier_OpenForkPullRequestEvent(t *testing.T) {
|
||||||
|
require.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
|
||||||
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 10})
|
||||||
|
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 3})
|
||||||
|
require.True(t, pr.IsFromFork())
|
||||||
|
|
||||||
|
testActionsNotifierPullRequest(t, repo, pr, &actions_module.DetectedWorkflow{}, webhook_module.HookEventPullRequest)
|
||||||
|
|
||||||
runs, err := db.Find[actions_model.ActionRun](db.DefaultContext, actions_model.FindRunOptions{
|
runs, err := db.Find[actions_model.ActionRun](db.DefaultContext, actions_model.FindRunOptions{
|
||||||
RepoID: repo.ID,
|
RepoID: repo.ID,
|
||||||
|
|
@ -147,36 +155,16 @@ func Test_OpenForkPullRequestEvent(t *testing.T) {
|
||||||
assert.True(t, runs[0].IsForkPullRequest)
|
assert.True(t, runs[0].IsForkPullRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestActionsNotifierConcurrencyGroup(t *testing.T) {
|
func TestActionsNotifier_ConcurrencyGroup(t *testing.T) {
|
||||||
require.NoError(t, unittest.PrepareTestDatabase())
|
require.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
|
||||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 10})
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 10})
|
||||||
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
|
||||||
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 3})
|
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 3})
|
||||||
|
|
||||||
commit := &git.Commit{
|
dw := &actions_module.DetectedWorkflow{
|
||||||
ID: git.MustIDFromString("0000000000000000000000000000000000000000"),
|
Content: []byte("{ on: pull_request, jobs: { j1: {} }}"),
|
||||||
CommitMessage: "test",
|
|
||||||
}
|
}
|
||||||
detectedWorkflows := []*actions_module.DetectedWorkflow{
|
testActionsNotifierPullRequest(t, repo, pr, dw, webhook_module.HookEventPullRequestSync)
|
||||||
{
|
|
||||||
EntryName: "test.yml",
|
|
||||||
TriggerEvent: &jobparser.Event{
|
|
||||||
Name: "pull_request",
|
|
||||||
},
|
|
||||||
Content: []byte("{ on: pull_request, jobs: { j1: {} }}"),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
input := ¬ifyInput{
|
|
||||||
Repo: repo,
|
|
||||||
Doer: doer,
|
|
||||||
Event: webhook_module.HookEventPullRequestSync,
|
|
||||||
PullRequest: pr,
|
|
||||||
Payload: &api.PullRequestPayload{},
|
|
||||||
}
|
|
||||||
|
|
||||||
err := handleWorkflows(db.DefaultContext, detectedWorkflows, commit, input, "refs/head/main")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
runs, err := db.Find[actions_model.ActionRun](db.DefaultContext, actions_model.FindRunOptions{
|
runs, err := db.Find[actions_model.ActionRun](db.DefaultContext, actions_model.FindRunOptions{
|
||||||
RepoID: repo.ID,
|
RepoID: repo.ID,
|
||||||
|
|
@ -192,8 +180,7 @@ func TestActionsNotifierConcurrencyGroup(t *testing.T) {
|
||||||
// Also... check if CancelPreviousWithConcurrencyGroup is invoked from handleWorkflows by firing off a second
|
// Also... check if CancelPreviousWithConcurrencyGroup is invoked from handleWorkflows by firing off a second
|
||||||
// workflow and checking that the first one gets cancelled:
|
// workflow and checking that the first one gets cancelled:
|
||||||
|
|
||||||
err = handleWorkflows(db.DefaultContext, detectedWorkflows, commit, input, "refs/head/main")
|
testActionsNotifierPullRequest(t, repo, pr, dw, webhook_module.HookEventPullRequestSync)
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
runs, err = db.Find[actions_model.ActionRun](db.DefaultContext, actions_model.FindRunOptions{
|
runs, err = db.Find[actions_model.ActionRun](db.DefaultContext, actions_model.FindRunOptions{
|
||||||
RepoID: repo.ID,
|
RepoID: repo.ID,
|
||||||
|
|
@ -210,36 +197,16 @@ func TestActionsNotifierConcurrencyGroup(t *testing.T) {
|
||||||
assert.Equal(t, actions_model.StatusCancelled, firstRun.Status)
|
assert.Equal(t, actions_model.StatusCancelled, firstRun.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestActionsPreExecutionErrorInvalidJobs(t *testing.T) {
|
func TestActionsNotifier_PreExecutionErrorInvalidJobs(t *testing.T) {
|
||||||
require.NoError(t, unittest.PrepareTestDatabase())
|
require.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
|
||||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 10})
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 10})
|
||||||
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
|
||||||
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 3})
|
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 3})
|
||||||
|
|
||||||
commit := &git.Commit{
|
dw := &actions_module.DetectedWorkflow{
|
||||||
ID: git.MustIDFromString("0000000000000000000000000000000000000000"),
|
Content: []byte("{ on: pull_request, jobs: 'hello, I am the jobs!' }"),
|
||||||
CommitMessage: "test",
|
|
||||||
}
|
}
|
||||||
detectedWorkflows := []*actions_module.DetectedWorkflow{
|
testActionsNotifierPullRequest(t, repo, pr, dw, webhook_module.HookEventPullRequestSync)
|
||||||
{
|
|
||||||
EntryName: "test.yml",
|
|
||||||
TriggerEvent: &jobparser.Event{
|
|
||||||
Name: "pull_request",
|
|
||||||
},
|
|
||||||
Content: []byte("{ on: pull_request, jobs: 'hello, I am the jobs!' }"),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
input := ¬ifyInput{
|
|
||||||
Repo: repo,
|
|
||||||
Doer: doer,
|
|
||||||
Event: webhook_module.HookEventPullRequestSync,
|
|
||||||
PullRequest: pr,
|
|
||||||
Payload: &api.PullRequestPayload{},
|
|
||||||
}
|
|
||||||
|
|
||||||
err := handleWorkflows(db.DefaultContext, detectedWorkflows, commit, input, "refs/head/main")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
runs, err := db.Find[actions_model.ActionRun](db.DefaultContext, actions_model.FindRunOptions{
|
runs, err := db.Find[actions_model.ActionRun](db.DefaultContext, actions_model.FindRunOptions{
|
||||||
RepoID: repo.ID,
|
RepoID: repo.ID,
|
||||||
|
|
@ -252,37 +219,17 @@ func TestActionsPreExecutionErrorInvalidJobs(t *testing.T) {
|
||||||
assert.Contains(t, createdRun.PreExecutionError, "actions.workflow.job_parsing_error%!(EXTRA *fmt.wrapError=")
|
assert.Contains(t, createdRun.PreExecutionError, "actions.workflow.job_parsing_error%!(EXTRA *fmt.wrapError=")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestActionsPreExecutionEventDetectionError(t *testing.T) {
|
func TestActionsNotifier_PreExecutionEventDetectionError(t *testing.T) {
|
||||||
require.NoError(t, unittest.PrepareTestDatabase())
|
require.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
|
||||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 10})
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 10})
|
||||||
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
|
||||||
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 3})
|
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 3})
|
||||||
|
|
||||||
commit := &git.Commit{
|
dw := &actions_module.DetectedWorkflow{
|
||||||
ID: git.MustIDFromString("0000000000000000000000000000000000000000"),
|
Content: []byte("{ on: nothing, jobs: { j1: {} }}"),
|
||||||
CommitMessage: "test",
|
EventDetectionError: errors.New("nothing is not a valid event"),
|
||||||
}
|
}
|
||||||
detectedWorkflows := []*actions_module.DetectedWorkflow{
|
testActionsNotifierPullRequest(t, repo, pr, dw, webhook_module.HookEventPullRequestSync)
|
||||||
{
|
|
||||||
EntryName: "test.yml",
|
|
||||||
TriggerEvent: &jobparser.Event{
|
|
||||||
Name: "pull_request",
|
|
||||||
},
|
|
||||||
Content: []byte("{ on: nothing, jobs: { j1: {} }}"),
|
|
||||||
EventDetectionError: errors.New("nothing is not a valid event"),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
input := ¬ifyInput{
|
|
||||||
Repo: repo,
|
|
||||||
Doer: doer,
|
|
||||||
Event: webhook_module.HookEventPullRequestSync,
|
|
||||||
PullRequest: pr,
|
|
||||||
Payload: &api.PullRequestPayload{},
|
|
||||||
}
|
|
||||||
|
|
||||||
err := handleWorkflows(db.DefaultContext, detectedWorkflows, commit, input, "refs/head/main")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
runs, err := db.Find[actions_model.ActionRun](db.DefaultContext, actions_model.FindRunOptions{
|
runs, err := db.Find[actions_model.ActionRun](db.DefaultContext, actions_model.FindRunOptions{
|
||||||
RepoID: repo.ID,
|
RepoID: repo.ID,
|
||||||
|
|
@ -294,3 +241,27 @@ func TestActionsPreExecutionEventDetectionError(t *testing.T) {
|
||||||
assert.Equal(t, actions_model.StatusFailure, createdRun.Status)
|
assert.Equal(t, actions_model.StatusFailure, createdRun.Status)
|
||||||
assert.Equal(t, "actions.workflow.event_detection_error%!(EXTRA *errors.errorString=nothing is not a valid event)", createdRun.PreExecutionError)
|
assert.Equal(t, "actions.workflow.event_detection_error%!(EXTRA *errors.errorString=nothing is not a valid event)", createdRun.PreExecutionError)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestActionsNotifier_handleWorkflows_setRunTrustForPullRequest(t *testing.T) {
|
||||||
|
require.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
|
||||||
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 10})
|
||||||
|
// poster is not trusted implicitly
|
||||||
|
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 3})
|
||||||
|
|
||||||
|
testActionsNotifierPullRequest(t, repo, pr, &actions_module.DetectedWorkflow{
|
||||||
|
NeedApproval: true,
|
||||||
|
}, webhook_module.HookEventPullRequest)
|
||||||
|
|
||||||
|
runs, err := db.Find[actions_model.ActionRun](db.DefaultContext, actions_model.FindRunOptions{
|
||||||
|
RepoID: repo.ID,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, runs, 1)
|
||||||
|
|
||||||
|
run := runs[0]
|
||||||
|
assert.True(t, run.IsForkPullRequest)
|
||||||
|
assert.Equal(t, pr.Issue.PosterID, run.PullRequestPosterID)
|
||||||
|
assert.Equal(t, pr.ID, run.PullRequestID)
|
||||||
|
assert.True(t, run.NeedApproval)
|
||||||
|
}
|
||||||
|
|
|
||||||
70
services/actions/run.go
Normal file
70
services/actions/run.go
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
// Copyright 2025 The Forgejo Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package actions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
actions_model "forgejo.org/models/actions"
|
||||||
|
"forgejo.org/models/db"
|
||||||
|
"forgejo.org/modules/timeutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
func CancelRun(ctx context.Context, run *actions_model.ActionRun) error {
|
||||||
|
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||||
|
jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, job := range jobs {
|
||||||
|
status := job.Status
|
||||||
|
if status.IsDone() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if job.TaskID == 0 {
|
||||||
|
job.Status = actions_model.StatusCancelled
|
||||||
|
job.Stopped = timeutil.TimeStampNow()
|
||||||
|
_, err := actions_model.UpdateRunJobWithoutNotification(ctx, job, nil, "status", "stopped")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := StopTask(ctx, job.TaskID, actions_model.StatusCancelled); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if run.NeedApproval {
|
||||||
|
if err := actions_model.UpdateRunApprovalByID(ctx, run.ID, actions_model.DoesNotNeedApproval, 0); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CreateCommitStatus(ctx, jobs...)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func ApproveRun(ctx context.Context, run *actions_model.ActionRun, doerID int64) error {
|
||||||
|
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||||
|
jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, job := range jobs {
|
||||||
|
if len(job.Needs) == 0 && job.Status.IsBlocked() {
|
||||||
|
job.Status = actions_model.StatusWaiting
|
||||||
|
_, err := UpdateRunJob(ctx, job, nil, "status")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CreateCommitStatus(ctx, jobs...)
|
||||||
|
|
||||||
|
return actions_model.UpdateRunApprovalByID(ctx, run.ID, actions_model.DoesNotNeedApproval, doerID)
|
||||||
|
})
|
||||||
|
}
|
||||||
104
services/actions/run_test.go
Normal file
104
services/actions/run_test.go
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
// Copyright 2025 The Forgejo Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package actions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
actions_model "forgejo.org/models/actions"
|
||||||
|
"forgejo.org/models/unittest"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestActions_CancelOrApproveRun(t *testing.T) {
|
||||||
|
t.Run("run, job and task Running changes to run, job and task Cancelled", func(t *testing.T) {
|
||||||
|
defer unittest.OverrideFixtures("services/actions/TestActions_CancelOrApproveRun")()
|
||||||
|
require.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
|
||||||
|
taskID := int64(711900)
|
||||||
|
task := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: taskID})
|
||||||
|
require.Equal(t, actions_model.StatusRunning.String(), task.Status.String())
|
||||||
|
job := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: task.JobID})
|
||||||
|
require.Equal(t, actions_model.StatusRunning.String(), job.Status.String())
|
||||||
|
require.Zero(t, job.Stopped)
|
||||||
|
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: job.RunID})
|
||||||
|
require.Equal(t, actions_model.StatusRunning.String(), run.Status.String())
|
||||||
|
|
||||||
|
require.NoError(t, CancelRun(t.Context(), run))
|
||||||
|
|
||||||
|
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: job.RunID})
|
||||||
|
assert.Equal(t, actions_model.StatusCancelled.String(), run.Status.String())
|
||||||
|
job = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: task.JobID})
|
||||||
|
assert.Equal(t, actions_model.StatusCancelled.String(), job.Status.String())
|
||||||
|
assert.NotZero(t, job.Stopped)
|
||||||
|
task = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: taskID})
|
||||||
|
require.Equal(t, actions_model.StatusCancelled.String(), task.Status.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("run Running, job and task Success changes to run Cancelled", func(t *testing.T) {
|
||||||
|
defer unittest.OverrideFixtures("services/actions/TestActions_CancelOrApproveRun")()
|
||||||
|
require.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
|
||||||
|
taskID := int64(710900)
|
||||||
|
task := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: taskID})
|
||||||
|
require.Equal(t, actions_model.StatusSuccess.String(), task.Status.String())
|
||||||
|
job := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: task.JobID})
|
||||||
|
require.Equal(t, actions_model.StatusSuccess.String(), job.Status.String())
|
||||||
|
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: job.RunID})
|
||||||
|
require.Equal(t, actions_model.StatusRunning.String(), run.Status.String())
|
||||||
|
|
||||||
|
require.NoError(t, CancelRun(t.Context(), run))
|
||||||
|
|
||||||
|
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: job.RunID})
|
||||||
|
assert.Equal(t, actions_model.StatusCancelled.String(), run.Status.String())
|
||||||
|
job = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: task.JobID})
|
||||||
|
assert.Equal(t, actions_model.StatusSuccess, job.Status)
|
||||||
|
task = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: taskID})
|
||||||
|
require.Equal(t, actions_model.StatusSuccess, task.Status)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("run Waiting and job Blocked for Approval changes to run and job Cancelled", func(t *testing.T) {
|
||||||
|
defer unittest.OverrideFixtures("services/actions/TestActions_CancelOrApproveRun")()
|
||||||
|
require.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
|
||||||
|
jobID := int64(10800)
|
||||||
|
job := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: jobID})
|
||||||
|
require.Equal(t, actions_model.StatusBlocked.String(), job.Status.String())
|
||||||
|
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: job.RunID})
|
||||||
|
require.Equal(t, actions_model.StatusWaiting.String(), run.Status.String())
|
||||||
|
require.True(t, run.NeedApproval)
|
||||||
|
|
||||||
|
require.NoError(t, CancelRun(t.Context(), run))
|
||||||
|
|
||||||
|
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: job.RunID})
|
||||||
|
assert.Equal(t, actions_model.StatusCancelled.String(), run.Status.String())
|
||||||
|
assert.False(t, run.NeedApproval)
|
||||||
|
job = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: jobID})
|
||||||
|
assert.Equal(t, actions_model.StatusCancelled, job.Status)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("run Waiting and job Blocked for Approval changes to job Waiting", func(t *testing.T) {
|
||||||
|
defer unittest.OverrideFixtures("services/actions/TestActions_CancelOrApproveRun")()
|
||||||
|
require.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
|
||||||
|
jobID := int64(10800)
|
||||||
|
job := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: jobID})
|
||||||
|
require.Equal(t, actions_model.StatusBlocked.String(), job.Status.String())
|
||||||
|
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: job.RunID})
|
||||||
|
require.Equal(t, actions_model.StatusWaiting.String(), run.Status.String())
|
||||||
|
require.True(t, run.NeedApproval)
|
||||||
|
|
||||||
|
doerID := int64(30)
|
||||||
|
require.NoError(t, ApproveRun(t.Context(), run, doerID))
|
||||||
|
|
||||||
|
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: job.RunID})
|
||||||
|
assert.Equal(t, actions_model.StatusWaiting.String(), run.Status.String())
|
||||||
|
assert.False(t, run.NeedApproval)
|
||||||
|
assert.Equal(t, doerID, run.ApprovedBy)
|
||||||
|
job = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: jobID})
|
||||||
|
assert.Equal(t, actions_model.StatusWaiting, job.Status)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,7 @@ import (
|
||||||
"forgejo.org/models/db"
|
"forgejo.org/models/db"
|
||||||
repo_model "forgejo.org/models/repo"
|
repo_model "forgejo.org/models/repo"
|
||||||
"forgejo.org/models/unit"
|
"forgejo.org/models/unit"
|
||||||
|
actions_module "forgejo.org/modules/actions"
|
||||||
"forgejo.org/modules/log"
|
"forgejo.org/modules/log"
|
||||||
"forgejo.org/modules/timeutil"
|
"forgejo.org/modules/timeutil"
|
||||||
webhook_module "forgejo.org/modules/webhook"
|
webhook_module "forgejo.org/modules/webhook"
|
||||||
|
|
@ -168,7 +169,7 @@ func CreateScheduleTask(ctx context.Context, cron *actions_model.ActionSchedule)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse the workflow specification from the cron schedule
|
// Parse the workflow specification from the cron schedule
|
||||||
workflows, err := jobParser(cron.Content, jobparser.WithVars(vars))
|
workflows, err := actions_module.JobParser(cron.Content, jobparser.WithVars(vars))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
326
services/actions/trust.go
Normal file
326
services/actions/trust.go
Normal file
|
|
@ -0,0 +1,326 @@
|
||||||
|
// Copyright 2025 The Forgejo Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package actions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"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
|
||||||
|
}
|
||||||
377
services/actions/trust_test.go
Normal file
377
services/actions/trust_test.go
Normal file
|
|
@ -0,0 +1,377 @@
|
||||||
|
// Copyright 2025 The Forgejo Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package actions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
actions_model "forgejo.org/models/actions"
|
||||||
|
issues_model "forgejo.org/models/issues"
|
||||||
|
"forgejo.org/models/unittest"
|
||||||
|
user_model "forgejo.org/models/user"
|
||||||
|
actions_module "forgejo.org/modules/actions"
|
||||||
|
webhook_module "forgejo.org/modules/webhook"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestActionsTrust_ChangeStatus(t *testing.T) {
|
||||||
|
require.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
|
||||||
|
repoID := int64(10)
|
||||||
|
pullRequestPosterID := int64(30)
|
||||||
|
|
||||||
|
runDone := &actions_model.ActionRun{
|
||||||
|
RepoID: repoID,
|
||||||
|
PullRequestPosterID: pullRequestPosterID,
|
||||||
|
Status: actions_model.StatusSuccess,
|
||||||
|
}
|
||||||
|
require.NoError(t, actions_model.InsertRun(t.Context(), runDone, nil))
|
||||||
|
|
||||||
|
runNotByPoster := &actions_model.ActionRun{
|
||||||
|
RepoID: repoID,
|
||||||
|
PullRequestPosterID: 43243,
|
||||||
|
Status: actions_model.StatusRunning,
|
||||||
|
}
|
||||||
|
require.NoError(t, actions_model.InsertRun(t.Context(), runNotByPoster, nil))
|
||||||
|
|
||||||
|
runNotInTheSameRepository := &actions_model.ActionRun{
|
||||||
|
RepoID: 5,
|
||||||
|
PullRequestPosterID: pullRequestPosterID,
|
||||||
|
Status: actions_model.StatusSuccess,
|
||||||
|
}
|
||||||
|
require.NoError(t, actions_model.InsertRun(t.Context(), runNotInTheSameRepository, nil))
|
||||||
|
|
||||||
|
t.Run("RevokeTrust", func(t *testing.T) {
|
||||||
|
singleWorkflows, err := actions_module.JobParser([]byte(`
|
||||||
|
jobs:
|
||||||
|
job:
|
||||||
|
runs-on: docker
|
||||||
|
steps:
|
||||||
|
- run: echo OK
|
||||||
|
`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, singleWorkflows, 1)
|
||||||
|
runNotDone := &actions_model.ActionRun{
|
||||||
|
TriggerUserID: 2,
|
||||||
|
RepoID: repoID,
|
||||||
|
Status: actions_model.StatusWaiting,
|
||||||
|
PullRequestPosterID: pullRequestPosterID,
|
||||||
|
}
|
||||||
|
require.NoError(t, actions_model.InsertRun(t.Context(), runNotDone, singleWorkflows))
|
||||||
|
require.NoError(t, actions_model.InsertActionUser(t.Context(), &actions_model.ActionUser{
|
||||||
|
UserID: pullRequestPosterID,
|
||||||
|
RepoID: repoID,
|
||||||
|
TrustedWithPullRequests: true,
|
||||||
|
}))
|
||||||
|
|
||||||
|
previousCancelledCount := unittest.GetCount(t, &actions_model.ActionRun{Status: actions_model.StatusCancelled})
|
||||||
|
_, err = actions_model.GetActionUserByUserIDAndRepoIDAndUpdateAccess(t.Context(), pullRequestPosterID, repoID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.NoError(t, RevokeTrust(t.Context(), repoID, pullRequestPosterID))
|
||||||
|
|
||||||
|
_, err = actions_model.GetActionUserByUserIDAndRepoIDAndUpdateAccess(t.Context(), pullRequestPosterID, repoID)
|
||||||
|
assert.True(t, actions_model.IsErrUserNotExist(err))
|
||||||
|
currentCancelledCount := unittest.GetCount(t, &actions_model.ActionRun{Status: actions_model.StatusCancelled})
|
||||||
|
assert.Equal(t, previousCancelledCount+1, currentCancelledCount)
|
||||||
|
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runNotDone.ID})
|
||||||
|
assert.Equal(t, actions_model.StatusCancelled.String(), run.Status.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
createPullRequestRun := func(t *testing.T, pullRequestID, repoID int64) *actions_model.ActionRun {
|
||||||
|
t.Helper()
|
||||||
|
singleWorkflows, err := actions_module.JobParser([]byte(`
|
||||||
|
jobs:
|
||||||
|
job:
|
||||||
|
runs-on: docker
|
||||||
|
steps:
|
||||||
|
- run: echo OK
|
||||||
|
`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, singleWorkflows, 1)
|
||||||
|
runNotApproved := &actions_model.ActionRun{
|
||||||
|
TriggerUserID: 2,
|
||||||
|
RepoID: repoID,
|
||||||
|
Status: actions_model.StatusWaiting,
|
||||||
|
NeedApproval: true,
|
||||||
|
PullRequestID: pullRequestID,
|
||||||
|
PullRequestPosterID: pullRequestPosterID,
|
||||||
|
}
|
||||||
|
require.NoError(t, actions_model.InsertRun(t.Context(), runNotApproved, singleWorkflows))
|
||||||
|
return runNotApproved
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("PullRequestCancel", func(t *testing.T) {
|
||||||
|
pullRequestID := int64(485)
|
||||||
|
runNotApproved := createPullRequestRun(t, pullRequestID, repoID)
|
||||||
|
|
||||||
|
previousCancelledCount := unittest.GetCount(t, &actions_model.ActionRun{Status: actions_model.StatusCancelled})
|
||||||
|
|
||||||
|
require.NoError(t, pullRequestCancel(t.Context(), repoID, pullRequestID))
|
||||||
|
|
||||||
|
currentCancelledCount := unittest.GetCount(t, &actions_model.ActionRun{Status: actions_model.StatusCancelled})
|
||||||
|
assert.Equal(t, previousCancelledCount+1, currentCancelledCount)
|
||||||
|
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runNotApproved.ID})
|
||||||
|
assert.Equal(t, actions_model.StatusCancelled.String(), run.Status.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("UpdateTrustedWithPullRequest deny", func(t *testing.T) {
|
||||||
|
pullRequestID := int64(485)
|
||||||
|
runNotApproved := createPullRequestRun(t, pullRequestID, repoID)
|
||||||
|
|
||||||
|
previousCancelledCount := unittest.GetCount(t, &actions_model.ActionRun{Status: actions_model.StatusCancelled})
|
||||||
|
|
||||||
|
require.NoError(t, UpdateTrustedWithPullRequest(t.Context(), 0, &issues_model.PullRequest{
|
||||||
|
ID: pullRequestID,
|
||||||
|
Issue: &issues_model.Issue{
|
||||||
|
RepoID: repoID,
|
||||||
|
},
|
||||||
|
}, UserTrustDenied))
|
||||||
|
|
||||||
|
currentCancelledCount := unittest.GetCount(t, &actions_model.ActionRun{Status: actions_model.StatusCancelled})
|
||||||
|
assert.Equal(t, previousCancelledCount+1, currentCancelledCount)
|
||||||
|
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runNotApproved.ID})
|
||||||
|
assert.Equal(t, actions_model.StatusCancelled.String(), run.Status.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("PullRequestApprove", func(t *testing.T) {
|
||||||
|
pullRequestID := int64(534)
|
||||||
|
runNotApproved := createPullRequestRun(t, pullRequestID, repoID)
|
||||||
|
|
||||||
|
previousWaitingCount := unittest.GetCount(t, &actions_model.ActionRunJob{Status: actions_model.StatusWaiting})
|
||||||
|
|
||||||
|
doerID := int64(84322)
|
||||||
|
require.NoError(t, pullRequestApprove(t.Context(), doerID, repoID, pullRequestID))
|
||||||
|
|
||||||
|
currentWaitingCount := unittest.GetCount(t, &actions_model.ActionRunJob{Status: actions_model.StatusWaiting})
|
||||||
|
assert.Equal(t, previousWaitingCount+1, currentWaitingCount)
|
||||||
|
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runNotApproved.ID})
|
||||||
|
assert.Equal(t, actions_model.StatusWaiting.String(), run.Status.String())
|
||||||
|
assert.Equal(t, doerID, run.ApprovedBy)
|
||||||
|
assert.False(t, run.NeedApproval)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("UpdateTrustedWithPullRequest once", func(t *testing.T) {
|
||||||
|
pullRequestID := int64(534)
|
||||||
|
runNotApproved := createPullRequestRun(t, pullRequestID, repoID)
|
||||||
|
|
||||||
|
previousWaitingCount := unittest.GetCount(t, &actions_model.ActionRunJob{Status: actions_model.StatusWaiting})
|
||||||
|
|
||||||
|
doerID := int64(84322)
|
||||||
|
require.NoError(t, UpdateTrustedWithPullRequest(t.Context(), doerID, &issues_model.PullRequest{
|
||||||
|
ID: pullRequestID,
|
||||||
|
Issue: &issues_model.Issue{
|
||||||
|
RepoID: repoID,
|
||||||
|
},
|
||||||
|
}, UserTrustedOnce))
|
||||||
|
|
||||||
|
currentWaitingCount := unittest.GetCount(t, &actions_model.ActionRunJob{Status: actions_model.StatusWaiting})
|
||||||
|
assert.Equal(t, previousWaitingCount+1, currentWaitingCount)
|
||||||
|
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runNotApproved.ID})
|
||||||
|
assert.Equal(t, actions_model.StatusWaiting.String(), run.Status.String())
|
||||||
|
assert.Equal(t, doerID, run.ApprovedBy)
|
||||||
|
assert.False(t, run.NeedApproval)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("UpdateTrustedWithPullRequest always", func(t *testing.T) {
|
||||||
|
pullRequestIDs := []int64{534, 645}
|
||||||
|
var runsNotApproved []*actions_model.ActionRun
|
||||||
|
for _, pullRequestID := range pullRequestIDs {
|
||||||
|
runsNotApproved = append(runsNotApproved, createPullRequestRun(t, pullRequestID, repoID))
|
||||||
|
}
|
||||||
|
|
||||||
|
previousWaitingCount := unittest.GetCount(t, &actions_model.ActionRunJob{Status: actions_model.StatusWaiting})
|
||||||
|
|
||||||
|
doerID := int64(84322)
|
||||||
|
require.NoError(t, UpdateTrustedWithPullRequest(t.Context(), doerID, &issues_model.PullRequest{
|
||||||
|
ID: pullRequestIDs[0],
|
||||||
|
Issue: &issues_model.Issue{
|
||||||
|
RepoID: repoID,
|
||||||
|
PosterID: pullRequestPosterID,
|
||||||
|
},
|
||||||
|
}, UserAlwaysTrusted))
|
||||||
|
|
||||||
|
currentWaitingCount := unittest.GetCount(t, &actions_model.ActionRunJob{Status: actions_model.StatusWaiting})
|
||||||
|
assert.Equal(t, previousWaitingCount+len(pullRequestIDs), currentWaitingCount)
|
||||||
|
|
||||||
|
for _, run := range runsNotApproved {
|
||||||
|
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
|
||||||
|
assert.Equal(t, actions_model.StatusWaiting.String(), run.Status.String())
|
||||||
|
assert.Equal(t, doerID, run.ApprovedBy)
|
||||||
|
assert.False(t, run.NeedApproval)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("UpdateTrustedWithPullRequest revoke", func(t *testing.T) {
|
||||||
|
pullRequestIDs := []int64{748, 953}
|
||||||
|
var runsNotApproved []*actions_model.ActionRun
|
||||||
|
for _, pullRequestID := range pullRequestIDs {
|
||||||
|
runsNotApproved = append(runsNotApproved, createPullRequestRun(t, pullRequestID, repoID))
|
||||||
|
}
|
||||||
|
|
||||||
|
doerID := int64(84322)
|
||||||
|
require.NoError(t, UpdateTrustedWithPullRequest(t.Context(), doerID, &issues_model.PullRequest{
|
||||||
|
ID: pullRequestIDs[0],
|
||||||
|
Issue: &issues_model.Issue{
|
||||||
|
RepoID: repoID,
|
||||||
|
PosterID: pullRequestPosterID,
|
||||||
|
},
|
||||||
|
}, UserTrustRevoked))
|
||||||
|
|
||||||
|
for _, run := range runsNotApproved {
|
||||||
|
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
|
||||||
|
assert.Equal(t, actions_model.StatusCancelled.String(), run.Status.String())
|
||||||
|
assert.False(t, run.NeedApproval)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActionsTrust_GetPullRequestUserIsTrustedWithActions(t *testing.T) {
|
||||||
|
defer unittest.OverrideFixtures("services/actions/TestActionsTrust_GetPullRequestUserIsTrustedWithActions")()
|
||||||
|
require.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
|
||||||
|
t.Run("implicitly trusted because the pull request is not from a fork", func(t *testing.T) {
|
||||||
|
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 2000})
|
||||||
|
trust, err := GetPullRequestPosterIsTrustedWithActions(t.Context(), pr)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.False(t, pr.IsForkPullRequest())
|
||||||
|
assert.Equal(t, UserIsImplicitlyTrustedWithActions, trust)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("implicitly trusted on a forked pull request when the poster is admin", func(t *testing.T) {
|
||||||
|
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 3000})
|
||||||
|
trust, err := GetPullRequestPosterIsTrustedWithActions(t.Context(), pr)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, pr.IsForkPullRequest())
|
||||||
|
require.True(t, pr.Issue.Poster.IsAdmin)
|
||||||
|
assert.Equal(t, UserIsImplicitlyTrustedWithActions, trust)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("explicitly trusted on a forked pull request when the poster was permanently approved", func(t *testing.T) {
|
||||||
|
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 1000})
|
||||||
|
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) // regular user
|
||||||
|
trust, err := GetPullRequestPosterIsTrustedWithActions(t.Context(), pr)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, pr.IsForkPullRequest())
|
||||||
|
_, err = actions_model.GetActionUserByUserIDAndRepoIDAndUpdateAccess(t.Context(), user.ID, pr.Issue.RepoID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, UserIsExplicitlyTrustedWithActions, trust)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("not trusted because on a forked pull request when the user has has no privileges", func(t *testing.T) {
|
||||||
|
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 4000})
|
||||||
|
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5}) // regular user
|
||||||
|
trust, err := GetPullRequestPosterIsTrustedWithActions(t.Context(), pr)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, user.ID, pr.Issue.PosterID)
|
||||||
|
require.True(t, pr.IsForkPullRequest())
|
||||||
|
assert.Equal(t, UserIsNotTrustedWithActions, trust)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("not trusted on a forked pull request because the user is restricted", func(t *testing.T) {
|
||||||
|
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 5000})
|
||||||
|
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 29}) // restricted user
|
||||||
|
trust, err := getPullRequestUserIsTrustedWithActions(t.Context(), pr, user)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, user.ID, pr.Issue.PosterID)
|
||||||
|
require.True(t, pr.IsForkPullRequest())
|
||||||
|
_, err = actions_model.GetActionUserByUserIDAndRepoIDAndUpdateAccess(t.Context(), user.ID, pr.Issue.RepoID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, user.IsRestricted)
|
||||||
|
assert.Equal(t, UserIsNotTrustedWithActions, trust)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("approval not needed because the pr is not from a fork", func(t *testing.T) {
|
||||||
|
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 2000})
|
||||||
|
useCommit, approval, err := getPullRequestCommitAndApproval(t.Context(), pr, nil, webhook_module.HookEventPullRequest)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, actions_model.DoesNotNeedApproval, approval)
|
||||||
|
assert.EqualValues(t, useHeadCommit, useCommit)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("approval not needed because the event is known to run out of the default branch", func(t *testing.T) {
|
||||||
|
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 3000})
|
||||||
|
useCommit, approval, err := getPullRequestCommitAndApproval(t.Context(), pr, nil, webhook_module.HookEventPullRequestComment)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, actions_model.DoesNotNeedApproval, approval)
|
||||||
|
assert.EqualValues(t, useHeadCommit, useCommit)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("approval not needed because it is not a pr", func(t *testing.T) {
|
||||||
|
useCommit, approval, err := getPullRequestCommitAndApproval(t.Context(), nil, nil, webhook_module.HookEventPullRequestComment)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, actions_model.DoesNotNeedApproval, approval)
|
||||||
|
assert.EqualValues(t, useHeadCommit, useCommit)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("approval not needed for a forked pr because the poster is trusted", func(t *testing.T) {
|
||||||
|
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 3000})
|
||||||
|
useCommit, approval, err := getPullRequestCommitAndApproval(t.Context(), pr, nil, webhook_module.HookEventPullRequestSync)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, pr.Issue.Poster.IsAdmin)
|
||||||
|
assert.Equal(t, actions_model.DoesNotNeedApproval, approval)
|
||||||
|
assert.EqualValues(t, useHeadCommit, useCommit)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("approval needed for a forked pr because the poster and the doer are not trusted", func(t *testing.T) {
|
||||||
|
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 4000})
|
||||||
|
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5}) // regular user
|
||||||
|
useCommit, approval, err := getPullRequestCommitAndApproval(t.Context(), pr, doer, webhook_module.HookEventPullRequestSync)
|
||||||
|
require.NoError(t, err)
|
||||||
|
posterTrust, err := GetPullRequestPosterIsTrustedWithActions(t.Context(), pr)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, UserIsNotTrustedWithActions, posterTrust)
|
||||||
|
doerTrust, err := getPullRequestUserIsTrustedWithActions(t.Context(), pr, doer)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, UserIsNotTrustedWithActions, doerTrust)
|
||||||
|
assert.Equal(t, actions_model.NeedApproval, approval)
|
||||||
|
assert.EqualValues(t, useHeadCommit, useCommit)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("approval not needed for a forked pr because the doer is trusted and runs from the base", func(t *testing.T) {
|
||||||
|
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 4000})
|
||||||
|
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) // admin
|
||||||
|
useCommit, approval, err := getPullRequestCommitAndApproval(t.Context(), pr, doer, webhook_module.HookEventPullRequestLabel)
|
||||||
|
require.NoError(t, err)
|
||||||
|
posterTrust, err := GetPullRequestPosterIsTrustedWithActions(t.Context(), pr)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, UserIsNotTrustedWithActions, posterTrust)
|
||||||
|
doerTrust, err := getPullRequestUserIsTrustedWithActions(t.Context(), pr, doer)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, UserIsImplicitlyTrustedWithActions, doerTrust)
|
||||||
|
assert.Equal(t, actions_model.DoesNotNeedApproval, approval)
|
||||||
|
assert.EqualValues(t, useBaseCommit, useCommit)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("approval not needed for a forked pr because the doer is trusted and pushed new commits", func(t *testing.T) {
|
||||||
|
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 4000})
|
||||||
|
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) // admin
|
||||||
|
useCommit, approval, err := getPullRequestCommitAndApproval(t.Context(), pr, doer, webhook_module.HookEventPullRequestSync)
|
||||||
|
require.NoError(t, err)
|
||||||
|
posterTrust, err := GetPullRequestPosterIsTrustedWithActions(t.Context(), pr)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, UserIsNotTrustedWithActions, posterTrust)
|
||||||
|
doerTrust, err := getPullRequestUserIsTrustedWithActions(t.Context(), pr, doer)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, UserIsImplicitlyTrustedWithActions, doerTrust)
|
||||||
|
assert.Equal(t, actions_model.DoesNotNeedApproval, approval)
|
||||||
|
assert.EqualValues(t, useHeadCommit, useCommit)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("run for a pull request is set with info related to trust", func(t *testing.T) {
|
||||||
|
run := &actions_model.ActionRun{
|
||||||
|
IsForkPullRequest: true,
|
||||||
|
}
|
||||||
|
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 5000})
|
||||||
|
needApproval := actions_model.NeedApproval
|
||||||
|
require.NoError(t, setRunTrustForPullRequest(t.Context(), run, nil, needApproval))
|
||||||
|
require.NoError(t, setRunTrustForPullRequest(t.Context(), run, pr, needApproval))
|
||||||
|
assert.True(t, run.NeedApproval)
|
||||||
|
assert.True(t, run.IsForkPullRequest)
|
||||||
|
assert.Equal(t, pr.Issue.PosterID, run.PullRequestPosterID)
|
||||||
|
assert.Equal(t, pr.ID, run.PullRequestID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -155,7 +155,7 @@ func (entry *Workflow) Dispatch(ctx context.Context, inputGetter InputValueGette
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
jobs, err := jobParser(content, jobparser.WithVars(vars), jobparser.WithInputs(inputsAny))
|
jobs, err := actions.JobParser(content, jobparser.WithVars(vars), jobparser.WithInputs(inputsAny))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
// CheckRepoScopedToken check whether personal access token has repo scope
|
||||||
func CheckRepoScopedToken(ctx *Context, repo *repo_model.Repository, level auth_model.AccessTokenScopeLevel) {
|
func CheckRepoScopedToken(ctx *Context, repo *repo_model.Repository, level auth_model.AccessTokenScopeLevel) {
|
||||||
if !ctx.IsBasicAuth || ctx.Data["IsApiToken"] != true {
|
if !ctx.IsBasicAuth || ctx.Data["IsApiToken"] != true {
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ func initActionsTasks() {
|
||||||
registerScheduleTasks()
|
registerScheduleTasks()
|
||||||
registerActionsCleanup()
|
registerActionsCleanup()
|
||||||
registerOfflineRunnersCleanup()
|
registerOfflineRunnersCleanup()
|
||||||
|
registerCleanupActionUser()
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerStopZombieTasks() {
|
func registerStopZombieTasks() {
|
||||||
|
|
@ -95,3 +96,13 @@ func registerOfflineRunnersCleanup() {
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func registerCleanupActionUser() {
|
||||||
|
RegisterTaskFatal("actions_action_user", &BaseConfig{
|
||||||
|
Enabled: true,
|
||||||
|
RunAtStart: true,
|
||||||
|
Schedule: "@weekly",
|
||||||
|
}, func(ctx context.Context, _ *user_model.User, _ Config) error {
|
||||||
|
return actions_service.CleanupActionUser(ctx)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -177,6 +177,7 @@ func DeleteRepositoryDirectly(ctx context.Context, doer *user_model.User, repoID
|
||||||
&actions_model.ActionScheduleSpec{RepoID: repoID},
|
&actions_model.ActionScheduleSpec{RepoID: repoID},
|
||||||
&actions_model.ActionSchedule{RepoID: repoID},
|
&actions_model.ActionSchedule{RepoID: repoID},
|
||||||
&actions_model.ActionArtifact{RepoID: repoID},
|
&actions_model.ActionArtifact{RepoID: repoID},
|
||||||
|
&actions_model.ActionUser{RepoID: repoID},
|
||||||
&repo_model.RepoArchiveDownloadCount{RepoID: repoID},
|
&repo_model.RepoArchiveDownloadCount{RepoID: repoID},
|
||||||
&actions_model.ActionRunnerToken{RepoID: repoID},
|
&actions_model.ActionRunnerToken{RepoID: repoID},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import (
|
||||||
"forgejo.org/models/db"
|
"forgejo.org/models/db"
|
||||||
repo_model "forgejo.org/models/repo"
|
repo_model "forgejo.org/models/repo"
|
||||||
user_model "forgejo.org/models/user"
|
user_model "forgejo.org/models/user"
|
||||||
|
actions_service "forgejo.org/services/actions"
|
||||||
|
|
||||||
"xorm.io/builder"
|
"xorm.io/builder"
|
||||||
)
|
)
|
||||||
|
|
@ -91,5 +92,12 @@ func BlockUser(ctx context.Context, userID, blockID int64) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = db.Iterate(ctx, builder.Eq{"owner_id": userID}, func(ctx context.Context, repo *repo_model.Repository) error {
|
||||||
|
return actions_service.RevokeTrust(ctx, repo.ID, blockID)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
return committer.Commit()
|
return committer.Commit()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,13 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
model "forgejo.org/models"
|
model "forgejo.org/models"
|
||||||
|
actions_model "forgejo.org/models/actions"
|
||||||
"forgejo.org/models/db"
|
"forgejo.org/models/db"
|
||||||
issues_model "forgejo.org/models/issues"
|
issues_model "forgejo.org/models/issues"
|
||||||
repo_model "forgejo.org/models/repo"
|
repo_model "forgejo.org/models/repo"
|
||||||
"forgejo.org/models/unittest"
|
"forgejo.org/models/unittest"
|
||||||
user_model "forgejo.org/models/user"
|
user_model "forgejo.org/models/user"
|
||||||
|
actions_module "forgejo.org/modules/actions"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
@ -110,4 +112,37 @@ func TestBlockUser(t *testing.T) {
|
||||||
_, err = issues_model.ChangeIssueStatus(db.DefaultContext, issue, blockedUser, false)
|
_, err = issues_model.ChangeIssueStatus(db.DefaultContext, issue, blockedUser, false)
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("Pull requests actions are cancelled", func(t *testing.T) {
|
||||||
|
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||||
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2, OwnerID: doer.ID})
|
||||||
|
blockedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
|
||||||
|
defer user_model.UnblockUser(db.DefaultContext, doer.ID, blockedUser.ID)
|
||||||
|
|
||||||
|
pullRequestPosterID := blockedUser.ID
|
||||||
|
singleWorkflows, err := actions_module.JobParser([]byte(`
|
||||||
|
jobs:
|
||||||
|
job:
|
||||||
|
runs-on: docker
|
||||||
|
steps:
|
||||||
|
- run: echo OK
|
||||||
|
`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, singleWorkflows, 1)
|
||||||
|
runWaiting := &actions_model.ActionRun{
|
||||||
|
TriggerUserID: 2,
|
||||||
|
RepoID: repo.ID,
|
||||||
|
Status: actions_model.StatusWaiting,
|
||||||
|
PullRequestPosterID: pullRequestPosterID,
|
||||||
|
}
|
||||||
|
require.NoError(t, actions_model.InsertRun(t.Context(), runWaiting, singleWorkflows))
|
||||||
|
|
||||||
|
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runWaiting.ID})
|
||||||
|
require.Equal(t, actions_model.StatusWaiting.String(), run.Status.String())
|
||||||
|
|
||||||
|
require.NoError(t, BlockUser(db.DefaultContext, doer.ID, blockedUser.ID))
|
||||||
|
|
||||||
|
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runWaiting.ID})
|
||||||
|
require.Equal(t, actions_model.StatusCancelled.String(), run.Status.String())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,7 @@ func deleteUser(ctx context.Context, u *user_model.User, purge bool) (err error)
|
||||||
&pull_model.ReviewState{UserID: u.ID},
|
&pull_model.ReviewState{UserID: u.ID},
|
||||||
&user_model.Redirect{RedirectUserID: u.ID},
|
&user_model.Redirect{RedirectUserID: u.ID},
|
||||||
&actions_model.ActionRunner{OwnerID: u.ID},
|
&actions_model.ActionRunner{OwnerID: u.ID},
|
||||||
|
&actions_model.ActionUser{UserID: u.ID},
|
||||||
&user_model.BlockedUser{BlockID: u.ID},
|
&user_model.BlockedUser{BlockID: u.ID},
|
||||||
&user_model.BlockedUser{UserID: u.ID},
|
&user_model.BlockedUser{UserID: u.ID},
|
||||||
&actions_model.ActionRunnerToken{OwnerID: u.ID},
|
&actions_model.ActionRunnerToken{OwnerID: u.ID},
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||
data-workflow-url="{{.WorkflowURL}}"
|
data-workflow-url="{{.WorkflowURL}}"
|
||||||
data-initial-post-response="{{.InitialData}}"
|
data-initial-post-response="{{.InitialData}}"
|
||||||
data-initial-artifacts-response="{{.InitialArtifactsData}}"
|
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-cancel="{{ctx.Locale.Tr "cancel"}}"
|
||||||
data-locale-rerun="{{ctx.Locale.Tr "rerun"}}"
|
data-locale-rerun="{{ctx.Locale.Tr "rerun"}}"
|
||||||
data-locale-rerun-all="{{ctx.Locale.Tr "rerun_all"}}"
|
data-locale-rerun-all="{{ctx.Locale.Tr "rerun_all"}}"
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,7 @@
|
||||||
</button>
|
</button>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
{{template "repo/pulls/trust" .}}
|
||||||
{{template "repo/issue/view_content/update_branch_by_merge" $}}
|
{{template "repo/issue/view_content/update_branch_by_merge" $}}
|
||||||
{{else if .Issue.PullRequest.IsChecking}}
|
{{else if .Issue.PullRequest.IsChecking}}
|
||||||
<div class="item">
|
<div class="item">
|
||||||
|
|
@ -187,6 +188,7 @@
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
{{template "repo/pulls/trust" .}}
|
||||||
{{template "repo/issue/view_content/update_branch_by_merge" $}}
|
{{template "repo/issue/view_content/update_branch_by_merge" $}}
|
||||||
{{if .Issue.PullRequest.IsEmpty}}
|
{{if .Issue.PullRequest.IsEmpty}}
|
||||||
<div class="divider"></div>
|
<div class="divider"></div>
|
||||||
|
|
|
||||||
61
templates/repo/pulls/trust.tmpl
Normal file
61
templates/repo/pulls/trust.tmpl
Normal file
|
|
@ -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}}
|
||||||
|
<div class="pull-request-trust-panel" id="pull-request-trust-panel">
|
||||||
|
<div class="divider"></div>
|
||||||
|
<div class="item item-section">
|
||||||
|
<div class="item-section-left flex-text-inline" data-tooltip-content="{{ctx.Locale.Tr "repo.pulls.poster_is_trusted.tooltip"}}">
|
||||||
|
{{ctx.Locale.Tr "repo.pulls.poster_is_trusted" "https://forgejo.org/docs/latest/user/actions/security-pull-request/"}}
|
||||||
|
</div>
|
||||||
|
<div class="item-section-right">
|
||||||
|
<form id="pull-request-trust-panel-revoke" class="ui form" method="post" action="{{.Link}}/action-user-trust">
|
||||||
|
<input type="hidden" name="trust" value="revoke">
|
||||||
|
<button class="ui primary button" data-tooltip-content="{{ctx.Locale.Tr "repo.pulls.poster_trust_revoke.tooltip"}}">{{ctx.Locale.Tr "repo.pulls.poster_trust_revoke"}}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{else if and .PullRequestPosterIsNotTrustedWithActions .SomePullRequestRunsNeedApproval}}
|
||||||
|
<div class="pull-request-trust-panel" id="pull-request-trust-panel">
|
||||||
|
<div class="divider"></div>
|
||||||
|
<div class="item item-section">
|
||||||
|
<div class="item-section-left flex-text-inline" data-tooltip-content="{{ctx.Locale.Tr "repo.pulls.poster_requires_approval.tooltip"}}">
|
||||||
|
{{svg "octicon-alert" 16 "text red"}}
|
||||||
|
{{ctx.Locale.Tr "repo.pulls.poster_requires_approval" "https://forgejo.org/docs/latest/user/actions/security-pull-request/"}}
|
||||||
|
</div>
|
||||||
|
{{if .UserCanDelegateTrustWithPullRequest}}
|
||||||
|
<div class="item-section-right">
|
||||||
|
<div class="tw-inline-block">
|
||||||
|
<form id="pull-request-trust-panel-deny" class="ui form" method="post" action="{{.Link}}/action-user-trust">
|
||||||
|
<input type="hidden" name="trust" value="deny">
|
||||||
|
<button class="ui primary button" data-tooltip-content="{{ctx.Locale.Tr "repo.pulls.poster_trust_deny.tooltip"}}">{{ctx.Locale.Tr "repo.pulls.poster_trust_deny"}}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="tw-inline-block">
|
||||||
|
<form id="pull-request-trust-panel-once" class="ui form" method="post" action="{{.Link}}/action-user-trust">
|
||||||
|
<input type="hidden" name="trust" value="once">
|
||||||
|
<button class="ui primary button" data-tooltip-content="{{ctx.Locale.Tr "repo.pulls.poster_trust_once.tooltip"}}">{{ctx.Locale.Tr "repo.pulls.poster_trust_once"}}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="tw-inline-block">
|
||||||
|
<form id="pull-request-trust-panel-always" class="ui form" method="post" action="{{.Link}}/action-user-trust">
|
||||||
|
<input type="hidden" name="trust" value="always">
|
||||||
|
<button class="ui primary button" data-tooltip-content="{{ctx.Locale.Tr "repo.pulls.poster_trust_always.tooltip"}}">{{ctx.Locale.Tr "repo.pulls.poster_trust_always"}}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
// Copyright 20124 The Forgejo Authors. All rights reserved.
|
// Copyright 2024 The Forgejo Authors. All rights reserved.
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
package integration
|
package integration
|
||||||
|
|
|
||||||
355
tests/integration/actions_trust_test.go
Normal file
355
tests/integration/actions_trust_test.go
Normal file
|
|
@ -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())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -357,11 +357,11 @@ func TestAPICron(t *testing.T) {
|
||||||
AddTokenAuth(token)
|
AddTokenAuth(token)
|
||||||
resp := MakeRequest(t, req, http.StatusOK)
|
resp := MakeRequest(t, req, http.StatusOK)
|
||||||
|
|
||||||
assert.Equal(t, "29", resp.Header().Get("X-Total-Count"))
|
assert.Equal(t, "30", resp.Header().Get("X-Total-Count"))
|
||||||
|
|
||||||
var crons []api.Cron
|
var crons []api.Cron
|
||||||
DecodeJSON(t, resp, &crons)
|
DecodeJSON(t, resp, &crons)
|
||||||
assert.Len(t, crons, 29)
|
assert.Len(t, crons, 30)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("Execute", func(t *testing.T) {
|
t.Run("Execute", func(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -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'));
|
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 () => {
|
test('artifacts download links', async () => {
|
||||||
Object.defineProperty(document.documentElement, 'lang', {value: 'en'});
|
Object.defineProperty(document.documentElement, 'lang', {value: 'en'});
|
||||||
vi.spyOn(global, 'fetch').mockImplementation((url, opts) => {
|
vi.spyOn(global, 'fetch').mockImplementation((url, opts) => {
|
||||||
|
|
|
||||||
|
|
@ -223,7 +223,8 @@ export default {
|
||||||
},
|
},
|
||||||
// approve a run
|
// approve a run
|
||||||
approveRun() {
|
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
|
// show/hide the step logs for a group
|
||||||
toggleGroupLogs(event) {
|
toggleGroupLogs(event) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue