Skip to content

Commit 42ae9c2

Browse files
authored
feat(scm): detect self-hosted GitLab out of the box with glab v1.5x (kunchenguid#346)
* fix(gitlab): drive self-hosted GitLab out-of-the-box with glab v1.5x Make the GitLab backend work against self-hosted instances and the current glab CLI without manual configuration. Self-hosted detection: - DetectProvider falls back to glab's configured hosts (config.yml, honoring GLAB_CONFIG_DIR/XDG_CONFIG_HOME) when a remote's hostname carries no "gitlab" marker, matching on the host key or its api_host. Reads only what the user configured; no host is hardcoded; any read/parse failure fails closed to ProviderUnknown. - Add scm.ExtractHost to recover the host (sans port, lowercased) from scp-style, URL, and ssh:// remotes, including IPv6 literals and '@'-in-path edge cases. glab v1.5x backend fixes: - Scope `glab auth status` with --hostname <host>. Unscoped, it checks every configured instance and fails if ANY has a stale token, falsely reporting an authenticated repo as unauthenticated. Falls back to the unscoped check when the host is unknown (fail-safe). - Drop the removed `--state opened` flag from `glab mr list`; open is the default in v1.5x and passing the unknown flag fails the whole command. - Read pipeline jobs via the branch-independent REST endpoint (`glab api projects/<group%2Fproject>/pipelines/<id>/jobs`) so they work in the daemon's detached-HEAD worktree, where `glab ci get` refuses to run. --paginate walks every page; the parser handles the resulting concatenated JSON documents. Maps finished_at into Check.CompletedAt for CI re-run detection. The legacy `glab ci get` path remains as the fallback when no project path is known. Structural polish: - Locate the GitLab project-path parser in the gitlab backend as exported gitlab.ProjectPath, mirroring github.RepoSlug, instead of a private helper in the pipeline layer. - Scope the Host via positional constructor params New(cmd, cliAvailable, host, projectPath), matching the GitHub backend, rather than an Options struct. - Distinguish clean EOF from a malformed mid-stream document when decoding paginated jobs: surface the decode error instead of silently swallowing a corrupt page, while still returning any jobs that parsed. - Escape each project-path segment with url.PathEscape (rejoined with %2F) instead of a bare slash replacement. * fix(gitlab): propagate paginated CI decode errors; tighten gitlab fallback test and ExtractHost subtests Surface decode errors from paginated `glab api` output so a corrupt later page can no longer hide a failed job. parseGitlabJobs previously dropped the decode error whenever at least one job parsed, letting GetChecks treat a partial slice as authoritative; a failed job on a dropped page would then read green. It now returns the parsed checks alongside any non-nil decode error. Align the GetChecks fallback test fixture with the real invocation: the fallback runs `glab ci get --pipeline-id <id> --output json --with-job-details`, but the mock keyed on the command without --with-job-details, so the case passed through the generic unexpected-command path instead of the intended fallback-error path. Convert TestExtractHost to t.Run subtests so each remote-URL case reports independently, matching the table-driven convention used by the neighboring tests in the file. * no-mistakes(document): document out-of-the-box self-hosted GitLab detection in provider guide * no-mistakes(document): document glab config env vars and self-hosted GitLab detection * no-mistakes: apply CI fixes
1 parent 3c09496 commit 42ae9c2

12 files changed

Lines changed: 722 additions & 62 deletions

File tree

AGENTS.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,12 @@ Safest local verification sequence after non-trivial changes:
6969
- GitLab and Bitbucket fork MR/PR routing is intentionally out of scope until implemented end to end.
7070
- If a legacy or manually edited row has `fork_url` for GitLab or Bitbucket, PR creation must skip instead of opening a self PR.
7171

72+
**GitLab Backend (`internal/scm/gitlab`)**
73+
74+
- The GitLab `Host` is constructed via `gitlab.New(cmd, cliAvailable, host, projectPath)`, mirroring the GitHub backend's positional constructor. `host` is the repo's GitLab hostname (from `scm.ExtractHost(UpstreamURL)`); `projectPath` is the `group/project` path (subgroups allowed, from `gitlab.ProjectPath` - which lives in the gitlab package next to the `Host` that consumes it, mirroring `github.RepoSlug`). Both are optional; passing `"", ""` reproduces the legacy unscoped behavior used by unit tests.
75+
- glab's flag surface drifts between versions; the backend is pinned against `glab v1.5x`. Two flags bit us: `glab auth status` must be **host-scoped** with `--hostname <host>` (unscoped, it checks every configured instance and fails if ANY has a stale token, falsely reporting an authenticated repo as unauthenticated); and `glab mr list` no longer accepts `--state opened` (open is the default; v1.5x exposes `-c/--closed`, `-M/--merged`, `-A/--all`) - passing the removed flag fails the whole command. When the host is unknown, fall back to the unscoped auth check (fail-safe).
76+
- The daemon operates in a **detached-HEAD worktree** (it checks out the commit, not a branch). `glab ci get` refuses to run there ("you're not on any Git branch (a 'detached HEAD' state)") even with an explicit `--pipeline-id`. Read pipeline jobs via the branch-independent REST endpoint instead: `glab api projects/<url-encoded group%2Fproject>/pipelines/<id>/jobs` (`Host.pipelineJobsArgs`). The legacy `glab ci get` path is kept only as the fallback when no project path is supplied. The `glab api .../jobs` payload carries `finished_at`, mapped into `Check.CompletedAt` (needed for CI re-run detection).
77+
7278
**Documentation**
7379

7480
- Keep `README.md` concise and high-level. The bar needs to be extremely high for what has to show up there.

docs/src/content/docs/guides/provider-integration.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,12 @@ These are GitHub and GitLab only right now.
140140

141141
Self-hosted GitHub Enterprise and self-hosted GitLab instances work through the same `gh` and `glab` CLIs. Authenticate the CLI against your instance (`gh auth login --hostname your-ghe.example.com`, `glab auth login --hostname gitlab.example.com`) and `no-mistakes` will route through the CLI as usual.
142142

143+
Self-hosted GitLab is detected out of the box even when the hostname carries no `gitlab` marker (for example `git.example.com`).
144+
When the hostname is not obviously GitLab, `no-mistakes` consults glab's configured hosts (`config.yml`, honoring `GLAB_CONFIG_DIR` then `XDG_CONFIG_HOME/glab-cli`, then `~/.config/glab-cli`) and treats the upstream as GitLab if its host appears there as a configured host or `api_host`.
145+
Running `glab auth login --hostname your-gitlab.example.com` is enough to make detection succeed; if glab is not configured for the host, detection fails closed and the upstream is treated as unsupported.
146+
147+
The GitLab backend is pinned against `glab v1.5x`. Self-hosted detection and the merge-request and CI steps rely on its current flag and API surface, so keep `glab` reasonably up to date.
148+
143149
## Unsupported hosts
144150

145151
If your upstream isn't GitHub, GitLab, or Bitbucket Cloud:

docs/src/content/docs/guides/troubleshooting.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,7 @@ Check the [Provider Integration](/no-mistakes/guides/provider-integration/) requ
191191
- `gh auth status` shows not authenticated
192192
- Bitbucket env vars not set in the daemon's environment
193193
- Upstream is on a host that isn't supported (GitHub, GitLab, or `bitbucket.org`)
194+
- Self-hosted GitLab on a hostname with no `gitlab` marker isn't detected because `glab` isn't configured for the host; run `glab auth login --hostname your-gitlab.example.com` so detection finds it
194195
- A GitLab or Bitbucket repo record has a fork URL set; fork MR/PR routing is currently GitHub-only
195196
- You pushed the default branch (PR step always skips on the default branch)
196197

docs/src/content/docs/reference/environment.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,28 @@ Data directory used to discover OpenCode transcripts for intent extraction.
7979
When set, no-mistakes looks for OpenCode's intent transcript database at `$XDG_DATA_HOME/opencode/opencode.db`.
8080
When unset, it falls back to `~/.local/share/opencode/opencode.db`.
8181

82+
## `GLAB_CONFIG_DIR`
83+
84+
Directory holding glab's `config.yml`, consulted when detecting self-hosted GitLab.
85+
86+
| | |
87+
|---|---|
88+
| Type | `string` |
89+
| Default | (none) |
90+
91+
When the upstream hostname carries no `gitlab` marker, no-mistakes reads glab's configured hosts from `$GLAB_CONFIG_DIR/config.yml` to decide whether the host is a GitLab instance. It takes precedence over `XDG_CONFIG_HOME`. See [Provider Integration](/no-mistakes/guides/provider-integration/#self-hosted-githubgitlab).
92+
93+
## `XDG_CONFIG_HOME`
94+
95+
Config directory used to locate glab's `config.yml` for self-hosted GitLab detection.
96+
97+
| | |
98+
|---|---|
99+
| Type | `string` |
100+
| Default | `~/.config` |
101+
102+
When `GLAB_CONFIG_DIR` is unset, no-mistakes looks for glab's configured hosts at `$XDG_CONFIG_HOME/glab-cli/config.yml`, falling back to `~/.config/glab-cli/config.yml` when `XDG_CONFIG_HOME` is unset.
103+
82104
## `NO_MISTAKES_UMAMI_HOST`
83105

84106
Override the telemetry collection host.

internal/pipeline/steps/host.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,12 @@ func buildHost(sctx *pipeline.StepContext, provider scm.Provider) (scm.Host, str
4141
// GitLab source-project routing is implemented end to end.
4242
return nil, "fork PR routing for GitLab is not implemented"
4343
}
44-
return gitlab.New(cmdFactory, func() bool { return stepCLIAvailable(sctx, provider) }), ""
44+
return gitlab.New(
45+
cmdFactory,
46+
func() bool { return stepCLIAvailable(sctx, provider) },
47+
scm.ExtractHost(sctx.Repo.UpstreamURL),
48+
gitlab.ProjectPath(sctx.Repo.UpstreamURL),
49+
), ""
4550
case scm.ProviderBitbucket:
4651
if sctx.Repo.ForkURL != "" {
4752
// Fork PR routing for Bitbucket is intentionally not half-wired.

internal/scm/auth_test.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,9 @@ func TestCLIAvailable(t *testing.T) {
4141
t.Fatal(err)
4242
}
4343
}
44-
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
44+
// Set PATH to JUST the temp dir so a real glab installed on the host does
45+
// not leak in and make the ProviderGitLab assertion below flaky.
46+
t.Setenv("PATH", binDir)
4547

4648
if !CLIAvailable(ProviderGitHub) {
4749
t.Fatal("expected gh to be available")

internal/scm/gitlab/gitlab.go

Lines changed: 179 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,16 @@
22
package gitlab
33

44
import (
5+
"bytes"
56
"context"
67
"encoding/json"
78
"errors"
89
"fmt"
10+
"io"
11+
"net/url"
912
"os/exec"
1013
"strings"
14+
"time"
1115

1216
"github.com/kunchenguid/no-mistakes/internal/scm"
1317
)
@@ -19,12 +23,93 @@ type CmdFactory func(ctx context.Context, name string, args ...string) *exec.Cmd
1923
type Host struct {
2024
cmd CmdFactory
2125
cliAvailable func() bool
26+
host string // repo's GitLab hostname; scopes the auth check
27+
projectPath string // repo's "group/project" path; enables REST job reads
2228
}
2329

2430
// New builds a Host. cliAvailable reports whether the glab binary is
25-
// resolvable on the caller's PATH (possibly overridden by env).
26-
func New(cmd CmdFactory, cliAvailable func() bool) *Host {
27-
return &Host{cmd: cmd, cliAvailable: cliAvailable}
31+
// resolvable on the caller's PATH (possibly overridden by env). host is the
32+
// repo's GitLab hostname; when set the availability check is scoped to it via
33+
// --hostname so a stale credential for an unrelated configured glab host cannot
34+
// make this repo look unauthenticated. projectPath is the repo's "group/project"
35+
// path (subgroups allowed); when set, pipeline-job reads go through `glab api`
36+
// (REST), which is branch-independent and works in the daemon's detached-HEAD
37+
// worktree, where `glab ci get` refuses to run without a current branch. Both
38+
// are optional; empty reproduces the legacy unscoped behavior.
39+
func New(cmd CmdFactory, cliAvailable func() bool, host, projectPath string) *Host {
40+
return &Host{
41+
cmd: cmd,
42+
cliAvailable: cliAvailable,
43+
host: strings.TrimSpace(host),
44+
projectPath: strings.TrimSpace(projectPath),
45+
}
46+
}
47+
48+
// ProjectPath extracts the "group/project" path (no host, no trailing .git)
49+
// from a GitLab remote URL. GitLab projects can live under nested subgroups, so
50+
// the full path - not just the last two segments - is returned. It handles
51+
// HTTPS/ssh:// URLs and scp-style SSH (git@host:group/project.git). Returns ""
52+
// when no path can be determined; callers treat that as "unknown" and fall back
53+
// to branch-dependent porcelain.
54+
func ProjectPath(raw string) string {
55+
raw = strings.TrimSpace(raw)
56+
if raw == "" {
57+
return ""
58+
}
59+
var path string
60+
if strings.Contains(raw, "://") {
61+
if u, err := url.Parse(raw); err == nil {
62+
path = u.Path
63+
}
64+
} else if colon := strings.Index(raw, ":"); colon >= 0 && !isWindowsDrivePath(raw) {
65+
// scp-style: [user@]host:group/project.git -> group/project. The first
66+
// ':' separates host from path, so the path is recovered whether or not
67+
// a "user@" prefix is present (e.g. gitlab.example.com:group/project.git).
68+
// A Windows drive-letter path (C:\...) carries a colon too, but it is a
69+
// local filesystem path, not a remote URL, so it is excluded above.
70+
path = raw[colon+1:]
71+
}
72+
path = strings.Trim(path, "/")
73+
return strings.TrimSuffix(path, ".git")
74+
}
75+
76+
// isWindowsDrivePath reports whether raw begins with a Windows drive specifier
77+
// like "C:\..." or "C:/...". Such a path's drive-letter colon must not be
78+
// mistaken for the host:path separator of scp-style SSH syntax, which would
79+
// otherwise turn a local filesystem path into a spurious "group/project".
80+
func isWindowsDrivePath(raw string) bool {
81+
if len(raw) < 2 || raw[1] != ':' {
82+
return false
83+
}
84+
c := raw[0]
85+
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')) {
86+
return false
87+
}
88+
return len(raw) == 2 || raw[2] == '\\' || raw[2] == '/'
89+
}
90+
91+
// pipelineJobsArgs returns the glab invocation that lists a pipeline's jobs.
92+
// With a known project path it uses `glab api` (branch-independent, works in a
93+
// detached-HEAD worktree); otherwise it falls back to `glab ci get`, which
94+
// needs a current branch.
95+
func (h *Host) pipelineJobsArgs(pipelineID int) []string {
96+
if h.projectPath != "" {
97+
// GitLab's REST API wants the project as a single URL-encoded
98+
// "group%2Fproject" path parameter. Escape each segment defensively and
99+
// rejoin with %2F so any reserved character in a segment is encoded too,
100+
// not just the separating slashes.
101+
segments := strings.Split(h.projectPath, "/")
102+
for i, seg := range segments {
103+
segments[i] = url.PathEscape(seg)
104+
}
105+
enc := strings.Join(segments, "%2F")
106+
// --paginate walks every page; a pipeline with more jobs than fit on one
107+
// page (GitLab defaults to 20 per page) would otherwise silently drop the
108+
// jobs on later pages and the CI verdict could miss a failed job. glab
109+
// writes one JSON array per page, so the parser handles concatenated docs.
110+
return []string{"api", "--paginate", fmt.Sprintf("projects/%s/pipelines/%d/jobs", enc, pipelineID)}
111+
}
112+
return []string{"ci", "get", "--pipeline-id", fmt.Sprintf("%d", pipelineID), "--output", "json", "--with-job-details"}
28113
}
29114

30115
func (h *Host) Provider() scm.Provider { return scm.ProviderGitLab }
@@ -37,7 +122,17 @@ func (h *Host) Available(ctx context.Context) error {
37122
if h.cliAvailable != nil && !h.cliAvailable() {
38123
return errors.New("glab CLI is not installed")
39124
}
40-
if err := h.cmd(ctx, "glab", "auth", "status").Run(); err != nil {
125+
// Scope the auth check to this repo's host. Unscoped `glab auth status`
126+
// checks every configured instance and exits non-zero if ANY of them has a
127+
// stale/expired token, even when this repo's own host is fully
128+
// authenticated. Passing --hostname keeps an unrelated bad credential from
129+
// poisoning availability for this repo. When the host is unknown we fall
130+
// back to the unscoped check (fail-safe: same behavior as before).
131+
authArgs := []string{"auth", "status"}
132+
if h.host != "" {
133+
authArgs = append(authArgs, "--hostname", h.host)
134+
}
135+
if err := h.cmd(ctx, "glab", authArgs...).Run(); err != nil {
41136
return errors.New("glab CLI is not authenticated")
42137
}
43138
return nil
@@ -70,7 +165,11 @@ func (h *Host) FindPR(ctx context.Context, branch, base string) (*scm.PR, error)
70165
if strings.TrimSpace(base) != "" {
71166
args = append(args, "--target-branch", base)
72167
}
73-
args = append(args, "--state", "opened", "--output", "json")
168+
// `glab mr list` returns open MRs by default. Older glab accepted
169+
// `--state opened`, but glab v1.5x removed it (it now exposes
170+
// -c/--closed, -M/--merged, -A/--all); passing the unknown flag fails the
171+
// whole command. Rely on the open-by-default behavior.
172+
args = append(args, "--output", "json")
74173
cmd := h.cmd(ctx, "glab", args...)
75174
out, err := cmd.CombinedOutput()
76175
if err != nil {
@@ -238,10 +337,10 @@ func (h *Host) getChecksFallback(ctx context.Context, pr *scm.PR) ([]scm.Check,
238337
if payload.HeadPipeline.ID == 0 {
239338
return nil, nil
240339
}
241-
jobsCmd := h.cmd(ctx, "glab", "ci", "get", "--pipeline-id", fmt.Sprintf("%d", payload.HeadPipeline.ID), "--output", "json", "--with-job-details")
340+
jobsCmd := h.cmd(ctx, "glab", h.pipelineJobsArgs(payload.HeadPipeline.ID)...)
242341
jobsOut, err := jobsCmd.CombinedOutput()
243342
if err != nil {
244-
return nil, fmt.Errorf("glab ci get: %s: %w", strings.TrimSpace(string(jobsOut)), err)
343+
return nil, fmt.Errorf("glab pipeline jobs: %s: %w", strings.TrimSpace(string(jobsOut)), err)
245344
}
246345
return parseGitlabJobs(jobsOut)
247346
}
@@ -264,7 +363,7 @@ func (h *Host) FetchFailedCheckLogs(ctx context.Context, pr *scm.PR, _ string, _
264363
if trimmed := bytesTrimToJSON(viewOut); len(trimmed) == 0 || json.Unmarshal(trimmed, &payload) != nil || payload.HeadPipeline.ID == 0 {
265364
return "", nil
266365
}
267-
jobsCmd := h.cmd(ctx, "glab", "ci", "get", "--pipeline-id", fmt.Sprintf("%d", payload.HeadPipeline.ID), "--output", "json", "--with-job-details")
366+
jobsCmd := h.cmd(ctx, "glab", h.pipelineJobsArgs(payload.HeadPipeline.ID)...)
268367
jobsOut, err := jobsCmd.CombinedOutput()
269368
if err != nil {
270369
return "", nil
@@ -306,63 +405,102 @@ func bytesTrimToJSON(out []byte) []byte {
306405
}
307406

308407
type gitlabJob struct {
309-
ID int `json:"id"`
310-
Name string `json:"name"`
311-
Status string `json:"status"`
312-
Stage string `json:"stage"`
408+
ID int `json:"id"`
409+
Name string `json:"name"`
410+
Status string `json:"status"`
411+
Stage string `json:"stage"`
412+
FinishedAt string `json:"finished_at"`
313413
}
314414

315-
func parseGitlabJobs(out []byte) ([]scm.Check, error) {
415+
// completedAt parses the job's finished_at timestamp, returning the zero time
416+
// when it is absent or unparseable. GitLab emits RFC3339 (often with
417+
// fractional seconds and a 'Z' offset), which time.RFC3339 handles.
418+
func (j gitlabJob) completedAt() time.Time {
419+
if strings.TrimSpace(j.FinishedAt) == "" {
420+
return time.Time{}
421+
}
422+
if parsed, err := time.Parse(time.RFC3339, j.FinishedAt); err == nil {
423+
return parsed
424+
}
425+
return time.Time{}
426+
}
427+
428+
// decodeGitlabJobs reads every job from glab output. The output may contain a
429+
// single bare job array, a pipeline object with nested .jobs, or - when
430+
// `glab api --paginate` walks multiple pages - several JSON documents
431+
// concatenated back to back (one array per page). A streaming decoder reads
432+
// each top-level value in turn and accumulates the jobs across all of them.
433+
// It returns whatever was parsed plus a non-nil error when a document was
434+
// malformed: io.EOF terminates the stream cleanly, but any other decode error
435+
// means a corrupt page, which the caller can surface instead of mistaking it
436+
// for an empty result.
437+
func decodeGitlabJobs(out []byte) ([]gitlabJob, error) {
316438
trimmed := bytesTrimToJSON(out)
317439
if len(trimmed) == 0 {
318440
return nil, nil
319441
}
320-
// glab may return a single pipeline object with nested jobs, or a bare job array.
321-
var asArray []gitlabJob
322-
if err := json.Unmarshal(trimmed, &asArray); err == nil && len(asArray) > 0 {
323-
return jobsToChecks(asArray), nil
324-
}
325-
var asObject struct {
326-
Jobs []gitlabJob `json:"jobs"`
327-
}
328-
if err := json.Unmarshal(trimmed, &asObject); err == nil && len(asObject.Jobs) > 0 {
329-
return jobsToChecks(asObject.Jobs), nil
442+
dec := json.NewDecoder(bytes.NewReader(trimmed))
443+
var jobs []gitlabJob
444+
for {
445+
var raw json.RawMessage
446+
err := dec.Decode(&raw)
447+
if errors.Is(err, io.EOF) {
448+
return jobs, nil
449+
}
450+
if err != nil {
451+
// Malformed mid-stream document: stop, but keep what parsed so far
452+
// and report the error rather than silently swallowing the page.
453+
return jobs, fmt.Errorf("decode gitlab jobs: %w", err)
454+
}
455+
var asArray []gitlabJob
456+
if err := json.Unmarshal(raw, &asArray); err == nil && len(asArray) > 0 {
457+
jobs = append(jobs, asArray...)
458+
continue
459+
}
460+
var asObject struct {
461+
Jobs []gitlabJob `json:"jobs"`
462+
}
463+
if err := json.Unmarshal(raw, &asObject); err == nil && len(asObject.Jobs) > 0 {
464+
jobs = append(jobs, asObject.Jobs...)
465+
}
330466
}
331-
return nil, nil
467+
}
468+
469+
func parseGitlabJobs(out []byte) ([]scm.Check, error) {
470+
jobs, err := decodeGitlabJobs(out)
471+
if len(jobs) == 0 {
472+
return nil, err
473+
}
474+
// Surface any decode error even when some jobs parsed. A corrupt later page
475+
// of paginated `glab api` output must not let a partial slice look
476+
// authoritative: a failed job on the dropped page would otherwise be hidden
477+
// and the CI verdict would read green.
478+
return jobsToChecks(jobs), err
332479
}
333480

334481
func jobsToChecks(jobs []gitlabJob) []scm.Check {
335482
checks := make([]scm.Check, 0, len(jobs))
336483
for _, job := range jobs {
337-
checks = append(checks, scm.Check{Name: job.Name, Bucket: gitlabStatusBucket(job.Status)})
484+
checks = append(checks, scm.Check{
485+
Name: job.Name,
486+
Bucket: gitlabStatusBucket(job.Status),
487+
CompletedAt: job.completedAt(),
488+
})
338489
}
339490
return checks
340491
}
341492

342493
func findFailedJobID(out []byte, failingNames []string) int {
343-
trimmed := bytesTrimToJSON(out)
344-
if len(trimmed) == 0 {
345-
return 0
346-
}
347494
targets := map[string]struct{}{}
348495
for _, name := range failingNames {
349496
name = strings.TrimSpace(name)
350497
if name != "" {
351498
targets[name] = struct{}{}
352499
}
353500
}
354-
var asArray []gitlabJob
355-
jobs := asArray
356-
if err := json.Unmarshal(trimmed, &asArray); err == nil && len(asArray) > 0 {
357-
jobs = asArray
358-
} else {
359-
var asObject struct {
360-
Jobs []gitlabJob `json:"jobs"`
361-
}
362-
if err := json.Unmarshal(trimmed, &asObject); err == nil {
363-
jobs = asObject.Jobs
364-
}
365-
}
501+
// Best effort: scan whatever jobs parsed; a corrupt later page does not
502+
// prevent locating a failed job that already decoded.
503+
jobs, _ := decodeGitlabJobs(out)
366504
for _, job := range jobs {
367505
if !strings.EqualFold(job.Status, "failed") {
368506
continue

0 commit comments

Comments
 (0)