@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.
- package/README.md +12 -4
- package/package.json +2 -2
- package/payload/INSTALL.md +8 -2
- package/payload/agents/agent-architect.md +1 -1
- package/payload/agents/code-reviewer.md +1 -1
- package/payload/agents/codebase-auditor.md +1 -1
- package/payload/agents/coder.md +1 -1
- package/payload/agents/decomposer.md +1 -1
- package/payload/agents/docs-sync.md +1 -1
- package/payload/agents/e2e-test-writer.md +1 -1
- package/payload/agents/performance-reviewer.md +1 -1
- package/payload/agents/release-mr.md +1 -1
- package/payload/agents/security-reviewer.md +1 -1
- package/payload/agents/test-fix.md +1 -1
- package/payload/agents/test-writer.md +1 -1
- package/payload/agents-omp/agent-architect.md +1 -1
- package/payload/agents-omp/code-reviewer.md +1 -1
- package/payload/agents-omp/codebase-auditor.md +1 -1
- package/payload/agents-omp/coder.md +1 -1
- package/payload/agents-omp/decomposer.md +1 -1
- package/payload/agents-omp/docs-sync.md +1 -1
- package/payload/agents-omp/e2e-test-writer.md +1 -1
- package/payload/agents-omp/migration-reviewer.md +1 -1
- package/payload/agents-omp/orchestrator.md +1 -1
- package/payload/agents-omp/performance-reviewer.md +1 -1
- package/payload/agents-omp/postmortem.md +1 -1
- package/payload/agents-omp/release-mr.md +1 -1
- package/payload/agents-omp/security-reviewer.md +1 -1
- package/payload/agents-omp/test-fix.md +1 -1
- package/payload/agents-omp/test-writer.md +1 -1
- package/payload/ci-templates/claude-pipeline.gitlab-ci.yml +210 -0
- package/payload/ci-templates/github/claude-issue-pipeline.yml +1 -1
- package/payload/ci-templates/github/claude-pipeline.yml +2 -2
- package/payload/ci-templates/github/claude-test-fix.yml +1 -1
- package/payload/ci-templates/scripts/agent-architect.sh +233 -0
- package/payload/ci-templates/scripts/code.sh +26 -27
- package/payload/ci-templates/scripts/codebase-audit.sh +252 -0
- package/payload/ci-templates/scripts/coverage-ratchet.sh +77 -0
- package/payload/ci-templates/scripts/cve-fix.sh +246 -0
- package/payload/ci-templates/scripts/docs-sync.sh +225 -0
- package/payload/ci-templates/scripts/e2e-test-gen.sh +201 -0
- package/payload/ci-templates/scripts/lib/failure-notice.sh +104 -0
- package/payload/ci-templates/scripts/lib/issue-loop.sh +155 -40
- package/payload/ci-templates/scripts/lib/pipeline-common.sh +100 -26
- package/payload/ci-templates/scripts/lib/platform.sh +270 -13
- package/payload/ci-templates/scripts/lib/usage-capture-omp.sh +7 -1
- package/payload/ci-templates/scripts/lib/usage-capture.sh +7 -1
- package/payload/ci-templates/scripts/metrics-snapshot.sh +377 -0
- package/payload/ci-templates/scripts/orchestrate.sh +55 -37
- package/payload/ci-templates/scripts/postmortem.sh +10 -0
- package/payload/ci-templates/scripts/review-fix.sh +22 -11
- package/payload/ci-templates/scripts/review.sh +11 -7
- package/payload/ci-templates/scripts/test-fix.sh +7 -0
- package/payload/skills/agent-architect/SKILL.md +45 -0
- package/payload/skills/codebase-audit/SKILL.md +84 -0
- package/payload/skills/cve-fix/SKILL.md +98 -0
- package/payload/skills/docs-sync/SKILL.md +74 -0
- package/payload/skills/e2e-test-gen/SKILL.md +65 -0
- package/payload/skills/fix-review-findings/SKILL.md +2 -2
- package/payload/skills/fix-tests/SKILL.md +2 -2
- package/payload/templates/memory-index.template.md +34 -0
- package/payload/templates/pipeline-config.template.md +11 -1
- package/payload/templates/review_suppressions.template.md +55 -0
- 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
|
-
|
|
117
|
-
# $1=title $2=
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
168
|
-
# $1=source $2=target $3=title $4=
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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"
|
|
235
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
334
|
+
-H "Content-Type: application/json" --input "$payload" > /dev/null
|
|
335
|
+
rm -f "$body_file" "$payload"
|
|
240
336
|
}
|
|
241
337
|
|
|
242
|
-
|
|
243
|
-
# $1=title $2=
|
|
244
|
-
local
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
264
|
-
|
|
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
|
-
|
|
282
|
-
# $1=source $2=target $3=title $4=
|
|
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
|
-
-
|
|
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
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
496
|
+
local json_file="$1"
|
|
383
497
|
DECOMP_REJECT_REASON=""
|
|
384
498
|
local reason
|
|
385
|
-
reason=$(
|
|
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"
|