1
1
mirror of https://github.com/go-gitea/gitea synced 2025-08-05 00:58:19 +00:00

Add workflow_run api + webhook (#33964)

Implements 
- https://docs.github.com/en/rest/actions/workflow-jobs?apiVersion=2022-11-28#list-jobs-for-a-workflow-run--code-samples
- https://docs.github.com/en/rest/actions/workflow-jobs?apiVersion=2022-11-28#get-a-job-for-a-workflow-run--code-samples
- https://docs.github.com/en/rest/actions/workflow-runs?apiVersion=2022-11-28#list-workflow-runs-for-a-repository
- https://docs.github.com/en/rest/actions/workflow-runs?apiVersion=2022-11-28#get-a-workflow-run
  - `/actions/runs` for global + user + org (Gitea only)
  - `/actions/jobs` for global + user + org + repository (Gitea only)
  - workflow_run webhook + action trigger
    - limitations
- workflow id is assigned to a string, this may result into problems in
strongly typed clients

Fixes
- workflow_job webhook url to no longer contain the `runs/<run>` part to
align with api
- workflow instance does now use it's name inside the file instead of
filename if set

Refactoring
- Moved a lot of logic from workflows/workflow_job into a shared module
used by both webhook and api

TODO
- [x] Verify Keda Compatibility
- [x] Edit Webhook API bug is resolved
 
Closes https://github.com/go-gitea/gitea/issues/23670
Closes https://github.com/go-gitea/gitea/issues/23796
Closes https://github.com/go-gitea/gitea/issues/24898
Replaces https://github.com/go-gitea/gitea/pull/28047 and is much more
complete

---------

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
ChristopherHX
2025-06-20 14:14:00 +02:00
committed by GitHub
parent d462ce149d
commit cda90eca31
51 changed files with 2815 additions and 235 deletions

View File

@@ -42,6 +42,10 @@ func notifyWorkflowJobStatusUpdate(ctx context.Context, jobs []*actions_model.Ac
_ = job.LoadAttributes(ctx)
notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil)
}
if len(jobs) > 0 {
job := jobs[0]
notify_service.WorkflowRunStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job.Run)
}
}
}
@@ -123,7 +127,7 @@ func CancelAbandonedJobs(ctx context.Context) error {
}
CreateCommitStatus(ctx, job)
if updated {
_ = job.LoadAttributes(ctx)
NotifyWorkflowRunStatusUpdateWithReload(ctx, job)
notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil)
}
}

View File

@@ -33,4 +33,8 @@ type API interface {
GetRunner(*context.APIContext)
// DeleteRunner delete runner
DeleteRunner(*context.APIContext)
// ListWorkflowJobs list jobs
ListWorkflowJobs(*context.APIContext)
// ListWorkflowRuns list runs
ListWorkflowRuns(*context.APIContext)
}

View File

@@ -11,6 +11,7 @@ import (
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/log"
"code.gitea.io/gitea/modules/queue"
notify_service "code.gitea.io/gitea/services/notify"
@@ -78,9 +79,30 @@ func checkJobsOfRun(ctx context.Context, runID int64) error {
_ = job.LoadAttributes(ctx)
notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil)
}
if len(jobs) > 0 {
runUpdated := true
for _, job := range jobs {
if !job.Status.IsDone() {
runUpdated = false
break
}
}
if runUpdated {
NotifyWorkflowRunStatusUpdateWithReload(ctx, jobs[0])
}
}
return nil
}
func NotifyWorkflowRunStatusUpdateWithReload(ctx context.Context, job *actions_model.ActionRunJob) {
job.Run = nil
if err := job.LoadAttributes(ctx); err != nil {
log.Error("LoadAttributes: %v", err)
return
}
notify_service.WorkflowRunStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job.Run)
}
type jobStatusResolver struct {
statuses map[int64]actions_model.Status
needs map[int64][]int64

View File

@@ -6,13 +6,16 @@ package actions
import (
"context"
actions_model "code.gitea.io/gitea/models/actions"
issues_model "code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/models/organization"
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/gitrepo"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/repository"
"code.gitea.io/gitea/modules/setting"
@@ -762,3 +765,41 @@ func (n *actionsNotifier) MigrateRepository(ctx context.Context, doer, u *user_m
Sender: convert.ToUser(ctx, doer, nil),
}).Notify(ctx)
}
func (n *actionsNotifier) WorkflowRunStatusUpdate(ctx context.Context, repo *repo_model.Repository, sender *user_model.User, run *actions_model.ActionRun) {
ctx = withMethod(ctx, "WorkflowRunStatusUpdate")
var org *api.Organization
if repo.Owner.IsOrganization() {
org = convert.ToOrganization(ctx, organization.OrgFromUser(repo.Owner))
}
status := convert.ToWorkflowRunAction(run.Status)
gitRepo, err := gitrepo.OpenRepository(ctx, repo)
if err != nil {
log.Error("OpenRepository: %v", err)
return
}
defer gitRepo.Close()
convertedWorkflow, err := convert.GetActionWorkflow(ctx, gitRepo, repo, run.WorkflowID)
if err != nil {
log.Error("GetActionWorkflow: %v", err)
return
}
convertedRun, err := convert.ToActionWorkflowRun(ctx, repo, run)
if err != nil {
log.Error("ToActionWorkflowRun: %v", err)
return
}
newNotifyInput(repo, sender, webhook_module.HookEventWorkflowRun).WithPayload(&api.WorkflowRunPayload{
Action: status,
Workflow: convertedWorkflow,
WorkflowRun: convertedRun,
Organization: org,
Repo: convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm_model.AccessModeOwner}),
Sender: convert.ToUser(ctx, sender, nil),
}).Notify(ctx)
}

View File

@@ -178,7 +178,7 @@ func notify(ctx context.Context, input *notifyInput) error {
return fmt.Errorf("gitRepo.GetCommit: %w", err)
}
if skipWorkflows(input, commit) {
if skipWorkflows(ctx, input, commit) {
return nil
}
@@ -243,7 +243,7 @@ func notify(ctx context.Context, input *notifyInput) error {
return handleWorkflows(ctx, detectedWorkflows, commit, input, ref.String())
}
func skipWorkflows(input *notifyInput, commit *git.Commit) bool {
func skipWorkflows(ctx context.Context, input *notifyInput, commit *git.Commit) bool {
// skip workflow runs with a configured skip-ci string in commit message or pr title if the event is push or pull_request(_sync)
// https://docs.github.com/en/actions/managing-workflow-runs/skipping-workflow-runs
skipWorkflowEvents := []webhook_module.HookEventType{
@@ -263,6 +263,27 @@ func skipWorkflows(input *notifyInput, commit *git.Commit) bool {
}
}
}
if input.Event == webhook_module.HookEventWorkflowRun {
wrun, ok := input.Payload.(*api.WorkflowRunPayload)
for i := 0; i < 5 && ok && wrun.WorkflowRun != nil; i++ {
if wrun.WorkflowRun.Event != "workflow_run" {
return false
}
r, err := actions_model.GetRunByRepoAndID(ctx, input.Repo.ID, wrun.WorkflowRun.ID)
if err != nil {
log.Error("GetRunByRepoAndID: %v", err)
return true
}
wrun, err = r.GetWorkflowRunEventPayload()
if err != nil {
log.Error("GetWorkflowRunEventPayload: %v", err)
return true
}
}
// skip workflow runs events exceeding the maxiumum of 5 recursive events
log.Debug("repo %s: skipped workflow_run because of recursive event of 5", input.Repo.RepoPath())
return true
}
return false
}
@@ -372,6 +393,15 @@ func handleWorkflows(
continue
}
CreateCommitStatus(ctx, alljobs...)
if len(alljobs) > 0 {
job := alljobs[0]
err := job.LoadRun(ctx)
if err != nil {
log.Error("LoadRun: %v", err)
continue
}
notify_service.WorkflowRunStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job.Run)
}
for _, job := range alljobs {
notify_service.WorkflowJobStatusUpdate(ctx, input.Repo, input.Doer, job, nil)
}

View File

@@ -157,6 +157,7 @@ func CreateScheduleTask(ctx context.Context, cron *actions_model.ActionSchedule)
if err != nil {
log.Error("LoadAttributes: %v", err)
}
notify_service.WorkflowRunStatusUpdate(ctx, run.Repo, run.TriggerUser, run)
for _, job := range allJobs {
notify_service.WorkflowJobStatusUpdate(ctx, run.Repo, run.TriggerUser, job, nil)
}

View File

@@ -5,9 +5,6 @@ package actions
import (
"fmt"
"net/http"
"net/url"
"path"
"strings"
actions_model "code.gitea.io/gitea/models/actions"
@@ -31,51 +28,8 @@ import (
"github.com/nektos/act/pkg/model"
)
func getActionWorkflowEntry(ctx *context.APIContext, commit *git.Commit, folder string, entry *git.TreeEntry) *api.ActionWorkflow {
cfgUnit := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeActions)
cfg := cfgUnit.ActionsConfig()
defaultBranch, _ := commit.GetBranchName()
workflowURL := fmt.Sprintf("%s/actions/workflows/%s", ctx.Repo.Repository.APIURL(), url.PathEscape(entry.Name()))
workflowRepoURL := fmt.Sprintf("%s/src/branch/%s/%s/%s", ctx.Repo.Repository.HTMLURL(ctx), util.PathEscapeSegments(defaultBranch), util.PathEscapeSegments(folder), url.PathEscape(entry.Name()))
badgeURL := fmt.Sprintf("%s/actions/workflows/%s/badge.svg?branch=%s", ctx.Repo.Repository.HTMLURL(ctx), url.PathEscape(entry.Name()), url.QueryEscape(ctx.Repo.Repository.DefaultBranch))
// See https://docs.github.com/en/rest/actions/workflows?apiVersion=2022-11-28#get-a-workflow
// State types:
// - active
// - deleted
// - disabled_fork
// - disabled_inactivity
// - disabled_manually
state := "active"
if cfg.IsWorkflowDisabled(entry.Name()) {
state = "disabled_manually"
}
// The CreatedAt and UpdatedAt fields currently reflect the timestamp of the latest commit, which can later be refined
// by retrieving the first and last commits for the file history. The first commit would indicate the creation date,
// while the last commit would represent the modification date. The DeletedAt could be determined by identifying
// the last commit where the file existed. However, this implementation has not been done here yet, as it would likely
// cause a significant performance degradation.
createdAt := commit.Author.When
updatedAt := commit.Author.When
return &api.ActionWorkflow{
ID: entry.Name(),
Name: entry.Name(),
Path: path.Join(folder, entry.Name()),
State: state,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
URL: workflowURL,
HTMLURL: workflowRepoURL,
BadgeURL: badgeURL,
}
}
func EnableOrDisableWorkflow(ctx *context.APIContext, workflowID string, isEnable bool) error {
workflow, err := GetActionWorkflow(ctx, workflowID)
workflow, err := convert.GetActionWorkflow(ctx, ctx.Repo.GitRepo, ctx.Repo.Repository, workflowID)
if err != nil {
return err
}
@@ -92,42 +46,6 @@ func EnableOrDisableWorkflow(ctx *context.APIContext, workflowID string, isEnabl
return repo_model.UpdateRepoUnit(ctx, cfgUnit)
}
func ListActionWorkflows(ctx *context.APIContext) ([]*api.ActionWorkflow, error) {
defaultBranchCommit, err := ctx.Repo.GitRepo.GetBranchCommit(ctx.Repo.Repository.DefaultBranch)
if err != nil {
ctx.APIErrorInternal(err)
return nil, err
}
folder, entries, err := actions.ListWorkflows(defaultBranchCommit)
if err != nil {
ctx.APIError(http.StatusNotFound, err.Error())
return nil, err
}
workflows := make([]*api.ActionWorkflow, len(entries))
for i, entry := range entries {
workflows[i] = getActionWorkflowEntry(ctx, defaultBranchCommit, folder, entry)
}
return workflows, nil
}
func GetActionWorkflow(ctx *context.APIContext, workflowID string) (*api.ActionWorkflow, error) {
entries, err := ListActionWorkflows(ctx)
if err != nil {
return nil, err
}
for _, entry := range entries {
if entry.Name == workflowID {
return entry, nil
}
}
return nil, util.NewNotExistErrorf("workflow %q not found", workflowID)
}
func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, repo *repo_model.Repository, gitRepo *git.Repository, workflowID, ref string, processInputs func(model *model.WorkflowDispatch, inputs map[string]any) error) error {
if workflowID == "" {
return util.ErrorWrapLocale(
@@ -285,6 +203,15 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re
log.Error("FindRunJobs: %v", err)
}
CreateCommitStatus(ctx, allJobs...)
if len(allJobs) > 0 {
job := allJobs[0]
err := job.LoadRun(ctx)
if err != nil {
log.Error("LoadRun: %v", err)
} else {
notify_service.WorkflowRunStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job.Run)
}
}
for _, job := range allJobs {
notify_service.WorkflowJobStatusUpdate(ctx, repo, doer, job, nil)
}

View File

@@ -12,6 +12,8 @@ import (
)
// FormString returns the first value matching the provided key in the form as a string
// It works the same as http.Request.FormValue:
// try urlencoded request body first, then query string, then multipart form body
func (b *Base) FormString(key string, def ...string) string {
s := b.Req.FormValue(key)
if s == "" {
@@ -20,7 +22,7 @@ func (b *Base) FormString(key string, def ...string) string {
return s
}
// FormStrings returns a string slice for the provided key from the form
// FormStrings returns a values for the key in the form (including query parameters), similar to FormString
func (b *Base) FormStrings(key string) []string {
if b.Req.Form == nil {
if err := b.Req.ParseMultipartForm(32 << 20); err != nil {

View File

@@ -5,8 +5,11 @@
package convert
import (
"bytes"
"context"
"fmt"
"net/url"
"path"
"strconv"
"strings"
"time"
@@ -14,6 +17,7 @@ import (
actions_model "code.gitea.io/gitea/models/actions"
asymkey_model "code.gitea.io/gitea/models/asymkey"
"code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/db"
git_model "code.gitea.io/gitea/models/git"
issues_model "code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/models/organization"
@@ -22,6 +26,7 @@ import (
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unit"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/actions"
"code.gitea.io/gitea/modules/container"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/log"
@@ -32,6 +37,7 @@ import (
"code.gitea.io/gitea/services/gitdiff"
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
"github.com/nektos/act/pkg/model"
)
// ToEmail convert models.EmailAddress to api.Email
@@ -241,6 +247,242 @@ func ToActionTask(ctx context.Context, t *actions_model.ActionTask) (*api.Action
}, nil
}
func ToActionWorkflowRun(ctx context.Context, repo *repo_model.Repository, run *actions_model.ActionRun) (*api.ActionWorkflowRun, error) {
err := run.LoadAttributes(ctx)
if err != nil {
return nil, err
}
status, conclusion := ToActionsStatus(run.Status)
return &api.ActionWorkflowRun{
ID: run.ID,
URL: fmt.Sprintf("%s/actions/runs/%d", repo.APIURL(), run.ID),
HTMLURL: run.HTMLURL(),
RunNumber: run.Index,
StartedAt: run.Started.AsLocalTime(),
CompletedAt: run.Stopped.AsLocalTime(),
Event: string(run.Event),
DisplayTitle: run.Title,
HeadBranch: git.RefName(run.Ref).BranchName(),
HeadSha: run.CommitSHA,
Status: status,
Conclusion: conclusion,
Path: fmt.Sprintf("%s@%s", run.WorkflowID, run.Ref),
Repository: ToRepo(ctx, repo, access_model.Permission{AccessMode: perm.AccessModeNone}),
TriggerActor: ToUser(ctx, run.TriggerUser, nil),
// We do not have a way to get a different User for the actor than the trigger user
Actor: ToUser(ctx, run.TriggerUser, nil),
}, nil
}
func ToWorkflowRunAction(status actions_model.Status) string {
var action string
switch status {
case actions_model.StatusWaiting, actions_model.StatusBlocked:
action = "requested"
case actions_model.StatusRunning:
action = "in_progress"
}
if status.IsDone() {
action = "completed"
}
return action
}
func ToActionsStatus(status actions_model.Status) (string, string) {
var action string
var conclusion string
switch status {
// This is a naming conflict of the webhook between Gitea and GitHub Actions
case actions_model.StatusWaiting:
action = "queued"
case actions_model.StatusBlocked:
action = "waiting"
case actions_model.StatusRunning:
action = "in_progress"
}
if status.IsDone() {
action = "completed"
switch status {
case actions_model.StatusSuccess:
conclusion = "success"
case actions_model.StatusCancelled:
conclusion = "cancelled"
case actions_model.StatusFailure:
conclusion = "failure"
case actions_model.StatusSkipped:
conclusion = "skipped"
}
}
return action, conclusion
}
// ToActionWorkflowJob convert a actions_model.ActionRunJob to an api.ActionWorkflowJob
// task is optional and can be nil
func ToActionWorkflowJob(ctx context.Context, repo *repo_model.Repository, task *actions_model.ActionTask, job *actions_model.ActionRunJob) (*api.ActionWorkflowJob, error) {
err := job.LoadAttributes(ctx)
if err != nil {
return nil, err
}
jobIndex := 0
jobs, err := actions_model.GetRunJobsByRunID(ctx, job.RunID)
if err != nil {
return nil, err
}
for i, j := range jobs {
if j.ID == job.ID {
jobIndex = i
break
}
}
status, conclusion := ToActionsStatus(job.Status)
var runnerID int64
var runnerName string
var steps []*api.ActionWorkflowStep
if job.TaskID != 0 {
if task == nil {
task, _, err = db.GetByID[actions_model.ActionTask](ctx, job.TaskID)
if err != nil {
return nil, err
}
}
runnerID = task.RunnerID
if runner, ok, _ := db.GetByID[actions_model.ActionRunner](ctx, runnerID); ok {
runnerName = runner.Name
}
for i, step := range task.Steps {
stepStatus, stepConclusion := ToActionsStatus(job.Status)
steps = append(steps, &api.ActionWorkflowStep{
Name: step.Name,
Number: int64(i),
Status: stepStatus,
Conclusion: stepConclusion,
StartedAt: step.Started.AsTime().UTC(),
CompletedAt: step.Stopped.AsTime().UTC(),
})
}
}
return &api.ActionWorkflowJob{
ID: job.ID,
// missing api endpoint for this location
URL: fmt.Sprintf("%s/actions/jobs/%d", repo.APIURL(), job.ID),
HTMLURL: fmt.Sprintf("%s/jobs/%d", job.Run.HTMLURL(), jobIndex),
RunID: job.RunID,
// Missing api endpoint for this location, artifacts are available under a nested url
RunURL: fmt.Sprintf("%s/actions/runs/%d", repo.APIURL(), job.RunID),
Name: job.Name,
Labels: job.RunsOn,
RunAttempt: job.Attempt,
HeadSha: job.Run.CommitSHA,
HeadBranch: git.RefName(job.Run.Ref).BranchName(),
Status: status,
Conclusion: conclusion,
RunnerID: runnerID,
RunnerName: runnerName,
Steps: steps,
CreatedAt: job.Created.AsTime().UTC(),
StartedAt: job.Started.AsTime().UTC(),
CompletedAt: job.Stopped.AsTime().UTC(),
}, nil
}
func getActionWorkflowEntry(ctx context.Context, repo *repo_model.Repository, commit *git.Commit, folder string, entry *git.TreeEntry) *api.ActionWorkflow {
cfgUnit := repo.MustGetUnit(ctx, unit.TypeActions)
cfg := cfgUnit.ActionsConfig()
defaultBranch, _ := commit.GetBranchName()
workflowURL := fmt.Sprintf("%s/actions/workflows/%s", repo.APIURL(), util.PathEscapeSegments(entry.Name()))
workflowRepoURL := fmt.Sprintf("%s/src/branch/%s/%s/%s", repo.HTMLURL(ctx), util.PathEscapeSegments(defaultBranch), util.PathEscapeSegments(folder), util.PathEscapeSegments(entry.Name()))
badgeURL := fmt.Sprintf("%s/actions/workflows/%s/badge.svg?branch=%s", repo.HTMLURL(ctx), util.PathEscapeSegments(entry.Name()), url.QueryEscape(repo.DefaultBranch))
// See https://docs.github.com/en/rest/actions/workflows?apiVersion=2022-11-28#get-a-workflow
// State types:
// - active
// - deleted
// - disabled_fork
// - disabled_inactivity
// - disabled_manually
state := "active"
if cfg.IsWorkflowDisabled(entry.Name()) {
state = "disabled_manually"
}
// The CreatedAt and UpdatedAt fields currently reflect the timestamp of the latest commit, which can later be refined
// by retrieving the first and last commits for the file history. The first commit would indicate the creation date,
// while the last commit would represent the modification date. The DeletedAt could be determined by identifying
// the last commit where the file existed. However, this implementation has not been done here yet, as it would likely
// cause a significant performance degradation.
createdAt := commit.Author.When
updatedAt := commit.Author.When
content, err := actions.GetContentFromEntry(entry)
name := entry.Name()
if err == nil {
workflow, err := model.ReadWorkflow(bytes.NewReader(content))
if err == nil {
// Only use the name when specified in the workflow file
if workflow.Name != "" {
name = workflow.Name
}
} else {
log.Error("getActionWorkflowEntry: Failed to parse workflow: %v", err)
}
} else {
log.Error("getActionWorkflowEntry: Failed to get content from entry: %v", err)
}
return &api.ActionWorkflow{
ID: entry.Name(),
Name: name,
Path: path.Join(folder, entry.Name()),
State: state,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
URL: workflowURL,
HTMLURL: workflowRepoURL,
BadgeURL: badgeURL,
}
}
func ListActionWorkflows(ctx context.Context, gitrepo *git.Repository, repo *repo_model.Repository) ([]*api.ActionWorkflow, error) {
defaultBranchCommit, err := gitrepo.GetBranchCommit(repo.DefaultBranch)
if err != nil {
return nil, err
}
folder, entries, err := actions.ListWorkflows(defaultBranchCommit)
if err != nil {
return nil, err
}
workflows := make([]*api.ActionWorkflow, len(entries))
for i, entry := range entries {
workflows[i] = getActionWorkflowEntry(ctx, repo, defaultBranchCommit, folder, entry)
}
return workflows, nil
}
func GetActionWorkflow(ctx context.Context, gitrepo *git.Repository, repo *repo_model.Repository, workflowID string) (*api.ActionWorkflow, error) {
entries, err := ListActionWorkflows(ctx, gitrepo, repo)
if err != nil {
return nil, err
}
for _, entry := range entries {
if entry.ID == workflowID {
return entry, nil
}
}
return nil, util.NewNotExistErrorf("workflow %q not found", workflowID)
}
// ToActionArtifact convert a actions_model.ActionArtifact to an api.ActionArtifact
func ToActionArtifact(repo *repo_model.Repository, art *actions_model.ActionArtifact) (*api.ActionArtifact, error) {
url := fmt.Sprintf("%s/actions/artifacts/%d", repo.APIURL(), art.ID)

View File

@@ -234,6 +234,7 @@ type WebhookForm struct {
Release bool
Package bool
Status bool
WorkflowRun bool
WorkflowJob bool
Active bool
BranchFilter string `binding:"GlobPattern"`

View File

@@ -79,5 +79,7 @@ type Notifier interface {
CreateCommitStatus(ctx context.Context, repo *repo_model.Repository, commit *repository.PushCommit, sender *user_model.User, status *git_model.CommitStatus)
WorkflowRunStatusUpdate(ctx context.Context, repo *repo_model.Repository, sender *user_model.User, run *actions_model.ActionRun)
WorkflowJobStatusUpdate(ctx context.Context, repo *repo_model.Repository, sender *user_model.User, job *actions_model.ActionRunJob, task *actions_model.ActionTask)
}

View File

@@ -376,6 +376,12 @@ func CreateCommitStatus(ctx context.Context, repo *repo_model.Repository, commit
}
}
func WorkflowRunStatusUpdate(ctx context.Context, repo *repo_model.Repository, sender *user_model.User, run *actions_model.ActionRun) {
for _, notifier := range notifiers {
notifier.WorkflowRunStatusUpdate(ctx, repo, sender, run)
}
}
func WorkflowJobStatusUpdate(ctx context.Context, repo *repo_model.Repository, sender *user_model.User, job *actions_model.ActionRunJob, task *actions_model.ActionTask) {
for _, notifier := range notifiers {
notifier.WorkflowJobStatusUpdate(ctx, repo, sender, job, task)

View File

@@ -214,5 +214,8 @@ func (*NullNotifier) ChangeDefaultBranch(ctx context.Context, repo *repo_model.R
func (*NullNotifier) CreateCommitStatus(ctx context.Context, repo *repo_model.Repository, commit *repository.PushCommit, sender *user_model.User, status *git_model.CommitStatus) {
}
func (*NullNotifier) WorkflowRunStatusUpdate(ctx context.Context, repo *repo_model.Repository, sender *user_model.User, run *actions_model.ActionRun) {
}
func (*NullNotifier) WorkflowJobStatusUpdate(ctx context.Context, repo *repo_model.Repository, sender *user_model.User, job *actions_model.ActionRunJob, task *actions_model.ActionTask) {
}

View File

@@ -176,6 +176,12 @@ func (dc dingtalkConvertor) Status(p *api.CommitStatusPayload) (DingtalkPayload,
return createDingtalkPayload(text, text, "Status Changed", p.TargetURL), nil
}
func (dingtalkConvertor) WorkflowRun(p *api.WorkflowRunPayload) (DingtalkPayload, error) {
text, _ := getWorkflowRunPayloadInfo(p, noneLinkFormatter, true)
return createDingtalkPayload(text, text, "Workflow Run", p.WorkflowRun.HTMLURL), nil
}
func (dingtalkConvertor) WorkflowJob(p *api.WorkflowJobPayload) (DingtalkPayload, error) {
text, _ := getWorkflowJobPayloadInfo(p, noneLinkFormatter, true)

View File

@@ -278,6 +278,12 @@ func (d discordConvertor) Status(p *api.CommitStatusPayload) (DiscordPayload, er
return d.createPayload(p.Sender, text, "", p.TargetURL, color), nil
}
func (d discordConvertor) WorkflowRun(p *api.WorkflowRunPayload) (DiscordPayload, error) {
text, color := getWorkflowRunPayloadInfo(p, noneLinkFormatter, false)
return d.createPayload(p.Sender, text, "", p.WorkflowRun.HTMLURL, color), nil
}
func (d discordConvertor) WorkflowJob(p *api.WorkflowJobPayload) (DiscordPayload, error) {
text, color := getWorkflowJobPayloadInfo(p, noneLinkFormatter, false)

View File

@@ -172,6 +172,12 @@ func (fc feishuConvertor) Status(p *api.CommitStatusPayload) (FeishuPayload, err
return newFeishuTextPayload(text), nil
}
func (feishuConvertor) WorkflowRun(p *api.WorkflowRunPayload) (FeishuPayload, error) {
text, _ := getWorkflowRunPayloadInfo(p, noneLinkFormatter, true)
return newFeishuTextPayload(text), nil
}
func (feishuConvertor) WorkflowJob(p *api.WorkflowJobPayload) (FeishuPayload, error) {
text, _ := getWorkflowJobPayloadInfo(p, noneLinkFormatter, true)

View File

@@ -327,6 +327,37 @@ func getStatusPayloadInfo(p *api.CommitStatusPayload, linkFormatter linkFormatte
return text, color
}
func getWorkflowRunPayloadInfo(p *api.WorkflowRunPayload, linkFormatter linkFormatter, withSender bool) (text string, color int) {
description := p.WorkflowRun.Conclusion
if description == "" {
description = p.WorkflowRun.Status
}
refLink := linkFormatter(p.WorkflowRun.HTMLURL, fmt.Sprintf("%s(#%d)", p.WorkflowRun.DisplayTitle, p.WorkflowRun.ID)+"["+base.ShortSha(p.WorkflowRun.HeadSha)+"]:"+description)
text = fmt.Sprintf("Workflow Run %s: %s", p.Action, refLink)
switch description {
case "waiting":
color = orangeColor
case "queued":
color = orangeColorLight
case "success":
color = greenColor
case "failure":
color = redColor
case "cancelled":
color = yellowColor
case "skipped":
color = purpleColor
default:
color = greyColor
}
if withSender {
text += " by " + linkFormatter(setting.AppURL+url.PathEscape(p.Sender.UserName), p.Sender.UserName)
}
return text, color
}
func getWorkflowJobPayloadInfo(p *api.WorkflowJobPayload, linkFormatter linkFormatter, withSender bool) (text string, color int) {
description := p.WorkflowJob.Conclusion
if description == "" {

View File

@@ -252,6 +252,12 @@ func (m matrixConvertor) Status(p *api.CommitStatusPayload) (MatrixPayload, erro
return m.newPayload(text)
}
func (m matrixConvertor) WorkflowRun(p *api.WorkflowRunPayload) (MatrixPayload, error) {
text, _ := getWorkflowRunPayloadInfo(p, htmlLinkFormatter, true)
return m.newPayload(text)
}
func (m matrixConvertor) WorkflowJob(p *api.WorkflowJobPayload) (MatrixPayload, error) {
text, _ := getWorkflowJobPayloadInfo(p, htmlLinkFormatter, true)

View File

@@ -318,6 +318,20 @@ func (m msteamsConvertor) Status(p *api.CommitStatusPayload) (MSTeamsPayload, er
), nil
}
func (msteamsConvertor) WorkflowRun(p *api.WorkflowRunPayload) (MSTeamsPayload, error) {
title, color := getWorkflowRunPayloadInfo(p, noneLinkFormatter, false)
return createMSTeamsPayload(
p.Repo,
p.Sender,
title,
"",
p.WorkflowRun.HTMLURL,
color,
&MSTeamsFact{"WorkflowRun:", p.WorkflowRun.DisplayTitle},
), nil
}
func (msteamsConvertor) WorkflowJob(p *api.WorkflowJobPayload) (MSTeamsPayload, error) {
title, color := getWorkflowJobPayloadInfo(p, noneLinkFormatter, false)

View File

@@ -5,10 +5,8 @@ package webhook
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"
issues_model "code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/models/organization"
@@ -18,6 +16,7 @@ import (
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/gitrepo"
"code.gitea.io/gitea/modules/httplib"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/repository"
@@ -956,72 +955,17 @@ func (*webhookNotifier) WorkflowJobStatusUpdate(ctx context.Context, repo *repo_
org = convert.ToOrganization(ctx, organization.OrgFromUser(repo.Owner))
}
err := job.LoadAttributes(ctx)
status, _ := convert.ToActionsStatus(job.Status)
convertedJob, err := convert.ToActionWorkflowJob(ctx, repo, task, job)
if err != nil {
log.Error("Error loading job attributes: %v", err)
log.Error("ToActionWorkflowJob: %v", err)
return
}
jobIndex := 0
jobs, err := actions_model.GetRunJobsByRunID(ctx, job.RunID)
if err != nil {
log.Error("Error loading getting run jobs: %v", err)
return
}
for i, j := range jobs {
if j.ID == job.ID {
jobIndex = i
break
}
}
status, conclusion := toActionStatus(job.Status)
var runnerID int64
var runnerName string
var steps []*api.ActionWorkflowStep
if task != nil {
runnerID = task.RunnerID
if runner, ok, _ := db.GetByID[actions_model.ActionRunner](ctx, runnerID); ok {
runnerName = runner.Name
}
for i, step := range task.Steps {
stepStatus, stepConclusion := toActionStatus(job.Status)
steps = append(steps, &api.ActionWorkflowStep{
Name: step.Name,
Number: int64(i),
Status: stepStatus,
Conclusion: stepConclusion,
StartedAt: step.Started.AsTime().UTC(),
CompletedAt: step.Stopped.AsTime().UTC(),
})
}
}
if err := PrepareWebhooks(ctx, source, webhook_module.HookEventWorkflowJob, &api.WorkflowJobPayload{
Action: status,
WorkflowJob: &api.ActionWorkflowJob{
ID: job.ID,
// missing api endpoint for this location
URL: fmt.Sprintf("%s/actions/runs/%d/jobs/%d", repo.APIURL(), job.RunID, job.ID),
HTMLURL: fmt.Sprintf("%s/jobs/%d", job.Run.HTMLURL(), jobIndex),
RunID: job.RunID,
// Missing api endpoint for this location, artifacts are available under a nested url
RunURL: fmt.Sprintf("%s/actions/runs/%d", repo.APIURL(), job.RunID),
Name: job.Name,
Labels: job.RunsOn,
RunAttempt: job.Attempt,
HeadSha: job.Run.CommitSHA,
HeadBranch: git.RefName(job.Run.Ref).BranchName(),
Status: status,
Conclusion: conclusion,
RunnerID: runnerID,
RunnerName: runnerName,
Steps: steps,
CreatedAt: job.Created.AsTime().UTC(),
StartedAt: job.Started.AsTime().UTC(),
CompletedAt: job.Stopped.AsTime().UTC(),
},
Action: status,
WorkflowJob: convertedJob,
Organization: org,
Repo: convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm.AccessModeOwner}),
Sender: convert.ToUser(ctx, sender, nil),
@@ -1030,28 +974,46 @@ func (*webhookNotifier) WorkflowJobStatusUpdate(ctx context.Context, repo *repo_
}
}
func toActionStatus(status actions_model.Status) (string, string) {
var action string
var conclusion string
switch status {
// This is a naming conflict of the webhook between Gitea and GitHub Actions
case actions_model.StatusWaiting:
action = "queued"
case actions_model.StatusBlocked:
action = "waiting"
case actions_model.StatusRunning:
action = "in_progress"
func (*webhookNotifier) WorkflowRunStatusUpdate(ctx context.Context, repo *repo_model.Repository, sender *user_model.User, run *actions_model.ActionRun) {
source := EventSource{
Repository: repo,
Owner: repo.Owner,
}
if status.IsDone() {
action = "completed"
switch status {
case actions_model.StatusSuccess:
conclusion = "success"
case actions_model.StatusCancelled:
conclusion = "cancelled"
case actions_model.StatusFailure:
conclusion = "failure"
}
var org *api.Organization
if repo.Owner.IsOrganization() {
org = convert.ToOrganization(ctx, organization.OrgFromUser(repo.Owner))
}
status := convert.ToWorkflowRunAction(run.Status)
gitRepo, err := gitrepo.OpenRepository(ctx, repo)
if err != nil {
log.Error("OpenRepository: %v", err)
return
}
defer gitRepo.Close()
convertedWorkflow, err := convert.GetActionWorkflow(ctx, gitRepo, repo, run.WorkflowID)
if err != nil {
log.Error("GetActionWorkflow: %v", err)
return
}
convertedRun, err := convert.ToActionWorkflowRun(ctx, repo, run)
if err != nil {
log.Error("ToActionWorkflowRun: %v", err)
return
}
if err := PrepareWebhooks(ctx, source, webhook_module.HookEventWorkflowRun, &api.WorkflowRunPayload{
Action: status,
Workflow: convertedWorkflow,
WorkflowRun: convertedRun,
Organization: org,
Repo: convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm.AccessModeOwner}),
Sender: convert.ToUser(ctx, sender, nil),
}); err != nil {
log.Error("PrepareWebhooks: %v", err)
}
return action, conclusion
}

View File

@@ -114,6 +114,10 @@ func (pc packagistConvertor) Status(_ *api.CommitStatusPayload) (PackagistPayloa
return PackagistPayload{}, nil
}
func (pc packagistConvertor) WorkflowRun(_ *api.WorkflowRunPayload) (PackagistPayload, error) {
return PackagistPayload{}, nil
}
func (pc packagistConvertor) WorkflowJob(_ *api.WorkflowJobPayload) (PackagistPayload, error) {
return PackagistPayload{}, nil
}

View File

@@ -29,6 +29,7 @@ type payloadConvertor[T any] interface {
Wiki(*api.WikiPayload) (T, error)
Package(*api.PackagePayload) (T, error)
Status(*api.CommitStatusPayload) (T, error)
WorkflowRun(*api.WorkflowRunPayload) (T, error)
WorkflowJob(*api.WorkflowJobPayload) (T, error)
}
@@ -81,6 +82,8 @@ func newPayload[T any](rc payloadConvertor[T], data []byte, event webhook_module
return convertUnmarshalledJSON(rc.Package, data)
case webhook_module.HookEventStatus:
return convertUnmarshalledJSON(rc.Status, data)
case webhook_module.HookEventWorkflowRun:
return convertUnmarshalledJSON(rc.WorkflowRun, data)
case webhook_module.HookEventWorkflowJob:
return convertUnmarshalledJSON(rc.WorkflowJob, data)
}

View File

@@ -173,6 +173,12 @@ func (s slackConvertor) Status(p *api.CommitStatusPayload) (SlackPayload, error)
return s.createPayload(text, nil), nil
}
func (s slackConvertor) WorkflowRun(p *api.WorkflowRunPayload) (SlackPayload, error) {
text, _ := getWorkflowRunPayloadInfo(p, SlackLinkFormatter, true)
return s.createPayload(text, nil), nil
}
func (s slackConvertor) WorkflowJob(p *api.WorkflowJobPayload) (SlackPayload, error) {
text, _ := getWorkflowJobPayloadInfo(p, SlackLinkFormatter, true)

View File

@@ -180,6 +180,12 @@ func (t telegramConvertor) Status(p *api.CommitStatusPayload) (TelegramPayload,
return createTelegramPayloadHTML(text), nil
}
func (telegramConvertor) WorkflowRun(p *api.WorkflowRunPayload) (TelegramPayload, error) {
text, _ := getWorkflowRunPayloadInfo(p, htmlLinkFormatter, true)
return createTelegramPayloadHTML(text), nil
}
func (telegramConvertor) WorkflowJob(p *api.WorkflowJobPayload) (TelegramPayload, error) {
text, _ := getWorkflowJobPayloadInfo(p, htmlLinkFormatter, true)

View File

@@ -181,6 +181,12 @@ func (wc wechatworkConvertor) Status(p *api.CommitStatusPayload) (WechatworkPayl
return newWechatworkMarkdownPayload(text), nil
}
func (wc wechatworkConvertor) WorkflowRun(p *api.WorkflowRunPayload) (WechatworkPayload, error) {
text, _ := getWorkflowRunPayloadInfo(p, noneLinkFormatter, true)
return newWechatworkMarkdownPayload(text), nil
}
func (wc wechatworkConvertor) WorkflowJob(p *api.WorkflowJobPayload) (WechatworkPayload, error) {
text, _ := getWorkflowJobPayloadInfo(p, noneLinkFormatter, true)