@cxi-lmai/ci-agent-platform 3.0.0

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 (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +219 -0
  3. package/bin/init.mjs +236 -0
  4. package/package.json +47 -0
  5. package/payload/INSTALL.md +113 -0
  6. package/payload/agents/agent-architect.md +101 -0
  7. package/payload/agents/code-reviewer.md +87 -0
  8. package/payload/agents/codebase-auditor.md +73 -0
  9. package/payload/agents/coder.md +56 -0
  10. package/payload/agents/decomposer.md +70 -0
  11. package/payload/agents/docs-sync.md +115 -0
  12. package/payload/agents/e2e-test-writer.md +47 -0
  13. package/payload/agents/migration-reviewer.md +100 -0
  14. package/payload/agents/orchestrator.md +50 -0
  15. package/payload/agents/performance-reviewer.md +82 -0
  16. package/payload/agents/postmortem.md +83 -0
  17. package/payload/agents/release-mr.md +274 -0
  18. package/payload/agents/security-reviewer.md +122 -0
  19. package/payload/agents/test-fix.md +33 -0
  20. package/payload/agents/test-writer.md +40 -0
  21. package/payload/ci-templates/claude-pipeline.gitlab-ci.yml +233 -0
  22. package/payload/ci-templates/github/README.md +76 -0
  23. package/payload/ci-templates/github/claude-issue-pipeline.yml +141 -0
  24. package/payload/ci-templates/github/claude-pipeline.yml +141 -0
  25. package/payload/ci-templates/github/claude-test-fix.yml +104 -0
  26. package/payload/ci-templates/scripts/code.sh +114 -0
  27. package/payload/ci-templates/scripts/lib/issue-loop.sh +430 -0
  28. package/payload/ci-templates/scripts/lib/pipeline-common.sh +280 -0
  29. package/payload/ci-templates/scripts/lib/platform.sh +177 -0
  30. package/payload/ci-templates/scripts/lib/usage-capture.sh +110 -0
  31. package/payload/ci-templates/scripts/orchestrate.sh +294 -0
  32. package/payload/ci-templates/scripts/postmortem.sh +45 -0
  33. package/payload/ci-templates/scripts/review-fix.sh +90 -0
  34. package/payload/ci-templates/scripts/review.sh +93 -0
  35. package/payload/ci-templates/scripts/test-fix.sh +58 -0
  36. package/payload/skills/fix-review-findings/SKILL.md +79 -0
  37. package/payload/skills/fix-tests/SKILL.md +70 -0
  38. package/payload/skills/implement-issue/SKILL.md +62 -0
  39. package/payload/skills/init-pipeline-config/SKILL.md +96 -0
  40. package/payload/skills/postmortem-mr/SKILL.md +50 -0
  41. package/payload/skills/review-mr/SKILL.md +82 -0
  42. package/payload/skills/triage-issue/SKILL.md +74 -0
  43. package/payload/templates/pipeline-config.template.md +98 -0
  44. package/payload/templates/review_suppressions.template.md +25 -0
  45. package/payload/templates/spec-issue.template.md +64 -0
@@ -0,0 +1,280 @@
1
+ # shellcheck shell=bash
2
+ # Source-only library. Shared runner helpers used by every tier-1 job script:
3
+ # variable defaults, git identity for bot commits, the claude invocation wrapper
4
+ # (with the unprivileged-user switch the original scripts used), dotenv read/write,
5
+ # and a metrics-event helper. No intelligence lives here, only runner plumbing.
6
+
7
+ set -u
8
+
9
+ SCRIPT_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
10
+ # shellcheck source=./usage-capture.sh
11
+ source "$SCRIPT_LIB_DIR/usage-capture.sh"
12
+ # shellcheck source=./platform.sh
13
+ source "$SCRIPT_LIB_DIR/platform.sh"
14
+
15
+ # --- Defaults ---------------------------------------------------------------
16
+ # Every PIPE_ variable has a default here so a project that overrides nothing
17
+ # still gets a working pipeline. The CI template also carries these defaults in
18
+ # its `variables:` block; this is the second line of defence for direct calls.
19
+
20
+ pipe_defaults() {
21
+ pipe_detect_platform || return 1
22
+ : "${PIPE_PLATFORM:=gitlab}" # final fallback for direct local calls
23
+ : "${PIPE_CONFIG_PATH:=.claude/pipeline-config.md}"
24
+ : "${PIPE_CONTEXT_DIR:=build/pipeline}"
25
+ : "${PIPE_METRICS_DIR:=$PIPE_CONTEXT_DIR/metrics}"
26
+ : "${PIPE_TARGET_BRANCH:=main}"
27
+ : "${PIPE_FIX_LOOP_CAP:=2}"
28
+ : "${PIPE_CODER_CAP:=3}"
29
+ : "${PIPE_ISSUE_SCAN:=20}"
30
+ : "${PIPE_MODEL_TRIAGE:=haiku}"
31
+ : "${PIPE_MODEL_CODE:=sonnet}"
32
+ : "${PIPE_MODEL_REVIEW:=sonnet}"
33
+ : "${PIPE_COMMIT_TESTFIX:=Fix test errors}"
34
+ : "${PIPE_COMMIT_REVIEWFIX:=Fix review findings}"
35
+ : "${PIPE_COMMIT_COVERAGE:=Add coverage tests}"
36
+ : "${PIPE_LABEL_READY:=pipe-ready}"
37
+ : "${PIPE_LABEL_WIP:=pipe-wip}"
38
+ : "${PIPE_LABEL_STUCK:=pipe-stuck}"
39
+ : "${PIPE_LABEL_BLOCKER:=pipe-blocker}"
40
+ : "${PIPE_LABEL_BLOCKED:=pipe-blocked}"
41
+ : "${PIPE_LABEL_DECOMPOSED:=pipe-decomposed}"
42
+ : "${PIPE_RESULT_REVIEW:=$PIPE_CONTEXT_DIR/code-review-result.txt}"
43
+ : "${PIPE_RESULT_REVIEWFIX:=$PIPE_CONTEXT_DIR/review-fix-result.json}"
44
+ : "${PIPE_VERIFY_CMD:=}"
45
+ : "${PIPE_COMPILE_CMD:=${PIPE_VERIFY_CMD}}"
46
+ : "${PIPE_TEST_REPORT_GLOB:=}"
47
+ : "${PIPE_COVERAGE_SIGNAL:=}"
48
+ # Spec template path. GitHub projects usually keep it under .github, GitLab
49
+ # under .gitlab, so the default follows the detected platform.
50
+ if [ "$PIPE_PLATFORM" = "github" ]; then
51
+ : "${PIPE_SPEC_TEMPLATE_PATH:=.github/ISSUE_TEMPLATE/spec.md}"
52
+ else
53
+ : "${PIPE_SPEC_TEMPLATE_PATH:=.gitlab/issue_templates/Spec.md}"
54
+ fi
55
+ mkdir -p "$PIPE_CONTEXT_DIR" "$PIPE_METRICS_DIR"
56
+ # Keep the runner's working files out of agent commits: agents commit with
57
+ # `git add -A`, and a repo without its own ignore rule for the context dir
58
+ # would pick up result files (E2E finding N-6). A `*` gitignore inside the
59
+ # dir hides everything in it, including itself, from any git add.
60
+ [ -f "$PIPE_CONTEXT_DIR/.gitignore" ] || printf '*\n' > "$PIPE_CONTEXT_DIR/.gitignore"
61
+ export PIPE_PLATFORM PIPE_CONFIG_PATH PIPE_CONTEXT_DIR PIPE_METRICS_DIR \
62
+ PIPE_TARGET_BRANCH PIPE_FIX_LOOP_CAP PIPE_CODER_CAP PIPE_ISSUE_SCAN PIPE_COMMIT_TESTFIX \
63
+ PIPE_COMMIT_REVIEWFIX PIPE_COMMIT_COVERAGE PIPE_LABEL_READY PIPE_LABEL_WIP \
64
+ PIPE_LABEL_STUCK PIPE_LABEL_BLOCKER PIPE_LABEL_BLOCKED PIPE_LABEL_DECOMPOSED \
65
+ PIPE_RESULT_REVIEW PIPE_RESULT_REVIEWFIX PIPE_VERIFY_CMD PIPE_COMPILE_CMD \
66
+ PIPE_TEST_REPORT_GLOB PIPE_COVERAGE_SIGNAL PIPE_SPEC_TEMPLATE_PATH \
67
+ PIPE_MODEL_TRIAGE PIPE_MODEL_CODE PIPE_MODEL_REVIEW
68
+ }
69
+
70
+ pipe_log() { echo "[pipeline] $*"; }
71
+
72
+ # --- Git identity and push remote ------------------------------------------
73
+ # Sets the bot commit identity and an authenticated push remote. The token is
74
+ # the bot account's token, passed as PIPE_TOKEN (GitLab) or GH_TOKEN (GitHub).
75
+
76
+ pipe_git_identity() {
77
+ git config user.email "${PIPE_GIT_EMAIL:-bot@pipeline.ci}"
78
+ git config user.name "${PIPE_GIT_NAME:-Pipeline Bot}"
79
+ local host="${PIPE_SERVER_HOST:-}"
80
+ local path="${PIPE_PROJECT_PATH:-}"
81
+ [ -z "$host" ] || [ -z "$path" ] && return 0
82
+ case "$PIPE_PLATFORM" in
83
+ gitlab) git remote set-url origin "https://oauth2:${PIPE_TOKEN}@${host}/${path}.git" ;;
84
+ github) git remote set-url origin "https://x-access-token:${GH_TOKEN:-${GITHUB_TOKEN:-}}@${host}/${path}.git" ;;
85
+ esac
86
+ }
87
+
88
+ # Checkout the real source branch so commits land on the branch tip, not on the
89
+ # detached MR/PR ref that can fall behind and cause non-fast-forward push errors.
90
+ pipe_checkout_source() {
91
+ [ -z "${PIPE_SOURCE_BRANCH:-}" ] && return 0
92
+ git fetch origin "$PIPE_TARGET_BRANCH" "$PIPE_SOURCE_BRANCH" 2>/dev/null || true
93
+ git checkout -B "$PIPE_SOURCE_BRANCH" "origin/$PIPE_SOURCE_BRANCH" 2>/dev/null || true
94
+ }
95
+
96
+ # Rebase onto the latest remote source branch, then push. Aborts cleanly on
97
+ # conflict (a human resolves it). Push is runner work, the agent never pushes.
98
+ pipe_rebase_and_push() {
99
+ git fetch origin "$PIPE_SOURCE_BRANCH"
100
+ if ! git rebase "origin/$PIPE_SOURCE_BRANCH"; then
101
+ pipe_log "Rebase conflict, cannot auto-merge on top of remote changes"
102
+ git rebase --abort
103
+ return 1
104
+ fi
105
+ git push origin "HEAD:$PIPE_SOURCE_BRANCH"
106
+ }
107
+
108
+ # --- Cap counting -----------------------------------------------------------
109
+ # Count consecutive commits from the tip back toward the target branch whose
110
+ # subject matches a pattern. The skill also computes this, but the runner needs
111
+ # it to decide whether the agent actually committed this run.
112
+
113
+ pipe_count_fix_commits() {
114
+ local pattern="$1" count=0 subject
115
+ while IFS= read -r subject; do
116
+ if echo "$subject" | grep -qF "$pattern"; then
117
+ count=$((count + 1))
118
+ else
119
+ break
120
+ fi
121
+ done < <(git log "origin/$PIPE_TARGET_BRANCH..HEAD" --format="%s" 2>/dev/null)
122
+ echo "$count"
123
+ }
124
+
125
+ # --- Claude invocation ------------------------------------------------------
126
+ # Runs `claude "/<skill>"` wrapped in usage-capture. Claude Code refuses to run
127
+ # as root with --dangerously-skip-permissions, so when the runner is root we
128
+ # create an unprivileged user and su into it. Otherwise we
129
+ # run directly. Runtime variables are exported so the skill reads them, but
130
+ # credentials used by the surrounding runner are removed from the model process.
131
+
132
+ pipe_agent_secret_var() {
133
+ # Return success when an exported variable looks credential-bearing and is
134
+ # not explicitly required by the Claude API itself or allowlisted for a
135
+ # project verification command. The allowlist is comma/space separated.
136
+ local name="$1" allow=" ${PIPE_AGENT_ENV_ALLOWLIST:-} "
137
+ allow=" ${allow//,/ } "
138
+ # The Claude process must keep its own credential regardless of which of the
139
+ # three supported auth variables the project uses.
140
+ case "$name" in
141
+ ANTHROPIC_API_KEY|ANTHROPIC_AUTH_TOKEN|CLAUDE_CODE_OAUTH_TOKEN) return 1 ;;
142
+ esac
143
+ [[ "$allow" == *" $name "* ]] && return 1
144
+ case "$name" in
145
+ *TOKEN*|*SECRET*|*PASSWORD*|*PASSWD*|*PRIVATE_KEY*|*API_KEY*|*CREDENTIAL*) return 0 ;;
146
+ *) return 1 ;;
147
+ esac
148
+ }
149
+
150
+ pipe_scrub_agent_secrets() {
151
+ local v
152
+ while IFS= read -r v; do
153
+ [ -n "$v" ] && unset "$v"
154
+ done < <(compgen -e | while IFS= read -r v; do pipe_agent_secret_var "$v" && echo "$v"; done)
155
+ }
156
+
157
+ pipe_run_claude() {
158
+ # $1 = agent label for metrics, $2 = skill invocation (e.g. "/review-mr"),
159
+ # $3 = allowed tools (optional), $4 = model for the main loop (optional,
160
+ # e.g. $PIPE_MODEL_REVIEW; subagents keep their frontmatter models),
161
+ # stdin = extra prompt (optional, usually empty)
162
+ local label="$1" skill="$2" tools="${3:-Agent,Read,Write,Edit,Glob,Grep,Bash}" model="${4:-}"
163
+ local stdin_file; stdin_file=$(mktemp)
164
+ cat > "$stdin_file" || true
165
+
166
+ if [ "$(id -u)" -eq 0 ]; then
167
+ local runuser="pipelinebot"
168
+ id "$runuser" &>/dev/null || useradd -m -s /bin/bash "$runuser"
169
+ chown -R "$runuser:$runuser" "$PWD" "$PIPE_CONTEXT_DIR" "$stdin_file" 2>/dev/null || true
170
+ local runner; runner=$(mktemp)
171
+ {
172
+ printf '#!/bin/bash\nset -e\nexport HOME=/home/%s\n' "$runuser"
173
+ # Re-export the runtime environment the skill and usage-capture need.
174
+ local v
175
+ for v in $(compgen -v | grep -E '^(PIPE_|CI_|GH_|GITHUB_|ANTHROPIC_|CLAUDE_)'); do
176
+ pipe_agent_secret_var "$v" && continue
177
+ printf 'export %s=%q\n' "$v" "${!v}"
178
+ done
179
+ # `su -m` preserves the parent environment, so explicitly remove every
180
+ # detected credential after exporting the safe runtime values above.
181
+ for v in $(compgen -e); do
182
+ pipe_agent_secret_var "$v" && printf 'unset %q\n' "$v"
183
+ done
184
+ printf 'cd %q\n' "$PWD"
185
+ printf 'source %q\n' "$SCRIPT_LIB_DIR/usage-capture.sh"
186
+ # The result JSON of `claude -p` has no model field, so pass the
187
+ # requested model to usage-capture for the metric (E2E finding N-5).
188
+ printf 'export PIPE_CAPTURE_MODEL=%q\n' "$model"
189
+ if [ -n "$model" ]; then
190
+ printf 'capture_claude %q -- --dangerously-skip-permissions --model %q --allowedTools %q -p %q < %q\n' \
191
+ "$label" "$model" "$tools" "$skill" "$stdin_file"
192
+ else
193
+ printf 'capture_claude %q -- --dangerously-skip-permissions --allowedTools %q -p %q < %q\n' \
194
+ "$label" "$tools" "$skill" "$stdin_file"
195
+ fi
196
+ } > "$runner"
197
+ chmod +x "$runner"
198
+ chown "$runuser:$runuser" "$runner"
199
+ su -m "$runuser" -s /bin/bash "$runner" || true
200
+ chown -R root:root "$PWD" 2>/dev/null || true
201
+ rm -f "$runner"
202
+ else
203
+ (
204
+ pipe_scrub_agent_secrets
205
+ export PIPE_CAPTURE_MODEL="$model"
206
+ if [ -n "$model" ]; then
207
+ capture_claude "$label" -- --dangerously-skip-permissions --model "$model" --allowedTools "$tools" -p "$skill" < "$stdin_file"
208
+ else
209
+ capture_claude "$label" -- --dangerously-skip-permissions --allowedTools "$tools" -p "$skill" < "$stdin_file"
210
+ fi
211
+ ) || true
212
+ fi
213
+ rm -f "$stdin_file"
214
+ }
215
+
216
+ # --- dotenv -----------------------------------------------------------------
217
+
218
+ # Load KEY=VALUE lines from a marker file into the current shell.
219
+ pipe_load_env() {
220
+ local f="$1"
221
+ [ -f "$f" ] || return 0
222
+ set -a
223
+ # shellcheck disable=SC1090
224
+ source "$f"
225
+ set +a
226
+ }
227
+
228
+ pipe_get_env() {
229
+ # $1 = file, $2 = key. Prints the value or empty.
230
+ [ -f "$1" ] || return 0
231
+ grep -E "^$2=" "$1" | head -1 | cut -d= -f2- || true
232
+ }
233
+
234
+ # --- Escalation -------------------------------------------------------------
235
+ # Applies the runner side of an escalation after the postmortem flow ran (either
236
+ # inline in a fix skill or via the /postmortem-mr skill in postmortem.sh). Reads
237
+ # the label changes and the diagnostic body the skill produced, applies labels,
238
+ # posts the comment, and emits the dev_stuck metric. Never invokes the model.
239
+
240
+ pipe_apply_escalation() {
241
+ local pm_env="$PIPE_CONTEXT_DIR/postmortem.env"
242
+ local pm_body="$PIPE_CONTEXT_DIR/postmortem.md"
243
+ local add remove category diag
244
+ add=$(pipe_get_env "$pm_env" ADD_LABELS); add=${add:-$PIPE_LABEL_STUCK}
245
+ remove=$(pipe_get_env "$pm_env" REMOVE_LABELS); remove=${remove:-$PIPE_LABEL_WIP}
246
+ category=$(pipe_get_env "$pm_env" FAILURE_CATEGORY); category=${category:-${1:-other}}
247
+
248
+ platform_set_labels "$add" "$remove"
249
+
250
+ if [ -s "$pm_body" ]; then
251
+ diag=$(cat "$pm_body")
252
+ else
253
+ diag="Automated fixes were exhausted after $PIPE_FIX_LOOP_CAP attempts. A human needs to take over."
254
+ fi
255
+ platform_post_comment "🤖 **Auto-fix stuck after $PIPE_FIX_LOOP_CAP attempts. Diagnostic analysis:**
256
+
257
+ $diag
258
+
259
+ Human intervention needed."
260
+
261
+ pipe_metric_event postmortem dev_stuck \
262
+ "$(jq -n --arg c "$category" --arg ctx "${2:-unknown}" '{failure_category: $c, context: $ctx}')"
263
+ }
264
+
265
+ # --- Metrics ----------------------------------------------------------------
266
+
267
+ pipe_metric_event() {
268
+ # $1 = agent, $2 = event, $3 = extra jq object (e.g. '{findings_total: 3}')
269
+ local agent="$1" event="$2" extra="${3:-{\}}"
270
+ local ts ts_nanos
271
+ ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)
272
+ ts_nanos=$(date +%s%N)
273
+ jq -n \
274
+ --arg ts "$ts" --arg agent "$agent" --arg event "$event" \
275
+ --argjson mr_iid "${PIPE_MR_IID:-null}" \
276
+ --argjson extra "$extra" \
277
+ '{ts: $ts, agent: $agent, event: $event, mr_iid: $mr_iid} + $extra' \
278
+ > "$PIPE_METRICS_DIR/${agent}-${event}-${ts_nanos}.json"
279
+ pipe_log "metric $event written"
280
+ }
@@ -0,0 +1,177 @@
1
+ # shellcheck shell=bash
2
+ # Source-only library. Platform abstraction over the merge-request / pull-request
3
+ # API, so the runner scripts read the same functions on GitLab and GitHub.
4
+ #
5
+ # Selection: $PIPE_PLATFORM is "gitlab" or "github". Each platform's CI template
6
+ # sets it along with the runtime variables the functions below expect:
7
+ #
8
+ # Common (both platforms):
9
+ # PIPE_MR_IID MR iid (GitLab) or PR number (GitHub)
10
+ # PIPE_TARGET_BRANCH branch the MR/PR targets
11
+ # PIPE_SOURCE_BRANCH the MR/PR source branch
12
+ # PIPE_HEAD_SHA current head commit under review
13
+ # PIPE_PROJECT_PATH namespace/project (for git push URL)
14
+ # PIPE_SERVER_HOST git host (for git push URL)
15
+ # PIPE_BOT_USER bot account username (filters bot comments)
16
+ #
17
+ # GitLab:
18
+ # PIPE_API_URL $CI_API_V4_URL
19
+ # PIPE_PROJECT_ID $CI_PROJECT_ID
20
+ # PIPE_TOKEN project-access / personal token of the bot account
21
+ # PIPE_PREV_HEAD_SHA (unused; GitLab computes incrementality from versions)
22
+ #
23
+ # GitHub:
24
+ # PIPE_REPO owner/repo (for `gh api`)
25
+ # GH_TOKEN / GITHUB_TOKEN token used by the `gh` CLI
26
+ # PIPE_PREV_HEAD_SHA github.event.before on a synchronize event, else empty
27
+ #
28
+ # All functions print to stdout and never call `exit`. On error they print an
29
+ # empty value so the caller can decide.
30
+
31
+ set -u
32
+
33
+ # --- Platform autodetection -------------------------------------------------
34
+ # Same runner scripts run on GitLab and GitHub without configuration. An
35
+ # explicit PIPE_PLATFORM always wins; otherwise the CI runner's own signal
36
+ # variable decides (GITLAB_CI=true or GITHUB_ACTIONS=true). Only genuine
37
+ # ambiguity (both signals present) is a hard error. When neither is present and
38
+ # nothing is set (a direct local call), the caller's default applies.
39
+ pipe_detect_platform() {
40
+ [ -n "${PIPE_PLATFORM:-}" ] && return 0 # explicit override wins
41
+ local gl="${GITLAB_CI:-}" gh="${GITHUB_ACTIONS:-}"
42
+ if [ "$gl" = "true" ] && [ "$gh" = "true" ]; then
43
+ echo "[pipeline] ERROR: both GITLAB_CI and GITHUB_ACTIONS are set; set PIPE_PLATFORM to disambiguate" >&2
44
+ return 1
45
+ elif [ "$gl" = "true" ]; then
46
+ PIPE_PLATFORM="gitlab"
47
+ elif [ "$gh" = "true" ]; then
48
+ PIPE_PLATFORM="github"
49
+ fi
50
+ return 0
51
+ }
52
+
53
+ # --- GitLab helpers ---------------------------------------------------------
54
+
55
+ _gl_get() {
56
+ # $1 = API path suffix under the MR, e.g. "" or "/notes?per_page=100"
57
+ curl -s -H "PRIVATE-TOKEN: $PIPE_TOKEN" \
58
+ "$PIPE_API_URL/projects/$PIPE_PROJECT_ID/merge_requests/$PIPE_MR_IID$1"
59
+ }
60
+
61
+ _gl_mr_meta() { _gl_get "" | jq -r '{title: (.title // ""), description: (.description // ""), labels: ((.labels // []) | join(","))}'; }
62
+ _gl_mr_labels() { _gl_get "" | jq -r '((.labels // []) | join(","))'; }
63
+ # Callers take the LAST matching note, so the array must be chronological AND
64
+ # must contain the newest notes. One asc page would return the oldest 100 on a
65
+ # long MR, so fetch the newest 100 (sort=desc) and reverse back to chronological.
66
+ _gl_notes() { _gl_get "/notes?per_page=100&sort=desc" | jq -r '[.[] | {author: .author.username, body: .body}] | reverse'; }
67
+ _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
+
69
+ _gl_post_comment() {
70
+ curl --fail-with-body -s -X POST \
71
+ -H "PRIVATE-TOKEN: $PIPE_TOKEN" \
72
+ "$PIPE_API_URL/projects/$PIPE_PROJECT_ID/merge_requests/$PIPE_MR_IID/notes" \
73
+ --data-urlencode "body=$1" > /dev/null
74
+ }
75
+
76
+ _gl_set_labels() {
77
+ # $1 = add (comma), $2 = remove (comma)
78
+ curl -s -X PUT -H "PRIVATE-TOKEN: $PIPE_TOKEN" -H "Content-Type: application/json" \
79
+ "$PIPE_API_URL/projects/$PIPE_PROJECT_ID/merge_requests/$PIPE_MR_IID" \
80
+ -d "{\"add_labels\":\"$1\",\"remove_labels\":\"$2\"}" > /dev/null || true
81
+ }
82
+
83
+ _gl_compute_diff_base() {
84
+ # Incremental re-review: use the previous push head unless the MR was rebased.
85
+ # After a rebase the base_commit_sha changes between versions, so the old head
86
+ # is on a different tree and would pull in absorbed base-branch changes.
87
+ local versions prev_head curr_base prev_base rebased
88
+ versions=$(_gl_get "/versions")
89
+ prev_head=$(echo "$versions" | jq -r '.[1].head_commit_sha // ""' 2>/dev/null || true)
90
+ curr_base=$(echo "$versions" | jq -r '.[0].base_commit_sha // ""' 2>/dev/null || true)
91
+ prev_base=$(echo "$versions" | jq -r '.[1].base_commit_sha // ""' 2>/dev/null || true)
92
+ rebased=$( [ -n "$prev_base" ] && [ "$curr_base" != "$prev_base" ] && echo true || echo false )
93
+ if [ -n "$prev_head" ] && [ "$rebased" = "false" ]; then
94
+ PIPE_DIFF_BASE="$prev_head"; PIPE_IS_INCREMENTAL="true"
95
+ else
96
+ PIPE_DIFF_BASE="${CI_MERGE_REQUEST_DIFF_BASE_SHA:-$(git merge-base "origin/$PIPE_TARGET_BRANCH" "$PIPE_HEAD_SHA" 2>/dev/null || echo "$PIPE_TARGET_BRANCH")}"
97
+ PIPE_IS_INCREMENTAL="false"
98
+ fi
99
+ }
100
+
101
+ # --- GitHub helpers ---------------------------------------------------------
102
+
103
+ _gh_get() { gh api "repos/$PIPE_REPO/$1"; }
104
+
105
+ _gh_mr_meta() { _gh_get "pulls/$PIPE_MR_IID" | jq -r '{title: (.title // ""), description: (.body // ""), labels: ((.labels // []) | map(.name) | join(","))}'; }
106
+ _gh_mr_labels() { _gh_get "pulls/$PIPE_MR_IID" | jq -r '((.labels // []) | map(.name) | join(","))'; }
107
+ # The per-issue comments endpoint has no sort param, so paginate all pages to
108
+ # guarantee the newest comments are present (callers take the last match).
109
+ _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
+ _gh_issue() { _gh_get "issues/$1" | jq -r '{iid: .number, title: (.title // ""), description: (.body // "")}'; }
111
+
112
+ _gh_post_comment() {
113
+ gh api -X POST "repos/$PIPE_REPO/issues/$PIPE_MR_IID/comments" -f "body=$1" > /dev/null
114
+ }
115
+
116
+ _gh_set_labels() {
117
+ # $1 = add (comma), $2 = remove (comma)
118
+ local IFS=','
119
+ local l
120
+ for l in $1; do
121
+ [ -n "$l" ] && gh api -X POST "repos/$PIPE_REPO/issues/$PIPE_MR_IID/labels" -f "labels[]=$l" > /dev/null || true
122
+ done
123
+ for l in $2; do
124
+ [ -n "$l" ] && gh api -X DELETE "repos/$PIPE_REPO/issues/$PIPE_MR_IID/labels/$l" > /dev/null 2>&1 || true
125
+ done
126
+ }
127
+
128
+ _gh_compute_diff_base() {
129
+ # GitHub gives the previous head as github.event.before on a synchronize event.
130
+ if [ -n "${PIPE_PREV_HEAD_SHA:-}" ] && [ "$PIPE_PREV_HEAD_SHA" != "0000000000000000000000000000000000000000" ]; then
131
+ PIPE_DIFF_BASE="$PIPE_PREV_HEAD_SHA"; PIPE_IS_INCREMENTAL="true"
132
+ else
133
+ PIPE_DIFF_BASE="$(git merge-base "origin/$PIPE_TARGET_BRANCH" "$PIPE_HEAD_SHA" 2>/dev/null || echo "origin/$PIPE_TARGET_BRANCH")"
134
+ PIPE_IS_INCREMENTAL="false"
135
+ fi
136
+ }
137
+
138
+ # --- Bot identity -------------------------------------------------------------
139
+ # The scripts filter MR/PR comments by the bot's username to recognise their own
140
+ # previous output. The token already knows whose it is, so an empty
141
+ # PIPE_BOT_USER is resolved from the API once instead of being configured.
142
+ _gl_resolve_bot_user() { curl -s -H "PRIVATE-TOKEN: $PIPE_TOKEN" "$PIPE_API_URL/user" | jq -r '.username // ""'; }
143
+ # /user does not answer for the default GITHUB_TOKEN (an app token, not a
144
+ # user); comments made with it are authored by github-actions[bot].
145
+ _gh_resolve_bot_user() { gh api user --jq '.login' 2>/dev/null || echo "github-actions[bot]"; }
146
+
147
+ platform_resolve_bot_user() {
148
+ [ -n "${PIPE_BOT_USER:-}" ] && return 0
149
+ case "$PIPE_PLATFORM" in
150
+ gitlab) PIPE_BOT_USER=$(_gl_resolve_bot_user) ;;
151
+ github) PIPE_BOT_USER=$(_gh_resolve_bot_user) ;;
152
+ esac
153
+ export PIPE_BOT_USER
154
+ }
155
+
156
+ # --- Public dispatch --------------------------------------------------------
157
+
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; }
164
+
165
+ # Sets PIPE_DIFF_BASE and PIPE_IS_INCREMENTAL. Call before building the diff.
166
+ platform_compute_diff_base() {
167
+ if [ -n "${PIPE_DIFF_BASE:-}" ]; then
168
+ # Caller (or the CI template) already provided it; respect it.
169
+ PIPE_IS_INCREMENTAL="${PIPE_IS_INCREMENTAL:-false}"
170
+ return 0
171
+ fi
172
+ case "$PIPE_PLATFORM" in
173
+ gitlab) _gl_compute_diff_base ;;
174
+ github) _gh_compute_diff_base ;;
175
+ esac
176
+ export PIPE_DIFF_BASE PIPE_IS_INCREMENTAL
177
+ }
@@ -0,0 +1,110 @@
1
+ # shellcheck shell=bash
2
+ # Source-only library. Wraps a `claude` invocation, captures the stream-json
3
+ # `result` event, writes a metrics record, and surfaces the assistant text on
4
+ # stdout so callers behave identically.
5
+ #
6
+ # Taken from a single-project original (audited as 100% generic).
7
+ # The only change is the metrics directory and the identifier fields, which now
8
+ # read the PIPE_ runtime variables first and fall back to the GitLab CI_ names,
9
+ # so the same wrapper works on GitLab and GitHub runners.
10
+ #
11
+ # Usage: capture_claude <agent_name> -- <args to pass to `claude`...>
12
+ # Stdin: forwarded to `claude` stdin
13
+ # Stdout: concatenated text from assistant turns (drop-in for `claude`)
14
+ # Side effect: writes one JSON record to $PIPE_METRICS_DIR
15
+
16
+ set -u
17
+
18
+ capture_claude() {
19
+ local agent="$1"; shift
20
+ [ "${1:-}" = "--" ] && shift
21
+
22
+ local metrics_dir="${PIPE_METRICS_DIR:-${PIPE_CONTEXT_DIR:-build/pipeline}/metrics}"
23
+ mkdir -p "$metrics_dir"
24
+
25
+ local job_name="${PIPE_JOB_NAME:-${CI_JOB_NAME:-job}}"
26
+ local job_id="${PIPE_JOB_ID:-${CI_JOB_ID:-$$}}"
27
+ local pipeline_id="${PIPE_PIPELINE_ID:-${CI_PIPELINE_ID:-}}"
28
+ local mr_iid="${PIPE_MR_IID:-${CI_MERGE_REQUEST_IID:-null}}"
29
+ local issue_iid="${PIPE_ISSUE_IID:-${CODER_ISSUE:-null}}"
30
+
31
+ local stream_file stderr_file
32
+ stream_file=$(mktemp)
33
+ stderr_file=$(mktemp)
34
+ local start_ns end_ns
35
+ start_ns=$(date +%s%N)
36
+ local exit_code=0
37
+
38
+ # Force stream-json so we always get a `result` event.
39
+ claude --output-format stream-json --verbose "$@" \
40
+ >"$stream_file" 2>"$stderr_file" || exit_code=$?
41
+
42
+ end_ns=$(date +%s%N)
43
+ local wall_ms=$(( (end_ns - start_ns) / 1000000 ))
44
+
45
+ # Extract the result event (last line that begins with {"type":"result")
46
+ local result_line
47
+ result_line=$(grep '^{"type":"result"' "$stream_file" | tail -1 || true)
48
+
49
+ local model input output cache_c cache_r cost duration
50
+ if [ -n "$result_line" ]; then
51
+ # The result event carries no model field (E2E finding N-5). Record the
52
+ # model the runner requested (PIPE_CAPTURE_MODEL). The last fallback,
53
+ # first key of modelUsage, is alphabetical and may name a subagent's
54
+ # model rather than the main loop's, so it comes last.
55
+ model=$(echo "$result_line" | jq -r '.model // empty')
56
+ [ -n "$model" ] || model="${PIPE_CAPTURE_MODEL:-}"
57
+ [ -n "$model" ] || model=$(echo "$result_line" | jq -r '.modelUsage // {} | keys | first // empty')
58
+ input=$(echo "$result_line" | jq -r '.usage.input_tokens // 0')
59
+ output=$(echo "$result_line" | jq -r '.usage.output_tokens // 0')
60
+ cache_c=$(echo "$result_line" | jq -r '.usage.cache_creation_input_tokens // 0')
61
+ cache_r=$(echo "$result_line" | jq -r '.usage.cache_read_input_tokens // 0')
62
+ cost=$(echo "$result_line" | jq -r '.total_cost_usd // 0')
63
+ duration=$(echo "$result_line" | jq -r '.duration_ms // 0')
64
+ else
65
+ model=""; input=0; output=0; cache_c=0; cache_r=0; cost=0; duration=$wall_ms
66
+ fi
67
+
68
+ # Recover the user-facing text (concatenate all assistant text blocks) so
69
+ # downstream consumers that read `claude`'s stdout still work.
70
+ grep '^{"type":"assistant"' "$stream_file" \
71
+ | jq -r '.message.content[]? | select(.type=="text") | .text' \
72
+ || true
73
+
74
+ # Write metrics record
75
+ local record_stamp record_file
76
+ record_stamp=$(date +%s%N)
77
+ record_file="$metrics_dir/${job_name}-${job_id}-${record_stamp}.json"
78
+ jq -n \
79
+ --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
80
+ --arg agent "$agent" \
81
+ --arg event "claude_invocation" \
82
+ --arg model "$model" \
83
+ --arg pipeline_id "$pipeline_id" \
84
+ --arg job_id "$job_id" \
85
+ --arg job_name "$job_name" \
86
+ --argjson mr_iid "$mr_iid" \
87
+ --argjson issue_iid "$issue_iid" \
88
+ --argjson input "$input" \
89
+ --argjson output "$output" \
90
+ --argjson cache_c "$cache_c" \
91
+ --argjson cache_r "$cache_r" \
92
+ --argjson cost "$cost" \
93
+ --argjson duration "$duration" \
94
+ --argjson wall_ms "$wall_ms" \
95
+ --argjson exit_code "$exit_code" \
96
+ '{
97
+ ts: $ts, agent: $agent, event: $event, model: $model,
98
+ pipeline_id: $pipeline_id, job_id: $job_id, job_name: $job_name,
99
+ mr_iid: $mr_iid, issue_iid: $issue_iid,
100
+ input_tokens: $input, output_tokens: $output,
101
+ cache_creation_tokens: $cache_c, cache_read_tokens: $cache_r,
102
+ total_cost_usd: $cost, duration_ms: $duration, wall_ms: $wall_ms,
103
+ claude_exit_code: $exit_code
104
+ }' > "$record_file"
105
+
106
+ # If Claude failed, forward stderr so debugging still works
107
+ [ "$exit_code" -ne 0 ] && cat "$stderr_file" >&2
108
+ rm -f "$stream_file" "$stderr_file"
109
+ return "$exit_code"
110
+ }