@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
@@ -0,0 +1,201 @@
1
+ #!/bin/bash
2
+ # Runner for the opt-in, issue-driven half of E2E test generation. The runner
3
+ # owns forge mutations and git; the skill may only write a new test file.
4
+ set -euo pipefail
5
+
6
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
7
+ # shellcheck source=lib/pipeline-common.sh
8
+ source "$SCRIPT_DIR/lib/pipeline-common.sh"
9
+ # shellcheck source=lib/issue-loop.sh
10
+ source "$SCRIPT_DIR/lib/issue-loop.sh"
11
+ pipe_defaults
12
+
13
+ E2E_ENV="$PIPE_CONTEXT_DIR/e2e-gen.env"
14
+ ISSUES_FILE="$PIPE_CONTEXT_DIR/e2e-gen-issues.json"
15
+ MR_BODY="$PIPE_CONTEXT_DIR/e2e-gen-mr.md"
16
+ GENERATED=0
17
+ CREDIT_ABORT=0
18
+ MR_CREATED=0
19
+
20
+ write_marker() {
21
+ printf 'E2E_GENERATED_COUNT=%s\nE2E_CREDIT_ABORT=%s\nE2E_MR_CREATED=%s\n' \
22
+ "$GENERATED" "$CREDIT_ABORT" "$MR_CREATED" > "$E2E_ENV"
23
+ }
24
+
25
+ # Persist an explicit zero marker even for an intentionally inactive schedule.
26
+ write_marker
27
+ if [ "$PIPE_E2E_TEST_GEN" != "1" ]; then
28
+ pipe_log "E2E test generation is disabled"
29
+ exit 0
30
+ fi
31
+
32
+ case "$PIPE_E2E_TEST_DIR" in
33
+ ''|/*|..|../*|*/..|*/../*)
34
+ pipe_log "Invalid project-relative E2E test directory: $PIPE_E2E_TEST_DIR"
35
+ exit 0
36
+ ;;
37
+ esac
38
+
39
+ # E2E verification is intentionally separate from PIPE_VERIFY_CMD: projects
40
+ # configure a command that accepts one generated E2E spec as its final argument.
41
+ if [ -z "$PIPE_E2E_VERIFY_CMD" ]; then
42
+ pipe_log "PIPE_E2E_VERIFY_CMD is required to verify generated E2E tests; leaving issues unchanged"
43
+ exit 0
44
+ fi
45
+ if [ -z "$PIPE_E2E_BASE_URL" ]; then
46
+ pipe_log "PIPE_E2E_BASE_URL is required for generated E2E tests; leaving issues unchanged"
47
+ exit 0
48
+ fi
49
+
50
+ if ! issues_by_labels "$PIPE_LABEL_TESTREADY" "$PIPE_LABEL_E2E_SCOPE" opened "$PIPE_ISSUE_SCAN" > "$ISSUES_FILE"; then
51
+ pipe_log "Could not list test-ready issues"
52
+ exit 1
53
+ fi
54
+ ISSUE_COUNT=$(jq 'length' "$ISSUES_FILE")
55
+ if [ "$ISSUE_COUNT" = "0" ]; then
56
+ pipe_log "No issues need E2E tests"
57
+ exit 0
58
+ fi
59
+
60
+ BRANCH="e2e-test-gen-$(date -u +%Y%m%d)"
61
+ pipe_git_identity
62
+ git fetch origin "$PIPE_TARGET_BRANCH" >/dev/null 2>&1 || {
63
+ pipe_log "Could not fetch target branch $PIPE_TARGET_BRANCH"
64
+ exit 1
65
+ }
66
+ # A branch of this daily name may contain a human amendment. Never reuse or
67
+ # force-push it; a later scheduled run can safely make a fresh daily branch.
68
+ git fetch origin "$BRANCH" >/dev/null 2>&1 || true
69
+ if git show-ref --verify --quiet "refs/remotes/origin/$BRANCH"; then
70
+ pipe_log "Branch $BRANCH already exists; refusing to overwrite possible human work"
71
+ exit 0
72
+ fi
73
+ git checkout -b "$BRANCH" "origin/$PIPE_TARGET_BRANCH"
74
+
75
+ mark_stuck() {
76
+ issue_set_labels "$1" "$PIPE_LABEL_STUCK" "$PIPE_LABEL_TESTREADY" || true
77
+ }
78
+
79
+ credit_abort() {
80
+ local iid="$1" body="$PIPE_CONTEXT_DIR/e2e-gen-credit-$1.md"
81
+ pipe_failure_notice_files "E2E test generation" \
82
+ "wait for credits to be restored, then let a later schedule retry this issue" \
83
+ "$PIPE_AGENT_STDERR" > "$body"
84
+ issue_comment_file "$iid" "$body" || pipe_log "Could not post credit-exhaustion notice for issue #$iid"
85
+ CREDIT_ABORT=1
86
+ write_marker
87
+ }
88
+
89
+ while IFS= read -r ISSUE; do
90
+ IID=$(jq -r '.iid' <<< "$ISSUE")
91
+ TITLE_FILE="$PIPE_CONTEXT_DIR/e2e-gen-title-$IID.txt"
92
+ DESC_FILE="$PIPE_CONTEXT_DIR/e2e-gen-description-$IID.txt"
93
+ CONTEXT_FILE="$PIPE_CONTEXT_DIR/e2e-gen-issue-$IID.md"
94
+ BEFORE_FILE="$PIPE_CONTEXT_DIR/e2e-gen-before-$IID.txt"
95
+ NEW_FILE="$PIPE_CONTEXT_DIR/e2e-gen-new-$IID.txt"
96
+
97
+ jq -r '(.title // "") | .[0:500]' <<< "$ISSUE" > "$TITLE_FILE"
98
+ jq -r '(.description // "(no description)") | .[0:3000]' <<< "$ISSUE" > "$DESC_FILE"
99
+ {
100
+ printf '# E2E test generation context\n\n'
101
+ printf 'Mode: generate\n'
102
+ printf 'Issue IID: %s\n' "$IID"
103
+ printf 'Issue Title File: %s\n' "$TITLE_FILE"
104
+ printf 'Issue Description File: %s\n' "$DESC_FILE"
105
+ printf 'Test Directory: %s\n' "$PIPE_E2E_TEST_DIR"
106
+ printf 'Base URL: %s\n' "$PIPE_E2E_BASE_URL"
107
+ printf 'Project Configuration: %s\n' "$PIPE_CONFIG_PATH"
108
+ printf 'Verification Command: %s\n' "$PIPE_E2E_VERIFY_CMD"
109
+ } > "$CONTEXT_FILE"
110
+
111
+ git ls-files --others --exclude-standard -- "$PIPE_E2E_TEST_DIR" | sort > "$BEFORE_FILE"
112
+ : > "$PIPE_AGENT_STDERR"
113
+ pipe_run_agent e2e-test-writer "/e2e-test-gen" "Agent,Read,Write,Edit,Glob,Grep" "$PIPE_MODEL_CODE" < /dev/null
114
+ # Discard all tracked edits: accepted work is strictly newly-created test files.
115
+ git reset --hard HEAD >/dev/null
116
+ if pipe_is_credit_exhaustion_files "$PIPE_AGENT_STDERR"; then
117
+ git ls-files --others --exclude-standard -- "$PIPE_E2E_TEST_DIR" | sort > "$PIPE_CONTEXT_DIR/e2e-gen-credit-after-$IID.txt"
118
+ comm -13 "$BEFORE_FILE" "$PIPE_CONTEXT_DIR/e2e-gen-credit-after-$IID.txt" | while IFS= read -r rejected; do
119
+ rm -f -- "$rejected"
120
+ done
121
+ credit_abort "$IID"
122
+ break
123
+ fi
124
+
125
+ git ls-files --others --exclude-standard -- "$PIPE_E2E_TEST_DIR" | sort > "$PIPE_CONTEXT_DIR/e2e-gen-after-$IID.txt"
126
+ comm -13 "$BEFORE_FILE" "$PIPE_CONTEXT_DIR/e2e-gen-after-$IID.txt" > "$NEW_FILE"
127
+ if [ ! -s "$NEW_FILE" ]; then
128
+ pipe_log "No new E2E test file generated for issue #$IID"
129
+ mark_stuck "$IID"
130
+ continue
131
+ fi
132
+
133
+ TEST_PASSED=true
134
+ while IFS= read -r SPEC; do
135
+ [ -n "$SPEC" ] || continue
136
+ if ! bash -c "$PIPE_E2E_VERIFY_CMD \"\$1\"" e2e-test-gen "$SPEC"; then
137
+ pipe_log "Generated E2E test $SPEC failed verification; attempting one repair"
138
+ {
139
+ printf '# E2E test generation context\n\n'
140
+ printf 'Mode: repair\n'
141
+ printf 'Generated Test: %s\n' "$SPEC"
142
+ printf 'Test Directory: %s\n' "$PIPE_E2E_TEST_DIR"
143
+ printf 'Base URL: %s\n' "$PIPE_E2E_BASE_URL"
144
+ printf 'Project Configuration: %s\n' "$PIPE_CONFIG_PATH"
145
+ printf 'Verification Command: %s\n' "$PIPE_E2E_VERIFY_CMD"
146
+ } > "$CONTEXT_FILE"
147
+ : > "$PIPE_AGENT_STDERR"
148
+ pipe_run_agent e2e-test-writer "/e2e-test-gen" "Agent,Read,Write,Edit,Glob,Grep" "$PIPE_MODEL_CODE" < /dev/null
149
+ git reset --hard HEAD >/dev/null
150
+ if pipe_is_credit_exhaustion_files "$PIPE_AGENT_STDERR"; then
151
+ while IFS= read -r rejected; do rm -f -- "$rejected"; done < "$NEW_FILE"
152
+ credit_abort "$IID"
153
+ break 2
154
+ fi
155
+ if ! bash -c "$PIPE_E2E_VERIFY_CMD \"\$1\"" e2e-test-gen "$SPEC"; then
156
+ TEST_PASSED=false
157
+ break
158
+ fi
159
+ fi
160
+ done < "$NEW_FILE"
161
+
162
+ if [ "$TEST_PASSED" = true ]; then
163
+ while IFS= read -r SPEC; do [ -n "$SPEC" ] && git add -- "$SPEC"; done < "$NEW_FILE"
164
+ if git diff --cached --quiet; then
165
+ pipe_log "Generated files for issue #$IID were not stageable"
166
+ mark_stuck "$IID"
167
+ continue
168
+ fi
169
+ git commit -m "test: add E2E coverage for issue #$IID"
170
+ GENERATED=$((GENERATED + 1))
171
+ issue_set_labels "$IID" "" "$PIPE_LABEL_TESTREADY" || true
172
+ else
173
+ while IFS= read -r rejected; do rm -f -- "$rejected"; done < "$NEW_FILE"
174
+ mark_stuck "$IID"
175
+ fi
176
+ write_marker
177
+ done < <(jq -c '.[]' "$ISSUES_FILE")
178
+
179
+ if [ "$GENERATED" = "0" ]; then
180
+ write_marker
181
+ exit 0
182
+ fi
183
+
184
+ # This is a new branch by construction. A concurrent branch creation makes this
185
+ # ordinary push fail rather than overwriting the other author's ref.
186
+ if ! git push origin "HEAD:refs/heads/$BRANCH"; then
187
+ pipe_log "Could not push $BRANCH safely; no MR/PR was created"
188
+ write_marker
189
+ exit 1
190
+ fi
191
+ {
192
+ printf '# Automated E2E test generation\n\n'
193
+ printf 'Generated and verified E2E tests for %s issue(s).\n' "$GENERATED"
194
+ } > "$MR_BODY"
195
+ MR_URL=$(mr_create_file "$BRANCH" "$PIPE_TARGET_BRANCH" "test: generated E2E coverage" "$MR_BODY" "" || true)
196
+ if [ -n "$MR_URL" ]; then
197
+ MR_CREATED=1
198
+ else
199
+ pipe_log "Push succeeded but MR/PR creation failed"
200
+ fi
201
+ write_marker
@@ -0,0 +1,104 @@
1
+ # shellcheck shell=bash
2
+ # Source-only library. Failure notices distinguish two classes: model credit or
3
+ # quota exhaustion, which cannot succeed until the balance is restored, and all
4
+ # other agent failures, which may be transient or require manual follow-up.
5
+ #
6
+ # Both supported harness wordings are covered here: one reports that a credit
7
+ # balance is too low, while the other reports that a request requires more
8
+ # credits. Notices intentionally keep that implementation detail generic.
9
+
10
+ set -u
11
+
12
+ PIPE_CREDIT_EXHAUSTION_PATTERN='credit balance is too low|insufficient credits?|out of credits?|credits? exhausted|quota exceeded|requires more credits|payment required|402'
13
+
14
+ pipe_is_credit_exhaustion() {
15
+ local text
16
+ for text in "$@"; do
17
+ [ -n "$text" ] && printf '%s\n' "$text" | grep -qiE "$PIPE_CREDIT_EXHAUSTION_PATTERN" && return 0
18
+ done
19
+ return 1
20
+ }
21
+
22
+ pipe_failure_notice() {
23
+ local task="$1" manual_action="$2"
24
+ shift 2
25
+
26
+ if pipe_is_credit_exhaustion "$@"; then
27
+ printf '**Automated %s could not run because the model credit balance is exhausted.**\n\n' "$task"
28
+ printf 'This is an automation outage, not a finding about your changes.\n\n'
29
+ printf 'Retrying the pipeline will not help until the balance is topped up.\n\n'
30
+ printf 'Please %s. Automation resumes on later pushes.\n' "$manual_action"
31
+ else
32
+ printf '**Automated %s failed to complete.**\n\n' "$task"
33
+ printf 'No result was produced. Check the CI job log for details.\n\n'
34
+ printf 'The failure may be transient, so retrying the pipeline is appropriate. If it continues, please %s.\n' "$manual_action"
35
+ fi
36
+ }
37
+
38
+ pipe_failure_log_notice() {
39
+ local task="$1" manual_action="$2"
40
+ shift 2
41
+
42
+ if pipe_is_credit_exhaustion "$@"; then
43
+ printf 'CREDIT EXHAUSTED: Automated %s could not run because the model credit balance is exhausted.\n' "$task"
44
+ printf 'Retrying will not help until the balance is topped up. Please %s.\n' "$manual_action"
45
+ else
46
+ printf 'FAILED: Automated %s failed to complete.\n' "$task"
47
+ printf 'Check stderr, retry if the failure is transient, and if it persists please %s.\n' "$manual_action"
48
+ fi
49
+ }
50
+
51
+ pipe_is_credit_exhaustion_files() {
52
+ local file
53
+ for file in "$@"; do
54
+ [ -s "$file" ] && grep -qiE "$PIPE_CREDIT_EXHAUSTION_PATTERN" "$file" && return 0
55
+ done
56
+ return 1
57
+ }
58
+
59
+ pipe_failure_notice_files() {
60
+ local task="$1" manual_action="$2"
61
+ shift 2
62
+
63
+ if pipe_is_credit_exhaustion_files "$@"; then
64
+ printf '**Automated %s could not run because the model credit balance is exhausted.**\n\n' "$task"
65
+ printf 'This is an automation outage, not a finding about your changes.\n\n'
66
+ printf 'Retrying the pipeline will not help until the balance is topped up.\n\n'
67
+ printf 'Please %s. Automation resumes on later pushes.\n' "$manual_action"
68
+ else
69
+ printf '**Automated %s failed to complete.**\n\n' "$task"
70
+ printf 'No result was produced. Check the CI job log for details.\n\n'
71
+ printf 'The failure may be transient, so retrying the pipeline is appropriate. If it continues, please %s.\n' "$manual_action"
72
+ fi
73
+ }
74
+
75
+ pipe_failure_log_notice_files() {
76
+ local task="$1" manual_action="$2"
77
+ shift 2
78
+
79
+ if pipe_is_credit_exhaustion_files "$@"; then
80
+ printf 'CREDIT EXHAUSTED: Automated %s could not run because the model credit balance is exhausted.\n' "$task"
81
+ printf 'Retrying will not help until the balance is topped up. Please %s.\n' "$manual_action"
82
+ else
83
+ printf 'FAILED: Automated %s failed to complete.\n' "$task"
84
+ printf 'Check stderr, retry if the failure is transient, and if it persists please %s.\n' "$manual_action"
85
+ fi
86
+ }
87
+
88
+ pipe_agent_failed() {
89
+ PIPE_AGENT_STDERR_TEXT=''
90
+ export PIPE_AGENT_STDERR_TEXT
91
+
92
+ if [ -f "${PIPE_AGENT_STDERR:-}" ]; then
93
+ PIPE_AGENT_STDERR_TEXT=$(cat "$PIPE_AGENT_STDERR" 2>/dev/null) || PIPE_AGENT_STDERR_TEXT=''
94
+ export PIPE_AGENT_STDERR_TEXT
95
+ fi
96
+
97
+ if [ -z "${1:-}" ]; then
98
+ [ -n "$PIPE_AGENT_STDERR_TEXT" ]
99
+ elif [ -n "$PIPE_AGENT_STDERR_TEXT" ]; then
100
+ return 1
101
+ else
102
+ return 0
103
+ fi
104
+ }
@@ -52,6 +52,27 @@ _gli_issues_by_label() {
52
52
  milestone_id: (.milestone.id // null)}]'
53
53
  }
54
54
 
55
+ # $1=primary label $2=optional secondary label $3=state $4=limit. Unlike the
56
+ # older single-label helpers this preserves fetch failure so callers never
57
+ # mistake a forge outage for an empty work queue.
58
+ _gli_issues_by_labels() {
59
+ local primary="$1" secondary="$2" state="$3" limit="$4" labels response state_param=""
60
+ [[ "$limit" =~ ^[1-9][0-9]*$ ]] || return 1
61
+ case "$state" in
62
+ all|'') ;;
63
+ opened|closed) state_param="&state=$state" ;;
64
+ *) return 1 ;;
65
+ esac
66
+ labels=$(printf '%s' "$primary${secondary:+,$secondary}" | jq -sRr @uri)
67
+ response=$(curl -s --fail -H "PRIVATE-TOKEN: $PIPE_TOKEN" \
68
+ "$(_gli_api)/issues?labels=$labels${state_param}&per_page=$limit") || return 1
69
+ jq --arg primary "$primary" --arg secondary "$secondary" --argjson limit "$limit" \
70
+ '[.[] | select((.labels // []) | index($primary))
71
+ | select($secondary == "" or ((.labels // []) | index($secondary)))
72
+ | {iid, title: (.title // ""), description: (.description // ""),
73
+ labels: ((.labels // []) | join(","))}] | .[0:$limit]' <<< "$response"
74
+ }
75
+
55
76
  _gli_issues_closed_by_label() {
56
77
  # $1=label. Closed issues still carrying a lifecycle label (janitor input).
57
78
  curl -s -H "PRIVATE-TOKEN: $PIPE_TOKEN" \
@@ -59,6 +80,21 @@ _gli_issues_closed_by_label() {
59
80
  | jq '[.[] | {iid}]'
60
81
  }
61
82
 
83
+ _gli_issues_by_label_created() {
84
+ # $1=label $2=state("all"|"opened"|"closed") $3=limit. Companion to
85
+ # _gli_issues_by_label that preserves created_at (dropped there); the
86
+ # codebase-audit job needs it to find the most recently filed audit issue.
87
+ # Captured into a variable (not piped straight into jq) so a fetch failure
88
+ # propagates via `return 1` instead of being swallowed as an empty result
89
+ # indistinguishable from "no audit issue exists yet" (review round 2 finding).
90
+ local state_param="" response
91
+ [ -n "$2" ] && [ "$2" != "all" ] && state_param="&state=$2"
92
+ response=$(curl -s --fail -H "PRIVATE-TOKEN: $PIPE_TOKEN" \
93
+ "$(_gli_api)/issues?labels=$1${state_param}&order_by=created_at&sort=desc&per_page=$3") || return 1
94
+ jq '[.[] | {iid, title: (.title // ""), description: (.description // ""),
95
+ labels: ((.labels // []) | join(",")), created_at: (.created_at // "")}]' <<< "$response"
96
+ }
97
+
62
98
  _gli_issue_notes() {
63
99
  # $1=iid. Non-system notes, newest first. The caller (orchestrate.sh) caps
64
100
  # the total volume and adds a visible truncation marker, so the newest and
@@ -113,12 +149,15 @@ _gli_children_state() {
113
149
  echo "$total $open"
114
150
  }
115
151
 
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'
152
+ _gli_create_issue_file() {
153
+ # $1=title $2=description file $3=labels(comma). Prints new iid or empty.
154
+ local payload result
155
+ payload=$(mktemp "$PIPE_CONTEXT_DIR/gitlab-issue.XXXXXX.json")
156
+ jq -n --arg t "$1" --rawfile b "$2" --arg l "$3" '{title:$t,description:$b,labels:$l}' > "$payload"
157
+ result=$(curl -s -X POST -H "PRIVATE-TOKEN: $PIPE_TOKEN" -H "Content-Type: application/json" \
158
+ "$(_gli_api)/issues" --data-binary "@$payload" | jq -r '.iid // empty')
159
+ rm -f "$payload"
160
+ printf '%s\n' "$result"
122
161
  }
123
162
 
124
163
  _gli_link_blocked_by() {
@@ -140,9 +179,10 @@ _gli_close_issue() {
140
179
  "$(_gli_api)/issues/$1" -d '{"state_event":"close"}' > /dev/null || true
141
180
  }
142
181
 
143
- _gli_issue_comment() {
182
+ _gli_issue_comment_file() {
183
+ # $1=iid $2=comment file.
144
184
  curl -s -X POST -H "PRIVATE-TOKEN: $PIPE_TOKEN" "$(_gli_api)/issues/$1/notes" \
145
- --data-urlencode "body=$2" > /dev/null
185
+ --data-urlencode "body@$2" > /dev/null
146
186
  }
147
187
 
148
188
  _gli_code_trigger() {
@@ -164,13 +204,16 @@ _gli_code_trigger() {
164
204
  }
165
205
  }
166
206
 
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'
207
+ _gli_mr_create_file() {
208
+ # $1=source $2=target $3=title $4=description file $5=labels(comma). Prints web_url.
209
+ local payload result
210
+ payload=$(mktemp "$PIPE_CONTEXT_DIR/gitlab-mr.XXXXXX.json")
211
+ jq -n --arg s "$1" --arg t "$2" --arg ti "$3" --rawfile d "$4" --arg l "$5" \
212
+ '{source_branch:$s, target_branch:$t, title:$ti, description:$d, labels:$l, remove_source_branch:true}' > "$payload"
213
+ result=$(curl --fail-with-body -s -X POST -H "PRIVATE-TOKEN: $PIPE_TOKEN" -H "Content-Type: application/json" \
214
+ "$(_gli_api)/merge_requests" --data-binary "@$payload" | jq -r '.web_url // empty')
215
+ rm -f "$payload"
216
+ printf '%s\n' "$result"
174
217
  }
175
218
 
176
219
  # =============================================================================
@@ -194,6 +237,28 @@ _ghi_issues_by_label() {
194
237
  milestone_id: (.milestone.number // null)}]'
195
238
  }
196
239
 
240
+ # $1=primary label $2=optional secondary label $3=state $4=limit. GitHub's
241
+ # shared issues endpoint returns pull requests too, so filter those before the
242
+ # requested slice; otherwise a recent PR can starve an issue queue.
243
+ _ghi_issues_by_labels() {
244
+ local primary="$1" secondary="$2" state="$3" limit="$4" gh_state response
245
+ [[ "$limit" =~ ^[1-9][0-9]*$ ]] || return 1
246
+ case "$state" in
247
+ opened|'') gh_state=open ;;
248
+ closed) gh_state=closed ;;
249
+ all) gh_state=all ;;
250
+ *) return 1 ;;
251
+ esac
252
+ response=$(gh api --paginate "repos/$PIPE_REPO/issues?labels=$primary&state=$gh_state&per_page=100") || return 1
253
+ jq -s 'add // []' <<< "$response" \
254
+ | jq --arg primary "$primary" --arg secondary "$secondary" --argjson limit "$limit" \
255
+ '[.[] | select(has("pull_request") | not)
256
+ | select(((.labels // []) | map(.name) | index($primary)))
257
+ | select($secondary == "" or ((.labels // []) | map(.name) | index($secondary)))
258
+ | {iid: .number, title: (.title // ""), description: (.body // ""),
259
+ labels: ((.labels // []) | map(.name) | join(","))}] | .[0:$limit]'
260
+ }
261
+
197
262
  _ghi_issues_closed_by_label() {
198
263
  # $1=label. Closed issues still carrying a lifecycle label (janitor input).
199
264
  # PRs share the issues endpoint; keep issues only, closed PR labels stay
@@ -202,6 +267,29 @@ _ghi_issues_closed_by_label() {
202
267
  | jq '[.[] | select(has("pull_request") | not) | {iid: .number}]'
203
268
  }
204
269
 
270
+ _ghi_issues_by_label_created() {
271
+ # $1=label $2=state("all"|"opened"|"closed") $3=limit. GitHub's shared
272
+ # issues/PRs endpoint has no server-side "issues only" filter, so applying
273
+ # $3 as per_page before the pull_request filter can empty the result: once
274
+ # this job's own auto-fix PR carries the same label, it is newer than the
275
+ # audit issues and a limit of 1 would fetch only that PR, filter it away,
276
+ # and return nothing (review round 1 finding). Fetch a full page (GitHub's
277
+ # per-page max) and slice to the requested limit after the PR filter.
278
+ # Captured into a variable (not piped straight into jq) so a fetch failure
279
+ # propagates via `return 1` instead of being swallowed as an empty result
280
+ # indistinguishable from "no audit issue exists yet" (review round 2 finding).
281
+ local gh_state response
282
+ case "$2" in
283
+ closed) gh_state=closed ;;
284
+ all) gh_state=all ;;
285
+ *) gh_state=open ;;
286
+ esac
287
+ response=$(gh api "repos/$PIPE_REPO/issues?labels=$1&state=$gh_state&sort=created&direction=desc&per_page=100") || return 1
288
+ jq --argjson limit "$3" '[.[] | select(has("pull_request") | not)
289
+ | {iid: .number, title: (.title // ""), description: (.body // ""),
290
+ labels: ((.labels // []) | map(.name) | join(",")), created_at: (.created_at // "")}] | .[0:$limit]' <<< "$response"
291
+ }
292
+
205
293
  _ghi_issue_notes() {
206
294
  # $1=iid. Comments newest first (GitHub returns them oldest first, so reverse).
207
295
  # The caller (orchestrate.sh) caps the total volume and adds a visible
@@ -231,21 +319,32 @@ _ghi_link_blocked_by() {
231
319
  # dependent issue body after all local decomposition keys have been mapped.
232
320
  # The marker is visible and deliberately idempotent because the release poll
233
321
  # parses the same "Blocked by #N" convention.
234
- local blocked="$1" blocker="$2" body marker
235
- body=$(_ghi_issue_body "$blocked")
322
+ local blocked="$1" blocker="$2" body_file payload marker
323
+ body_file=$(mktemp "$PIPE_CONTEXT_DIR/github-issue-body.XXXXXX")
324
+ _ghi_issue_body "$blocked" > "$body_file"
236
325
  marker="Blocked by #$blocker"
237
- printf '%s\n' "$body" | grep -qiE "(^|[[:space:]])Blocked by #${blocker}([^0-9]|$)" && return 0
326
+ grep -qiE "(^|[[:space:]])Blocked by #${blocker}([^0-9]|$)" "$body_file" && {
327
+ rm -f "$body_file"
328
+ return 0
329
+ }
330
+ payload=$(mktemp "$PIPE_CONTEXT_DIR/github-issue.XXXXXX.json")
331
+ jq -n --rawfile body "$body_file" --arg marker "$marker" \
332
+ '{body: $body + (if $body == "" then "" else "\n\n" end) + $marker}' > "$payload"
238
333
  gh api -X PATCH "repos/$PIPE_REPO/issues/$blocked" \
239
- -f "body=${body}${body:+$'\n\n'}$marker" > /dev/null
334
+ -H "Content-Type: application/json" --input "$payload" > /dev/null
335
+ rm -f "$body_file" "$payload"
240
336
  }
241
337
 
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'
338
+ _ghi_create_issue_file() {
339
+ # $1=title $2=description file $3=labels(comma). Prints new number.
340
+ local payload result
341
+ payload=$(mktemp "$PIPE_CONTEXT_DIR/github-issue.XXXXXX.json")
342
+ jq -n --arg t "$1" --rawfile b "$2" --arg l "$3" \
343
+ '{title:$t, body:$b, labels: ($l | split(",") | map(select(length > 0)))}' > "$payload"
344
+ result=$(gh api -X POST "repos/$PIPE_REPO/issues" \
345
+ -H "Content-Type: application/json" --input "$payload" --jq '.number // empty')
346
+ rm -f "$payload"
347
+ printf '%s\n' "$result"
249
348
  }
250
349
 
251
350
  _ghi_set_issue_labels() {
@@ -260,8 +359,15 @@ _ghi_close_issue() {
260
359
  gh api -X PATCH "repos/$PIPE_REPO/issues/$1" -f "state=closed" > /dev/null 2>&1 || true
261
360
  }
262
361
 
263
- _ghi_issue_comment() {
264
- gh api -X POST "repos/$PIPE_REPO/issues/$1/comments" -f "body=$2" > /dev/null
362
+ _ghi_issue_comment_file() {
363
+ # $1=iid $2=comment file.
364
+ local payload status=0
365
+ payload=$(mktemp "$PIPE_CONTEXT_DIR/github-comment.XXXXXX.json")
366
+ jq -n --rawfile body "$2" '{body:$body}' > "$payload"
367
+ gh api -X POST "repos/$PIPE_REPO/issues/$1/comments" \
368
+ -H "Content-Type: application/json" --input "$payload" > /dev/null || status=$?
369
+ rm -f "$payload"
370
+ return "$status"
265
371
  }
266
372
 
267
373
  _ghi_code_trigger() {
@@ -278,21 +384,27 @@ _ghi_code_trigger() {
278
384
  }
279
385
  }
280
386
 
281
- _ghi_pr_create() {
282
- # $1=source $2=target $3=title $4=desc $5=labels(comma). Prints html_url.
387
+ _ghi_pr_create_file() {
388
+ # $1=source $2=target $3=title $4=description file $5=labels(comma). Prints html_url.
283
389
  # Created as a draft and flipped to ready only after the labels are on
284
390
  # (finding P5): GitHub cannot attach labels at PR creation, so a non-draft
285
391
  # PR emits an `opened` + `labeled` event pair and the review workflow runs
286
392
  # twice. Draft PRs do not trigger the review job, which makes the single
287
393
  # `ready_for_review` event the only trigger. Falls back to a plain PR when
288
394
  # the plan does not support drafts on this repo (free plan, private repo).
289
- local url num
395
+ local url num payload
396
+ payload=$(mktemp "$PIPE_CONTEXT_DIR/github-pr.XXXXXX.json")
397
+ jq -n --arg h "$1" --arg b "$2" --arg t "$3" --rawfile d "$4" \
398
+ '{head:$h, base:$b, title:$t, body:$d, draft:true}' > "$payload"
290
399
  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)
400
+ -H "Content-Type: application/json" --input "$payload" --jq '.html_url // empty' 2>/dev/null || true)
292
401
  if [ -z "$url" ]; then
402
+ jq -n --arg h "$1" --arg b "$2" --arg t "$3" --rawfile d "$4" \
403
+ '{head:$h, base:$b, title:$t, body:$d}' > "$payload"
293
404
  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)
405
+ -H "Content-Type: application/json" --input "$payload" --jq '.html_url // empty' 2>/dev/null || true)
295
406
  fi
407
+ rm -f "$payload"
296
408
  num=$(echo "$url" | grep -oE '[0-9]+$' || true)
297
409
  if [ -n "$num" ] && [ -n "$5" ]; then
298
410
  # Labels go through the issues API (a PR is an issue on GitHub).
@@ -309,17 +421,19 @@ _ghi_pr_create() {
309
421
 
310
422
  issue_ensure_label() { case "$PIPE_PLATFORM" in gitlab) _gli_ensure_label "$1" "$2" "$3" ;; github) _ghi_ensure_label "$1" "$2" "$3" ;; esac; }
311
423
  issues_by_label() { case "$PIPE_PLATFORM" in gitlab) _gli_issues_by_label "$1" ;; github) _ghi_issues_by_label "$1" ;; esac; }
424
+ issues_by_labels() { case "$PIPE_PLATFORM" in gitlab) _gli_issues_by_labels "$1" "$2" "$3" "$4" ;; github) _ghi_issues_by_labels "$1" "$2" "$3" "$4" ;; esac; }
312
425
  issues_closed_by_label() { case "$PIPE_PLATFORM" in gitlab) _gli_issues_closed_by_label "$1" ;; github) _ghi_issues_closed_by_label "$1" ;; esac; }
426
+ issues_by_label_created() { case "$PIPE_PLATFORM" in gitlab) _gli_issues_by_label_created "$1" "$2" "$3" ;; github) _ghi_issues_by_label_created "$1" "$2" "$3" ;; esac; }
313
427
  issue_notes() { case "$PIPE_PLATFORM" in gitlab) _gli_issue_notes "$1" ;; github) _ghi_issue_notes "$1" ;; esac; }
314
428
  issue_body() { case "$PIPE_PLATFORM" in gitlab) _gli_issue_body "$1" ;; github) _ghi_issue_body "$1" ;; esac; }
315
429
  issue_meta() { case "$PIPE_PLATFORM" in gitlab) _gli_issue_meta "$1" ;; github) _ghi_issue_meta "$1" ;; esac; }
316
430
  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; }
431
+ issue_create_file() { case "$PIPE_PLATFORM" in gitlab) _gli_create_issue_file "$1" "$2" "$3" ;; github) _ghi_create_issue_file "$1" "$2" "$3" ;; esac; }
318
432
  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
433
  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; }
434
+ issue_comment_file() { case "$PIPE_PLATFORM" in gitlab) _gli_issue_comment_file "$1" "$2" ;; github) _ghi_issue_comment_file "$1" "$2" ;; esac; }
321
435
  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; }
436
+ mr_create_file() { case "$PIPE_PLATFORM" in gitlab) _gli_mr_create_file "$1" "$2" "$3" "$4" "$5" ;; github) _ghi_pr_create_file "$1" "$2" "$3" "$4" "$5" ;; esac; }
323
437
  issue_link_blocked_by() { case "$PIPE_PLATFORM" in gitlab) _gli_link_blocked_by "$1" "$2" ;; github) _ghi_link_blocked_by "$1" "$2" ;; esac; }
324
438
 
325
439
  # Count how many issues that block $1 are still open. GitLab uses native links,
@@ -376,13 +490,13 @@ issue_children_state() {
376
490
  # templates/spec-issue.template.md. If you rename a section
377
491
  # header or change a placeholder token in the template, update both lists here.
378
492
  DECOMP_REJECT_REASON=""
379
- pipe_validate_decomposition() {
493
+ pipe_validate_decomposition_file() {
380
494
  # Prints the first problem to stdout on failure (empty on success). Sets
381
495
  # DECOMP_REJECT_REASON and returns 1 when invalid, 0 when valid.
382
- local json="$1"
496
+ local json_file="$1"
383
497
  DECOMP_REJECT_REASON=""
384
498
  local reason
385
- reason=$(printf '%s' "$json" | jq -r '
499
+ reason=$(jq -r '
386
500
  def required: ["## 1. Outcome","## 2. Context","## 3. Acceptance Criteria",
387
501
  "## 4. Scope Envelope","## 5. Existing Code Anchors","## 6. Risk Flags",
388
502
  "## 7. Test Plan","## 8. Out of Scope"];
@@ -394,6 +508,7 @@ pipe_validate_decomposition() {
394
508
  | if ($conf != "high" and $conf != "low") then "confidence must be high or low, got: \($conf)"
395
509
  elif (($subs | length) < 2 or ($subs | length) > 5) then "sub_issues count must be 2-5, got: \($subs | length)"
396
510
  elif ($keys | any(. == null or . == "")) then "every sub_issue needs a non-empty key"
511
+ elif ($keys | length) != ($keys | unique | length) then "sub_issue keys must be unique"
397
512
  else
398
513
  ( [ $subs[] | (.depends_on // [])[] as $d | select(($keys | index($d)) == null) | $d ] | first ) as $unknown
399
514
  | if $unknown != null then "depends_on references unknown key: \($unknown)"
@@ -420,7 +535,7 @@ pipe_validate_decomposition() {
420
535
  end
421
536
  end
422
537
  end
423
- ' 2>/dev/null || echo "invalid JSON")
538
+ ' "$json_file" 2>/dev/null || echo "invalid JSON")
424
539
  if [ -n "$reason" ]; then
425
540
  # shellcheck disable=SC2034 # read by orchestrate.sh once this returns 1
426
541
  DECOMP_REJECT_REASON="$reason"