@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.
- package/LICENSE +21 -0
- package/README.md +219 -0
- package/bin/init.mjs +236 -0
- package/package.json +47 -0
- package/payload/INSTALL.md +113 -0
- package/payload/agents/agent-architect.md +101 -0
- package/payload/agents/code-reviewer.md +87 -0
- package/payload/agents/codebase-auditor.md +73 -0
- package/payload/agents/coder.md +56 -0
- package/payload/agents/decomposer.md +70 -0
- package/payload/agents/docs-sync.md +115 -0
- package/payload/agents/e2e-test-writer.md +47 -0
- package/payload/agents/migration-reviewer.md +100 -0
- package/payload/agents/orchestrator.md +50 -0
- package/payload/agents/performance-reviewer.md +82 -0
- package/payload/agents/postmortem.md +83 -0
- package/payload/agents/release-mr.md +274 -0
- package/payload/agents/security-reviewer.md +122 -0
- package/payload/agents/test-fix.md +33 -0
- package/payload/agents/test-writer.md +40 -0
- package/payload/ci-templates/claude-pipeline.gitlab-ci.yml +233 -0
- package/payload/ci-templates/github/README.md +76 -0
- package/payload/ci-templates/github/claude-issue-pipeline.yml +141 -0
- package/payload/ci-templates/github/claude-pipeline.yml +141 -0
- package/payload/ci-templates/github/claude-test-fix.yml +104 -0
- package/payload/ci-templates/scripts/code.sh +114 -0
- package/payload/ci-templates/scripts/lib/issue-loop.sh +430 -0
- package/payload/ci-templates/scripts/lib/pipeline-common.sh +280 -0
- package/payload/ci-templates/scripts/lib/platform.sh +177 -0
- package/payload/ci-templates/scripts/lib/usage-capture.sh +110 -0
- package/payload/ci-templates/scripts/orchestrate.sh +294 -0
- package/payload/ci-templates/scripts/postmortem.sh +45 -0
- package/payload/ci-templates/scripts/review-fix.sh +90 -0
- package/payload/ci-templates/scripts/review.sh +93 -0
- package/payload/ci-templates/scripts/test-fix.sh +58 -0
- package/payload/skills/fix-review-findings/SKILL.md +79 -0
- package/payload/skills/fix-tests/SKILL.md +70 -0
- package/payload/skills/implement-issue/SKILL.md +62 -0
- package/payload/skills/init-pipeline-config/SKILL.md +96 -0
- package/payload/skills/postmortem-mr/SKILL.md +50 -0
- package/payload/skills/review-mr/SKILL.md +82 -0
- package/payload/skills/triage-issue/SKILL.md +74 -0
- package/payload/templates/pipeline-config.template.md +98 -0
- package/payload/templates/review_suppressions.template.md +25 -0
- 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
|
+
}
|