Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ One file per `src/commands/<name>.ts`. Open only the file for the command you ch
| `schedule.md` | `venfork schedule` | no |
| `stage.md` | `venfork stage [branch] <name>` | yes, unless `--no-pr` |
| `pull-request.md` | `venfork pull pr` | yes |
| `approval.md` | `venfork stage approval` | no |
| `issue.md` | `venfork stage issue`, `venfork pull issue` | yes |
| `doctor.md` | `venfork doctor` | no (`transport` skips for a non-GitHub origin; `token`, `last-run`, `cron-age` need a schedule, gh and a GitHub origin) |
| `workflows.md` | `venfork workflows` | no |
Expand Down
42 changes: 42 additions & 0 deletions .agents/skills/verify-venfork/references/features/approval.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
command: approval
entry: src/commands/approval.ts
shared: [json-output]
config:
read: [approval]
written: [approval]
tests:
unit: [tests/unit/stage-args.test.ts, tests/unit/config.test.ts]
integration: [tests/integration/approval.test.ts]
e2e: []
---

## What exists

- `venfork stage approval` prints the mirror's `approval` level from venfork-config: `off` (field absent), `reviewed` or `two-person`. `off` also prints the warning `The approval gate is off: ...`.
- `venfork stage approval <on|reviewed|two-person|off>` writes the level with a leased config push. `on` is `two-person`. `off` deletes the field and prints the same warning.
- Any other stored value (a level from a newer venfork, or a non-string) is kept on read and by every other config write, and refused by `approvalOf` with `ConfigError` (it suggests `venfork stage approval two-person`), so an older CLI never treats it as `off`. Only `"off"` and `""` read as off.
- Usage errors (exit 2): an unknown level (the message names the explicit form `stage branch approval`), a second level, and any branch flag (`--draft`, `--title`, `--dry-run`, `--pr`, ...); the branch-flag message also names `stage branch approval`. `stage approval` needs no gh.
- `--json`: `result` is `{ level }` after the action.

## How a user reaches it

`venfork stage approval [on|reviewed|two-person|off] [--json]`. A branch named `approval` needs the explicit form `stage branch approval` (see `stage.md`). No prompt.

## How to drive it

```bash
venfork stage approval --json </dev/null 2>/dev/null | jq -c .result # {"level":"off"}
venfork stage approval on </dev/null 2>&1 | grep -o "Approval set to two-person"
git -C "$VF_ORIGIN" show venfork-config:.venfork/config.json | jq -r .approval # two-person
venfork stage approval off </dev/null 2>&1 | grep -o "The approval gate is off"
venfork stage approval maybe </dev/null; echo $? # 2
```

## What proves it

- `git -C "$VF_ORIGIN" show venfork-config:.venfork/config.json` has `"approval"` after `on`/`reviewed`, and none after `off`.

## What usually lies

- The setting alone proves nothing about stage. Check the stage feature file for what stage does with the level.
2 changes: 1 addition & 1 deletion .agents/skills/verify-venfork/references/features/stage.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ tests:

## How a user reaches it

`venfork stage <branch>`, `venfork stage branch <name>` (for a branch named `issue` or `branch`), plus `--no-pr`, `--draft`, `--dry-run`, `--title <t>`, `--base <b>`, `--internal-pr <n>`, `--no-update-existing`, `--yes`/`-y`, `--no-input`, `--json`. `stage issue` is in `issue.md`.
`venfork stage <branch>`, `venfork stage branch <name>` (for a branch named `issue`, `approval` or `branch`), plus `--no-pr`, `--draft`, `--dry-run`, `--title <t>`, `--base <b>`, `--internal-pr <n>`, `--no-update-existing`, `--yes`/`-y`, `--no-input`, `--json`. `stage issue` is in `issue.md`; `stage approval` is in `approval.md`.

## How to drive it

Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ CLI entry `src/index.ts` parses `argv[0]` as the command and dispatches via a sw

`src/commands/` holds one file per command. Helpers shared by several commands live in `src/shared/`: `net.ts` (network-op safety), `managed-commit.ts` (managed-commit detection), `mirror-commit.ts` (building the managed commit), `divergence.ts`, `redaction.ts`, `worktree.ts` (temporary detached worktrees), `prompt.ts` (every interactive prompt), `ui.ts` (all human output, on stderr), `json-output.ts` (the one `--json` envelope: a command returns its result, `src/index.ts` writes it, `error.code` comes from `VenforkError.code`, and a failed `git`/`gh` subprocess maps to `git`/`github`), `interrupt.ts` (a command that handles SIGINT/SIGTERM itself claims them, so the `--json` signal envelope waits for its rejection), `constants.ts`. The rest are focused modules:

- **`config.ts`** — the source of truth for persisted state. All cross-run state lives in `VenforkConfig` (`.venfork/config.json`) on an **orphan `venfork-config` branch** in the private mirror — never on a working branch. It tracks `upstreamUrl`, `publicForkUrl`, `mode`, `schedule`, `scheduleAuth` (top level; `'app'` or absent for token auth), workflow allow/block lists, the `preserve` allowlist, and link maps (`shippedBranches`, `pulledPrs`, `shippedIssues`, `pulledIssues`). Mutate via `updateVenforkConfig` with a `VenforkConfigPatch` (shallow-merge; `null` deletes an entry or clears a field). Read with `fetchVenforkConfig` (clones only the config branch to a temp dir) or `readVenforkConfigFromRepo`.
- **`config.ts`** — the source of truth for persisted state. All cross-run state lives in `VenforkConfig` (`.venfork/config.json`) on an **orphan `venfork-config` branch** in the private mirror — never on a working branch. It tracks `upstreamUrl`, `publicForkUrl`, `mode`, `schedule`, `scheduleAuth` (top level; `'app'` or absent for token auth), `approval` (top level; `'reviewed'`, `'two-person'` or absent for off, set by `venfork stage approval`), workflow allow/block lists, the `preserve` allowlist, and link maps (`shippedBranches`, `pulledPrs`, `shippedIssues`, `pulledIssues`). Mutate via `updateVenforkConfig` with a `VenforkConfigPatch` (shallow-merge; `null` deletes an entry or clears a field). Read with `fetchVenforkConfig` (clones only the config branch to a temp dir) or `readVenforkConfigFromRepo`.
- **`git.ts`** — thin git/gh wrappers (`checkGhAuth`, `getRemotes`, `getDefaultBranch`, `ghRepoExists`, `ghRepoIsForkOf`, …).
- **`utils.ts`** — URL/shorthand parsing (`parseOwner`, `parseRepoName`, `parseRepoPath`, `normalizeGitHubRepoInput`); shorthand `owner/repo` is treated as `git@github.com:owner/repo.git`.
- **`workflow.ts`** — generates the deterministic GitHub Actions sync YAML (`.github/workflows/venfork-sync.yml`).
Expand Down
21 changes: 19 additions & 2 deletions docs/reference/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ venfork sync # Sync default branches with upstream

### `venfork stage <branch> [--no-pr] [--draft] [--dry-run] [--title <text>] [--base <branch>] [--internal-pr <n>] [--no-update-existing] [--yes] [--no-input] [--json]`

Push a branch to the public fork, making it visible and ready for PR to upstream. `venfork stage branch <name>` is the explicit form of the same command; use it for a branch named `issue` or `branch`. It also opens the upstream PR for you using your internal review PR's body, unless you pass `--no-pr`. With `--json`, stage never prompts and prints one JSON envelope on stdout (see [JSON output](json.md#json-output)).
Push a branch to the public fork, making it visible and ready for PR to upstream. `venfork stage branch <name>` is the explicit form of the same command; use it for a branch named `issue`, `approval` or `branch`. It also opens the upstream PR for you using your internal review PR's body, unless you pass `--no-pr`. With `--json`, stage never prompts and prints one JSON envelope on stdout (see [JSON output](json.md#json-output)).

**⚠️ Important:** This is when your work becomes visible to the client!

Expand Down Expand Up @@ -252,6 +252,23 @@ The implementation follows the spec at https://example.com/oauth.

A comment that mentions venfork but is not a well-formed marker (for example a typo such as `venfork:intenral`), an unmatched close marker, or any `venfork` left in the text after stripping stops the stage before anything is pushed. An unmatched open marker drops everything after it. If you forget to add markers around other internal context, it is sent upstream, so review the preview prompt before confirming.

### `venfork stage approval [on|reviewed|two-person|off] [--yes] [--no-input] [--json]`

Show or set how much review a branch needs before `venfork stage` publishes it. The level is stored as `approval` in the mirror's venfork settings, so every clone of the mirror reads the same level. With no argument it prints the current level. `venfork stage branch approval` stages a branch named `approval`.

- `off` - No review needed. This is the default.
- `reviewed` - The branch's PR on the private mirror needs an approving review on its current head from a person (not a bot) with write access to the mirror who is not the PR author, and every check on that head must pass.
- `two-person` - As `reviewed`, and the approver must also be someone other than the GitHub user who runs `venfork stage`.
- `on` - The same as `two-person`.

Setting `off` prints a warning. The gate is a guardrail against publishing by accident or by an agent, not enforcement: anyone with push access to the public fork or upstream can publish without venfork. With `--json`, `result` is `{ "level" }` after the action.

```bash
venfork stage approval # print the level
venfork stage approval on # two-person
venfork stage approval reviewed # a solo vendor reviewing an agent's PR
```

### `venfork pull pr <pr-number-or-url> [--branch-name <override>] [--no-push] [--yes] [--no-input] [--json]`

Pull a third-party upstream PR into the private mirror so your team can review it internally before it lands. The PR's commits land on a new branch (`upstream-pr/<n>` by default) that's pushed to your mirror. With `--json`, it prints one JSON envelope on stdout (see [JSON output](json.md#json-output)).
Expand Down Expand Up @@ -299,7 +316,7 @@ Move *issue* context between the private mirror and upstream. The command names
- `venfork stage issue <internal-#>` - read an internal triage issue from the mirror, redact `<!-- venfork:internal -->...<!-- /venfork:internal -->` blocks (same convention as `stage <branch>`), and open the upstream counterpart via `gh issue create`.
- `venfork pull issue <upstream-#>` - read an upstream issue **and its comments**, create a parallel internal issue on the mirror titled `[upstream #N] <original title>` so the team can triage it without leaving the private space. The upstream comment thread is snapshotted into the mirror issue body under an "Upstream comments" section.

A branch named `issue` or `branch` cannot use the bare `venfork stage <name>` form. Run `venfork stage branch issue` instead.
A branch named `issue`, `approval` or `branch` cannot use the bare `venfork stage <name>` form. Run `venfork stage branch issue` instead.

**Flags:**
- `--title <text>` - Override the destination issue's title.
Expand Down
3 changes: 2 additions & 1 deletion docs/reference/json.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ When stderr is a terminal, venfork draws styled output with colors and spinners.

## JSON output

Every command accepts `--json`: `setup`, `clone`, `sync`, `schedule`, `stage` (branch and `stage issue`), `pull pr`, `pull issue`, `doctor`, `workflows` and `keep`, with every action of `schedule`, `workflows` and `keep`. With it, stdout gets exactly one JSON object on one line, followed by a newline, also when the command fails. Progress, warnings and errors still go to stderr as usual. The exit code still follows the [exit codes](headless.md#non-interactive-use) table.
Every command accepts `--json`: `setup`, `clone`, `sync`, `schedule`, `stage` (branch, `stage issue` and `stage approval`), `pull pr`, `pull issue`, `doctor`, `workflows` and `keep`, with every action of `schedule`, `workflows` and `keep`. With it, stdout gets exactly one JSON object on one line, followed by a newline, also when the command fails. Progress, warnings and errors still go to stderr as usual. The exit code still follows the [exit codes](headless.md#non-interactive-use) table.

`--json` implies `--no-input`: venfork never prompts, even at a terminal. A confirm without `--yes` gives a `missing-input` error and exit 2, and so does a value that only a prompt could give (for example `setup` without `<upstream>`, or `clone` of a legacy mirror without `--public-fork`).

Expand Down Expand Up @@ -61,6 +61,7 @@ Any command line that contains `--json` before any `--` gets the envelope, whate
- `pull pr`: `{ "branch", "head", "prNumber", "prUrl", "pushed", "refreshed" }`. `refreshed` is true when this PR's copy already existed (the local branch, the mirror copy or a matching `pulledPrs` record), also when its head did not move. `pushed` is false with `--no-push` or a failed push; a failed push exits 1, with or without `--json`.
- `stage <branch>`: `{ "branch", "head", "target": "public" | "upstream", "compareUrl", "prAction"?, "prUrl"?, "internalPrUrl"? }`. `head` is the rebuilt commit that was pushed. `prAction` is present unless `--no-pr`: `created`, `updated` (an open PR's body was refreshed), `unchanged` (an open PR was left as is) or `failed` (the push worked, the PR could not be opened or its body could not be updated; the run exits 1).
- `stage <branch> --dry-run`: `{ "dryRun": true, "branch", "head", "target", "compareUrl", "base", "commits", "files", "removedLater", "prTitle"?, "prBody"?, "existingPrUrl"?, "internalPrUrl"? }`. Nothing was pushed: `head` is the preview rebuild's local commit (a real stage rebuilds again, so its SHA differs but its tree is the same), `commits` are its subjects oldest first, and `prTitle`/`prBody` are the redacted PR text that a real stage would publish (absent with `--no-pr`). `existingPrUrl` is the open upstream PR a real stage would update; with it, `prTitle` is absent (the title stays), and with `--no-update-existing` `prBody` is absent too.
- `stage approval` (with or without a level): `{ "level": "off" | "reviewed" | "two-person" }`, the level after the action.
- `stage issue` and `pull issue`: `{ "number", "url", "source": { "number", "url" } }`. `number` and `url` are the issue venfork opened; `source` is the issue it was made from.
- `doctor`: `{ "checks": [{ "id", "ok": true | false | "skipped", "detail", "fix"? }], "links": { "shippedBranches", "pulledPrs", "shippedIssues", "pulledIssues" } | null, "healthy" }`. `healthy` is false when any check failed. Doctor that ran is `ok: true` even when a check failed; it then exits 1.
- `setup`: `{ "mode": "standard" | "no-public", "mirror": { "repo", "url" }, "publicFork"?: { "repo", "url" }, "upstream": { "repo", "url" }, "directory" }`. `repo` is `owner/name` and `url` its `https://github.com/` page. `publicFork` is absent in no-public mode. `directory` is the absolute path of the local mirror checkout.
Expand Down
1 change: 1 addition & 0 deletions src/commands.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { type ApprovalResult, approvalCommand } from './commands/approval.js';
export { cloneCommand } from './commands/clone.js';
export {
type DoctorCheck,
Expand Down
56 changes: 56 additions & 0 deletions src/commands/approval.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import {
type ApprovalLevel,
approvalOf,
configNotFoundError,
readVenforkConfigFromRepo,
updateVenforkConfig,
} from '../config.js';
import * as p from '../shared/ui.js';

/** The approval level after `venfork stage approval`, reported as the `--json` result. */
export interface ApprovalResult {
level: ApprovalLevel;
}

const MEANING: Record<ApprovalLevel, string> = {
off: 'venfork stage publishes a branch without a review on its mirror PR.',
reviewed:
'venfork stage needs an approving review on the mirror PR head from a person with write access who is not the PR author, and passing checks.',
'two-person':
'venfork stage needs an approving review on the mirror PR head from a person with write access who is not the PR author and not the person running stage, and passing checks.',
};

/** The warning printed whenever the gate is turned off. */
export const APPROVAL_OFF_WARNING =
'The approval gate is off: venfork stage can publish any branch, from any person or agent with access to this clone, without a review. Turn it back on with `venfork stage approval on`.';

/**
* `venfork stage approval [level]`: prints the mirror's approval level, or
* sets it in venfork-config. Turning the gate off prints a warning.
* @returns The level after the action, for `--json`.
*/
export async function approvalCommand(
level?: ApprovalLevel
): Promise<ApprovalResult> {
p.intro('🛂 Venfork Stage Approval');
const repoDir = process.cwd();
try {
if (level === undefined) {
const config = await readVenforkConfigFromRepo(repoDir);
if (!config) throw configNotFoundError();
const current = approvalOf(config);
p.note(`${current}: ${MEANING[current]}`, 'Approval');
if (current === 'off') p.log.warn(APPROVAL_OFF_WARNING);
p.outro('✨ Approval level shown');
return { level: current };
}
const updated = await updateVenforkConfig(repoDir, { approval: level });
const now = approvalOf(updated);
p.note(`${now}: ${MEANING[now]}`, 'Approval');
if (now === 'off') p.log.warn(APPROVAL_OFF_WARNING);
p.outro(`✨ Approval set to ${now}`);
return { level: now };
} catch (error) {
throw p.reportFailure(error);
}
}
19 changes: 18 additions & 1 deletion src/commands/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,9 @@ const COMMAND_HELP: ReadonlyArray<readonly [string, string]> = [
`venfork stage <branch> [--no-pr] [--draft] [--dry-run] [--title <text>] [--base <branch>] [--internal-pr <n>] [--no-update-existing] [--yes] [--no-input] [--json]
venfork stage branch <name> [same options]
venfork stage issue <number-or-url> [--title <text>] [--yes] [--no-input] [--json]
venfork stage approval [on|reviewed|two-person|off] [--yes] [--no-input] [--json]
Push a branch (or an issue) from the mirror outward, to the public fork or upstream
Branch names \`issue\` and \`branch\` need the explicit form: venfork stage branch issue
Branch names \`issue\`, \`approval\` and \`branch\` need the explicit form: venfork stage branch issue
Branch: push to public fork for PR to upstream
Rebuilds the branch as linear history on upstream (merges and venfork's sync
commit dropped, new SHAs) and refuses commits that carry mirror-only files or
Expand All @@ -114,6 +115,8 @@ venfork stage issue <number-or-url> [--title <text>] [--yes] [--no-input] [--jso
• --json Print one JSON envelope on stdout; implies --no-input
Issue: read the internal mirror issue, strip venfork:internal blocks, open the
upstream counterpart, record the link
Approval: show or set the review a branch needs on its mirror PR before stage
publishes it (see venfork stage approval --help)
This is when your work becomes visible to the client`,
],
[
Expand Down Expand Up @@ -149,6 +152,20 @@ venfork pull issue <number-or-url> [--title <text>] [--yes] [--no-input] [--json
];

const SUBCOMMAND_HELP: ReadonlyArray<readonly [string, string]> = [
[
'stage approval',
`venfork stage approval [on|reviewed|two-person|off] [--yes] [--no-input] [--json]
Show (no argument) or set the approval the mirror PR of a branch needs
before venfork stage publishes it. Stored in the mirror's venfork settings
• off No review needed (the default)
• reviewed An approving review on the PR head from a person with write
access who is not the PR author, and passing checks
• two-person As reviewed, and the approver is not the person running stage
• on Same as two-person
A guardrail against accidental or agent-driven publishing, not enforcement:
anyone who can push can publish without venfork
--json prints { level } after the action as one JSON envelope on stdout`,
],
[
'pull pr',
`venfork pull pr <pr-number-or-url> [--branch-name <name>] [--no-push] [--yes] [--no-input] [--json]
Expand Down
Loading
Loading