@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.2",
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). Returns HTTP-independent (best effort).
172
- curl -s -X PUT -H "PRIVATE-TOKEN: $PIPE_TOKEN" -H "Content-Type: application/json" \
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; s=$(gh api "repos/$PIPE_REPO/issues/$1" --jq '.state // "open"' 2>/dev/null || echo open)
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 [ -n "$l" ] && gh api -X POST "repos/$PIPE_REPO/issues/$1/labels" -f "labels[]=$l" > /dev/null 2>&1 || true; done
355
- for l in $3; do [ -n "$l" ] && gh api -X DELETE "repos/$PIPE_REPO/issues/$1/labels/$l" > /dev/null 2>&1 || true; done
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
- [ "$(_ghi_issue_state "$n")" = "opened" ] && open=$((open + 1))
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
- # Atomic claim: relabel ready -> wip first, so a concurrent orchestrator does
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