@cxi-lmai/ci-agent-platform 3.0.1 → 3.1.1

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.
Files changed (64) hide show
  1. package/README.md +12 -4
  2. package/package.json +2 -2
  3. package/payload/INSTALL.md +8 -2
  4. package/payload/agents/agent-architect.md +1 -1
  5. package/payload/agents/code-reviewer.md +1 -1
  6. package/payload/agents/codebase-auditor.md +1 -1
  7. package/payload/agents/coder.md +1 -1
  8. package/payload/agents/decomposer.md +1 -1
  9. package/payload/agents/docs-sync.md +1 -1
  10. package/payload/agents/e2e-test-writer.md +1 -1
  11. package/payload/agents/performance-reviewer.md +1 -1
  12. package/payload/agents/release-mr.md +1 -1
  13. package/payload/agents/security-reviewer.md +1 -1
  14. package/payload/agents/test-fix.md +1 -1
  15. package/payload/agents/test-writer.md +1 -1
  16. package/payload/agents-omp/agent-architect.md +1 -1
  17. package/payload/agents-omp/code-reviewer.md +1 -1
  18. package/payload/agents-omp/codebase-auditor.md +1 -1
  19. package/payload/agents-omp/coder.md +1 -1
  20. package/payload/agents-omp/decomposer.md +1 -1
  21. package/payload/agents-omp/docs-sync.md +1 -1
  22. package/payload/agents-omp/e2e-test-writer.md +1 -1
  23. package/payload/agents-omp/migration-reviewer.md +1 -1
  24. package/payload/agents-omp/orchestrator.md +1 -1
  25. package/payload/agents-omp/performance-reviewer.md +1 -1
  26. package/payload/agents-omp/postmortem.md +1 -1
  27. package/payload/agents-omp/release-mr.md +1 -1
  28. package/payload/agents-omp/security-reviewer.md +1 -1
  29. package/payload/agents-omp/test-fix.md +1 -1
  30. package/payload/agents-omp/test-writer.md +1 -1
  31. package/payload/ci-templates/claude-pipeline.gitlab-ci.yml +210 -0
  32. package/payload/ci-templates/github/claude-issue-pipeline.yml +1 -1
  33. package/payload/ci-templates/github/claude-pipeline.yml +2 -2
  34. package/payload/ci-templates/github/claude-test-fix.yml +1 -1
  35. package/payload/ci-templates/scripts/agent-architect.sh +233 -0
  36. package/payload/ci-templates/scripts/code.sh +26 -27
  37. package/payload/ci-templates/scripts/codebase-audit.sh +252 -0
  38. package/payload/ci-templates/scripts/coverage-ratchet.sh +77 -0
  39. package/payload/ci-templates/scripts/cve-fix.sh +246 -0
  40. package/payload/ci-templates/scripts/docs-sync.sh +225 -0
  41. package/payload/ci-templates/scripts/e2e-test-gen.sh +201 -0
  42. package/payload/ci-templates/scripts/lib/failure-notice.sh +104 -0
  43. package/payload/ci-templates/scripts/lib/issue-loop.sh +155 -40
  44. package/payload/ci-templates/scripts/lib/pipeline-common.sh +100 -26
  45. package/payload/ci-templates/scripts/lib/platform.sh +270 -13
  46. package/payload/ci-templates/scripts/lib/usage-capture-omp.sh +7 -1
  47. package/payload/ci-templates/scripts/lib/usage-capture.sh +7 -1
  48. package/payload/ci-templates/scripts/metrics-snapshot.sh +377 -0
  49. package/payload/ci-templates/scripts/orchestrate.sh +55 -37
  50. package/payload/ci-templates/scripts/postmortem.sh +10 -0
  51. package/payload/ci-templates/scripts/review-fix.sh +22 -11
  52. package/payload/ci-templates/scripts/review.sh +11 -7
  53. package/payload/ci-templates/scripts/test-fix.sh +7 -0
  54. package/payload/skills/agent-architect/SKILL.md +45 -0
  55. package/payload/skills/codebase-audit/SKILL.md +84 -0
  56. package/payload/skills/cve-fix/SKILL.md +98 -0
  57. package/payload/skills/docs-sync/SKILL.md +74 -0
  58. package/payload/skills/e2e-test-gen/SKILL.md +65 -0
  59. package/payload/skills/fix-review-findings/SKILL.md +2 -2
  60. package/payload/skills/fix-tests/SKILL.md +2 -2
  61. package/payload/templates/memory-index.template.md +34 -0
  62. package/payload/templates/pipeline-config.template.md +11 -1
  63. package/payload/templates/review_suppressions.template.md +55 -0
  64. package/payload/templates/spec-issue.template.md +39 -7
@@ -13,6 +13,8 @@ source "$SCRIPT_LIB_DIR/usage-capture.sh"
13
13
  source "$SCRIPT_LIB_DIR/usage-capture-omp.sh"
14
14
  # shellcheck source=./platform.sh
15
15
  source "$SCRIPT_LIB_DIR/platform.sh"
16
+ # shellcheck source=./failure-notice.sh
17
+ source "$SCRIPT_LIB_DIR/failure-notice.sh"
16
18
 
17
19
  # --- Defaults ---------------------------------------------------------------
18
20
  # Every PIPE_ variable has a default here so a project that overrides nothing
@@ -25,6 +27,7 @@ pipe_defaults() {
25
27
  : "${PIPE_CONFIG_PATH:=.claude/pipeline-config.md}"
26
28
  : "${PIPE_CONTEXT_DIR:=build/pipeline}"
27
29
  : "${PIPE_METRICS_DIR:=$PIPE_CONTEXT_DIR/metrics}"
30
+ : "${PIPE_AGENT_STDERR:=$PIPE_CONTEXT_DIR/agent-stderr.log}"
28
31
  : "${PIPE_TARGET_BRANCH:=main}"
29
32
  : "${PIPE_FIX_LOOP_CAP:=2}"
30
33
  : "${PIPE_CODER_CAP:=3}"
@@ -33,29 +36,78 @@ pipe_defaults() {
33
36
  # Model defaults track the active harness. Each PIPE_MODEL_* variable remains
34
37
  # explicitly overridable.
35
38
  if [ "$PIPE_HARNESS" = "omp" ]; then
36
- : "${PIPE_MODEL_TRIAGE:=openrouter/anthropic/claude-haiku-4-5}"
37
- : "${PIPE_MODEL_CODE:=openrouter/anthropic/claude-sonnet-5-0}"
38
- : "${PIPE_MODEL_REVIEW:=openrouter/anthropic/claude-sonnet-5-0}"
39
+ : "${PIPE_MODEL_TRIAGE:=openrouter/anthropic/claude-haiku-4.5}"
40
+ : "${PIPE_MODEL_CODE:=openrouter/anthropic/claude-sonnet-5}"
41
+ : "${PIPE_MODEL_REVIEW:=openrouter/anthropic/claude-sonnet-5}"
39
42
  else
40
43
  : "${PIPE_MODEL_TRIAGE:=haiku}"
41
- : "${PIPE_MODEL_CODE:=claude-sonnet-5-0}"
42
- : "${PIPE_MODEL_REVIEW:=claude-sonnet-5-0}"
44
+ : "${PIPE_MODEL_CODE:=claude-sonnet-5}"
45
+ : "${PIPE_MODEL_REVIEW:=claude-sonnet-5}"
43
46
  fi
44
47
  : "${PIPE_COMMIT_TESTFIX:=Fix test errors}"
45
48
  : "${PIPE_COMMIT_REVIEWFIX:=Fix review findings}"
46
49
  : "${PIPE_COMMIT_COVERAGE:=Add coverage tests}"
50
+ : "${PIPE_COMMIT_DOCSSYNC:=Update docs per docs-sync findings}"
51
+ : "${PIPE_COMMIT_AUDIT:=docs: apply convention audit findings}"
52
+ : "${PIPE_COMMIT_CVEFIX:=Update dependencies to resolve CVEs}"
47
53
  : "${PIPE_LABEL_READY:=pipe-ready}"
48
54
  : "${PIPE_LABEL_WIP:=pipe-wip}"
49
55
  : "${PIPE_LABEL_STUCK:=pipe-stuck}"
50
56
  : "${PIPE_LABEL_BLOCKER:=pipe-blocker}"
51
57
  : "${PIPE_LABEL_BLOCKED:=pipe-blocked}"
52
58
  : "${PIPE_LABEL_DECOMPOSED:=pipe-decomposed}"
59
+ : "${PIPE_LABEL_AUDIT:=codebase-audit}"
53
60
  : "${PIPE_RESULT_REVIEW:=$PIPE_CONTEXT_DIR/code-review-result.txt}"
54
61
  : "${PIPE_RESULT_REVIEWFIX:=$PIPE_CONTEXT_DIR/review-fix-result.json}"
62
+ : "${PIPE_RESULT_DOCSSYNC:=$PIPE_CONTEXT_DIR/docs-sync.json}"
55
63
  : "${PIPE_VERIFY_CMD:=}"
56
64
  : "${PIPE_COMPILE_CMD:=${PIPE_VERIFY_CMD}}"
57
65
  : "${PIPE_TEST_REPORT_GLOB:=}"
66
+ : "${PIPE_COVERAGE_RATCHET:=0}"
67
+ : "${PIPE_COVERAGE_REPORT:=}"
68
+ : "${PIPE_COVERAGE_REPORT_KIND:=jacoco}"
58
69
  : "${PIPE_COVERAGE_SIGNAL:=}"
70
+ # CVE remediation. Off by default: project-local scanners provide the
71
+ # HIGH/CRITICAL identifier list when PIPE_CVE_FIX=1 enables this job.
72
+ : "${PIPE_CVE_FIX:=0}"
73
+ : "${PIPE_DEPENDENCY_MANIFEST:=build.gradle}"
74
+ : "${PIPE_CVE_SUPPRESSIONS:=owasp-suppressions.xml}"
75
+ # Issue-driven E2E generation. Off by default; PIPE_VERIFY_CMD is the
76
+ # configured verification command and must be non-empty before tests run.
77
+ : "${PIPE_E2E_TEST_GEN:=0}"
78
+ : "${PIPE_LABEL_TESTREADY:=test-ready}"
79
+ : "${PIPE_LABEL_E2E_SCOPE:=}"
80
+ : "${PIPE_E2E_TEST_DIR:=e2e/tests}"
81
+ : "${PIPE_E2E_BASE_URL:=}"
82
+ : "${PIPE_E2E_VERIFY_CMD:=}"
83
+ # Documentation sync. Off by default: the job only runs when the pipeline
84
+ # sets PIPE_DOCS_SYNC=1. PIPE_DOCS_ROOTS is the comma-separated set of paths
85
+ # the docs-sync agent may edit and the runner may commit.
86
+ : "${PIPE_DOCS_SYNC:=0}"
87
+ : "${PIPE_DOCS_ROOTS:=docs,CLAUDE.md,.claude/memory}"
88
+ # Codebase audit. Off by default: the job only runs on a schedule that sets
89
+ # PIPE_CODEBASE_AUDIT=1. Reuses PIPE_DOCS_ROOTS for the optional auto-fix
90
+ # phase's coder scope.
91
+ : "${PIPE_CODEBASE_AUDIT:=0}"
92
+ # Agent architect. Off by default: a scheduled pipeline explicitly enables
93
+ # this evidence-backed proposal job after a project has reviewed its output.
94
+ : "${PIPE_AGENT_ARCHITECT:=0}"
95
+ : "${PIPE_ARCHITECT_WINDOW_DAYS:=7}"
96
+ : "${PIPE_ARCHITECT_DRY_RUN:=true}"
97
+ : "${PIPE_LABEL_IMPROVEMENT:=pipe-improvement}"
98
+ # Metrics aggregation. Off by default: the job only runs on a schedule that
99
+ # sets PIPE_METRICS_SNAPSHOT=1, and it publishes an artifact unless the
100
+ # project explicitly opts in to committing generated data to its repository.
101
+ : "${PIPE_METRICS_SNAPSHOT:=0}"
102
+ : "${PIPE_METRICS_OUTPUT_DIR:=data/metrics/snapshots}"
103
+ : "${PIPE_METRICS_WINDOW_DAYS:=1}"
104
+ : "${PIPE_METRICS_COMMIT_REPO:=false}"
105
+ : "${PIPE_METRICS_REVIEW_AGENTS:=code-reviewer,review-verdict,review-fix}"
106
+ # Commit subjects that mark a defect in already-merged work. Keep in sync with
107
+ # the Metrics Conventions section of the pipeline config.
108
+ : "${PIPE_METRICS_DEFECT_REVERT:=Reverts !}"
109
+ : "${PIPE_METRICS_DEFECT_REGRESSION:=Fixes regression from !}"
110
+ : "${PIPE_METRICS_DEFECT_REINTRODUCE:=Reintroduces work from !}"
59
111
  # Spec template path. GitHub projects usually keep it under .github, GitLab
60
112
  # under .gitlab, so the default follows the detected platform.
61
113
  if [ "$PIPE_PLATFORM" = "github" ]; then
@@ -69,12 +121,20 @@ pipe_defaults() {
69
121
  # would pick up result files (E2E finding N-6). A `*` gitignore inside the
70
122
  # dir hides everything in it, including itself, from any git add.
71
123
  [ -f "$PIPE_CONTEXT_DIR/.gitignore" ] || printf '*\n' > "$PIPE_CONTEXT_DIR/.gitignore"
72
- export PIPE_PLATFORM PIPE_CONFIG_PATH PIPE_CONTEXT_DIR PIPE_METRICS_DIR \
124
+ export PIPE_PLATFORM PIPE_CONFIG_PATH PIPE_CONTEXT_DIR PIPE_METRICS_DIR PIPE_AGENT_STDERR \
73
125
  PIPE_TARGET_BRANCH PIPE_FIX_LOOP_CAP PIPE_CODER_CAP PIPE_ISSUE_SCAN PIPE_COMMIT_TESTFIX \
74
- PIPE_COMMIT_REVIEWFIX PIPE_COMMIT_COVERAGE PIPE_LABEL_READY PIPE_LABEL_WIP \
75
- PIPE_LABEL_STUCK PIPE_LABEL_BLOCKER PIPE_LABEL_BLOCKED PIPE_LABEL_DECOMPOSED \
76
- PIPE_RESULT_REVIEW PIPE_RESULT_REVIEWFIX PIPE_VERIFY_CMD PIPE_COMPILE_CMD \
77
- PIPE_TEST_REPORT_GLOB PIPE_COVERAGE_SIGNAL PIPE_SPEC_TEMPLATE_PATH \
126
+ PIPE_COMMIT_REVIEWFIX PIPE_COMMIT_COVERAGE PIPE_COMMIT_CVEFIX PIPE_LABEL_READY PIPE_LABEL_WIP \
127
+ PIPE_LABEL_STUCK PIPE_LABEL_BLOCKER PIPE_LABEL_BLOCKED PIPE_LABEL_DECOMPOSED PIPE_LABEL_AUDIT \
128
+ PIPE_RESULT_REVIEW PIPE_RESULT_REVIEWFIX PIPE_RESULT_DOCSSYNC PIPE_COMMIT_DOCSSYNC PIPE_COMMIT_AUDIT \
129
+ PIPE_CVE_FIX PIPE_DEPENDENCY_MANIFEST PIPE_CVE_SUPPRESSIONS PIPE_E2E_TEST_GEN PIPE_LABEL_TESTREADY \
130
+ PIPE_LABEL_E2E_SCOPE PIPE_E2E_TEST_DIR PIPE_E2E_BASE_URL PIPE_E2E_VERIFY_CMD PIPE_DOCS_SYNC PIPE_DOCS_ROOTS PIPE_VERIFY_CMD PIPE_COMPILE_CMD \
131
+ PIPE_TEST_REPORT_GLOB PIPE_COVERAGE_RATCHET PIPE_COVERAGE_REPORT PIPE_COVERAGE_REPORT_KIND \
132
+ PIPE_COVERAGE_SIGNAL PIPE_SPEC_TEMPLATE_PATH PIPE_CODEBASE_AUDIT PIPE_AGENT_ARCHITECT \
133
+ PIPE_ARCHITECT_WINDOW_DAYS PIPE_ARCHITECT_DRY_RUN PIPE_LABEL_IMPROVEMENT \
134
+ PIPE_METRICS_SNAPSHOT PIPE_METRICS_OUTPUT_DIR PIPE_METRICS_WINDOW_DAYS \
135
+ PIPE_METRICS_COMMIT_REPO PIPE_METRICS_REVIEW_AGENTS PIPE_METRICS_DEFECT_REVERT \
136
+ PIPE_METRICS_DEFECT_REGRESSION PIPE_METRICS_DEFECT_REINTRODUCE \
137
+ PIPE_METRICS_SNAPSHOT_DATE PIPE_METRICS_RAW_DIR PIPE_METRICS_MRS_FILE \
78
138
  PIPE_HARNESS PIPE_MODEL_TRIAGE PIPE_MODEL_CODE PIPE_MODEL_REVIEW
79
139
  }
80
140
 
@@ -99,6 +159,17 @@ pipe_git_identity() {
99
159
  # Checkout the real source branch so commits land on the branch tip, not on the
100
160
  # detached MR/PR ref that can fall behind and cause non-fast-forward push errors.
101
161
  pipe_checkout_source() {
162
+ # The MR/PR itself is the authoritative source of the branch name. A
163
+ # workflow_run or branch pipeline carries no merge-request source ref, and
164
+ # without one this checkout used to no-op silently, leaving the fix commit on
165
+ # a detached head that no push could land.
166
+ if [ -z "${PIPE_SOURCE_BRANCH:-}" ] && [ -n "${PIPE_MR_IID:-}" ]; then
167
+ # `|| PIPE_SOURCE_BRANCH=""` is the whole error path: an unreachable or
168
+ # unconfigured API must leave the caller where it was, not abort it.
169
+ PIPE_SOURCE_BRANCH=$(platform_mr_meta 2>/dev/null | jq -r '.source_branch // ""' 2>/dev/null) \
170
+ || PIPE_SOURCE_BRANCH=""
171
+ export PIPE_SOURCE_BRANCH
172
+ fi
102
173
  [ -z "${PIPE_SOURCE_BRANCH:-}" ] && return 0
103
174
  git fetch origin "$PIPE_TARGET_BRANCH" "$PIPE_SOURCE_BRANCH" 2>/dev/null || true
104
175
  git checkout -B "$PIPE_SOURCE_BRANCH" "origin/$PIPE_SOURCE_BRANCH" 2>/dev/null || true
@@ -117,15 +188,17 @@ pipe_rebase_and_push() {
117
188
  }
118
189
 
119
190
  # --- Cap counting -----------------------------------------------------------
120
- # Count consecutive commits from the tip back toward the target branch whose
121
- # subject matches a pattern. The skill also computes this, but the runner needs
122
- # it to decide whether the agent actually committed this run.
123
-
191
+ # Count consecutive matching commits from the tip back toward the target branch.
192
+ # Docs-sync commits are transparent: they do not count, but do not interrupt a
193
+ # fix streak. The skills use the same exact-subject rules for their cap checks;
194
+ # the runner needs this count only to detect whether a fix commit landed.
124
195
  pipe_count_fix_commits() {
125
196
  local pattern="$1" count=0 subject
126
197
  while IFS= read -r subject; do
127
- if echo "$subject" | grep -qF "$pattern"; then
198
+ if [ "$subject" = "$pattern" ]; then
128
199
  count=$((count + 1))
200
+ elif [ "$subject" = "$PIPE_COMMIT_DOCSSYNC" ]; then
201
+ continue
129
202
  else
130
203
  break
131
204
  fi
@@ -368,23 +441,24 @@ pipe_get_env() {
368
441
  pipe_apply_escalation() {
369
442
  local pm_env="$PIPE_CONTEXT_DIR/postmortem.env"
370
443
  local pm_body="$PIPE_CONTEXT_DIR/postmortem.md"
371
- local add remove category diag
444
+ local add remove category comment_file
372
445
  add=$(pipe_get_env "$pm_env" ADD_LABELS); add=${add:-$PIPE_LABEL_STUCK}
373
446
  remove=$(pipe_get_env "$pm_env" REMOVE_LABELS); remove=${remove:-$PIPE_LABEL_WIP}
374
447
  category=$(pipe_get_env "$pm_env" FAILURE_CATEGORY); category=${category:-${1:-other}}
375
448
 
376
449
  platform_set_labels "$add" "$remove"
377
450
 
378
- if [ -s "$pm_body" ]; then
379
- diag=$(cat "$pm_body")
380
- else
381
- diag="Automated fixes were exhausted after $PIPE_FIX_LOOP_CAP attempts. A human needs to take over."
382
- fi
383
- platform_post_comment "🤖 **Auto-fix stuck after $PIPE_FIX_LOOP_CAP attempts. Diagnostic analysis:**
384
-
385
- $diag
386
-
387
- Human intervention needed."
451
+ comment_file="$PIPE_CONTEXT_DIR/auto-fix-stuck.md"
452
+ {
453
+ printf '🤖 **Auto-fix stuck after %s attempts. Diagnostic analysis:**\n\n' "$PIPE_FIX_LOOP_CAP"
454
+ if [ -s "$pm_body" ]; then
455
+ cat "$pm_body"
456
+ else
457
+ printf 'Automated fixes were exhausted after %s attempts. A human needs to take over.' "$PIPE_FIX_LOOP_CAP"
458
+ fi
459
+ printf '\n\nHuman intervention needed.\n'
460
+ } > "$comment_file"
461
+ platform_post_comment_file "$comment_file"
388
462
 
389
463
  pipe_metric_event postmortem dev_stuck \
390
464
  "$(jq -n --arg c "$category" --arg ctx "${2:-unknown}" '{failure_category: $c, context: $ctx}')"
@@ -8,7 +8,8 @@
8
8
  # Common (both platforms):
9
9
  # PIPE_MR_IID MR iid (GitLab) or PR number (GitHub)
10
10
  # PIPE_TARGET_BRANCH branch the MR/PR targets
11
- # PIPE_SOURCE_BRANCH the MR/PR source branch
11
+ # PIPE_SOURCE_BRANCH the MR/PR source branch (optional: platform_mr_meta
12
+ # reports it as `source_branch` when the runtime does not)
12
13
  # PIPE_HEAD_SHA current head commit under review
13
14
  # PIPE_PROJECT_PATH namespace/project (for git push URL)
14
15
  # PIPE_SERVER_HOST git host (for git push URL)
@@ -58,7 +59,10 @@ _gl_get() {
58
59
  "$PIPE_API_URL/projects/$PIPE_PROJECT_ID/merge_requests/$PIPE_MR_IID$1"
59
60
  }
60
61
 
61
- _gl_mr_meta() { _gl_get "" | jq -r '{title: (.title // ""), description: (.description // ""), labels: ((.labels // []) | join(","))}'; }
62
+ # `source_branch` is the branch the MR/PR was opened from. It comes from the API
63
+ # rather than the CI runtime because branch and workflow_run pipelines carry no
64
+ # merge-request source ref, and a runner that pushes needs it.
65
+ _gl_mr_meta() { _gl_get "" | jq -r '{title: (.title // ""), description: (.description // ""), labels: ((.labels // []) | join(",")), source_branch: (.source_branch // "")}'; }
62
66
  _gl_mr_labels() { _gl_get "" | jq -r '((.labels // []) | join(","))'; }
63
67
  # Callers take the LAST matching note, so the array must be chronological AND
64
68
  # must contain the newest notes. One asc page would return the oldest 100 on a
@@ -66,11 +70,12 @@ _gl_mr_labels() { _gl_get "" | jq -r '((.labels // []) | join(","))'; }
66
70
  _gl_notes() { _gl_get "/notes?per_page=100&sort=desc" | jq -r '[.[] | {author: .author.username, body: .body}] | reverse'; }
67
71
  _gl_issue() { curl -s -H "PRIVATE-TOKEN: $PIPE_TOKEN" "$PIPE_API_URL/projects/$PIPE_PROJECT_ID/issues/$1" | jq -r '{iid: .iid, title: (.title // ""), description: (.description // "")}'; }
68
72
 
69
- _gl_post_comment() {
73
+ _gl_post_comment_file() {
74
+ # $1 = body file under PIPE_CONTEXT_DIR.
70
75
  curl --fail-with-body -s -X POST \
71
76
  -H "PRIVATE-TOKEN: $PIPE_TOKEN" \
72
77
  "$PIPE_API_URL/projects/$PIPE_PROJECT_ID/merge_requests/$PIPE_MR_IID/notes" \
73
- --data-urlencode "body=$1" > /dev/null
78
+ --data-urlencode "body@$1" > /dev/null
74
79
  }
75
80
 
76
81
  _gl_set_labels() {
@@ -102,15 +107,22 @@ _gl_compute_diff_base() {
102
107
 
103
108
  _gh_get() { gh api "repos/$PIPE_REPO/$1"; }
104
109
 
105
- _gh_mr_meta() { _gh_get "pulls/$PIPE_MR_IID" | jq -r '{title: (.title // ""), description: (.body // ""), labels: ((.labels // []) | map(.name) | join(","))}'; }
110
+ _gh_mr_meta() { _gh_get "pulls/$PIPE_MR_IID" | jq -r '{title: (.title // ""), description: (.body // ""), labels: ((.labels // []) | map(.name) | join(",")), source_branch: (.head.ref // "")}'; }
106
111
  _gh_mr_labels() { _gh_get "pulls/$PIPE_MR_IID" | jq -r '((.labels // []) | map(.name) | join(","))'; }
107
112
  # The per-issue comments endpoint has no sort param, so paginate all pages to
108
113
  # guarantee the newest comments are present (callers take the last match).
109
114
  _gh_notes() { gh api --paginate "repos/$PIPE_REPO/issues/$PIPE_MR_IID/comments?per_page=100" --jq '.[] | {author: .user.login, body: .body}' | jq -s '.'; }
110
115
  _gh_issue() { _gh_get "issues/$1" | jq -r '{iid: .number, title: (.title // ""), description: (.body // "")}'; }
111
116
 
112
- _gh_post_comment() {
113
- gh api -X POST "repos/$PIPE_REPO/issues/$PIPE_MR_IID/comments" -f "body=$1" > /dev/null
117
+ _gh_post_comment_file() {
118
+ # $1 = body file under PIPE_CONTEXT_DIR.
119
+ local payload status=0
120
+ payload=$(mktemp "$PIPE_CONTEXT_DIR/github-comment.XXXXXX.json")
121
+ jq -n --rawfile body "$1" '{body:$body}' > "$payload"
122
+ gh api -X POST "repos/$PIPE_REPO/issues/$PIPE_MR_IID/comments" \
123
+ -H "Content-Type: application/json" --input "$payload" > /dev/null || status=$?
124
+ rm -f "$payload"
125
+ return "$status"
114
126
  }
115
127
 
116
128
  _gh_set_labels() {
@@ -135,6 +147,179 @@ _gh_compute_diff_base() {
135
147
  fi
136
148
  }
137
149
 
150
+ # --- Pipeline and artifact helpers ------------------------------------------
151
+
152
+ _gl_completed_pipelines() {
153
+ local updated_after
154
+ updated_after=$(date -u -d "$1 days ago" '+%Y-%m-%dT%H:%M:%SZ') || return 1
155
+ curl -s --fail -H "PRIVATE-TOKEN: $PIPE_TOKEN" \
156
+ "$PIPE_API_URL/projects/$PIPE_PROJECT_ID/pipelines?scope=finished&updated_after=$updated_after&per_page=100" |
157
+ jq -c '[.[] | {id, status: (.status // ""), ref: (.ref // ""), updated_at: (.updated_at // "")}]'
158
+ }
159
+
160
+ _gl_pipeline_artifacts() {
161
+ curl -s --fail -H "PRIVATE-TOKEN: $PIPE_TOKEN" \
162
+ "$PIPE_API_URL/projects/$PIPE_PROJECT_ID/pipelines/$1/jobs?per_page=100" |
163
+ jq -c '[.[] | {job_id: .id, job_name: (.name // ""), artifact_available: (((.artifacts_file // {}) | .filename) != null)}]'
164
+ }
165
+
166
+ _gl_download_artifact() {
167
+ curl -sSL --fail -H "PRIVATE-TOKEN: $PIPE_TOKEN" \
168
+ "$PIPE_API_URL/projects/$PIPE_PROJECT_ID/jobs/$1/artifacts" -o "$2"
169
+ }
170
+
171
+ _gl_latest_success_coverage() {
172
+ curl -s --fail -H "PRIVATE-TOKEN: $PIPE_TOKEN" \
173
+ "$PIPE_API_URL/projects/$PIPE_PROJECT_ID/pipelines?ref=$1&status=success&per_page=1" |
174
+ jq -r '.[0].coverage // ""'
175
+ }
176
+
177
+ _gl_merge_requests_updated_since() {
178
+ local page response page_size results
179
+ page=1
180
+ results='[]'
181
+ while :; do
182
+ response=$(curl -s --fail -H "PRIVATE-TOKEN: $PIPE_TOKEN" \
183
+ "$PIPE_API_URL/projects/$PIPE_PROJECT_ID/merge_requests?updated_after=$1&per_page=100&page=$page&scope=all") || return 1
184
+ page_size=$(jq -r 'length' <<< "$response") || return 1
185
+ results=$(jq -c --argjson results "$results" \
186
+ '$results + [.[] | {iid, title: (.title // ""), state: (.state // ""), author: (.author.username // ""), labels: (.labels // []), created_at: (.created_at // ""), merged_at: .merged_at, target_branch: (.target_branch // ""), description: (.description // "")}]' <<< "$response") || return 1
187
+ [ "$page_size" -lt 100 ] && break
188
+ page=$((page + 1))
189
+ done
190
+ printf '%s\n' "$results"
191
+ }
192
+
193
+ # Merged MRs targeting a branch, newest first, filtered to iid > since_iid.
194
+ # Used by the codebase-audit job to find work since the last audit.
195
+ _gl_merged_mrs() {
196
+ # $1=target_branch $2=since_iid $3=limit. One page, newest merged first;
197
+ # matches the source script's own non-paginated fetch. Captured into a
198
+ # variable (not piped straight into jq) so a curl failure propagates: a
199
+ # pipeline's exit status is jq's, not curl's, without `set -o pipefail`.
200
+ local response
201
+ response=$(curl -s --fail -H "PRIVATE-TOKEN: $PIPE_TOKEN" \
202
+ "$PIPE_API_URL/projects/$PIPE_PROJECT_ID/merge_requests?target_branch=$1&state=merged&per_page=$3&order_by=created_at&sort=desc") || return 1
203
+ jq -c --argjson since "$2" '[.[] | select(.iid > $since) | {iid, title: (.title // ""), created_at: (.created_at // "")}]' <<< "$response"
204
+ }
205
+
206
+ _gl_mr_changes() {
207
+ # $1=iid $2=limit. Changed file paths for one merged MR, capped at limit.
208
+ local response
209
+ response=$(curl -s --fail -H "PRIVATE-TOKEN: $PIPE_TOKEN" \
210
+ "$PIPE_API_URL/projects/$PIPE_PROJECT_ID/merge_requests/$1/changes?per_page=$2") || return 1
211
+ jq -r --argjson limit "$2" '[.changes[]?.new_path] | .[0:$limit][]' <<< "$response" 2>/dev/null
212
+ }
213
+
214
+ # $1=iid $2=limit $3=sort("asc"|"desc"). Normalized, non-system MR notes.
215
+ _gl_mr_notes() {
216
+ local response
217
+ [[ "$2" =~ ^[1-9][0-9]*$ ]] || return 1
218
+ [[ "$3" = "asc" || "$3" = "desc" ]] || return 1
219
+ response=$(curl -s --fail -H "PRIVATE-TOKEN: $PIPE_TOKEN" \
220
+ "$PIPE_API_URL/projects/$PIPE_PROJECT_ID/merge_requests/$1/notes?per_page=$2&sort=$3") || return 1
221
+ jq --argjson limit "$2" '[.[] | select(.system != true)
222
+ | {author: (.author.username // ""), body: (.body // "")}] | .[0:$limit]' <<< "$response"
223
+ }
224
+
225
+ # These APIs return downloadable binary responses as well as JSON. Use curl
226
+ # rather than gh so artifact downloads remain a silent, status-only operation.
227
+ _gh_pipeline_api() {
228
+ local token="${GH_TOKEN:-${GITHUB_TOKEN:-}}"
229
+ curl -s --fail \
230
+ -H "Accept: application/vnd.github+json" \
231
+ -H "Authorization: Bearer $token" \
232
+ "${GITHUB_API_URL:-https://api.github.com}/repos/$PIPE_REPO/$1"
233
+ }
234
+
235
+ _gh_completed_pipelines() {
236
+ local updated_after page response page_size results
237
+ updated_after=$(date -u -d "$1 days ago" '+%Y-%m-%dT%H:%M:%SZ') || return 1
238
+ page=1
239
+ results='[]'
240
+ while :; do
241
+ response=$(_gh_pipeline_api "actions/runs?status=completed&per_page=100&page=$page") || return 1
242
+ page_size=$(jq -r '(.workflow_runs // []) | length' <<< "$response") || return 1
243
+ results=$(jq -c --argjson results "$results" --arg updated_after "$updated_after" \
244
+ '$results + [.workflow_runs[] | select((.updated_at // "") >= $updated_after) | {id, status: (.conclusion // .status // ""), ref: (.head_branch // ""), updated_at: (.updated_at // "")}]' <<< "$response") || return 1
245
+ [ "$page_size" -lt 100 ] && break
246
+ page=$((page + 1))
247
+ done
248
+ printf '%s\n' "$results"
249
+ }
250
+
251
+ _gh_pipeline_artifacts() {
252
+ _gh_pipeline_api "actions/runs/$1/artifacts?per_page=100" |
253
+ jq -c '[.artifacts[] | {job_id: .id, job_name: (.name // ""), artifact_available: ((.expired // true) | not)}]'
254
+ }
255
+
256
+ _gh_download_artifact() {
257
+ local token="${GH_TOKEN:-${GITHUB_TOKEN:-}}"
258
+ curl -sSL --fail \
259
+ -H "Accept: application/vnd.github+json" \
260
+ -H "Authorization: Bearer $token" \
261
+ "${GITHUB_API_URL:-https://api.github.com}/repos/$PIPE_REPO/actions/artifacts/$1/zip" -o "$2"
262
+ }
263
+
264
+ _gh_latest_success_coverage() {
265
+ _gh_pipeline_api "actions/runs?branch=$1&status=success&per_page=1" |
266
+ jq -r '.workflow_runs[0].coverage // ""'
267
+ }
268
+
269
+ # The pull-request API has no updated-since filter, so the newest-first listing
270
+ # is walked until a page carries nothing inside the window.
271
+ _gh_merge_requests_updated_since() {
272
+ local page response page_size kept kept_size results
273
+ page=1
274
+ results='[]'
275
+ while :; do
276
+ response=$(_gh_pipeline_api "pulls?state=all&sort=updated&direction=desc&per_page=100&page=$page") || return 1
277
+ page_size=$(jq -r 'length' <<< "$response") || return 1
278
+ kept=$(jq -c --arg since "$1" \
279
+ '[.[] | select((.updated_at // "") >= $since) | {iid: .number, title: (.title // ""), state: (if .merged_at then "merged" else (.state // "") end), author: (.user.login // ""), labels: [(.labels // [])[].name], created_at: (.created_at // ""), merged_at: .merged_at, target_branch: (.base.ref // ""), description: (.body // "")}]' <<< "$response") || return 1
280
+ kept_size=$(jq -r 'length' <<< "$kept") || return 1
281
+ results=$(jq -nc --argjson results "$results" --argjson kept "$kept" '$results + $kept') || return 1
282
+ [ "$kept_size" -lt "$page_size" ] && break
283
+ [ "$page_size" -lt 100 ] && break
284
+ page=$((page + 1))
285
+ done
286
+ printf '%s\n' "$results"
287
+ }
288
+
289
+ # Merged PRs targeting a branch, newest first, filtered to number > since_iid.
290
+ # GitHub's closed-PR listing has no server-side merged-only filter, so
291
+ # merged_at != null is applied client-side.
292
+ _gh_merged_mrs() {
293
+ # $1=target_branch $2=since_iid $3=limit.
294
+ local response
295
+ response=$(_gh_pipeline_api "pulls?base=$1&state=closed&per_page=$3&sort=created&direction=desc") || return 1
296
+ jq -c --argjson since "$2" '[.[] | select(.merged_at != null and .number > $since) | {iid: .number, title: (.title // ""), created_at: (.created_at // "")}]' <<< "$response"
297
+ }
298
+
299
+ _gh_mr_changes() {
300
+ # $1=iid $2=limit. Changed file paths for one merged PR, capped at limit.
301
+ local response
302
+ response=$(_gh_pipeline_api "pulls/$1/files?per_page=$2") || return 1
303
+ jq -r --argjson limit "$2" '[.[]?.filename] | .[0:$limit][]' <<< "$response" 2>/dev/null
304
+ }
305
+
306
+ # $1=iid $2=limit $3=sort("asc"|"desc"). GitHub returns comments oldest
307
+ # first, so descend requests paginate before reversing and slicing to preserve
308
+ # real newest-first semantics rather than reversing an arbitrary first page.
309
+ _gh_mr_notes() {
310
+ local response filter
311
+ [[ "$2" =~ ^[1-9][0-9]*$ ]] || return 1
312
+ [[ "$3" = "asc" || "$3" = "desc" ]] || return 1
313
+ if [ "$3" = "asc" ]; then
314
+ response=$(gh api "repos/$PIPE_REPO/issues/$1/comments?per_page=$2") || return 1
315
+ else
316
+ response=$(gh api --paginate "repos/$PIPE_REPO/issues/$1/comments?per_page=100") || return 1
317
+ fi
318
+ filter='[.[] | {author: (.user.login // ""), body: (.body // "")}]'
319
+ [ "$3" = "desc" ] && filter="$filter | reverse"
320
+ jq --argjson limit "$2" "$filter | .[0:\$limit]" <<< "$response"
321
+ }
322
+
138
323
  # --- Bot identity -------------------------------------------------------------
139
324
  # The scripts filter MR/PR comments by the bot's username to recognise their own
140
325
  # previous output. The token already knows whose it is, so an empty
@@ -155,12 +340,84 @@ platform_resolve_bot_user() {
155
340
 
156
341
  # --- Public dispatch --------------------------------------------------------
157
342
 
158
- platform_mr_meta() { case "$PIPE_PLATFORM" in gitlab) _gl_mr_meta ;; github) _gh_mr_meta ;; esac; }
159
- platform_mr_labels() { case "$PIPE_PLATFORM" in gitlab) _gl_mr_labels ;; github) _gh_mr_labels ;; esac; }
160
- platform_notes() { case "$PIPE_PLATFORM" in gitlab) _gl_notes ;; github) _gh_notes ;; esac; }
161
- platform_issue() { case "$PIPE_PLATFORM" in gitlab) _gl_issue "$1" ;; github) _gh_issue "$1" ;; esac; }
162
- platform_post_comment() { case "$PIPE_PLATFORM" in gitlab) _gl_post_comment "$1" ;; github) _gh_post_comment "$1" ;; esac; }
163
- platform_set_labels() { case "$PIPE_PLATFORM" in gitlab) _gl_set_labels "$1" "$2" ;; github) _gh_set_labels "$1" "$2" ;; esac; }
343
+ platform_mr_meta() { case "$PIPE_PLATFORM" in gitlab) _gl_mr_meta ;; github) _gh_mr_meta ;; esac; }
344
+ platform_mr_labels() { case "$PIPE_PLATFORM" in gitlab) _gl_mr_labels ;; github) _gh_mr_labels ;; esac; }
345
+ platform_notes() { case "$PIPE_PLATFORM" in gitlab) _gl_notes ;; github) _gh_notes ;; esac; }
346
+ platform_issue() { case "$PIPE_PLATFORM" in gitlab) _gl_issue "$1" ;; github) _gh_issue "$1" ;; esac; }
347
+ platform_post_comment_file() { case "$PIPE_PLATFORM" in gitlab) _gl_post_comment_file "$1" ;; github) _gh_post_comment_file "$1" ;; esac; }
348
+ platform_set_labels() { case "$PIPE_PLATFORM" in gitlab) _gl_set_labels "$1" "$2" ;; github) _gh_set_labels "$1" "$2" ;; esac; }
349
+
350
+ platform_completed_pipelines() {
351
+ [ "$#" -eq 1 ] || return 1
352
+ case "$PIPE_PLATFORM" in
353
+ gitlab) _gl_completed_pipelines "$1" ;;
354
+ github) _gh_completed_pipelines "$1" ;;
355
+ *) return 1 ;;
356
+ esac
357
+ }
358
+
359
+ platform_pipeline_artifacts() {
360
+ [ "$#" -eq 1 ] || return 1
361
+ case "$PIPE_PLATFORM" in
362
+ gitlab) _gl_pipeline_artifacts "$1" ;;
363
+ github) _gh_pipeline_artifacts "$1" ;;
364
+ *) return 1 ;;
365
+ esac
366
+ }
367
+
368
+ platform_download_artifact() {
369
+ [ "$#" -eq 2 ] || return 1
370
+ case "$PIPE_PLATFORM" in
371
+ gitlab) _gl_download_artifact "$1" "$2" ;;
372
+ github) _gh_download_artifact "$1" "$2" ;;
373
+ *) return 1 ;;
374
+ esac
375
+ }
376
+
377
+ platform_latest_success_coverage() {
378
+ [ "$#" -eq 1 ] || return 1
379
+ case "$PIPE_PLATFORM" in
380
+ gitlab) _gl_latest_success_coverage "$1" ;;
381
+ github) _gh_latest_success_coverage "$1" ;;
382
+ *) return 1 ;;
383
+ esac
384
+ }
385
+
386
+ platform_merge_requests_updated_since() {
387
+ [ "$#" -eq 1 ] || return 1
388
+ case "$PIPE_PLATFORM" in
389
+ gitlab) _gl_merge_requests_updated_since "$1" ;;
390
+ github) _gh_merge_requests_updated_since "$1" ;;
391
+ *) return 1 ;;
392
+ esac
393
+ }
394
+
395
+ platform_merged_mrs() {
396
+ [ "$#" -eq 3 ] || return 1
397
+ case "$PIPE_PLATFORM" in
398
+ gitlab) _gl_merged_mrs "$1" "$2" "$3" ;;
399
+ github) _gh_merged_mrs "$1" "$2" "$3" ;;
400
+ *) return 1 ;;
401
+ esac
402
+ }
403
+
404
+ platform_mr_changes() {
405
+ [ "$#" -eq 2 ] || return 1
406
+ case "$PIPE_PLATFORM" in
407
+ gitlab) _gl_mr_changes "$1" "$2" ;;
408
+ github) _gh_mr_changes "$1" "$2" ;;
409
+ *) return 1 ;;
410
+ esac
411
+ }
412
+
413
+ platform_mr_notes() {
414
+ [ "$#" -eq 3 ] || return 1
415
+ case "$PIPE_PLATFORM" in
416
+ gitlab) _gl_mr_notes "$1" "$2" "$3" ;;
417
+ github) _gh_mr_notes "$1" "$2" "$3" ;;
418
+ *) return 1 ;;
419
+ esac
420
+ }
164
421
 
165
422
  # Sets PIPE_DIFF_BASE and PIPE_IS_INCREMENTAL. Call before building the diff.
166
423
  platform_compute_diff_base() {
@@ -110,7 +110,13 @@ capture_omp() {
110
110
  omp_exit_code: $exit_code
111
111
  }' > "$record_file"
112
112
 
113
- # If omp failed, forward stderr so debugging still works.
113
+ # If the agent failed, forward stderr so debugging still works, and keep a
114
+ # durable copy: the failure detector runs after the invocation returns and
115
+ # cannot read a temp file the wrapper already deleted.
116
+ if [ -n "${PIPE_AGENT_STDERR:-}" ]; then
117
+ mkdir -p "$(dirname "$PIPE_AGENT_STDERR")" 2>/dev/null || true
118
+ cp "$stderr_file" "$PIPE_AGENT_STDERR" 2>/dev/null || true
119
+ fi
114
120
  [ "$exit_code" -ne 0 ] && cat "$stderr_file" >&2
115
121
  rm -f "$stream_file" "$stderr_file"
116
122
  return "$exit_code"
@@ -103,7 +103,13 @@ capture_claude() {
103
103
  claude_exit_code: $exit_code
104
104
  }' > "$record_file"
105
105
 
106
- # If Claude failed, forward stderr so debugging still works
106
+ # If the agent failed, forward stderr so debugging still works, and keep a
107
+ # durable copy: the failure detector runs after the invocation returns and
108
+ # cannot read a temp file the wrapper already deleted.
109
+ if [ -n "${PIPE_AGENT_STDERR:-}" ]; then
110
+ mkdir -p "$(dirname "$PIPE_AGENT_STDERR")" 2>/dev/null || true
111
+ cp "$stderr_file" "$PIPE_AGENT_STDERR" 2>/dev/null || true
112
+ fi
107
113
  [ "$exit_code" -ne 0 ] && cat "$stderr_file" >&2
108
114
  rm -f "$stream_file" "$stderr_file"
109
115
  return "$exit_code"