mirror of
https://github.com/go-gitea/gitea.git
synced 2026-09-08 22:13:26 +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 |
@@ -59,7 +59,12 @@ func NewInterpeter(
|
|||||||
ee := &exprparser.EvaluationEnvironment{
|
ee := &exprparser.EvaluationEnvironment{
|
||||||
Github: gitCtx,
|
Github: gitCtx,
|
||||||
Env: nil, // no need
|
Env: nil, // no need
|
||||||
Job: 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
|
Steps: nil, // no need
|
||||||
Runner: nil, // no need
|
Runner: nil, // no need
|
||||||
Secrets: 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,
|
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)
|
expr, err := rewriteSubExpression(job.If.Value, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
package jobparser
|
package jobparser
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"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 {
|
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"
|
// ref: https://git-scm.com/docs/git-interpret-trailers
|
||||||
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*)$`)
|
// 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
|
// 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")]
|
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 {
|
func CommitMessageParseTrailer(s string) CommitMessageTrailerValues {
|
||||||
ret := CommitMessageTrailerValues{}
|
ret := CommitMessageTrailerValues{}
|
||||||
for line := range strings.SplitSeq(util.NormalizeStringEOL(s), "\n") {
|
for line := range strings.SplitSeq(util.NormalizeStringEOL(s), "\n") {
|
||||||
|
|||||||
@@ -26,10 +26,12 @@ func TestCommitMessageTrailer(t *testing.T) {
|
|||||||
{"a", "a", "", ""},
|
{"a", "a", "", ""},
|
||||||
{"a\n\nk", "a\n\nk", "", ""},
|
{"a\n\nk", "a\n\nk", "", ""},
|
||||||
{"a\n\nk:v", "a", "\n\n", "k:v"},
|
{"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\n\n", "a", "\n\n", "k:v\n\n"},
|
||||||
{"a\n--\nk:v", "a\n--\nk:v", "", ""},
|
{"a\n--\nk:v", "a\n--\nk:v", "", ""},
|
||||||
{"a\n---\nk:v", "a", "\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"},
|
{"a\n\n---\n\nk:v", "a", "\n\n---\n\n", "k:v"},
|
||||||
|
|
||||||
{"k: v", "", "", "k: v"},
|
{"k: v", "", "", "k: v"},
|
||||||
{"\nk:v", "", "\n", "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")
|
cmd := gitcmd.NewCommand().AddArguments("clone")
|
||||||
// Never follow HTTP redirects: no clone caller needs them, and a remote redirecting to an
|
HandleGitCmdHTTPRedirection(cmd, from, to)
|
||||||
// otherwise-blocked address would be an SSRF vector (e.g. migrating from an attacker URL).
|
|
||||||
cmd.AddArguments("-c", "http.followRedirects=false")
|
|
||||||
if opts.SkipTLSVerify {
|
if opts.SkipTLSVerify {
|
||||||
cmd.AddArguments("-c", "http.sslVerify=false")
|
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
|
// TestCloneRefusesRedirects ensures Clone never follows HTTP redirects, so a remote
|
||||||
// cannot redirect to an otherwise-blocked address (SSRF, e.g. during migration).
|
// cannot redirect to an otherwise-blocked address (SSRF, e.g. during migration).
|
||||||
func TestCloneRefusesRedirects(t *testing.T) {
|
func TestCloneRefusesRedirects(t *testing.T) {
|
||||||
|
t.Skip("FIXME: GIT-CLONE-HTTP-REDIRECT-SSRF: need a complete solution in the future")
|
||||||
var targetHit atomic.Bool
|
var targetHit atomic.Bool
|
||||||
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
targetHit.Store(true)
|
targetHit.Store(true)
|
||||||
|
|||||||
@@ -124,6 +124,7 @@
|
|||||||
"artifacts": "Artifacts",
|
"artifacts": "Artifacts",
|
||||||
"expired": "Expired",
|
"expired": "Expired",
|
||||||
"artifact_expires_at": "Expires at %s",
|
"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'?",
|
"confirm_delete_artifact": "Are you sure you want to delete the artifact '%s'?",
|
||||||
"archived": "Archived",
|
"archived": "Archived",
|
||||||
"concept_system_global": "Global",
|
"concept_system_global": "Global",
|
||||||
@@ -2251,7 +2252,6 @@
|
|||||||
"repo.settings.webhook_deletion_success": "The webhook has been removed.",
|
"repo.settings.webhook_deletion_success": "The webhook has been removed.",
|
||||||
"repo.settings.webhook.test_delivery": "Test Push Event",
|
"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": "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.request": "Request",
|
||||||
"repo.settings.webhook.response": "Response",
|
"repo.settings.webhook.response": "Response",
|
||||||
"repo.settings.webhook.headers": "Headers",
|
"repo.settings.webhook.headers": "Headers",
|
||||||
@@ -3784,6 +3784,9 @@
|
|||||||
"actions.runs.pushed_by": "pushed by",
|
"actions.runs.pushed_by": "pushed by",
|
||||||
"actions.runs.invalid_workflow_helper": "Workflow config file is invalid. Please check your config file: %s",
|
"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_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_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.no_job": "The workflow must contain at least one job",
|
||||||
"actions.runs.invalid_reusable_workflow_uses": "Invalid reusable workflow \"uses\": %s",
|
"actions.runs.invalid_reusable_workflow_uses": "Invalid reusable workflow \"uses\": %s",
|
||||||
|
|||||||
@@ -67,15 +67,34 @@ func GetRepositoryFile(ctx *context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
branch := ctx.PathParam("branch")
|
||||||
|
repository := ctx.PathParam("repository")
|
||||||
|
architecture := ctx.PathParam("architecture")
|
||||||
|
|
||||||
s, u, pf, err := packages_service.OpenFileForDownloadByPackageVersion(
|
s, u, pf, err := packages_service.OpenFileForDownloadByPackageVersion(
|
||||||
ctx,
|
ctx,
|
||||||
pv,
|
pv,
|
||||||
&packages_service.PackageFileInfo{
|
&packages_service.PackageFileInfo{
|
||||||
Filename: alpine_service.IndexArchiveFilename,
|
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,
|
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 err != nil {
|
||||||
if errors.Is(err, util.ErrNotExist) {
|
if errors.Is(err, util.ErrNotExist) {
|
||||||
apiError(ctx, http.StatusNotFound, err)
|
apiError(ctx, http.StatusNotFound, err)
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ func TestHook(ctx *context.APIContext) {
|
|||||||
commit := convert.ToPayloadCommit(ctx, ctx.Repo.Repository, ctx.Repo.Commit)
|
commit := convert.ToPayloadCommit(ctx, ctx.Repo.Repository, ctx.Repo.Commit)
|
||||||
|
|
||||||
commitID := ctx.Repo.Commit.ID.String()
|
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,
|
Ref: ref,
|
||||||
Before: commitID,
|
Before: commitID,
|
||||||
After: commitID,
|
After: commitID,
|
||||||
|
|||||||
@@ -382,6 +382,8 @@ func MockActionsRunsJobs(ctx *context.Context) {
|
|||||||
// └ deep_job (regular)
|
// └ deep_job (regular)
|
||||||
// cross_caller (caller, cross-repo, expanded)
|
// cross_caller (caller, cross-repo, expanded)
|
||||||
// └ external_job (regular)
|
// └ 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)
|
// final (regular, needs local_caller + cross_caller)
|
||||||
const (
|
const (
|
||||||
prepareID = int64(400)
|
prepareID = int64(400)
|
||||||
@@ -392,6 +394,21 @@ func MockActionsRunsJobs(ctx *context.Context) {
|
|||||||
crossCallerID = int64(405)
|
crossCallerID = int64(405)
|
||||||
externalJobID = int64(406)
|
externalJobID = int64(406)
|
||||||
finalID = int64(407)
|
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{
|
resp.State.Run.Jobs = []*actions.ViewJob{
|
||||||
@@ -432,6 +449,53 @@ func MockActionsRunsJobs(ctx *context.Context) {
|
|||||||
Status: actions_model.StatusWaiting.String(), Duration: "0s",
|
Status: actions_model.StatusWaiting.String(), Duration: "0s",
|
||||||
ParentJobID: crossCallerID,
|
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",
|
ID: finalID, Link: jobLink(finalID), JobID: "final", Name: "final",
|
||||||
Status: actions_model.StatusBlocked.String(), Duration: "0s",
|
Status: actions_model.StatusBlocked.String(), Duration: "0s",
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import (
|
|||||||
"gitea.dev/modules/httplib"
|
"gitea.dev/modules/httplib"
|
||||||
"gitea.dev/modules/json"
|
"gitea.dev/modules/json"
|
||||||
"gitea.dev/modules/log"
|
"gitea.dev/modules/log"
|
||||||
|
"gitea.dev/modules/optional"
|
||||||
"gitea.dev/modules/storage"
|
"gitea.dev/modules/storage"
|
||||||
api "gitea.dev/modules/structs"
|
api "gitea.dev/modules/structs"
|
||||||
"gitea.dev/modules/templates"
|
"gitea.dev/modules/templates"
|
||||||
@@ -719,6 +720,7 @@ func fillViewRunResponseSummary(ctx *context_module.Context, resp *ViewResponse,
|
|||||||
Name: art.ArtifactName,
|
Name: art.ArtifactName,
|
||||||
Size: art.FileSize,
|
Size: art.FileSize,
|
||||||
Status: util.Iif(art.Status == actions_model.ArtifactStatusExpired, "expired", "completed"),
|
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)
|
resp.State.CurrentJob.Detail = current.Status.LocaleString(ctx.Locale)
|
||||||
if run.NeedApproval {
|
if run.NeedApproval {
|
||||||
resp.State.CurrentJob.Detail = ctx.Locale.TrString("actions.need_approval_desc")
|
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.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
|
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) {
|
func convertToViewModel(ctx context.Context, locale translation.Locale, cursors []LogCursor, task *actions_model.ActionTask) ([]*ViewJobStep, []*ViewStepLog, error) {
|
||||||
var viewJobs []*ViewJobStep
|
var viewJobs []*ViewJobStep
|
||||||
var logs []*ViewStepLog
|
var logs []*ViewStepLog
|
||||||
|
|||||||
@@ -138,3 +138,48 @@ func TestConvertToViewModelCancellingTaskDoesNotRenderRunningSteps(t *testing.T)
|
|||||||
}
|
}
|
||||||
assert.Equal(t, expectedViewJobs, viewJobSteps)
|
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 := ""
|
wontSignReason := ""
|
||||||
if ctx.Doer != nil {
|
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.willSign = sign
|
||||||
data.signingKeyMergeDisplay = asymkey_model.GetDisplaySigningKey(key)
|
data.signingKeyMergeDisplay = asymkey_model.GetDisplaySigningKey(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import (
|
|||||||
pull_model "gitea.dev/models/pull"
|
pull_model "gitea.dev/models/pull"
|
||||||
repo_model "gitea.dev/models/repo"
|
repo_model "gitea.dev/models/repo"
|
||||||
"gitea.dev/models/unit"
|
"gitea.dev/models/unit"
|
||||||
|
"gitea.dev/modules/git"
|
||||||
|
"gitea.dev/modules/log"
|
||||||
"gitea.dev/modules/svg"
|
"gitea.dev/modules/svg"
|
||||||
"gitea.dev/modules/templates"
|
"gitea.dev/modules/templates"
|
||||||
"gitea.dev/modules/util"
|
"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)
|
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)
|
var defaultMergeTitle, defaultMergeBody string
|
||||||
if err != nil && !errors.Is(err, util.ErrNotExist) {
|
var defaultSquashMergeTitle, defaultSquashMergeBody string
|
||||||
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 defaultSquashMergeCommitMessages string
|
var defaultSquashMergeCommitMessages string
|
||||||
if !prInfo.IsPullRequestBroken {
|
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
|
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
|
// if this pr can be merged now, then hide the auto merge
|
||||||
generalHideAutoMerge := prInfo.MergeBoxData.canMergeNow && allOverridableChecksOk
|
generalHideAutoMerge := prInfo.MergeBoxData.canMergeNow && allOverridableChecksOk
|
||||||
|
|
||||||
var mergeStyles []any
|
var mergeStyles []any
|
||||||
if pull.IsStatusMergeable() {
|
if pull.IsStatusMergeable() {
|
||||||
mergeStyles = []any{
|
mergeStyles = []any{
|
||||||
@@ -138,7 +142,7 @@ func (prInfo *pullRequestViewInfo) prepareMergeBoxFormProps(ctx *context.Context
|
|||||||
"allowed": prConfig.AllowSquash,
|
"allowed": prConfig.AllowSquash,
|
||||||
"textDoMerge": ctx.Locale.Tr("repo.pulls.squash_merge_pull_request"),
|
"textDoMerge": ctx.Locale.Tr("repo.pulls.squash_merge_pull_request"),
|
||||||
"mergeTitleFieldText": defaultSquashMergeTitle,
|
"mergeTitleFieldText": defaultSquashMergeTitle,
|
||||||
"mergeMessageFieldText": defaultSquashMergeCommitMessages + defaultSquashMergeBody,
|
"mergeMessageFieldText": git.CommitMessageMerge(defaultSquashMergeCommitMessages, defaultSquashMergeBody),
|
||||||
"hideAutoMerge": generalHideAutoMerge,
|
"hideAutoMerge": generalHideAutoMerge,
|
||||||
},
|
},
|
||||||
map[string]any{
|
map[string]any{
|
||||||
|
|||||||
@@ -664,19 +664,14 @@ func TestWebhook(ctx *context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Grab latest commit or fake one if it's empty repository.
|
// use a fake commit to test webhook
|
||||||
// Note: in old code, the "ctx.Repo.Commit" is the last commit of the default branch.
|
ghostUser := user_model.NewGhostUser()
|
||||||
// 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)
|
objectFormat := git.ObjectFormatFromName(ctx.Repo.Repository.ObjectFormatName)
|
||||||
commit = &git.Commit{
|
commit := &git.Commit{
|
||||||
ID: objectFormat.EmptyObjectID(),
|
ID: objectFormat.EmptyObjectID(),
|
||||||
Author: ghost.NewGitSig(),
|
Author: ghostUser.NewGitSig(),
|
||||||
Committer: ghost.NewGitSig(),
|
Committer: ghostUser.NewGitSig(),
|
||||||
CommitMessage: git.CommitMessage{MessageRaw: "This is a fake commit"},
|
CommitMessage: git.CommitMessage{MessageRaw: "This is a fake commit for webhook push test"},
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
apiUser := convert.ToUserWithAccessMode(ctx, ctx.Doer, perm.AccessModeNone)
|
apiUser := convert.ToUserWithAccessMode(ctx, ctx.Doer, perm.AccessModeNone)
|
||||||
@@ -697,7 +692,7 @@ func TestWebhook(ctx *context.Context) {
|
|||||||
|
|
||||||
commitID := commit.ID.String()
|
commitID := commit.ID.String()
|
||||||
p := &api.PushPayload{
|
p := &api.PushPayload{
|
||||||
Ref: git.BranchPrefix + ctx.Repo.Repository.DefaultBranch,
|
Ref: git.RefNameFromBranch(ctx.Repo.Repository.DefaultBranch).String(),
|
||||||
Before: commitID,
|
Before: commitID,
|
||||||
After: commitID,
|
After: commitID,
|
||||||
CompareURL: setting.AppURL + ctx.Repo.Repository.ComposeCompareURL(commitID, commitID),
|
CompareURL: setting.AppURL + ctx.Repo.Repository.ComposeCompareURL(commitID, commitID),
|
||||||
@@ -708,8 +703,8 @@ func TestWebhook(ctx *context.Context) {
|
|||||||
Pusher: apiUser,
|
Pusher: apiUser,
|
||||||
Sender: apiUser,
|
Sender: apiUser,
|
||||||
}
|
}
|
||||||
if err := webhook_service.PrepareWebhook(ctx, w, webhook_module.HookEventPush, p); err != nil {
|
if err := webhook_service.PrepareTestWebhook(ctx, w, webhook_module.HookEventPush, p); err != nil {
|
||||||
ctx.Flash.Error("PrepareWebhook: " + err.Error())
|
ctx.Flash.Error("PrepareTestWebhook: " + err.Error())
|
||||||
ctx.Status(http.StatusInternalServerError)
|
ctx.Status(http.StatusInternalServerError)
|
||||||
} else {
|
} else {
|
||||||
ctx.Flash.Info(ctx.Tr("repo.settings.webhook.delivery.success"))
|
ctx.Flash.Info(ctx.Tr("repo.settings.webhook.delivery.success"))
|
||||||
|
|||||||
@@ -373,14 +373,16 @@ func RunnerBulkActionPost(ctx *context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var runnerIDs []int64
|
if !rCtx.IsAdmin {
|
||||||
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"
|
// 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
|
// 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!
|
// In the future, if you need to support such operation on non-admin pages, be careful!
|
||||||
runnerIDs = ctx.FormStringInt64s("ids")
|
runnerIDs := ctx.FormStringInt64s("ids")
|
||||||
} else {
|
if len(runnerIDs) == 0 {
|
||||||
ctx.HTTPError(http.StatusForbidden, "bulk actions are admin-only")
|
ctx.HTTPError(http.StatusBadRequest, "missing runner IDs")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+130
-91
@@ -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.
|
// 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 {
|
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 cancelledConcurrencyJobs []*actions_model.ActionRunJob
|
||||||
var hasWaitingCallerJobs bool
|
var needPostCommitEmit bool
|
||||||
if err := db.WithTx(ctx, func(ctx context.Context) error {
|
if err := db.WithTx(ctx, func(ctx context.Context) error {
|
||||||
index, err := db.GetNextResourceIndex(ctx, "action_run_index", run.RepoID)
|
index, err := db.GetNextResourceIndex(ctx, "action_run_index", run.RepoID)
|
||||||
if err != nil {
|
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))
|
runJobs := make([]*actions_model.ActionRunJob, 0, len(jobs))
|
||||||
var hasWaitingJobs bool
|
var hasWaitingJobs bool
|
||||||
|
|
||||||
for _, v := range jobs {
|
for _, v := range jobs {
|
||||||
id, job := v.Job()
|
runJob, jobsToCancel, jobNeedsPostCommitEmit, err := insertRunJob(ctx, run, runAttempt, v, vars, inputs)
|
||||||
needs := job.Needs()
|
if err != nil {
|
||||||
if err := v.SetJob(id, job.EraseNeeds()); err != nil {
|
|
||||||
return err
|
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)
|
|
||||||
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...)
|
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.
|
// 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)
|
hasWaitingJobs = hasWaitingJobs || (runJob.Status == actions_model.StatusWaiting && !runJob.IsReusableCaller)
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
runJobs = append(runJobs, runJob)
|
runJobs = append(runJobs, runJob)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,8 +165,8 @@ func InsertRun(ctx context.Context, run *actions_model.ActionRun, content []byte
|
|||||||
NotifyWorkflowJobsAndRunsStatusUpdate(ctx, cancelledConcurrencyJobs)
|
NotifyWorkflowJobsAndRunsStatusUpdate(ctx, cancelledConcurrencyJobs)
|
||||||
EmitJobsIfReadyByJobs(cancelledConcurrencyJobs)
|
EmitJobsIfReadyByJobs(cancelledConcurrencyJobs)
|
||||||
|
|
||||||
// Post-commit kick for expanded callers: let job_emitter resolve its child jobs
|
// Post-commit kick: let the job emitter resolve jobs if needed
|
||||||
if hasWaitingCallerJobs {
|
if needPostCommitEmit {
|
||||||
if err := EmitJobsIfReadyByRun(run.ID); err != nil {
|
if err := EmitJobsIfReadyByRun(run.ID); err != nil {
|
||||||
log.Error("emit run %d after InsertRun: %v", run.ID, err)
|
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
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"maps"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
actions_model "gitea.dev/models/actions"
|
actions_model "gitea.dev/models/actions"
|
||||||
"gitea.dev/models/db"
|
"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"
|
repo_model "gitea.dev/models/repo"
|
||||||
"gitea.dev/models/unit"
|
"gitea.dev/models/unit"
|
||||||
|
user_model "gitea.dev/models/user"
|
||||||
"gitea.dev/modules/json"
|
"gitea.dev/modules/json"
|
||||||
"gitea.dev/modules/log"
|
"gitea.dev/modules/log"
|
||||||
"gitea.dev/modules/timeutil"
|
"gitea.dev/modules/timeutil"
|
||||||
webhook_module "gitea.dev/modules/webhook"
|
webhook_module "gitea.dev/modules/webhook"
|
||||||
|
"gitea.dev/services/convert"
|
||||||
)
|
)
|
||||||
|
|
||||||
// StartScheduleTasks start the task
|
// 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.
|
// 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 {
|
func CreateScheduleTask(ctx context.Context, spec *actions_model.ActionScheduleSpec) error {
|
||||||
cron := spec.Schedule
|
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
|
// Create a new action run based on the schedule
|
||||||
run := &actions_model.ActionRun{
|
run := &actions_model.ActionRun{
|
||||||
@@ -134,7 +152,7 @@ func CreateScheduleTask(ctx context.Context, spec *actions_model.ActionScheduleS
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func withScheduleInEventPayload(eventPayload, schedule string) string {
|
func withScheduleInEventPayload(eventPayload, schedule string, fields map[string]any) string {
|
||||||
if schedule == "" {
|
if schedule == "" {
|
||||||
return eventPayload
|
return eventPayload
|
||||||
}
|
}
|
||||||
@@ -153,6 +171,7 @@ func withScheduleInEventPayload(eventPayload, schedule string) string {
|
|||||||
event = map[string]any{}
|
event = map[string]any{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
maps.Copy(event, fields)
|
||||||
event["schedule"] = schedule
|
event["schedule"] = schedule
|
||||||
updatedPayload, err := json.Marshal(event)
|
updatedPayload, err := json.Marshal(event)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.dev/modules/json"
|
"gitea.dev/modules/json"
|
||||||
|
api "gitea.dev/modules/structs"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
@@ -14,7 +15,7 @@ import (
|
|||||||
func TestWithScheduleInEventPayload(t *testing.T) {
|
func TestWithScheduleInEventPayload(t *testing.T) {
|
||||||
t.Run("adds schedule to existing payload", func(t *testing.T) {
|
t.Run("adds schedule to existing payload", func(t *testing.T) {
|
||||||
payload := `{"ref":"refs/heads/main"}`
|
payload := `{"ref":"refs/heads/main"}`
|
||||||
updated := withScheduleInEventPayload(payload, "*/5 * * * *")
|
updated := withScheduleInEventPayload(payload, "*/5 * * * *", nil)
|
||||||
|
|
||||||
event := map[string]any{}
|
event := map[string]any{}
|
||||||
assert.NoError(t, json.Unmarshal([]byte(updated), &event))
|
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) {
|
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{}
|
event := map[string]any{}
|
||||||
assert.NoError(t, json.Unmarshal([]byte(updated), &event))
|
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) {
|
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{}
|
event := map[string]any{}
|
||||||
assert.NoError(t, json.Unmarshal([]byte(updated), &event))
|
assert.NoError(t, json.Unmarshal([]byte(updated), &event))
|
||||||
assert.Equal(t, "37 12 5 1 2", event["schedule"])
|
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) {
|
t.Run("keeps payload when schedule empty", func(t *testing.T) {
|
||||||
payload := `{"ref":"refs/heads/main"}`
|
payload := `{"ref":"refs/heads/main"}`
|
||||||
updated := withScheduleInEventPayload(payload, "")
|
updated := withScheduleInEventPayload(payload, "", nil)
|
||||||
assert.Equal(t, payload, updated)
|
assert.Equal(t, payload, updated)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("keeps payload when malformed JSON", func(t *testing.T) {
|
t.Run("keeps payload when malformed JSON", func(t *testing.T) {
|
||||||
payload := `not a json object`
|
payload := `not a json object`
|
||||||
updated := withScheduleInEventPayload(payload, "*/5 * * * *")
|
updated := withScheduleInEventPayload(payload, "*/5 * * * *", nil)
|
||||||
assert.Equal(t, payload, updated)
|
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 {
|
if err = processInputs(workflowDispatch, inputsWithDefaults); err != nil {
|
||||||
return 0, err
|
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
|
// ctx.Req.PostForm -> WorkflowDispatchPayload.Inputs -> ActionRun.EventPayload -> runner: ghc.Event
|
||||||
// https://docs.github.com/en/actions/learn-github-actions/contexts#github-context
|
// https://docs.github.com/en/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
|
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.
|
// resolveDispatchWorkflowContent returns the YAML for a dispatched workflow and records its source on the run.
|
||||||
// - Repo-level: from the consumer's runTargetCommit.
|
// - Repo-level: from the consumer's runTargetCommit.
|
||||||
// - Scoped: from the source repo's default branch.
|
// - 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"])
|
||||||
|
}
|
||||||
@@ -270,19 +270,21 @@ Loop:
|
|||||||
return true, signingKey, sig, nil
|
return true, signingKey, sig, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SignMerge determines if we should sign a PR merge commit to the base repository
|
// 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) {
|
// 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 {
|
if err := pr.LoadBaseRepo(ctx); err != nil {
|
||||||
log.Error("Unable to get Base Repo for pull request")
|
log.Error("Unable to get Base Repo for pull request")
|
||||||
return false, nil, nil, err
|
return false, nil, nil, err
|
||||||
}
|
}
|
||||||
repo := pr.BaseRepo
|
repo := pr.BaseRepo
|
||||||
|
|
||||||
baseCommit, err := gitRepo.GetCommit(pr.BaseBranch)
|
baseCommit, err := gitRepo.GetCommit(baseRef)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, nil, nil, err
|
return false, nil, nil, err
|
||||||
}
|
}
|
||||||
headCommit, err := gitRepo.GetCommit(pr.GetGitHeadRefName())
|
headCommit, err := gitRepo.GetCommit(headRef)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, nil, nil, err
|
return false, nil, nil, err
|
||||||
}
|
}
|
||||||
@@ -338,7 +340,7 @@ Loop:
|
|||||||
return false, nil, nil, &ErrWontSign{headSigned}
|
return false, nil, nil, &ErrWontSign{headSigned}
|
||||||
}
|
}
|
||||||
case commitsSigned:
|
case commitsSigned:
|
||||||
verified, err := AllHeadCommitsVerified(ctx, pr, gitRepo)
|
verified, err := allCommitsVerified(ctx, baseCommit, headCommit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, nil, nil, err
|
return false, nil, nil, err
|
||||||
}
|
}
|
||||||
@@ -361,11 +363,13 @@ func AllHeadCommitsVerified(ctx context.Context, pr *issues_model.PullRequest, g
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
mergeBaseCommit, err := gitrepo.MergeBase(ctx, pr.BaseRepo, baseCommit.ID.String(), headCommit.ID.String())
|
return allCommitsVerified(ctx, baseCommit, headCommit)
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
}
|
||||||
commitList, err := headCommit.CommitsBeforeUntil(git.RefNameFromCommit(mergeBaseCommit))
|
|
||||||
|
// 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 {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1285,6 +1285,8 @@ func getDiffBasic(ctx context.Context, gitRepo *git.Repository, opts *DiffOption
|
|||||||
return nil, nil, nil, err
|
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().
|
cmdDiff := gitcmd.NewCommand().
|
||||||
AddArguments("diff", "--src-prefix=\\a/", "--dst-prefix=\\b/").
|
AddArguments("diff", "--src-prefix=\\a/", "--dst-prefix=\\b/").
|
||||||
AddArguments(opts.WhitespaceBehavior...).
|
AddArguments(opts.WhitespaceBehavior...).
|
||||||
@@ -1405,8 +1407,12 @@ func highlightCodeLines(name, lang string, sections []*DiffSection, isLeft bool,
|
|||||||
if setting.Git.DisableDiffHighlight || len(rawContent) > MaxFullFileHighlightSizeLimit {
|
if setting.Git.DisableDiffHighlight || len(rawContent) > MaxFullFileHighlightSizeLimit {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
content := util.UnsafeBytesToString(charset.ToUTF8(rawContent, charset.ConvertOpts{}))
|
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)
|
lexer := highlight.DetectChromaLexerByFileName(name, lang)
|
||||||
highlightedNewContent := highlight.RenderCodeByLexer(lexer, content)
|
highlightedNewContent := highlight.RenderCodeByLexer(lexer, content)
|
||||||
unsafeLines := highlight.UnsafeSplitHighlightedLines(highlightedNewContent)
|
unsafeLines := highlight.UnsafeSplitHighlightedLines(highlightedNewContent)
|
||||||
|
|||||||
@@ -1143,6 +1143,19 @@ func TestHighlightCodeLines(t *testing.T) {
|
|||||||
1: `<span class="n">b</span>` + nl,
|
1: `<span class="n">b</span>` + nl,
|
||||||
}, ret)
|
}, 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) {
|
func TestSyncUserSpecificDiff_UpdatedFiles(t *testing.T) {
|
||||||
|
|||||||
@@ -99,7 +99,9 @@ func composeIssueCommentMessages(ctx context.Context, comment *mailComment, lang
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
locale := translation.NewLocale(lang)
|
locale := translation.NewLocale(lang)
|
||||||
|
if lang == "mock" {
|
||||||
|
locale = &translation.MockLocale{}
|
||||||
|
}
|
||||||
mailMeta := map[string]any{
|
mailMeta := map[string]any{
|
||||||
"locale": locale,
|
"locale": locale,
|
||||||
"FallbackSubject": fallback,
|
"FallbackSubject": fallback,
|
||||||
|
|||||||
@@ -18,10 +18,14 @@ import (
|
|||||||
|
|
||||||
actions_model "gitea.dev/models/actions"
|
actions_model "gitea.dev/models/actions"
|
||||||
activities_model "gitea.dev/models/activities"
|
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"
|
issues_model "gitea.dev/models/issues"
|
||||||
repo_model "gitea.dev/models/repo"
|
repo_model "gitea.dev/models/repo"
|
||||||
"gitea.dev/models/unittest"
|
"gitea.dev/models/unittest"
|
||||||
user_model "gitea.dev/models/user"
|
user_model "gitea.dev/models/user"
|
||||||
|
"gitea.dev/modules/git"
|
||||||
"gitea.dev/modules/markup"
|
"gitea.dev/modules/markup"
|
||||||
"gitea.dev/modules/setting"
|
"gitea.dev/modules/setting"
|
||||||
"gitea.dev/modules/storage"
|
"gitea.dev/modules/storage"
|
||||||
@@ -556,3 +560,32 @@ func TestEmbedBase64Images(t *testing.T) {
|
|||||||
assert.Equal(t, expected, string(resultMailBody))
|
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 {
|
func pruneBrokenReferences(ctx context.Context, m *repo_model.Mirror, gitRepo gitrepo.Repository, timeout time.Duration) error {
|
||||||
// Never follow HTTP redirects, see cmdFetch in runSync.
|
// 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)
|
stdout, _, pruneErr := gitrepo.RunCmdString(ctx, gitRepo, cmd)
|
||||||
if pruneErr != nil {
|
if pruneErr != nil {
|
||||||
// sanitize the output, since it may contain the remote address, which may contain a password
|
// 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
|
// use fetch but not remote update because git fetch support --tags but remote update doesn't
|
||||||
cmdFetch := func() *gitcmd.Command {
|
cmdFetch := func() *gitcmd.Command {
|
||||||
// Never follow HTTP redirects: a mirror remote that later starts redirecting to an
|
cmd := gitcmd.NewCommand("fetch", "--tags")
|
||||||
// otherwise-blocked address would be an SSRF/exfiltration vector on scheduled syncs.
|
git.HandleGitCmdHTTPRedirection(cmd, m.GetRemoteName())
|
||||||
cmd := gitcmd.NewCommand("fetch", "--tags").AddConfig("http.followRedirects", "false")
|
|
||||||
if m.EnablePrune {
|
if m.EnablePrune {
|
||||||
cmd.AddArguments("--prune")
|
cmd.AddArguments("--prune")
|
||||||
}
|
}
|
||||||
@@ -212,9 +212,9 @@ func runSync(ctx context.Context, m *repo_model.Mirror) ([]*repo_module.SyncResu
|
|||||||
}
|
}
|
||||||
|
|
||||||
cmdRemoteUpdatePrune := func() *gitcmd.Command {
|
cmdRemoteUpdatePrune := func() *gitcmd.Command {
|
||||||
// Never follow HTTP redirects, see cmdFetch above.
|
cmd := gitcmd.NewCommand("remote", "update", "--prune").AddDynamicArguments(m.GetRemoteName()).WithTimeout(timeout).WithEnv(envs)
|
||||||
return gitcmd.NewCommand("remote", "update", "--prune").AddConfig("http.followRedirects", "false").
|
git.HandleGitCmdHTTPRedirection(cmd, m.GetRemoteName())
|
||||||
AddDynamicArguments(m.GetRemoteName()).WithTimeout(timeout).WithEnv(envs)
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
if repo_service.HasWiki(ctx, m.Repo) {
|
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 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
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import (
|
|||||||
user_model "gitea.dev/models/user"
|
user_model "gitea.dev/models/user"
|
||||||
"gitea.dev/modules/git"
|
"gitea.dev/modules/git"
|
||||||
"gitea.dev/modules/git/gitcmd"
|
"gitea.dev/modules/git/gitcmd"
|
||||||
"gitea.dev/modules/gitrepo"
|
|
||||||
"gitea.dev/modules/log"
|
"gitea.dev/modules/log"
|
||||||
"gitea.dev/modules/util"
|
"gitea.dev/modules/util"
|
||||||
asymkey_service "gitea.dev/services/asymkey"
|
asymkey_service "gitea.dev/services/asymkey"
|
||||||
@@ -103,15 +102,18 @@ func createTemporaryRepoForMerge(ctx context.Context, pr *issues_model.PullReque
|
|||||||
mergeCtx.sig = doer.NewGitSig()
|
mergeCtx.sig = doer.NewGitSig()
|
||||||
mergeCtx.committer = mergeCtx.sig
|
mergeCtx.committer = mergeCtx.sig
|
||||||
|
|
||||||
gitRepo, err := gitrepo.OpenRepository(ctx, pr.BaseRepo)
|
gitRepo, err := git.OpenRepository(ctx, mergeCtx.tmpBasePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
defer cancel()
|
defer cancel()
|
||||||
return nil, nil, fmt.Errorf("failed to open temp git repo for pr[%d]: %w", mergeCtx.pr.ID, err)
|
return nil, nil, fmt.Errorf("failed to open temp git repo for pr[%d]: %w", mergeCtx.pr.ID, err)
|
||||||
}
|
}
|
||||||
defer gitRepo.Close()
|
defer gitRepo.Close()
|
||||||
|
|
||||||
// Determine if we should sign
|
// Determine if we should sign, using the temp repo's own refs (see SignMerge for why)
|
||||||
sign, key, signer, _ := asymkey_service.SignMerge(ctx, pr, doer, gitRepo)
|
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 {
|
if sign {
|
||||||
mergeCtx.signKey = key
|
mergeCtx.signKey = key
|
||||||
if pr.BaseRepo.GetTrustModel() == repo_model.CommitterTrustModel || pr.BaseRepo.GetTrustModel() == repo_model.CollaboratorCommitterTrustModel {
|
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)
|
// 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 {
|
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 "", err
|
||||||
return ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := pr.Issue.LoadPoster(ctx); err != nil {
|
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 "", err
|
||||||
return ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if pr.HeadRepo == nil {
|
if pr.HeadRepo == nil {
|
||||||
var err error
|
|
||||||
pr.HeadRepo, err = repo_model.GetRepositoryByID(ctx, pr.HeadRepoID)
|
pr.HeadRepo, err = repo_model.GetRepositoryByID(ctx, pr.HeadRepoID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("GetRepositoryByIdCtx[%d]: %v", pr.HeadRepoID, err)
|
return "", err
|
||||||
return ""
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
gitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, pr.HeadRepo)
|
gitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, pr.HeadRepo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("Unable to open head repository: Error: %v", err)
|
return "", err
|
||||||
return ""
|
|
||||||
}
|
}
|
||||||
defer closer.Close()
|
defer closer.Close()
|
||||||
|
|
||||||
@@ -810,8 +805,7 @@ func GetSquashMergeCommitMessages(ctx context.Context, pr *issues_model.PullRequ
|
|||||||
} else {
|
} else {
|
||||||
pr.HeadCommitID, err = gitRepo.GetRefCommitID(pr.GetGitHeadRefName())
|
pr.HeadCommitID, err = gitRepo.GetRefCommitID(pr.GetGitHeadRefName())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("Unable to get head commit: %s Error: %v", pr.GetGitHeadRefName(), err)
|
return "", err
|
||||||
return ""
|
|
||||||
}
|
}
|
||||||
headCommitRef = git.RefNameFromCommit(pr.HeadCommitID)
|
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)
|
limitedCommits, err := gitRepo.CommitsBetween(headCommitRef, mergeBaseRef, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("Unable to get commits between: %s %s Error: %v", pr.HeadBranch, pr.MergeBase, err)
|
return "", err
|
||||||
return ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
mergeMessage := strings.TrimSpace(pr.Issue.Content) // use PR's title and description as squash commit message
|
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
|
mergeMessage = formatSquashMergeCommitMessages(limitedCommits) // use PR's commit messages as squash commit message
|
||||||
}
|
}
|
||||||
coAuthors := collectSquashMergeCommitCoAuthors(ctx, gitRepo, pr, headCommitRef, mergeBaseRef, limit, limitedCommits)
|
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 {
|
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},
|
&user_model.Blocking{BlockeeID: u.ID},
|
||||||
&actions_model.ActionRunnerToken{OwnerID: u.ID},
|
&actions_model.ActionRunnerToken{OwnerID: u.ID},
|
||||||
&actions_model.ActionScopedWorkflowSource{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 {
|
); err != nil {
|
||||||
return fmt.Errorf("deleteBeans: %w", err)
|
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()) {
|
if !u.IsOrganization() && !setting.Service.AllowedUserVisibilityModesSlice.IsAllowedVisibility(opts.Visibility.Value()) {
|
||||||
return fmt.Errorf("visibility mode not allowed: %s", opts.Visibility.Value().String())
|
return fmt.Errorf("visibility mode not allowed: %s", opts.Visibility.Value().String())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import (
|
|||||||
user_model "gitea.dev/models/user"
|
user_model "gitea.dev/models/user"
|
||||||
password_module "gitea.dev/modules/auth/password"
|
password_module "gitea.dev/modules/auth/password"
|
||||||
"gitea.dev/modules/optional"
|
"gitea.dev/modules/optional"
|
||||||
|
"gitea.dev/modules/setting"
|
||||||
"gitea.dev/modules/structs"
|
"gitea.dev/modules/structs"
|
||||||
|
"gitea.dev/modules/test"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
@@ -121,3 +123,33 @@ func TestUpdateAuth(t *testing.T) {
|
|||||||
Password: optional.Some("aaaa"),
|
Password: optional.Some("aaaa"),
|
||||||
}), password_module.ErrMinLength)
|
}), 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"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
activities_model "gitea.dev/models/activities"
|
||||||
"gitea.dev/models/auth"
|
"gitea.dev/models/auth"
|
||||||
"gitea.dev/models/db"
|
"gitea.dev/models/db"
|
||||||
|
issues_model "gitea.dev/models/issues"
|
||||||
"gitea.dev/models/organization"
|
"gitea.dev/models/organization"
|
||||||
repo_model "gitea.dev/models/repo"
|
repo_model "gitea.dev/models/repo"
|
||||||
"gitea.dev/models/unittest"
|
"gitea.dev/models/unittest"
|
||||||
@@ -61,6 +63,32 @@ func TestDeleteUser(t *testing.T) {
|
|||||||
|
|
||||||
org := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3})
|
org := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3})
|
||||||
assert.Error(t, DeleteUser(t.Context(), org, false))
|
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) {
|
func TestDeleteUserUnlinkedAttachments(t *testing.T) {
|
||||||
|
|||||||
@@ -129,6 +129,33 @@ func checkBranchFilter(branchFilter string, ref git.RefName) bool {
|
|||||||
return g.Match(ref.String())
|
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.
|
// PrepareWebhook creates a hook task and enqueues it for processing.
|
||||||
// The payload is saved as-is. The adjustments depending on the webhook type happen
|
// The payload is saved as-is. The adjustments depending on the webhook type happen
|
||||||
// right before delivery, in the [Deliver] method.
|
// right before delivery, in the [Deliver] method.
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ func TestWebhookService(t *testing.T) {
|
|||||||
t.Run("PrepareBranchFilterNoMatch", testWebhookPrepareBranchFilterNoMatch)
|
t.Run("PrepareBranchFilterNoMatch", testWebhookPrepareBranchFilterNoMatch)
|
||||||
t.Run("WebhookUserMail", testWebhookUserMail)
|
t.Run("WebhookUserMail", testWebhookUserMail)
|
||||||
t.Run("CheckBranchFilter", testWebhookCheckBranchFilter)
|
t.Run("CheckBranchFilter", testWebhookCheckBranchFilter)
|
||||||
|
t.Run("PrepareTestWebhookIgnoresGates", testPrepareTestWebhookIgnoresGates)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testWebhookGetSlackHook(t *testing.T) {
|
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)
|
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"}}
|
{{template "devtest/devtest-header"}}
|
||||||
<div class="page-content devtest">
|
<div class="page-content devtest">
|
||||||
<div class="ui container">
|
<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="divider"></div>
|
||||||
<div class="flex-divided-list items-with-main">
|
<div class="flex-divided-list items-with-main">
|
||||||
<div class="item">
|
<div class="item">
|
||||||
@@ -87,7 +97,7 @@
|
|||||||
|
|
||||||
<div class="divider"></div>
|
<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="ui attached segment">
|
||||||
<div class="flex-divided-list">
|
<div class="flex-divided-list">
|
||||||
<div class="item">item 1</div>
|
<div class="item">item 1</div>
|
||||||
@@ -101,7 +111,7 @@
|
|||||||
<div class="item">item 2</div>
|
<div class="item">item 2</div>
|
||||||
</div>
|
</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="ui fitted segment">
|
||||||
<div class="flex-divided-list items-px-default">
|
<div class="flex-divided-list items-px-default">
|
||||||
<div class="item">item 1</div>
|
<div class="item">item 1</div>
|
||||||
|
|||||||
@@ -63,19 +63,18 @@
|
|||||||
<div>{{.RenderedContent}}</div>
|
<div>{{.RenderedContent}}</div>
|
||||||
</div>
|
</div>
|
||||||
{{end -}}
|
{{end -}}
|
||||||
|
</p>
|
||||||
{{if eq .ActionName "push"}}
|
{{if eq .ActionName "push"}}
|
||||||
<ul>
|
<ul>
|
||||||
{{$repoURL := $.Comment.Issue.PullRequest.BaseRepo.HTMLURL}}
|
{{$repoURL := $.Comment.Issue.PullRequest.BaseRepo.HTMLURL}}
|
||||||
{{range $commit := $.Comment.Commits}}
|
{{range $commit := $.Comment.Commits}}
|
||||||
|
{{$gitCommit := $commit.UserCommit.GitCommit}}
|
||||||
<li>
|
<li>
|
||||||
<a href="{{$repoURL}}/commit/{{$commit.ID}}">
|
<a href="{{$repoURL}}/commit/{{$gitCommit.ID}}">{{ShortSha $gitCommit.ID.String}}</a> - {{$gitCommit.MessageTitle}}
|
||||||
{{ShortSha $commit.ID.String}}
|
|
||||||
</a> - {{$commit.MessageTitle}}
|
|
||||||
</li>
|
</li>
|
||||||
{{end}}
|
{{end}}
|
||||||
</ul>
|
</ul>
|
||||||
{{end}}
|
{{end}}
|
||||||
</p>
|
|
||||||
<div style="font-size:small; color:#666;">
|
<div style="font-size:small; color:#666;">
|
||||||
<p>
|
<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>{{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="ui header flex-left-right tw-m-0">
|
||||||
<div class="flex-text-block">
|
<div class="flex-text-block">
|
||||||
<span class="tw-text-2xl">{{.Org.DisplayName}}</span>
|
<span class="tw-text-2xl">{{.Org.DisplayName}}</span>
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
data-locale-artifacts-title="{{ctx.Locale.Tr "artifacts"}}"
|
data-locale-artifacts-title="{{ctx.Locale.Tr "artifacts"}}"
|
||||||
data-locale-artifact-expired="{{ctx.Locale.Tr "expired"}}"
|
data-locale-artifact-expired="{{ctx.Locale.Tr "expired"}}"
|
||||||
data-locale-artifact-expires-at="{{ctx.Locale.Tr "artifact_expires_at"}}"
|
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-confirm-delete-artifact="{{ctx.Locale.Tr "confirm_delete_artifact"}}"
|
||||||
data-locale-show-timestamps="{{ctx.Locale.Tr "show_timestamps"}}"
|
data-locale-show-timestamps="{{ctx.Locale.Tr "show_timestamps"}}"
|
||||||
data-locale-show-log-seconds="{{ctx.Locale.Tr "show_log_seconds"}}"
|
data-locale-show-log-seconds="{{ctx.Locale.Tr "show_log_seconds"}}"
|
||||||
|
|||||||
@@ -1,16 +1,12 @@
|
|||||||
{{$isNew:=or .PageIsSettingsHooksNew .PageIsAdminDefaultHooksNew .PageIsAdminSystemHooksNew}}
|
|
||||||
{{if .PageIsSettingsHooksEdit}}
|
{{if .PageIsSettingsHooksEdit}}
|
||||||
<h4 class="ui top attached header">
|
<h4 class="ui top attached header flex-left-right">
|
||||||
{{ctx.Locale.Tr "repo.settings.recent_deliveries"}}
|
<span>{{ctx.Locale.Tr "repo.settings.recent_deliveries"}}</span>
|
||||||
{{if .Permission.IsAdmin}}
|
{{if .Permission.IsAdmin}}
|
||||||
<div class="ui right">
|
<button class="ui tiny button" id="test-delivery" data-link="{{.Link}}/test"
|
||||||
<!-- 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 -->
|
data-tooltip-content="{{ctx.Locale.Tr "repo.settings.webhook.test_delivery_desc"}}"
|
||||||
<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">
|
{{ctx.Locale.Tr "repo.settings.webhook.test_delivery"}}
|
||||||
<span class="text">{{ctx.Locale.Tr "repo.settings.webhook.test_delivery"}}</span>
|
|
||||||
</button>
|
</button>
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
{{end}}
|
||||||
</h4>
|
</h4>
|
||||||
<div class="ui attached segment">
|
<div class="ui attached segment">
|
||||||
|
|||||||
@@ -44,9 +44,9 @@
|
|||||||
<div class="ui attached segment tw-hidden" data-global-init="initRunnerBulkToolbar">
|
<div class="ui attached segment tw-hidden" data-global-init="initRunnerBulkToolbar">
|
||||||
<form action="{{$.Link}}/bulk" method="post" class="form-fetch-action">
|
<form action="{{$.Link}}/bulk" method="post" class="form-fetch-action">
|
||||||
<input type="hidden" name="ids">
|
<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 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" name="action" value="enable">{{ctx.Locale.Tr "actions.runners.enable_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" name="action" value="delete"
|
<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-header="{{ctx.Locale.Tr "actions.runners.delete_runner_header"}}"
|
||||||
data-modal-confirm-content="{{ctx.Locale.Tr "actions.runners.delete_runner_notice"}}"
|
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>
|
>{{ctx.Locale.Tr "actions.runners.delete_runner"}} <span class="runner-bulk-count"></span>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
user_model "gitea.dev/models/user"
|
user_model "gitea.dev/models/user"
|
||||||
"gitea.dev/modules/gitrepo"
|
"gitea.dev/modules/gitrepo"
|
||||||
"gitea.dev/modules/json"
|
"gitea.dev/modules/json"
|
||||||
|
"gitea.dev/modules/queue"
|
||||||
api "gitea.dev/modules/structs"
|
api "gitea.dev/modules/structs"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -763,6 +764,73 @@ jobs:
|
|||||||
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
|
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
|
||||||
assert.Equal(t, actions_model.StatusSuccess, run.Status)
|
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)
|
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) {
|
t.Run("DisableEnable", func(t *testing.T) {
|
||||||
doBulk(t, sessionAdmin, "disable", allIDs, http.StatusOK)
|
doBulk(t, sessionAdmin, "disable", allIDs, http.StatusOK)
|
||||||
for _, id := range allIDs {
|
for _, id := range allIDs {
|
||||||
|
|||||||
@@ -1164,7 +1164,7 @@ jobs:
|
|||||||
assert.Contains(t, dispatchPayload.Inputs, "myinput3")
|
assert.Contains(t, dispatchPayload.Inputs, "myinput3")
|
||||||
assert.Equal(t, "val0", dispatchPayload.Inputs["myinput"])
|
assert.Equal(t, "val0", dispatchPayload.Inputs["myinput"])
|
||||||
assert.Equal(t, "def2", dispatchPayload.Inputs["myinput2"])
|
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.Contains(t, dispatchPayload.Inputs, "myinput3")
|
||||||
assert.Equal(t, "val0", dispatchPayload.Inputs["myinput"])
|
assert.Equal(t, "val0", dispatchPayload.Inputs["myinput"])
|
||||||
assert.Equal(t, "def2", dispatchPayload.Inputs["myinput2"])
|
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.Contains(t, dispatchPayload.Inputs, "myinput3")
|
||||||
assert.Equal(t, "val0", dispatchPayload.Inputs["myinput"])
|
assert.Equal(t, "val0", dispatchPayload.Inputs["myinput"])
|
||||||
assert.Equal(t, "def2", dispatchPayload.Inputs["myinput2"])
|
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.Contains(t, dispatchPayload.Inputs, "myinput3")
|
||||||
assert.Equal(t, "val0", dispatchPayload.Inputs["myinput"])
|
assert.Equal(t, "val0", dispatchPayload.Inputs["myinput"])
|
||||||
assert.Equal(t, "def2", dispatchPayload.Inputs["myinput2"])
|
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)
|
AddBasicAuth(user.Name)
|
||||||
MakeRequest(t, req, http.StatusNoContent)
|
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)
|
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)
|
pullIndex, err := strconv.ParseInt(elems[4], 10, 64)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
pullRequest := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{BaseRepoID: repo.ID, Index: pullIndex})
|
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)
|
assert.Equal(t, tc.expectedMessage, squashMergeCommitMessage)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--gap-block);
|
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 {
|
.flex-relaxed-list > .divider {
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const iconClass = computed(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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"/>
|
<SvgIcon :name="icon.name" :class="iconClass" :size="size"/>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ const mergeButtonStyleClass = computed(() => {
|
|||||||
const mergeSelectStyleClass = computed(() => {
|
const mergeSelectStyleClass = computed(() => {
|
||||||
if (mergeForm.emptyCommit) return '';
|
if (mergeForm.emptyCommit) return '';
|
||||||
if (mergeStyle.value === mergeStyleManuallyMerged) return 'red';
|
if (mergeStyle.value === mergeStyleManuallyMerged) return 'red';
|
||||||
|
if (!mergeForm.allOverridableChecksOk) return 'red';
|
||||||
return 'primary';
|
return 'primary';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -216,10 +216,7 @@ onBeforeUnmount(() => {
|
|||||||
<div class="ui divider"/>
|
<div class="ui divider"/>
|
||||||
<div class="left-list-header">{{ locale.allJobs }}</div>
|
<div class="left-list-header">{{ locale.allJobs }}</div>
|
||||||
<div class="flex-items-block action-view-sidebar-list">
|
<div class="flex-items-block action-view-sidebar-list">
|
||||||
<div
|
<template
|
||||||
class="item job-brief-item"
|
|
||||||
:class="{'selected': props.jobId === item.job.id}"
|
|
||||||
:style="{paddingLeft: `${10 + item.depth * 16}px`}"
|
|
||||||
v-for="item in visibleJobListItems"
|
v-for="item in visibleJobListItems"
|
||||||
:key="item.job.id"
|
:key="item.job.id"
|
||||||
>
|
>
|
||||||
@@ -228,7 +225,9 @@ onBeforeUnmount(() => {
|
|||||||
<button
|
<button
|
||||||
v-if="item.job.isReusableCaller"
|
v-if="item.job.isReusableCaller"
|
||||||
type="button"
|
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)"
|
@click="toggleExpandedJob(item.job.id)"
|
||||||
:title="isJobCollapsed(item.job.id) ? locale.expandCallerJobs : locale.collapseCallerJobs"
|
:title="isJobCollapsed(item.job.id) ? locale.expandCallerJobs : locale.collapseCallerJobs"
|
||||||
:aria-label="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>
|
<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)}"/>
|
<SvgIcon name="octicon-chevron-down" :size="14" class="job-brief-toggle-icon" :class="{'collapsed': isJobCollapsed(item.job.id)}"/>
|
||||||
</button>
|
</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"/>
|
<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>
|
<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"/>
|
<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>
|
<span class="job-duration">{{ item.job.duration }}</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- artifacts list -->
|
<!-- artifacts list -->
|
||||||
@@ -269,7 +274,12 @@ onBeforeUnmount(() => {
|
|||||||
<SvgIcon name="octicon-trash"/>
|
<SvgIcon name="octicon-trash"/>
|
||||||
</a>
|
</a>
|
||||||
</template>
|
</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"/>
|
<SvgIcon name="octicon-file-removed"/>
|
||||||
<span class="tw-flex-1 gt-ellipsis">{{ artifact.name }}</span>
|
<span class="tw-flex-1 gt-ellipsis">{{ artifact.name }}</span>
|
||||||
<span class="ui label tw-flex-shrink-0">{{ locale.artifactExpired }}</span>
|
<span class="ui label tw-flex-shrink-0">{{ locale.artifactExpired }}</span>
|
||||||
@@ -450,10 +460,11 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.caller-row-toggle {
|
.caller-row-toggle {
|
||||||
|
width: 100%;
|
||||||
border: none;
|
border: none;
|
||||||
padding: 0;
|
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
|
line-height: inherit; /* buttons don't inherit line-height; match the <a> rows' row height */
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-align: inherit;
|
text-align: inherit;
|
||||||
}
|
}
|
||||||
@@ -483,13 +494,13 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.action-view-sidebar-list > .item:hover .job-rerun-button,
|
.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;
|
display: inline-flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* only swap out the duration when a re-run button exists to take its place */
|
/* 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: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;
|
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');
|
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[] = [
|
const jobs: ActionsJob[] = [
|
||||||
{id: 1, link: '', jobId: 'deploy_dev', name: 'deploy (dev)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '1s'},
|
{id: 1, link: '', jobId: 'prepare', name: 'prepare', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '30s'},
|
||||||
{id: 2, link: '', jobId: 'deploy_qa', name: 'deploy (qa)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '1s'},
|
{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: 'deploy_staging', name: 'deploy (staging)', status: 'running', canRerun: false, isReusableCaller: true, parentJobID: 0, duration: '2s', callUses: './.gitea/workflows/deploy.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);
|
const graph = createWorkflowGraphModel(jobs);
|
||||||
expect(graph.nodes.find((n) => n.id === 'job:3')?.name).toBe('deploy (staging)');
|
const matrixNodes = graph.nodes.filter((n) => n.type === 'matrix');
|
||||||
const matrixNode = graph.nodes.find((n) => n.type === 'matrix');
|
expect(matrixNodes).toHaveLength(1);
|
||||||
expect(matrixNode?.jobs.map((j) => j.id).sort()).toEqual([1, 2]);
|
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', () => {
|
test('directed highlight state covers ancestors and descendants of the hovered node', () => {
|
||||||
|
|||||||
@@ -264,9 +264,8 @@ function buildVisualGraph(
|
|||||||
|
|
||||||
const matrixJobsByKey = new Map<string, ActionsJob[]>();
|
const matrixJobsByKey = new Map<string, ActionsJob[]>();
|
||||||
for (const job of jobs) {
|
for (const job of jobs) {
|
||||||
// Reusable callers are distinct workflow files — never fold them into a matrix bucket
|
// Matrix legs that call a reusable workflow are still one logical job (a single `uses:`
|
||||||
// even if their display name happens to look like "name (variant)".
|
// expanded over the matrix), so fold them into a matrix node like any other matrix job.
|
||||||
if (job.isReusableCaller) continue;
|
|
||||||
const matrixKey = matrixKeyFromJobName(job.name);
|
const matrixKey = matrixKeyFromJobName(job.name);
|
||||||
if (!matrixKey) continue;
|
if (!matrixKey) continue;
|
||||||
if (!matrixJobsByKey.has(matrixKey)) matrixJobsByKey.set(matrixKey, []);
|
if (!matrixJobsByKey.has(matrixKey)) matrixJobsByKey.set(matrixKey, []);
|
||||||
@@ -322,10 +321,8 @@ function buildVisualGraph(
|
|||||||
const visualIdByJobId = new Map<number, string>();
|
const visualIdByJobId = new Map<number, string>();
|
||||||
for (const job of jobs) {
|
for (const job of jobs) {
|
||||||
const matrixKey = matrixKeyFromJobName(job.name);
|
const matrixKey = matrixKeyFromJobName(job.name);
|
||||||
// Symmetric with the matrix-bucket loop above: a reusable caller whose display name
|
// Symmetric with the matrix-bucket loop above (callers included).
|
||||||
// happens to look like "name (variant)" must never be folded into the matrix node, or it
|
if (matrixKey && (matrixJobsByKey.get(matrixKey)?.length ?? 0) > 1) {
|
||||||
// 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) {
|
|
||||||
visualIdByJobId.set(job.id, `matrix:${matrixKey}`);
|
visualIdByJobId.set(job.id, `matrix:${matrixKey}`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ function initAdminRunnerBulk(toolbar: HTMLElement) {
|
|||||||
|
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
const checked = Array.from(rowCheckboxes).filter((c) => c.checked);
|
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);
|
toggleElem(toolbar, checked.length > 0);
|
||||||
for (const btn of actionButtons) {
|
for (const btn of actionButtons) {
|
||||||
btn.querySelector<HTMLElement>('.runner-bulk-count')!.textContent = `(${checked.length})`;
|
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);
|
for (const cb of rowCheckboxes) cb.addEventListener('change', refresh);
|
||||||
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() {
|
function initAdminUser() {
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ function initRepositoryActionsView() {
|
|||||||
artifactsTitle: el.getAttribute('data-locale-artifacts-title'),
|
artifactsTitle: el.getAttribute('data-locale-artifacts-title'),
|
||||||
artifactExpired: el.getAttribute('data-locale-artifact-expired'),
|
artifactExpired: el.getAttribute('data-locale-artifact-expired'),
|
||||||
artifactExpiresAt: el.getAttribute('data-locale-artifact-expires-at'),
|
artifactExpiresAt: el.getAttribute('data-locale-artifact-expires-at'),
|
||||||
|
artifactExpiredAt: el.getAttribute('data-locale-artifact-expired-at'),
|
||||||
confirmDeleteArtifact: el.getAttribute('data-locale-confirm-delete-artifact'),
|
confirmDeleteArtifact: el.getAttribute('data-locale-confirm-delete-artifact'),
|
||||||
showTimeStamps: el.getAttribute('data-locale-show-timestamps'),
|
showTimeStamps: el.getAttribute('data-locale-show-timestamps'),
|
||||||
showLogSeconds: el.getAttribute('data-locale-show-log-seconds'),
|
showLogSeconds: el.getAttribute('data-locale-show-log-seconds'),
|
||||||
|
|||||||
Reference in New Issue
Block a user