@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,430 @@
|
|
|
1
|
+
# shellcheck shell=bash
|
|
2
|
+
# Source-only library. Platform abstraction for the issue -> code loop, so the
|
|
3
|
+
# orchestrate.sh and code.sh runners read the same functions on GitLab and
|
|
4
|
+
# GitHub. It complements platform.sh (which covers the MR/PR review loop).
|
|
5
|
+
#
|
|
6
|
+
# Selection: $PIPE_PLATFORM ("gitlab" or "github"), set by pipe_detect_platform.
|
|
7
|
+
# Runtime variables expected (set by each platform's CI template):
|
|
8
|
+
#
|
|
9
|
+
# GitLab:
|
|
10
|
+
# PIPE_API_URL $CI_API_V4_URL
|
|
11
|
+
# PIPE_PROJECT_ID $CI_PROJECT_ID
|
|
12
|
+
# PIPE_TOKEN bot token with api + write_repository (triage, MR author)
|
|
13
|
+
# PIPE_TRIGGER_TOKEN pipeline trigger token (POST /projects/:id/pipeline)
|
|
14
|
+
# GitHub:
|
|
15
|
+
# PIPE_REPO owner/repo (for `gh api`)
|
|
16
|
+
# GH_TOKEN bot PAT (PIPE_BOT_TOKEN) with issues + contents + PR
|
|
17
|
+
# PIPE_CODE_WORKFLOW filename of the code workflow to dispatch (default claude-issue-pipeline.yml)
|
|
18
|
+
#
|
|
19
|
+
# Blocking model (decision D1): GitHub has no native is_blocked_by. This loop
|
|
20
|
+
# uses the text convention "Blocked by #N" in the issue description. GitLab uses
|
|
21
|
+
# native issue links. See issue_blockers_open_count / issue_children.
|
|
22
|
+
#
|
|
23
|
+
# All functions print to stdout and never call `exit`.
|
|
24
|
+
|
|
25
|
+
set -u
|
|
26
|
+
|
|
27
|
+
# =============================================================================
|
|
28
|
+
# GitLab helpers
|
|
29
|
+
# =============================================================================
|
|
30
|
+
|
|
31
|
+
_gli_api() { echo "$PIPE_API_URL/projects/$PIPE_PROJECT_ID"; }
|
|
32
|
+
|
|
33
|
+
_gli_ensure_label() {
|
|
34
|
+
# $1=name $2=color $3=desc. Project-level (generic: no group-admin needed).
|
|
35
|
+
# The original used group-level labels shared across a group; the template keeps
|
|
36
|
+
# them project-level so any project works without namespace privileges.
|
|
37
|
+
local http
|
|
38
|
+
http=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
|
|
39
|
+
-H "PRIVATE-TOKEN: $PIPE_TOKEN" -H "Content-Type: application/json" \
|
|
40
|
+
"$(_gli_api)/labels" \
|
|
41
|
+
-d "$(jq -n --arg n "$1" --arg c "$2" --arg d "$3" '{name:$n,color:$c,description:$d}')")
|
|
42
|
+
# 201 created, 409 already exists: both fine.
|
|
43
|
+
[ "$http" = "201" ] || [ "$http" = "409" ] || echo "[pipeline] WARNING: label '$1' ensure returned HTTP $http" >&2
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
_gli_issues_by_label() {
|
|
47
|
+
# $1=label. Returns JSON array of {iid,title,description,labels,milestone_id}.
|
|
48
|
+
curl -s -H "PRIVATE-TOKEN: $PIPE_TOKEN" \
|
|
49
|
+
"$(_gli_api)/issues?labels=$1&state=opened&per_page=${PIPE_ISSUE_SCAN:-20}" \
|
|
50
|
+
| jq '[.[] | {iid, title: (.title // ""), description: (.description // ""),
|
|
51
|
+
labels: ((.labels // []) | join(",")),
|
|
52
|
+
milestone_id: (.milestone.id // null)}]'
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
_gli_issues_closed_by_label() {
|
|
56
|
+
# $1=label. Closed issues still carrying a lifecycle label (janitor input).
|
|
57
|
+
curl -s -H "PRIVATE-TOKEN: $PIPE_TOKEN" \
|
|
58
|
+
"$(_gli_api)/issues?labels=$1&state=closed&per_page=${PIPE_ISSUE_SCAN:-20}" \
|
|
59
|
+
| jq '[.[] | {iid}]'
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
_gli_issue_notes() {
|
|
63
|
+
# $1=iid. Non-system notes, newest first. The caller (orchestrate.sh) caps
|
|
64
|
+
# the total volume and adds a visible truncation marker, so the newest and
|
|
65
|
+
# most relevant comment survives when a long discussion is trimmed.
|
|
66
|
+
curl -s -H "PRIVATE-TOKEN: $PIPE_TOKEN" \
|
|
67
|
+
"$(_gli_api)/issues/$1/notes?per_page=50&sort=desc" \
|
|
68
|
+
| jq -r '[.[] | select(.system == false) | "**" + .author.name + ":** " + .body] | join("\n\n")'
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
_gli_issue_body() {
|
|
72
|
+
# $1=iid. Description only.
|
|
73
|
+
curl -s -H "PRIVATE-TOKEN: $PIPE_TOKEN" "$(_gli_api)/issues/$1" | jq -r '.description // ""'
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
_gli_issue_meta() {
|
|
77
|
+
# $1=iid. Returns {iid,title,description,labels}.
|
|
78
|
+
curl -s -H "PRIVATE-TOKEN: $PIPE_TOKEN" "$(_gli_api)/issues/$1" \
|
|
79
|
+
| jq '{iid, title: (.title // ""), description: (.description // ""), labels: ((.labels // []) | join(","))}'
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
_gli_issue_state() {
|
|
83
|
+
# $1=iid. Prints "opened" or "closed".
|
|
84
|
+
curl -s -H "PRIVATE-TOKEN: $PIPE_TOKEN" "$(_gli_api)/issues/$1" | jq -r '.state // "opened"'
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
_gli_blockers_open_count() {
|
|
88
|
+
# $1=iid. Count of open issues that block this one (native links).
|
|
89
|
+
curl -s -H "PRIVATE-TOKEN: $PIPE_TOKEN" "$(_gli_api)/issues/$1/links" \
|
|
90
|
+
| jq '[.[] | select(.link_type == "is_blocked_by" and .state == "opened")] | length' 2>/dev/null || echo 1
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
_gli_children_state() {
|
|
94
|
+
# $1=iid. Prints "<total> <open>". New installs keep native links when
|
|
95
|
+
# available; the comment marker fallback also supports already-decomposed
|
|
96
|
+
# parents and GitLab editions where issue-link behavior differs.
|
|
97
|
+
local links total open kids n
|
|
98
|
+
links=$(curl -s -H "PRIVATE-TOKEN: $PIPE_TOKEN" "$(_gli_api)/issues/$1/links")
|
|
99
|
+
total=$(echo "$links" | jq '[.[] | select(.link_type == "blocks")] | length' 2>/dev/null || echo 0)
|
|
100
|
+
open=$(echo "$links" | jq '[.[] | select(.link_type == "blocks" and .state == "opened")] | length' 2>/dev/null || echo 1)
|
|
101
|
+
if [ "${total:-0}" = "0" ]; then
|
|
102
|
+
kids=$(curl -s -H "PRIVATE-TOKEN: $PIPE_TOKEN" \
|
|
103
|
+
"$(_gli_api)/issues/$1/notes?per_page=100&sort=desc" \
|
|
104
|
+
| jq -r '.[].body // empty' 2>/dev/null \
|
|
105
|
+
| grep -oE '<!-- pipe-children:[^>]*-->' \
|
|
106
|
+
| head -1 | grep -oE '#[0-9]+' | grep -oE '[0-9]+' | sort -u || true)
|
|
107
|
+
total=0; open=0
|
|
108
|
+
for n in $kids; do
|
|
109
|
+
total=$((total + 1))
|
|
110
|
+
[ "$(_gli_issue_state "$n")" = "opened" ] && open=$((open + 1))
|
|
111
|
+
done
|
|
112
|
+
fi
|
|
113
|
+
echo "$total $open"
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
_gli_create_issue() {
|
|
117
|
+
# $1=title $2=desc $3=labels(comma). Prints new iid or empty.
|
|
118
|
+
curl -s -X POST -H "PRIVATE-TOKEN: $PIPE_TOKEN" -H "Content-Type: application/json" \
|
|
119
|
+
"$(_gli_api)/issues" \
|
|
120
|
+
-d "$(jq -n --arg t "$1" --arg b "$2" --arg l "$3" '{title:$t,description:$b,labels:$l}')" \
|
|
121
|
+
| jq -r '.iid // empty'
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
_gli_link_blocked_by() {
|
|
125
|
+
# $1=blocked_iid $2=blocking_iid
|
|
126
|
+
curl -s -X POST -H "PRIVATE-TOKEN: $PIPE_TOKEN" -H "Content-Type: application/json" \
|
|
127
|
+
"$(_gli_api)/issues/$1/links" \
|
|
128
|
+
-d "$(jq -n --argjson t "$2" '{target_issue_iid:$t, link_type:"is_blocked_by"}')" > /dev/null 2>&1 || true
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
_gli_set_issue_labels() {
|
|
132
|
+
# $1=iid $2=add(comma) $3=remove(comma). Returns HTTP-independent (best effort).
|
|
133
|
+
curl -s -X PUT -H "PRIVATE-TOKEN: $PIPE_TOKEN" -H "Content-Type: application/json" \
|
|
134
|
+
"$(_gli_api)/issues/$1" \
|
|
135
|
+
-d "$(jq -n --arg a "$2" --arg r "$3" '{add_labels:$a, remove_labels:$r}')" > /dev/null
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
_gli_close_issue() {
|
|
139
|
+
curl -s -X PUT -H "PRIVATE-TOKEN: $PIPE_TOKEN" -H "Content-Type: application/json" \
|
|
140
|
+
"$(_gli_api)/issues/$1" -d '{"state_event":"close"}' > /dev/null || true
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
_gli_issue_comment() {
|
|
144
|
+
curl -s -X POST -H "PRIVATE-TOKEN: $PIPE_TOKEN" "$(_gli_api)/issues/$1/notes" \
|
|
145
|
+
--data-urlencode "body=$2" > /dev/null
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
_gli_code_trigger() {
|
|
149
|
+
# $1=iid $2=branch. Fires the coder via the Pipeline Trigger API. Returns 0/1.
|
|
150
|
+
# On failure the API response is logged so token/permission problems are
|
|
151
|
+
# visible in the job log instead of a bare "trigger failed".
|
|
152
|
+
# Trigger tokens (glptt-*) are only accepted as the `token` form field of
|
|
153
|
+
# POST /projects/:id/trigger/pipeline. Passing one as PRIVATE-TOKEN to
|
|
154
|
+
# /projects/:id/pipeline returns 401 (pilot 2 finding, P7).
|
|
155
|
+
local out
|
|
156
|
+
out=$(curl --fail-with-body -s -X POST \
|
|
157
|
+
--form "token=$PIPE_TRIGGER_TOKEN" \
|
|
158
|
+
--form "ref=$PIPE_TARGET_BRANCH" \
|
|
159
|
+
--form "variables[CODER_ISSUE]=$1" \
|
|
160
|
+
--form "variables[CODER_BRANCH]=$2" \
|
|
161
|
+
"$(_gli_api)/trigger/pipeline" 2>&1) || {
|
|
162
|
+
echo "[pipeline] code trigger API error: $out" >&2
|
|
163
|
+
return 1
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
_gli_mr_create() {
|
|
168
|
+
# $1=source $2=target $3=title $4=desc $5=labels(comma). Prints web_url.
|
|
169
|
+
curl --fail-with-body -s -X POST -H "PRIVATE-TOKEN: $PIPE_TOKEN" -H "Content-Type: application/json" \
|
|
170
|
+
"$(_gli_api)/merge_requests" \
|
|
171
|
+
-d "$(jq -n --arg s "$1" --arg t "$2" --arg ti "$3" --arg d "$4" --arg l "$5" \
|
|
172
|
+
'{source_branch:$s, target_branch:$t, title:$ti, description:$d, labels:$l, remove_source_branch:true}')" \
|
|
173
|
+
| jq -r '.web_url // empty'
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
# =============================================================================
|
|
177
|
+
# GitHub helpers (gh CLI, GH_TOKEN)
|
|
178
|
+
# =============================================================================
|
|
179
|
+
|
|
180
|
+
_ghi_ensure_label() {
|
|
181
|
+
# $1=name $2=color(hex, no #) $3=desc. Repo-level.
|
|
182
|
+
gh api -X POST "repos/$PIPE_REPO/labels" \
|
|
183
|
+
-f "name=$1" -f "color=${2#\#}" -f "description=$3" > /dev/null 2>&1 || true
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
_ghi_issues_by_label() {
|
|
187
|
+
# $1=label. Returns JSON array; filters out pull requests (they share the
|
|
188
|
+
# issues endpoint but carry a .pull_request field).
|
|
189
|
+
gh api --paginate "repos/$PIPE_REPO/issues?labels=$1&state=open&per_page=${PIPE_ISSUE_SCAN:-20}" \
|
|
190
|
+
| jq -s 'add // []' \
|
|
191
|
+
| jq '[.[] | select(has("pull_request") | not)
|
|
192
|
+
| {iid: .number, title: (.title // ""), description: (.body // ""),
|
|
193
|
+
labels: ((.labels // []) | map(.name) | join(",")),
|
|
194
|
+
milestone_id: (.milestone.number // null)}]'
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
_ghi_issues_closed_by_label() {
|
|
198
|
+
# $1=label. Closed issues still carrying a lifecycle label (janitor input).
|
|
199
|
+
# PRs share the issues endpoint; keep issues only, closed PR labels stay
|
|
200
|
+
# as history.
|
|
201
|
+
gh api "repos/$PIPE_REPO/issues?labels=$1&state=closed&per_page=${PIPE_ISSUE_SCAN:-20}" \
|
|
202
|
+
| jq '[.[] | select(has("pull_request") | not) | {iid: .number}]'
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
_ghi_issue_notes() {
|
|
206
|
+
# $1=iid. Comments newest first (GitHub returns them oldest first, so reverse).
|
|
207
|
+
# The caller (orchestrate.sh) caps the total volume and adds a visible
|
|
208
|
+
# truncation marker, so the newest comment survives when the discussion is long.
|
|
209
|
+
gh api --paginate "repos/$PIPE_REPO/issues/$1/comments?per_page=100" \
|
|
210
|
+
| jq -s -r 'add // [] | reverse | .[] | "**" + .user.login + ":** " + .body' || true
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
_ghi_issue_body() {
|
|
214
|
+
gh api "repos/$PIPE_REPO/issues/$1" --jq '.body // ""'
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
_ghi_issue_meta() {
|
|
218
|
+
# $1=iid. Returns {iid,title,description,labels}.
|
|
219
|
+
gh api "repos/$PIPE_REPO/issues/$1" \
|
|
220
|
+
| jq '{iid: .number, title: (.title // ""), description: (.body // ""), labels: ((.labels // []) | map(.name) | join(","))}'
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
_ghi_issue_state() {
|
|
224
|
+
# Prints "opened" or "closed" (normalised to the GitLab vocabulary).
|
|
225
|
+
local s; s=$(gh api "repos/$PIPE_REPO/issues/$1" --jq '.state // "open"' 2>/dev/null || echo open)
|
|
226
|
+
[ "$s" = "closed" ] && echo closed || echo opened
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
_ghi_link_blocked_by() {
|
|
230
|
+
# GitHub has no native blocking link. Persist the real issue number in the
|
|
231
|
+
# dependent issue body after all local decomposition keys have been mapped.
|
|
232
|
+
# The marker is visible and deliberately idempotent because the release poll
|
|
233
|
+
# parses the same "Blocked by #N" convention.
|
|
234
|
+
local blocked="$1" blocker="$2" body marker
|
|
235
|
+
body=$(_ghi_issue_body "$blocked")
|
|
236
|
+
marker="Blocked by #$blocker"
|
|
237
|
+
printf '%s\n' "$body" | grep -qiE "(^|[[:space:]])Blocked by #${blocker}([^0-9]|$)" && return 0
|
|
238
|
+
gh api -X PATCH "repos/$PIPE_REPO/issues/$blocked" \
|
|
239
|
+
-f "body=${body}${body:+$'\n\n'}$marker" > /dev/null
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
_ghi_create_issue() {
|
|
243
|
+
# $1=title $2=desc $3=labels(comma). Prints new number.
|
|
244
|
+
local args=() l
|
|
245
|
+
IFS=',' read -ra _labels <<< "$3"
|
|
246
|
+
for l in "${_labels[@]}"; do [ -n "$l" ] && args+=(-f "labels[]=$l"); done
|
|
247
|
+
gh api -X POST "repos/$PIPE_REPO/issues" -f "title=$1" -f "body=$2" "${args[@]}" \
|
|
248
|
+
--jq '.number // empty'
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
_ghi_set_issue_labels() {
|
|
252
|
+
# $1=iid $2=add(comma) $3=remove(comma)
|
|
253
|
+
local l
|
|
254
|
+
local IFS=','
|
|
255
|
+
for l in $2; do [ -n "$l" ] && gh api -X POST "repos/$PIPE_REPO/issues/$1/labels" -f "labels[]=$l" > /dev/null 2>&1 || true; done
|
|
256
|
+
for l in $3; do [ -n "$l" ] && gh api -X DELETE "repos/$PIPE_REPO/issues/$1/labels/$l" > /dev/null 2>&1 || true; done
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
_ghi_close_issue() {
|
|
260
|
+
gh api -X PATCH "repos/$PIPE_REPO/issues/$1" -f "state=closed" > /dev/null 2>&1 || true
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
_ghi_issue_comment() {
|
|
264
|
+
gh api -X POST "repos/$PIPE_REPO/issues/$1/comments" -f "body=$2" > /dev/null
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
_ghi_code_trigger() {
|
|
268
|
+
# $1=iid $2=branch. Dispatches the code workflow with explicit inputs. Uses
|
|
269
|
+
# the bot PAT (already in GH_TOKEN) so the coder's push and PR re-trigger
|
|
270
|
+
# downstream workflows. On failure the API response is logged so token
|
|
271
|
+
# permission problems (Actions write) are visible in the job log.
|
|
272
|
+
local out
|
|
273
|
+
out=$(gh api -X POST "repos/$PIPE_REPO/actions/workflows/${PIPE_CODE_WORKFLOW:-claude-issue-pipeline.yml}/dispatches" \
|
|
274
|
+
-f "ref=$PIPE_TARGET_BRANCH" \
|
|
275
|
+
-f "inputs[coder_issue]=$1" -f "inputs[coder_branch]=$2" 2>&1) || {
|
|
276
|
+
echo "[pipeline] code dispatch API error: $out" >&2
|
|
277
|
+
return 1
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
_ghi_pr_create() {
|
|
282
|
+
# $1=source $2=target $3=title $4=desc $5=labels(comma). Prints html_url.
|
|
283
|
+
# Created as a draft and flipped to ready only after the labels are on
|
|
284
|
+
# (finding P5): GitHub cannot attach labels at PR creation, so a non-draft
|
|
285
|
+
# PR emits an `opened` + `labeled` event pair and the review workflow runs
|
|
286
|
+
# twice. Draft PRs do not trigger the review job, which makes the single
|
|
287
|
+
# `ready_for_review` event the only trigger. Falls back to a plain PR when
|
|
288
|
+
# the plan does not support drafts on this repo (free plan, private repo).
|
|
289
|
+
local url num
|
|
290
|
+
url=$(gh api -X POST "repos/$PIPE_REPO/pulls" \
|
|
291
|
+
-f "head=$1" -f "base=$2" -f "title=$3" -f "body=$4" -F "draft=true" --jq '.html_url // empty' 2>/dev/null || true)
|
|
292
|
+
if [ -z "$url" ]; then
|
|
293
|
+
url=$(gh api -X POST "repos/$PIPE_REPO/pulls" \
|
|
294
|
+
-f "head=$1" -f "base=$2" -f "title=$3" -f "body=$4" --jq '.html_url // empty' 2>/dev/null || true)
|
|
295
|
+
fi
|
|
296
|
+
num=$(echo "$url" | grep -oE '[0-9]+$' || true)
|
|
297
|
+
if [ -n "$num" ] && [ -n "$5" ]; then
|
|
298
|
+
# Labels go through the issues API (a PR is an issue on GitHub).
|
|
299
|
+
_ghi_set_issue_labels "$num" "$5" ""
|
|
300
|
+
fi
|
|
301
|
+
# Flip to ready (a no-op when the fallback already created a non-draft PR).
|
|
302
|
+
{ [ -n "$num" ] && gh pr ready "$num" -R "$PIPE_REPO" > /dev/null 2>&1; } || true
|
|
303
|
+
echo "$url"
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
# =============================================================================
|
|
307
|
+
# Public dispatch
|
|
308
|
+
# =============================================================================
|
|
309
|
+
|
|
310
|
+
issue_ensure_label() { case "$PIPE_PLATFORM" in gitlab) _gli_ensure_label "$1" "$2" "$3" ;; github) _ghi_ensure_label "$1" "$2" "$3" ;; esac; }
|
|
311
|
+
issues_by_label() { case "$PIPE_PLATFORM" in gitlab) _gli_issues_by_label "$1" ;; github) _ghi_issues_by_label "$1" ;; esac; }
|
|
312
|
+
issues_closed_by_label() { case "$PIPE_PLATFORM" in gitlab) _gli_issues_closed_by_label "$1" ;; github) _ghi_issues_closed_by_label "$1" ;; esac; }
|
|
313
|
+
issue_notes() { case "$PIPE_PLATFORM" in gitlab) _gli_issue_notes "$1" ;; github) _ghi_issue_notes "$1" ;; esac; }
|
|
314
|
+
issue_body() { case "$PIPE_PLATFORM" in gitlab) _gli_issue_body "$1" ;; github) _ghi_issue_body "$1" ;; esac; }
|
|
315
|
+
issue_meta() { case "$PIPE_PLATFORM" in gitlab) _gli_issue_meta "$1" ;; github) _ghi_issue_meta "$1" ;; esac; }
|
|
316
|
+
issue_state() { case "$PIPE_PLATFORM" in gitlab) _gli_issue_state "$1" ;; github) _ghi_issue_state "$1" ;; esac; }
|
|
317
|
+
issue_create() { case "$PIPE_PLATFORM" in gitlab) _gli_create_issue "$1" "$2" "$3" ;; github) _ghi_create_issue "$1" "$2" "$3" ;; esac; }
|
|
318
|
+
issue_set_labels() { case "$PIPE_PLATFORM" in gitlab) _gli_set_issue_labels "$1" "$2" "$3" ;; github) _ghi_set_issue_labels "$1" "$2" "$3" ;; esac; }
|
|
319
|
+
issue_close() { case "$PIPE_PLATFORM" in gitlab) _gli_close_issue "$1" ;; github) _ghi_close_issue "$1" ;; esac; }
|
|
320
|
+
issue_comment() { case "$PIPE_PLATFORM" in gitlab) _gli_issue_comment "$1" "$2" ;; github) _ghi_issue_comment "$1" "$2" ;; esac; }
|
|
321
|
+
code_trigger() { case "$PIPE_PLATFORM" in gitlab) _gli_code_trigger "$1" "$2" ;; github) _ghi_code_trigger "$1" "$2" ;; esac; }
|
|
322
|
+
mr_create() { case "$PIPE_PLATFORM" in gitlab) _gli_mr_create "$1" "$2" "$3" "$4" "$5" ;; github) _ghi_pr_create "$1" "$2" "$3" "$4" "$5" ;; esac; }
|
|
323
|
+
issue_link_blocked_by() { case "$PIPE_PLATFORM" in gitlab) _gli_link_blocked_by "$1" "$2" ;; github) _ghi_link_blocked_by "$1" "$2" ;; esac; }
|
|
324
|
+
|
|
325
|
+
# Count how many issues that block $1 are still open. GitLab uses native links,
|
|
326
|
+
# GitHub parses "Blocked by #N" from the issue body (decision D1, text convention).
|
|
327
|
+
issue_blockers_open_count() {
|
|
328
|
+
case "$PIPE_PLATFORM" in
|
|
329
|
+
gitlab) _gli_blockers_open_count "$1" ;;
|
|
330
|
+
github)
|
|
331
|
+
local body refs n open=0
|
|
332
|
+
body=$(_ghi_issue_body "$1")
|
|
333
|
+
# Match "Blocked by #12" / "blocked by #12, #13" case-insensitively.
|
|
334
|
+
refs=$(printf '%s' "$body" | grep -ioE 'blocked by[^\n]*' | grep -oE '#[0-9]+' | grep -oE '[0-9]+' | sort -u || true)
|
|
335
|
+
for n in $refs; do
|
|
336
|
+
[ "$(_ghi_issue_state "$n")" = "opened" ] && open=$((open + 1))
|
|
337
|
+
done
|
|
338
|
+
echo "$open"
|
|
339
|
+
;;
|
|
340
|
+
esac
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
# Prints "<total> <open>" child count for a decomposed parent.
|
|
344
|
+
# GitLab reads native links and falls back to the machine marker. GitHub parses
|
|
345
|
+
# the machine marker from the decomposition comment. The marker lives in a
|
|
346
|
+
# comment, not in the issue body, so both implementations query comments.
|
|
347
|
+
issue_children_state() {
|
|
348
|
+
case "$PIPE_PLATFORM" in
|
|
349
|
+
gitlab) _gli_children_state "$1" ;;
|
|
350
|
+
github)
|
|
351
|
+
local kids n total=0 open=0
|
|
352
|
+
kids=$(gh api --paginate "repos/$PIPE_REPO/issues/$1/comments?per_page=100" \
|
|
353
|
+
--jq '.[].body // empty' 2>/dev/null \
|
|
354
|
+
| grep -oE '<!-- pipe-children:[^>]*-->' \
|
|
355
|
+
| tail -1 | grep -oE '#[0-9]+' | grep -oE '[0-9]+' | sort -u || true)
|
|
356
|
+
for n in $kids; do
|
|
357
|
+
total=$((total + 1))
|
|
358
|
+
[ "$(_ghi_issue_state "$n")" = "opened" ] && open=$((open + 1))
|
|
359
|
+
done
|
|
360
|
+
echo "$total $open"
|
|
361
|
+
;;
|
|
362
|
+
esac
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
# =============================================================================
|
|
366
|
+
# Decomposition validation (mechanical gate, runner side)
|
|
367
|
+
# =============================================================================
|
|
368
|
+
# Deterministic re-check of the decomposition JSON the triage-issue skill
|
|
369
|
+
# produced, before the runner mutates the platform (creates sub-issues). The
|
|
370
|
+
# skill is instructed to self-validate; this is the runner's defensive gate.
|
|
371
|
+
# Written in jq (already a hard dependency everywhere in this pipeline), so it
|
|
372
|
+
# needs no extra runtime such as python3.
|
|
373
|
+
#
|
|
374
|
+
# IMPORTANT: the 8 required section headers and the placeholder tokens below MUST
|
|
375
|
+
# stay in sync with the spec template shipped in
|
|
376
|
+
# templates/spec-issue.template.md. If you rename a section
|
|
377
|
+
# header or change a placeholder token in the template, update both lists here.
|
|
378
|
+
DECOMP_REJECT_REASON=""
|
|
379
|
+
pipe_validate_decomposition() {
|
|
380
|
+
# Prints the first problem to stdout on failure (empty on success). Sets
|
|
381
|
+
# DECOMP_REJECT_REASON and returns 1 when invalid, 0 when valid.
|
|
382
|
+
local json="$1"
|
|
383
|
+
DECOMP_REJECT_REASON=""
|
|
384
|
+
local reason
|
|
385
|
+
reason=$(printf '%s' "$json" | jq -r '
|
|
386
|
+
def required: ["## 1. Outcome","## 2. Context","## 3. Acceptance Criteria",
|
|
387
|
+
"## 4. Scope Envelope","## 5. Existing Code Anchors","## 6. Risk Flags",
|
|
388
|
+
"## 7. Test Plan","## 8. Out of Scope"];
|
|
389
|
+
def placeholders: ["<one sentence","<why now","<at least one","<what will be tested","TODO"];
|
|
390
|
+
|
|
391
|
+
(.confidence // "") as $conf
|
|
392
|
+
| (.sub_issues // []) as $subs
|
|
393
|
+
| [ $subs[].key ] as $keys
|
|
394
|
+
| if ($conf != "high" and $conf != "low") then "confidence must be high or low, got: \($conf)"
|
|
395
|
+
elif (($subs | length) < 2 or ($subs | length) > 5) then "sub_issues count must be 2-5, got: \($subs | length)"
|
|
396
|
+
elif ($keys | any(. == null or . == "")) then "every sub_issue needs a non-empty key"
|
|
397
|
+
else
|
|
398
|
+
( [ $subs[] | (.depends_on // [])[] as $d | select(($keys | index($d)) == null) | $d ] | first ) as $unknown
|
|
399
|
+
| if $unknown != null then "depends_on references unknown key: \($unknown)"
|
|
400
|
+
else
|
|
401
|
+
( [ $subs[]
|
|
402
|
+
| . as $s
|
|
403
|
+
| (($s.spec_markdown // "")) as $md
|
|
404
|
+
| ( required | map(. as $r | select(($md | contains($r)) | not)) ) as $miss
|
|
405
|
+
| if ($miss | length) > 0 then "sub_issue \($s.key) missing sections: \($miss | join(", "))"
|
|
406
|
+
elif (placeholders | any(. as $p | $md | contains($p))) then "sub_issue \($s.key) contains unfilled placeholder text"
|
|
407
|
+
else empty end ] | first ) as $secfail
|
|
408
|
+
| if $secfail != null then $secfail
|
|
409
|
+
else
|
|
410
|
+
# Cycle check: iteratively drop nodes whose deps are all removed.
|
|
411
|
+
( { rem: [ $subs[] | {key: .key, deps: (.depends_on // [])} ] }
|
|
412
|
+
| reduce range(0; ($subs | length)) as $_ (.;
|
|
413
|
+
(.rem | map(.key)) as $present
|
|
414
|
+
| (.rem | map(. as $e
|
|
415
|
+
| select(([ $e.deps[] as $d | select(($present | index($d)) != null) ] | length) == 0))) as $ready
|
|
416
|
+
| ($ready | map(.key)) as $rk
|
|
417
|
+
| if ($ready | length) == 0 then .
|
|
418
|
+
else { rem: (.rem | map(. as $e | select(($rk | index($e.key)) == null))) } end)
|
|
419
|
+
| if (.rem | length) > 0 then "depends_on graph contains a cycle" else "" end )
|
|
420
|
+
end
|
|
421
|
+
end
|
|
422
|
+
end
|
|
423
|
+
' 2>/dev/null || echo "invalid JSON")
|
|
424
|
+
if [ -n "$reason" ]; then
|
|
425
|
+
# shellcheck disable=SC2034 # read by orchestrate.sh once this returns 1
|
|
426
|
+
DECOMP_REJECT_REASON="$reason"
|
|
427
|
+
return 1
|
|
428
|
+
fi
|
|
429
|
+
return 0
|
|
430
|
+
}
|