mirror of
https://github.com/go-gitea/gitea.git
synced 2026-07-17 11:55:25 +02:00
Compare commits
14 Commits
v1.27.0
...
release/v1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
895d848ff4 | ||
|
|
7199547218 | ||
|
|
7cb4201f8e | ||
|
|
5f017302bf | ||
|
|
59c619660c | ||
|
|
8599289459 | ||
|
|
da9f0a3726 | ||
|
|
08fd59959e | ||
|
|
d60215c2a2 | ||
|
|
bf594690db | ||
|
|
5cb7ec9304 | ||
|
|
6e86c4cde8 | ||
|
|
c32af046a2 | ||
|
|
a98468da30 |
@@ -57,9 +57,14 @@ func NewInterpeter(
|
||||
}
|
||||
|
||||
ee := &exprparser.EvaluationEnvironment{
|
||||
Github: gitCtx,
|
||||
Env: nil, // no need
|
||||
Job: nil, // no need
|
||||
Github: gitCtx,
|
||||
Env: nil, // no need
|
||||
// Job must be non-nil because cancelled() dereferences Job.Status unconditionally.
|
||||
// See: https://gitea.com/gitea/runner/src/commit/ad967330a8788c9b8ab723abbc1a86d53c3bc5e6/act/exprparser/functions.go#L299
|
||||
// TODO: The empty JobContext.Status is right for now because Gitea never checks `if` condition when the workflow run is cancelled.
|
||||
// This is an implementation gap in Gitea Actions. When a workflow run is cancelled, Gitea should check the job's `if` condition,
|
||||
// and if the condition is met (e.g. `if: ${{ cancelled() }}` ), the job should be executed rather than cancelled.
|
||||
Job: &model.JobContext{},
|
||||
Steps: nil, // no need
|
||||
Runner: nil, // no need
|
||||
Secrets: nil, // no need
|
||||
|
||||
@@ -464,3 +464,61 @@ func TestParseMappingNode(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateJobIfExpression(t *testing.T) {
|
||||
kases := []struct {
|
||||
name string
|
||||
ifCond string
|
||||
needResult string
|
||||
expected bool
|
||||
}{
|
||||
{name: "empty need success", ifCond: "${{ 1 == 1 }}", needResult: "success", expected: true},
|
||||
{name: "always", ifCond: "${{ always() }}", needResult: "failure", expected: true},
|
||||
{name: "failure true", ifCond: "${{ failure() }}", needResult: "failure", expected: true},
|
||||
{name: "failure false", ifCond: "${{ failure() }}", needResult: "success", expected: false},
|
||||
{name: "success true", ifCond: "${{ success() }}", needResult: "success", expected: true},
|
||||
// cancelled() is always false on the server: a cancelled run never evaluates a blocked job's `if:`
|
||||
{name: "cancelled", ifCond: "${{ cancelled() }}", needResult: "success", expected: false},
|
||||
{name: "not cancelled or failure", ifCond: "${{ !(cancelled() || failure()) }}", needResult: "success", expected: true},
|
||||
{name: "not cancelled or failure, need failed", ifCond: "${{ !(cancelled() || failure()) }}", needResult: "failure", expected: false},
|
||||
}
|
||||
for _, kase := range kases {
|
||||
t.Run(kase.name, func(t *testing.T) {
|
||||
content := strings.ReplaceAll(`
|
||||
name: test
|
||||
on: push
|
||||
jobs:
|
||||
job1:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo job1
|
||||
job2:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [job1]
|
||||
if: IF_COND
|
||||
steps:
|
||||
- run: echo job2
|
||||
`, "IF_COND", kase.ifCond)
|
||||
|
||||
workflows, err := Parse([]byte(content))
|
||||
require.NoError(t, err)
|
||||
|
||||
var job2 *Job
|
||||
for _, wf := range workflows {
|
||||
if id, job := wf.Job(); id == "job2" {
|
||||
job2 = job
|
||||
}
|
||||
}
|
||||
require.NotNil(t, job2)
|
||||
|
||||
// mirrors findJobNeedsAndFillJobResults: the needs' results plus a self entry carrying Needs
|
||||
results := map[string]*JobResult{
|
||||
"job1": {Result: kase.needResult},
|
||||
"job2": {Needs: []string{"job1"}},
|
||||
}
|
||||
got, err := EvaluateJobIfExpression("job2", job2, map[string]any{}, results, nil, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, kase.expected, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,8 +77,12 @@ func (c *CommitMessage) MessageTrailer() CommitMessageTrailerValues {
|
||||
}
|
||||
|
||||
var commitMessageTrailerSplit = sync.OnceValue(func() *regexp.Regexp {
|
||||
// the sep is either something like "\n---\n" or "\n\n" in the body, or at the start of the body like "---\n"
|
||||
return regexp.MustCompile(`(?s)^(?P<content>.*?)(?P<sep>^|^\n|^-{3,}\n+|\n-{3,}\n+|\n\n)(?P<trailer>(?:[A-Za-z0-9][-A-Za-z0-9]*:[^\n]*\n?)*\n*)$`)
|
||||
// ref: https://git-scm.com/docs/git-interpret-trailers
|
||||
// TODO: the regexp is not able to perfectly parse the all kinds of trailers
|
||||
// It was just copied from legacy code, it is not exactly the same as how Git parses the trailer and not quite right in some cases.
|
||||
// For the key characters: it follows RFC 822 field name syntax (or RFC 2822/RFC 5322): printable ASCII characters between 33 and 126 except the colon (:),
|
||||
// but maybe we don't want to make it that complicated, so here we only support some common "symbol-like" characters.
|
||||
return regexp.MustCompile(`(?s)^(?P<content>.*?)(?P<sep>^|^\n|^-{3,}\n+|\n+-{3,}\n+|\n{2,})(?P<trailer>(?:[A-Za-z0-9][-\w]*:[^\n]*(\n\s+[^\n]*)*\n?)*\n*)$`)
|
||||
})
|
||||
|
||||
// CommitMessageSplitTrailer tries to split the message by the trailer separator
|
||||
@@ -93,6 +97,41 @@ func CommitMessageSplitTrailer(s string) (content, sep, trailer string) {
|
||||
return v[re.SubexpIndex("content")], v[re.SubexpIndex("sep")], v[re.SubexpIndex("trailer")]
|
||||
}
|
||||
|
||||
// CommitMessageMerge merges two commit messages with their trailers
|
||||
func CommitMessageMerge(m1, m2 string) string {
|
||||
c1, s1, t1 := CommitMessageSplitTrailer(m1)
|
||||
c2, s2, t2 := CommitMessageSplitTrailer(m2)
|
||||
c1, t1 = strings.TrimSpace(c1), strings.TrimSpace(t1)
|
||||
c2, t2 = strings.TrimSpace(c2), strings.TrimSpace(t2)
|
||||
out := strings.Builder{}
|
||||
if c1 != "" && c2 != "" {
|
||||
out.WriteString(c1)
|
||||
out.WriteString("\n\n")
|
||||
out.WriteString(c2)
|
||||
} else if c1 != "" {
|
||||
out.WriteString(c1)
|
||||
} else if c2 != "" {
|
||||
out.WriteString(c2)
|
||||
}
|
||||
if t1 != "" || t2 != "" {
|
||||
sep := util.Iif(t1 == "", s2, s1)
|
||||
sep = util.IfZero(sep, "\n\n")
|
||||
if c1 != "" || c2 != "" {
|
||||
out.WriteString(sep)
|
||||
}
|
||||
if t1 != "" {
|
||||
out.WriteString(t1)
|
||||
}
|
||||
if t1 != "" && t2 != "" {
|
||||
out.WriteString("\n")
|
||||
}
|
||||
if t2 != "" {
|
||||
out.WriteString(t2)
|
||||
}
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func CommitMessageParseTrailer(s string) CommitMessageTrailerValues {
|
||||
ret := CommitMessageTrailerValues{}
|
||||
for line := range strings.SplitSeq(util.NormalizeStringEOL(s), "\n") {
|
||||
|
||||
@@ -26,10 +26,12 @@ func TestCommitMessageTrailer(t *testing.T) {
|
||||
{"a", "a", "", ""},
|
||||
{"a\n\nk", "a\n\nk", "", ""},
|
||||
{"a\n\nk:v", "a", "\n\n", "k:v"},
|
||||
{"a\n\nk:v\n next-line", "a", "\n\n", "k:v\n next-line"},
|
||||
{"a\n\nk:v\n next-line\nother: v", "a", "\n\n", "k:v\n next-line\nother: v"},
|
||||
{"a\n\nk:v\n\n", "a", "\n\n", "k:v\n\n"},
|
||||
{"a\n--\nk:v", "a\n--\nk:v", "", ""},
|
||||
{"a\n---\nk:v", "a", "\n---\n", "k:v"},
|
||||
{"a\n\n---\n\nk:v", "a\n", "\n---\n\n", "k:v"},
|
||||
{"a\n---\nk:v", "a", "\n---\n", "k:v"}, // TODO: should we support such case? No empty line between "---" and the trailer
|
||||
{"a\n\n---\n\nk:v", "a", "\n\n---\n\n", "k:v"},
|
||||
|
||||
{"k: v", "", "", "k: v"},
|
||||
{"\nk:v", "", "\n", "k:v"},
|
||||
@@ -127,3 +129,31 @@ func TestCommitMessageParticipants(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCommitMessageMerge(t *testing.T) {
|
||||
cases := []struct {
|
||||
m1, m2 string
|
||||
out string
|
||||
}{
|
||||
{"", "", ""},
|
||||
{"msg1", "", "msg1"},
|
||||
{"", "msg2", "msg2"},
|
||||
{"msg1", "msg2", "msg1\n\nmsg2"},
|
||||
{"k1: a", "", "k1: a"},
|
||||
{"", "k2: b", "k2: b"},
|
||||
{"k1: a", "k2: b", "k1: a\nk2: b"},
|
||||
{"msg1", "k2: b", "msg1\n\nk2: b"},
|
||||
{"k1: a", "msg2", "msg2\n\nk1: a"},
|
||||
{"msg1\n\nk1: a", "msg2", "msg1\n\nmsg2\n\nk1: a"},
|
||||
{"msg1\n----\nk1: a", "msg2", "msg1\n\nmsg2\n----\nk1: a"},
|
||||
{"msg1\n\n----\n\nk1: a", "msg2", "msg1\n\nmsg2\n\n----\n\nk1: a"},
|
||||
{"msg1", "msg2\n----\nk2: b", "msg1\n\nmsg2\n----\nk2: b"},
|
||||
{"msg1", "msg2\n\nk2: b", "msg1\n\nmsg2\n\nk2: b"},
|
||||
{"msg1\n\nk1: a", "msg2\n\nk2: b", "msg1\n\nmsg2\n\nk1: a\nk2: b"},
|
||||
}
|
||||
|
||||
for i, c := range cases {
|
||||
out := CommitMessageMerge(c.m1, c.m2)
|
||||
assert.Equal(t, c.out, out, "idx=%d, m1=%q m2=%q", i, c.m1, c.m2)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,6 +124,7 @@
|
||||
"artifacts": "Artifacts",
|
||||
"expired": "Expired",
|
||||
"artifact_expires_at": "Expires at %s",
|
||||
"artifact_expired_at": "Expired at %s",
|
||||
"confirm_delete_artifact": "Are you sure you want to delete the artifact '%s'?",
|
||||
"archived": "Archived",
|
||||
"concept_system_global": "Global",
|
||||
@@ -2251,7 +2252,6 @@
|
||||
"repo.settings.webhook_deletion_success": "The webhook has been removed.",
|
||||
"repo.settings.webhook.test_delivery": "Test Push Event",
|
||||
"repo.settings.webhook.test_delivery_desc": "Test this webhook with a fake push event.",
|
||||
"repo.settings.webhook.test_delivery_desc_disabled": "To test this webhook with a fake event, activate it.",
|
||||
"repo.settings.webhook.request": "Request",
|
||||
"repo.settings.webhook.response": "Response",
|
||||
"repo.settings.webhook.headers": "Headers",
|
||||
@@ -3784,6 +3784,9 @@
|
||||
"actions.runs.pushed_by": "pushed by",
|
||||
"actions.runs.invalid_workflow_helper": "Workflow config file is invalid. Please check your config file: %s",
|
||||
"actions.runs.no_matching_online_runner_helper": "No matching online runner with label: %s",
|
||||
"actions.runs.no_runner_online": "No runner is online to pick up this job.",
|
||||
"actions.runs.waiting_for_available_runner": "Waiting for a matching runner to become available.",
|
||||
"actions.runs.waiting_for_dependent_jobs": "Waiting for the following jobs to complete: %s",
|
||||
"actions.runs.no_job_without_needs": "The workflow must contain at least one job without dependencies.",
|
||||
"actions.runs.no_job": "The workflow must contain at least one job",
|
||||
"actions.runs.invalid_reusable_workflow_uses": "Invalid reusable workflow \"uses\": %s",
|
||||
|
||||
@@ -67,15 +67,34 @@ func GetRepositoryFile(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
branch := ctx.PathParam("branch")
|
||||
repository := ctx.PathParam("repository")
|
||||
architecture := ctx.PathParam("architecture")
|
||||
|
||||
s, u, pf, err := packages_service.OpenFileForDownloadByPackageVersion(
|
||||
ctx,
|
||||
pv,
|
||||
&packages_service.PackageFileInfo{
|
||||
Filename: alpine_service.IndexArchiveFilename,
|
||||
CompositeKey: fmt.Sprintf("%s|%s|%s", ctx.PathParam("branch"), ctx.PathParam("repository"), ctx.PathParam("architecture")),
|
||||
CompositeKey: fmt.Sprintf("%s|%s|%s", branch, repository, architecture),
|
||||
},
|
||||
ctx.Req.Method,
|
||||
)
|
||||
// A repository that only contains "noarch" packages has no per-architecture
|
||||
// index. Since noarch packages are installable on every architecture, fall
|
||||
// back to the noarch index so clients requesting their own architecture
|
||||
// (e.g. x86_64) can still discover them.
|
||||
if errors.Is(err, util.ErrNotExist) && architecture != alpine_module.NoArch {
|
||||
s, u, pf, err = packages_service.OpenFileForDownloadByPackageVersion(
|
||||
ctx,
|
||||
pv,
|
||||
&packages_service.PackageFileInfo{
|
||||
Filename: alpine_service.IndexArchiveFilename,
|
||||
CompositeKey: fmt.Sprintf("%s|%s|%s", branch, repository, alpine_module.NoArch),
|
||||
},
|
||||
ctx.Req.Method,
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
|
||||
@@ -177,7 +177,7 @@ func TestHook(ctx *context.APIContext) {
|
||||
commit := convert.ToPayloadCommit(ctx, ctx.Repo.Repository, ctx.Repo.Commit)
|
||||
|
||||
commitID := ctx.Repo.Commit.ID.String()
|
||||
if err := webhook_service.PrepareWebhook(ctx, hook, webhook_module.HookEventPush, &api.PushPayload{
|
||||
if err := webhook_service.PrepareTestWebhook(ctx, hook, webhook_module.HookEventPush, &api.PushPayload{
|
||||
Ref: ref,
|
||||
Before: commitID,
|
||||
After: commitID,
|
||||
|
||||
@@ -382,6 +382,8 @@ func MockActionsRunsJobs(ctx *context.Context) {
|
||||
// └ deep_job (regular)
|
||||
// cross_caller (caller, cross-repo, expanded)
|
||||
// └ external_job (regular)
|
||||
// build (linux|windows|macos) (regular matrix; graph folds into one "build" node)
|
||||
// build-call (linux|windows|macos) (caller matrix, each calls build.yml; folds into one "build-call" node like "build")
|
||||
// final (regular, needs local_caller + cross_caller)
|
||||
const (
|
||||
prepareID = int64(400)
|
||||
@@ -392,6 +394,21 @@ func MockActionsRunsJobs(ctx *context.Context) {
|
||||
crossCallerID = int64(405)
|
||||
externalJobID = int64(406)
|
||||
finalID = int64(407)
|
||||
|
||||
// Regular matrix set – the graph already folds these into a single "build" node.
|
||||
buildLinuxID = int64(410)
|
||||
buildWindowsID = int64(411)
|
||||
buildMacosID = int64(412)
|
||||
|
||||
// Caller matrix set – each matrix leg calls the same reusable workflow. #38466: like the
|
||||
// regular "build" matrix above, these fold into one "build-call" node. Matrix legs share a
|
||||
// single JobID, so the legs below use JobID "build-call" and differ only by their name suffix.
|
||||
buildCallLinuxID = int64(420)
|
||||
buildCallWindowsID = int64(421)
|
||||
buildCallMacosID = int64(422)
|
||||
buildCallLinuxJobID = int64(423)
|
||||
buildCallWinJobID = int64(424)
|
||||
buildCallMacJobID = int64(425)
|
||||
)
|
||||
|
||||
resp.State.Run.Jobs = []*actions.ViewJob{
|
||||
@@ -432,6 +449,53 @@ func MockActionsRunsJobs(ctx *context.Context) {
|
||||
Status: actions_model.StatusWaiting.String(), Duration: "0s",
|
||||
ParentJobID: crossCallerID,
|
||||
},
|
||||
|
||||
// Regular matrix "build" – these fold into one matrix node in the graph. The matrix legs
|
||||
// share a single JobID ("build"); the " (variant)" name suffix distinguishes the legs.
|
||||
{
|
||||
ID: buildLinuxID, Link: jobLink(buildLinuxID), JobID: "build", Name: "build (linux)",
|
||||
Status: actions_model.StatusSuccess.String(), Duration: "1m", Needs: []string{"prepare"},
|
||||
},
|
||||
{
|
||||
ID: buildWindowsID, Link: jobLink(buildWindowsID), JobID: "build", Name: "build (windows)",
|
||||
Status: actions_model.StatusSuccess.String(), Duration: "2m", Needs: []string{"prepare"},
|
||||
},
|
||||
{
|
||||
ID: buildMacosID, Link: jobLink(buildMacosID), JobID: "build", Name: "build (macos)",
|
||||
Status: actions_model.StatusSuccess.String(), Duration: "90s", Needs: []string{"prepare"},
|
||||
},
|
||||
|
||||
// Caller matrix "build-call" – each leg calls the same reusable workflow. #38466: like the
|
||||
// regular "build" matrix above, these fold into one node. The matrix legs share a single
|
||||
// JobID ("build-call"); the " (variant)" name suffix distinguishes the legs.
|
||||
{
|
||||
ID: buildCallLinuxID, Link: jobLink(buildCallLinuxID), JobID: "build-call", Name: "build-call (linux)",
|
||||
Status: actions_model.StatusSuccess.String(), Duration: "1m", Needs: []string{"prepare"},
|
||||
IsReusableCaller: true, CallUses: "./.gitea/workflows/build.yml",
|
||||
},
|
||||
{
|
||||
ID: buildCallLinuxJobID, Link: jobLink(buildCallLinuxJobID), JobID: "bc_linux_build", Name: "build",
|
||||
Status: actions_model.StatusSuccess.String(), Duration: "1m", ParentJobID: buildCallLinuxID,
|
||||
},
|
||||
{
|
||||
ID: buildCallWindowsID, Link: jobLink(buildCallWindowsID), JobID: "build-call", Name: "build-call (windows)",
|
||||
Status: actions_model.StatusSuccess.String(), Duration: "2m", Needs: []string{"prepare"},
|
||||
IsReusableCaller: true, CallUses: "./.gitea/workflows/build.yml",
|
||||
},
|
||||
{
|
||||
ID: buildCallWinJobID, Link: jobLink(buildCallWinJobID), JobID: "bc_windows_build", Name: "build",
|
||||
Status: actions_model.StatusSuccess.String(), Duration: "2m", ParentJobID: buildCallWindowsID,
|
||||
},
|
||||
{
|
||||
ID: buildCallMacosID, Link: jobLink(buildCallMacosID), JobID: "build-call", Name: "build-call (macos)",
|
||||
Status: actions_model.StatusSuccess.String(), Duration: "90s", Needs: []string{"prepare"},
|
||||
IsReusableCaller: true, CallUses: "./.gitea/workflows/build.yml",
|
||||
},
|
||||
{
|
||||
ID: buildCallMacJobID, Link: jobLink(buildCallMacJobID), JobID: "bc_macos_build", Name: "build",
|
||||
Status: actions_model.StatusSuccess.String(), Duration: "90s", ParentJobID: buildCallMacosID,
|
||||
},
|
||||
|
||||
{
|
||||
ID: finalID, Link: jobLink(finalID), JobID: "final", Name: "final",
|
||||
Status: actions_model.StatusBlocked.String(), Duration: "0s",
|
||||
|
||||
@@ -32,6 +32,7 @@ import (
|
||||
"gitea.dev/modules/httplib"
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/storage"
|
||||
api "gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/templates"
|
||||
@@ -716,9 +717,10 @@ func fillViewRunResponseSummary(ctx *context_module.Context, resp *ViewResponse,
|
||||
resp.Artifacts = make([]*ArtifactsViewItem, 0, len(arts))
|
||||
for _, art := range arts {
|
||||
resp.Artifacts = append(resp.Artifacts, &ArtifactsViewItem{
|
||||
Name: art.ArtifactName,
|
||||
Size: art.FileSize,
|
||||
Status: util.Iif(art.Status == actions_model.ArtifactStatusExpired, "expired", "completed"),
|
||||
Name: art.ArtifactName,
|
||||
Size: art.FileSize,
|
||||
Status: util.Iif(art.Status == actions_model.ArtifactStatusExpired, "expired", "completed"),
|
||||
ExpiresUnix: int64(art.ExpiredUnix),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -752,6 +754,8 @@ func fillViewRunResponseCurrentJob(ctx *context_module.Context, resp *ViewRespon
|
||||
resp.State.CurrentJob.Detail = current.Status.LocaleString(ctx.Locale)
|
||||
if run.NeedApproval {
|
||||
resp.State.CurrentJob.Detail = ctx.Locale.TrString("actions.need_approval_desc")
|
||||
} else if detail := describePendingJobDetail(ctx, current, jobs); detail != "" {
|
||||
resp.State.CurrentJob.Detail = detail
|
||||
}
|
||||
resp.State.CurrentJob.Steps = make([]*ViewJobStep, 0) // marshal to '[]' instead fo 'null' in json
|
||||
resp.Logs.StepsLog = make([]*ViewStepLog, 0) // marshal to '[]' instead fo 'null' in json
|
||||
@@ -766,6 +770,78 @@ func fillViewRunResponseCurrentJob(ctx *context_module.Context, resp *ViewRespon
|
||||
}
|
||||
}
|
||||
|
||||
// describePendingJobDetail explains why a blocked or waiting job has not started
|
||||
// yet, so the user can tell whether it is waiting on its dependencies or on an
|
||||
// available runner. It returns an empty string when the job is not pending or the
|
||||
// cause can't be determined (the caller keeps the generic status label then).
|
||||
func describePendingJobDetail(ctx *context_module.Context, current *actions_model.ActionRunJob, jobs []*actions_model.ActionRunJob) string {
|
||||
switch {
|
||||
case current.Status.IsBlocked():
|
||||
// A blocked job is held back by the jobs listed in its `needs`.
|
||||
if pending := pendingNeeds(current, jobs); len(pending) > 0 {
|
||||
return ctx.Locale.TrString("actions.runs.waiting_for_dependent_jobs", strings.Join(pending, ", "))
|
||||
}
|
||||
case current.Status.IsWaiting():
|
||||
// A waiting job has no runner to pick it up yet. A busy runner is still
|
||||
// "online", so distinguish three cases: no runner online at all, online
|
||||
// runners but none match the labels, and a matching runner that is busy.
|
||||
runners, err := db.Find[actions_model.ActionRunner](ctx, actions_model.FindRunnerOptions{
|
||||
RepoID: current.RepoID,
|
||||
IsOnline: optional.Some(true),
|
||||
WithAvailable: true,
|
||||
})
|
||||
if err != nil {
|
||||
log.Error("FindRunners for job %d: %v", current.ID, err)
|
||||
return ""
|
||||
}
|
||||
hasOnlineRunner, hasMatchingRunner := false, false
|
||||
for _, runner := range runners {
|
||||
if runner.IsDisabled {
|
||||
continue
|
||||
}
|
||||
hasOnlineRunner = true
|
||||
if runner.CanMatchLabels(current.RunsOn) {
|
||||
hasMatchingRunner = true
|
||||
break
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case !hasOnlineRunner:
|
||||
return ctx.Locale.TrString("actions.runs.no_runner_online")
|
||||
case !hasMatchingRunner:
|
||||
return ctx.Locale.TrString("actions.runs.no_matching_online_runner_helper", strings.Join(current.RunsOn, ", "))
|
||||
default:
|
||||
// A matching runner exists but hasn't claimed the job, so it is busy.
|
||||
return ctx.Locale.TrString("actions.runs.waiting_for_available_runner")
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// pendingNeeds returns the `needs` keys of jobs the given job depends on that
|
||||
// have not finished yet, scoped to the same parent job (matrix expansions of a
|
||||
// need are all required to be done). Unresolved needs are treated as pending.
|
||||
func pendingNeeds(current *actions_model.ActionRunJob, jobs []*actions_model.ActionRunJob) []string {
|
||||
var pending []string
|
||||
for _, need := range current.Needs {
|
||||
found, allDone := false, true
|
||||
for _, job := range jobs {
|
||||
if job.ParentJobID != current.ParentJobID || job.JobID != need {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if !job.Status.IsDone() {
|
||||
allDone = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found || !allDone {
|
||||
pending = append(pending, need)
|
||||
}
|
||||
}
|
||||
return pending
|
||||
}
|
||||
|
||||
func convertToViewModel(ctx context.Context, locale translation.Locale, cursors []LogCursor, task *actions_model.ActionTask) ([]*ViewJobStep, []*ViewStepLog, error) {
|
||||
var viewJobs []*ViewJobStep
|
||||
var logs []*ViewStepLog
|
||||
|
||||
@@ -138,3 +138,48 @@ func TestConvertToViewModelCancellingTaskDoesNotRenderRunningSteps(t *testing.T)
|
||||
}
|
||||
assert.Equal(t, expectedViewJobs, viewJobSteps)
|
||||
}
|
||||
|
||||
func TestPendingNeeds(t *testing.T) {
|
||||
current := &actions_model.ActionRunJob{JobID: "deploy", Needs: []string{"build", "test"}}
|
||||
jobs := []*actions_model.ActionRunJob{
|
||||
current,
|
||||
{JobID: "build", Status: actions_model.StatusSuccess},
|
||||
{JobID: "test", Status: actions_model.StatusRunning},
|
||||
}
|
||||
// "test" is not done yet, "build" succeeded, so only "test" blocks.
|
||||
assert.Equal(t, []string{"test"}, pendingNeeds(current, jobs))
|
||||
|
||||
t.Run("all needs done", func(t *testing.T) {
|
||||
done := []*actions_model.ActionRunJob{
|
||||
current,
|
||||
{JobID: "build", Status: actions_model.StatusSuccess},
|
||||
{JobID: "test", Status: actions_model.StatusSkipped},
|
||||
}
|
||||
assert.Empty(t, pendingNeeds(current, done))
|
||||
})
|
||||
|
||||
t.Run("matrix expansion all required", func(t *testing.T) {
|
||||
matrix := []*actions_model.ActionRunJob{
|
||||
current,
|
||||
{JobID: "build", Status: actions_model.StatusSuccess},
|
||||
{JobID: "build", Status: actions_model.StatusRunning},
|
||||
{JobID: "test", Status: actions_model.StatusSuccess},
|
||||
}
|
||||
assert.Equal(t, []string{"build"}, pendingNeeds(current, matrix))
|
||||
})
|
||||
|
||||
t.Run("unresolved need treated as pending", func(t *testing.T) {
|
||||
missing := []*actions_model.ActionRunJob{current}
|
||||
assert.Equal(t, []string{"build", "test"}, pendingNeeds(current, missing))
|
||||
})
|
||||
|
||||
t.Run("parent job scope", func(t *testing.T) {
|
||||
// a same-named job under a different parent must not satisfy the need
|
||||
scoped := &actions_model.ActionRunJob{JobID: "deploy", Needs: []string{"build"}, ParentJobID: 5}
|
||||
jobs := []*actions_model.ActionRunJob{
|
||||
scoped,
|
||||
{JobID: "build", Status: actions_model.StatusSuccess, ParentJobID: 0},
|
||||
}
|
||||
assert.Equal(t, []string{"build"}, pendingNeeds(scoped, jobs))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
pull_model "gitea.dev/models/pull"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unit"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/svg"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/util"
|
||||
@@ -62,20 +64,23 @@ func (prInfo *pullRequestViewInfo) prepareMergeBoxFormProps(ctx *context.Context
|
||||
hasPendingPullRequestMergeTip = ctx.Locale.Tr("repo.pulls.auto_merge_has_pending_schedule", pendingPullRequestMerge.Doer.Name, createdPRMergeStr)
|
||||
}
|
||||
|
||||
defaultMergeTitle, defaultMergeBody, err := pull_service.GetDefaultMergeMessage(ctx, ctx.Repo.GitRepo, pull, mergeStyle)
|
||||
if err != nil && !errors.Is(err, util.ErrNotExist) {
|
||||
ctx.ServerError("GetDefaultMergeMessage", err)
|
||||
return
|
||||
}
|
||||
defaultSquashMergeTitle, defaultSquashMergeBody, err := pull_service.GetDefaultMergeMessage(ctx, ctx.Repo.GitRepo, pull, repo_model.MergeStyleSquash)
|
||||
if err != nil && !errors.Is(err, util.ErrNotExist) {
|
||||
ctx.ServerError("GetDefaultSquashMergeMessage", err)
|
||||
return
|
||||
}
|
||||
|
||||
var defaultMergeTitle, defaultMergeBody string
|
||||
var defaultSquashMergeTitle, defaultSquashMergeBody string
|
||||
var defaultSquashMergeCommitMessages string
|
||||
if !prInfo.IsPullRequestBroken {
|
||||
defaultSquashMergeCommitMessages = pull_service.GetSquashMergeCommitMessages(ctx, pull)
|
||||
var err error
|
||||
defaultMergeTitle, defaultMergeBody, err = pull_service.GetDefaultMergeMessage(ctx, ctx.Repo.GitRepo, pull, mergeStyle)
|
||||
if err != nil && !errors.Is(err, util.ErrNotExist) {
|
||||
log.Error("GetDefaultMergeMessage for style %s failed, error: %v", mergeStyle, err)
|
||||
}
|
||||
defaultSquashMergeTitle, defaultSquashMergeBody, err = pull_service.GetDefaultMergeMessage(ctx, ctx.Repo.GitRepo, pull, repo_model.MergeStyleSquash)
|
||||
if err != nil && !errors.Is(err, util.ErrNotExist) {
|
||||
log.Error("GetDefaultMergeMessage for squash failed, error: %v", err)
|
||||
}
|
||||
defaultSquashMergeCommitMessages, err = pull_service.GetSquashMergeCommitMessages(ctx, pull)
|
||||
if err != nil && !errors.Is(err, util.ErrNotExist) {
|
||||
log.Error("GetSquashMergeCommitMessages failed, error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
allOverridableChecksOk := !prInfo.MergeBoxData.hasOverridableBlockers
|
||||
@@ -106,7 +111,6 @@ func (prInfo *pullRequestViewInfo) prepareMergeBoxFormProps(ctx *context.Context
|
||||
|
||||
// if this pr can be merged now, then hide the auto merge
|
||||
generalHideAutoMerge := prInfo.MergeBoxData.canMergeNow && allOverridableChecksOk
|
||||
|
||||
var mergeStyles []any
|
||||
if pull.IsStatusMergeable() {
|
||||
mergeStyles = []any{
|
||||
@@ -138,7 +142,7 @@ func (prInfo *pullRequestViewInfo) prepareMergeBoxFormProps(ctx *context.Context
|
||||
"allowed": prConfig.AllowSquash,
|
||||
"textDoMerge": ctx.Locale.Tr("repo.pulls.squash_merge_pull_request"),
|
||||
"mergeTitleFieldText": defaultSquashMergeTitle,
|
||||
"mergeMessageFieldText": defaultSquashMergeCommitMessages + defaultSquashMergeBody,
|
||||
"mergeMessageFieldText": git.CommitMessageMerge(defaultSquashMergeCommitMessages, defaultSquashMergeBody),
|
||||
"hideAutoMerge": generalHideAutoMerge,
|
||||
},
|
||||
map[string]any{
|
||||
|
||||
@@ -664,19 +664,14 @@ func TestWebhook(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Grab latest commit or fake one if it's empty repository.
|
||||
// Note: in old code, the "ctx.Repo.Commit" is the last commit of the default branch.
|
||||
// New code doesn't set that commit, so it always uses the fake commit to test webhook.
|
||||
commit := ctx.Repo.Commit
|
||||
if commit == nil {
|
||||
ghost := user_model.NewGhostUser()
|
||||
objectFormat := git.ObjectFormatFromName(ctx.Repo.Repository.ObjectFormatName)
|
||||
commit = &git.Commit{
|
||||
ID: objectFormat.EmptyObjectID(),
|
||||
Author: ghost.NewGitSig(),
|
||||
Committer: ghost.NewGitSig(),
|
||||
CommitMessage: git.CommitMessage{MessageRaw: "This is a fake commit"},
|
||||
}
|
||||
// use a fake commit to test webhook
|
||||
ghostUser := user_model.NewGhostUser()
|
||||
objectFormat := git.ObjectFormatFromName(ctx.Repo.Repository.ObjectFormatName)
|
||||
commit := &git.Commit{
|
||||
ID: objectFormat.EmptyObjectID(),
|
||||
Author: ghostUser.NewGitSig(),
|
||||
Committer: ghostUser.NewGitSig(),
|
||||
CommitMessage: git.CommitMessage{MessageRaw: "This is a fake commit for webhook push test"},
|
||||
}
|
||||
|
||||
apiUser := convert.ToUserWithAccessMode(ctx, ctx.Doer, perm.AccessModeNone)
|
||||
@@ -697,7 +692,7 @@ func TestWebhook(ctx *context.Context) {
|
||||
|
||||
commitID := commit.ID.String()
|
||||
p := &api.PushPayload{
|
||||
Ref: git.BranchPrefix + ctx.Repo.Repository.DefaultBranch,
|
||||
Ref: git.RefNameFromBranch(ctx.Repo.Repository.DefaultBranch).String(),
|
||||
Before: commitID,
|
||||
After: commitID,
|
||||
CompareURL: setting.AppURL + ctx.Repo.Repository.ComposeCompareURL(commitID, commitID),
|
||||
@@ -708,8 +703,8 @@ func TestWebhook(ctx *context.Context) {
|
||||
Pusher: apiUser,
|
||||
Sender: apiUser,
|
||||
}
|
||||
if err := webhook_service.PrepareWebhook(ctx, w, webhook_module.HookEventPush, p); err != nil {
|
||||
ctx.Flash.Error("PrepareWebhook: " + err.Error())
|
||||
if err := webhook_service.PrepareTestWebhook(ctx, w, webhook_module.HookEventPush, p); err != nil {
|
||||
ctx.Flash.Error("PrepareTestWebhook: " + err.Error())
|
||||
ctx.Status(http.StatusInternalServerError)
|
||||
} else {
|
||||
ctx.Flash.Info(ctx.Tr("repo.settings.webhook.delivery.success"))
|
||||
|
||||
@@ -373,16 +373,18 @@ func RunnerBulkActionPost(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var runnerIDs []int64
|
||||
if rCtx.IsAdmin {
|
||||
// ATTENTION: it completely depends on the assumption that the doer is "site admin"
|
||||
// So it doesn't do extra permission check to the runner IDs
|
||||
// In the future, if you need to support such operation on non-admin pages, be careful!
|
||||
runnerIDs = ctx.FormStringInt64s("ids")
|
||||
} else {
|
||||
if !rCtx.IsAdmin {
|
||||
ctx.HTTPError(http.StatusForbidden, "bulk actions are admin-only")
|
||||
return
|
||||
}
|
||||
// ATTENTION: it completely depends on the assumption that the doer is "site admin"
|
||||
// So it doesn't do extra permission check to the runner IDs
|
||||
// In the future, if you need to support such operation on non-admin pages, be careful!
|
||||
runnerIDs := ctx.FormStringInt64s("ids")
|
||||
if len(runnerIDs) == 0 {
|
||||
ctx.HTTPError(http.StatusBadRequest, "missing runner IDs")
|
||||
return
|
||||
}
|
||||
|
||||
action := ctx.FormString("action")
|
||||
var successKey, failedKey string
|
||||
|
||||
@@ -6,16 +6,22 @@ package actions
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"maps"
|
||||
"time"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/organization"
|
||||
perm_model "gitea.dev/models/perm"
|
||||
access_model "gitea.dev/models/perm/access"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unit"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/timeutil"
|
||||
webhook_module "gitea.dev/modules/webhook"
|
||||
"gitea.dev/services/convert"
|
||||
)
|
||||
|
||||
// StartScheduleTasks start the task
|
||||
@@ -102,7 +108,19 @@ func startTasks(ctx context.Context) error {
|
||||
// It creates an action run based on the schedule, inserts it into the database, and creates commit statuses for each job.
|
||||
func CreateScheduleTask(ctx context.Context, spec *actions_model.ActionScheduleSpec) error {
|
||||
cron := spec.Schedule
|
||||
eventPayload := withScheduleInEventPayload(cron.EventPayload, spec.Spec)
|
||||
|
||||
// Scheduled runs carry no webhook payload; synthesize what github.event.* expects.
|
||||
if err := spec.Repo.LoadOwner(ctx); err != nil {
|
||||
return fmt.Errorf("LoadOwner: %w", err)
|
||||
}
|
||||
fields := map[string]any{
|
||||
"repository": convert.ToRepo(ctx, spec.Repo, access_model.Permission{AccessMode: perm_model.AccessModeRead}),
|
||||
"sender": convert.ToUser(ctx, user_model.NewActionsUser(), nil),
|
||||
}
|
||||
if spec.Repo.Owner.IsOrganization() {
|
||||
fields["organization"] = convert.ToOrganization(ctx, organization.OrgFromUser(spec.Repo.Owner))
|
||||
}
|
||||
eventPayload := withScheduleInEventPayload(cron.EventPayload, spec.Spec, fields)
|
||||
|
||||
// Create a new action run based on the schedule
|
||||
run := &actions_model.ActionRun{
|
||||
@@ -134,7 +152,7 @@ func CreateScheduleTask(ctx context.Context, spec *actions_model.ActionScheduleS
|
||||
return nil
|
||||
}
|
||||
|
||||
func withScheduleInEventPayload(eventPayload, schedule string) string {
|
||||
func withScheduleInEventPayload(eventPayload, schedule string, fields map[string]any) string {
|
||||
if schedule == "" {
|
||||
return eventPayload
|
||||
}
|
||||
@@ -153,6 +171,7 @@ func withScheduleInEventPayload(eventPayload, schedule string) string {
|
||||
event = map[string]any{}
|
||||
}
|
||||
|
||||
maps.Copy(event, fields)
|
||||
event["schedule"] = schedule
|
||||
updatedPayload, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modules/json"
|
||||
api "gitea.dev/modules/structs"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
@@ -14,7 +15,7 @@ import (
|
||||
func TestWithScheduleInEventPayload(t *testing.T) {
|
||||
t.Run("adds schedule to existing payload", func(t *testing.T) {
|
||||
payload := `{"ref":"refs/heads/main"}`
|
||||
updated := withScheduleInEventPayload(payload, "*/5 * * * *")
|
||||
updated := withScheduleInEventPayload(payload, "*/5 * * * *", nil)
|
||||
|
||||
event := map[string]any{}
|
||||
assert.NoError(t, json.Unmarshal([]byte(updated), &event))
|
||||
@@ -23,7 +24,7 @@ func TestWithScheduleInEventPayload(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("adds schedule to null payload", func(t *testing.T) {
|
||||
updated := withScheduleInEventPayload("null", "37 12 5 1 2")
|
||||
updated := withScheduleInEventPayload("null", "37 12 5 1 2", nil)
|
||||
|
||||
event := map[string]any{}
|
||||
assert.NoError(t, json.Unmarshal([]byte(updated), &event))
|
||||
@@ -31,22 +32,37 @@ func TestWithScheduleInEventPayload(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("adds schedule to empty payload", func(t *testing.T) {
|
||||
updated := withScheduleInEventPayload("", "37 12 5 1 2")
|
||||
updated := withScheduleInEventPayload("", "37 12 5 1 2", nil)
|
||||
|
||||
event := map[string]any{}
|
||||
assert.NoError(t, json.Unmarshal([]byte(updated), &event))
|
||||
assert.Equal(t, "37 12 5 1 2", event["schedule"])
|
||||
})
|
||||
|
||||
t.Run("adds schedule with repository, sender, organization", func(t *testing.T) {
|
||||
updated := withScheduleInEventPayload("null", "@weekly", map[string]any{
|
||||
"repository": &api.Repository{Name: "test-repo"},
|
||||
"sender": &api.User{UserName: "test-user"},
|
||||
"organization": &api.Organization{Name: "test-org"},
|
||||
})
|
||||
|
||||
event := map[string]any{}
|
||||
assert.NoError(t, json.Unmarshal([]byte(updated), &event))
|
||||
assert.Equal(t, "@weekly", event["schedule"])
|
||||
assert.Equal(t, "test-repo", event["repository"].(map[string]any)["name"])
|
||||
assert.Equal(t, "test-user", event["sender"].(map[string]any)["login"])
|
||||
assert.Equal(t, "test-org", event["organization"].(map[string]any)["name"])
|
||||
})
|
||||
|
||||
t.Run("keeps payload when schedule empty", func(t *testing.T) {
|
||||
payload := `{"ref":"refs/heads/main"}`
|
||||
updated := withScheduleInEventPayload(payload, "")
|
||||
updated := withScheduleInEventPayload(payload, "", nil)
|
||||
assert.Equal(t, payload, updated)
|
||||
})
|
||||
|
||||
t.Run("keeps payload when malformed JSON", func(t *testing.T) {
|
||||
payload := `not a json object`
|
||||
updated := withScheduleInEventPayload(payload, "*/5 * * * *")
|
||||
updated := withScheduleInEventPayload(payload, "*/5 * * * *", nil)
|
||||
assert.Equal(t, payload, updated)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1285,6 +1285,8 @@ func getDiffBasic(ctx context.Context, gitRepo *git.Repository, opts *DiffOption
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
// HINT: GIT-DIFF-HIGHLIGHT-LINE-NUMBER: git doesn't treat CR(\r) as EOL, CR is just a plain char which can appear anywhere in the diff output
|
||||
// Since we have to do full-file-highlighting for the diff result, we need to make sure the highlighted lines exactly match the git's diff output.
|
||||
cmdDiff := gitcmd.NewCommand().
|
||||
AddArguments("diff", "--src-prefix=\\a/", "--dst-prefix=\\b/").
|
||||
AddArguments(opts.WhitespaceBehavior...).
|
||||
@@ -1405,8 +1407,12 @@ func highlightCodeLines(name, lang string, sections []*DiffSection, isLeft bool,
|
||||
if setting.Git.DisableDiffHighlight || len(rawContent) > MaxFullFileHighlightSizeLimit {
|
||||
return nil
|
||||
}
|
||||
|
||||
content := util.UnsafeBytesToString(charset.ToUTF8(rawContent, charset.ConvertOpts{}))
|
||||
// HINT: GIT-DIFF-HIGHLIGHT-LINE-NUMBER: it should handle all CR(\r) before highlight to make line numbers match
|
||||
if strings.Contains(content, "\r") {
|
||||
content = strings.ReplaceAll(content, "\r\n", "\n")
|
||||
content = strings.ReplaceAll(content, "\r", "␍")
|
||||
}
|
||||
lexer := highlight.DetectChromaLexerByFileName(name, lang)
|
||||
highlightedNewContent := highlight.RenderCodeByLexer(lexer, content)
|
||||
unsafeLines := highlight.UnsafeSplitHighlightedLines(highlightedNewContent)
|
||||
|
||||
@@ -1143,6 +1143,19 @@ func TestHighlightCodeLines(t *testing.T) {
|
||||
1: `<span class="n">b</span>` + nl,
|
||||
}, ret)
|
||||
})
|
||||
t.Run("CharCR", func(t *testing.T) {
|
||||
diffFile := &DiffFile{
|
||||
Name: "a.txt",
|
||||
Sections: []*DiffSection{
|
||||
{
|
||||
Lines: []*DiffLine{{LeftIdx: 1}, {LeftIdx: 2}},
|
||||
},
|
||||
},
|
||||
}
|
||||
ret := highlightCodeLinesForDiffFile(diffFile, true, []byte("a\rb\r\nc"))
|
||||
assert.Equal(t, "a␍b\n", string(ret[0]))
|
||||
assert.Equal(t, `c`, string(ret[1]))
|
||||
})
|
||||
}
|
||||
|
||||
func TestSyncUserSpecificDiff_UpdatedFiles(t *testing.T) {
|
||||
|
||||
@@ -99,7 +99,9 @@ func composeIssueCommentMessages(ctx context.Context, comment *mailComment, lang
|
||||
}
|
||||
}
|
||||
locale := translation.NewLocale(lang)
|
||||
|
||||
if lang == "mock" {
|
||||
locale = &translation.MockLocale{}
|
||||
}
|
||||
mailMeta := map[string]any{
|
||||
"locale": locale,
|
||||
"FallbackSubject": fallback,
|
||||
|
||||
@@ -18,10 +18,14 @@ import (
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
activities_model "gitea.dev/models/activities"
|
||||
"gitea.dev/models/asymkey"
|
||||
git_model "gitea.dev/models/git"
|
||||
"gitea.dev/models/gituser"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/markup"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/storage"
|
||||
@@ -556,3 +560,32 @@ func TestEmbedBase64Images(t *testing.T) {
|
||||
assert.Equal(t, expected, string(resultMailBody))
|
||||
})
|
||||
}
|
||||
|
||||
func TestMailPullRequestPush(t *testing.T) {
|
||||
doer, _, issue, comment := prepareMailerTest(t)
|
||||
mc := &mailComment{
|
||||
Issue: issue,
|
||||
Comment: comment,
|
||||
Doer: doer,
|
||||
}
|
||||
issue.IsPull = true
|
||||
issue.PullRequest = &issues_model.PullRequest{BaseRepo: mc.Issue.Repo}
|
||||
mc.Comment.Type = issues_model.CommentTypePullRequestPush
|
||||
mc.Comment.Commits = []*git_model.SignCommitWithStatuses{
|
||||
{
|
||||
SignCommit: &asymkey.SignCommit{
|
||||
UserCommit: &gituser.UserCommit{
|
||||
GitCommit: &git.Commit{
|
||||
CommitMessage: git.CommitMessage{MessageRaw: "test commit msg"},
|
||||
ID: git.Sha1ObjectFormat.EmptyObjectID(),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
msgs, err := composeIssueCommentMessages(t.Context(), mc, "mock", []*user_model.User{{Name: "Test", Email: "test@gitea.com"}}, false, "pull request push")
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, msgs[0].Body, `<a href="https://try.gitea.io/user2/repo1/commit/0000000000000000000000000000000000000000">0000000000</a> - test commit msg`)
|
||||
assert.Contains(t, msgs[0].Body, `</html>`)
|
||||
}
|
||||
|
||||
@@ -777,30 +777,25 @@ func CloseRepoBranchesPulls(ctx context.Context, doer *user_model.User, repo *re
|
||||
}
|
||||
|
||||
// GetSquashMergeCommitMessages returns the commit messages between head and merge base (if there is one)
|
||||
func GetSquashMergeCommitMessages(ctx context.Context, pr *issues_model.PullRequest) string {
|
||||
func GetSquashMergeCommitMessages(ctx context.Context, pr *issues_model.PullRequest) (_ string, err error) {
|
||||
if err := pr.LoadIssue(ctx); err != nil {
|
||||
log.Error("Cannot load issue %d for PR id %d: Error: %v", pr.IssueID, pr.ID, err)
|
||||
return ""
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := pr.Issue.LoadPoster(ctx); err != nil {
|
||||
log.Error("Cannot load poster %d for pr id %d, index %d Error: %v", pr.Issue.PosterID, pr.ID, pr.Index, err)
|
||||
return ""
|
||||
return "", err
|
||||
}
|
||||
|
||||
if pr.HeadRepo == nil {
|
||||
var err error
|
||||
pr.HeadRepo, err = repo_model.GetRepositoryByID(ctx, pr.HeadRepoID)
|
||||
if err != nil {
|
||||
log.Error("GetRepositoryByIdCtx[%d]: %v", pr.HeadRepoID, err)
|
||||
return ""
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
gitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, pr.HeadRepo)
|
||||
if err != nil {
|
||||
log.Error("Unable to open head repository: Error: %v", err)
|
||||
return ""
|
||||
return "", err
|
||||
}
|
||||
defer closer.Close()
|
||||
|
||||
@@ -810,8 +805,7 @@ func GetSquashMergeCommitMessages(ctx context.Context, pr *issues_model.PullRequ
|
||||
} else {
|
||||
pr.HeadCommitID, err = gitRepo.GetRefCommitID(pr.GetGitHeadRefName())
|
||||
if err != nil {
|
||||
log.Error("Unable to get head commit: %s Error: %v", pr.GetGitHeadRefName(), err)
|
||||
return ""
|
||||
return "", err
|
||||
}
|
||||
headCommitRef = git.RefNameFromCommit(pr.HeadCommitID)
|
||||
}
|
||||
@@ -822,8 +816,7 @@ func GetSquashMergeCommitMessages(ctx context.Context, pr *issues_model.PullRequ
|
||||
|
||||
limitedCommits, err := gitRepo.CommitsBetween(headCommitRef, mergeBaseRef, limit)
|
||||
if err != nil {
|
||||
log.Error("Unable to get commits between: %s %s Error: %v", pr.HeadBranch, pr.MergeBase, err)
|
||||
return ""
|
||||
return "", err
|
||||
}
|
||||
|
||||
mergeMessage := strings.TrimSpace(pr.Issue.Content) // use PR's title and description as squash commit message
|
||||
@@ -831,7 +824,7 @@ func GetSquashMergeCommitMessages(ctx context.Context, pr *issues_model.PullRequ
|
||||
mergeMessage = formatSquashMergeCommitMessages(limitedCommits) // use PR's commit messages as squash commit message
|
||||
}
|
||||
coAuthors := collectSquashMergeCommitCoAuthors(ctx, gitRepo, pr, headCommitRef, mergeBaseRef, limit, limitedCommits)
|
||||
return buildSquashMergeCommitMessages(mergeMessage, coAuthors)
|
||||
return buildSquashMergeCommitMessages(mergeMessage, coAuthors), nil
|
||||
}
|
||||
|
||||
func buildSquashMergeCommitMessages(mergeMessage string, coAuthors []string) string {
|
||||
|
||||
@@ -145,7 +145,8 @@ func UpdateUser(ctx context.Context, u *user_model.User, opts *UpdateOptions) er
|
||||
}
|
||||
}
|
||||
|
||||
if opts.Visibility.Has() {
|
||||
// only validate and persist the visibility when it actually changes
|
||||
if opts.Visibility.Has() && opts.Visibility.Value() != u.Visibility {
|
||||
if !u.IsOrganization() && !setting.Service.AllowedUserVisibilityModesSlice.IsAllowedVisibility(opts.Visibility.Value()) {
|
||||
return fmt.Errorf("visibility mode not allowed: %s", opts.Visibility.Value().String())
|
||||
}
|
||||
|
||||
@@ -10,7 +10,9 @@ import (
|
||||
user_model "gitea.dev/models/user"
|
||||
password_module "gitea.dev/modules/auth/password"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/test"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
@@ -121,3 +123,33 @@ func TestUpdateAuth(t *testing.T) {
|
||||
Password: optional.Some("aaaa"),
|
||||
}), password_module.ErrMinLength)
|
||||
}
|
||||
|
||||
func TestUpdateUserVisibility(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
// user28's current visibility is public, e.g. an account created before public was disallowed
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 28})
|
||||
assert.Equal(t, structs.VisibleTypePublic, user.Visibility)
|
||||
|
||||
// public is no longer an allowed visibility mode, e.g. ALLOWED_USER_VISIBILITY_MODES = limited, private
|
||||
defer test.MockVariableValue(&setting.Service.AllowedUserVisibilityModesSlice, setting.AllowedVisibility{false, true, true})()
|
||||
|
||||
// re-submitting the unchanged (now-disallowed) visibility must not fail the whole update
|
||||
assert.NoError(t, UpdateUser(t.Context(), user, &UpdateOptions{
|
||||
FullName: optional.Some("Changed Name"),
|
||||
Visibility: optional.Some(structs.VisibleTypePublic),
|
||||
}))
|
||||
assert.Equal(t, "Changed Name", user.FullName)
|
||||
assert.Equal(t, structs.VisibleTypePublic, user.Visibility)
|
||||
|
||||
// changing to an allowed visibility still works
|
||||
assert.NoError(t, UpdateUser(t.Context(), user, &UpdateOptions{
|
||||
Visibility: optional.Some(structs.VisibleTypePrivate),
|
||||
}))
|
||||
assert.Equal(t, structs.VisibleTypePrivate, user.Visibility)
|
||||
|
||||
// genuinely changing to a disallowed visibility is still rejected
|
||||
assert.Error(t, UpdateUser(t.Context(), user, &UpdateOptions{
|
||||
Visibility: optional.Some(structs.VisibleTypePublic),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -129,6 +129,33 @@ func checkBranchFilter(branchFilter string, ref git.RefName) bool {
|
||||
return g.Match(ref.String())
|
||||
}
|
||||
|
||||
// PrepareTestWebhook always creates and enqueues a hook task for manual testing.
|
||||
// Unlike PrepareWebhook, it ignores event subscriptions and branch filters so the
|
||||
// Test Push Event control can verify delivery even when those gates would suppress
|
||||
// a real event.
|
||||
func PrepareTestWebhook(ctx context.Context, w *webhook_model.Webhook, event webhook_module.HookEventType, p api.Payloader) error {
|
||||
if setting.DisableWebhooks {
|
||||
return nil
|
||||
}
|
||||
|
||||
payload, err := p.JSONPayload()
|
||||
if err != nil {
|
||||
return fmt.Errorf("JSONPayload for %s: %w", event, err)
|
||||
}
|
||||
|
||||
task, err := webhook_model.CreateHookTask(ctx, &webhook_model.HookTask{
|
||||
HookID: w.ID,
|
||||
PayloadContent: string(payload),
|
||||
EventType: event,
|
||||
PayloadVersion: 2,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("CreateHookTask for %s: %w", event, err)
|
||||
}
|
||||
|
||||
return enqueueHookTask(task.ID)
|
||||
}
|
||||
|
||||
// PrepareWebhook creates a hook task and enqueues it for processing.
|
||||
// The payload is saved as-is. The adjustments depending on the webhook type happen
|
||||
// right before delivery, in the [Deliver] method.
|
||||
|
||||
@@ -30,6 +30,7 @@ func TestWebhookService(t *testing.T) {
|
||||
t.Run("PrepareBranchFilterNoMatch", testWebhookPrepareBranchFilterNoMatch)
|
||||
t.Run("WebhookUserMail", testWebhookUserMail)
|
||||
t.Run("CheckBranchFilter", testWebhookCheckBranchFilter)
|
||||
t.Run("PrepareTestWebhookIgnoresGates", testPrepareTestWebhookIgnoresGates)
|
||||
}
|
||||
|
||||
func testWebhookGetSlackHook(t *testing.T) {
|
||||
@@ -132,3 +133,37 @@ func testWebhookCheckBranchFilter(t *testing.T) {
|
||||
assert.Equal(t, v.match, checkBranchFilter(v.filter, v.ref), "filter: %q ref: %q", v.filter, v.ref)
|
||||
}
|
||||
}
|
||||
|
||||
func testPrepareTestWebhookIgnoresGates(t *testing.T) {
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
hook := &webhook_model.Webhook{
|
||||
RepoID: repo.ID,
|
||||
URL: "http://localhost/gitea-webhook-test-prepare_test_webhook",
|
||||
ContentType: webhook_model.ContentTypeJSON,
|
||||
IsActive: true,
|
||||
HookEvent: &webhook_module.HookEvent{
|
||||
ChooseEvents: true,
|
||||
BranchFilter: "dev",
|
||||
HookEvents: webhook_module.HookEvents{
|
||||
webhook_module.HookEventWorkflowRun: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
require.NoError(t, hook.UpdateEvent())
|
||||
require.NoError(t, db.Insert(t.Context(), hook))
|
||||
|
||||
payload := &api.PushPayload{
|
||||
Ref: "refs/heads/master",
|
||||
Commits: []*api.PayloadCommit{{}},
|
||||
}
|
||||
hookTask := &webhook_model.HookTask{HookID: hook.ID, EventType: webhook_module.HookEventPush}
|
||||
|
||||
// Real deliveries stay gated: no push event + branch filter mismatch => nothing queued.
|
||||
unittest.AssertNotExistsBean(t, hookTask)
|
||||
require.NoError(t, PrepareWebhook(t.Context(), hook, webhook_module.HookEventPush, payload))
|
||||
unittest.AssertNotExistsBean(t, hookTask)
|
||||
|
||||
// Manual test delivery always queues so the endpoint can be verified.
|
||||
require.NoError(t, PrepareTestWebhook(t.Context(), hook, webhook_module.HookEventPush, payload))
|
||||
unittest.AssertExistsAndLoadBean(t, hookTask)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
{{template "devtest/devtest-header"}}
|
||||
<div class="page-content devtest">
|
||||
<div class="ui container">
|
||||
<h3>Flex List (standalone)</h3>
|
||||
<h3>Flex Relaxed List</h3>
|
||||
<div class="flex-container tw-border">
|
||||
<div class="flex-relaxed-list tw-flex-1">
|
||||
<div class="flex-left-right">
|
||||
<span class="gt-ellipsis">left looooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong</span>
|
||||
<span>right</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Flex Divided List (standalone)</h3>
|
||||
<div class="divider"></div>
|
||||
<div class="flex-divided-list items-with-main">
|
||||
<div class="item">
|
||||
@@ -87,7 +97,7 @@
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<h3>Flex List (with "ui segment")</h3>
|
||||
<h3>Flex Divided List (with "ui segment")</h3>
|
||||
<div class="ui attached segment">
|
||||
<div class="flex-divided-list">
|
||||
<div class="item">item 1</div>
|
||||
@@ -101,7 +111,7 @@
|
||||
<div class="item">item 2</div>
|
||||
</div>
|
||||
</div>
|
||||
<h3>Flex List (with "ui segment fitted", items have their own padding)</h3>
|
||||
<h3>Flex Divided List (with "ui segment fitted", items have their own padding)</h3>
|
||||
<div class="ui fitted segment">
|
||||
<div class="flex-divided-list items-px-default">
|
||||
<div class="item">item 1</div>
|
||||
|
||||
@@ -63,19 +63,18 @@
|
||||
<div>{{.RenderedContent}}</div>
|
||||
</div>
|
||||
{{end -}}
|
||||
{{if eq .ActionName "push"}}
|
||||
<ul>
|
||||
{{$repoURL := $.Comment.Issue.PullRequest.BaseRepo.HTMLURL}}
|
||||
{{range $commit := $.Comment.Commits}}
|
||||
<li>
|
||||
<a href="{{$repoURL}}/commit/{{$commit.ID}}">
|
||||
{{ShortSha $commit.ID.String}}
|
||||
</a> - {{$commit.MessageTitle}}
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
</p>
|
||||
{{if eq .ActionName "push"}}
|
||||
<ul>
|
||||
{{$repoURL := $.Comment.Issue.PullRequest.BaseRepo.HTMLURL}}
|
||||
{{range $commit := $.Comment.Commits}}
|
||||
{{$gitCommit := $commit.UserCommit.GitCommit}}
|
||||
<li>
|
||||
<a href="{{$repoURL}}/commit/{{$gitCommit.ID}}">{{ShortSha $gitCommit.ID.String}}</a> - {{$gitCommit.MessageTitle}}
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
<div style="font-size:small; color:#666;">
|
||||
<p>
|
||||
---
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div class="ui container tw-flex tw-gap-4">
|
||||
<div class="ui container flex-container">
|
||||
<div>{{ctx.AvatarUtils.Avatar .Org 100}}</div>
|
||||
<div class="flex-relaxed-list">
|
||||
<div class="flex-relaxed-list tw-flex-1">
|
||||
<div class="ui header flex-left-right tw-m-0">
|
||||
<div class="flex-text-block">
|
||||
<span class="tw-text-2xl">{{.Org.DisplayName}}</span>
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
data-locale-artifacts-title="{{ctx.Locale.Tr "artifacts"}}"
|
||||
data-locale-artifact-expired="{{ctx.Locale.Tr "expired"}}"
|
||||
data-locale-artifact-expires-at="{{ctx.Locale.Tr "artifact_expires_at"}}"
|
||||
data-locale-artifact-expired-at="{{ctx.Locale.Tr "artifact_expired_at"}}"
|
||||
data-locale-confirm-delete-artifact="{{ctx.Locale.Tr "confirm_delete_artifact"}}"
|
||||
data-locale-show-timestamps="{{ctx.Locale.Tr "show_timestamps"}}"
|
||||
data-locale-show-log-seconds="{{ctx.Locale.Tr "show_log_seconds"}}"
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
{{$isNew:=or .PageIsSettingsHooksNew .PageIsAdminDefaultHooksNew .PageIsAdminSystemHooksNew}}
|
||||
{{if .PageIsSettingsHooksEdit}}
|
||||
<h4 class="ui top attached header">
|
||||
{{ctx.Locale.Tr "repo.settings.recent_deliveries"}}
|
||||
<h4 class="ui top attached header flex-left-right">
|
||||
<span>{{ctx.Locale.Tr "repo.settings.recent_deliveries"}}</span>
|
||||
{{if .Permission.IsAdmin}}
|
||||
<div class="ui right">
|
||||
<!-- the button is wrapped with a span because the tooltip doesn't show on hover if we put data-tooltip-content directly on the button -->
|
||||
<span data-tooltip-content="{{if or $isNew .Webhook.IsActive}}{{ctx.Locale.Tr "repo.settings.webhook.test_delivery_desc"}}{{else}}{{ctx.Locale.Tr "repo.settings.webhook.test_delivery_desc_disabled"}}{{end}}">
|
||||
<button class="ui tiny button{{if not (or $isNew .Webhook.IsActive)}} disabled{{end}}" id="test-delivery" data-link="{{.Link}}/test">
|
||||
<span class="text">{{ctx.Locale.Tr "repo.settings.webhook.test_delivery"}}</span>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<button class="ui tiny button" id="test-delivery" data-link="{{.Link}}/test"
|
||||
data-tooltip-content="{{ctx.Locale.Tr "repo.settings.webhook.test_delivery_desc"}}"
|
||||
>
|
||||
{{ctx.Locale.Tr "repo.settings.webhook.test_delivery"}}
|
||||
</button>
|
||||
{{end}}
|
||||
</h4>
|
||||
<div class="ui attached segment">
|
||||
|
||||
@@ -44,9 +44,9 @@
|
||||
<div class="ui attached segment tw-hidden" data-global-init="initRunnerBulkToolbar">
|
||||
<form action="{{$.Link}}/bulk" method="post" class="form-fetch-action">
|
||||
<input type="hidden" name="ids">
|
||||
<button class="ui small button" name="action" value="disable">{{ctx.Locale.Tr "actions.runners.disable_runner"}} <span class="runner-bulk-count"></span></button>
|
||||
<button class="ui small button" name="action" value="enable">{{ctx.Locale.Tr "actions.runners.enable_runner"}} <span class="runner-bulk-count"></span></button>
|
||||
<button class="ui small red button" name="action" value="delete"
|
||||
<button class="ui small button runner-bulk-action" name="action" value="disable">{{ctx.Locale.Tr "actions.runners.disable_runner"}} <span class="runner-bulk-count"></span></button>
|
||||
<button class="ui small button runner-bulk-action" name="action" value="enable">{{ctx.Locale.Tr "actions.runners.enable_runner"}} <span class="runner-bulk-count"></span></button>
|
||||
<button class="ui small red button runner-bulk-action" name="action" value="delete"
|
||||
data-modal-confirm-header="{{ctx.Locale.Tr "actions.runners.delete_runner_header"}}"
|
||||
data-modal-confirm-content="{{ctx.Locale.Tr "actions.runners.delete_runner_notice"}}"
|
||||
>{{ctx.Locale.Tr "actions.runners.delete_runner"}} <span class="runner-bulk-count"></span>
|
||||
|
||||
@@ -196,6 +196,13 @@ func TestActionsRunnerModify(t *testing.T) {
|
||||
doBulk(t, sessionAdmin, "evict", allIDs, http.StatusBadRequest)
|
||||
})
|
||||
|
||||
t.Run("EmptyIDs", func(t *testing.T) {
|
||||
doBulk(t, sessionAdmin, "delete", nil, http.StatusBadRequest)
|
||||
for _, id := range allIDs {
|
||||
unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{ID: id})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("DisableEnable", func(t *testing.T) {
|
||||
doBulk(t, sessionAdmin, "disable", allIDs, http.StatusOK)
|
||||
for _, id := range allIDs {
|
||||
|
||||
@@ -268,6 +268,36 @@ AACAX/AKARNTyAAoAAA=`
|
||||
AddBasicAuth(user.Name)
|
||||
MakeRequest(t, req, http.StatusNoContent)
|
||||
})
|
||||
|
||||
t.Run("NoArchOnly", func(t *testing.T) {
|
||||
defer tests.PrintCurrentTest(t)()
|
||||
|
||||
// A repository that only contains noarch packages has no per-architecture index,
|
||||
// but apk always requests the index for its own architecture (e.g. x86_64).
|
||||
// That request must fall back to the noarch index instead of 404ing.
|
||||
noarchRepository := repository + "-noarchonly"
|
||||
|
||||
req := NewRequestWithBody(t, "PUT", fmt.Sprintf("%s/%s/%s", rootURL, branch, noarchRepository), bytes.NewReader(noarchContent)).
|
||||
AddBasicAuth(user.Name)
|
||||
MakeRequest(t, req, http.StatusCreated)
|
||||
|
||||
req = NewRequest(t, "GET", fmt.Sprintf("%s/%s/%s/x86_64/APKINDEX.tar.gz", rootURL, branch, noarchRepository))
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
content, err := readIndexContent(resp.Body)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Contains(t, content, "C:Q1kbH5WoIPFccQYyATanaKXd2cJcc=\n")
|
||||
assert.Contains(t, content, "A:noarch\n")
|
||||
|
||||
// The noarch index is still directly retrievable too.
|
||||
req = NewRequest(t, "GET", fmt.Sprintf("%s/%s/%s/noarch/APKINDEX.tar.gz", rootURL, branch, noarchRepository))
|
||||
MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
req = NewRequest(t, "DELETE", fmt.Sprintf("%s/%s/%s/noarch/gitea-noarch-1.4-r0.apk", rootURL, branch, noarchRepository)).
|
||||
AddBasicAuth(user.Name)
|
||||
MakeRequest(t, req, http.StatusNoContent)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1321,7 +1321,8 @@ Co-authored-by: user4 <user4@example.com>
|
||||
pullIndex, err := strconv.ParseInt(elems[4], 10, 64)
|
||||
assert.NoError(t, err)
|
||||
pullRequest := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{BaseRepoID: repo.ID, Index: pullIndex})
|
||||
squashMergeCommitMessage := pull_service.GetSquashMergeCommitMessages(t.Context(), pullRequest)
|
||||
squashMergeCommitMessage, err := pull_service.GetSquashMergeCommitMessages(t.Context(), pullRequest)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tc.expectedMessage, squashMergeCommitMessage)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-block);
|
||||
min-width: 0; /* keep the same style as "flex-text-block" etc, make the text content wrap/ellipse correctly */
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.flex-relaxed-list > .divider {
|
||||
|
||||
@@ -216,10 +216,7 @@ onBeforeUnmount(() => {
|
||||
<div class="ui divider"/>
|
||||
<div class="left-list-header">{{ locale.allJobs }}</div>
|
||||
<div class="flex-items-block action-view-sidebar-list">
|
||||
<div
|
||||
class="item job-brief-item"
|
||||
:class="{'selected': props.jobId === item.job.id}"
|
||||
:style="{paddingLeft: `${10 + item.depth * 16}px`}"
|
||||
<template
|
||||
v-for="item in visibleJobListItems"
|
||||
:key="item.job.id"
|
||||
>
|
||||
@@ -228,7 +225,9 @@ onBeforeUnmount(() => {
|
||||
<button
|
||||
v-if="item.job.isReusableCaller"
|
||||
type="button"
|
||||
class="tw-contents caller-row-toggle"
|
||||
class="item caller-row-toggle"
|
||||
:class="{'selected': props.jobId === item.job.id}"
|
||||
:style="{paddingLeft: `${10 + item.depth * 16}px`}"
|
||||
@click="toggleExpandedJob(item.job.id)"
|
||||
:title="isJobCollapsed(item.job.id) ? locale.expandCallerJobs : locale.collapseCallerJobs"
|
||||
:aria-label="isJobCollapsed(item.job.id) ? locale.expandCallerJobs : locale.collapseCallerJobs"
|
||||
@@ -239,13 +238,19 @@ onBeforeUnmount(() => {
|
||||
<span class="job-duration">{{ item.job.duration }}</span>
|
||||
<SvgIcon name="octicon-chevron-down" :size="14" class="job-brief-toggle-icon" :class="{'collapsed': isJobCollapsed(item.job.id)}"/>
|
||||
</button>
|
||||
<a v-else class="tw-contents silenced" :href="item.job.link">
|
||||
<a
|
||||
v-else
|
||||
class="item silenced"
|
||||
:class="{'selected': props.jobId === item.job.id}"
|
||||
:style="{paddingLeft: `${10 + item.depth * 16}px`}"
|
||||
:href="item.job.link"
|
||||
>
|
||||
<ActionStatusIcon :locale-status="locale.status[item.job.status]" :status="item.job.status" icon-variant="circle-fill"/>
|
||||
<span class="tw-min-w-0 gt-ellipsis">{{ item.job.name }}</span>
|
||||
<SvgIcon name="octicon-sync" role="button" :data-tooltip-content="locale.rerun" class="job-rerun-button tw-cursor-pointer link-action interact-fg" :data-url="`${run.link}/jobs/${item.job.id}/rerun`" v-if="item.job.canRerun"/>
|
||||
<span class="job-duration">{{ item.job.duration }}</span>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- artifacts list -->
|
||||
@@ -269,7 +274,12 @@ onBeforeUnmount(() => {
|
||||
<SvgIcon name="octicon-trash"/>
|
||||
</a>
|
||||
</template>
|
||||
<span v-else class="flex-text-block tw-flex-1 tw-min-w-0 tw-text-text-light-2">
|
||||
<span
|
||||
v-else class="flex-text-block tw-flex-1 tw-min-w-0 tw-text-text-light-2"
|
||||
:data-tooltip-content="buildArtifactTooltipHtml(artifact, locale.artifactExpiredAt)"
|
||||
data-tooltip-render="html"
|
||||
data-tooltip-placement="top-end"
|
||||
>
|
||||
<SvgIcon name="octicon-file-removed"/>
|
||||
<span class="tw-flex-1 gt-ellipsis">{{ artifact.name }}</span>
|
||||
<span class="ui label tw-flex-shrink-0">{{ locale.artifactExpired }}</span>
|
||||
@@ -450,10 +460,11 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.caller-row-toggle {
|
||||
width: 100%;
|
||||
border: none;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
line-height: inherit; /* buttons don't inherit line-height; match the <a> rows' row height */
|
||||
cursor: pointer;
|
||||
text-align: inherit;
|
||||
}
|
||||
@@ -483,13 +494,13 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.action-view-sidebar-list > .item:hover .job-rerun-button,
|
||||
.action-view-sidebar-list > .item:has(a:focus) .job-rerun-button {
|
||||
.action-view-sidebar-list > .item:focus .job-rerun-button {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
/* only swap out the duration when a re-run button exists to take its place */
|
||||
.action-view-sidebar-list > .item:hover .job-rerun-button ~ .job-duration,
|
||||
.action-view-sidebar-list > .item:has(a:focus) .job-rerun-button ~ .job-duration {
|
||||
.action-view-sidebar-list > .item:focus .job-rerun-button ~ .job-duration {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -157,16 +157,18 @@ test('reusable callers with identical dependency signature are kept as separate
|
||||
expect(graph.nodes.find((n) => n.id === 'job:3')?.name).toBe('cross-repo caller');
|
||||
});
|
||||
|
||||
test('reusable caller with matrix-pattern name does not get absorbed into a sibling matrix node', () => {
|
||||
test('matrix legs that call a reusable workflow are folded into a single matrix node', () => {
|
||||
const jobs: ActionsJob[] = [
|
||||
{id: 1, link: '', jobId: 'deploy_dev', name: 'deploy (dev)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '1s'},
|
||||
{id: 2, link: '', jobId: 'deploy_qa', name: 'deploy (qa)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '1s'},
|
||||
{id: 3, link: '', jobId: 'deploy_staging', name: 'deploy (staging)', status: 'running', canRerun: false, isReusableCaller: true, parentJobID: 0, duration: '2s', callUses: './.gitea/workflows/deploy.yml'},
|
||||
{id: 1, link: '', jobId: 'prepare', name: 'prepare', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '30s'},
|
||||
{id: 2, link: '', jobId: 'build_call_linux', name: 'build-call (linux)', status: 'success', canRerun: false, isReusableCaller: true, parentJobID: 0, duration: '1m', needs: ['prepare'], callUses: './.gitea/workflows/build.yml'},
|
||||
{id: 3, link: '', jobId: 'build_call_windows', name: 'build-call (windows)', status: 'success', canRerun: false, isReusableCaller: true, parentJobID: 0, duration: '2m', needs: ['prepare'], callUses: './.gitea/workflows/build.yml'},
|
||||
{id: 4, link: '', jobId: 'build_call_macos', name: 'build-call (macos)', status: 'success', canRerun: false, isReusableCaller: true, parentJobID: 0, duration: '90s', needs: ['prepare'], callUses: './.gitea/workflows/build.yml'},
|
||||
];
|
||||
const graph = createWorkflowGraphModel(jobs);
|
||||
expect(graph.nodes.find((n) => n.id === 'job:3')?.name).toBe('deploy (staging)');
|
||||
const matrixNode = graph.nodes.find((n) => n.type === 'matrix');
|
||||
expect(matrixNode?.jobs.map((j) => j.id).sort()).toEqual([1, 2]);
|
||||
const matrixNodes = graph.nodes.filter((n) => n.type === 'matrix');
|
||||
expect(matrixNodes).toHaveLength(1);
|
||||
expect(matrixNodes[0].matrixKey).toBe('build-call');
|
||||
expect(matrixNodes[0].jobs.map((j) => j.id).sort()).toEqual([2, 3, 4]);
|
||||
});
|
||||
|
||||
test('directed highlight state covers ancestors and descendants of the hovered node', () => {
|
||||
|
||||
@@ -264,9 +264,8 @@ function buildVisualGraph(
|
||||
|
||||
const matrixJobsByKey = new Map<string, ActionsJob[]>();
|
||||
for (const job of jobs) {
|
||||
// Reusable callers are distinct workflow files — never fold them into a matrix bucket
|
||||
// even if their display name happens to look like "name (variant)".
|
||||
if (job.isReusableCaller) continue;
|
||||
// Matrix legs that call a reusable workflow are still one logical job (a single `uses:`
|
||||
// expanded over the matrix), so fold them into a matrix node like any other matrix job.
|
||||
const matrixKey = matrixKeyFromJobName(job.name);
|
||||
if (!matrixKey) continue;
|
||||
if (!matrixJobsByKey.has(matrixKey)) matrixJobsByKey.set(matrixKey, []);
|
||||
@@ -322,10 +321,8 @@ function buildVisualGraph(
|
||||
const visualIdByJobId = new Map<number, string>();
|
||||
for (const job of jobs) {
|
||||
const matrixKey = matrixKeyFromJobName(job.name);
|
||||
// Symmetric with the matrix-bucket loop above: a reusable caller whose display name
|
||||
// happens to look like "name (variant)" must never be folded into the matrix node, or it
|
||||
// would silently vanish (its visualId would point at a matrix node it isn't part of).
|
||||
if (matrixKey && !job.isReusableCaller && (matrixJobsByKey.get(matrixKey)?.length ?? 0) > 1) {
|
||||
// Symmetric with the matrix-bucket loop above (callers included).
|
||||
if (matrixKey && (matrixJobsByKey.get(matrixKey)?.length ?? 0) > 1) {
|
||||
visualIdByJobId.set(job.id, `matrix:${matrixKey}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ function initAdminRunnerBulk(toolbar: HTMLElement) {
|
||||
|
||||
const refresh = () => {
|
||||
const checked = Array.from(rowCheckboxes).filter((c) => c.checked);
|
||||
formRunnerIds.value = checked.map((c) => c.getAttribute('data-runner-id')!).join(',');
|
||||
toggleElem(toolbar, checked.length > 0);
|
||||
for (const btn of actionButtons) {
|
||||
btn.querySelector<HTMLElement>('.runner-bulk-count')!.textContent = `(${checked.length})`;
|
||||
@@ -50,15 +51,6 @@ function initAdminRunnerBulk(toolbar: HTMLElement) {
|
||||
});
|
||||
for (const cb of rowCheckboxes) cb.addEventListener('change', refresh);
|
||||
refresh();
|
||||
|
||||
const collectSelectedIds = () => {
|
||||
const ids = [];
|
||||
for (const cb of rowCheckboxes) {
|
||||
if (cb.checked) ids.push(cb.getAttribute('data-runner-id')!);
|
||||
}
|
||||
return ids.join(',');
|
||||
};
|
||||
formRunnerIds.value = collectSelectedIds();
|
||||
}
|
||||
|
||||
function initAdminUser() {
|
||||
|
||||
@@ -67,6 +67,7 @@ function initRepositoryActionsView() {
|
||||
artifactsTitle: el.getAttribute('data-locale-artifacts-title'),
|
||||
artifactExpired: el.getAttribute('data-locale-artifact-expired'),
|
||||
artifactExpiresAt: el.getAttribute('data-locale-artifact-expires-at'),
|
||||
artifactExpiredAt: el.getAttribute('data-locale-artifact-expired-at'),
|
||||
confirmDeleteArtifact: el.getAttribute('data-locale-confirm-delete-artifact'),
|
||||
showTimeStamps: el.getAttribute('data-locale-show-timestamps'),
|
||||
showLogSeconds: el.getAttribute('data-locale-show-log-seconds'),
|
||||
|
||||
Reference in New Issue
Block a user