feat: vendor gitea 1.16.2

This commit is contained in:
2026-06-02 08:36:01 +00:00
parent 247cd60f69
commit 54798b4615
2145 changed files with 162965 additions and 126447 deletions
@@ -246,6 +246,8 @@ func DeleteRun(ctx context.Context, run *actions_model.ActionRun) error {
return err
}
actions_model.UpdateRepoRunsNumbers(ctx, repoID)
// Delete files on storage
for _, tas := range tasks {
removeTaskLog(ctx, tas)
@@ -12,9 +12,11 @@ import (
"code.gitea.io/gitea/models/db"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/modules/actions"
"code.gitea.io/gitea/modules/container"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/util"
webhook_module "code.gitea.io/gitea/modules/webhook"
notify_service "code.gitea.io/gitea/services/notify"
)
@@ -36,29 +38,115 @@ func StopEndlessTasks(ctx context.Context) error {
}
func notifyWorkflowJobStatusUpdate(ctx context.Context, jobs []*actions_model.ActionRunJob) {
if len(jobs) > 0 {
CreateCommitStatus(ctx, jobs...)
for _, job := range jobs {
_ = job.LoadAttributes(ctx)
notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil)
if len(jobs) == 0 {
return
}
// The input jobs may belong to different runs, so track each affected run.
runs := make(map[int64]*actions_model.ActionRun, len(jobs))
for _, job := range jobs {
if err := job.LoadAttributes(ctx); err != nil {
log.Error("Failed to load job attributes: %v", err)
continue
}
job := jobs[0]
notify_service.WorkflowRunStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job.Run)
CreateCommitStatusForRunJobs(ctx, job.Run, job)
notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil)
if _, ok := runs[job.RunID]; !ok {
runs[job.RunID] = job.Run
}
}
for _, run := range runs {
notify_service.WorkflowRunStatusUpdate(ctx, run.Repo, run.TriggerUser, run)
}
}
func CancelPreviousJobs(ctx context.Context, repoID int64, ref, workflowID string, event webhook_module.HookEventType) error {
jobs, err := actions_model.CancelPreviousJobs(ctx, repoID, ref, workflowID, event)
notifyWorkflowJobStatusUpdate(ctx, jobs)
if len(jobs) > 0 {
actions_model.UpdateRepoRunsNumbers(ctx, repoID)
}
EmitJobsIfReadyByJobs(jobs)
return err
}
func CleanRepoScheduleTasks(ctx context.Context, repo *repo_model.Repository) error {
jobs, err := actions_model.CleanRepoScheduleTasks(ctx, repo)
notifyWorkflowJobStatusUpdate(ctx, jobs)
if len(jobs) > 0 {
actions_model.UpdateRepoRunsNumbers(ctx, repo.ID)
}
EmitJobsIfReadyByJobs(jobs)
return err
}
func shouldBlockJobByConcurrency(ctx context.Context, job *actions_model.ActionRunJob) (bool, error) {
if job.RawConcurrency != "" && !job.IsConcurrencyEvaluated {
// when the job depends on other jobs, we cannot evaluate its concurrency, so it should be blocked and will be evaluated again when its dependencies are done
return true, nil
}
if job.ConcurrencyGroup == "" || job.ConcurrencyCancel {
return false, nil
}
runs, jobs, err := actions_model.GetConcurrentRunsAndJobs(ctx, job.RepoID, job.ConcurrencyGroup, []actions_model.Status{actions_model.StatusRunning})
if err != nil {
return false, fmt.Errorf("GetConcurrentRunsAndJobs: %w", err)
}
return len(runs) > 0 || len(jobs) > 0, nil
}
// PrepareToStartJobWithConcurrency prepares a job to start by its evaluated concurrency group and cancelling previous jobs if necessary.
// It returns the new status of the job (either StatusBlocked or StatusWaiting) and any error encountered during the process.
func PrepareToStartJobWithConcurrency(ctx context.Context, job *actions_model.ActionRunJob) (actions_model.Status, error) {
shouldBlock, err := shouldBlockJobByConcurrency(ctx, job)
if err != nil {
return actions_model.StatusBlocked, err
}
// even if the current job is blocked, we still need to cancel previous "waiting/blocked" jobs in the same concurrency group
jobs, err := actions_model.CancelPreviousJobsByJobConcurrency(ctx, job)
if err != nil {
return actions_model.StatusBlocked, fmt.Errorf("CancelPreviousJobsByJobConcurrency: %w", err)
}
notifyWorkflowJobStatusUpdate(ctx, jobs)
return util.Iif(shouldBlock, actions_model.StatusBlocked, actions_model.StatusWaiting), nil
}
func shouldBlockRunByConcurrency(ctx context.Context, actionRun *actions_model.ActionRun) (bool, error) {
if actionRun.ConcurrencyGroup == "" || actionRun.ConcurrencyCancel {
return false, nil
}
runs, jobs, err := actions_model.GetConcurrentRunsAndJobs(ctx, actionRun.RepoID, actionRun.ConcurrencyGroup, []actions_model.Status{actions_model.StatusRunning})
if err != nil {
return false, fmt.Errorf("find concurrent runs and jobs: %w", err)
}
return len(runs) > 0 || len(jobs) > 0, nil
}
// PrepareToStartRunWithConcurrency prepares a run to start by its evaluated concurrency group and cancelling previous jobs if necessary.
// It returns the new status of the run (either StatusBlocked or StatusWaiting) and any error encountered during the process.
func PrepareToStartRunWithConcurrency(ctx context.Context, run *actions_model.ActionRun) (actions_model.Status, error) {
shouldBlock, err := shouldBlockRunByConcurrency(ctx, run)
if err != nil {
return actions_model.StatusBlocked, err
}
// even if the current run is blocked, we still need to cancel previous "waiting/blocked" jobs in the same concurrency group
jobs, err := actions_model.CancelPreviousJobsByRunConcurrency(ctx, run)
if err != nil {
return actions_model.StatusBlocked, fmt.Errorf("CancelPreviousJobsByRunConcurrency: %w", err)
}
notifyWorkflowJobStatusUpdate(ctx, jobs)
return util.Iif(shouldBlock, actions_model.StatusBlocked, actions_model.StatusWaiting), nil
}
func stopTasks(ctx context.Context, opts actions_model.FindTaskOptions) error {
tasks, err := db.Find[actions_model.ActionTask](ctx, opts)
if err != nil {
@@ -96,6 +184,17 @@ func stopTasks(ctx context.Context, opts actions_model.FindTaskOptions) error {
notifyWorkflowJobStatusUpdate(ctx, jobs)
// Recompute counters post-commit for every repo whose runs may have flipped done-status.
reconcileRepos := make(container.Set[int64])
for _, job := range jobs {
reconcileRepos.Add(job.RepoID)
}
for repoID := range reconcileRepos {
actions_model.UpdateRepoRunsNumbers(ctx, repoID)
}
EmitJobsIfReadyByJobs(jobs)
return nil
}
@@ -103,7 +202,7 @@ func stopTasks(ctx context.Context, opts actions_model.FindTaskOptions) error {
func CancelAbandonedJobs(ctx context.Context) error {
jobs, err := db.Find[actions_model.ActionRunJob](ctx, actions_model.FindRunJobOptions{
Statuses: []actions_model.Status{actions_model.StatusWaiting, actions_model.StatusBlocked},
UpdatedBefore: timeutil.TimeStamp(time.Now().Add(-setting.Actions.AbandonedJobTimeout).Unix()),
UpdatedBefore: timeutil.TimeStampNow().AddDuration(-setting.Actions.AbandonedJobTimeout),
})
if err != nil {
log.Warn("find abandoned tasks: %v", err)
@@ -114,6 +213,8 @@ func CancelAbandonedJobs(ctx context.Context) error {
// Collect one job per run to send workflow run status update
updatedRuns := map[int64]*actions_model.ActionRunJob{}
updatedJobs := []*actions_model.ActionRunJob{}
updatedRepoIDs := make(container.Set[int64])
for _, job := range jobs {
job.Status = actions_model.StatusCancelled
@@ -130,14 +231,19 @@ func CancelAbandonedJobs(ctx context.Context) error {
updated = n > 0
if updated && job.Run.Status.IsDone() {
updatedRuns[job.RunID] = job
updatedRepoIDs.Add(job.RepoID)
}
return nil
}); err != nil {
log.Warn("cancel abandoned job %v: %v", job.ID, err)
// go on
}
CreateCommitStatus(ctx, job)
if job.Run == nil || job.Run.Repo == nil {
continue // error occurs during loading attributes, the following code that depends on "Run.Repo" will fail, so ignore and skip
}
CreateCommitStatusForRunJobs(ctx, job.Run, job)
if updated {
updatedJobs = append(updatedJobs, job)
notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil)
}
}
@@ -145,6 +251,11 @@ func CancelAbandonedJobs(ctx context.Context) error {
for _, job := range updatedRuns {
notify_service.WorkflowRunStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job.Run)
}
EmitJobsIfReadyByJobs(updatedJobs)
for repoID := range updatedRepoIDs {
actions_model.UpdateRepoRunsNumbers(ctx, repoID)
}
return nil
}
@@ -13,49 +13,84 @@ import (
actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/models/db"
git_model "code.gitea.io/gitea/models/git"
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
actions_module "code.gitea.io/gitea/modules/actions"
"code.gitea.io/gitea/modules/actions/jobparser"
"code.gitea.io/gitea/modules/commitstatus"
git "code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/util"
webhook_module "code.gitea.io/gitea/modules/webhook"
commitstatus_service "code.gitea.io/gitea/services/repository/commitstatus"
"github.com/nektos/act/pkg/jobparser"
)
// CreateCommitStatus creates a commit status for the given job.
// CreateCommitStatusForRunJobs creates a commit status for the given job if it has a supported event and related commit.
// It won't return an error failed, but will log it, because it's not critical.
func CreateCommitStatus(ctx context.Context, jobs ...*actions_model.ActionRunJob) {
func CreateCommitStatusForRunJobs(ctx context.Context, run *actions_model.ActionRun, jobs ...*actions_model.ActionRunJob) {
// don't create commit status for cron job
if run.ScheduleID != 0 {
return
}
event, commitID, err := getCommitStatusEventNameAndCommitID(run)
if err != nil {
log.Error("GetCommitStatusEventNameAndSHA: %v", err)
}
if event == "" || commitID == "" {
return // unsupported event, or no commit id, or error occurs, do nothing
}
if err = run.LoadAttributes(ctx); err != nil {
log.Error("run.LoadAttributes: %v", err)
return
}
for _, job := range jobs {
if err := createCommitStatus(ctx, job); err != nil {
if err = createCommitStatus(ctx, run.Repo, event, commitID, run, job); err != nil {
log.Error("Failed to create commit status for job %d: %v", job.ID, err)
}
}
}
func createCommitStatus(ctx context.Context, job *actions_model.ActionRunJob) error {
if err := job.LoadAttributes(ctx); err != nil {
return fmt.Errorf("load run: %w", err)
func GetRunsFromCommitStatuses(ctx context.Context, statuses []*git_model.CommitStatus) ([]*actions_model.ActionRun, error) {
runMap := make(map[int64]*actions_model.ActionRun)
for _, status := range statuses {
runID, _, ok := status.ParseGiteaActionsTargetURL(ctx)
if !ok {
continue
}
_, ok = runMap[runID]
if !ok {
run, err := actions_model.GetRunByRepoAndID(ctx, status.RepoID, runID)
if err != nil {
if errors.Is(err, util.ErrNotExist) {
// the run may be deleted manually, just skip it
continue
}
return nil, fmt.Errorf("GetRunByRepoAndID: %w", err)
}
runMap[runID] = run
}
}
runs := make([]*actions_model.ActionRun, 0, len(runMap))
for _, run := range runMap {
runs = append(runs, run)
}
return runs, nil
}
run := job.Run
var (
sha string
event string
)
func getCommitStatusEventNameAndCommitID(run *actions_model.ActionRun) (event, commitID string, _ error) {
switch run.Event {
case webhook_module.HookEventPush:
event = "push"
payload, err := run.GetPushEventPayload()
if err != nil {
return fmt.Errorf("GetPushEventPayload: %w", err)
return "", "", fmt.Errorf("GetPushEventPayload: %w", err)
}
if payload.HeadCommit == nil {
return errors.New("head commit is missing in event payload")
return "", "", errors.New("head commit is missing in event payload")
}
sha = payload.HeadCommit.ID
commitID = payload.HeadCommit.ID
case // pull_request
webhook_module.HookEventPullRequest,
webhook_module.HookEventPullRequestSync,
@@ -70,83 +105,94 @@ func createCommitStatus(ctx context.Context, job *actions_model.ActionRunJob) er
}
payload, err := run.GetPullRequestEventPayload()
if err != nil {
return fmt.Errorf("GetPullRequestEventPayload: %w", err)
return "", "", fmt.Errorf("GetPullRequestEventPayload: %w", err)
}
if payload.PullRequest == nil {
return errors.New("pull request is missing in event payload")
return "", "", errors.New("pull request is missing in event payload")
} else if payload.PullRequest.Head == nil {
return errors.New("head of pull request is missing in event payload")
return "", "", errors.New("head of pull request is missing in event payload")
}
sha = payload.PullRequest.Head.Sha
commitID = payload.PullRequest.Head.Sha
case // pull_request_review events share the same PullRequestPayload as pull_request
webhook_module.HookEventPullRequestReviewApproved,
webhook_module.HookEventPullRequestReviewRejected,
webhook_module.HookEventPullRequestReviewComment:
event = run.TriggerEvent
payload, err := run.GetPullRequestEventPayload()
if err != nil {
return "", "", fmt.Errorf("GetPullRequestEventPayload: %w", err)
}
if payload.PullRequest == nil {
return "", "", errors.New("pull request is missing in event payload")
} else if payload.PullRequest.Head == nil {
return "", "", errors.New("head of pull request is missing in event payload")
}
commitID = payload.PullRequest.Head.Sha
case webhook_module.HookEventRelease:
event = string(run.Event)
sha = run.CommitSHA
default:
return nil
commitID = run.CommitSHA
default: // do nothing, return empty
}
return event, commitID, nil
}
repo := run.Repo
func createCommitStatus(ctx context.Context, repo *repo_model.Repository, event, commitID string, run *actions_model.ActionRun, job *actions_model.ActionRunJob) error {
// TODO: store workflow name as a field in ActionRun to avoid parsing
runName := path.Base(run.WorkflowID)
if wfs, err := jobparser.Parse(job.WorkflowPayload); err == nil && len(wfs) > 0 {
runName = wfs[0].Name
}
ctxname := fmt.Sprintf("%s / %s (%s)", runName, job.Name, event)
ctxname = strings.TrimSpace(ctxname) // git_model.NewCommitStatus also trims spaces
ctxName := strings.TrimSpace(fmt.Sprintf("%s / %s (%s)", runName, job.Name, event)) // git_model.NewCommitStatus also trims spaces
state := toCommitStatus(job.Status)
if statuses, err := git_model.GetLatestCommitStatus(ctx, repo.ID, sha, db.ListOptionsAll); err == nil {
for _, v := range statuses {
if v.Context == ctxname {
if v.State == state {
// no need to update
return nil
}
break
}
}
} else {
targetURL := fmt.Sprintf("%s/jobs/%d", run.Link(), job.ID)
description := toCommitStatusDescription(job)
statuses, err := git_model.GetLatestCommitStatus(ctx, repo.ID, commitID, db.ListOptionsAll)
if err != nil {
return fmt.Errorf("GetLatestCommitStatus: %w", err)
}
description := ""
switch job.Status {
// TODO: if we want support description in different languages, we need to support i18n placeholders in it
case actions_model.StatusSuccess:
description = fmt.Sprintf("Successful in %s", job.Duration())
case actions_model.StatusFailure:
description = fmt.Sprintf("Failing after %s", job.Duration())
case actions_model.StatusCancelled:
description = "Has been cancelled"
case actions_model.StatusSkipped:
description = "Has been skipped"
case actions_model.StatusRunning:
description = "Has started running"
case actions_model.StatusWaiting:
description = "Waiting to run"
case actions_model.StatusBlocked:
description = "Blocked by required conditions"
}
index, err := getIndexOfJob(ctx, job)
if err != nil {
return fmt.Errorf("getIndexOfJob: %w", err)
for _, v := range statuses {
if v.Context == ctxName {
if v.State == state && v.TargetURL == targetURL && v.Description == description {
return nil
}
break
}
}
creator := user_model.NewActionsUser()
commitID, err := git.NewIDFromString(sha)
if err != nil {
return fmt.Errorf("HashTypeInterfaceFromHashString: %w", err)
}
status := git_model.CommitStatus{
SHA: sha,
TargetURL: fmt.Sprintf("%s/jobs/%d", run.Link(), index),
SHA: commitID,
TargetURL: targetURL,
Description: description,
Context: ctxname,
CreatorID: creator.ID,
Context: ctxName,
State: state,
CreatorID: creator.ID,
}
return commitstatus_service.CreateCommitStatus(ctx, repo, creator, commitID.String(), &status)
return commitstatus_service.CreateCommitStatus(ctx, repo, creator, commitID, &status)
}
func toCommitStatusDescription(job *actions_model.ActionRunJob) string {
switch job.Status {
// TODO: if we want support description in different languages, we need to support i18n placeholders in it
case actions_model.StatusSuccess:
return fmt.Sprintf("Successful in %s", job.Duration())
case actions_model.StatusFailure:
return fmt.Sprintf("Failing after %s", job.Duration())
case actions_model.StatusCancelled:
return "Has been cancelled"
case actions_model.StatusSkipped:
return "Has been skipped"
case actions_model.StatusRunning:
return "Has started running"
case actions_model.StatusWaiting:
return "Waiting to run"
case actions_model.StatusBlocked:
return "Blocked by required conditions"
default:
return fmt.Sprintf("Unknown status: %d", job.Status)
}
}
func toCommitStatus(status actions_model.Status) commitstatus.CommitStatusState {
@@ -163,17 +209,3 @@ func toCommitStatus(status actions_model.Status) commitstatus.CommitStatusState
return commitstatus.CommitStatusError
}
}
func getIndexOfJob(ctx context.Context, job *actions_model.ActionRunJob) (int, error) {
// TODO: store job index as a field in ActionRunJob to avoid this
jobs, err := actions_model.GetRunJobsByRunID(ctx, job.RunID)
if err != nil {
return 0, err
}
for i, v := range jobs {
if v.ID == job.ID {
return i, nil
}
}
return 0, nil
}
@@ -0,0 +1,88 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"testing"
actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/models/db"
git_model "code.gitea.io/gitea/models/git"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
"code.gitea.io/gitea/modules/commitstatus"
"code.gitea.io/gitea/modules/gitrepo"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCreateCommitStatus_Dedupe(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
gitRepo, err := gitrepo.OpenRepository(t.Context(), repo)
require.NoError(t, err)
defer gitRepo.Close()
commit, err := gitRepo.GetBranchCommit(repo.DefaultBranch)
require.NoError(t, err)
run := &actions_model.ActionRun{
ID: 99001,
RepoID: repo.ID,
Repo: repo,
WorkflowID: "status-dedupe-test.yaml",
}
job := &actions_model.ActionRunJob{
ID: 99002,
RunID: run.ID,
RepoID: repo.ID,
Name: "status-dedupe-job",
Status: actions_model.StatusWaiting,
}
expectedContext := "status-dedupe-test.yaml / status-dedupe-job (push)"
expectedTargetURL := run.Link() + "/jobs/99002"
require.NoError(t, createCommitStatus(t.Context(), repo, "push", commit.ID.String(), run, job))
statuses := findCommitStatusesForContext(t, repo.ID, commit.ID.String(), expectedContext)
require.Len(t, statuses, 1)
assert.Equal(t, commitstatus.CommitStatusPending, statuses[0].State)
assert.Equal(t, "Waiting to run", statuses[0].Description)
assert.Equal(t, expectedTargetURL, statuses[0].TargetURL)
job.Status = actions_model.StatusRunning
require.NoError(t, createCommitStatus(t.Context(), repo, "push", commit.ID.String(), run, job))
statuses = findCommitStatusesForContext(t, repo.ID, commit.ID.String(), expectedContext)
require.Len(t, statuses, 2)
assert.Equal(t, "Waiting to run", statuses[0].Description)
assert.Equal(t, commitstatus.CommitStatusPending, statuses[1].State)
assert.Equal(t, "Has started running", statuses[1].Description)
assert.Equal(t, expectedTargetURL, statuses[1].TargetURL)
require.NoError(t, createCommitStatus(t.Context(), repo, "push", commit.ID.String(), run, job))
statuses = findCommitStatusesForContext(t, repo.ID, commit.ID.String(), expectedContext)
assert.Len(t, statuses, 2)
job.Status = actions_model.StatusSuccess
require.NoError(t, createCommitStatus(t.Context(), repo, "push", commit.ID.String(), run, job))
statuses = findCommitStatusesForContext(t, repo.ID, commit.ID.String(), expectedContext)
require.Len(t, statuses, 3)
assert.Equal(t, commitstatus.CommitStatusSuccess, statuses[2].State)
}
func findCommitStatusesForContext(t *testing.T, repoID int64, sha, context string) []*git_model.CommitStatus {
t.Helper()
var statuses []*git_model.CommitStatus
err := db.GetEngine(t.Context()).
Where("repo_id = ? AND sha = ? AND context = ?", repoID, sha, context).
Asc("`index`").
Find(&statuses)
require.NoError(t, err)
return statuses
}
@@ -0,0 +1,121 @@
// Copyright 2025 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/modules/actions/jobparser"
"code.gitea.io/gitea/modules/json"
api "code.gitea.io/gitea/modules/structs"
act_model "github.com/nektos/act/pkg/model"
"go.yaml.in/yaml/v4"
)
// EvaluateRunConcurrencyFillModel evaluates the expressions in a run-level (workflow) concurrency,
// and fills the run's model fields with `concurrency.group` and `concurrency.cancel-in-progress`.
// Workflow-level concurrency doesn't depend on the job outputs, so it can always be evaluated if there is no syntax error.
// See https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#concurrency
func EvaluateRunConcurrencyFillModel(ctx context.Context, run *actions_model.ActionRun, wfRawConcurrency *act_model.RawConcurrency, vars map[string]string, inputs map[string]any) error {
if err := run.LoadAttributes(ctx); err != nil {
return fmt.Errorf("run LoadAttributes: %w", err)
}
actionsRunCtx := GenerateGiteaContext(run, nil)
jobResults := map[string]*jobparser.JobResult{"": {}}
if inputs == nil {
var err error
inputs, err = getInputsFromRun(run)
if err != nil {
return fmt.Errorf("get inputs: %w", err)
}
}
rawConcurrency, err := yaml.Marshal(wfRawConcurrency)
if err != nil {
return fmt.Errorf("marshal raw concurrency: %w", err)
}
run.RawConcurrency = string(rawConcurrency)
run.ConcurrencyGroup, run.ConcurrencyCancel, err = jobparser.EvaluateConcurrency(wfRawConcurrency, "", nil, actionsRunCtx, jobResults, vars, inputs)
if err != nil {
return fmt.Errorf("evaluate concurrency: %w", err)
}
return nil
}
func findJobNeedsAndFillJobResults(ctx context.Context, job *actions_model.ActionRunJob) (map[string]*jobparser.JobResult, error) {
taskNeeds, err := FindTaskNeeds(ctx, job)
if err != nil {
return nil, fmt.Errorf("find task needs: %w", err)
}
jobResults := make(map[string]*jobparser.JobResult, len(taskNeeds))
for jobID, taskNeed := range taskNeeds {
jobResult := &jobparser.JobResult{
Result: taskNeed.Result.String(),
Outputs: taskNeed.Outputs,
}
jobResults[jobID] = jobResult
}
jobResults[job.JobID] = &jobparser.JobResult{
Needs: job.Needs,
}
return jobResults, nil
}
// EvaluateJobConcurrencyFillModel evaluates the expressions in a job-level concurrency,
// and fills the job's model fields with `concurrency.group` and `concurrency.cancel-in-progress`.
// Job-level concurrency may depend on other job's outputs (via `needs`): `concurrency.group: my-group-${{ needs.job1.outputs.out1 }}`
// If the needed jobs haven't been executed yet, this evaluation will also fail.
// See https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob_idconcurrency
func EvaluateJobConcurrencyFillModel(ctx context.Context, run *actions_model.ActionRun, actionRunJob *actions_model.ActionRunJob, vars map[string]string, inputs map[string]any) error {
if err := actionRunJob.LoadAttributes(ctx); err != nil {
return fmt.Errorf("job LoadAttributes: %w", err)
}
var rawConcurrency act_model.RawConcurrency
if err := yaml.Unmarshal([]byte(actionRunJob.RawConcurrency), &rawConcurrency); err != nil {
return fmt.Errorf("unmarshal raw concurrency: %w", err)
}
actionsJobCtx := GenerateGiteaContext(run, actionRunJob)
jobResults, err := findJobNeedsAndFillJobResults(ctx, actionRunJob)
if err != nil {
return fmt.Errorf("find job needs and fill job results: %w", err)
}
if inputs == nil {
var err error
inputs, err = getInputsFromRun(run)
if err != nil {
return fmt.Errorf("get inputs: %w", err)
}
}
workflowJob, err := actionRunJob.ParseJob()
if err != nil {
return fmt.Errorf("load job %d: %w", actionRunJob.ID, err)
}
actionRunJob.ConcurrencyGroup, actionRunJob.ConcurrencyCancel, err = jobparser.EvaluateConcurrency(&rawConcurrency, actionRunJob.JobID, workflowJob, actionsJobCtx, jobResults, vars, inputs)
if err != nil {
return fmt.Errorf("evaluate concurrency: %w", err)
}
actionRunJob.IsConcurrencyEvaluated = true
return nil
}
func getInputsFromRun(run *actions_model.ActionRun) (map[string]any, error) {
if run.Event != "workflow_dispatch" {
return map[string]any{}, nil
}
var payload api.WorkflowDispatchPayload
if err := json.Unmarshal([]byte(run.EventPayload), &payload); err != nil {
return nil, err
}
return payload.Inputs, nil
}
@@ -73,7 +73,7 @@ func GenerateGiteaContext(run *actions_model.ActionRun, job *actions_model.Actio
"repository_owner": run.Repo.OwnerName, // string, The repository owner's name. For example, Codertocat.
"repositoryUrl": run.Repo.HTMLURL(), // string, The Git URL to the repository. For example, git://github.com/codertocat/hello-world.git.
"retention_days": "", // string, The number of days that workflow run logs and artifacts are kept.
"run_id": "", // string, A unique number for each workflow run within a repository. This number does not change if you re-run the workflow run.
"run_id": strconv.FormatInt(run.ID, 10), // string, A unique number for each workflow run within a repository. This number does not change if you re-run the workflow run.
"run_number": strconv.FormatInt(run.Index, 10), // string, A unique number for each run of a particular workflow in a repository. This number begins at 1 for the workflow's first run, and increments with each new run. This number does not change if you re-run the workflow run.
"run_attempt": "", // string, A unique number for each attempt of a particular workflow run in a repository. This number begins at 1 for the workflow run's first attempt, and increments with each re-run.
"secret_source": "Actions", // string, The source of a secret used in a workflow. Possible values are None, Actions, Dependabot, or Codespaces.
@@ -104,7 +104,7 @@ type TaskNeed struct {
// FindTaskNeeds finds the `needs` for the task by the task's job
func FindTaskNeeds(ctx context.Context, job *actions_model.ActionRunJob) (map[string]*TaskNeed, error) {
if len(job.Needs) == 0 {
return nil, nil
return nil, nil //nolint:nilnil // return nil when the job has no needs
}
needs := container.SetOf(job.Needs...)
@@ -4,14 +4,86 @@
package actions
import (
"strconv"
"testing"
actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/models/unittest"
act_model "github.com/nektos/act/pkg/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestEvaluateRunConcurrency_RunIDFallback(t *testing.T) {
// Unit-level check that EvaluateRunConcurrencyFillModel resolves
// github.run_id from run.ID. The full-flow regression — that run.ID is
// non-zero by the time evaluation happens — is in
// TestPrepareRunAndInsert_ExpressionsSeeRunID.
assert.NoError(t, unittest.PrepareTestDatabase())
ctx := t.Context()
runA := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 791})
runB := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 792})
expr := &act_model.RawConcurrency{
Group: "${{ github.workflow }}-${{ github.head_ref || github.run_id }}",
CancelInProgress: "true",
}
assert.NoError(t, EvaluateRunConcurrencyFillModel(ctx, runA, expr, nil, nil))
assert.NoError(t, EvaluateRunConcurrencyFillModel(ctx, runB, expr, nil, nil))
assert.Contains(t, runA.ConcurrencyGroup, "791")
assert.Contains(t, runB.ConcurrencyGroup, "792")
assert.NotEqual(t, runA.ConcurrencyGroup, runB.ConcurrencyGroup)
}
func TestPrepareRunAndInsert_ExpressionsSeeRunID(t *testing.T) {
// Regression for the cross-branch concurrency leak: github.run_id must
// be available during BOTH jobparser.Parse (run-name) and workflow-level
// concurrency evaluation. Re-ordering db.Insert relative to either step
// would leave run.ID at 0 and break this test.
assert.NoError(t, unittest.PrepareTestDatabase())
ctx := t.Context()
content := []byte(`name: cross-branch
run-name: "Run ${{ github.run_id }}"
on: push
concurrency:
group: group-${{ github.run_id }}
cancel-in-progress: true
jobs:
hello:
runs-on: ubuntu-latest
steps:
- run: echo hi
`)
run := &actions_model.ActionRun{
Title: "before parse",
RepoID: 4,
OwnerID: 1,
WorkflowID: "expr-runid.yaml",
TriggerUserID: 1,
Ref: "refs/heads/master",
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
Event: "push",
TriggerEvent: "push",
EventPayload: "{}",
}
require.NoError(t, PrepareRunAndInsert(ctx, content, run, nil))
require.Positive(t, run.ID)
persisted := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
runIDStr := strconv.FormatInt(run.ID, 10)
assert.Equal(t, "Run "+runIDStr, persisted.Title)
assert.Equal(t, "group-"+runIDStr, persisted.ConcurrencyGroup)
// Rerun reads raw_concurrency from the DB to re-evaluate the group;
// see services/actions/rerun.go. Must survive the insert.
assert.NotEmpty(t, persisted.RawConcurrency)
}
func TestFindTaskNeeds(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
@@ -18,7 +18,6 @@ import (
func TestMain(m *testing.M) {
unittest.MainTest(m)
os.Exit(m.Run())
}
func TestInitToken(t *testing.T) {
@@ -23,8 +23,6 @@ type API interface {
CreateVariable(*context.APIContext)
// UpdateVariable update a variable
UpdateVariable(*context.APIContext)
// GetRegistrationToken get registration token
GetRegistrationToken(*context.APIContext)
// CreateRegistrationToken get registration token
CreateRegistrationToken(*context.APIContext)
// ListRunners list runners
@@ -33,6 +31,8 @@ type API interface {
GetRunner(*context.APIContext)
// DeleteRunner delete runner
DeleteRunner(*context.APIContext)
// UpdateRunner update runner
UpdateRunner(*context.APIContext)
// ListWorkflowJobs list jobs
ListWorkflowJobs(*context.APIContext)
// ListWorkflowRuns list runs
@@ -10,12 +10,14 @@ import (
actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/modules/container"
"code.gitea.io/gitea/modules/graceful"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/queue"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
notify_service "code.gitea.io/gitea/services/notify"
"github.com/nektos/act/pkg/jobparser"
"xorm.io/builder"
)
@@ -25,7 +27,7 @@ type jobUpdate struct {
RunID int64
}
func EmitJobsIfReady(runID int64) error {
func EmitJobsIfReadyByRun(runID int64) error {
err := jobEmitterQueue.Push(&jobUpdate{
RunID: runID,
})
@@ -35,30 +37,191 @@ func EmitJobsIfReady(runID int64) error {
return err
}
func EmitJobsIfReadyByJobs(jobs []*actions_model.ActionRunJob) {
checkedRuns := make(container.Set[int64])
for _, job := range jobs {
if !job.Status.IsDone() || checkedRuns.Contains(job.RunID) {
continue
}
if err := EmitJobsIfReadyByRun(job.RunID); err != nil {
log.Error("Check jobs of run %d: %v", job.RunID, err)
}
checkedRuns.Add(job.RunID)
}
}
func jobEmitterQueueHandler(items ...*jobUpdate) []*jobUpdate {
ctx := graceful.GetManager().ShutdownContext()
var ret []*jobUpdate
for _, update := range items {
if err := checkJobsOfRun(ctx, update.RunID); err != nil {
if err := checkJobsByRunID(ctx, update.RunID); err != nil {
log.Error("check run %d: %v", update.RunID, err)
ret = append(ret, update)
}
}
return ret
}
func checkJobsOfRun(ctx context.Context, runID int64) error {
jobs, err := db.Find[actions_model.ActionRunJob](ctx, actions_model.FindRunJobOptions{RunID: runID})
func checkJobsByRunID(ctx context.Context, runID int64) error {
run, exist, err := db.GetByID[actions_model.ActionRun](ctx, runID)
if !exist {
return fmt.Errorf("run %d does not exist", runID)
}
if err != nil {
return fmt.Errorf("get action run: %w", err)
}
var jobs, updatedJobs []*actions_model.ActionRunJob
if err := db.WithTx(ctx, func(ctx context.Context) error {
// check jobs of the current run
if js, ujs, err := checkJobsOfRun(ctx, run); err != nil {
return err
} else {
jobs = append(jobs, js...)
updatedJobs = append(updatedJobs, ujs...)
}
if js, ujs, err := checkRunConcurrency(ctx, run); err != nil {
return err
} else {
jobs = append(jobs, js...)
updatedJobs = append(updatedJobs, ujs...)
}
return nil
}); err != nil {
return err
}
var updatedjobs []*actions_model.ActionRunJob
if err := db.WithTx(ctx, func(ctx context.Context) error {
idToJobs := make(map[string][]*actions_model.ActionRunJob, len(jobs))
CreateCommitStatusForRunJobs(ctx, run, jobs...)
for _, job := range updatedJobs {
_ = job.LoadAttributes(ctx)
notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil)
}
runJobs := make(map[int64][]*actions_model.ActionRunJob)
for _, job := range jobs {
runJobs[job.RunID] = append(runJobs[job.RunID], job)
}
runUpdatedJobs := make(map[int64][]*actions_model.ActionRunJob)
for _, uj := range updatedJobs {
runUpdatedJobs[uj.RunID] = append(runUpdatedJobs[uj.RunID], uj)
}
for runID, js := range runJobs {
if len(runUpdatedJobs[runID]) == 0 {
continue
}
runUpdated := true
for _, job := range js {
if !job.Status.IsDone() {
runUpdated = false
break
}
}
if runUpdated {
NotifyWorkflowRunStatusUpdateWithReload(ctx, js[0])
}
}
return nil
}
// findBlockedRunByConcurrency finds the blocked concurrent run in a repo and returns `nil, nil` when there is no blocked run.
func findBlockedRunByConcurrency(ctx context.Context, repoID int64, concurrencyGroup string) (*actions_model.ActionRun, error) {
if concurrencyGroup == "" {
return nil, nil //nolint:nilnil // return nil to indicate that no blocked run exists
}
cRuns, cJobs, err := actions_model.GetConcurrentRunsAndJobs(ctx, repoID, concurrencyGroup, []actions_model.Status{actions_model.StatusBlocked})
if err != nil {
return nil, fmt.Errorf("find concurrent runs and jobs: %w", err)
}
// There can be at most one blocked run or job
var concurrentRun *actions_model.ActionRun
if len(cRuns) > 0 {
concurrentRun = cRuns[0]
} else if len(cJobs) > 0 {
jobRun, exist, err := db.GetByID[actions_model.ActionRun](ctx, cJobs[0].RunID)
if !exist {
return nil, fmt.Errorf("run %d does not exist", cJobs[0].RunID)
}
if err != nil {
return nil, fmt.Errorf("get run by job %d: %w", cJobs[0].ID, err)
}
concurrentRun = jobRun
}
return concurrentRun, nil
}
func checkRunConcurrency(ctx context.Context, run *actions_model.ActionRun) (jobs, updatedJobs []*actions_model.ActionRunJob, err error) {
checkedConcurrencyGroup := make(container.Set[string])
collect := func(concurrencyGroup string) error {
concurrentRun, err := findBlockedRunByConcurrency(ctx, run.RepoID, concurrencyGroup)
if err != nil {
return fmt.Errorf("find blocked run by concurrency: %w", err)
}
if concurrentRun != nil && !concurrentRun.NeedApproval {
js, ujs, err := checkJobsOfRun(ctx, concurrentRun)
if err != nil {
return err
}
jobs = append(jobs, js...)
updatedJobs = append(updatedJobs, ujs...)
}
checkedConcurrencyGroup.Add(concurrencyGroup)
return nil
}
// check run (workflow-level) concurrency
if run.ConcurrencyGroup != "" {
if err := collect(run.ConcurrencyGroup); err != nil {
return nil, nil, err
}
}
// check job concurrency
runJobs, err := db.Find[actions_model.ActionRunJob](ctx, actions_model.FindRunJobOptions{RunID: run.ID})
if err != nil {
return nil, nil, fmt.Errorf("find run %d jobs: %w", run.ID, err)
}
for _, job := range runJobs {
if !job.Status.IsDone() {
continue
}
if job.ConcurrencyGroup == "" || checkedConcurrencyGroup.Contains(job.ConcurrencyGroup) {
continue
}
if err := collect(job.ConcurrencyGroup); err != nil {
return nil, nil, err
}
}
return jobs, updatedJobs, nil
}
func checkJobsOfRun(ctx context.Context, run *actions_model.ActionRun) (jobs, updatedJobs []*actions_model.ActionRunJob, err error) {
jobs, err = db.Find[actions_model.ActionRunJob](ctx, actions_model.FindRunJobOptions{RunID: run.ID})
if err != nil {
return nil, nil, err
}
// The resolver below only considers needs and job-level concurrency, so a run blocked
// solely by run-level concurrency would have its jobs unblocked here. checkRunConcurrency
// re-evaluates when the holding run finishes.
if run.Status.IsBlocked() {
shouldBlock, err := shouldBlockRunByConcurrency(ctx, run)
if err != nil {
return nil, nil, fmt.Errorf("shouldBlockRunByConcurrency: %w", err)
}
if shouldBlock {
return jobs, nil, nil
}
}
vars, err := actions_model.GetVariablesOfRun(ctx, run)
if err != nil {
return nil, nil, err
}
if err = db.WithTx(ctx, func(ctx context.Context) error {
for _, job := range jobs {
idToJobs[job.JobID] = append(idToJobs[job.JobID], job)
job.Run = run
}
updates := newJobStatusResolver(jobs).Resolve()
updates := newJobStatusResolver(jobs, vars).Resolve(ctx)
for _, job := range jobs {
if status, ok := updates[job.ID]; ok {
job.Status = status
@@ -67,31 +230,15 @@ func checkJobsOfRun(ctx context.Context, runID int64) error {
} else if n != 1 {
return fmt.Errorf("no affected for updating blocked job %v", job.ID)
}
updatedjobs = append(updatedjobs, job)
updatedJobs = append(updatedJobs, job)
}
}
return nil
}); err != nil {
return err
return nil, nil, err
}
CreateCommitStatus(ctx, jobs...)
for _, job := range updatedjobs {
_ = 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
return jobs, updatedJobs, nil
}
func NotifyWorkflowRunStatusUpdateWithReload(ctx context.Context, job *actions_model.ActionRunJob) {
@@ -101,15 +248,19 @@ func NotifyWorkflowRunStatusUpdateWithReload(ctx context.Context, job *actions_m
return
}
notify_service.WorkflowRunStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job.Run)
// Recomputes the repository's num_action_runs / num_closed_action_runs counters since the run's status changed
actions_model.UpdateRepoRunsNumbers(ctx, job.RepoID)
}
type jobStatusResolver struct {
statuses map[int64]actions_model.Status
needs map[int64][]int64
jobMap map[int64]*actions_model.ActionRunJob
vars map[string]string
}
func newJobStatusResolver(jobs actions_model.ActionJobList) *jobStatusResolver {
func newJobStatusResolver(jobs actions_model.ActionJobList, vars map[string]string) *jobStatusResolver {
idToJobs := make(map[string][]*actions_model.ActionRunJob, len(jobs))
jobMap := make(map[int64]*actions_model.ActionRunJob)
for _, job := range jobs {
@@ -131,13 +282,14 @@ func newJobStatusResolver(jobs actions_model.ActionJobList) *jobStatusResolver {
statuses: statuses,
needs: needs,
jobMap: jobMap,
vars: vars,
}
}
func (r *jobStatusResolver) Resolve() map[int64]actions_model.Status {
func (r *jobStatusResolver) Resolve(ctx context.Context) map[int64]actions_model.Status {
ret := map[int64]actions_model.Status{}
for i := 0; i < len(r.statuses); i++ {
updated := r.resolve()
updated := r.resolve(ctx)
if len(updated) == 0 {
return ret
}
@@ -149,43 +301,86 @@ func (r *jobStatusResolver) Resolve() map[int64]actions_model.Status {
return ret
}
func (r *jobStatusResolver) resolve() map[int64]actions_model.Status {
func (r *jobStatusResolver) resolveCheckNeeds(id int64) (allDone, allSucceed bool) {
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
}
}
return allDone, allSucceed
}
func (r *jobStatusResolver) resolveJobHasIfCondition(actionRunJob *actions_model.ActionRunJob) (hasIf bool) {
// FIXME evaluate this on the server side
if job, err := actionRunJob.ParseJob(); err == nil {
return len(job.If.Value) > 0
}
return hasIf
}
func (r *jobStatusResolver) resolve(ctx context.Context) map[int64]actions_model.Status {
ret := map[int64]actions_model.Status{}
for id, status := range r.statuses {
actionRunJob := r.jobMap[id]
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
allDone, allSucceed := r.resolveCheckNeeds(id)
if !allDone {
continue
}
// update concurrency and check whether the job can run now
err := updateConcurrencyEvaluationForJobWithNeeds(ctx, actionRunJob, r.vars)
if err != nil {
// The err can be caused by different cases: database error, or syntax error, or the needed jobs haven't completed
// At the moment there is no way to distinguish them.
// Actually, for most cases, the error is caused by "syntax error" / "the needed jobs haven't completed (skipped?)"
// TODO: if workflow or concurrency expression has syntax error, there should be a user error message, need to show it to end users
log.Debug("updateConcurrencyEvaluationForJobWithNeeds failed, this job will stay blocked: job: %d, err: %v", id, err)
continue
}
shouldStartJob := true
if !allSucceed {
// Not all dependent jobs completed successfully:
// * if the job has "if" condition, it can be started, then the act_runner will evaluate the "if" condition.
// * otherwise, the job should be skipped.
shouldStartJob = r.resolveJobHasIfCondition(actionRunJob)
}
newStatus := util.Iif(shouldStartJob, actions_model.StatusWaiting, actions_model.StatusSkipped)
if newStatus == actions_model.StatusWaiting {
newStatus, err = PrepareToStartJobWithConcurrency(ctx, actionRunJob)
if err != nil {
log.Error("ShouldBlockJobByConcurrency failed, this job will stay blocked: job: %d, err: %v", id, err)
}
}
if allDone {
if allSucceed {
ret[id] = actions_model.StatusWaiting
} else {
// Check if the job has an "if" condition
hasIf := false
if wfJobs, _ := jobparser.Parse(r.jobMap[id].WorkflowPayload); len(wfJobs) == 1 {
_, wfJob := wfJobs[0].Job()
hasIf = len(wfJob.If.Value) > 0
}
if hasIf {
// act_runner will check the "if" condition
ret[id] = actions_model.StatusWaiting
} else {
// If the "if" condition is empty and not all dependent jobs completed successfully,
// the job should be skipped.
ret[id] = actions_model.StatusSkipped
}
}
if newStatus != actions_model.StatusBlocked {
ret[id] = newStatus
}
}
return ret
}
func updateConcurrencyEvaluationForJobWithNeeds(ctx context.Context, actionRunJob *actions_model.ActionRunJob, vars map[string]string) error {
if setting.IsInTesting && actionRunJob.RepoID == 0 {
return nil // for testing purpose only, no repo, no evaluation
}
err := EvaluateJobConcurrencyFillModel(ctx, actionRunJob.Run, actionRunJob, vars, nil)
if err != nil {
return fmt.Errorf("evaluate job concurrency: %w", err)
}
if _, err := actions_model.UpdateRunJob(ctx, actionRunJob, nil, "concurrency_group", "concurrency_cancel", "is_concurrency_evaluated"); err != nil {
return fmt.Errorf("update run job: %w", err)
}
return nil
}
@@ -7,6 +7,8 @@ import (
"testing"
actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/unittest"
"github.com/stretchr/testify/assert"
)
@@ -129,8 +131,125 @@ jobs:
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := newJobStatusResolver(tt.jobs)
assert.Equal(t, tt.want, r.Resolve())
r := newJobStatusResolver(tt.jobs, nil)
assert.Equal(t, tt.want, r.Resolve(t.Context()))
})
}
}
// Test_checkRunConcurrency_NoDuplicateConcurrencyGroupCheck verifies that when a run's
// ConcurrencyGroup has already been checked at the run level, the same group is not
// re-checked for individual jobs.
func Test_checkRunConcurrency_NoDuplicateConcurrencyGroupCheck(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
ctx := t.Context()
// Run A: the triggering run with a concurrency group.
runA := &actions_model.ActionRun{
RepoID: 4,
OwnerID: 1,
TriggerUserID: 1,
WorkflowID: "test.yml",
Index: 9901,
Ref: "refs/heads/main",
Status: actions_model.StatusRunning,
ConcurrencyGroup: "test-cg",
}
assert.NoError(t, db.Insert(ctx, runA))
// A done job for run A with the same ConcurrencyGroup.
// This triggers the job-level concurrency check in checkRunConcurrency.
jobADone := &actions_model.ActionRunJob{
RunID: runA.ID,
RepoID: 4,
OwnerID: 1,
JobID: "job1",
Name: "job1",
Status: actions_model.StatusSuccess,
ConcurrencyGroup: "test-cg",
}
assert.NoError(t, db.Insert(ctx, jobADone))
// Blocked run B competing for the same concurrency group.
runB := &actions_model.ActionRun{
RepoID: 4,
OwnerID: 1,
TriggerUserID: 1,
WorkflowID: "test.yml",
Index: 9902,
Ref: "refs/heads/main",
Status: actions_model.StatusBlocked,
ConcurrencyGroup: "test-cg",
}
assert.NoError(t, db.Insert(ctx, runB))
// A blocked job belonging to run B (no job-level concurrency group).
jobBBlocked := &actions_model.ActionRunJob{
RunID: runB.ID,
RepoID: 4,
OwnerID: 1,
JobID: "job1",
Name: "job1",
Status: actions_model.StatusBlocked,
}
assert.NoError(t, db.Insert(ctx, jobBBlocked))
jobs, _, err := checkRunConcurrency(ctx, runA)
assert.NoError(t, err)
if assert.Len(t, jobs, 1) {
assert.Equal(t, jobBBlocked.ID, jobs[0].ID)
}
}
// Test_checkJobsOfRun_RunLevelConcurrencyKeepsJobsBlocked verifies that
// the resolver does not transition a job out of Blocked while another run still holds
// the workflow-level concurrency group. Regression for #37446.
func Test_checkJobsOfRun_RunLevelConcurrencyKeepsJobsBlocked(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
ctx := t.Context()
const group = "test-run-level-concurrency-keeps-blocked"
// Holder run: Running run in the concurrency group.
holderRun := &actions_model.ActionRun{
RepoID: 4, OwnerID: 1, TriggerUserID: 1,
WorkflowID: "test.yml", Index: 9911, Ref: "refs/heads/main",
Status: actions_model.StatusRunning,
ConcurrencyGroup: group,
}
assert.NoError(t, db.Insert(ctx, holderRun))
// Blocked run: Blocked run in the same group, with one Blocked job that has
// no needs and no job-level concurrency. Without the run-level guard in
// checkJobsOfRun, the resolver would transition this job to Waiting.
blockedRun := &actions_model.ActionRun{
RepoID: 4, OwnerID: 1, TriggerUserID: 1,
WorkflowID: "test.yml", Index: 9912, Ref: "refs/heads/main",
Status: actions_model.StatusBlocked,
ConcurrencyGroup: group,
}
assert.NoError(t, db.Insert(ctx, blockedRun))
blockedJob := &actions_model.ActionRunJob{
RunID: blockedRun.ID,
RepoID: 4, OwnerID: 1, JobID: "job1", Name: "job1",
Status: actions_model.StatusBlocked,
WorkflowPayload: []byte(`
name: test
on: push
jobs:
job1:
runs-on: ubuntu-latest
steps:
- run: echo
`),
}
assert.NoError(t, db.Insert(ctx, blockedJob))
_, updated, err := checkJobsOfRun(ctx, blockedRun)
assert.NoError(t, err)
assert.Empty(t, updated)
refreshed := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: blockedJob.ID})
assert.Equal(t, actions_model.StatusBlocked, refreshed.Status)
}
@@ -5,6 +5,7 @@ package actions
import (
"context"
"errors"
actions_model "code.gitea.io/gitea/models/actions"
issues_model "code.gitea.io/gitea/models/issues"
@@ -20,6 +21,7 @@ import (
"code.gitea.io/gitea/modules/repository"
"code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/util"
webhook_module "code.gitea.io/gitea/modules/webhook"
"code.gitea.io/gitea/services/convert"
notify_service "code.gitea.io/gitea/services/notify"
@@ -47,7 +49,7 @@ func (n *actionsNotifier) NewIssue(ctx context.Context, issue *issues_model.Issu
log.Error("issue.LoadPoster: %v", err)
return
}
permission, _ := access_model.GetUserRepoPermission(ctx, issue.Repo, issue.Poster)
permission, _ := access_model.GetIndividualUserRepoPermission(ctx, issue.Repo, issue.Poster)
newNotifyInputFromIssue(issue, webhook_module.HookEventIssues).WithPayload(&api.IssuePayload{
Action: api.HookIssueOpened,
@@ -76,7 +78,7 @@ func (n *actionsNotifier) notifyIssueChangeWithTitleOrContent(ctx context.Contex
return
}
permission, _ := access_model.GetUserRepoPermission(ctx, issue.Repo, issue.Poster)
permission, _ := access_model.GetIndividualUserRepoPermission(ctx, issue.Repo, issue.Poster)
if issue.IsPull {
if err = issue.LoadPullRequest(ctx); err != nil {
log.Error("loadPullRequest: %v", err)
@@ -110,7 +112,7 @@ func (n *actionsNotifier) notifyIssueChangeWithTitleOrContent(ctx context.Contex
// IssueChangeStatus notifies close or reopen issue to notifiers
func (n *actionsNotifier) IssueChangeStatus(ctx context.Context, doer *user_model.User, commitID string, issue *issues_model.Issue, _ *issues_model.Comment, isClosed bool) {
ctx = withMethod(ctx, "IssueChangeStatus")
permission, _ := access_model.GetUserRepoPermission(ctx, issue.Repo, issue.Poster)
permission, _ := access_model.GetIndividualUserRepoPermission(ctx, issue.Repo, issue.Poster)
if issue.IsPull {
if err := issue.LoadPullRequest(ctx); err != nil {
log.Error("LoadPullRequest: %v", err)
@@ -259,7 +261,7 @@ func notifyIssueChange(ctx context.Context, doer *user_model.User, issue *issues
return
}
permission, _ := access_model.GetUserRepoPermission(ctx, issue.Repo, issue.Poster)
permission, _ := access_model.GetIndividualUserRepoPermission(ctx, issue.Repo, issue.Poster)
payload := &api.IssuePayload{
Action: action,
Index: issue.Index,
@@ -322,7 +324,7 @@ func notifyIssueCommentChange(ctx context.Context, doer *user_model.User, commen
return
}
permission, _ := access_model.GetUserRepoPermission(ctx, comment.Issue.Repo, doer)
permission, _ := access_model.GetDoerRepoPermission(ctx, comment.Issue.Repo, doer)
payload := &api.IssueCommentPayload{
Action: action,
@@ -376,7 +378,7 @@ func (n *actionsNotifier) NewPullRequest(ctx context.Context, pull *issues_model
return
}
permission, _ := access_model.GetUserRepoPermission(ctx, pull.Issue.Repo, pull.Issue.Poster)
permission, _ := access_model.GetIndividualUserRepoPermission(ctx, pull.Issue.Repo, pull.Issue.Poster)
newNotifyInputFromIssue(pull.Issue, webhook_module.HookEventPullRequest).
WithPayload(&api.PullRequestPayload{
@@ -404,8 +406,8 @@ func (n *actionsNotifier) CreateRepository(ctx context.Context, doer, u *user_mo
func (n *actionsNotifier) ForkRepository(ctx context.Context, doer *user_model.User, oldRepo, repo *repo_model.Repository) {
ctx = withMethod(ctx, "ForkRepository")
oldPermission, _ := access_model.GetUserRepoPermission(ctx, oldRepo, doer)
permission, _ := access_model.GetUserRepoPermission(ctx, repo, doer)
oldPermission, _ := access_model.GetDoerRepoPermission(ctx, oldRepo, doer)
permission, _ := access_model.GetDoerRepoPermission(ctx, repo, doer)
// forked webhook
newNotifyInput(oldRepo, doer, webhook_module.HookEventFork).WithPayload(&api.ForkPayload{
@@ -452,9 +454,9 @@ func (n *actionsNotifier) PullRequestReview(ctx context.Context, pr *issues_mode
return
}
permission, err := access_model.GetUserRepoPermission(ctx, review.Issue.Repo, review.Issue.Poster)
permission, err := access_model.GetIndividualUserRepoPermission(ctx, review.Issue.Repo, review.Issue.Poster)
if err != nil {
log.Error("models.GetUserRepoPermission: %v", err)
log.Error("models.GetIndividualUserRepoPermission: %v", err)
return
}
@@ -481,7 +483,7 @@ func (n *actionsNotifier) PullRequestReviewRequest(ctx context.Context, doer *us
ctx = withMethod(ctx, "PullRequestReviewRequest")
permission, _ := access_model.GetUserRepoPermission(ctx, issue.Repo, doer)
permission, _ := access_model.GetDoerRepoPermission(ctx, issue.Repo, doer)
if err := issue.LoadPullRequest(ctx); err != nil {
log.Error("LoadPullRequest failed: %v", err)
return
@@ -525,9 +527,9 @@ func (*actionsNotifier) MergePullRequest(ctx context.Context, doer *user_model.U
return
}
permission, err := access_model.GetUserRepoPermission(ctx, pr.Issue.Repo, doer)
permission, err := access_model.GetDoerRepoPermission(ctx, pr.Issue.Repo, doer)
if err != nil {
log.Error("models.GetUserRepoPermission: %v", err)
log.Error("models.GetDoerRepoPermission: %v", err)
return
}
@@ -723,7 +725,7 @@ func (n *actionsNotifier) PullRequestChangeTargetBranch(ctx context.Context, doe
return
}
permission, _ := access_model.GetUserRepoPermission(ctx, pr.Issue.Repo, pr.Issue.Poster)
permission, _ := access_model.GetIndividualUserRepoPermission(ctx, pr.Issue.Repo, pr.Issue.Poster)
newNotifyInput(pr.Issue.Repo, doer, webhook_module.HookEventPullRequest).
WithPayload(&api.PullRequestPayload{
Action: api.HookIssueEdited,
@@ -805,7 +807,10 @@ func (n *actionsNotifier) WorkflowRunStatusUpdate(ctx context.Context, repo *rep
}
defer gitRepo.Close()
convertedWorkflow, err := convert.GetActionWorkflow(ctx, gitRepo, repo, run.WorkflowID)
convertedWorkflow, err := convert.GetActionWorkflowByRef(ctx, gitRepo, repo, run.WorkflowID, git.RefName(run.Ref))
if err != nil && errors.Is(err, util.ErrNotExist) {
convertedWorkflow, err = convert.GetActionWorkflow(ctx, gitRepo, repo, run.WorkflowID)
}
if err != nil {
log.Error("GetActionWorkflow: %v", err)
return
@@ -816,12 +821,14 @@ func (n *actionsNotifier) WorkflowRunStatusUpdate(ctx context.Context, repo *rep
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)
newNotifyInput(repo, sender, webhook_module.HookEventWorkflowRun).
WithRef(git.RefNameFromBranch(repo.DefaultBranch).String()).
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)
}
@@ -27,9 +27,7 @@ import (
api "code.gitea.io/gitea/modules/structs"
webhook_module "code.gitea.io/gitea/modules/webhook"
"code.gitea.io/gitea/services/convert"
notify_service "code.gitea.io/gitea/services/notify"
"github.com/nektos/act/pkg/jobparser"
"github.com/nektos/act/pkg/model"
)
@@ -195,7 +193,7 @@ func notify(ctx context.Context, input *notifyInput) error {
}
log.Trace("repo %s with commit %s event %s find %d workflows and %d schedules",
input.Repo.RepoPath(),
input.Repo.RelativePath(),
commit.ID,
input.Event,
len(workflows),
@@ -204,7 +202,7 @@ func notify(ctx context.Context, input *notifyInput) error {
for _, wf := range workflows {
if actionsConfig.IsWorkflowDisabled(wf.EntryName) {
log.Trace("repo %s has disable workflows %s", input.Repo.RepoPath(), wf.EntryName)
log.Trace("repo %s has disable workflows %s", input.Repo.RelativePath(), wf.EntryName)
continue
}
@@ -225,7 +223,7 @@ func notify(ctx context.Context, input *notifyInput) error {
return fmt.Errorf("DetectWorkflows: %w", err)
}
if len(baseWorkflows) == 0 {
log.Trace("repo %s with commit %s couldn't find pull_request_target workflows", input.Repo.RepoPath(), baseCommit.ID)
log.Trace("repo %s with commit %s couldn't find pull_request_target workflows", input.Repo.RelativePath(), baseCommit.ID)
} else {
for _, wf := range baseWorkflows {
if wf.TriggerEvent.Name == actions_module.GithubEventPullRequestTarget {
@@ -255,11 +253,11 @@ func skipWorkflows(ctx context.Context, input *notifyInput, commit *git.Commit)
if slices.Contains(skipWorkflowEvents, input.Event) {
for _, s := range setting.Actions.SkipWorkflowStrings {
if input.PullRequest != nil && strings.Contains(input.PullRequest.Issue.Title, s) {
log.Debug("repo %s: skipped run for pr %v because of %s string", input.Repo.RepoPath(), input.PullRequest.Issue.ID, s)
log.Debug("repo %s: skipped run for pr %v because of %s string", input.Repo.RelativePath(), input.PullRequest.Issue.ID, s)
return true
}
if strings.Contains(commit.CommitMessage, s) {
log.Debug("repo %s with commit %s: skipped run because of %s string", input.Repo.RepoPath(), commit.ID, s)
log.Debug("repo %s with commit %s: skipped run because of %s string", input.Repo.RelativePath(), commit.ID, s)
return true
}
}
@@ -282,7 +280,7 @@ func skipWorkflows(ctx context.Context, input *notifyInput, commit *git.Commit)
}
}
// skip workflow runs events exceeding the maximum of 5 recursive events
log.Debug("repo %s: skipped workflow_run because of recursive event of 5", input.Repo.RepoPath())
log.Debug("repo %s: skipped workflow_run because of recursive event of 5", input.Repo.RelativePath())
return true
}
return false
@@ -296,7 +294,7 @@ func handleWorkflows(
ref git.RefName,
) error {
if len(detectedWorkflows) == 0 {
log.Trace("repo %s with commit %s couldn't find workflows", input.Repo.RepoPath(), commit.ID)
log.Trace("repo %s with commit %s couldn't find workflows", input.Repo.RelativePath(), commit.ID)
return nil
}
@@ -322,7 +320,7 @@ func handleWorkflows(
for _, dwf := range detectedWorkflows {
run := &actions_model.ActionRun{
Title: strings.SplitN(commit.CommitMessage, "\n", 2)[0],
Title: commit.Summary(),
RepoID: input.Repo.ID,
Repo: input.Repo,
OwnerID: input.Repo.OwnerID,
@@ -346,66 +344,10 @@ func handleWorkflows(
run.NeedApproval = need
if err := run.LoadAttributes(ctx); err != nil {
log.Error("LoadAttributes: %v", err)
if err := PrepareRunAndInsert(ctx, dwf.Content, run, nil); err != nil {
log.Error("PrepareRunAndInsert: %v", err)
continue
}
vars, err := actions_model.GetVariablesOfRun(ctx, run)
if err != nil {
log.Error("GetVariablesOfRun: %v", err)
continue
}
giteaCtx := GenerateGiteaContext(run, nil)
jobs, err := jobparser.Parse(dwf.Content, jobparser.WithVars(vars), jobparser.WithGitContext(giteaCtx.ToGitHubContext()))
if err != nil {
log.Error("jobparser.Parse: %v", err)
continue
}
if len(jobs) > 0 && jobs[0].RunName != "" {
run.Title = jobs[0].RunName
}
// cancel running jobs if the event is push or pull_request_sync
if run.Event == webhook_module.HookEventPush ||
run.Event == webhook_module.HookEventPullRequestSync {
if err := CancelPreviousJobs(
ctx,
run.RepoID,
run.Ref,
run.WorkflowID,
run.Event,
); err != nil {
log.Error("CancelPreviousJobs: %v", err)
}
}
if err := actions_model.InsertRun(ctx, run, jobs); err != nil {
log.Error("InsertRun: %v", err)
continue
}
alljobs, err := db.Find[actions_model.ActionRunJob](ctx, actions_model.FindRunJobOptions{RunID: run.ID})
if err != nil {
log.Error("FindRunJobs: %v", err)
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)
}
}
return nil
}
@@ -420,7 +362,7 @@ func notifyRelease(ctx context.Context, doer *user_model.User, rel *repo_model.R
return
}
permission, _ := access_model.GetUserRepoPermission(ctx, rel.Repo, doer)
permission, _ := access_model.GetDoerRepoPermission(ctx, rel.Repo, doer)
newNotifyInput(rel.Repo, doer, webhook_module.HookEventRelease).
WithRef(git.RefNameFromTag(rel.TagName).String()).
@@ -471,8 +413,8 @@ func ifNeedApproval(ctx context.Context, run *actions_model.ActionRun, repo *rep
}
// don't need approval if the user can write
if perm, err := access_model.GetUserRepoPermission(ctx, repo, user); err != nil {
return false, fmt.Errorf("GetUserRepoPermission: %w", err)
if perm, err := access_model.GetDoerRepoPermission(ctx, repo, user); err != nil {
return false, fmt.Errorf("GetDoerRepoPermission: %w", err)
} else if perm.CanWrite(unit_model.TypeActions) {
log.Trace("do not need approval because user %d can write", user.ID)
return false, nil
@@ -517,7 +459,7 @@ func handleSchedules(
}
if len(detectedWorkflows) == 0 {
log.Trace("repo %s with commit %s couldn't find schedules", input.Repo.RepoPath(), commit.ID)
log.Trace("repo %s with commit %s couldn't find schedules", input.Repo.RelativePath(), commit.ID)
return nil
}
@@ -541,7 +483,7 @@ func handleSchedules(
}
run := &actions_model.ActionSchedule{
Title: strings.SplitN(commit.CommitMessage, "\n", 2)[0],
Title: commit.Summary(),
RepoID: input.Repo.ID,
Repo: input.Repo,
OwnerID: input.Repo.OwnerID,
@@ -556,24 +498,6 @@ func handleSchedules(
Content: dwf.Content,
}
vars, err := actions_model.GetVariablesOfRun(ctx, run.ToActionRun())
if err != nil {
log.Error("GetVariablesOfRun: %v", err)
continue
}
giteaCtx := GenerateGiteaContext(run.ToActionRun(), nil)
jobs, err := jobparser.Parse(dwf.Content, jobparser.WithVars(vars), jobparser.WithGitContext(giteaCtx.ToGitHubContext()))
if err != nil {
log.Error("jobparser.Parse: %v", err)
continue
}
if len(jobs) > 0 && jobs[0].RunName != "" {
run.Title = jobs[0].RunName
}
crons = append(crons, run)
}
@@ -0,0 +1,146 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"code.gitea.io/gitea/models/perm"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unit"
"code.gitea.io/gitea/modules/actions/jobparser"
"code.gitea.io/gitea/modules/setting"
"go.yaml.in/yaml/v4"
)
// ExtractJobPermissionsFromWorkflow extracts permissions from an already parsed workflow/job.
// It returns nil if neither workflow nor job explicitly specifies permissions.
func ExtractJobPermissionsFromWorkflow(flow *jobparser.SingleWorkflow, job *jobparser.Job) *repo_model.ActionsTokenPermissions {
if flow == nil || job == nil {
return nil
}
jobPerms := parseRawPermissionsExplicit(&job.RawPermissions)
if jobPerms != nil {
return jobPerms
}
workflowPerms := parseRawPermissionsExplicit(&flow.RawPermissions)
if workflowPerms != nil {
return workflowPerms
}
return nil
}
// parseRawPermissionsExplicit parses a YAML permissions node and returns only explicit scopes.
// It returns nil if the node does not explicitly specify permissions.
func parseRawPermissionsExplicit(rawPerms *yaml.Node) *repo_model.ActionsTokenPermissions {
if rawPerms == nil || (rawPerms.Kind == yaml.ScalarNode && rawPerms.Value == "") {
return nil
}
// Unwrap DocumentNode and resolve AliasNode
node := rawPerms
for node.Kind == yaml.DocumentNode || node.Kind == yaml.AliasNode {
if node.Kind == yaml.DocumentNode {
if len(node.Content) == 0 {
return nil
}
node = node.Content[0]
} else {
node = node.Alias
}
}
if node.Kind == yaml.ScalarNode && node.Value == "" {
return nil
}
// Handle scalar values: "read-all" or "write-all"
if node.Kind == yaml.ScalarNode {
switch node.Value {
case "read-all":
return new(repo_model.MakeActionsTokenPermissions(perm.AccessModeRead))
case "write-all":
return new(repo_model.MakeActionsTokenPermissions(perm.AccessModeWrite))
default:
// Explicit but unrecognized scalar: return all-none permissions.
return new(repo_model.MakeActionsTokenPermissions(perm.AccessModeNone))
}
}
// Handle mapping: individual permission scopes
if node.Kind == yaml.MappingNode {
result := repo_model.MakeActionsTokenPermissions(perm.AccessModeNone)
// Collect all scopes into a map first to handle priority
scopes := make(map[string]perm.AccessMode)
for i := 0; i < len(node.Content); i += 2 {
if i+1 >= len(node.Content) {
break
}
keyNode := node.Content[i]
valueNode := node.Content[i+1]
if keyNode.Kind != yaml.ScalarNode || valueNode.Kind != yaml.ScalarNode {
continue
}
scopes[keyNode.Value] = parseAccessMode(valueNode.Value)
}
// 1. Apply 'contents' first (lower priority)
if mode, ok := scopes["contents"]; ok {
result.UnitAccessModes[unit.TypeCode] = mode
result.UnitAccessModes[unit.TypeReleases] = mode
}
// 2. Apply all other scopes (overwrites contents if specified)
for scope, mode := range scopes {
switch scope {
case "contents":
// already handled
case "code":
result.UnitAccessModes[unit.TypeCode] = mode
case "issues":
result.UnitAccessModes[unit.TypeIssues] = mode
case "pull-requests":
result.UnitAccessModes[unit.TypePullRequests] = mode
case "packages":
result.UnitAccessModes[unit.TypePackages] = mode
case "actions":
result.UnitAccessModes[unit.TypeActions] = mode
case "wiki":
result.UnitAccessModes[unit.TypeWiki] = mode
case "releases":
result.UnitAccessModes[unit.TypeReleases] = mode
case "projects":
result.UnitAccessModes[unit.TypeProjects] = mode
// Scopes github supports but gitea does not, see url for details
// https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax
case "artifact-metadata", "attestations", "checks", "deployments",
"id-token", "models", "discussions", "pages", "security-events", "statuses":
// not supported
default:
setting.PanicInDevOrTesting("Unrecognized permission scope: %s", scope)
}
}
return &result
}
return nil
}
// parseAccessMode converts a string access level to perm.AccessMode
func parseAccessMode(s string) perm.AccessMode {
switch s {
case "write":
return perm.AccessModeWrite
case "read":
return perm.AccessModeRead
default:
return perm.AccessModeNone
}
}
@@ -0,0 +1,226 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"testing"
"code.gitea.io/gitea/models/perm"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unit"
"code.gitea.io/gitea/modules/actions/jobparser"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.yaml.in/yaml/v4"
)
func TestParseRawPermissions_ReadAll(t *testing.T) {
var rawPerms yaml.Node
err := yaml.Unmarshal([]byte(`read-all`), &rawPerms)
assert.NoError(t, err)
result := parseRawPermissionsExplicit(&rawPerms)
require.NotNil(t, result)
assert.Equal(t, perm.AccessModeRead, result.UnitAccessModes[unit.TypeCode])
assert.Equal(t, perm.AccessModeRead, result.UnitAccessModes[unit.TypeIssues])
assert.Equal(t, perm.AccessModeRead, result.UnitAccessModes[unit.TypePullRequests])
assert.Equal(t, perm.AccessModeRead, result.UnitAccessModes[unit.TypePackages])
assert.Equal(t, perm.AccessModeRead, result.UnitAccessModes[unit.TypeActions])
assert.Equal(t, perm.AccessModeRead, result.UnitAccessModes[unit.TypeWiki])
assert.Equal(t, perm.AccessModeRead, result.UnitAccessModes[unit.TypeProjects])
}
// TestParseRawPermissions_GithubScopes verifies that all scopes that github supports are accounted for
func TestParseRawPermissions_GithubScopes(t *testing.T) {
var rawPerms yaml.Node
// Taken and stripped down from:
// https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#defining-access-for-the-github_token-scopes
yamlContent := `
actions: read
artifact-metadata: read
attestations: read
checks: read
contents: read
deployments: read
id-token: write
issues: read
models: read
discussions: read
packages: read
pages: read
pull-requests: read
security-events: read
statuses: read`
err := yaml.Unmarshal([]byte(yamlContent), &rawPerms)
require.NoError(t, err)
result := parseRawPermissionsExplicit(&rawPerms)
require.NotNil(t, result)
// No asserts for permissions set on purpose
}
func TestParseRawPermissions_WriteAll(t *testing.T) {
var rawPerms yaml.Node
err := yaml.Unmarshal([]byte(`write-all`), &rawPerms)
assert.NoError(t, err)
result := parseRawPermissionsExplicit(&rawPerms)
require.NotNil(t, result)
assert.Equal(t, perm.AccessModeWrite, result.UnitAccessModes[unit.TypeCode])
assert.Equal(t, perm.AccessModeWrite, result.UnitAccessModes[unit.TypeIssues])
assert.Equal(t, perm.AccessModeWrite, result.UnitAccessModes[unit.TypePullRequests])
assert.Equal(t, perm.AccessModeWrite, result.UnitAccessModes[unit.TypePackages])
assert.Equal(t, perm.AccessModeWrite, result.UnitAccessModes[unit.TypeActions])
assert.Equal(t, perm.AccessModeWrite, result.UnitAccessModes[unit.TypeWiki])
assert.Equal(t, perm.AccessModeWrite, result.UnitAccessModes[unit.TypeProjects])
}
func TestParseRawPermissions_IndividualScopes(t *testing.T) {
yamlContent := `
contents: write
issues: read
pull-requests: none
packages: write
actions: read
wiki: write
projects: none
`
var rawPerms yaml.Node
err := yaml.Unmarshal([]byte(yamlContent), &rawPerms)
assert.NoError(t, err)
result := parseRawPermissionsExplicit(&rawPerms)
require.NotNil(t, result)
assert.Equal(t, perm.AccessModeWrite, result.UnitAccessModes[unit.TypeCode])
assert.Equal(t, perm.AccessModeRead, result.UnitAccessModes[unit.TypeIssues])
assert.Equal(t, perm.AccessModeNone, result.UnitAccessModes[unit.TypePullRequests])
assert.Equal(t, perm.AccessModeWrite, result.UnitAccessModes[unit.TypePackages])
assert.Equal(t, perm.AccessModeRead, result.UnitAccessModes[unit.TypeActions])
assert.Equal(t, perm.AccessModeWrite, result.UnitAccessModes[unit.TypeWiki])
assert.Equal(t, perm.AccessModeNone, result.UnitAccessModes[unit.TypeProjects])
}
func TestParseRawPermissions_Priority(t *testing.T) {
t.Run("granular-wins-over-contents", func(t *testing.T) {
yamlContent := `
contents: read
code: write
releases: none
`
var rawPerms yaml.Node
err := yaml.Unmarshal([]byte(yamlContent), &rawPerms)
assert.NoError(t, err)
result := parseRawPermissionsExplicit(&rawPerms)
require.NotNil(t, result)
assert.Equal(t, perm.AccessModeWrite, result.UnitAccessModes[unit.TypeCode])
assert.Equal(t, perm.AccessModeNone, result.UnitAccessModes[unit.TypeReleases])
})
t.Run("contents-applied-first", func(t *testing.T) {
yamlContent := `
code: none
releases: write
contents: read
`
var rawPerms yaml.Node
err := yaml.Unmarshal([]byte(yamlContent), &rawPerms)
assert.NoError(t, err)
result := parseRawPermissionsExplicit(&rawPerms)
require.NotNil(t, result)
// code: none should win over contents: read
assert.Equal(t, perm.AccessModeNone, result.UnitAccessModes[unit.TypeCode])
// releases: write should win over contents: read
assert.Equal(t, perm.AccessModeWrite, result.UnitAccessModes[unit.TypeReleases])
})
}
func TestParseRawPermissions_EmptyNode(t *testing.T) {
var rawPerms yaml.Node
// Empty node
result := parseRawPermissionsExplicit(&rawPerms)
// Should return nil for non-explicit
assert.Nil(t, result)
}
func TestParseRawPermissions_NilNode(t *testing.T) {
result := parseRawPermissionsExplicit(nil)
// Should return nil
assert.Nil(t, result)
}
func TestParseAccessMode(t *testing.T) {
tests := []struct {
input string
expected perm.AccessMode
}{
{"write", perm.AccessModeWrite},
{"read", perm.AccessModeRead},
{"none", perm.AccessModeNone},
{"", perm.AccessModeNone},
{"invalid", perm.AccessModeNone},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
result := parseAccessMode(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
func TestExtractJobPermissionsFromWorkflow(t *testing.T) {
workflowYAML := `
name: Test Permissions
on: workflow_dispatch
permissions: read-all
jobs:
job-read-only:
runs-on: ubuntu-latest
steps:
- run: echo "Full read-only"
job-none-perms:
permissions: none
runs-on: ubuntu-latest
steps:
- run: echo "Full read-only"
job-override:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- run: echo "Override to write"
`
expectedPerms := map[string]*repo_model.ActionsTokenPermissions{}
expectedPerms["job-read-only"] = new(repo_model.MakeActionsTokenPermissions(perm.AccessModeRead))
expectedPerms["job-none-perms"] = new(repo_model.MakeActionsTokenPermissions(perm.AccessModeNone))
expectedPerms["job-override"] = new(repo_model.MakeActionsTokenPermissions(perm.AccessModeNone))
expectedPerms["job-override"].UnitAccessModes[unit.TypeCode] = perm.AccessModeWrite
expectedPerms["job-override"].UnitAccessModes[unit.TypeReleases] = perm.AccessModeWrite
singleWorkflows, err := jobparser.Parse([]byte(workflowYAML))
require.NoError(t, err)
for _, flow := range singleWorkflows {
jobID, jobDef := flow.Job()
require.NotNil(t, jobDef)
t.Run(jobID, func(t *testing.T) {
assert.Equal(t, expectedPerms[jobID], ExtractJobPermissionsFromWorkflow(flow, jobDef))
})
}
}
+183 -1
View File
@@ -4,11 +4,43 @@
package actions
import (
"context"
"fmt"
actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/models/db"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unit"
"code.gitea.io/gitea/modules/container"
"code.gitea.io/gitea/modules/util"
notify_service "code.gitea.io/gitea/services/notify"
"github.com/nektos/act/pkg/model"
"go.yaml.in/yaml/v4"
"xorm.io/builder"
)
// GetAllRerunJobs get all jobs that need to be rerun when job should be rerun
// GetFailedRerunJobs returns all failed jobs and their downstream dependent jobs that need to be rerun
func GetFailedRerunJobs(allJobs []*actions_model.ActionRunJob) []*actions_model.ActionRunJob {
rerunJobIDSet := make(container.Set[int64])
var jobsToRerun []*actions_model.ActionRunJob
for _, job := range allJobs {
if job.Status == actions_model.StatusFailure || job.Status == actions_model.StatusCancelled {
for _, j := range GetAllRerunJobs(job, allJobs) {
if !rerunJobIDSet.Contains(j.ID) {
rerunJobIDSet.Add(j.ID)
jobsToRerun = append(jobsToRerun, j)
}
}
}
}
return jobsToRerun
}
// GetAllRerunJobs returns the target job and all jobs that transitively depend on it.
// Downstream jobs are included regardless of their current status.
func GetAllRerunJobs(job *actions_model.ActionRunJob, allJobs []*actions_model.ActionRunJob) []*actions_model.ActionRunJob {
rerunJobs := []*actions_model.ActionRunJob{job}
rerunJobsIDSet := make(container.Set[string])
@@ -36,3 +68,153 @@ func GetAllRerunJobs(job *actions_model.ActionRunJob, allJobs []*actions_model.A
return rerunJobs
}
// prepareRunRerun validates the run, resets its state, handles concurrency, persists the
// updated run, and fires a status-update notification.
// It returns isRunBlocked (true when the run itself is held by a concurrency group).
func prepareRunRerun(ctx context.Context, repo *repo_model.Repository, run *actions_model.ActionRun, jobs []*actions_model.ActionRunJob) (isRunBlocked bool, err error) {
if !run.Status.IsDone() {
return false, util.NewInvalidArgumentErrorf("this workflow run is not done")
}
cfgUnit := repo.MustGetUnit(ctx, unit.TypeActions)
// Rerun is not allowed when workflow is disabled.
cfg := cfgUnit.ActionsConfig()
if cfg.IsWorkflowDisabled(run.WorkflowID) {
return false, util.NewInvalidArgumentErrorf("workflow %s is disabled", run.WorkflowID)
}
// Reset run's timestamps and status.
run.PreviousDuration = run.Duration()
run.Started = 0
run.Stopped = 0
run.Status = actions_model.StatusWaiting
vars, err := actions_model.GetVariablesOfRun(ctx, run)
if err != nil {
return false, fmt.Errorf("get run %d variables: %w", run.ID, err)
}
if run.RawConcurrency != "" {
var rawConcurrency model.RawConcurrency
if err := yaml.Unmarshal([]byte(run.RawConcurrency), &rawConcurrency); err != nil {
return false, fmt.Errorf("unmarshal raw concurrency: %w", err)
}
if err := EvaluateRunConcurrencyFillModel(ctx, run, &rawConcurrency, vars, nil); err != nil {
return false, err
}
run.Status, err = PrepareToStartRunWithConcurrency(ctx, run)
if err != nil {
return false, err
}
}
if err := actions_model.UpdateRun(ctx, run, "started", "stopped", "previous_duration", "status", "concurrency_group", "concurrency_cancel"); err != nil {
return false, err
}
if err := run.LoadAttributes(ctx); err != nil {
return false, err
}
for _, job := range jobs {
job.Run = run
}
// Recomputes the repository's num_action_runs / num_closed_action_runs counters since the run's status changed
actions_model.UpdateRepoRunsNumbers(ctx, run.RepoID)
notify_service.WorkflowRunStatusUpdate(ctx, run.Repo, run.TriggerUser, run)
return run.Status == actions_model.StatusBlocked, nil
}
// RerunWorkflowRunJobs reruns the given jobs of a workflow run.
// jobsToRerun must include all jobs to be rerun (the target job and its transitively dependent jobs).
// A job is blocked (waiting for dependencies) if the run itself is blocked or if any of its
// needs are also being rerun.
func RerunWorkflowRunJobs(ctx context.Context, repo *repo_model.Repository, run *actions_model.ActionRun, jobsToRerun []*actions_model.ActionRunJob) error {
if len(jobsToRerun) == 0 {
return nil
}
isRunBlocked, err := prepareRunRerun(ctx, repo, run, jobsToRerun)
if err != nil {
return err
}
rerunJobIDs := make(container.Set[string])
for _, j := range jobsToRerun {
rerunJobIDs.Add(j.JobID)
}
for _, job := range jobsToRerun {
shouldBlockJob := isRunBlocked
if !shouldBlockJob {
for _, need := range job.Needs {
if rerunJobIDs.Contains(need) {
shouldBlockJob = true
break
}
}
}
if err := rerunWorkflowJob(ctx, job, shouldBlockJob); err != nil {
return err
}
}
return nil
}
func rerunWorkflowJob(ctx context.Context, job *actions_model.ActionRunJob, shouldBlock bool) error {
status := job.Status
if !status.IsDone() {
return nil
}
job.TaskID = 0
job.Status = util.Iif(shouldBlock, actions_model.StatusBlocked, actions_model.StatusWaiting)
job.Started = 0
job.Stopped = 0
job.ConcurrencyGroup = ""
job.ConcurrencyCancel = false
job.IsConcurrencyEvaluated = false
if err := job.LoadRun(ctx); err != nil {
return err
}
if err := job.Run.LoadAttributes(ctx); err != nil {
return err
}
vars, err := actions_model.GetVariablesOfRun(ctx, job.Run)
if err != nil {
return fmt.Errorf("get run %d variables: %w", job.Run.ID, err)
}
if job.RawConcurrency != "" && !shouldBlock {
if err := EvaluateJobConcurrencyFillModel(ctx, job.Run, job, vars, nil); err != nil {
return fmt.Errorf("evaluate job concurrency: %w", err)
}
job.Status, err = PrepareToStartJobWithConcurrency(ctx, job)
if err != nil {
return err
}
}
if err := db.WithTx(ctx, func(ctx context.Context) error {
updateCols := []string{"task_id", "status", "started", "stopped", "concurrency_group", "concurrency_cancel", "is_concurrency_evaluated"}
_, err := actions_model.UpdateRunJob(ctx, job, builder.Eq{"status": status}, updateCols...)
return err
}); err != nil {
return err
}
CreateCommitStatusForRunJobs(ctx, job.Run, job)
notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil)
return nil
}
@@ -4,11 +4,14 @@
package actions
import (
"context"
"testing"
actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/modules/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetAllRerunJobs(t *testing.T) {
@@ -46,3 +49,97 @@ func TestGetAllRerunJobs(t *testing.T) {
assert.ElementsMatch(t, tc.rerunJobs, rerunJobs)
}
}
func TestGetFailedRerunJobs(t *testing.T) {
// IDs must be non-zero to distinguish jobs in the dedup set.
makeJob := func(id int64, jobID string, status actions_model.Status, needs ...string) *actions_model.ActionRunJob {
return &actions_model.ActionRunJob{ID: id, JobID: jobID, Status: status, Needs: needs}
}
t.Run("no failed jobs returns empty", func(t *testing.T) {
jobs := []*actions_model.ActionRunJob{
makeJob(1, "job1", actions_model.StatusSuccess),
makeJob(2, "job2", actions_model.StatusSkipped, "job1"),
}
assert.Empty(t, GetFailedRerunJobs(jobs))
})
t.Run("single failed job with no dependents", func(t *testing.T) {
job1 := makeJob(1, "job1", actions_model.StatusFailure)
job2 := makeJob(2, "job2", actions_model.StatusSuccess)
jobs := []*actions_model.ActionRunJob{job1, job2}
result := GetFailedRerunJobs(jobs)
assert.ElementsMatch(t, []*actions_model.ActionRunJob{job1}, result)
})
t.Run("failed job pulls in downstream dependents", func(t *testing.T) {
// job1 failed; job2 depends on job1 (skipped); job3 depends on job2 (skipped)
job1 := makeJob(1, "job1", actions_model.StatusFailure)
job2 := makeJob(2, "job2", actions_model.StatusSkipped, "job1")
job3 := makeJob(3, "job3", actions_model.StatusSkipped, "job2")
job4 := makeJob(4, "job4", actions_model.StatusSuccess) // unrelated, must not appear
jobs := []*actions_model.ActionRunJob{job1, job2, job3, job4}
result := GetFailedRerunJobs(jobs)
assert.ElementsMatch(t, []*actions_model.ActionRunJob{job1, job2, job3}, result)
})
t.Run("multiple independent failed jobs each pull in their own dependents", func(t *testing.T) {
// job1 failed -> job3 depends on job1
// job2 failed -> job4 depends on job2
job1 := makeJob(1, "job1", actions_model.StatusFailure)
job2 := makeJob(2, "job2", actions_model.StatusFailure)
job3 := makeJob(3, "job3", actions_model.StatusSkipped, "job1")
job4 := makeJob(4, "job4", actions_model.StatusSkipped, "job2")
jobs := []*actions_model.ActionRunJob{job1, job2, job3, job4}
result := GetFailedRerunJobs(jobs)
assert.ElementsMatch(t, []*actions_model.ActionRunJob{job1, job2, job3, job4}, result)
})
t.Run("shared downstream dependent is not duplicated", func(t *testing.T) {
// job1 and job2 both failed; job3 depends on both
job1 := makeJob(1, "job1", actions_model.StatusFailure)
job2 := makeJob(2, "job2", actions_model.StatusFailure)
job3 := makeJob(3, "job3", actions_model.StatusSkipped, "job1", "job2")
jobs := []*actions_model.ActionRunJob{job1, job2, job3}
result := GetFailedRerunJobs(jobs)
assert.ElementsMatch(t, []*actions_model.ActionRunJob{job1, job2, job3}, result)
assert.Len(t, result, 3) // job3 must appear exactly once
})
t.Run("successful downstream job of a failed job is still included", func(t *testing.T) {
// job1 failed; job2 succeeded but depends on job1 — downstream is always rerun
// regardless of its own status (GetAllRerunJobs includes all transitive dependents)
job1 := makeJob(1, "job1", actions_model.StatusFailure)
job2 := makeJob(2, "job2", actions_model.StatusSuccess, "job1")
jobs := []*actions_model.ActionRunJob{job1, job2}
result := GetFailedRerunJobs(jobs)
assert.ElementsMatch(t, []*actions_model.ActionRunJob{job1, job2}, result)
})
}
func TestRerunValidation(t *testing.T) {
runningRun := &actions_model.ActionRun{Status: actions_model.StatusRunning}
t.Run("RerunWorkflowRunJobs rejects a non-done run", func(t *testing.T) {
jobs := []*actions_model.ActionRunJob{
{ID: 1, JobID: "job1"},
}
err := RerunWorkflowRunJobs(context.Background(), nil, runningRun, jobs)
require.Error(t, err)
assert.ErrorIs(t, err, util.ErrInvalidArgument)
})
t.Run("RerunWorkflowRunJobs rejects a non-done run when failed jobs exist", func(t *testing.T) {
jobs := []*actions_model.ActionRunJob{
{ID: 1, JobID: "job1", Status: actions_model.StatusFailure},
}
err := RerunWorkflowRunJobs(context.Background(), nil, runningRun, GetFailedRerunJobs(jobs))
require.Error(t, err)
assert.ErrorIs(t, err, util.ErrInvalidArgument)
})
}
@@ -0,0 +1,190 @@
// Copyright 2025 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"
"code.gitea.io/gitea/modules/actions/jobparser"
"code.gitea.io/gitea/modules/util"
notify_service "code.gitea.io/gitea/services/notify"
act_model "github.com/nektos/act/pkg/model"
"go.yaml.in/yaml/v4"
)
// PrepareRunAndInsert prepares a run and inserts it into the database
// It parses the workflow content, evaluates concurrency if needed, and inserts the run and its jobs into the database.
// The title will be cut off at 255 characters if it's longer than 255 characters.
func PrepareRunAndInsert(ctx context.Context, content []byte, run *actions_model.ActionRun, inputsWithDefaults map[string]any) error {
if err := run.LoadAttributes(ctx); err != nil {
return fmt.Errorf("LoadAttributes: %w", err)
}
vars, err := actions_model.GetVariablesOfRun(ctx, run)
if err != nil {
return fmt.Errorf("GetVariablesOfRun: %w", err)
}
wfRawConcurrency, err := jobparser.ReadWorkflowRawConcurrency(content)
if err != nil {
return fmt.Errorf("ReadWorkflowRawConcurrency: %w", err)
}
if err = InsertRun(ctx, run, content, vars, inputsWithDefaults, wfRawConcurrency); err != nil {
return fmt.Errorf("InsertRun: %w", err)
}
// Load the newly inserted jobs with all fields from database (the job models in InsertRun are partial, so load again)
allJobs, err := db.Find[actions_model.ActionRunJob](ctx, actions_model.FindRunJobOptions{RunID: run.ID})
if err != nil {
return fmt.Errorf("FindRunJob: %w", err)
}
CreateCommitStatusForRunJobs(ctx, run, allJobs...)
notify_service.WorkflowRunStatusUpdate(ctx, run.Repo, run.TriggerUser, run)
for _, job := range allJobs {
notify_service.WorkflowJobStatusUpdate(ctx, run.Repo, run.TriggerUser, job, nil)
}
// Recomputes the repository's num_action_runs / num_closed_action_runs counters since a new run is created
actions_model.UpdateRepoRunsNumbers(ctx, run.RepoID)
return nil
}
// InsertRun inserts a run
// The title will be cut off at 255 characters if it's longer than 255 characters.
func InsertRun(ctx context.Context, run *actions_model.ActionRun, content []byte, vars map[string]string, inputs map[string]any, wfRawConcurrency *act_model.RawConcurrency) error {
return db.WithTx(ctx, func(ctx context.Context) error {
index, err := db.GetNextResourceIndex(ctx, "action_run_index", run.RepoID)
if err != nil {
return err
}
run.Index = index
run.Title = util.EllipsisDisplayString(run.Title, 255)
run.Status = actions_model.StatusWaiting
// Insert before parsing jobs or evaluating workflow-level concurrency
// so that run.ID is populated. Expressions referencing github.run_id —
// in run-name, job names, runs-on, or a workflow-level concurrency
// group like `${{ github.head_ref || github.run_id }}` — would otherwise
// interpolate to an empty string.
if err := db.Insert(ctx, run); err != nil {
return err
}
giteaCtx := GenerateGiteaContext(run, nil)
jobs, err := jobparser.Parse(content, jobparser.WithVars(vars), jobparser.WithGitContext(giteaCtx.ToGitHubContext()), jobparser.WithInputs(inputs))
if err != nil {
return fmt.Errorf("parse workflow: %w", err)
}
titleChanged := len(jobs) > 0 && jobs[0].RunName != ""
if titleChanged {
run.Title = util.EllipsisDisplayString(jobs[0].RunName, 255)
}
if wfRawConcurrency != nil {
if err := EvaluateRunConcurrencyFillModel(ctx, run, wfRawConcurrency, vars, inputs); err != nil {
return fmt.Errorf("EvaluateRunConcurrencyFillModel: %w", err)
}
run.Status, err = PrepareToStartRunWithConcurrency(ctx, run)
if err != nil {
return err
}
}
runJobs := make([]*actions_model.ActionRunJob, 0, len(jobs))
var hasWaitingJobs bool
for _, v := range jobs {
id, job := v.Job()
needs := job.Needs()
if err := v.SetJob(id, job.EraseNeeds()); err != nil {
return err
}
payload, _ := v.Marshal()
shouldBlockJob := len(needs) > 0 || run.NeedApproval || run.Status == actions_model.StatusBlocked
job.Name = util.EllipsisDisplayString(job.Name, 255)
runJob := &actions_model.ActionRunJob{
RunID: run.ID,
RepoID: run.RepoID,
OwnerID: run.OwnerID,
CommitSHA: run.CommitSHA,
IsForkPullRequest: run.IsForkPullRequest,
Name: job.Name,
WorkflowPayload: payload,
JobID: id,
Needs: needs,
RunsOn: job.RunsOn(),
Status: util.Iif(shouldBlockJob, actions_model.StatusBlocked, actions_model.StatusWaiting),
}
// Parse workflow/job permissions (no clamping here)
if perms := ExtractJobPermissionsFromWorkflow(v, job); perms != nil {
runJob.TokenPermissions = perms
}
// check job concurrency
if job.RawConcurrency != nil {
rawConcurrency, err := yaml.Marshal(job.RawConcurrency)
if err != nil {
return fmt.Errorf("marshal raw concurrency: %w", err)
}
runJob.RawConcurrency = string(rawConcurrency)
// do not evaluate job concurrency when it requires `needs`, the jobs with `needs` will be evaluated later by job emitter
if len(needs) == 0 {
err = EvaluateJobConcurrencyFillModel(ctx, run, runJob, vars, inputs)
if err != nil {
return fmt.Errorf("evaluate job concurrency: %w", err)
}
}
// If a job needs other jobs ("needs" is not empty), its status is set to StatusBlocked at the entry of the loop
// No need to check job concurrency for a blocked job (it will be checked by job emitter later)
if runJob.Status == actions_model.StatusWaiting {
runJob.Status, err = PrepareToStartJobWithConcurrency(ctx, runJob)
if err != nil {
return fmt.Errorf("prepare to start job with concurrency: %w", err)
}
}
}
hasWaitingJobs = hasWaitingJobs || runJob.Status == actions_model.StatusWaiting
if err := db.Insert(ctx, runJob); err != nil {
return err
}
runJobs = append(runJobs, runJob)
}
run.Status = actions_model.AggregateJobStatus(runJobs)
cols := []string{"status"}
if titleChanged {
cols = append(cols, "title")
}
if wfRawConcurrency != nil {
cols = append(cols, "raw_concurrency", "concurrency_group", "concurrency_cancel")
}
if err := actions_model.UpdateRun(ctx, run, cols...); err != nil {
return err
}
// if there is a job in the waiting status, increase tasks version.
if hasWaitingJobs {
if err := actions_model.IncreaseTaskVersion(ctx, run.OwnerID, run.RepoID); err != nil {
return err
}
}
return nil
})
}
@@ -12,12 +12,10 @@ import (
"code.gitea.io/gitea/models/db"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unit"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/timeutil"
webhook_module "code.gitea.io/gitea/modules/webhook"
notify_service "code.gitea.io/gitea/services/notify"
"github.com/nektos/act/pkg/jobparser"
)
// StartScheduleTasks start the task
@@ -53,20 +51,6 @@ func startTasks(ctx context.Context) error {
// Loop through each spec and create a schedule task for it
for _, row := range specs {
// cancel running jobs if the event is push
if row.Schedule.Event == webhook_module.HookEventPush {
// cancel running jobs of the same workflow
if err := CancelPreviousJobs(
ctx,
row.RepoID,
row.Schedule.Ref,
row.Schedule.WorkflowID,
webhook_module.HookEventSchedule,
); err != nil {
log.Error("CancelPreviousJobs: %v", err)
}
}
if row.Repo.IsArchived {
// Skip if the repo is archived
continue
@@ -84,7 +68,7 @@ func startTasks(ctx context.Context) error {
continue
}
if err := CreateScheduleTask(ctx, row.Schedule); err != nil {
if err := CreateScheduleTask(ctx, row); err != nil {
log.Error("CreateScheduleTask: %v", err)
return err
}
@@ -114,9 +98,12 @@ func startTasks(ctx context.Context) error {
return nil
}
// CreateScheduleTask creates a scheduled task from a cron action schedule.
// CreateScheduleTask creates a scheduled task from a cron action schedule spec.
// It creates an action run based on the schedule, inserts it into the database, and creates commit statuses for each job.
func CreateScheduleTask(ctx context.Context, cron *actions_model.ActionSchedule) error {
func CreateScheduleTask(ctx context.Context, spec *actions_model.ActionScheduleSpec) error {
cron := spec.Schedule
eventPayload := withScheduleInEventPayload(cron.EventPayload, spec.Spec)
// Create a new action run based on the schedule
run := &actions_model.ActionRun{
Title: cron.Title,
@@ -127,41 +114,48 @@ func CreateScheduleTask(ctx context.Context, cron *actions_model.ActionSchedule)
Ref: cron.Ref,
CommitSHA: cron.CommitSHA,
Event: cron.Event,
EventPayload: cron.EventPayload,
EventPayload: eventPayload,
TriggerEvent: string(webhook_module.HookEventSchedule),
ScheduleID: cron.ID,
Status: actions_model.StatusWaiting,
}
vars, err := actions_model.GetVariablesOfRun(ctx, run)
if err != nil {
log.Error("GetVariablesOfRun: %v", err)
return err
}
// Parse the workflow specification from the cron schedule
workflows, err := jobparser.Parse(cron.Content, jobparser.WithVars(vars))
if err != nil {
return err
}
// FIXME cron.Content might be outdated if the workflow file has been changed.
// Load the latest sha from default branch
// Insert the action run and its associated jobs into the database
if err := actions_model.InsertRun(ctx, run, workflows); err != nil {
if err := PrepareRunAndInsert(ctx, cron.Content, run, nil); err != nil {
return err
}
allJobs, err := db.Find[actions_model.ActionRunJob](ctx, actions_model.FindRunJobOptions{RunID: run.ID})
if err != nil {
log.Error("FindRunJobs: %v", err)
}
err = run.LoadAttributes(ctx)
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)
}
// Return nil if no errors occurred
return nil
}
func withScheduleInEventPayload(eventPayload, schedule string) string {
if schedule == "" {
return eventPayload
}
// eventPayload originates from json.Marshal(input.Payload) in handleSchedules,
// so a nil payload is stored as the literal "null" and pre-existing rows may be
// empty. Both cases start from a fresh map so the schedule field can still be set.
var event map[string]any
if eventPayload != "" {
if err := json.Unmarshal([]byte(eventPayload), &event); err != nil {
log.Error("withScheduleInEventPayload: unmarshal: %v", err)
return eventPayload
}
}
if event == nil {
event = map[string]any{}
}
event["schedule"] = schedule
updatedPayload, err := json.Marshal(event)
if err != nil {
log.Error("withScheduleInEventPayload: marshal: %v", err)
return eventPayload
}
return string(updatedPayload)
}
@@ -0,0 +1,52 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"testing"
"code.gitea.io/gitea/modules/json"
"github.com/stretchr/testify/assert"
)
func TestWithScheduleInEventPayload(t *testing.T) {
t.Run("adds schedule to existing payload", func(t *testing.T) {
payload := `{"ref":"refs/heads/main"}`
updated := withScheduleInEventPayload(payload, "*/5 * * * *")
event := map[string]any{}
assert.NoError(t, json.Unmarshal([]byte(updated), &event))
assert.Equal(t, "*/5 * * * *", event["schedule"])
assert.Equal(t, "refs/heads/main", event["ref"])
})
t.Run("adds schedule to null payload", func(t *testing.T) {
updated := withScheduleInEventPayload("null", "37 12 5 1 2")
event := map[string]any{}
assert.NoError(t, json.Unmarshal([]byte(updated), &event))
assert.Equal(t, "37 12 5 1 2", event["schedule"])
})
t.Run("adds schedule to empty payload", func(t *testing.T) {
updated := withScheduleInEventPayload("", "37 12 5 1 2")
event := map[string]any{}
assert.NoError(t, json.Unmarshal([]byte(updated), &event))
assert.Equal(t, "37 12 5 1 2", event["schedule"])
})
t.Run("keeps payload when schedule empty", func(t *testing.T) {
payload := `{"ref":"refs/heads/main"}`
updated := withScheduleInEventPayload(payload, "")
assert.Equal(t, payload, updated)
})
t.Run("keeps payload when malformed JSON", func(t *testing.T) {
payload := `not a json object`
updated := withScheduleInEventPayload(payload, "*/5 * * * *")
assert.Equal(t, payload, updated)
})
}
+10 -1
View File
@@ -24,6 +24,10 @@ func PickTask(ctx context.Context, runner *actions_model.ActionRunner) (*runnerv
actionTask *actions_model.ActionTask
)
if runner.IsDisabled {
return nil, false, nil
}
if runner.Ephemeral {
var task actions_model.ActionTask
has, err := db.GetEngine(ctx).Where("runner_id = ?", runner.ID).Get(&task)
@@ -97,8 +101,13 @@ func PickTask(ctx context.Context, runner *actions_model.ActionRunner) (*runnerv
return nil, false, nil
}
CreateCommitStatus(ctx, job)
CreateCommitStatusForRunJobs(ctx, job.Run, job)
notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, actionTask)
// job.Run is loaded inside the transaction before UpdateRunJob sets run.Started,
// so Started is zero only on the very first pick-up of that run.
if job.Run.Started.IsZero() {
NotifyWorkflowRunStatusUpdateWithReload(ctx, job)
}
return task, true, nil
}
@@ -0,0 +1,123 @@
# Actions Token Permission System Design
This document details the design of the Actions Token Permission system within Gitea, originally proposed in [#24635](https://github.com/go-gitea/gitea/issues/24635).
## Design Philosophy & GitHub Differences
Gitea Actions uses a **strict clamping mechanism** for token permissions.
While workflows can request explicit permissions that exceed the repository's default baseline
(e.g., requesting `write` when the default mode is `Restricted`),
these requests are always bounded by a hard ceiling.
The maximum allowable permissions (`MaxTokenPermissions`) are set at the Repository or Organization level.
**Any permissions requested by a workflow are strictly clamped by this ceiling policy.**
This ensures that workflows cannot bypass organizational or repository-level security restrictions.
## Terminology
### 1. `GITEA_TOKEN`
- The automatic token generated for each Actions job.
- Its permissions (read/write/none) are scoped to the repository and specific features (Code, Issues, etc.).
### 2. Token Permission Mode
- The default access level granted to a token when no explicit `permissions:` block is present in a workflow.
- **Permissive**: Grants `write` access to most repository scopes by default.
- **Restricted**: Grants `read` access (or none) to repository scopes by default.
### 3. Actions Token Permissions
- A structure representing the granular permission scopes available to a token.
- Includes scopes like: Code, Releases (both grouped under `contents` in workflow syntax),
Issues, PullRequests, Actions, Wiki, and Projects.
- **Note**: The `Packages` scope is supported in workflow/job `permissions:` blocks
but is currently hidden from the settings UI.
### 4. Cross-Repository Access
- By default, a token can access the repository where the workflow is running,
as well as any **public repositories (read-only)** on the instance.
- Users and organizations can configure an `AllowedCrossRepoIDs` list in their owner-level settings
to grant the token **read-only** access to other private/internal repositories they own.
- If the `AllowedCrossRepoIDs` list is empty, there is no cross-repository access
to other private repositories (default for enhanced security).
- In any configuration, individual jobs can disable or limit cross-repo access
by explicitly restricting their permissions (e.g., `permissions: none`).
- **Note on Forks**: Cross-repository access to private repositories is fundamentally denied
for workflows triggered by fork pull requests (see [Special Cases](#2-fork-pull-requests)).
## Token Lifecycle & Permission Evaluation
When a job starts, Gitea evaluates the requested permissions for the `GITEA_TOKEN` through a multistep clamping process:
### Step 1: Determine Base Permissions From Workflow
- If the job explicitly specifies a valid `permissions:` block, Gitea parses it.
- If the job inherits a top-level `permissions:` block, Gitea parses that.
- If an invalid or unparseable `permissions:` block is specified, or no explicit permissions are defined at all,
Gitea falls back to using the repository's default `TokenPermissionMode` (Permissive or Restricted)
to generate base permissions.
### Step 2: Apply Repository Clamping
- Repositories can define `MaxTokenPermissions` in their Actions settings.
- The base permissions from Step 1 are clamped against these maximum allowed permissions.
- If the repository says `Issues: read` and the workflow requests `Issues: write`, the final token gets `Issues: read`.
### Step 3: Apply Organization/User Clamping (Hierarchical Override)
- The organization (or user) has an owner-level configuration (`UserActionsConfig`) containing `MaxTokenPermissions`,
and these restrictions cascade down.
- The repository's clamping limits cannot exceed the owner's limits
UNLESS the repository explicitly enables `OverrideOwnerConfig`.
- If `OverrideOwnerConfig` is false, and the owner sets `MaxTokenPermissions` to `read` for all scopes,
no repository under that owner can grant `write` access, regardless of their own settings or the workflow's request.
## Parsing Priority for "contents" Scope
In GitHub Actions compatibility, the `contents` scope maps to multiple granular scopes in Gitea.
- `contents: write` maps to `Code: write` and `Releases: write`.
- When a workflow specifies both `contents` and a more granular scope (e.g., `code`),
the granular scope takes absolute priority.
**Example YAML**:
```yaml
permissions:
contents: write
code: read
```
**Result**: The token gets `Code: read` (from granular) and `Releases: write` (from contents).
## Special Cases & Edge Scenarios
### 1. Empty Permissions Mapping (`permissions: {}`)
- Explicitly setting an empty mapping means "revoke all permissions".
- The token gets `none` for all scopes.
### 2. Fork Pull Requests
- Workflows triggered by Pull Requests from forks inherently operate in `Restricted` mode for security reasons.
- The base permissions for the current repository are automatically downgraded to `read` (or `none`),
preventing untrusted code from modifying the repository.
- **Cross-Repo Access in Forks**: For workflows triggered by fork pull requests, cross-repository access
to other private repositories is strictly denied, regardless of the `AllowedCrossRepoIDs` configuration.
Fork PRs can only read the target repository and truly public repositories.
### 3. Public Repositories in Cross-Repo Access
- As mentioned in Cross-Repository Access, truly public repositories can always be read by the token,
regardless of the `AllowedCrossRepoIDs` setting. The allowed list only governs access
to private/internal repositories owned by the same user or organization.
## Packages Registry
"Packages" belong to "owner" but not "repository". Although there is a function "linking a package to a repository",
in most cases it doesn't really work. When accessing a package, usually there is no information about a repository.
So the "packages" permission should be designed separately from other permissions.
A possible approach is like this: let owner set packages permissions, and make the repositories follow.
- On owner-level:
- Add a "Packages" permission section
- "Default permissions for all repositories" can be set to none/read/write
- Set different permissions for selected repositories (if needed), like the "Collaborators" permission setting
- On repository-level:
- Now a repository can have "Packages" permission
- The repository-level "Packages" permission is clamped by the owner-level "Packages" permission
- If the owner-level "Packages" permission for this repository is read,
then the repository cannot set its "Packages" permission to write
Maybe reusing the "org teams" permission system is a good choice: bind a repository's Actions token to a team.
@@ -16,7 +16,7 @@ func CreateVariable(ctx context.Context, ownerID, repoID int64, name, data, desc
return nil, err
}
v, err := actions_model.InsertVariable(ctx, ownerID, repoID, name, util.ReserveLineBreakForTextarea(data), description)
v, err := actions_model.InsertVariable(ctx, ownerID, repoID, name, util.NormalizeStringEOL(data), description)
if err != nil {
return nil, err
}
@@ -29,7 +29,7 @@ func UpdateVariableNameData(ctx context.Context, variable *actions_model.ActionV
return false, err
}
variable.Data = util.ReserveLineBreakForTextarea(variable.Data)
variable.Data = util.NormalizeStringEOL(variable.Data)
return actions_model.UpdateVariableCols(ctx, variable, "name", "data", "description")
}
@@ -5,28 +5,24 @@ package actions
import (
"fmt"
"strings"
actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/perm"
access_model "code.gitea.io/gitea/models/perm/access"
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/actions/jobparser"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/reqctx"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/convert"
notify_service "code.gitea.io/gitea/services/notify"
"github.com/nektos/act/pkg/jobparser"
"github.com/nektos/act/pkg/model"
"gopkg.in/yaml.v3"
"go.yaml.in/yaml/v4"
)
func EnableOrDisableWorkflow(ctx *context.APIContext, workflowID string, isEnable bool) error {
@@ -44,19 +40,19 @@ func EnableOrDisableWorkflow(ctx *context.APIContext, workflowID string, isEnabl
cfg.DisableWorkflow(workflow.ID)
}
return repo_model.UpdateRepoUnit(ctx, cfgUnit)
return repo_model.UpdateRepoUnitConfig(ctx, cfgUnit)
}
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 {
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) (runID int64, _ error) {
if workflowID == "" {
return util.ErrorWrapTranslatable(
return 0, util.ErrorWrapTranslatable(
util.NewNotExistErrorf("workflowID is empty"),
"actions.workflow.not_found", workflowID,
)
}
if ref == "" {
return util.ErrorWrapTranslatable(
return 0, util.ErrorWrapTranslatable(
util.NewNotExistErrorf("ref is empty"),
"form.target_ref_not_exist", ref,
)
@@ -66,7 +62,7 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re
cfgUnit := repo.MustGetUnit(ctx, unit.TypeActions)
cfg := cfgUnit.ActionsConfig()
if cfg.IsWorkflowDisabled(workflowID) {
return util.ErrorWrapTranslatable(
return 0, util.ErrorWrapTranslatable(
util.NewPermissionDeniedErrorf("workflow is disabled"),
"actions.workflow.disabled",
)
@@ -85,7 +81,7 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re
runTargetCommit, err = gitRepo.GetBranchCommit(ref)
}
if err != nil {
return util.ErrorWrapTranslatable(
return 0, util.ErrorWrapTranslatable(
util.NewNotExistErrorf("ref %q doesn't exist", ref),
"form.target_ref_not_exist", ref,
)
@@ -94,15 +90,14 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re
// get workflow entry from runTargetCommit
_, entries, err := actions.ListWorkflows(runTargetCommit)
if err != nil {
return err
return 0, err
}
// find workflow from commit
var workflows []*jobparser.SingleWorkflow
var entry *git.TreeEntry
run := &actions_model.ActionRun{
Title: strings.SplitN(runTargetCommit.CommitMessage, "\n", 2)[0],
Title: runTargetCommit.Summary(),
RepoID: repo.ID,
Repo: repo,
OwnerID: repo.OwnerID,
@@ -126,7 +121,7 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re
}
if entry == nil {
return util.ErrorWrapTranslatable(
return 0, util.ErrorWrapTranslatable(
util.NewNotExistErrorf("workflow %q doesn't exist", workflowID),
"actions.workflow.not_found", workflowID,
)
@@ -134,12 +129,12 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re
content, err := actions.GetContentFromEntry(entry)
if err != nil {
return err
return 0, err
}
singleWorkflow := &jobparser.SingleWorkflow{}
if err := yaml.Unmarshal(content, singleWorkflow); err != nil {
return fmt.Errorf("failed to unmarshal workflow content: %w", err)
return 0, fmt.Errorf("failed to unmarshal workflow content: %w", err)
}
// get inputs from post
workflow := &model.Workflow{
@@ -148,28 +143,10 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re
inputsWithDefaults := make(map[string]any)
if workflowDispatch := workflow.WorkflowDispatchConfig(); workflowDispatch != nil {
if err = processInputs(workflowDispatch, inputsWithDefaults); err != nil {
return err
return 0, err
}
}
giteaCtx := GenerateGiteaContext(run, nil)
workflows, err = jobparser.Parse(content, jobparser.WithGitContext(giteaCtx.ToGitHubContext()), jobparser.WithInputs(inputsWithDefaults))
if err != nil {
return err
}
if len(workflows) > 0 && workflows[0].RunName != "" {
run.Title = workflows[0].RunName
}
if len(workflows) == 0 {
return util.ErrorWrapTranslatable(
util.NewNotExistErrorf("workflow %q doesn't exist", workflowID),
"actions.workflow.not_found", workflowID,
)
}
// ctx.Req.PostForm -> WorkflowDispatchPayload.Inputs -> ActionRun.EventPayload -> runner: ghc.Event
// https://docs.github.com/en/actions/learn-github-actions/contexts#github-context
// https://docs.github.com/en/webhooks/webhook-events-and-payloads#workflow_dispatch
@@ -183,42 +160,13 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re
var eventPayload []byte
if eventPayload, err = workflowDispatchPayload.JSONPayload(); err != nil {
return fmt.Errorf("JSONPayload: %w", err)
return 0, fmt.Errorf("JSONPayload: %w", err)
}
run.EventPayload = string(eventPayload)
// cancel running jobs of the same workflow
if err := CancelPreviousJobs(
ctx,
run.RepoID,
run.Ref,
run.WorkflowID,
run.Event,
); err != nil {
log.Error("CancelRunningJobs: %v", err)
}
// Insert the action run and its associated jobs into the database
if err := actions_model.InsertRun(ctx, run, workflows); err != nil {
return fmt.Errorf("InsertRun: %w", err)
if err := PrepareRunAndInsert(ctx, content, run, inputsWithDefaults); err != nil {
return 0, fmt.Errorf("PrepareRun: %w", err)
}
allJobs, err := db.Find[actions_model.ActionRunJob](ctx, actions_model.FindRunJobOptions{RunID: run.ID})
if err != nil {
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)
}
return nil
return run.ID, nil
}
+53 -19
View File
@@ -6,8 +6,8 @@ package agit
import (
"context"
"encoding/base64"
"errors"
"fmt"
"os"
"strings"
git_model "code.gitea.io/gitea/models/git"
@@ -33,6 +33,34 @@ func parseAgitPushOptionValue(s string) string {
return s
}
func GetAgitBranchInfo(ctx context.Context, repoID int64, baseBranchName string) (string, string, error) {
baseBranchExist, err := git_model.IsBranchExist(ctx, repoID, baseBranchName)
if err != nil {
return "", "", err
}
if baseBranchExist {
return baseBranchName, "", nil
}
// try match <target-branch>/<topic-branch>
// refs/for have been trimmed to get baseBranchName
for p, v := range baseBranchName {
if v != '/' {
continue
}
baseBranchExist, err := git_model.IsBranchExist(ctx, repoID, baseBranchName[:p])
if err != nil {
return "", "", err
}
if baseBranchExist {
return baseBranchName[:p], baseBranchName[p+1:], nil
}
}
return "", "", util.NewNotExistErrorf("base branch does not exist")
}
// ProcReceive handle proc receive work
func ProcReceive(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, opts *private.HookOptions) ([]private.HookProcReceiveRefResult, error) {
results := make([]private.HookProcReceiveRefResult, 0, len(opts.OldCommitIDs))
@@ -71,17 +99,19 @@ func ProcReceive(ctx context.Context, repo *repo_model.Repository, gitRepo *git.
continue
}
baseBranchName := opts.RefFullNames[i].ForBranchName()
currentTopicBranch := ""
if !gitrepo.IsBranchExist(ctx, repo, baseBranchName) {
// try match refs/for/<target-branch>/<topic-branch>
for p, v := range baseBranchName {
if v == '/' && gitrepo.IsBranchExist(ctx, repo, baseBranchName[:p]) && p != len(baseBranchName)-1 {
currentTopicBranch = baseBranchName[p+1:]
baseBranchName = baseBranchName[:p]
break
}
baseBranchName, currentTopicBranch, err := GetAgitBranchInfo(ctx, repo.ID, opts.RefFullNames[i].ForBranchName())
if err != nil {
if !errors.Is(err, util.ErrNotExist) {
return nil, fmt.Errorf("failed to get branch information. Error: %w", err)
}
// If branch does not exist, we can continue
results = append(results, private.HookProcReceiveRefResult{
OriginalRef: opts.RefFullNames[i],
OldOID: opts.OldCommitIDs[i],
NewOID: opts.NewCommitIDs[i],
Err: "base-branch does not exist",
})
continue
}
if len(topicBranch) == 0 && len(currentTopicBranch) == 0 {
@@ -124,10 +154,10 @@ func ProcReceive(ctx context.Context, repo *repo_model.Repository, gitRepo *git.
// create a new pull request
if title == "" {
title = strings.Split(commit.CommitMessage, "\n")[0]
title = commit.Summary()
}
if description == "" {
_, description, _ = strings.Cut(commit.CommitMessage, "\n\n")
_, description, _ = strings.Cut(commit.Message(), "\n\n")
}
if description == "" {
description = title
@@ -199,9 +229,10 @@ func ProcReceive(ctx context.Context, repo *repo_model.Repository, gitRepo *git.
}
if !forcePush.Value() {
output, _, err := gitcmd.NewCommand("rev-list", "--max-count=1").
AddDynamicArguments(oldCommitID, "^"+opts.NewCommitIDs[i]).
RunStdString(ctx, &gitcmd.RunOpts{Dir: repo.RepoPath(), Env: os.Environ()})
output, _, err := gitrepo.RunCmdString(ctx, repo,
gitcmd.NewCommand("rev-list", "--max-count=1").
AddDynamicArguments(oldCommitID, "^"+opts.NewCommitIDs[i]),
)
if err != nil {
return nil, fmt.Errorf("failed to detect force push: %w", err)
} else if len(output) > 0 {
@@ -251,12 +282,15 @@ func ProcReceive(ctx context.Context, repo *repo_model.Repository, gitRepo *git.
if err != nil {
return nil, fmt.Errorf("failed to load pull issue. Error: %w", err)
}
comment, err := pull_service.CreatePushPullComment(ctx, pusher, pr, oldCommitID, opts.NewCommitIDs[i], forcePush.Value())
if err == nil && comment != nil {
isForcePush := forcePush.Value()
comment, commentCreated, err := pull_service.CreatePushPullComment(ctx, pusher, pr, oldCommitID, opts.NewCommitIDs[i], isForcePush)
if err != nil {
log.Error("CreatePushPullComment: %v", err)
} else if commentCreated {
notify_service.PullRequestPushCommits(ctx, pusher, pr, comment)
}
notify_service.PullRequestSynchronized(ctx, pusher, pr)
isForcePush := comment != nil && comment.IsForcePush
results = append(results, private.HookProcReceiveRefResult{
OldOID: oldCommitID,
@@ -6,11 +6,56 @@ package agit
import (
"testing"
"code.gitea.io/gitea/models/unittest"
"code.gitea.io/gitea/modules/util"
"github.com/stretchr/testify/assert"
)
func TestMain(m *testing.M) {
unittest.MainTest(m)
}
func TestParseAgitPushOptionValue(t *testing.T) {
assert.Equal(t, "a", parseAgitPushOptionValue("a"))
assert.Equal(t, "a", parseAgitPushOptionValue("{base64}YQ=="))
assert.Equal(t, "{base64}invalid value", parseAgitPushOptionValue("{base64}invalid value"))
}
func TestGetAgitBranchInfo(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
_, _, err := GetAgitBranchInfo(t.Context(), 1, "non-exist-basebranch")
assert.ErrorIs(t, err, util.ErrNotExist)
baseBranch, currentTopicBranch, err := GetAgitBranchInfo(t.Context(), 1, "master")
assert.NoError(t, err)
assert.Equal(t, "master", baseBranch)
assert.Empty(t, currentTopicBranch)
baseBranch, currentTopicBranch, err = GetAgitBranchInfo(t.Context(), 1, "master/topicbranch")
assert.NoError(t, err)
assert.Equal(t, "master", baseBranch)
assert.Equal(t, "topicbranch", currentTopicBranch)
baseBranch, currentTopicBranch, err = GetAgitBranchInfo(t.Context(), 1, "master/")
assert.NoError(t, err)
assert.Equal(t, "master", baseBranch)
assert.Empty(t, currentTopicBranch)
_, _, err = GetAgitBranchInfo(t.Context(), 1, "/")
assert.ErrorIs(t, err, util.ErrNotExist)
_, _, err = GetAgitBranchInfo(t.Context(), 1, "//")
assert.ErrorIs(t, err, util.ErrNotExist)
baseBranch, currentTopicBranch, err = GetAgitBranchInfo(t.Context(), 1, "master/topicbranch/")
assert.NoError(t, err)
assert.Equal(t, "master", baseBranch)
assert.Equal(t, "topicbranch/", currentTopicBranch)
baseBranch, currentTopicBranch, err = GetAgitBranchInfo(t.Context(), 1, "master/topicbranch/1")
assert.NoError(t, err)
assert.Equal(t, "master", baseBranch)
assert.Equal(t, "topicbranch/1", currentTopicBranch)
}
@@ -162,7 +162,7 @@ func parseCommitWithGPGSignature(ctx context.Context, c *git.Commit, committer *
}
}
defaultGPGSettings, err := c.GetRepositoryDefaultPublicGPGKey(false)
defaultGPGSettings, err := git.GetDefaultPublicGPGKey(ctx, false)
if err != nil {
log.Error("Error getting default public gpg key: %v", err)
} else if defaultGPGSettings == nil {
@@ -31,7 +31,7 @@ func TestParseCommitWithSSHSignature(t *testing.T) {
// AAAEDWqPHTH51xb4hy1y1f1VeWL/2A9Q0b6atOyv5fx8x5prpPrMXSg9qTx04jPNPWRcHs
// utyxWjThIpzcaO68yWVnAAAAEXVzZXIyQGV4YW1wbGUuY29tAQIDBA==
// -----END OPENSSH PRIVATE KEY-----
sshPubKey, err := asymkey_model.AddPublicKey(t.Context(), 999, "user-ssh-key-any-name", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILpPrMXSg9qTx04jPNPWRcHsutyxWjThIpzcaO68yWVn", 0)
sshPubKey, err := asymkey_model.AddPublicKey(t.Context(), 999, "user-ssh-key-any-name", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILpPrMXSg9qTx04jPNPWRcHsutyxWjThIpzcaO68yWVn", 0, false)
require.NoError(t, err)
_, err = db.GetEngine(t.Context()).ID(sshPubKey.ID).Cols("verified").Update(&asymkey_model.PublicKey{Verified: true})
require.NoError(t, err)
+28 -112
View File
@@ -17,7 +17,6 @@ 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/git/gitcmd"
"code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/process"
@@ -109,79 +108,34 @@ func IsErrWontSign(err error) bool {
return ok
}
// SigningKey returns the KeyID and git Signature for the repo
func SigningKey(ctx context.Context, repoPath string) (*git.SigningKey, *git.Signature) {
if setting.Repository.Signing.SigningKey == "none" {
return nil, nil
}
if setting.Repository.Signing.SigningKey == "default" || setting.Repository.Signing.SigningKey == "" {
// Can ignore the error here as it means that commit.gpgsign is not set
value, _, _ := gitcmd.NewCommand("config", "--get", "commit.gpgsign").RunStdString(ctx, &gitcmd.RunOpts{Dir: repoPath})
sign, valid := git.ParseBool(strings.TrimSpace(value))
if !sign || !valid {
return nil, nil
}
format, _, _ := gitcmd.NewCommand("config", "--default", git.SigningKeyFormatOpenPGP, "--get", "gpg.format").RunStdString(ctx, &gitcmd.RunOpts{Dir: repoPath})
signingKey, _, _ := gitcmd.NewCommand("config", "--get", "user.signingkey").RunStdString(ctx, &gitcmd.RunOpts{Dir: repoPath})
signingName, _, _ := gitcmd.NewCommand("config", "--get", "user.name").RunStdString(ctx, &gitcmd.RunOpts{Dir: repoPath})
signingEmail, _, _ := gitcmd.NewCommand("config", "--get", "user.email").RunStdString(ctx, &gitcmd.RunOpts{Dir: repoPath})
if strings.TrimSpace(signingKey) == "" {
return nil, nil
}
return &git.SigningKey{
KeyID: strings.TrimSpace(signingKey),
Format: strings.TrimSpace(format),
}, &git.Signature{
Name: strings.TrimSpace(signingName),
Email: strings.TrimSpace(signingEmail),
}
}
if setting.Repository.Signing.SigningKey == "" {
return nil, nil
}
return &git.SigningKey{
KeyID: setting.Repository.Signing.SigningKey,
Format: setting.Repository.Signing.SigningFormat,
}, &git.Signature{
Name: setting.Repository.Signing.SigningName,
Email: setting.Repository.Signing.SigningEmail,
}
}
// PublicSigningKey gets the public signing key within a provided repository directory
func PublicSigningKey(ctx context.Context, repoPath string) (content, format string, err error) {
signingKey, _ := SigningKey(ctx, repoPath)
// PublicSigningKey gets the public signing key of the entire instance
func PublicSigningKey(ctx context.Context) (content, format string, err error) {
signingKey, _ := git.GetSigningKey(ctx)
if signingKey == nil {
return "", "", nil
}
if signingKey.Format == git.SigningKeyFormatSSH {
content, err := os.ReadFile(signingKey.KeyID)
if err != nil {
log.Error("Unable to read SSH public key file in %s: %s, %v", repoPath, signingKey, err)
log.Error("Unable to read SSH public key file: %s, %v", signingKey, err)
return "", signingKey.Format, err
}
return string(content), signingKey.Format, nil
}
content, stderr, err := process.GetManager().ExecDir(ctx, -1, repoPath,
content, stderr, err := process.GetManager().ExecDir(ctx, -1, setting.Git.HomePath,
"gpg --export -a", "gpg", "--export", "-a", signingKey.KeyID)
if err != nil {
log.Error("Unable to get default signing key in %s: %s, %s, %v", repoPath, signingKey, stderr, err)
log.Error("Unable to get default signing key: %s, %s, %v", signingKey, stderr, err)
return "", signingKey.Format, err
}
return content, signingKey.Format, nil
}
// SignInitialCommit determines if we should sign the initial commit to this repository
func SignInitialCommit(ctx context.Context, repoPath string, u *user_model.User) (bool, *git.SigningKey, *git.Signature, error) {
func SignInitialCommit(ctx context.Context, u *user_model.User) (bool, *git.SigningKey, *git.Signature, error) {
rules := signingModeFromStrings(setting.Repository.Signing.InitialCommit)
signingKey, sig := SigningKey(ctx, repoPath)
signingKey, sig := git.GetSigningKey(ctx)
if signingKey == nil {
return false, nil, nil, &ErrWontSign{noKey}
}
@@ -215,10 +169,9 @@ Loop:
}
// SignWikiCommit determines if we should sign the commits to this repository wiki
func SignWikiCommit(ctx context.Context, repo *repo_model.Repository, u *user_model.User) (bool, *git.SigningKey, *git.Signature, error) {
repoWikiPath := repo.WikiPath()
func SignWikiCommit(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, u *user_model.User) (bool, *git.SigningKey, *git.Signature, error) {
rules := signingModeFromStrings(setting.Repository.Signing.Wiki)
signingKey, sig := SigningKey(ctx, repoWikiPath)
signingKey, sig := gitrepo.GetSigningKey(ctx)
if signingKey == nil {
return false, nil, nil, &ErrWontSign{noKey}
}
@@ -247,11 +200,6 @@ Loop:
return false, nil, nil, &ErrWontSign{twofa}
}
case parentSigned:
gitRepo, err := gitrepo.OpenRepository(ctx, repo.WikiStorageRepo())
if err != nil {
return false, nil, nil, err
}
defer gitRepo.Close()
commit, err := gitRepo.GetCommit("HEAD")
if err != nil {
return false, nil, nil, err
@@ -269,9 +217,9 @@ Loop:
}
// SignCRUDAction determines if we should sign a CRUD commit to this repository
func SignCRUDAction(ctx context.Context, repoPath string, u *user_model.User, tmpBasePath, parentCommit string) (bool, *git.SigningKey, *git.Signature, error) {
func SignCRUDAction(ctx context.Context, u *user_model.User, gitRepo *git.Repository, parentCommit string) (bool, *git.SigningKey, *git.Signature, error) {
rules := signingModeFromStrings(setting.Repository.Signing.CRUDActions)
signingKey, sig := SigningKey(ctx, repoPath)
signingKey, sig := git.GetSigningKey(ctx)
if signingKey == nil {
return false, nil, nil, &ErrWontSign{noKey}
}
@@ -300,11 +248,6 @@ Loop:
return false, nil, nil, &ErrWontSign{twofa}
}
case parentSigned:
gitRepo, err := git.OpenRepository(ctx, tmpBasePath)
if err != nil {
return false, nil, nil, err
}
defer gitRepo.Close()
isEmpty, err := gitRepo.IsEmpty()
if err != nil {
return false, nil, nil, err
@@ -328,22 +271,28 @@ Loop:
}
// SignMerge determines if we should sign a PR merge commit to the base repository
func SignMerge(ctx context.Context, pr *issues_model.PullRequest, u *user_model.User, tmpBasePath, baseCommit, headCommit string) (bool, *git.SigningKey, *git.Signature, error) {
func SignMerge(ctx context.Context, pr *issues_model.PullRequest, u *user_model.User, gitRepo *git.Repository) (bool, *git.SigningKey, *git.Signature, error) {
if err := pr.LoadBaseRepo(ctx); err != nil {
log.Error("Unable to get Base Repo for pull request")
return false, nil, nil, err
}
repo := pr.BaseRepo
signingKey, signer := SigningKey(ctx, repo.RepoPath())
baseCommit, err := gitRepo.GetCommit(pr.BaseBranch)
if err != nil {
return false, nil, nil, err
}
headCommit, err := gitRepo.GetCommit(pr.GetGitHeadRefName())
if err != nil {
return false, nil, nil, err
}
signingKey, signer := gitrepo.GetSigningKey(ctx)
if signingKey == nil {
return false, nil, nil, &ErrWontSign{noKey}
}
rules := signingModeFromStrings(setting.Repository.Signing.Merges)
var gitRepo *git.Repository
var err error
Loop:
for _, rule := range rules {
switch rule {
@@ -379,59 +328,26 @@ Loop:
return false, nil, nil, &ErrWontSign{approved}
}
case baseSigned:
if gitRepo == nil {
gitRepo, err = git.OpenRepository(ctx, tmpBasePath)
if err != nil {
return false, nil, nil, err
}
defer gitRepo.Close()
}
commit, err := gitRepo.GetCommit(baseCommit)
if err != nil {
return false, nil, nil, err
}
verification := ParseCommitWithSignature(ctx, commit)
verification := ParseCommitWithSignature(ctx, baseCommit)
if !verification.Verified {
return false, nil, nil, &ErrWontSign{baseSigned}
}
case headSigned:
if gitRepo == nil {
gitRepo, err = git.OpenRepository(ctx, tmpBasePath)
if err != nil {
return false, nil, nil, err
}
defer gitRepo.Close()
}
commit, err := gitRepo.GetCommit(headCommit)
if err != nil {
return false, nil, nil, err
}
verification := ParseCommitWithSignature(ctx, commit)
verification := ParseCommitWithSignature(ctx, headCommit)
if !verification.Verified {
return false, nil, nil, &ErrWontSign{headSigned}
}
case commitsSigned:
if gitRepo == nil {
gitRepo, err = git.OpenRepository(ctx, tmpBasePath)
if err != nil {
return false, nil, nil, err
}
defer gitRepo.Close()
}
commit, err := gitRepo.GetCommit(headCommit)
if err != nil {
return false, nil, nil, err
}
verification := ParseCommitWithSignature(ctx, commit)
verification := ParseCommitWithSignature(ctx, headCommit)
if !verification.Verified {
return false, nil, nil, &ErrWontSign{commitsSigned}
}
// need to work out merge-base
mergeBaseCommit, _, err := gitRepo.GetMergeBase("", baseCommit, headCommit)
mergeBaseCommit, err := gitrepo.MergeBase(ctx, pr.BaseRepo, baseCommit.ID.String(), headCommit.ID.String())
if err != nil {
return false, nil, nil, err
}
commitList, err := commit.CommitsBeforeUntil(mergeBaseCommit)
commitList, err := headCommit.CommitsBeforeUntil(mergeBaseCommit)
if err != nil {
return false, nil, nil, err
}
@@ -66,7 +66,7 @@ ssh-dss AAAAB3NzaC1kc3MAAACBAOChCC7lf6Uo9n7BmZ6M8St19PZf4Tn59NriyboW2x/DZuYAz3ib
for i, kase := range testCases {
s.ID = int64(i) + 20
asymkey_model.AddPublicKeysBySource(t.Context(), user, s, []string{kase.keyString})
asymkey_model.AddPublicKeysBySource(t.Context(), user, s, []string{kase.keyString}, false)
keys, err := db.Find[asymkey_model.PublicKey](t.Context(), asymkey_model.FindPublicKeyOptions{
OwnerID: user.ID,
LoginSourceID: s.ID,
@@ -54,8 +54,17 @@ func NewLimitedUploaderMaxBytesReader(r io.ReadCloser, w http.ResponseWriter) *U
return &UploaderFile{rd: r, size: -1, respWriter: w}
}
func UploadAttachmentGeneralSizeLimit(ctx context.Context, file *UploaderFile, allowedTypes string, attach *repo_model.Attachment) (*repo_model.Attachment, error) {
return uploadAttachment(ctx, file, allowedTypes, setting.Attachment.MaxSize<<20, attach)
type UploadAttachmentFunc func(ctx context.Context, file *UploaderFile, attach *repo_model.Attachment) (*repo_model.Attachment, error)
func UploadAttachmentForIssue(ctx context.Context, file *UploaderFile, attach *repo_model.Attachment) (*repo_model.Attachment, error) {
return uploadAttachment(ctx, file, setting.Attachment.AllowedTypes, setting.Attachment.MaxSize<<20, attach)
}
func UploadAttachmentForRelease(ctx context.Context, file *UploaderFile, attach *repo_model.Attachment) (*repo_model.Attachment, error) {
// FIXME: although the release attachment has different settings from the issue attachment,
// it still uses the same attachment table, the same storage and the same upload logic
// So if the "issue attachment [attachment]" is not enabled, it will also affect the release attachment, which is not expected.
return uploadAttachment(ctx, file, setting.Repository.Release.AllowedTypes, setting.Repository.Release.FileMaxSize<<20, attach)
}
func uploadAttachment(ctx context.Context, file *UploaderFile, allowedTypes string, maxFileSize int64, attach *repo_model.Attachment) (*repo_model.Attachment, error) {
@@ -8,39 +8,16 @@ import (
"errors"
"fmt"
"net/http"
"regexp"
"strings"
"sync"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/auth/webauthn"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/session"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/web/middleware"
gitea_context "code.gitea.io/gitea/services/context"
user_service "code.gitea.io/gitea/services/user"
)
type globalVarsStruct struct {
gitRawOrAttachPathRe *regexp.Regexp
lfsPathRe *regexp.Regexp
archivePathRe *regexp.Regexp
feedPathRe *regexp.Regexp
feedRefPathRe *regexp.Regexp
}
var globalVars = sync.OnceValue(func() *globalVarsStruct {
return &globalVarsStruct{
gitRawOrAttachPathRe: regexp.MustCompile(`^/[-.\w]+/[-.\w]+/(?:(?:git-(?:(?:upload)|(?:receive))-pack$)|(?:info/refs$)|(?:HEAD$)|(?:objects/)|(?:raw/)|(?:releases/download/)|(?:attachments/))`),
lfsPathRe: regexp.MustCompile(`^/[-.\w]+/[-.\w]+/info/lfs/`),
archivePathRe: regexp.MustCompile(`^/[-.\w]+/[-.\w]+/archive/`),
feedPathRe: regexp.MustCompile(`^/[-.\w]+(/[-.\w]+)?\.(rss|atom)$`), // "/owner.rss" or "/owner/repo.atom"
feedRefPathRe: regexp.MustCompile(`^/[-.\w]+/[-.\w]+/(rss|atom)/`), // "/owner/repo/rss/branch/..."
}
})
type ErrUserAuthMessage string
func (e ErrUserAuthMessage) Error() string {
@@ -61,66 +38,6 @@ func Init() {
webauthn.Init()
}
type authPathDetector struct {
req *http.Request
vars *globalVarsStruct
}
func newAuthPathDetector(req *http.Request) *authPathDetector {
return &authPathDetector{req: req, vars: globalVars()}
}
// isAPIPath returns true if the specified URL is an API path
func (a *authPathDetector) isAPIPath() bool {
return strings.HasPrefix(a.req.URL.Path, "/api/")
}
// isAttachmentDownload check if request is a file download (GET) with URL to an attachment
func (a *authPathDetector) isAttachmentDownload() bool {
return strings.HasPrefix(a.req.URL.Path, "/attachments/") && a.req.Method == http.MethodGet
}
func (a *authPathDetector) isFeedRequest(req *http.Request) bool {
if !setting.Other.EnableFeed {
return false
}
if req.Method != http.MethodGet {
return false
}
return a.vars.feedPathRe.MatchString(req.URL.Path) || a.vars.feedRefPathRe.MatchString(req.URL.Path)
}
// isContainerPath checks if the request targets the container endpoint
func (a *authPathDetector) isContainerPath() bool {
return strings.HasPrefix(a.req.URL.Path, "/v2/")
}
func (a *authPathDetector) isGitRawOrAttachPath() bool {
return a.vars.gitRawOrAttachPathRe.MatchString(a.req.URL.Path)
}
func (a *authPathDetector) isGitRawOrAttachOrLFSPath() bool {
if a.isGitRawOrAttachPath() {
return true
}
if setting.LFS.StartServer {
return a.vars.lfsPathRe.MatchString(a.req.URL.Path)
}
return false
}
func (a *authPathDetector) isArchivePath() bool {
return a.vars.archivePathRe.MatchString(a.req.URL.Path)
}
func (a *authPathDetector) isAuthenticatedTokenRequest() bool {
switch a.req.URL.Path {
case "/login/oauth/userinfo", "/login/oauth/introspect":
return true
}
return false
}
// handleSignIn clears existing session variables and stores new ones for the specified user object
func handleSignIn(resp http.ResponseWriter, req *http.Request, sess SessionStore, user *user_model.User) {
// We need to regenerate the session...
@@ -162,9 +79,4 @@ func handleSignIn(resp http.ResponseWriter, req *http.Request, sess SessionStore
}
middleware.SetLocaleCookie(resp, user.Language, 0)
// force to generate a new CSRF token
if ctx := gitea_context.GetWebContext(req.Context()); ctx != nil {
ctx.Csrf.PrepareForSessionUser(ctx)
}
}
@@ -1,155 +0,0 @@
// Copyright 2014 The Gogs Authors. All rights reserved.
// Copyright 2019 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"net/http"
"testing"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/test"
"github.com/stretchr/testify/assert"
)
func Test_isGitRawOrLFSPath(t *testing.T) {
tests := []struct {
path string
want bool
}{
{
"/owner/repo/git-upload-pack",
true,
},
{
"/owner/repo/git-receive-pack",
true,
},
{
"/owner/repo/info/refs",
true,
},
{
"/owner/repo/HEAD",
true,
},
{
"/owner/repo/objects/info/alternates",
true,
},
{
"/owner/repo/objects/info/http-alternates",
true,
},
{
"/owner/repo/objects/info/packs",
true,
},
{
"/owner/repo/objects/info/blahahsdhsdkla",
true,
},
{
"/owner/repo/objects/01/23456789abcdef0123456789abcdef01234567",
true,
},
{
"/owner/repo/objects/pack/pack-123456789012345678921234567893124567894.pack",
true,
},
{
"/owner/repo/objects/pack/pack-0123456789abcdef0123456789abcdef0123456.idx",
true,
},
{
"/owner/repo/raw/branch/foo/fanaso",
true,
},
{
"/owner/repo/stars",
false,
},
{
"/notowner",
false,
},
{
"/owner/repo",
false,
},
{
"/owner/repo/commit/123456789012345678921234567893124567894",
false,
},
{
"/owner/repo/releases/download/tag/repo.tar.gz",
true,
},
{
"/owner/repo/attachments/6d92a9ee-5d8b-4993-97c9-6181bdaa8955",
true,
},
}
defer test.MockVariableValue(&setting.LFS.StartServer)()
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
req, _ := http.NewRequest(http.MethodPost, "http://localhost"+tt.path, nil)
setting.LFS.StartServer = false
assert.Equal(t, tt.want, newAuthPathDetector(req).isGitRawOrAttachOrLFSPath())
setting.LFS.StartServer = true
assert.Equal(t, tt.want, newAuthPathDetector(req).isGitRawOrAttachOrLFSPath())
})
}
lfsTests := []string{
"/owner/repo/info/lfs/",
"/owner/repo/info/lfs/objects/batch",
"/owner/repo/info/lfs/objects/oid/filename",
"/owner/repo/info/lfs/objects/oid",
"/owner/repo/info/lfs/objects",
"/owner/repo/info/lfs/verify",
"/owner/repo/info/lfs/locks",
"/owner/repo/info/lfs/locks/verify",
"/owner/repo/info/lfs/locks/123/unlock",
}
for _, tt := range lfsTests {
t.Run(tt, func(t *testing.T) {
req, _ := http.NewRequest(http.MethodPost, tt, nil)
setting.LFS.StartServer = false
got := newAuthPathDetector(req).isGitRawOrAttachOrLFSPath()
assert.Equalf(t, setting.LFS.StartServer, got, "isGitOrLFSPath(%q) = %v, want %v, %v", tt, got, setting.LFS.StartServer, globalVars().gitRawOrAttachPathRe.MatchString(tt))
setting.LFS.StartServer = true
got = newAuthPathDetector(req).isGitRawOrAttachOrLFSPath()
assert.Equalf(t, setting.LFS.StartServer, got, "isGitOrLFSPath(%q) = %v, want %v", tt, got, setting.LFS.StartServer)
})
}
}
func Test_isFeedRequest(t *testing.T) {
tests := []struct {
want bool
path string
}{
{true, "/user.rss"},
{true, "/user/repo.atom"},
{false, "/user/repo"},
{false, "/use/repo/file.rss"},
{true, "/org/repo/rss/branch/xxx"},
{true, "/org/repo/atom/tag/xxx"},
{false, "/org/repo/branch/main/rss/any"},
{false, "/org/atom/any"},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
req, _ := http.NewRequest(http.MethodGet, "http://localhost"+tt.path, nil)
assert.Equal(t, tt.want, newAuthPathDetector(req).isFeedRequest(req))
})
}
}
@@ -32,7 +32,7 @@ var (
func CheckAuthToken(ctx context.Context, value string) (*auth_model.AuthToken, error) {
if len(value) == 0 {
return nil, nil
return nil, nil //nolint:nilnil // the auth method is not applicable
}
parts := strings.SplitN(value, ":", 2)
+29 -21
View File
@@ -40,25 +40,14 @@ func (b *Basic) Name() string {
return BasicMethodName
}
// Verify extracts and validates Basic data (username and password/token) from the
// "Authorization" header of the request and returns the corresponding user object for that
// name/token on successful validation.
// Returns nil if header is empty or validation fails.
func (b *Basic) Verify(req *http.Request, w http.ResponseWriter, store DataStore, sess SessionStore) (*user_model.User, error) {
// Basic authentication should only fire on API, Feed, Download, Archives or on Git or LFSPaths
// Not all feed (rss/atom) clients feature the ability to add cookies or headers, so we need to allow basic auth for feeds
detector := newAuthPathDetector(req)
if !detector.isAPIPath() && !detector.isFeedRequest(req) && !detector.isContainerPath() && !detector.isAttachmentDownload() && !detector.isArchivePath() && !detector.isGitRawOrAttachOrLFSPath() {
return nil, nil
}
func (b *Basic) parseAuthBasic(req *http.Request) (ret struct{ authToken, uname, passwd string }) {
authHeader := req.Header.Get("Authorization")
if authHeader == "" {
return nil, nil
return ret
}
parsed, ok := httpauth.ParseAuthorizationHeader(authHeader)
if !ok || parsed.BasicAuth == nil {
return nil, nil
return ret
}
uname, passwd := parsed.BasicAuth.Username, parsed.BasicAuth.Password
@@ -73,9 +62,14 @@ func (b *Basic) Verify(req *http.Request, w http.ResponseWriter, store DataStore
} else {
log.Trace("Basic Authorization: Attempting login with username as token")
}
ret.authToken, ret.uname, ret.passwd = authToken, uname, passwd
return ret
}
// get oauth2 token's user's ID
_, uid := GetOAuthAccessTokenScopeAndUserID(req.Context(), authToken)
// VerifyAuthToken only the access token provided as parameter, used by other auth methods that want to reuse access token verification logic
func (b *Basic) VerifyAuthToken(req *http.Request, w http.ResponseWriter, store DataStore, sess SessionStore, authToken string) (*user_model.User, error) {
// get oauth2 token's user's ID and access scope
accessTokenScope, uid := GetOAuthAccessTokenScopeAndUserID(req.Context(), authToken)
if uid != 0 {
log.Trace("Basic Authorization: Valid OAuthAccessToken for user[%d]", uid)
@@ -87,6 +81,7 @@ func (b *Basic) Verify(req *http.Request, w http.ResponseWriter, store DataStore
store.GetData()["LoginMethod"] = OAuth2TokenMethodName
store.GetData()["IsApiToken"] = true
store.GetData()["ApiTokenScope"] = accessTokenScope
return u, nil
}
@@ -117,16 +112,29 @@ func (b *Basic) Verify(req *http.Request, w http.ResponseWriter, store DataStore
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()["LoginMethod"] = ActionTokenMethodName
store.GetData()["IsActionsToken"] = true
store.GetData()["ActionsTaskID"] = task.ID
return user_model.NewActionsUserWithTaskID(task.ID), nil
}
return nil, nil //nolint:nilnil // the auth method is not applicable
}
return user_model.NewActionsUser(), nil
// Verify extracts and validates Basic data (username and password/token) from the
// "Authorization" header of the request and returns the corresponding user object for that
// name/token on successful validation.
// Returns nil if header is empty or validation fails.
func (b *Basic) Verify(req *http.Request, w http.ResponseWriter, store DataStore, sess SessionStore) (*user_model.User, error) {
parseBasicRet := b.parseAuthBasic(req)
authToken, uname, passwd := parseBasicRet.authToken, parseBasicRet.uname, parseBasicRet.passwd
if authToken == "" && uname == "" {
return nil, nil //nolint:nilnil // the auth method is not applicable
}
u, err := b.VerifyAuthToken(req, w, store, sess, authToken)
if u != nil || err != nil {
return u, err
}
if !setting.Service.EnableBasicAuth {
return nil, nil
return nil, nil //nolint:nilnil // the auth method is not applicable
}
log.Trace("Basic Authorization: Attempting SignIn for %s", uname)
@@ -42,7 +42,7 @@ func (h *HTTPSign) Name() string {
func (h *HTTPSign) Verify(req *http.Request, w http.ResponseWriter, store DataStore, sess SessionStore) (*user_model.User, error) {
sigHead := req.Header.Get("Signature")
if len(sigHead) == 0 {
return nil, nil
return nil, nil //nolint:nilnil // the auth method is not applicable
}
var (
@@ -53,14 +53,14 @@ func (h *HTTPSign) Verify(req *http.Request, w http.ResponseWriter, store DataSt
if len(req.Header.Get("X-Ssh-Certificate")) != 0 {
// Handle Signature signed by SSH certificates
if len(setting.SSH.TrustedUserCAKeys) == 0 {
return nil, nil
return nil, nil //nolint:nilnil // the auth method is not applicable
}
publicKey, err = VerifyCert(req)
if err != nil {
log.Debug("VerifyCert on request from %s: failed: %v", req.RemoteAddr, err)
log.Warn("Failed authentication attempt from %s", req.RemoteAddr)
return nil, nil
return nil, nil //nolint:nilnil // the auth method is not applicable
}
} else {
// Handle Signature signed by Public Key
@@ -68,7 +68,7 @@ func (h *HTTPSign) Verify(req *http.Request, w http.ResponseWriter, store DataSt
if err != nil {
log.Debug("VerifyPubKey on request from %s: failed: %v", req.RemoteAddr, err)
log.Warn("Failed authentication attempt from %s", req.RemoteAddr)
return nil, nil
return nil, nil //nolint:nilnil // the auth method is not applicable
}
}
+18 -46
View File
@@ -6,6 +6,7 @@ package auth
import (
"context"
"errors"
"net/http"
"strings"
"time"
@@ -17,14 +18,12 @@ import (
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/services/actions"
"code.gitea.io/gitea/services/oauth2_provider"
)
// Ensure the struct implements the interface.
var (
_ Method = &OAuth2{}
)
var _ Method = &OAuth2{}
// GetOAuthAccessTokenScopeAndUserID returns access token scope and user id
func GetOAuthAccessTokenScopeAndUserID(ctx context.Context, accessToken string) (auth_model.AccessTokenScope, int64) {
@@ -106,18 +105,16 @@ func parseToken(req *http.Request) (string, bool) {
return "", false
}
// userIDFromToken returns the user id corresponding to the OAuth token.
// userFromToken returns the user corresponding to the OAuth token.
// It will set 'IsApiToken' to true if the token is an API token and
// set 'ApiTokenScope' to the scope of the access token
func (o *OAuth2) userIDFromToken(ctx context.Context, tokenSHA string, store DataStore) int64 {
// set 'ApiTokenScope' to the scope of the access token (TODO: this behavior should be fixed, don't set ctx.Data)
func (o *OAuth2) userFromToken(ctx context.Context, tokenSHA string, store DataStore) (*user_model.User, error) {
// Let's see if token is valid.
if strings.Contains(tokenSHA, ".") {
// First attempt to decode an actions JWT, returning the actions user
if taskID, err := actions.TokenToTaskID(tokenSHA); err == nil {
if CheckTaskIsRunning(ctx, taskID) {
store.GetData()["IsActionsToken"] = true
store.GetData()["ActionsTaskID"] = taskID
return user_model.ActionsUserID
return user_model.NewActionsUserWithTaskID(taskID), nil
}
}
@@ -127,33 +124,27 @@ func (o *OAuth2) userIDFromToken(ctx context.Context, tokenSHA string, store Dat
store.GetData()["IsApiToken"] = true
store.GetData()["ApiTokenScope"] = accessTokenScope
}
return uid
return user_model.GetUserByID(ctx, uid)
}
t, err := auth_model.GetAccessTokenBySHA(ctx, tokenSHA)
if err != nil {
if auth_model.IsErrAccessTokenNotExist(err) {
// check task token
task, err := actions_model.GetRunningTaskByToken(ctx, tokenSHA)
if err == nil && task != nil {
if task, err := actions_model.GetRunningTaskByToken(ctx, tokenSHA); err == 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
return user_model.NewActionsUserWithTaskID(task.ID), nil
}
} else if !auth_model.IsErrAccessTokenNotExist(err) && !auth_model.IsErrAccessTokenEmpty(err) {
log.Error("GetAccessTokenBySHA: %v", err)
}
return 0
return nil, err
}
t.UpdatedUnix = timeutil.TimeStampNow()
if err = auth_model.UpdateAccessToken(ctx, t); err != nil {
log.Error("UpdateAccessToken: %v", err)
}
store.GetData()["IsApiToken"] = true
store.GetData()["ApiTokenScope"] = t.Scope
return t.UID
return user_model.GetUserByID(ctx, t.UID)
}
// Verify extracts the user ID from the OAuth token in the query parameters
@@ -161,33 +152,14 @@ func (o *OAuth2) userIDFromToken(ctx context.Context, tokenSHA string, store Dat
// If verification is successful returns an existing user object.
// Returns nil if verification fails.
func (o *OAuth2) Verify(req *http.Request, w http.ResponseWriter, store DataStore, sess SessionStore) (*user_model.User, error) {
// These paths are not API paths, but we still want to check for tokens because they maybe in the API returned URLs
detector := newAuthPathDetector(req)
if !detector.isAPIPath() && !detector.isAttachmentDownload() && !detector.isAuthenticatedTokenRequest() &&
!detector.isGitRawOrAttachPath() && !detector.isArchivePath() {
return nil, nil
}
token, ok := parseToken(req)
if !ok {
return nil, nil
return nil, nil //nolint:nilnil // the auth method is not applicable
}
id := o.userIDFromToken(req.Context(), token, store)
if id <= 0 && id != -2 { // -2 means actions, so we need to allow it.
return nil, user_model.ErrUserNotExist{}
user, err := o.userFromToken(req.Context(), token, store)
if err != nil && !errors.Is(err, util.ErrNotExist) {
log.Error("userFromToken: %v", err) // the callers might ignore the error, so log it here
}
log.Trace("OAuth2 Authorization: Found token for user[%d]", id)
user, err := user_model.GetPossibleUserByID(req.Context(), id)
if err != nil {
if !user_model.IsErrUserNotExist(err) {
log.Error("GetUserByName: %v", err)
}
return nil, err
}
log.Trace("OAuth2 Authorization: Logged in user %-v", user)
return user, nil
return user, err
}
@@ -12,23 +12,26 @@ import (
"code.gitea.io/gitea/services/actions"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestUserIDFromToken(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
t.Run("Actions JWT", func(t *testing.T) {
const RunningTaskID = 47
const RunningTaskID int64 = 47
token, err := actions.CreateAuthorizationToken(RunningTaskID, 1, 2)
assert.NoError(t, err)
ds := make(reqctx.ContextData)
o := OAuth2{}
uid := o.userIDFromToken(t.Context(), token, ds)
assert.Equal(t, user_model.ActionsUserID, uid)
assert.Equal(t, true, ds["IsActionsToken"])
assert.Equal(t, ds["ActionsTaskID"], int64(RunningTaskID))
u, err := o.userFromToken(t.Context(), token, ds)
require.NoError(t, err)
assert.Equal(t, user_model.ActionsUserID, u.ID)
taskID, ok := user_model.GetActionsUserTaskID(u)
assert.True(t, ok)
assert.Equal(t, RunningTaskID, taskID)
})
}
@@ -29,7 +29,9 @@ const ReverseProxyMethodName = "reverse_proxy"
// On successful authentication the proxy is expected to populate the username in the
// "setting.ReverseProxyAuthUser" header. Optionally it can also populate the email of the
// user in the "setting.ReverseProxyAuthEmail" header.
type ReverseProxy struct{}
type ReverseProxy struct {
CreateSession bool
}
// getUserName extracts the username from the "setting.ReverseProxyAuthUser" header
func (r *ReverseProxy) getUserName(req *http.Request) string {
@@ -51,7 +53,7 @@ func (r *ReverseProxy) Name() string {
func (r *ReverseProxy) getUserFromAuthUser(req *http.Request) (*user_model.User, error) {
username := r.getUserName(req)
if len(username) == 0 {
return nil, nil
return nil, nil //nolint:nilnil // the auth method is not applicable
}
log.Trace("ReverseProxy Authorization: Found username: %s", username)
@@ -111,13 +113,11 @@ func (r *ReverseProxy) Verify(req *http.Request, w http.ResponseWriter, store Da
if user == nil {
user = r.getUserFromAuthEmail(req)
if user == nil {
return nil, nil
return nil, nil //nolint:nilnil // the auth method is not applicable
}
}
// Make sure requests to API paths, attachment downloads, git and LFS do not create a new session
detector := newAuthPathDetector(req)
if !detector.isAPIPath() && !detector.isAttachmentDownload() && !detector.isGitRawOrAttachOrLFSPath() {
if r.CreateSession {
if sess != nil && (sess.Get("uid") == nil || sess.Get("uid").(int64) != user.ID) {
handleSignIn(w, req, sess, user)
}
@@ -29,19 +29,19 @@ func (s *Session) Name() string {
// Returns nil if there is no user uid stored in the session.
func (s *Session) Verify(req *http.Request, w http.ResponseWriter, store DataStore, sess SessionStore) (*user_model.User, error) {
if sess == nil {
return nil, nil
return nil, nil //nolint:nilnil // the auth method is not applicable
}
// Get user ID
uid := sess.Get("uid")
if uid == nil {
return nil, nil
return nil, nil //nolint:nilnil // the auth method is not applicable
}
log.Trace("Session Authorization: Found user[%d]", uid)
id, ok := uid.(int64)
if !ok {
return nil, nil
return nil, nil //nolint:nilnil // the auth method is not applicable
}
// Get user object
@@ -52,7 +52,7 @@ func (s *Session) Verify(req *http.Request, w http.ResponseWriter, store DataSto
// Return the err as-is to keep current signed-in session, in case the err is something like context.Canceled. Otherwise non-existing user (nil, nil) will make the caller clear the signed-in session.
return nil, err
}
return nil, nil
return nil, nil //nolint:nilnil // the auth method is not applicable
}
log.Trace("Session Authorization: Logged in user %-v", user)
+1 -1
View File
@@ -51,7 +51,7 @@ func UserSignIn(ctx context.Context, username, password string) (*user_model.Use
}
if user != nil {
hasUser, err := user_model.GetUser(ctx, user)
hasUser, err := user_model.GetIndividualUser(ctx, user)
if err != nil {
return nil, nil, err
}
@@ -44,6 +44,7 @@ type Source struct {
AttributesInBind bool // fetch attributes in bind context (not user)
AttributeSSHPublicKey string // LDAP SSH Public Key attribute
AttributeAvatar string
SSHKeysAreVerified bool // true if SSH keys in LDAP are verified
SearchPageSize uint32 // Search with paging page size
Filter string // Query filter to validate entry
AdminFilter string // Query filter to check if user is admin
@@ -73,7 +73,7 @@ func (source *Source) Authenticate(ctx context.Context, user *user_model.User, u
}
if user != nil {
if isAttributeSSHPublicKeySet && asymkey_model.SynchronizePublicKeys(ctx, user, source.AuthSource, sr.SSHPublicKey) {
if isAttributeSSHPublicKeySet && asymkey_model.SynchronizePublicKeys(ctx, user, source.AuthSource, sr.SSHPublicKey, source.SSHKeysAreVerified) {
if err := asymkey_service.RewriteAllPublicKeys(ctx); err != nil {
return user, err
}
@@ -99,7 +99,7 @@ func (source *Source) Authenticate(ctx context.Context, user *user_model.User, u
return user, err
}
if isAttributeSSHPublicKeySet && asymkey_model.AddPublicKeysBySource(ctx, user, source.AuthSource, sr.SSHPublicKey) {
if isAttributeSSHPublicKeySet && asymkey_model.AddPublicKeysBySource(ctx, user, source.AuthSource, sr.SSHPublicKey, source.SSHKeysAreVerified) {
if err := asymkey_service.RewriteAllPublicKeys(ctx); err != nil {
return user, err
}
@@ -135,7 +135,7 @@ func (source *Source) Sync(ctx context.Context, updateExisting bool) error {
if err == nil && isAttributeSSHPublicKeySet {
log.Trace("SyncExternalUsers[%s]: Adding LDAP Public SSH Keys for user %s", source.AuthSource.Name, usr.Name)
if asymkey_model.AddPublicKeysBySource(ctx, usr, source.AuthSource, su.SSHPublicKey) {
if asymkey_model.AddPublicKeysBySource(ctx, usr, source.AuthSource, su.SSHPublicKey, source.SSHKeysAreVerified) {
sshKeysNeedUpdate = true
}
}
@@ -145,7 +145,7 @@ func (source *Source) Sync(ctx context.Context, updateExisting bool) error {
}
} else if updateExisting {
// Synchronize SSH Public Key if that attribute is set
if isAttributeSSHPublicKeySet && asymkey_model.SynchronizePublicKeys(ctx, usr, source.AuthSource, su.SSHPublicKey) {
if isAttributeSSHPublicKeySet && asymkey_model.SynchronizePublicKeys(ctx, usr, source.AuthSource, su.SSHPublicKey, source.SSHKeysAreVerified) {
sshKeysNeedUpdate = true
}
@@ -22,9 +22,6 @@ import (
var gothRWMutex = sync.RWMutex{}
// UsersStoreKey is the key for the store
const UsersStoreKey = "gitea-oauth2-sessions"
// ProviderHeaderKey is the HTTP header key
const ProviderHeaderKey = "gitea-oauth2-provider"
@@ -33,7 +30,7 @@ func Init(ctx context.Context) error {
// Lock our mutex
gothRWMutex.Lock()
gob.Register(&sessions.Session{})
gob.Register(&sessions.Session{}) // TODO: CHI-SESSION-GOB-REGISTER. FIXME: it seems to be an abuse, why the Session struct itself is stored in session store again?
gothic.Store = &SessionsStore{
maxLength: int64(setting.OAuth2.MaxTokenLength),
@@ -20,6 +20,7 @@ import (
"code.gitea.io/gitea/modules/setting"
"github.com/markbates/goth"
"github.com/markbates/goth/providers/openidConnect"
)
// Provider is an interface for describing a single OAuth2 provider
@@ -61,7 +62,7 @@ func (p *AuthSourceProvider) DisplayName() string {
func (p *AuthSourceProvider) IconHTML(size int) template.HTML {
if p.iconURL != "" {
img := fmt.Sprintf(`<img class="tw-object-contain tw-mr-2" width="%d" height="%d" src="%s" alt="%s">`,
img := fmt.Sprintf(`<img class="tw-object-contain" width="%d" height="%d" src="%s" alt="%s">`,
size,
size,
html.EscapeString(p.iconURL), html.EscapeString(p.DisplayName()),
@@ -197,6 +198,26 @@ func ClearProviders() {
goth.ClearProviders()
}
// GetOIDCEndSessionEndpoint returns the OIDC end_session_endpoint for the
// given provider name. Returns "" if the provider is not OIDC or doesn't
// advertise an end_session_endpoint in its discovery document.
func GetOIDCEndSessionEndpoint(providerName string) string {
gothRWMutex.RLock()
defer gothRWMutex.RUnlock()
provider, ok := goth.GetProviders()[providerName]
if !ok {
return ""
}
oidcProvider, ok := provider.(*openidConnect.Provider)
if !ok || oidcProvider.OpenIDConfig == nil {
return ""
}
return oidcProvider.OpenIDConfig.EndSessionEndpoint
}
var ErrAuthSourceNotActivated = errors.New("auth source is not activated")
// used to create different types of goth providers
@@ -42,10 +42,10 @@ func (b *BaseProvider) IconHTML(size int) template.HTML {
case "github":
svgName = "octicon-mark-github"
}
svgHTML := svg.RenderHTML(svgName, size, "tw-mr-2")
svgHTML := svg.RenderHTML(svgName, size)
if svgHTML == "" {
log.Error("No SVG icon for oauth2 provider %q", b.name)
svgHTML = svg.RenderHTML("gitea-openid", size, "tw-mr-2")
svgHTML = svg.RenderHTML("gitea-openid", size)
}
return svgHTML
}
@@ -33,7 +33,7 @@ func (o *OpenIDProvider) DisplayName() string {
// IconHTML returns icon HTML for this provider
func (o *OpenIDProvider) IconHTML(size int) template.HTML {
return svg.RenderHTML("gitea-openid", size, "tw-mr-2")
return svg.RenderHTML("gitea-openid", size)
}
// CreateGothProvider creates a GothProvider from this Provider
@@ -19,11 +19,11 @@ func (p *fakeProvider) Name() string {
func (p *fakeProvider) SetName(name string) {}
func (p *fakeProvider) BeginAuth(state string) (goth.Session, error) {
return nil, nil
return nil, nil //nolint:nilnil // the auth method is not applicable
}
func (p *fakeProvider) UnmarshalSession(string) (goth.Session, error) {
return nil, nil
return nil, nil //nolint:nilnil // the auth method is not applicable
}
func (p *fakeProvider) FetchUser(goth.Session) (goth.User, error) {
@@ -67,7 +67,7 @@ func (source *Source) refresh(ctx context.Context, provider goth.Provider, u *us
LoginSource: u.LoginSourceID,
}
hasUser, err := user_model.GetUser(ctx, user)
hasUser, err := user_model.GetIndividualUser(ctx, user)
if err != nil {
return err
}
@@ -35,9 +35,9 @@ func (source *Source) Authenticate(ctx context.Context, user *user_model.User, u
// Allow PAM sources with `@` in their name, like from Active Directory
username := pamLogin
email := pamLogin
idx := strings.Index(pamLogin, "@")
if idx > -1 {
username = pamLogin[:idx]
before, _, ok := strings.Cut(pamLogin, "@")
if ok {
username = before
}
if user_model.ValidateEmail(email) != nil {
if source.EmailDomain != "" {
@@ -21,10 +21,10 @@ import (
func (source *Source) Authenticate(ctx context.Context, user *user_model.User, userName, password string) (*user_model.User, error) {
// Verify allowed domains.
if len(source.AllowedDomains) > 0 {
idx := strings.Index(userName, "@")
if idx == -1 {
_, after, ok := strings.Cut(userName, "@")
if !ok {
return nil, user_model.ErrUserNotExist{Name: userName}
} else if !util.SliceContainsString(strings.Split(source.AllowedDomains, ","), userName[idx+1:], true) {
} else if !util.SliceContainsString(strings.Split(source.AllowedDomains, ","), after, true) {
return nil, user_model.ErrUserNotExist{Name: userName}
}
}
@@ -61,9 +61,9 @@ func (source *Source) Authenticate(ctx context.Context, user *user_model.User, u
}
username := userName
idx := strings.Index(userName, "@")
if idx > -1 {
username = userName[:idx]
before, _, ok := strings.Cut(userName, "@")
if ok {
username = before
}
user = &user_model.User{
+10 -19
View File
@@ -46,7 +46,9 @@ var (
// The SSPI plugin is expected to be executed last, as it returns 401 status code if negotiation
// fails (or if negotiation should continue), which would prevent other authentication methods
// to execute at all.
type SSPI struct{}
type SSPI struct {
CreateSession bool
}
// Name represents the name of auth method
func (s *SSPI) Name() string {
@@ -63,7 +65,7 @@ func (s *SSPI) Verify(req *http.Request, w http.ResponseWriter, store DataStore,
return nil, sspiAuthErrInit
}
if !s.shouldAuthenticate(req) {
return nil, nil
return nil, nil //nolint:nilnil // the auth method is not applicable
}
cfg, err := s.getConfig(req.Context())
@@ -97,7 +99,7 @@ func (s *SSPI) Verify(req *http.Request, w http.ResponseWriter, store DataStore,
username := sanitizeUsername(userInfo.Username, cfg)
if len(username) == 0 {
return nil, nil
return nil, nil //nolint:nilnil // the auth method is not applicable
}
log.Info("Authenticated as %s\n", username)
@@ -109,7 +111,7 @@ func (s *SSPI) Verify(req *http.Request, w http.ResponseWriter, store DataStore,
}
if !cfg.AutoCreateUsers {
log.Error("User '%s' not found", username)
return nil, nil
return nil, nil //nolint:nilnil // the auth method is not applicable
}
user, err = s.newUser(req.Context(), username, cfg)
if err != nil {
@@ -118,9 +120,7 @@ func (s *SSPI) Verify(req *http.Request, w http.ResponseWriter, store DataStore,
}
}
// Make sure requests to API paths and PWA resources do not create a new session
detector := newAuthPathDetector(req)
if !detector.isAPIPath() && !detector.isAttachmentDownload() {
if s.CreateSession {
handleSignIn(w, req, sess, user)
}
@@ -147,18 +147,9 @@ func (s *SSPI) getConfig(ctx context.Context) (*sspi.Source, error) {
}
func (s *SSPI) shouldAuthenticate(req *http.Request) (shouldAuth bool) {
shouldAuth = false
path := strings.TrimSuffix(req.URL.Path, "/")
if path == "/user/login" {
if req.FormValue("user_name") != "" && req.FormValue("password") != "" {
shouldAuth = false
} else if req.FormValue("auth_with_sspi") == "1" {
shouldAuth = true
}
} else {
detector := newAuthPathDetector(req)
shouldAuth = detector.isAPIPath() || detector.isAttachmentDownload()
}
// SSPI is only applicable for login requests with "auth_with_sspi" form value set to "1"
// See the template code with "auth_with_sspi"
shouldAuth = req.URL.Path == "/user/login" && req.FormValue("auth_with_sspi") == "1"
return shouldAuth
}
@@ -11,6 +11,7 @@ import (
"strings"
"code.gitea.io/gitea/models/db"
git_model "code.gitea.io/gitea/models/git"
issues_model "code.gitea.io/gitea/models/issues"
access_model "code.gitea.io/gitea/models/perm/access"
pull_model "code.gitea.io/gitea/models/pull"
@@ -207,7 +208,10 @@ func handlePullRequestAutoMerge(pullID int64, sha string) {
switch pr.Flow {
case issues_model.PullRequestFlowGithub:
headBranchExist := pr.HeadRepo != nil && gitrepo.IsBranchExist(ctx, pr.HeadRepo, pr.HeadBranch)
headBranchExist := pr.HeadRepo != nil
if headBranchExist {
headBranchExist, _ = git_model.IsBranchExist(ctx, pr.HeadRepo.ID, pr.HeadBranch)
}
if !headBranchExist {
log.Warn("Head branch of auto merge %-v does not exist [HeadRepoID: %d, Branch: %s]", pr, pr.HeadRepoID, pr.HeadBranch)
return
@@ -241,9 +245,9 @@ func handlePullRequestAutoMerge(pullID int64, sha string) {
return
}
perm, err := access_model.GetUserRepoPermission(ctx, pr.HeadRepo, doer)
perm, err := access_model.GetDoerRepoPermission(ctx, pr.BaseRepo, doer)
if err != nil {
log.Error("GetUserRepoPermission %-v: %v", pr.HeadRepo, err)
log.Error("GetDoerRepoPermission %-v: %v", pr.BaseRepo, err)
return
}
@@ -256,7 +260,7 @@ func handlePullRequestAutoMerge(pullID int64, sha string) {
return
}
if err := pull_service.Merge(ctx, pr, doer, baseGitRepo, scheduledPRM.MergeStyle, "", scheduledPRM.Message, true); err != nil {
if err := pull_service.Merge(ctx, pr, doer, scheduledPRM.MergeStyle, "", scheduledPRM.Message, true); err != nil {
log.Error("pull_service.Merge: %v", err)
// FIXME: if merge failed, we should display some error message to the pull request page.
// The resolution is add a new column on automerge table named `error_message` to store the error message and displayed
@@ -10,6 +10,7 @@ import (
"strings"
"text/template"
"time"
"unicode"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/log"
@@ -37,6 +38,16 @@ const keyOfRequestIDInTemplate = ".RequestID"
// So, we accept a Request ID with a maximum character length of 40
const maxRequestIDByteLength = 40
func isSafeRequestID(id string) bool {
for _, r := range id {
safe := unicode.IsPrint(r)
if !safe {
return false
}
}
return true
}
func parseRequestIDFromRequestHeader(req *http.Request) string {
requestID := "-"
for _, key := range setting.Log.RequestIDHeaders {
@@ -45,6 +56,9 @@ func parseRequestIDFromRequestHeader(req *http.Request) string {
break
}
}
if !isSafeRequestID(requestID) {
return "-"
}
if len(requestID) > maxRequestIDByteLength {
requestID = requestID[:maxRequestIDByteLength] + "..."
}
@@ -69,3 +69,8 @@ func TestAccessLogger(t *testing.T) {
recorder.record(time.Date(2000, 1, 2, 3, 4, 5, 0, time.UTC), &testAccessLoggerResponseWriterMock{}, req)
assert.Equal(t, []string{`remote-addr - - [02/Jan/2000:03:04:05 +0000] "GET /path https" 200 123123 "referer" "user-agent"`}, mockLogger.logs)
}
func TestAccessLoggerRequestID(t *testing.T) {
assert.False(t, isSafeRequestID("\x00"))
assert.True(t, isSafeRequestID("a b-c"))
}
+12 -6
View File
@@ -13,6 +13,7 @@ import (
"strconv"
"strings"
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/cache"
@@ -47,6 +48,12 @@ type APIContext struct {
PublicOnly bool // Whether the request is for a public endpoint
}
// TokenCanAccessRepo reports whether the current API token is allowed to access the repository.
// A public-only token cannot reach a private repo; any other token is unrestricted by this check.
func (ctx *APIContext) TokenCanAccessRepo(repo *repo_model.Repository) bool {
return repo == nil || !ctx.PublicOnly || !repo.IsPrivate
}
func init() {
web.RegisterResponseStatusProvider[*APIContext](func(req *http.Request) web_types.ResponseStatusProvider {
return req.Context().Value(apiContextKey).(*APIContext)
@@ -163,7 +170,7 @@ func GetAPIContext(req *http.Request) *APIContext {
return req.Context().Value(apiContextKey).(*APIContext)
}
func genAPILinks(curURL *url.URL, total, pageSize, curPage int) []string {
func genAPILinks(curURL *url.URL, total int64, pageSize, curPage int) []string {
page := NewPagination(total, pageSize, curPage, 0)
paginater := page.Paginater
links := make([]string, 0, 4)
@@ -204,7 +211,8 @@ func genAPILinks(curURL *url.URL, total, pageSize, curPage int) []string {
}
// SetLinkHeader sets pagination link header by given total number and page size.
func (ctx *APIContext) SetLinkHeader(total, pageSize int) {
// "count" is usually from database result "count int64", so it also uses int64,
func (ctx *APIContext) SetLinkHeader(total int64, pageSize int) {
links := genAPILinks(ctx.Req.URL, total, pageSize, ctx.FormInt("page"))
if len(links) > 0 {
@@ -221,13 +229,13 @@ func APIContexter() func(http.Handler) http.Handler {
ctx := &APIContext{
Base: base,
Cache: cache.GetCache(),
Repo: &Repository{PullRequest: &PullRequest{}},
Repo: &Repository{},
Org: &APIOrganization{},
}
ctx.SetContextValue(apiContextKey, ctx)
// If request sends files, parse them here otherwise the Query() can't be parsed and the CsrfToken will be invalid.
// FIXME: GLOBAL-PARSE-FORM: see more details in another FIXME comment
if ctx.Req.Method == http.MethodPost && strings.Contains(ctx.Req.Header.Get("Content-Type"), "multipart/form-data") {
if !ctx.ParseMultipartForm() {
return
@@ -235,8 +243,6 @@ func APIContexter() func(http.Handler) http.Handler {
}
httpcache.SetCacheControlInHeader(ctx.Resp.Header(), &httpcache.CacheControlOptions{NoTransform: true})
ctx.Resp.Header().Set(`X-Frame-Options`, setting.CORSConfig.XFrameOptions)
next.ServeHTTP(ctx.Resp, ctx.Req)
})
}
@@ -18,6 +18,7 @@ import (
"code.gitea.io/gitea/modules/reqctx"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/translation"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/web/middleware"
)
@@ -147,10 +148,7 @@ func (b *Base) PlainText(status int, text string) {
// Redirect redirects the request
func (b *Base) Redirect(location string, status ...int) {
code := http.StatusSeeOther
if len(status) == 1 {
code = status[0]
}
code := util.OptionalArg(status, http.StatusSeeOther)
if !httplib.IsRelativeURL(location) {
// Some browsers (Safari) have buggy behavior for Cookie + Cache + External Redirection, eg: /my-path => https://other/path
@@ -172,15 +170,15 @@ func (b *Base) Redirect(location string, status ...int) {
http.Redirect(b.Resp, b.Req, location, code)
}
type ServeHeaderOptions httplib.ServeHeaderOptions
type ServeHeaderOptions = httplib.ServeHeaderOptions
func (b *Base) SetServeHeaders(opt *ServeHeaderOptions) {
httplib.ServeSetHeaders(b.Resp, (*httplib.ServeHeaderOptions)(opt))
func (b *Base) SetServeHeaders(opts ServeHeaderOptions) {
httplib.ServeSetHeaders(b.Resp, opts)
}
// ServeContent serves content to http request
func (b *Base) ServeContent(r io.ReadSeeker, opts *ServeHeaderOptions) {
httplib.ServeSetHeaders(b.Resp, (*httplib.ServeHeaderOptions)(opts))
func (b *Base) ServeContent(r io.ReadSeeker, opts ServeHeaderOptions) {
httplib.ServeSetHeaders(b.Resp, opts)
http.ServeContent(b.Resp, b.Req, opts.Filename, opts.LastModified, r)
}
@@ -37,11 +37,16 @@ func (b *Base) PathParamInt64(p string) int64 {
return v
}
func (b *Base) PathParamInt(p string) int {
v, _ := strconv.Atoi(b.PathParam(p))
return v
}
// SetPathParam set request path params into routes
func (b *Base) SetPathParam(name, value string) {
if strings.HasPrefix(name, ":") {
setting.PanicInDevOrTesting("path param should not start with ':'")
name = name[1:]
}
chi.RouteContext(b).URLParams.Add(name, url.PathEscape(value))
}
func (b *Base) SetPathParamRaw(name, value string) {
chi.RouteContext(b).URLParams.Add(name, value)
}
@@ -5,6 +5,7 @@ package context
import (
"fmt"
"image/color"
"sync"
"code.gitea.io/gitea/modules/cache"
@@ -29,32 +30,25 @@ func GetImageCaptcha() *captcha.Captcha {
imageCaptchaOnce.Do(func() {
cpt = captcha.NewCaptcha(captcha.Options{
SubURL: setting.AppSubURL,
// Use a color palette with high contrast colors suitable for both light and dark modes
// These colors provide good visibility and readability in both themes
ColorPalette: color.Palette{
color.RGBA{R: 234, G: 67, B: 53, A: 255}, // Bright red
color.RGBA{R: 66, G: 133, B: 244, A: 255}, // Medium blue
color.RGBA{R: 52, G: 168, B: 83, A: 255}, // Green
color.RGBA{R: 251, G: 188, B: 5, A: 255}, // Yellow/gold
color.RGBA{R: 171, G: 71, B: 188, A: 255}, // Purple
},
})
cpt.Store = cache.GetCache().ChiCache()
})
return cpt
}
// SetCaptchaData sets common captcha data
func SetCaptchaData(ctx *Context) {
if !setting.Service.EnableCaptcha {
return
}
ctx.Data["EnableCaptcha"] = setting.Service.EnableCaptcha
ctx.Data["RecaptchaURL"] = setting.Service.RecaptchaURL
ctx.Data["Captcha"] = GetImageCaptcha()
ctx.Data["CaptchaType"] = setting.Service.CaptchaType
ctx.Data["RecaptchaSitekey"] = setting.Service.RecaptchaSitekey
ctx.Data["HcaptchaSitekey"] = setting.Service.HcaptchaSitekey
ctx.Data["McaptchaSitekey"] = setting.Service.McaptchaSitekey
ctx.Data["McaptchaURL"] = setting.Service.McaptchaURL
ctx.Data["CfTurnstileSitekey"] = setting.Service.CfTurnstileSitekey
}
const (
gRecaptchaResponseField = "g-recaptcha-response"
hCaptchaResponseField = "h-captcha-response"
mCaptchaResponseField = "m-captcha-response"
mCaptchaResponseField = "mcaptcha__token" // this form key is hard-coded in the mcaptcha frontend library
cfTurnstileResponseField = "cf-turnstile-response"
)
@@ -88,6 +82,6 @@ func VerifyCaptcha(ctx *Context, tpl templates.TplName, form any) {
if !valid {
ctx.Data["Err_Captcha"] = true
ctx.RenderWithErr(ctx.Tr("form.captcha_incorrect"), tpl, form)
ctx.RenderWithErrDeprecated(ctx.Tr("form.captcha_incorrect"), tpl, form)
}
}
@@ -6,19 +6,18 @@ package context
import (
"context"
"encoding/hex"
"fmt"
"html/template"
"io"
"net/http"
"net/url"
"strings"
"time"
"code.gitea.io/gitea/models/unit"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/cache"
"code.gitea.io/gitea/modules/httpcache"
"code.gitea.io/gitea/modules/reqctx"
"code.gitea.io/gitea/modules/session"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates"
@@ -48,7 +47,6 @@ type Context struct {
PageData map[string]any // data used by JavaScript modules in one page, it's `window.config.pageData`
Cache cache.StringCache
Csrf CSRFProtector
Flash *middleware.Flash
Session session.Store
@@ -102,12 +100,13 @@ func GetValidateContext(req *http.Request) (ctx *ValidateContext) {
return ctx
}
func NewTemplateContextForWeb(ctx *Context) TemplateContext {
tmplCtx := NewTemplateContext(ctx)
tmplCtx["Locale"] = ctx.Base.Locale
func NewTemplateContextForWeb(ctx reqctx.RequestContext, req *http.Request, locale translation.Locale) TemplateContext {
tmplCtx := NewTemplateContext(ctx, req)
tmplCtx["Locale"] = locale
tmplCtx["AvatarUtils"] = templates.NewAvatarUtils(ctx)
tmplCtx["RenderUtils"] = templates.NewRenderUtils(ctx)
tmplCtx["RootData"] = ctx.Data
tmplCtx["MiscUtils"] = templates.NewMiscUtils(ctx)
tmplCtx["RootData"] = ctx.GetData()
tmplCtx["Consts"] = map[string]any{
"RepoUnitTypeCode": unit.TypeCode,
"RepoUnitTypeIssues": unit.TypeIssues,
@@ -131,30 +130,36 @@ func NewWebContext(base *Base, render Render, session session.Store) *Context {
Cache: cache.GetCache(),
Link: setting.AppSubURL + strings.TrimSuffix(base.Req.URL.EscapedPath(), "/"),
Repo: &Repository{PullRequest: &PullRequest{}},
Repo: &Repository{},
Org: &Organization{},
}
ctx.TemplateContext = NewTemplateContextForWeb(ctx)
ctx.TemplateContext = NewTemplateContextForWeb(ctx, ctx.Base.Req, ctx.Base.Locale)
ctx.Flash = &middleware.Flash{DataStore: ctx, Values: url.Values{}}
ctx.SetContextValue(WebContextKey, ctx)
return ctx
}
func ContexterInstallPage(data map[string]any) func(next http.Handler) http.Handler {
rnd := templates.PageRenderer()
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
base := NewBaseContext(resp, req)
ctx := NewWebContext(base, rnd, session.GetContextSession(req))
ctx.Data.MergeFrom(middleware.CommonTemplateContextData())
ctx.Data.MergeFrom(reqctx.ContextData{
"Title": ctx.Locale.Tr("install.install"),
"PageIsInstall": true,
"AllLangs": translation.AllLangs(),
})
ctx.Data.MergeFrom(data)
next.ServeHTTP(resp, ctx.Req)
})
}
}
// Contexter initializes a classic context for a request.
func Contexter() func(next http.Handler) http.Handler {
rnd := templates.HTMLRenderer()
csrfOpts := CsrfOptions{
Secret: hex.EncodeToString(setting.GetGeneralTokenSigningSecret()),
Cookie: setting.CSRFCookieName,
Secure: setting.SessionConfig.Secure,
CookieHTTPOnly: setting.CSRFCookieHTTPOnly,
CookieDomain: setting.SessionConfig.Domain,
CookiePath: setting.SessionConfig.CookiePath,
SameSite: setting.SessionConfig.SameSite,
}
if !setting.IsProd {
CsrfTokenRegenerationInterval = 5 * time.Second // in dev, re-generate the tokens more aggressively for debug purpose
}
rnd := templates.PageRenderer()
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
base := NewBaseContext(resp, req)
@@ -167,8 +172,6 @@ func Contexter() func(next http.Handler) http.Handler {
ctx.PageData = map[string]any{}
ctx.Data["PageData"] = ctx.PageData
ctx.Csrf = NewCSRFProtector(csrfOpts)
// get the last flash message from cookie
lastFlashCookie, lastFlashMsg := middleware.GetSiteCookieFlashMessage(ctx, ctx.Req, CookieNameFlash)
if vals, _ := url.ParseQuery(lastFlashCookie); len(vals) > 0 {
@@ -184,7 +187,10 @@ func Contexter() func(next http.Handler) http.Handler {
}
})
// If request sends files, parse them here otherwise the Query() can't be parsed and the CsrfToken will be invalid.
// FIXME: GLOBAL-PARSE-FORM: this ParseMultipartForm was used for parsing the csrf token from multipart/form-data
// We have dropped the csrf token, so ideally this global ParseMultipartForm should be removed.
// When removing this, we need to avoid regressions in the handler functions because Golang's http framework is quite fragile
// and developers sometimes need to manually parse the form before accessing some values.
if ctx.Req.Method == http.MethodPost && strings.Contains(ctx.Req.Header.Get("Content-Type"), "multipart/form-data") {
if !ctx.ParseMultipartForm() {
return
@@ -192,7 +198,10 @@ func Contexter() func(next http.Handler) http.Handler {
}
httpcache.SetCacheControlInHeader(ctx.Resp.Header(), &httpcache.CacheControlOptions{NoTransform: true})
ctx.Resp.Header().Set(`X-Frame-Options`, setting.CORSConfig.XFrameOptions)
if setting.Security.XFrameOptions != "unset" {
ctx.Resp.Header().Set(`X-Frame-Options`, setting.Security.XFrameOptions)
}
ctx.Data["SystemConfig"] = setting.Config()
@@ -25,13 +25,11 @@ func removeSessionCookieHeader(w http.ResponseWriter) {
}
// SetSiteCookie convenience function to set most cookies consistently
// CSRF and a few others are the exception here
func (ctx *Context) SetSiteCookie(name, value string, maxAge int) {
middleware.SetSiteCookie(ctx.Resp, name, value, maxAge)
}
// DeleteSiteCookie convenience function to delete most cookies consistently
// CSRF and a few others are the exception here
func (ctx *Context) DeleteSiteCookie(name string) {
middleware.SetSiteCookie(ctx.Resp, name, "", -1)
}
@@ -22,6 +22,7 @@ import (
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/web/middleware"
)
@@ -124,8 +125,12 @@ func (ctx *Context) RenderToHTML(name templates.TplName, data any) (template.HTM
return template.HTML(buf.String()), err
}
// RenderWithErr used for page has form validation but need to prompt error to users.
func (ctx *Context) RenderWithErr(msg any, tpl templates.TplName, form any) {
// RenderWithErrDeprecated render the page with form validation when it needs to prompt error to users.
// Deprecated: use "form-fetch-action" and JSON response instead.
// WARNING: in many cases, this function is not able to render the page or recover the form fields correctly.
// And it is very difficult to test the page rendered by this function.
// DO NOT USE IT ANYMORE.
func (ctx *Context) RenderWithErrDeprecated(msg any, tpl templates.TplName, form any) {
if form != nil {
middleware.AssignForm(form, ctx.Data)
}
@@ -139,11 +144,9 @@ func (ctx *Context) NotFound(logErr error) {
}
func (ctx *Context) notFoundInternal(logMsg string, logErr error) {
// TODO: it's safe to show the error message to end users if the error is fully controlled by our error system
if logErr != nil {
log.Log(2, log.DEBUG, "%s: %v", logMsg, logErr)
if !setting.IsProd {
ctx.Data["ErrorMsg"] = logErr
}
}
// response simple message if Accept isn't text/html
@@ -162,11 +165,17 @@ func (ctx *Context) notFoundInternal(logMsg string, logErr error) {
ctx.Data["IsRepo"] = ctx.Repo.Repository != nil
ctx.Data["Title"] = "Page Not Found"
ctx.Data["ErrorMsg"] = "" // FIXME: the template never renders this message, need to fix in the future (and show safe messages to end users)
ctx.HTML(http.StatusNotFound, "status/404")
}
// ServerError displays a 500 (Internal Server Error) page and prints the given error, if any.
// If the error is controlled by our error system, a related 404 page can be displayed instead.
func (ctx *Context) ServerError(logMsg string, logErr error) {
if errors.Is(logErr, util.ErrNotExist) {
ctx.notFoundInternal(logMsg, logErr)
return
}
ctx.serverErrorInternal(logMsg, logErr)
}
@@ -5,13 +5,26 @@ package context
import (
"context"
"html/template"
"net/http"
"strconv"
"strings"
"time"
"code.gitea.io/gitea/modules/httplib"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/web/middleware"
"code.gitea.io/gitea/services/webtheme"
)
var _ context.Context = TemplateContext(nil)
func NewTemplateContext(ctx context.Context) TemplateContext {
return TemplateContext{"_ctx": ctx}
func NewTemplateContext(ctx context.Context, req *http.Request) TemplateContext {
return TemplateContext{"_ctx": ctx, "_req": req}
}
func (c TemplateContext) req() *http.Request {
return c["_req"].(*http.Request)
}
func (c TemplateContext) parentContext() context.Context {
@@ -33,3 +46,40 @@ func (c TemplateContext) Err() error {
func (c TemplateContext) Value(key any) any {
return c.parentContext().Value(key)
}
func (c TemplateContext) CurrentWebTheme() *webtheme.ThemeMetaInfo {
var themeName string
if webCtx := GetWebContext(c); webCtx != nil {
if webCtx.Doer != nil {
themeName = webCtx.Doer.Theme
}
}
if themeName == "" {
themeName = middleware.GetSiteCookie(c.req(), middleware.CookieTheme)
}
return webtheme.GuaranteeGetThemeMetaInfo(themeName)
}
func (c TemplateContext) CurrentWebBanner() *setting.WebBannerType {
// Using revision as a simple approach to determine if the banner has been changed after the user dismissed it.
// There could be some false-positives because revision can be changed even if the banner isn't.
// While it should be still good enough (no admin would keep changing the settings) and doesn't really harm end users (just a few more times to see the banner)
// So it doesn't need to make it more complicated by allocating unique IDs or using hashes.
dismissedBannerRevision, _ := strconv.Atoi(middleware.GetSiteCookie(c.req(), middleware.CookieWebBannerDismissed))
banner, revision, _ := setting.Config().Instance.WebBanner.ValueRevision(c)
if banner.ShouldDisplay() && dismissedBannerRevision != revision {
return &banner
}
return nil
}
// AppFullLink returns a full URL link with AppSubURL for the given app link (no AppSubURL)
// If no link is given, it returns the current app full URL with sub-path but without trailing slash (that's why it is not named as AppURL)
func (c TemplateContext) AppFullLink(link ...string) template.URL {
s := httplib.GuessCurrentAppURL(c.parentContext())
s = strings.TrimSuffix(s, "/")
if len(link) == 0 {
return template.URL(s)
}
return template.URL(s + "/" + strings.TrimPrefix(link[0], "/"))
}
@@ -49,3 +49,17 @@ func TestRedirectToCurrentSite(t *testing.T) {
})
}
}
func TestAppFullLink(t *testing.T) {
setting.IsInTesting = true
defer test.MockVariableValue(&setting.AppURL, "https://gitea.example.com/sub/")()
defer test.MockVariableValue(&setting.AppSubURL, "/sub")()
defer test.MockVariableValue(&setting.PublicURLDetection, setting.PublicURLNever)()
req := httptest.NewRequest(http.MethodGet, "https://gitea.example.com/sub/", nil)
tmplCtx := NewTemplateContext(req.Context(), req)
assert.Equal(t, "https://gitea.example.com/sub", string(tmplCtx.AppFullLink()))
assert.Equal(t, "https://gitea.example.com/sub/user/repo", string(tmplCtx.AppFullLink("user/repo")))
assert.Equal(t, "https://gitea.example.com/sub/user/repo", string(tmplCtx.AppFullLink("/user/repo")))
}
@@ -1,168 +0,0 @@
// Copyright 2013 Martini Authors
// Copyright 2014 The Macaron Authors
// Copyright 2021 The Gitea Authors
//
// Licensed under the Apache License, Version 2.0 (the "License"): you may
// not use this file except in compliance with the License. You may obtain
// a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.
// SPDX-License-Identifier: Apache-2.0
// a middleware that generates and validates CSRF tokens.
package context
import (
"html/template"
"net/http"
"strconv"
"time"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/util"
)
const (
CsrfHeaderName = "X-Csrf-Token"
CsrfFormName = "_csrf"
)
// CSRFProtector represents a CSRF protector and is used to get the current token and validate the token.
type CSRFProtector interface {
// PrepareForSessionUser prepares the csrf protector for the current session user.
PrepareForSessionUser(ctx *Context)
// Validate validates the csrf token in http context.
Validate(ctx *Context)
// DeleteCookie deletes the csrf cookie
DeleteCookie(ctx *Context)
}
type csrfProtector struct {
opt CsrfOptions
// id must be unique per user.
id string
// token is the valid one which will be used by end user and passed via header, cookie, or hidden form value.
token string
}
// CsrfOptions maintains options to manage behavior of Generate.
type CsrfOptions struct {
// The global secret value used to generate Tokens.
Secret string
// Cookie value used to set and get token.
Cookie string
// Cookie domain.
CookieDomain string
// Cookie path.
CookiePath string
CookieHTTPOnly bool
// SameSite set the cookie SameSite type
SameSite http.SameSite
// Set the Secure flag to true on the cookie.
Secure bool
// sessionKey is the key used for getting the unique ID per user.
sessionKey string
// oldSessionKey saves old value corresponding to sessionKey.
oldSessionKey string
}
func newCsrfCookie(opt *CsrfOptions, value string) *http.Cookie {
return &http.Cookie{
Name: opt.Cookie,
Value: value,
Path: opt.CookiePath,
Domain: opt.CookieDomain,
MaxAge: int(CsrfTokenTimeout.Seconds()),
Secure: opt.Secure,
HttpOnly: opt.CookieHTTPOnly,
SameSite: opt.SameSite,
}
}
func NewCSRFProtector(opt CsrfOptions) CSRFProtector {
if opt.Secret == "" {
panic("CSRF secret is empty but it must be set") // it shouldn't happen because it is always set in code
}
opt.Cookie = util.IfZero(opt.Cookie, "_csrf")
opt.CookiePath = util.IfZero(opt.CookiePath, "/")
opt.sessionKey = "uid"
opt.oldSessionKey = "_old_" + opt.sessionKey
return &csrfProtector{opt: opt}
}
func (c *csrfProtector) PrepareForSessionUser(ctx *Context) {
c.id = "0"
if uidAny := ctx.Session.Get(c.opt.sessionKey); uidAny != nil {
switch uidVal := uidAny.(type) {
case string:
c.id = uidVal
case int64:
c.id = strconv.FormatInt(uidVal, 10)
default:
log.Error("invalid uid type in session: %T", uidAny)
}
}
oldUID := ctx.Session.Get(c.opt.oldSessionKey)
uidChanged := oldUID == nil || oldUID.(string) != c.id
cookieToken := ctx.GetSiteCookie(c.opt.Cookie)
needsNew := true
if uidChanged {
_ = ctx.Session.Set(c.opt.oldSessionKey, c.id)
} else if cookieToken != "" {
// If cookie token present, re-use existing unexpired token, else generate a new one.
if issueTime, ok := ParseCsrfToken(cookieToken); ok {
dur := time.Since(issueTime) // issueTime is not a monotonic-clock, the server time may change a lot to an early time.
if dur >= -CsrfTokenRegenerationInterval && dur <= CsrfTokenRegenerationInterval {
c.token = cookieToken
needsNew = false
}
}
}
if needsNew {
c.token = GenerateCsrfToken(c.opt.Secret, c.id, "POST", time.Now())
ctx.Resp.Header().Add("Set-Cookie", newCsrfCookie(&c.opt, c.token).String())
}
ctx.Data["CsrfToken"] = c.token
ctx.Data["CsrfTokenHtml"] = template.HTML(`<input type="hidden" name="_csrf" value="` + template.HTMLEscapeString(c.token) + `">`)
}
func (c *csrfProtector) validateToken(ctx *Context, token string) {
if !ValidCsrfToken(token, c.opt.Secret, c.id, "POST", time.Now()) {
c.DeleteCookie(ctx)
// currently, there should be no access to the APIPath with CSRF token. because templates shouldn't use the `/api/` endpoints.
// FIXME: distinguish what the response is for: HTML (web page) or JSON (fetch)
http.Error(ctx.Resp, "Invalid CSRF token.", http.StatusBadRequest)
}
}
// Validate should be used as a per route middleware. It attempts to get a token from an "X-Csrf-Token"
// HTTP header and then a "_csrf" form value. If one of these is found, the token will be validated.
// If this validation fails, http.StatusBadRequest is sent.
func (c *csrfProtector) Validate(ctx *Context) {
if token := ctx.Req.Header.Get(CsrfHeaderName); token != "" {
c.validateToken(ctx, token)
return
}
if token := ctx.Req.FormValue(CsrfFormName); token != "" {
c.validateToken(ctx, token)
return
}
c.validateToken(ctx, "") // no csrf token, use an empty token to respond error
}
func (c *csrfProtector) DeleteCookie(ctx *Context) {
cookie := newCsrfCookie(&c.opt, "")
cookie.MaxAge = -1
ctx.Resp.Header().Add("Set-Cookie", cookie.String())
}
+6 -4
View File
@@ -132,10 +132,12 @@ func OrgAssignment(orgAssignmentOpts OrgAssignmentOptions) func(ctx *Context) {
ctx.ServerError("IsOrgMember", err)
return
}
ctx.Org.CanCreateOrgRepo, err = org.CanCreateOrgRepo(ctx, ctx.Doer.ID)
if err != nil {
ctx.ServerError("CanCreateOrgRepo", err)
return
if ctx.Org.IsMember {
ctx.Org.CanCreateOrgRepo, err = org.CanCreateOrgRepo(ctx, ctx.Doer.ID)
if err != nil {
ctx.ServerError("CanCreateOrgRepo", err)
return
}
}
}
} else {
@@ -4,6 +4,7 @@
package context
import (
"errors"
"fmt"
"net/http"
@@ -58,23 +59,28 @@ func PackageAssignmentAPI() func(ctx *APIContext) {
}
func packageAssignment(ctx *packageAssignmentCtx, errCb func(int, any)) *Package {
pkg := &Package{
Owner: ctx.ContextUser,
}
var err error
pkg.AccessMode, err = determineAccessMode(ctx.Base, pkg, ctx.Doer)
pkgOwner := ctx.ContextUser
accessMode, err := determineAccessMode(ctx.Base, pkgOwner, ctx.Doer)
if err != nil {
errCb(http.StatusInternalServerError, fmt.Errorf("determineAccessMode: %w", err))
return nil
}
pkg := &Package{
Owner: pkgOwner,
AccessMode: accessMode,
}
packageType := ctx.PathParam("type")
name := ctx.PathParam("name")
if packageType == "" || name == "" {
return pkg
}
packageType := ctx.PathParam("type")
name := ctx.PathParam("name")
version := ctx.PathParam("version")
if packageType != "" && name != "" && version != "" {
if version != "" {
pv, err := packages_model.GetVersionByNameAndVersion(ctx, pkg.Owner.ID, packages_model.Type(packageType), name, version)
if err != nil {
if err == packages_model.ErrPackageNotExist {
if errors.Is(err, packages_model.ErrPackageNotExist) {
errCb(http.StatusNotFound, fmt.Errorf("GetVersionByNameAndVersion: %w", err))
} else {
errCb(http.StatusInternalServerError, fmt.Errorf("GetVersionByNameAndVersion: %w", err))
@@ -87,12 +93,27 @@ func packageAssignment(ctx *packageAssignmentCtx, errCb func(int, any)) *Package
errCb(http.StatusInternalServerError, fmt.Errorf("GetPackageDescriptor: %w", err))
return pkg
}
} else {
p, err := packages_model.GetPackageByName(ctx, pkg.Owner.ID, packages_model.Type(packageType), name)
if err != nil {
if errors.Is(err, packages_model.ErrPackageNotExist) {
errCb(http.StatusNotFound, fmt.Errorf("GetPackageByName: %w", err))
} else {
errCb(http.StatusInternalServerError, fmt.Errorf("GetPackageByName: %w", err))
}
return pkg
}
pkg.Descriptor = &packages_model.PackageDescriptor{
Package: p,
Owner: pkg.Owner,
}
}
return pkg
}
func determineAccessMode(ctx *Base, pkg *Package, doer *user_model.User) (perm.AccessMode, error) {
func determineAccessMode(ctx *Base, pkgOwner, doer *user_model.User) (perm.AccessMode, error) {
if setting.Service.RequireSignInViewStrict && (doer == nil || doer.IsGhost()) {
return perm.AccessModeNone, nil
}
@@ -103,8 +124,8 @@ func determineAccessMode(ctx *Base, pkg *Package, doer *user_model.User) (perm.A
// TODO: ActionUser permission check
accessMode := perm.AccessModeNone
if pkg.Owner.IsOrganization() {
org := organization.OrgFromUser(pkg.Owner)
if pkgOwner.IsOrganization() {
org := organization.OrgFromUser(pkgOwner)
if doer != nil && !doer.IsGhost() {
// 1. If user is logged in, check all team packages permissions
@@ -128,19 +149,19 @@ func determineAccessMode(ctx *Base, pkg *Package, doer *user_model.User) (perm.A
}
}
}
if accessMode == perm.AccessModeNone && organization.HasOrgOrUserVisible(ctx, pkg.Owner, doer) {
if accessMode == perm.AccessModeNone && organization.HasOrgOrUserVisible(ctx, pkgOwner, doer) {
// 2. If user is unauthorized or no org member, check if org is visible
accessMode = perm.AccessModeRead
}
} else {
if doer != nil && !doer.IsGhost() {
// 1. Check if user is package owner
if doer.ID == pkg.Owner.ID {
if doer.ID == pkgOwner.ID {
accessMode = perm.AccessModeOwner
} else if pkg.Owner.Visibility == structs.VisibleTypePublic || pkg.Owner.Visibility == structs.VisibleTypeLimited { // 2. Check if package owner is public or limited
} else if pkgOwner.Visibility == structs.VisibleTypePublic || pkgOwner.Visibility == structs.VisibleTypeLimited { // 2. Check if package owner is public or limited
accessMode = perm.AccessModeRead
}
} else if pkg.Owner.Visibility == structs.VisibleTypePublic { // 3. Check if package owner is public
} else if pkgOwner.Visibility == structs.VisibleTypePublic { // 3. Check if package owner is public
accessMode = perm.AccessModeRead
}
}
@@ -150,7 +171,7 @@ func determineAccessMode(ctx *Base, pkg *Package, doer *user_model.User) (perm.A
// PackageContexter initializes a package context for a request.
func PackageContexter() func(next http.Handler) http.Handler {
renderer := templates.HTMLRenderer()
renderer := templates.PageRenderer()
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
base := NewBaseContext(resp, req)
@@ -6,6 +6,7 @@ package context
import (
"fmt"
"html/template"
"math"
"net/http"
"net/url"
"slices"
@@ -22,11 +23,13 @@ type Pagination struct {
}
// NewPagination creates a new instance of the Pagination struct.
// "total" is usually from database result "count int64", so it also uses int64
// "pagingNum" is "page size" or "limit", "current" is "page"
// total=-1 means only showing prev/next
func NewPagination(total, pagingNum, current, numPages int) *Pagination {
func NewPagination(total int64, pagingNum, current, numPages int) *Pagination {
totalInt := int(min(total, int64(math.MaxInt)))
p := &Pagination{}
p.Paginater = paginator.New(total, pagingNum, current, numPages)
p.Paginater = paginator.New(totalInt, pagingNum, current, numPages)
return p
}
@@ -12,6 +12,39 @@ import (
"code.gitea.io/gitea/models/unit"
)
// CheckTokenScopes checks whether the authenticated API token contains any of the given scopes.
func CheckTokenScopes(ctx *Context, repo *repo_model.Repository, scopes ...auth_model.AccessTokenScope) {
if ctx.Data["IsApiToken"] != true {
return
}
scope, ok := ctx.Data["ApiTokenScope"].(auth_model.AccessTokenScope)
if !ok {
return
}
publicOnly, err := scope.PublicOnly()
if err != nil {
ctx.ServerError("PublicOnly", err)
return
}
if publicOnly && repo != nil && repo.IsPrivate {
ctx.HTTPError(http.StatusForbidden)
return
}
scopeMatched, err := scope.HasAnyScope(scopes...)
if err != nil {
ctx.ServerError("HasAnyScope", err)
return
}
if !scopeMatched {
ctx.HTTPError(http.StatusForbidden)
}
}
// RequireRepoAdmin returns a middleware for requiring repository admin permission
func RequireRepoAdmin() func(ctx *Context) {
return func(ctx *Context) {
@@ -57,39 +90,7 @@ func RequireUnitReader(unitTypes ...unit.Type) func(ctx *Context) {
}
}
// CheckRepoScopedToken check whether personal access token has repo scope
// CheckRepoScopedToken checks whether the authenticated API token has repo scope.
func CheckRepoScopedToken(ctx *Context, repo *repo_model.Repository, level auth_model.AccessTokenScopeLevel) {
if !ctx.IsBasicAuth || ctx.Data["IsApiToken"] != true {
return
}
scope, ok := ctx.Data["ApiTokenScope"].(auth_model.AccessTokenScope)
if ok { // it's a personal access token but not oauth2 token
var scopeMatched bool
requiredScopes := auth_model.GetRequiredScopes(level, auth_model.AccessTokenScopeCategoryRepository)
// check if scope only applies to public resources
publicOnly, err := scope.PublicOnly()
if err != nil {
ctx.ServerError("HasScope", err)
return
}
if publicOnly && repo.IsPrivate {
ctx.HTTPError(http.StatusForbidden)
return
}
scopeMatched, err = scope.HasScope(requiredScopes...)
if err != nil {
ctx.ServerError("HasScope", err)
return
}
if !scopeMatched {
ctx.HTTPError(http.StatusForbidden)
return
}
}
CheckTokenScopes(ctx, repo, auth_model.GetRequiredScopes(level, auth_model.AccessTokenScopeCategoryRepository)...)
}
+83 -38
View File
@@ -37,11 +37,46 @@ import (
"github.com/editorconfig/editorconfig-core-go/v2"
)
// PullRequest contains information to make a pull request
type PullRequest struct {
BaseRepo *repo_model.Repository
Allowed bool // it only used by the web tmpl: "PullRequestCtx.Allowed"
SameRepo bool // it only used by the web tmpl: "PullRequestCtx.SameRepo"
// PullRequestContext contains context information for making a new pull request
type PullRequestContext struct {
ctx *Context
baseRepo, headRepo *repo_model.Repository
canCreateNewPull *bool
defaultTargetBranch *string
}
func (prc *PullRequestContext) SameRepo() bool {
return prc.baseRepo != nil && prc.headRepo != nil && prc.baseRepo.ID == prc.headRepo.ID
}
func (prc *PullRequestContext) CanCreateNewPull() bool {
if prc.canCreateNewPull != nil {
return *prc.canCreateNewPull
}
ctx := prc.ctx
// People who have push access or have forked repository can propose a new pull request.
can := prc.baseRepo.CanContentChange() &&
(ctx.Repo.CanWrite(unit_model.TypeCode) || (ctx.IsSigned && repo_model.HasForkedRepo(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID)))
prc.canCreateNewPull = &can
return can
}
func (prc *PullRequestContext) MakeDefaultCompareLink(headBranch string) string {
return prc.baseRepo.Link() + "/compare/" +
util.PathEscapeSegments(prc.DefaultTargetBranch()) + "..." +
util.Iif(prc.SameRepo(), "", util.PathEscapeSegments(prc.headRepo.OwnerName)+":") +
util.PathEscapeSegments(headBranch)
}
func (prc *PullRequestContext) DefaultTargetBranch() string {
if prc.defaultTargetBranch != nil {
return *prc.defaultTargetBranch
}
branchName := prc.baseRepo.GetPullRequestTargetBranch(prc.ctx)
prc.defaultTargetBranch = &branchName
return branchName
}
// Repository contains information to operate a repository
@@ -64,7 +99,7 @@ type Repository struct {
CommitID string
CommitsCount int64
PullRequest *PullRequest
PullRequestCtx *PullRequestContext
}
// CanWriteToBranch checks if the branch is writable by the user
@@ -138,9 +173,25 @@ func PrepareCommitFormOptions(ctx *Context, doer *user_model.User, targetRepo *r
protectedBranch.Repo = targetRepo
canPushWithProtection = protectedBranch.CanUserPush(ctx, doer)
protectionRequireSigned = protectedBranch.RequireSignedCommits
// If branch-wide push is restricted, allow direct commit when the
// URL-derived tree path matches an unprotected file pattern. The
// pre-receive hook re-checks every path the commit actually touches
// (e.g. rename source and destination).
if !canPushWithProtection && ctx.Repo.TreePath != "" && protectedBranch.UnprotectedFilePatterns != "" {
globs := protectedBranch.GetUnprotectedFilePatterns()
if protectedBranch.IsUnprotectedFile(globs, ctx.Repo.TreePath) {
canPushWithProtection = true
}
}
}
willSign, signKey, _, err := asymkey_service.SignCRUDAction(ctx, targetRepo.RepoPath(), doer, targetRepo.RepoPath(), refName.String())
targetGitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, targetRepo)
if err != nil {
return nil, err
}
defer closer.Close()
willSign, signKey, _, err := asymkey_service.SignCRUDAction(ctx, doer, targetGitRepo, refName.String())
wontSignReason := ""
if asymkey_service.IsErrWontSign(err) {
wontSignReason = string(err.(*asymkey_service.ErrWontSign).Reason)
@@ -202,14 +253,14 @@ func (r *Repository) CanCreateIssueDependencies(ctx context.Context, user *user_
}
// GetCommitsCount returns cached commit count for current view
func (r *Repository) GetCommitsCount() (int64, error) {
func (r *Repository) GetCommitsCount(ctx context.Context) (int64, error) {
if r.Commit == nil {
return 0, nil
}
contextName := r.RefFullName.ShortName()
isRef := r.RefFullName.IsBranch() || r.RefFullName.IsTag()
return cache.GetInt64(r.Repository.GetCommitsCountCacheKey(contextName, isRef), func() (int64, error) {
return r.Commit.CommitsCount()
return gitrepo.CommitsCountOfCommit(ctx, r.Repository, r.Commit.ID.String())
})
}
@@ -219,11 +270,10 @@ func (r *Repository) GetCommitGraphsCount(ctx context.Context, hidePRRefs bool,
return cache.GetInt64(cacheKey, func() (int64, error) {
if len(branches) == 0 {
return git.AllCommitsCount(ctx, r.Repository.RepoPath(), hidePRRefs, files...)
return gitrepo.AllCommitsCount(ctx, r.Repository, hidePRRefs, files...)
}
return git.CommitsCount(ctx,
git.CommitsCountOptions{
RepoPath: r.Repository.RepoPath(),
return gitrepo.CommitsCount(ctx, r.Repository,
gitrepo.CommitsCountOptions{
Revision: branches,
RelPath: files,
})
@@ -304,9 +354,9 @@ func RetrieveTemplateRepo(ctx *Context, repo *repo_model.Repository) {
return
}
perm, err := access_model.GetUserRepoPermission(ctx, templateRepo, ctx.Doer)
perm, err := access_model.GetDoerRepoPermission(ctx, templateRepo, ctx.Doer)
if err != nil {
ctx.ServerError("GetUserRepoPermission", err)
ctx.ServerError("GetDoerRepoPermission", err)
return
}
@@ -381,9 +431,9 @@ func repoAssignment(ctx *Context, repo *repo_model.Repository) {
if ctx.DoerNeedTwoFactorAuth() {
ctx.Repo.Permission = access_model.PermissionNoAccess()
} else {
ctx.Repo.Permission, err = access_model.GetUserRepoPermission(ctx, repo, ctx.Doer)
ctx.Repo.Permission, err = access_model.GetDoerRepoPermission(ctx, repo, ctx.Doer)
if err != nil {
ctx.ServerError("GetUserRepoPermission", err)
ctx.ServerError("GetDoerRepoPermission", err)
return
}
}
@@ -413,6 +463,12 @@ func repoAssignment(ctx *Context, repo *repo_model.Repository) {
ctx.Data["IsEmptyRepo"] = ctx.Repo.Repository.IsEmpty
}
func InitRepoPullRequestCtx(ctx *Context, base, head *repo_model.Repository) {
ctx.Repo.PullRequestCtx = &PullRequestContext{ctx: ctx}
ctx.Repo.PullRequestCtx.baseRepo, ctx.Repo.PullRequestCtx.headRepo = base, head
ctx.Data["PullRequestCtx"] = ctx.Repo.PullRequestCtx
}
// RepoAssignment returns a middleware to handle repository assignment
func RepoAssignment(ctx *Context) {
if ctx.Data["Repository"] != nil {
@@ -622,7 +678,7 @@ func RepoAssignment(ctx *Context) {
ctx.Repo.GitRepo, err = gitrepo.RepositoryFromRequestContextOrOpen(ctx, repo)
if err != nil {
if strings.Contains(err.Error(), "repository does not exist") || strings.Contains(err.Error(), "no such file or directory") {
log.Error("Repository %-v has a broken repository on the file system: %s Error: %v", ctx.Repo.Repository, ctx.Repo.Repository.RepoPath(), err)
log.Error("Repository %-v has a broken repository on the file system: %s Error: %v", ctx.Repo.Repository, ctx.Repo.Repository.RelativePath(), err)
ctx.Repo.Repository.MarkAsBrokenEmpty()
// Only allow access to base of repo or settings
if !isHomeOrSettings {
@@ -661,28 +717,16 @@ func RepoAssignment(ctx *Context) {
ctx.Data["BranchesCount"] = branchesTotal
// People who have push access or have forked repository can propose a new pull request.
canPush := ctx.Repo.CanWrite(unit_model.TypeCode) ||
(ctx.IsSigned && repo_model.HasForkedRepo(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID))
canCompare := false
// Pull request is allowed if this is a fork repository
// and base repository accepts pull requests.
// Pull request is allowed if this is a fork repository, and base repository accepts pull requests.
if repo.BaseRepo != nil && repo.BaseRepo.AllowsPulls(ctx) {
canCompare = true
// TODO: this (and below) "BaseRepo" var is not clear and should be removed in the future
ctx.Data["BaseRepo"] = repo.BaseRepo
ctx.Repo.PullRequest.BaseRepo = repo.BaseRepo
ctx.Repo.PullRequest.Allowed = canPush
InitRepoPullRequestCtx(ctx, repo.BaseRepo, repo)
} else if repo.AllowsPulls(ctx) {
// Or, this is repository accepts pull requests between branches.
canCompare = true
ctx.Data["BaseRepo"] = repo
ctx.Repo.PullRequest.BaseRepo = repo
ctx.Repo.PullRequest.Allowed = canPush
ctx.Repo.PullRequest.SameRepo = true
InitRepoPullRequestCtx(ctx, repo, repo)
}
ctx.Data["CanCompareOrPull"] = canCompare
ctx.Data["PullRequestCtx"] = ctx.Repo.PullRequest
if ctx.Repo.Repository.Status == repo_model.RepositoryPendingTransfer {
repoTransfer, err := repo_model.GetPendingRepositoryTransfer(ctx, ctx.Repo.Repository)
@@ -821,7 +865,7 @@ func RepoRefByDefaultBranch() func(*Context) {
ctx.Repo.RefFullName = git.RefNameFromBranch(ctx.Repo.Repository.DefaultBranch)
ctx.Repo.BranchName = ctx.Repo.Repository.DefaultBranch
ctx.Repo.Commit, _ = ctx.Repo.GitRepo.GetBranchCommit(ctx.Repo.BranchName)
ctx.Repo.CommitsCount, _ = ctx.Repo.GetCommitsCount()
ctx.Repo.CommitsCount, _ = ctx.Repo.GetCommitsCount(ctx)
ctx.Data["RefFullName"] = ctx.Repo.RefFullName
ctx.Data["BranchName"] = ctx.Repo.BranchName
ctx.Data["CommitsCount"] = ctx.Repo.CommitsCount
@@ -858,7 +902,7 @@ func RepoRefByType(detectRefType git.RefType) func(*Context) {
if err == nil && len(brs) != 0 {
refShortName = brs[0]
} else if len(brs) == 0 {
log.Error("No branches in non-empty repository %s", ctx.Repo.GitRepo.Path)
log.Error("No branches in non-empty repository %s", ctx.Repo.Repository.RelativePath())
} else {
log.Error("GetBranches error: %v", err)
}
@@ -927,7 +971,8 @@ func RepoRefByType(detectRefType git.RefType) func(*Context) {
}
// If short commit ID add canonical link header
if len(refShortName) < ctx.Repo.GetObjectFormat().FullLength() {
canonicalURL := util.URLJoin(httplib.GuessCurrentAppURL(ctx), strings.Replace(ctx.Req.URL.RequestURI(), util.PathEscapeSegments(refShortName), url.PathEscape(ctx.Repo.Commit.ID.String()), 1))
// FIXME: the dirty hack of "strings.Replace" should be fixed
canonicalURL := strings.TrimSuffix(httplib.GuessCurrentAppURL(ctx), "/") + strings.Replace(ctx.Req.URL.RequestURI(), util.PathEscapeSegments(refShortName), url.PathEscape(ctx.Repo.Commit.ID.String()), 1)
ctx.RespHeader().Set("Link", fmt.Sprintf(`<%s>; rel="canonical"`, canonicalURL))
}
} else {
@@ -970,7 +1015,7 @@ func RepoRefByType(detectRefType git.RefType) func(*Context) {
ctx.Data["CanCreateBranch"] = ctx.Repo.CanCreateBranch() // only used by the branch selector dropdown: AllowCreateNewRef
ctx.Repo.CommitsCount, err = ctx.Repo.GetCommitsCount()
ctx.Repo.CommitsCount, err = ctx.Repo.GetCommitsCount(ctx)
if err != nil {
ctx.ServerError("GetCommitsCount", err)
return
@@ -4,6 +4,8 @@
package context
import (
"bufio"
"net"
"net/http"
web_types "code.gitea.io/gitea/modules/web/types"
@@ -67,6 +69,15 @@ func (r *Response) WriteHeader(statusCode int) {
}
}
// Hijack implements http.Hijacker, delegating to the underlying ResponseWriter.
// This is needed for WebSocket upgrades through reverse proxies.
func (r *Response) Hijack() (net.Conn, *bufio.ReadWriter, error) {
if hj, ok := r.ResponseWriter.(http.Hijacker); ok {
return hj.Hijack()
}
return nil, nil, http.ErrNotSupported
}
// Flush flushes cached data
func (r *Response) Flush() {
if f, ok := r.ResponseWriter.(http.Flusher); ok {
@@ -95,8 +95,8 @@ func AddUploadContext(ctx *context.Context, uploadType string) {
ctx.Data["UploadRemoveUrl"] = ctx.Repo.RepoLink + "/releases/attachments/remove"
ctx.Data["UploadLinkUrl"] = ctx.Repo.RepoLink + "/releases/attachments"
ctx.Data["UploadAccepts"] = strings.ReplaceAll(setting.Repository.Release.AllowedTypes, "|", ",")
ctx.Data["UploadMaxFiles"] = setting.Attachment.MaxFiles
ctx.Data["UploadMaxSize"] = setting.Attachment.MaxSize
ctx.Data["UploadMaxFiles"] = setting.Repository.Release.MaxFiles
ctx.Data["UploadMaxSize"] = setting.Repository.Release.FileMaxSize
case "comment":
ctx.Data["UploadUrl"] = ctx.Repo.RepoLink + "/issues/attachments"
ctx.Data["UploadRemoveUrl"] = ctx.Repo.RepoLink + "/issues/attachments/remove"
@@ -1,99 +0,0 @@
// Copyright 2012 Google Inc. All Rights Reserved.
// Copyright 2014 The Macaron Authors
// Copyright 2020 The Gitea Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// SPDX-License-Identifier: Apache-2.0
package context
import (
"bytes"
"crypto/hmac"
"crypto/sha1"
"crypto/subtle"
"encoding/base64"
"fmt"
"strconv"
"strings"
"time"
)
// CsrfTokenTimeout represents the duration that XSRF tokens are valid.
// It is exported so clients may set cookie timeouts that match generated tokens.
const CsrfTokenTimeout = 24 * time.Hour
// CsrfTokenRegenerationInterval is the interval between token generations, old tokens are still valid before CsrfTokenTimeout
var CsrfTokenRegenerationInterval = 10 * time.Minute
var csrfTokenSep = []byte(":")
// GenerateCsrfToken returns a URL-safe secure XSRF token that expires in CsrfTokenTimeout hours.
// key is a secret key for your application.
// userID is a unique identifier for the user.
// actionID is the action the user is taking (e.g. POSTing to a particular path).
func GenerateCsrfToken(key, userID, actionID string, now time.Time) string {
nowUnixNano := now.UnixNano()
nowUnixNanoStr := strconv.FormatInt(nowUnixNano, 10)
h := hmac.New(sha1.New, []byte(key))
h.Write([]byte(strings.ReplaceAll(userID, ":", "_")))
h.Write(csrfTokenSep)
h.Write([]byte(strings.ReplaceAll(actionID, ":", "_")))
h.Write(csrfTokenSep)
h.Write([]byte(nowUnixNanoStr))
tok := fmt.Sprintf("%s:%s", h.Sum(nil), nowUnixNanoStr)
return base64.RawURLEncoding.EncodeToString([]byte(tok))
}
func ParseCsrfToken(token string) (issueTime time.Time, ok bool) {
data, err := base64.RawURLEncoding.DecodeString(token)
if err != nil {
return time.Time{}, false
}
pos := bytes.LastIndex(data, csrfTokenSep)
if pos == -1 {
return time.Time{}, false
}
nanos, err := strconv.ParseInt(string(data[pos+1:]), 10, 64)
if err != nil {
return time.Time{}, false
}
return time.Unix(0, nanos), true
}
// ValidCsrfToken returns true if token is a valid and unexpired token returned by Generate.
func ValidCsrfToken(token, key, userID, actionID string, now time.Time) bool {
issueTime, ok := ParseCsrfToken(token)
if !ok {
return false
}
// Check that the token is not expired.
if now.Sub(issueTime) >= CsrfTokenTimeout {
return false
}
// Check that the token is not from the future.
// Allow 1-minute grace period in case the token is being verified on a
// machine whose clock is behind the machine that issued the token.
if issueTime.After(now.Add(1 * time.Minute)) {
return false
}
expected := GenerateCsrfToken(key, userID, actionID, issueTime)
// Check that the token matches the expected value.
// Use constant time comparison to avoid timing attacks.
return subtle.ConstantTimeCompare([]byte(token), []byte(expected)) == 1
}
@@ -1,91 +0,0 @@
// Copyright 2012 Google Inc. All Rights Reserved.
// Copyright 2014 The Macaron Authors
// Copyright 2020 The Gitea Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// SPDX-License-Identifier: Apache-2.0
package context
import (
"encoding/base64"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
const (
key = "quay"
userID = "12345678"
actionID = "POST /form"
)
var (
now = time.Now()
oneMinuteFromNow = now.Add(1 * time.Minute)
)
func Test_ValidToken(t *testing.T) {
t.Run("Validate token", func(t *testing.T) {
tok := GenerateCsrfToken(key, userID, actionID, now)
assert.True(t, ValidCsrfToken(tok, key, userID, actionID, oneMinuteFromNow))
assert.True(t, ValidCsrfToken(tok, key, userID, actionID, now.Add(CsrfTokenTimeout-1*time.Nanosecond)))
assert.True(t, ValidCsrfToken(tok, key, userID, actionID, now.Add(-1*time.Minute)))
})
}
// Test_SeparatorReplacement tests that separators are being correctly substituted
func Test_SeparatorReplacement(t *testing.T) {
t.Run("Test two separator replacements", func(t *testing.T) {
assert.NotEqual(t, GenerateCsrfToken("foo:bar", "baz", "wah", now),
GenerateCsrfToken("foo", "bar:baz", "wah", now))
})
}
func Test_InvalidToken(t *testing.T) {
t.Run("Test invalid tokens", func(t *testing.T) {
invalidTokenTests := []struct {
name, key, userID, actionID string
t time.Time
}{
{"Bad key", "foobar", userID, actionID, oneMinuteFromNow},
{"Bad userID", key, "foobar", actionID, oneMinuteFromNow},
{"Bad actionID", key, userID, "foobar", oneMinuteFromNow},
{"Expired", key, userID, actionID, now.Add(CsrfTokenTimeout)},
{"More than 1 minute from the future", key, userID, actionID, now.Add(-1*time.Nanosecond - 1*time.Minute)},
}
tok := GenerateCsrfToken(key, userID, actionID, now)
for _, itt := range invalidTokenTests {
assert.False(t, ValidCsrfToken(tok, itt.key, itt.userID, itt.actionID, itt.t))
}
})
}
// Test_ValidateBadData primarily tests that no unexpected panics are triggered during parsing
func Test_ValidateBadData(t *testing.T) {
t.Run("Validate bad data", func(t *testing.T) {
badDataTests := []struct {
name, tok string
}{
{"Invalid Base64", "ASDab24(@)$*=="},
{"No delimiter", base64.URLEncoding.EncodeToString([]byte("foobar12345678"))},
{"Invalid time", base64.URLEncoding.EncodeToString([]byte("foobar:foobar"))},
}
for _, bdt := range badDataTests {
assert.False(t, ValidCsrfToken(bdt.tok, key, userID, actionID, oneMinuteFromNow))
}
})
}
@@ -125,7 +125,7 @@ func LoadRepo(t *testing.T, ctx gocontext.Context, repoID int64) {
repo.Owner, err = user_model.GetUserByID(ctx, repo.Repository.OwnerID)
assert.NoError(t, err)
repo.RepoLink = repo.Repository.Link()
repo.Permission, err = access_model.GetUserRepoPermission(ctx, repo.Repository, doer)
repo.Permission, err = access_model.GetDoerRepoPermission(ctx, repo.Repository, doer)
assert.NoError(t, err)
}
@@ -143,8 +143,9 @@ func LoadRepoCommit(t *testing.T, ctx gocontext.Context) {
gitRepo, err := gitrepo.OpenRepository(ctx, repo.Repository)
require.NoError(t, err)
defer gitRepo.Close()
t.Cleanup(func() {
gitRepo.Close()
})
if repo.RefFullName == "" {
repo.RefFullName = git_module.RefNameFromBranch(repo.Repository.DefaultBranch)
}
@@ -161,8 +162,10 @@ func LoadUser(t *testing.T, ctx gocontext.Context, userID int64) {
switch ctx := ctx.(type) {
case *context.Context:
ctx.Doer = doer
ctx.IsSigned = true
case *context.APIContext:
ctx.Doer = doer
ctx.IsSigned = true
default:
assert.FailNow(t, "context is not *context.Context or *context.APIContext")
}
@@ -189,7 +192,7 @@ func LoadGitRepo(t *testing.T, ctx gocontext.Context) {
type MockRender struct{}
func (tr *MockRender) TemplateLookup(tmpl string, _ gocontext.Context) (templates.TemplateExecutor, error) {
return nil, nil
return nil, nil //nolint:nilnil // mock implementation returns nil to indicate no template found
}
func (tr *MockRender) HTML(w io.Writer, status int, _ templates.TplName, _ any, _ gocontext.Context) error {
@@ -0,0 +1,179 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package convert
import (
"fmt"
"strings"
"testing"
actions_model "code.gitea.io/gitea/models/actions"
db "code.gitea.io/gitea/models/db"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unit"
"code.gitea.io/gitea/models/unittest"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/git/gitcmd"
"code.gitea.io/gitea/modules/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// buildWorkflowTestRepo creates a temporary git repository for testing GetActionWorkflow.
// The default branch "main" has no workflow files; "feature" and "release-v1" each add their own workflow file.
func buildWorkflowTestRepo(t *testing.T) string {
t.Helper()
ctx := t.Context()
tmpDir := t.TempDir()
_, _, err := gitcmd.NewCommand("init").WithDir(tmpDir).RunStdString(ctx)
require.NoError(t, err)
readme := "readme"
featureWF := "on: [push]\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: echo test\n"
releaseWF := "on: [push]\njobs:\n release:\n runs-on: ubuntu-latest\n steps:\n - run: echo release\n"
// Build a git fast-import stream:
// :4 = initial commit on main (README.md only)
// :5 = feature branch commit (adds feature workflow)
// :6 = release commit from :4 (adds release workflow, tagged release-v1, not on main)
var sb strings.Builder
fmt.Fprintf(&sb, "blob\nmark :1\ndata %d\n%s\n", len(readme), readme)
fmt.Fprintf(&sb, "blob\nmark :2\ndata %d\n%s\n", len(featureWF), featureWF)
fmt.Fprintf(&sb, "blob\nmark :3\ndata %d\n%s\n", len(releaseWF), releaseWF)
fmt.Fprintf(&sb, "commit refs/heads/main\nmark :4\nauthor Test <test@gitea.com> 1000000000 +0000\ncommitter Test <test@gitea.com> 1000000000 +0000\ndata 14\ninitial commit\nM 100644 :1 README.md\n\n")
fmt.Fprintf(&sb, "commit refs/heads/feature\nmark :5\nauthor Test <test@gitea.com> 1000000001 +0000\ncommitter Test <test@gitea.com> 1000000001 +0000\ndata 12\nadd workflow\nfrom :4\nM 100644 :2 .gitea/workflows/my-workflow.yml\n\n")
fmt.Fprintf(&sb, "reset refs/pull/42/merge\nfrom :5\n\n")
fmt.Fprintf(&sb, "commit refs/heads/main\nmark :6\nauthor Test <test@gitea.com> 1000000002 +0000\ncommitter Test <test@gitea.com> 1000000002 +0000\ndata 16\nrelease workflow\nfrom :4\nM 100644 :3 .gitea/workflows/my-workflow.yml\n\n")
fmt.Fprintf(&sb, "reset refs/tags/release-v1\nfrom :6\n\n")
fmt.Fprintf(&sb, "reset refs/heads/main\nfrom :4\n\n")
fmt.Fprintf(&sb, "done\n")
_, _, err = gitcmd.NewCommand("fast-import").WithDir(tmpDir).WithStdinBytes([]byte(sb.String())).RunStdString(ctx)
require.NoError(t, err)
return tmpDir
}
func TestGetActionWorkflow_FallbackRef(t *testing.T) {
ctx := t.Context()
repoDir := buildWorkflowTestRepo(t)
gitRepo, err := git.OpenRepository(ctx, repoDir)
require.NoError(t, err)
defer gitRepo.Close()
repo := &repo_model.Repository{
DefaultBranch: "main",
OwnerName: "test-owner",
Name: "test-repo",
Units: []*repo_model.RepoUnit{
{
Type: unit.TypeActions,
Config: &repo_model.ActionsConfig{},
},
},
}
t.Run("returns error when workflow only on non-default branch", func(t *testing.T) {
_, err := GetActionWorkflow(ctx, gitRepo, repo, "my-workflow.yml")
require.Error(t, err)
assert.ErrorIs(t, err, util.ErrNotExist)
})
t.Run("returns workflow when found via ref", func(t *testing.T) {
wf, err := GetActionWorkflowByRef(ctx, gitRepo, repo, "my-workflow.yml", git.RefName("refs/heads/feature"))
require.NoError(t, err)
assert.Equal(t, "my-workflow.yml", wf.ID)
})
t.Run("returns workflow when found via pull ref", func(t *testing.T) {
wf, err := GetActionWorkflowByRef(ctx, gitRepo, repo, "my-workflow.yml", git.RefName("refs/pull/42/merge"))
require.NoError(t, err)
assert.Equal(t, "my-workflow.yml", wf.ID)
assert.Contains(t, wf.HTMLURL, "/src/commit/")
})
t.Run("returns workflow with tag link when found via tag ref", func(t *testing.T) {
wf, err := GetActionWorkflowByRef(ctx, gitRepo, repo, "my-workflow.yml", git.RefName("refs/tags/release-v1"))
require.NoError(t, err)
assert.Equal(t, "my-workflow.yml", wf.ID)
assert.Contains(t, wf.HTMLURL, "/src/tag/release-v1/")
})
t.Run("returns error when workflow missing from ref", func(t *testing.T) {
_, err := GetActionWorkflowByRef(ctx, gitRepo, repo, "nonexistent.yml", git.RefName("refs/heads/feature"))
require.Error(t, err)
assert.ErrorIs(t, err, util.ErrNotExist)
})
}
func TestToActionWorkflowRun_UsesTriggerEvent(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 803})
// Scheduled runs keep Event as the registration event (push) and use TriggerEvent as the real trigger.
run.Event = "push"
run.TriggerEvent = "schedule"
apiRun, err := ToActionWorkflowRun(t.Context(), repo, run)
require.NoError(t, err)
assert.Equal(t, "schedule", apiRun.Event)
}
func TestToActionWorkflowJob_StepStatusIsIndependentOfJobStatus(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
ctx := t.Context()
run := &actions_model.ActionRun{
ID: 9001,
RepoID: 2,
TriggerUserID: 1,
WorkflowID: "test.yaml",
Index: 12345,
Ref: "refs/heads/main",
Status: actions_model.StatusFailure,
}
require.NoError(t, db.Insert(ctx, run))
task := &actions_model.ActionTask{
ID: 900102,
JobID: 9001,
RepoID: 2,
Status: actions_model.StatusFailure,
}
require.NoError(t, db.Insert(ctx, task))
job := &actions_model.ActionRunJob{
ID: 90010203,
RunID: 9001,
TaskID: 900102,
RepoID: 2,
Name: "test-job-name",
Attempt: 1,
JobID: "test-job-id",
Status: actions_model.StatusFailure,
}
require.NoError(t, db.Insert(ctx, job))
require.NoError(t, db.Insert(ctx,
&actions_model.ActionTaskStep{TaskID: task.ID, RepoID: 2, Index: 0, Name: "step-success", Status: actions_model.StatusSuccess},
&actions_model.ActionTaskStep{TaskID: task.ID, RepoID: 2, Index: 1, Name: "step-failure", Status: actions_model.StatusFailure},
))
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
apiJob, err := ToActionWorkflowJob(ctx, repo, task, job)
require.NoError(t, err)
require.Len(t, apiJob.Steps, 2)
assert.Equal(t, "completed", apiJob.Steps[0].Status, "step 0 status")
assert.Equal(t, "success", apiJob.Steps[0].Conclusion, "step 0 conclusion (succeeded before the failure)")
assert.Equal(t, "completed", apiJob.Steps[1].Status, "step 1 status")
assert.Equal(t, "failure", apiJob.Steps[1].Conclusion, "step 1 conclusion")
}
@@ -15,9 +15,9 @@ import (
)
func ToActivity(ctx context.Context, ac *activities_model.Action, doer *user_model.User) *api.Activity {
p, err := access_model.GetUserRepoPermission(ctx, ac.Repo, doer)
p, err := access_model.GetDoerRepoPermission(ctx, ac.Repo, doer)
if err != nil {
log.Error("GetUserRepoPermission[%d]: %v", ac.RepoID, err)
log.Error("GetDoerRepoPermission[%d]: %v", ac.RepoID, err)
p.AccessMode = perm_model.AccessModeNone
}
@@ -72,7 +72,7 @@ func ToBranch(ctx context.Context, repo *repo_model.Repository, branchName strin
return nil, err
}
perms, err := access_model.GetUserRepoPermission(ctx, repo, user)
perms, err := access_model.GetIndividualUserRepoPermission(ctx, repo, user)
if err != nil {
return nil, err
}
@@ -105,7 +105,7 @@ func ToBranch(ctx context.Context, repo *repo_model.Repository, branchName strin
}
if user != nil {
permission, err := access_model.GetUserRepoPermission(ctx, repo, user)
permission, err := access_model.GetIndividualUserRepoPermission(ctx, repo, user)
if err != nil {
return nil, err
}
@@ -203,8 +203,8 @@ func ToBranchProtection(ctx context.Context, bp *git_model.ProtectedBranch, repo
// ToTag convert a git.Tag to an api.Tag
func ToTag(repo *repo_model.Repository, t *git.Tag) *api.Tag {
tarballURL := util.URLJoin(repo.HTMLURL(), "archive", t.Name+".tar.gz")
zipballURL := util.URLJoin(repo.HTMLURL(), "archive", t.Name+".zip")
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 {
@@ -214,7 +214,7 @@ func ToTag(repo *repo_model.Repository, t *git.Tag) *api.Tag {
return &api.Tag{
Name: t.Name,
Message: strings.TrimSpace(t.Message),
Message: strings.ToValidUTF8(strings.TrimSpace(t.Message), "?"), // the trim is not right, 1.27 won't trim
ID: t.ID.String(),
Commit: ToCommitMeta(repo, t),
ZipballURL: zipballURL,
@@ -260,7 +260,7 @@ func ToActionWorkflowRun(ctx context.Context, repo *repo_model.Repository, run *
RunNumber: run.Index,
StartedAt: run.Started.AsLocalTime(),
CompletedAt: run.Stopped.AsLocalTime(),
Event: string(run.Event),
Event: run.TriggerEvent,
DisplayTitle: run.Title,
HeadBranch: git.RefName(run.Ref).BranchName(),
HeadSha: run.CommitSHA,
@@ -274,33 +274,31 @@ func ToActionWorkflowRun(ctx context.Context, repo *repo_model.Repository, run *
}, nil
}
func ToWorkflowRunAction(status actions_model.Status) string {
var action string
func ToWorkflowRunAction(status actions_model.Status) (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"
default:
if status.IsDone() {
action = "completed"
} else {
setting.PanicInDevOrTesting("unknown action status: %v", status)
}
}
return action
}
func ToActionsStatus(status actions_model.Status) (string, string) {
var action string
var conclusion string
func ToActionsStatus(status actions_model.Status) (action, conclusion string) {
switch status {
// This is a naming conflict of the webhook between Gitea and GitHub Actions
case actions_model.StatusWaiting:
action = "queued"
action = "queued" // "waiting" is a naming conflict of the webhook between Gitea and GitHub Actions
case actions_model.StatusBlocked:
action = "waiting"
action = "waiting" // naming conflict (as above)
case actions_model.StatusRunning:
action = "in_progress"
}
if status.IsDone() {
default:
action = "completed"
switch status {
case actions_model.StatusSuccess:
@@ -311,6 +309,8 @@ func ToActionsStatus(status actions_model.Status) (string, string) {
conclusion = "failure"
case actions_model.StatusSkipped:
conclusion = "skipped"
default:
setting.PanicInDevOrTesting("unknown action status: %v", status)
}
}
return action, conclusion
@@ -324,18 +324,6 @@ func ToActionWorkflowJob(ctx context.Context, repo *repo_model.Repository, task
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
@@ -349,20 +337,29 @@ func ToActionWorkflowJob(ctx context.Context, repo *repo_model.Repository, task
}
}
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(),
})
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(),
})
}
}
}
@@ -370,7 +367,7 @@ func ToActionWorkflowJob(ctx context.Context, repo *repo_model.Repository, task
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),
HTMLURL: fmt.Sprintf("%s/jobs/%d", job.Run.HTMLURL(), 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(), job.RunID),
@@ -383,21 +380,22 @@ func ToActionWorkflowJob(ctx context.Context, repo *repo_model.Repository, task
Conclusion: conclusion,
RunnerID: runnerID,
RunnerName: runnerName,
Steps: steps,
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, folder string, entry *git.TreeEntry) *api.ActionWorkflow {
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()
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()))
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
@@ -462,21 +460,47 @@ func ListActionWorkflows(ctx context.Context, gitrepo *git.Repository, repo *rep
workflows := make([]*api.ActionWorkflow, len(entries))
for i, entry := range entries {
workflows[i] = getActionWorkflowEntry(ctx, repo, defaultBranchCommit, folder, entry)
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) {
entries, err := ListActionWorkflows(ctx, gitrepo, repo)
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.ID == workflowID {
return entry, nil
if entry.Name() == workflowID {
return getActionWorkflowEntry(ctx, repo, commit, refName, folder, entry), nil
}
}
@@ -524,6 +548,7 @@ func ToActionRunner(ctx context.Context, runner *actions_model.ActionRunner) *ap
Name: runner.Name,
Status: apiStatus,
Busy: status == runnerv1.RunnerStatus_RUNNER_STATUS_ACTIVE,
Disabled: runner.IsDisabled,
Ephemeral: runner.Ephemeral,
Labels: labels,
}
@@ -703,8 +728,8 @@ func ToAnnotatedTag(ctx context.Context, repo *repo_model.Repository, t *git.Tag
Tag: t.Name,
SHA: t.ID.String(),
Object: ToAnnotatedTagObject(repo, c),
Message: t.Message,
URL: util.URLJoin(repo.APIURL(), "git/tags", t.ID.String()),
Message: strings.ToValidUTF8(t.Message, "?"),
URL: repo.APIURL() + "/git/tags/" + t.ID.String(),
Tagger: ToCommitUser(t.Tagger),
Verification: ToVerification(ctx, c),
}
@@ -715,7 +740,7 @@ func ToAnnotatedTagObject(repo *repo_model.Repository, commit *git.Commit) *api.
return &api.AnnotatedTagObject{
SHA: commit.ID.String(),
Type: string(git.ObjectCommit),
URL: util.URLJoin(repo.APIURL(), "git/commits", commit.ID.String()),
URL: repo.APIURL() + "/git/commits/" + commit.ID.String(),
}
}
@@ -772,7 +797,7 @@ func ToOAuth2Application(app *auth.OAuth2Application) *api.OAuth2Application {
// ToLFSLock convert a LFSLock to api.LFSLock
func ToLFSLock(ctx context.Context, l *git_model.LFSLock) *api.LFSLock {
u, err := user_model.GetUserByID(ctx, l.OwnerID)
_, u, err := user_model.GetPossibleUserByID(ctx, l.OwnerID)
if err != nil {
return nil
}
@@ -11,9 +11,9 @@ 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/log"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/util"
ctx "code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/gitdiff"
)
@@ -33,7 +33,7 @@ func ToCommitUser(sig *git.Signature) *api.CommitUser {
func ToCommitMeta(repo *repo_model.Repository, tag *git.Tag) *api.CommitMeta {
return &api.CommitMeta{
SHA: tag.Object.String(),
URL: util.URLJoin(repo.APIURL(), "git/commits", tag.ID.String()),
URL: repo.APIURL() + "/git/commits/" + tag.ID.String(),
Created: tag.Tagger.When,
}
}
@@ -57,7 +57,7 @@ func ToPayloadCommit(ctx context.Context, repo *repo_model.Repository, c *git.Co
return &api.PayloadCommit{
ID: c.ID.String(),
Message: c.Message(),
URL: util.URLJoin(repo.HTMLURL(), "commit", c.ID.String()),
URL: repo.HTMLURL() + "/commit/" + c.ID.String(),
Author: &api.PayloadUser{
Name: c.Author.Name,
Email: c.Author.Email,
@@ -190,7 +190,7 @@ func ToCommit(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Rep
// Retrieve files affected by the commit
if opts.Files {
fileStatus, err := git.GetCommitFileStatus(gitRepo.Ctx, repo.RepoPath(), commit.ID.String())
fileStatus, err := gitrepo.GetCommitFileStatus(ctx, repo, commit.ID.String())
if err != nil {
return nil, err
}
@@ -210,7 +210,7 @@ func ToCommit(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Rep
// Get diff stats for commit
if opts.Stat {
diffShortStat, err := gitdiff.GetDiffShortStat(gitRepo, "", commit.ID.String())
diffShortStat, err := gitdiff.GetDiffShortStat(ctx, repo, gitRepo, "", commit.ID.String())
if err != nil {
return nil, err
}
@@ -11,7 +11,6 @@ import (
"code.gitea.io/gitea/models/unittest"
"code.gitea.io/gitea/modules/git"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/util"
"github.com/stretchr/testify/assert"
)
@@ -35,7 +34,7 @@ func TestToCommitMeta(t *testing.T) {
assert.NotNil(t, commitMeta)
assert.Equal(t, &api.CommitMeta{
SHA: sha1.EmptyObjectID().String(),
URL: util.URLJoin(headRepo.APIURL(), "git/commits", sha1.EmptyObjectID().String()),
URL: headRepo.APIURL() + "/git/commits/" + sha1.EmptyObjectID().String(),
Created: time.Unix(0, 0),
}, commitMeta)
}
+18 -2
View File
@@ -13,6 +13,7 @@ import (
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/cache"
"code.gitea.io/gitea/modules/label"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
@@ -61,7 +62,8 @@ func toIssue(ctx context.Context, doer *user_model.User, issue *issues_model.Iss
Updated: issue.UpdatedUnix.AsTime(),
PinOrder: util.Iif(issue.PinOrder == -1, 0, issue.PinOrder), // -1 means loaded with no pin order
TimeEstimate: issue.TimeEstimate,
TimeEstimate: issue.TimeEstimate,
ContentVersion: issue.ContentVersion,
}
if issue.Repo != nil {
@@ -199,7 +201,7 @@ func ToStopWatches(ctx context.Context, doer *user_model.User, sws []*issues_mod
// ADD: Check user permissions
perm, ok := permCache[repo.ID]
if !ok {
perm, err = access_model.GetUserRepoPermission(ctx, repo, doer)
perm, err = access_model.GetDoerRepoPermission(ctx, repo, doer)
if err != nil {
continue
}
@@ -226,7 +228,21 @@ func ToStopWatches(ctx context.Context, doer *user_model.User, sws []*issues_mod
// ToTrackedTimeList converts TrackedTimeList to API format
func ToTrackedTimeList(ctx context.Context, doer *user_model.User, tl issues_model.TrackedTimeList) api.TrackedTimeList {
result := make([]*api.TrackedTime, 0, len(tl))
permCache := cache.NewEphemeralCache()
for _, t := range tl {
// If the issue is not loaded, conservatively skip this entry to avoid bypassing permission checks.
if t.Issue == nil || t.Issue.Repo == nil {
continue
}
perm, err := cache.GetWithEphemeralCache(ctx, permCache, "repo-perm", t.Issue.RepoID, func(ctx context.Context, repoID int64) (access_model.Permission, error) {
return access_model.GetDoerRepoPermission(ctx, t.Issue.Repo, doer)
})
if err != nil {
continue
}
if !perm.CanReadIssuesOrPulls(t.Issue.IsPull) {
continue
}
result = append(result, ToTrackedTime(ctx, doer, t))
}
return result
@@ -18,6 +18,7 @@ import (
"code.gitea.io/gitea/modules/timeutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestLabel_ToLabel(t *testing.T) {
@@ -83,3 +84,43 @@ func TestToStopWatchesRespectsPermissions(t *testing.T) {
assert.Len(t, visibleAdmin, 2)
assert.ElementsMatch(t, []string{"repo1", "repo3"}, []string{visibleAdmin[0].RepoName, visibleAdmin[1].RepoName})
}
func TestToTrackedTime(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
ctx := t.Context()
publicIssue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: 1})
privateIssue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: 3})
regularUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
adminUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
publicTrackedTime := &issues_model.TrackedTime{IssueID: publicIssue.ID, UserID: regularUser.ID, Time: 3600}
privateTrackedTime := &issues_model.TrackedTime{IssueID: privateIssue.ID, UserID: regularUser.ID, Time: 1800}
require.NoError(t, db.Insert(ctx, publicTrackedTime))
require.NoError(t, db.Insert(ctx, privateTrackedTime))
t.Run("NilIssues", func(t *testing.T) {
list := ToTrackedTimeList(ctx, regularUser, issues_model.TrackedTimeList{publicTrackedTime, privateTrackedTime})
assert.Empty(t, list)
})
t.Run("NilRepo", func(t *testing.T) {
badTrackedTime := &issues_model.TrackedTime{Issue: &issues_model.Issue{RepoID: 999999}}
visible := ToTrackedTimeList(ctx, regularUser, issues_model.TrackedTimeList{badTrackedTime})
assert.Empty(t, visible)
})
trackedTimes := issues_model.TrackedTimeList{publicTrackedTime, privateTrackedTime}
require.NoError(t, trackedTimes.LoadAttributes(ctx))
t.Run("ToRegularUser", func(t *testing.T) {
list := ToTrackedTimeList(ctx, regularUser, trackedTimes)
require.Len(t, list, 1)
assert.Equal(t, "repo1", list[0].Issue.Repo.Name)
})
t.Run("ToAdminUser", func(t *testing.T) {
list := ToTrackedTimeList(ctx, adminUser, trackedTimes)
require.Len(t, list, 2)
assert.ElementsMatch(t, []string{"repo1", "repo3"}, []string{list[0].Issue.Repo.Name, list[1].Issue.Repo.Name})
})
}
@@ -25,9 +25,9 @@ func ToNotificationThread(ctx context.Context, n *activities_model.Notification)
// since user only get notifications when he has access to use minimal access mode
if n.Repository != nil {
perm, err := access_model.GetUserRepoPermission(ctx, n.Repository, n.User)
perm, err := access_model.GetIndividualUserRepoPermission(ctx, n.Repository, n.User)
if err != nil {
log.Error("GetUserRepoPermission failed: %v", err)
log.Error("GetIndividualUserRepoPermission failed: %v", err)
return result
}
if perm.HasAnyUnitAccessOrPublicAccess() { // if user has been revoked access to repo, do not show repo info
@@ -47,7 +47,7 @@ func ToNotificationThread(ctx context.Context, n *activities_model.Notification)
result.Subject.Title = n.Issue.Title
result.Subject.URL = n.Issue.APIURL(ctx)
result.Subject.HTMLURL = n.Issue.HTMLURL(ctx)
result.Subject.State = n.Issue.State()
result.Subject.State = api.NotifySubjectStateType(n.Issue.State())
comment, err := n.Issue.GetLastComment(ctx)
if err == nil && comment != nil {
result.Subject.LatestCommentURL = comment.APIURL(ctx)
@@ -60,7 +60,7 @@ func ToNotificationThread(ctx context.Context, n *activities_model.Notification)
result.Subject.Title = n.Issue.Title
result.Subject.URL = n.Issue.APIURL(ctx)
result.Subject.HTMLURL = n.Issue.HTMLURL(ctx)
result.Subject.State = n.Issue.State()
result.Subject.State = api.NotifySubjectStateType(n.Issue.State())
comment, err := n.Issue.GetLastComment(ctx)
if err == nil && comment != nil {
result.Subject.LatestCommentURL = comment.APIURL(ctx)
@@ -70,7 +70,7 @@ func ToNotificationThread(ctx context.Context, n *activities_model.Notification)
if err := n.Issue.LoadPullRequest(ctx); err == nil &&
n.Issue.PullRequest != nil &&
n.Issue.PullRequest.HasMerged {
result.Subject.State = "merged"
result.Subject.State = api.NotifySubjectStateMerged
}
}
case activities_model.NotificationSourceCommit:
@@ -7,12 +7,15 @@ import (
"testing"
activities_model "code.gitea.io/gitea/models/activities"
issues_model "code.gitea.io/gitea/models/issues"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/timeutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestToNotificationThreadIncludesRepoForAccessibleUser(t *testing.T) {
@@ -36,6 +39,78 @@ func TestToNotificationThreadOmitsRepoWhenAccessRevoked(t *testing.T) {
assert.Nil(t, thread.Repository)
}
func TestToNotificationThread(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
t.Run("issue notification", func(t *testing.T) {
// Notification 1: source=issue, issue_id=1, status=unread
n := unittest.AssertExistsAndLoadBean(t, &activities_model.Notification{ID: 1})
require.NoError(t, n.LoadAttributes(t.Context()))
thread := ToNotificationThread(t.Context(), n)
assert.Equal(t, int64(1), thread.ID)
assert.True(t, thread.Unread)
assert.False(t, thread.Pinned)
require.NotNil(t, thread.Subject)
assert.Equal(t, api.NotifySubjectIssue, thread.Subject.Type)
assert.Equal(t, api.NotifySubjectStateOpen, thread.Subject.State)
})
t.Run("pinned notification", func(t *testing.T) {
// Notification 3: status=pinned
n := unittest.AssertExistsAndLoadBean(t, &activities_model.Notification{ID: 3})
require.NoError(t, n.LoadAttributes(t.Context()))
thread := ToNotificationThread(t.Context(), n)
assert.False(t, thread.Unread)
assert.True(t, thread.Pinned)
})
t.Run("merged pull request returns merged state", func(t *testing.T) {
// Issue 2 is a pull request; pull_request 1 has has_merged=true.
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2})
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID})
n := &activities_model.Notification{
ID: 999,
UserID: 2,
RepoID: repo.ID,
Status: activities_model.NotificationStatusUnread,
Source: activities_model.NotificationSourcePullRequest,
IssueID: issue.ID,
Issue: issue,
Repository: repo,
}
thread := ToNotificationThread(t.Context(), n)
require.NotNil(t, thread.Subject)
assert.Equal(t, api.NotifySubjectPull, thread.Subject.Type)
assert.Equal(t, api.NotifySubjectStateMerged, thread.Subject.State)
})
t.Run("open pull request returns open state", func(t *testing.T) {
// Issue 3 is a pull request; pull_request 2 has has_merged=false.
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 3})
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID})
n := &activities_model.Notification{
ID: 998,
UserID: 2,
RepoID: repo.ID,
Status: activities_model.NotificationStatusUnread,
Source: activities_model.NotificationSourcePullRequest,
IssueID: issue.ID,
Issue: issue,
Repository: repo,
}
thread := ToNotificationThread(t.Context(), n)
require.NotNil(t, thread.Subject)
assert.Equal(t, api.NotifySubjectPull, thread.Subject.Type)
assert.Equal(t, api.NotifySubjectStateOpen, thread.Subject.State)
})
}
func newRepoNotification(t *testing.T, repoID, userID int64) *activities_model.Notification {
t.Helper()
@@ -16,7 +16,7 @@ import (
func ToPackage(ctx context.Context, pd *packages.PackageDescriptor, doer *user_model.User) (*api.Package, error) {
var repo *api.Repository
if pd.Repository != nil {
permission, err := access_model.GetUserRepoPermission(ctx, pd.Repository, doer)
permission, err := access_model.GetDoerRepoPermission(ctx, pd.Repository, doer)
if err != nil {
return nil, err
}
+14 -12
View File
@@ -63,11 +63,11 @@ func ToAPIPullRequest(ctx context.Context, pr *issues_model.PullRequest, doer *u
repoUserPerm, err := cache.GetWithContextCache(ctx, cachegroup.RepoUserPermission, fmt.Sprintf("%d-%d", pr.BaseRepoID, doerID),
func(ctx context.Context, _ string) (access_model.Permission, error) {
return access_model.GetUserRepoPermission(ctx, pr.BaseRepo, doer)
return access_model.GetDoerRepoPermission(ctx, pr.BaseRepo, doer)
},
)
if err != nil {
log.Error("GetUserRepoPermission[%d]: %v", pr.BaseRepoID, err)
log.Error("GetDoerRepoPermission[%d]: %v", pr.BaseRepoID, err)
repoUserPerm.AccessMode = perm.AccessModeNone
}
@@ -97,6 +97,7 @@ func ToAPIPullRequest(ctx context.Context, pr *issues_model.PullRequest, doer *u
Created: pr.Issue.CreatedUnix.AsTimePtr(),
Updated: pr.Issue.UpdatedUnix.AsTimePtr(),
PinOrder: util.Iif(apiIssue.PinOrder == -1, 0, apiIssue.PinOrder),
ContentVersion: apiIssue.ContentVersion,
// output "[]" rather than null to align to github outputs
RequestedReviewers: []*api.User{},
@@ -146,7 +147,7 @@ func ToAPIPullRequest(ctx context.Context, pr *issues_model.PullRequest, doer *u
gitRepo, err := gitrepo.OpenRepository(ctx, pr.BaseRepo)
if err != nil {
log.Error("OpenRepository[%s]: %v", pr.BaseRepo.RepoPath(), err)
log.Error("OpenRepository[%s]: %v", pr.BaseRepo.RelativePath(), err)
return nil
}
defer gitRepo.Close()
@@ -181,9 +182,9 @@ func ToAPIPullRequest(ctx context.Context, pr *issues_model.PullRequest, doer *u
}
if pr.HeadRepo != nil && pr.Flow == issues_model.PullRequestFlowGithub {
p, err := access_model.GetUserRepoPermission(ctx, pr.HeadRepo, doer)
p, err := access_model.GetDoerRepoPermission(ctx, pr.HeadRepo, doer)
if err != nil {
log.Error("GetUserRepoPermission[%d]: %v", pr.HeadRepoID, err)
log.Error("GetDoerRepoPermission[%d]: %v", pr.HeadRepoID, err)
p.AccessMode = perm.AccessModeNone
}
@@ -192,7 +193,7 @@ func ToAPIPullRequest(ctx context.Context, pr *issues_model.PullRequest, doer *u
headGitRepo, err := gitrepo.OpenRepository(ctx, pr.HeadRepo)
if err != nil {
log.Error("OpenRepository[%s]: %v", pr.HeadRepo.RepoPath(), err)
log.Error("OpenRepository[%s]: %v", pr.HeadRepo.RelativePath(), err)
return nil
}
defer headGitRepo.Close()
@@ -235,7 +236,7 @@ func ToAPIPullRequest(ctx context.Context, pr *issues_model.PullRequest, doer *u
// Calculate diff
startCommitID = pr.MergeBase
diffShortStats, err := gitdiff.GetDiffShortStat(gitRepo, startCommitID, endCommitID)
diffShortStats, err := gitdiff.GetDiffShortStat(ctx, pr.BaseRepo, gitRepo, startCommitID, endCommitID)
if err != nil {
log.Error("GetDiffShortStat: %v", err)
} else {
@@ -248,7 +249,7 @@ func ToAPIPullRequest(ctx context.Context, pr *issues_model.PullRequest, doer *u
if len(apiPullRequest.Head.Sha) == 0 && len(apiPullRequest.Head.Ref) != 0 {
baseGitRepo, err := gitrepo.OpenRepository(ctx, pr.BaseRepo)
if err != nil {
log.Error("OpenRepository[%s]: %v", pr.BaseRepo.RepoPath(), err)
log.Error("OpenRepository[%s]: %v", pr.BaseRepo.RelativePath(), err)
return nil
}
defer baseGitRepo.Close()
@@ -334,9 +335,9 @@ func ToAPIPullRequests(ctx context.Context, baseRepo *repo_model.Repository, prs
}
defer gitRepo.Close()
baseRepoPerm, err := access_model.GetUserRepoPermission(ctx, baseRepo, doer)
baseRepoPerm, err := access_model.GetDoerRepoPermission(ctx, baseRepo, doer)
if err != nil {
log.Error("GetUserRepoPermission[%d]: %v", baseRepo.ID, err)
log.Error("GetDoerRepoPermission[%d]: %v", baseRepo.ID, err)
baseRepoPerm.AccessMode = perm.AccessModeNone
}
@@ -372,6 +373,7 @@ func ToAPIPullRequests(ctx context.Context, baseRepo *repo_model.Repository, prs
Created: pr.Issue.CreatedUnix.AsTimePtr(),
Updated: pr.Issue.UpdatedUnix.AsTimePtr(),
PinOrder: util.Iif(apiIssue.PinOrder == -1, 0, apiIssue.PinOrder),
ContentVersion: apiIssue.ContentVersion,
AllowMaintainerEdit: pr.AllowMaintainerEdit,
@@ -435,9 +437,9 @@ func ToAPIPullRequests(ctx context.Context, baseRepo *repo_model.Repository, prs
apiPullRequest.Head.Ref = pr.HeadBranch
}
if pr.HeadRepoID != pr.BaseRepoID {
p, err := access_model.GetUserRepoPermission(ctx, pr.HeadRepo, doer)
p, err := access_model.GetDoerRepoPermission(ctx, pr.HeadRepo, doer)
if err != nil {
log.Error("GetUserRepoPermission[%d]: %v", pr.HeadRepoID, err)
log.Error("GetDoerRepoPermission[%d]: %v", pr.HeadRepoID, err)
p.AccessMode = perm.AccessModeNone
}
apiPullRequest.Head.Repository = ToRepo(ctx, pr.HeadRepo, p)
@@ -92,34 +92,40 @@ func ToPullReviewCommentList(ctx context.Context, review *issues_model.Review, d
for _, lines := range review.CodeComments {
for _, comments := range lines {
for _, comment := range comments {
apiComment := &api.PullReviewComment{
ID: comment.ID,
Body: comment.Content,
Poster: ToUser(ctx, comment.Poster, doer),
Resolver: ToUser(ctx, comment.ResolveDoer, doer),
ReviewID: review.ID,
Created: comment.CreatedUnix.AsTime(),
Updated: comment.UpdatedUnix.AsTime(),
Path: comment.TreePath,
CommitID: comment.CommitSHA,
OrigCommitID: comment.OldRef,
DiffHunk: patch2diff(comment.Patch),
HTMLURL: comment.HTMLURL(ctx),
HTMLPullURL: review.Issue.HTMLURL(ctx),
}
if comment.Line < 0 {
apiComment.OldLineNum = comment.UnsignedLine()
} else {
apiComment.LineNum = comment.UnsignedLine()
}
apiComments = append(apiComments, apiComment)
apiComments = append(apiComments, ToPullReviewComment(ctx, comment, doer))
}
}
}
return apiComments, nil
}
// ToPullReviewComment convert a single code review comment to api format
func ToPullReviewComment(ctx context.Context, comment *issues_model.Comment, doer *user_model.User) *api.PullReviewComment {
apiComment := &api.PullReviewComment{
ID: comment.ID,
Body: comment.Content,
Poster: ToUser(ctx, comment.Poster, doer),
Resolver: ToUser(ctx, comment.ResolveDoer, doer),
ReviewID: comment.ReviewID,
Created: comment.CreatedUnix.AsTime(),
Updated: comment.UpdatedUnix.AsTime(),
Path: comment.TreePath,
CommitID: comment.CommitSHA,
OrigCommitID: comment.OldRef,
DiffHunk: patch2diff(comment.Patch),
HTMLURL: comment.HTMLURL(ctx),
HTMLPullURL: comment.Issue.HTMLURL(ctx),
}
if comment.Line < 0 {
apiComment.OldLineNum = comment.UnsignedLine()
} else {
apiComment.LineNum = comment.UnsignedLine()
}
return apiComment
}
func patch2diff(patch string) string {
split := strings.Split(patch, "\n@@")
if len(split) == 2 {
@@ -8,6 +8,7 @@ import (
"time"
"code.gitea.io/gitea/models/db"
git_model "code.gitea.io/gitea/models/git"
"code.gitea.io/gitea/models/perm"
access_model "code.gitea.io/gitea/models/perm/access"
repo_model "code.gitea.io/gitea/models/repo"
@@ -34,7 +35,7 @@ func innerToRepo(ctx context.Context, repo *repo_model.Repository, permissionInR
permissionInRepo.SetUnitsWithDefaultAccessMode(repo.Units, permissionInRepo.AccessMode)
}
// TODO: ideally we should pass "doer" into "ToRepo" to to make CloneLink could generate user-related links
// TODO: ideally we should pass "doer" into "ToRepo" to make CloneLink could generate user-related links
// And passing "doer" in will also fix other FIXMEs in this file.
cloneLink := repo.CloneLinkGeneral(ctx) // no doer at the moment
permission := &api.Permission{
@@ -103,6 +104,7 @@ func innerToRepo(ctx context.Context, repo *repo_model.Repository, permissionInR
defaultDeleteBranchAfterMerge := false
defaultMergeStyle := repo_model.MergeStyleMerge
defaultAllowMaintainerEdit := false
defaultTargetBranch := ""
if unit, err := repo.GetUnit(ctx, unit_model.TypePullRequests); err == nil {
config := unit.PullRequestsConfig()
hasPullRequests = true
@@ -116,8 +118,9 @@ func innerToRepo(ctx context.Context, repo *repo_model.Repository, permissionInR
allowManualMerge = config.AllowManualMerge
autodetectManualMerge = config.AutodetectManualMerge
defaultDeleteBranchAfterMerge = config.DefaultDeleteBranchAfterMerge
defaultMergeStyle = config.GetDefaultMergeStyle()
defaultMergeStyle = config.DefaultMergeStyle
defaultAllowMaintainerEdit = config.DefaultAllowMaintainerEdit
defaultTargetBranch = config.DefaultTargetBranch
}
hasProjects := false
projectsMode := repo_model.ProjectsModeAll
@@ -142,6 +145,11 @@ func innerToRepo(ctx context.Context, repo *repo_model.Repository, permissionInR
RepoID: repo.ID,
})
branchCount, err := git_model.CountBranches(ctx, repo.ID, false)
if err != nil {
log.Error("CountBranches [%d]: %v", repo.ID, err)
}
mirrorInterval := ""
var mirrorUpdated time.Time
if repo.IsMirror {
@@ -203,6 +211,7 @@ func innerToRepo(ctx context.Context, repo *repo_model.Repository, permissionInR
Stars: repo.NumStars,
Forks: repo.NumForks,
Watchers: repo.NumWatches,
BranchCount: int(branchCount),
OpenIssues: repo.NumOpenIssues,
OpenPulls: repo.NumOpenPulls,
Releases: int(numReleases),
@@ -235,6 +244,7 @@ func innerToRepo(ctx context.Context, repo *repo_model.Repository, permissionInR
DefaultDeleteBranchAfterMerge: defaultDeleteBranchAfterMerge,
DefaultMergeStyle: string(defaultMergeStyle),
DefaultAllowMaintainerEdit: defaultAllowMaintainerEdit,
DefaultTargetBranch: defaultTargetBranch,
AvatarURL: repo.AvatarLink(ctx),
Internal: !repo.IsPrivate && repo.Owner.Visibility == api.VisibleTypePrivate,
MirrorInterval: mirrorInterval,
@@ -9,6 +9,7 @@ import (
git_model "code.gitea.io/gitea/models/git"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/commitstatus"
api "code.gitea.io/gitea/modules/structs"
)
@@ -55,6 +56,8 @@ func ToCombinedStatus(ctx context.Context, commitID string, statuses []*git_mode
if combinedStatus != nil {
status.Statuses = ToCommitStatuses(ctx, statuses)
status.State = combinedStatus.State
} else {
status.State = commitstatus.CommitStatusPending
}
return &status
}
@@ -28,7 +28,7 @@ func ToWikiCommit(commit *git.Commit) *api.WikiCommit {
},
Date: commit.Committer.When.UTC().Format(time.RFC3339),
},
Message: commit.CommitMessage,
Message: commit.Message(),
}
}
+20 -13
View File
@@ -10,22 +10,27 @@ import (
"time"
"code.gitea.io/gitea/modules/graceful"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/process"
"code.gitea.io/gitea/modules/sync"
"code.gitea.io/gitea/modules/translation"
"github.com/go-co-op/gocron"
"github.com/go-co-op/gocron/v2"
)
var scheduler = gocron.NewScheduler(time.Local)
var scheduler gocron.Scheduler
// Prevent duplicate running tasks.
var taskStatusTable = sync.NewStatusTable()
func init() {
var err error
scheduler, err = gocron.NewScheduler(gocron.WithLocation(time.Local))
if err != nil {
log.Fatal("Unable to create cron scheduler: %v", err)
}
}
// NewContext begins cron tasks
// Init begins cron tasks
// Each cron task is run within the shutdown context as a running server
// AtShutdown the cron server is stopped
func NewContext(original context.Context) {
func Init(original context.Context) {
defer pprof.SetGoroutineLabels(original)
_, _, finished := process.GetManager().AddTypedContext(graceful.GetManager().ShutdownContext(), "Service: Cron", process.SystemProcessType, true)
initBasicTasks()
@@ -39,11 +44,13 @@ func NewContext(original context.Context) {
}
}
scheduler.StartAsync()
scheduler.Start()
started = true
lock.Unlock()
graceful.GetManager().RunAtShutdown(context.Background(), func() {
scheduler.Stop()
if err := scheduler.Shutdown(); err != nil {
log.Error("Unable to shutdown cron scheduler: %v", err)
}
lock.Lock()
started = false
lock.Unlock()
@@ -78,14 +85,14 @@ type TaskTable []*TaskTableRow
// ListTasks returns all running cron tasks.
func ListTasks() TaskTable {
jobs := scheduler.Jobs()
jobMap := map[string]*gocron.Job{}
jobMap := map[string]gocron.Job{}
for _, job := range jobs {
// the first tag is the task name
tags := job.Tags()
if len(tags) == 0 { // should never happen
continue
}
jobMap[job.Tags()[0]] = job
jobMap[tags[0]] = job
}
lock.Lock()
@@ -103,8 +110,8 @@ func ListTasks() TaskTable {
if len(tags) > 1 {
spec = tags[1] // the second tag is the task spec
}
next = e.NextRun()
prev = e.PreviousRun()
next, _ = e.NextRun()
prev, _ = e.LastRun()
}
task.lock.Lock()
+24 -10
View File
@@ -14,11 +14,14 @@ import (
"code.gitea.io/gitea/models/db"
system_model "code.gitea.io/gitea/models/system"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/globallock"
"code.gitea.io/gitea/modules/graceful"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/process"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/translation"
"github.com/go-co-op/gocron/v2"
)
var (
@@ -71,20 +74,30 @@ func (t *Task) Run() {
}, t.config)
}
func getCronTaskLockKey(name string) string {
return "cron_task:" + name
}
// RunWithUser will run the task incrementing the cron counter at the time with User
func (t *Task) RunWithUser(doer *user_model.User, config Config) {
if !taskStatusTable.StartIfNotRunning(t.Name) {
locked, releaser, err := globallock.TryLock(graceful.GetManager().ShutdownContext(), getCronTaskLockKey(t.Name))
if err != nil {
log.Error("Failed to acquire lock for cron task %q: %v", t.Name, err)
return
}
if !locked {
log.Trace("a cron task %q is already running", t.Name)
return
}
defer releaser()
t.lock.Lock()
if config == nil {
config = t.config
}
t.ExecTimes++
t.lock.Unlock()
defer func() {
taskStatusTable.Stop(t.Name)
}()
graceful.GetManager().RunWithShutdownContext(func(baseCtx context.Context) {
defer func() {
if err := recover(); err != nil {
@@ -213,12 +226,13 @@ func RegisterTaskFatal(name string, config Config, fun func(context.Context, *us
func addTaskToScheduler(task *Task) error {
tags := []string{task.Name, task.config.GetSchedule()} // name and schedule can't be get from job, so we add them as tag
if scheduleHasSeconds(task.config.GetSchedule()) {
scheduler = scheduler.CronWithSeconds(task.config.GetSchedule())
} else {
scheduler = scheduler.Cron(task.config.GetSchedule())
}
if _, err := scheduler.Tag(tags...).Do(task.Run); err != nil {
withSeconds := scheduleHasSeconds(task.config.GetSchedule())
_, err := scheduler.NewJob(
gocron.CronJob(task.config.GetSchedule(), withSeconds),
gocron.NewTask(task.Run),
gocron.WithTags(tags...),
)
if err != nil {
log.Error("Unable to register cron task with name: %s Error: %v", task.Name, err)
return err
}
@@ -54,7 +54,7 @@ func registerRepoHealthCheck() {
RunAtStart: false,
Schedule: "@midnight",
},
Timeout: time.Duration(setting.Git.Timeout.Default) * time.Second,
Timeout: time.Duration(setting.Git.Timeout.GC) * time.Second,
Args: []string{},
}, func(ctx context.Context, _ *user_model.User, config Config) error {
rhcConfig := config.(*RepoHealthCheckConfig)
@@ -13,7 +13,11 @@ import (
func TestAddTaskToScheduler(t *testing.T) {
assert.Empty(t, scheduler.Jobs())
defer scheduler.Clear()
defer func() {
for _, j := range scheduler.Jobs() {
_ = scheduler.RemoveJob(j.ID())
}
}()
// no seconds
err := addTaskToScheduler(&Task{
@@ -105,6 +105,8 @@ func fixUnfinishedRunStatus(ctx context.Context, logger log.Logger, autofix bool
}
fixed++
actions_model.UpdateRepoRunsNumbers(ctx, run.RepoID)
return nil
},
)
@@ -19,6 +19,6 @@ func Test_fixUnfinishedRunStatus(t *testing.T) {
fixUnfinishedRunStatus(t.Context(), log.GetLogger(log.DEFAULT), true)
// check if the run is cancelled by id
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 796})
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 805})
assert.Equal(t, actions_model.StatusCancelled, run.Status)
}
@@ -5,12 +5,10 @@ package doctor
import (
"context"
"os"
"path/filepath"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/util"
)
func checkOldArchives(ctx context.Context, logger log.Logger, autofix bool) error {
@@ -21,18 +19,18 @@ func checkOldArchives(ctx context.Context, logger log.Logger, autofix bool) erro
return nil
}
p := filepath.Join(repo.RepoPath(), "archives")
isDir, err := util.IsDir(p)
isDir, err := gitrepo.IsRepoDirExist(ctx, repo, "archives")
if err != nil {
log.Warn("check if %s is directory failed: %v", p, err)
log.Warn("check if %s is directory failed: %v", repo.FullName(), err)
}
if isDir {
numRepos++
if autofix {
if err := os.RemoveAll(p); err == nil {
err := gitrepo.RemoveRepoFileOrDir(ctx, repo, "archives")
if err == nil {
numReposUpdated++
} else {
log.Warn("remove %s failed: %v", p, err)
log.Warn("remove %s failed: %v", repo.FullName(), err)
}
}
}
@@ -296,7 +296,7 @@ func fixBrokenRepoUnits16961(ctx context.Context, logger log.Logger, autofix boo
return nil
}
return repo_model.UpdateRepoUnit(ctx, repoUnit)
return repo_model.UpdateRepoUnitConfig(ctx, repoUnit)
},
)
if err != nil {
@@ -9,6 +9,7 @@ import (
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/git/gitcmd"
"code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/log"
)
@@ -19,9 +20,11 @@ func synchronizeRepoHeads(ctx context.Context, logger log.Logger, autofix bool)
numReposUpdated := 0
err := iterateRepositories(ctx, func(repo *repo_model.Repository) error {
numRepos++
_, _, defaultBranchErr := gitcmd.NewCommand("rev-parse").AddDashesAndList(repo.DefaultBranch).RunStdString(ctx, &gitcmd.RunOpts{Dir: repo.RepoPath()})
_, _, defaultBranchErr := gitrepo.RunCmdString(ctx, repo,
gitcmd.NewCommand("rev-parse").AddDashesAndList(repo.DefaultBranch))
head, _, headErr := gitcmd.NewCommand("symbolic-ref", "--short", "HEAD").RunStdString(ctx, &gitcmd.RunOpts{Dir: repo.RepoPath()})
head, _, headErr := gitrepo.RunCmdString(ctx, repo,
gitcmd.NewCommand("symbolic-ref", "--short", "HEAD"))
// what we expect: default branch is valid, and HEAD points to it
if headErr == nil && defaultBranchErr == nil && head == repo.DefaultBranch {
@@ -47,7 +50,7 @@ func synchronizeRepoHeads(ctx context.Context, logger log.Logger, autofix bool)
}
// otherwise, let's try fixing HEAD
err := gitcmd.NewCommand("symbolic-ref").AddDashesAndList("HEAD", git.BranchPrefix+repo.DefaultBranch).Run(ctx, &gitcmd.RunOpts{Dir: repo.RepoPath()})
err := gitrepo.RunCmd(ctx, repo, gitcmd.NewCommand("symbolic-ref").AddDashesAndList("HEAD", git.BranchPrefix+repo.DefaultBranch))
if err != nil {
logger.Warn("Failed to fix HEAD for %s/%s: %v", repo.OwnerName, repo.Name, err)
return nil

Some files were not shown because too many files have changed in this diff Show More