forgedock 1.2.0 → 1.4.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/README.md +8 -2
- package/bin/batch-runner.mjs +31 -2
- package/bin/engine/admission.mjs +294 -0
- package/bin/engine/admission.test.mjs +265 -0
- package/bin/engine/invariants.mjs +17 -4
- package/bin/engine/phases.mjs +358 -42
- package/bin/engine/resolve.mjs +256 -0
- package/bin/engine/resolve.test.mjs +149 -0
- package/bin/engine/state.mjs +31 -7
- package/bin/engine-cli.mjs +138 -2
- package/bin/engine.mjs +414 -21
- package/bin/forgedock.mjs +552 -44
- package/bin/hooks/interactive-engine.mjs +50 -24
- package/bin/hooks/pre-tool-use.mjs +189 -19
- package/bin/journey.mjs +109 -11
- package/bin/labels.json +5 -0
- package/bin/runner.mjs +321 -10
- package/bin/watch-utils.mjs +255 -0
- package/commands/cleanup.md +7 -3
- package/commands/forge-stats.md +5 -4
- package/commands/issue.md +5 -1
- package/commands/orchestrate/config.md +41 -1
- package/commands/orchestrate/phase-1-resolve.md +234 -8
- package/commands/orchestrate/phase-3-dependency.md +27 -18
- package/commands/orchestrate/phase-4-execution.md +223 -22
- package/commands/qa-sweep.md +17 -0
- package/commands/quality-gate.md +6 -6
- package/commands/replay.md +16 -2
- package/commands/review-pr-agents/spec-cli.md +179 -0
- package/commands/review-pr.md +65 -0
- package/commands/upgrade-deps.md +523 -0
- package/commands/work-on/build/validate.md +61 -1
- package/commands/work-on/build.md +9 -1
- package/commands/work-on/decompose.md +10 -1
- package/commands/work-on/investigate.md +22 -4
- package/commands/work-on/remediate.md +2 -0
- package/commands/work-on/review.md +40 -1
- package/commands/work-on.md +19 -4
- package/docs/AIDER.md +264 -0
- package/docs/AUTOPILOT-CRON.md +130 -0
- package/docs/CONFIG.md +156 -0
- package/docs/CURSOR.md +135 -0
- package/docs/OPENCODE.md +312 -0
- package/docs/demo.gif +0 -0
- package/docs/eval/README.md +4 -4
- package/docs/launch/show-hn.md +3 -3
- package/docs/site/architecture.md +180 -0
- package/docs/site/command-reference.md +17 -0
- package/docs/site/configuration.md +243 -0
- package/docs/site/for-companies.md +4 -4
- package/docs/site/forge-annotation-protocol.md +1 -1
- package/docs/site/github-is-the-memory.md +2 -2
- package/docs/site/how-it-works.md +1 -1
- package/docs/site/public/llms-full.txt +380 -0
- package/docs/site/public/llms.txt +35 -0
- package/docs/spec/eval-run-result.md +8 -4
- package/docs/spec/label-state-machine.md +9 -0
- package/docs/spec-graph-schema.md +3 -3
- package/package.json +3 -3
- package/packages/protocol/README.md +75 -0
- package/packages/protocol/src/card.js +114 -0
- package/packages/protocol/src/cli.js +4 -77
- package/packages/protocol/src/emit.js +5 -11
- package/packages/protocol/src/html-comment-escape.js +68 -0
- package/packages/protocol/src/index.js +3 -1
- package/packages/protocol/src/parse.js +19 -11
- package/packages/protocol/src/phases.js +105 -0
- package/packages/protocol/src/types.js +18 -1
- package/packages/protocol/src/validate.js +11 -0
- package/scripts/check-admission-parity.mjs +167 -0
- package/scripts/check-command-side-effects.sh +376 -0
- package/scripts/check-phase-registry-drift.mjs +96 -0
- package/scripts/check-spec-markers.sh +274 -0
- package/scripts/classify-lane.sh +29 -7
- package/scripts/forge-run.sh +353 -0
- package/templates/cross-repo-trigger.yml +282 -0
- package/templates/cursor/forge.mdc +148 -0
- package/templates/github-actions/forgedock-autopilot.yml +113 -0
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# forge-run.sh — Universal shell-script entry point for non-Claude agents
|
|
3
|
+
#
|
|
4
|
+
# Usage: forge-run.sh <command> <issue_number> [-R <owner/repo>]
|
|
5
|
+
# command: Pipeline command to run (currently: work-on)
|
|
6
|
+
# issue_number: GitHub issue number (required)
|
|
7
|
+
# -R <owner/repo>: GitHub repository (optional, defaults to current repo)
|
|
8
|
+
#
|
|
9
|
+
# Description:
|
|
10
|
+
# GitHub-state-aware phase router. Reads workflow:* labels and FORGE annotation
|
|
11
|
+
# comments on the given issue to determine the current pipeline phase, then emits
|
|
12
|
+
# structured JSON events to stdout describing phase state and transitions.
|
|
13
|
+
#
|
|
14
|
+
# This script does NOT invoke LLM agents — it reports what phase the pipeline is
|
|
15
|
+
# in and what the next action should be. It is designed to be called by any
|
|
16
|
+
# agent runtime that can execute bash (Aider /run, Cursor terminal, CI runners,
|
|
17
|
+
# OpenCode shell) to get deterministic routing information without re-implementing
|
|
18
|
+
# the phase detection logic.
|
|
19
|
+
#
|
|
20
|
+
# JSON Event Schema:
|
|
21
|
+
# Each event is a single JSON object on its own line (newline-delimited JSON):
|
|
22
|
+
#
|
|
23
|
+
# Phase detection:
|
|
24
|
+
# {"event":"phase_detected","command":"work-on","issue":N,"phase":"<phase>","ts":"<ISO8601>"}
|
|
25
|
+
#
|
|
26
|
+
# Phase detail:
|
|
27
|
+
# {"event":"phase_detail","phase":"<phase>","lane":"<lane>","branch":"<branch>","labels":["<label>",...]}
|
|
28
|
+
#
|
|
29
|
+
# Action required:
|
|
30
|
+
# {"event":"action_required","phase":"<phase>","action":"<description>","ts":"<ISO8601>"}
|
|
31
|
+
#
|
|
32
|
+
# Terminal state:
|
|
33
|
+
# {"event":"terminal","phase":"<phase>","label":"<terminal-label>","issue":N,"ts":"<ISO8601>"}
|
|
34
|
+
#
|
|
35
|
+
# Error:
|
|
36
|
+
# {"event":"error","code":"<error_code>","message":"<message>","ts":"<ISO8601>"}
|
|
37
|
+
#
|
|
38
|
+
# Phase values (work-on command):
|
|
39
|
+
# no-comments — No investigation started; next: Phase 1 (investigate)
|
|
40
|
+
# investigating — Investigation in progress (partial comment, no INVESTIGATION:COMPLETE)
|
|
41
|
+
# ready-to-build — Investigation complete; next: Phase 3 (build)
|
|
42
|
+
# building — Build in progress (no FORGE:BUILDER comment yet)
|
|
43
|
+
# awaiting-pr — Builder complete, no PR; next: Phase 4 (create PR)
|
|
44
|
+
# in-review — PR exists, review in progress; next: Phase 5 (auto-review)
|
|
45
|
+
# merged — Terminal: workflow:merged label set
|
|
46
|
+
# invalid — Terminal: workflow:invalid label set
|
|
47
|
+
# decomposed — Terminal: workflow:decomposed label set (sub-issues spawned)
|
|
48
|
+
# needs-human — Terminal: needs-human label set
|
|
49
|
+
#
|
|
50
|
+
# Exit codes: 0 = success, 1 = error (invalid args, gh auth failure, etc.)
|
|
51
|
+
#
|
|
52
|
+
# <!-- Added: forge#965 -->
|
|
53
|
+
|
|
54
|
+
set -euo pipefail
|
|
55
|
+
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
# Helpers
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
# ISO 8601 timestamp (UTC)
|
|
61
|
+
ts() { date -u +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || echo "1970-01-01T00:00:00Z"; }
|
|
62
|
+
|
|
63
|
+
# Emit a JSON event to stdout (one object per line — NDJSON)
|
|
64
|
+
emit() {
|
|
65
|
+
local json="$1"
|
|
66
|
+
printf '%s\n' "$json"
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
# Emit an error event and exit 1
|
|
70
|
+
die() {
|
|
71
|
+
local code="$1"
|
|
72
|
+
local message="$2"
|
|
73
|
+
local ts_val
|
|
74
|
+
ts_val=$(ts)
|
|
75
|
+
# Route through json_str for full JSON-string escaping (backslash, quote, and
|
|
76
|
+
# control characters) — die() is fed raw multi-line `gh` CLI stderr, which may
|
|
77
|
+
# contain backslashes and newlines that a quote-only escape would not handle.
|
|
78
|
+
message=$(json_str "$message")
|
|
79
|
+
emit "{\"event\":\"error\",\"code\":\"${code}\",\"message\":\"${message}\",\"ts\":\"${ts_val}\"}"
|
|
80
|
+
exit 1
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
# JSON-escape a string: backslash, double-quote, and the full C0 control range
|
|
84
|
+
# (U+0000-U+001F), per RFC 8259 section 7. Order matters:
|
|
85
|
+
# 1. Backslash escaping runs first so a literal backslash becomes \\ before
|
|
86
|
+
# any other pass runs, avoiding double-escaping ambiguity.
|
|
87
|
+
# 2. Double-quote next.
|
|
88
|
+
# 3. The three named control escapes (\n, \r, \t) next, so they get their
|
|
89
|
+
# short two-character form rather than falling through to \u00XX.
|
|
90
|
+
# 4. Every remaining C0 control byte (U+0000-U+001F, excluding \n/\r/\t
|
|
91
|
+
# already handled above) is escaped as \u00XX — bash's ${s//pat/rep}
|
|
92
|
+
# cannot loop over an arbitrary byte range in one substitution, so this
|
|
93
|
+
# is a bounded loop over hex 00-1F.
|
|
94
|
+
json_str() {
|
|
95
|
+
local s="$1"
|
|
96
|
+
s="${s//\\/\\\\}"
|
|
97
|
+
s="${s//\"/\\\"}"
|
|
98
|
+
s="${s//$'\n'/\\n}"
|
|
99
|
+
s="${s//$'\r'/\\r}"
|
|
100
|
+
s="${s//$'\t'/\\t}"
|
|
101
|
+
local i
|
|
102
|
+
for ((i = 0; i <= 31; i++)); do
|
|
103
|
+
# \n (10=0x0a), \r (13=0x0d), \t (9=0x09) already escaped above — skip them
|
|
104
|
+
# here so they keep their short named form instead of becoming \u00XX.
|
|
105
|
+
if [ "$i" -eq 9 ] || [ "$i" -eq 10 ] || [ "$i" -eq 13 ]; then
|
|
106
|
+
continue
|
|
107
|
+
fi
|
|
108
|
+
local hex
|
|
109
|
+
hex=$(printf '%02x' "$i")
|
|
110
|
+
local ctrl_char
|
|
111
|
+
printf -v ctrl_char '\\x%02x' "$i"
|
|
112
|
+
ctrl_char=$(printf "$ctrl_char")
|
|
113
|
+
if [[ "$s" == *"$ctrl_char"* ]]; then
|
|
114
|
+
s="${s//$ctrl_char/\\u00$hex}"
|
|
115
|
+
fi
|
|
116
|
+
done
|
|
117
|
+
printf '%s' "$s"
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
# Argument parsing
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
COMMAND="${1:-}"
|
|
125
|
+
ISSUE_NUMBER="${2:-}"
|
|
126
|
+
GH_REPO_ARGS=()
|
|
127
|
+
|
|
128
|
+
if [ -z "$COMMAND" ]; then
|
|
129
|
+
die "MISSING_COMMAND" "command is required. Usage: forge-run.sh <command> <issue_number> [-R <owner/repo>]"
|
|
130
|
+
fi
|
|
131
|
+
|
|
132
|
+
if [ -z "$ISSUE_NUMBER" ]; then
|
|
133
|
+
die "MISSING_ISSUE" "issue_number is required. Usage: forge-run.sh <command> <issue_number> [-R <owner/repo>]"
|
|
134
|
+
fi
|
|
135
|
+
|
|
136
|
+
# Validate command
|
|
137
|
+
case "$COMMAND" in
|
|
138
|
+
work-on) ;;
|
|
139
|
+
*)
|
|
140
|
+
die "UNKNOWN_COMMAND" "unknown command: ${COMMAND}. Supported: work-on"
|
|
141
|
+
;;
|
|
142
|
+
esac
|
|
143
|
+
|
|
144
|
+
# Validate issue number is numeric
|
|
145
|
+
if ! [[ "$ISSUE_NUMBER" =~ ^[0-9]+$ ]]; then
|
|
146
|
+
die "INVALID_ISSUE" "issue_number must be numeric, got: ${ISSUE_NUMBER}"
|
|
147
|
+
fi
|
|
148
|
+
|
|
149
|
+
# Parse remaining flags
|
|
150
|
+
shift 2
|
|
151
|
+
while [ $# -gt 0 ]; do
|
|
152
|
+
case "$1" in
|
|
153
|
+
-R)
|
|
154
|
+
if [ $# -lt 2 ]; then
|
|
155
|
+
die "MISSING_REPO" "-R requires a value <owner/repo>. Usage: forge-run.sh <command> <issue_number> [-R <owner/repo>]"
|
|
156
|
+
fi
|
|
157
|
+
if ! [[ "$2" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then
|
|
158
|
+
die "INVALID_REPO" "-R value must be owner/repo format, got: ${2}"
|
|
159
|
+
fi
|
|
160
|
+
GH_REPO_ARGS=(-R "$2")
|
|
161
|
+
shift 2
|
|
162
|
+
;;
|
|
163
|
+
*)
|
|
164
|
+
die "UNKNOWN_FLAG" "unknown argument: ${1}. Usage: forge-run.sh <command> <issue_number> [-R <owner/repo>]"
|
|
165
|
+
;;
|
|
166
|
+
esac
|
|
167
|
+
done
|
|
168
|
+
|
|
169
|
+
# ---------------------------------------------------------------------------
|
|
170
|
+
# Locate scripts directory (where classify-lane.sh lives alongside this script)
|
|
171
|
+
# ---------------------------------------------------------------------------
|
|
172
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
173
|
+
CLASSIFY_LANE="${SCRIPT_DIR}/classify-lane.sh"
|
|
174
|
+
|
|
175
|
+
if [ ! -x "$CLASSIFY_LANE" ]; then
|
|
176
|
+
die "MISSING_SCRIPT" "classify-lane.sh not found or not executable at: ${CLASSIFY_LANE}"
|
|
177
|
+
fi
|
|
178
|
+
|
|
179
|
+
# ---------------------------------------------------------------------------
|
|
180
|
+
# Phase 1: Read issue state from GitHub
|
|
181
|
+
# ---------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
# Fetch issue: state, labels
|
|
184
|
+
GH_STDERR_TMP=$(mktemp)
|
|
185
|
+
ISSUE_JSON=$(gh issue view "$ISSUE_NUMBER" "${GH_REPO_ARGS[@]}" \
|
|
186
|
+
--json number,state,labels,title \
|
|
187
|
+
2>"$GH_STDERR_TMP") || {
|
|
188
|
+
MSG=$(cat "$GH_STDERR_TMP")
|
|
189
|
+
rm -f "$GH_STDERR_TMP"
|
|
190
|
+
die "GH_FETCH_FAILED" "failed to fetch issue #${ISSUE_NUMBER}: ${MSG}"
|
|
191
|
+
}
|
|
192
|
+
rm -f "$GH_STDERR_TMP"
|
|
193
|
+
|
|
194
|
+
# Extract state and labels
|
|
195
|
+
ISSUE_STATE=$(printf '%s' "$ISSUE_JSON" | jq -r '.state')
|
|
196
|
+
LABELS_JSON=$(printf '%s' "$ISSUE_JSON" | jq -c '[.labels[].name]')
|
|
197
|
+
ISSUE_TITLE=$(printf '%s' "$ISSUE_JSON" | jq -r '.title')
|
|
198
|
+
|
|
199
|
+
# Check if issue is closed
|
|
200
|
+
if [ "$ISSUE_STATE" = "CLOSED" ]; then
|
|
201
|
+
ts_val=$(ts)
|
|
202
|
+
emit "{\"event\":\"terminal\",\"phase\":\"closed\",\"label\":\"closed\",\"issue\":${ISSUE_NUMBER},\"ts\":\"${ts_val}\"}"
|
|
203
|
+
exit 0
|
|
204
|
+
fi
|
|
205
|
+
|
|
206
|
+
# ---------------------------------------------------------------------------
|
|
207
|
+
# Phase 2: Detect terminal labels
|
|
208
|
+
# ---------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
TERMINAL_LABEL=""
|
|
211
|
+
for label in "workflow:merged" "workflow:invalid" "workflow:decomposed" "needs-human"; do
|
|
212
|
+
if printf '%s' "$LABELS_JSON" | jq -e --arg l "$label" 'contains([$l])' > /dev/null 2>&1; then
|
|
213
|
+
TERMINAL_LABEL="$label"
|
|
214
|
+
break
|
|
215
|
+
fi
|
|
216
|
+
done
|
|
217
|
+
|
|
218
|
+
if [ -n "$TERMINAL_LABEL" ]; then
|
|
219
|
+
ts_val=$(ts)
|
|
220
|
+
emit "{\"event\":\"terminal\",\"phase\":\"${TERMINAL_LABEL}\",\"label\":\"$(json_str "${TERMINAL_LABEL}")\",\"issue\":${ISSUE_NUMBER},\"ts\":\"${ts_val}\"}"
|
|
221
|
+
exit 0
|
|
222
|
+
fi
|
|
223
|
+
|
|
224
|
+
# ---------------------------------------------------------------------------
|
|
225
|
+
# Phase 3: Read FORGE annotation comments to determine pipeline phase
|
|
226
|
+
# ---------------------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
# gh issue view with --json comments is the reliable approach: it accepts the -R flag
|
|
229
|
+
# via GH_REPO_ARGS and handles both default-repo and explicit-repo cases.
|
|
230
|
+
# gh api repos/{owner}/{repo}/... would need GH_REPO env var for -R override — avoided here.
|
|
231
|
+
GH_STDERR_TMP=$(mktemp)
|
|
232
|
+
COMMENTS_JSON=$(gh issue view "$ISSUE_NUMBER" "${GH_REPO_ARGS[@]}" \
|
|
233
|
+
--json comments --jq '[.comments[] | {id: .id, body: .body}]' \
|
|
234
|
+
2>"$GH_STDERR_TMP") || {
|
|
235
|
+
MSG=$(cat "$GH_STDERR_TMP")
|
|
236
|
+
rm -f "$GH_STDERR_TMP"
|
|
237
|
+
die "GH_COMMENTS_FAILED" "failed to fetch comments for issue #${ISSUE_NUMBER}: ${MSG}"
|
|
238
|
+
}
|
|
239
|
+
rm -f "$GH_STDERR_TMP" 2>/dev/null || true
|
|
240
|
+
|
|
241
|
+
if [ -z "$COMMENTS_JSON" ]; then
|
|
242
|
+
COMMENTS_JSON="[]"
|
|
243
|
+
fi
|
|
244
|
+
|
|
245
|
+
# Detect annotation presence
|
|
246
|
+
has_annotation() {
|
|
247
|
+
local marker="$1"
|
|
248
|
+
printf '%s' "$COMMENTS_JSON" | jq -e --arg m "$marker" 'any(.[]; .body | contains($m))' > /dev/null 2>&1
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
# Detect complete annotation (has both the annotation tag AND its completion sentinel)
|
|
252
|
+
is_annotation_complete() {
|
|
253
|
+
local open_tag="$1"
|
|
254
|
+
local sentinel="$2"
|
|
255
|
+
printf '%s' "$COMMENTS_JSON" | jq -e \
|
|
256
|
+
--arg o "$open_tag" --arg s "$sentinel" \
|
|
257
|
+
'any(.[]; (.body | contains($o)) and (.body | contains($s)))' > /dev/null 2>&1
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
INVESTIGATOR_COMPLETE=false
|
|
261
|
+
BUILDER_COMPLETE=false
|
|
262
|
+
BUILDER_PRESENT=false
|
|
263
|
+
REVIEW_STARTED=false
|
|
264
|
+
|
|
265
|
+
if is_annotation_complete "FORGE:INVESTIGATOR" "INVESTIGATION:COMPLETE"; then
|
|
266
|
+
INVESTIGATOR_COMPLETE=true
|
|
267
|
+
fi
|
|
268
|
+
if has_annotation "FORGE:BUILDER"; then
|
|
269
|
+
BUILDER_PRESENT=true
|
|
270
|
+
fi
|
|
271
|
+
if is_annotation_complete "FORGE:BUILDER" "FORGE:BUILDER:COMPLETE"; then
|
|
272
|
+
BUILDER_COMPLETE=true
|
|
273
|
+
fi
|
|
274
|
+
if has_annotation "FORGE:REVIEW_STARTED"; then
|
|
275
|
+
REVIEW_STARTED=true
|
|
276
|
+
fi
|
|
277
|
+
|
|
278
|
+
# ---------------------------------------------------------------------------
|
|
279
|
+
# Phase 4: Determine current workflow phase from labels + annotations
|
|
280
|
+
# ---------------------------------------------------------------------------
|
|
281
|
+
|
|
282
|
+
# Read current workflow labels
|
|
283
|
+
WORKFLOW_LABEL=""
|
|
284
|
+
for label in "workflow:in-review" "workflow:building" "workflow:ready-to-build" "workflow:investigating"; do
|
|
285
|
+
if printf '%s' "$LABELS_JSON" | jq -e --arg l "$label" 'contains([$l])' > /dev/null 2>&1; then
|
|
286
|
+
WORKFLOW_LABEL="$label"
|
|
287
|
+
break
|
|
288
|
+
fi
|
|
289
|
+
done
|
|
290
|
+
|
|
291
|
+
# Phase detection logic (matches work-on.md Phase 0B: "Determine resume point")
|
|
292
|
+
PHASE="no-comments"
|
|
293
|
+
ACTION=""
|
|
294
|
+
|
|
295
|
+
if [ "$REVIEW_STARTED" = "true" ]; then
|
|
296
|
+
PHASE="in-review"
|
|
297
|
+
ACTION="Review in progress — /review-pr handles merge. Check PR state."
|
|
298
|
+
elif [ "$BUILDER_COMPLETE" = "true" ] && [ "$WORKFLOW_LABEL" != "workflow:in-review" ]; then
|
|
299
|
+
PHASE="awaiting-pr"
|
|
300
|
+
ACTION="Builder complete. Create PR targeting the classified lane branch."
|
|
301
|
+
elif [ "$BUILDER_PRESENT" = "true" ] && [ "$BUILDER_COMPLETE" = "false" ]; then
|
|
302
|
+
PHASE="building"
|
|
303
|
+
ACTION="Builder comment present but incomplete. Resume build from FORGE:BUILDER comment."
|
|
304
|
+
elif [ "$WORKFLOW_LABEL" = "workflow:building" ]; then
|
|
305
|
+
PHASE="building"
|
|
306
|
+
ACTION="Build phase active. No FORGE:BUILDER comment yet — build not complete."
|
|
307
|
+
elif [ "$INVESTIGATOR_COMPLETE" = "true" ] || [ "$WORKFLOW_LABEL" = "workflow:ready-to-build" ]; then
|
|
308
|
+
PHASE="ready-to-build"
|
|
309
|
+
ACTION="Investigation complete. Invoke /work-on ${ISSUE_NUMBER} to continue to build phase."
|
|
310
|
+
elif has_annotation "FORGE:INVESTIGATOR" && [ "$INVESTIGATOR_COMPLETE" = "false" ]; then
|
|
311
|
+
PHASE="investigating"
|
|
312
|
+
ACTION="Investigation comment present but incomplete (no INVESTIGATION:COMPLETE sentinel). Delete partial comment and restart investigation."
|
|
313
|
+
elif [ "$WORKFLOW_LABEL" = "workflow:investigating" ]; then
|
|
314
|
+
PHASE="investigating"
|
|
315
|
+
ACTION="Investigation in progress. No FORGE:INVESTIGATOR comment yet."
|
|
316
|
+
else
|
|
317
|
+
PHASE="no-comments"
|
|
318
|
+
ACTION="No pipeline activity detected. Invoke /work-on ${ISSUE_NUMBER} to start investigation."
|
|
319
|
+
fi
|
|
320
|
+
|
|
321
|
+
# ---------------------------------------------------------------------------
|
|
322
|
+
# Phase 5: Classify lane (for informational output in phase_detail event)
|
|
323
|
+
# ---------------------------------------------------------------------------
|
|
324
|
+
|
|
325
|
+
LANE=""
|
|
326
|
+
BRANCH=""
|
|
327
|
+
LANE_JSON=""
|
|
328
|
+
if LANE_JSON=$("$CLASSIFY_LANE" "$ISSUE_NUMBER" "${GH_REPO_ARGS[@]}" --json 2>/dev/null); then
|
|
329
|
+
LANE=$(printf '%s' "$LANE_JSON" | jq -r '.lane // empty')
|
|
330
|
+
BRANCH=$(printf '%s' "$LANE_JSON" | jq -r '.branch // empty')
|
|
331
|
+
fi
|
|
332
|
+
|
|
333
|
+
# ---------------------------------------------------------------------------
|
|
334
|
+
# Phase 6: Emit events
|
|
335
|
+
# ---------------------------------------------------------------------------
|
|
336
|
+
|
|
337
|
+
ts_val=$(ts)
|
|
338
|
+
|
|
339
|
+
# Event 1: phase_detected
|
|
340
|
+
emit "{\"event\":\"phase_detected\",\"command\":\"$(json_str "${COMMAND}")\",\"issue\":${ISSUE_NUMBER},\"phase\":\"$(json_str "${PHASE}")\",\"ts\":\"${ts_val}\"}"
|
|
341
|
+
|
|
342
|
+
# Event 2: phase_detail
|
|
343
|
+
LABELS_STR=$(printf '%s' "$LABELS_JSON")
|
|
344
|
+
emit "{\"event\":\"phase_detail\",\"phase\":\"$(json_str "${PHASE}")\",\"lane\":\"$(json_str "${LANE}")\",\"branch\":\"$(json_str "${BRANCH}")\",\"labels\":${LABELS_STR}}"
|
|
345
|
+
|
|
346
|
+
# Event 3: action_required (only for non-terminal active phases)
|
|
347
|
+
if [ -n "$ACTION" ]; then
|
|
348
|
+
ts_val=$(ts)
|
|
349
|
+
ACTION_ESCAPED=$(json_str "${ACTION}")
|
|
350
|
+
emit "{\"event\":\"action_required\",\"phase\":\"$(json_str "${PHASE}")\",\"action\":\"${ACTION_ESCAPED}\",\"ts\":\"${ts_val}\"}"
|
|
351
|
+
fi
|
|
352
|
+
|
|
353
|
+
exit 0
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
# ForgeDock Cross-Repo Trigger — Workflow Template
|
|
2
|
+
#
|
|
3
|
+
# Installation
|
|
4
|
+
# ─────────────
|
|
5
|
+
# Copy this file into your PARENT repo at:
|
|
6
|
+
# .github/workflows/cross-repo-trigger.yml
|
|
7
|
+
#
|
|
8
|
+
# When a PR merges in this repo, the workflow reads forge.yaml to find satellite
|
|
9
|
+
# repos that have trigger_on_merge: true, checks whether touched files match each
|
|
10
|
+
# satellite's trigger_paths globs, and creates a structured investigation issue in
|
|
11
|
+
# every matching satellite repo.
|
|
12
|
+
#
|
|
13
|
+
# Token requirements
|
|
14
|
+
# ──────────────────
|
|
15
|
+
# GITHUB_TOKEN (provided automatically by GitHub Actions) only has write access
|
|
16
|
+
# to THIS repo. To create issues in satellite repos you need a Personal Access
|
|
17
|
+
# Token (PAT) with "issues: write" scope on each satellite repo:
|
|
18
|
+
#
|
|
19
|
+
# Repo Settings → Secrets and variables → Actions → New repository secret
|
|
20
|
+
# Name: FORGE_PAT
|
|
21
|
+
# Value: ghp_... (your PAT)
|
|
22
|
+
#
|
|
23
|
+
# The workflow uses FORGE_PAT when set; it falls back to GITHUB_TOKEN otherwise.
|
|
24
|
+
# For same-org repos you may not need a PAT if GITHUB_TOKEN already has cross-repo
|
|
25
|
+
# write access (org-level permission inheritance). Cross-org or private satellite
|
|
26
|
+
# repos always require a PAT.
|
|
27
|
+
#
|
|
28
|
+
# forge.yaml configuration
|
|
29
|
+
# ─────────────────────────
|
|
30
|
+
# Each satellite that should receive cross-repo triggers must set trigger_on_merge:
|
|
31
|
+
#
|
|
32
|
+
# repos:
|
|
33
|
+
# satellites:
|
|
34
|
+
# - prefix: "sdk"
|
|
35
|
+
# repo: "acme-org/acme-python-sdk"
|
|
36
|
+
# staging_branch: "main"
|
|
37
|
+
# local_path: "/home/youruser/projects/acme-python-sdk"
|
|
38
|
+
# trigger_on_merge: true
|
|
39
|
+
# trigger_paths:
|
|
40
|
+
# - "src/api/**"
|
|
41
|
+
# - "types/**"
|
|
42
|
+
# auto_run: false # reserved — see ForgeDock docs for auto_run setup
|
|
43
|
+
#
|
|
44
|
+
# Fields:
|
|
45
|
+
# trigger_on_merge — boolean; set true to enable triggering for this satellite
|
|
46
|
+
# trigger_paths — list of glob strings (GitHub Actions glob syntax); trigger
|
|
47
|
+
# fires only when at least one changed file matches; omit to
|
|
48
|
+
# trigger on every merge
|
|
49
|
+
# auto_run — boolean; reserved for future auto_run handler; currently no-op
|
|
50
|
+
#
|
|
51
|
+
# Limits and guards
|
|
52
|
+
# ─────────────────
|
|
53
|
+
# - Max 10 satellite issues created per merge event (rate limit guard)
|
|
54
|
+
# - Circular dependency guard: skips satellites whose repo matches this repo
|
|
55
|
+
# - Satellites with trigger_on_merge: false (or absent) are skipped
|
|
56
|
+
# - If forge.yaml is absent, the workflow exits cleanly with an informational log
|
|
57
|
+
|
|
58
|
+
name: ForgeDock Cross-Repo Trigger
|
|
59
|
+
|
|
60
|
+
on:
|
|
61
|
+
pull_request:
|
|
62
|
+
types: [closed]
|
|
63
|
+
|
|
64
|
+
jobs:
|
|
65
|
+
trigger:
|
|
66
|
+
name: Fire cross-repo satellite triggers
|
|
67
|
+
runs-on: ubuntu-latest
|
|
68
|
+
|
|
69
|
+
# Only run when the PR was actually merged (not just closed)
|
|
70
|
+
if: github.event.pull_request.merged == true
|
|
71
|
+
|
|
72
|
+
permissions:
|
|
73
|
+
contents: read # Required: read forge.yaml from checked-out repo
|
|
74
|
+
issues: write # Required: fallback if this repo is a self-referencing satellite
|
|
75
|
+
|
|
76
|
+
env:
|
|
77
|
+
# Use FORGE_PAT when set (cross-repo access); fall back to GITHUB_TOKEN.
|
|
78
|
+
# Assign via env so it is available to all steps without repeating the expression.
|
|
79
|
+
FORGE_TOKEN: ${{ secrets.FORGE_PAT || github.token }}
|
|
80
|
+
|
|
81
|
+
steps:
|
|
82
|
+
- name: Checkout repository
|
|
83
|
+
uses: actions/checkout@v4
|
|
84
|
+
|
|
85
|
+
- name: Check forge.yaml exists
|
|
86
|
+
id: forge_check
|
|
87
|
+
run: |
|
|
88
|
+
if [ -f "forge.yaml" ]; then
|
|
89
|
+
echo "exists=true" >> "$GITHUB_OUTPUT"
|
|
90
|
+
echo "forge.yaml found"
|
|
91
|
+
else
|
|
92
|
+
echo "exists=false" >> "$GITHUB_OUTPUT"
|
|
93
|
+
echo "forge.yaml not found — no satellite configuration to process. Exiting cleanly."
|
|
94
|
+
fi
|
|
95
|
+
|
|
96
|
+
- name: Parse forge.yaml and get changed files
|
|
97
|
+
id: forge_parse
|
|
98
|
+
if: steps.forge_check.outputs.exists == 'true'
|
|
99
|
+
env:
|
|
100
|
+
GH_TOKEN: ${{ env.FORGE_TOKEN }}
|
|
101
|
+
PR_NUMBER: ${{ github.event.pull_request.number }}
|
|
102
|
+
UPSTREAM_REPO: ${{ github.repository }}
|
|
103
|
+
run: |
|
|
104
|
+
# Get changed files from the merged PR
|
|
105
|
+
CHANGED_FILES_JSON=$(gh pr view "$PR_NUMBER" -R "$UPSTREAM_REPO" --json files --jq '[.files[].path]')
|
|
106
|
+
echo "Changed files: $CHANGED_FILES_JSON"
|
|
107
|
+
echo "changed_files_json=$CHANGED_FILES_JSON" >> "$GITHUB_OUTPUT"
|
|
108
|
+
|
|
109
|
+
# Parse satellite list from forge.yaml using Python3 (always available on ubuntu-latest)
|
|
110
|
+
SATELLITES_JSON=$(python3 -c "
|
|
111
|
+
import yaml, json, sys
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
with open('forge.yaml', 'r') as f:
|
|
115
|
+
config = yaml.safe_load(f)
|
|
116
|
+
except Exception as e:
|
|
117
|
+
print(json.dumps([]), end='')
|
|
118
|
+
sys.exit(0)
|
|
119
|
+
|
|
120
|
+
repos = config.get('repos', {}) or {}
|
|
121
|
+
satellites = repos.get('satellites', []) or []
|
|
122
|
+
|
|
123
|
+
result = []
|
|
124
|
+
for sat in satellites:
|
|
125
|
+
if not isinstance(sat, dict):
|
|
126
|
+
continue
|
|
127
|
+
repo = sat.get('repo', '')
|
|
128
|
+
trigger_on_merge = sat.get('trigger_on_merge', False)
|
|
129
|
+
trigger_paths = sat.get('trigger_paths', []) or []
|
|
130
|
+
auto_run = sat.get('auto_run', False)
|
|
131
|
+
if not repo or not trigger_on_merge:
|
|
132
|
+
continue
|
|
133
|
+
result.append({
|
|
134
|
+
'repo': repo,
|
|
135
|
+
'trigger_paths': trigger_paths,
|
|
136
|
+
'auto_run': bool(auto_run),
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
print(json.dumps(result), end='')
|
|
140
|
+
")
|
|
141
|
+
echo "Satellites with trigger_on_merge=true: $SATELLITES_JSON"
|
|
142
|
+
echo "satellites_json=$SATELLITES_JSON" >> "$GITHUB_OUTPUT"
|
|
143
|
+
|
|
144
|
+
- name: Fire satellite triggers
|
|
145
|
+
if: steps.forge_check.outputs.exists == 'true'
|
|
146
|
+
env:
|
|
147
|
+
GH_TOKEN: ${{ env.FORGE_TOKEN }}
|
|
148
|
+
UPSTREAM_REPO: ${{ github.repository }}
|
|
149
|
+
PR_NUMBER: ${{ github.event.pull_request.number }}
|
|
150
|
+
MERGED_BRANCH: ${{ github.event.pull_request.head.ref }}
|
|
151
|
+
PR_URL: ${{ github.event.pull_request.html_url }}
|
|
152
|
+
PR_TITLE: ${{ github.event.pull_request.title }}
|
|
153
|
+
SATELLITES_JSON: ${{ steps.forge_parse.outputs.satellites_json }}
|
|
154
|
+
CHANGED_FILES_JSON: ${{ steps.forge_parse.outputs.changed_files_json }}
|
|
155
|
+
run: |
|
|
156
|
+
python3 -c "
|
|
157
|
+
import json, os, subprocess, sys, fnmatch
|
|
158
|
+
|
|
159
|
+
upstream_repo = os.environ['UPSTREAM_REPO']
|
|
160
|
+
pr_number = os.environ['PR_NUMBER']
|
|
161
|
+
merged_branch = os.environ['MERGED_BRANCH']
|
|
162
|
+
pr_url = os.environ['PR_URL']
|
|
163
|
+
pr_title = os.environ['PR_TITLE']
|
|
164
|
+
satellites_json = os.environ.get('SATELLITES_JSON', '[]')
|
|
165
|
+
changed_files_json = os.environ.get('CHANGED_FILES_JSON', '[]')
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
satellites = json.loads(satellites_json) if satellites_json else []
|
|
169
|
+
except json.JSONDecodeError:
|
|
170
|
+
satellites = []
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
changed_files = json.loads(changed_files_json) if changed_files_json else []
|
|
174
|
+
except json.JSONDecodeError:
|
|
175
|
+
changed_files = []
|
|
176
|
+
|
|
177
|
+
if not satellites:
|
|
178
|
+
print('No satellites with trigger_on_merge: true found in forge.yaml')
|
|
179
|
+
sys.exit(0)
|
|
180
|
+
|
|
181
|
+
if not changed_files:
|
|
182
|
+
print('No changed files found in merged PR — nothing to match against trigger_paths')
|
|
183
|
+
sys.exit(0)
|
|
184
|
+
|
|
185
|
+
def matches_glob(path, pattern):
|
|
186
|
+
if fnmatch.fnmatch(path, pattern):
|
|
187
|
+
return True
|
|
188
|
+
if '**' not in pattern:
|
|
189
|
+
return False
|
|
190
|
+
parts = pattern.split('**')
|
|
191
|
+
prefix = parts[0].rstrip('/')
|
|
192
|
+
suffix = parts[-1].lstrip('/')
|
|
193
|
+
if prefix and not path.startswith(prefix):
|
|
194
|
+
return False
|
|
195
|
+
if suffix and not (fnmatch.fnmatch(path, suffix) or '/' + suffix in path):
|
|
196
|
+
return False
|
|
197
|
+
return True
|
|
198
|
+
|
|
199
|
+
issues_created = 0
|
|
200
|
+
MAX_ISSUES = 10
|
|
201
|
+
NL = chr(10)
|
|
202
|
+
|
|
203
|
+
for sat in satellites:
|
|
204
|
+
if issues_created >= MAX_ISSUES:
|
|
205
|
+
print(f'Rate limit reached: {MAX_ISSUES} satellite issues created. Stopping.')
|
|
206
|
+
break
|
|
207
|
+
|
|
208
|
+
sat_repo = sat.get('repo', '')
|
|
209
|
+
trigger_paths = sat.get('trigger_paths', [])
|
|
210
|
+
auto_run = sat.get('auto_run', False)
|
|
211
|
+
|
|
212
|
+
if not sat_repo:
|
|
213
|
+
continue
|
|
214
|
+
|
|
215
|
+
# Circular dependency guard
|
|
216
|
+
if sat_repo == upstream_repo:
|
|
217
|
+
print(f'Skipping {sat_repo}: circular dependency guard (satellite == upstream repo)')
|
|
218
|
+
continue
|
|
219
|
+
|
|
220
|
+
# Glob matching against trigger_paths
|
|
221
|
+
if trigger_paths:
|
|
222
|
+
matched_files = [
|
|
223
|
+
f for f in changed_files
|
|
224
|
+
if any(matches_glob(f, p) for p in trigger_paths)
|
|
225
|
+
]
|
|
226
|
+
if not matched_files:
|
|
227
|
+
print(f'Skipping {sat_repo}: no changed files match trigger_paths {trigger_paths}')
|
|
228
|
+
continue
|
|
229
|
+
else:
|
|
230
|
+
matched_files = list(changed_files)
|
|
231
|
+
print(f'{sat_repo}: no trigger_paths set — triggering on all {len(matched_files)} changed file(s)')
|
|
232
|
+
|
|
233
|
+
print(f'Creating issue in {sat_repo}: {len(matched_files)} matched file(s)')
|
|
234
|
+
|
|
235
|
+
matched_list = NL.join(f'- {f}' for f in matched_files)
|
|
236
|
+
auto_run_note = (
|
|
237
|
+
NL + '> **auto_run** is set for this satellite. '
|
|
238
|
+
'Run /work-on on this issue number to begin automated investigation.' + NL
|
|
239
|
+
if auto_run else ''
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
issue_body = (
|
|
243
|
+
'<!-- FORGE:CROSS_REPO_TRIGGER -->' + NL +
|
|
244
|
+
'## Cross-Repo Trigger' + NL + NL +
|
|
245
|
+
f'A PR merged in **{upstream_repo}** touches paths relevant to this satellite repo.' + NL + NL +
|
|
246
|
+
'### Upstream Event' + NL +
|
|
247
|
+
f'- Upstream repo: {upstream_repo}' + NL +
|
|
248
|
+
f'- Merged PR: #{pr_number} ({pr_url})' + NL +
|
|
249
|
+
f'- PR title: {pr_title}' + NL +
|
|
250
|
+
f'- Merged branch: {merged_branch}' + NL + NL +
|
|
251
|
+
'### Matched Files' + NL +
|
|
252
|
+
f'These changed files matched this satellites trigger_paths:' + NL + NL +
|
|
253
|
+
matched_list + NL + NL +
|
|
254
|
+
'### Recommended Action' + NL +
|
|
255
|
+
'Investigate whether these changes affect this satellite repos contracts, types, or integrations.' + NL + NL +
|
|
256
|
+
f'Review the diff at {pr_url} or run /work-on on this issue to begin automated investigation.' + NL +
|
|
257
|
+
auto_run_note + NL +
|
|
258
|
+
'### Context' + NL +
|
|
259
|
+
'Created automatically by the ForgeDock cross-repo trigger workflow.' + NL +
|
|
260
|
+
'See https://github.com/RapierCraftStudios/ForgeDock for documentation.' + NL
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
issue_title = f'feat(cross-repo): upstream merge in {upstream_repo} (PR #{pr_number}) — investigate impact'
|
|
264
|
+
|
|
265
|
+
result = subprocess.run(
|
|
266
|
+
['gh', 'issue', 'create',
|
|
267
|
+
'-R', sat_repo,
|
|
268
|
+
'--title', issue_title,
|
|
269
|
+
'--body', issue_body],
|
|
270
|
+
capture_output=True, text=True
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
if result.returncode == 0:
|
|
274
|
+
issue_url = result.stdout.strip()
|
|
275
|
+
print(f'Created issue in {sat_repo}: {issue_url}')
|
|
276
|
+
issues_created += 1
|
|
277
|
+
else:
|
|
278
|
+
print(f'ERROR creating issue in {sat_repo}: {result.stderr.strip()}')
|
|
279
|
+
print('Hint: Ensure FORGE_PAT has issues:write access to this satellite repo.')
|
|
280
|
+
|
|
281
|
+
print(f'Done: {issues_created} satellite issue(s) created.')
|
|
282
|
+
"
|