3"""Render a review-only GitHub script with complete preflight and recovery."""
5from __future__
import annotations
9from dataclasses
import dataclass
11from work_plan
import Node, Plan, _require_safe_plan, plan_to_json
14def _record_call(*cells: str) -> str:
15 """Return one argv-preserving recovery-ledger function call."""
16 return "record_recovery " +
" ".join(shlex.quote(cell)
for cell
in cells)
19@dataclass(frozen=True)
21 """The data for one issue creation and its project fields."""
25 fields: tuple[tuple[str, str], ...]
27 depends_on: tuple[str, ...]
30def _issue_body(node: Node) -> str:
31 """Build one issue body, including stable planning metadata."""
32 parts = [node.body]
if node.body
else []
33 trailer = [f
"Plan-key: {node.key}"]
35 trailer.append(f
"Priority: {node.priority}")
37 trailer.append(f
"Estimate: {node.estimate}")
38 parts.append(
"\n".join(trailer))
39 return "\n\n".join(parts)
42def issue_commands(plan: Plan) -> list[EmittedCommand]:
43 """Build every issue command in deterministic dependency order."""
44 _require_safe_plan(plan)
46 built: list[EmittedCommand] = []
47 for key
in plan.order:
49 argv: list[str] = [
"--title", node.title,
"--body", _issue_body(node)]
50 for label
in node.labels:
51 argv.extend([
"--label", label])
55 (
"Status", node.status),
56 (
"Track", node.track),
57 (
"Priority", node.priority),
67 depends_on=node.depends_on,
73def _script_targeting(plan: Plan, plan_id: str) -> str:
74 """Return the fixed GitHub targeting preamble for the emitted script."""
76# shellcheck disable=SC2016 # GraphQL and hostile notes data must remain literal argv.
77# REVIEW BEFORE RUNNING. This script was emitted, never executed by work.
81GH_HOST={shlex.quote(plan.github_host)}
82TARGET_REPO={shlex.quote(plan.repository)}
83PROJECT_OWNER={shlex.quote(plan.project_owner)}
84PROJECT_NUMBER={plan.project_number}
92def _script_ledger_anchor() -> str:
93 """Return the fail-closed HOME anchor for the mutation recovery ledger.
95 The rerun guard is only as strong as this anchor, so every untrustworthy
96 HOME shape refuses here rather than being resolved into a fresh ledger.
98 return """# The rerun guard below is only as strong as this anchor. A ledger placed
99# relative to the working directory refused a second run from the SAME
100# directory and silently created a duplicate set of issues from any other
101# one, which is exactly what the guard claims to prevent. Anchor it to the
102# invoking operator, so one plan has one ledger wherever this is run from.
103# A relative HOME would put the anchor back under the working directory, so
104# it is rejected rather than resolved.
106 printf '%s\\n' "$@" >&2
111 ledger_refusal "HOME must be set; it anchors the GitHub mutation recovery ledger"
116 "HOME must be an absolute path; a relative HOME re-anchors the" \\
117 "recovery ledger to the working directory"
121 ledger_refusal "HOME does not name an existing directory;" \\
122 "refusing to fabricate a recovery ledger root"
123RECOVERY_ROOT="$HOME/.ra8-work-recovery"
124RECOVERY_DIR="$RECOVERY_ROOT/$PLAN_ID"
125RECOVERY_LEDGER="$RECOVERY_DIR/ledger.tsv"
129def _script_recovery_harness() -> str:
130 """Return the recovery-ledger writer, reporter, and signal traps."""
135 printf '%s' "$first" >&3
137 printf '\\t%s' "$field" >&3
144 trap - EXIT HUP INT TERM
145 if [ "$MUTATION_STARTED" -eq 1 ]; then
146 printf '%s\\n' "GitHub mutation recovery ledger: $RECOVERY_LEDGER" >&2
147 cat "$RECOVERY_LEDGER" >&2
148 if [ "$rc" -ne 0 ]; then
150 "A mutation failed. Do not rerun blindly and do not auto-delete." \\
151 "Review BEGIN rows without matching result rows, inspect the exact" \\
152 "repository/project above, then resume or clean up each URL/item by hand." >&2
158trap report_recovery EXIT
159trap 'exit 130' HUP INT TERM
163def _script_header(plan: Plan) -> str:
164 """Return fixed GitHub targeting and the fail-closed recovery harness."""
165 plan_id = hashlib.sha256(plan_to_json(plan).encode(
"utf-8")).hexdigest()[:20]
167 _script_targeting(plan, plan_id)
168 + _script_ledger_anchor()
169 + _script_recovery_harness()
174def _board_helpers() -> str:
175 """Return exact project discovery and field helper functions."""
176 return """load_project() {
177 gh api --hostname "$GH_HOST" graphql \
178 -f login="$PROJECT_OWNER" -F number="$PROJECT_NUMBER" -f query='
179 query($login: String!, $number: Int!) {
180 user(login: $login) {
181 projectV2(number: $number) { id number title viewerCanUpdate }
183 }' --jq '.data.user.projectV2'
186load_project_fields() {
187 gh api --hostname "$GH_HOST" graphql --paginate -f project="$PROJECT_ID" -f query='
188 query($project: ID!, $endCursor: String) {
191 fields(first: 100, after: $endCursor) {
193 ... on ProjectV2SingleSelectField { id name options { id name } }
195 pageInfo { hasNextPage endCursor }
199 }' --jq '.data.node.fields.nodes[]'
203 count="$(printf %s "$PROJECT_FIELDS" | jq -s --arg n "$1" \
204 '[.[] | select(.name==$n)] | length')"
205 [ "$count" -eq 1 ] || { echo "field must resolve exactly once: $1" >&2; exit 1; }
209 printf %s "$PROJECT_FIELDS" | jq -r -s --arg n "$1" \
210 '[.[] | select(.name==$n)][0].id'
214 count="$(printf %s "$PROJECT_FIELDS" | jq -s --arg n "$1" --arg o "$2" \
215 '[.[] | select(.name==$n) | .options[] | select(.name==$o)] | length')"
216 [ "$count" -eq 1 ] || {
217 echo "option must resolve exactly once: $1 / $2" >&2
223 printf %s "$PROJECT_FIELDS" | jq -r -s --arg n "$1" --arg o "$2" \
224 '[.[] | select(.name==$n) | .options[] | select(.name==$o)][0].id'
230def _preflight_authorization(plan: Plan) -> list[str]:
231 """Return the identity and permission proofs that precede any mutation."""
233 'command -v gh >/dev/null 2>&1 || { echo "gh is required" >&2; exit 1; }',
234 'command -v jq >/dev/null 2>&1 || { echo "jq is required" >&2; exit 1; }',
235 'gh auth status --hostname "$GH_HOST" >/dev/null',
236 'REPO_META="$(gh api --hostname "$GH_HOST" "repos/$TARGET_REPO")"',
237 f
'[ "$(printf %s "$REPO_META" | jq -r .full_name)" = {shlex.quote(plan.repository)} ] || '
238 '{ echo "repository identity mismatch" >&2; exit 1; }',
239 '[ "$(printf %s "$REPO_META" | jq -r .has_issues)" = true ] || '
240 '{ echo "issues are disabled" >&2; exit 1; }',
241 '[ "$(printf %s "$REPO_META" | jq -r .permissions.push)" = true ] || '
242 '{ echo "token cannot create issues in the exact repository" >&2; exit 1; }',
243 'PROJECT_META="$(load_project)"',
244 '[ "$(printf %s "$PROJECT_META" | jq -r .number)" -eq "$PROJECT_NUMBER" ] || '
245 '{ echo "exact project did not resolve" >&2; exit 1; }',
246 '[ "$(printf %s "$PROJECT_META" | jq -r .viewerCanUpdate)" = true ] || '
247 '{ echo "token cannot update the exact project" >&2; exit 1; }',
248 'PROJECT_ID="$(printf %s "$PROJECT_META" | jq -r .id)"',
249 '[ -n "$PROJECT_ID" ] && [ "$PROJECT_ID" != null ] || '
250 '{ echo "project id is absent" >&2; exit 1; }',
251 'PROJECT_FIELDS="$(load_project_fields)"',
255def _preflight_schema(commands: list[EmittedCommand]) -> list[str]:
256 """Return the field, option, and label existence proofs for this plan."""
257 fields = sorted({field
for command
in commands
for field, _value
in command.fields})
258 options = sorted({item
for command
in commands
for item
in command.fields})
262 for command
in commands
263 for flag, value
in zip(command.argv[0::2], command.argv[1::2], strict=
True)
267 lines: list[str] = []
268 lines.extend(f
"require_field {shlex.quote(name)}" for name
in fields)
270 f
"require_option {shlex.quote(name)} {shlex.quote(value)}" for name, value
in options
273 'REPO_LABELS="$(gh api --hostname "$GH_HOST" --paginate '
274 '"repos/$TARGET_REPO/labels?per_page=100" --jq \'.[] | {name: .name}\')"'
277 quoted = shlex.quote(label)
279 f
'[ "$(printf %s "$REPO_LABELS" | jq -s --arg n {quoted} '
280 "'[.[] | select(.name==$n)] | length')\" -eq 1 ] || "
281 '{ echo "a required label did not resolve exactly once" >&2; exit 1; }'
285 '[ ! -L "$RECOVERY_ROOT" ] || ledger_refusal '
286 '"the recovery ledger root is a symlink; refusing to follow it"',
287 '[ ! -e "$RECOVERY_ROOT" ] || [ -d "$RECOVERY_ROOT" ] || ledger_refusal '
288 '"the recovery ledger root exists and is not a directory"',
289 '[ -d "$RECOVERY_ROOT" ] || mkdir -m 700 "$RECOVERY_ROOT"',
293 '[ ! -L "$RECOVERY_ROOT" ] && [ -d "$RECOVERY_ROOT" ] || ledger_refusal '
294 '"the recovery ledger root is not a plain directory; refusing to use it"',
295 '[ ! -e "$RECOVERY_DIR" ] && [ ! -L "$RECOVERY_DIR" ] || '
296 "{ printf '%s\\n' \"this plan already has a recovery ledger at "
297 '$RECOVERY_DIR; review it instead of duplicating issues" >&2; exit 1; }',
298 'mkdir -m 700 "$RECOVERY_DIR"',
299 'exec 3>"$RECOVERY_LEDGER"',
300 'record_recovery plan "$PLAN_ID" "$GH_HOST" "$TARGET_REPO" '
301 '"$PROJECT_OWNER/$PROJECT_NUMBER"',
302 'echo "preflight complete; all following commands mutate GitHub" >&2',
308def _render_preflight(plan: Plan, commands: list[EmittedCommand]) -> str:
309 """Render every authorization and schema proof before mutation."""
310 lines = _preflight_authorization(plan) + _preflight_schema(commands)
311 return "\n".join(lines) +
"\n\n"
314def _render_create(command: EmittedCommand, number_vars: dict[str, str]) -> str:
315 """Render issue creation plus immediate recovery evidence."""
316 body = command.argv[3]
317 lines = [f
"ISSUE_BODY={shlex.quote(body)}"]
318 if command.epic
is not None:
319 lines.extend([
'ISSUE_BODY="${ISSUE_BODY}', f
'Epic: #${{{number_vars[command.epic]}}}"'])
320 for dependency
in command.depends_on:
321 lines.extend([
'ISSUE_BODY="${ISSUE_BODY}', f
'Depends-on: #${{{number_vars[dependency]}}}"'])
324 "MUTATION_STARTED=1",
325 _record_call(
"BEGIN",
"issue", command.key),
326 'ISSUE_URL="$(gh issue create --repo "$TARGET_REPO" \\',
329 pairs = list(zip(command.argv[0::2], command.argv[1::2], strict=
True))
330 for position, (flag, value)
in enumerate(pairs):
331 value_text =
'"$ISSUE_BODY"' if flag ==
"--body" else shlex.quote(value)
332 tail =
"\\" if position + 1 < len(pairs)
else ')"'
333 lines.append(f
" {flag} {value_text} {tail}")
336 'case "$ISSUE_URL" in',
337 ' "https://github.com/$TARGET_REPO/issues/"*) ;;',
338 ' *) echo "created issue URL did not match exact host/repository" >&2; exit 1 ;;',
340 f
'{number_vars[command.key]}="${{ISSUE_URL##*/}}"',
341 f
'case "${{{number_vars[command.key]}}}" in ""|*[!0-9]*) exit 1 ;; esac',
342 f
'record_recovery issue {shlex.quote(command.key)} "$ISSUE_URL"',
345 return "\n".join(lines) +
"\n"
348def _render_board(command: EmittedCommand, number_var: str) -> str:
349 """Render board placement and field updates with per-step evidence."""
352 _record_call(
"BEGIN",
"item", key),
353 f
'CONTENT_ID="$(gh api --hostname "$GH_HOST" '
354 f
'"repos/$TARGET_REPO/issues/${{{number_var}}}" --jq .node_id)"',
355 'ITEM_ID="$(gh api --hostname "$GH_HOST" graphql -f project="$PROJECT_ID" '
356 '-f content="$CONTENT_ID" -f query=\'mutation($project: ID!, $content: ID!) {'
357 " addProjectV2ItemById(input: {projectId: $project, contentId: $content}) {"
358 " item { id } } }' --jq .data.addProjectV2ItemById.item.id)\"",
359 'case "$ITEM_ID" in ""|*[!A-Za-z0-9_-]*) '
360 'echo "invalid project item id" >&2; exit 1 ;; esac',
361 f
'record_recovery item {shlex.quote(key)} "$ITEM_ID"',
363 for field, value
in command.fields:
364 field_q = shlex.quote(field)
365 value_q = shlex.quote(value)
368 _record_call(
"BEGIN",
"field", key, field, value),
369 'gh api --hostname "$GH_HOST" graphql -f project="$PROJECT_ID" '
370 f
'-f item="$ITEM_ID" -f field="$(field_id {field_q})" '
371 f
'-f option="$(option_id {field_q} {value_q})" -f query=\''
372 "mutation($project: ID!, $item: ID!, $field: ID!, $option: String!) {"
373 " updateProjectV2ItemFieldValue(input: {projectId: $project, itemId: $item,"
374 " fieldId: $field, value: {singleSelectOptionId: $option}}) {"
375 " projectV2Item { id } } }' >/dev/null",
376 _record_call(
"field", key, field, value),
379 return "\n".join(lines) +
"\n"
382def render_commands(plan: Plan) -> str:
383 """Render the complete human-reviewed GitHub mutation script."""
384 commands = issue_commands(plan)
386 command.key: f
"ISSUE_NUMBER_{position}" for position, command
in enumerate(commands, 1)
388 chunks = [_script_header(plan), _board_helpers(), _render_preflight(plan, commands)]
389 for command
in commands:
390 chunks.append(f
"# --- {command.key} " +
"-" * max(1, 60 - len(command.key)) +
"\n")
391 chunks.append(_render_create(command, number_vars))
392 chunks.append(_render_board(command, number_vars[command.key]))
394 chunks.append(
'record_recovery complete "$PLAN_ID"\n')
396 'echo "all mutations completed; retain $RECOVERY_LEDGER as the operator record" >&2\n'
398 return "".join(chunks)