@cxi-lmai/ci-agent-platform 3.1.2 → 3.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/payload/ci-templates/claude-pipeline.gitlab-ci.yml +17 -1
- package/payload/ci-templates/scripts/lib/issue-loop.sh +93 -8
- package/payload/ci-templates/scripts/lib/pipeline-common.sh +59 -6
- package/payload/ci-templates/scripts/metrics-snapshot.sh +5 -1
- package/payload/ci-templates/scripts/orchestrate.sh +95 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cxi-lmai/ci-agent-platform",
|
|
3
|
-
"version": "3.1.
|
|
3
|
+
"version": "3.1.4",
|
|
4
4
|
"description": "Autonomous dev pipeline on plain GitLab CI or GitHub Actions, driven by Claude Code. A labeled issue goes in, an open merge request comes out.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
|
@@ -79,6 +79,18 @@ variables:
|
|
|
79
79
|
PIPE_ORCHESTRATE: "0" # set to "1" in the schedule to run orchestrate
|
|
80
80
|
PIPE_SPEC_TEMPLATE_PATH: ".gitlab/issue_templates/Spec.md"
|
|
81
81
|
|
|
82
|
+
# --- Agent deadline (omp harness) -----------------------------------------
|
|
83
|
+
# A job `timeout:` is a hard kill — the runner SIGKILLs the process tree and
|
|
84
|
+
# the runner script never posts its comment, metrics or marker file. So
|
|
85
|
+
# `pipe_run_omp` bounds the agent itself with `omp --max-time`, derived from
|
|
86
|
+
# GitLab's own CI_JOB_TIMEOUT minus the time already spent minus the reserve
|
|
87
|
+
# below, which leaves room for the post-agent steps. An overrun then takes
|
|
88
|
+
# the normal degraded path (failure-notice comment + metrics) instead of
|
|
89
|
+
# dying silently. Set PIPE_AGENT_MAX_TIME (e.g. "20m") to override the
|
|
90
|
+
# derivation for every job; GitHub Actions exposes no job-timeout variable,
|
|
91
|
+
# so there the flag is omitted and timeout-minutes stays the only bound.
|
|
92
|
+
PIPE_AGENT_TIME_RESERVE: "180" # seconds of the job budget kept for the post-agent steps
|
|
93
|
+
|
|
82
94
|
# --- Build / verify --------------------------------------------------------
|
|
83
95
|
PIPE_VERIFY_CMD: "" # compile + unit tests, run after a fix
|
|
84
96
|
PIPE_COMPILE_CMD: "" # compile only (defaults to PIPE_VERIFY_CMD)
|
|
@@ -212,11 +224,15 @@ variables:
|
|
|
212
224
|
|
|
213
225
|
# =============================================================================
|
|
214
226
|
# review: run the automated review on a non-draft MR that changes code.
|
|
227
|
+
# The 45m budget matches `code` and is not generous: the skill fans out to the
|
|
228
|
+
# code-reviewer plus up to three specialist subagents, and observed runs on a
|
|
229
|
+
# real project spread from 1m to over 20m with diff size. It was 20m until
|
|
230
|
+
# three consecutive cap-outs on large MRs were measured in production.
|
|
215
231
|
# =============================================================================
|
|
216
232
|
review:
|
|
217
233
|
extends: .claude-base
|
|
218
234
|
stage: review
|
|
219
|
-
timeout:
|
|
235
|
+
timeout: 45m
|
|
220
236
|
script:
|
|
221
237
|
- bash "$PIPE_SCRIPTS_DIR/review.sh"
|
|
222
238
|
artifacts:
|
|
@@ -120,6 +120,35 @@ _gli_issue_state() {
|
|
|
120
120
|
curl -s -H "PRIVATE-TOKEN: $PIPE_TOKEN" "$(_gli_api)/issues/$1" | jq -r '.state // "opened"'
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
_gli_merged_closer() {
|
|
124
|
+
# $1=iid. Prints the newest merged MR that closes the issue as compact JSON,
|
|
125
|
+
# or nothing. A later reopen invalidates an earlier merge.
|
|
126
|
+
local response closer events merged_at
|
|
127
|
+
response=$(curl -s --fail -H "PRIVATE-TOKEN: $PIPE_TOKEN" \
|
|
128
|
+
"$(_gli_api)/issues/$1/closed_by?per_page=100") || return 1
|
|
129
|
+
closer=$(jq -c --arg branch "$PIPE_TARGET_BRANCH" '
|
|
130
|
+
[.[] | select(.state == "merged" and .merged_at != null and .target_branch == $branch)]
|
|
131
|
+
| max_by(.merged_at)
|
|
132
|
+
| if . == null then empty else
|
|
133
|
+
{kind: "MR",
|
|
134
|
+
reference: (.references.relative // .reference // ("!" + (.iid | tostring))),
|
|
135
|
+
url: .web_url,
|
|
136
|
+
merged_at: .merged_at}
|
|
137
|
+
end
|
|
138
|
+
' <<< "$response") || return 1
|
|
139
|
+
[ -n "$closer" ] || return 0
|
|
140
|
+
|
|
141
|
+
events=$(curl -s --fail -H "PRIVATE-TOKEN: $PIPE_TOKEN" \
|
|
142
|
+
"$(_gli_api)/issues/$1/resource_state_events?per_page=100") || return 1
|
|
143
|
+
merged_at=$(jq -r '.merged_at' <<< "$closer")
|
|
144
|
+
if jq -e --arg merged_at "$merged_at" \
|
|
145
|
+
'any(.[]; .state == "opened" and (.created_at // "") > $merged_at)' \
|
|
146
|
+
<<< "$events" >/dev/null; then
|
|
147
|
+
return 0
|
|
148
|
+
fi
|
|
149
|
+
printf '%s\n' "$closer"
|
|
150
|
+
}
|
|
151
|
+
|
|
123
152
|
_gli_blockers_open_count() {
|
|
124
153
|
# $1=iid. Count of open issues that block this one (native links).
|
|
125
154
|
curl -s -H "PRIVATE-TOKEN: $PIPE_TOKEN" "$(_gli_api)/issues/$1/links" \
|
|
@@ -168,8 +197,9 @@ _gli_link_blocked_by() {
|
|
|
168
197
|
}
|
|
169
198
|
|
|
170
199
|
_gli_set_issue_labels() {
|
|
171
|
-
# $1=iid $2=add(comma) $3=remove(comma).
|
|
172
|
-
curl -s -X PUT
|
|
200
|
+
# $1=iid $2=add(comma) $3=remove(comma). HTTP failures must stop dispatch.
|
|
201
|
+
curl --fail-with-body -s -X PUT \
|
|
202
|
+
-H "PRIVATE-TOKEN: $PIPE_TOKEN" -H "Content-Type: application/json" \
|
|
173
203
|
"$(_gli_api)/issues/$1" \
|
|
174
204
|
-d "$(jq -n --arg a "$2" --arg r "$3" '{add_labels:$a, remove_labels:$r}')" > /dev/null
|
|
175
205
|
}
|
|
@@ -310,10 +340,57 @@ _ghi_issue_meta() {
|
|
|
310
340
|
|
|
311
341
|
_ghi_issue_state() {
|
|
312
342
|
# Prints "opened" or "closed" (normalised to the GitLab vocabulary).
|
|
313
|
-
local s
|
|
343
|
+
local s
|
|
344
|
+
s=$(gh api "repos/$PIPE_REPO/issues/$1" --jq '.state // "open"' 2>/dev/null) || return 1
|
|
314
345
|
[ "$s" = "closed" ] && echo closed || echo opened
|
|
315
346
|
}
|
|
316
347
|
|
|
348
|
+
_ghi_merged_closer() {
|
|
349
|
+
# $1=iid. GitHub's GraphQL closing-reference connection is exact for both
|
|
350
|
+
# keyword and manually linked PRs. One query also carries reopen events.
|
|
351
|
+
local owner="${PIPE_REPO%%/*}" repo="${PIPE_REPO#*/}" response
|
|
352
|
+
response=$(gh api graphql \
|
|
353
|
+
-f "owner=$owner" -f "name=$repo" -F "number=$1" \
|
|
354
|
+
-f query='
|
|
355
|
+
query($owner: String!, $name: String!, $number: Int!) {
|
|
356
|
+
repository(owner: $owner, name: $name) {
|
|
357
|
+
issue(number: $number) {
|
|
358
|
+
closedByPullRequestsReferences(first: 100, includeClosedPrs: true) {
|
|
359
|
+
nodes {
|
|
360
|
+
number
|
|
361
|
+
url
|
|
362
|
+
merged
|
|
363
|
+
mergedAt
|
|
364
|
+
baseRefName
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
timelineItems(last: 100, itemTypes: [REOPENED_EVENT]) {
|
|
368
|
+
nodes {
|
|
369
|
+
... on ReopenedEvent {
|
|
370
|
+
createdAt
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
') || return 1
|
|
378
|
+
jq -c --arg branch "$PIPE_TARGET_BRANCH" '
|
|
379
|
+
.data.repository.issue as $issue
|
|
380
|
+
| ([$issue.closedByPullRequestsReferences.nodes[]
|
|
381
|
+
| select(.merged == true and .mergedAt != null and .baseRefName == $branch)]
|
|
382
|
+
| max_by(.mergedAt)) as $closer
|
|
383
|
+
| ([$issue.timelineItems.nodes[]?.createdAt] | max // "") as $reopened_at
|
|
384
|
+
| if $closer != null and ($reopened_at == "" or $closer.mergedAt > $reopened_at)
|
|
385
|
+
then {kind: "PR",
|
|
386
|
+
reference: ("#" + ($closer.number | tostring)),
|
|
387
|
+
url: $closer.url,
|
|
388
|
+
merged_at: $closer.mergedAt}
|
|
389
|
+
else empty
|
|
390
|
+
end
|
|
391
|
+
' <<< "$response"
|
|
392
|
+
}
|
|
393
|
+
|
|
317
394
|
_ghi_link_blocked_by() {
|
|
318
395
|
# GitHub has no native blocking link. Persist the real issue number in the
|
|
319
396
|
# dependent issue body after all local decomposition keys have been mapped.
|
|
@@ -351,8 +428,14 @@ _ghi_set_issue_labels() {
|
|
|
351
428
|
# $1=iid $2=add(comma) $3=remove(comma)
|
|
352
429
|
local l
|
|
353
430
|
local IFS=','
|
|
354
|
-
for l in $2; do
|
|
355
|
-
|
|
431
|
+
for l in $2; do
|
|
432
|
+
[ -z "$l" ] || gh api -X POST "repos/$PIPE_REPO/issues/$1/labels" \
|
|
433
|
+
-f "labels[]=$l" > /dev/null 2>&1 || return 1
|
|
434
|
+
done
|
|
435
|
+
for l in $3; do
|
|
436
|
+
[ -z "$l" ] || gh api -X DELETE "repos/$PIPE_REPO/issues/$1/labels/$l" \
|
|
437
|
+
> /dev/null 2>&1 || return 1
|
|
438
|
+
done
|
|
356
439
|
}
|
|
357
440
|
|
|
358
441
|
_ghi_close_issue() {
|
|
@@ -428,6 +511,7 @@ issue_notes() { case "$PIPE_PLATFORM" in gitlab) _gli_issue_notes "
|
|
|
428
511
|
issue_body() { case "$PIPE_PLATFORM" in gitlab) _gli_issue_body "$1" ;; github) _ghi_issue_body "$1" ;; esac; }
|
|
429
512
|
issue_meta() { case "$PIPE_PLATFORM" in gitlab) _gli_issue_meta "$1" ;; github) _ghi_issue_meta "$1" ;; esac; }
|
|
430
513
|
issue_state() { case "$PIPE_PLATFORM" in gitlab) _gli_issue_state "$1" ;; github) _ghi_issue_state "$1" ;; esac; }
|
|
514
|
+
issue_merged_closer() { case "$PIPE_PLATFORM" in gitlab) _gli_merged_closer "$1" ;; github) _ghi_merged_closer "$1" ;; esac; }
|
|
431
515
|
issue_create_file() { case "$PIPE_PLATFORM" in gitlab) _gli_create_issue_file "$1" "$2" "$3" ;; github) _ghi_create_issue_file "$1" "$2" "$3" ;; esac; }
|
|
432
516
|
issue_set_labels() { case "$PIPE_PLATFORM" in gitlab) _gli_set_issue_labels "$1" "$2" "$3" ;; github) _ghi_set_issue_labels "$1" "$2" "$3" ;; esac; }
|
|
433
517
|
issue_close() { case "$PIPE_PLATFORM" in gitlab) _gli_close_issue "$1" ;; github) _ghi_close_issue "$1" ;; esac; }
|
|
@@ -442,12 +526,13 @@ issue_blockers_open_count() {
|
|
|
442
526
|
case "$PIPE_PLATFORM" in
|
|
443
527
|
gitlab) _gli_blockers_open_count "$1" ;;
|
|
444
528
|
github)
|
|
445
|
-
local body refs n open=0
|
|
446
|
-
body=$(_ghi_issue_body "$1")
|
|
529
|
+
local body refs n state open=0
|
|
530
|
+
body=$(_ghi_issue_body "$1") || return 1
|
|
447
531
|
# Match "Blocked by #12" / "blocked by #12, #13" case-insensitively.
|
|
448
532
|
refs=$(printf '%s' "$body" | grep -ioE 'blocked by[^\n]*' | grep -oE '#[0-9]+' | grep -oE '[0-9]+' | sort -u || true)
|
|
449
533
|
for n in $refs; do
|
|
450
|
-
|
|
534
|
+
state=$(_ghi_issue_state "$n") || return 1
|
|
535
|
+
[ "$state" = "opened" ] && open=$((open + 1))
|
|
451
536
|
done
|
|
452
537
|
echo "$open"
|
|
453
538
|
;;
|
|
@@ -369,6 +369,49 @@ pipe_translate_tools_to_omp() {
|
|
|
369
369
|
echo "${result#,}"
|
|
370
370
|
}
|
|
371
371
|
|
|
372
|
+
# --- Agent deadline -----------------------------------------------------
|
|
373
|
+
# A CI job timeout is a hard kill: the runner SIGKILLs the whole process tree,
|
|
374
|
+
# so the runner script never reaches the steps that post the comment, write
|
|
375
|
+
# the metrics record and emit the marker file. An agent that overruns
|
|
376
|
+
# therefore leaves a red job with no output at all (observed in production:
|
|
377
|
+
# three review timeouts in one week, each at the cap with the agent mid-run).
|
|
378
|
+
# Bounding the agent itself with `omp --max-time` turns an overrun into the
|
|
379
|
+
# platform's normal degraded path — failure-notice comment plus metrics —
|
|
380
|
+
# instead of a hard kill.
|
|
381
|
+
|
|
382
|
+
pipe_agent_max_time() {
|
|
383
|
+
# Echoes the --max-time value to pass to omp, or nothing when no deadline
|
|
384
|
+
# can be derived (GitHub Actions exposes no CI_JOB_TIMEOUT equivalent;
|
|
385
|
+
# there the workflow's timeout-minutes stays the only bound). An explicit
|
|
386
|
+
# PIPE_AGENT_MAX_TIME wins and is passed through verbatim, so a project can
|
|
387
|
+
# set "20m" or "1h" by hand. Otherwise the budget is the job timeout, minus
|
|
388
|
+
# the time already spent (image pull, checkout, before_script, API prep),
|
|
389
|
+
# minus PIPE_AGENT_TIME_RESERVE for the post-agent steps.
|
|
390
|
+
if [ -n "${PIPE_AGENT_MAX_TIME:-}" ]; then
|
|
391
|
+
printf '%s' "$PIPE_AGENT_MAX_TIME"
|
|
392
|
+
return 0
|
|
393
|
+
fi
|
|
394
|
+
local budget="${CI_JOB_TIMEOUT:-}"
|
|
395
|
+
case "$budget" in ''|*[!0-9]*) return 0 ;; esac
|
|
396
|
+
local reserve="${PIPE_AGENT_TIME_RESERVE:-180}" elapsed=0 started now
|
|
397
|
+
# Guard the emptiness explicitly: `date -d ""` does not fail, it silently
|
|
398
|
+
# resolves to midnight today, which would read as a many-hour-old job.
|
|
399
|
+
if [ -n "${CI_JOB_STARTED_AT:-}" ]; then
|
|
400
|
+
started=$(date -d "$CI_JOB_STARTED_AT" +%s 2>/dev/null || true)
|
|
401
|
+
if [ -n "$started" ]; then
|
|
402
|
+
now=$(date +%s)
|
|
403
|
+
elapsed=$(( now - started ))
|
|
404
|
+
[ "$elapsed" -lt 0 ] && elapsed=0
|
|
405
|
+
fi
|
|
406
|
+
fi
|
|
407
|
+
local left=$(( budget - elapsed - reserve ))
|
|
408
|
+
# A job already out of budget gets no flag: let the agent start and be cut
|
|
409
|
+
# by the runner exactly as it was before this function existed, rather than
|
|
410
|
+
# be killed one second in with a nonsensical deadline.
|
|
411
|
+
[ "$left" -lt 60 ] && return 0
|
|
412
|
+
printf '%s' "$left"
|
|
413
|
+
}
|
|
414
|
+
|
|
372
415
|
pipe_run_omp() {
|
|
373
416
|
# $1 = agent label for metrics, $2 = skill invocation (e.g. "/review-mr"),
|
|
374
417
|
# $3 = allowed tools in Claude Code naming (optional, translated
|
|
@@ -377,6 +420,14 @@ pipe_run_omp() {
|
|
|
377
420
|
# frontmatter), stdin = extra prompt (optional, usually empty).
|
|
378
421
|
local label="$1" skill="$2" tools="${3:-Agent,Read,Write,Edit,Glob,Grep,Bash}" model="${4:-}"
|
|
379
422
|
local omp_tools; omp_tools=$(pipe_translate_tools_to_omp "$tools")
|
|
423
|
+
# Bound the run below the job's own hard kill (see pipe_agent_max_time).
|
|
424
|
+
local max_time; max_time=$(pipe_agent_max_time)
|
|
425
|
+
local -a max_time_args=()
|
|
426
|
+
local max_time_txt=""
|
|
427
|
+
if [ -n "$max_time" ]; then
|
|
428
|
+
max_time_args=(--max-time "$max_time")
|
|
429
|
+
max_time_txt=$(printf ' --max-time %q' "$max_time")
|
|
430
|
+
fi
|
|
380
431
|
pipe_require_harness omp
|
|
381
432
|
local stdin_file; stdin_file=$(mktemp)
|
|
382
433
|
cat > "$stdin_file" || true
|
|
@@ -402,12 +453,14 @@ pipe_run_omp() {
|
|
|
402
453
|
printf 'cd %q\n' "$PWD"
|
|
403
454
|
printf 'source %q\n' "$SCRIPT_LIB_DIR/usage-capture-omp.sh"
|
|
404
455
|
printf 'export PIPE_CAPTURE_MODEL=%q\n' "$model"
|
|
456
|
+
# The %s after `--` is the optional " --max-time <d>" fragment, already
|
|
457
|
+
# shell-quoted; it collapses to nothing when no deadline was derived.
|
|
405
458
|
if [ -n "$model" ]; then
|
|
406
|
-
printf 'capture_omp %q
|
|
407
|
-
"$label" "$model" "$omp_tools" "$skill" "$stdin_file"
|
|
459
|
+
printf 'capture_omp %q --%s --yolo --no-session --no-title --model %q --tools %q -p %q < %q\n' \
|
|
460
|
+
"$label" "$max_time_txt" "$model" "$omp_tools" "$skill" "$stdin_file"
|
|
408
461
|
else
|
|
409
|
-
printf 'capture_omp %q
|
|
410
|
-
"$label" "$omp_tools" "$skill" "$stdin_file"
|
|
462
|
+
printf 'capture_omp %q --%s --yolo --no-session --no-title --tools %q -p %q < %q\n' \
|
|
463
|
+
"$label" "$max_time_txt" "$omp_tools" "$skill" "$stdin_file"
|
|
411
464
|
fi
|
|
412
465
|
} > "$runner"
|
|
413
466
|
chmod +x "$runner"
|
|
@@ -420,9 +473,9 @@ pipe_run_omp() {
|
|
|
420
473
|
pipe_scrub_agent_secrets
|
|
421
474
|
export PIPE_CAPTURE_MODEL="$model"
|
|
422
475
|
if [ -n "$model" ]; then
|
|
423
|
-
capture_omp "$label" -- --yolo --no-session --no-title --model "$model" --tools "$omp_tools" -p "$skill" < "$stdin_file"
|
|
476
|
+
capture_omp "$label" -- "${max_time_args[@]}" --yolo --no-session --no-title --model "$model" --tools "$omp_tools" -p "$skill" < "$stdin_file"
|
|
424
477
|
else
|
|
425
|
-
capture_omp "$label" -- --yolo --no-session --no-title --tools "$omp_tools" -p "$skill" < "$stdin_file"
|
|
478
|
+
capture_omp "$label" -- "${max_time_args[@]}" --yolo --no-session --no-title --tools "$omp_tools" -p "$skill" < "$stdin_file"
|
|
426
479
|
fi
|
|
427
480
|
) || true
|
|
428
481
|
fi
|
|
@@ -186,9 +186,13 @@ MRS_VIEW=$(jq -c \
|
|
|
186
186
|
elif ($body | test($reintroduce; "i")) then "reintroduces"
|
|
187
187
|
else null end
|
|
188
188
|
),
|
|
189
|
+
# match/2 with "g", not scan/2: a Debian bookworm based runner image
|
|
190
|
+
# ships jq 1.6, which has no scan/2 and fails the whole program with
|
|
191
|
+
# a compile error.
|
|
189
192
|
references: [
|
|
190
193
|
$body
|
|
191
|
-
|
|
|
194
|
+
| match("(?:" + $revert + "|" + $regression + "|" + $reintroduce + ")[[:space:]]*[!#]?([0-9]+)"; "gi")
|
|
195
|
+
| .captures[].string
|
|
192
196
|
| select(. != null)
|
|
193
197
|
| tonumber
|
|
194
198
|
]
|
|
@@ -87,6 +87,100 @@ emit_decision_metric() {
|
|
|
87
87
|
'{issue_iid: $i, verdict: $v, decision_type: $dt, reason: $r} + $x')"
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
dispatch_restore_ready() {
|
|
91
|
+
# $1=iid. Best-effort recovery back to ready after a mid-dispatch failure.
|
|
92
|
+
# Nothing further can be attempted from this runner if this also fails, so
|
|
93
|
+
# it logs loudly and meters a terminal, distinctly countable label-error.
|
|
94
|
+
if issue_set_labels "$1" "$PIPE_LABEL_READY" "$PIPE_LABEL_WIP"; then
|
|
95
|
+
return 0
|
|
96
|
+
fi
|
|
97
|
+
pipe_log " ERROR: could not restore $PIPE_LABEL_READY on #$1; lifecycle labels may be inconsistent, manual check required"
|
|
98
|
+
emit_decision_metric "$1" skipped label-error "Failed to restore ready after a dispatch-time error"
|
|
99
|
+
return 1
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
dispatch_queued_issue() {
|
|
103
|
+
# $1=iid $2=branch. Claim atomically, then re-check repository state at the
|
|
104
|
+
# last possible point before spending a coder invocation. Every subsequent
|
|
105
|
+
# label transition is checked: a silently failed transition is exactly how
|
|
106
|
+
# an issue gets stuck in wip or loses its ready label forever.
|
|
107
|
+
local iid="$1" branch="$2" open closer kind reference url reason comment_file
|
|
108
|
+
|
|
109
|
+
if ! issue_set_labels "$iid" "$PIPE_LABEL_WIP" "$PIPE_LABEL_READY"; then
|
|
110
|
+
pipe_log " ERROR: could not claim #$iid, skipping to avoid a double trigger"
|
|
111
|
+
emit_decision_metric "$iid" skipped claim-failed "Could not atomically claim the ready issue"
|
|
112
|
+
return 0
|
|
113
|
+
fi
|
|
114
|
+
|
|
115
|
+
if ! open=$(issue_blockers_open_count "$iid"); then
|
|
116
|
+
reason="Could not verify whether issue prerequisites are still open"
|
|
117
|
+
pipe_log " ERROR: $reason for #$iid; restoring $PIPE_LABEL_READY"
|
|
118
|
+
dispatch_restore_ready "$iid" || return 0
|
|
119
|
+
emit_decision_metric "$iid" deferred blocker-validation-error "$reason"
|
|
120
|
+
return 0
|
|
121
|
+
fi
|
|
122
|
+
if [ "${open:-1}" != "0" ]; then
|
|
123
|
+
reason="Issue has ${open:-unknown} open prerequisite(s)"
|
|
124
|
+
if issue_set_labels "$iid" "$PIPE_LABEL_BLOCKED" "$PIPE_LABEL_WIP,$PIPE_LABEL_READY"; then
|
|
125
|
+
pipe_log " #$iid returned to $PIPE_LABEL_BLOCKED: $reason"
|
|
126
|
+
emit_decision_metric "$iid" deferred blocked-prerequisite "$reason"
|
|
127
|
+
else
|
|
128
|
+
pipe_log " ERROR: could not move #$iid to $PIPE_LABEL_BLOCKED, restoring $PIPE_LABEL_READY: $reason"
|
|
129
|
+
dispatch_restore_ready "$iid" || return 0
|
|
130
|
+
emit_decision_metric "$iid" deferred blocked-transition-error "$reason"
|
|
131
|
+
fi
|
|
132
|
+
return 0
|
|
133
|
+
fi
|
|
134
|
+
|
|
135
|
+
if ! closer=$(issue_merged_closer "$iid"); then
|
|
136
|
+
reason="Could not verify whether a closing MR/PR has already merged"
|
|
137
|
+
pipe_log " ERROR: $reason for #$iid; restoring $PIPE_LABEL_READY"
|
|
138
|
+
dispatch_restore_ready "$iid" || return 0
|
|
139
|
+
emit_decision_metric "$iid" deferred validation-error "$reason"
|
|
140
|
+
return 0
|
|
141
|
+
fi
|
|
142
|
+
|
|
143
|
+
if [ -n "$closer" ]; then
|
|
144
|
+
kind=$(jq -r '.kind' <<< "$closer")
|
|
145
|
+
reference=$(jq -r '.reference' <<< "$closer")
|
|
146
|
+
url=$(jq -r '.url' <<< "$closer")
|
|
147
|
+
reason="$kind $reference already merged into $PIPE_TARGET_BRANCH"
|
|
148
|
+
comment_file="$PIPE_CONTEXT_DIR/orchestrator-already-merged-$iid.md"
|
|
149
|
+
{
|
|
150
|
+
printf '**Dispatch skipped:** %s %s has already merged into `%s`.\n\n' \
|
|
151
|
+
"$kind" "$reference" "$PIPE_TARGET_BRANCH"
|
|
152
|
+
printf '%s\n\n' "$url"
|
|
153
|
+
printf 'The pipeline removed `%s`; apply it again only if more work is required.\n' \
|
|
154
|
+
"$PIPE_LABEL_READY"
|
|
155
|
+
} > "$comment_file"
|
|
156
|
+
# Comment before the final label removal: if the comment never lands,
|
|
157
|
+
# the ready label must survive so the skip is retried and the change is
|
|
158
|
+
# named next time, instead of silently vanishing off the board.
|
|
159
|
+
if ! issue_comment_file "$iid" "$comment_file"; then
|
|
160
|
+
pipe_log " ERROR: could not comment on #$iid before skipping; restoring $PIPE_LABEL_READY for retry"
|
|
161
|
+
dispatch_restore_ready "$iid" || return 0
|
|
162
|
+
emit_decision_metric "$iid" deferred comment-error "$reason"
|
|
163
|
+
return 0
|
|
164
|
+
fi
|
|
165
|
+
pipe_log " skipping coder for #$iid: $reason ($url)"
|
|
166
|
+
if ! issue_set_labels "$iid" "" "$PIPE_LABEL_WIP,$PIPE_LABEL_READY"; then
|
|
167
|
+
pipe_log " ERROR: could not clear lifecycle labels on #$iid after commenting; restoring $PIPE_LABEL_READY"
|
|
168
|
+
dispatch_restore_ready "$iid"
|
|
169
|
+
fi
|
|
170
|
+
emit_decision_metric "$iid" skipped already-merged "$reason" \
|
|
171
|
+
"$(jq -n --arg kind "$kind" --arg reference "$reference" --arg url "$url" \
|
|
172
|
+
'{closer_kind: $kind, closer_reference: $reference, closer_url: $url}')"
|
|
173
|
+
return 0
|
|
174
|
+
fi
|
|
175
|
+
|
|
176
|
+
if code_trigger "$iid" "$branch"; then
|
|
177
|
+
pipe_log " triggered coder for #$iid on $branch"
|
|
178
|
+
else
|
|
179
|
+
pipe_log " ERROR: trigger failed for #$iid, reverting label"
|
|
180
|
+
dispatch_restore_ready "$iid"
|
|
181
|
+
fi
|
|
182
|
+
}
|
|
183
|
+
|
|
90
184
|
while IFS= read -r ISSUE; do
|
|
91
185
|
[ "${#QUEUE[@]}" -ge "$PIPE_CODER_CAP" ] && { pipe_log "Coder cap ($PIPE_CODER_CAP) reached, deferring the rest"; break; }
|
|
92
186
|
|
|
@@ -297,16 +391,5 @@ pipe_log "Triggering ${#QUEUE[@]} coder run(s)"
|
|
|
297
391
|
for ITEM in "${QUEUE[@]}"; do
|
|
298
392
|
IID="${ITEM%%:*}"
|
|
299
393
|
BRANCH="${ITEM#*:}"
|
|
300
|
-
|
|
301
|
-
# not double-trigger. Revert on trigger failure.
|
|
302
|
-
if issue_set_labels "$IID" "$PIPE_LABEL_WIP" "$PIPE_LABEL_READY"; then
|
|
303
|
-
if code_trigger "$IID" "$BRANCH"; then
|
|
304
|
-
pipe_log " triggered coder for #$IID on $BRANCH"
|
|
305
|
-
else
|
|
306
|
-
pipe_log " ERROR: trigger failed for #$IID, reverting label"
|
|
307
|
-
issue_set_labels "$IID" "$PIPE_LABEL_READY" "$PIPE_LABEL_WIP"
|
|
308
|
-
fi
|
|
309
|
-
else
|
|
310
|
-
pipe_log " ERROR: could not claim #$IID, skipping to avoid a double trigger"
|
|
311
|
-
fi
|
|
394
|
+
dispatch_queued_issue "$IID" "$BRANCH"
|
|
312
395
|
done
|