0
0
mirror of https://github.com/go-gitea/gitea.git synced 2026-07-01 22:22:10 +02:00
gitea/services/convert/convert.go
Zettat123 f46c9a9769
feat(actions): support owner-level and global scoped workflows (#38154)
## Summary

This PR adds **scoped workflows** to Gitea Actions. Workflows defined
centrally in a "source" repository that automatically run on every
repository in scope: an organization's repositories, or (for instance
admins) every repository on the instance. Each scoped run executes in
the consuming repository's own context (its runners, secrets, and
branch) while its content is read from the source repository, so an org
or instance can mandate shared CI across many repositories without
copying workflow files into each one.

An owner or instance admin registers source repositories on a settings
page and can mark individual workflows as **required**. A required
scoped workflow cannot be opted out by a consuming repository and gates
its pull-request merges; an optional one can be disabled per repository.
Scoped workflows live under a dedicated `SCOPED_WORKFLOW_DIRS` (default
`.gitea/scoped_workflows`), kept separate from regular `WORKFLOW_DIRS`.

## Main changes

### Configuration 
New `SCOPED_WORKFLOW_DIRS` setting, validated to not overlap with
`WORKFLOW_DIRS`. Default: `.gitea/scoped_workflows`

### Data model & migration
- New `action_scoped_workflow_source` table mapping a registering owner
(`owner_id`, where `0` = instance-level) to a source repository, with a
per-workflow `WorkflowConfigs` map.
- `ActionRun` gains `WorkflowRepoID` / `WorkflowCommitSHA` (the pinned
content source) and an `IsScopedRun` flag.

###  Detection & run creation
On consumer events, scoped workflows from the effective sources (the
owner's own sources plus instance-level ones) are matched and turned
into runs that execute in the consumer's context, with content pinned to
the source repo's default-branch commit.

`on: workflow_run` and `on: schedule` are currently not supported.

###  Opt-out
A consuming repository can disable an optional scoped workflow (tracked
separately from regular `DisabledWorkflows`); required scoped workflows
can never be disabled, opted out, or bypassed.

###  Commit status 
A scoped run's status context format is `"<source repo full name>:
<workflow display name> / <job> (<event>)"`
(for example: `my-org/scoped-workflows: db-tests / test-sqlite
(pull_request)`),
keeping it distinct from a same-named repo-level workflow and from other
sources.

###  Required status checks
Admins mark workflows required and supply status-check patterns.
`EffectiveRequiredContexts` appends those patterns to the branch
protection's required contexts and they are matched
must-present-and-pass. If the status checks from scoped workflows fail,
the PR cannot be merged.

NOTE: scoped workflows' required status checks patterns can protect any
target branch that has a protection rule, even though the rule's "Status
Check" is disabled. A target branch with no protection rule cannot be
protected.

<details>
  <summary>Screenshots</summary>

<img width="1400" alt="image"
src="https://github.com/user-attachments/assets/a5d1db33-15ec-487e-93be-2bc04b4e6643"
/>

</details>


###  Reusable workflows (`uses:`)
A scoped workflow's local `uses: ./...` resolves against the source
repository. `uses:` directory validation honors the
instance-configurable `WORKFLOW_DIRS` and `SCOPED_WORKFLOW_DIRS`
(previously hardcoded to `.gitea`/`.github/workflows`).

###  Manual dispatch
`workflow_dispatch` is supported for scoped workflows (web and API),
resolving inputs/content from the source repo.

###  Performance
A process-local LRU cache keyed by source repo ID for the per-source
workflow parse, so instance-level and owner-level sources don't open the
source repo and parse workflow files on every event.

### UI
Org / user / admin pages to register and remove sources, search
repositories, and mark workflows required with their status-check
patterns. The repository Actions sidebar groups scoped workflows by
source with owner/instance labels and required/disabled badges.

<details>
  <summary>Screenshots</summary>

Scoped workflows setting page:

<img width="1600" alt="image"
src="https://github.com/user-attachments/assets/9d19f667-97a5-4935-92b2-e53f105e3642"
/>


Consumer repo's Actions runs list:

<img width="1600" alt="image"
src="https://github.com/user-attachments/assets/a77241f9-0aa9-41aa-ba73-12a9a688cb64"
/>

- `Owner`: this is a owner-level scoped workflows source repo
- `Global`: this is a global scoped workflows source repo
- `Required`: this scoped workflow is required, repo admin cannot
disable it

</details>

---

Docs: https://gitea.com/gitea/docs/pulls/447

---------

Co-authored-by: bircni <bircni@icloud.com>
2026-06-28 09:31:35 +00:00

1040 lines
36 KiB
Go

// Copyright 2015 The Gogs Authors. All rights reserved.
// Copyright 2018 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package convert
import (
"bytes"
"context"
"errors"
"fmt"
"net/url"
"path"
"strconv"
"time"
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
actions_model "gitea.dev/models/actions"
asymkey_model "gitea.dev/models/asymkey"
"gitea.dev/models/auth"
"gitea.dev/models/db"
git_model "gitea.dev/models/git"
issues_model "gitea.dev/models/issues"
"gitea.dev/models/organization"
"gitea.dev/models/perm"
access_model "gitea.dev/models/perm/access"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unit"
user_model "gitea.dev/models/user"
"gitea.dev/modules/actions"
"gitea.dev/modules/container"
"gitea.dev/modules/git"
"gitea.dev/modules/gitrepo"
"gitea.dev/modules/httplib"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
api "gitea.dev/modules/structs"
"gitea.dev/modules/util"
webhook_module "gitea.dev/modules/webhook"
asymkey_service "gitea.dev/services/asymkey"
"gitea.dev/services/gitdiff"
"gitea.com/gitea/runner/act/model"
)
// ToEmail convert models.EmailAddress to api.Email
func ToEmail(email *user_model.EmailAddress) *api.Email {
return &api.Email{
Email: email.Email,
Verified: email.IsActivated,
Primary: email.IsPrimary,
}
}
// ToEmail convert models.EmailAddress to api.Email
func ToEmailSearch(email *user_model.SearchEmailResult) *api.Email {
return &api.Email{
Email: email.Email,
Verified: email.IsActivated,
Primary: email.IsPrimary,
UserID: email.UID,
UserName: email.Name,
}
}
// ToBranch convert a git.Commit and git.Branch to an api.Branch
func ToBranch(ctx context.Context, repo *repo_model.Repository, branchName string, c *git.Commit, bp *git_model.ProtectedBranch, user *user_model.User, isRepoAdmin bool) (*api.Branch, error) {
if bp == nil {
var hasPerm bool
var canPush bool
var err error
if user != nil {
hasPerm, err = access_model.HasAccessUnit(ctx, user, repo, unit.TypeCode, perm.AccessModeWrite)
if err != nil {
return nil, err
}
perms, err := access_model.GetIndividualUserRepoPermission(ctx, repo, user)
if err != nil {
return nil, err
}
canPush = issues_model.CanMaintainerWriteToBranch(ctx, perms, branchName, user)
}
return &api.Branch{
Name: branchName,
Commit: ToPayloadCommit(ctx, repo, c),
Protected: false,
RequiredApprovals: 0,
EnableStatusCheck: false,
StatusCheckContexts: []string{},
UserCanPush: canPush,
UserCanMerge: hasPerm,
}, nil
}
branch := &api.Branch{
Name: branchName,
Commit: ToPayloadCommit(ctx, repo, c),
Protected: true,
RequiredApprovals: bp.RequiredApprovals,
EnableStatusCheck: bp.EnableStatusCheck,
StatusCheckContexts: bp.StatusCheckContexts,
}
if isRepoAdmin {
branch.EffectiveBranchProtectionName = bp.RuleName
}
if user != nil {
permission, err := access_model.GetIndividualUserRepoPermission(ctx, repo, user)
if err != nil {
return nil, err
}
bp.Repo = repo
branch.UserCanPush = bp.CanUserPush(ctx, user)
branch.UserCanMerge = git_model.IsUserMergeWhitelisted(ctx, bp, user.ID, permission)
}
return branch, nil
}
// getWhitelistEntities returns the names of the entities that are in the whitelist
func getWhitelistEntities[T *user_model.User | *organization.Team](entities []T, whitelistIDs []int64) []string {
whitelistUserIDsSet := container.SetOf(whitelistIDs...)
whitelistNames := make([]string, 0)
for _, entity := range entities {
switch v := any(entity).(type) {
case *user_model.User:
if whitelistUserIDsSet.Contains(v.ID) {
whitelistNames = append(whitelistNames, v.Name)
}
case *organization.Team:
if whitelistUserIDsSet.Contains(v.ID) {
whitelistNames = append(whitelistNames, v.Name)
}
}
}
return whitelistNames
}
// ToBranchProtection convert a ProtectedBranch to api.BranchProtection
func ToBranchProtection(ctx context.Context, bp *git_model.ProtectedBranch, repo *repo_model.Repository) *api.BranchProtection {
readers, err := access_model.GetUsersWithUnitAccess(ctx, repo, perm.AccessModeRead, unit.TypePullRequests)
if err != nil {
log.Error("GetRepoReaders: %v", err)
}
pushWhitelistUsernames := getWhitelistEntities(readers, bp.WhitelistUserIDs)
forcePushAllowlistUsernames := getWhitelistEntities(readers, bp.ForcePushAllowlistUserIDs)
mergeWhitelistUsernames := getWhitelistEntities(readers, bp.MergeWhitelistUserIDs)
approvalsWhitelistUsernames := getWhitelistEntities(readers, bp.ApprovalsWhitelistUserIDs)
bypassAllowlistUsernames := getWhitelistEntities(readers, bp.BypassAllowlistUserIDs)
teamReaders, err := organization.GetTeamsWithAccessToAnyRepoUnit(ctx, repo.Owner.ID, repo.ID, perm.AccessModeRead, unit.TypeCode, unit.TypePullRequests)
if err != nil {
log.Error("Repo.Owner.TeamsWithAccessToRepo: %v", err)
}
pushWhitelistTeams := getWhitelistEntities(teamReaders, bp.WhitelistTeamIDs)
forcePushAllowlistTeams := getWhitelistEntities(teamReaders, bp.ForcePushAllowlistTeamIDs)
mergeWhitelistTeams := getWhitelistEntities(teamReaders, bp.MergeWhitelistTeamIDs)
approvalsWhitelistTeams := getWhitelistEntities(teamReaders, bp.ApprovalsWhitelistTeamIDs)
bypassAllowlistTeams := getWhitelistEntities(teamReaders, bp.BypassAllowlistTeamIDs)
branchName := ""
if !git_model.IsRuleNameSpecial(bp.RuleName) {
branchName = bp.RuleName
}
return &api.BranchProtection{
BranchName: branchName,
RuleName: bp.RuleName,
Priority: bp.Priority,
EnablePush: bp.CanPush,
EnablePushWhitelist: bp.EnableWhitelist,
PushWhitelistUsernames: pushWhitelistUsernames,
PushWhitelistTeams: pushWhitelistTeams,
PushWhitelistDeployKeys: bp.WhitelistDeployKeys,
EnableForcePush: bp.CanForcePush,
EnableForcePushAllowlist: bp.EnableForcePushAllowlist,
ForcePushAllowlistUsernames: forcePushAllowlistUsernames,
ForcePushAllowlistTeams: forcePushAllowlistTeams,
ForcePushAllowlistDeployKeys: bp.ForcePushAllowlistDeployKeys,
EnableMergeWhitelist: bp.EnableMergeWhitelist,
MergeWhitelistUsernames: mergeWhitelistUsernames,
MergeWhitelistTeams: mergeWhitelistTeams,
EnableBypassAllowlist: bp.EnableBypassAllowlist,
BypassAllowlistUsernames: bypassAllowlistUsernames,
BypassAllowlistTeams: bypassAllowlistTeams,
EnableStatusCheck: bp.EnableStatusCheck,
StatusCheckContexts: bp.StatusCheckContexts,
RequiredApprovals: bp.RequiredApprovals,
EnableApprovalsWhitelist: bp.EnableApprovalsWhitelist,
ApprovalsWhitelistUsernames: approvalsWhitelistUsernames,
ApprovalsWhitelistTeams: approvalsWhitelistTeams,
BlockOnRejectedReviews: bp.BlockOnRejectedReviews,
BlockOnOfficialReviewRequests: bp.BlockOnOfficialReviewRequests,
BlockOnOutdatedBranch: bp.BlockOnOutdatedBranch,
DismissStaleApprovals: bp.DismissStaleApprovals,
IgnoreStaleApprovals: bp.IgnoreStaleApprovals,
RequireSignedCommits: bp.RequireSignedCommits,
ProtectedFilePatterns: bp.ProtectedFilePatterns,
UnprotectedFilePatterns: bp.UnprotectedFilePatterns,
BlockAdminMergeOverride: bp.BlockAdminMergeOverride,
Created: bp.CreatedUnix.AsTime(),
Updated: bp.UpdatedUnix.AsTime(),
}
}
// ToTag convert a git.Tag to an api.Tag
func ToTag(repo *repo_model.Repository, t *git.Tag) *api.Tag {
tarballURL := repo.HTMLURL() + "/archive/" + url.PathEscape(t.Name+".tar.gz")
zipballURL := repo.HTMLURL() + "/archive/" + url.PathEscape(t.Name+".zip")
// Archive URLs are "" if the download feature is disabled
if setting.Repository.DisableDownloadSourceArchives {
tarballURL = ""
zipballURL = ""
}
return &api.Tag{
Name: t.Name,
Message: t.MessageUTF8(),
ID: t.ID.String(),
Commit: ToCommitMeta(repo, t),
ZipballURL: zipballURL,
TarballURL: tarballURL,
}
}
// ToActionTask convert an actions_model.ActionTask to an api.ActionTask
func ToActionTask(ctx context.Context, t *actions_model.ActionTask) (*api.ActionTask, error) {
// don't need Steps here, only need to load job and its run
if err := t.LoadJob(ctx); err != nil {
return nil, err
}
if err := t.Job.LoadRun(ctx); err != nil {
return nil, err
}
if err := t.Job.Run.LoadRepo(ctx); err != nil {
return nil, err
}
return &api.ActionTask{
ID: t.ID,
Name: t.Job.Name,
HeadBranch: t.Job.Run.PrettyRef(),
HeadSHA: t.Job.CommitSHA,
RunNumber: t.Job.Run.Index,
Event: t.Job.Run.TriggerEvent,
DisplayTitle: t.Job.Run.Title,
Status: t.Status.String(),
WorkflowID: t.Job.Run.WorkflowID,
URL: httplib.MakeAbsoluteURL(ctx, t.Job.Run.Link()),
CreatedAt: t.Created.AsLocalTime(),
UpdatedAt: t.Updated.AsLocalTime(),
RunStartedAt: t.Started.AsLocalTime(),
}, nil
}
func ToActionWorkflowRun(ctx context.Context, run *actions_model.ActionRun, attempt *actions_model.ActionRunAttempt, excludePullRequests bool) (_ *api.ActionWorkflowRun, err error) {
if err := run.LoadAttributes(ctx); err != nil {
return nil, err
}
if attempt == nil {
attempt, _, err = run.GetLatestAttempt(ctx)
if err != nil {
return nil, err
}
}
runAttempt := int64(0)
status, conclusion := ToActionsStatus(run.Status)
startedAt := run.Started.AsLocalTime()
completedAt := run.Stopped.AsLocalTime()
actor := run.TriggerUser // The username of the user that triggered the initial workflow run.
triggerUser := run.TriggerUser // The username of the user that initiated the workflow run. If the workflow run is a re-run, this value may differ from actor.
// previousAttemptURL is the value of ActionWorkflowRun.PreviousAttemptURL, which is declared as *string without `omitempty` on purpose:
// a nil value must still appear in the JSON body as `"previous_attempt_url": null`, matching GitHub's Actions API.
var previousAttemptURL *string
if attempt != nil {
attempt.Run = run
if err := attempt.LoadAttributes(ctx); err != nil {
return nil, err
}
runAttempt = attempt.Attempt
status, conclusion = ToActionsStatus(attempt.Status)
startedAt = attempt.Started.AsLocalTime()
completedAt = attempt.Stopped.AsLocalTime()
triggerUser = attempt.TriggerUser
if attempt.Attempt > 1 {
url := fmt.Sprintf("%s/actions/runs/%d/attempts/%d", run.Repo.APIURL(ctx), run.ID, attempt.Attempt-1)
previousAttemptURL = &url
}
}
pullRequests := []*api.PullRequestMinimal{}
if !excludePullRequests {
pullRequests, err = loadPullRequestsForRun(ctx, run)
if err != nil {
return nil, err
}
}
return &api.ActionWorkflowRun{
ID: run.ID,
URL: fmt.Sprintf("%s/actions/runs/%d", run.Repo.APIURL(ctx), run.ID),
PreviousAttemptURL: previousAttemptURL,
HTMLURL: run.HTMLURL(ctx),
RunNumber: run.Index,
RunAttempt: runAttempt,
StartedAt: startedAt,
CompletedAt: completedAt,
Event: run.TriggerEvent,
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, run.Repo, access_model.Permission{AccessMode: perm.AccessModeNone}),
TriggerActor: ToUser(ctx, triggerUser, nil),
Actor: ToUser(ctx, actor, nil),
PullRequests: pullRequests,
}, nil
}
// loadPullRequestsForRun returns the pull requests associated with a run, matching
// GitHub's `pull_requests` field on workflow run responses:
// - For pull_request / pull_request_review events, the PR whose ref triggered the run.
// - For push events, open PRs whose head branch matches the pushed ref in the same repo.
// - For other events, no PRs.
func loadPullRequestsForRun(ctx context.Context, run *actions_model.ActionRun) ([]*api.PullRequestMinimal, error) {
result := []*api.PullRequestMinimal{}
refName := git.RefName(run.Ref)
var prs issues_model.PullRequestList
switch {
case run.Event.IsPullRequest() || run.Event.IsPullRequestReview():
index, err := strconv.ParseInt(refName.PullName(), 10, 64)
if err != nil {
return result, nil
}
pr, err := issues_model.GetPullRequestByIndex(ctx, run.RepoID, index)
if err != nil {
if issues_model.IsErrPullRequestNotExist(err) {
return result, nil
}
return nil, err
}
prs = issues_model.PullRequestList{pr}
case run.Event == webhook_module.HookEventPush:
branch := refName.BranchName()
if branch == "" {
return result, nil
}
var err error
prs, err = issues_model.GetUnmergedPullRequestsByHeadInfo(ctx, run.RepoID, branch)
if err != nil {
return nil, err
}
default:
return result, nil
}
for _, pr := range prs {
minimal, err := toPullRequestMinimal(ctx, run.Repo, pr, run.CommitSHA)
if err != nil {
return nil, err
}
result = append(result, minimal)
}
return result, nil
}
func toPullRequestMinimal(ctx context.Context, repo *repo_model.Repository, pr *issues_model.PullRequest, headSHA string) (*api.PullRequestMinimal, error) {
if err := pr.LoadBaseRepo(ctx); err != nil {
return nil, err
}
if err := pr.LoadHeadRepo(ctx); err != nil {
return nil, err
}
headRepo := pr.HeadRepo
if headRepo == nil {
headRepo = pr.BaseRepo
}
return &api.PullRequestMinimal{
ID: pr.ID,
Number: pr.Index,
URL: fmt.Sprintf("%s/pulls/%d", repo.APIURL(ctx), pr.Index),
Head: api.PullRequestMinimalHead{
Ref: pr.HeadBranch,
SHA: headSHA,
Repo: api.PullRequestMinimalHeadRepo{
ID: headRepo.ID,
URL: headRepo.APIURL(ctx),
Name: headRepo.Name,
},
},
Base: api.PullRequestMinimalHead{
Ref: pr.BaseBranch,
SHA: pr.MergeBase,
Repo: api.PullRequestMinimalHeadRepo{
ID: pr.BaseRepo.ID,
URL: pr.BaseRepo.APIURL(ctx),
Name: pr.BaseRepo.Name,
},
},
}, nil
}
func ToWorkflowRunAction(status actions_model.Status) (action string) {
switch status {
case actions_model.StatusWaiting, actions_model.StatusBlocked:
action = "requested"
case actions_model.StatusRunning, actions_model.StatusCancelling:
action = "in_progress"
default:
if status.IsDone() {
action = "completed"
} else {
setting.PanicInDevOrTesting("unknown action status: %v", status)
}
}
return action
}
func ToActionsStatus(status actions_model.Status) (action, conclusion string) {
switch status {
case actions_model.StatusWaiting:
action = "queued" // "waiting" is a naming conflict of the webhook between Gitea and GitHub Actions
case actions_model.StatusBlocked:
action = "waiting" // naming conflict (as above)
case actions_model.StatusRunning, actions_model.StatusCancelling:
action = "in_progress"
default:
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"
default:
setting.PanicInDevOrTesting("unknown action status: %v", status)
}
}
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
}
status, conclusion := ToActionsStatus(job.Status)
var runnerID int64
var runnerName string
var steps []*api.ActionWorkflowStep
if effectiveTaskID := job.EffectiveTaskID(); effectiveTaskID != 0 {
if task == nil {
task, _, err = db.GetByID[actions_model.ActionTask](ctx, effectiveTaskID)
if err != nil {
return nil, err
}
}
if task != nil {
if task.Steps == nil {
task.Steps, err = actions_model.GetTaskStepsByTaskID(ctx, task.ID)
if err != nil {
return nil, err
}
task.Steps = util.SliceNilAsEmpty(task.Steps)
}
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(step.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(ctx), job.ID),
HTMLURL: fmt.Sprintf("%s/jobs/%d", job.Run.HTMLURL(ctx), job.ID),
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(ctx), 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: util.SliceNilAsEmpty(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, refName git.RefName, folder string, entry *git.TreeEntry) *api.ActionWorkflow {
cfgUnit := repo.MustGetUnit(ctx, unit.TypeActions)
cfg := cfgUnit.ActionsConfig()
workflowURL := fmt.Sprintf("%s/actions/workflows/%s", repo.APIURL(), util.PathEscapeSegments(entry.Name()))
workflowRepoURL := fmt.Sprintf("%s/src/commit/%s/%s/%s", repo.HTMLURL(ctx), commit.ID.String(), util.PathEscapeSegments(folder), util.PathEscapeSegments(entry.Name()))
if refWebLinkPath := refName.RefWebLinkPath(); refWebLinkPath != "" {
workflowRepoURL = fmt.Sprintf("%s/src/%s/%s/%s", repo.HTMLURL(ctx), refWebLinkPath, 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, git.RefNameFromBranch(repo.DefaultBranch), folder, entry)
}
return workflows, nil
}
func GetActionWorkflow(ctx context.Context, gitrepo *git.Repository, repo *repo_model.Repository, workflowID string) (*api.ActionWorkflow, error) {
defaultBranchCommit, err := gitrepo.GetBranchCommit(repo.DefaultBranch)
if err != nil {
return nil, err
}
return getActionWorkflowFromCommit(ctx, repo, defaultBranchCommit, git.RefNameFromBranch(repo.DefaultBranch), workflowID)
}
func GetActionWorkflowByRef(ctx context.Context, gitrepo *git.Repository, repo *repo_model.Repository, workflowID string, ref git.RefName) (*api.ActionWorkflow, error) {
if ref == "" {
return nil, util.NewNotExistErrorf("workflow %q not found", workflowID)
}
refCommitID, err := gitrepo.GetRefCommitID(ref.String())
if err != nil {
return nil, err
}
refCommit, err := gitrepo.GetCommit(refCommitID)
if err != nil {
return nil, err
}
return getActionWorkflowFromCommit(ctx, repo, refCommit, ref, workflowID)
}
func getActionWorkflowFromCommit(ctx context.Context, repo *repo_model.Repository, commit *git.Commit, refName git.RefName, workflowID string) (*api.ActionWorkflow, error) {
folder, entries, err := actions.ListWorkflows(commit)
if err != nil {
return nil, err
}
for _, entry := range entries {
if entry.Name() == workflowID {
return getActionWorkflowEntry(ctx, repo, commit, refName, folder, entry), nil
}
}
return nil, util.NewNotExistErrorf("workflow %q not found", workflowID)
}
// GetScopedActionWorkflow resolves a scoped workflow definition (under SCOPED_WORKFLOW_DIRS) from the source repo at commitSHA.
func GetScopedActionWorkflow(ctx context.Context, sourceGitRepo *git.Repository, sourceRepo *repo_model.Repository, workflowID, commitSHA string) (*api.ActionWorkflow, error) {
commit, err := sourceGitRepo.GetCommit(commitSHA)
if err != nil {
return nil, err
}
folder, entries, err := actions.ListScopedWorkflows(commit)
if err != nil {
return nil, err
}
for _, entry := range entries {
if entry.Name() == workflowID {
// An empty ref pins HTMLURL to commit (the run's WorkflowCommitSHA) rather than the moving default branch.
wf := getActionWorkflowEntry(ctx, sourceRepo, commit, git.RefName(""), folder, entry)
// TODO: a scoped workflow has no repo-level representation on the source: the workflow API scans WORKFLOW_DIRS (not SCOPED_WORKFLOW_DIRS),
// and the badge only reflects the source's repo-level runs, so neither link resolves a scoped workflow.
// Blank them for now and populate once a scoped-aware workflow/badge endpoint exists.
wf.URL = ""
wf.BadgeURL = ""
return wf, nil
}
}
return nil, util.NewNotExistErrorf("scoped workflow %q not found", workflowID)
}
// ResolveActionWorkflowForRun returns the api.ActionWorkflow describing a run's workflow definition.
// For a scoped run the definition lives in the source repo (run.WorkflowRepoID @ run.WorkflowCommitSHA) under SCOPED_WORKFLOW_DIRS,
// not in the consuming repo, so it is resolved against the source repo.
func ResolveActionWorkflowForRun(ctx context.Context, repo *repo_model.Repository, run *actions_model.ActionRun) (*api.ActionWorkflow, error) {
if run.IsScopedRun {
sourceRepo, err := repo_model.GetRepositoryByID(ctx, run.WorkflowRepoID)
if err != nil {
return nil, err
}
sourceGitRepo, err := gitrepo.OpenRepository(ctx, sourceRepo)
if err != nil {
return nil, err
}
defer sourceGitRepo.Close()
return GetScopedActionWorkflow(ctx, sourceGitRepo, sourceRepo, run.WorkflowID, run.WorkflowCommitSHA)
}
gitRepo, err := gitrepo.OpenRepository(ctx, repo)
if err != nil {
return nil, err
}
defer gitRepo.Close()
convertedWorkflow, err := GetActionWorkflowByRef(ctx, gitRepo, repo, run.WorkflowID, git.RefName(run.Ref))
if err != nil && errors.Is(err, util.ErrNotExist) {
convertedWorkflow, err = GetActionWorkflow(ctx, gitRepo, repo, run.WorkflowID)
}
return convertedWorkflow, err
}
// 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)
return &api.ActionArtifact{
ID: art.ID,
Name: art.ArtifactName,
SizeInBytes: art.FileSize,
Expired: art.Status == actions_model.ArtifactStatusExpired,
URL: url,
ArchiveDownloadURL: url + "/zip",
CreatedAt: art.CreatedUnix.AsLocalTime(),
UpdatedAt: art.UpdatedUnix.AsLocalTime(),
ExpiresAt: art.ExpiredUnix.AsLocalTime(),
WorkflowRun: &api.ActionWorkflowRun{
ID: art.RunID,
RepositoryID: art.RepoID,
HeadSha: art.CommitSHA,
},
}, nil
}
func ToActionRunner(ctx context.Context, runner *actions_model.ActionRunner) *api.ActionRunner {
status := runner.Status()
apiStatus := "offline"
if runner.IsOnline() {
apiStatus = "online"
}
labels := make([]*api.ActionRunnerLabel, len(runner.AgentLabels))
for i, label := range runner.AgentLabels {
labels[i] = &api.ActionRunnerLabel{
ID: int64(i),
Name: label,
Type: "custom",
}
}
return &api.ActionRunner{
ID: runner.ID,
Name: runner.Name,
Status: apiStatus,
Busy: status == runnerv1.RunnerStatus_RUNNER_STATUS_ACTIVE,
Disabled: runner.IsDisabled,
Ephemeral: runner.Ephemeral,
Labels: labels,
}
}
// ToVerification convert a git.Commit.Signature to an api.PayloadCommitVerification
func ToVerification(ctx context.Context, c *git.Commit) *api.PayloadCommitVerification {
verif := asymkey_service.ParseCommitWithSignature(ctx, c)
commitVerification := &api.PayloadCommitVerification{
Verified: verif.Verified,
Reason: verif.Reason,
}
if c.Signature != nil {
commitVerification.Signature = c.Signature.Signature
commitVerification.Payload = c.Signature.Payload
}
if verif.SigningUser != nil {
commitVerification.Signer = &api.PayloadUser{
UserName: verif.SigningUser.Name,
Name: verif.SigningUser.DisplayName(),
Email: verif.SigningEmail, // Use the email from the signature, not from the user profile
}
}
return commitVerification
}
// ToPublicKey convert asymkey_model.PublicKey to api.PublicKey
func ToPublicKey(apiLink string, key *asymkey_model.PublicKey) *api.PublicKey {
return &api.PublicKey{
ID: key.ID,
Key: key.Content,
URL: fmt.Sprintf("%s%d", apiLink, key.ID),
Title: key.Name,
Fingerprint: key.Fingerprint,
Created: key.CreatedUnix.AsTime(),
Updated: key.UpdatedUnix.AsTime(),
}
}
// ToGPGKey converts models.GPGKey to api.GPGKey
func ToGPGKey(key *asymkey_model.GPGKey) *api.GPGKey {
subkeys := make([]*api.GPGKey, len(key.SubsKey))
for id, k := range key.SubsKey {
subkeys[id] = &api.GPGKey{
ID: k.ID,
PrimaryKeyID: k.PrimaryKeyID,
KeyID: k.KeyID,
PublicKey: k.Content,
Created: k.CreatedUnix.AsTime(),
Expires: k.ExpiredUnix.AsTime(),
CanSign: k.CanSign,
CanEncryptComms: k.CanEncryptComms,
CanEncryptStorage: k.CanEncryptStorage,
CanCertify: k.CanSign,
Verified: k.Verified,
}
}
emails := make([]*api.GPGKeyEmail, len(key.Emails))
for i, e := range key.Emails {
emails[i] = ToGPGKeyEmail(e)
}
return &api.GPGKey{
ID: key.ID,
PrimaryKeyID: key.PrimaryKeyID,
KeyID: key.KeyID,
PublicKey: key.Content,
Created: key.CreatedUnix.AsTime(),
Expires: key.ExpiredUnix.AsTime(),
Emails: emails,
SubsKey: subkeys,
CanSign: key.CanSign,
CanEncryptComms: key.CanEncryptComms,
CanEncryptStorage: key.CanEncryptStorage,
CanCertify: key.CanSign,
Verified: key.Verified,
}
}
// ToGPGKeyEmail convert models.EmailAddress to api.GPGKeyEmail
func ToGPGKeyEmail(email *user_model.EmailAddress) *api.GPGKeyEmail {
return &api.GPGKeyEmail{
Email: email.Email,
Verified: email.IsActivated,
}
}
// ToGitHook convert git.Hook to api.GitHook
func ToGitHook(h *git.Hook) *api.GitHook {
return &api.GitHook{
Name: h.Name(),
IsActive: h.IsActive,
Content: h.Content,
}
}
// ToDeployKey convert asymkey_model.DeployKey to api.DeployKey
func ToDeployKey(apiLink string, key *asymkey_model.DeployKey) *api.DeployKey {
return &api.DeployKey{
ID: key.ID,
KeyID: key.KeyID,
Key: key.Content,
Fingerprint: key.Fingerprint,
URL: fmt.Sprintf("%s%d", apiLink, key.ID),
Title: key.Name,
Created: key.CreatedUnix.AsTime(),
ReadOnly: key.Mode == perm.AccessModeRead, // All deploy keys are read-only.
}
}
// ToOrganization convert user_model.User to api.Organization
func ToOrganization(ctx context.Context, org *organization.Organization) *api.Organization {
return &api.Organization{
ID: org.ID,
AvatarURL: org.AsUser().AvatarLink(ctx),
Name: org.Name,
UserName: org.Name,
FullName: org.FullName,
Email: org.Email,
Description: org.Description,
Website: org.Website,
Location: org.Location,
Visibility: api.UserVisibility(org.Visibility.String()),
RepoAdminChangeTeamAccess: org.RepoAdminChangeTeamAccess,
}
}
// ToTeam convert models.Team to api.Team
func ToTeam(ctx context.Context, team *organization.Team, loadOrg ...bool) (*api.Team, error) {
teams, err := ToTeams(ctx, []*organization.Team{team}, len(loadOrg) != 0 && loadOrg[0])
if err != nil || len(teams) == 0 {
return nil, err
}
return teams[0], nil
}
// ToTeams convert models.Team list to api.Team list
func ToTeams(ctx context.Context, teams []*organization.Team, loadOrgs bool) ([]*api.Team, error) {
cache := make(map[int64]*api.Organization)
apiTeams := make([]*api.Team, 0, len(teams))
for _, t := range teams {
if err := t.LoadUnits(ctx); err != nil {
return nil, err
}
apiTeam := &api.Team{
ID: t.ID,
Name: t.Name,
Description: t.Description,
IncludesAllRepositories: t.IncludesAllRepositories,
CanCreateOrgRepo: t.CanCreateOrgRepo,
Permission: api.AccessLevelName(t.AccessMode.ToString()),
Units: t.GetUnitNames(),
UnitsMap: t.GetUnitsMap(),
Visibility: api.TeamVisibility(t.Visibility.String()),
}
if loadOrgs {
apiOrg, ok := cache[t.OrgID]
if !ok {
org, err := organization.GetOrgByID(ctx, t.OrgID)
if err != nil {
return nil, err
}
apiOrg = ToOrganization(ctx, org)
cache[t.OrgID] = apiOrg
}
apiTeam.Organization = apiOrg
}
apiTeams = append(apiTeams, apiTeam)
}
return apiTeams, nil
}
// ToAnnotatedTag convert git.Tag to api.AnnotatedTag
func ToAnnotatedTag(ctx context.Context, repo *repo_model.Repository, t *git.Tag, c *git.Commit) *api.AnnotatedTag {
return &api.AnnotatedTag{
Tag: t.Name,
SHA: t.ID.String(),
Object: ToAnnotatedTagObject(repo, c),
Message: t.MessageUTF8(),
URL: repo.APIURL() + "/git/tags/" + t.ID.String(),
Tagger: ToCommitUser(t.Tagger),
Verification: ToVerification(ctx, c),
}
}
// ToAnnotatedTagObject convert a git.Commit to an api.AnnotatedTagObject
func ToAnnotatedTagObject(repo *repo_model.Repository, commit *git.Commit) *api.AnnotatedTagObject {
return &api.AnnotatedTagObject{
SHA: commit.ID.String(),
Type: string(git.ObjectCommit),
URL: repo.APIURL() + "/git/commits/" + commit.ID.String(),
}
}
// ToTagProtection convert a git.ProtectedTag to an api.TagProtection
func ToTagProtection(ctx context.Context, pt *git_model.ProtectedTag, repo *repo_model.Repository) *api.TagProtection {
readers, err := access_model.GetUsersWithUnitAccess(ctx, repo, perm.AccessModeRead, unit.TypePullRequests)
if err != nil {
log.Error("GetRepoReaders: %v", err)
}
whitelistUsernames := getWhitelistEntities(readers, pt.AllowlistUserIDs)
teamReaders, err := organization.GetTeamsWithAccessToAnyRepoUnit(ctx, repo.Owner.ID, repo.ID, perm.AccessModeRead, unit.TypeCode, unit.TypePullRequests)
if err != nil {
log.Error("Repo.Owner.TeamsWithAccessToRepo: %v", err)
}
whitelistTeams := getWhitelistEntities(teamReaders, pt.AllowlistTeamIDs)
return &api.TagProtection{
ID: pt.ID,
NamePattern: pt.NamePattern,
WhitelistUsernames: whitelistUsernames,
WhitelistTeams: whitelistTeams,
Created: pt.CreatedUnix.AsTime(),
Updated: pt.UpdatedUnix.AsTime(),
}
}
// ToTopicResponse convert from models.Topic to api.TopicResponse
func ToTopicResponse(topic *repo_model.Topic) *api.TopicResponse {
return &api.TopicResponse{
ID: topic.ID,
Name: topic.Name,
RepoCount: topic.RepoCount,
Created: topic.CreatedUnix.AsTime(),
Updated: topic.UpdatedUnix.AsTime(),
}
}
// ToOAuth2Application convert from auth.OAuth2Application to api.OAuth2Application
func ToOAuth2Application(app *auth.OAuth2Application) *api.OAuth2Application {
return &api.OAuth2Application{
ID: app.ID,
Name: app.Name,
ClientID: app.ClientID,
ClientSecret: app.ClientSecret,
ConfidentialClient: app.ConfidentialClient,
SkipSecondaryAuthorization: app.SkipSecondaryAuthorization,
RedirectURIs: app.RedirectURIs,
Created: app.CreatedUnix.AsTime(),
}
}
// ToLFSLock convert a LFSLock to api.LFSLock
func ToLFSLock(ctx context.Context, l *git_model.LFSLock) *api.LFSLock {
_, u, err := user_model.GetPossibleUserByID(ctx, l.OwnerID)
if err != nil {
return nil
}
return &api.LFSLock{
ID: strconv.FormatInt(l.ID, 10),
Path: l.Path,
LockedAt: l.Created.Round(time.Second),
Owner: &api.LFSLockOwner{
Name: u.Name,
},
}
}
// ToChangedFile convert a gitdiff.DiffFile to api.ChangedFile
func ToChangedFile(f *gitdiff.DiffFile, repo *repo_model.Repository, commit string) *api.ChangedFile {
status := "changed"
previousFilename := ""
if f.IsDeleted {
status = "deleted"
} else if f.IsCreated {
status = "added"
} else if f.IsRenamed && f.Type == gitdiff.DiffFileCopy {
status = "copied"
} else if f.IsRenamed && f.Type == gitdiff.DiffFileRename {
status = "renamed"
previousFilename = f.OldName
} else if f.Addition == 0 && f.Deletion == 0 {
status = "unchanged"
}
file := &api.ChangedFile{
Filename: f.GetDiffFileName(),
Status: status,
Additions: f.Addition,
Deletions: f.Deletion,
Changes: f.Addition + f.Deletion,
PreviousFilename: previousFilename,
HTMLURL: fmt.Sprint(repo.HTMLURL(), "/src/commit/", commit, "/", util.PathEscapeSegments(f.GetDiffFileName())),
ContentsURL: fmt.Sprint(repo.APIURL(), "/contents/", util.PathEscapeSegments(f.GetDiffFileName()), "?ref=", commit),
RawURL: fmt.Sprint(repo.HTMLURL(), "/raw/commit/", commit, "/", util.PathEscapeSegments(f.GetDiffFileName())),
}
return file
}