mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-13 11:57:19 +01:00
feat(ship,spec,review): PR and issue text goes out only through gstack-post
/ship: Step 18 writes the title into an agent-written file. An existing PR's title is piped from gh/glab through gstack-pr-title-rewrite.sh --stdin (new), so neither the agent nor a shell string carries it; a new title is written with the file tool and its version prefix checked in the file. Step 19 composes the body as before and publishes with gstack-post pr-create, or pr-body then pr-title, which scan the exact bytes they send. The separate title and body scans, the hand-rolled REST fallback and the title self-check are gone (the helper does all three). Title preparation moved into the pr-body section, so the skeleton shrinks. /spec files its issue with gstack-post issue-create and drops the pre-issue re-scan; Greptile replies use gstack-post reply and pr-comment. scripts/resolvers/redact-doc.ts loses its PR and issue sinks, and a test keeps any template from rendering one again. Tests that pinned the old scan-then-gh wiring now pin the gstack-post wiring; the publication tests run the real compose and publish blocks against a stub gh with the real helper (HIGH in title or body refused, MEDIUM token flow). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
eac410f7e2
commit
b104c3df4b
21 files changed
+419
-268
No files matched your search
@@ -2,7 +2,10 @@
|
||||
# Rewrite a PR/MR title to start with v<NEW_VERSION>.
|
||||
#
|
||||
# Usage: bin/gstack-pr-title-rewrite.sh <NEW_VERSION> <CURRENT_TITLE>
|
||||
# Output: corrected title on stdout.
|
||||
# gh pr view N --json title -q .title | bin/gstack-pr-title-rewrite.sh <NEW_VERSION> --stdin
|
||||
# Output: corrected title on stdout. --stdin reads the title from standard
|
||||
# input, so /ship pipes the platform's title through without the agent or a
|
||||
# shell string ever carrying it.
|
||||
#
|
||||
# Rule: PR titles MUST start with v<NEW_VERSION>. Three cases:
|
||||
# 1. Already starts with "v<NEW_VERSION>" -> no change.
|
||||
@@ -25,12 +28,17 @@
|
||||
set -euo pipefail
|
||||
|
||||
if [ $# -lt 2 ]; then
|
||||
echo "usage: $0 <NEW_VERSION> <CURRENT_TITLE>" >&2
|
||||
echo "usage: $0 <NEW_VERSION> <CURRENT_TITLE|--stdin>" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
NEW_VERSION="$1"
|
||||
TITLE="$2"
|
||||
if [ "$TITLE" = "--stdin" ] && [ $# -eq 2 ]; then
|
||||
TITLE=$(cat)
|
||||
case "$TITLE" in *$'\n'*) echo "error: --stdin title must be one line" >&2; exit 2 ;; esac
|
||||
[ -n "$TITLE" ] || { echo "error: --stdin read an empty title" >&2; exit 2; }
|
||||
fi
|
||||
|
||||
# Reject malformed NEW_VERSION early. Real values are dot-separated digits;
|
||||
# anything with shell pattern metacharacters or whitespace is a caller bug.
|
||||
|
||||
@@ -111,20 +111,25 @@ REPLY_FILE=$(mktemp "${_GT:?}/reply.XXXXXX") || { echo "Not sent: mktemp failed
|
||||
|
||||
Line-level comments (from `pulls/$PR/comments`):
|
||||
```bash
|
||||
REPO=$(gh repo view --json nameWithOwner --jq '.nameWithOwner') && PR_NUMBER=$(gh pr view --json number --jq '.number') || { echo "Not sent: gh could not resolve the repository and PR." >&2; exit 1; }
|
||||
PR_NUMBER=$(gh pr view --json number --jq '.number') || { echo "Not sent: gh could not resolve the PR." >&2; exit 1; }
|
||||
REPLY_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/<reply-file-name>"
|
||||
[ -s "$REPLY_FILE" ] || { echo "Not sent: $REPLY_FILE is missing or empty, so the text was never written. Write it, then send by hand: gh api repos/$REPO/pulls/$PR_NUMBER/comments/<comment-id>/replies -F body=@$REPLY_FILE" >&2; exit 1; }
|
||||
gh api "repos/$REPO/pulls/$PR_NUMBER/comments/<comment-id>/replies" -F "body=@$REPLY_FILE" >/dev/null && rm -f "$REPLY_FILE"
|
||||
[ -s "$REPLY_FILE" ] || { echo "Not sent: $REPLY_FILE is missing or empty, so the text was never written. Write it, then send by hand: ~/.claude/skills/gstack/bin/gstack-post reply $PR_NUMBER --to <comment-id> --body-file $REPLY_FILE" >&2; exit 1; }
|
||||
~/.claude/skills/gstack/bin/gstack-post reply "$PR_NUMBER" --to <comment-id> --body-file "$REPLY_FILE" && rm -f "$REPLY_FILE"
|
||||
```
|
||||
|
||||
Top-level comments (from `issues/$PR/comments`):
|
||||
```bash
|
||||
REPO=$(gh repo view --json nameWithOwner --jq '.nameWithOwner') && PR_NUMBER=$(gh pr view --json number --jq '.number') || { echo "Not sent: gh could not resolve the repository and PR." >&2; exit 1; }
|
||||
PR_NUMBER=$(gh pr view --json number --jq '.number') || { echo "Not sent: gh could not resolve the PR." >&2; exit 1; }
|
||||
REPLY_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/<reply-file-name>"
|
||||
[ -s "$REPLY_FILE" ] || { echo "Not sent: $REPLY_FILE is missing or empty, so the text was never written. Write it, then send by hand: gh pr comment $PR_NUMBER --body-file $REPLY_FILE" >&2; exit 1; }
|
||||
gh api "repos/$REPO/issues/$PR_NUMBER/comments" -F "body=@$REPLY_FILE" >/dev/null && rm -f "$REPLY_FILE"
|
||||
[ -s "$REPLY_FILE" ] || { echo "Not sent: $REPLY_FILE is missing or empty, so the text was never written. Write it, then send by hand: ~/.claude/skills/gstack/bin/gstack-post pr-comment $PR_NUMBER --body-file $REPLY_FILE" >&2; exit 1; }
|
||||
~/.claude/skills/gstack/bin/gstack-post pr-comment "$PR_NUMBER" --body-file "$REPLY_FILE" && rm -f "$REPLY_FILE"
|
||||
```
|
||||
|
||||
`gstack-post` scans the reply before sending it. Exit 1 (HIGH) means the reply quotes a
|
||||
credential: rewrite it without the value. Exit 2 (MEDIUM) prints `RULE:` lines and a
|
||||
`TOKEN:`: rewrite the reply, or ask the user and rerun the block with
|
||||
`--confirm <confirm-token>` added to the `gstack-post` command.
|
||||
|
||||
**If a reply POST fails** (e.g., PR was closed, no write permission): warn and continue. Do not stop the workflow for a failed reply.
|
||||
|
||||
---
|
||||
|
||||
@@ -6,10 +6,13 @@
|
||||
* and /cso never drift from the engine.
|
||||
* {{REDACT_INVOCATION_BLOCK:<sink>}} → the canonical scan-at-sink bash + prose
|
||||
* for one enforcement point. <sink> is a
|
||||
* hyphenated label: pre-codex, pre-issue,
|
||||
* pre-archive, pre-pr-body, pre-pr-title,
|
||||
* hyphenated label: pre-codex, pre-archive,
|
||||
* pre-commit.
|
||||
*
|
||||
* PR, MR and issue text has no sink here: bin/gstack-post scans the exact
|
||||
* bytes it sends and refuses HIGH itself (CEO-19), so those sites carry no
|
||||
* separate scan.
|
||||
*
|
||||
* DRY: every skill writes one placeholder per enforcement point; UX/threshold
|
||||
* changes land here once. test/redact-doc-resolver.test.ts golden-pins the output.
|
||||
*/
|
||||
@@ -25,17 +28,15 @@ interface SinkSpec {
|
||||
|
||||
const SINKS: Record<string, SinkSpec> = {
|
||||
'pre-codex': { noun: 'the spec body', blockVerb: 'dispatch to the outside reviewer' },
|
||||
'pre-issue': { noun: "the issue body you're about to file", blockVerb: 'file the issue' },
|
||||
'pre-archive': { noun: 'the body about to be archived', blockVerb: 'write the archive' },
|
||||
'pre-pr-body': { noun: 'the composed PR body', blockVerb: 'create/edit the PR' },
|
||||
'pre-pr-title': { noun: 'the PR title', blockVerb: 'set the PR title' },
|
||||
'pre-commit': { noun: 'the generated docs about to be committed', blockVerb: 'commit' },
|
||||
};
|
||||
const UNKNOWN_SINK: SinkSpec = { noun: 'the text about to be sent', blockVerb: 'send it' };
|
||||
|
||||
export function generateRedactInvocationBlock(ctx: TemplateContext, args?: string[]): string {
|
||||
const sinkLabel = args?.[0] ?? 'pre-issue';
|
||||
const sinkLabel = args?.[0] ?? 'unknown';
|
||||
const brief = args?.[1] === 'brief';
|
||||
const sink = SINKS[sinkLabel] ?? SINKS['pre-issue'];
|
||||
const sink = SINKS[sinkLabel] ?? UNKNOWN_SINK;
|
||||
const bin = `${ctx.paths.binDir}/gstack-redact`;
|
||||
const outsideGate = sinkLabel === 'pre-codex';
|
||||
const scan = `REDACT_JSON=$(${outsideGate ? `"${toShellPath(bin)}"` : bin} --from-file "$REDACT_FILE" --repo-visibility "$REDACT_VIS" --self-email "$(git config user.email 2>/dev/null)" --json)`;
|
||||
|
||||
+3
-10
@@ -496,7 +496,7 @@ sections. Read a section in full before doing its step; do not work from memory.
|
||||
| the adversarial review and learnings capture (Step 11) | `sections/adversarial.md` |
|
||||
| writing the CHANGELOG entry (Step 13) | `sections/changelog.md` |
|
||||
| auditing docs before final commit/verification (Step 14.5), on every ship | `sections/documentation.md` |
|
||||
| creating or updating the PR/MR with the verified documentation outcome (Step 19) | `sections/pr-body.md` |
|
||||
| preparing the title and creating or updating the PR/MR with the verified documentation outcome (Steps 18-19) | `sections/pr-body.md` |
|
||||
|
||||
---
|
||||
|
||||
@@ -1124,16 +1124,9 @@ A successful empty array means new; one match supplies the existing title/identi
|
||||
Lookup failure or ambiguous matches **STOP** for resolution, never mean no PR.
|
||||
Save the result for Step 19's recheck.
|
||||
|
||||
Prepare the title from that result; Step 19 scans and publishes it:
|
||||
1. For an existing open PR/MR, use the matched title and run
|
||||
`~/.claude/skills/gstack/bin/gstack-pr-title-rewrite.sh "$NEW_VERSION" "<current title>"`.
|
||||
2. For a new PR/MR, compose `v<NEW_VERSION> <type>: <summary>`.
|
||||
3. Save the result as `NEW_TITLE` for Step 19. Every created or updated title MUST
|
||||
start with `v$NEW_VERSION `; never publish an unprefixed title.
|
||||
4. **NO_VERSION:** replaces items 1-3: keep an existing title, or compose
|
||||
`<type>: <summary>`; no version prefix.
|
||||
The pr-body section prepares the title from that result, then publishes.
|
||||
|
||||
> **STOP.** Before creating or updating the PR/MR with the verified documentation outcome (Step 19), Read `~/.claude/skills/gstack/ship/sections/pr-body.md` and execute it
|
||||
> **STOP.** Before preparing the title and creating or updating the PR/MR with the verified documentation outcome (Steps 18-19), Read `~/.claude/skills/gstack/ship/sections/pr-body.md` and execute it
|
||||
> in full. Do not work from memory — that section is the source of truth for this step.
|
||||
|
||||
## Step 20: Persist ship metrics
|
||||
|
||||
+1
-8
@@ -594,14 +594,7 @@ A successful empty array means new; one match supplies the existing title/identi
|
||||
Lookup failure or ambiguous matches **STOP** for resolution, never mean no PR.
|
||||
Save the result for Step 19's recheck.
|
||||
|
||||
Prepare the title from that result; Step 19 scans and publishes it:
|
||||
1. For an existing open PR/MR, use the matched title and run
|
||||
`~/.claude/skills/gstack/bin/gstack-pr-title-rewrite.sh "$NEW_VERSION" "<current title>"`.
|
||||
2. For a new PR/MR, compose `v<NEW_VERSION> <type>: <summary>`.
|
||||
3. Save the result as `NEW_TITLE` for Step 19. Every created or updated title MUST
|
||||
start with `v$NEW_VERSION `; never publish an unprefixed title.
|
||||
4. **NO_VERSION:** replaces items 1-3: keep an existing title, or compose
|
||||
`<type>: <summary>`; no version prefix.
|
||||
The pr-body section prepares the title from that result, then publishes.
|
||||
|
||||
{{SECTION:pr-body}}
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
"id": "pr-body",
|
||||
"file": "pr-body.md",
|
||||
"title": "PR/MR creation and documentation outcome",
|
||||
"trigger": "creating or updating the PR/MR with the verified documentation outcome (Step 19)"
|
||||
"trigger": "preparing the title and creating or updating the PR/MR with the verified documentation outcome (Steps 18-19)"
|
||||
}
|
||||
]
|
||||
}
|
||||
+76
-55
@@ -1,10 +1,40 @@
|
||||
<!-- AUTO-GENERATED from pr-body.md.tmpl — do not edit directly -->
|
||||
<!-- Regenerate: bun run gen:skill-docs -->
|
||||
### Prepare the title (Step 18)
|
||||
|
||||
Prepare the title in a private file; Step 19 posts that file, never a shell string:
|
||||
|
||||
```bash
|
||||
_GT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp"
|
||||
mkdir -p "$_GT" && chmod 700 "$_GT" || { echo "Not sent: cannot create $_GT for the text file." >&2; exit 1; }
|
||||
_EX=$(git rev-parse --git-path info/exclude 2>/dev/null) && mkdir -p "$(dirname "$_EX")" && { grep -qxF '/.gstack/tmp/' "$_EX" 2>/dev/null || echo '/.gstack/tmp/' >> "$_EX"; }
|
||||
TITLE_FILE=$(mktemp "${_GT:?}/pr-title.XXXXXX") || { echo "Not sent: mktemp failed in $_GT." >&2; exit 1; }; echo "TITLE_FILE: $TITLE_FILE (name: ${TITLE_FILE##*/})"
|
||||
```
|
||||
|
||||
Write the text into each printed file with your file-write tool (Claude Code's Write tool needs a Read of the empty file first), exactly as it should appear. The text never goes into a shell command, heredoc or quoted argument. If a write fails or is refused, do not send: print the cause, the file path and the command below for sending by hand.
|
||||
|
||||
1. For an existing open PR/MR, fill the file from the platform, substituting the
|
||||
printed name, the matched number and `NEW_VERSION`; no title text passes through you:
|
||||
```bash
|
||||
TITLE_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/<title-file-name>"
|
||||
gh pr view <pr-number> --json title -q .title | ~/.claude/skills/gstack/bin/gstack-pr-title-rewrite.sh <new-version> --stdin > "$TITLE_FILE" || exit 1
|
||||
```
|
||||
GitLab reads `glab mr view <pr-number> --output json | jq -r .title` into the same pipe.
|
||||
2. For a new PR/MR, write `v<NEW_VERSION> <type>: <summary>` into the file with your file-write tool.
|
||||
3. Every created or updated title MUST start with `v$NEW_VERSION `; check the file with
|
||||
`grep -Eq '^v<new-version>( |$)' "$TITLE_FILE"` and never publish an unprefixed title.
|
||||
4. **NO_VERSION:** replaces items 1-3: keep an existing title (the platform title
|
||||
piped straight into the file), or write `<type>: <summary>`; no version prefix.
|
||||
|
||||
Save the title file name for Step 19.
|
||||
|
||||
---
|
||||
|
||||
## Step 19: Create PR/MR
|
||||
|
||||
Recheck Step 18's PR/MR lookup and record it. Errors or ambiguous matches STOP publication.
|
||||
If the open PR/MR or title changed, repeat Step 18's identity/title preparation,
|
||||
then return here for a new lookup, fresh body and both redaction scans before publishing.
|
||||
then return here for a new lookup and a fresh body before publishing.
|
||||
|
||||
### Resolve Linked Spec before composing the body
|
||||
|
||||
@@ -24,7 +54,7 @@ then return here for a new lookup, fresh body and both redaction scans before pu
|
||||
completed Step 8 plan scope permits `Closes #N`, with every spec criterion
|
||||
verified. Partial, deferred, failed, dropped or unverified scope uses `Linked to #N`
|
||||
and names the remaining work; never auto-close it. Include the archive filename
|
||||
and `spec_filed_at`, not a private absolute path. Send these fields through the same redaction scan.
|
||||
and `spec_filed_at`, not a private absolute path; `gstack-post` scans them with the body.
|
||||
|
||||
The PR/MR body should contain these sections (never reuse a prior run's body):
|
||||
|
||||
@@ -92,7 +122,7 @@ Unavailable/inconclusive is never PASS.>
|
||||
|
||||
## Documentation
|
||||
<Embed Step 14.5's vetted nonempty `documentation_section` for this invocation:
|
||||
its saved section file, inserted unchanged by the scan block's `DOCS_SECTION_FILE` lines.
|
||||
its saved section file, inserted unchanged by the compose block's `DOCS_SECTION_FILE` lines.
|
||||
A blocked audit shipped under a user exception has no section file: state its
|
||||
blocked status, scope and exception here and drop the block's `DOCS_SECTION_FILE` guard
|
||||
and its `cat -- "$DOCS_SECTION_FILE" && echo &&` step.>
|
||||
@@ -104,14 +134,14 @@ and its `cat -- "$DOCS_SECTION_FILE" && echo &&` step.>
|
||||
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||||
```
|
||||
|
||||
#### Redaction scan (PR body + title) — runs before create AND edit
|
||||
#### Compose the body, then publish through gstack-post
|
||||
|
||||
The PR body is world-readable on a public repo. Scan-at-sink before sending:
|
||||
compose the body into a temp file, scan THAT file with the shared engine,
|
||||
and pass the same file to `gh`/`glab`. Wrap any Codex / Greptile / eval output
|
||||
sections in tool-attributed fences (` ```codex-review ` / ` ```greptile `) so the
|
||||
engine WARN-degrades the example credentials those tools quote instead of blocking
|
||||
the PR (a live-format credential inside the fence still blocks).
|
||||
The PR body is world-readable on a public repo. `gstack-post` scans the exact title
|
||||
and body bytes it sends and passes them to `gh`/`glab` as arguments, so there is no
|
||||
separate scan here. Wrap any Codex / Greptile / eval output sections in
|
||||
tool-attributed fences (` ```codex-review ` / ` ```greptile `) so the scanner
|
||||
WARN-degrades the example credentials those tools quote instead of blocking the PR
|
||||
(a live-format credential inside the fence still blocks).
|
||||
|
||||
Write the body from above into two private files: the first through the
|
||||
`## Documentation` heading line, the second from `## Test plan` on.
|
||||
@@ -126,69 +156,60 @@ BODY_REST_FILE=$(mktemp "${_GT:?}/pr-body-rest.XXXXXX") || { echo "Not sent: mkt
|
||||
|
||||
Write the text into each printed file with your file-write tool (Claude Code's Write tool needs a Read of the empty file first), exactly as it should appear. The text never goes into a shell command, heredoc or quoted argument. If a write fails or is refused, do not send: print the cause, the file path and the command below for sending by hand.
|
||||
|
||||
Use Step 18's `NEW_TITLE` unchanged; its version prefix is already present.
|
||||
In a new shell, restore the saved literal title before this block, and Step 14.5's
|
||||
saved section file path as `DOCS_SECTION_FILE`. Substitute the printed names. The
|
||||
block deletes both drafts; to change the body, write fresh ones.
|
||||
In a new shell, restore Step 14.5's saved section file path as `DOCS_SECTION_FILE`
|
||||
and substitute the printed names. The block deletes both drafts; to change the body,
|
||||
write fresh ones.
|
||||
|
||||
```bash
|
||||
: "${NEW_TITLE:?Restore the saved Step 18 title before scanning}"
|
||||
: "${DOCS_SECTION_FILE:?Restore the saved Step 14.5 section file path before composing}"
|
||||
REDACT_VIS=$(~/.claude/skills/gstack/bin/gstack-config get redact_repo_visibility 2>/dev/null)
|
||||
[ -z "$REDACT_VIS" ] && REDACT_VIS=$(gh repo view --json visibility -q .visibility 2>/dev/null | tr 'A-Z' 'a-z')
|
||||
REDACT_VIS="${REDACT_VIS:-unknown}"
|
||||
PR_BODY_FILE=$(mktemp "${TMPDIR:-/tmp}/gstack-pr-body.XXXXXX") || { echo "ERROR: mktemp failed — cannot scan the PR body; refusing to create the PR unscanned." >&2; exit 1; }
|
||||
PR_BODY_FILE=$(mktemp "${TMPDIR:-/tmp}/gstack-pr-body.XXXXXX") || { echo "ERROR: mktemp failed — cannot compose the PR body; refusing to publish." >&2; exit 1; }
|
||||
BODY_TOP_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/<body-top-file-name>"
|
||||
BODY_REST_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/<body-rest-file-name>"
|
||||
[ -s "$BODY_TOP_FILE" ] && [ -s "$BODY_REST_FILE" ] || { echo "Not scanned: $BODY_TOP_FILE or $BODY_REST_FILE is empty, so the body was never written. Write both, then rerun." >&2; exit 1; }
|
||||
[ -s "$BODY_TOP_FILE" ] && [ -s "$BODY_REST_FILE" ] || { echo "Not composed: $BODY_TOP_FILE or $BODY_REST_FILE is empty, so the body was never written. Write both, then rerun." >&2; exit 1; }
|
||||
{ awk 1 "$BODY_TOP_FILE" && cat -- "$DOCS_SECTION_FILE" && echo && awk 1 "$BODY_REST_FILE"; } > "$PR_BODY_FILE" || exit 1
|
||||
rm -f "$BODY_TOP_FILE" "$BODY_REST_FILE"
|
||||
~/.claude/skills/gstack/bin/gstack-redact --from-file "$PR_BODY_FILE" --repo-visibility "$REDACT_VIS" --self-email "$(git config user.email 2>/dev/null)" --json
|
||||
case $? in
|
||||
0) ;;
|
||||
3) echo "BLOCKED — credential in PR body. Rotate + redact, do not create the PR."; exit 1 ;;
|
||||
2) echo "MEDIUM findings — confirm per finding (sterner on public) before proceeding." ;;
|
||||
*) echo "BLOCKED — PR body scan failed. Repair the scanner and repeat before publication."; exit 1 ;;
|
||||
esac
|
||||
printf '%s' "$NEW_TITLE" | ~/.claude/skills/gstack/bin/gstack-redact --repo-visibility "$REDACT_VIS" --json
|
||||
echo "PR_BODY_FILE: $PR_BODY_FILE"
|
||||
```
|
||||
|
||||
Check both scan results: exit 0 permits publication; exit 2 requires
|
||||
AskUserQuestion per MEDIUM finding (PII offers `--auto-redact`); exit 3 blocks for
|
||||
HIGH findings. Exit 1 or any other error blocks until the scanner works and both
|
||||
scans pass. When visibility lookup is unavailable, including on GitLab, `unknown`
|
||||
uses the scanner's public-strict policy.
|
||||
In each block below, restore the literal `PR_BODY_FILE` path and substitute Step 18's
|
||||
title file name; `gstack-post` detects GitHub or GitLab from the remote and the repo's
|
||||
visibility (an unavailable lookup, including on GitLab, uses the scanner's
|
||||
public-strict policy). Never re-render the body: every retry sends the same files.
|
||||
|
||||
For every create/edit command below, send the same scanned bytes. Never re-render
|
||||
the body. In a new shell, restore the literal `PR_BODY_FILE` path and `NEW_TITLE`.
|
||||
|
||||
**Existing open PR/MR:** update using `gh pr edit --body-file "$PR_BODY_FILE"` (GitHub)
|
||||
or `glab mr update -d "$(cat "$PR_BODY_FILE")"` (GitLab).
|
||||
|
||||
Update the title with the same scanned `NEW_TITLE`: `gh pr edit --title "$NEW_TITLE"` (or `glab mr update -t "$NEW_TITLE"`).
|
||||
|
||||
**REST fallback:** if `gh pr edit` fails with the `repository.pullRequest.projectCards` GraphQL deprecation, do not re-ask for auth. Use the SAME scanned file: `PR_NUMBER=$(gh pr view --json number -q .number)`, then `gh api "repos/{owner}/{repo}/pulls/$PR_NUMBER" -X PATCH -F body=@"$PR_BODY_FILE"`; for the title use `gh api "repos/{owner}/{repo}/pulls/$PR_NUMBER" -X PATCH -f title="$NEW_TITLE"`.
|
||||
|
||||
**Self-check:** re-fetch the title and assert it equals `NEW_TITLE`. Retry once if wrong, then surface any failure. Print the existing URL and continue to Step 20; do not run the create commands below.
|
||||
|
||||
**No open PR/MR, GitHub:**
|
||||
**Existing open PR/MR** (`<pr-number>` from the recheck):
|
||||
|
||||
```bash
|
||||
[ -s "$PR_BODY_FILE" ] || { echo "ERROR: scanned body file missing/empty — re-run the scan block." >&2; exit 1; }
|
||||
gh pr create --base <base> --title "$NEW_TITLE" --body-file "$PR_BODY_FILE"
|
||||
rm -f "$PR_BODY_FILE"
|
||||
TITLE_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/<title-file-name>"
|
||||
~/.claude/skills/gstack/bin/gstack-post pr-body <pr-number> --body-file "${PR_BODY_FILE:?restore the composed body path}" && \
|
||||
~/.claude/skills/gstack/bin/gstack-post pr-title <pr-number> --title-file "$TITLE_FILE"
|
||||
```
|
||||
|
||||
**No open PR/MR, GitLab:**
|
||||
It updates the body, then the title, retries a retired-GraphQL edit through REST and
|
||||
reads the title back. If Step 6.5 opened this PR as an early draft and the user did not
|
||||
ask for a draft, mark it ready now: `gh pr ready <pr-number>`. Print the existing URL
|
||||
and continue to Step 20; do not run the create command below.
|
||||
|
||||
**No open PR/MR:**
|
||||
|
||||
```bash
|
||||
[ -s "$PR_BODY_FILE" ] || { echo "ERROR: scanned body file missing/empty — re-run the scan block." >&2; exit 1; }
|
||||
glab mr create -b <base> -t "$NEW_TITLE" -d "$(cat "$PR_BODY_FILE")"
|
||||
rm -f "$PR_BODY_FILE"
|
||||
TITLE_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/<title-file-name>"
|
||||
~/.claude/skills/gstack/bin/gstack-post pr-create --base <base> --title-file "$TITLE_FILE" --body-file "${PR_BODY_FILE:?restore the composed body path}"
|
||||
```
|
||||
|
||||
**If neither CLI is available:**
|
||||
Print the branch name, remote URL, and instruct the user to create the PR/MR manually via the web UI. Do not stop — the code is pushed and ready.
|
||||
Branch on the exit code of either block:
|
||||
- **0** posted. Remove `$PR_BODY_FILE` and the title file.
|
||||
- **1** HIGH finding: BLOCKED — do not publish. Rotate and redact at the source, then
|
||||
compose fresh files.
|
||||
- **2** MEDIUM findings, printed as `RULE:` lines with a `TOKEN:`. AskUserQuestion per
|
||||
finding (sterner on public repos, no batch acknowledge). PII offers auto-redact:
|
||||
write `~/.claude/skills/gstack/bin/gstack-redact --from-file "$PR_BODY_FILE" --auto-redact <ids>`
|
||||
output back into the body file and rerun the block, which scans again. Only when the
|
||||
user accepts the findings as they are, rerun the same block with
|
||||
`--confirm <confirm-token>` added to the command that printed it; any edit needs a new token.
|
||||
- **3** `gh`/`glab` failed, or **64** no usable remote or CLI: print the error, the
|
||||
branch name, the remote URL and the block for posting by hand. Do not stop — the code
|
||||
is pushed — but never report a PR that was not created.
|
||||
- Any other exit is an error that blocks publication until its cause is fixed.
|
||||
|
||||
**Output the PR/MR URL** — then proceed to Step 20.
|
||||
|
||||
|
||||
@@ -1,8 +1,31 @@
|
||||
### Prepare the title (Step 18)
|
||||
|
||||
Prepare the title in a private file; Step 19 posts that file, never a shell string:
|
||||
|
||||
{{FREE_TEXT_FILE:TITLE_FILE=pr-title}}
|
||||
|
||||
1. For an existing open PR/MR, fill the file from the platform, substituting the
|
||||
printed name, the matched number and `NEW_VERSION`; no title text passes through you:
|
||||
```bash
|
||||
TITLE_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/<title-file-name>"
|
||||
gh pr view <pr-number> --json title -q .title | ~/.claude/skills/gstack/bin/gstack-pr-title-rewrite.sh <new-version> --stdin > "$TITLE_FILE" || exit 1
|
||||
```
|
||||
GitLab reads `glab mr view <pr-number> --output json | jq -r .title` into the same pipe.
|
||||
2. For a new PR/MR, write `v<NEW_VERSION> <type>: <summary>` into the file with your file-write tool.
|
||||
3. Every created or updated title MUST start with `v$NEW_VERSION `; check the file with
|
||||
`grep -Eq '^v<new-version>( |$)' "$TITLE_FILE"` and never publish an unprefixed title.
|
||||
4. **NO_VERSION:** replaces items 1-3: keep an existing title (the platform title
|
||||
piped straight into the file), or write `<type>: <summary>`; no version prefix.
|
||||
|
||||
Save the title file name for Step 19.
|
||||
|
||||
---
|
||||
|
||||
## Step 19: Create PR/MR
|
||||
|
||||
Recheck Step 18's PR/MR lookup and record it. Errors or ambiguous matches STOP publication.
|
||||
If the open PR/MR or title changed, repeat Step 18's identity/title preparation,
|
||||
then return here for a new lookup, fresh body and both redaction scans before publishing.
|
||||
then return here for a new lookup and a fresh body before publishing.
|
||||
|
||||
### Resolve Linked Spec before composing the body
|
||||
|
||||
@@ -22,7 +45,7 @@ then return here for a new lookup, fresh body and both redaction scans before pu
|
||||
completed Step 8 plan scope permits `Closes #N`, with every spec criterion
|
||||
verified. Partial, deferred, failed, dropped or unverified scope uses `Linked to #N`
|
||||
and names the remaining work; never auto-close it. Include the archive filename
|
||||
and `spec_filed_at`, not a private absolute path. Send these fields through the same redaction scan.
|
||||
and `spec_filed_at`, not a private absolute path; `gstack-post` scans them with the body.
|
||||
|
||||
The PR/MR body should contain these sections (never reuse a prior run's body):
|
||||
|
||||
@@ -90,7 +113,7 @@ Unavailable/inconclusive is never PASS.>
|
||||
|
||||
## Documentation
|
||||
<Embed Step 14.5's vetted nonempty `documentation_section` for this invocation:
|
||||
its saved section file, inserted unchanged by the scan block's `DOCS_SECTION_FILE` lines.
|
||||
its saved section file, inserted unchanged by the compose block's `DOCS_SECTION_FILE` lines.
|
||||
A blocked audit shipped under a user exception has no section file: state its
|
||||
blocked status, scope and exception here and drop the block's `DOCS_SECTION_FILE` guard
|
||||
and its `cat -- "$DOCS_SECTION_FILE" && echo &&` step.>
|
||||
@@ -102,83 +125,74 @@ and its `cat -- "$DOCS_SECTION_FILE" && echo &&` step.>
|
||||
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||||
```
|
||||
|
||||
#### Redaction scan (PR body + title) — runs before create AND edit
|
||||
#### Compose the body, then publish through gstack-post
|
||||
|
||||
The PR body is world-readable on a public repo. Scan-at-sink before sending:
|
||||
compose the body into a temp file, scan THAT file with the shared engine,
|
||||
and pass the same file to `gh`/`glab`. Wrap any Codex / Greptile / eval output
|
||||
sections in tool-attributed fences (` ```codex-review ` / ` ```greptile `) so the
|
||||
engine WARN-degrades the example credentials those tools quote instead of blocking
|
||||
the PR (a live-format credential inside the fence still blocks).
|
||||
The PR body is world-readable on a public repo. `gstack-post` scans the exact title
|
||||
and body bytes it sends and passes them to `gh`/`glab` as arguments, so there is no
|
||||
separate scan here. Wrap any Codex / Greptile / eval output sections in
|
||||
tool-attributed fences (` ```codex-review ` / ` ```greptile `) so the scanner
|
||||
WARN-degrades the example credentials those tools quote instead of blocking the PR
|
||||
(a live-format credential inside the fence still blocks).
|
||||
|
||||
Write the body from above into two private files: the first through the
|
||||
`## Documentation` heading line, the second from `## Test plan` on.
|
||||
|
||||
{{FREE_TEXT_FILE:BODY_TOP_FILE=pr-body-top:BODY_REST_FILE=pr-body-rest}}
|
||||
|
||||
Use Step 18's `NEW_TITLE` unchanged; its version prefix is already present.
|
||||
In a new shell, restore the saved literal title before this block, and Step 14.5's
|
||||
saved section file path as `DOCS_SECTION_FILE`. Substitute the printed names. The
|
||||
block deletes both drafts; to change the body, write fresh ones.
|
||||
In a new shell, restore Step 14.5's saved section file path as `DOCS_SECTION_FILE`
|
||||
and substitute the printed names. The block deletes both drafts; to change the body,
|
||||
write fresh ones.
|
||||
|
||||
```bash
|
||||
: "${NEW_TITLE:?Restore the saved Step 18 title before scanning}"
|
||||
: "${DOCS_SECTION_FILE:?Restore the saved Step 14.5 section file path before composing}"
|
||||
REDACT_VIS=$(~/.claude/skills/gstack/bin/gstack-config get redact_repo_visibility 2>/dev/null)
|
||||
[ -z "$REDACT_VIS" ] && REDACT_VIS=$(gh repo view --json visibility -q .visibility 2>/dev/null | tr 'A-Z' 'a-z')
|
||||
REDACT_VIS="${REDACT_VIS:-unknown}"
|
||||
PR_BODY_FILE=$(mktemp "${TMPDIR:-/tmp}/gstack-pr-body.XXXXXX") || { echo "ERROR: mktemp failed — cannot scan the PR body; refusing to create the PR unscanned." >&2; exit 1; }
|
||||
PR_BODY_FILE=$(mktemp "${TMPDIR:-/tmp}/gstack-pr-body.XXXXXX") || { echo "ERROR: mktemp failed — cannot compose the PR body; refusing to publish." >&2; exit 1; }
|
||||
BODY_TOP_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/<body-top-file-name>"
|
||||
BODY_REST_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/<body-rest-file-name>"
|
||||
[ -s "$BODY_TOP_FILE" ] && [ -s "$BODY_REST_FILE" ] || { echo "Not scanned: $BODY_TOP_FILE or $BODY_REST_FILE is empty, so the body was never written. Write both, then rerun." >&2; exit 1; }
|
||||
[ -s "$BODY_TOP_FILE" ] && [ -s "$BODY_REST_FILE" ] || { echo "Not composed: $BODY_TOP_FILE or $BODY_REST_FILE is empty, so the body was never written. Write both, then rerun." >&2; exit 1; }
|
||||
{ awk 1 "$BODY_TOP_FILE" && cat -- "$DOCS_SECTION_FILE" && echo && awk 1 "$BODY_REST_FILE"; } > "$PR_BODY_FILE" || exit 1
|
||||
rm -f "$BODY_TOP_FILE" "$BODY_REST_FILE"
|
||||
~/.claude/skills/gstack/bin/gstack-redact --from-file "$PR_BODY_FILE" --repo-visibility "$REDACT_VIS" --self-email "$(git config user.email 2>/dev/null)" --json
|
||||
case $? in
|
||||
0) ;;
|
||||
3) echo "BLOCKED — credential in PR body. Rotate + redact, do not create the PR."; exit 1 ;;
|
||||
2) echo "MEDIUM findings — confirm per finding (sterner on public) before proceeding." ;;
|
||||
*) echo "BLOCKED — PR body scan failed. Repair the scanner and repeat before publication."; exit 1 ;;
|
||||
esac
|
||||
printf '%s' "$NEW_TITLE" | ~/.claude/skills/gstack/bin/gstack-redact --repo-visibility "$REDACT_VIS" --json
|
||||
echo "PR_BODY_FILE: $PR_BODY_FILE"
|
||||
```
|
||||
|
||||
Check both scan results: exit 0 permits publication; exit 2 requires
|
||||
AskUserQuestion per MEDIUM finding (PII offers `--auto-redact`); exit 3 blocks for
|
||||
HIGH findings. Exit 1 or any other error blocks until the scanner works and both
|
||||
scans pass. When visibility lookup is unavailable, including on GitLab, `unknown`
|
||||
uses the scanner's public-strict policy.
|
||||
In each block below, restore the literal `PR_BODY_FILE` path and substitute Step 18's
|
||||
title file name; `gstack-post` detects GitHub or GitLab from the remote and the repo's
|
||||
visibility (an unavailable lookup, including on GitLab, uses the scanner's
|
||||
public-strict policy). Never re-render the body: every retry sends the same files.
|
||||
|
||||
For every create/edit command below, send the same scanned bytes. Never re-render
|
||||
the body. In a new shell, restore the literal `PR_BODY_FILE` path and `NEW_TITLE`.
|
||||
|
||||
**Existing open PR/MR:** update using `gh pr edit --body-file "$PR_BODY_FILE"` (GitHub)
|
||||
or `glab mr update -d "$(cat "$PR_BODY_FILE")"` (GitLab).
|
||||
|
||||
Update the title with the same scanned `NEW_TITLE`: `gh pr edit --title "$NEW_TITLE"` (or `glab mr update -t "$NEW_TITLE"`).
|
||||
|
||||
**REST fallback:** if `gh pr edit` fails with the `repository.pullRequest.projectCards` GraphQL deprecation, do not re-ask for auth. Use the SAME scanned file: `PR_NUMBER=$(gh pr view --json number -q .number)`, then `gh api "repos/{owner}/{repo}/pulls/$PR_NUMBER" -X PATCH -F body=@"$PR_BODY_FILE"`; for the title use `gh api "repos/{owner}/{repo}/pulls/$PR_NUMBER" -X PATCH -f title="$NEW_TITLE"`.
|
||||
|
||||
**Self-check:** re-fetch the title and assert it equals `NEW_TITLE`. Retry once if wrong, then surface any failure. Print the existing URL and continue to Step 20; do not run the create commands below.
|
||||
|
||||
**No open PR/MR, GitHub:**
|
||||
**Existing open PR/MR** (`<pr-number>` from the recheck):
|
||||
|
||||
```bash
|
||||
[ -s "$PR_BODY_FILE" ] || { echo "ERROR: scanned body file missing/empty — re-run the scan block." >&2; exit 1; }
|
||||
gh pr create --base <base> --title "$NEW_TITLE" --body-file "$PR_BODY_FILE"
|
||||
rm -f "$PR_BODY_FILE"
|
||||
TITLE_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/<title-file-name>"
|
||||
~/.claude/skills/gstack/bin/gstack-post pr-body <pr-number> --body-file "${PR_BODY_FILE:?restore the composed body path}" && \
|
||||
~/.claude/skills/gstack/bin/gstack-post pr-title <pr-number> --title-file "$TITLE_FILE"
|
||||
```
|
||||
|
||||
**No open PR/MR, GitLab:**
|
||||
It updates the body, then the title, retries a retired-GraphQL edit through REST and
|
||||
reads the title back. If Step 6.5 opened this PR as an early draft and the user did not
|
||||
ask for a draft, mark it ready now: `gh pr ready <pr-number>`. Print the existing URL
|
||||
and continue to Step 20; do not run the create command below.
|
||||
|
||||
**No open PR/MR:**
|
||||
|
||||
```bash
|
||||
[ -s "$PR_BODY_FILE" ] || { echo "ERROR: scanned body file missing/empty — re-run the scan block." >&2; exit 1; }
|
||||
glab mr create -b <base> -t "$NEW_TITLE" -d "$(cat "$PR_BODY_FILE")"
|
||||
rm -f "$PR_BODY_FILE"
|
||||
TITLE_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/<title-file-name>"
|
||||
~/.claude/skills/gstack/bin/gstack-post pr-create --base <base> --title-file "$TITLE_FILE" --body-file "${PR_BODY_FILE:?restore the composed body path}"
|
||||
```
|
||||
|
||||
**If neither CLI is available:**
|
||||
Print the branch name, remote URL, and instruct the user to create the PR/MR manually via the web UI. Do not stop — the code is pushed and ready.
|
||||
Branch on the exit code of either block:
|
||||
- **0** posted. Remove `$PR_BODY_FILE` and the title file.
|
||||
- **1** HIGH finding: BLOCKED — do not publish. Rotate and redact at the source, then
|
||||
compose fresh files.
|
||||
- **2** MEDIUM findings, printed as `RULE:` lines with a `TOKEN:`. AskUserQuestion per
|
||||
finding (sterner on public repos, no batch acknowledge). PII offers auto-redact:
|
||||
write `~/.claude/skills/gstack/bin/gstack-redact --from-file "$PR_BODY_FILE" --auto-redact <ids>`
|
||||
output back into the body file and rerun the block, which scans again. Only when the
|
||||
user accepts the findings as they are, rerun the same block with
|
||||
`--confirm <confirm-token>` added to the command that printed it; any edit needs a new token.
|
||||
- **3** `gh`/`glab` failed, or **64** no usable remote or CLI: print the error, the
|
||||
branch name, the remote URL and the block for posting by hand. Do not stop — the code
|
||||
is pushed — but never report a PR that was not created.
|
||||
- Any other exit is an error that blocks publication until its cause is fixed.
|
||||
|
||||
**Output the PR/MR URL** — then proceed to Step 20.
|
||||
|
||||
|
||||
@@ -247,20 +247,8 @@ interrupt before the work happens.
|
||||
|
||||
#### File the issue (always)
|
||||
|
||||
**Re-scan before filing** (Phase 4 edits can introduce content the 4.5b scan
|
||||
never saw, and the issue is world-readable):
|
||||
|
||||
#### Redaction scan — pre-issue (the issue body you're about to file)
|
||||
|
||||
Run the SAME scan-at-sink procedure shown above (resolve `$REDACT_VIS` once and
|
||||
reuse it; when the issue body you're about to file changed since the last scan, rewrite the same `$REDACT_FILE`
|
||||
with your file-write tool; `~/.claude/skills/gstack/bin/gstack-redact --from-file "$REDACT_FILE"
|
||||
--repo-visibility "$REDACT_VIS" --json`), now on the issue body you're about to file. Apply the same
|
||||
exit-3/2/0 handling. On exit 3, do NOT file the issue; HIGH has no skip. Pass the
|
||||
same `$REDACT_FILE` downstream so the bytes scanned are the bytes sent.
|
||||
|
||||
If `gh` is available and authenticated, file from the scanned file. The title and
|
||||
the one-line approach for the decision log are free text too, so they go into their
|
||||
If `gh` is available and authenticated, file from the scanned draft file. The title
|
||||
and the one-line approach for the decision log are free text too, so they go into their
|
||||
own private files:
|
||||
|
||||
```bash
|
||||
@@ -273,14 +261,19 @@ APPROACH_FILE=$(mktemp "${_GT:?}/approach.XXXXXX") || { echo "Not sent: mktemp f
|
||||
|
||||
Write the text into each printed file with your file-write tool (Claude Code's Write tool needs a Read of the empty file first), exactly as it should appear. The text never goes into a shell command, heredoc or quoted argument. If a write fails or is refused, do not send: print the cause, the file path and the command below for sending by hand.
|
||||
|
||||
Then file, substituting the three printed names:
|
||||
Then file, substituting the three printed names. `gstack-post` scans the exact title
|
||||
and body it sends (Phase 4 edits can introduce content the 4.5b scan never saw, and the
|
||||
issue is world-readable) and passes both to `gh` as arguments:
|
||||
|
||||
```bash
|
||||
REDACT_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/<redact-file-name>"
|
||||
TITLE_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/<title-file-name>"
|
||||
APPROACH_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/<approach-file-name>"
|
||||
[ -s "$REDACT_FILE" ] && [ -s "$TITLE_FILE" ] || { echo "Not filed: $REDACT_FILE or $TITLE_FILE is missing or empty. Write them, then file by hand: gh issue create --title \"\$(cat $TITLE_FILE)\" --body-file $REDACT_FILE" >&2; exit 1; }
|
||||
ISSUE_URL=$(gh issue create --title "$(cat "$TITLE_FILE")" --body-file "$REDACT_FILE") || exit 1
|
||||
[ -s "$REDACT_FILE" ] && [ -s "$TITLE_FILE" ] || { echo "Not filed: $REDACT_FILE or $TITLE_FILE is missing or empty. Write them, then file by hand: ~/.claude/skills/gstack/bin/gstack-post issue-create --title-file $TITLE_FILE --body-file $REDACT_FILE" >&2; exit 1; }
|
||||
POST_OUT=$(~/.claude/skills/gstack/bin/gstack-post issue-create --title-file "$TITLE_FILE" --body-file "$REDACT_FILE"); POST_CODE=$?
|
||||
printf '%s\n' "$POST_OUT"
|
||||
[ "$POST_CODE" = 0 ] || { echo "Not filed (gstack-post exit $POST_CODE)." >&2; exit "$POST_CODE"; }
|
||||
ISSUE_URL=$(printf '%s\n' "$POST_OUT" | grep -m1 -E '^https?://')
|
||||
ISSUE_NUMBER=$(echo "$ISSUE_URL" | sed -E 's|.*/issues/([0-9]+)$|\1|')
|
||||
echo "Filed: $ISSUE_URL (ISSUE_NUMBER: $ISSUE_NUMBER)"
|
||||
[ -s "$APPROACH_FILE" ] && ~/.claude/skills/gstack/bin/gstack-decision-log "$(jq -cn --arg n "$ISSUE_NUMBER" --rawfile t "$TITLE_FILE" --rawfile a "$APPROACH_FILE" \
|
||||
@@ -288,6 +281,12 @@ echo "Filed: $ISSUE_URL (ISSUE_NUMBER: $ISSUE_NUMBER)"
|
||||
rm -f "$APPROACH_FILE"
|
||||
```
|
||||
|
||||
Exit 1 (HIGH): do NOT file; rotate and redact at source, no skip. Exit 2 (MEDIUM): ask
|
||||
per printed `RULE:` line exactly as in the 4.5b disposition (auto-redact rewrites
|
||||
`$REDACT_FILE`, which the archive then uses); when the user accepts a finding as it
|
||||
is, rerun the block with `--confirm <confirm-token>` after `--body-file "$REDACT_FILE"`.
|
||||
Any edit needs a new scan, so the token no longer applies. Exit 3: `gh` failed; report it.
|
||||
|
||||
The last line records the spec as a durable, issue-scoped cross-session decision so a future session (or `/ship` closing the issue) inherits the core approach and why, not just the issue link. Non-interactive, best-effort (`|| true`). The approach file holds the one core approach/decision the spec settled. Only fires when the issue was actually filed.
|
||||
|
||||
If `gh` is not available, print: "`gh` not authenticated — title and body below
|
||||
|
||||
@@ -120,25 +120,25 @@ interrupt before the work happens.
|
||||
|
||||
#### File the issue (always)
|
||||
|
||||
**Re-scan before filing** (Phase 4 edits can introduce content the 4.5b scan
|
||||
never saw, and the issue is world-readable):
|
||||
|
||||
{{REDACT_INVOCATION_BLOCK:pre-issue:brief}}
|
||||
|
||||
If `gh` is available and authenticated, file from the scanned file. The title and
|
||||
the one-line approach for the decision log are free text too, so they go into their
|
||||
If `gh` is available and authenticated, file from the scanned draft file. The title
|
||||
and the one-line approach for the decision log are free text too, so they go into their
|
||||
own private files:
|
||||
|
||||
{{FREE_TEXT_FILE:TITLE_FILE=title:APPROACH_FILE=approach}}
|
||||
|
||||
Then file, substituting the three printed names:
|
||||
Then file, substituting the three printed names. `gstack-post` scans the exact title
|
||||
and body it sends (Phase 4 edits can introduce content the 4.5b scan never saw, and the
|
||||
issue is world-readable) and passes both to `gh` as arguments:
|
||||
|
||||
```bash
|
||||
REDACT_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/<redact-file-name>"
|
||||
TITLE_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/<title-file-name>"
|
||||
APPROACH_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/<approach-file-name>"
|
||||
[ -s "$REDACT_FILE" ] && [ -s "$TITLE_FILE" ] || { echo "Not filed: $REDACT_FILE or $TITLE_FILE is missing or empty. Write them, then file by hand: gh issue create --title \"\$(cat $TITLE_FILE)\" --body-file $REDACT_FILE" >&2; exit 1; }
|
||||
ISSUE_URL=$(gh issue create --title "$(cat "$TITLE_FILE")" --body-file "$REDACT_FILE") || exit 1
|
||||
[ -s "$REDACT_FILE" ] && [ -s "$TITLE_FILE" ] || { echo "Not filed: $REDACT_FILE or $TITLE_FILE is missing or empty. Write them, then file by hand: ~/.claude/skills/gstack/bin/gstack-post issue-create --title-file $TITLE_FILE --body-file $REDACT_FILE" >&2; exit 1; }
|
||||
POST_OUT=$(~/.claude/skills/gstack/bin/gstack-post issue-create --title-file "$TITLE_FILE" --body-file "$REDACT_FILE"); POST_CODE=$?
|
||||
printf '%s\n' "$POST_OUT"
|
||||
[ "$POST_CODE" = 0 ] || { echo "Not filed (gstack-post exit $POST_CODE)." >&2; exit "$POST_CODE"; }
|
||||
ISSUE_URL=$(printf '%s\n' "$POST_OUT" | grep -m1 -E '^https?://')
|
||||
ISSUE_NUMBER=$(echo "$ISSUE_URL" | sed -E 's|.*/issues/([0-9]+)$|\1|')
|
||||
echo "Filed: $ISSUE_URL (ISSUE_NUMBER: $ISSUE_NUMBER)"
|
||||
[ -s "$APPROACH_FILE" ] && ~/.claude/skills/gstack/bin/gstack-decision-log "$(jq -cn --arg n "$ISSUE_NUMBER" --rawfile t "$TITLE_FILE" --rawfile a "$APPROACH_FILE" \
|
||||
@@ -146,6 +146,12 @@ echo "Filed: $ISSUE_URL (ISSUE_NUMBER: $ISSUE_NUMBER)"
|
||||
rm -f "$APPROACH_FILE"
|
||||
```
|
||||
|
||||
Exit 1 (HIGH): do NOT file; rotate and redact at source, no skip. Exit 2 (MEDIUM): ask
|
||||
per printed `RULE:` line exactly as in the 4.5b disposition (auto-redact rewrites
|
||||
`$REDACT_FILE`, which the archive then uses); when the user accepts a finding as it
|
||||
is, rerun the block with `--confirm <confirm-token>` after `--body-file "$REDACT_FILE"`.
|
||||
Any edit needs a new scan, so the token no longer applies. Exit 3: `gh` failed; report it.
|
||||
|
||||
The last line records the spec as a durable, issue-scoped cross-session decision so a future session (or `/ship` closing the issue) inherits the core approach and why, not just the issue link. Non-interactive, best-effort (`|| true`). The approach file holds the one core approach/decision the spec settled. Only fires when the issue was actually filed.
|
||||
|
||||
If `gh` is not available, print: "`gh` not authenticated — title and body below
|
||||
|
||||
@@ -52,20 +52,25 @@ function sandbox(name: string) {
|
||||
const log = path.join(dir, 'log');
|
||||
const runtime = path.join(dir, 'runtime');
|
||||
for (const d of [repo, bin, log, path.join(runtime, 'bin'), path.join(dir, 'state')]) fs.mkdirSync(d, { recursive: true });
|
||||
expect(spawnSync('git', ['init', '-q'], { cwd: repo, timeout: 10_000 }).status).toBe(0);
|
||||
// The fixture ignores the machine's git config (a url.insteadOf rewrite
|
||||
// would change the origin gstack-post resolves).
|
||||
fs.writeFileSync(path.join(dir, 'gitconfig'), '');
|
||||
const gitEnv = { GIT_CONFIG_GLOBAL: path.join(dir, 'gitconfig'), GIT_CONFIG_NOSYSTEM: '1' };
|
||||
expect(spawnSync('git', ['init', '-q'], { cwd: repo, timeout: 10_000, env: { ...process.env, ...gitEnv } }).status).toBe(0);
|
||||
expect(spawnSync('git', ['remote', 'add', 'origin', 'https://github.com/o/r.git'], { cwd: repo, timeout: 10_000, env: { ...process.env, ...gitEnv } }).status).toBe(0);
|
||||
const stub = (file: string, body: string) => fs.writeFileSync(file, `#!/usr/bin/env bash\n${body}\n`, { mode: 0o755 });
|
||||
stub(path.join(bin, 'gh'), `
|
||||
LOG="${log}"
|
||||
n=$(ls "$LOG" | wc -l | tr -d ' ')
|
||||
case "$1 $2" in
|
||||
"repo view") echo o/r; exit 0 ;;
|
||||
"repo view") case "$*" in *visibility*) echo PRIVATE ;; *) echo o/r ;; esac; exit 0 ;;
|
||||
"pr view") echo 7; exit 0 ;;
|
||||
"issue view") echo "https://github.com/o/r/issues/$3"; exit 0 ;;
|
||||
esac
|
||||
printf '%s\\n' "$@" > "$LOG/$n.args"
|
||||
prev=""
|
||||
for a in "$@"; do
|
||||
case "$a" in body=@*) cp "\${a#body=@}" "$LOG/$n.body" ;; esac
|
||||
case "$a" in body=@-|--field=body=@-|--body-file=-) cat > "$LOG/$n.body" ;; body=@*) cp "\${a#body=@}" "$LOG/$n.body" ;; --title=*) printf '%s' "\${a#--title=}" > "$LOG/$n.title" ;; esac
|
||||
[ "$prev" = "--body-file" ] && cp "$a" "$LOG/$n.body"
|
||||
[ "$prev" = "--title" ] && printf '%s' "$a" > "$LOG/$n.title"
|
||||
prev="$a"
|
||||
@@ -81,11 +86,12 @@ for a in "$@"; do
|
||||
done
|
||||
printf '%s\\n' "$@" > "$LOG/$n.args"`);
|
||||
stub(path.join(runtime, 'bin', 'gstack-decision-log'), `printf '%s' "$1" > "${log}/decision.json"`);
|
||||
stub(path.join(runtime, 'bin', 'gstack-post'), `exec ${JSON.stringify(process.execPath)} ${JSON.stringify(path.join(ROOT, 'bin', 'gstack-post'))} "$@"`);
|
||||
stub(path.join(runtime, 'bin', 'gstack-paths'), `echo "${path.join(dir, 'state')}"`);
|
||||
stub(path.join(runtime, 'bin', 'gstack-slug'), 'echo proj');
|
||||
const run = (script: string) => spawnSync('bash', ['-c', script.replaceAll('~/.claude/skills/gstack', runtime)], {
|
||||
cwd: repo, encoding: 'utf8', timeout: 15_000,
|
||||
env: { PATH: `${bin}:${process.env.PATH}`, HOME: path.join(dir, 'home'), TMPDIR: dir },
|
||||
env: { PATH: `${bin}:${process.env.PATH}`, HOME: path.join(dir, 'home'), TMPDIR: dir, ...gitEnv },
|
||||
});
|
||||
/** Run a create block, then do what the agent's file-write tool does: write each printed file. */
|
||||
const create = (script: string, texts: Record<string, string>) => {
|
||||
@@ -158,8 +164,8 @@ describe('identifier grammars (CEO-12, ENG-8)', () => {
|
||||
describe('Greptile replies through the real review/greptile-triage.md blocks', () => {
|
||||
const doc = fs.readFileSync(path.join(ROOT, 'review/greptile-triage.md'), 'utf8');
|
||||
const createBlock = fence(doc, 'REPLY_FILE=$(mktemp');
|
||||
const lineBlock = fence(doc, 'comments/<comment-id>/replies');
|
||||
const topBlock = fence(doc, 'issues/$PR_NUMBER/comments" -F');
|
||||
const lineBlock = fence(doc, 'gstack-post reply "$PR_NUMBER"');
|
||||
const topBlock = fence(doc, 'gstack-post pr-comment "$PR_NUMBER"');
|
||||
|
||||
test('the create block is the shared free-text block', () => {
|
||||
expect(createBlock).toBe(freeTextFileBash([{ variable: 'REPLY_FILE', stem: 'reply' }]));
|
||||
@@ -170,7 +176,7 @@ describe('Greptile replies through the real review/greptile-triage.md blocks', (
|
||||
expect(fence(doc, 'GREPTILE_DIR=$(mktemp -d')).toContain('"$GREPTILE_DIR/line.json"');
|
||||
});
|
||||
|
||||
for (const [kind, block, api] of [['line-level', lineBlock, 'repos/o/r/pulls/7/comments/123/replies'], ['top-level', topBlock, 'repos/o/r/issues/7/comments']] as const) {
|
||||
for (const [kind, block, args] of [['line-level', lineBlock, ['api', '--method=POST', 'repos/{owner}/{repo}/pulls/7/comments/123/replies', '--field=body=@-']], ['top-level', topBlock, ['pr', 'comment', '7', '--body-file=-']]] as const) {
|
||||
test(`${kind}: a hostile reply posts byte for byte and nothing in it runs`, () => {
|
||||
const s = sandbox(`greptile-${kind}`);
|
||||
const canary = path.join(s.dir, 'PWNED');
|
||||
@@ -179,7 +185,7 @@ describe('Greptile replies through the real review/greptile-triage.md blocks', (
|
||||
const r = s.run(block.replaceAll('<reply-file-name>', names.REPLY_FILE).replaceAll('<comment-id>', '123'));
|
||||
expect(r.status, r.stderr).toBe(0);
|
||||
const [call] = s.calls();
|
||||
expect(call.args.slice(0, 2)).toEqual(['api', api]);
|
||||
expect(call.args).toEqual([...args]);
|
||||
expect(call.body).toBe(body);
|
||||
expect(ranAnything(s.dir, canary)).toEqual([]);
|
||||
expect(fs.readdirSync(path.join(s.repo, '.gstack/tmp'))).toEqual([]);
|
||||
@@ -195,7 +201,7 @@ describe('Greptile replies through the real review/greptile-triage.md blocks', (
|
||||
expect(r.status).toBe(1);
|
||||
const file = path.join(fs.realpathSync(s.repo), '.gstack/tmp', names.REPLY_FILE);
|
||||
expect(r.stderr).toContain(`Not sent: ${file} is missing or empty`);
|
||||
expect(r.stderr).toContain(`gh api repos/o/r/pulls/7/comments/123/replies -F body=@${file}`);
|
||||
expect(r.stderr).toContain(`gstack-post reply 7 --to 123 --body-file ${file}`);
|
||||
expect(s.calls()).toEqual([]);
|
||||
expect(fs.existsSync(file)).toBe(true);
|
||||
});
|
||||
@@ -248,7 +254,7 @@ describe('/spec filing and archive through the real generated gate-and-file text
|
||||
const doc = fs.readFileSync(path.join(ROOT, 'spec/sections/gate-and-file.md'), 'utf8');
|
||||
const draftBlock = fence(doc, 'REDACT_FILE=$(mktemp');
|
||||
const titleBlock = fence(doc, 'TITLE_FILE=$(mktemp');
|
||||
const fileBlock = fence(doc, 'ISSUE_URL=$(gh issue create');
|
||||
const fileBlock = fence(doc, 'gstack-post issue-create --title-file "$TITLE_FILE"');
|
||||
const archiveBlock = fence(doc, 'ARCHIVE_PATH.tmp');
|
||||
|
||||
test('hostile title, body and approach are filed and archived as literal text', () => {
|
||||
@@ -284,7 +290,7 @@ describe('/spec filing and archive through the real generated gate-and-file text
|
||||
const r = s.run(fileBlock.replaceAll('<redact-file-name>', draft.REDACT_FILE).replaceAll('<title-file-name>', files.TITLE_FILE).replaceAll('<approach-file-name>', files.APPROACH_FILE));
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.stderr).toContain('Not filed:');
|
||||
expect(r.stderr).toContain('gh issue create --title "$(cat ');
|
||||
expect(r.stderr).toContain('gstack-post issue-create --title-file ');
|
||||
expect(s.calls()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -47,6 +47,8 @@ export const IDENTIFIER_PLACEHOLDERS: Record<string, IdentifierGrammar> = {
|
||||
'<key>': { grammar: ID, what: 'option key' },
|
||||
'<summary-slug>': { grammar: ID, what: 'question summary as a kebab-case slug' },
|
||||
'<comment-id>': { grammar: NUM, what: 'GitHub comment id from the fetched JSON' },
|
||||
'<pr-number>': { grammar: NUM, what: 'PR/MR number from the open-PR lookup or the early-PR create output' },
|
||||
'<new-version>': { grammar: /^[0-9]+(?:\.[0-9]+)*$/, what: 'NEW_VERSION from the version bump (dot-separated digits)' },
|
||||
'<issue-number>': { grammar: /^[0-9]*$/, what: 'filed issue number, or empty when none was filed' },
|
||||
'<check-number>': { grammar: NUM, what: 'canary check number' },
|
||||
'<run-id>': { grammar: NUM, what: 'GitHub Actions run id' },
|
||||
|
||||
@@ -68,4 +68,21 @@ describe('gstack-pr-title-rewrite', () => {
|
||||
const twice = rewrite('1.2.3.4', once).stdout;
|
||||
expect(twice).toBe(once);
|
||||
});
|
||||
|
||||
test('--stdin reads the platform title from standard input, so no shell string carries it', () => {
|
||||
const title = "feat: `id` $(touch x) it's \"quoted\"";
|
||||
const r = spawnSync(HELPER, ['1.2.3.4', '--stdin'], { input: title + '\n', encoding: 'utf-8', timeout: 30_000 });
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toBe(`v1.2.3.4 ${title}\n`);
|
||||
const again = spawnSync(HELPER, ['1.2.3.4', '--stdin'], { input: 'v1.2.3 fix: y', encoding: 'utf-8', timeout: 30_000 });
|
||||
expect(again.stdout).toBe('v1.2.3.4 fix: y\n');
|
||||
});
|
||||
|
||||
test('--stdin refuses an empty or multi-line title', () => {
|
||||
for (const input of ['', 'a\nb\n']) {
|
||||
const r = spawnSync(HELPER, ['1.2.3.4', '--stdin'], { input, encoding: 'utf-8', timeout: 30_000 });
|
||||
expect(r.status).toBe(2);
|
||||
expect(r.stdout).toBe('');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,8 @@
|
||||
* branches, the which-bun probe, and the guardrail framing.
|
||||
*/
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { spawnSync } from "child_process";
|
||||
import * as path from "path";
|
||||
import {
|
||||
generateRedactInvocationBlock,
|
||||
} from "../scripts/resolvers/redact-doc";
|
||||
@@ -22,7 +24,7 @@ const ctx = {
|
||||
|
||||
describe("REDACT_INVOCATION_BLOCK", () => {
|
||||
test("scan-at-sink: agent-written private file → scan that file → exact bytes", () => {
|
||||
const block = generateRedactInvocationBlock(ctx, ["pre-issue"]);
|
||||
const block = generateRedactInvocationBlock(ctx, ["pre-archive"]);
|
||||
// CEO-12: the bytes come from the file the agent wrote (created by the
|
||||
// shared mktemp free-text block), never from a heredoc.
|
||||
expect(block).toContain('/.gstack/tmp/<redact-file-name>"');
|
||||
@@ -39,34 +41,46 @@ describe("REDACT_INVOCATION_BLOCK", () => {
|
||||
});
|
||||
|
||||
test("resolves visibility config → gh → glab → unknown", () => {
|
||||
const block = generateRedactInvocationBlock(ctx, ["pre-issue"]);
|
||||
const block = generateRedactInvocationBlock(ctx, ["pre-archive"]);
|
||||
expect(block).toContain("redact_repo_visibility");
|
||||
expect(block).toContain("gh repo view --json visibility");
|
||||
expect(block).toContain("glab repo view");
|
||||
});
|
||||
|
||||
test("includes a which-bun probe", () => {
|
||||
expect(generateRedactInvocationBlock(ctx, ["pre-issue"])).toContain("command -v bun");
|
||||
expect(generateRedactInvocationBlock(ctx, ["pre-archive"])).toContain("command -v bun");
|
||||
});
|
||||
|
||||
test("HIGH has no skip flag; framed as guardrail not enforcement", () => {
|
||||
const block = generateRedactInvocationBlock(ctx, ["pre-issue"]);
|
||||
const block = generateRedactInvocationBlock(ctx, ["pre-archive"]);
|
||||
expect(block).toMatch(/no skip flag for HIGH/i);
|
||||
expect(block).toMatch(/guardrail, not airtight enforcement/i);
|
||||
});
|
||||
|
||||
test("PII subset offers auto-redact; non-PII MEDIUM does not", () => {
|
||||
const block = generateRedactInvocationBlock(ctx, ["pre-pr-body"]);
|
||||
const block = generateRedactInvocationBlock(ctx, ["pre-commit"]);
|
||||
expect(block).toContain("--auto-redact");
|
||||
expect(block).toContain("Proceed (acknowledged)");
|
||||
});
|
||||
|
||||
test("sink label drives the prose noun/verb", () => {
|
||||
expect(generateRedactInvocationBlock(ctx, ["pre-commit"])).toContain("commit");
|
||||
expect(generateRedactInvocationBlock(ctx, ["pre-pr-title"])).toContain("PR title");
|
||||
expect(generateRedactInvocationBlock(ctx, ["pre-archive"])).toContain("write the archive");
|
||||
});
|
||||
|
||||
test("unknown sink label falls back without throwing", () => {
|
||||
expect(() => generateRedactInvocationBlock(ctx, ["bogus-sink"])).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PR and issue sites carry no separate scan (CEO-19)", () => {
|
||||
// gstack-post scans the exact bytes it posts; a second scan at a PR or issue
|
||||
// site would ask the user twice and could drift from what is sent.
|
||||
test("no template renders a PR, MR or issue sink", () => {
|
||||
const r = spawnSync("git", ["grep", "-n", "-E", "REDACT_INVOCATION_BLOCK:pre-(issue|pr-)", "--", "*.tmpl"], {
|
||||
cwd: path.resolve(import.meta.dir, ".."), encoding: "utf8", timeout: 30_000,
|
||||
});
|
||||
expect(r.stdout).toBe("");
|
||||
expect(r.status).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -79,10 +79,11 @@ describe("#2679: skill-content mktemp guards", () => {
|
||||
expect(body).toMatch(/PR_BODY_FILE=\$\(mktemp "\$\{TMPDIR:-\/tmp\}\/[^"]+"\)\s*\|\|\s*\{[^}]*exit 1/);
|
||||
});
|
||||
|
||||
test("ship pr-body GitLab path sends the SCANNED file, never a re-rendered heredoc", () => {
|
||||
test("ship pr-body GitLab path sends the SCANNED file through gstack-post, never a re-rendered heredoc", () => {
|
||||
const body = readScript("ship/sections/pr-body.md.tmpl");
|
||||
expect(body).toContain('-d "$(cat "$PR_BODY_FILE")"');
|
||||
expect(body).not.toMatch(/glab mr create[^\n]*-d "\$\(cat <<'EOF'/);
|
||||
expect(body).toContain('gstack-post pr-create --base <base> --title-file "$TITLE_FILE" --body-file "${PR_BODY_FILE:?restore the composed body path}"');
|
||||
expect(body).not.toMatch(/glab mr (?:create|update)/);
|
||||
expect(body).not.toMatch(/cat <<'EOF'/);
|
||||
});
|
||||
|
||||
test("gstack-upgrade vendored block guards mktemp -d and clone with loud aborts", () => {
|
||||
|
||||
@@ -333,6 +333,6 @@ describe('ship source controller', () => {
|
||||
expect(push).toMatch(/never bypass failed guards/i);
|
||||
const publication = compact(read('ship/sections/pr-body.md.tmpl'));
|
||||
expect(publication).toMatch(/repeat Step 18's identity\/title preparation/i);
|
||||
expectMentions(publication, [['before', 'publishing', 'redaction']], 'publication');
|
||||
expectMentions(publication, [['fresh body', 'before', 'publishing']], 'publication');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { expectMentions } from './helpers/prompt-structure';
|
||||
@@ -13,45 +13,86 @@ function writeDrafts(root: string, top = '## Summary\n\n## Documentation', rest
|
||||
writeFileSync(join(root, '.gstack/tmp/pr-body-rest.abc123'), rest);
|
||||
return block => block.replace('<body-top-file-name>', 'pr-body-top.abc123').replace('<body-rest-file-name>', 'pr-body-rest.abc123');
|
||||
}
|
||||
const scanner = resolve(import.meta.dir, '../bin/gstack-redact');
|
||||
const POST = resolve(import.meta.dir, '../bin/gstack-post');
|
||||
const fenceAfter = (marker: string) => {
|
||||
const at = template.indexOf(marker);
|
||||
expect(at).toBeGreaterThan(-1);
|
||||
const open = template.indexOf('```bash\n', at) + 8;
|
||||
return template.slice(open, template.indexOf('\n```', open));
|
||||
};
|
||||
const composeBlock = template.match(/```bash\n(: "\$\{DOCS_SECTION_FILE:[\s\S]*?)\n```/)?.[1]!;
|
||||
const editBlock = fenceAfter('**Existing open PR/MR**');
|
||||
const createBlock = fenceAfter('**No open PR/MR:**');
|
||||
|
||||
/**
|
||||
* A repo whose origin is GitHub, a stub gh that records argv and stdin, and
|
||||
* the real gstack-post at the installed path the blocks call.
|
||||
*/
|
||||
function publicationRepo(root: string) {
|
||||
const log = join(root, 'log');
|
||||
const stubs = join(root, 'stubs');
|
||||
const bin = join(root, '.claude/skills/gstack/bin');
|
||||
for (const dir of [log, stubs, bin]) mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(root, 'gitconfig'), '');
|
||||
const gitEnv = { GIT_CONFIG_GLOBAL: join(root, 'gitconfig'), GIT_CONFIG_NOSYSTEM: '1' };
|
||||
for (const args of [['init', '-q'], ['remote', 'add', 'origin', 'https://github.com/o/r.git']]) {
|
||||
Bun.spawnSync(['git', ...args], { cwd: root, env: { ...process.env, ...gitEnv }, timeout: 10_000 });
|
||||
}
|
||||
writeFileSync(join(bin, 'gstack-post'), `#!/bin/sh\nexec ${JSON.stringify(process.execPath)} ${JSON.stringify(POST)} "$@"\n`, { mode: 0o755 });
|
||||
writeFileSync(join(stubs, 'gh'), `#!/usr/bin/env bash
|
||||
case "$1 $2" in
|
||||
"repo view") echo PRIVATE; exit 0 ;;
|
||||
"pr view") cat "${log}/title" 2>/dev/null; exit 0 ;;
|
||||
esac
|
||||
n=$(ls "${log}" | grep -c args)
|
||||
for a in "$@"; do printf '%s\\0' "$a"; done > "${log}/$n.args"
|
||||
cat > "${log}/$n.stdin"
|
||||
for a in "$@"; do case "$a" in --title=*) printf '%s\\n' "\${a#--title=}" > "${log}/title" ;; esac; done
|
||||
[ "$1 $2" = "pr create" ] && echo "https://github.com/o/r/pull/9"
|
||||
exit 0
|
||||
`, { mode: 0o755 });
|
||||
const run = (script: string) => Bun.spawnSync(['bash', '-c', script], {
|
||||
cwd: root, env: { ...process.env, ...gitEnv, HOME: root, PATH: `${stubs}:${process.env.PATH}`, DOCS_SECTION_FILE: join(root, 'section.md'), TMPDIR: root },
|
||||
stdout: 'pipe', stderr: 'pipe', timeout: 30_000,
|
||||
});
|
||||
const calls = () => readdirSync(log).filter(f => f.endsWith('.args')).sort().map(f => ({
|
||||
args: readFileSync(join(log, f), 'utf8').split('\0').slice(0, -1),
|
||||
stdin: readFileSync(join(log, f.replace('.args', '.stdin')), 'utf8'),
|
||||
}));
|
||||
return { run, calls };
|
||||
}
|
||||
|
||||
/** Compose the body with the real compose block and write the Step 18 title file. */
|
||||
function composed(root: string, opts: { title?: string; top?: string } = {}) {
|
||||
const repo = publicationRepo(root);
|
||||
writeFileSync(join(root, 'section.md'), '**Status:** current — no edits.');
|
||||
const compose = repo.run(writeDrafts(root, opts.top)(composeBlock));
|
||||
expect(compose.exitCode, compose.stderr.toString()).toBe(0);
|
||||
const bodyFile = /^PR_BODY_FILE: (\S+)$/m.exec(compose.stdout.toString())![1]!;
|
||||
writeFileSync(join(root, '.gstack/tmp/pr-title.abc123'), (opts.title ?? 'v1.2.3.4 fix: publication checks') + '\n');
|
||||
const fill = (block: string) => `PR_BODY_FILE=${JSON.stringify(bodyFile)}\n` +
|
||||
block.replace('<title-file-name>', 'pr-title.abc123').replaceAll('<pr-number>', '9').replace('<base>', 'main');
|
||||
return { ...repo, bodyFile, publish: (block: string) => repo.run(fill(block)) };
|
||||
}
|
||||
|
||||
for (const failure of ['none', 'body', 'title'] as const) {
|
||||
test(`publication scan consumes the real scanner result: ${failure}`, () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'ship-scan-'));
|
||||
test(`publication posts only scanned bytes through gstack-post: ${failure}`, () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'ship-post-'));
|
||||
try {
|
||||
const bin = join(root, '.claude/skills/gstack/bin');
|
||||
mkdirSync(bin, { recursive: true });
|
||||
writeFileSync(join(bin, 'gstack-config'), '#!/bin/sh\nprintf "private\\n"\n', { mode: 0o755 });
|
||||
writeFileSync(join(bin, 'gstack-redact'), `#!/bin/sh
|
||||
kind=title
|
||||
case "$*" in *--from-file*) kind=body ;; esac
|
||||
printf '%s\\n' "$kind" >> "$HOME/scans"
|
||||
if [ "$kind" = "$FAIL_AT" ]; then
|
||||
exec ${JSON.stringify(process.execPath)} ${JSON.stringify(scanner)} --from-file "$HOME/missing-input" --json
|
||||
fi
|
||||
exec ${JSON.stringify(process.execPath)} ${JSON.stringify(scanner)} "$@"
|
||||
`, { mode: 0o755 });
|
||||
const block = template.match(/```bash\n(: "\$\{NEW_TITLE:[\s\S]*?)\n```/)?.[1];
|
||||
expect(block).toBeDefined();
|
||||
writeFileSync(join(root, 'section.md'), '**Status:** current — no edits.');
|
||||
const result = Bun.spawnSync(['bash', '-c', writeDrafts(root)(block!)], {
|
||||
cwd: root,
|
||||
env: { ...process.env, HOME: root, NEW_TITLE: 'v1.2.3.4 fix: publication checks', FAIL_AT: failure,
|
||||
DOCS_SECTION_FILE: join(root, 'section.md') },
|
||||
stdout: 'pipe', stderr: 'pipe', timeout: 10_000,
|
||||
const secret = 'AKIA' + '1234567890ABCDEF';
|
||||
const c = composed(root, {
|
||||
top: failure === 'body' ? `## Summary\nkey ${secret}\n\n## Documentation` : undefined,
|
||||
title: failure === 'title' ? `v1.2.3.4 fix: ${secret}` : undefined,
|
||||
});
|
||||
const output = result.stdout.toString();
|
||||
const error = result.stderr.toString();
|
||||
const calls = readFileSync(join(root, 'scans'), 'utf8').trim().split('\n');
|
||||
const result = c.publish(createBlock);
|
||||
if (failure === 'none') {
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(calls).toEqual(['body', 'title']);
|
||||
expect(output).toContain('"HIGH": 0');
|
||||
expect(result.exitCode, result.stderr.toString()).toBe(0);
|
||||
expect(result.stdout.toString()).toContain('https://github.com/o/r/pull/9');
|
||||
expect(c.calls()).toEqual([{ args: ['pr', 'create', '--base=main', '--title=v1.2.3.4 fix: publication checks', '--body-file=-'], stdin: readFileSync(c.bodyFile, 'utf8') }]);
|
||||
} else {
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(error).toContain('missing-input');
|
||||
expect(calls).toEqual(failure === 'body' ? ['body'] : ['body', 'title']);
|
||||
if (failure === 'body') expect(output).toContain('BLOCKED');
|
||||
expect(result.stdout.toString()).toContain(`HIGH RULE: aws.access_key LINE: ${failure === 'body' ? 2 : 1} PART: ${failure}`);
|
||||
expect(c.calls()).toEqual([]);
|
||||
}
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
@@ -59,9 +100,33 @@ exec ${JSON.stringify(process.execPath)} ${JSON.stringify(scanner)} "$@"
|
||||
});
|
||||
}
|
||||
|
||||
test('publication composes the saved documentation section unchanged and refuses to scan without it', () => {
|
||||
const block = template.match(/```bash\n(: "\$\{NEW_TITLE:[\s\S]*?)\n```/)?.[1]!;
|
||||
const compose = block.slice(block.indexOf('PR_BODY_FILE=$(mktemp'), block.indexOf('~/.claude/skills/gstack/bin/gstack-redact --from-file'));
|
||||
test('MEDIUM findings publish nothing until the block reruns with the printed token', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'ship-post-medium-'));
|
||||
try {
|
||||
const c = composed(root, { top: '## Summary\nThanks jane.roe@acme-corp.io\n\n## Documentation' });
|
||||
const first = c.publish(editBlock);
|
||||
expect(first.exitCode).toBe(2);
|
||||
expect(first.stdout.toString()).toContain('RULE: pii.email LINE: 2 PART: body');
|
||||
expect(c.calls()).toEqual([]);
|
||||
const token = /^TOKEN: (\S+)$/m.exec(first.stdout.toString())![1]!;
|
||||
const flagged = 'pr-body <pr-number> --body-file "${PR_BODY_FILE:?restore the composed body path}"';
|
||||
expect(editBlock).toContain(flagged);
|
||||
const then = c.publish(editBlock.replace(flagged, `${flagged} --confirm ${token}`));
|
||||
expect(then.exitCode, then.stderr.toString()).toBe(0);
|
||||
const calls = c.calls();
|
||||
expect(calls.map(call => call.args)).toEqual([
|
||||
['pr', 'edit', '9', '--body-file=-'],
|
||||
['pr', 'edit', '9', '--title=v1.2.3.4 fix: publication checks'],
|
||||
]);
|
||||
expect(calls[0]!.stdin).toBe(readFileSync(c.bodyFile, 'utf8'));
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('publication composes the saved documentation section unchanged and refuses to compose without it', () => {
|
||||
const block = composeBlock;
|
||||
const compose = block.slice(block.indexOf('PR_BODY_FILE=$(mktemp'), block.indexOf('echo "PR_BODY_FILE:'));
|
||||
const root = mkdtempSync(join(tmpdir(), 'ship-compose-'));
|
||||
try {
|
||||
const section = '**Status:** current — no edits.\n\n- Diagram drift: none (no diagrams in any doc).';
|
||||
@@ -85,7 +150,7 @@ test('publication composes the saved documentation section unchanged and refuses
|
||||
expect(unwritten.stdout.toString()).toBe('');
|
||||
expect(unwritten.stderr.toString()).toContain('the body was never written');
|
||||
const unset = Bun.spawnSync(['bash', '-c', block], {
|
||||
cwd: root, env: { ...process.env, HOME: root, NEW_TITLE: 'v1.2.3.4 fix: publication checks', DOCS_SECTION_FILE: '' },
|
||||
cwd: root, env: { ...process.env, HOME: root, DOCS_SECTION_FILE: '' },
|
||||
stdout: 'pipe', stderr: 'pipe', timeout: 10_000,
|
||||
});
|
||||
expect(unset.exitCode).not.toBe(0);
|
||||
@@ -106,7 +171,7 @@ test('publication reports unavailable triage separately from an empty successful
|
||||
test('publication refreshes the open review and title without treating lookup failure as absence', () => {
|
||||
const lookup = template.slice(0, template.indexOf('### Resolve Linked Spec'));
|
||||
expect(lookup.replace(/\s+/g, ' ')).toContain('Errors or ambiguous matches STOP publication');
|
||||
expectMentions(lookup.replace(/\s+/g, ' '), [['before', 'publishing', 'redaction']], 'lookup.replace(/\s+/g, )');
|
||||
expectMentions(lookup.replace(/\s+/g, ' '), [['fresh body', 'before', 'publishing']], 'lookup.replace(/\s+/g, )');
|
||||
expect(lookup).not.toContain('Ship control flow');
|
||||
expect(lookup).not.toContain('|| echo "NO_PR"');
|
||||
expect(lookup).not.toContain('|| echo "NO_MR"');
|
||||
|
||||
@@ -59,10 +59,10 @@ test('late behavioral repairs rebuild before the docs decision and commit only r
|
||||
expect(commit).toMatch(/preserve unrelated user files/i);
|
||||
});
|
||||
|
||||
test('the title is prefixed once before the exact scanned value is published', () => {
|
||||
test('the title is prefixed once in Step 18 and posted from its file unchanged', () => {
|
||||
const body = compact(read('ship/sections/pr-body.md.tmpl'));
|
||||
expect(body).toContain("Step 18's `NEW_TITLE` unchanged");
|
||||
expect(body).not.toContain('`NEW_TITLE`, prefixed with');
|
||||
expect(body).toContain('--title "$NEW_TITLE"');
|
||||
expect(body).toContain('scanned `NEW_TITLE`');
|
||||
expect(body).toContain("substitute Step 18's title file name");
|
||||
expect(body).not.toContain('prefixed with');
|
||||
expect(body).toContain('--title-file "$TITLE_FILE"');
|
||||
expect(body).toContain('every retry sends the same files');
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* /ship redaction wiring (T5/T11). The PR body + title are scanned at-sink before
|
||||
* create AND edit; tool output goes in attributed fences so example credentials
|
||||
* WARN-degrade instead of blocking; create/edit file from the scanned temp file.
|
||||
* /ship redaction wiring (T5/T11, CEO-19). The PR body + title are published
|
||||
* only through gstack-post, which scans the exact bytes it sends on create AND
|
||||
* edit; tool output goes in attributed fences so example credentials
|
||||
* WARN-degrade instead of blocking.
|
||||
*/
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import * as fs from "fs";
|
||||
@@ -25,31 +26,33 @@ function readShipTemplateUnion(): string {
|
||||
const TMPL = readShipTemplateUnion();
|
||||
|
||||
describe("/ship redaction wiring", () => {
|
||||
test("scans the PR body via the shared bin before create", () => {
|
||||
expect(TMPL).toContain("gstack-redact --from-file");
|
||||
// CEO-19: gstack-post owns the scan of the exact title and body bytes it
|
||||
// sends (test/gstack-post*.test.ts, test/ship-publication-gates.test.ts), so
|
||||
// /ship publishes only through it and never calls gh/glab with text itself.
|
||||
test("creates and edits only through gstack-post, from the composed body file and the title file", () => {
|
||||
expect(TMPL).toContain('gstack-post pr-create --base <base> --title-file "$TITLE_FILE" --body-file "${PR_BODY_FILE:?restore the composed body path}"');
|
||||
expect(TMPL).toContain('gstack-post pr-body <pr-number> --body-file "${PR_BODY_FILE:?restore the composed body path}"');
|
||||
expect(TMPL).toContain('gstack-post pr-title <pr-number> --title-file "$TITLE_FILE"');
|
||||
});
|
||||
test("creates from the scanned temp file (exact bytes)", () => {
|
||||
expect(TMPL).toMatch(/gh pr create[\s\S]{0,120}--body-file "\$PR_BODY_FILE"/);
|
||||
test("no gh/glab call carries a title or body, and no shell variable holds the title", () => {
|
||||
expect(TMPL).not.toMatch(/gh pr (?:create|edit)[^\n`]*--(?:title|body)/);
|
||||
expect(TMPL).not.toMatch(/glab mr (?:create|update)[^\n`]*(?:-t|-d) /);
|
||||
expect(TMPL).not.toMatch(/-f title=|-F body=@"\$PR_BODY_FILE"/);
|
||||
expect(TMPL).not.toContain("NEW_TITLE");
|
||||
});
|
||||
test("edit path also scans before sending", () => {
|
||||
expect(TMPL).toMatch(/gh pr edit --body-file "\$PR_BODY_FILE"/);
|
||||
const scanAt = TMPL.indexOf('gstack-redact --from-file "$PR_BODY_FILE"');
|
||||
expect(scanAt).toBeGreaterThan(0);
|
||||
expect(TMPL.indexOf('gh pr edit --body-file "$PR_BODY_FILE"')).toBeGreaterThan(scanAt);
|
||||
test("HIGH blocks the PR (exit 1), with no confirmation path", () => {
|
||||
expect(TMPL).toMatch(/\*\*1\*\* HIGH finding: BLOCKED — do not publish/);
|
||||
});
|
||||
test("HIGH blocks the PR (exit 3), no skip", () => {
|
||||
expect(TMPL).toMatch(/BLOCKED — credential in PR body/);
|
||||
test("MEDIUM needs the user's answer and the token printed for those bytes", () => {
|
||||
expect(TMPL).toContain("AskUserQuestion per\n finding");
|
||||
expect(TMPL).toContain("`--confirm <confirm-token>`");
|
||||
expect(TMPL).toContain("any edit needs a new token");
|
||||
});
|
||||
test("instructs wrapping tool output in attributed fences (TENSION-3)", () => {
|
||||
expect(TMPL).toMatch(/tool-attributed fences/);
|
||||
expect(TMPL).toMatch(/codex-review/);
|
||||
expect(TMPL).toMatch(/greptile/);
|
||||
});
|
||||
test("scans the title too", () => {
|
||||
expect(TMPL).toContain('printf \'%s\' "$NEW_TITLE" | ~/.claude/skills/gstack/bin/gstack-redact');
|
||||
expect(TMPL).toContain('gh pr create --base <base> --title "$NEW_TITLE"');
|
||||
expect(TMPL).toContain('gh pr edit --title "$NEW_TITLE"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("tool-attributed fence behavior (engine contract /ship relies on)", () => {
|
||||
|
||||
@@ -168,21 +168,24 @@ test('late verified outputs are committed before push, without tags', () => {
|
||||
expectTokens(pushFlow, ['`ALREADY_PUSHED`'], 'ship Step 17');
|
||||
});
|
||||
|
||||
test('Step 18 resolves open PR state before preparing the exact title Step 19 scans', () => {
|
||||
const title = compact(between(entryTemplate, '## Step 18:', '{{SECTION:pr-body}}'));
|
||||
expectOrdered(title, ['gh pr list --head <branch-name> --state open --json number,title,url', 'Prepare the title'], 'ship Step 18');
|
||||
expectTokens(title, ['glab mr list --source-branch <branch-name> --output json', '`NEW_TITLE`', '`v<NEW_VERSION> <type>: <summary>`', '`v$NEW_VERSION `'], 'ship Step 18');
|
||||
expectTokens(prTemplate, [': "${NEW_TITLE:?Restore the saved Step 18 title before scanning}"', 'exit 3'], 'ship pr-body');
|
||||
expectAbsent(prTemplate, ['NEW_TITLE="<final vNEW_VERSION type: summary>"'], 'ship pr-body');
|
||||
test('Step 18 resolves open PR state before preparing the title file Step 19 posts', () => {
|
||||
const lookup = compact(between(entryTemplate, '## Step 18:', '{{SECTION:pr-body}}'));
|
||||
expectOrdered(lookup, ['gh pr list --head <branch-name> --state open --json number,title,url', 'prepares the title'], 'ship Step 18');
|
||||
expectTokens(lookup, ['glab mr list --source-branch <branch-name> --output json'], 'ship Step 18');
|
||||
const title = compact(between(prTemplate, '### Prepare the title (Step 18)', '## Step 19:'));
|
||||
expectOrdered(title, ['{{FREE_TEXT_FILE:TITLE_FILE=pr-title}}', 'gstack-pr-title-rewrite.sh <new-version> --stdin > "$TITLE_FILE"'], 'ship Step 18 title');
|
||||
expectTokens(title, ['`v<NEW_VERSION> <type>: <summary>`', '`v$NEW_VERSION `', "grep -Eq '^v<new-version>( |$)' \"$TITLE_FILE\""], 'ship Step 18 title');
|
||||
expectAbsent(compact(entryTemplate + prTemplate), ['"<current title>"', 'NEW_TITLE'], 'ship Steps 18-19');
|
||||
expectTokens(prTemplate, ['--title-file "$TITLE_FILE"', 'exit 1', '**3**'], 'ship pr-body');
|
||||
const generated = readTemplate('ship/sections/pr-body.md');
|
||||
expectTokens(generated, ['**Existing open PR/MR:**'], 'ship pr-body section');
|
||||
expectTokens(generated, ['**Existing open PR/MR**'], 'ship pr-body section');
|
||||
});
|
||||
|
||||
test('linked spec discovery reads frontmatter fields and claims closure only for completed scope', () => {
|
||||
const instructions = compact(between(prTemplate, /^/, 'The PR/MR body should contain'));
|
||||
expectTokens(instructions, ['`spec_branch`', '`spec_filed_at`', '`spec_issue_number`', '`## Linked Spec`', '`Closes #N`', '`Linked to #N`'], 'ship linked spec');
|
||||
expectMentions(instructions, [['frontmatter', 'never', 'source']], 'ship linked spec');
|
||||
const body = between(prTemplate, 'The PR/MR body should contain', '#### Redaction scan');
|
||||
const body = between(prTemplate, 'The PR/MR body should contain', '#### Compose the body');
|
||||
expectAbsent(body, ['CURRENT_BRANCH=', 'SPEC_ARCHIVES=', 'SPEC_FILE=$(grep'], 'ship pr body');
|
||||
expectAbsent(prTemplate, ['[ -z "$SPEC_FILE" ] && exit'], 'ship pr-body');
|
||||
});
|
||||
|
||||
@@ -177,14 +177,14 @@ describe('/spec fail-closed redaction (shared engine)', () => {
|
||||
});
|
||||
|
||||
describe('/spec redaction at every sink (scan-at-sink, carved: gate-and-file section)', () => {
|
||||
test('scan precedes the gh issue create (pre-issue)', () => {
|
||||
const scanIdx = SEC_GEN.indexOf('Re-scan before filing');
|
||||
const fileIdx = SEC_GEN.indexOf('gh issue create --title');
|
||||
expect(scanIdx).toBeGreaterThan(-1);
|
||||
expect(fileIdx).toBeGreaterThan(scanIdx);
|
||||
test('the issue is filed only through gstack-post, which scans the title and body it sends (CEO-19)', () => {
|
||||
const fileIdx = SEC_GEN.indexOf('gstack-post issue-create --title-file "$TITLE_FILE" --body-file "$REDACT_FILE")');
|
||||
expect(fileIdx).toBeGreaterThan(-1);
|
||||
expect(SEC_GEN).not.toMatch(/gh issue create --title/);
|
||||
expect(SEC_GEN.slice(fileIdx)).toMatch(/Exit 1 \(HIGH\): do NOT file/);
|
||||
});
|
||||
test('files from the scanned temp file (exact bytes, not a re-render)', () => {
|
||||
expect(SEC_GEN).toContain('ISSUE_URL=$(gh issue create --title "$(cat "$TITLE_FILE")" --body-file "$REDACT_FILE")');
|
||||
expect(SEC_GEN).toContain('POST_OUT=$(~/.claude/skills/gstack/bin/gstack-post issue-create --title-file "$TITLE_FILE" --body-file "$REDACT_FILE"); POST_CODE=$?');
|
||||
// CEO-12: the title is an agent-written file; no heredoc or quoted placeholder carries it.
|
||||
expect(SEC_GEN).toContain('TITLE_FILE=$(mktemp "${_GT:?}/title.XXXXXX")');
|
||||
expect(SEC_GEN).not.toContain('<<\'GSTACK_TITLE\'');
|
||||
|
||||
Reference in new issue
Block a user