@cxi-lmai/ci-agent-platform 3.1.2 → 3.1.3
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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cxi-lmai/ci-agent-platform",
|
|
3
|
-
"version": "3.1.
|
|
3
|
+
"version": "3.1.3",
|
|
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",
|
|
@@ -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
|
;;
|
|
@@ -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
|