diff options
author | Jason Song <i@wolfogre.com> | 2023-01-31 02:45:19 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-01-31 02:45:19 +0100 |
commit | 4011821c946e8db032be86266dd9364ccb204118 (patch) | |
tree | a8a1cf1b8f088df583f316c8233bc18a89881099 /services | |
parent | Pull request yaml template support for including commit body in a field (#22629) (diff) | |
download | forgejo-4011821c946e8db032be86266dd9364ccb204118.tar.xz forgejo-4011821c946e8db032be86266dd9364ccb204118.zip |
Implement actions (#21937)
Close #13539.
Co-authored by: @lunny @appleboy @fuxiaohei and others.
Related projects:
- https://gitea.com/gitea/actions-proto-def
- https://gitea.com/gitea/actions-proto-go
- https://gitea.com/gitea/act
- https://gitea.com/gitea/act_runner
### Summary
The target of this PR is to bring a basic implementation of "Actions",
an internal CI/CD system of Gitea. That means even though it has been
merged, the state of the feature is **EXPERIMENTAL**, and please note
that:
- It is disabled by default;
- It shouldn't be used in a production environment currently;
- It shouldn't be used in a public Gitea instance currently;
- Breaking changes may be made before it's stable.
**Please comment on #13539 if you have any different product design
ideas**, all decisions reached there will be adopted here. But in this
PR, we don't talk about **naming, feature-creep or alternatives**.
### ⚠️ Breaking
`gitea-actions` will become a reserved user name. If a user with the
name already exists in the database, it is recommended to rename it.
### Some important reviews
- What is `DEFAULT_ACTIONS_URL` in `app.ini` for?
- https://github.com/go-gitea/gitea/pull/21937#discussion_r1055954954
- Why the api for runners is not under the normal `/api/v1` prefix?
- https://github.com/go-gitea/gitea/pull/21937#discussion_r1061173592
- Why DBFS?
- https://github.com/go-gitea/gitea/pull/21937#discussion_r1061301178
- Why ignore events triggered by `gitea-actions` bot?
- https://github.com/go-gitea/gitea/pull/21937#discussion_r1063254103
- Why there's no permission control for actions?
- https://github.com/go-gitea/gitea/pull/21937#discussion_r1090229868
### What it looks like
<details>
#### Manage runners
<img width="1792" alt="image"
src="https://user-images.githubusercontent.com/9418365/205870657-c72f590e-2e08-4cd4-be7f-2e0abb299bbf.png">
#### List runs
<img width="1792" alt="image"
src="https://user-images.githubusercontent.com/9418365/205872794-50fde990-2b45-48c1-a178-908e4ec5b627.png">
#### View logs
<img width="1792" alt="image"
src="https://user-images.githubusercontent.com/9418365/205872501-9b7b9000-9542-4991-8f55-18ccdada77c3.png">
</details>
### How to try it
<details>
#### 1. Start Gitea
Clone this branch and [install from
source](https://docs.gitea.io/en-us/install-from-source).
Add additional configurations in `app.ini` to enable Actions:
```ini
[actions]
ENABLED = true
```
Start it.
If all is well, you'll see the management page of runners:
<img width="1792" alt="image"
src="https://user-images.githubusercontent.com/9418365/205877365-8e30a780-9b10-4154-b3e8-ee6c3cb35a59.png">
#### 2. Start runner
Clone the [act_runner](https://gitea.com/gitea/act_runner), and follow
the
[README](https://gitea.com/gitea/act_runner/src/branch/main/README.md)
to start it.
If all is well, you'll see a new runner has been added:
<img width="1792" alt="image"
src="https://user-images.githubusercontent.com/9418365/205878000-216f5937-e696-470d-b66c-8473987d91c3.png">
#### 3. Enable actions for a repo
Create a new repo or open an existing one, check the `Actions` checkbox
in settings and submit.
<img width="1792" alt="image"
src="https://user-images.githubusercontent.com/9418365/205879705-53e09208-73c0-4b3e-a123-2dcf9aba4b9c.png">
<img width="1792" alt="image"
src="https://user-images.githubusercontent.com/9418365/205879383-23f3d08f-1a85-41dd-a8b3-54e2ee6453e8.png">
If all is well, you'll see a new tab "Actions":
<img width="1792" alt="image"
src="https://user-images.githubusercontent.com/9418365/205881648-a8072d8c-5803-4d76-b8a8-9b2fb49516c1.png">
#### 4. Upload workflow files
Upload some workflow files to `.gitea/workflows/xxx.yaml`, you can
follow the [quickstart](https://docs.github.com/en/actions/quickstart)
of GitHub Actions. Yes, Gitea Actions is compatible with GitHub Actions
in most cases, you can use the same demo:
```yaml
name: GitHub Actions Demo
run-name: ${{ github.actor }} is testing out GitHub Actions 🚀
on: [push]
jobs:
Explore-GitHub-Actions:
runs-on: ubuntu-latest
steps:
- run: echo "🎉 The job was automatically triggered by a ${{ github.event_name }} event."
- run: echo "🐧 This job is now running on a ${{ runner.os }} server hosted by GitHub!"
- run: echo "🔎 The name of your branch is ${{ github.ref }} and your repository is ${{ github.repository }}."
- name: Check out repository code
uses: actions/checkout@v3
- run: echo "💡 The ${{ github.repository }} repository has been cloned to the runner."
- run: echo "🖥️ The workflow is now ready to test your code on the runner."
- name: List files in the repository
run: |
ls ${{ github.workspace }}
- run: echo "🍏 This job's status is ${{ job.status }}."
```
If all is well, you'll see a new run in `Actions` tab:
<img width="1792" alt="image"
src="https://user-images.githubusercontent.com/9418365/205884473-79a874bc-171b-4aaf-acd5-0241a45c3b53.png">
#### 5. Check the logs of jobs
Click a run and you'll see the logs:
<img width="1792" alt="image"
src="https://user-images.githubusercontent.com/9418365/205884800-994b0374-67f7-48ff-be9a-4c53f3141547.png">
#### 6. Go on
You can try more examples in [the
documents](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions)
of GitHub Actions, then you might find a lot of bugs.
Come on, PRs are welcome.
</details>
See also: [Feature Preview: Gitea
Actions](https://blog.gitea.io/2022/12/feature-preview-gitea-actions/)
---------
Co-authored-by: a1012112796 <1012112796@qq.com>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: delvh <dev.lh@web.de>
Co-authored-by: ChristopherHX <christopher.homberger@web.de>
Co-authored-by: John Olheiser <john.olheiser@gmail.com>
Diffstat (limited to 'services')
-rw-r--r-- | services/actions/clear_tasks.go | 94 | ||||
-rw-r--r-- | services/actions/commit_status.go | 88 | ||||
-rw-r--r-- | services/actions/init.go | 22 | ||||
-rw-r--r-- | services/actions/job_emitter.go | 140 | ||||
-rw-r--r-- | services/actions/job_emitter_test.go | 80 | ||||
-rw-r--r-- | services/actions/notifier.go | 528 | ||||
-rw-r--r-- | services/actions/notifier_helper.go | 229 | ||||
-rw-r--r-- | services/auth/basic.go | 14 | ||||
-rw-r--r-- | services/auth/oauth2.go | 19 | ||||
-rw-r--r-- | services/cron/cron.go | 1 | ||||
-rw-r--r-- | services/cron/tasks_actions.go | 51 | ||||
-rw-r--r-- | services/forms/repo_form.go | 1 | ||||
-rw-r--r-- | services/forms/runner.go | 25 | ||||
-rw-r--r-- | services/repository/push.go | 20 |
14 files changed, 1303 insertions, 9 deletions
diff --git a/services/actions/clear_tasks.go b/services/actions/clear_tasks.go new file mode 100644 index 0000000000..583e588de4 --- /dev/null +++ b/services/actions/clear_tasks.go @@ -0,0 +1,94 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "context" + "fmt" + "time" + + actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/actions" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/timeutil" +) + +const ( + zombieTaskTimeout = 10 * time.Minute + endlessTaskTimeout = 3 * time.Hour + abandonedJobTimeout = 24 * time.Hour +) + +// StopZombieTasks stops the task which have running status, but haven't been updated for a long time +func StopZombieTasks(ctx context.Context) error { + return stopTasks(ctx, actions_model.FindTaskOptions{ + Status: actions_model.StatusRunning, + UpdatedBefore: timeutil.TimeStamp(time.Now().Add(-zombieTaskTimeout).Unix()), + }) +} + +// StopEndlessTasks stops the tasks which have running status and continuous updates, but don't end for a long time +func StopEndlessTasks(ctx context.Context) error { + return stopTasks(ctx, actions_model.FindTaskOptions{ + Status: actions_model.StatusRunning, + StartedBefore: timeutil.TimeStamp(time.Now().Add(-endlessTaskTimeout).Unix()), + }) +} + +func stopTasks(ctx context.Context, opts actions_model.FindTaskOptions) error { + tasks, err := actions_model.FindTasks(ctx, opts) + if err != nil { + return fmt.Errorf("find tasks: %w", err) + } + + for _, task := range tasks { + if err := db.WithTx(ctx, func(ctx context.Context) error { + if err := actions_model.StopTask(ctx, task.ID, actions_model.StatusFailure); err != nil { + return err + } + if err := task.LoadJob(ctx); err != nil { + return err + } + return CreateCommitStatus(ctx, task.Job) + }); err != nil { + log.Warn("Cannot stop task %v: %v", task.ID, err) + // go on + } else if remove, err := actions.TransferLogs(ctx, task.LogFilename); err != nil { + log.Warn("Cannot transfer logs of task %v: %v", task.ID, err) + } else { + remove() + } + } + return nil +} + +// CancelAbandonedJobs cancels the jobs which have waiting status, but haven't been picked by a runner for a long time +func CancelAbandonedJobs(ctx context.Context) error { + jobs, _, err := actions_model.FindRunJobs(ctx, actions_model.FindRunJobOptions{ + Statuses: []actions_model.Status{actions_model.StatusWaiting, actions_model.StatusBlocked}, + UpdatedBefore: timeutil.TimeStamp(time.Now().Add(-abandonedJobTimeout).Unix()), + }) + if err != nil { + log.Warn("find abandoned tasks: %v", err) + return err + } + + now := timeutil.TimeStampNow() + for _, job := range jobs { + job.Status = actions_model.StatusCancelled + job.Stopped = now + if err := db.WithTx(ctx, func(ctx context.Context) error { + if _, err := actions_model.UpdateRunJob(ctx, job, nil, "status", "stopped"); err != nil { + return err + } + return CreateCommitStatus(ctx, job) + }); err != nil { + log.Warn("cancel abandoned job %v: %v", job.ID, err) + // go on + } + } + + return nil +} diff --git a/services/actions/commit_status.go b/services/actions/commit_status.go new file mode 100644 index 0000000000..c17f8ef153 --- /dev/null +++ b/services/actions/commit_status.go @@ -0,0 +1,88 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "context" + "fmt" + + actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/models/db" + git_model "code.gitea.io/gitea/models/git" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + webhook_module "code.gitea.io/gitea/modules/webhook" +) + +func CreateCommitStatus(ctx context.Context, job *actions_model.ActionRunJob) error { + if err := job.LoadAttributes(ctx); err != nil { + return fmt.Errorf("load run: %w", err) + } + + run := job.Run + if run.Event != webhook_module.HookEventPush { + return nil + } + + payload, err := run.GetPushEventPayload() + if err != nil { + return fmt.Errorf("GetPushEventPayload: %w", err) + } + + creator, err := user_model.GetUserByID(ctx, payload.Pusher.ID) + if err != nil { + return fmt.Errorf("GetUserByID: %w", err) + } + + repo := run.Repo + sha := payload.HeadCommit.ID + ctxname := job.Name + state := toCommitStatus(job.Status) + + if statuses, _, err := git_model.GetLatestCommitStatus(ctx, repo.ID, sha, db.ListOptions{}); err == nil { + for _, v := range statuses { + if v.Context == ctxname { + if v.State == state { + return nil + } + break + } + } + } else { + return fmt.Errorf("GetLatestCommitStatus: %w", err) + } + + if err := git_model.NewCommitStatus(ctx, git_model.NewCommitStatusOptions{ + Repo: repo, + SHA: payload.HeadCommit.ID, + Creator: creator, + CommitStatus: &git_model.CommitStatus{ + SHA: sha, + TargetURL: run.HTMLURL(), + Description: "", + Context: ctxname, + CreatorID: payload.Pusher.ID, + State: state, + }, + }); err != nil { + return fmt.Errorf("NewCommitStatus: %w", err) + } + + return nil +} + +func toCommitStatus(status actions_model.Status) api.CommitStatusState { + switch status { + case actions_model.StatusSuccess: + return api.CommitStatusSuccess + case actions_model.StatusFailure, actions_model.StatusCancelled, actions_model.StatusSkipped: + return api.CommitStatusFailure + case actions_model.StatusWaiting, actions_model.StatusBlocked: + return api.CommitStatusPending + case actions_model.StatusRunning: + return api.CommitStatusRunning + default: + return api.CommitStatusError + } +} diff --git a/services/actions/init.go b/services/actions/init.go new file mode 100644 index 0000000000..3fd03eeb6f --- /dev/null +++ b/services/actions/init.go @@ -0,0 +1,22 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "code.gitea.io/gitea/modules/graceful" + "code.gitea.io/gitea/modules/notification" + "code.gitea.io/gitea/modules/queue" + "code.gitea.io/gitea/modules/setting" +) + +func Init() { + if !setting.Actions.Enabled { + return + } + + jobEmitterQueue = queue.CreateUniqueQueue("actions_ready_job", jobEmitterQueueHandle, new(jobUpdate)) + go graceful.GetManager().RunWithShutdownFns(jobEmitterQueue.Run) + + notification.RegisterNotifier(NewNotifier()) +} diff --git a/services/actions/job_emitter.go b/services/actions/job_emitter.go new file mode 100644 index 0000000000..cb2cc8d1ac --- /dev/null +++ b/services/actions/job_emitter.go @@ -0,0 +1,140 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "context" + "errors" + "fmt" + + actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/graceful" + "code.gitea.io/gitea/modules/queue" + + "xorm.io/builder" +) + +var jobEmitterQueue queue.UniqueQueue + +type jobUpdate struct { + RunID int64 +} + +func EmitJobsIfReady(runID int64) error { + err := jobEmitterQueue.Push(&jobUpdate{ + RunID: runID, + }) + if errors.Is(err, queue.ErrAlreadyInQueue) { + return nil + } + return err +} + +func jobEmitterQueueHandle(data ...queue.Data) []queue.Data { + ctx := graceful.GetManager().ShutdownContext() + var ret []queue.Data + for _, d := range data { + update := d.(*jobUpdate) + if err := checkJobsOfRun(ctx, update.RunID); err != nil { + ret = append(ret, d) + } + } + return ret +} + +func checkJobsOfRun(ctx context.Context, runID int64) error { + return db.WithTx(ctx, func(ctx context.Context) error { + jobs, _, err := actions_model.FindRunJobs(ctx, actions_model.FindRunJobOptions{RunID: runID}) + if err != nil { + return err + } + idToJobs := make(map[string][]*actions_model.ActionRunJob, len(jobs)) + for _, job := range jobs { + idToJobs[job.JobID] = append(idToJobs[job.JobID], job) + } + + updates := newJobStatusResolver(jobs).Resolve() + for _, job := range jobs { + if status, ok := updates[job.ID]; ok { + job.Status = status + if n, err := actions_model.UpdateRunJob(ctx, job, builder.Eq{"status": actions_model.StatusBlocked}, "status"); err != nil { + return err + } else if n != 1 { + return fmt.Errorf("no affected for updating blocked job %v", job.ID) + } + } + } + return nil + }) +} + +type jobStatusResolver struct { + statuses map[int64]actions_model.Status + needs map[int64][]int64 +} + +func newJobStatusResolver(jobs actions_model.ActionJobList) *jobStatusResolver { + idToJobs := make(map[string][]*actions_model.ActionRunJob, len(jobs)) + for _, job := range jobs { + idToJobs[job.JobID] = append(idToJobs[job.JobID], job) + } + + statuses := make(map[int64]actions_model.Status, len(jobs)) + needs := make(map[int64][]int64, len(jobs)) + for _, job := range jobs { + statuses[job.ID] = job.Status + for _, need := range job.Needs { + for _, v := range idToJobs[need] { + needs[job.ID] = append(needs[job.ID], v.ID) + } + } + } + return &jobStatusResolver{ + statuses: statuses, + needs: needs, + } +} + +func (r *jobStatusResolver) Resolve() map[int64]actions_model.Status { + ret := map[int64]actions_model.Status{} + for i := 0; i < len(r.statuses); i++ { + updated := r.resolve() + if len(updated) == 0 { + return ret + } + for k, v := range updated { + ret[k] = v + r.statuses[k] = v + } + } + return ret +} + +func (r *jobStatusResolver) resolve() map[int64]actions_model.Status { + ret := map[int64]actions_model.Status{} + for id, status := range r.statuses { + if status != actions_model.StatusBlocked { + continue + } + allDone, allSucceed := true, true + for _, need := range r.needs[id] { + needStatus := r.statuses[need] + if !needStatus.IsDone() { + allDone = false + } + if needStatus.In(actions_model.StatusFailure, actions_model.StatusCancelled, actions_model.StatusSkipped) { + allSucceed = false + } + } + if allDone { + if allSucceed { + ret[id] = actions_model.StatusWaiting + } else { + ret[id] = actions_model.StatusSkipped + } + } + } + return ret +} diff --git a/services/actions/job_emitter_test.go b/services/actions/job_emitter_test.go new file mode 100644 index 0000000000..e81aa61d80 --- /dev/null +++ b/services/actions/job_emitter_test.go @@ -0,0 +1,80 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "testing" + + actions_model "code.gitea.io/gitea/models/actions" + + "github.com/stretchr/testify/assert" +) + +func Test_jobStatusResolver_Resolve(t *testing.T) { + tests := []struct { + name string + jobs actions_model.ActionJobList + want map[int64]actions_model.Status + }{ + { + name: "no blocked", + jobs: actions_model.ActionJobList{ + {ID: 1, JobID: "1", Status: actions_model.StatusWaiting, Needs: []string{}}, + {ID: 2, JobID: "2", Status: actions_model.StatusWaiting, Needs: []string{}}, + {ID: 3, JobID: "3", Status: actions_model.StatusWaiting, Needs: []string{}}, + }, + want: map[int64]actions_model.Status{}, + }, + { + name: "single blocked", + jobs: actions_model.ActionJobList{ + {ID: 1, JobID: "1", Status: actions_model.StatusSuccess, Needs: []string{}}, + {ID: 2, JobID: "2", Status: actions_model.StatusBlocked, Needs: []string{"1"}}, + {ID: 3, JobID: "3", Status: actions_model.StatusWaiting, Needs: []string{}}, + }, + want: map[int64]actions_model.Status{ + 2: actions_model.StatusWaiting, + }, + }, + { + name: "multiple blocked", + jobs: actions_model.ActionJobList{ + {ID: 1, JobID: "1", Status: actions_model.StatusSuccess, Needs: []string{}}, + {ID: 2, JobID: "2", Status: actions_model.StatusBlocked, Needs: []string{"1"}}, + {ID: 3, JobID: "3", Status: actions_model.StatusBlocked, Needs: []string{"1"}}, + }, + want: map[int64]actions_model.Status{ + 2: actions_model.StatusWaiting, + 3: actions_model.StatusWaiting, + }, + }, + { + name: "chain blocked", + jobs: actions_model.ActionJobList{ + {ID: 1, JobID: "1", Status: actions_model.StatusFailure, Needs: []string{}}, + {ID: 2, JobID: "2", Status: actions_model.StatusBlocked, Needs: []string{"1"}}, + {ID: 3, JobID: "3", Status: actions_model.StatusBlocked, Needs: []string{"2"}}, + }, + want: map[int64]actions_model.Status{ + 2: actions_model.StatusSkipped, + 3: actions_model.StatusSkipped, + }, + }, + { + name: "loop need", + jobs: actions_model.ActionJobList{ + {ID: 1, JobID: "1", Status: actions_model.StatusBlocked, Needs: []string{"3"}}, + {ID: 2, JobID: "2", Status: actions_model.StatusBlocked, Needs: []string{"1"}}, + {ID: 3, JobID: "3", Status: actions_model.StatusBlocked, Needs: []string{"2"}}, + }, + want: map[int64]actions_model.Status{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := newJobStatusResolver(tt.jobs) + assert.Equal(t, tt.want, r.Resolve()) + }) + } +} diff --git a/services/actions/notifier.go b/services/actions/notifier.go new file mode 100644 index 0000000000..0ed69097dc --- /dev/null +++ b/services/actions/notifier.go @@ -0,0 +1,528 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "context" + + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + packages_model "code.gitea.io/gitea/models/packages" + perm_model "code.gitea.io/gitea/models/perm" + access_model "code.gitea.io/gitea/models/perm/access" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/notification/base" + "code.gitea.io/gitea/modules/repository" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + webhook_module "code.gitea.io/gitea/modules/webhook" + "code.gitea.io/gitea/services/convert" +) + +type actionsNotifier struct { + base.NullNotifier +} + +var _ base.Notifier = &actionsNotifier{} + +// NewNotifier create a new actionsNotifier notifier +func NewNotifier() base.Notifier { + return &actionsNotifier{} +} + +// NotifyNewIssue notifies issue created event +func (n *actionsNotifier) NotifyNewIssue(ctx context.Context, issue *issues_model.Issue, _ []*user_model.User) { + ctx = withMethod(ctx, "NotifyNewIssue") + if err := issue.LoadRepo(ctx); err != nil { + log.Error("issue.LoadRepo: %v", err) + return + } + if err := issue.LoadPoster(ctx); err != nil { + log.Error("issue.LoadPoster: %v", err) + return + } + mode, _ := access_model.AccessLevel(ctx, issue.Poster, issue.Repo) + + newNotifyInputFromIssue(issue, webhook_module.HookEventIssues).WithPayload(&api.IssuePayload{ + Action: api.HookIssueOpened, + Index: issue.Index, + Issue: convert.ToAPIIssue(ctx, issue), + Repository: convert.ToRepo(ctx, issue.Repo, mode), + Sender: convert.ToUser(issue.Poster, nil), + }).Notify(withMethod(ctx, "NotifyNewIssue")) +} + +// NotifyIssueChangeStatus notifies close or reopen issue to notifiers +func (n *actionsNotifier) NotifyIssueChangeStatus(ctx context.Context, doer *user_model.User, commitID string, issue *issues_model.Issue, _ *issues_model.Comment, isClosed bool) { + ctx = withMethod(ctx, "NotifyIssueChangeStatus") + mode, _ := access_model.AccessLevel(ctx, issue.Poster, issue.Repo) + if issue.IsPull { + if err := issue.LoadPullRequest(ctx); err != nil { + log.Error("LoadPullRequest: %v", err) + return + } + // Merge pull request calls issue.changeStatus so we need to handle separately. + apiPullRequest := &api.PullRequestPayload{ + Index: issue.Index, + PullRequest: convert.ToAPIPullRequest(db.DefaultContext, issue.PullRequest, nil), + Repository: convert.ToRepo(ctx, issue.Repo, mode), + Sender: convert.ToUser(doer, nil), + CommitID: commitID, + } + if isClosed { + apiPullRequest.Action = api.HookIssueClosed + } else { + apiPullRequest.Action = api.HookIssueReOpened + } + newNotifyInputFromIssue(issue, webhook_module.HookEventPullRequest). + WithDoer(doer). + WithPayload(apiPullRequest). + Notify(ctx) + return + } + apiIssue := &api.IssuePayload{ + Index: issue.Index, + Issue: convert.ToAPIIssue(ctx, issue), + Repository: convert.ToRepo(ctx, issue.Repo, mode), + Sender: convert.ToUser(doer, nil), + } + if isClosed { + apiIssue.Action = api.HookIssueClosed + } else { + apiIssue.Action = api.HookIssueReOpened + } + newNotifyInputFromIssue(issue, webhook_module.HookEventIssues). + WithDoer(doer). + WithPayload(apiIssue). + Notify(ctx) +} + +func (n *actionsNotifier) NotifyIssueChangeLabels(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, + _, _ []*issues_model.Label, +) { + ctx = withMethod(ctx, "NotifyIssueChangeLabels") + + var err error + if err = issue.LoadRepo(ctx); err != nil { + log.Error("LoadRepo: %v", err) + return + } + + if err = issue.LoadPoster(ctx); err != nil { + log.Error("LoadPoster: %v", err) + return + } + + mode, _ := access_model.AccessLevel(ctx, issue.Poster, issue.Repo) + if issue.IsPull { + if err = issue.LoadPullRequest(ctx); err != nil { + log.Error("loadPullRequest: %v", err) + return + } + if err = issue.PullRequest.LoadIssue(ctx); err != nil { + log.Error("LoadIssue: %v", err) + return + } + newNotifyInputFromIssue(issue, webhook_module.HookEventPullRequestLabel). + WithDoer(doer). + WithPayload(&api.PullRequestPayload{ + Action: api.HookIssueLabelUpdated, + Index: issue.Index, + PullRequest: convert.ToAPIPullRequest(ctx, issue.PullRequest, nil), + Repository: convert.ToRepo(ctx, issue.Repo, perm_model.AccessModeNone), + Sender: convert.ToUser(doer, nil), + }). + Notify(ctx) + return + } + newNotifyInputFromIssue(issue, webhook_module.HookEventIssueLabel). + WithDoer(doer). + WithPayload(&api.IssuePayload{ + Action: api.HookIssueLabelUpdated, + Index: issue.Index, + Issue: convert.ToAPIIssue(ctx, issue), + Repository: convert.ToRepo(ctx, issue.Repo, mode), + Sender: convert.ToUser(doer, nil), + }). + Notify(ctx) +} + +// NotifyCreateIssueComment notifies comment on an issue to notifiers +func (n *actionsNotifier) NotifyCreateIssueComment(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, + issue *issues_model.Issue, comment *issues_model.Comment, _ []*user_model.User, +) { + ctx = withMethod(ctx, "NotifyCreateIssueComment") + + mode, _ := access_model.AccessLevel(ctx, doer, repo) + + if issue.IsPull { + newNotifyInputFromIssue(issue, webhook_module.HookEventPullRequestComment). + WithDoer(doer). + WithPayload(&api.IssueCommentPayload{ + Action: api.HookIssueCommentCreated, + Issue: convert.ToAPIIssue(ctx, issue), + Comment: convert.ToComment(comment), + Repository: convert.ToRepo(ctx, repo, mode), + Sender: convert.ToUser(doer, nil), + IsPull: true, + }). + Notify(ctx) + return + } + newNotifyInputFromIssue(issue, webhook_module.HookEventIssueComment). + WithDoer(doer). + WithPayload(&api.IssueCommentPayload{ + Action: api.HookIssueCommentCreated, + Issue: convert.ToAPIIssue(ctx, issue), + Comment: convert.ToComment(comment), + Repository: convert.ToRepo(ctx, repo, mode), + Sender: convert.ToUser(doer, nil), + IsPull: false, + }). + Notify(ctx) +} + +func (n *actionsNotifier) NotifyNewPullRequest(ctx context.Context, pull *issues_model.PullRequest, _ []*user_model.User) { + ctx = withMethod(ctx, "NotifyNewPullRequest") + + if err := pull.LoadIssue(ctx); err != nil { + log.Error("pull.LoadIssue: %v", err) + return + } + if err := pull.Issue.LoadRepo(ctx); err != nil { + log.Error("pull.Issue.LoadRepo: %v", err) + return + } + if err := pull.Issue.LoadPoster(ctx); err != nil { + log.Error("pull.Issue.LoadPoster: %v", err) + return + } + + mode, _ := access_model.AccessLevel(ctx, pull.Issue.Poster, pull.Issue.Repo) + + newNotifyInputFromIssue(pull.Issue, webhook_module.HookEventPullRequest). + WithPayload(&api.PullRequestPayload{ + Action: api.HookIssueOpened, + Index: pull.Issue.Index, + PullRequest: convert.ToAPIPullRequest(ctx, pull, nil), + Repository: convert.ToRepo(ctx, pull.Issue.Repo, mode), + Sender: convert.ToUser(pull.Issue.Poster, nil), + }). + WithPullRequest(pull). + Notify(ctx) +} + +func (n *actionsNotifier) NotifyCreateRepository(ctx context.Context, doer, u *user_model.User, repo *repo_model.Repository) { + ctx = withMethod(ctx, "NotifyCreateRepository") + + newNotifyInput(repo, doer, webhook_module.HookEventRepository).WithPayload(&api.RepositoryPayload{ + Action: api.HookRepoCreated, + Repository: convert.ToRepo(ctx, repo, perm_model.AccessModeOwner), + Organization: convert.ToUser(u, nil), + Sender: convert.ToUser(doer, nil), + }).Notify(ctx) +} + +func (n *actionsNotifier) NotifyForkRepository(ctx context.Context, doer *user_model.User, oldRepo, repo *repo_model.Repository) { + ctx = withMethod(ctx, "NotifyForkRepository") + + oldMode, _ := access_model.AccessLevel(ctx, doer, oldRepo) + mode, _ := access_model.AccessLevel(ctx, doer, repo) + + // forked webhook + newNotifyInput(oldRepo, doer, webhook_module.HookEventFork).WithPayload(&api.ForkPayload{ + Forkee: convert.ToRepo(ctx, oldRepo, oldMode), + Repo: convert.ToRepo(ctx, repo, mode), + Sender: convert.ToUser(doer, nil), + }).Notify(ctx) + + u := repo.MustOwner(ctx) + + // Add to hook queue for created repo after session commit. + if u.IsOrganization() { + newNotifyInput(repo, doer, webhook_module.HookEventRepository). + WithRef(oldRepo.DefaultBranch). + WithPayload(&api.RepositoryPayload{ + Action: api.HookRepoCreated, + Repository: convert.ToRepo(ctx, repo, perm_model.AccessModeOwner), + Organization: convert.ToUser(u, nil), + Sender: convert.ToUser(doer, nil), + }).Notify(ctx) + } +} + +func (n *actionsNotifier) NotifyPullRequestReview(ctx context.Context, pr *issues_model.PullRequest, review *issues_model.Review, _ *issues_model.Comment, _ []*user_model.User) { + ctx = withMethod(ctx, "NotifyPullRequestReview") + + var reviewHookType webhook_module.HookEventType + + switch review.Type { + case issues_model.ReviewTypeApprove: + reviewHookType = webhook_module.HookEventPullRequestReviewApproved + case issues_model.ReviewTypeComment: + reviewHookType = webhook_module.HookEventPullRequestComment + case issues_model.ReviewTypeReject: + reviewHookType = webhook_module.HookEventPullRequestReviewRejected + default: + // unsupported review webhook type here + log.Error("Unsupported review webhook type") + return + } + + if err := pr.LoadIssue(ctx); err != nil { + log.Error("pr.LoadIssue: %v", err) + return + } + + mode, err := access_model.AccessLevel(ctx, review.Issue.Poster, review.Issue.Repo) + if err != nil { + log.Error("models.AccessLevel: %v", err) + return + } + + newNotifyInput(review.Issue.Repo, review.Reviewer, reviewHookType). + WithRef(review.CommitID). + WithPayload(&api.PullRequestPayload{ + Action: api.HookIssueReviewed, + Index: review.Issue.Index, + PullRequest: convert.ToAPIPullRequest(db.DefaultContext, pr, nil), + Repository: convert.ToRepo(ctx, review.Issue.Repo, mode), + Sender: convert.ToUser(review.Reviewer, nil), + Review: &api.ReviewPayload{ + Type: string(reviewHookType), + Content: review.Content, + }, + }).Notify(ctx) +} + +func (*actionsNotifier) NotifyMergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) { + ctx = withMethod(ctx, "NotifyMergePullRequest") + + // Reload pull request information. + if err := pr.LoadAttributes(ctx); err != nil { + log.Error("LoadAttributes: %v", err) + return + } + + if err := pr.LoadIssue(ctx); err != nil { + log.Error("LoadAttributes: %v", err) + return + } + + if err := pr.Issue.LoadRepo(db.DefaultContext); err != nil { + log.Error("pr.Issue.LoadRepo: %v", err) + return + } + + mode, err := access_model.AccessLevel(ctx, doer, pr.Issue.Repo) + if err != nil { + log.Error("models.AccessLevel: %v", err) + return + } + + // Merge pull request calls issue.changeStatus so we need to handle separately. + apiPullRequest := &api.PullRequestPayload{ + Index: pr.Issue.Index, + PullRequest: convert.ToAPIPullRequest(db.DefaultContext, pr, nil), + Repository: convert.ToRepo(ctx, pr.Issue.Repo, mode), + Sender: convert.ToUser(doer, nil), + Action: api.HookIssueClosed, + } + + newNotifyInput(pr.Issue.Repo, doer, webhook_module.HookEventPullRequest). + WithRef(pr.MergedCommitID). + WithPayload(apiPullRequest). + WithPullRequest(pr). + Notify(ctx) +} + +func (n *actionsNotifier) NotifyPushCommits(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, opts *repository.PushUpdateOptions, commits *repository.PushCommits) { + ctx = withMethod(ctx, "NotifyPushCommits") + + apiPusher := convert.ToUser(pusher, nil) + apiCommits, apiHeadCommit, err := commits.ToAPIPayloadCommits(ctx, repo.RepoPath(), repo.HTMLURL()) + if err != nil { + log.Error("commits.ToAPIPayloadCommits failed: %v", err) + return + } + + newNotifyInput(repo, pusher, webhook_module.HookEventPush). + WithRef(opts.RefFullName). + WithPayload(&api.PushPayload{ + Ref: opts.RefFullName, + Before: opts.OldCommitID, + After: opts.NewCommitID, + CompareURL: setting.AppURL + commits.CompareURL, + Commits: apiCommits, + HeadCommit: apiHeadCommit, + Repo: convert.ToRepo(ctx, repo, perm_model.AccessModeOwner), + Pusher: apiPusher, + Sender: apiPusher, + }). + Notify(ctx) +} + +func (n *actionsNotifier) NotifyCreateRef(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, refType, refFullName, refID string) { + ctx = withMethod(ctx, "NotifyCreateRef") + + apiPusher := convert.ToUser(pusher, nil) + apiRepo := convert.ToRepo(ctx, repo, perm_model.AccessModeNone) + refName := git.RefEndName(refFullName) + + newNotifyInput(repo, pusher, webhook_module.HookEventCreate). + WithRef(refName). + WithPayload(&api.CreatePayload{ + Ref: refName, + Sha: refID, + RefType: refType, + Repo: apiRepo, + Sender: apiPusher, + }). + Notify(ctx) +} + +func (n *actionsNotifier) NotifyDeleteRef(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, refType, refFullName string) { + ctx = withMethod(ctx, "NotifyDeleteRef") + + apiPusher := convert.ToUser(pusher, nil) + apiRepo := convert.ToRepo(ctx, repo, perm_model.AccessModeNone) + refName := git.RefEndName(refFullName) + + newNotifyInput(repo, pusher, webhook_module.HookEventDelete). + WithRef(refName). + WithPayload(&api.DeletePayload{ + Ref: refName, + RefType: refType, + PusherType: api.PusherTypeUser, + Repo: apiRepo, + Sender: apiPusher, + }). + Notify(ctx) +} + +func (n *actionsNotifier) NotifySyncPushCommits(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, opts *repository.PushUpdateOptions, commits *repository.PushCommits) { + ctx = withMethod(ctx, "NotifySyncPushCommits") + + apiPusher := convert.ToUser(pusher, nil) + apiCommits, apiHeadCommit, err := commits.ToAPIPayloadCommits(db.DefaultContext, repo.RepoPath(), repo.HTMLURL()) + if err != nil { + log.Error("commits.ToAPIPayloadCommits failed: %v", err) + return + } + + newNotifyInput(repo, pusher, webhook_module.HookEventPush). + WithRef(opts.RefFullName). + WithPayload(&api.PushPayload{ + Ref: opts.RefFullName, + Before: opts.OldCommitID, + After: opts.NewCommitID, + CompareURL: setting.AppURL + commits.CompareURL, + Commits: apiCommits, + TotalCommits: commits.Len, + HeadCommit: apiHeadCommit, + Repo: convert.ToRepo(ctx, repo, perm_model.AccessModeOwner), + Pusher: apiPusher, + Sender: apiPusher, + }). + Notify(ctx) +} + +func (n *actionsNotifier) NotifySyncCreateRef(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, refType, refFullName, refID string) { + ctx = withMethod(ctx, "NotifySyncCreateRef") + n.NotifyCreateRef(ctx, pusher, repo, refType, refFullName, refID) +} + +func (n *actionsNotifier) NotifySyncDeleteRef(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, refType, refFullName string) { + ctx = withMethod(ctx, "NotifySyncDeleteRef") + n.NotifyDeleteRef(ctx, pusher, repo, refType, refFullName) +} + +func (n *actionsNotifier) NotifyNewRelease(ctx context.Context, rel *repo_model.Release) { + ctx = withMethod(ctx, "NotifyNewRelease") + notifyRelease(ctx, rel.Publisher, rel, rel.Sha1, api.HookReleasePublished) +} + +func (n *actionsNotifier) NotifyUpdateRelease(ctx context.Context, doer *user_model.User, rel *repo_model.Release) { + ctx = withMethod(ctx, "NotifyUpdateRelease") + notifyRelease(ctx, doer, rel, rel.Sha1, api.HookReleaseUpdated) +} + +func (n *actionsNotifier) NotifyDeleteRelease(ctx context.Context, doer *user_model.User, rel *repo_model.Release) { + ctx = withMethod(ctx, "NotifyDeleteRelease") + notifyRelease(ctx, doer, rel, rel.Sha1, api.HookReleaseDeleted) +} + +func (n *actionsNotifier) NotifyPackageCreate(ctx context.Context, doer *user_model.User, pd *packages_model.PackageDescriptor) { + ctx = withMethod(ctx, "NotifyPackageCreate") + notifyPackage(ctx, doer, pd, api.HookPackageCreated) +} + +func (n *actionsNotifier) NotifyPackageDelete(ctx context.Context, doer *user_model.User, pd *packages_model.PackageDescriptor) { + ctx = withMethod(ctx, "NotifyPackageDelete") + notifyPackage(ctx, doer, pd, api.HookPackageDeleted) +} + +func (n *actionsNotifier) NotifyAutoMergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) { + ctx = withMethod(ctx, "NotifyAutoMergePullRequest") + n.NotifyMergePullRequest(ctx, doer, pr) +} + +func (n *actionsNotifier) NotifyPullRequestSynchronized(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) { + ctx = withMethod(ctx, "NotifyPullRequestSynchronized") + + if err := pr.LoadIssue(ctx); err != nil { + log.Error("LoadAttributes: %v", err) + return + } + + if err := pr.Issue.LoadRepo(db.DefaultContext); err != nil { + log.Error("pr.Issue.LoadRepo: %v", err) + return + } + + newNotifyInput(pr.Issue.Repo, doer, webhook_module.HookEventPullRequestSync). + WithPayload(&api.PullRequestPayload{ + Action: api.HookIssueSynchronized, + Index: pr.Issue.Index, + PullRequest: convert.ToAPIPullRequest(ctx, pr, nil), + Repository: convert.ToRepo(ctx, pr.Issue.Repo, perm_model.AccessModeNone), + Sender: convert.ToUser(doer, nil), + }). + WithPullRequest(pr). + Notify(ctx) +} + +func (n *actionsNotifier) NotifyPullRequestChangeTargetBranch(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest, oldBranch string) { + ctx = withMethod(ctx, "NotifyPullRequestChangeTargetBranch") + + if err := pr.LoadIssue(ctx); err != nil { + log.Error("LoadAttributes: %v", err) + return + } + + if err := pr.Issue.LoadRepo(db.DefaultContext); err != nil { + log.Error("pr.Issue.LoadRepo: %v", err) + return + } + + mode, _ := access_model.AccessLevel(ctx, pr.Issue.Poster, pr.Issue.Repo) + newNotifyInput(pr.Issue.Repo, doer, webhook_module.HookEventPullRequest). + WithPayload(&api.PullRequestPayload{ + Action: api.HookIssueEdited, + Index: pr.Issue.Index, + Changes: &api.ChangesPayload{ + Ref: &api.ChangesFromPayload{ + From: oldBranch, + }, + }, + PullRequest: convert.ToAPIPullRequest(ctx, pr, nil), + Repository: convert.ToRepo(ctx, pr.Issue.Repo, mode), + Sender: convert.ToUser(doer, nil), + }). + WithPullRequest(pr). + Notify(ctx) +} diff --git a/services/actions/notifier_helper.go b/services/actions/notifier_helper.go new file mode 100644 index 0000000000..44df568cbe --- /dev/null +++ b/services/actions/notifier_helper.go @@ -0,0 +1,229 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "context" + "fmt" + "strings" + + actions_model "code.gitea.io/gitea/models/actions" + issues_model "code.gitea.io/gitea/models/issues" + packages_model "code.gitea.io/gitea/models/packages" + access_model "code.gitea.io/gitea/models/perm/access" + repo_model "code.gitea.io/gitea/models/repo" + unit_model "code.gitea.io/gitea/models/unit" + user_model "code.gitea.io/gitea/models/user" + actions_module "code.gitea.io/gitea/modules/actions" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/log" + api "code.gitea.io/gitea/modules/structs" + webhook_module "code.gitea.io/gitea/modules/webhook" + "code.gitea.io/gitea/services/convert" + + "github.com/nektos/act/pkg/jobparser" +) + +var methodCtxKey struct{} + +// withMethod sets the notification method that this context currently executes. +// Used for debugging/ troubleshooting purposes. +func withMethod(ctx context.Context, method string) context.Context { + // don't overwrite + if v := ctx.Value(methodCtxKey); v != nil { + if _, ok := v.(string); ok { + return ctx + } + } + return context.WithValue(ctx, methodCtxKey, method) +} + +// getMethod gets the notification method that this context currently executes. +// Default: "notify" +// Used for debugging/ troubleshooting purposes. +func getMethod(ctx context.Context) string { + if v := ctx.Value(methodCtxKey); v != nil { + if s, ok := v.(string); ok { + return s + } + } + return "notify" +} + +type notifyInput struct { + // required + Repo *repo_model.Repository + Doer *user_model.User + Event webhook_module.HookEventType + + // optional + Ref string + Payload api.Payloader + PullRequest *issues_model.PullRequest +} + +func newNotifyInput(repo *repo_model.Repository, doer *user_model.User, event webhook_module.HookEventType) *notifyInput { + return ¬ifyInput{ + Repo: repo, + Ref: repo.DefaultBranch, + Doer: doer, + Event: event, + } +} + +func (input *notifyInput) WithDoer(doer *user_model.User) *notifyInput { + input.Doer = doer + return input +} + +func (input *notifyInput) WithRef(ref string) *notifyInput { + input.Ref = ref + return input +} + +func (input *notifyInput) WithPayload(payload api.Payloader) *notifyInput { + input.Payload = payload + return input +} + +func (input *notifyInput) WithPullRequest(pr *issues_model.PullRequest) *notifyInput { + input.PullRequest = pr + return input +} + +func (input *notifyInput) Notify(ctx context.Context) { + log.Trace("execute %v for event %v whose doer is %v", getMethod(ctx), input.Event, input.Doer.Name) + + if err := notify(ctx, input); err != nil { + log.Error("an error occurred while executing the %s actions method: %v", getMethod(ctx), err) + } +} + +func notify(ctx context.Context, input *notifyInput) error { + if input.Doer.IsActions() { + // avoiding triggering cyclically, for example: + // a comment of an issue will trigger the runner to add a new comment as reply, + // and the new comment will trigger the runner again. + log.Debug("ignore executing %v for event %v whose doer is %v", getMethod(ctx), input.Event, input.Doer.Name) + return nil + } + if unit_model.TypeActions.UnitGlobalDisabled() { + return nil + } + if err := input.Repo.LoadUnits(ctx); err != nil { + return fmt.Errorf("repo.LoadUnits: %w", err) + } else if !input.Repo.UnitEnabled(ctx, unit_model.TypeActions) { + return nil + } + + gitRepo, err := git.OpenRepository(context.Background(), input.Repo.RepoPath()) + if err != nil { + return fmt.Errorf("git.OpenRepository: %w", err) + } + defer gitRepo.Close() + + // Get the commit object for the ref + commit, err := gitRepo.GetCommit(input.Ref) + if err != nil { + return fmt.Errorf("gitRepo.GetCommit: %w", err) + } + + workflows, err := actions_module.DetectWorkflows(commit, input.Event) + if err != nil { + return fmt.Errorf("DetectWorkflows: %w", err) + } + + if len(workflows) == 0 { + log.Trace("repo %s with commit %s couldn't find workflows", input.Repo.RepoPath(), commit.ID) + return nil + } + + p, err := json.Marshal(input.Payload) + if err != nil { + return fmt.Errorf("json.Marshal: %w", err) + } + + for id, content := range workflows { + run := actions_model.ActionRun{ + Title: strings.SplitN(commit.CommitMessage, "\n", 2)[0], + RepoID: input.Repo.ID, + OwnerID: input.Repo.OwnerID, + WorkflowID: id, + TriggerUserID: input.Doer.ID, + Ref: input.Ref, + CommitSHA: commit.ID.String(), + IsForkPullRequest: input.PullRequest != nil && input.PullRequest.IsFromFork(), + Event: input.Event, + EventPayload: string(p), + Status: actions_model.StatusWaiting, + } + jobs, err := jobparser.Parse(content) + if err != nil { + log.Error("jobparser.Parse: %v", err) + continue + } + if err := actions_model.InsertRun(ctx, &run, jobs); err != nil { + log.Error("InsertRun: %v", err) + continue + } + if jobs, _, err := actions_model.FindRunJobs(ctx, actions_model.FindRunJobOptions{RunID: run.ID}); err != nil { + log.Error("FindRunJobs: %v", err) + } else { + for _, job := range jobs { + if err := CreateCommitStatus(ctx, job); err != nil { + log.Error("CreateCommitStatus: %v", err) + } + } + } + + } + return nil +} + +func newNotifyInputFromIssue(issue *issues_model.Issue, event webhook_module.HookEventType) *notifyInput { + return newNotifyInput(issue.Repo, issue.Poster, event) +} + +func notifyRelease(ctx context.Context, doer *user_model.User, rel *repo_model.Release, ref string, action api.HookReleaseAction) { + if err := rel.LoadAttributes(ctx); err != nil { + log.Error("LoadAttributes: %v", err) + return + } + + mode, _ := access_model.AccessLevel(ctx, doer, rel.Repo) + + newNotifyInput(rel.Repo, doer, webhook_module.HookEventRelease). + WithRef(ref). + WithPayload(&api.ReleasePayload{ + Action: action, + Release: convert.ToRelease(rel), + Repository: convert.ToRepo(ctx, rel.Repo, mode), + Sender: convert.ToUser(doer, nil), + }). + Notify(ctx) +} + +func notifyPackage(ctx context.Context, sender *user_model.User, pd *packages_model.PackageDescriptor, action api.HookPackageAction) { + if pd.Repository == nil { + // When a package is uploaded to an organization, it could trigger an event to notify. + // So the repository could be nil, however, actions can't support that yet. + // See https://github.com/go-gitea/gitea/pull/17940 + return + } + + apiPackage, err := convert.ToPackage(ctx, pd, sender) + if err != nil { + log.Error("Error converting package: %v", err) + return + } + + newNotifyInput(pd.Repository, sender, webhook_module.HookEventPackage). + WithPayload(&api.PackagePayload{ + Action: action, + Package: apiPackage, + Sender: convert.ToUser(sender, nil), + }). + Notify(ctx) +} diff --git a/services/auth/basic.go b/services/auth/basic.go index 5fb80703ab..dc03780905 100644 --- a/services/auth/basic.go +++ b/services/auth/basic.go @@ -8,6 +8,7 @@ import ( "net/http" "strings" + actions_model "code.gitea.io/gitea/models/actions" auth_model "code.gitea.io/gitea/models/auth" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/base" @@ -70,6 +71,7 @@ func (b *Basic) Verify(req *http.Request, w http.ResponseWriter, store DataStore log.Trace("Basic Authorization: Attempting login with username as token") } + // check oauth2 token uid := CheckOAuthAccessToken(authToken) if uid != 0 { log.Trace("Basic Authorization: Valid OAuthAccessToken for user[%d]", uid) @@ -84,6 +86,7 @@ func (b *Basic) Verify(req *http.Request, w http.ResponseWriter, store DataStore return u, nil } + // check personal access token token, err := auth_model.GetAccessTokenBySHA(authToken) if err == nil { log.Trace("Basic Authorization: Valid AccessToken for user[%d]", uid) @@ -104,6 +107,17 @@ func (b *Basic) Verify(req *http.Request, w http.ResponseWriter, store DataStore log.Error("GetAccessTokenBySha: %v", err) } + // check task token + task, err := actions_model.GetRunningTaskByToken(req.Context(), authToken) + if err == nil && task != nil { + log.Trace("Basic Authorization: Valid AccessToken for task[%d]", task.ID) + + store.GetData()["IsActionsToken"] = true + store.GetData()["ActionsTaskID"] = task.ID + + return user_model.NewActionsUser(), nil + } + if !setting.Service.EnableBasicAuth { return nil, nil } diff --git a/services/auth/oauth2.go b/services/auth/oauth2.go index 1be78b85c5..b70f84da9b 100644 --- a/services/auth/oauth2.go +++ b/services/auth/oauth2.go @@ -9,6 +9,7 @@ import ( "strings" "time" + actions_model "code.gitea.io/gitea/models/actions" auth_model "code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/models/db" user_model "code.gitea.io/gitea/models/user" @@ -94,7 +95,18 @@ func (o *OAuth2) userIDFromToken(req *http.Request, store DataStore) int64 { } t, err := auth_model.GetAccessTokenBySHA(tokenSHA) if err != nil { - if !auth_model.IsErrAccessTokenNotExist(err) && !auth_model.IsErrAccessTokenEmpty(err) { + if auth_model.IsErrAccessTokenNotExist(err) { + // check task token + task, err := actions_model.GetRunningTaskByToken(db.DefaultContext, tokenSHA) + if err == nil && task != nil { + log.Trace("Basic Authorization: Valid AccessToken for task[%d]", task.ID) + + store.GetData()["IsActionsToken"] = true + store.GetData()["ActionsTaskID"] = task.ID + + return user_model.ActionsUserID + } + } else if !auth_model.IsErrAccessTokenNotExist(err) && !auth_model.IsErrAccessTokenEmpty(err) { log.Error("GetAccessTokenBySHA: %v", err) } return 0 @@ -118,12 +130,13 @@ func (o *OAuth2) Verify(req *http.Request, w http.ResponseWriter, store DataStor } id := o.userIDFromToken(req, store) - if id <= 0 { + + if id <= 0 && id != -2 { // -2 means actions, so we need to allow it. return nil, nil } log.Trace("OAuth2 Authorization: Found token for user[%d]", id) - user, err := user_model.GetUserByID(req.Context(), id) + user, err := user_model.GetPossibleUserByID(req.Context(), id) if err != nil { if !user_model.IsErrUserNotExist(err) { log.Error("GetUserByName: %v", err) diff --git a/services/cron/cron.go b/services/cron/cron.go index bda8f12f12..72deb94ceb 100644 --- a/services/cron/cron.go +++ b/services/cron/cron.go @@ -30,6 +30,7 @@ func NewContext(original context.Context) { _, _, finished := process.GetManager().AddTypedContext(graceful.GetManager().ShutdownContext(), "Service: Cron", process.SystemProcessType, true) initBasicTasks() initExtendedTasks() + initActionsTasks() lock.Lock() for _, task := range tasks { diff --git a/services/cron/tasks_actions.go b/services/cron/tasks_actions.go new file mode 100644 index 0000000000..30e8749a5e --- /dev/null +++ b/services/cron/tasks_actions.go @@ -0,0 +1,51 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cron + +import ( + "context" + + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" + actions_service "code.gitea.io/gitea/services/actions" +) + +func initActionsTasks() { + if !setting.Actions.Enabled { + return + } + registerStopZombieTasks() + registerStopEndlessTasks() + registerCancelAbandonedJobs() +} + +func registerStopZombieTasks() { + RegisterTaskFatal("stop_zombie_tasks", &BaseConfig{ + Enabled: true, + RunAtStart: true, + Schedule: "@every 5m", + }, func(ctx context.Context, _ *user_model.User, cfg Config) error { + return actions_service.StopZombieTasks(ctx) + }) +} + +func registerStopEndlessTasks() { + RegisterTaskFatal("stop_endless_tasks", &BaseConfig{ + Enabled: true, + RunAtStart: true, + Schedule: "@every 30m", + }, func(ctx context.Context, _ *user_model.User, cfg Config) error { + return actions_service.StopEndlessTasks(ctx) + }) +} + +func registerCancelAbandonedJobs() { + RegisterTaskFatal("cancel_abandoned_jobs", &BaseConfig{ + Enabled: true, + RunAtStart: true, + Schedule: "@every 6h", + }, func(ctx context.Context, _ *user_model.User, cfg Config) error { + return actions_service.CancelAbandonedJobs(ctx) + }) +} diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index b7687af2b5..c084e65600 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -148,6 +148,7 @@ type RepoSettingForm struct { EnableProjects bool EnablePackages bool EnablePulls bool + EnableActions bool PullsIgnoreWhitespace bool PullsAllowMerge bool PullsAllowRebase bool diff --git a/services/forms/runner.go b/services/forms/runner.go new file mode 100644 index 0000000000..9063060346 --- /dev/null +++ b/services/forms/runner.go @@ -0,0 +1,25 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package forms + +import ( + "net/http" + + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/web/middleware" + + "gitea.com/go-chi/binding" +) + +// EditRunnerForm form for admin to create runner +type EditRunnerForm struct { + Description string + CustomLabels string // comma-separated +} + +// Validate validates form fields +func (f *EditRunnerForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { + ctx := context.GetContext(req) + return middleware.Validate(errs, ctx.Data, f, ctx.Locale) +} diff --git a/services/repository/push.go b/services/repository/push.go index 0135243388..ef6460cef4 100644 --- a/services/repository/push.go +++ b/services/repository/push.go @@ -110,9 +110,13 @@ func pushUpdates(optsList []*repo_module.PushUpdateOptions) error { } if opts.IsTag() { // If is tag reference if pusher == nil || pusher.ID != opts.PusherID { - var err error - if pusher, err = user_model.GetUserByID(ctx, opts.PusherID); err != nil { - return err + if opts.PusherID == user_model.ActionsUserID { + pusher = user_model.NewActionsUser() + } else { + var err error + if pusher, err = user_model.GetUserByID(ctx, opts.PusherID); err != nil { + return err + } } } tagName := opts.TagName() @@ -150,9 +154,13 @@ func pushUpdates(optsList []*repo_module.PushUpdateOptions) error { } } else if opts.IsBranch() { // If is branch reference if pusher == nil || pusher.ID != opts.PusherID { - var err error - if pusher, err = user_model.GetUserByID(ctx, opts.PusherID); err != nil { - return err + if opts.PusherID == user_model.ActionsUserID { + pusher = user_model.NewActionsUser() + } else { + var err error + if pusher, err = user_model.GetUserByID(ctx, opts.PusherID); err != nil { + return err + } } } |