ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
work_emit.py
Go to the documentation of this file.
1# SPDX-License-Identifier: MIT
2# Copyright (c) 2026 Brighton Sikarskie
3"""Render a review-only GitHub script with complete preflight and recovery."""
4
5from __future__ import annotations
6
7import hashlib
8import shlex
9from dataclasses import dataclass
10
11from work_plan import Node, Plan, _require_safe_plan, plan_to_json
12
13
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)
17
18
19@dataclass(frozen=True)
20class EmittedCommand:
21 """The data for one issue creation and its project fields."""
22
23 key: str
24 argv: tuple[str, ...]
25 fields: tuple[tuple[str, str], ...]
26 epic: str | None
27 depends_on: tuple[str, ...]
28
29
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}"]
34 if node.priority:
35 trailer.append(f"Priority: {node.priority}")
36 if node.estimate:
37 trailer.append(f"Estimate: {node.estimate}")
38 parts.append("\n".join(trailer))
39 return "\n\n".join(parts)
40
41
42def issue_commands(plan: Plan) -> list[EmittedCommand]:
43 """Build every issue command in deterministic dependency order."""
44 _require_safe_plan(plan)
45 index = plan.by_key()
46 built: list[EmittedCommand] = []
47 for key in plan.order:
48 node = index[key]
49 argv: list[str] = ["--title", node.title, "--body", _issue_body(node)]
50 for label in node.labels:
51 argv.extend(["--label", label])
52 fields = tuple(
53 (name, value)
54 for name, value in (
55 ("Status", node.status),
56 ("Track", node.track),
57 ("Priority", node.priority),
58 )
59 if value is not None
60 )
61 built.append(
62 EmittedCommand(
63 key=node.key,
64 argv=tuple(argv),
65 fields=fields,
66 epic=node.epic,
67 depends_on=node.depends_on,
68 )
69 )
70 return built
71
72
73def _script_targeting(plan: Plan, plan_id: str) -> str:
74 """Return the fixed GitHub targeting preamble for the emitted script."""
75 return f"""#!/bin/sh
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.
78set -eu
79umask 077
80
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}
85PLAN_ID={plan_id}
86export GH_HOST
87
88MUTATION_STARTED=0
89"""
90
91
92def _script_ledger_anchor() -> str:
93 """Return the fail-closed HOME anchor for the mutation recovery ledger.
94
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.
97 """
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.
105ledger_refusal() {
106 printf '%s\\n' "$@" >&2
107 exit 2
108}
109
110[ -n "${HOME-}" ] ||
111 ledger_refusal "HOME must be set; it anchors the GitHub mutation recovery ledger"
112case $HOME in
113 /*) ;;
114 *)
115 ledger_refusal \\
116 "HOME must be an absolute path; a relative HOME re-anchors the" \\
117 "recovery ledger to the working directory"
118 ;;
119esac
120[ -d "$HOME" ] ||
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"
126"""
127
128
129def _script_recovery_harness() -> str:
130 """Return the recovery-ledger writer, reporter, and signal traps."""
131 return """
132record_recovery() {
133 first=$1
134 shift
135 printf '%s' "$first" >&3
136 for field do
137 printf '\\t%s' "$field" >&3
138 done
139 printf '\\n' >&3
140}
141
142report_recovery() {
143 rc=$?
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
149 printf '%s\\n' \\
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
153 fi
154 fi
155 exit "$rc"
156}
157
158trap report_recovery EXIT
159trap 'exit 130' HUP INT TERM
160"""
161
162
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]
166 return (
167 _script_targeting(plan, plan_id)
168 + _script_ledger_anchor()
169 + _script_recovery_harness()
170 + "\n"
171 )
172
173
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 }
182 }
183 }' --jq '.data.user.projectV2'
184}
185
186load_project_fields() {
187 gh api --hostname "$GH_HOST" graphql --paginate -f project="$PROJECT_ID" -f query='
188 query($project: ID!, $endCursor: String) {
189 node(id: $project) {
190 ... on ProjectV2 {
191 fields(first: 100, after: $endCursor) {
192 nodes {
193 ... on ProjectV2SingleSelectField { id name options { id name } }
194 }
195 pageInfo { hasNextPage endCursor }
196 }
197 }
198 }
199 }' --jq '.data.node.fields.nodes[]'
200}
201
202require_field() {
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; }
206}
207
208field_id() {
209 printf %s "$PROJECT_FIELDS" | jq -r -s --arg n "$1" \
210 '[.[] | select(.name==$n)][0].id'
211}
212
213require_option() {
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
218 exit 1
219 }
220}
221
222option_id() {
223 printf %s "$PROJECT_FIELDS" | jq -r -s --arg n "$1" --arg o "$2" \
224 '[.[] | select(.name==$n) | .options[] | select(.name==$o)][0].id'
225}
226
227"""
228
229
230def _preflight_authorization(plan: Plan) -> list[str]:
231 """Return the identity and permission proofs that precede any mutation."""
232 return [
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)"',
252 ]
253
254
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})
259 labels = sorted(
260 {
261 value
262 for command in commands
263 for flag, value in zip(command.argv[0::2], command.argv[1::2], strict=True)
264 if flag == "--label"
265 }
266 )
267 lines: list[str] = []
268 lines.extend(f"require_field {shlex.quote(name)}" for name in fields)
269 lines.extend(
270 f"require_option {shlex.quote(name)} {shlex.quote(value)}" for name, value in options
271 )
272 lines.append(
273 'REPO_LABELS="$(gh api --hostname "$GH_HOST" --paginate '
274 '"repos/$TARGET_REPO/labels?per_page=100" --jq \'.[] | {name: .name}\')"'
275 )
276 for label in labels:
277 quoted = shlex.quote(label)
278 lines.append(
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; }'
282 )
283 lines.extend(
284 [
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"',
290 # Re-check after the create: this is the only thing standing between
291 # a symlink swapped in during the window above and a ledger written
292 # through it. Deleting it leaves every other check passing.
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',
303 ]
304 )
305 return lines
306
307
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"
312
313
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]}}}"'])
322 lines.extend(
323 [
324 "MUTATION_STARTED=1",
325 _record_call("BEGIN", "issue", command.key),
326 'ISSUE_URL="$(gh issue create --repo "$TARGET_REPO" \\',
327 ]
328 )
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}")
334 lines.extend(
335 [
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 ;;',
339 "esac",
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"',
343 ]
344 )
345 return "\n".join(lines) + "\n"
346
347
348def _render_board(command: EmittedCommand, number_var: str) -> str:
349 """Render board placement and field updates with per-step evidence."""
350 key = command.key
351 lines = [
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"',
362 ]
363 for field, value in command.fields:
364 field_q = shlex.quote(field)
365 value_q = shlex.quote(value)
366 lines.extend(
367 [
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),
377 ]
378 )
379 return "\n".join(lines) + "\n"
380
381
382def render_commands(plan: Plan) -> str:
383 """Render the complete human-reviewed GitHub mutation script."""
384 commands = issue_commands(plan)
385 number_vars = {
386 command.key: f"ISSUE_NUMBER_{position}" for position, command in enumerate(commands, 1)
387 }
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]))
393 chunks.append("\n")
394 chunks.append('record_recovery complete "$PLAN_ID"\n')
395 chunks.append(
396 'echo "all mutations completed; retain $RECOVERY_LEDGER as the operator record" >&2\n'
397 )
398 return "".join(chunks)