Compare commits

..

14 Commits

Author SHA1 Message Date
Giteabot
895d848ff4 fix: make commit message merge correctly (#38490) (#38502)
Backport #38490

fix #38487

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: bircni <bircni@icloud.com>
2026-07-17 08:09:01 +00:00
Giteabot
7199547218 fix(actions): explain why a blocked or waiting job has not started (#38476) (#38498)
Backport #38476 by @bircni

When an Actions job is blocked or waiting, the job view only shows the
generic **Blocked** / **Waiting** label. Users have no way to tell *why*
a job is stuck — whether it's waiting on dependencies, waiting for a
runner that doesn't exist, waiting for a runner whose labels don't
match, or simply queued behind busy runners.

## Change

The current-job detail line now surfaces the actual cause:

- **Blocked** → lists the dependency jobs (`needs`) that haven't
finished yet, e.g. *"Waiting for the following jobs to complete:
build."*
- **Waiting**, no online runner → *"No runner is online to pick up this
job."*
- **Waiting**, online runners but none match `runs-on` → *"No matching
online runner with label: X"* (reuses the existing string)
- **Waiting**, a matching runner exists but hasn't claimed the job →
*"Waiting for a matching runner to become available."*

The runner lookup reuses the same available-online-runner query the run
list already performs, and only runs while a selected job is actually
pending. Dependency resolution is scoped to the same parent job and
treats matrix expansions of a `need` as pending until all of them
complete.

Co-authored-by: bircni <bircni@icloud.com>
2026-07-16 21:45:11 +00:00
Giteabot
7cb4201f8e fix(actions): make cancelled() work in job if evaluation (#38495) (#38497)
Backport #38495 by @Zettat123

Fix #38485

#37478 moved `if` evaluation from runner to server. But `NewInterpeter`
always provides a nil `JobContext` for the evaluator, which makes
[`cancelled()`](ad967330a8/act/exprparser/functions.go (L299))
panic on `Job.Status` because `Job` is a nil pointer.

Co-authored-by: Zettat123 <zettat123@gmail.com>
2026-07-16 20:54:26 +00:00
Giteabot
5f017302bf fix(actions): show retention info on hover for expired artifacts (#38477) (#38493)
Backport #38477 by @bircni

The artifact info hover tooltip (#37100) only worked for live artifacts.
Hovering an expired/deleted artifact showed nothing, and even on live
artifacts the retention date was missing on real runs.

Two root causes:

1. **Expired artifacts had no tooltip.** In the run view sidebar,
expired artifacts render in a separate branch that omitted
`data-tooltip-content` entirely, so there was nothing to hover.
2. **`ExpiresUnix` was no longer sent.** The `ActionRunAttempt` refactor
(#37119) dropped `ExpiresUnix` from `fillViewRunResponseArtifacts`, so
real runs always returned `0` and the tooltip fell back to size-only.
Only the devtest mock still populated it, which masked the regression.

Artifacts without a recorded expiry (`expiresUnix <= 0`) still degrade
gracefully to a size-only tooltip. Both states can be previewed on the
`/devtest/mock/*` actions run page.

Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-07-16 17:01:56 +00:00
Giteabot
59c619660c fix(actions): group reusable-workflow matrix legs in the workflow graph (#38475) (#38492)
Backport #38475

Co-authored-by: bircni <bircni@icloud.com>
2026-07-16 15:58:06 +00:00
Giteabot
8599289459 fix: full file highlighting for git diff with CR char (#38484) (#38491)
Backport #38484

fix #38481

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-07-16 06:11:20 -07:00
Giteabot
da9f0a3726 fix(packages): serve noarch Alpine index for any requested architecture (#38479) (#38486)
Backport #38479 by @gaurav0107

Fixes #38456

## Problem
The Alpine package registry serves one `APKINDEX.tar.gz` per
architecture. When a repository contains only `noarch` packages (no
architecture-specific packages), only the `noarch` index is built.
Because `apk` substitutes `$ARCH` with the host architecture and
requests e.g. `x86_64/APKINDEX.tar.gz`, such a repository returned HTTP
404 and was unusable, matching the report in #38456.

## Fix
`GetRepositoryFile` now falls back to the `noarch` index when the
requested architecture has no index of its own, mirroring the fallback
already present in the sibling `DownloadPackageFile` handler. `noarch`
packages are installable on every architecture, so serving them for any
requested architecture is correct. The index-build side is unchanged;
only the serving path gains the fallback, so mixed repositories (which
already merge `noarch` into each per-architecture index) are unaffected.

## AI assistance disclosure
This change was implemented with the help of an AI coding assistant,
which gitea's CONTRIBUTING.md explicitly welcomes when disclosed. I have
reviewed the change, understand it, and can explain and defend it.

## Tests
Added a `NoArchOnly` subtest to `TestPackageAlpine` that publishes only
a `noarch` package to a fresh repository and asserts that `GET
.../x86_64/APKINDEX.tar.gz` now returns `200` (previously `404`) and
that the served index lists the noarch package. Verified locally with
`go build`/`go vet` on the changed package and a compile of the
integration test package (`go test -c`); the full integration run relies
on CI.

Signed-off-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: Gaurav Dubey <gauravdubey0107@gmail.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-07-16 12:28:39 +02:00
Giteabot
08fd59959e fix: 500 error when updating user visibility (#38480) (#38483)
Backport #38480 by @Zettat123

## How to reproduce this bug

1. Create a user with `visibility=public`
2. Update `ALLOWED_USER_VISIBILITY_MODES` setting to `limited, private`
3. Modify any field other than "User visibility" (e.g. "Full Name")
4. UI shows 500 error

## Fix

Only update the visibility field when it actually changes.

Co-authored-by: Zettat123 <zettat123@gmail.com>
2026-07-16 11:10:54 +02:00
Giteabot
d60215c2a2 fix(actions): make job list item fully clickable (#38462) (#38471)
Backport #38462 by @SudhanshuMatrix

Clicking the empty space to the right of a job in the Actions sidebar
didn't switch jobs: the interactive `<a>`/`<button>` used `display:
contents` and so generated no clickable box.

Fixes: https://github.com/go-gitea/gitea/issues/38460

Drop the wrapper `div` and `display: contents`, moving the `.item` and
layout styles directly onto the `<a>`/`<button>` so the whole row is
clickable. Reusable-caller `<button>` rows also get `width: 100%` and
`line-height: inherit` to match the `<a>` rows.

Co-authored-by: Shudhanshu Singh <sudhanshuwriterblc@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-07-15 17:18:05 +00:00
wxiaoguang
bf594690db fix: mail template for push event (#38467) (#38468)
Backport #38467
2026-07-15 18:57:03 +02:00
Giteabot
5cb7ec9304 fix: make "test push webhook" always work (#38425) (#38455)
Backport #38425

* fix #38309
* fix #26238
* fix #37886

Co-authored-by: Harsh Satyajit Thakur <f20240223@goa.bits-pilani.ac.in>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-07-15 05:51:44 +00:00
Giteabot
6e86c4cde8 fix(actions): prevent bulk actions from affecting all runners (#38453) (#38457)
Backport #38453

Co-authored-by: xkm <fzc_study@163.com>
2026-07-14 20:06:17 -07:00
Giteabot
c32af046a2 fix(org): align follow button and wrap description (#38448) (#38454)
Backport #38448

Co-authored-by: Shudhanshu Singh <sudhanshuwriterblc@gmail.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-07-15 10:12:39 +08:00
Giteabot
a98468da30 fix(actions): populate github.event for scheduled runs (#38446) (#38452)
Backport #38446 by @silverwind

Scheduled runs stored a `null` event payload, so
`github.event.repository`/`sender`/`organization` were empty in
scheduled workflows. Every other event carries them, and so does GitHub
Actions. `CreateScheduleTask` now synthesizes them.

Co-authored-by: silverwind <me@silverwind.io>
2026-07-14 19:32:08 +02:00
39 changed files with 707 additions and 141 deletions

View File

@@ -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

View File

@@ -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)
})
}
}

View File

@@ -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") {

View File

@@ -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)
}
}

View File

@@ -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",

View File

@@ -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)

View File

@@ -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,

View File

@@ -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",

View File

@@ -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

View File

@@ -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))
})
}

View File

@@ -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{

View File

@@ -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"))

View File

@@ -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

View File

@@ -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 {

View File

@@ -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)
})
}

View File

@@ -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)

View File

@@ -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) {

View File

@@ -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,

View File

@@ -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>`)
}

View File

@@ -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 {

View File

@@ -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())
}

View File

@@ -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),
}))
}

View File

@@ -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.

View File

@@ -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)
}

View File

@@ -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>

View File

@@ -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>
---

View File

@@ -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>

View File

@@ -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"}}"

View File

@@ -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">

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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)
})
})
}
}

View File

@@ -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)
})
}

View File

@@ -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 {

View File

@@ -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;
}

View File

@@ -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', () => {

View File

@@ -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;
}

View File

@@ -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() {

View File

@@ -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'),