mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-30 01:29:40 +09:00
Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d88bbfd0db | ||
|
|
5e494f9cad | ||
|
|
9731ad7c3c | ||
|
|
148d528814 | ||
|
|
1af5277aba | ||
|
|
6d41731184 | ||
|
|
cfc9f4c685 | ||
|
|
895d848ff4 | ||
|
|
7199547218 | ||
|
|
7cb4201f8e | ||
|
|
5f017302bf | ||
|
|
59c619660c | ||
|
|
8599289459 | ||
|
|
da9f0a3726 | ||
|
|
08fd59959e | ||
|
|
d60215c2a2 | ||
|
|
bf594690db | ||
|
|
5cb7ec9304 | ||
|
|
6e86c4cde8 | ||
|
|
c32af046a2 | ||
|
|
a98468da30 |
@@ -57,9 +57,14 @@ func NewInterpeter(
|
||||
}
|
||||
|
||||
ee := &exprparser.EvaluationEnvironment{
|
||||
Github: gitCtx,
|
||||
Env: nil, // no need
|
||||
Job: nil, // no need
|
||||
Github: gitCtx,
|
||||
Env: nil, // no need
|
||||
// Job must be non-nil because cancelled() dereferences Job.Status unconditionally.
|
||||
// See: https://gitea.com/gitea/runner/src/commit/ad967330a8788c9b8ab723abbc1a86d53c3bc5e6/act/exprparser/functions.go#L299
|
||||
// TODO: The empty JobContext.Status is right for now because Gitea never checks `if` condition when the workflow run is cancelled.
|
||||
// This is an implementation gap in Gitea Actions. When a workflow run is cancelled, Gitea should check the job's `if` condition,
|
||||
// and if the condition is met (e.g. `if: ${{ cancelled() }}` ), the job should be executed rather than cancelled.
|
||||
Job: &model.JobContext{},
|
||||
Steps: nil, // no need
|
||||
Runner: nil, // no need
|
||||
Secrets: nil, // no need
|
||||
|
||||
@@ -490,7 +490,19 @@ func EvaluateJobIfExpression(jobID string, job *Job, gitCtx map[string]any, resu
|
||||
RawMatrix: job.Strategy.RawMatrix,
|
||||
},
|
||||
}
|
||||
evaluator := NewExpressionEvaluator(NewInterpeter(jobID, actJob, nil, toGitContext(gitCtx), results, vars, inputs))
|
||||
// Each per-matrix job carries its single matrix combination in RawMatrix so resolve it and pass it in;
|
||||
// otherwise `matrix.*` references in `if:` evaluate to null.
|
||||
// GetMatrixes always returns at least one element (an empty map for a job without a matrix),
|
||||
// so only a non-empty combination should populate `matrix.*`, leaving it nil otherwise.
|
||||
var matrix map[string]any
|
||||
matrixes, err := actJob.GetMatrixes()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if len(matrixes) > 0 && len(matrixes[0]) > 0 {
|
||||
matrix = matrixes[0]
|
||||
}
|
||||
evaluator := NewExpressionEvaluator(NewInterpeter(jobID, actJob, matrix, toGitContext(gitCtx), results, vars, inputs))
|
||||
expr, err := rewriteSubExpression(job.If.Value, false)
|
||||
if err != nil {
|
||||
return false, err
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package jobparser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -464,3 +465,106 @@ func TestParseMappingNode(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateJobIfExpressionMatrix(t *testing.T) {
|
||||
ifExprs := []string{
|
||||
`${{ contains(fromJSON('["linux","windows"]'), matrix.target) }}`,
|
||||
`${{ contains('["linux","windows"]', matrix.target) }}`,
|
||||
}
|
||||
|
||||
want := map[string]bool{
|
||||
"build (linux)": true,
|
||||
"build (windows)": true,
|
||||
"build (macos)": false,
|
||||
}
|
||||
|
||||
for _, ifExpr := range ifExprs {
|
||||
t.Run(ifExpr, func(t *testing.T) {
|
||||
content := fmt.Sprintf(`
|
||||
name: test
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
if: %s
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
target: [linux, windows, macos]
|
||||
steps:
|
||||
- run: echo ${{ matrix.target }}
|
||||
`, ifExpr)
|
||||
|
||||
swfs, err := Parse([]byte(content))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, swfs, 3)
|
||||
|
||||
got := make(map[string]bool, len(swfs))
|
||||
for _, swf := range swfs {
|
||||
id, job := swf.Job()
|
||||
shouldRun, err := EvaluateJobIfExpression(id, job, map[string]any{}, map[string]*JobResult{id: {}}, nil, nil)
|
||||
require.NoError(t, err)
|
||||
got[job.Name] = shouldRun
|
||||
}
|
||||
assert.Equal(t, want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateJobIfExpression(t *testing.T) {
|
||||
kases := []struct {
|
||||
name string
|
||||
ifCond string
|
||||
needResult string
|
||||
expected bool
|
||||
}{
|
||||
{name: "empty need success", ifCond: "${{ 1 == 1 }}", needResult: "success", expected: true},
|
||||
{name: "always", ifCond: "${{ always() }}", needResult: "failure", expected: true},
|
||||
{name: "failure true", ifCond: "${{ failure() }}", needResult: "failure", expected: true},
|
||||
{name: "failure false", ifCond: "${{ failure() }}", needResult: "success", expected: false},
|
||||
{name: "success true", ifCond: "${{ success() }}", needResult: "success", expected: true},
|
||||
// cancelled() is always false on the server: a cancelled run never evaluates a blocked job's `if:`
|
||||
{name: "cancelled", ifCond: "${{ cancelled() }}", needResult: "success", expected: false},
|
||||
{name: "not cancelled or failure", ifCond: "${{ !(cancelled() || failure()) }}", needResult: "success", expected: true},
|
||||
{name: "not cancelled or failure, need failed", ifCond: "${{ !(cancelled() || failure()) }}", needResult: "failure", expected: false},
|
||||
}
|
||||
for _, kase := range kases {
|
||||
t.Run(kase.name, func(t *testing.T) {
|
||||
content := strings.ReplaceAll(`
|
||||
name: test
|
||||
on: push
|
||||
jobs:
|
||||
job1:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo job1
|
||||
job2:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [job1]
|
||||
if: IF_COND
|
||||
steps:
|
||||
- run: echo job2
|
||||
`, "IF_COND", kase.ifCond)
|
||||
|
||||
workflows, err := Parse([]byte(content))
|
||||
require.NoError(t, err)
|
||||
|
||||
var job2 *Job
|
||||
for _, wf := range workflows {
|
||||
if id, job := wf.Job(); id == "job2" {
|
||||
job2 = job
|
||||
}
|
||||
}
|
||||
require.NotNil(t, job2)
|
||||
|
||||
// mirrors findJobNeedsAndFillJobResults: the needs' results plus a self entry carrying Needs
|
||||
results := map[string]*JobResult{
|
||||
"job1": {Result: kase.needResult},
|
||||
"job2": {Needs: []string{"job1"}},
|
||||
}
|
||||
got, err := EvaluateJobIfExpression("job2", job2, map[string]any{}, results, nil, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, kase.expected, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,8 +77,12 @@ func (c *CommitMessage) MessageTrailer() CommitMessageTrailerValues {
|
||||
}
|
||||
|
||||
var commitMessageTrailerSplit = sync.OnceValue(func() *regexp.Regexp {
|
||||
// the sep is either something like "\n---\n" or "\n\n" in the body, or at the start of the body like "---\n"
|
||||
return regexp.MustCompile(`(?s)^(?P<content>.*?)(?P<sep>^|^\n|^-{3,}\n+|\n-{3,}\n+|\n\n)(?P<trailer>(?:[A-Za-z0-9][-A-Za-z0-9]*:[^\n]*\n?)*\n*)$`)
|
||||
// ref: https://git-scm.com/docs/git-interpret-trailers
|
||||
// TODO: the regexp is not able to perfectly parse the all kinds of trailers
|
||||
// It was just copied from legacy code, it is not exactly the same as how Git parses the trailer and not quite right in some cases.
|
||||
// For the key characters: it follows RFC 822 field name syntax (or RFC 2822/RFC 5322): printable ASCII characters between 33 and 126 except the colon (:),
|
||||
// but maybe we don't want to make it that complicated, so here we only support some common "symbol-like" characters.
|
||||
return regexp.MustCompile(`(?s)^(?P<content>.*?)(?P<sep>^|^\n|^-{3,}\n+|\n+-{3,}\n+|\n{2,})(?P<trailer>(?:[A-Za-z0-9][-\w]*:[^\n]*(\n\s+[^\n]*)*\n?)*\n*)$`)
|
||||
})
|
||||
|
||||
// CommitMessageSplitTrailer tries to split the message by the trailer separator
|
||||
@@ -93,6 +97,41 @@ func CommitMessageSplitTrailer(s string) (content, sep, trailer string) {
|
||||
return v[re.SubexpIndex("content")], v[re.SubexpIndex("sep")], v[re.SubexpIndex("trailer")]
|
||||
}
|
||||
|
||||
// CommitMessageMerge merges two commit messages with their trailers
|
||||
func CommitMessageMerge(m1, m2 string) string {
|
||||
c1, s1, t1 := CommitMessageSplitTrailer(m1)
|
||||
c2, s2, t2 := CommitMessageSplitTrailer(m2)
|
||||
c1, t1 = strings.TrimSpace(c1), strings.TrimSpace(t1)
|
||||
c2, t2 = strings.TrimSpace(c2), strings.TrimSpace(t2)
|
||||
out := strings.Builder{}
|
||||
if c1 != "" && c2 != "" {
|
||||
out.WriteString(c1)
|
||||
out.WriteString("\n\n")
|
||||
out.WriteString(c2)
|
||||
} else if c1 != "" {
|
||||
out.WriteString(c1)
|
||||
} else if c2 != "" {
|
||||
out.WriteString(c2)
|
||||
}
|
||||
if t1 != "" || t2 != "" {
|
||||
sep := util.Iif(t1 == "", s2, s1)
|
||||
sep = util.IfZero(sep, "\n\n")
|
||||
if c1 != "" || c2 != "" {
|
||||
out.WriteString(sep)
|
||||
}
|
||||
if t1 != "" {
|
||||
out.WriteString(t1)
|
||||
}
|
||||
if t1 != "" && t2 != "" {
|
||||
out.WriteString("\n")
|
||||
}
|
||||
if t2 != "" {
|
||||
out.WriteString(t2)
|
||||
}
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func CommitMessageParseTrailer(s string) CommitMessageTrailerValues {
|
||||
ret := CommitMessageTrailerValues{}
|
||||
for line := range strings.SplitSeq(util.NormalizeStringEOL(s), "\n") {
|
||||
|
||||
@@ -26,10 +26,12 @@ func TestCommitMessageTrailer(t *testing.T) {
|
||||
{"a", "a", "", ""},
|
||||
{"a\n\nk", "a\n\nk", "", ""},
|
||||
{"a\n\nk:v", "a", "\n\n", "k:v"},
|
||||
{"a\n\nk:v\n next-line", "a", "\n\n", "k:v\n next-line"},
|
||||
{"a\n\nk:v\n next-line\nother: v", "a", "\n\n", "k:v\n next-line\nother: v"},
|
||||
{"a\n\nk:v\n\n", "a", "\n\n", "k:v\n\n"},
|
||||
{"a\n--\nk:v", "a\n--\nk:v", "", ""},
|
||||
{"a\n---\nk:v", "a", "\n---\n", "k:v"},
|
||||
{"a\n\n---\n\nk:v", "a\n", "\n---\n\n", "k:v"},
|
||||
{"a\n---\nk:v", "a", "\n---\n", "k:v"}, // TODO: should we support such case? No empty line between "---" and the trailer
|
||||
{"a\n\n---\n\nk:v", "a", "\n\n---\n\n", "k:v"},
|
||||
|
||||
{"k: v", "", "", "k: v"},
|
||||
{"\nk:v", "", "\n", "k:v"},
|
||||
@@ -127,3 +129,31 @@ func TestCommitMessageParticipants(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCommitMessageMerge(t *testing.T) {
|
||||
cases := []struct {
|
||||
m1, m2 string
|
||||
out string
|
||||
}{
|
||||
{"", "", ""},
|
||||
{"msg1", "", "msg1"},
|
||||
{"", "msg2", "msg2"},
|
||||
{"msg1", "msg2", "msg1\n\nmsg2"},
|
||||
{"k1: a", "", "k1: a"},
|
||||
{"", "k2: b", "k2: b"},
|
||||
{"k1: a", "k2: b", "k1: a\nk2: b"},
|
||||
{"msg1", "k2: b", "msg1\n\nk2: b"},
|
||||
{"k1: a", "msg2", "msg2\n\nk1: a"},
|
||||
{"msg1\n\nk1: a", "msg2", "msg1\n\nmsg2\n\nk1: a"},
|
||||
{"msg1\n----\nk1: a", "msg2", "msg1\n\nmsg2\n----\nk1: a"},
|
||||
{"msg1\n\n----\n\nk1: a", "msg2", "msg1\n\nmsg2\n\n----\n\nk1: a"},
|
||||
{"msg1", "msg2\n----\nk2: b", "msg1\n\nmsg2\n----\nk2: b"},
|
||||
{"msg1", "msg2\n\nk2: b", "msg1\n\nmsg2\n\nk2: b"},
|
||||
{"msg1\n\nk1: a", "msg2\n\nk2: b", "msg1\n\nmsg2\n\nk1: a\nk2: b"},
|
||||
}
|
||||
|
||||
for i, c := range cases {
|
||||
out := CommitMessageMerge(c.m1, c.m2)
|
||||
assert.Equal(t, c.out, out, "idx=%d, m1=%q m2=%q", i, c.m1, c.m2)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import "gitea.dev/modules/git/gitcmd"
|
||||
|
||||
func HandleGitCmdHTTPRedirection(cmd *gitcmd.Command, targets ...string) {
|
||||
// Protect from SSRF vector (e.g. migrating from an attacker URL).
|
||||
// cmd.AddConfig("http.followRedirects", "false")
|
||||
// However, we can't do so at the moment:
|
||||
// this fails due to 301: git -c http.followRedirects=false clone -v https://gitlab.com/{owner}/{repo}
|
||||
// this succeeds: git -c http.followRedirects=false clone -v https://gitlab.com/{owner}/{repo}.git
|
||||
// FIXME: GIT-CLONE-HTTP-REDIRECT-SSRF: need a complete solution in the future
|
||||
}
|
||||
+1
-3
@@ -121,9 +121,7 @@ func Clone(ctx context.Context, from, to string, opts CloneRepoOptions) error {
|
||||
}
|
||||
|
||||
cmd := gitcmd.NewCommand().AddArguments("clone")
|
||||
// Never follow HTTP redirects: no clone caller needs them, and a remote redirecting to an
|
||||
// otherwise-blocked address would be an SSRF vector (e.g. migrating from an attacker URL).
|
||||
cmd.AddArguments("-c", "http.followRedirects=false")
|
||||
HandleGitCmdHTTPRedirection(cmd, from, to)
|
||||
if opts.SkipTLSVerify {
|
||||
cmd.AddArguments("-c", "http.sslVerify=false")
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ func TestRepoIsEmpty(t *testing.T) {
|
||||
// TestCloneRefusesRedirects ensures Clone never follows HTTP redirects, so a remote
|
||||
// cannot redirect to an otherwise-blocked address (SSRF, e.g. during migration).
|
||||
func TestCloneRefusesRedirects(t *testing.T) {
|
||||
t.Skip("FIXME: GIT-CLONE-HTTP-REDIRECT-SSRF: need a complete solution in the future")
|
||||
var targetHit atomic.Bool
|
||||
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
targetHit.Store(true)
|
||||
|
||||
@@ -124,6 +124,7 @@
|
||||
"artifacts": "Artifacts",
|
||||
"expired": "Expired",
|
||||
"artifact_expires_at": "Expires at %s",
|
||||
"artifact_expired_at": "Expired at %s",
|
||||
"confirm_delete_artifact": "Are you sure you want to delete the artifact '%s'?",
|
||||
"archived": "Archived",
|
||||
"concept_system_global": "Global",
|
||||
@@ -2251,7 +2252,6 @@
|
||||
"repo.settings.webhook_deletion_success": "The webhook has been removed.",
|
||||
"repo.settings.webhook.test_delivery": "Test Push Event",
|
||||
"repo.settings.webhook.test_delivery_desc": "Test this webhook with a fake push event.",
|
||||
"repo.settings.webhook.test_delivery_desc_disabled": "To test this webhook with a fake event, activate it.",
|
||||
"repo.settings.webhook.request": "Request",
|
||||
"repo.settings.webhook.response": "Response",
|
||||
"repo.settings.webhook.headers": "Headers",
|
||||
@@ -3784,6 +3784,9 @@
|
||||
"actions.runs.pushed_by": "pushed by",
|
||||
"actions.runs.invalid_workflow_helper": "Workflow config file is invalid. Please check your config file: %s",
|
||||
"actions.runs.no_matching_online_runner_helper": "No matching online runner with label: %s",
|
||||
"actions.runs.no_runner_online": "No runner is online to pick up this job.",
|
||||
"actions.runs.waiting_for_available_runner": "Waiting for a matching runner to become available.",
|
||||
"actions.runs.waiting_for_dependent_jobs": "Waiting for the following jobs to complete: %s",
|
||||
"actions.runs.no_job_without_needs": "The workflow must contain at least one job without dependencies.",
|
||||
"actions.runs.no_job": "The workflow must contain at least one job",
|
||||
"actions.runs.invalid_reusable_workflow_uses": "Invalid reusable workflow \"uses\": %s",
|
||||
|
||||
@@ -67,15 +67,34 @@ func GetRepositoryFile(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
branch := ctx.PathParam("branch")
|
||||
repository := ctx.PathParam("repository")
|
||||
architecture := ctx.PathParam("architecture")
|
||||
|
||||
s, u, pf, err := packages_service.OpenFileForDownloadByPackageVersion(
|
||||
ctx,
|
||||
pv,
|
||||
&packages_service.PackageFileInfo{
|
||||
Filename: alpine_service.IndexArchiveFilename,
|
||||
CompositeKey: fmt.Sprintf("%s|%s|%s", ctx.PathParam("branch"), ctx.PathParam("repository"), ctx.PathParam("architecture")),
|
||||
CompositeKey: fmt.Sprintf("%s|%s|%s", branch, repository, architecture),
|
||||
},
|
||||
ctx.Req.Method,
|
||||
)
|
||||
// A repository that only contains "noarch" packages has no per-architecture
|
||||
// index. Since noarch packages are installable on every architecture, fall
|
||||
// back to the noarch index so clients requesting their own architecture
|
||||
// (e.g. x86_64) can still discover them.
|
||||
if errors.Is(err, util.ErrNotExist) && architecture != alpine_module.NoArch {
|
||||
s, u, pf, err = packages_service.OpenFileForDownloadByPackageVersion(
|
||||
ctx,
|
||||
pv,
|
||||
&packages_service.PackageFileInfo{
|
||||
Filename: alpine_service.IndexArchiveFilename,
|
||||
CompositeKey: fmt.Sprintf("%s|%s|%s", branch, repository, alpine_module.NoArch),
|
||||
},
|
||||
ctx.Req.Method,
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
|
||||
@@ -177,7 +177,7 @@ func TestHook(ctx *context.APIContext) {
|
||||
commit := convert.ToPayloadCommit(ctx, ctx.Repo.Repository, ctx.Repo.Commit)
|
||||
|
||||
commitID := ctx.Repo.Commit.ID.String()
|
||||
if err := webhook_service.PrepareWebhook(ctx, hook, webhook_module.HookEventPush, &api.PushPayload{
|
||||
if err := webhook_service.PrepareTestWebhook(ctx, hook, webhook_module.HookEventPush, &api.PushPayload{
|
||||
Ref: ref,
|
||||
Before: commitID,
|
||||
After: commitID,
|
||||
|
||||
@@ -382,6 +382,8 @@ func MockActionsRunsJobs(ctx *context.Context) {
|
||||
// └ deep_job (regular)
|
||||
// cross_caller (caller, cross-repo, expanded)
|
||||
// └ external_job (regular)
|
||||
// build (linux|windows|macos) (regular matrix; graph folds into one "build" node)
|
||||
// build-call (linux|windows|macos) (caller matrix, each calls build.yml; folds into one "build-call" node like "build")
|
||||
// final (regular, needs local_caller + cross_caller)
|
||||
const (
|
||||
prepareID = int64(400)
|
||||
@@ -392,6 +394,21 @@ func MockActionsRunsJobs(ctx *context.Context) {
|
||||
crossCallerID = int64(405)
|
||||
externalJobID = int64(406)
|
||||
finalID = int64(407)
|
||||
|
||||
// Regular matrix set – the graph already folds these into a single "build" node.
|
||||
buildLinuxID = int64(410)
|
||||
buildWindowsID = int64(411)
|
||||
buildMacosID = int64(412)
|
||||
|
||||
// Caller matrix set – each matrix leg calls the same reusable workflow. #38466: like the
|
||||
// regular "build" matrix above, these fold into one "build-call" node. Matrix legs share a
|
||||
// single JobID, so the legs below use JobID "build-call" and differ only by their name suffix.
|
||||
buildCallLinuxID = int64(420)
|
||||
buildCallWindowsID = int64(421)
|
||||
buildCallMacosID = int64(422)
|
||||
buildCallLinuxJobID = int64(423)
|
||||
buildCallWinJobID = int64(424)
|
||||
buildCallMacJobID = int64(425)
|
||||
)
|
||||
|
||||
resp.State.Run.Jobs = []*actions.ViewJob{
|
||||
@@ -432,6 +449,53 @@ func MockActionsRunsJobs(ctx *context.Context) {
|
||||
Status: actions_model.StatusWaiting.String(), Duration: "0s",
|
||||
ParentJobID: crossCallerID,
|
||||
},
|
||||
|
||||
// Regular matrix "build" – these fold into one matrix node in the graph. The matrix legs
|
||||
// share a single JobID ("build"); the " (variant)" name suffix distinguishes the legs.
|
||||
{
|
||||
ID: buildLinuxID, Link: jobLink(buildLinuxID), JobID: "build", Name: "build (linux)",
|
||||
Status: actions_model.StatusSuccess.String(), Duration: "1m", Needs: []string{"prepare"},
|
||||
},
|
||||
{
|
||||
ID: buildWindowsID, Link: jobLink(buildWindowsID), JobID: "build", Name: "build (windows)",
|
||||
Status: actions_model.StatusSuccess.String(), Duration: "2m", Needs: []string{"prepare"},
|
||||
},
|
||||
{
|
||||
ID: buildMacosID, Link: jobLink(buildMacosID), JobID: "build", Name: "build (macos)",
|
||||
Status: actions_model.StatusSuccess.String(), Duration: "90s", Needs: []string{"prepare"},
|
||||
},
|
||||
|
||||
// Caller matrix "build-call" – each leg calls the same reusable workflow. #38466: like the
|
||||
// regular "build" matrix above, these fold into one node. The matrix legs share a single
|
||||
// JobID ("build-call"); the " (variant)" name suffix distinguishes the legs.
|
||||
{
|
||||
ID: buildCallLinuxID, Link: jobLink(buildCallLinuxID), JobID: "build-call", Name: "build-call (linux)",
|
||||
Status: actions_model.StatusSuccess.String(), Duration: "1m", Needs: []string{"prepare"},
|
||||
IsReusableCaller: true, CallUses: "./.gitea/workflows/build.yml",
|
||||
},
|
||||
{
|
||||
ID: buildCallLinuxJobID, Link: jobLink(buildCallLinuxJobID), JobID: "bc_linux_build", Name: "build",
|
||||
Status: actions_model.StatusSuccess.String(), Duration: "1m", ParentJobID: buildCallLinuxID,
|
||||
},
|
||||
{
|
||||
ID: buildCallWindowsID, Link: jobLink(buildCallWindowsID), JobID: "build-call", Name: "build-call (windows)",
|
||||
Status: actions_model.StatusSuccess.String(), Duration: "2m", Needs: []string{"prepare"},
|
||||
IsReusableCaller: true, CallUses: "./.gitea/workflows/build.yml",
|
||||
},
|
||||
{
|
||||
ID: buildCallWinJobID, Link: jobLink(buildCallWinJobID), JobID: "bc_windows_build", Name: "build",
|
||||
Status: actions_model.StatusSuccess.String(), Duration: "2m", ParentJobID: buildCallWindowsID,
|
||||
},
|
||||
{
|
||||
ID: buildCallMacosID, Link: jobLink(buildCallMacosID), JobID: "build-call", Name: "build-call (macos)",
|
||||
Status: actions_model.StatusSuccess.String(), Duration: "90s", Needs: []string{"prepare"},
|
||||
IsReusableCaller: true, CallUses: "./.gitea/workflows/build.yml",
|
||||
},
|
||||
{
|
||||
ID: buildCallMacJobID, Link: jobLink(buildCallMacJobID), JobID: "bc_macos_build", Name: "build",
|
||||
Status: actions_model.StatusSuccess.String(), Duration: "90s", ParentJobID: buildCallMacosID,
|
||||
},
|
||||
|
||||
{
|
||||
ID: finalID, Link: jobLink(finalID), JobID: "final", Name: "final",
|
||||
Status: actions_model.StatusBlocked.String(), Duration: "0s",
|
||||
|
||||
@@ -32,6 +32,7 @@ import (
|
||||
"gitea.dev/modules/httplib"
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/storage"
|
||||
api "gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/templates"
|
||||
@@ -716,9 +717,10 @@ func fillViewRunResponseSummary(ctx *context_module.Context, resp *ViewResponse,
|
||||
resp.Artifacts = make([]*ArtifactsViewItem, 0, len(arts))
|
||||
for _, art := range arts {
|
||||
resp.Artifacts = append(resp.Artifacts, &ArtifactsViewItem{
|
||||
Name: art.ArtifactName,
|
||||
Size: art.FileSize,
|
||||
Status: util.Iif(art.Status == actions_model.ArtifactStatusExpired, "expired", "completed"),
|
||||
Name: art.ArtifactName,
|
||||
Size: art.FileSize,
|
||||
Status: util.Iif(art.Status == actions_model.ArtifactStatusExpired, "expired", "completed"),
|
||||
ExpiresUnix: int64(art.ExpiredUnix),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -752,6 +754,8 @@ func fillViewRunResponseCurrentJob(ctx *context_module.Context, resp *ViewRespon
|
||||
resp.State.CurrentJob.Detail = current.Status.LocaleString(ctx.Locale)
|
||||
if run.NeedApproval {
|
||||
resp.State.CurrentJob.Detail = ctx.Locale.TrString("actions.need_approval_desc")
|
||||
} else if detail := describePendingJobDetail(ctx, current, jobs); detail != "" {
|
||||
resp.State.CurrentJob.Detail = detail
|
||||
}
|
||||
resp.State.CurrentJob.Steps = make([]*ViewJobStep, 0) // marshal to '[]' instead fo 'null' in json
|
||||
resp.Logs.StepsLog = make([]*ViewStepLog, 0) // marshal to '[]' instead fo 'null' in json
|
||||
@@ -766,6 +770,78 @@ func fillViewRunResponseCurrentJob(ctx *context_module.Context, resp *ViewRespon
|
||||
}
|
||||
}
|
||||
|
||||
// describePendingJobDetail explains why a blocked or waiting job has not started
|
||||
// yet, so the user can tell whether it is waiting on its dependencies or on an
|
||||
// available runner. It returns an empty string when the job is not pending or the
|
||||
// cause can't be determined (the caller keeps the generic status label then).
|
||||
func describePendingJobDetail(ctx *context_module.Context, current *actions_model.ActionRunJob, jobs []*actions_model.ActionRunJob) string {
|
||||
switch {
|
||||
case current.Status.IsBlocked():
|
||||
// A blocked job is held back by the jobs listed in its `needs`.
|
||||
if pending := pendingNeeds(current, jobs); len(pending) > 0 {
|
||||
return ctx.Locale.TrString("actions.runs.waiting_for_dependent_jobs", strings.Join(pending, ", "))
|
||||
}
|
||||
case current.Status.IsWaiting():
|
||||
// A waiting job has no runner to pick it up yet. A busy runner is still
|
||||
// "online", so distinguish three cases: no runner online at all, online
|
||||
// runners but none match the labels, and a matching runner that is busy.
|
||||
runners, err := db.Find[actions_model.ActionRunner](ctx, actions_model.FindRunnerOptions{
|
||||
RepoID: current.RepoID,
|
||||
IsOnline: optional.Some(true),
|
||||
WithAvailable: true,
|
||||
})
|
||||
if err != nil {
|
||||
log.Error("FindRunners for job %d: %v", current.ID, err)
|
||||
return ""
|
||||
}
|
||||
hasOnlineRunner, hasMatchingRunner := false, false
|
||||
for _, runner := range runners {
|
||||
if runner.IsDisabled {
|
||||
continue
|
||||
}
|
||||
hasOnlineRunner = true
|
||||
if runner.CanMatchLabels(current.RunsOn) {
|
||||
hasMatchingRunner = true
|
||||
break
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case !hasOnlineRunner:
|
||||
return ctx.Locale.TrString("actions.runs.no_runner_online")
|
||||
case !hasMatchingRunner:
|
||||
return ctx.Locale.TrString("actions.runs.no_matching_online_runner_helper", strings.Join(current.RunsOn, ", "))
|
||||
default:
|
||||
// A matching runner exists but hasn't claimed the job, so it is busy.
|
||||
return ctx.Locale.TrString("actions.runs.waiting_for_available_runner")
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// pendingNeeds returns the `needs` keys of jobs the given job depends on that
|
||||
// have not finished yet, scoped to the same parent job (matrix expansions of a
|
||||
// need are all required to be done). Unresolved needs are treated as pending.
|
||||
func pendingNeeds(current *actions_model.ActionRunJob, jobs []*actions_model.ActionRunJob) []string {
|
||||
var pending []string
|
||||
for _, need := range current.Needs {
|
||||
found, allDone := false, true
|
||||
for _, job := range jobs {
|
||||
if job.ParentJobID != current.ParentJobID || job.JobID != need {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if !job.Status.IsDone() {
|
||||
allDone = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found || !allDone {
|
||||
pending = append(pending, need)
|
||||
}
|
||||
}
|
||||
return pending
|
||||
}
|
||||
|
||||
func convertToViewModel(ctx context.Context, locale translation.Locale, cursors []LogCursor, task *actions_model.ActionTask) ([]*ViewJobStep, []*ViewStepLog, error) {
|
||||
var viewJobs []*ViewJobStep
|
||||
var logs []*ViewStepLog
|
||||
|
||||
@@ -138,3 +138,48 @@ func TestConvertToViewModelCancellingTaskDoesNotRenderRunningSteps(t *testing.T)
|
||||
}
|
||||
assert.Equal(t, expectedViewJobs, viewJobSteps)
|
||||
}
|
||||
|
||||
func TestPendingNeeds(t *testing.T) {
|
||||
current := &actions_model.ActionRunJob{JobID: "deploy", Needs: []string{"build", "test"}}
|
||||
jobs := []*actions_model.ActionRunJob{
|
||||
current,
|
||||
{JobID: "build", Status: actions_model.StatusSuccess},
|
||||
{JobID: "test", Status: actions_model.StatusRunning},
|
||||
}
|
||||
// "test" is not done yet, "build" succeeded, so only "test" blocks.
|
||||
assert.Equal(t, []string{"test"}, pendingNeeds(current, jobs))
|
||||
|
||||
t.Run("all needs done", func(t *testing.T) {
|
||||
done := []*actions_model.ActionRunJob{
|
||||
current,
|
||||
{JobID: "build", Status: actions_model.StatusSuccess},
|
||||
{JobID: "test", Status: actions_model.StatusSkipped},
|
||||
}
|
||||
assert.Empty(t, pendingNeeds(current, done))
|
||||
})
|
||||
|
||||
t.Run("matrix expansion all required", func(t *testing.T) {
|
||||
matrix := []*actions_model.ActionRunJob{
|
||||
current,
|
||||
{JobID: "build", Status: actions_model.StatusSuccess},
|
||||
{JobID: "build", Status: actions_model.StatusRunning},
|
||||
{JobID: "test", Status: actions_model.StatusSuccess},
|
||||
}
|
||||
assert.Equal(t, []string{"build"}, pendingNeeds(current, matrix))
|
||||
})
|
||||
|
||||
t.Run("unresolved need treated as pending", func(t *testing.T) {
|
||||
missing := []*actions_model.ActionRunJob{current}
|
||||
assert.Equal(t, []string{"build", "test"}, pendingNeeds(current, missing))
|
||||
})
|
||||
|
||||
t.Run("parent job scope", func(t *testing.T) {
|
||||
// a same-named job under a different parent must not satisfy the need
|
||||
scoped := &actions_model.ActionRunJob{JobID: "deploy", Needs: []string{"build"}, ParentJobID: 5}
|
||||
jobs := []*actions_model.ActionRunJob{
|
||||
scoped,
|
||||
{JobID: "build", Status: actions_model.StatusSuccess, ParentJobID: 0},
|
||||
}
|
||||
assert.Equal(t, []string{"build"}, pendingNeeds(scoped, jobs))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -495,7 +495,7 @@ func (prInfo *pullRequestViewInfo) prepareMergeBoxCommitSigning(ctx *context.Con
|
||||
|
||||
wontSignReason := ""
|
||||
if ctx.Doer != nil {
|
||||
sign, key, _, err := asymkey_service.SignMerge(ctx, pull, ctx.Doer, ctx.Repo.GitRepo)
|
||||
sign, key, _, err := asymkey_service.SignMerge(ctx, pull, ctx.Doer, ctx.Repo.GitRepo, pull.BaseBranch, pull.GetGitHeadRefName())
|
||||
data.willSign = sign
|
||||
data.signingKeyMergeDisplay = asymkey_model.GetDisplaySigningKey(key)
|
||||
if err != nil {
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
pull_model "gitea.dev/models/pull"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unit"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/svg"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/util"
|
||||
@@ -62,20 +64,23 @@ func (prInfo *pullRequestViewInfo) prepareMergeBoxFormProps(ctx *context.Context
|
||||
hasPendingPullRequestMergeTip = ctx.Locale.Tr("repo.pulls.auto_merge_has_pending_schedule", pendingPullRequestMerge.Doer.Name, createdPRMergeStr)
|
||||
}
|
||||
|
||||
defaultMergeTitle, defaultMergeBody, err := pull_service.GetDefaultMergeMessage(ctx, ctx.Repo.GitRepo, pull, mergeStyle)
|
||||
if err != nil && !errors.Is(err, util.ErrNotExist) {
|
||||
ctx.ServerError("GetDefaultMergeMessage", err)
|
||||
return
|
||||
}
|
||||
defaultSquashMergeTitle, defaultSquashMergeBody, err := pull_service.GetDefaultMergeMessage(ctx, ctx.Repo.GitRepo, pull, repo_model.MergeStyleSquash)
|
||||
if err != nil && !errors.Is(err, util.ErrNotExist) {
|
||||
ctx.ServerError("GetDefaultSquashMergeMessage", err)
|
||||
return
|
||||
}
|
||||
|
||||
var defaultMergeTitle, defaultMergeBody string
|
||||
var defaultSquashMergeTitle, defaultSquashMergeBody string
|
||||
var defaultSquashMergeCommitMessages string
|
||||
if !prInfo.IsPullRequestBroken {
|
||||
defaultSquashMergeCommitMessages = pull_service.GetSquashMergeCommitMessages(ctx, pull)
|
||||
var err error
|
||||
defaultMergeTitle, defaultMergeBody, err = pull_service.GetDefaultMergeMessage(ctx, ctx.Repo.GitRepo, pull, mergeStyle)
|
||||
if err != nil && !errors.Is(err, util.ErrNotExist) {
|
||||
log.Error("GetDefaultMergeMessage for style %s failed, error: %v", mergeStyle, err)
|
||||
}
|
||||
defaultSquashMergeTitle, defaultSquashMergeBody, err = pull_service.GetDefaultMergeMessage(ctx, ctx.Repo.GitRepo, pull, repo_model.MergeStyleSquash)
|
||||
if err != nil && !errors.Is(err, util.ErrNotExist) {
|
||||
log.Error("GetDefaultMergeMessage for squash failed, error: %v", err)
|
||||
}
|
||||
defaultSquashMergeCommitMessages, err = pull_service.GetSquashMergeCommitMessages(ctx, pull)
|
||||
if err != nil && !errors.Is(err, util.ErrNotExist) {
|
||||
log.Error("GetSquashMergeCommitMessages failed, error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
allOverridableChecksOk := !prInfo.MergeBoxData.hasOverridableBlockers
|
||||
@@ -106,7 +111,6 @@ func (prInfo *pullRequestViewInfo) prepareMergeBoxFormProps(ctx *context.Context
|
||||
|
||||
// if this pr can be merged now, then hide the auto merge
|
||||
generalHideAutoMerge := prInfo.MergeBoxData.canMergeNow && allOverridableChecksOk
|
||||
|
||||
var mergeStyles []any
|
||||
if pull.IsStatusMergeable() {
|
||||
mergeStyles = []any{
|
||||
@@ -138,7 +142,7 @@ func (prInfo *pullRequestViewInfo) prepareMergeBoxFormProps(ctx *context.Context
|
||||
"allowed": prConfig.AllowSquash,
|
||||
"textDoMerge": ctx.Locale.Tr("repo.pulls.squash_merge_pull_request"),
|
||||
"mergeTitleFieldText": defaultSquashMergeTitle,
|
||||
"mergeMessageFieldText": defaultSquashMergeCommitMessages + defaultSquashMergeBody,
|
||||
"mergeMessageFieldText": git.CommitMessageMerge(defaultSquashMergeCommitMessages, defaultSquashMergeBody),
|
||||
"hideAutoMerge": generalHideAutoMerge,
|
||||
},
|
||||
map[string]any{
|
||||
|
||||
@@ -664,19 +664,14 @@ func TestWebhook(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Grab latest commit or fake one if it's empty repository.
|
||||
// Note: in old code, the "ctx.Repo.Commit" is the last commit of the default branch.
|
||||
// New code doesn't set that commit, so it always uses the fake commit to test webhook.
|
||||
commit := ctx.Repo.Commit
|
||||
if commit == nil {
|
||||
ghost := user_model.NewGhostUser()
|
||||
objectFormat := git.ObjectFormatFromName(ctx.Repo.Repository.ObjectFormatName)
|
||||
commit = &git.Commit{
|
||||
ID: objectFormat.EmptyObjectID(),
|
||||
Author: ghost.NewGitSig(),
|
||||
Committer: ghost.NewGitSig(),
|
||||
CommitMessage: git.CommitMessage{MessageRaw: "This is a fake commit"},
|
||||
}
|
||||
// use a fake commit to test webhook
|
||||
ghostUser := user_model.NewGhostUser()
|
||||
objectFormat := git.ObjectFormatFromName(ctx.Repo.Repository.ObjectFormatName)
|
||||
commit := &git.Commit{
|
||||
ID: objectFormat.EmptyObjectID(),
|
||||
Author: ghostUser.NewGitSig(),
|
||||
Committer: ghostUser.NewGitSig(),
|
||||
CommitMessage: git.CommitMessage{MessageRaw: "This is a fake commit for webhook push test"},
|
||||
}
|
||||
|
||||
apiUser := convert.ToUserWithAccessMode(ctx, ctx.Doer, perm.AccessModeNone)
|
||||
@@ -697,7 +692,7 @@ func TestWebhook(ctx *context.Context) {
|
||||
|
||||
commitID := commit.ID.String()
|
||||
p := &api.PushPayload{
|
||||
Ref: git.BranchPrefix + ctx.Repo.Repository.DefaultBranch,
|
||||
Ref: git.RefNameFromBranch(ctx.Repo.Repository.DefaultBranch).String(),
|
||||
Before: commitID,
|
||||
After: commitID,
|
||||
CompareURL: setting.AppURL + ctx.Repo.Repository.ComposeCompareURL(commitID, commitID),
|
||||
@@ -708,8 +703,8 @@ func TestWebhook(ctx *context.Context) {
|
||||
Pusher: apiUser,
|
||||
Sender: apiUser,
|
||||
}
|
||||
if err := webhook_service.PrepareWebhook(ctx, w, webhook_module.HookEventPush, p); err != nil {
|
||||
ctx.Flash.Error("PrepareWebhook: " + err.Error())
|
||||
if err := webhook_service.PrepareTestWebhook(ctx, w, webhook_module.HookEventPush, p); err != nil {
|
||||
ctx.Flash.Error("PrepareTestWebhook: " + err.Error())
|
||||
ctx.Status(http.StatusInternalServerError)
|
||||
} else {
|
||||
ctx.Flash.Info(ctx.Tr("repo.settings.webhook.delivery.success"))
|
||||
|
||||
@@ -373,16 +373,18 @@ func RunnerBulkActionPost(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var runnerIDs []int64
|
||||
if rCtx.IsAdmin {
|
||||
// ATTENTION: it completely depends on the assumption that the doer is "site admin"
|
||||
// So it doesn't do extra permission check to the runner IDs
|
||||
// In the future, if you need to support such operation on non-admin pages, be careful!
|
||||
runnerIDs = ctx.FormStringInt64s("ids")
|
||||
} else {
|
||||
if !rCtx.IsAdmin {
|
||||
ctx.HTTPError(http.StatusForbidden, "bulk actions are admin-only")
|
||||
return
|
||||
}
|
||||
// ATTENTION: it completely depends on the assumption that the doer is "site admin"
|
||||
// So it doesn't do extra permission check to the runner IDs
|
||||
// In the future, if you need to support such operation on non-admin pages, be careful!
|
||||
runnerIDs := ctx.FormStringInt64s("ids")
|
||||
if len(runnerIDs) == 0 {
|
||||
ctx.HTTPError(http.StatusBadRequest, "missing runner IDs")
|
||||
return
|
||||
}
|
||||
|
||||
action := ctx.FormString("action")
|
||||
var successKey, failedKey string
|
||||
|
||||
+131
-92
@@ -60,7 +60,7 @@ func PrepareRunAndInsert(ctx context.Context, content []byte, run *actions_model
|
||||
// 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 {
|
||||
var cancelledConcurrencyJobs []*actions_model.ActionRunJob
|
||||
var hasWaitingCallerJobs bool
|
||||
var needPostCommitEmit bool
|
||||
if err := db.WithTx(ctx, func(ctx context.Context) error {
|
||||
index, err := db.GetNextResourceIndex(ctx, "action_run_index", run.RepoID)
|
||||
if err != nil {
|
||||
@@ -133,99 +133,15 @@ func InsertRun(ctx context.Context, run *actions_model.ActionRun, content []byte
|
||||
|
||||
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()
|
||||
|
||||
isReusableWorkflowCaller := job.Uses != ""
|
||||
shouldBlockJob := runAttempt.Status == actions_model.StatusBlocked || len(needs) > 0 || run.NeedApproval
|
||||
|
||||
attemptJobID, err := actions_model.GetNextAttemptJobID(ctx, run.ID)
|
||||
runJob, jobsToCancel, jobNeedsPostCommitEmit, err := insertRunJob(ctx, run, runAttempt, v, vars, inputs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("alloc attempt_job_id: %w", err)
|
||||
}
|
||||
|
||||
job.Name = util.EllipsisDisplayString(job.Name, 255)
|
||||
runJob := &actions_model.ActionRunJob{
|
||||
RunID: run.ID,
|
||||
RunAttemptID: runAttempt.ID,
|
||||
RepoID: run.RepoID,
|
||||
OwnerID: run.OwnerID,
|
||||
CommitSHA: run.CommitSHA,
|
||||
IsForkPullRequest: run.IsForkPullRequest,
|
||||
Name: job.Name,
|
||||
Attempt: runAttempt.Attempt,
|
||||
WorkflowPayload: payload,
|
||||
JobID: id,
|
||||
AttemptJobID: attemptJobID,
|
||||
Needs: needs,
|
||||
RunsOn: job.RunsOn(),
|
||||
Status: util.Iif(shouldBlockJob, actions_model.StatusBlocked, actions_model.StatusWaiting),
|
||||
WorkflowSourceRepoID: run.WorkflowRepoID,
|
||||
WorkflowSourceCommitSHA: run.WorkflowCommitSHA,
|
||||
ContinueOnError: job.GetContinueOnError(),
|
||||
}
|
||||
// Parse workflow/job permissions (no clamping here)
|
||||
if perms := ExtractJobPermissionsFromWorkflow(v, job); perms != nil {
|
||||
runJob.TokenPermissions = perms
|
||||
}
|
||||
|
||||
if isReusableWorkflowCaller {
|
||||
runJob.IsReusableCaller = true
|
||||
runJob.CallUses = job.Uses
|
||||
}
|
||||
|
||||
// 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, runAttempt, 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 {
|
||||
var jobsToCancel []*actions_model.ActionRunJob
|
||||
runJob.Status, jobsToCancel, err = PrepareToStartJobWithConcurrency(ctx, runJob)
|
||||
if err != nil {
|
||||
return fmt.Errorf("prepare to start job with concurrency: %w", err)
|
||||
}
|
||||
cancelledConcurrencyJobs = append(cancelledConcurrencyJobs, jobsToCancel...)
|
||||
}
|
||||
}
|
||||
|
||||
// A reusable caller is never dispatched to a runner, so it must not drive the task-version bump.
|
||||
hasWaitingJobs = hasWaitingJobs || (runJob.Status == actions_model.StatusWaiting && !isReusableWorkflowCaller)
|
||||
if err := db.Insert(ctx, runJob); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// expand reusable caller
|
||||
if isReusableWorkflowCaller && runJob.Status == actions_model.StatusWaiting {
|
||||
if err := expandReusableWorkflowCaller(ctx, run, runAttempt, runJob, vars); err != nil {
|
||||
return fmt.Errorf("inline trigger caller %d ready: %w", runJob.ID, err)
|
||||
}
|
||||
// refresh the caller status
|
||||
if err := actions_model.RefreshReusableCallerStatus(ctx, runJob); err != nil {
|
||||
return fmt.Errorf("refresh caller %d status: %w", runJob.ID, err)
|
||||
}
|
||||
hasWaitingCallerJobs = true
|
||||
}
|
||||
|
||||
cancelledConcurrencyJobs = append(cancelledConcurrencyJobs, jobsToCancel...)
|
||||
needPostCommitEmit = needPostCommitEmit || jobNeedsPostCommitEmit
|
||||
// A reusable caller is never dispatched to a runner, so it must not drive the task-version bump.
|
||||
hasWaitingJobs = hasWaitingJobs || (runJob.Status == actions_model.StatusWaiting && !runJob.IsReusableCaller)
|
||||
runJobs = append(runJobs, runJob)
|
||||
}
|
||||
|
||||
@@ -249,8 +165,8 @@ func InsertRun(ctx context.Context, run *actions_model.ActionRun, content []byte
|
||||
NotifyWorkflowJobsAndRunsStatusUpdate(ctx, cancelledConcurrencyJobs)
|
||||
EmitJobsIfReadyByJobs(cancelledConcurrencyJobs)
|
||||
|
||||
// Post-commit kick for expanded callers: let job_emitter resolve its child jobs
|
||||
if hasWaitingCallerJobs {
|
||||
// Post-commit kick: let the job emitter resolve jobs if needed
|
||||
if needPostCommitEmit {
|
||||
if err := EmitJobsIfReadyByRun(run.ID); err != nil {
|
||||
log.Error("emit run %d after InsertRun: %v", run.ID, err)
|
||||
}
|
||||
@@ -258,3 +174,126 @@ func InsertRun(ctx context.Context, run *actions_model.ActionRun, content []byte
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// insertRunJob builds a single run job from a parsed workflow job, evaluates its
|
||||
// job-level concurrency, inserts it, and — for a ready no-needs reusable caller —
|
||||
// inline-expands (or skips) it. It returns the inserted job, any jobs cancelled by
|
||||
// job concurrency, and whether a post-commit emitter pass is needed to resolve the
|
||||
// caller's dependents.
|
||||
func insertRunJob(ctx context.Context, run *actions_model.ActionRun, runAttempt *actions_model.ActionRunAttempt, workflowJob *jobparser.SingleWorkflow, vars map[string]string, inputs map[string]any) (*actions_model.ActionRunJob, []*actions_model.ActionRunJob, bool, error) {
|
||||
id, job := workflowJob.Job()
|
||||
needs := job.Needs()
|
||||
if err := workflowJob.SetJob(id, job.EraseNeeds()); err != nil {
|
||||
return nil, nil, false, err
|
||||
}
|
||||
payload, _ := workflowJob.Marshal()
|
||||
|
||||
isReusableWorkflowCaller := job.Uses != ""
|
||||
shouldBlockJob := runAttempt.Status == actions_model.StatusBlocked || len(needs) > 0 || run.NeedApproval
|
||||
|
||||
attemptJobID, err := actions_model.GetNextAttemptJobID(ctx, run.ID)
|
||||
if err != nil {
|
||||
return nil, nil, false, fmt.Errorf("alloc attempt_job_id: %w", err)
|
||||
}
|
||||
|
||||
job.Name = util.EllipsisDisplayString(job.Name, 255)
|
||||
runJob := &actions_model.ActionRunJob{
|
||||
RunID: run.ID,
|
||||
RunAttemptID: runAttempt.ID,
|
||||
RepoID: run.RepoID,
|
||||
OwnerID: run.OwnerID,
|
||||
CommitSHA: run.CommitSHA,
|
||||
IsForkPullRequest: run.IsForkPullRequest,
|
||||
Name: job.Name,
|
||||
Attempt: runAttempt.Attempt,
|
||||
WorkflowPayload: payload,
|
||||
JobID: id,
|
||||
AttemptJobID: attemptJobID,
|
||||
Needs: needs,
|
||||
RunsOn: job.RunsOn(),
|
||||
Status: util.Iif(shouldBlockJob, actions_model.StatusBlocked, actions_model.StatusWaiting),
|
||||
WorkflowSourceRepoID: run.WorkflowRepoID,
|
||||
WorkflowSourceCommitSHA: run.WorkflowCommitSHA,
|
||||
ContinueOnError: job.GetContinueOnError(),
|
||||
}
|
||||
// Parse workflow/job permissions (no clamping here)
|
||||
if perms := ExtractJobPermissionsFromWorkflow(workflowJob, job); perms != nil {
|
||||
runJob.TokenPermissions = perms
|
||||
}
|
||||
|
||||
if isReusableWorkflowCaller {
|
||||
runJob.IsReusableCaller = true
|
||||
runJob.CallUses = job.Uses
|
||||
}
|
||||
|
||||
var cancelledConcurrencyJobs []*actions_model.ActionRunJob
|
||||
// check job concurrency
|
||||
if job.RawConcurrency != nil {
|
||||
rawConcurrency, err := yaml.Marshal(job.RawConcurrency)
|
||||
if err != nil {
|
||||
return nil, nil, false, 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 {
|
||||
if err := EvaluateJobConcurrencyFillModel(ctx, run, runAttempt, runJob, vars, inputs); err != nil {
|
||||
return nil, nil, false, 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 {
|
||||
var jobsToCancel []*actions_model.ActionRunJob
|
||||
runJob.Status, jobsToCancel, err = PrepareToStartJobWithConcurrency(ctx, runJob)
|
||||
if err != nil {
|
||||
return nil, nil, false, fmt.Errorf("prepare to start job with concurrency: %w", err)
|
||||
}
|
||||
cancelledConcurrencyJobs = append(cancelledConcurrencyJobs, jobsToCancel...)
|
||||
}
|
||||
}
|
||||
|
||||
if err := db.Insert(ctx, runJob); err != nil {
|
||||
return nil, nil, false, err
|
||||
}
|
||||
|
||||
// expand reusable caller
|
||||
var needPostCommitEmit bool
|
||||
if isReusableWorkflowCaller && runJob.Status == actions_model.StatusWaiting {
|
||||
if err := processInlineReusableCaller(ctx, run, runAttempt, runJob, vars); err != nil {
|
||||
return nil, nil, false, err
|
||||
}
|
||||
// A processed caller always needs a resolver pass:
|
||||
// - if the caller is expanded, resolve its children jobs;
|
||||
// - if the caller is skipped, propagate its state to its dependents
|
||||
needPostCommitEmit = true
|
||||
}
|
||||
|
||||
return runJob, cancelledConcurrencyJobs, needPostCommitEmit, nil
|
||||
}
|
||||
|
||||
// processInlineReusableCaller evaluates a no-needs reusable caller's own `if:` and
|
||||
// either inline-expands it into child jobs or marks it skipped.
|
||||
// (A caller with needs is Blocked and gets its `if:` evaluated by the job emitter instead.)
|
||||
func processInlineReusableCaller(ctx context.Context, run *actions_model.ActionRun, runAttempt *actions_model.ActionRunAttempt, caller *actions_model.ActionRunJob, vars map[string]string) error {
|
||||
shouldStart, err := evaluateJobIf(ctx, run, runAttempt, caller, vars, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("evaluate caller %d if: %w", caller.ID, err)
|
||||
}
|
||||
if shouldStart {
|
||||
if err := expandReusableWorkflowCaller(ctx, run, runAttempt, caller, vars); err != nil {
|
||||
return fmt.Errorf("inline trigger caller %d ready: %w", caller.ID, err)
|
||||
}
|
||||
// refresh the caller status
|
||||
if err := actions_model.RefreshReusableCallerStatus(ctx, caller); err != nil {
|
||||
return fmt.Errorf("refresh caller %d status: %w", caller.ID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
caller.Status = actions_model.StatusSkipped
|
||||
if _, err := actions_model.UpdateRunJob(ctx, caller, nil, "status"); err != nil {
|
||||
return fmt.Errorf("skip caller %d: %w", caller.ID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -6,16 +6,22 @@ package actions
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"maps"
|
||||
"time"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/organization"
|
||||
perm_model "gitea.dev/models/perm"
|
||||
access_model "gitea.dev/models/perm/access"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unit"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/timeutil"
|
||||
webhook_module "gitea.dev/modules/webhook"
|
||||
"gitea.dev/services/convert"
|
||||
)
|
||||
|
||||
// StartScheduleTasks start the task
|
||||
@@ -102,7 +108,19 @@ func startTasks(ctx context.Context) error {
|
||||
// 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, spec *actions_model.ActionScheduleSpec) error {
|
||||
cron := spec.Schedule
|
||||
eventPayload := withScheduleInEventPayload(cron.EventPayload, spec.Spec)
|
||||
|
||||
// Scheduled runs carry no webhook payload; synthesize what github.event.* expects.
|
||||
if err := spec.Repo.LoadOwner(ctx); err != nil {
|
||||
return fmt.Errorf("LoadOwner: %w", err)
|
||||
}
|
||||
fields := map[string]any{
|
||||
"repository": convert.ToRepo(ctx, spec.Repo, access_model.Permission{AccessMode: perm_model.AccessModeRead}),
|
||||
"sender": convert.ToUser(ctx, user_model.NewActionsUser(), nil),
|
||||
}
|
||||
if spec.Repo.Owner.IsOrganization() {
|
||||
fields["organization"] = convert.ToOrganization(ctx, organization.OrgFromUser(spec.Repo.Owner))
|
||||
}
|
||||
eventPayload := withScheduleInEventPayload(cron.EventPayload, spec.Spec, fields)
|
||||
|
||||
// Create a new action run based on the schedule
|
||||
run := &actions_model.ActionRun{
|
||||
@@ -134,7 +152,7 @@ func CreateScheduleTask(ctx context.Context, spec *actions_model.ActionScheduleS
|
||||
return nil
|
||||
}
|
||||
|
||||
func withScheduleInEventPayload(eventPayload, schedule string) string {
|
||||
func withScheduleInEventPayload(eventPayload, schedule string, fields map[string]any) string {
|
||||
if schedule == "" {
|
||||
return eventPayload
|
||||
}
|
||||
@@ -153,6 +171,7 @@ func withScheduleInEventPayload(eventPayload, schedule string) string {
|
||||
event = map[string]any{}
|
||||
}
|
||||
|
||||
maps.Copy(event, fields)
|
||||
event["schedule"] = schedule
|
||||
updatedPayload, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modules/json"
|
||||
api "gitea.dev/modules/structs"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
@@ -14,7 +15,7 @@ import (
|
||||
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 * * * *")
|
||||
updated := withScheduleInEventPayload(payload, "*/5 * * * *", nil)
|
||||
|
||||
event := map[string]any{}
|
||||
assert.NoError(t, json.Unmarshal([]byte(updated), &event))
|
||||
@@ -23,7 +24,7 @@ func TestWithScheduleInEventPayload(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("adds schedule to null payload", func(t *testing.T) {
|
||||
updated := withScheduleInEventPayload("null", "37 12 5 1 2")
|
||||
updated := withScheduleInEventPayload("null", "37 12 5 1 2", nil)
|
||||
|
||||
event := map[string]any{}
|
||||
assert.NoError(t, json.Unmarshal([]byte(updated), &event))
|
||||
@@ -31,22 +32,37 @@ func TestWithScheduleInEventPayload(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("adds schedule to empty payload", func(t *testing.T) {
|
||||
updated := withScheduleInEventPayload("", "37 12 5 1 2")
|
||||
updated := withScheduleInEventPayload("", "37 12 5 1 2", nil)
|
||||
|
||||
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 with repository, sender, organization", func(t *testing.T) {
|
||||
updated := withScheduleInEventPayload("null", "@weekly", map[string]any{
|
||||
"repository": &api.Repository{Name: "test-repo"},
|
||||
"sender": &api.User{UserName: "test-user"},
|
||||
"organization": &api.Organization{Name: "test-org"},
|
||||
})
|
||||
|
||||
event := map[string]any{}
|
||||
assert.NoError(t, json.Unmarshal([]byte(updated), &event))
|
||||
assert.Equal(t, "@weekly", event["schedule"])
|
||||
assert.Equal(t, "test-repo", event["repository"].(map[string]any)["name"])
|
||||
assert.Equal(t, "test-user", event["sender"].(map[string]any)["login"])
|
||||
assert.Equal(t, "test-org", event["organization"].(map[string]any)["name"])
|
||||
})
|
||||
|
||||
t.Run("keeps payload when schedule empty", func(t *testing.T) {
|
||||
payload := `{"ref":"refs/heads/main"}`
|
||||
updated := withScheduleInEventPayload(payload, "")
|
||||
updated := withScheduleInEventPayload(payload, "", nil)
|
||||
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 * * * *")
|
||||
updated := withScheduleInEventPayload(payload, "*/5 * * * *", nil)
|
||||
assert.Equal(t, payload, updated)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -144,6 +144,11 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re
|
||||
if err = processInputs(workflowDispatch, inputsWithDefaults); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// The dispatch callbacks fill boolean inputs as the strings "true"/"false". Normalize them to
|
||||
// native JSON booleans so `type: boolean` inputs match GitHub, whose `inputs` context preserves
|
||||
// booleans as booleans. Without this, a server-side needs-gated job `if: inputs.flag == true`
|
||||
// evaluates against the string "true" and never matches, leaving the job blocked forever.
|
||||
coerceDispatchInputTypes(workflowDispatch, inputsWithDefaults)
|
||||
|
||||
// ctx.Req.PostForm -> WorkflowDispatchPayload.Inputs -> ActionRun.EventPayload -> runner: ghc.Event
|
||||
// https://docs.github.com/en/actions/learn-github-actions/contexts#github-context
|
||||
@@ -169,6 +174,23 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re
|
||||
return run.ID, nil
|
||||
}
|
||||
|
||||
// coerceDispatchInputTypes normalizes workflow_dispatch input values to the JSON types declared by
|
||||
// the workflow. Only booleans are coerced, matching GitHub, whose `inputs` context "preserves
|
||||
// Boolean values as Booleans instead of converting them to strings" while every other type stays a
|
||||
// string. workflow_dispatch has no `number` type (its input types are string, choice, boolean and
|
||||
// environment), so booleans are the complete set to coerce here.
|
||||
// A value that is already a bool is left untouched, so the coercion is idempotent.
|
||||
func coerceDispatchInputTypes(dispatch *model.WorkflowDispatch, inputs map[string]any) {
|
||||
for name, cfg := range dispatch.Inputs {
|
||||
if cfg.Type != "boolean" {
|
||||
continue
|
||||
}
|
||||
if s, ok := inputs[name].(string); ok {
|
||||
inputs[name] = s == "true"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// resolveDispatchWorkflowContent returns the YAML for a dispatched workflow and records its source on the run.
|
||||
// - Repo-level: from the consumer's runTargetCommit.
|
||||
// - Scoped: from the source repo's default branch.
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCoerceDispatchInputTypes(t *testing.T) {
|
||||
dispatch := &model.WorkflowDispatch{
|
||||
Inputs: map[string]model.WorkflowDispatchInput{
|
||||
"build_server": {Type: "boolean"},
|
||||
"dry_run": {Type: "boolean"},
|
||||
"already_bool": {Type: "boolean"},
|
||||
"version": {Type: "string"},
|
||||
},
|
||||
}
|
||||
|
||||
inputs := map[string]any{
|
||||
// dispatch callbacks fill booleans as strconv.FormatBool(...) strings
|
||||
"build_server": "true",
|
||||
"dry_run": "false",
|
||||
// already-native booleans are passed through unchanged (coercion is idempotent)
|
||||
"already_bool": true,
|
||||
// non-boolean inputs must be left untouched
|
||||
"version": "1.2.3",
|
||||
}
|
||||
|
||||
coerceDispatchInputTypes(dispatch, inputs)
|
||||
|
||||
// Regression: without coercion these stay strings, and a server-side needs-gated
|
||||
// job `if: inputs.build_server == true` never matches, leaving the job blocked.
|
||||
assert.Equal(t, true, inputs["build_server"])
|
||||
assert.Equal(t, false, inputs["dry_run"])
|
||||
assert.Equal(t, true, inputs["already_bool"])
|
||||
assert.Equal(t, "1.2.3", inputs["version"])
|
||||
}
|
||||
+14
-10
@@ -270,19 +270,21 @@ Loop:
|
||||
return true, signingKey, sig, nil
|
||||
}
|
||||
|
||||
// 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, gitRepo *git.Repository) (bool, *git.SigningKey, *git.Signature, error) {
|
||||
// SignMerge determines if we should sign a PR merge commit to the base repository.
|
||||
// baseRef and headRef must resolve in gitRepo. Callers pass the temporary merge repo's own
|
||||
// refs for an update by merge, whose fake reverse PR has no head ref in the base repository.
|
||||
func SignMerge(ctx context.Context, pr *issues_model.PullRequest, u *user_model.User, gitRepo *git.Repository, baseRef, headRef string) (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
|
||||
|
||||
baseCommit, err := gitRepo.GetCommit(pr.BaseBranch)
|
||||
baseCommit, err := gitRepo.GetCommit(baseRef)
|
||||
if err != nil {
|
||||
return false, nil, nil, err
|
||||
}
|
||||
headCommit, err := gitRepo.GetCommit(pr.GetGitHeadRefName())
|
||||
headCommit, err := gitRepo.GetCommit(headRef)
|
||||
if err != nil {
|
||||
return false, nil, nil, err
|
||||
}
|
||||
@@ -338,7 +340,7 @@ Loop:
|
||||
return false, nil, nil, &ErrWontSign{headSigned}
|
||||
}
|
||||
case commitsSigned:
|
||||
verified, err := AllHeadCommitsVerified(ctx, pr, gitRepo)
|
||||
verified, err := allCommitsVerified(ctx, baseCommit, headCommit)
|
||||
if err != nil {
|
||||
return false, nil, nil, err
|
||||
}
|
||||
@@ -361,11 +363,13 @@ func AllHeadCommitsVerified(ctx context.Context, pr *issues_model.PullRequest, g
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
mergeBaseCommit, err := gitrepo.MergeBase(ctx, pr.BaseRepo, baseCommit.ID.String(), headCommit.ID.String())
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
commitList, err := headCommit.CommitsBeforeUntil(git.RefNameFromCommit(mergeBaseCommit))
|
||||
return allCommitsVerified(ctx, baseCommit, headCommit)
|
||||
}
|
||||
|
||||
// allCommitsVerified checks the commits a merge would introduce, those reachable from
|
||||
// headCommit but not from baseCommit. Both commits must come from the same repository.
|
||||
func allCommitsVerified(ctx context.Context, baseCommit, headCommit *git.Commit) (bool, error) {
|
||||
commitList, err := headCommit.CommitsBeforeUntil(baseCommit.ID.RefName())
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
@@ -1285,6 +1285,8 @@ func getDiffBasic(ctx context.Context, gitRepo *git.Repository, opts *DiffOption
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
// HINT: GIT-DIFF-HIGHLIGHT-LINE-NUMBER: git doesn't treat CR(\r) as EOL, CR is just a plain char which can appear anywhere in the diff output
|
||||
// Since we have to do full-file-highlighting for the diff result, we need to make sure the highlighted lines exactly match the git's diff output.
|
||||
cmdDiff := gitcmd.NewCommand().
|
||||
AddArguments("diff", "--src-prefix=\\a/", "--dst-prefix=\\b/").
|
||||
AddArguments(opts.WhitespaceBehavior...).
|
||||
@@ -1405,8 +1407,12 @@ func highlightCodeLines(name, lang string, sections []*DiffSection, isLeft bool,
|
||||
if setting.Git.DisableDiffHighlight || len(rawContent) > MaxFullFileHighlightSizeLimit {
|
||||
return nil
|
||||
}
|
||||
|
||||
content := util.UnsafeBytesToString(charset.ToUTF8(rawContent, charset.ConvertOpts{}))
|
||||
// HINT: GIT-DIFF-HIGHLIGHT-LINE-NUMBER: it should handle all CR(\r) before highlight to make line numbers match
|
||||
if strings.Contains(content, "\r") {
|
||||
content = strings.ReplaceAll(content, "\r\n", "\n")
|
||||
content = strings.ReplaceAll(content, "\r", "␍")
|
||||
}
|
||||
lexer := highlight.DetectChromaLexerByFileName(name, lang)
|
||||
highlightedNewContent := highlight.RenderCodeByLexer(lexer, content)
|
||||
unsafeLines := highlight.UnsafeSplitHighlightedLines(highlightedNewContent)
|
||||
|
||||
@@ -1143,6 +1143,19 @@ func TestHighlightCodeLines(t *testing.T) {
|
||||
1: `<span class="n">b</span>` + nl,
|
||||
}, ret)
|
||||
})
|
||||
t.Run("CharCR", func(t *testing.T) {
|
||||
diffFile := &DiffFile{
|
||||
Name: "a.txt",
|
||||
Sections: []*DiffSection{
|
||||
{
|
||||
Lines: []*DiffLine{{LeftIdx: 1}, {LeftIdx: 2}},
|
||||
},
|
||||
},
|
||||
}
|
||||
ret := highlightCodeLinesForDiffFile(diffFile, true, []byte("a\rb\r\nc"))
|
||||
assert.Equal(t, "a␍b\n", string(ret[0]))
|
||||
assert.Equal(t, `c`, string(ret[1]))
|
||||
})
|
||||
}
|
||||
|
||||
func TestSyncUserSpecificDiff_UpdatedFiles(t *testing.T) {
|
||||
|
||||
@@ -99,7 +99,9 @@ func composeIssueCommentMessages(ctx context.Context, comment *mailComment, lang
|
||||
}
|
||||
}
|
||||
locale := translation.NewLocale(lang)
|
||||
|
||||
if lang == "mock" {
|
||||
locale = &translation.MockLocale{}
|
||||
}
|
||||
mailMeta := map[string]any{
|
||||
"locale": locale,
|
||||
"FallbackSubject": fallback,
|
||||
|
||||
@@ -18,10 +18,14 @@ import (
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
activities_model "gitea.dev/models/activities"
|
||||
"gitea.dev/models/asymkey"
|
||||
git_model "gitea.dev/models/git"
|
||||
"gitea.dev/models/gituser"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/markup"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/storage"
|
||||
@@ -556,3 +560,32 @@ func TestEmbedBase64Images(t *testing.T) {
|
||||
assert.Equal(t, expected, string(resultMailBody))
|
||||
})
|
||||
}
|
||||
|
||||
func TestMailPullRequestPush(t *testing.T) {
|
||||
doer, _, issue, comment := prepareMailerTest(t)
|
||||
mc := &mailComment{
|
||||
Issue: issue,
|
||||
Comment: comment,
|
||||
Doer: doer,
|
||||
}
|
||||
issue.IsPull = true
|
||||
issue.PullRequest = &issues_model.PullRequest{BaseRepo: mc.Issue.Repo}
|
||||
mc.Comment.Type = issues_model.CommentTypePullRequestPush
|
||||
mc.Comment.Commits = []*git_model.SignCommitWithStatuses{
|
||||
{
|
||||
SignCommit: &asymkey.SignCommit{
|
||||
UserCommit: &gituser.UserCommit{
|
||||
GitCommit: &git.Commit{
|
||||
CommitMessage: git.CommitMessage{MessageRaw: "test commit msg"},
|
||||
ID: git.Sha1ObjectFormat.EmptyObjectID(),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
msgs, err := composeIssueCommentMessages(t.Context(), mc, "mock", []*user_model.User{{Name: "Test", Email: "test@gitea.com"}}, false, "pull request push")
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, msgs[0].Body, `<a href="https://try.gitea.io/user2/repo1/commit/0000000000000000000000000000000000000000">0000000000</a> - test commit msg`)
|
||||
assert.Contains(t, msgs[0].Body, `</html>`)
|
||||
}
|
||||
|
||||
@@ -72,7 +72,8 @@ func UpdateAddress(ctx context.Context, m *repo_model.Mirror, addr string) error
|
||||
|
||||
func pruneBrokenReferences(ctx context.Context, m *repo_model.Mirror, gitRepo gitrepo.Repository, timeout time.Duration) error {
|
||||
// Never follow HTTP redirects, see cmdFetch in runSync.
|
||||
cmd := gitcmd.NewCommand("remote", "prune").AddConfig("http.followRedirects", "false").AddDynamicArguments(m.GetRemoteName()).WithTimeout(timeout)
|
||||
cmd := gitcmd.NewCommand("remote", "prune").AddDynamicArguments(m.GetRemoteName()).WithTimeout(timeout)
|
||||
git.HandleGitCmdHTTPRedirection(cmd, m.GetRemoteName())
|
||||
stdout, _, pruneErr := gitrepo.RunCmdString(ctx, gitRepo, cmd)
|
||||
if pruneErr != nil {
|
||||
// sanitize the output, since it may contain the remote address, which may contain a password
|
||||
@@ -129,9 +130,8 @@ func runSync(ctx context.Context, m *repo_model.Mirror) ([]*repo_module.SyncResu
|
||||
|
||||
// use fetch but not remote update because git fetch support --tags but remote update doesn't
|
||||
cmdFetch := func() *gitcmd.Command {
|
||||
// Never follow HTTP redirects: a mirror remote that later starts redirecting to an
|
||||
// otherwise-blocked address would be an SSRF/exfiltration vector on scheduled syncs.
|
||||
cmd := gitcmd.NewCommand("fetch", "--tags").AddConfig("http.followRedirects", "false")
|
||||
cmd := gitcmd.NewCommand("fetch", "--tags")
|
||||
git.HandleGitCmdHTTPRedirection(cmd, m.GetRemoteName())
|
||||
if m.EnablePrune {
|
||||
cmd.AddArguments("--prune")
|
||||
}
|
||||
@@ -212,9 +212,9 @@ func runSync(ctx context.Context, m *repo_model.Mirror) ([]*repo_module.SyncResu
|
||||
}
|
||||
|
||||
cmdRemoteUpdatePrune := func() *gitcmd.Command {
|
||||
// Never follow HTTP redirects, see cmdFetch above.
|
||||
return gitcmd.NewCommand("remote", "update", "--prune").AddConfig("http.followRedirects", "false").
|
||||
AddDynamicArguments(m.GetRemoteName()).WithTimeout(timeout).WithEnv(envs)
|
||||
cmd := gitcmd.NewCommand("remote", "update", "--prune").AddDynamicArguments(m.GetRemoteName()).WithTimeout(timeout).WithEnv(envs)
|
||||
git.HandleGitCmdHTTPRedirection(cmd, m.GetRemoteName())
|
||||
return cmd
|
||||
}
|
||||
|
||||
if repo_service.HasWiki(ctx, m.Repo) {
|
||||
|
||||
@@ -264,7 +264,7 @@ func checkSigningRequirements(ctx context.Context, pr *issues_model.PullRequest,
|
||||
}
|
||||
|
||||
if mergeStyle != repo_model.MergeStyleFastForwardOnly {
|
||||
if _, _, _, err := asymkey_service.SignMerge(ctx, pr, doer, gitRepo); err != nil {
|
||||
if _, _, _, err := asymkey_service.SignMerge(ctx, pr, doer, gitRepo, pr.BaseBranch, pr.GetGitHeadRefName()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/util"
|
||||
asymkey_service "gitea.dev/services/asymkey"
|
||||
@@ -103,15 +102,18 @@ func createTemporaryRepoForMerge(ctx context.Context, pr *issues_model.PullReque
|
||||
mergeCtx.sig = doer.NewGitSig()
|
||||
mergeCtx.committer = mergeCtx.sig
|
||||
|
||||
gitRepo, err := gitrepo.OpenRepository(ctx, pr.BaseRepo)
|
||||
gitRepo, err := git.OpenRepository(ctx, mergeCtx.tmpBasePath)
|
||||
if err != nil {
|
||||
defer cancel()
|
||||
return nil, nil, fmt.Errorf("failed to open temp git repo for pr[%d]: %w", mergeCtx.pr.ID, err)
|
||||
}
|
||||
defer gitRepo.Close()
|
||||
|
||||
// Determine if we should sign
|
||||
sign, key, signer, _ := asymkey_service.SignMerge(ctx, pr, doer, gitRepo)
|
||||
// Determine if we should sign, using the temp repo's own refs (see SignMerge for why)
|
||||
sign, key, signer, err := asymkey_service.SignMerge(ctx, pr, doer, gitRepo, git.BranchPrefix+tmpRepoBaseBranch, git.BranchPrefix+tmpRepoTrackingBranch)
|
||||
if err != nil && !asymkey_service.IsErrWontSign(err) {
|
||||
log.Error("%-v SignMerge: %v", mergeCtx.pr, err) // the merge proceeds unsigned regardless, so log it here
|
||||
}
|
||||
if sign {
|
||||
mergeCtx.signKey = key
|
||||
if pr.BaseRepo.GetTrustModel() == repo_model.CommitterTrustModel || pr.BaseRepo.GetTrustModel() == repo_model.CollaboratorCommitterTrustModel {
|
||||
|
||||
+8
-15
@@ -777,30 +777,25 @@ func CloseRepoBranchesPulls(ctx context.Context, doer *user_model.User, repo *re
|
||||
}
|
||||
|
||||
// GetSquashMergeCommitMessages returns the commit messages between head and merge base (if there is one)
|
||||
func GetSquashMergeCommitMessages(ctx context.Context, pr *issues_model.PullRequest) string {
|
||||
func GetSquashMergeCommitMessages(ctx context.Context, pr *issues_model.PullRequest) (_ string, err error) {
|
||||
if err := pr.LoadIssue(ctx); err != nil {
|
||||
log.Error("Cannot load issue %d for PR id %d: Error: %v", pr.IssueID, pr.ID, err)
|
||||
return ""
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := pr.Issue.LoadPoster(ctx); err != nil {
|
||||
log.Error("Cannot load poster %d for pr id %d, index %d Error: %v", pr.Issue.PosterID, pr.ID, pr.Index, err)
|
||||
return ""
|
||||
return "", err
|
||||
}
|
||||
|
||||
if pr.HeadRepo == nil {
|
||||
var err error
|
||||
pr.HeadRepo, err = repo_model.GetRepositoryByID(ctx, pr.HeadRepoID)
|
||||
if err != nil {
|
||||
log.Error("GetRepositoryByIdCtx[%d]: %v", pr.HeadRepoID, err)
|
||||
return ""
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
gitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, pr.HeadRepo)
|
||||
if err != nil {
|
||||
log.Error("Unable to open head repository: Error: %v", err)
|
||||
return ""
|
||||
return "", err
|
||||
}
|
||||
defer closer.Close()
|
||||
|
||||
@@ -810,8 +805,7 @@ func GetSquashMergeCommitMessages(ctx context.Context, pr *issues_model.PullRequ
|
||||
} else {
|
||||
pr.HeadCommitID, err = gitRepo.GetRefCommitID(pr.GetGitHeadRefName())
|
||||
if err != nil {
|
||||
log.Error("Unable to get head commit: %s Error: %v", pr.GetGitHeadRefName(), err)
|
||||
return ""
|
||||
return "", err
|
||||
}
|
||||
headCommitRef = git.RefNameFromCommit(pr.HeadCommitID)
|
||||
}
|
||||
@@ -822,8 +816,7 @@ func GetSquashMergeCommitMessages(ctx context.Context, pr *issues_model.PullRequ
|
||||
|
||||
limitedCommits, err := gitRepo.CommitsBetween(headCommitRef, mergeBaseRef, limit)
|
||||
if err != nil {
|
||||
log.Error("Unable to get commits between: %s %s Error: %v", pr.HeadBranch, pr.MergeBase, err)
|
||||
return ""
|
||||
return "", err
|
||||
}
|
||||
|
||||
mergeMessage := strings.TrimSpace(pr.Issue.Content) // use PR's title and description as squash commit message
|
||||
@@ -831,7 +824,7 @@ func GetSquashMergeCommitMessages(ctx context.Context, pr *issues_model.PullRequ
|
||||
mergeMessage = formatSquashMergeCommitMessages(limitedCommits) // use PR's commit messages as squash commit message
|
||||
}
|
||||
coAuthors := collectSquashMergeCommitCoAuthors(ctx, gitRepo, pr, headCommitRef, mergeBaseRef, limit, limitedCommits)
|
||||
return buildSquashMergeCommitMessages(mergeMessage, coAuthors)
|
||||
return buildSquashMergeCommitMessages(mergeMessage, coAuthors), nil
|
||||
}
|
||||
|
||||
func buildSquashMergeCommitMessages(mergeMessage string, coAuthors []string) string {
|
||||
|
||||
@@ -96,6 +96,10 @@ func deleteUser(ctx context.Context, u *user_model.User, purge bool) (err error)
|
||||
&user_model.Blocking{BlockeeID: u.ID},
|
||||
&actions_model.ActionRunnerToken{OwnerID: u.ID},
|
||||
&actions_model.ActionScopedWorkflowSource{OwnerID: u.ID},
|
||||
&auth_model.TwoFactor{UID: u.ID},
|
||||
&auth_model.WebAuthnCredential{UserID: u.ID},
|
||||
&activities_model.Notification{UserID: u.ID},
|
||||
&issues_model.IssueWatch{UserID: u.ID},
|
||||
); err != nil {
|
||||
return fmt.Errorf("deleteBeans: %w", err)
|
||||
}
|
||||
|
||||
@@ -145,7 +145,8 @@ func UpdateUser(ctx context.Context, u *user_model.User, opts *UpdateOptions) er
|
||||
}
|
||||
}
|
||||
|
||||
if opts.Visibility.Has() {
|
||||
// only validate and persist the visibility when it actually changes
|
||||
if opts.Visibility.Has() && opts.Visibility.Value() != u.Visibility {
|
||||
if !u.IsOrganization() && !setting.Service.AllowedUserVisibilityModesSlice.IsAllowedVisibility(opts.Visibility.Value()) {
|
||||
return fmt.Errorf("visibility mode not allowed: %s", opts.Visibility.Value().String())
|
||||
}
|
||||
|
||||
@@ -10,7 +10,9 @@ import (
|
||||
user_model "gitea.dev/models/user"
|
||||
password_module "gitea.dev/modules/auth/password"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/test"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
@@ -121,3 +123,33 @@ func TestUpdateAuth(t *testing.T) {
|
||||
Password: optional.Some("aaaa"),
|
||||
}), password_module.ErrMinLength)
|
||||
}
|
||||
|
||||
func TestUpdateUserVisibility(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
// user28's current visibility is public, e.g. an account created before public was disallowed
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 28})
|
||||
assert.Equal(t, structs.VisibleTypePublic, user.Visibility)
|
||||
|
||||
// public is no longer an allowed visibility mode, e.g. ALLOWED_USER_VISIBILITY_MODES = limited, private
|
||||
defer test.MockVariableValue(&setting.Service.AllowedUserVisibilityModesSlice, setting.AllowedVisibility{false, true, true})()
|
||||
|
||||
// re-submitting the unchanged (now-disallowed) visibility must not fail the whole update
|
||||
assert.NoError(t, UpdateUser(t.Context(), user, &UpdateOptions{
|
||||
FullName: optional.Some("Changed Name"),
|
||||
Visibility: optional.Some(structs.VisibleTypePublic),
|
||||
}))
|
||||
assert.Equal(t, "Changed Name", user.FullName)
|
||||
assert.Equal(t, structs.VisibleTypePublic, user.Visibility)
|
||||
|
||||
// changing to an allowed visibility still works
|
||||
assert.NoError(t, UpdateUser(t.Context(), user, &UpdateOptions{
|
||||
Visibility: optional.Some(structs.VisibleTypePrivate),
|
||||
}))
|
||||
assert.Equal(t, structs.VisibleTypePrivate, user.Visibility)
|
||||
|
||||
// genuinely changing to a disallowed visibility is still rejected
|
||||
assert.Error(t, UpdateUser(t.Context(), user, &UpdateOptions{
|
||||
Visibility: optional.Some(structs.VisibleTypePublic),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -9,8 +9,10 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
activities_model "gitea.dev/models/activities"
|
||||
"gitea.dev/models/auth"
|
||||
"gitea.dev/models/db"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
"gitea.dev/models/organization"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
@@ -61,6 +63,32 @@ func TestDeleteUser(t *testing.T) {
|
||||
|
||||
org := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3})
|
||||
assert.Error(t, DeleteUser(t.Context(), org, false))
|
||||
|
||||
t.Run("CleanupOrphanedTables", func(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
// assert they exist before deletion
|
||||
unittest.AssertExistsAndLoadBean(t, &auth.TwoFactor{UID: 24})
|
||||
unittest.AssertExistsAndLoadBean(t, &auth.WebAuthnCredential{UserID: 32})
|
||||
unittest.AssertExistsAndLoadBean(t, &activities_model.Notification{UserID: 2})
|
||||
unittest.AssertExistsAndLoadBean(t, &issues_model.IssueWatch{UserID: 2})
|
||||
|
||||
// delete users
|
||||
user24 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 24})
|
||||
assert.NoError(t, DeleteUser(t.Context(), user24, true))
|
||||
|
||||
user32 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 32})
|
||||
assert.NoError(t, DeleteUser(t.Context(), user32, true))
|
||||
|
||||
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
assert.NoError(t, DeleteUser(t.Context(), user2, true))
|
||||
|
||||
// assert they do not exist after deletion
|
||||
unittest.AssertNotExistsBean(t, &auth.TwoFactor{UID: 24})
|
||||
unittest.AssertNotExistsBean(t, &auth.WebAuthnCredential{UserID: 32})
|
||||
unittest.AssertNotExistsBean(t, &activities_model.Notification{UserID: 2})
|
||||
unittest.AssertNotExistsBean(t, &issues_model.IssueWatch{UserID: 2})
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeleteUserUnlinkedAttachments(t *testing.T) {
|
||||
|
||||
@@ -129,6 +129,33 @@ func checkBranchFilter(branchFilter string, ref git.RefName) bool {
|
||||
return g.Match(ref.String())
|
||||
}
|
||||
|
||||
// PrepareTestWebhook always creates and enqueues a hook task for manual testing.
|
||||
// Unlike PrepareWebhook, it ignores event subscriptions and branch filters so the
|
||||
// Test Push Event control can verify delivery even when those gates would suppress
|
||||
// a real event.
|
||||
func PrepareTestWebhook(ctx context.Context, w *webhook_model.Webhook, event webhook_module.HookEventType, p api.Payloader) error {
|
||||
if setting.DisableWebhooks {
|
||||
return nil
|
||||
}
|
||||
|
||||
payload, err := p.JSONPayload()
|
||||
if err != nil {
|
||||
return fmt.Errorf("JSONPayload for %s: %w", event, err)
|
||||
}
|
||||
|
||||
task, err := webhook_model.CreateHookTask(ctx, &webhook_model.HookTask{
|
||||
HookID: w.ID,
|
||||
PayloadContent: string(payload),
|
||||
EventType: event,
|
||||
PayloadVersion: 2,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("CreateHookTask for %s: %w", event, err)
|
||||
}
|
||||
|
||||
return enqueueHookTask(task.ID)
|
||||
}
|
||||
|
||||
// PrepareWebhook creates a hook task and enqueues it for processing.
|
||||
// The payload is saved as-is. The adjustments depending on the webhook type happen
|
||||
// right before delivery, in the [Deliver] method.
|
||||
|
||||
@@ -30,6 +30,7 @@ func TestWebhookService(t *testing.T) {
|
||||
t.Run("PrepareBranchFilterNoMatch", testWebhookPrepareBranchFilterNoMatch)
|
||||
t.Run("WebhookUserMail", testWebhookUserMail)
|
||||
t.Run("CheckBranchFilter", testWebhookCheckBranchFilter)
|
||||
t.Run("PrepareTestWebhookIgnoresGates", testPrepareTestWebhookIgnoresGates)
|
||||
}
|
||||
|
||||
func testWebhookGetSlackHook(t *testing.T) {
|
||||
@@ -132,3 +133,37 @@ func testWebhookCheckBranchFilter(t *testing.T) {
|
||||
assert.Equal(t, v.match, checkBranchFilter(v.filter, v.ref), "filter: %q ref: %q", v.filter, v.ref)
|
||||
}
|
||||
}
|
||||
|
||||
func testPrepareTestWebhookIgnoresGates(t *testing.T) {
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
hook := &webhook_model.Webhook{
|
||||
RepoID: repo.ID,
|
||||
URL: "http://localhost/gitea-webhook-test-prepare_test_webhook",
|
||||
ContentType: webhook_model.ContentTypeJSON,
|
||||
IsActive: true,
|
||||
HookEvent: &webhook_module.HookEvent{
|
||||
ChooseEvents: true,
|
||||
BranchFilter: "dev",
|
||||
HookEvents: webhook_module.HookEvents{
|
||||
webhook_module.HookEventWorkflowRun: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
require.NoError(t, hook.UpdateEvent())
|
||||
require.NoError(t, db.Insert(t.Context(), hook))
|
||||
|
||||
payload := &api.PushPayload{
|
||||
Ref: "refs/heads/master",
|
||||
Commits: []*api.PayloadCommit{{}},
|
||||
}
|
||||
hookTask := &webhook_model.HookTask{HookID: hook.ID, EventType: webhook_module.HookEventPush}
|
||||
|
||||
// Real deliveries stay gated: no push event + branch filter mismatch => nothing queued.
|
||||
unittest.AssertNotExistsBean(t, hookTask)
|
||||
require.NoError(t, PrepareWebhook(t.Context(), hook, webhook_module.HookEventPush, payload))
|
||||
unittest.AssertNotExistsBean(t, hookTask)
|
||||
|
||||
// Manual test delivery always queues so the endpoint can be verified.
|
||||
require.NoError(t, PrepareTestWebhook(t.Context(), hook, webhook_module.HookEventPush, payload))
|
||||
unittest.AssertExistsAndLoadBean(t, hookTask)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
{{template "devtest/devtest-header"}}
|
||||
<div class="page-content devtest">
|
||||
<div class="ui container">
|
||||
<h3>Flex List (standalone)</h3>
|
||||
<h3>Flex Relaxed List</h3>
|
||||
<div class="flex-container tw-border">
|
||||
<div class="flex-relaxed-list tw-flex-1">
|
||||
<div class="flex-left-right">
|
||||
<span class="gt-ellipsis">left looooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong</span>
|
||||
<span>right</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Flex Divided List (standalone)</h3>
|
||||
<div class="divider"></div>
|
||||
<div class="flex-divided-list items-with-main">
|
||||
<div class="item">
|
||||
@@ -87,7 +97,7 @@
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<h3>Flex List (with "ui segment")</h3>
|
||||
<h3>Flex Divided List (with "ui segment")</h3>
|
||||
<div class="ui attached segment">
|
||||
<div class="flex-divided-list">
|
||||
<div class="item">item 1</div>
|
||||
@@ -101,7 +111,7 @@
|
||||
<div class="item">item 2</div>
|
||||
</div>
|
||||
</div>
|
||||
<h3>Flex List (with "ui segment fitted", items have their own padding)</h3>
|
||||
<h3>Flex Divided List (with "ui segment fitted", items have their own padding)</h3>
|
||||
<div class="ui fitted segment">
|
||||
<div class="flex-divided-list items-px-default">
|
||||
<div class="item">item 1</div>
|
||||
|
||||
@@ -63,19 +63,18 @@
|
||||
<div>{{.RenderedContent}}</div>
|
||||
</div>
|
||||
{{end -}}
|
||||
{{if eq .ActionName "push"}}
|
||||
<ul>
|
||||
{{$repoURL := $.Comment.Issue.PullRequest.BaseRepo.HTMLURL}}
|
||||
{{range $commit := $.Comment.Commits}}
|
||||
<li>
|
||||
<a href="{{$repoURL}}/commit/{{$commit.ID}}">
|
||||
{{ShortSha $commit.ID.String}}
|
||||
</a> - {{$commit.MessageTitle}}
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
</p>
|
||||
{{if eq .ActionName "push"}}
|
||||
<ul>
|
||||
{{$repoURL := $.Comment.Issue.PullRequest.BaseRepo.HTMLURL}}
|
||||
{{range $commit := $.Comment.Commits}}
|
||||
{{$gitCommit := $commit.UserCommit.GitCommit}}
|
||||
<li>
|
||||
<a href="{{$repoURL}}/commit/{{$gitCommit.ID}}">{{ShortSha $gitCommit.ID.String}}</a> - {{$gitCommit.MessageTitle}}
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
<div style="font-size:small; color:#666;">
|
||||
<p>
|
||||
---
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div class="ui container tw-flex tw-gap-4">
|
||||
<div class="ui container flex-container">
|
||||
<div>{{ctx.AvatarUtils.Avatar .Org 100}}</div>
|
||||
<div class="flex-relaxed-list">
|
||||
<div class="flex-relaxed-list tw-flex-1">
|
||||
<div class="ui header flex-left-right tw-m-0">
|
||||
<div class="flex-text-block">
|
||||
<span class="tw-text-2xl">{{.Org.DisplayName}}</span>
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
data-locale-artifacts-title="{{ctx.Locale.Tr "artifacts"}}"
|
||||
data-locale-artifact-expired="{{ctx.Locale.Tr "expired"}}"
|
||||
data-locale-artifact-expires-at="{{ctx.Locale.Tr "artifact_expires_at"}}"
|
||||
data-locale-artifact-expired-at="{{ctx.Locale.Tr "artifact_expired_at"}}"
|
||||
data-locale-confirm-delete-artifact="{{ctx.Locale.Tr "confirm_delete_artifact"}}"
|
||||
data-locale-show-timestamps="{{ctx.Locale.Tr "show_timestamps"}}"
|
||||
data-locale-show-log-seconds="{{ctx.Locale.Tr "show_log_seconds"}}"
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
{{$isNew:=or .PageIsSettingsHooksNew .PageIsAdminDefaultHooksNew .PageIsAdminSystemHooksNew}}
|
||||
{{if .PageIsSettingsHooksEdit}}
|
||||
<h4 class="ui top attached header">
|
||||
{{ctx.Locale.Tr "repo.settings.recent_deliveries"}}
|
||||
<h4 class="ui top attached header flex-left-right">
|
||||
<span>{{ctx.Locale.Tr "repo.settings.recent_deliveries"}}</span>
|
||||
{{if .Permission.IsAdmin}}
|
||||
<div class="ui right">
|
||||
<!-- the button is wrapped with a span because the tooltip doesn't show on hover if we put data-tooltip-content directly on the button -->
|
||||
<span data-tooltip-content="{{if or $isNew .Webhook.IsActive}}{{ctx.Locale.Tr "repo.settings.webhook.test_delivery_desc"}}{{else}}{{ctx.Locale.Tr "repo.settings.webhook.test_delivery_desc_disabled"}}{{end}}">
|
||||
<button class="ui tiny button{{if not (or $isNew .Webhook.IsActive)}} disabled{{end}}" id="test-delivery" data-link="{{.Link}}/test">
|
||||
<span class="text">{{ctx.Locale.Tr "repo.settings.webhook.test_delivery"}}</span>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<button class="ui tiny button" id="test-delivery" data-link="{{.Link}}/test"
|
||||
data-tooltip-content="{{ctx.Locale.Tr "repo.settings.webhook.test_delivery_desc"}}"
|
||||
>
|
||||
{{ctx.Locale.Tr "repo.settings.webhook.test_delivery"}}
|
||||
</button>
|
||||
{{end}}
|
||||
</h4>
|
||||
<div class="ui attached segment">
|
||||
|
||||
@@ -44,9 +44,9 @@
|
||||
<div class="ui attached segment tw-hidden" data-global-init="initRunnerBulkToolbar">
|
||||
<form action="{{$.Link}}/bulk" method="post" class="form-fetch-action">
|
||||
<input type="hidden" name="ids">
|
||||
<button class="ui small button" name="action" value="disable">{{ctx.Locale.Tr "actions.runners.disable_runner"}} <span class="runner-bulk-count"></span></button>
|
||||
<button class="ui small button" name="action" value="enable">{{ctx.Locale.Tr "actions.runners.enable_runner"}} <span class="runner-bulk-count"></span></button>
|
||||
<button class="ui small red button" name="action" value="delete"
|
||||
<button class="ui small button runner-bulk-action" name="action" value="disable">{{ctx.Locale.Tr "actions.runners.disable_runner"}} <span class="runner-bulk-count"></span></button>
|
||||
<button class="ui small button runner-bulk-action" name="action" value="enable">{{ctx.Locale.Tr "actions.runners.enable_runner"}} <span class="runner-bulk-count"></span></button>
|
||||
<button class="ui small red button runner-bulk-action" name="action" value="delete"
|
||||
data-modal-confirm-header="{{ctx.Locale.Tr "actions.runners.delete_runner_header"}}"
|
||||
data-modal-confirm-content="{{ctx.Locale.Tr "actions.runners.delete_runner_notice"}}"
|
||||
>{{ctx.Locale.Tr "actions.runners.delete_runner"}} <span class="runner-bulk-count"></span>
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/queue"
|
||||
api "gitea.dev/modules/structs"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -763,6 +764,73 @@ jobs:
|
||||
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
|
||||
assert.Equal(t, actions_model.StatusSuccess, run.Status)
|
||||
})
|
||||
|
||||
t.Run("No-needs caller if evaluated inline: false skips, true expands", func(t *testing.T) {
|
||||
// A no-needs reusable-workflow caller is processed inline during InsertRun, where its own
|
||||
// `if:` is now evaluated before expansion:
|
||||
// - a false `if:` skips the caller without inserting any children, and the skip is
|
||||
// propagated to a dependent job (via the post-commit emitter kick);
|
||||
// - a true `if:` still expands the caller into its child jobs.
|
||||
apiRepo := createActionsTestRepo(t, user2Token, "caller-inline-if-test", false)
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID})
|
||||
|
||||
createRepoWorkflowFile(t, user2, user2Token, repo, ".gitea/workflows/lib.yaml",
|
||||
`name: Lib
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
inner:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo inner
|
||||
`)
|
||||
createRepoWorkflowFile(t, user2, user2Token, repo, ".gitea/workflows/caller.yaml",
|
||||
`name: Caller
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- '.gitea/workflows/caller.yaml'
|
||||
jobs:
|
||||
will_skip:
|
||||
if: ${{ false }}
|
||||
uses: ./.gitea/workflows/lib.yaml
|
||||
|
||||
will_run:
|
||||
if: ${{ true }}
|
||||
uses: ./.gitea/workflows/lib.yaml
|
||||
|
||||
after_skip:
|
||||
needs: [will_skip]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo after_skip
|
||||
`)
|
||||
|
||||
// drain the emitter queue so the skip has propagated to the dependent job
|
||||
assert.NoError(t, queue.GetManager().FlushAll(t.Context(), 5*time.Second))
|
||||
|
||||
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: repo.ID})
|
||||
runID := run.ID
|
||||
|
||||
// will_skip: a caller with a false `if:` is skipped inline and never expands (no children inserted).
|
||||
willSkip := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, JobID: "will_skip"})
|
||||
assert.True(t, willSkip.IsReusableCaller)
|
||||
assert.False(t, willSkip.IsExpanded)
|
||||
assert.Equal(t, actions_model.StatusSkipped, willSkip.Status)
|
||||
unittest.AssertNotExistsBean(t, &actions_model.ActionRunJob{RunID: runID, ParentJobID: willSkip.ID})
|
||||
|
||||
// will_run: a caller with a true `if:` still expands into its child job.
|
||||
willRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, JobID: "will_run"})
|
||||
assert.True(t, willRun.IsReusableCaller)
|
||||
assert.True(t, willRun.IsExpanded)
|
||||
innerChild := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, JobID: "inner"})
|
||||
assert.Equal(t, willRun.ID, innerChild.ParentJobID)
|
||||
|
||||
// after_skip: a dependent of the skipped caller resolves to Skipped instead of staying Blocked.
|
||||
afterSkip := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, JobID: "after_skip"})
|
||||
assert.Equal(t, actions_model.StatusSkipped, afterSkip.Status)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -196,6 +196,13 @@ func TestActionsRunnerModify(t *testing.T) {
|
||||
doBulk(t, sessionAdmin, "evict", allIDs, http.StatusBadRequest)
|
||||
})
|
||||
|
||||
t.Run("EmptyIDs", func(t *testing.T) {
|
||||
doBulk(t, sessionAdmin, "delete", nil, http.StatusBadRequest)
|
||||
for _, id := range allIDs {
|
||||
unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{ID: id})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("DisableEnable", func(t *testing.T) {
|
||||
doBulk(t, sessionAdmin, "disable", allIDs, http.StatusOK)
|
||||
for _, id := range allIDs {
|
||||
|
||||
@@ -1164,7 +1164,7 @@ jobs:
|
||||
assert.Contains(t, dispatchPayload.Inputs, "myinput3")
|
||||
assert.Equal(t, "val0", dispatchPayload.Inputs["myinput"])
|
||||
assert.Equal(t, "def2", dispatchPayload.Inputs["myinput2"])
|
||||
assert.Equal(t, "true", dispatchPayload.Inputs["myinput3"])
|
||||
assert.Equal(t, true, dispatchPayload.Inputs["myinput3"])
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1344,7 +1344,7 @@ jobs:
|
||||
assert.Contains(t, dispatchPayload.Inputs, "myinput3")
|
||||
assert.Equal(t, "val0", dispatchPayload.Inputs["myinput"])
|
||||
assert.Equal(t, "def2", dispatchPayload.Inputs["myinput2"])
|
||||
assert.Equal(t, "true", dispatchPayload.Inputs["myinput3"])
|
||||
assert.Equal(t, true, dispatchPayload.Inputs["myinput3"])
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1475,7 +1475,7 @@ jobs:
|
||||
assert.Contains(t, dispatchPayload.Inputs, "myinput3")
|
||||
assert.Equal(t, "val0", dispatchPayload.Inputs["myinput"])
|
||||
assert.Equal(t, "def2", dispatchPayload.Inputs["myinput2"])
|
||||
assert.Equal(t, "true", dispatchPayload.Inputs["myinput3"])
|
||||
assert.Equal(t, true, dispatchPayload.Inputs["myinput3"])
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1672,7 +1672,7 @@ jobs:
|
||||
assert.Contains(t, dispatchPayload.Inputs, "myinput3")
|
||||
assert.Equal(t, "val0", dispatchPayload.Inputs["myinput"])
|
||||
assert.Equal(t, "def2", dispatchPayload.Inputs["myinput2"])
|
||||
assert.Equal(t, "true", dispatchPayload.Inputs["myinput3"])
|
||||
assert.Equal(t, true, dispatchPayload.Inputs["myinput3"])
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -268,6 +268,36 @@ AACAX/AKARNTyAAoAAA=`
|
||||
AddBasicAuth(user.Name)
|
||||
MakeRequest(t, req, http.StatusNoContent)
|
||||
})
|
||||
|
||||
t.Run("NoArchOnly", func(t *testing.T) {
|
||||
defer tests.PrintCurrentTest(t)()
|
||||
|
||||
// A repository that only contains noarch packages has no per-architecture index,
|
||||
// but apk always requests the index for its own architecture (e.g. x86_64).
|
||||
// That request must fall back to the noarch index instead of 404ing.
|
||||
noarchRepository := repository + "-noarchonly"
|
||||
|
||||
req := NewRequestWithBody(t, "PUT", fmt.Sprintf("%s/%s/%s", rootURL, branch, noarchRepository), bytes.NewReader(noarchContent)).
|
||||
AddBasicAuth(user.Name)
|
||||
MakeRequest(t, req, http.StatusCreated)
|
||||
|
||||
req = NewRequest(t, "GET", fmt.Sprintf("%s/%s/%s/x86_64/APKINDEX.tar.gz", rootURL, branch, noarchRepository))
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
content, err := readIndexContent(resp.Body)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Contains(t, content, "C:Q1kbH5WoIPFccQYyATanaKXd2cJcc=\n")
|
||||
assert.Contains(t, content, "A:noarch\n")
|
||||
|
||||
// The noarch index is still directly retrievable too.
|
||||
req = NewRequest(t, "GET", fmt.Sprintf("%s/%s/%s/noarch/APKINDEX.tar.gz", rootURL, branch, noarchRepository))
|
||||
MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
req = NewRequest(t, "DELETE", fmt.Sprintf("%s/%s/%s/noarch/gitea-noarch-1.4-r0.apk", rootURL, branch, noarchRepository)).
|
||||
AddBasicAuth(user.Name)
|
||||
MakeRequest(t, req, http.StatusNoContent)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,6 +303,84 @@ func testGitSigning(t *testing.T) {
|
||||
assert.True(t, branch.Commit.Verification.Verified)
|
||||
}))
|
||||
})
|
||||
|
||||
t.Run("UpdateMergeSigned", func(t *testing.T) {
|
||||
defer tests.PrintCurrentTest(t)()
|
||||
testCtx := NewAPITestContext(t, username, "update-merge-signed", auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
|
||||
t.Run("CreateRepository", doAPICreateRepository(testCtx, false))
|
||||
|
||||
t.Run("CreateFeatureCommit", crudActionCreateFile(
|
||||
t, testCtx, user, "master", "feature", "signed-feature.txt"))
|
||||
pr, err := doAPICreatePullRequest(testCtx, testCtx.Username, testCtx.Reponame, "master", "feature")(t)
|
||||
require.NoError(t, err)
|
||||
|
||||
content := base64.StdEncoding.EncodeToString([]byte("update base"))
|
||||
t.Run("UpdateBase", doAPICreateFile(testCtx, "signed-base.txt", &api.CreateFileOptions{
|
||||
FileOptions: api.FileOptions{
|
||||
BranchName: "master",
|
||||
Message: "update base",
|
||||
Author: api.Identity{
|
||||
Name: user.FullName,
|
||||
Email: user.Email,
|
||||
},
|
||||
Committer: api.Identity{
|
||||
Name: user.FullName,
|
||||
Email: user.Email,
|
||||
},
|
||||
},
|
||||
ContentBase64: content,
|
||||
}))
|
||||
|
||||
req := NewRequestf(t, "POST", "/api/v1/repos/%s/%s/pulls/%d/update?style=merge", testCtx.Username, testCtx.Reponame, pr.Index).
|
||||
AddTokenAuth(testCtx.Token)
|
||||
testCtx.Session.MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
t.Run("CheckFeatureBranchSigned", doAPIGetBranch(testCtx, "feature", func(t *testing.T, branch api.Branch) {
|
||||
require.NotNil(t, branch.Commit)
|
||||
require.NotNil(t, branch.Commit.Verification)
|
||||
assert.True(t, branch.Commit.Verification.Verified)
|
||||
}))
|
||||
})
|
||||
|
||||
setting.Repository.Signing.CRUDActions = []string{"never"}
|
||||
t.Run("UpdateMergeUnsigned", func(t *testing.T) {
|
||||
defer tests.PrintCurrentTest(t)()
|
||||
testCtx := NewAPITestContext(t, username, "update-merge-unsigned", auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
|
||||
t.Run("CreateRepository", doAPICreateRepository(testCtx, false))
|
||||
|
||||
t.Run("CreateFeatureCommit", crudActionCreateFile(
|
||||
t, testCtx, user, "master", "feature", "unsigned-feature.txt"))
|
||||
pr, err := doAPICreatePullRequest(testCtx, testCtx.Username, testCtx.Reponame, "master", "feature")(t)
|
||||
require.NoError(t, err)
|
||||
|
||||
// the base commit the update merges in is unsigned, so the commitssigned rule must refuse
|
||||
content := base64.StdEncoding.EncodeToString([]byte("update base"))
|
||||
t.Run("UpdateBase", doAPICreateFile(testCtx, "unsigned-base.txt", &api.CreateFileOptions{
|
||||
FileOptions: api.FileOptions{
|
||||
BranchName: "master",
|
||||
Message: "update base",
|
||||
Author: api.Identity{
|
||||
Name: user.FullName,
|
||||
Email: user.Email,
|
||||
},
|
||||
Committer: api.Identity{
|
||||
Name: user.FullName,
|
||||
Email: user.Email,
|
||||
},
|
||||
},
|
||||
ContentBase64: content,
|
||||
}))
|
||||
|
||||
req := NewRequestf(t, "POST", "/api/v1/repos/%s/%s/pulls/%d/update?style=merge", testCtx.Username, testCtx.Reponame, pr.Index).
|
||||
AddTokenAuth(testCtx.Token)
|
||||
testCtx.Session.MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
t.Run("CheckFeatureBranchUnsigned", doAPIGetBranch(testCtx, "feature", func(t *testing.T, branch api.Branch) {
|
||||
require.NotNil(t, branch.Commit)
|
||||
require.NotNil(t, branch.Commit.Verification)
|
||||
assert.False(t, branch.Commit.Verification.Verified)
|
||||
}))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1321,7 +1321,8 @@ Co-authored-by: user4 <user4@example.com>
|
||||
pullIndex, err := strconv.ParseInt(elems[4], 10, 64)
|
||||
assert.NoError(t, err)
|
||||
pullRequest := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{BaseRepoID: repo.ID, Index: pullIndex})
|
||||
squashMergeCommitMessage := pull_service.GetSquashMergeCommitMessages(t.Context(), pullRequest)
|
||||
squashMergeCommitMessage, err := pull_service.GetSquashMergeCommitMessages(t.Context(), pullRequest)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tc.expectedMessage, squashMergeCommitMessage)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-block);
|
||||
min-width: 0; /* keep the same style as "flex-text-block" etc, make the text content wrap/ellipse correctly */
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.flex-relaxed-list > .divider {
|
||||
|
||||
@@ -28,7 +28,7 @@ const iconClass = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :data-tooltip-content="localeStatus ?? status" v-if="status">
|
||||
<span class="flex-text-inline" :data-tooltip-content="localeStatus ?? status" v-if="status">
|
||||
<SvgIcon :name="icon.name" :class="iconClass" :size="size"/>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -39,6 +39,7 @@ const mergeButtonStyleClass = computed(() => {
|
||||
const mergeSelectStyleClass = computed(() => {
|
||||
if (mergeForm.emptyCommit) return '';
|
||||
if (mergeStyle.value === mergeStyleManuallyMerged) return 'red';
|
||||
if (!mergeForm.allOverridableChecksOk) return 'red';
|
||||
return 'primary';
|
||||
});
|
||||
|
||||
|
||||
@@ -216,10 +216,7 @@ onBeforeUnmount(() => {
|
||||
<div class="ui divider"/>
|
||||
<div class="left-list-header">{{ locale.allJobs }}</div>
|
||||
<div class="flex-items-block action-view-sidebar-list">
|
||||
<div
|
||||
class="item job-brief-item"
|
||||
:class="{'selected': props.jobId === item.job.id}"
|
||||
:style="{paddingLeft: `${10 + item.depth * 16}px`}"
|
||||
<template
|
||||
v-for="item in visibleJobListItems"
|
||||
:key="item.job.id"
|
||||
>
|
||||
@@ -228,7 +225,9 @@ onBeforeUnmount(() => {
|
||||
<button
|
||||
v-if="item.job.isReusableCaller"
|
||||
type="button"
|
||||
class="tw-contents caller-row-toggle"
|
||||
class="item caller-row-toggle"
|
||||
:class="{'selected': props.jobId === item.job.id}"
|
||||
:style="{paddingLeft: `${10 + item.depth * 16}px`}"
|
||||
@click="toggleExpandedJob(item.job.id)"
|
||||
:title="isJobCollapsed(item.job.id) ? locale.expandCallerJobs : locale.collapseCallerJobs"
|
||||
:aria-label="isJobCollapsed(item.job.id) ? locale.expandCallerJobs : locale.collapseCallerJobs"
|
||||
@@ -239,13 +238,19 @@ onBeforeUnmount(() => {
|
||||
<span class="job-duration">{{ item.job.duration }}</span>
|
||||
<SvgIcon name="octicon-chevron-down" :size="14" class="job-brief-toggle-icon" :class="{'collapsed': isJobCollapsed(item.job.id)}"/>
|
||||
</button>
|
||||
<a v-else class="tw-contents silenced" :href="item.job.link">
|
||||
<a
|
||||
v-else
|
||||
class="item silenced"
|
||||
:class="{'selected': props.jobId === item.job.id}"
|
||||
:style="{paddingLeft: `${10 + item.depth * 16}px`}"
|
||||
:href="item.job.link"
|
||||
>
|
||||
<ActionStatusIcon :locale-status="locale.status[item.job.status]" :status="item.job.status" icon-variant="circle-fill"/>
|
||||
<span class="tw-min-w-0 gt-ellipsis">{{ item.job.name }}</span>
|
||||
<SvgIcon name="octicon-sync" role="button" :data-tooltip-content="locale.rerun" class="job-rerun-button tw-cursor-pointer link-action interact-fg" :data-url="`${run.link}/jobs/${item.job.id}/rerun`" v-if="item.job.canRerun"/>
|
||||
<span class="job-duration">{{ item.job.duration }}</span>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- artifacts list -->
|
||||
@@ -269,7 +274,12 @@ onBeforeUnmount(() => {
|
||||
<SvgIcon name="octicon-trash"/>
|
||||
</a>
|
||||
</template>
|
||||
<span v-else class="flex-text-block tw-flex-1 tw-min-w-0 tw-text-text-light-2">
|
||||
<span
|
||||
v-else class="flex-text-block tw-flex-1 tw-min-w-0 tw-text-text-light-2"
|
||||
:data-tooltip-content="buildArtifactTooltipHtml(artifact, locale.artifactExpiredAt)"
|
||||
data-tooltip-render="html"
|
||||
data-tooltip-placement="top-end"
|
||||
>
|
||||
<SvgIcon name="octicon-file-removed"/>
|
||||
<span class="tw-flex-1 gt-ellipsis">{{ artifact.name }}</span>
|
||||
<span class="ui label tw-flex-shrink-0">{{ locale.artifactExpired }}</span>
|
||||
@@ -450,10 +460,11 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.caller-row-toggle {
|
||||
width: 100%;
|
||||
border: none;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
line-height: inherit; /* buttons don't inherit line-height; match the <a> rows' row height */
|
||||
cursor: pointer;
|
||||
text-align: inherit;
|
||||
}
|
||||
@@ -483,13 +494,13 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.action-view-sidebar-list > .item:hover .job-rerun-button,
|
||||
.action-view-sidebar-list > .item:has(a:focus) .job-rerun-button {
|
||||
.action-view-sidebar-list > .item:focus .job-rerun-button {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
/* only swap out the duration when a re-run button exists to take its place */
|
||||
.action-view-sidebar-list > .item:hover .job-rerun-button ~ .job-duration,
|
||||
.action-view-sidebar-list > .item:has(a:focus) .job-rerun-button ~ .job-duration {
|
||||
.action-view-sidebar-list > .item:focus .job-rerun-button ~ .job-duration {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -157,16 +157,18 @@ test('reusable callers with identical dependency signature are kept as separate
|
||||
expect(graph.nodes.find((n) => n.id === 'job:3')?.name).toBe('cross-repo caller');
|
||||
});
|
||||
|
||||
test('reusable caller with matrix-pattern name does not get absorbed into a sibling matrix node', () => {
|
||||
test('matrix legs that call a reusable workflow are folded into a single matrix node', () => {
|
||||
const jobs: ActionsJob[] = [
|
||||
{id: 1, link: '', jobId: 'deploy_dev', name: 'deploy (dev)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '1s'},
|
||||
{id: 2, link: '', jobId: 'deploy_qa', name: 'deploy (qa)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '1s'},
|
||||
{id: 3, link: '', jobId: 'deploy_staging', name: 'deploy (staging)', status: 'running', canRerun: false, isReusableCaller: true, parentJobID: 0, duration: '2s', callUses: './.gitea/workflows/deploy.yml'},
|
||||
{id: 1, link: '', jobId: 'prepare', name: 'prepare', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '30s'},
|
||||
{id: 2, link: '', jobId: 'build_call_linux', name: 'build-call (linux)', status: 'success', canRerun: false, isReusableCaller: true, parentJobID: 0, duration: '1m', needs: ['prepare'], callUses: './.gitea/workflows/build.yml'},
|
||||
{id: 3, link: '', jobId: 'build_call_windows', name: 'build-call (windows)', status: 'success', canRerun: false, isReusableCaller: true, parentJobID: 0, duration: '2m', needs: ['prepare'], callUses: './.gitea/workflows/build.yml'},
|
||||
{id: 4, link: '', jobId: 'build_call_macos', name: 'build-call (macos)', status: 'success', canRerun: false, isReusableCaller: true, parentJobID: 0, duration: '90s', needs: ['prepare'], callUses: './.gitea/workflows/build.yml'},
|
||||
];
|
||||
const graph = createWorkflowGraphModel(jobs);
|
||||
expect(graph.nodes.find((n) => n.id === 'job:3')?.name).toBe('deploy (staging)');
|
||||
const matrixNode = graph.nodes.find((n) => n.type === 'matrix');
|
||||
expect(matrixNode?.jobs.map((j) => j.id).sort()).toEqual([1, 2]);
|
||||
const matrixNodes = graph.nodes.filter((n) => n.type === 'matrix');
|
||||
expect(matrixNodes).toHaveLength(1);
|
||||
expect(matrixNodes[0].matrixKey).toBe('build-call');
|
||||
expect(matrixNodes[0].jobs.map((j) => j.id).sort()).toEqual([2, 3, 4]);
|
||||
});
|
||||
|
||||
test('directed highlight state covers ancestors and descendants of the hovered node', () => {
|
||||
|
||||
@@ -264,9 +264,8 @@ function buildVisualGraph(
|
||||
|
||||
const matrixJobsByKey = new Map<string, ActionsJob[]>();
|
||||
for (const job of jobs) {
|
||||
// Reusable callers are distinct workflow files — never fold them into a matrix bucket
|
||||
// even if their display name happens to look like "name (variant)".
|
||||
if (job.isReusableCaller) continue;
|
||||
// Matrix legs that call a reusable workflow are still one logical job (a single `uses:`
|
||||
// expanded over the matrix), so fold them into a matrix node like any other matrix job.
|
||||
const matrixKey = matrixKeyFromJobName(job.name);
|
||||
if (!matrixKey) continue;
|
||||
if (!matrixJobsByKey.has(matrixKey)) matrixJobsByKey.set(matrixKey, []);
|
||||
@@ -322,10 +321,8 @@ function buildVisualGraph(
|
||||
const visualIdByJobId = new Map<number, string>();
|
||||
for (const job of jobs) {
|
||||
const matrixKey = matrixKeyFromJobName(job.name);
|
||||
// Symmetric with the matrix-bucket loop above: a reusable caller whose display name
|
||||
// happens to look like "name (variant)" must never be folded into the matrix node, or it
|
||||
// would silently vanish (its visualId would point at a matrix node it isn't part of).
|
||||
if (matrixKey && !job.isReusableCaller && (matrixJobsByKey.get(matrixKey)?.length ?? 0) > 1) {
|
||||
// Symmetric with the matrix-bucket loop above (callers included).
|
||||
if (matrixKey && (matrixJobsByKey.get(matrixKey)?.length ?? 0) > 1) {
|
||||
visualIdByJobId.set(job.id, `matrix:${matrixKey}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ function initAdminRunnerBulk(toolbar: HTMLElement) {
|
||||
|
||||
const refresh = () => {
|
||||
const checked = Array.from(rowCheckboxes).filter((c) => c.checked);
|
||||
formRunnerIds.value = checked.map((c) => c.getAttribute('data-runner-id')!).join(',');
|
||||
toggleElem(toolbar, checked.length > 0);
|
||||
for (const btn of actionButtons) {
|
||||
btn.querySelector<HTMLElement>('.runner-bulk-count')!.textContent = `(${checked.length})`;
|
||||
@@ -50,15 +51,6 @@ function initAdminRunnerBulk(toolbar: HTMLElement) {
|
||||
});
|
||||
for (const cb of rowCheckboxes) cb.addEventListener('change', refresh);
|
||||
refresh();
|
||||
|
||||
const collectSelectedIds = () => {
|
||||
const ids = [];
|
||||
for (const cb of rowCheckboxes) {
|
||||
if (cb.checked) ids.push(cb.getAttribute('data-runner-id')!);
|
||||
}
|
||||
return ids.join(',');
|
||||
};
|
||||
formRunnerIds.value = collectSelectedIds();
|
||||
}
|
||||
|
||||
function initAdminUser() {
|
||||
|
||||
@@ -67,6 +67,7 @@ function initRepositoryActionsView() {
|
||||
artifactsTitle: el.getAttribute('data-locale-artifacts-title'),
|
||||
artifactExpired: el.getAttribute('data-locale-artifact-expired'),
|
||||
artifactExpiresAt: el.getAttribute('data-locale-artifact-expires-at'),
|
||||
artifactExpiredAt: el.getAttribute('data-locale-artifact-expired-at'),
|
||||
confirmDeleteArtifact: el.getAttribute('data-locale-confirm-delete-artifact'),
|
||||
showTimeStamps: el.getAttribute('data-locale-show-timestamps'),
|
||||
showLogSeconds: el.getAttribute('data-locale-show-log-seconds'),
|
||||
|
||||
Reference in New Issue
Block a user