Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f55d62cf92 | ||
|
|
a3446a13e2 |
@@ -108,13 +108,11 @@ export class MissionsController {
|
||||
) {
|
||||
const mission = await this.brain.missions.findByIdAndUser(missionId, user.id);
|
||||
if (!mission) throw new NotFoundException('Mission not found');
|
||||
// dto.status is deliberately not forwarded: mission_tasks.status is
|
||||
// write-prohibited through the N-1 window (SHARED-CONTRACT §5.1 phase 1);
|
||||
// the repo strips it as well.
|
||||
return this.brain.missionTasks.create({
|
||||
missionId,
|
||||
taskId: dto.taskId,
|
||||
userId: user.id,
|
||||
status: dto.status,
|
||||
description: dto.description,
|
||||
notes: dto.notes,
|
||||
pr: dto.pr,
|
||||
|
||||
@@ -77,12 +77,6 @@ export class CreateMissionTaskDto {
|
||||
@IsUUID()
|
||||
taskId?: string;
|
||||
|
||||
/**
|
||||
* @deprecated Accepted for N-1 wire compatibility but ignored: mission_tasks.status
|
||||
* is write-prohibited through the migration window (SHARED-CONTRACT §5.1 phase 1).
|
||||
* The field stays declared because the global ValidationPipe runs with
|
||||
* forbidNonWhitelisted, and removing it would 400 frozen legacy consumers.
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsIn(taskStatuses)
|
||||
status?: 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
|
||||
@@ -108,12 +102,6 @@ export class UpdateMissionTaskDto {
|
||||
@IsUUID()
|
||||
taskId?: string;
|
||||
|
||||
/**
|
||||
* @deprecated Accepted for N-1 wire compatibility but ignored: mission_tasks.status
|
||||
* is write-prohibited through the migration window (SHARED-CONTRACT §5.1 phase 1).
|
||||
* The field stays declared because the global ValidationPipe runs with
|
||||
* forbidNonWhitelisted, and removing it would 400 frozen legacy consumers.
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsIn(taskStatuses)
|
||||
status?: 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
|
||||
|
||||
+17
-34
@@ -197,41 +197,24 @@ conflict must amend one of them explicitly, never fork a third document
|
||||
- A second writable task store beside PostgreSQL (native-kanban-sot invariants).
|
||||
- Fully-designed federation in v1 (D3 — roadmap placeholder only).
|
||||
|
||||
### D15 — Tiered containerized deployment (2026-08-30, containerization lane)
|
||||
### 12. Decision registry
|
||||
|
||||
The stack ships a tiered deployment target, additive to the architecture
|
||||
gate (D8): (1) Standalone tier — docker compose is the canonical
|
||||
single-host deployment: postgres, valkey, openbao, gateway, appservice
|
||||
and the served webUI in one composition, with migrations, health checks,
|
||||
and a documented install/upgrade path; the registry (CI-published
|
||||
images) is the only deployment source. (2) Enterprise tier — Kubernetes
|
||||
manifests for the same service set, phase-gated on the standalone tier
|
||||
holding its acceptance bar. The v1 acceptance bar for the standalone
|
||||
tier: compose-up healthy; webUI hosts agent chat; an in-stack agent can
|
||||
open a PR to this repo; CI validates it; the running deployment adopts
|
||||
the merged change (pull + restart). Federation (D3 clause) remains
|
||||
deferred and unforeclosed. Implementation plan:
|
||||
docs/plans/2026-08-30_containerization.md.
|
||||
|
||||
## 12. Decision registry
|
||||
|
||||
| ID | Decision (short form) |
|
||||
| --- | ------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------- |
|
||||
| D1 | Open-source, AI-first, self-hosted platform for agentic management + life OS |
|
||||
| D2 | Hierarchy company→estate→project→workspace→kanban; bubble-up; granular RBAC |
|
||||
| D3 | Standalone vs Enterprise; one-way conversion; per-user brains + Vault required in Enterprise; federation deferred |
|
||||
| D4 | Re-runnable, extensible, per-mode onboarding wizards |
|
||||
| D5 | North star = this rewrite of docs/PRD.md; stack docs/ = product SSOT |
|
||||
| D6 | Only product-relevant material migrates from brains; operational records stay and link |
|
||||
| D7 | Spec-inventory sweep launched immediately (executed; INPUTS baseline frozen by operator ruling T2, 2026-08-25) |
|
||||
| D8 | webUI sits over official framework tooling; CLI primary |
|
||||
| D9 | Not a hosted business; company = organizational separation for one operator |
|
||||
| D10 | better-auth is the account system of record; external IdPs via OIDC |
|
||||
| D11 | Small v1 slice; ALL phases on the documented roadmap from day one |
|
||||
| D12 | HARD RULE: webUI never bypasses tooling; missing tool ⇒ build the tool first |
|
||||
| D13 | workspace_id stays the hard isolation unit; hierarchy is parent structure above; kanban SOT amended, not rewritten |
|
||||
| D14 | Sensitive profile data in the user's own brain only; postgres holds structure/consent/pointers |
|
||||
| D15 | Tiered containerized deployment: compose standalone tier (five-point v1 bar) + phase-gated k8s enterprise tier; registry-only image source | 2026-08-30 containerization lane; plan docs/plans/2026-08-30_containerization.md |
|
||||
| ID | Decision (short form) |
|
||||
| --- | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| D1 | Open-source, AI-first, self-hosted platform for agentic management + life OS |
|
||||
| D2 | Hierarchy company→estate→project→workspace→kanban; bubble-up; granular RBAC |
|
||||
| D3 | Standalone vs Enterprise; one-way conversion; per-user brains + Vault required in Enterprise; federation deferred |
|
||||
| D4 | Re-runnable, extensible, per-mode onboarding wizards |
|
||||
| D5 | North star = this rewrite of docs/PRD.md; stack docs/ = product SSOT |
|
||||
| D6 | Only product-relevant material migrates from brains; operational records stay and link |
|
||||
| D7 | Spec-inventory sweep launched immediately (executed; INPUTS baseline frozen by operator ruling T2, 2026-08-25) |
|
||||
| D8 | webUI sits over official framework tooling; CLI primary |
|
||||
| D9 | Not a hosted business; company = organizational separation for one operator |
|
||||
| D10 | better-auth is the account system of record; external IdPs via OIDC |
|
||||
| D11 | Small v1 slice; ALL phases on the documented roadmap from day one |
|
||||
| D12 | HARD RULE: webUI never bypasses tooling; missing tool ⇒ build the tool first |
|
||||
| D13 | workspace_id stays the hard isolation unit; hierarchy is parent structure above; kanban SOT amended, not rewritten |
|
||||
| D14 | Sensitive profile data in the user's own brain only; postgres holds structure/consent/pointers |
|
||||
|
||||
The full decision texts are recorded in the operator decision log (USC estate
|
||||
brain, webui-audit lane, `GRILL.md`).
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
| [`KBN-101-DB-ROLE-SPLIT.md`](./KBN-101-DB-ROLE-SPLIT.md) | rc.16 direct-Drizzle current storage-wrapper hold: legacy N-1/uncertified/non-operative pending -02/-03/-06/-08; exact README commented/user-guide executable forms fail before masking and source-consistency rejects runner-delegation copy; held future bootstrap → TLS/roles → run → verify → readiness; plus prior production boundary, pgvector owner, attestation, inventory, manifests, DDL classifier, TLS/bootstrap, activation, and certification contract; foundation prerequisite of KBN-100 and real-role gate before KBN-105 |
|
||||
| [`KBN-101-ENVELOPE-A.md`](./KBN-101-ENVELOPE-A.md) | KBN-101 Envelope A (v6) — RATIFIED, part of the frozen SSOT: rc.20 declarative sink-RBAC + per-role connection-selection + RLS `WITH CHECK`/`USING` write-source + `FORCE ROW LEVEL SECURITY` + sink-resident `task_status_write_override`; adds owner card KBN-101-10 + responsibility-widenings; authority Jason B1 + Mos OPTION A/Q1/Q2 |
|
||||
| [`SHARED-CONTRACT.md`](./SHARED-CONTRACT.md) | Remediated v1 integration contract: proof authority, exact failures/routes/DTOs/MCP ownership, concrete current-main field migration map, relational invariants, Coordinator split, recovery delivery |
|
||||
| [`P0-MAP-CURRENCY-2026-08-29.md`](./P0-MAP-CURRENCY-2026-08-29.md) | REQ-MIG-001 lane-opening verification: SHARED-CONTRACT §5 field map re-verified byte-identical at `next` @ `abb0c936`; workspaces/audit-pattern refinements; measured `mission_tasks.status` writer inventory and the pre-expand stop-write work item |
|
||||
| [`contracts/kanban-schema.v1.ts`](./contracts/kanban-schema.v1.ts) | Drizzle target declarations including exact owner/principal membership, project congruence, tags/archive, proposals, persisted assignments, monotonic fences, durable retry, immutable evidence/audit |
|
||||
| [`contracts/mechanical-coordinator.v1.ts`](./contracts/mechanical-coordinator.v1.ts) | Pure snapshot decision engine separated from persistence/service adapter; ID-bound approvals, bigint-safe fences, durable retry/quarantine, artifact-backed checkpoints, exact failures |
|
||||
| [`contracts/health-state.v1.ts`](./contracts/health-state.v1.ts) | Discriminated public health, separate branded transaction-local write proof, and non-overlapping denial/transport/version-conflict mappings |
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
---
|
||||
kind: verification
|
||||
status: active
|
||||
---
|
||||
|
||||
# P0 Field-Map Currency Verification — 2026-08-29
|
||||
|
||||
**Purpose:** REQ-MIG-001 (native-kanban-sot.md §5) accepts only when "P0 publishes
|
||||
the current `origin/main` field-by-field expand/backfill/compatibility/switch/contract
|
||||
map before any schema lane starts." That map exists: [`SHARED-CONTRACT.md`](./SHARED-CONTRACT.md)
|
||||
§5, inspected at `packages/db/src/schema.ts` @ `e72388b2cbfe400842fe940fa6cabf984ed43711`
|
||||
(2026-07-13). The M4-3 schema lane (expand migration 0021+) now opens against the
|
||||
integration trunk `next`. This document re-verifies the map's currency at the
|
||||
lane-opening head and records the measured pre-expand writer inventory. It amends
|
||||
nothing normative in SHARED-CONTRACT.md; where the two disagree, SHARED-CONTRACT.md
|
||||
wins.
|
||||
|
||||
## 1. Currency verification (measured)
|
||||
|
||||
- Map pin: `e72388b2cbfe400842fe940fa6cabf984ed43711` (2026-07-13, `main`).
|
||||
- Lane-opening head: `abb0c936011c7f6b8c0bcc90a20a865d5e8a40e9` (`origin/next`,
|
||||
2026-08-29).
|
||||
- Measurement: `git diff e72388b2 abb0c936 -- packages/db/src/schema.ts` reports
|
||||
**300 insertions, 0 deletions** — no existing declaration changed.
|
||||
- The additions: the new declarations `logicalAgentConnectorLeases`,
|
||||
`connectorLeaseAuditLog`, and the hierarchy layer (`companies`, `estates`,
|
||||
`platformProjects`, `workspaces`, `hierarchyGrants`, `hierarchyAuditEvents`,
|
||||
`hierarchyOutbox`, plus their enums and constant arrays); a nullable `issuer`
|
||||
column on the unmapped BetterAuth `accounts` table (shipped as
|
||||
`drizzle/0017_accounts_issuer.sql`); and expanded `drizzle-orm` imports
|
||||
(`sql`, `AnyPgColumn`, `unique`, `check`, `bigint`). None touch a mapped
|
||||
source.
|
||||
- Stronger literal fact: REQ-MIG-001's acceptance names `origin/main`. Measured
|
||||
pin → `origin/main` (`7102ccb9`, 2026-08-13): **63 insertions, 0 deletions**
|
||||
for `schema.ts`, and `origin/main` is an ancestor of `abb0c936`. The map is
|
||||
therefore current at `origin/main` itself, and at the trunk head beyond it.
|
||||
|
||||
**Consequence:** every source column mapped in SHARED-CONTRACT.md §5.4 —
|
||||
`teams`/`team_members`, `projects`, `missions`, `tasks`, `mission_tasks`,
|
||||
`agents`, fleet `backlog` — is byte-identical to the declaration the map
|
||||
inspected. The field map is current as written. No row changes.
|
||||
|
||||
## 2. Refinements available since the pin (context, not map changes)
|
||||
|
||||
1. **The `workspaces` table exists.** The map predates contract 1's hierarchy
|
||||
layer; its "bootstrap workspace" backfill step now has a shipped target:
|
||||
`workspaces` (uuid PK, chained under platform projects per
|
||||
`docs/requirements/hierarchy-schema.md`; hierarchy core in
|
||||
`drizzle/0018_clean_cobalt_man.sql`, audit/outbox in
|
||||
`0019_volatile_killraven.sql`, visibility in
|
||||
`0020_special_betty_brant.sql`). New `workspace_id` columns FK there.
|
||||
2. **The audit/outbox envelope pattern is shipped.** `hierarchyAuditEvents` +
|
||||
`hierarchyOutbox` implement same-transaction semantic event + outbox. The
|
||||
task lane's `task_events`/`task_outbox` mirror the pattern but are
|
||||
workspace-scoped with the composite `(workspace_id, id)` key required by
|
||||
§5.3 and REQ-SOT-004. The hierarchy tables are a pattern reference, never a
|
||||
shared store for task events.
|
||||
3. **Trunk designation.** The integration trunk is `next` (`.mosaic/repo.json`).
|
||||
§1 measures currency at both the literal `origin/main` REQ-MIG-001 names and
|
||||
the trunk head pinned above, so no reinterpretation of the acceptance text
|
||||
is needed.
|
||||
4. **Migration ownership.** SHARED-CONTRACT.md §6 assigns schema/migration
|
||||
ownership to the mission seat `coder2`. Seat identity is operational fleet
|
||||
state, not resolvable from this repository, and is outside this document's
|
||||
scope. The invariant §6 protects binds regardless of seat and is restated
|
||||
here as binding on the M4-3 schema lane: exactly one lane generates
|
||||
migrations at a time; expand is additive; no drop/rename/narrow; constraints
|
||||
validate before NOT NULL.
|
||||
|
||||
## 3. Pre-expand writer inventory (measured 2026-08-29 at `abb0c936`)
|
||||
|
||||
SHARED-CONTRACT.md §5.1 phase 1 requires an N-1 patch that stops
|
||||
`mission_tasks.status` as a write source, plus a writer inventory, before any
|
||||
expand DDL.
|
||||
|
||||
- **Sole authoring write path:** `packages/brain/src/mission-tasks.ts`
|
||||
`create`/`update` (Drizzle insert/update on `mission_tasks`), invoked by
|
||||
`apps/gateway/src/missions/missions.controller.ts`. `update` accepts
|
||||
`Partial<NewMissionTask>`, so `status` is writable through both DTOs today.
|
||||
The same module also exposes `remove`/`removeByMission` DELETE paths —
|
||||
immaterial to `status` writes, listed for inventory completeness.
|
||||
- **Storage-layer surfaces that touch the column without authoring it**
|
||||
(added 2026-08-29 after independent review of the phase-1 patch):
|
||||
`packages/storage/src/migrate-tier.ts` copies whole `mission_tasks` rows
|
||||
between storage tiers and must preserve the stored `status` verbatim — row
|
||||
transport, exempt from the write prohibition (stripping there would corrupt
|
||||
data inside the N-1 window). The generic table-keyed storage adapters
|
||||
(`adapters/postgres.ts`, `adapters/pglite.ts`) register `mission_tasks` in
|
||||
their table maps but have no caller that targets it: measured at this head,
|
||||
every runtime adapter caller passes a fixed collection constant
|
||||
(preferences/insights). Neither surface authors a new `status` value.
|
||||
- **Read-only consumers of `mission_tasks`:** federation verb services
|
||||
(`get-query.service.ts`, `list-query.service.ts`) select only. The MCP
|
||||
`brain_*` tools do not touch `mission_tasks` at all; `brain_create_task` /
|
||||
`brain_update_task` write the separately mapped `tasks` table, a legitimate
|
||||
N-1 writer through the compatibility window.
|
||||
- The ratified contract 5 decision
|
||||
(`docs/requirements/tool-gateway-mapping.md` §3.2, ruled 2026-08-27) freezes
|
||||
the legacy endpoints — including MCP `brain_*` task mutations — for new
|
||||
consumers, while existing consumers keep working until each surface's owning
|
||||
contract retires it. It does not stop existing writes.
|
||||
|
||||
**Standing work item:** the phase-1 stop-write patch (reject or ignore `status`
|
||||
on `mission_tasks` create/update) MUST land before the expand DDL of migration
|
||||
lane M4-3a. It is N-1-safe per the §5.4 row for `mission_tasks.status` (linked
|
||||
status is ignored; the column stays declared and readable through the whole
|
||||
N-1 window; retirement only after no readers).
|
||||
|
||||
## 4. Lane opening
|
||||
|
||||
With this verification merged, REQ-MIG-001's P0-map precondition is satisfied
|
||||
for the M4-3 schema lane at pinned head `abb0c936`. The ordered phases (§5.1),
|
||||
mission candidate-key DDL order (§5.2), audit/proposal DDL order (§5.3), field
|
||||
map (§5.4), and required migration tests (§5.5) bind as written. External
|
||||
import machinery (jarvis-brain/Vikunja shadow import, REQ-MIG-001) and client
|
||||
cutover (REQ-MIG-002) remain out of scope for M4-3; the legacy surface stays
|
||||
frozen for new consumers meanwhile (`tool-gateway-mapping.md` §3.2 decision).
|
||||
@@ -1,315 +0,0 @@
|
||||
---
|
||||
kind: spec
|
||||
status: active
|
||||
audience: developer
|
||||
---
|
||||
|
||||
# Agent Enrollment Command Family — v1 Design (M4-4-0)
|
||||
|
||||
Status: design note (implementation-facing; amends no contract).
|
||||
Authority chain: tool-gateway-mapping.md §3.1 rank-4 row + §4 envelope
|
||||
(ruled 2026-08-27), onboarding-wizard.md §3.5 (D11 minimal enrollment),
|
||||
custody-schema.md §5.2 at revision 13 (agent-grantee FK bound to the
|
||||
live `agents` table — a binding introduced at rev 4 and standing
|
||||
verbatim), PRD §9 D11. Where this note and a ratified contract disagree,
|
||||
the contract wins.
|
||||
|
||||
## 1. What the contracts bind (and what they leave open)
|
||||
|
||||
There is no standalone enrollment contract. The rank-4 family is defined
|
||||
by composition:
|
||||
|
||||
1. **Contract 5 §3.1 rank 4:** "Enroll one agent: harness, credential
|
||||
reference/API-key intake (values never echoed), name/persona,
|
||||
assignment scope (contract 3 §3.5)."
|
||||
2. **Contract 5 §4 — all five sub-clauses:** §4.1 typed request/result
|
||||
DTOs validated at the Gateway boundary (expected-version only where
|
||||
an owning contract defines one); §4.2 closed per-family error enum
|
||||
(validation, authentication, authorization, not-found, conflict,
|
||||
precondition, internal) with HTTP mappings; §4.3 audit linkage — the
|
||||
envelope contributes correlation: every request accepts/generates a
|
||||
correlation id, carried into the audit events **and returned in the
|
||||
result**, with no second audit stream; §4.4 fail-closed — an
|
||||
operation that cannot evaluate its authorization or reach its owning
|
||||
tool refuses, never degrading to a fallback read or direct data
|
||||
access; §4.5 CLI parity — the family MUST be invocable through the
|
||||
official CLI against the same Gateway commands with the same
|
||||
request/result/error contracts (a Gateway command without CLI
|
||||
exposure is a tracked conformance gap).
|
||||
**Idempotency keys are NOT contract 5 §4.3:** the idempotency-key
|
||||
envelope is contract 3 §4.3, ratified as a drafting addition to
|
||||
contract 5 §4's command envelope via contract 3 §7 item 4. Its fence
|
||||
and replay rules bind as written there; §3.1 rule 5 below designs to
|
||||
them.
|
||||
3. **Contract 3 §3.5:** the wizard's enrollment step is minimal (one
|
||||
harness, API-key login, agent name and persona — D11), uses ONLY this
|
||||
family, and is skippable. Wizard witness §6.10: a run that skips the
|
||||
step produces zero enrollment-family mutations.
|
||||
4. **Custody-schema §5.2 (rev 13; binding introduced at rev 4):**
|
||||
contract 7's agent-grantee FK references the live `agents` table
|
||||
(`agents.id`, uuid); an enrollment surface with its own table would
|
||||
force a contract-7 amendment.
|
||||
|
||||
**Assignment scope (open point, pinned here):** the rank-4 row cites
|
||||
contract 3 §3.5, which defines no assignment semantics; the PRD's full
|
||||
enrollment vision (Part I, Standalone flow) includes "account
|
||||
assignment", but the D11 v1 slice is exactly "one harness, API key,
|
||||
name/persona". v1 therefore scopes assignment to the two bindings the
|
||||
minimal slice already implies — the enrolling user becomes the agent's
|
||||
owner (`agents.owner_id`), and the credential reference names which of
|
||||
that user's stored provider credentials the agent uses. Richer
|
||||
assignment (multi-account, comms auto-enroll, workspace placement) is
|
||||
deferred with the rest of the PRD's full flow (D11); when a contract
|
||||
defines it, this family extends by ordinary amendment of the design.
|
||||
The deferral rests on contract 3 §3.5's explicit delegation of
|
||||
enrollment specifics to this family — not on reading the D11 list as
|
||||
exhaustive (it is not: the §3.1 `model`/`provider` fields are required
|
||||
by the live table's NOT NULL columns, though D11 does not name them).
|
||||
|
||||
## 2. Current state (measured 2026-08-29 at `origin/next` = `94d626df`)
|
||||
|
||||
- `agents` table (packages/db `schema.ts`): id uuid PK, name, provider,
|
||||
model, status enum, project_id (legacy `projects`, ON DELETE SET
|
||||
NULL), owner_id → users, system_prompt, allowed_tools, skills,
|
||||
is_system, config jsonb, timestamps. No harness column (provider and
|
||||
model describe the LLM backend, not the harness), no audit coupling.
|
||||
- Sole write path: `packages/brain/src/agents.ts` repository (the only
|
||||
module issuing `insert(agents)`), with three write consumers: the
|
||||
legacy `/api/agents` CRUD controller
|
||||
(`apps/gateway/src/agent/agent-configs.controller.ts`), the `/agent
|
||||
new` chat command (`apps/gateway/src/commands/command-executor.service.ts`
|
||||
→ `brain.agents.create`), and workspace bootstrap
|
||||
(`apps/gateway/src/workspace/project-bootstrap.service.ts`). All
|
||||
three keep serving existing consumers; none is touched by M4-4.
|
||||
- Sealed credential store exists: `ProviderCredentialsService`
|
||||
(apps/gateway/src/agent/) — one row per (userId, provider), values
|
||||
sealed at rest, decrypt server-side only, summaries never carry
|
||||
values.
|
||||
- Harness registry exists (`apps/gateway/src/harness/`), the validation
|
||||
source for the harness field.
|
||||
- Implementation pattern: the merged hierarchy module (M4-1) —
|
||||
transaction-scoped command context, in-tx authorization, discriminated
|
||||
result unions, same-transaction semantic audit event + transactional
|
||||
outbox, no-oracle not_found folding.
|
||||
|
||||
**F1 — contract-5 mapping note (disposition, not an amendment):**
|
||||
`/api/agents` appears nowhere in contract 5 — neither as a P0 row nor in
|
||||
the §3.2 legacy non-substitutes list (the ruled §3.2 freeze names
|
||||
specific endpoints, and `/api/agents` is not among them). The operative
|
||||
constraints are §3.3's amendment-only rule for new mapping rows and §5's
|
||||
closure rule: this design adds no new consumer to `/api/agents` and
|
||||
builds the rank-4 family as the P1 path for enrollment. Adding the
|
||||
missing P0 row is a contract amendment for a future S2 pass; nothing in
|
||||
M4-4 depends on it.
|
||||
|
||||
## 3. Command family surface (v1)
|
||||
|
||||
One command, one query. Module: `apps/gateway/src/enrollment/`
|
||||
(`enrollment.module.ts`), mirroring the hierarchy module's shape.
|
||||
|
||||
### 3.1 `agent.enroll` (mutation)
|
||||
|
||||
Request DTO (shared types package, class-validator at the boundary):
|
||||
|
||||
| Field | Type | Rule |
|
||||
| ---------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `harness` | string | syntactically invalid (empty/malformed) → `validation_failed`; well-formed but not in the harness registry → `precondition_failed` |
|
||||
| `correlationId` | string (uuid) | optional; generated when absent (contract 5 §4.3); carried into audit events and returned in the result |
|
||||
| `replayMode` | 'actor-bound' | optional, default `actor-bound`. `shared` is seed-only (contract 3 §4.3 binds it to the §3.4 canonical seed key set and "no other operation can carry a shared declaration"; §7 item 4 closes it); a `shared` declaration here is refused `validation_failed`, executes nothing, and records no fence row |
|
||||
| `name` | string | non-empty, trimmed, ≤ 200 chars |
|
||||
| `persona` | string \| null | optional; stored as the agent's system prompt |
|
||||
| `model` | string | non-empty (provider-qualified model id) |
|
||||
| `provider` | string | non-empty; names the credential's provider |
|
||||
| `credential` | discriminated union | `{ mode: 'reference' }` — a credential for (actor, provider) MUST already exist; `{ mode: 'intake', type: 'api_key', value: string }` — value is sealed into the credential store in the same flow |
|
||||
| `idempotencyKey` | string (uuid) | required (contract 3 §4.3, ratified into contract 5 §4 via contract 3 §7 item 4) |
|
||||
|
||||
Rules:
|
||||
|
||||
1. **Never echoed.** The credential value appears in no result DTO, no
|
||||
audit event, no outbox payload, and no log line. The result carries
|
||||
only `{ provider, credentialMode }`.
|
||||
2. **Intake = the existing sealed store, inside the transaction.**
|
||||
`intake` writes through the sealed-store path
|
||||
(`ProviderCredentialsService.store` semantics: seal-at-rest, upsert
|
||||
per (userId, provider)) **in the same transaction** as the agent
|
||||
insert — a failure after the credential write rolls everything back,
|
||||
leaving no orphan credential. Enrollment persists no second copy and
|
||||
no plaintext.
|
||||
3. **Reference must resolve.** `reference` with no stored credential for
|
||||
(actor, provider) refuses with `precondition_failed` (nothing is
|
||||
created).
|
||||
4. **Ownership.** `owner_id` = the authenticated actor. v1 authorization
|
||||
is AuthGuard-authenticated user; no hierarchy grant is required
|
||||
because v1 enrollment binds no hierarchy node (§1 assignment-scope
|
||||
pin). `is_system` is never settable through this command.
|
||||
5. **Idempotency fence (contract 3 §4.3, in full).** The command layer
|
||||
records, in a uniqueness-constrained fence table in the same
|
||||
transaction as the mutation and its audit event: the key, the
|
||||
operation identifier (`agent.enroll`), the acting principal, the
|
||||
authorization scope, a digest of the canonicalized request payload
|
||||
(the digest input EXCLUDES the credential value — it covers
|
||||
provider + credentialMode, never plaintext), the declared replay
|
||||
mode (always `actor-bound` for this family — the `shared` refusal
|
||||
in the table above means no shared fence row can exist here; the
|
||||
column is kept for envelope-shape fidelity and mode-mismatch
|
||||
collision checks), and a reference to the committed outcome (the
|
||||
agent id). The recorded **authorization scope** for this family is
|
||||
pinned to the acting principal's platform-user scope (v1
|
||||
authorization is grant-free per rule 4, so the scope is the
|
||||
authenticated-user identity domain — recorded so the §4.3
|
||||
scope-equality check has a defined value). Fence uniqueness is the
|
||||
pair (operation identifier, key). **Replay:** a submission whose
|
||||
(operation, key) is recorded is first authorized exactly as a fresh
|
||||
submission; then replay-mode, scope, and digest equality are
|
||||
checked (a mismatch on any — including scope — is a collision);
|
||||
then **target-result authorization** — the submitter must hold, at
|
||||
replay time, read authority on the referenced agent row under
|
||||
§3.2's rule (owner or admin) — plus recorded-actor equality
|
||||
(`actor-bound`). A passing replay executes nothing, returns the
|
||||
recorded outcome, and appends a replay access event (non-mutation
|
||||
audit class: accessing principal, current correlation id,
|
||||
fence-row reference). Any equality or authorization failure refuses
|
||||
with the single bounded `conflict` shape — constant, identifying no
|
||||
record — preserving the no-existence-oracle rule. **Concurrency
|
||||
(contract 3 §4.3's rule, ratified via §7 item 4):** two submissions
|
||||
with the same (operation, key) serialize on the fence's unique
|
||||
constraint — exactly one executes; the loser waits for the winner's
|
||||
transaction, and is then handled as a replay if it committed
|
||||
(through the full replay path above) or executes afresh if it
|
||||
aborted. A unique-violation race never surfaces as an unhandled
|
||||
internal fault.
|
||||
6. **Audit + outbox, same transaction.** Insert into `agents` +
|
||||
sealed credential write (intake mode) + fence row + semantic audit
|
||||
event (`agent.enrolled`: actor, agent id, harness, provider, name,
|
||||
credentialMode — no credential material) + outbox row commit
|
||||
atomically, hierarchy-pattern style. Audit rows reference the agent
|
||||
by **snapshot id, not FK** — mirroring the hierarchy audit tables'
|
||||
deliberate FK-free linkage so audit history survives agent deletion
|
||||
through the legacy CRUD DELETE path.
|
||||
|
||||
Result union: `enrolled { agent, correlationId }` | refusal from the
|
||||
§3.3 enum (refusals also carry the correlation id, per contract 5
|
||||
§4.3's end-to-end traceability). `agent` in the result is the persisted
|
||||
row minus nothing sensitive (the table stores no credential material).
|
||||
|
||||
### 3.2 `agent.enrollment.get` (query)
|
||||
|
||||
By agent id; actor must be the owner (or admin). Unauthorized and
|
||||
missing fold to the same `not_found` wire shape (contract 2
|
||||
no-existence-oracle rule, applied family-wide for uniformity).
|
||||
|
||||
The query carries the same non-state envelope as the mutation
|
||||
(contract 5 §4.3; contract 3's envelope reconciliation confirms closed
|
||||
query responses carry it): typed request DTO with an optional
|
||||
`correlationId` (generated when absent) and a typed result —
|
||||
`found { agent, correlationId }` | `not_found` (the folded shape,
|
||||
also carrying the correlation id). Queries take no idempotency key
|
||||
(the fence binds mutations).
|
||||
|
||||
### 3.3 Error enum (closed, §4.2)
|
||||
|
||||
`validation_failed` 400 · `authentication_failed` 401 ·
|
||||
`authorization_refused` 403 (owner-only paths; folded to `not_found`
|
||||
where §3.2 applies) · `not_found` 404 · `conflict` 409 (the single
|
||||
bounded idempotency refusal shape of §3.1 rule 5) · `precondition_failed`
|
||||
422 (unresolvable credential reference; well-formed harness not in the
|
||||
registry — syntactic invalidity is `validation_failed` per the §3.1
|
||||
table) · `internal_fault` 500 (also the §4.4 fail-closed class when the
|
||||
owning tool is unreachable; unauthorized-fallback behavior is
|
||||
prohibited).
|
||||
|
||||
## 4. Schema delta (migration 0021, additive-only)
|
||||
|
||||
Extend `agents` — no new agent table, preserving custody-schema §5.2's
|
||||
FK binding without amendment:
|
||||
|
||||
- `harness` text NULL — registered harness name; NULL for pre-existing
|
||||
rows (legacy rows predate the concept).
|
||||
- `enrolled_at` timestamptz NULL — set by `agent.enroll`; NULL marks a
|
||||
legacy (non-enrolled) row. No backfill: enrollment is a fact this
|
||||
command creates, not one to invent for existing rows.
|
||||
|
||||
New tables, mirroring the hierarchy audit/outbox pair (pattern reuse,
|
||||
separate store): `agent_audit_events` (append-only: id, event_type,
|
||||
actor id, agent id — snapshot value, no FK, per §3.1 rule 6 —
|
||||
correlation id, causation id, payload jsonb, created_at; per-agent
|
||||
ordering index), `agent_outbox` (hierarchy-outbox shape), and
|
||||
`agent_idempotency_fence` (contract 3 §4.3 shape: operation identifier,
|
||||
key, acting principal, authorization scope, canonicalized-payload
|
||||
digest, replay mode, committed-outcome reference (agent id), created_at;
|
||||
UNIQUE (operation identifier, key)). Persona reuses the existing
|
||||
`system_prompt` column; no version column (no ratified expected-version
|
||||
rule names `agents` — §4.1 binds only where the owning contract defines
|
||||
one).
|
||||
|
||||
Witnesses (real PostgreSQL, lane standard): append-only enforcement,
|
||||
same-tx atomicity (agent row + credential write + fence row + audit +
|
||||
outbox all-or-nothing under injected failure at multiple points,
|
||||
including after the credential write), fence uniqueness on
|
||||
(operation, key).
|
||||
|
||||
Sequencing: additive DDL via the same migration path as 0018–0020
|
||||
(hierarchy). The docs/native-kanban-sot/SHARED-CONTRACT.md §5.3 DDL
|
||||
gate binds the kanban lane's audit/proposal DDL, not this lane; if a
|
||||
pending operator ruling on migration sequencing changes mechanics
|
||||
lane-wide, re-check before generating 0021.
|
||||
|
||||
## 5. Witnesses the implementation slice must ship
|
||||
|
||||
1. Never-echo: enroll via `intake`, assert the value string is absent
|
||||
from the HTTP result, the audit row, the outbox payload, and captured
|
||||
logs.
|
||||
2. Sealed-store single-copy: after intake, the credential exists only in
|
||||
`provider_credentials` (sealed), and `agents` has no credential
|
||||
column at all.
|
||||
3. Reference-resolution refusal (`precondition_failed`, no row created).
|
||||
4. Harness refusals, both codes: syntactically invalid →
|
||||
`validation_failed`; well-formed registry miss →
|
||||
`precondition_failed` (against the live registry).
|
||||
5. Idempotency (contract 3 §4.3 set): actor-bound replay returns the
|
||||
recorded outcome and executes nothing (no new agent/audit/outbox
|
||||
mutation rows; a replay access event is appended); payload-digest
|
||||
mismatch, replay-mode mismatch, scope mismatch, and different-actor
|
||||
actor-bound replay each refuse with the single bounded `conflict`
|
||||
shape; a replay is re-authorized fresh (a submitter whose
|
||||
authorization was revoked since the original is refused, not
|
||||
replayed); a `shared` declaration on `agent.enroll` is refused
|
||||
`validation_failed` with nothing executed and no fence row
|
||||
recorded (seed-only rule); two concurrent same-(operation, key)
|
||||
submissions produce exactly one mutation, the loser resolving
|
||||
through the replay path (no unhandled unique-violation fault).
|
||||
6. Same-tx atomicity fault injection (agent / credential write / fence
|
||||
/ audit / outbox), including a failure injected after the intake
|
||||
credential write commits its statement — everything rolls back, no
|
||||
orphan credential.
|
||||
7. Wizard-facing zero-mutation witness (contract 3 §6.10 shape): no
|
||||
call → zero rows in `agents`/`agent_audit_events`/`agent_outbox`/
|
||||
`agent_idempotency_fence` attributable to the family.
|
||||
8. `is_system` injection attempt is rejected by DTO validation.
|
||||
9. Correlation-id witness (contract 5 §6.3): a correlation id submitted
|
||||
on `agent.enroll` appears in its audit event(s) and in the result;
|
||||
the same holds for `agent.enrollment.get`'s result; the §6.3 static
|
||||
companions (no `any`-typed boundary pass-through; single audit
|
||||
emitter) apply. §6.3's no-existence-oracle probe: an unauthorized
|
||||
`agent.enrollment.get` of an existing agent and a get of a
|
||||
nonexistent id return indistinguishable results.
|
||||
10. CLI-parity witness (contract 5 §6.4): a CLI smoke invocation of
|
||||
`agent.enroll` and `agent.enrollment.get` against the Gateway
|
||||
succeeds with the same typed results the web client receives. The
|
||||
implementation slice therefore SHIPS CLI exposure for both
|
||||
operations (contract 5 §4.5 — a Gateway command without CLI
|
||||
exposure is a tracked conformance gap; this design refuses to open
|
||||
one).
|
||||
11. Fail-closed witness (contract 5 §6.5): with the owning tool or
|
||||
grant state unreachable (fault injection), the operation returns
|
||||
the internal-fault or authorization-refusal class and performs no
|
||||
fallback read/write.
|
||||
|
||||
## 6. Out of scope
|
||||
|
||||
Wizard orchestration (M4-6); any UI (D8/D12); un-enroll/update lifecycle
|
||||
(no contract requires it in v1 — the legacy write surfaces named in §2
|
||||
keep serving existing consumers); OAuth login, multi-account, comms
|
||||
auto-enroll, model recommendation (PRD full flow, deferred by D11);
|
||||
contract amendments (F1 recorded above for a future S2 pass). CLI
|
||||
exposure is explicitly IN scope (witness 10 — contract 5 §4.5 binds it).
|
||||
@@ -1,99 +0,0 @@
|
||||
# Plan — Stack Containerization (tiered deployment)
|
||||
|
||||
Status: DRAFT for review. Charter: fleet/lanes/stack-containerization
|
||||
(brain) NORTH-STAR.md; PRD amendment in the same PR adds D15.
|
||||
Supersedes nothing; sequences the absorbed M4 remainder per its lane.
|
||||
|
||||
## Measured baseline (origin/next @ 143ba0f5, 2026-08-30)
|
||||
|
||||
- `docker-compose.yml`: dev infrastructure only — postgres (pgvector),
|
||||
valkey, otel-collector, jaeger. No application services.
|
||||
- `docker-compose.federated.yml`: standalone overlay for the FEDERATED
|
||||
storage tier (own postgres/valkey; port-conflicts the base stack by
|
||||
design). Not an app deployment.
|
||||
- `docker/gateway.Dockerfile`, `docker/appservice.Dockerfile`:
|
||||
multi-stage production builds (node:22-alpine) EXIST; the gateway image
|
||||
includes the web SPA bundle (#1444).
|
||||
- CI (`publish.yml`) builds and publishes these images (next-channel
|
||||
prereleases + main stable), and runs `verify:release` fail-closed.
|
||||
- Gap: no stack-level composition wires gateway+appservice+data plane
|
||||
into one deployable unit; no blessed install/upgrade path; no
|
||||
in-container agent-runtime story for the dogfood loop.
|
||||
|
||||
## Target (PRD D15 amendment)
|
||||
|
||||
Tiered deployment, additive to the existing architecture:
|
||||
|
||||
1. **Standalone tier (v1 bar)**: `docker compose up` on one host brings
|
||||
postgres, valkey, openbao, gateway, appservice (and the webUI the
|
||||
gateway serves) to healthy; migrations apply; the webUI hosts agent
|
||||
chat; an in-stack agent can read this repo and open a PR; CI
|
||||
validates; the deployment adopts merged images (pull + restart).
|
||||
2. **Enterprise tier (post-v1)**: Kubernetes manifests (or Helm) for the
|
||||
same service set, phase-gated on the standalone bar holding.
|
||||
|
||||
## Phases
|
||||
|
||||
### Phase A — blessed standalone compose
|
||||
|
||||
- A1 Compose service definitions for gateway + appservice joining the
|
||||
existing infra compose (profiles: `dev` keeps today's behavior;
|
||||
`stack` adds the app tier), with health checks and dependency order.
|
||||
- A2 Migrations on boot (or an explicit migrate step) with idempotency
|
||||
and version pinning; init-db.sql folded into pg-init.
|
||||
- A3 Openbao in the compose set (secret plumbing for the app tier).
|
||||
- A4 `.env.example` + `mosaic.config.json` defaults documented for the
|
||||
standalone mode; mode recorded per the mode-conversion contract.
|
||||
- A5 Smoke: `docker compose --profile stack up` green on a scratch host;
|
||||
webUI served; agent chat reachable; failures catalogued and fixed.
|
||||
- Acceptance: the five-point NORTH-STAR bar measured live.
|
||||
|
||||
### Phase B — component completion
|
||||
|
||||
- Interface assumption (velma verdict A1, P5-RM-005/006): in-stack
|
||||
dogfood agents inherit SEAT-GRADE identity — credential-slot
|
||||
isolation, wrapper-first enforcement, no privileged coordination
|
||||
identity, evidence by references that resolve outside the container
|
||||
lifetime.
|
||||
- Decompose JIT from A5's catalogue. Known candidates: agent runtime
|
||||
bits (brain/tool access paths in-container), repo credentials for the
|
||||
dogfood agent, watch/comms surfaces inside the deployment.
|
||||
|
||||
### Phase C — CI/CD parity
|
||||
|
||||
- Publish pipeline is the only image source (already true); add the
|
||||
deployment-side pull/upgrade path (compose pull + migrate + restart =
|
||||
next iteration); document the promotion flow next -> registry ->
|
||||
deployment.
|
||||
|
||||
### Phase D — coordinator integration (GATED)
|
||||
|
||||
- Gate (velma verdict C2): blocked until the checkpoint-and-lease child
|
||||
of the guides-proposed control-plane refactor — core + WU-P1-CHECKPOINT
|
||||
(schema, freshness, incarnation, clean-replacement resume; D57-D60
|
||||
lineage) — carries an independent target-bound PASS. Wiring restarts
|
||||
against the core alone re-creates the stale-incarnation failure class
|
||||
D57-D60 closed. Transitive: inherits the T108 gates (P0 exit + Jason
|
||||
P1 authorization).
|
||||
- Scope (velma verdict C1): lifecycle actions (start/stop/restart/
|
||||
health/recovery) executed by the SHIPPED coord client over the one
|
||||
typed coordination contract (request id, actor identity, epoch,
|
||||
revision, lease, correlation; typed stale rejection; worker role
|
||||
boundary). No second coordination interface gets designed here —
|
||||
containerization consumes the coordination contract, never defines it.
|
||||
|
||||
### Phase E — enterprise tier
|
||||
|
||||
- k8s manifests/Helm for the same set; phase-gated on Phase A holding.
|
||||
|
||||
### Absorbed M4 remainder
|
||||
|
||||
- M4-3 pivot: KBN-101 foundation first (per ruling R6), then expand DDL.
|
||||
- M4-5: lands inside Phase B/C where natural.
|
||||
- M4-6 (composes M4-1+M4-4): last, as designed.
|
||||
|
||||
## Non-goals (v1)
|
||||
|
||||
- No Kubernetes in v1; no multi-host federation; no replacement of the
|
||||
fleet's brain-based seats (the stack is an additional operator
|
||||
surface); no on-host image builds for deployment (registry only).
|
||||
@@ -13,10 +13,6 @@ status: active
|
||||
- [Documentation structure README implementation](2026-08-10-docs-structure-readme.md) — completed implementation plan for the documentation contract and atlas.
|
||||
- [Documentation catalog and truth audit](2026-08-10-docs-catalog-audit.md) — audit method, evidence statuses, deliverables, and acceptance criteria.
|
||||
|
||||
## Feature design plans
|
||||
|
||||
- [Agent enrollment command design](2026-08-29-agent-enrollment-command-design.md) — v1 rank-4 enrollment command family: contract composition, command surface, schema delta, witnesses (M4-4-0).
|
||||
|
||||
After a plan is delivered, update the canonical guide, contract, decision, or index. Do not cite a plan as proof that intended behavior shipped.
|
||||
|
||||
## Related
|
||||
|
||||
@@ -514,47 +514,3 @@ itself permits, so that contract does not stretch A1 by interpretation.
|
||||
default-open disclosure.
|
||||
5. Every other constraint of A1 — §8.1.2's remaining bullets, §8.2 in full,
|
||||
and §8.3's other acceptance criteria — is untouched.
|
||||
|
||||
## 10. Amendment A3 — capability-holder existence disclosure
|
||||
|
||||
**Status:** amendment to Amendment A2, added by reviewed PR together with
|
||||
contract 2 Amendment 1 (`rbac-grant-model.md` §8, this PR), under that
|
||||
amendment's ruling request (decision owner Jason). It binds if and only if
|
||||
contract 2 Amendment 1 ratifies; until then §9.1.2's sole-disclosure rule
|
||||
stands unmodified — which is consistent, because until ratification the
|
||||
company-CRUD capability class is empty and the carve-out below has no
|
||||
holders. Everything in §§1–9 remains binding verbatim, with exactly the one
|
||||
express modification below. The detailed contract text lives in
|
||||
`rbac-grant-model.md` §8.1; this amendment changes only what A2 itself
|
||||
permits, so that contract does not stretch A2 by interpretation.
|
||||
|
||||
### 10.1 What A3 modifies in A2
|
||||
|
||||
1. **Capability-holder disclosure (narrows §9.1.2's sole-disclosure rule
|
||||
by one carve-out).** §9.1.2 makes the directory the sole permitted
|
||||
existence disclosure and keeps private companies undisclosed to
|
||||
non-granted subjects everywhere. A3 admits exactly one further
|
||||
disclosure channel: a subject holding the company-CRUD capability
|
||||
(contract 2 §8), when exercising the hierarchy schema §5.5 visibility
|
||||
command, learns the target company's existence and its old/new
|
||||
visibility values through the command's redacted actor receipt —
|
||||
success for an existing target (private or directory alike) versus
|
||||
`not_found` for a nonexistent id — bounded exactly as contract 2 §8.1
|
||||
states: no name, slug, structure, content, grant, or membership
|
||||
information, and no read command of any kind. To every other
|
||||
non-granted subject, private companies remain undisclosed everywhere,
|
||||
including the directory; the directory remains the sole
|
||||
existence-disclosure _listing_.
|
||||
|
||||
### 10.2 What A3 explicitly does not change
|
||||
|
||||
1. The directory itself is unchanged: read-only, directory-class companies
|
||||
only, existence/name/slug only (§9.1.2's enumeration is narrowed for
|
||||
capability holders' receipts, widened for nothing).
|
||||
2. No join-request surface, no curation listing, no read command of any
|
||||
family is authorized (§9.2.2 unchanged; a curation listing is a further
|
||||
amendment per contract 2 §8.1).
|
||||
3. The canonical audit event for visibility mutations is untouched — it
|
||||
keeps hierarchy schema §5.2's full immutable target snapshot; the
|
||||
capability confers no audit read (contract 2 §8.5).
|
||||
4. Every other constraint of A1 and A2 is untouched.
|
||||
|
||||
@@ -24,26 +24,6 @@ fail-closed-fault, and existence-oracle observables added (§7);
|
||||
role-string namespacing rule added (§4.5); ruling request now names the
|
||||
interpretive resolution of PRD "admins".
|
||||
|
||||
Amendment 1 (company-CRUD capability): defines the capability class that
|
||||
contract 1 Amendment 1 (Ruling 4b, 2026-08-28) and hierarchy schema §5.5
|
||||
anticipate. §8 defines the capability as a platform-scoped, admin-assigned,
|
||||
audited delegation of exactly the hierarchy schema §5.5 company visibility
|
||||
command — no read command, no other company operation; the mutation's
|
||||
inherent existence disclosure is ratified as a bounded carve-out to
|
||||
hierarchy schema §6.7/§2.8 and to kanban SOT Amendment A2's
|
||||
sole-disclosure rule — SOT Amendment A3 (native-kanban-sot.md §10, this
|
||||
PR) expressly extends A2 by exactly this carve-out (§8.1). The holder
|
||||
sees only a redacted actor receipt; the canonical audit event keeps
|
||||
hierarchy schema §5.2's full immutable snapshot. The hierarchy role
|
||||
vocabulary (§2), every evaluation rule (§3), and grant management (§4) are
|
||||
untouched: the capability is not a `hierarchy_grants.role` value and
|
||||
evaluates outside the chain; capability-row deletion joins §6.1's
|
||||
revocation enumeration (§8.4). Until this amendment ratifies, the capability
|
||||
class is empty and
|
||||
the visibility command remains admin-only (hierarchy schema §5.5 states
|
||||
this fallback; the shipped gate at
|
||||
`apps/gateway/src/hierarchy/hierarchy.repository.ts` implements it).
|
||||
|
||||
Scope: the roles that can appear in `hierarchy_grants.role`, what a grant at
|
||||
each hierarchy level confers, how grants evaluate down the chain, how
|
||||
revocation propagates, and who may manage grants. Out of scope: the hierarchy
|
||||
@@ -262,191 +242,6 @@ contract 1 §6):
|
||||
8. Transfer: both-sides `owner` accepted, each single-side case refused
|
||||
(completing contract 1 §6.5).
|
||||
|
||||
## 8. Company-CRUD capability (Amendment 1)
|
||||
|
||||
Hierarchy schema §5.5 authorizes the company visibility mutation for
|
||||
exactly two actor classes: platform admins and "subjects holding the
|
||||
company-CRUD capability that a follow-up amendment to contract 2 will
|
||||
define". This section is that definition. The name is historical — coined
|
||||
in contract 1 Amendment 1 before the capability's content was fixed — and
|
||||
confers nothing by connotation: the ratified content is exactly §8.1.
|
||||
Company _creation_ is already ruled open to active users and always
|
||||
private (contract 3 §5.2, Ruling 4); rename, delete, and transfer of
|
||||
companies remain hierarchy `owner` operations (§2.3, §5); none of those is
|
||||
part of this capability, and widening it to any other operation is a
|
||||
further amendment, not an implementation decision.
|
||||
|
||||
1. **Content: exactly one command, no read command, disclosure stated.**
|
||||
Holding the capability authorizes executing the hierarchy schema §5.5
|
||||
visibility command (`companies.visibility`, both directions:
|
||||
`private → directory` and `directory → private`) on any company in the
|
||||
deployment, and no other command of any family. It confers **no read
|
||||
command**: no company enumeration, no curation listing, no structure
|
||||
read. The practical flow this implies is deliberate: to publish a
|
||||
private company, the holder is given the target identifier by the
|
||||
requesting company `owner` out of band; to unpublish, the target is
|
||||
already directory-listed. A curation listing for capability holders,
|
||||
if ever wanted, is a further amendment with its own disclosure
|
||||
analysis under hierarchy schema §6.7.
|
||||
|
||||
**Existence disclosure carve-out, stated rather than pretended away:**
|
||||
exercising a mutation inherently discloses its target's existence.
|
||||
The command's result distinguishes an existing company (success, for
|
||||
private and directory targets alike) from a nonexistent id
|
||||
(`not_found`), so a holder presenting candidate ids learns existence —
|
||||
exactly as a platform admin already does through the same command.
|
||||
This amendment ratifies that disclosure as part of the §5.5 curation
|
||||
authority, bounded as follows. The holder-visible surface is the
|
||||
command's **actor receipt** — the mutation result payload, carrying
|
||||
exactly the target id, old visibility, and new visibility, and
|
||||
**nothing else**: no name, slug, structure, content, grant, or
|
||||
membership information. The actor receipt is a redacted projection
|
||||
distinct from the **canonical audit event**, which is unchanged by
|
||||
this amendment: it keeps hierarchy schema §5.2's deletion-safe
|
||||
immutable target snapshot (id, slug, and parent chain at event time)
|
||||
in full. The two never converge on the holder: the capability confers
|
||||
no audit read (§8.5), so the canonical event — and with it the slug
|
||||
and parent chain — is reachable only by subjects independently
|
||||
authorized to read audit data, never through this capability. A
|
||||
successful publish additionally makes the target directory-listed to
|
||||
every authenticated user; that is the command's ratified purpose
|
||||
(hierarchy schema §5.5), not a leak. Hierarchy schema §6.7's
|
||||
existence-oracle rule and §2.8's directory-only disclosure are amended
|
||||
by exactly this carve-out for capability holders, kanban SOT Amendment
|
||||
A3 (native-kanban-sot.md §10, this PR) expressly extends A2's
|
||||
sole-disclosure enumeration by the same carve-out, and all three are
|
||||
otherwise untouched. Witnessed in §8.6.3.
|
||||
|
||||
2. **Holding: platform-scoped assignment, user subjects only.** The
|
||||
capability is not a hierarchy grant: it attaches to no node, has no
|
||||
role, and never enters §3 chain evaluation. It is held via a
|
||||
`platform_capabilities` table whose column set is exactly (nothing
|
||||
else, per the contract 1 §2.7 exhaustiveness discipline):
|
||||
- `id` — uuid, primary key;
|
||||
- `user_id` — text, NOT NULL, FK `users` **ON DELETE RESTRICT**;
|
||||
- `capability` — text, NOT NULL, constraint-checked against exactly
|
||||
`company_crud`;
|
||||
- `granted_by` — text, NOT NULL, FK `users` **ON DELETE RESTRICT**;
|
||||
- `created_at` — timestamptz, NOT NULL;
|
||||
- UNIQUE (`user_id`, `capability`).
|
||||
|
||||
The user FKs are **text**, not uuid, because `users.id` is a BetterAuth
|
||||
text key (`packages/db/src/schema.ts`; custody schema records the same)
|
||||
— PostgreSQL cannot reference a text primary key with a uuid column.
|
||||
This matches the shipped `hierarchy_grants` shape exactly: uuid
|
||||
surrogate `id`, text FKs to `users`.
|
||||
|
||||
Both user FKs are RESTRICT for the same reason contract 1 §3.3 pins
|
||||
RESTRICT on principal FKs: the identity contract (§7.3) gates user
|
||||
deletion, and a cascade here could silently destroy a capability
|
||||
without its §8.3 revocation audit event. Revocation is row deletion
|
||||
through the §8.3 command — there is no other removal path, no expiry
|
||||
column, and no tombstone. A deactivated holder confers nothing while
|
||||
deactivated: identity contract §7.1 denies all authorization to
|
||||
deactivated accounts, and the §8.4 predicate evaluates on the
|
||||
authenticated live user. No team subjects (§1.4's suspension reasoning
|
||||
applies with more force here — a workspace-bound team holding
|
||||
deployment-wide curation authority has no ratified meaning).
|
||||
|
||||
3. **Assignment is instance administration on the normal admin surface.**
|
||||
Only platform admins (`users.role = 'admin'`) may assign or revoke the
|
||||
capability, through an ordinary admin command (the same command class
|
||||
`AdminGuard` governs, §1.1) — not through direct table writes.
|
||||
Assignment delegates a slice of instance administration and is itself
|
||||
an instance-administration act under §1.1. A capability holder as such
|
||||
may NOT assign or revoke it (no self-propagation). Every assignment
|
||||
and revocation is a semantic audit event carrying actor, verb, subject
|
||||
user, and capability; serialized capability strings are namespaced per
|
||||
§4.5 (`platform-capability:company-crud` — a bare `company_crud` in
|
||||
any serialized artifact is non-conformant).
|
||||
4. **Evaluation and revocation follow this contract's existing rules.**
|
||||
The hierarchy schema §5.5 command's authorization predicate is:
|
||||
`users.role = 'admin'` OR a live `platform_capabilities` row
|
||||
(`user_id`, `company_crud`). Both disjuncts are evaluated live and
|
||||
fail closed per §3.5 — **independently**: with capability state
|
||||
unreadable (fault), the capability disjunct denies, but a platform
|
||||
admin whose `users.role` is readable remains authorized through the
|
||||
admin disjunct; with role state unreadable, the admin disjunct denies
|
||||
likewise. A decision that can read neither denies. Capability-row
|
||||
deletion is hereby added to §6.1's enumerated revocation paths:
|
||||
it propagates identically, under §6.2's bound, on every transport —
|
||||
no new HTTP/MCP command authorized by the deleted row after the
|
||||
revoking transaction commits, and any cached authorization is
|
||||
invalidated in the revoking transaction (§3.5).
|
||||
5. **What it does not confer**, stated so implementing PRs cannot drift:
|
||||
no hierarchy grant or effective role at any node; no workspace
|
||||
authorization or membership; no content, structure, or roll-up read;
|
||||
no grant management (§4.1 unchanged); no MCP scope; no other instance
|
||||
administration (user management, system settings, provider
|
||||
configuration remain platform-admin-only); no company create, rename,
|
||||
delete, or transfer. Hierarchy schema §5.5's rule that a company
|
||||
`owner` as such may NOT change visibility is unchanged — `owner` and
|
||||
this capability are disjoint authorities that combine only by a
|
||||
subject holding both.
|
||||
6. **Verification requirements** (extends §7, binding on implementing
|
||||
PRs):
|
||||
1. Schema witnesses (real PostgreSQL, `ci-postgres` service in the
|
||||
`test` CI step): the `capability` CHECK constraint rejects any
|
||||
value outside `company_crud`; NOT NULL enforced on every declared
|
||||
NOT NULL column; UNIQUE (`user_id`, `capability`) rejects a
|
||||
duplicate; both user FKs reject a dangling reference AND deleting a
|
||||
referenced user is refused (RESTRICT witnessed in both directions);
|
||||
the table's column set is exactly the §8.2 declared set (contract 1
|
||||
§6.2 discipline).
|
||||
2. Capability-only command matrix — the witness that proves "exactly
|
||||
one command", not merely "at least one": a non-admin holder with no
|
||||
other grants succeeds on the visibility command in **both**
|
||||
directions with contract 1 §5.2's audit event (old and new values
|
||||
as semantic content), and the **same** actor is refused, case by
|
||||
enumerated case: every hierarchy mutation family (company/child
|
||||
create under another's node, rename, delete, transfer); grant
|
||||
create/change/revoke; the workspace read and write command
|
||||
families; roll-up reads; structure reads — including the
|
||||
not-found-indistinguishable refusal on a structure read of the very
|
||||
company they just mutated (hierarchy schema §6.7); every
|
||||
instance-administration surface other than the visibility command
|
||||
(user management, system settings, provider configuration, and
|
||||
capability assign/revoke itself); and MCP scope derivation yields
|
||||
nothing — the §7.4 deny-by-default matrix gains this row. Company
|
||||
creation compares against an eligible-user baseline: the holder's
|
||||
create behaves exactly as any active user's — always `private`,
|
||||
and a creation request carrying a visibility argument is refused
|
||||
for holder and baseline alike (contract 3 §5.2).
|
||||
3. Disclosure bound (§8.1 carve-out witnessed, receipt and canonical
|
||||
event separately): the mutation result for a private-valid target,
|
||||
a directory-valid target, and a nonexistent id is exactly {success,
|
||||
success, `not_found`}; the actor receipt for a success carries
|
||||
exactly {target id, old visibility, new visibility} and no result
|
||||
or error payload carries name, slug, structure, content, grant, or
|
||||
membership data; the canonical audit event for the same mutation —
|
||||
asserted directly against the hierarchy outbox, not through any
|
||||
holder-facing surface — carries hierarchy schema §5.2's full
|
||||
immutable snapshot (id, slug, parent chain); and the holder's
|
||||
attempt to read audit data is refused (no audit read conferred,
|
||||
§8.5), proving the receipt/event separation reaches the holder as
|
||||
a redaction, not a weakened event.
|
||||
4. Assignment path, both polarities: a platform admin assigns and
|
||||
revokes through the normal admin command (positive witnesses —
|
||||
assign then observe the §8.6.2 allow, revoke then observe deny); a
|
||||
non-admin — including a current capability holder — is refused
|
||||
assign and revoke; every assign/revoke produces its audit event
|
||||
with the namespaced string (§8.3); a direct-write path that skips
|
||||
the command surface is non-conformant (the §8.3 command is the only
|
||||
writer of `platform_capabilities`).
|
||||
5. Revocation joins the §7.6 matrix: assignment is decision-time-live
|
||||
(capability assigned → the holder's next visibility command allows,
|
||||
no re-login); after row deletion, the ex-holder's next visibility
|
||||
command is refused **on every exposed transport**, measured with
|
||||
the revocation and the decision on distinct physical connections; a
|
||||
cached-authorization implementation proves transactional
|
||||
invalidation (§3.5). Fail-closed fault witnesses, both disjuncts
|
||||
(§8.4): with `platform_capabilities` unreadable, a non-admin holder
|
||||
is denied while a platform admin remains authorized; with role
|
||||
state unreadable, the admin disjunct denies.
|
||||
6. Owner-as-such refusal re-witnessed: hierarchy schema §6.9's
|
||||
owner-cannot-publish witness re-asserted with the
|
||||
`platform_capabilities` table present and empty for that owner.
|
||||
|
||||
## Ruling request
|
||||
|
||||
Ratify sections 1–7 as written, with one decision embedded and one
|
||||
@@ -462,17 +257,3 @@ interpretive resolution named:
|
||||
platform admins. A1 §8.1.3 does not attribute grant declaration to
|
||||
platform admins, and the §1.1 decision above is what makes this reading
|
||||
binding.
|
||||
|
||||
## Ruling request (Amendment 1)
|
||||
|
||||
Ratify §8, the Amendment 1 header note, and kanban SOT Amendment A3
|
||||
(native-kanban-sot.md §10 — the express A2 carve-out extension, which
|
||||
binds only with this ratification) as written, with one decision
|
||||
embedded:
|
||||
|
||||
- Decision: the company-CRUD capability is a platform-scoped,
|
||||
admin-assigned, audited delegation of exactly the hierarchy schema §5.5
|
||||
visibility command — no read command, no other company operation, with
|
||||
the mutation's inherent existence disclosure ratified as a bounded
|
||||
carve-out (§8.1). Say "agreed" or name the additional operations (or
|
||||
the curation listing) you want it to carry.
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { createMissionTasksRepo } from './mission-tasks.js';
|
||||
|
||||
/**
|
||||
* SHARED-CONTRACT §5.5 "mission_tasks.status write prohibition": this repo is
|
||||
* the sole path that authors mission_tasks.status from caller input (storage
|
||||
* tier migration is row transport and preserves stored values; the generic
|
||||
* storage adapters have no mission_tasks caller), and it must never forward a
|
||||
* caller-supplied status to the database on create or update. Callers keep
|
||||
* working (the field is accepted and ignored), so these tests assert on what
|
||||
* reaches the Drizzle chain, not on rejection.
|
||||
*/
|
||||
|
||||
function makeInsertDb(returned: unknown[]) {
|
||||
const values = vi.fn((_v: unknown) => ({ returning: vi.fn().mockResolvedValue(returned) }));
|
||||
return { db: { insert: vi.fn(() => ({ values })) }, values };
|
||||
}
|
||||
|
||||
function makeUpdateDb(returned: unknown[]) {
|
||||
const set = vi.fn((_v: unknown) => ({
|
||||
where: vi.fn(() => ({ returning: vi.fn().mockResolvedValue(returned) })),
|
||||
}));
|
||||
return { db: { update: vi.fn(() => ({ set })) }, set };
|
||||
}
|
||||
|
||||
describe('createMissionTasksRepo — status write prohibition', () => {
|
||||
it('create strips a caller-supplied status before insert', async () => {
|
||||
const { db, values } = makeInsertDb([{ id: 'mt1', status: 'not-started' }]);
|
||||
const repo = createMissionTasksRepo(db as never);
|
||||
|
||||
const result = await repo.create({
|
||||
missionId: 'm1',
|
||||
userId: 'u1',
|
||||
status: 'done',
|
||||
description: 'd',
|
||||
} as never);
|
||||
|
||||
expect(values).toHaveBeenCalledTimes(1);
|
||||
const inserted = values.mock.calls[0]![0] as Record<string, unknown>;
|
||||
expect('status' in inserted).toBe(false);
|
||||
expect(inserted.missionId).toBe('m1');
|
||||
expect(inserted.description).toBe('d');
|
||||
expect(result.id).toBe('mt1');
|
||||
});
|
||||
|
||||
it('create without status still inserts (DB default applies)', async () => {
|
||||
const { db, values } = makeInsertDb([{ id: 'mt2' }]);
|
||||
const repo = createMissionTasksRepo(db as never);
|
||||
|
||||
await repo.create({ missionId: 'm1', userId: 'u1' } as never);
|
||||
|
||||
const inserted = values.mock.calls[0]![0] as Record<string, unknown>;
|
||||
expect('status' in inserted).toBe(false);
|
||||
});
|
||||
|
||||
it('update strips a caller-supplied status but keeps the other fields', async () => {
|
||||
const { db, set } = makeUpdateDb([{ id: 'mt1', notes: 'n' }]);
|
||||
const repo = createMissionTasksRepo(db as never);
|
||||
|
||||
const result = await repo.update('mt1', { status: 'done', notes: 'n' } as never);
|
||||
|
||||
expect(set).toHaveBeenCalledTimes(1);
|
||||
const updated = set.mock.calls[0]![0] as Record<string, unknown>;
|
||||
expect('status' in updated).toBe(false);
|
||||
expect(updated.notes).toBe('n');
|
||||
expect(updated.updatedAt).toBeInstanceOf(Date);
|
||||
expect(result?.id).toBe('mt1');
|
||||
});
|
||||
|
||||
it('update with only status degenerates to a timestamp-only update', async () => {
|
||||
const { db, set } = makeUpdateDb([{ id: 'mt1' }]);
|
||||
const repo = createMissionTasksRepo(db as never);
|
||||
|
||||
await repo.update('mt1', { status: 'blocked' } as never);
|
||||
|
||||
const updated = set.mock.calls[0]![0] as Record<string, unknown>;
|
||||
expect(Object.keys(updated)).toEqual(['updatedAt']);
|
||||
});
|
||||
});
|
||||
@@ -3,24 +3,6 @@ import { eq, and, type Db, missionTasks } from '@mosaicstack/db';
|
||||
export type MissionTask = typeof missionTasks.$inferSelect;
|
||||
export type NewMissionTask = typeof missionTasks.$inferInsert;
|
||||
|
||||
// SHARED-CONTRACT §5.1 phase 1 / §5.4: mission_tasks.status is prohibited as a
|
||||
// write source through the N-1 window. This repo is the sole path that authors
|
||||
// status from caller input, so the field is stripped here — accepted and
|
||||
// ignored rather than rejected, because the legacy surface is frozen with
|
||||
// existing consumers kept working (tool-gateway-mapping.md §3.2). Two other
|
||||
// surfaces touch the column and are deliberately NOT stripped:
|
||||
// packages/storage/migrate-tier.ts copies whole rows between storage tiers and
|
||||
// must preserve the stored value verbatim, and the generic table-keyed storage
|
||||
// adapters register mission_tasks but have no caller that targets it (runtime
|
||||
// callers use fixed collection constants). Neither authors a new status. The
|
||||
// column keeps its DB default, stays declared and readable, and is retired
|
||||
// only after no readers remain.
|
||||
function stripStatus<T extends { status?: unknown }>(data: T): Omit<T, 'status'> {
|
||||
const rest = { ...data };
|
||||
delete rest.status;
|
||||
return rest;
|
||||
}
|
||||
|
||||
export function createMissionTasksRepo(db: Db) {
|
||||
return {
|
||||
async findByMission(missionId: string): Promise<MissionTask[]> {
|
||||
@@ -48,14 +30,14 @@ export function createMissionTasksRepo(db: Db) {
|
||||
},
|
||||
|
||||
async create(data: NewMissionTask): Promise<MissionTask> {
|
||||
const rows = await db.insert(missionTasks).values(stripStatus(data)).returning();
|
||||
const rows = await db.insert(missionTasks).values(data).returning();
|
||||
return rows[0]!;
|
||||
},
|
||||
|
||||
async update(id: string, data: Partial<NewMissionTask>): Promise<MissionTask | undefined> {
|
||||
const rows = await db
|
||||
.update(missionTasks)
|
||||
.set({ ...stripStatus(data), updatedAt: new Date() })
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(eq(missionTasks.id, id))
|
||||
.returning();
|
||||
return rows[0];
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
CREATE TYPE "public"."agent_outbox_status" AS ENUM('pending', 'processing', 'delivered');--> statement-breakpoint
|
||||
CREATE TABLE "agent_audit_events" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"seq" bigint GENERATED ALWAYS AS IDENTITY (sequence name "agent_audit_events_seq_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
|
||||
"event_type" text NOT NULL,
|
||||
"actor_id" text NOT NULL,
|
||||
"agent_id" uuid NOT NULL,
|
||||
"correlation_id" text NOT NULL,
|
||||
"causation_id" uuid,
|
||||
"payload" jsonb NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "agent_audit_events_type_check" CHECK (event_type IN ('agent.enrolled', 'agent.enrollment.replayed'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "agent_idempotency_fence" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"operation" text NOT NULL,
|
||||
"idempotency_key" text NOT NULL,
|
||||
"actor_id" text NOT NULL,
|
||||
"authorization_scope" text NOT NULL,
|
||||
"payload_digest" text NOT NULL,
|
||||
"replay_mode" text DEFAULT 'actor-bound' NOT NULL,
|
||||
"outcome_agent_id" uuid NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "agent_idempotency_fence_replay_mode_check" CHECK (replay_mode IN ('actor-bound', 'shared'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "agent_outbox" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"event_id" uuid NOT NULL,
|
||||
"correlation_id" text NOT NULL,
|
||||
"status" "agent_outbox_status" DEFAULT 'pending' NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"delivered_at" timestamp with time zone
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "agents" ADD COLUMN "harness" text;--> statement-breakpoint
|
||||
ALTER TABLE "agents" ADD COLUMN "enrolled_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "agent_audit_events" ADD CONSTRAINT "agent_audit_events_causation_id_agent_audit_events_id_fk" FOREIGN KEY ("causation_id") REFERENCES "public"."agent_audit_events"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "agent_outbox" ADD CONSTRAINT "agent_outbox_event_id_agent_audit_events_id_fk" FOREIGN KEY ("event_id") REFERENCES "public"."agent_audit_events"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "agent_audit_events_seq_idx" ON "agent_audit_events" USING btree ("seq");--> statement-breakpoint
|
||||
CREATE INDEX "agent_audit_events_agent_seq_idx" ON "agent_audit_events" USING btree ("agent_id","seq");--> statement-breakpoint
|
||||
CREATE INDEX "agent_audit_events_correlation_idx" ON "agent_audit_events" USING btree ("correlation_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "agent_idempotency_fence_operation_key_idx" ON "agent_idempotency_fence" USING btree ("operation","idempotency_key");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "agent_outbox_event_idx" ON "agent_outbox" USING btree ("event_id");--> statement-breakpoint
|
||||
CREATE INDEX "agent_outbox_status_created_idx" ON "agent_outbox" USING btree ("status","created_at");
|
||||
File diff suppressed because it is too large
Load Diff
@@ -148,13 +148,6 @@
|
||||
"when": 1787963521142,
|
||||
"tag": "0020_special_betty_brant",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 21,
|
||||
"version": "7",
|
||||
"when": 1788053011351,
|
||||
"tag": "0021_agent_enrollment",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,412 +0,0 @@
|
||||
/**
|
||||
* Agent enrollment schema witnesses — M4-4a, the schema-level half of the
|
||||
* witness list in docs/plans/2026-08-29-agent-enrollment-command-design.md §5.
|
||||
*
|
||||
* Witnesses the guarantees migration 0021's tables themselves carry: the
|
||||
* event-type CHECK, monotonic per-agent append order (`seq`), deletion-safe
|
||||
* linkage (no foreign key from the events or fence tables into `agents` —
|
||||
* rows survive a legacy CRUD DELETE of the agent), the causation self-FK,
|
||||
* the outbox's FK/uniqueness/status shape, the fence's UNIQUE
|
||||
* (operation, key) and replay-mode CHECK, and the nullable enrollment
|
||||
* columns on `agents` (legacy rows insert without them). The command-level
|
||||
* witnesses (never-echo, same-tx atomicity, replay semantics, correlation,
|
||||
* CLI parity, fail-closed) belong to the M4-4b implementation slice.
|
||||
*
|
||||
* Two legs run the same witness body:
|
||||
* - PGlite (WASM Postgres): always runs.
|
||||
* - Real PostgreSQL: runs when DATABASE_URL is set — the binding witness;
|
||||
* CI migrates ci-postgres before `pnpm test`.
|
||||
*/
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { createDb } from './client.js';
|
||||
import { createPgliteDb } from './client-pglite.js';
|
||||
import { runPgliteMigrations } from './migrate.js';
|
||||
import { agentAuditEvents, agentIdempotencyFence, agentOutbox, agents } from './schema.js';
|
||||
|
||||
type AnyDb = {
|
||||
db: {
|
||||
insert: (t: unknown) => { values: (v: unknown) => Promise<unknown> };
|
||||
execute: (q: unknown) => Promise<{ rows?: unknown[] } | unknown[]>;
|
||||
};
|
||||
close: () => Promise<void>;
|
||||
};
|
||||
|
||||
/** Match a constraint failure anywhere along drizzle's cause chain. */
|
||||
async function expectViolation(p: Promise<unknown>, re: RegExp, label = ''): Promise<void> {
|
||||
let err: unknown;
|
||||
try {
|
||||
await p;
|
||||
} catch (e) {
|
||||
err = e;
|
||||
}
|
||||
expect(err, label || 'expected the statement to be refused').toBeDefined();
|
||||
const messages: string[] = [];
|
||||
let cur: unknown = err;
|
||||
while (cur instanceof Error) {
|
||||
messages.push(cur.message);
|
||||
cur = (cur as { cause?: unknown }).cause;
|
||||
}
|
||||
expect(messages.join(' | '), label).toMatch(re);
|
||||
}
|
||||
|
||||
function rows(res: { rows?: unknown[] } | unknown[]): Record<string, unknown>[] {
|
||||
return (Array.isArray(res) ? res : (res.rows ?? [])) as Record<string, unknown>[];
|
||||
}
|
||||
|
||||
/** Unique per-run prefix so real-PG runs never collide and clean up safely. */
|
||||
const T = `agent-e-${randomUUID().slice(0, 8)}`;
|
||||
|
||||
type EventInsert = typeof agentAuditEvents.$inferInsert;
|
||||
|
||||
function eventRow(overrides: Partial<EventInsert> = {}): EventInsert {
|
||||
return {
|
||||
eventType: 'agent.enrolled',
|
||||
actorId: `${T}-actor`,
|
||||
agentId: randomUUID(),
|
||||
correlationId: `${T}-corr-${randomUUID()}`,
|
||||
payload: {
|
||||
harness: 'claude-code',
|
||||
provider: 'anthropic',
|
||||
name: 'x',
|
||||
credentialMode: 'reference',
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
type FenceInsert = typeof agentIdempotencyFence.$inferInsert;
|
||||
|
||||
function fenceRow(overrides: Partial<FenceInsert> = {}): FenceInsert {
|
||||
return {
|
||||
operation: 'agent.enroll',
|
||||
idempotencyKey: `${T}-${randomUUID()}`,
|
||||
actorId: `${T}-actor`,
|
||||
authorizationScope: 'platform-user',
|
||||
payloadDigest: `${T}-digest`,
|
||||
outcomeAgentId: randomUUID(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function witnessSuite(getHandle: () => AnyDb): void {
|
||||
const db = () => getHandle().db as unknown as ReturnType<typeof createDb>['db'];
|
||||
|
||||
afterAll(async () => {
|
||||
const d = db();
|
||||
await d.execute(sql`DELETE FROM agent_outbox WHERE correlation_id LIKE ${T + '%'}`);
|
||||
// Caused events first: the causation self-FK is RESTRICT.
|
||||
await d.execute(
|
||||
sql`DELETE FROM agent_audit_events WHERE correlation_id LIKE ${T + '%'} AND causation_id IS NOT NULL`,
|
||||
);
|
||||
await d.execute(sql`DELETE FROM agent_audit_events WHERE correlation_id LIKE ${T + '%'}`);
|
||||
await d.execute(sql`DELETE FROM agent_idempotency_fence WHERE actor_id LIKE ${T + '%'}`);
|
||||
await d.execute(sql`DELETE FROM agents WHERE name LIKE ${T + '%'}`);
|
||||
});
|
||||
|
||||
// ── agents: nullable enrollment columns (no backfill semantics) ────────────
|
||||
|
||||
it('legacy agent rows insert without enrollment columns; enrolled rows carry both', async () => {
|
||||
const legacyId = randomUUID();
|
||||
await db()
|
||||
.insert(agents)
|
||||
.values({
|
||||
id: legacyId,
|
||||
name: `${T}-legacy`,
|
||||
provider: 'anthropic',
|
||||
model: 'claude-fable-5',
|
||||
});
|
||||
const legacy = rows(
|
||||
await db().execute(sql`SELECT harness, enrolled_at FROM agents WHERE id = ${legacyId}`),
|
||||
)[0]!;
|
||||
expect(legacy['harness']).toBeNull();
|
||||
expect(legacy['enrolled_at']).toBeNull();
|
||||
|
||||
const enrolledId = randomUUID();
|
||||
await db()
|
||||
.insert(agents)
|
||||
.values({
|
||||
id: enrolledId,
|
||||
name: `${T}-enrolled`,
|
||||
provider: 'anthropic',
|
||||
model: 'claude-fable-5',
|
||||
harness: 'claude-code',
|
||||
enrolledAt: new Date(),
|
||||
});
|
||||
const enrolled = rows(
|
||||
await db().execute(sql`SELECT harness, enrolled_at FROM agents WHERE id = ${enrolledId}`),
|
||||
)[0]!;
|
||||
expect(enrolled['harness']).toBe('claude-code');
|
||||
expect(enrolled['enrolled_at']).not.toBeNull();
|
||||
});
|
||||
|
||||
// ── agent_audit_events: CHECK, ordering, deletion-safe linkage ─────────────
|
||||
|
||||
it('accepts both declared event types and refuses an undeclared one', async () => {
|
||||
await db()
|
||||
.insert(agentAuditEvents)
|
||||
.values(eventRow({ eventType: 'agent.enrolled' }));
|
||||
await db()
|
||||
.insert(agentAuditEvents)
|
||||
.values(eventRow({ eventType: 'agent.enrollment.replayed' }));
|
||||
await expectViolation(
|
||||
db()
|
||||
.insert(agentAuditEvents)
|
||||
.values(eventRow({ eventType: 'agent.deleted' })),
|
||||
/type_check|violates check/i,
|
||||
'undeclared event type must be refused',
|
||||
);
|
||||
});
|
||||
|
||||
it('assigns strictly increasing seq in insert order for one agent', async () => {
|
||||
const agentId = randomUUID();
|
||||
const c1 = `${T}-seq-1-${randomUUID()}`;
|
||||
const c2 = `${T}-seq-2-${randomUUID()}`;
|
||||
await db()
|
||||
.insert(agentAuditEvents)
|
||||
.values(eventRow({ agentId, correlationId: c1 }));
|
||||
await db()
|
||||
.insert(agentAuditEvents)
|
||||
.values(eventRow({ agentId, eventType: 'agent.enrollment.replayed', correlationId: c2 }));
|
||||
const res = rows(
|
||||
await db().execute(
|
||||
sql`SELECT correlation_id, seq FROM agent_audit_events WHERE agent_id = ${agentId} ORDER BY seq ASC`,
|
||||
),
|
||||
);
|
||||
expect(res.map((r) => r['correlation_id'])).toEqual([c1, c2]);
|
||||
expect(Number(res[1]!['seq'])).toBeGreaterThan(Number(res[0]!['seq']));
|
||||
});
|
||||
|
||||
it('has no foreign key into agents, and events survive agent deletion', async () => {
|
||||
const fks = rows(
|
||||
await db().execute(sql`
|
||||
SELECT ccu.table_name AS referenced_table
|
||||
FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.constraint_column_usage ccu
|
||||
ON ccu.constraint_name = tc.constraint_name AND ccu.constraint_schema = tc.constraint_schema
|
||||
WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_name = 'agent_audit_events'
|
||||
`),
|
||||
);
|
||||
// The causation self-FK is the ONLY foreign key on the events table.
|
||||
expect([...new Set(fks.map((r) => r['referenced_table']))]).toEqual(['agent_audit_events']);
|
||||
|
||||
const agentId = randomUUID();
|
||||
await db()
|
||||
.insert(agents)
|
||||
.values({ id: agentId, name: `${T}-doomed`, provider: 'anthropic', model: 'claude-fable-5' });
|
||||
const corr = `${T}-survive-${randomUUID()}`;
|
||||
await db()
|
||||
.insert(agentAuditEvents)
|
||||
.values(eventRow({ agentId, correlationId: corr }));
|
||||
await db().execute(sql`DELETE FROM agents WHERE id = ${agentId}`);
|
||||
const after = rows(
|
||||
await db().execute(
|
||||
sql`SELECT agent_id FROM agent_audit_events WHERE correlation_id = ${corr}`,
|
||||
),
|
||||
);
|
||||
expect(after).toHaveLength(1);
|
||||
expect(after[0]!['agent_id']).toBe(agentId);
|
||||
});
|
||||
|
||||
it('enforces the causation self-FK and RESTRICTs deleting a cause', async () => {
|
||||
await expectViolation(
|
||||
db()
|
||||
.insert(agentAuditEvents)
|
||||
.values(eventRow({ causationId: randomUUID() })),
|
||||
/foreign key/i,
|
||||
'causation must reference an existing event',
|
||||
);
|
||||
const causeCorr = `${T}-cause-${randomUUID()}`;
|
||||
await db()
|
||||
.insert(agentAuditEvents)
|
||||
.values(eventRow({ correlationId: causeCorr }));
|
||||
const cause = rows(
|
||||
await db().execute(
|
||||
sql`SELECT id FROM agent_audit_events WHERE correlation_id = ${causeCorr}`,
|
||||
),
|
||||
)[0]!;
|
||||
await db()
|
||||
.insert(agentAuditEvents)
|
||||
.values(
|
||||
eventRow({
|
||||
eventType: 'agent.enrollment.replayed',
|
||||
causationId: cause['id'] as string,
|
||||
}),
|
||||
);
|
||||
await expectViolation(
|
||||
db().execute(sql`DELETE FROM agent_audit_events WHERE id = ${cause['id'] as string}`),
|
||||
/foreign key/i,
|
||||
'a cause with dependent events must not be deletable',
|
||||
);
|
||||
});
|
||||
|
||||
// ── agent_outbox shape ─────────────────────────────────────────────────────
|
||||
|
||||
it('outbox rows require an existing event, one outbox row per event, closed status enum', async () => {
|
||||
await expectViolation(
|
||||
db()
|
||||
.insert(agentOutbox)
|
||||
.values({ eventId: randomUUID(), correlationId: `${T}-corr` }),
|
||||
/foreign key/i,
|
||||
'outbox must reference an existing event',
|
||||
);
|
||||
const corr = `${T}-ob-${randomUUID()}`;
|
||||
await db()
|
||||
.insert(agentAuditEvents)
|
||||
.values(eventRow({ correlationId: corr }));
|
||||
const event = rows(
|
||||
await db().execute(sql`SELECT id FROM agent_audit_events WHERE correlation_id = ${corr}`),
|
||||
)[0]!;
|
||||
const eventId = event['id'] as string;
|
||||
await db().insert(agentOutbox).values({ eventId, correlationId: corr });
|
||||
await expectViolation(
|
||||
db()
|
||||
.insert(agentOutbox)
|
||||
.values({ eventId, correlationId: `${T}-ob2` }),
|
||||
/duplicate key|unique/i,
|
||||
'one outbox record per event',
|
||||
);
|
||||
await expectViolation(
|
||||
db().execute(
|
||||
sql`INSERT INTO agent_outbox (event_id, correlation_id, status)
|
||||
VALUES (${eventId}, ${`${T}-ob3`}, 'failed')`,
|
||||
),
|
||||
/invalid input value for enum|22P02/i,
|
||||
'status outside pending/processing/delivered must be refused',
|
||||
);
|
||||
});
|
||||
|
||||
it('outbox FK RESTRICTs event deletion while the outbox row exists', async () => {
|
||||
const corr = `${T}-obr-${randomUUID()}`;
|
||||
await db()
|
||||
.insert(agentAuditEvents)
|
||||
.values(eventRow({ correlationId: corr }));
|
||||
const event = rows(
|
||||
await db().execute(sql`SELECT id FROM agent_audit_events WHERE correlation_id = ${corr}`),
|
||||
)[0]!;
|
||||
await db()
|
||||
.insert(agentOutbox)
|
||||
.values({ eventId: event['id'] as string, correlationId: corr });
|
||||
await expectViolation(
|
||||
db().execute(sql`DELETE FROM agent_audit_events WHERE id = ${event['id'] as string}`),
|
||||
/foreign key/i,
|
||||
);
|
||||
});
|
||||
|
||||
// ── agent_idempotency_fence: (operation, key) uniqueness, mode CHECK ───────
|
||||
|
||||
it('refuses a duplicate (operation, key) pair but allows the same key under another operation', async () => {
|
||||
const key = `${T}-fence-${randomUUID()}`;
|
||||
await db()
|
||||
.insert(agentIdempotencyFence)
|
||||
.values(fenceRow({ idempotencyKey: key }));
|
||||
await expectViolation(
|
||||
db()
|
||||
.insert(agentIdempotencyFence)
|
||||
.values(fenceRow({ idempotencyKey: key })),
|
||||
/duplicate key|unique/i,
|
||||
'fence uniqueness is (operation, key)',
|
||||
);
|
||||
// Same key, different operation identifier: a distinct fence.
|
||||
await db()
|
||||
.insert(agentIdempotencyFence)
|
||||
.values(fenceRow({ idempotencyKey: key, operation: 'agent.other' }));
|
||||
});
|
||||
|
||||
it('defaults replay mode to actor-bound and refuses an undeclared mode', async () => {
|
||||
const key = `${T}-mode-${randomUUID()}`;
|
||||
await db()
|
||||
.insert(agentIdempotencyFence)
|
||||
.values(fenceRow({ idempotencyKey: key }));
|
||||
const row = rows(
|
||||
await db().execute(
|
||||
sql`SELECT replay_mode FROM agent_idempotency_fence WHERE idempotency_key = ${key}`,
|
||||
),
|
||||
)[0]!;
|
||||
expect(row['replay_mode']).toBe('actor-bound');
|
||||
await expectViolation(
|
||||
db()
|
||||
.insert(agentIdempotencyFence)
|
||||
.values(fenceRow({ replayMode: 'unbound' as 'actor-bound' })),
|
||||
/replay_mode_check|violates check/i,
|
||||
'a mode outside actor-bound/shared must be refused',
|
||||
);
|
||||
});
|
||||
|
||||
it('fence has no foreign key at all, and rows survive agent deletion', async () => {
|
||||
const fks = rows(
|
||||
await db().execute(sql`
|
||||
SELECT ccu.table_name AS referenced_table
|
||||
FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.constraint_column_usage ccu
|
||||
ON ccu.constraint_name = tc.constraint_name AND ccu.constraint_schema = tc.constraint_schema
|
||||
WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_name = 'agent_idempotency_fence'
|
||||
`),
|
||||
);
|
||||
expect(fks).toHaveLength(0);
|
||||
|
||||
const agentId = randomUUID();
|
||||
await db()
|
||||
.insert(agents)
|
||||
.values({
|
||||
id: agentId,
|
||||
name: `${T}-fdoomed`,
|
||||
provider: 'anthropic',
|
||||
model: 'claude-fable-5',
|
||||
});
|
||||
const key = `${T}-fsurvive-${randomUUID()}`;
|
||||
await db()
|
||||
.insert(agentIdempotencyFence)
|
||||
.values(fenceRow({ idempotencyKey: key, outcomeAgentId: agentId }));
|
||||
await db().execute(sql`DELETE FROM agents WHERE id = ${agentId}`);
|
||||
const after = rows(
|
||||
await db().execute(
|
||||
sql`SELECT outcome_agent_id FROM agent_idempotency_fence WHERE idempotency_key = ${key}`,
|
||||
),
|
||||
);
|
||||
expect(after).toHaveLength(1);
|
||||
expect(after[0]!['outcome_agent_id']).toBe(agentId);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Leg 1: PGlite (always runs — local witness signal) ───────────────────────
|
||||
|
||||
describe('agent enrollment schema witnesses — PGlite', () => {
|
||||
let dir: string;
|
||||
let handle: ReturnType<typeof createPgliteDb>;
|
||||
|
||||
beforeAll(async () => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'agent-enroll-witness-'));
|
||||
handle = createPgliteDb(dir);
|
||||
await runPgliteMigrations(handle);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await handle.close();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
witnessSuite(() => handle as unknown as AnyDb);
|
||||
});
|
||||
|
||||
// ── Leg 2: real PostgreSQL (binding witness, ci-postgres in CI) ──────────────
|
||||
|
||||
const hasPostgres = Boolean(process.env['DATABASE_URL']);
|
||||
|
||||
describe.skipIf(!hasPostgres)('agent enrollment schema witnesses — real PostgreSQL', () => {
|
||||
let handle: ReturnType<typeof createDb>;
|
||||
|
||||
beforeAll(() => {
|
||||
handle = createDb(process.env['DATABASE_URL']!);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await handle.close();
|
||||
});
|
||||
|
||||
witnessSuite(() => handle as unknown as AnyDb);
|
||||
});
|
||||
@@ -302,11 +302,6 @@ export const agents = pgTable(
|
||||
skills: jsonb('skills').$type<string[]>(),
|
||||
isSystem: boolean('is_system').notNull().default(false),
|
||||
config: jsonb('config'),
|
||||
// Enrollment (M4-4, docs/plans/2026-08-29-agent-enrollment-command-design.md §4).
|
||||
// NULL on both marks a legacy (non-enrolled) row; no backfill — enrollment
|
||||
// is a fact the rank-4 command creates, not one to invent for existing rows.
|
||||
harness: text('harness'),
|
||||
enrolledAt: timestamp('enrolled_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
@@ -1284,109 +1279,3 @@ export const hierarchyOutbox = pgTable(
|
||||
index('hierarchy_outbox_status_created_idx').on(t.status, t.createdAt),
|
||||
],
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent enrollment (M4-4) — rank-4 command family audit/outbox/fence stores.
|
||||
// Design: docs/plans/2026-08-29-agent-enrollment-command-design.md §4.
|
||||
// Pattern reuse from the hierarchy audit/outbox pair, separate store. Audit
|
||||
// rows reference the agent by snapshot id, deliberately with NO FK, so audit
|
||||
// history survives agent deletion through the legacy CRUD DELETE path.
|
||||
// Idempotency for this family lives in agent_idempotency_fence (contract 3
|
||||
// §4.3 envelope, ratified into contract 5 §4 via contract 3 §7 item 4) — the
|
||||
// audit and outbox tables carry no idempotency key of their own.
|
||||
|
||||
export const AGENT_AUDIT_EVENT_TYPES = [
|
||||
// Semantic mutation event of agent.enroll.
|
||||
'agent.enrolled',
|
||||
// Non-mutation access class: a passing idempotent replay appends this and
|
||||
// nothing else (accessing principal, current correlation id, fence-row
|
||||
// reference in the payload).
|
||||
'agent.enrollment.replayed',
|
||||
] as const;
|
||||
|
||||
export const agentAuditEvents = pgTable(
|
||||
'agent_audit_events',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
// Global append order; per-agent ordering is a filter on agent_id ordered
|
||||
// by seq.
|
||||
seq: bigint('seq', { mode: 'number' }).notNull().generatedAlwaysAsIdentity(),
|
||||
eventType: text('event_type').notNull(),
|
||||
// No FK: audit events outlive every principal and every target.
|
||||
actorId: text('actor_id').notNull(),
|
||||
agentId: uuid('agent_id').notNull(),
|
||||
correlationId: text('correlation_id').notNull(),
|
||||
causationId: uuid('causation_id').references((): AnyPgColumn => agentAuditEvents.id, {
|
||||
onDelete: 'restrict',
|
||||
}),
|
||||
// Immutable snapshot at event time; never carries credential material
|
||||
// (§3.1 rule 1: actor, agent id, harness, provider, name, credentialMode).
|
||||
payload: jsonb('payload').notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('agent_audit_events_seq_idx').on(t.seq),
|
||||
index('agent_audit_events_agent_seq_idx').on(t.agentId, t.seq),
|
||||
index('agent_audit_events_correlation_idx').on(t.correlationId),
|
||||
check(
|
||||
'agent_audit_events_type_check',
|
||||
sql`event_type IN ('agent.enrolled', 'agent.enrollment.replayed')`,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export const agentOutboxStatusEnum = pgEnum('agent_outbox_status', [
|
||||
'pending',
|
||||
'processing',
|
||||
'delivered',
|
||||
]);
|
||||
|
||||
export const agentOutbox = pgTable(
|
||||
'agent_outbox',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
// FK into the append-only events table: never dangles, RESTRICT is safe.
|
||||
eventId: uuid('event_id')
|
||||
.notNull()
|
||||
.references(() => agentAuditEvents.id, { onDelete: 'restrict' }),
|
||||
correlationId: text('correlation_id').notNull(),
|
||||
status: agentOutboxStatusEnum('status').notNull().default('pending'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
deliveredAt: timestamp('delivered_at', { withTimezone: true }),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('agent_outbox_event_idx').on(t.eventId),
|
||||
index('agent_outbox_status_created_idx').on(t.status, t.createdAt),
|
||||
],
|
||||
);
|
||||
|
||||
// Contract 3 §4.3 fence shape. Uniqueness is (operation, key); the recorded
|
||||
// replay mode is always 'actor-bound' for this family (`shared` is seed-only
|
||||
// and refused at validation — design §3.1), but the column keeps the ratified
|
||||
// envelope shape and serves the mode-mismatch collision check. The payload
|
||||
// digest input EXCLUDES the credential value (design §3.1 rule 5).
|
||||
export const agentIdempotencyFence = pgTable(
|
||||
'agent_idempotency_fence',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
operation: text('operation').notNull(),
|
||||
idempotencyKey: text('idempotency_key').notNull(),
|
||||
// No FK: fence rows outlive principals, mirroring the audit tables.
|
||||
actorId: text('actor_id').notNull(),
|
||||
authorizationScope: text('authorization_scope').notNull(),
|
||||
payloadDigest: text('payload_digest').notNull(),
|
||||
replayMode: text('replay_mode').notNull().default('actor-bound'),
|
||||
// Committed-outcome reference (the enrolled agent's id). Snapshot value,
|
||||
// no FK: the fence must keep answering replays after a legacy DELETE.
|
||||
outcomeAgentId: uuid('outcome_agent_id').notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('agent_idempotency_fence_operation_key_idx').on(t.operation, t.idempotencyKey),
|
||||
check(
|
||||
'agent_idempotency_fence_replay_mode_check',
|
||||
sql`replay_mode IN ('actor-bound', 'shared')`,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
# Usage: issue-assign.sh -i ISSUE_NUMBER [-a assignee] [-l labels] [-m milestone]
|
||||
|
||||
set -e
|
||||
set -o pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/detect-platform.sh"
|
||||
@@ -34,36 +33,25 @@ Examples:
|
||||
$(basename "$0") -i 42 -l "in-progress" -m "0.2.0"
|
||||
$(basename "$0") -i 42 -a @me
|
||||
EOF
|
||||
exit "${1:-2}"
|
||||
}
|
||||
|
||||
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
|
||||
# distinct from provider, credential, and verification failures (exit 1).
|
||||
usage_error() {
|
||||
echo "Error: $*" >&2
|
||||
usage >&2
|
||||
exit "${1:-1}"
|
||||
}
|
||||
|
||||
# Parse arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-i|--issue|--number)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
-i|--issue)
|
||||
ISSUE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-a|--assignee)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
ASSIGNEE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-l|--labels)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
LABELS="$2"
|
||||
shift 2
|
||||
;;
|
||||
-m|--milestone)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
MILESTONE="$2"
|
||||
shift 2
|
||||
;;
|
||||
@@ -91,35 +79,20 @@ PLATFORM=$(detect_platform)
|
||||
case "$PLATFORM" in
|
||||
github)
|
||||
if [[ -n "$ASSIGNEE" ]]; then
|
||||
prov_rc=0
|
||||
gh issue edit "$ISSUE" --add-assignee "$ASSIGNEE" || prov_rc=$?
|
||||
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
|
||||
gh issue edit "$ISSUE" --add-assignee "$ASSIGNEE"
|
||||
fi
|
||||
if [[ "$REMOVE_ASSIGNEE" == true ]]; then
|
||||
# Get current assignees and remove them
|
||||
# pipefail preserves the provider status through the pipeline;
|
||||
# a FAILED lookup exits here instead of reading as a silent
|
||||
# no-assignees skip (codex PR #1464). A successful lookup with
|
||||
# zero assignees still skips the edit below.
|
||||
CURRENT=$(gh issue view "$ISSUE" --json assignees -q '.assignees[].login' 2>/dev/null | tr '\n' ',') || {
|
||||
echo "Error: could not read current assignees (provider lookup failed)" >&2
|
||||
exit 1
|
||||
}
|
||||
CURRENT=$(gh issue view "$ISSUE" --json assignees -q '.assignees[].login' 2>/dev/null | tr '\n' ',')
|
||||
if [[ -n "$CURRENT" ]]; then
|
||||
prov_rc=0
|
||||
gh issue edit "$ISSUE" --remove-assignee "${CURRENT%,}" || prov_rc=$?
|
||||
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
|
||||
gh issue edit "$ISSUE" --remove-assignee "${CURRENT%,}"
|
||||
fi
|
||||
fi
|
||||
if [[ -n "$LABELS" ]]; then
|
||||
prov_rc=0
|
||||
gh issue edit "$ISSUE" --add-label "$LABELS" || prov_rc=$?
|
||||
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
|
||||
gh issue edit "$ISSUE" --add-label "$LABELS"
|
||||
fi
|
||||
if [[ -n "$MILESTONE" ]]; then
|
||||
prov_rc=0
|
||||
gh issue edit "$ISSUE" --milestone "$MILESTONE" || prov_rc=$?
|
||||
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
|
||||
gh issue edit "$ISSUE" --milestone "$MILESTONE"
|
||||
fi
|
||||
echo "Issue #$ISSUE updated successfully"
|
||||
;;
|
||||
@@ -158,9 +131,7 @@ case "$PLATFORM" in
|
||||
fi
|
||||
|
||||
if [[ "$NEEDS_EDIT" == true ]]; then
|
||||
prov_rc=0
|
||||
"${CMD[@]}" || prov_rc=$?
|
||||
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
|
||||
"${CMD[@]}"
|
||||
echo "Issue #$ISSUE updated successfully"
|
||||
else
|
||||
echo "No changes specified"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
#!/bin/bash
|
||||
# issue-close.sh - Close an issue on GitHub or Gitea
|
||||
# Usage: issue-close.sh -i <issue_number> [-b <comment>]
|
||||
# (-c/--comment is a backward-compatible alias for -b/--body; R1/R4 2026-08-28)
|
||||
# Usage: issue-close.sh -i <issue_number> [-c <comment>]
|
||||
|
||||
set -e
|
||||
|
||||
@@ -12,71 +11,36 @@ source "$SCRIPT_DIR/detect-platform.sh"
|
||||
# Parse arguments
|
||||
ISSUE_NUMBER=""
|
||||
COMMENT=""
|
||||
BODY_FILE=""
|
||||
|
||||
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
|
||||
# distinct from provider, credential, and verification failures (exit 1), so a
|
||||
# caller or stop gate can tell an invocation defect from a delivery blocker.
|
||||
usage_error() {
|
||||
echo "Error: $*" >&2
|
||||
echo "Usage: issue-close.sh -i <issue_number> [-b <comment>] (see --help)" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-i|--issue|--number)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
-i|--issue)
|
||||
ISSUE_NUMBER="$2"
|
||||
shift 2
|
||||
;;
|
||||
-b|--body|-c|--comment)
|
||||
# R1 (2026-08-28): --body is the canonical flag; -c/--comment stays
|
||||
# a backward-compatible alias.
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
-c|--comment)
|
||||
COMMENT="$2"
|
||||
shift 2
|
||||
;;
|
||||
--body-file)
|
||||
# R3: body from file (or '-' = stdin); mutually exclusive with --body.
|
||||
[[ $# -ge 2 && "$2" != --* ]] || usage_error "option $1 requires a path (or - for stdin)"
|
||||
BODY_FILE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
echo "Usage: issue-close.sh -i <issue_number> [-b <comment>]"
|
||||
echo "Usage: issue-close.sh -i <issue_number> [-c <comment>]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " -n, --number Issue number (required; canonical)"
|
||||
echo " -i, --issue Alias for --number"
|
||||
echo " -b, --body Comment to add before closing (optional; canonical)"
|
||||
echo " -c, --comment Alias for --body"
|
||||
echo " -i, --issue Issue number (required)"
|
||||
echo " -c, --comment Comment to add before closing (optional)"
|
||||
echo " -h, --help Show this help"
|
||||
echo ""
|
||||
echo "Exit codes: 0 success; 2 usage error (stderr); 1 provider/credential/verification failure."
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
usage_error "unknown option: $1"
|
||||
echo "Unknown option: $1"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# R3 (2026-08-29): resolve --body-file into COMMENT (file or stdin '-');
|
||||
# exclusive with an explicit --body/--comment value.
|
||||
if [[ -n "$BODY_FILE" ]]; then
|
||||
[[ -z "$COMMENT" ]] || usage_error "--body-file and --body are mutually exclusive"
|
||||
if [[ "$BODY_FILE" == "-" ]]; then
|
||||
COMMENT=$(cat) || usage_error "could not read body from stdin"
|
||||
else
|
||||
[[ -r "$BODY_FILE" ]] || usage_error "body file not readable: $BODY_FILE"
|
||||
COMMENT=$(cat "$BODY_FILE") || usage_error "could not read body file: $BODY_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
if [[ -z "$ISSUE_NUMBER" ]]; then
|
||||
usage_error "issue number is required (-i/--issue)"
|
||||
echo "Error: Issue number is required (-i)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Detect platform and close issue
|
||||
@@ -118,22 +82,10 @@ gitea_issue_close_api() {
|
||||
}
|
||||
|
||||
if [[ "$PLATFORM" == "github" ]]; then
|
||||
# R4: normalize provider failures to exit 1 (gh's own usage errors exit 2
|
||||
# and would collide with the reserved usage-error status).
|
||||
if [[ -n "$COMMENT" ]]; then
|
||||
gh_rc=0
|
||||
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT" || gh_rc=$?
|
||||
if [[ "$gh_rc" -ne 0 ]]; then
|
||||
echo "Error: GitHub comment before close failed (gh exit $gh_rc)" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
gh_rc=0
|
||||
gh issue close "$ISSUE_NUMBER" || gh_rc=$?
|
||||
if [[ "$gh_rc" -ne 0 ]]; then
|
||||
echo "Error: GitHub issue close failed (gh exit $gh_rc)" >&2
|
||||
exit 1
|
||||
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT"
|
||||
fi
|
||||
gh issue close "$ISSUE_NUMBER"
|
||||
echo "Closed GitHub issue #$ISSUE_NUMBER"
|
||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||
GITEA_LOGIN_NAME=$(get_gitea_login || true)
|
||||
@@ -155,9 +107,7 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
prov_rc=0
|
||||
tea issue close "$ISSUE_NUMBER" --repo "$OWNER/$REPO" --login "$GITEA_LOGIN_NAME" || prov_rc=$?
|
||||
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
|
||||
tea issue close "$ISSUE_NUMBER" --repo "$OWNER/$REPO" --login "$GITEA_LOGIN_NAME"
|
||||
else
|
||||
echo "No tea login configured for $(get_remote_host); using authenticated Gitea API fallback." >&2
|
||||
if [[ -n "$COMMENT" ]]; then
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
#!/bin/bash
|
||||
# issue-comment.sh - Add a comment to an issue on GitHub or Gitea
|
||||
# Usage: issue-comment.sh -i <issue_number> -b <comment> [--login <name>]
|
||||
# (-c/--comment is a backward-compatible alias for -b/--body; R1, 2026-08-28)
|
||||
# Usage: issue-comment.sh -i <issue_number> -c <comment> [--login <name>]
|
||||
#
|
||||
# tea v0.11.1 defines no `comment` subcommand under `tea issue` (or `tea pr`);
|
||||
# the non-existent `tea issue comment ...` form does not error — tea silently
|
||||
@@ -31,84 +30,47 @@ source "$SCRIPT_DIR/detect-platform.sh"
|
||||
# Parse arguments
|
||||
ISSUE_NUMBER=""
|
||||
COMMENT=""
|
||||
BODY_FILE=""
|
||||
LOGIN_OVERRIDE=""
|
||||
|
||||
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
|
||||
# distinct from provider, credential, and verification failures (exit 1), so a
|
||||
# caller or stop gate can tell an invocation defect from a delivery blocker
|
||||
# (CONSTITUTION gate 8 as amended; E2E-DELIVERY).
|
||||
usage_error() {
|
||||
echo "Error: $*" >&2
|
||||
echo "Usage: issue-comment.sh -i <issue_number> -b <comment> [--login <name>] (see --help)" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-i|--issue|--number)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
-i|--issue)
|
||||
ISSUE_NUMBER="$2"
|
||||
shift 2
|
||||
;;
|
||||
-b|--body|-c|--comment)
|
||||
# R1 (2026-08-28): --body is the canonical flag, matching
|
||||
# issue-create/issue-edit/pr-create/pr-edit; -c/--comment stays a
|
||||
# backward-compatible alias.
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
-c|--comment)
|
||||
COMMENT="$2"
|
||||
shift 2
|
||||
;;
|
||||
--body-file)
|
||||
# R3: body from file (or '-' = stdin); mutually exclusive with --body.
|
||||
[[ $# -ge 2 && "$2" != --* ]] || usage_error "option $1 requires a path (or - for stdin)"
|
||||
BODY_FILE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-l|--login)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
LOGIN_OVERRIDE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
echo "Usage: issue-comment.sh -i <issue_number> -b <comment> [--login <name>]"
|
||||
echo "Usage: issue-comment.sh -i <issue_number> -c <comment> [--login <name>]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " -n, --number Issue number (required; canonical)"
|
||||
echo " -i, --issue Alias for --number"
|
||||
echo " -b, --body Comment text (required; canonical)"
|
||||
echo " -c, --comment Alias for --body"
|
||||
echo " -i, --issue Issue number (required)"
|
||||
echo " -c, --comment Comment text (required)"
|
||||
echo " -l, --login Override the detected Gitea tea login for this call"
|
||||
echo " -h, --help Show this help"
|
||||
echo ""
|
||||
echo "Exit codes: 0 success; 2 usage error (stderr); 1 provider/credential/verification failure."
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
usage_error "unknown option: $1"
|
||||
echo "Unknown option: $1"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# R3 (2026-08-29): resolve --body-file into COMMENT (file or stdin '-');
|
||||
# exclusive with an explicit --body/--comment value.
|
||||
if [[ -n "$BODY_FILE" ]]; then
|
||||
[[ -z "$COMMENT" ]] || usage_error "--body-file and --body are mutually exclusive"
|
||||
if [[ "$BODY_FILE" == "-" ]]; then
|
||||
COMMENT=$(cat) || usage_error "could not read body from stdin"
|
||||
else
|
||||
[[ -r "$BODY_FILE" ]] || usage_error "body file not readable: $BODY_FILE"
|
||||
COMMENT=$(cat "$BODY_FILE") || usage_error "could not read body file: $BODY_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
if [[ -z "$ISSUE_NUMBER" ]]; then
|
||||
usage_error "issue number is required (-i/--issue)"
|
||||
echo "Error: Issue number is required (-i)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "$COMMENT" ]]; then
|
||||
usage_error "comment is required (-b/--body, or the -c/--comment alias)"
|
||||
echo "Error: Comment is required (-c)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
detect_platform >/dev/null
|
||||
@@ -378,15 +340,7 @@ PY
|
||||
}
|
||||
|
||||
if [[ "$PLATFORM" == "github" ]]; then
|
||||
# R4 exit-code contract: normalize provider failures to exit 1. gh's own
|
||||
# usage errors exit 2, which would collide with this wrapper's reserved
|
||||
# usage-error status if propagated raw (codex review of 08a00149).
|
||||
gh_rc=0
|
||||
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT" || gh_rc=$?
|
||||
if [[ "$gh_rc" -ne 0 ]]; then
|
||||
echo "Error: GitHub comment write failed (gh exit $gh_rc; provider/credential failure — usage errors are exit 2)" >&2
|
||||
exit 1
|
||||
fi
|
||||
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT"
|
||||
echo "Added comment to GitHub issue #$ISSUE_NUMBER"
|
||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||
# A --login override selects a NAMED tea credential and is the only way to
|
||||
|
||||
@@ -10,7 +10,6 @@ source "$SCRIPT_DIR/detect-platform.sh"
|
||||
# Default values
|
||||
TITLE=""
|
||||
BODY=""
|
||||
BODY_FILE=""
|
||||
LABELS=""
|
||||
MILESTONE=""
|
||||
INTERACTIVE=false
|
||||
@@ -75,45 +74,26 @@ Examples:
|
||||
$(basename "$0") -t "Fix login bug" -l "bug,priority-high"
|
||||
$(basename "$0") -t "Add dark mode" -b "Implement theme switching" -m "0.2.0"
|
||||
$(basename "$0") -i
|
||||
|
||||
Exit codes: 0 success; 2 usage error (stderr); 1 provider/credential failure.
|
||||
EOF
|
||||
exit "${1:-2}"
|
||||
exit "${1:-1}"
|
||||
}
|
||||
|
||||
# Parse arguments
|
||||
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
|
||||
# distinct from provider, credential, and verification failures (exit 1).
|
||||
usage_error() {
|
||||
echo "Error: $*" >&2
|
||||
usage >&2
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-t|--title)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
TITLE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-b|--body)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
BODY="$2"
|
||||
shift 2
|
||||
;;
|
||||
--body-file)
|
||||
# R3: body from file (or '-' = stdin); mutually exclusive with --body.
|
||||
[[ $# -ge 2 && "$2" != --* ]] || usage_error "option $1 requires a path (or - for stdin)"
|
||||
BODY_FILE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-l|--labels)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
LABELS="$2"
|
||||
shift 2
|
||||
;;
|
||||
-m|--milestone)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
MILESTONE="$2"
|
||||
shift 2
|
||||
;;
|
||||
@@ -131,19 +111,6 @@ while [[ $# -gt 0 ]]; do
|
||||
esac
|
||||
done
|
||||
|
||||
# R3 (2026-08-29): resolve --body-file into BODY (file or stdin '-');
|
||||
# exclusive with an explicit --body/--comment value.
|
||||
if [[ -n "$BODY_FILE" ]]; then
|
||||
[[ -z "$BODY" ]] || usage_error "--body-file and --body are mutually exclusive"
|
||||
if [[ "$BODY_FILE" == "-" ]]; then
|
||||
BODY=$(cat) || usage_error "could not read body from stdin"
|
||||
else
|
||||
[[ -r "$BODY_FILE" ]] || usage_error "body file not readable: $BODY_FILE"
|
||||
BODY=$(cat "$BODY_FILE") || usage_error "could not read body file: $BODY_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
if [[ "$INTERACTIVE" == true ]]; then
|
||||
[[ -n "$TITLE" ]] || read -r -p "Issue title: " TITLE
|
||||
[[ -n "$BODY" ]] || read -r -p "Issue body (optional): " BODY || true
|
||||
@@ -164,9 +131,7 @@ case "$PLATFORM" in
|
||||
[[ -n "$BODY" ]] && CMD+=(--body "$BODY")
|
||||
[[ -n "$LABELS" ]] && CMD+=(--label "$LABELS")
|
||||
[[ -n "$MILESTONE" ]] && CMD+=(--milestone "$MILESTONE")
|
||||
prov_rc=0
|
||||
"${CMD[@]}" || prov_rc=$?
|
||||
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
|
||||
"${CMD[@]}"
|
||||
;;
|
||||
gitea)
|
||||
if command -v tea >/dev/null 2>&1; then
|
||||
|
||||
@@ -11,49 +11,28 @@ source "$SCRIPT_DIR/detect-platform.sh"
|
||||
ISSUE_NUMBER=""
|
||||
TITLE=""
|
||||
BODY=""
|
||||
BODY_FILE=""
|
||||
LABELS=""
|
||||
MILESTONE=""
|
||||
|
||||
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
|
||||
# distinct from provider, credential, and verification failures (exit 1), so a
|
||||
# caller or stop gate can tell an invocation defect from a delivery blocker.
|
||||
usage_error() {
|
||||
echo "Error: $*" >&2
|
||||
echo "Usage: issue-edit.sh -i <issue_number> [-t <title>] [-b <body>] [-l <labels>] [-m <milestone>] (see --help)" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-i|--issue|--number)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
-i|--issue)
|
||||
ISSUE_NUMBER="$2"
|
||||
shift 2
|
||||
;;
|
||||
-t|--title)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
TITLE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-b|--body)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
BODY="$2"
|
||||
shift 2
|
||||
;;
|
||||
--body-file)
|
||||
# R3: body from file (or '-' = stdin); mutually exclusive with --body.
|
||||
[[ $# -ge 2 && "$2" != --* ]] || usage_error "option $1 requires a path (or - for stdin)"
|
||||
BODY_FILE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-l|--labels)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
LABELS="$2"
|
||||
shift 2
|
||||
;;
|
||||
-m|--milestone)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
MILESTONE="$2"
|
||||
shift 2
|
||||
;;
|
||||
@@ -61,38 +40,24 @@ while [[ $# -gt 0 ]]; do
|
||||
echo "Usage: issue-edit.sh -i <issue_number> [-t <title>] [-b <body>] [-l <labels>] [-m <milestone>]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " -n, --number Issue number (required; canonical)"
|
||||
echo " -i, --issue Alias for --number"
|
||||
echo " -i, --issue Issue number (required)"
|
||||
echo " -t, --title New title"
|
||||
echo " -b, --body New body/description"
|
||||
echo " -l, --labels Labels (comma-separated, replaces existing)"
|
||||
echo " -m, --milestone Milestone name"
|
||||
echo " -h, --help Show this help"
|
||||
echo ""
|
||||
echo "Exit codes: 0 success; 2 usage error (stderr); 1 provider/credential/verification failure."
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
usage_error "unknown option: $1"
|
||||
echo "Unknown option: $1"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# R3 (2026-08-29): resolve --body-file into BODY (file or stdin '-');
|
||||
# exclusive with an explicit --body/--comment value.
|
||||
if [[ -n "$BODY_FILE" ]]; then
|
||||
[[ -z "$BODY" ]] || usage_error "--body-file and --body are mutually exclusive"
|
||||
if [[ "$BODY_FILE" == "-" ]]; then
|
||||
BODY=$(cat) || usage_error "could not read body from stdin"
|
||||
else
|
||||
[[ -r "$BODY_FILE" ]] || usage_error "body file not readable: $BODY_FILE"
|
||||
BODY=$(cat "$BODY_FILE") || usage_error "could not read body file: $BODY_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
if [[ -z "$ISSUE_NUMBER" ]]; then
|
||||
usage_error "issue number is required (-i/--issue)"
|
||||
echo "Error: Issue number is required (-i)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
detect_platform >/dev/null
|
||||
@@ -103,9 +68,7 @@ if [[ "$PLATFORM" == "github" ]]; then
|
||||
[[ -n "$BODY" ]] && CMD+=(--body "$BODY")
|
||||
[[ -n "$LABELS" ]] && CMD+=(--add-label "$LABELS")
|
||||
[[ -n "$MILESTONE" ]] && CMD+=(--milestone "$MILESTONE")
|
||||
prov_rc=0
|
||||
"${CMD[@]}" || prov_rc=$?
|
||||
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
|
||||
"${CMD[@]}"
|
||||
echo "Updated GitHub issue #$ISSUE_NUMBER"
|
||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||
REPO_SLUG=$(get_repo_slug) || {
|
||||
@@ -121,9 +84,7 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||
[[ -n "$BODY" ]] && CMD+=(--description "$BODY")
|
||||
[[ -n "$LABELS" ]] && CMD+=(--add-labels "$LABELS")
|
||||
[[ -n "$MILESTONE" ]] && CMD+=(--milestone "$MILESTONE")
|
||||
prov_rc=0
|
||||
"${CMD[@]}" || prov_rc=$?
|
||||
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
|
||||
"${CMD[@]}"
|
||||
echo "Updated Gitea issue #$ISSUE_NUMBER"
|
||||
else
|
||||
echo "Error: Unknown platform"
|
||||
|
||||
@@ -36,46 +36,33 @@ Examples:
|
||||
$(basename "$0") -m "0.2.0" # Issues in milestone 0.2.0
|
||||
$(basename "$0") --repo ddk/ai-bma # List issues from anywhere
|
||||
EOF
|
||||
exit "${1:-2}"
|
||||
exit "${1:-1}"
|
||||
}
|
||||
|
||||
# Parse arguments
|
||||
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
|
||||
# distinct from provider, credential, and verification failures (exit 1).
|
||||
usage_error() {
|
||||
echo "Error: $*" >&2
|
||||
usage >&2
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-s|--state)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
STATE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-l|--label|--labels)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
-l|--label)
|
||||
LABEL="$2"
|
||||
shift 2
|
||||
;;
|
||||
-m|--milestone)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
MILESTONE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-a|--assignee)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
ASSIGNEE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-n|--limit)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
LIMIT="$2"
|
||||
shift 2
|
||||
;;
|
||||
-r|--repo)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
REPO_OVERRIDE="$2"
|
||||
shift 2
|
||||
;;
|
||||
@@ -108,9 +95,7 @@ case "$PLATFORM" in
|
||||
[[ -n "$LABEL" ]] && CMD+=(--label "$LABEL")
|
||||
[[ -n "$MILESTONE" ]] && CMD+=(--milestone "$MILESTONE")
|
||||
[[ -n "$ASSIGNEE" ]] && CMD+=(--assignee "$ASSIGNEE")
|
||||
prov_rc=0
|
||||
"${CMD[@]}" || prov_rc=$?
|
||||
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
|
||||
"${CMD[@]}"
|
||||
;;
|
||||
gitea)
|
||||
if [[ -n "$REPO_OVERRIDE" ]]; then
|
||||
@@ -129,9 +114,7 @@ case "$PLATFORM" in
|
||||
[[ -n "$MILESTONE" ]] && CMD+=(--milestones "$MILESTONE")
|
||||
# Note: tea may not support assignee filter directly in all versions.
|
||||
[[ -n "$ASSIGNEE" ]] && echo "Note: Assignee filtering may require manual review for Gitea" >&2
|
||||
prov_rc=0
|
||||
"${CMD[@]}" || prov_rc=$?
|
||||
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
|
||||
"${CMD[@]}"
|
||||
;;
|
||||
*)
|
||||
echo "Error: Could not detect git platform" >&2
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
#!/bin/bash
|
||||
# issue-reopen.sh - Reopen a closed issue on GitHub or Gitea
|
||||
# Usage: issue-reopen.sh -i <issue_number> [-b <comment>]
|
||||
# (-c/--comment is a backward-compatible alias for -b/--body; R1/R4 2026-08-28)
|
||||
# Usage: issue-reopen.sh -i <issue_number> [-c <comment>]
|
||||
|
||||
set -e
|
||||
|
||||
@@ -11,71 +10,36 @@ source "$SCRIPT_DIR/detect-platform.sh"
|
||||
# Parse arguments
|
||||
ISSUE_NUMBER=""
|
||||
COMMENT=""
|
||||
BODY_FILE=""
|
||||
|
||||
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
|
||||
# distinct from provider, credential, and verification failures (exit 1), so a
|
||||
# caller or stop gate can tell an invocation defect from a delivery blocker.
|
||||
usage_error() {
|
||||
echo "Error: $*" >&2
|
||||
echo "Usage: issue-reopen.sh -i <issue_number> [-b <comment>] (see --help)" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-i|--issue|--number)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
-i|--issue)
|
||||
ISSUE_NUMBER="$2"
|
||||
shift 2
|
||||
;;
|
||||
-b|--body|-c|--comment)
|
||||
# R1 (2026-08-28): --body is the canonical flag; -c/--comment stays
|
||||
# a backward-compatible alias.
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
-c|--comment)
|
||||
COMMENT="$2"
|
||||
shift 2
|
||||
;;
|
||||
--body-file)
|
||||
# R3: body from file (or '-' = stdin); mutually exclusive with --body.
|
||||
[[ $# -ge 2 && "$2" != --* ]] || usage_error "option $1 requires a path (or - for stdin)"
|
||||
BODY_FILE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
echo "Usage: issue-reopen.sh -i <issue_number> [-b <comment>]"
|
||||
echo "Usage: issue-reopen.sh -i <issue_number> [-c <comment>]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " -n, --number Issue number (required; canonical)"
|
||||
echo " -i, --issue Alias for --number"
|
||||
echo " -b, --body Comment to add when reopening (optional; canonical)"
|
||||
echo " -c, --comment Alias for --body"
|
||||
echo " -i, --issue Issue number (required)"
|
||||
echo " -c, --comment Comment to add when reopening (optional)"
|
||||
echo " -h, --help Show this help"
|
||||
echo ""
|
||||
echo "Exit codes: 0 success; 2 usage error (stderr); 1 provider/credential/verification failure."
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
usage_error "unknown option: $1"
|
||||
echo "Unknown option: $1"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# R3 (2026-08-29): resolve --body-file into COMMENT (file or stdin '-');
|
||||
# exclusive with an explicit --body/--comment value.
|
||||
if [[ -n "$BODY_FILE" ]]; then
|
||||
[[ -z "$COMMENT" ]] || usage_error "--body-file and --body are mutually exclusive"
|
||||
if [[ "$BODY_FILE" == "-" ]]; then
|
||||
COMMENT=$(cat) || usage_error "could not read body from stdin"
|
||||
else
|
||||
[[ -r "$BODY_FILE" ]] || usage_error "body file not readable: $BODY_FILE"
|
||||
COMMENT=$(cat "$BODY_FILE") || usage_error "could not read body file: $BODY_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
if [[ -z "$ISSUE_NUMBER" ]]; then
|
||||
usage_error "issue number is required (-i/--issue)"
|
||||
echo "Error: Issue number is required (-i)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
detect_platform >/dev/null
|
||||
@@ -116,34 +80,18 @@ gitea_issue_reopen_api() {
|
||||
}
|
||||
|
||||
if [[ "$PLATFORM" == "github" ]]; then
|
||||
# R4: normalize provider failures to exit 1 (gh's own usage errors exit 2
|
||||
# and would collide with the reserved usage-error status).
|
||||
if [[ -n "$COMMENT" ]]; then
|
||||
gh_rc=0
|
||||
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT" || gh_rc=$?
|
||||
if [[ "$gh_rc" -ne 0 ]]; then
|
||||
echo "Error: GitHub comment before reopen failed (gh exit $gh_rc)" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
gh_rc=0
|
||||
gh issue reopen "$ISSUE_NUMBER" || gh_rc=$?
|
||||
if [[ "$gh_rc" -ne 0 ]]; then
|
||||
echo "Error: GitHub issue reopen failed (gh exit $gh_rc)" >&2
|
||||
exit 1
|
||||
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT"
|
||||
fi
|
||||
gh issue reopen "$ISSUE_NUMBER"
|
||||
echo "Reopened GitHub issue #$ISSUE_NUMBER"
|
||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||
REPO_ARGS=$(get_gitea_repo_args || true)
|
||||
if [[ -n "$REPO_ARGS" ]]; then
|
||||
if [[ -n "$COMMENT" ]]; then
|
||||
prov_rc=0
|
||||
tea issue comment "$ISSUE_NUMBER" "$COMMENT" $REPO_ARGS || prov_rc=$?
|
||||
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
|
||||
tea issue comment "$ISSUE_NUMBER" "$COMMENT" $REPO_ARGS
|
||||
fi
|
||||
prov_rc=0
|
||||
tea issue reopen "$ISSUE_NUMBER" $REPO_ARGS || prov_rc=$?
|
||||
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
|
||||
tea issue reopen "$ISSUE_NUMBER" $REPO_ARGS
|
||||
else
|
||||
echo "No tea login configured for $(get_remote_host); using authenticated Gitea API fallback." >&2
|
||||
if [[ -n "$COMMENT" ]]; then
|
||||
|
||||
@@ -8,14 +8,6 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/detect-platform.sh"
|
||||
|
||||
# Parse arguments
|
||||
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
|
||||
# distinct from provider, credential, and verification failures (exit 1).
|
||||
usage_error() {
|
||||
echo "Error: $*" >&2
|
||||
echo "Usage: issue-view.sh -i <issue_number> (see --help)" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
ISSUE_NUMBER=""
|
||||
|
||||
# get_remote_host and get_gitea_token are provided by detect-platform.sh
|
||||
@@ -81,8 +73,7 @@ if comments:
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-i|--issue|--number)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
-i|--issue)
|
||||
ISSUE_NUMBER="$2"
|
||||
shift 2
|
||||
;;
|
||||
@@ -90,29 +81,28 @@ while [[ $# -gt 0 ]]; do
|
||||
echo "Usage: issue-view.sh -i <issue_number>"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " -n, --number Issue number (required; canonical)"
|
||||
echo " -i, --issue Alias for --number"
|
||||
echo " -i, --issue Issue number (required)"
|
||||
echo ""
|
||||
echo "Comments are always included (tea --comments / Gitea API /comments)."
|
||||
echo " -h, --help Show this help"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
usage_error "unknown option: $1"
|
||||
echo "Unknown option: $1"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$ISSUE_NUMBER" ]]; then
|
||||
usage_error "Issue number is required"
|
||||
echo "Error: Issue number is required (-i)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
detect_platform >/dev/null
|
||||
|
||||
if [[ "$PLATFORM" == "github" ]]; then
|
||||
prov_rc=0
|
||||
gh issue view "$ISSUE_NUMBER" || prov_rc=$?
|
||||
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
|
||||
gh issue view "$ISSUE_NUMBER"
|
||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||
if command -v tea >/dev/null 2>&1; then
|
||||
# --comments is what makes tea print the comment bodies (#1357 F3).
|
||||
|
||||
@@ -28,25 +28,18 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/detect-platform.sh"
|
||||
|
||||
REPO="" MILESTONE="" LABEL="" LOGIN="" LIMIT=100
|
||||
# R2 (2026-08-28): long-flag aliases with the same usage-error contract the
|
||||
# wrapper family shares (rc 2, stderr). getopts could not take long flags.
|
||||
usage_error() {
|
||||
echo "Error: $*" >&2
|
||||
echo "Usage: lane-brief.sh -r <owner/repo> [-m milestone] [-l label] [-L login] [-n limit]" >&2
|
||||
exit 2
|
||||
}
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-r|--repo) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; REPO="$2"; shift 2 ;;
|
||||
-m|--milestone) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; MILESTONE="$2"; shift 2 ;;
|
||||
-l|--label|--labels) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; LABEL="$2"; shift 2 ;;
|
||||
-L|--login) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; LOGIN="$2"; shift 2 ;;
|
||||
-n|--limit) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; LIMIT="$2"; shift 2 ;;
|
||||
-h|--help) grep '^#' "$0" | sed 's/^# \?//'; exit 0 ;;
|
||||
*) usage_error "unknown option: $1" ;;
|
||||
while getopts "r:m:l:L:n:h" opt; do
|
||||
case "$opt" in
|
||||
r) REPO="$OPTARG" ;;
|
||||
m) MILESTONE="$OPTARG" ;;
|
||||
l) LABEL="$OPTARG" ;;
|
||||
L) LOGIN="$OPTARG" ;;
|
||||
n) LIMIT="$OPTARG" ;;
|
||||
h) grep '^#' "$0" | sed 's/^# \?//'; exit 0 ;;
|
||||
*) echo "see -h" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
[[ -n "$REPO" ]] || usage_error "-r/--repo <owner/repo> required"
|
||||
[[ -n "$REPO" ]] || { echo "FATAL: -r <owner/repo> required" >&2; exit 2; }
|
||||
|
||||
# Resolve login: explicit -L, then $GITEA_LOGIN, then owner inference, then the
|
||||
# shared default-login resolver. Owner inference comes before the shared fallback
|
||||
@@ -79,7 +72,7 @@ if [[ -z "$LOGIN" ]]; then
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
[[ -n "$LOGIN" ]] || { echo "FATAL: could not resolve a Gitea login for $REPO (pass -L or set GITEA_LOGIN)" >&2; exit 1; }
|
||||
[[ -n "$LOGIN" ]] || { echo "FATAL: could not resolve a Gitea login for $REPO (pass -L or set GITEA_LOGIN)" >&2; exit 2; }
|
||||
|
||||
command -v tea >/dev/null || { echo "FATAL: tea not found" >&2; exit 1; }
|
||||
command -v jq >/dev/null || { echo "FATAL: jq not found" >&2; exit 1; }
|
||||
|
||||
@@ -8,20 +8,11 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/detect-platform.sh"
|
||||
|
||||
# Parse arguments
|
||||
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
|
||||
# distinct from provider, credential, and verification failures (exit 1).
|
||||
usage_error() {
|
||||
echo "Error: $*" >&2
|
||||
echo "Usage: milestone-close.sh -t <title> (see --help)" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
TITLE=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-t|--title)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
TITLE="$2"
|
||||
shift 2
|
||||
;;
|
||||
@@ -34,30 +25,28 @@ while [[ $# -gt 0 ]]; do
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
usage_error "unknown option: $1"
|
||||
echo "Unknown option: $1"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$TITLE" ]]; then
|
||||
usage_error "Milestone title is required"
|
||||
echo "Error: Milestone title is required (-t)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
detect_platform >/dev/null
|
||||
|
||||
if [[ "$PLATFORM" == "github" ]]; then
|
||||
prov_rc=0
|
||||
gh api -X PATCH "/repos/{owner}/{repo}/milestones/$(gh api "/repos/{owner}/{repo}/milestones" --jq ".[] | select(.title==\"$TITLE\") | .number")" -f state=closed || prov_rc=$?
|
||||
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
|
||||
gh api -X PATCH "/repos/{owner}/{repo}/milestones/$(gh api "/repos/{owner}/{repo}/milestones" --jq ".[] | select(.title==\"$TITLE\") | .number")" -f state=closed
|
||||
echo "Closed GitHub milestone: $TITLE"
|
||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||
REPO_ARGS=$(get_gitea_repo_args) || {
|
||||
echo "Error: Could not resolve Gitea repo/login for remote host" >&2
|
||||
exit 1
|
||||
}
|
||||
prov_rc=0
|
||||
tea milestone close "$TITLE" $REPO_ARGS || prov_rc=$?
|
||||
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
|
||||
tea milestone close "$TITLE" $REPO_ARGS
|
||||
echo "Closed Gitea milestone: $TITLE"
|
||||
else
|
||||
echo "Error: Unknown platform"
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
# Usage: milestone-create.sh -t "Title" [-d "Description"] [--due "YYYY-MM-DD"]
|
||||
|
||||
set -e
|
||||
set -o pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/detect-platform.sh"
|
||||
@@ -38,31 +37,21 @@ Examples:
|
||||
$(basename "$0") -t "0.0.1" -d "Pre-MVP Foundation Sprint"
|
||||
$(basename "$0") -t "0.1.0" -d "MVP Release" --due "2025-03-01"
|
||||
EOF
|
||||
exit "${1:-2}"
|
||||
exit "${1:-1}"
|
||||
}
|
||||
|
||||
# Parse arguments
|
||||
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
|
||||
# distinct from provider, credential, and verification failures (exit 1).
|
||||
usage_error() {
|
||||
echo "Error: $*" >&2
|
||||
usage >&2
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-t|--title)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
TITLE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-d|--desc)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
DESCRIPTION="$2"
|
||||
shift 2
|
||||
;;
|
||||
--due)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
DUE_DATE="$2"
|
||||
shift 2
|
||||
;;
|
||||
@@ -85,18 +74,14 @@ PLATFORM=$(detect_platform)
|
||||
if [[ "$LIST_ONLY" == true ]]; then
|
||||
case "$PLATFORM" in
|
||||
github)
|
||||
prov_rc=0
|
||||
gh api repos/:owner/:repo/milestones --jq '.[] | "\(.number)\t\(.title)\t\(.state)\t\(.open_issues)/\(.closed_issues) issues"' || prov_rc=$?
|
||||
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
|
||||
gh api repos/:owner/:repo/milestones --jq '.[] | "\(.number)\t\(.title)\t\(.state)\t\(.open_issues)/\(.closed_issues) issues"'
|
||||
;;
|
||||
gitea)
|
||||
REPO_ARGS=$(get_gitea_repo_args) || {
|
||||
echo "Error: Could not resolve Gitea repo/login for remote host" >&2
|
||||
exit 1
|
||||
}
|
||||
prov_rc=0
|
||||
tea milestones list $REPO_ARGS || prov_rc=$?
|
||||
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
|
||||
tea milestones list $REPO_ARGS
|
||||
;;
|
||||
*)
|
||||
echo "Error: Could not detect git platform" >&2
|
||||
@@ -107,7 +92,8 @@ if [[ "$LIST_ONLY" == true ]]; then
|
||||
fi
|
||||
|
||||
if [[ -z "$TITLE" ]]; then
|
||||
usage_error "Title is required (-t) for creating milestones"
|
||||
echo "Error: Title is required (-t) for creating milestones" >&2
|
||||
usage
|
||||
fi
|
||||
|
||||
case "$PLATFORM" in
|
||||
@@ -123,9 +109,7 @@ case "$PLATFORM" in
|
||||
+ (if $d != "" then {"description": $d} else {} end)
|
||||
+ (if $due != "" then {"due_on": ($due + "T00:00:00Z")} else {} end)')
|
||||
|
||||
prov_rc=0
|
||||
gh api repos/:owner/:repo/milestones --method POST --input - <<< "$JSON_PAYLOAD" || prov_rc=$?
|
||||
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
|
||||
gh api repos/:owner/:repo/milestones --method POST --input - <<< "$JSON_PAYLOAD"
|
||||
echo "Milestone '$TITLE' created successfully"
|
||||
;;
|
||||
gitea)
|
||||
@@ -136,9 +120,7 @@ case "$PLATFORM" in
|
||||
CMD=(tea milestones create --title "$TITLE")
|
||||
[[ -n "$DESCRIPTION" ]] && CMD+=(--description "$DESCRIPTION")
|
||||
[[ -n "$DUE_DATE" ]] && CMD+=(--deadline "$DUE_DATE")
|
||||
prov_rc=0
|
||||
"${CMD[@]}" $REPO_ARGS || prov_rc=$?
|
||||
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
|
||||
"${CMD[@]}" $REPO_ARGS
|
||||
echo "Milestone '$TITLE' created successfully"
|
||||
;;
|
||||
*)
|
||||
|
||||
@@ -8,20 +8,11 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/detect-platform.sh"
|
||||
|
||||
# Parse arguments
|
||||
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
|
||||
# distinct from provider, credential, and verification failures (exit 1).
|
||||
usage_error() {
|
||||
echo "Error: $*" >&2
|
||||
echo "Usage: milestone-list.sh [-s <state>] (see --help)" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
STATE="open"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-s|--state)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
STATE="$2"
|
||||
shift 2
|
||||
;;
|
||||
@@ -34,7 +25,8 @@ while [[ $# -gt 0 ]]; do
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
usage_error "unknown option: $1"
|
||||
echo "Unknown option: $1"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
@@ -42,17 +34,13 @@ done
|
||||
detect_platform >/dev/null
|
||||
|
||||
if [[ "$PLATFORM" == "github" ]]; then
|
||||
prov_rc=0
|
||||
gh api "/repos/{owner}/{repo}/milestones?state=$STATE" --jq '.[] | "\(.title) (\(.state)) - \(.open_issues) open, \(.closed_issues) closed"' || prov_rc=$?
|
||||
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
|
||||
gh api "/repos/{owner}/{repo}/milestones?state=$STATE" --jq '.[] | "\(.title) (\(.state)) - \(.open_issues) open, \(.closed_issues) closed"'
|
||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||
REPO_ARGS=$(get_gitea_repo_args) || {
|
||||
echo "Error: Could not resolve Gitea repo/login for remote host" >&2
|
||||
exit 1
|
||||
}
|
||||
prov_rc=0
|
||||
tea milestone list $REPO_ARGS || prov_rc=$?
|
||||
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
|
||||
tea milestone list $REPO_ARGS
|
||||
else
|
||||
echo "Error: Unknown platform"
|
||||
exit 1
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
#!/bin/bash
|
||||
# pr-close.sh - Close a pull request without merging on GitHub or Gitea
|
||||
# Usage: pr-close.sh -n <pr_number> [-b <comment>]
|
||||
# (-c/--comment is a backward-compatible alias for -b/--body; R1/R4 2026-08-28)
|
||||
# Usage: pr-close.sh -n <pr_number> [-c <comment>]
|
||||
|
||||
set -e
|
||||
|
||||
@@ -11,101 +10,51 @@ source "$SCRIPT_DIR/detect-platform.sh"
|
||||
# Parse arguments
|
||||
PR_NUMBER=""
|
||||
COMMENT=""
|
||||
BODY_FILE=""
|
||||
|
||||
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
|
||||
# distinct from provider, credential, and verification failures (exit 1), so a
|
||||
# caller or stop gate can tell an invocation defect from a delivery blocker.
|
||||
usage_error() {
|
||||
echo "Error: $*" >&2
|
||||
echo "Usage: pr-close.sh -n <pr_number> [-b <comment>] (see --help)" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-n|--number)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
PR_NUMBER="$2"
|
||||
shift 2
|
||||
;;
|
||||
-b|--body|-c|--comment)
|
||||
# R1 (2026-08-28): --body is the canonical flag; -c/--comment stays
|
||||
# a backward-compatible alias.
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
-c|--comment)
|
||||
COMMENT="$2"
|
||||
shift 2
|
||||
;;
|
||||
--body-file)
|
||||
# R3: body from file (or '-' = stdin); mutually exclusive with --body.
|
||||
[[ $# -ge 2 && "$2" != --* ]] || usage_error "option $1 requires a path (or - for stdin)"
|
||||
BODY_FILE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
echo "Usage: pr-close.sh -n <pr_number> [-b <comment>]"
|
||||
echo "Usage: pr-close.sh -n <pr_number> [-c <comment>]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " -n, --number PR number (required)"
|
||||
echo " -b, --body Comment before closing (optional; canonical)"
|
||||
echo " -c, --comment Alias for --body"
|
||||
echo " -c, --comment Comment before closing (optional)"
|
||||
echo " -h, --help Show this help"
|
||||
echo ""
|
||||
echo "Exit codes: 0 success; 2 usage error (stderr); 1 provider/credential/verification failure."
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
usage_error "unknown option: $1"
|
||||
echo "Unknown option: $1"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# R3 (2026-08-29): resolve --body-file into COMMENT (file or stdin '-');
|
||||
# exclusive with an explicit --body/--comment value.
|
||||
if [[ -n "$BODY_FILE" ]]; then
|
||||
[[ -z "$COMMENT" ]] || usage_error "--body-file and --body are mutually exclusive"
|
||||
if [[ "$BODY_FILE" == "-" ]]; then
|
||||
COMMENT=$(cat) || usage_error "could not read body from stdin"
|
||||
else
|
||||
[[ -r "$BODY_FILE" ]] || usage_error "body file not readable: $BODY_FILE"
|
||||
COMMENT=$(cat "$BODY_FILE") || usage_error "could not read body file: $BODY_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
if [[ -z "$PR_NUMBER" ]]; then
|
||||
usage_error "PR number is required (-n/--number)"
|
||||
echo "Error: PR number is required (-n)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
detect_platform >/dev/null
|
||||
|
||||
if [[ "$PLATFORM" == "github" ]]; then
|
||||
# R4: normalize provider failures to exit 1 (gh's own usage errors exit 2
|
||||
# and would collide with the reserved usage-error status).
|
||||
if [[ -n "$COMMENT" ]]; then
|
||||
gh_rc=0
|
||||
gh pr comment "$PR_NUMBER" --body "$COMMENT" || gh_rc=$?
|
||||
if [[ "$gh_rc" -ne 0 ]]; then
|
||||
echo "Error: GitHub PR comment before close failed (gh exit $gh_rc)" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
gh_rc=0
|
||||
gh pr close "$PR_NUMBER" || gh_rc=$?
|
||||
if [[ "$gh_rc" -ne 0 ]]; then
|
||||
echo "Error: GitHub PR close failed (gh exit $gh_rc)" >&2
|
||||
exit 1
|
||||
gh pr comment "$PR_NUMBER" --body "$COMMENT"
|
||||
fi
|
||||
gh pr close "$PR_NUMBER"
|
||||
echo "Closed GitHub PR #$PR_NUMBER"
|
||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||
if [[ -n "$COMMENT" ]]; then
|
||||
prov_rc=0
|
||||
tea pr comment "$PR_NUMBER" "$COMMENT" $(get_gitea_repo_args) || prov_rc=$?
|
||||
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
|
||||
tea pr comment "$PR_NUMBER" "$COMMENT" $(get_gitea_repo_args)
|
||||
fi
|
||||
prov_rc=0
|
||||
tea pr close "$PR_NUMBER" $(get_gitea_repo_args) || prov_rc=$?
|
||||
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
|
||||
tea pr close "$PR_NUMBER" $(get_gitea_repo_args)
|
||||
echo "Closed Gitea PR #$PR_NUMBER"
|
||||
else
|
||||
echo "Error: Unknown platform"
|
||||
|
||||
@@ -10,7 +10,6 @@ source "$SCRIPT_DIR/detect-platform.sh"
|
||||
# Default values
|
||||
TITLE=""
|
||||
BODY=""
|
||||
BODY_FILE=""
|
||||
BASE_BRANCH=""
|
||||
HEAD_BRANCH=""
|
||||
LABELS=""
|
||||
@@ -136,57 +135,37 @@ Examples:
|
||||
$(basename "$0") -i 42 -b "Implements the feature described in #42"
|
||||
$(basename "$0") -t "WIP: New feature" --draft
|
||||
EOF
|
||||
exit "${1:-2}"
|
||||
}
|
||||
|
||||
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
|
||||
# distinct from provider, credential, and verification failures (exit 1).
|
||||
usage_error() {
|
||||
echo "Error: $*" >&2
|
||||
usage >&2
|
||||
exit "${1:-1}"
|
||||
}
|
||||
|
||||
# Parse arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-t|--title)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
TITLE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-b|--body)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
BODY="$2"
|
||||
shift 2
|
||||
;;
|
||||
--body-file)
|
||||
# R3: body from file (or '-' = stdin); mutually exclusive with --body.
|
||||
[[ $# -ge 2 && "$2" != --* ]] || usage_error "option $1 requires a path (or - for stdin)"
|
||||
BODY_FILE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-B|--base)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
BASE_BRANCH="$2"
|
||||
shift 2
|
||||
;;
|
||||
-H|--head)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
HEAD_BRANCH="$2"
|
||||
shift 2
|
||||
;;
|
||||
-l|--labels)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
LABELS="$2"
|
||||
shift 2
|
||||
;;
|
||||
-m|--milestone)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
MILESTONE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-i|--issue)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
ISSUE="$2"
|
||||
shift 2
|
||||
;;
|
||||
@@ -204,19 +183,6 @@ while [[ $# -gt 0 ]]; do
|
||||
esac
|
||||
done
|
||||
|
||||
# R3 (2026-08-29): resolve --body-file into BODY (file or stdin '-');
|
||||
# exclusive with an explicit --body/--comment value.
|
||||
if [[ -n "$BODY_FILE" ]]; then
|
||||
[[ -z "$BODY" ]] || usage_error "--body-file and --body are mutually exclusive"
|
||||
if [[ "$BODY_FILE" == "-" ]]; then
|
||||
BODY=$(cat) || usage_error "could not read body from stdin"
|
||||
else
|
||||
[[ -r "$BODY_FILE" ]] || usage_error "body file not readable: $BODY_FILE"
|
||||
BODY=$(cat "$BODY_FILE") || usage_error "could not read body file: $BODY_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
# If no title but issue provided, generate title
|
||||
if [[ -z "$TITLE" ]] && [[ -n "$ISSUE" ]]; then
|
||||
TITLE="Fixes #$ISSUE"
|
||||
@@ -300,9 +266,7 @@ case "$PLATFORM" in
|
||||
[[ -n "$LABELS" ]] && CMD+=(--label "$LABELS")
|
||||
[[ -n "$MILESTONE" ]] && CMD+=(--milestone "$MILESTONE")
|
||||
[[ "$DRAFT" == true ]] && CMD+=(--draft)
|
||||
prov_rc=0
|
||||
"${CMD[@]}" || prov_rc=$?
|
||||
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
|
||||
"${CMD[@]}"
|
||||
;;
|
||||
gitea)
|
||||
# tea pull create syntax. Always pass --repo because tea repo inference
|
||||
|
||||
@@ -13,33 +13,21 @@ OUTPUT_FILE=""
|
||||
REPO_OVERRIDE=""
|
||||
HOST_OVERRIDE=""
|
||||
|
||||
# Usage-error contract (R4): usage errors print to STDERR and exit 2,
|
||||
# distinct from provider, credential, and verification failures (exit 1).
|
||||
usage_error() {
|
||||
echo "Error: $*" >&2
|
||||
echo "Usage: pr-diff.sh -n <pr_number> [-r owner/repo] [--host host] [-o <output_file>] (see --help)" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-n|--number)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
PR_NUMBER="$2"
|
||||
shift 2
|
||||
;;
|
||||
-o|--output)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
OUTPUT_FILE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-r|--repo)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
REPO_OVERRIDE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--host)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
HOST_OVERRIDE="$2"
|
||||
shift 2
|
||||
;;
|
||||
@@ -52,18 +40,18 @@ while [[ $# -gt 0 ]]; do
|
||||
echo " --host Gitea host for --repo API calls (or set GITEA_HOST/GITEA_URL)"
|
||||
echo " -o, --output Output file (optional, prints to stdout if omitted)"
|
||||
echo " -h, --help Show this help"
|
||||
echo ""
|
||||
echo "Exit codes: 0 success; 2 usage error (stderr); 1 provider/credential failure."
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
usage_error "unknown option: $1"
|
||||
echo "Unknown option: $1"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$PR_NUMBER" ]]; then
|
||||
usage_error "PR number is required (-n/--number)"
|
||||
echo "Error: PR number is required (-n)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -n "$REPO_OVERRIDE" ]]; then
|
||||
|
||||
@@ -11,7 +11,6 @@ source "$SCRIPT_DIR/detect-platform.sh"
|
||||
PR_NUMBER=""
|
||||
TITLE=""
|
||||
BODY=""
|
||||
BODY_FILE=""
|
||||
BASE_BRANCH=""
|
||||
DRAFT_MODE=""
|
||||
LOGIN_OVERRIDE=""
|
||||
@@ -51,59 +50,38 @@ Options:
|
||||
-H, --host HOST Explicit Gitea host (required with --repo off-host)
|
||||
-h, --help Show this help message
|
||||
EOF
|
||||
exit "${1:-2}"
|
||||
}
|
||||
|
||||
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
|
||||
# distinct from provider, credential, and verification failures (exit 1).
|
||||
usage_error() {
|
||||
echo "Error: $*" >&2
|
||||
usage >&2
|
||||
exit "${1:-1}"
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-n|--number) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; PR_NUMBER="${2:-}"; shift 2 ;;
|
||||
-t|--title) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; TITLE="${2:-}"; shift 2 ;;
|
||||
-b|--body) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; BODY="${2:-}"; shift 2 ;;
|
||||
--body-file) [[ $# -ge 2 && "$2" != --* ]] || usage_error "option $1 requires a path (or - for stdin)"; BODY_FILE="$2"; shift 2 ;;
|
||||
-B|--base) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; BASE_BRANCH="${2:-}"; shift 2 ;;
|
||||
-n|--number) PR_NUMBER="${2:-}"; shift 2 ;;
|
||||
-t|--title) TITLE="${2:-}"; shift 2 ;;
|
||||
-b|--body) BODY="${2:-}"; shift 2 ;;
|
||||
-B|--base) BASE_BRANCH="${2:-}"; shift 2 ;;
|
||||
--draft)
|
||||
[[ "$DRAFT_MODE" != "ready" ]] || { echo "Error: --draft and --ready are mutually exclusive" >&2; exit 2; }
|
||||
[[ "$DRAFT_MODE" != "ready" ]] || { echo "Error: --draft and --ready are mutually exclusive" >&2; exit 1; }
|
||||
DRAFT_MODE="draft"; shift ;;
|
||||
--ready)
|
||||
[[ "$DRAFT_MODE" != "draft" ]] || { echo "Error: --draft and --ready are mutually exclusive" >&2; exit 2; }
|
||||
[[ "$DRAFT_MODE" != "draft" ]] || { echo "Error: --draft and --ready are mutually exclusive" >&2; exit 1; }
|
||||
DRAFT_MODE="ready"; shift ;;
|
||||
-l|--login) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; LOGIN_OVERRIDE="${2:-}"; shift 2 ;;
|
||||
-r|--repo) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; REPO_OVERRIDE="${2:-}"; shift 2 ;;
|
||||
-H|--host) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; HOST_OVERRIDE="${2:-}"; shift 2 ;;
|
||||
-l|--login) LOGIN_OVERRIDE="${2:-}"; shift 2 ;;
|
||||
-r|--repo) REPO_OVERRIDE="${2:-}"; shift 2 ;;
|
||||
-H|--host) HOST_OVERRIDE="${2:-}"; shift 2 ;;
|
||||
-h|--help) usage 0 ;;
|
||||
*) echo "Unknown option: $1" >&2; usage ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# R3 (2026-08-29): resolve --body-file into BODY (file or stdin '-');
|
||||
# exclusive with an explicit --body value.
|
||||
if [[ -n "$BODY_FILE" ]]; then
|
||||
[[ -z "$BODY" ]] || usage_error "--body-file and --body are mutually exclusive"
|
||||
if [[ "$BODY_FILE" == "-" ]]; then
|
||||
BODY=$(cat) || usage_error "could not read body from stdin"
|
||||
else
|
||||
[[ -r "$BODY_FILE" ]] || usage_error "body file not readable: $BODY_FILE"
|
||||
BODY=$(cat "$BODY_FILE") || usage_error "could not read body file: $BODY_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
[[ -n "$PR_NUMBER" ]] || { echo "Error: Pull request number is required (-n)" >&2; exit 2; }
|
||||
[[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]] || { echo "Error: Pull request number must be a positive integer" >&2; exit 2; }
|
||||
[[ -n "$PR_NUMBER" ]] || { echo "Error: Pull request number is required (-n)" >&2; exit 1; }
|
||||
[[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]] || { echo "Error: Pull request number must be a positive integer" >&2; exit 1; }
|
||||
if [[ -z "$TITLE" && -z "$BODY" && -z "$BASE_BRANCH" && -z "$DRAFT_MODE" ]]; then
|
||||
echo "Error: At least one edit option is required" >&2
|
||||
exit 2
|
||||
exit 1
|
||||
fi
|
||||
[[ -z "$REPO_OVERRIDE" || "$REPO_OVERRIDE" =~ ^[^/[:space:]]+/[^/[:space:]]+$ ]] || {
|
||||
echo "Error: --repo must be OWNER/REPO" >&2
|
||||
exit 2
|
||||
exit 1
|
||||
}
|
||||
|
||||
if [[ -n "$HOST_OVERRIDE" || -n "$REPO_OVERRIDE" ]]; then
|
||||
@@ -114,24 +92,18 @@ fi
|
||||
|
||||
case "$PLATFORM" in
|
||||
github)
|
||||
[[ -z "$LOGIN_OVERRIDE" ]] || { echo "Error: --login is only valid for Gitea" >&2; exit 2; }
|
||||
[[ -z "$LOGIN_OVERRIDE" ]] || { echo "Error: --login is only valid for Gitea" >&2; exit 1; }
|
||||
if [[ -n "$TITLE" || -n "$BODY" || -n "$BASE_BRANCH" ]]; then
|
||||
CMD=(gh pr edit "$PR_NUMBER")
|
||||
[[ -n "$TITLE" ]] && CMD+=(--title "$TITLE")
|
||||
[[ -n "$BODY" ]] && CMD+=(--body "$BODY")
|
||||
[[ -n "$BASE_BRANCH" ]] && CMD+=(--base "$BASE_BRANCH")
|
||||
prov_rc=0
|
||||
"${CMD[@]}" || prov_rc=$?
|
||||
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
|
||||
"${CMD[@]}"
|
||||
fi
|
||||
if [[ "$DRAFT_MODE" == "draft" ]]; then
|
||||
prov_rc=0
|
||||
gh pr ready "$PR_NUMBER" --undo || prov_rc=$?
|
||||
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
|
||||
gh pr ready "$PR_NUMBER" --undo
|
||||
elif [[ "$DRAFT_MODE" == "ready" ]]; then
|
||||
prov_rc=0
|
||||
gh pr ready "$PR_NUMBER" || prov_rc=$?
|
||||
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
|
||||
gh pr ready "$PR_NUMBER"
|
||||
fi
|
||||
;;
|
||||
gitea)
|
||||
|
||||
@@ -34,41 +34,29 @@ Examples:
|
||||
$(basename "$0") -s merged -a username # Merged PRs by user
|
||||
$(basename "$0") --repo ddk/ai-bma # List PRs from anywhere
|
||||
EOF
|
||||
exit "${1:-2}"
|
||||
exit "${1:-1}"
|
||||
}
|
||||
|
||||
# Parse arguments
|
||||
# Usage-error contract (R4): usage errors print to STDERR and exit 2,
|
||||
# distinct from provider, credential, and verification failures (exit 1).
|
||||
usage_error() {
|
||||
echo "Error: $*" >&2
|
||||
usage >&2
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-s|--state)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
STATE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-l|--label|--labels)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
-l|--label)
|
||||
LABEL="$2"
|
||||
shift 2
|
||||
;;
|
||||
-a|--author)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
AUTHOR="$2"
|
||||
shift 2
|
||||
;;
|
||||
-n|--limit)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
LIMIT="$2"
|
||||
shift 2
|
||||
;;
|
||||
-r|--repo)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
REPO_OVERRIDE="$2"
|
||||
shift 2
|
||||
;;
|
||||
@@ -76,7 +64,8 @@ while [[ $# -gt 0 ]]; do
|
||||
usage 0
|
||||
;;
|
||||
*)
|
||||
usage_error "unknown option: $1"
|
||||
echo "Unknown option: $1" >&2
|
||||
usage
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/bin/bash
|
||||
# pr-merge.sh - Merge pull requests on Gitea or GitHub
|
||||
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--expect-head SHA] [--no-ci-expected] [--base-line BRANCH] [--co-author-trailers --escalate-to PRINCIPAL]
|
||||
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--expect-head SHA] [--no-ci-expected] [--co-author-trailers --escalate-to PRINCIPAL]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -30,12 +30,6 @@ Options:
|
||||
-d, --delete-branch Delete the head branch after merge
|
||||
--dry-run Run metadata/login preflight without merging
|
||||
--expect-head SHA Refuse unless the PR head matches this full commit SHA
|
||||
--base-line BRANCH Documented intra-line exception (B5, ruled
|
||||
2026-08-29): authorize a merge whose base is
|
||||
neither main nor next (stacked PR lines). The
|
||||
value must MATCH the PR base; all other gates
|
||||
(queue guard, head pin, CI) still run and the
|
||||
exception is recorded in the merge audit.
|
||||
--no-ci-expected Assert the target repository has no CI: forward --no-ci-expected to the queue guard (requires repository admin)
|
||||
--co-author-trailers Build verified trailers from linked PR commit authors
|
||||
--escalate-to NAME Named principal for an unresolved-author BLOCK
|
||||
@@ -52,7 +46,6 @@ EOF
|
||||
}
|
||||
|
||||
# Parse arguments
|
||||
BASE_LINE_OVERRIDE=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-n|--number)
|
||||
@@ -71,11 +64,6 @@ while [[ $# -gt 0 ]]; do
|
||||
DRY_RUN=true
|
||||
shift
|
||||
;;
|
||||
--base-line)
|
||||
[[ $# -ge 2 ]] || { echo "Error: --base-line requires a branch name." >&2; exit 1; }
|
||||
BASE_LINE_OVERRIDE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--expect-head)
|
||||
if [[ $# -lt 2 ]]; then
|
||||
echo "Error: --expect-head requires one full commit SHA." >&2
|
||||
@@ -184,17 +172,8 @@ if [[ "$DECL_STATE" == valid && "$DECL_SCHEMA" == 2 ]]; then
|
||||
else
|
||||
repo_decl_warn_absent_irreversible "pr-merge"
|
||||
if [[ "$BASE_BRANCH" != "main" && "$BASE_BRANCH" != "next" ]]; then
|
||||
if [[ -n "$BASE_LINE_OVERRIDE" && "$BASE_LINE_OVERRIDE" != "$BASE_BRANCH" ]]; then
|
||||
echo "Error: --base-line '$BASE_LINE_OVERRIDE' does not match the PR base '$BASE_BRANCH' (refusing; the exception must name the real base)." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$BASE_LINE_OVERRIDE" == "$BASE_BRANCH" ]]; then
|
||||
echo "audit: base-line exception — merge into '$BASE_BRANCH' authorized by explicit --base-line (B5 ruling 2026-08-29); queue guard, head pin, and CI gates unchanged." >&2
|
||||
else
|
||||
echo "Error: Mosaic policy allows merges only for PRs targeting 'main' or 'next' (found '$BASE_BRANCH')." >&2
|
||||
echo " A ruled intra-line merge may pass --base-line '$BASE_BRANCH' (same gates; the exception is recorded)." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Error: Mosaic policy allows merges only for PRs targeting 'main' or 'next' (found '$BASE_BRANCH')." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
if [[ -z "$HEAD_BRANCH" || -z "$HEAD_REPO" || ! "$HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then
|
||||
|
||||
@@ -12,23 +12,13 @@ source "$SCRIPT_DIR/detect-platform.sh"
|
||||
PR_NUMBER=""
|
||||
OUTPUT_FILE=""
|
||||
|
||||
# Usage-error contract (R4): usage errors print to STDERR and exit 2,
|
||||
# distinct from provider, credential, and verification failures (exit 1).
|
||||
usage_error() {
|
||||
echo "Error: $*" >&2
|
||||
echo "Usage: pr-metadata.sh -n <pr_number> [-o <output_file>] (see --help)" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-n|--number)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
PR_NUMBER="$2"
|
||||
shift 2
|
||||
;;
|
||||
-o|--output)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
OUTPUT_FILE="$2"
|
||||
shift 2
|
||||
;;
|
||||
@@ -39,18 +29,18 @@ while [[ $# -gt 0 ]]; do
|
||||
echo " -n, --number PR number (required)"
|
||||
echo " -o, --output Output file (optional, prints to stdout if omitted)"
|
||||
echo " -h, --help Show this help"
|
||||
echo ""
|
||||
echo "Exit codes: 0 success; 2 usage error (stderr); 1 provider/credential failure."
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
usage_error "unknown option: $1"
|
||||
echo "Unknown option: $1" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$PR_NUMBER" ]]; then
|
||||
usage_error "PR number is required (-n/--number)"
|
||||
echo "Error: PR number is required (-n)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
write_metadata() {
|
||||
|
||||
@@ -39,116 +39,64 @@ source "$SCRIPT_DIR/detect-platform.sh"
|
||||
PR_NUMBER=""
|
||||
ACTION=""
|
||||
COMMENT=""
|
||||
BODY_FILE=""
|
||||
LOGIN_OVERRIDE=""
|
||||
REPO_OVERRIDE=""
|
||||
HOST_OVERRIDE=""
|
||||
|
||||
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
|
||||
# distinct from provider, credential, and verification failures (exit 1), so a
|
||||
# caller or stop gate can tell an invocation defect from a delivery blocker.
|
||||
usage_error() {
|
||||
echo "Error: $*" >&2
|
||||
echo "Usage: pr-review.sh -n <pr_number> -a <action> [-b <comment>] (see --help)" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-n|--number)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
PR_NUMBER="$2"
|
||||
shift 2
|
||||
;;
|
||||
-a|--action)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
ACTION="$2"
|
||||
shift 2
|
||||
;;
|
||||
-b|--body|-c|--comment)
|
||||
# R1 (2026-08-28): --body is the canonical flag; -c/--comment stays
|
||||
# a backward-compatible alias.
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
-c|--comment)
|
||||
COMMENT="$2"
|
||||
shift 2
|
||||
;;
|
||||
--body-file)
|
||||
# R3: body from file (or '-' = stdin); mutually exclusive with --body.
|
||||
[[ $# -ge 2 && "$2" != --* ]] || usage_error "option $1 requires a path (or - for stdin)"
|
||||
BODY_FILE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-l|--login)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
LOGIN_OVERRIDE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-r|--repo)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
REPO_OVERRIDE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-H|--host)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
HOST_OVERRIDE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
echo "Usage: pr-review.sh -n <pr_number> -a <action> [-b <comment>] [--login <name>] [-r owner/repo] [-H host]"
|
||||
echo "Usage: pr-review.sh -n <pr_number> -a <action> [-c <comment>] [--login <name>] [-r owner/repo] [-H host]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " -n, --number PR number (required)"
|
||||
echo " -a, --action Review action: approve, request-changes, comment (required)"
|
||||
echo " -b, --body Review comment (required for request-changes; canonical)"
|
||||
echo " -c, --comment Alias for --body"
|
||||
echo " -c, --comment Review comment (required for request-changes)"
|
||||
echo " -l, --login Override the detected Gitea tea login (approve/request-changes only)"
|
||||
echo " -r, --repo Explicit owner/repo slug (skips git-remote slug inference)"
|
||||
echo " -H, --host Explicit Gitea host (skips remote-host inference)"
|
||||
echo " -h, --help Show this help"
|
||||
echo ""
|
||||
echo "Exit codes: 0 success; 2 usage error (stderr); 1 provider/credential/verification failure."
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
usage_error "unknown option: $1"
|
||||
echo "Unknown option: $1"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# R3 (2026-08-29): resolve --body-file into COMMENT (file or stdin '-');
|
||||
# exclusive with an explicit --body/--comment value.
|
||||
if [[ -n "$BODY_FILE" ]]; then
|
||||
[[ -z "$COMMENT" ]] || usage_error "--body-file and --body are mutually exclusive"
|
||||
if [[ "$BODY_FILE" == "-" ]]; then
|
||||
COMMENT=$(cat) || usage_error "could not read body from stdin"
|
||||
else
|
||||
[[ -r "$BODY_FILE" ]] || usage_error "body file not readable: $BODY_FILE"
|
||||
COMMENT=$(cat "$BODY_FILE") || usage_error "could not read body file: $BODY_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
if [[ -z "$PR_NUMBER" ]]; then
|
||||
usage_error "PR number is required (-n/--number)"
|
||||
echo "Error: PR number is required (-n)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "$ACTION" ]]; then
|
||||
usage_error "Action is required (-a/--action): approve, request-changes, comment"
|
||||
fi
|
||||
|
||||
# Validate the action BEFORE any provider contact (codex review of PR #1464:
|
||||
# an unsupported --action previously reached platform detection and could
|
||||
# touch the provider before failing with a provider-class status).
|
||||
case "$ACTION" in
|
||||
approve|request-changes|comment) ;;
|
||||
*) usage_error "unknown action '$ACTION': approve, request-changes, comment" ;;
|
||||
esac
|
||||
|
||||
# Body-required actions fail fast too (codex follow-up on PR #1464):
|
||||
# request-changes and comment both require a body; validate before any
|
||||
# provider contact.
|
||||
if [[ ( "$ACTION" == "request-changes" || "$ACTION" == "comment" ) && -z "$COMMENT" ]]; then
|
||||
usage_error "comment required for $ACTION (-b/--body)"
|
||||
echo "Error: Action is required (-a): approve, request-changes, comment"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -n "$REPO_OVERRIDE" ]]; then
|
||||
@@ -731,18 +679,15 @@ PY
|
||||
if [[ "$PLATFORM" == "github" ]]; then
|
||||
case $ACTION in
|
||||
approve)
|
||||
gh_rc=0
|
||||
gh pr review "$PR_NUMBER" --approve ${COMMENT:+--body "$COMMENT"} || gh_rc=$?
|
||||
[[ "$gh_rc" -eq 0 ]] || { echo "Error: GitHub approve failed (gh exit $gh_rc; provider failure, not a usage error)" >&2; exit 1; }
|
||||
gh pr review "$PR_NUMBER" --approve ${COMMENT:+--body "$COMMENT"}
|
||||
echo "Approved GitHub PR #$PR_NUMBER"
|
||||
;;
|
||||
request-changes)
|
||||
if [[ -z "$COMMENT" ]]; then
|
||||
usage_error "comment required for request-changes (-b/--body)"
|
||||
echo "Error: Comment required for request-changes"
|
||||
exit 1
|
||||
fi
|
||||
gh_rc=0
|
||||
gh pr review "$PR_NUMBER" --request-changes --body "$COMMENT" || gh_rc=$?
|
||||
[[ "$gh_rc" -eq 0 ]] || { echo "Error: GitHub request-changes failed (gh exit $gh_rc; provider failure, not a usage error)" >&2; exit 1; }
|
||||
gh pr review "$PR_NUMBER" --request-changes --body "$COMMENT"
|
||||
echo "Requested changes on GitHub PR #$PR_NUMBER"
|
||||
;;
|
||||
comment)
|
||||
@@ -750,13 +695,12 @@ if [[ "$PLATFORM" == "github" ]]; then
|
||||
echo "Error: Comment required"
|
||||
exit 1
|
||||
fi
|
||||
gh_rc=0
|
||||
gh pr review "$PR_NUMBER" --comment --body "$COMMENT" || gh_rc=$?
|
||||
[[ "$gh_rc" -eq 0 ]] || { echo "Error: GitHub review comment failed (gh exit $gh_rc; provider failure, not a usage error)" >&2; exit 1; }
|
||||
gh pr review "$PR_NUMBER" --comment --body "$COMMENT"
|
||||
echo "Added review comment to GitHub PR #$PR_NUMBER"
|
||||
;;
|
||||
*)
|
||||
usage_error "unknown action: $ACTION"
|
||||
echo "Error: Unknown action: $ACTION"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||
@@ -794,7 +738,8 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||
;;
|
||||
request-changes)
|
||||
if [[ -z "$COMMENT" ]]; then
|
||||
usage_error "comment required for request-changes (-b/--body)"
|
||||
echo "Error: Comment required for request-changes"
|
||||
exit 1
|
||||
fi
|
||||
# Best-effort host for credential resolution only (gitea_resolve_api_for_login
|
||||
# below re-derives the real host from HOST_OVERRIDE/remote independently and
|
||||
@@ -849,7 +794,8 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||
echo "Added and verified comment on Gitea PR #$PR_NUMBER (comment ID $comment_id)"
|
||||
;;
|
||||
*)
|
||||
usage_error "unknown action: $ACTION"
|
||||
echo "Error: Unknown action: $ACTION"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
else
|
||||
|
||||
@@ -11,23 +11,13 @@ source "$SCRIPT_DIR/detect-platform.sh"
|
||||
PR_NUMBER=""
|
||||
REPO_OVERRIDE=""
|
||||
|
||||
# Usage-error contract (R4): usage errors print to STDERR and exit 2,
|
||||
# distinct from provider, credential, and verification failures (exit 1).
|
||||
usage_error() {
|
||||
echo "Error: $*" >&2
|
||||
echo "Usage: pr-view.sh -n <pr_number> [-r owner/repo] (see --help)" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-n|--number)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
PR_NUMBER="$2"
|
||||
shift 2
|
||||
;;
|
||||
-r|--repo)
|
||||
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
REPO_OVERRIDE="$2"
|
||||
shift 2
|
||||
;;
|
||||
@@ -38,18 +28,18 @@ while [[ $# -gt 0 ]]; do
|
||||
echo " -n, --number PR number (required)"
|
||||
echo " -r, --repo Repository slug (default: infer from git origin)"
|
||||
echo " -h, --help Show this help"
|
||||
echo ""
|
||||
echo "Exit codes: 0 success; 2 usage error (stderr); 1 provider/credential failure."
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
usage_error "unknown option: $1"
|
||||
echo "Unknown option: $1"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$PR_NUMBER" ]]; then
|
||||
usage_error "PR number is required (-n/--number)"
|
||||
echo "Error: PR number is required (-n)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -n "$REPO_OVERRIDE" ]]; then
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Usage-error contract for issue-assign.sh (R4, 2026-08-28).
|
||||
#
|
||||
# issue-edit already uses long-flag-first parsing (-i/--issue, -t/--title,
|
||||
# -b/--body, -l/--labels, -m/--milestone); this adds the rc=2 usage-error
|
||||
# contract, value checks, and the no-provider-contact proof. Required: -i.
|
||||
#
|
||||
# Arms:
|
||||
# 1. --help and -h exit 0 and print usage.
|
||||
# 2. Unknown option exits 2 with the message on stderr.
|
||||
# 3. Missing required -i exits 2 (stderr).
|
||||
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
|
||||
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
|
||||
# at credential resolution, nonzero and NOT 2) — no real token is
|
||||
# ever read and no provider is contacted.
|
||||
# 6. No arm performs any provider request (PATH shims record every
|
||||
# invocation; the probe log must stay empty).
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-assign-usage}"
|
||||
BIN_DIR="$WORK_DIR/bin"
|
||||
PROBE_LOG="$WORK_DIR/provider-probes.log"
|
||||
OUT_FILE="$WORK_DIR/out.log"
|
||||
ERR_FILE="$WORK_DIR/err.log"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$WORK_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mkdir -p "$BIN_DIR"
|
||||
: > "$PROBE_LOG"
|
||||
|
||||
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
|
||||
# API fallback that treats a successful curl as a closed PR, so exit-0
|
||||
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
|
||||
for tool in gh tea curl; do
|
||||
cat > "$BIN_DIR/$tool" <<STUB
|
||||
#!/usr/bin/env bash
|
||||
echo "$tool \$*" >> "$PROBE_LOG"
|
||||
exit 99
|
||||
STUB
|
||||
chmod +x "$BIN_DIR/$tool"
|
||||
done
|
||||
|
||||
run_wrapper() {
|
||||
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/issue-assign.sh" "$@" )
|
||||
}
|
||||
|
||||
# Hermetic variant: neutralizes every identity/credential source the wrapper
|
||||
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
|
||||
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
|
||||
run_wrapper_sandboxed() {
|
||||
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
|
||||
(
|
||||
cd "$WORK_DIR"
|
||||
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
|
||||
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
|
||||
"$SCRIPT_DIR/issue-assign.sh" "$@"
|
||||
)
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
echo "--- stderr ---" >&2
|
||||
cat "$ERR_FILE" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
expect_rc() { # expect_rc <want> <desc> <args...>
|
||||
local want="$1" desc="$2" rc=0
|
||||
shift 2
|
||||
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
|
||||
}
|
||||
|
||||
expect_stderr() { # expect_stderr <pattern> <desc>
|
||||
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
|
||||
}
|
||||
|
||||
# 1. Help exits 0 and prints usage.
|
||||
expect_rc 0 "--help exits 0" --help
|
||||
grep -q "Usage: issue-assign.sh" "$OUT_FILE" || fail "--help did not print usage"
|
||||
expect_rc 0 "-h exits 0" -h
|
||||
|
||||
# 2. Unknown option: rc 2, stderr.
|
||||
expect_rc 2 "unknown option exits 2" --bogus
|
||||
expect_stderr "[Uu]nknown option" "unknown option names itself on stderr"
|
||||
|
||||
# 3. Missing required PR number: rc 2, stderr.
|
||||
expect_rc 2 "missing -i exits 2"
|
||||
expect_stderr "Issue number is required" "missing -i message on stderr"
|
||||
|
||||
|
||||
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
|
||||
for flag in -i -a -l -m --issue --assignee --labels --milestone; do
|
||||
expect_rc 2 "value-less $flag exits 2" "$flag"
|
||||
expect_stderr "requires a value" "value-less $flag message on stderr"
|
||||
done
|
||||
|
||||
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
|
||||
# -b --help previously consumed --help as the body and performed the write).
|
||||
expect_rc 2 "option-like value rejected" -i 5 -a --help
|
||||
expect_rc 2 "short flag value rejected" -i 5 -a -h
|
||||
expect_stderr "requires a value" "short flag value message on stderr"
|
||||
expect_stderr "requires a value" "option-like value message on stderr"
|
||||
|
||||
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
|
||||
if [[ -s "$PROBE_LOG" ]]; then
|
||||
echo "FAIL: a parser-failure arm contacted a provider:" >&2
|
||||
cat "$PROBE_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
|
||||
# parsing; the run fails at credential resolution nonzero and NOT 2.
|
||||
for flag in -a; do
|
||||
rc=0
|
||||
run_wrapper_sandboxed -i 5 "$flag" "value" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
|
||||
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
|
||||
done
|
||||
|
||||
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
|
||||
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
|
||||
# comment parses, then falls back to the API. Hermeticity for this
|
||||
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
|
||||
# the arm above proves only parse acceptance and non-usage classification.
|
||||
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
|
||||
|
||||
# 6b. Provider-exit normalization (codex PR #1464): a provider stub exiting
|
||||
# 2 (its own usage-error status) must surface as wrapper exit 1, never 2.
|
||||
GH_REPO="$WORK_DIR/repo-gh"
|
||||
mkdir -p "$GH_REPO"
|
||||
git -C "$GH_REPO" init -q
|
||||
git -C "$GH_REPO" remote add origin https://github.com/acme/widgets.git
|
||||
cat > "$BIN_DIR/gh" <<GHSTUB
|
||||
#!/usr/bin/env bash
|
||||
echo "gh \$*" >> "$PROBE_LOG"
|
||||
if [[ "\$1 \$2" == "issue edit" ]]; then exit 2; fi
|
||||
exit 0
|
||||
GHSTUB
|
||||
chmod +x "$BIN_DIR/gh"
|
||||
rc=0
|
||||
(
|
||||
cd "$GH_REPO"
|
||||
PATH="$BIN_DIR:$PATH" MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
|
||||
"$SCRIPT_DIR/issue-assign.sh" -i 5 -a someone >"$OUT_FILE" 2>"$ERR_FILE"
|
||||
) || rc=$?
|
||||
[[ "$rc" -eq 1 ]] || fail "GitHub path: gh exit 2 must normalize to wrapper exit 1 (got $rc)"
|
||||
grep -q "provider" "$ERR_FILE" || fail "GitHub path: normalized provider error missing from stderr"
|
||||
|
||||
echo "issue-assign.sh usage-contract regression passed (R1/R4)"
|
||||
@@ -1,136 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Usage-error contract for issue-close.sh (R1/R4, 2026-08-28).
|
||||
#
|
||||
# R4: usage errors print to STDERR and exit 2, distinct from provider,
|
||||
# credential, and verification failures (exit 1). R1: -b/--body is the
|
||||
# canonical comment flag; -c/--comment remains a compatible alias.
|
||||
# The comment is OPTIONAL here (an issue may close without one), so unlike
|
||||
# issue-comment there is no missing-comment arm.
|
||||
#
|
||||
# Arms:
|
||||
# 1. --help and -h exit 0 and print usage.
|
||||
# 2. Unknown option exits 2 with the message on stderr.
|
||||
# 3. Missing required -i exits 2 (stderr).
|
||||
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
|
||||
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
|
||||
# at credential resolution, nonzero and NOT 2) — no real token is
|
||||
# ever read and no provider is contacted.
|
||||
# 6. No arm performs any provider request (PATH shims record every
|
||||
# invocation; the probe log must stay empty).
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-close-usage}"
|
||||
BIN_DIR="$WORK_DIR/bin"
|
||||
PROBE_LOG="$WORK_DIR/provider-probes.log"
|
||||
OUT_FILE="$WORK_DIR/out.log"
|
||||
ERR_FILE="$WORK_DIR/err.log"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$WORK_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mkdir -p "$BIN_DIR"
|
||||
: > "$PROBE_LOG"
|
||||
|
||||
for tool in gh tea curl; do
|
||||
cat > "$BIN_DIR/$tool" <<STUB
|
||||
#!/usr/bin/env bash
|
||||
echo "$tool \$*" >> "$PROBE_LOG"
|
||||
exit 0
|
||||
STUB
|
||||
chmod +x "$BIN_DIR/$tool"
|
||||
done
|
||||
|
||||
run_wrapper() {
|
||||
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/issue-close.sh" "$@" )
|
||||
}
|
||||
|
||||
# Hermetic variant: neutralizes every identity/credential source the wrapper
|
||||
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
|
||||
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
|
||||
run_wrapper_sandboxed() {
|
||||
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
|
||||
(
|
||||
cd "$WORK_DIR"
|
||||
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
|
||||
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
|
||||
"$SCRIPT_DIR/issue-close.sh" "$@"
|
||||
)
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
echo "--- stderr ---" >&2
|
||||
cat "$ERR_FILE" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
expect_rc() { # expect_rc <want> <desc> <args...>
|
||||
local want="$1" desc="$2" rc=0
|
||||
shift 2
|
||||
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
|
||||
}
|
||||
|
||||
expect_stderr() { # expect_stderr <pattern> <desc>
|
||||
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
|
||||
}
|
||||
|
||||
# 1. Help exits 0 and prints usage.
|
||||
expect_rc 0 "--help exits 0" --help
|
||||
grep -q "Usage: issue-close.sh" "$OUT_FILE" || fail "--help did not print usage"
|
||||
expect_rc 0 "-h exits 0" -h
|
||||
|
||||
# 2. Unknown option: rc 2, stderr.
|
||||
expect_rc 2 "unknown option exits 2" --bogus
|
||||
expect_stderr "unknown option" "unknown option names itself on stderr"
|
||||
|
||||
# 3. Missing required issue number: rc 2, stderr.
|
||||
expect_rc 2 "missing -i exits 2"
|
||||
expect_stderr "issue number is required" "missing -i message on stderr"
|
||||
|
||||
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
|
||||
for flag in -i -b -c --issue --body --comment; do
|
||||
expect_rc 2 "value-less $flag exits 2" "$flag"
|
||||
expect_stderr "requires a value" "value-less $flag message on stderr"
|
||||
done
|
||||
|
||||
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
|
||||
# -b --help previously consumed --help as the body and performed the write).
|
||||
expect_rc 2 "option-like value rejected" -i 5 -b --help
|
||||
expect_rc 2 "short flag value rejected" -i 5 -b -h
|
||||
expect_stderr "requires a value" "short flag value message on stderr"
|
||||
expect_stderr "requires a value" "option-like value message on stderr"
|
||||
|
||||
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
|
||||
if [[ -s "$PROBE_LOG" ]]; then
|
||||
echo "FAIL: a parser-failure arm contacted a provider:" >&2
|
||||
cat "$PROBE_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
|
||||
# parsing; the run fails at credential resolution nonzero and NOT 2.
|
||||
for flag in -b -c; do
|
||||
rc=0
|
||||
run_wrapper_sandboxed -i 5 "$flag" "closing note" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
|
||||
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
|
||||
done
|
||||
|
||||
# 6. Sandbox arms may issue DETECTION reads only (tea login list via the
|
||||
# stub); no gh/curl write or read may occur.
|
||||
if grep -Ev '^(gh|tea|curl) login list' "$PROBE_LOG" | grep -q .; then
|
||||
echo "FAIL: a sandbox arm performed a non-detection provider request:" >&2
|
||||
grep -Ev '^(gh|tea|curl) login list' "$PROBE_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -qE '^(gh|curl)' "$PROBE_LOG"; then
|
||||
echo "FAIL: gh or curl was invoked during a sandbox arm:" >&2
|
||||
grep -E '^(gh|curl)' "$PROBE_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "issue-close.sh usage-contract regression passed (R1/R4)"
|
||||
@@ -42,8 +42,6 @@
|
||||
# 10. leaves NO temp files behind (POST/GET bodies + metadata) on either the
|
||||
# success or the failure path — nested function-scoped RETURN traps do not
|
||||
# clobber each other and every scratch file is removed on all exit paths.
|
||||
# 11. accepts the canonical -b/--body flag exactly like the -c/--comment alias
|
||||
# (R1, 2026-08-28): a full verified write via -b alone.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -411,28 +409,11 @@ run_comment() {
|
||||
seed_state "$mode"
|
||||
(
|
||||
cd "$REPO_DIR"
|
||||
# Provisioned seats export MOSAIC_GIT_IDENTITY and MOSAIC_BRAIN_HOME
|
||||
# seat-wide (launcher), and both escape this harness's sandboxed HOME:
|
||||
# detect-platform.sh consults MOSAIC_GIT_IDENTITY BEFORE the repo-local
|
||||
# mosaic.gitIdentity pin, and resolves the brain home (whose
|
||||
# fleet/agents presence arms the no-identity fail-loud branch) from
|
||||
# MOSAIC_BRAIN_HOME before $HOME. Without these explicit empties the
|
||||
# wrapper either resolves the REAL seat-slot token (stub curl rejects
|
||||
# it: the documented HTTP 401) or fails loud before any request.
|
||||
# Set-but-empty reads as unset to detect-platform's "${VAR:-}" forms.
|
||||
# NOTE: keep this comment block ABOVE the assignment chain — a comment
|
||||
# inside a backslash-continued prefix chain terminates the command and
|
||||
# silently demotes every earlier assignment to an unexported subshell
|
||||
# assignment (measured 2026-08-28: the wrapper then ran without
|
||||
# MOSAIC_CREDENTIALS_FILE and the suite died at credential resolution
|
||||
# with zero diagnostic output).
|
||||
PATH="$BIN_DIR:$PATH" \
|
||||
TMPDIR="$TMP_SCRATCH" \
|
||||
HOME="$HOME_DIR" \
|
||||
XDG_CONFIG_HOME="$XDG_DIR" \
|
||||
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||
MOSAIC_GIT_IDENTITY="" \
|
||||
MOSAIC_BRAIN_HOME="" \
|
||||
ISSUE_COMMENT_TEA_LOG="$TEA_LOG" \
|
||||
ISSUE_COMMENT_CURL_LOG="$CURL_LOG" \
|
||||
ISSUE_COMMENT_CURL_ARGV_LOG="$CURL_ARGV_LOG" \
|
||||
@@ -449,7 +430,7 @@ run_comment() {
|
||||
ISSUE_COMMENT_REPO_SLUG="$REPO_SLUG" \
|
||||
ISSUE_COMMENT_API_BASE="$API_BASE" \
|
||||
ISSUE_COMMENT_API_ROOT="$API_ROOT" \
|
||||
"$SCRIPT_DIR/issue-comment.sh" -i "$ISSUE_NUMBER" "${BODY_FLAG:--c}" "$BODY" "$@"
|
||||
"$SCRIPT_DIR/issue-comment.sh" -i "$ISSUE_NUMBER" -c "$BODY" "$@"
|
||||
) > "$OUTPUT_FILE" 2>&1
|
||||
}
|
||||
|
||||
@@ -633,21 +614,4 @@ done
|
||||
# issue_url (already exercised by Case 1's fresh-success), so the tightened check
|
||||
# is not rejecting genuine writes.
|
||||
|
||||
# Case 11 (R1, 2026-08-28): -b/--body is the canonical comment flag and must
|
||||
# drive a full verified write exactly like the -c/--comment alias. BODY_FLAG
|
||||
# swaps only the flag spelling; every assertion below is case 1's contract.
|
||||
BODY_FLAG="-b"
|
||||
run_comment fresh-success
|
||||
grep -q 'Added and verified comment on Gitea issue #7 (comment ID 51)' "$OUTPUT_FILE"
|
||||
grep -q "^POST $API_BASE/issues/7/comments$" "$CURL_LOG"
|
||||
if grep -Eq '^comment |^issue comment ' "$TEA_LOG"; then
|
||||
echo "FAIL: --body write went through tea instead of REST" >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -q "^GET $API_BASE/issues/comments/51$" "$CURL_LOG"
|
||||
grep -q "^POST $API_BASE/issues/7/comments $ACTING_LOGIN$" "$AUTH_LOG"
|
||||
assert_no_temp_leak "fresh-success-body-flag"
|
||||
assert_token_not_in_argv "fresh-success-body-flag"
|
||||
unset BODY_FLAG
|
||||
|
||||
echo "issue-comment.sh REST create + exact-id read-back regression passed"
|
||||
|
||||
@@ -1,196 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Usage-error contract for issue-comment.sh (R1/R4 remediation, 2026-08-28).
|
||||
#
|
||||
# R4: usage errors print to STDERR and exit 2, distinct from provider,
|
||||
# credential, and verification failures (exit 1), so a caller (or a stop gate)
|
||||
# can tell an invocation defect from a delivery blocker. Before this contract
|
||||
# the wrapper exited 1 for usage errors with messages on STDOUT, and a
|
||||
# value-less flag (-c with no value) died SILENTLY at rc=1 because set -e
|
||||
# killed the failed `shift 2`. That silent shape is what full-stopped a fleet
|
||||
# seat: a caller could not distinguish "I invoked it wrong" from "delivery is
|
||||
# blocked".
|
||||
#
|
||||
# R1: -b/--body is the canonical comment flag (matching issue-create,
|
||||
# issue-edit, pr-create, pr-edit); -c/--comment remains a backward-compatible
|
||||
# alias.
|
||||
#
|
||||
# Arms:
|
||||
# 1. --help and -h exit 0 and print usage.
|
||||
# 2. Unknown option exits 2 with the message on stderr.
|
||||
# 3. Missing required -i exits 2 (stderr).
|
||||
# 4. Missing required comment exits 2 (stderr).
|
||||
# 5. A value-less flag (-i -b -c -l and long forms) exits 2 with a
|
||||
# "requires a value" message on stderr (the former silent-death class).
|
||||
# 6. -b and -c both pass parsing (the run then fails at platform detection
|
||||
# in this non-repo fixture, nonzero and NOT 2), proving alias acceptance
|
||||
# without any provider fixture.
|
||||
# 7. No arm performs any provider request: PATH shims for gh/tea/curl
|
||||
# record every invocation and the probe log must stay empty.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-comment-usage}"
|
||||
BIN_DIR="$WORK_DIR/bin"
|
||||
PROBE_LOG="$WORK_DIR/provider-probes.log"
|
||||
OUT_FILE="$WORK_DIR/out.log"
|
||||
ERR_FILE="$WORK_DIR/err.log"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$WORK_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mkdir -p "$BIN_DIR"
|
||||
: > "$PROBE_LOG"
|
||||
|
||||
# Provider shims: any invocation is recorded and fails the run at the end.
|
||||
# Usage-error arms must exit during argument parsing, before detect_platform,
|
||||
# so these prove "no provider request on parser failure".
|
||||
for tool in gh tea curl; do
|
||||
cat > "$BIN_DIR/$tool" <<STUB
|
||||
#!/usr/bin/env bash
|
||||
echo "$tool \$*" >> "$PROBE_LOG"
|
||||
# gh doubles as platform probe AND write path in arm 6b: probes exit 0; the
|
||||
# comment write exits 2 (gh's own usage-error status) to prove the wrapper
|
||||
# normalizes provider failures to exit 1 instead of propagating 2.
|
||||
if [[ "\$1 \$2" == "issue comment" ]]; then exit 2; fi
|
||||
exit 0
|
||||
STUB
|
||||
chmod +x "$BIN_DIR/$tool"
|
||||
done
|
||||
|
||||
run_wrapper() {
|
||||
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/issue-comment.sh" "$@" )
|
||||
}
|
||||
|
||||
# Hermetic variant for parse-acceptance arms: neutralizes every identity/
|
||||
# credential source the wrapper consults (seat env vars, HOME, XDG tea config)
|
||||
# so the arm fails at credential resolution in ANY cwd repo, never reading a
|
||||
# real token or contacting a provider. Measured 2026-08-28: without this, the
|
||||
# arm's outcome depended on incidental URL-resolution state (brain cwd died at
|
||||
# URL-not-found; a stack worktree cwd resolved a configured URL, read the real
|
||||
# seat token, and invoked the curl stub — the suite then failed its own
|
||||
# no-provider-contact check, correctly).
|
||||
run_wrapper_sandboxed() {
|
||||
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
|
||||
(
|
||||
cd "$WORK_DIR"
|
||||
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
|
||||
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
|
||||
"$SCRIPT_DIR/issue-comment.sh" "$@"
|
||||
)
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
echo "--- stderr ---" >&2
|
||||
cat "$ERR_FILE" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
expect_rc() { # expect_rc <want> <desc> <args...>
|
||||
local want="$1" desc="$2" rc=0
|
||||
shift 2
|
||||
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
|
||||
}
|
||||
|
||||
expect_stderr() { # expect_stderr <pattern> <desc>
|
||||
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
|
||||
}
|
||||
|
||||
# 1. Help exits 0 and prints usage on stdout.
|
||||
expect_rc 0 "--help exits 0" --help
|
||||
grep -q "Usage: issue-comment.sh" "$OUT_FILE" || fail "--help did not print usage"
|
||||
expect_rc 0 "-h exits 0" -h
|
||||
|
||||
# 2. Unknown option: rc 2, message on stderr.
|
||||
expect_rc 2 "unknown option exits 2" --bogus
|
||||
expect_stderr "unknown option" "unknown option names itself on stderr"
|
||||
|
||||
# 3. Missing required issue number: rc 2, stderr.
|
||||
expect_rc 2 "missing -i exits 2"
|
||||
expect_stderr "issue number is required" "missing -i message on stderr"
|
||||
|
||||
# 4. Missing required comment: rc 2, stderr.
|
||||
expect_rc 2 "missing comment exits 2" -i 5
|
||||
expect_stderr "comment is required" "missing comment message on stderr"
|
||||
|
||||
# 5. Value-less flags: rc 2 with "requires a value" on stderr. The old parser
|
||||
# died here silently (set -e on the failed shift 2).
|
||||
for flag in -i -b -c -l --issue --body --comment --login; do
|
||||
expect_rc 2 "value-less $flag exits 2" "$flag"
|
||||
expect_stderr "requires a value" "value-less $flag message on stderr"
|
||||
done
|
||||
|
||||
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
|
||||
# -b --help previously consumed --help as the body and performed the write).
|
||||
expect_rc 2 "option-like value rejected" -i 5 -b --help
|
||||
expect_rc 2 "short flag value rejected" -i 5 -b -h
|
||||
expect_stderr "requires a value" "short flag value message on stderr"
|
||||
expect_stderr "requires a value" "option-like value message on stderr"
|
||||
|
||||
# 6. Alias acceptance at parse level: both -b and -c carry a value past
|
||||
# parsing; the wrapper then fails at platform detection (not a git repo)
|
||||
# nonzero but NOT as a usage error (rc must not be 2).
|
||||
for flag in -b -c; do
|
||||
rc=0
|
||||
run_wrapper_sandboxed -i 5 "$flag" "some text" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
|
||||
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
|
||||
done
|
||||
|
||||
# 6b. GitHub-path exit normalization (codex blocker on 08a00149): gh's own
|
||||
# usage errors exit 2; the wrapper must NOT propagate that status (reserved
|
||||
# for the wrapper's usage-error contract). With a github remote and a gh stub
|
||||
# whose comment write exits 2, the wrapper must exit 1 with the normalized
|
||||
# error on stderr.
|
||||
GH_REPO="$WORK_DIR/repo-gh"
|
||||
mkdir -p "$GH_REPO"
|
||||
git -C "$GH_REPO" init -q
|
||||
git -C "$GH_REPO" remote add origin https://github.com/acme/widgets.git
|
||||
git -C "$GH_REPO" config mosaic.gitIdentity ""
|
||||
rc=0
|
||||
(
|
||||
cd "$GH_REPO"
|
||||
PATH="$BIN_DIR:$PATH" MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
|
||||
"$SCRIPT_DIR/issue-comment.sh" -i 5 -b "text" >"$OUT_FILE" 2>"$ERR_FILE"
|
||||
) || rc=$?
|
||||
[[ "$rc" -eq 1 ]] || fail "GitHub path: gh exit 2 must normalize to wrapper exit 1 (got $rc)"
|
||||
grep -q "GitHub comment write failed" "$ERR_FILE" || fail "GitHub path: normalized error missing from stderr"
|
||||
grep -q "^gh issue comment" "$PROBE_LOG" || fail "GitHub path: gh write was not invoked"
|
||||
|
||||
# R3 body-file arms (2026-08-29): --body-file <path> and '-' (stdin).
|
||||
BF_FILE="$WORK_DIR/body.md"
|
||||
printf 'line one\nline two\n' > "$BF_FILE"
|
||||
|
||||
# File loads the body: parse acceptance then credential-class failure
|
||||
# (sandboxed runner: rc nonzero and NOT 2).
|
||||
rc=0
|
||||
run_wrapper_sandboxed -i 5 --body-file "$BF_FILE" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||
[[ "$rc" -ne 0 ]] || fail "body-file arm unexpectedly succeeded in the sandbox"
|
||||
[[ "$rc" -ne 2 ]] || fail "body-file arm misclassified credential failure as a usage error"
|
||||
|
||||
# Stdin form loads the body the same way.
|
||||
rc=0
|
||||
printf 'from stdin' | run_wrapper_sandboxed -i 5 --body-file - >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||
[[ "$rc" -ne 0 && "$rc" -ne 2 ]] || fail "body-file stdin arm rc=$rc (want nonzero, not 2)"
|
||||
|
||||
# Mutually exclusive with --body: rc 2.
|
||||
expect_rc 2 "body-file + body exclusive" -i 5 --body-file "$BF_FILE" -b explicit
|
||||
expect_stderr "mutually exclusive" "exclusivity message on stderr"
|
||||
|
||||
# Missing file: rc 2 naming the path.
|
||||
expect_rc 2 "missing body file" -i 5 --body-file "$WORK_DIR/nope.md"
|
||||
expect_stderr "not readable" "missing-file message on stderr"
|
||||
|
||||
# 7. No provider contact from any usage-error arm (arm 6b's deliberate gh
|
||||
# invocation is the only permitted entry in the probe log).
|
||||
if grep -v '^gh issue comment' "$PROBE_LOG" | grep -q .; then
|
||||
echo "FAIL: a parser-failure arm contacted a provider:" >&2
|
||||
grep -v '^gh issue comment' "$PROBE_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "issue-comment.sh usage-contract regression passed (R1/R4)"
|
||||
@@ -1,132 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Usage-error contract for issue-create.sh (R4, 2026-08-28).
|
||||
#
|
||||
# issue-edit already uses long-flag-first parsing (-i/--issue, -t/--title,
|
||||
# -b/--body, -l/--labels, -m/--milestone); this adds the rc=2 usage-error
|
||||
# contract, value checks, and the no-provider-contact proof. Required: -i.
|
||||
#
|
||||
# Arms:
|
||||
# 1. --help and -h exit 0 and print usage.
|
||||
# 2. Unknown option exits 2 with the message on stderr.
|
||||
# 3. Missing required -i exits 2 (stderr).
|
||||
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
|
||||
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
|
||||
# at credential resolution, nonzero and NOT 2) — no real token is
|
||||
# ever read and no provider is contacted.
|
||||
# 6. No arm performs any provider request (PATH shims record every
|
||||
# invocation; the probe log must stay empty).
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-create-usage}"
|
||||
BIN_DIR="$WORK_DIR/bin"
|
||||
PROBE_LOG="$WORK_DIR/provider-probes.log"
|
||||
OUT_FILE="$WORK_DIR/out.log"
|
||||
ERR_FILE="$WORK_DIR/err.log"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$WORK_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mkdir -p "$BIN_DIR"
|
||||
: > "$PROBE_LOG"
|
||||
|
||||
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
|
||||
# API fallback that treats a successful curl as a closed PR, so exit-0
|
||||
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
|
||||
for tool in gh tea curl; do
|
||||
cat > "$BIN_DIR/$tool" <<STUB
|
||||
#!/usr/bin/env bash
|
||||
echo "$tool \$*" >> "$PROBE_LOG"
|
||||
exit 99
|
||||
STUB
|
||||
chmod +x "$BIN_DIR/$tool"
|
||||
done
|
||||
|
||||
run_wrapper() {
|
||||
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/issue-create.sh" "$@" )
|
||||
}
|
||||
|
||||
# Hermetic variant: neutralizes every identity/credential source the wrapper
|
||||
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
|
||||
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
|
||||
run_wrapper_sandboxed() {
|
||||
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
|
||||
(
|
||||
cd "$WORK_DIR"
|
||||
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
|
||||
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
|
||||
"$SCRIPT_DIR/issue-create.sh" "$@"
|
||||
)
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
echo "--- stderr ---" >&2
|
||||
cat "$ERR_FILE" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
expect_rc() { # expect_rc <want> <desc> <args...>
|
||||
local want="$1" desc="$2" rc=0
|
||||
shift 2
|
||||
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
|
||||
}
|
||||
|
||||
expect_stderr() { # expect_stderr <pattern> <desc>
|
||||
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
|
||||
}
|
||||
|
||||
# 1. Help exits 0 and prints usage.
|
||||
expect_rc 0 "--help exits 0" --help
|
||||
grep -q "Usage: issue-create.sh" "$OUT_FILE" || fail "--help did not print usage"
|
||||
expect_rc 0 "-h exits 0" -h
|
||||
|
||||
# 2. Unknown option: rc 2, stderr.
|
||||
expect_rc 2 "unknown option exits 2" --bogus
|
||||
expect_stderr "[Uu]nknown option" "unknown option names itself on stderr"
|
||||
|
||||
# 3. Missing required PR number: rc 2, stderr.
|
||||
expect_rc 2 "missing -t exits 2"
|
||||
expect_stderr "Title is required" "missing -t message on stderr"
|
||||
|
||||
|
||||
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
|
||||
for flag in -t -b -l -m --title --body --labels --milestone; do
|
||||
expect_rc 2 "value-less $flag exits 2" "$flag"
|
||||
expect_stderr "requires a value" "value-less $flag message on stderr"
|
||||
done
|
||||
|
||||
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
|
||||
# -b --help previously consumed --help as the body and performed the write).
|
||||
expect_rc 2 "option-like value rejected" -t smoke -b --help
|
||||
expect_rc 2 "short flag value rejected" -t smoke -b -h
|
||||
expect_stderr "requires a value" "short flag value message on stderr"
|
||||
expect_stderr "requires a value" "option-like value message on stderr"
|
||||
|
||||
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
|
||||
if [[ -s "$PROBE_LOG" ]]; then
|
||||
echo "FAIL: a parser-failure arm contacted a provider:" >&2
|
||||
cat "$PROBE_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
|
||||
# parsing; the run fails at credential resolution nonzero and NOT 2.
|
||||
for flag in -b; do
|
||||
rc=0
|
||||
run_wrapper_sandboxed -t "smoke" "$flag" "value" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
|
||||
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
|
||||
done
|
||||
|
||||
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
|
||||
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
|
||||
# comment parses, then falls back to the API. Hermeticity for this
|
||||
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
|
||||
# the arm above proves only parse acceptance and non-usage classification.
|
||||
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
|
||||
|
||||
echo "issue-create.sh usage-contract regression passed (R1/R4)"
|
||||
@@ -1,132 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Usage-error contract for issue-edit.sh (R4, 2026-08-28).
|
||||
#
|
||||
# issue-edit already uses long-flag-first parsing (-i/--issue, -t/--title,
|
||||
# -b/--body, -l/--labels, -m/--milestone); this adds the rc=2 usage-error
|
||||
# contract, value checks, and the no-provider-contact proof. Required: -i.
|
||||
#
|
||||
# Arms:
|
||||
# 1. --help and -h exit 0 and print usage.
|
||||
# 2. Unknown option exits 2 with the message on stderr.
|
||||
# 3. Missing required -i exits 2 (stderr).
|
||||
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
|
||||
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
|
||||
# at credential resolution, nonzero and NOT 2) — no real token is
|
||||
# ever read and no provider is contacted.
|
||||
# 6. No arm performs any provider request (PATH shims record every
|
||||
# invocation; the probe log must stay empty).
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-edit-usage}"
|
||||
BIN_DIR="$WORK_DIR/bin"
|
||||
PROBE_LOG="$WORK_DIR/provider-probes.log"
|
||||
OUT_FILE="$WORK_DIR/out.log"
|
||||
ERR_FILE="$WORK_DIR/err.log"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$WORK_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mkdir -p "$BIN_DIR"
|
||||
: > "$PROBE_LOG"
|
||||
|
||||
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
|
||||
# API fallback that treats a successful curl as a closed PR, so exit-0
|
||||
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
|
||||
for tool in gh tea curl; do
|
||||
cat > "$BIN_DIR/$tool" <<STUB
|
||||
#!/usr/bin/env bash
|
||||
echo "$tool \$*" >> "$PROBE_LOG"
|
||||
exit 99
|
||||
STUB
|
||||
chmod +x "$BIN_DIR/$tool"
|
||||
done
|
||||
|
||||
run_wrapper() {
|
||||
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/issue-edit.sh" "$@" )
|
||||
}
|
||||
|
||||
# Hermetic variant: neutralizes every identity/credential source the wrapper
|
||||
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
|
||||
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
|
||||
run_wrapper_sandboxed() {
|
||||
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
|
||||
(
|
||||
cd "$WORK_DIR"
|
||||
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
|
||||
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
|
||||
"$SCRIPT_DIR/issue-edit.sh" "$@"
|
||||
)
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
echo "--- stderr ---" >&2
|
||||
cat "$ERR_FILE" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
expect_rc() { # expect_rc <want> <desc> <args...>
|
||||
local want="$1" desc="$2" rc=0
|
||||
shift 2
|
||||
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
|
||||
}
|
||||
|
||||
expect_stderr() { # expect_stderr <pattern> <desc>
|
||||
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
|
||||
}
|
||||
|
||||
# 1. Help exits 0 and prints usage.
|
||||
expect_rc 0 "--help exits 0" --help
|
||||
grep -q "Usage: issue-edit.sh" "$OUT_FILE" || fail "--help did not print usage"
|
||||
expect_rc 0 "-h exits 0" -h
|
||||
|
||||
# 2. Unknown option: rc 2, stderr.
|
||||
expect_rc 2 "unknown option exits 2" --bogus
|
||||
expect_stderr "unknown option" "unknown option names itself on stderr"
|
||||
|
||||
# 3. Missing required PR number: rc 2, stderr.
|
||||
expect_rc 2 "missing -i exits 2"
|
||||
expect_stderr "issue number is required" "missing -i message on stderr"
|
||||
|
||||
|
||||
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
|
||||
for flag in -i -t -b -l -m --issue --title --body --labels --milestone; do
|
||||
expect_rc 2 "value-less $flag exits 2" "$flag"
|
||||
expect_stderr "requires a value" "value-less $flag message on stderr"
|
||||
done
|
||||
|
||||
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
|
||||
# -b --help previously consumed --help as the body and performed the write).
|
||||
expect_rc 2 "option-like value rejected" -i 5 -b --help
|
||||
expect_rc 2 "short flag value rejected" -i 5 -b -h
|
||||
expect_stderr "requires a value" "short flag value message on stderr"
|
||||
expect_stderr "requires a value" "option-like value message on stderr"
|
||||
|
||||
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
|
||||
if [[ -s "$PROBE_LOG" ]]; then
|
||||
echo "FAIL: a parser-failure arm contacted a provider:" >&2
|
||||
cat "$PROBE_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
|
||||
# parsing; the run fails at credential resolution nonzero and NOT 2.
|
||||
for flag in -b; do
|
||||
rc=0
|
||||
run_wrapper_sandboxed -i 5 "$flag" "value" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
|
||||
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
|
||||
done
|
||||
|
||||
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
|
||||
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
|
||||
# comment parses, then falls back to the API. Hermeticity for this
|
||||
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
|
||||
# the arm above proves only parse acceptance and non-usage classification.
|
||||
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
|
||||
|
||||
echo "issue-edit.sh usage-contract regression passed (R1/R4)"
|
||||
@@ -1,130 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Usage-error contract for issue-list.sh (R4, 2026-08-28).
|
||||
#
|
||||
# issue-edit already uses long-flag-first parsing (-i/--issue, -t/--title,
|
||||
# -b/--body, -l/--labels, -m/--milestone); this adds the rc=2 usage-error
|
||||
# contract, value checks, and the no-provider-contact proof. Required: -i.
|
||||
#
|
||||
# Arms:
|
||||
# 1. --help and -h exit 0 and print usage.
|
||||
# 2. Unknown option exits 2 with the message on stderr.
|
||||
# 3. Missing required -i exits 2 (stderr).
|
||||
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
|
||||
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
|
||||
# at credential resolution, nonzero and NOT 2) — no real token is
|
||||
# ever read and no provider is contacted.
|
||||
# 6. No arm performs any provider request (PATH shims record every
|
||||
# invocation; the probe log must stay empty).
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-list-usage}"
|
||||
BIN_DIR="$WORK_DIR/bin"
|
||||
PROBE_LOG="$WORK_DIR/provider-probes.log"
|
||||
OUT_FILE="$WORK_DIR/out.log"
|
||||
ERR_FILE="$WORK_DIR/err.log"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$WORK_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mkdir -p "$BIN_DIR"
|
||||
: > "$PROBE_LOG"
|
||||
|
||||
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
|
||||
# API fallback that treats a successful curl as a closed PR, so exit-0
|
||||
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
|
||||
for tool in gh tea curl; do
|
||||
cat > "$BIN_DIR/$tool" <<STUB
|
||||
#!/usr/bin/env bash
|
||||
echo "$tool \$*" >> "$PROBE_LOG"
|
||||
exit 99
|
||||
STUB
|
||||
chmod +x "$BIN_DIR/$tool"
|
||||
done
|
||||
|
||||
run_wrapper() {
|
||||
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/issue-list.sh" "$@" )
|
||||
}
|
||||
|
||||
# Hermetic variant: neutralizes every identity/credential source the wrapper
|
||||
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
|
||||
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
|
||||
run_wrapper_sandboxed() {
|
||||
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
|
||||
(
|
||||
cd "$WORK_DIR"
|
||||
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
|
||||
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
|
||||
"$SCRIPT_DIR/issue-list.sh" "$@"
|
||||
)
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
echo "--- stderr ---" >&2
|
||||
cat "$ERR_FILE" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
expect_rc() { # expect_rc <want> <desc> <args...>
|
||||
local want="$1" desc="$2" rc=0
|
||||
shift 2
|
||||
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
|
||||
}
|
||||
|
||||
expect_stderr() { # expect_stderr <pattern> <desc>
|
||||
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
|
||||
}
|
||||
|
||||
# 1. Help exits 0 and prints usage.
|
||||
expect_rc 0 "--help exits 0" --help
|
||||
grep -q "Usage: issue-list.sh" "$OUT_FILE" || fail "--help did not print usage"
|
||||
expect_rc 0 "-h exits 0" -h
|
||||
|
||||
# 2. Unknown option: rc 2, stderr.
|
||||
expect_rc 2 "unknown option exits 2" --bogus
|
||||
expect_stderr "[Uu]nknown option" "unknown option names itself on stderr"
|
||||
|
||||
# 3. Missing required PR number: rc 2, stderr.
|
||||
|
||||
|
||||
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
|
||||
for flag in -s -l -m -a -n -r --state --label --milestone --assignee --limit --repo; do
|
||||
expect_rc 2 "value-less $flag exits 2" "$flag"
|
||||
expect_stderr "requires a value" "value-less $flag message on stderr"
|
||||
done
|
||||
|
||||
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
|
||||
# -b --help previously consumed --help as the body and performed the write).
|
||||
expect_rc 2 "option-like value rejected" -s --help
|
||||
expect_rc 2 "short flag value rejected" -s -h
|
||||
expect_stderr "requires a value" "short flag value message on stderr"
|
||||
expect_stderr "requires a value" "option-like value message on stderr"
|
||||
|
||||
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
|
||||
if [[ -s "$PROBE_LOG" ]]; then
|
||||
echo "FAIL: a parser-failure arm contacted a provider:" >&2
|
||||
cat "$PROBE_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
|
||||
# parsing; the run fails at credential resolution nonzero and NOT 2.
|
||||
for flag in -s; do
|
||||
rc=0
|
||||
run_wrapper_sandboxed -s open >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
|
||||
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
|
||||
done
|
||||
|
||||
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
|
||||
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
|
||||
# comment parses, then falls back to the API. Hermeticity for this
|
||||
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
|
||||
# the arm above proves only parse acceptance and non-usage classification.
|
||||
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
|
||||
|
||||
echo "issue-list.sh usage-contract regression passed (R1/R4)"
|
||||
@@ -1,136 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Usage-error contract for issue-reopen.sh (R1/R4, 2026-08-28).
|
||||
#
|
||||
# R4: usage errors print to STDERR and exit 2, distinct from provider,
|
||||
# credential, and verification failures (exit 1). R1: -b/--body is the
|
||||
# canonical comment flag; -c/--comment remains a compatible alias.
|
||||
# The comment is OPTIONAL here (an issue may close without one), so unlike
|
||||
# issue-comment there is no missing-comment arm.
|
||||
#
|
||||
# Arms:
|
||||
# 1. --help and -h exit 0 and print usage.
|
||||
# 2. Unknown option exits 2 with the message on stderr.
|
||||
# 3. Missing required -i exits 2 (stderr).
|
||||
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
|
||||
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
|
||||
# at credential resolution, nonzero and NOT 2) — no real token is
|
||||
# ever read and no provider is contacted.
|
||||
# 6. No arm performs any provider request (PATH shims record every
|
||||
# invocation; the probe log must stay empty).
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-reopen-usage}"
|
||||
BIN_DIR="$WORK_DIR/bin"
|
||||
PROBE_LOG="$WORK_DIR/provider-probes.log"
|
||||
OUT_FILE="$WORK_DIR/out.log"
|
||||
ERR_FILE="$WORK_DIR/err.log"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$WORK_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mkdir -p "$BIN_DIR"
|
||||
: > "$PROBE_LOG"
|
||||
|
||||
for tool in gh tea curl; do
|
||||
cat > "$BIN_DIR/$tool" <<STUB
|
||||
#!/usr/bin/env bash
|
||||
echo "$tool \$*" >> "$PROBE_LOG"
|
||||
exit 0
|
||||
STUB
|
||||
chmod +x "$BIN_DIR/$tool"
|
||||
done
|
||||
|
||||
run_wrapper() {
|
||||
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/issue-reopen.sh" "$@" )
|
||||
}
|
||||
|
||||
# Hermetic variant: neutralizes every identity/credential source the wrapper
|
||||
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
|
||||
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
|
||||
run_wrapper_sandboxed() {
|
||||
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
|
||||
(
|
||||
cd "$WORK_DIR"
|
||||
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
|
||||
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
|
||||
"$SCRIPT_DIR/issue-reopen.sh" "$@"
|
||||
)
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
echo "--- stderr ---" >&2
|
||||
cat "$ERR_FILE" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
expect_rc() { # expect_rc <want> <desc> <args...>
|
||||
local want="$1" desc="$2" rc=0
|
||||
shift 2
|
||||
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
|
||||
}
|
||||
|
||||
expect_stderr() { # expect_stderr <pattern> <desc>
|
||||
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
|
||||
}
|
||||
|
||||
# 1. Help exits 0 and prints usage.
|
||||
expect_rc 0 "--help exits 0" --help
|
||||
grep -q "Usage: issue-reopen.sh" "$OUT_FILE" || fail "--help did not print usage"
|
||||
expect_rc 0 "-h exits 0" -h
|
||||
|
||||
# 2. Unknown option: rc 2, stderr.
|
||||
expect_rc 2 "unknown option exits 2" --bogus
|
||||
expect_stderr "unknown option" "unknown option names itself on stderr"
|
||||
|
||||
# 3. Missing required issue number: rc 2, stderr.
|
||||
expect_rc 2 "missing -i exits 2"
|
||||
expect_stderr "issue number is required" "missing -i message on stderr"
|
||||
|
||||
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
|
||||
for flag in -i -b -c --issue --body --comment; do
|
||||
expect_rc 2 "value-less $flag exits 2" "$flag"
|
||||
expect_stderr "requires a value" "value-less $flag message on stderr"
|
||||
done
|
||||
|
||||
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
|
||||
# -b --help previously consumed --help as the body and performed the write).
|
||||
expect_rc 2 "option-like value rejected" -i 5 -b --help
|
||||
expect_rc 2 "short flag value rejected" -i 5 -b -h
|
||||
expect_stderr "requires a value" "short flag value message on stderr"
|
||||
expect_stderr "requires a value" "option-like value message on stderr"
|
||||
|
||||
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
|
||||
if [[ -s "$PROBE_LOG" ]]; then
|
||||
echo "FAIL: a parser-failure arm contacted a provider:" >&2
|
||||
cat "$PROBE_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
|
||||
# parsing; the run fails at credential resolution nonzero and NOT 2.
|
||||
for flag in -b -c; do
|
||||
rc=0
|
||||
run_wrapper_sandboxed -i 5 "$flag" "closing note" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
|
||||
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
|
||||
done
|
||||
|
||||
# 6. Sandbox arms may issue DETECTION reads only (tea login list via the
|
||||
# stub); no gh/curl write or read may occur.
|
||||
if grep -Ev '^(gh|tea|curl) login list' "$PROBE_LOG" | grep -q .; then
|
||||
echo "FAIL: a sandbox arm performed a non-detection provider request:" >&2
|
||||
grep -Ev '^(gh|tea|curl) login list' "$PROBE_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -qE '^(gh|curl)' "$PROBE_LOG"; then
|
||||
echo "FAIL: gh or curl was invoked during a sandbox arm:" >&2
|
||||
grep -E '^(gh|curl)' "$PROBE_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "issue-reopen.sh usage-contract regression passed (R1/R4)"
|
||||
@@ -1,132 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Usage-error contract for issue-view.sh (R4, 2026-08-28).
|
||||
#
|
||||
# issue-edit already uses long-flag-first parsing (-i/--issue, -t/--title,
|
||||
# -b/--body, -l/--labels, -m/--milestone); this adds the rc=2 usage-error
|
||||
# contract, value checks, and the no-provider-contact proof. Required: -i.
|
||||
#
|
||||
# Arms:
|
||||
# 1. --help and -h exit 0 and print usage.
|
||||
# 2. Unknown option exits 2 with the message on stderr.
|
||||
# 3. Missing required -i exits 2 (stderr).
|
||||
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
|
||||
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
|
||||
# at credential resolution, nonzero and NOT 2) — no real token is
|
||||
# ever read and no provider is contacted.
|
||||
# 6. No arm performs any provider request (PATH shims record every
|
||||
# invocation; the probe log must stay empty).
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-view-usage}"
|
||||
BIN_DIR="$WORK_DIR/bin"
|
||||
PROBE_LOG="$WORK_DIR/provider-probes.log"
|
||||
OUT_FILE="$WORK_DIR/out.log"
|
||||
ERR_FILE="$WORK_DIR/err.log"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$WORK_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mkdir -p "$BIN_DIR"
|
||||
: > "$PROBE_LOG"
|
||||
|
||||
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
|
||||
# API fallback that treats a successful curl as a closed PR, so exit-0
|
||||
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
|
||||
for tool in gh tea curl; do
|
||||
cat > "$BIN_DIR/$tool" <<STUB
|
||||
#!/usr/bin/env bash
|
||||
echo "$tool \$*" >> "$PROBE_LOG"
|
||||
exit 99
|
||||
STUB
|
||||
chmod +x "$BIN_DIR/$tool"
|
||||
done
|
||||
|
||||
run_wrapper() {
|
||||
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/issue-view.sh" "$@" )
|
||||
}
|
||||
|
||||
# Hermetic variant: neutralizes every identity/credential source the wrapper
|
||||
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
|
||||
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
|
||||
run_wrapper_sandboxed() {
|
||||
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
|
||||
(
|
||||
cd "$WORK_DIR"
|
||||
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
|
||||
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
|
||||
"$SCRIPT_DIR/issue-view.sh" "$@"
|
||||
)
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
echo "--- stderr ---" >&2
|
||||
cat "$ERR_FILE" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
expect_rc() { # expect_rc <want> <desc> <args...>
|
||||
local want="$1" desc="$2" rc=0
|
||||
shift 2
|
||||
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
|
||||
}
|
||||
|
||||
expect_stderr() { # expect_stderr <pattern> <desc>
|
||||
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
|
||||
}
|
||||
|
||||
# 1. Help exits 0 and prints usage.
|
||||
expect_rc 0 "--help exits 0" --help
|
||||
grep -q "Usage: issue-view.sh" "$OUT_FILE" || fail "--help did not print usage"
|
||||
expect_rc 0 "-h exits 0" -h
|
||||
|
||||
# 2. Unknown option: rc 2, stderr.
|
||||
expect_rc 2 "unknown option exits 2" --bogus
|
||||
expect_stderr "[Uu]nknown option" "unknown option names itself on stderr"
|
||||
|
||||
# 3. Missing required PR number: rc 2, stderr.
|
||||
expect_rc 2 "missing -i exits 2"
|
||||
expect_stderr "Issue number is required" "missing -i message on stderr"
|
||||
|
||||
|
||||
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
|
||||
for flag in -i --issue; do
|
||||
expect_rc 2 "value-less $flag exits 2" "$flag"
|
||||
expect_stderr "requires a value" "value-less $flag message on stderr"
|
||||
done
|
||||
|
||||
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
|
||||
# -b --help previously consumed --help as the body and performed the write).
|
||||
expect_rc 2 "option-like value rejected" -i --help
|
||||
expect_rc 2 "short flag value rejected" -i -h
|
||||
expect_stderr "requires a value" "short flag value message on stderr"
|
||||
expect_stderr "requires a value" "option-like value message on stderr"
|
||||
|
||||
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
|
||||
if [[ -s "$PROBE_LOG" ]]; then
|
||||
echo "FAIL: a parser-failure arm contacted a provider:" >&2
|
||||
cat "$PROBE_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
|
||||
# parsing; the run fails at credential resolution nonzero and NOT 2.
|
||||
for flag in -i; do
|
||||
rc=0
|
||||
run_wrapper_sandboxed -i 5 >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
|
||||
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
|
||||
done
|
||||
|
||||
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
|
||||
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
|
||||
# comment parses, then falls back to the API. Hermeticity for this
|
||||
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
|
||||
# the arm above proves only parse acceptance and non-usage classification.
|
||||
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
|
||||
|
||||
echo "issue-view.sh usage-contract regression passed (R1/R4)"
|
||||
Executable → Regular
@@ -1,132 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Usage-error contract for lane-brief.sh (R4, 2026-08-28).
|
||||
#
|
||||
# issue-edit already uses long-flag-first parsing (-i/--issue, -t/--title,
|
||||
# -b/--body, -l/--labels, -m/--milestone); this adds the rc=2 usage-error
|
||||
# contract, value checks, and the no-provider-contact proof. Required: -i.
|
||||
#
|
||||
# Arms:
|
||||
# 1. --help and -h exit 0 and print usage.
|
||||
# 2. Unknown option exits 2 with the message on stderr.
|
||||
# 3. Missing required -i exits 2 (stderr).
|
||||
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
|
||||
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
|
||||
# at credential resolution, nonzero and NOT 2) — no real token is
|
||||
# ever read and no provider is contacted.
|
||||
# 6. No arm performs any provider request (PATH shims record every
|
||||
# invocation; the probe log must stay empty).
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/lane-brief-usage}"
|
||||
BIN_DIR="$WORK_DIR/bin"
|
||||
PROBE_LOG="$WORK_DIR/provider-probes.log"
|
||||
OUT_FILE="$WORK_DIR/out.log"
|
||||
ERR_FILE="$WORK_DIR/err.log"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$WORK_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mkdir -p "$BIN_DIR"
|
||||
: > "$PROBE_LOG"
|
||||
|
||||
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
|
||||
# API fallback that treats a successful curl as a closed PR, so exit-0
|
||||
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
|
||||
for tool in gh tea curl; do
|
||||
cat > "$BIN_DIR/$tool" <<STUB
|
||||
#!/usr/bin/env bash
|
||||
echo "$tool \$*" >> "$PROBE_LOG"
|
||||
exit 99
|
||||
STUB
|
||||
chmod +x "$BIN_DIR/$tool"
|
||||
done
|
||||
|
||||
run_wrapper() {
|
||||
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/lane-brief.sh" "$@" )
|
||||
}
|
||||
|
||||
# Hermetic variant: neutralizes every identity/credential source the wrapper
|
||||
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
|
||||
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
|
||||
run_wrapper_sandboxed() {
|
||||
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
|
||||
(
|
||||
cd "$WORK_DIR"
|
||||
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
|
||||
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
|
||||
"$SCRIPT_DIR/lane-brief.sh" "$@"
|
||||
)
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
echo "--- stderr ---" >&2
|
||||
cat "$ERR_FILE" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
expect_rc() { # expect_rc <want> <desc> <args...>
|
||||
local want="$1" desc="$2" rc=0
|
||||
shift 2
|
||||
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
|
||||
}
|
||||
|
||||
expect_stderr() { # expect_stderr <pattern> <desc>
|
||||
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
|
||||
}
|
||||
|
||||
# 1. Help exits 0 and prints usage.
|
||||
expect_rc 0 "--help exits 0" --help
|
||||
grep -q "owner/repo" "$OUT_FILE" || fail "--help did not print usage"
|
||||
expect_rc 0 "-h exits 0" -h
|
||||
|
||||
# 2. Unknown option: rc 2, stderr.
|
||||
expect_rc 2 "unknown option exits 2" --bogus
|
||||
expect_stderr "[Uu]nknown option" "unknown option names itself on stderr"
|
||||
|
||||
# 3. Missing required PR number: rc 2, stderr.
|
||||
expect_rc 2 "missing -r exits 2"
|
||||
expect_stderr "required" "missing -r message on stderr"
|
||||
|
||||
|
||||
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
|
||||
for flag in -r -m -l -L -n --repo --milestone --label --login --limit; do
|
||||
expect_rc 2 "value-less $flag exits 2" "$flag"
|
||||
expect_stderr "requires a value" "value-less $flag message on stderr"
|
||||
done
|
||||
|
||||
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
|
||||
# -b --help previously consumed --help as the body and performed the write).
|
||||
expect_rc 2 "option-like value rejected" -r owner/repo -m --help
|
||||
expect_rc 2 "short flag value rejected" -r owner/repo -m -h
|
||||
expect_stderr "requires a value" "short flag value message on stderr"
|
||||
expect_stderr "requires a value" "option-like value message on stderr"
|
||||
|
||||
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
|
||||
if [[ -s "$PROBE_LOG" ]]; then
|
||||
echo "FAIL: a parser-failure arm contacted a provider:" >&2
|
||||
cat "$PROBE_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
|
||||
# parsing; the run fails at credential resolution nonzero and NOT 2.
|
||||
for flag in -r; do
|
||||
rc=0
|
||||
run_wrapper_sandboxed -r owner/repo >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
|
||||
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
|
||||
done
|
||||
|
||||
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
|
||||
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
|
||||
# comment parses, then falls back to the API. Hermeticity for this
|
||||
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
|
||||
# the arm above proves only parse acceptance and non-usage classification.
|
||||
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
|
||||
|
||||
echo "lane-brief.sh usage-contract regression passed (R1/R4)"
|
||||
@@ -1,132 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Usage-error contract for milestone-close.sh (R4, 2026-08-28).
|
||||
#
|
||||
# issue-edit already uses long-flag-first parsing (-i/--issue, -t/--title,
|
||||
# -b/--body, -l/--labels, -m/--milestone); this adds the rc=2 usage-error
|
||||
# contract, value checks, and the no-provider-contact proof. Required: -i.
|
||||
#
|
||||
# Arms:
|
||||
# 1. --help and -h exit 0 and print usage.
|
||||
# 2. Unknown option exits 2 with the message on stderr.
|
||||
# 3. Missing required -i exits 2 (stderr).
|
||||
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
|
||||
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
|
||||
# at credential resolution, nonzero and NOT 2) — no real token is
|
||||
# ever read and no provider is contacted.
|
||||
# 6. No arm performs any provider request (PATH shims record every
|
||||
# invocation; the probe log must stay empty).
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/milestone-close-usage}"
|
||||
BIN_DIR="$WORK_DIR/bin"
|
||||
PROBE_LOG="$WORK_DIR/provider-probes.log"
|
||||
OUT_FILE="$WORK_DIR/out.log"
|
||||
ERR_FILE="$WORK_DIR/err.log"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$WORK_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mkdir -p "$BIN_DIR"
|
||||
: > "$PROBE_LOG"
|
||||
|
||||
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
|
||||
# API fallback that treats a successful curl as a closed PR, so exit-0
|
||||
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
|
||||
for tool in gh tea curl; do
|
||||
cat > "$BIN_DIR/$tool" <<STUB
|
||||
#!/usr/bin/env bash
|
||||
echo "$tool \$*" >> "$PROBE_LOG"
|
||||
exit 99
|
||||
STUB
|
||||
chmod +x "$BIN_DIR/$tool"
|
||||
done
|
||||
|
||||
run_wrapper() {
|
||||
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/milestone-close.sh" "$@" )
|
||||
}
|
||||
|
||||
# Hermetic variant: neutralizes every identity/credential source the wrapper
|
||||
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
|
||||
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
|
||||
run_wrapper_sandboxed() {
|
||||
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
|
||||
(
|
||||
cd "$WORK_DIR"
|
||||
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
|
||||
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
|
||||
"$SCRIPT_DIR/milestone-close.sh" "$@"
|
||||
)
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
echo "--- stderr ---" >&2
|
||||
cat "$ERR_FILE" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
expect_rc() { # expect_rc <want> <desc> <args...>
|
||||
local want="$1" desc="$2" rc=0
|
||||
shift 2
|
||||
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
|
||||
}
|
||||
|
||||
expect_stderr() { # expect_stderr <pattern> <desc>
|
||||
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
|
||||
}
|
||||
|
||||
# 1. Help exits 0 and prints usage.
|
||||
expect_rc 0 "--help exits 0" --help
|
||||
grep -q "Usage: milestone-close.sh" "$OUT_FILE" || fail "--help did not print usage"
|
||||
expect_rc 0 "-h exits 0" -h
|
||||
|
||||
# 2. Unknown option: rc 2, stderr.
|
||||
expect_rc 2 "unknown option exits 2" --bogus
|
||||
expect_stderr "[Uu]nknown option" "unknown option names itself on stderr"
|
||||
|
||||
# 3. Missing required PR number: rc 2, stderr.
|
||||
expect_rc 2 "missing -t exits 2"
|
||||
expect_stderr "Milestone title is required" "missing -t message on stderr"
|
||||
|
||||
|
||||
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
|
||||
for flag in -t --title; do
|
||||
expect_rc 2 "value-less $flag exits 2" "$flag"
|
||||
expect_stderr "requires a value" "value-less $flag message on stderr"
|
||||
done
|
||||
|
||||
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
|
||||
# -b --help previously consumed --help as the body and performed the write).
|
||||
expect_rc 2 "option-like value rejected" -t --help --help
|
||||
expect_rc 2 "short flag value rejected" -t --help -h
|
||||
expect_stderr "requires a value" "short flag value message on stderr"
|
||||
expect_stderr "requires a value" "option-like value message on stderr"
|
||||
|
||||
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
|
||||
if [[ -s "$PROBE_LOG" ]]; then
|
||||
echo "FAIL: a parser-failure arm contacted a provider:" >&2
|
||||
cat "$PROBE_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
|
||||
# parsing; the run fails at credential resolution nonzero and NOT 2.
|
||||
for flag in -t; do
|
||||
rc=0
|
||||
run_wrapper_sandboxed -t "smoke" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
|
||||
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
|
||||
done
|
||||
|
||||
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
|
||||
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
|
||||
# comment parses, then falls back to the API. Hermeticity for this
|
||||
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
|
||||
# the arm above proves only parse acceptance and non-usage classification.
|
||||
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
|
||||
|
||||
echo "milestone-close.sh usage-contract regression passed (R1/R4)"
|
||||
@@ -1,132 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Usage-error contract for milestone-create.sh (R4, 2026-08-28).
|
||||
#
|
||||
# issue-edit already uses long-flag-first parsing (-i/--issue, -t/--title,
|
||||
# -b/--body, -l/--labels, -m/--milestone); this adds the rc=2 usage-error
|
||||
# contract, value checks, and the no-provider-contact proof. Required: -i.
|
||||
#
|
||||
# Arms:
|
||||
# 1. --help and -h exit 0 and print usage.
|
||||
# 2. Unknown option exits 2 with the message on stderr.
|
||||
# 3. Missing required -i exits 2 (stderr).
|
||||
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
|
||||
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
|
||||
# at credential resolution, nonzero and NOT 2) — no real token is
|
||||
# ever read and no provider is contacted.
|
||||
# 6. No arm performs any provider request (PATH shims record every
|
||||
# invocation; the probe log must stay empty).
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/milestone-create-usage}"
|
||||
BIN_DIR="$WORK_DIR/bin"
|
||||
PROBE_LOG="$WORK_DIR/provider-probes.log"
|
||||
OUT_FILE="$WORK_DIR/out.log"
|
||||
ERR_FILE="$WORK_DIR/err.log"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$WORK_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mkdir -p "$BIN_DIR"
|
||||
: > "$PROBE_LOG"
|
||||
|
||||
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
|
||||
# API fallback that treats a successful curl as a closed PR, so exit-0
|
||||
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
|
||||
for tool in gh tea curl; do
|
||||
cat > "$BIN_DIR/$tool" <<STUB
|
||||
#!/usr/bin/env bash
|
||||
echo "$tool \$*" >> "$PROBE_LOG"
|
||||
exit 99
|
||||
STUB
|
||||
chmod +x "$BIN_DIR/$tool"
|
||||
done
|
||||
|
||||
run_wrapper() {
|
||||
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/milestone-create.sh" "$@" )
|
||||
}
|
||||
|
||||
# Hermetic variant: neutralizes every identity/credential source the wrapper
|
||||
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
|
||||
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
|
||||
run_wrapper_sandboxed() {
|
||||
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
|
||||
(
|
||||
cd "$WORK_DIR"
|
||||
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
|
||||
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
|
||||
"$SCRIPT_DIR/milestone-create.sh" "$@"
|
||||
)
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
echo "--- stderr ---" >&2
|
||||
cat "$ERR_FILE" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
expect_rc() { # expect_rc <want> <desc> <args...>
|
||||
local want="$1" desc="$2" rc=0
|
||||
shift 2
|
||||
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
|
||||
}
|
||||
|
||||
expect_stderr() { # expect_stderr <pattern> <desc>
|
||||
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
|
||||
}
|
||||
|
||||
# 1. Help exits 0 and prints usage.
|
||||
expect_rc 0 "--help exits 0" --help
|
||||
grep -q "Usage: milestone-create.sh" "$OUT_FILE" || fail "--help did not print usage"
|
||||
expect_rc 0 "-h exits 0" -h
|
||||
|
||||
# 2. Unknown option: rc 2, stderr.
|
||||
expect_rc 2 "unknown option exits 2" --bogus
|
||||
expect_stderr "[Uu]nknown option" "unknown option names itself on stderr"
|
||||
|
||||
# 3. Missing required PR number: rc 2, stderr.
|
||||
expect_rc 2 "missing -t exits 2"
|
||||
expect_stderr "Title is required" "missing -t message on stderr"
|
||||
|
||||
|
||||
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
|
||||
for flag in -t -d --due --title --desc; do
|
||||
expect_rc 2 "value-less $flag exits 2" "$flag"
|
||||
expect_stderr "requires a value" "value-less $flag message on stderr"
|
||||
done
|
||||
|
||||
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
|
||||
# -b --help previously consumed --help as the body and performed the write).
|
||||
expect_rc 2 "option-like value rejected" -t smoke -d --help
|
||||
expect_rc 2 "short flag value rejected" -t smoke -d -h
|
||||
expect_stderr "requires a value" "short flag value message on stderr"
|
||||
expect_stderr "requires a value" "option-like value message on stderr"
|
||||
|
||||
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
|
||||
if [[ -s "$PROBE_LOG" ]]; then
|
||||
echo "FAIL: a parser-failure arm contacted a provider:" >&2
|
||||
cat "$PROBE_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
|
||||
# parsing; the run fails at credential resolution nonzero and NOT 2.
|
||||
for flag in -t; do
|
||||
rc=0
|
||||
run_wrapper_sandboxed -t "smoke" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
|
||||
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
|
||||
done
|
||||
|
||||
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
|
||||
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
|
||||
# comment parses, then falls back to the API. Hermeticity for this
|
||||
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
|
||||
# the arm above proves only parse acceptance and non-usage classification.
|
||||
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
|
||||
|
||||
echo "milestone-create.sh usage-contract regression passed (R1/R4)"
|
||||
@@ -1,153 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Usage-error contract for milestone-list.sh (R4, 2026-08-28).
|
||||
#
|
||||
# issue-edit already uses long-flag-first parsing (-i/--issue, -t/--title,
|
||||
# -b/--body, -l/--labels, -m/--milestone); this adds the rc=2 usage-error
|
||||
# contract, value checks, and the no-provider-contact proof. Required: -i.
|
||||
#
|
||||
# Arms:
|
||||
# 1. --help and -h exit 0 and print usage.
|
||||
# 2. Unknown option exits 2 with the message on stderr.
|
||||
# 3. Missing required -i exits 2 (stderr).
|
||||
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
|
||||
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
|
||||
# at credential resolution, nonzero and NOT 2) — no real token is
|
||||
# ever read and no provider is contacted.
|
||||
# 6. No arm performs any provider request (PATH shims record every
|
||||
# invocation; the probe log must stay empty).
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/milestone-list-usage}"
|
||||
BIN_DIR="$WORK_DIR/bin"
|
||||
PROBE_LOG="$WORK_DIR/provider-probes.log"
|
||||
OUT_FILE="$WORK_DIR/out.log"
|
||||
ERR_FILE="$WORK_DIR/err.log"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$WORK_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mkdir -p "$BIN_DIR"
|
||||
: > "$PROBE_LOG"
|
||||
|
||||
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
|
||||
# API fallback that treats a successful curl as a closed PR, so exit-0
|
||||
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
|
||||
for tool in gh tea curl; do
|
||||
cat > "$BIN_DIR/$tool" <<STUB
|
||||
#!/usr/bin/env bash
|
||||
echo "$tool \$*" >> "$PROBE_LOG"
|
||||
exit 99
|
||||
STUB
|
||||
chmod +x "$BIN_DIR/$tool"
|
||||
done
|
||||
|
||||
run_wrapper() {
|
||||
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/milestone-list.sh" "$@" )
|
||||
}
|
||||
|
||||
# Hermetic variant: neutralizes every identity/credential source the wrapper
|
||||
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
|
||||
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
|
||||
run_wrapper_sandboxed() {
|
||||
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
|
||||
(
|
||||
cd "$WORK_DIR"
|
||||
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
|
||||
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
|
||||
"$SCRIPT_DIR/milestone-list.sh" "$@"
|
||||
)
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
echo "--- stderr ---" >&2
|
||||
cat "$ERR_FILE" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
expect_rc() { # expect_rc <want> <desc> <args...>
|
||||
local want="$1" desc="$2" rc=0
|
||||
shift 2
|
||||
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
|
||||
}
|
||||
|
||||
expect_stderr() { # expect_stderr <pattern> <desc>
|
||||
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
|
||||
}
|
||||
|
||||
# 1. Help exits 0 and prints usage.
|
||||
expect_rc 0 "--help exits 0" --help
|
||||
grep -q "Usage: milestone-list.sh" "$OUT_FILE" || fail "--help did not print usage"
|
||||
expect_rc 0 "-h exits 0" -h
|
||||
|
||||
# 2. Unknown option: rc 2, stderr.
|
||||
expect_rc 2 "unknown option exits 2" --bogus
|
||||
expect_stderr "[Uu]nknown option" "unknown option names itself on stderr"
|
||||
|
||||
# 3. Missing required PR number: rc 2, stderr.
|
||||
|
||||
|
||||
|
||||
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
|
||||
for flag in -s --state; do
|
||||
expect_rc 2 "value-less $flag exits 2" "$flag"
|
||||
expect_stderr "requires a value" "value-less $flag message on stderr"
|
||||
done
|
||||
|
||||
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
|
||||
# -b --help previously consumed --help as the body and performed the write).
|
||||
expect_rc 2 "option-like value rejected" -s --help
|
||||
expect_rc 2 "short flag value rejected" -s -h
|
||||
expect_stderr "requires a value" "short flag value message on stderr"
|
||||
expect_stderr "requires a value" "option-like value message on stderr"
|
||||
|
||||
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
|
||||
if [[ -s "$PROBE_LOG" ]]; then
|
||||
echo "FAIL: a parser-failure arm contacted a provider:" >&2
|
||||
cat "$PROBE_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
|
||||
# parsing; the run fails at credential resolution nonzero and NOT 2.
|
||||
for flag in -s; do
|
||||
rc=0
|
||||
run_wrapper_sandboxed -s open >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
|
||||
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
|
||||
done
|
||||
|
||||
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
|
||||
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
|
||||
# comment parses, then falls back to the API. Hermeticity for this
|
||||
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
|
||||
# the arm above proves only parse acceptance and non-usage classification.
|
||||
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
|
||||
|
||||
# 6b. Provider-exit normalization (codex PR #1464): a provider stub exiting
|
||||
# 2 (its own usage-error status) must surface as wrapper exit 1, never 2.
|
||||
GH_REPO="$WORK_DIR/repo-gh"
|
||||
mkdir -p "$GH_REPO"
|
||||
git -C "$GH_REPO" init -q
|
||||
git -C "$GH_REPO" remote add origin https://github.com/acme/widgets.git
|
||||
cat > "$BIN_DIR/gh" <<GHSTUB
|
||||
#!/usr/bin/env bash
|
||||
echo "gh \$*" >> "$PROBE_LOG"
|
||||
if [[ "\$1" == "api" ]]; then exit 2; fi
|
||||
exit 0
|
||||
GHSTUB
|
||||
chmod +x "$BIN_DIR/gh"
|
||||
rc=0
|
||||
(
|
||||
cd "$GH_REPO"
|
||||
PATH="$BIN_DIR:$PATH" MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
|
||||
"$SCRIPT_DIR/milestone-list.sh" >"$OUT_FILE" 2>"$ERR_FILE"
|
||||
) || rc=$?
|
||||
[[ "$rc" -eq 1 ]] || fail "GitHub path: gh exit 2 must normalize to wrapper exit 1 (got $rc)"
|
||||
grep -q "provider" "$ERR_FILE" || fail "GitHub path: normalized provider error missing from stderr"
|
||||
|
||||
echo "milestone-list.sh usage-contract regression passed (R1/R4)"
|
||||
@@ -1,133 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Usage-error contract for pr-close.sh (R1/R4, 2026-08-28).
|
||||
#
|
||||
# R4: usage errors print to STDERR and exit 2, distinct from provider,
|
||||
# credential, and verification failures (exit 1). R1: -b/--body is the
|
||||
# canonical comment flag; -c/--comment remains a compatible alias.
|
||||
# The comment is OPTIONAL here (an issue may close without one), so unlike
|
||||
# issue-comment there is no missing-comment arm.
|
||||
#
|
||||
# Arms:
|
||||
# 1. --help and -h exit 0 and print usage.
|
||||
# 2. Unknown option exits 2 with the message on stderr.
|
||||
# 3. Missing required -i exits 2 (stderr).
|
||||
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
|
||||
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
|
||||
# at credential resolution, nonzero and NOT 2) — no real token is
|
||||
# ever read and no provider is contacted.
|
||||
# 6. No arm performs any provider request (PATH shims record every
|
||||
# invocation; the probe log must stay empty).
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-close-usage}"
|
||||
BIN_DIR="$WORK_DIR/bin"
|
||||
PROBE_LOG="$WORK_DIR/provider-probes.log"
|
||||
OUT_FILE="$WORK_DIR/out.log"
|
||||
ERR_FILE="$WORK_DIR/err.log"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$WORK_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mkdir -p "$BIN_DIR"
|
||||
: > "$PROBE_LOG"
|
||||
|
||||
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
|
||||
# API fallback that treats a successful curl as a closed PR, so exit-0
|
||||
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
|
||||
for tool in gh tea curl; do
|
||||
cat > "$BIN_DIR/$tool" <<STUB
|
||||
#!/usr/bin/env bash
|
||||
echo "$tool \$*" >> "$PROBE_LOG"
|
||||
exit 99
|
||||
STUB
|
||||
chmod +x "$BIN_DIR/$tool"
|
||||
done
|
||||
|
||||
run_wrapper() {
|
||||
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/pr-close.sh" "$@" )
|
||||
}
|
||||
|
||||
# Hermetic variant: neutralizes every identity/credential source the wrapper
|
||||
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
|
||||
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
|
||||
run_wrapper_sandboxed() {
|
||||
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
|
||||
(
|
||||
cd "$WORK_DIR"
|
||||
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
|
||||
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
|
||||
"$SCRIPT_DIR/pr-close.sh" "$@"
|
||||
)
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
echo "--- stderr ---" >&2
|
||||
cat "$ERR_FILE" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
expect_rc() { # expect_rc <want> <desc> <args...>
|
||||
local want="$1" desc="$2" rc=0
|
||||
shift 2
|
||||
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
|
||||
}
|
||||
|
||||
expect_stderr() { # expect_stderr <pattern> <desc>
|
||||
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
|
||||
}
|
||||
|
||||
# 1. Help exits 0 and prints usage.
|
||||
expect_rc 0 "--help exits 0" --help
|
||||
grep -q "Usage: pr-close.sh" "$OUT_FILE" || fail "--help did not print usage"
|
||||
expect_rc 0 "-h exits 0" -h
|
||||
|
||||
# 2. Unknown option: rc 2, stderr.
|
||||
expect_rc 2 "unknown option exits 2" --bogus
|
||||
expect_stderr "unknown option" "unknown option names itself on stderr"
|
||||
|
||||
# 3. Missing required PR number: rc 2, stderr.
|
||||
expect_rc 2 "missing -n exits 2"
|
||||
expect_stderr "PR number is required" "missing -n message on stderr"
|
||||
|
||||
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
|
||||
for flag in -n -b -c --number --body --comment; do
|
||||
expect_rc 2 "value-less $flag exits 2" "$flag"
|
||||
expect_stderr "requires a value" "value-less $flag message on stderr"
|
||||
done
|
||||
|
||||
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
|
||||
# -b --help previously consumed --help as the body and performed the write).
|
||||
expect_rc 2 "option-like value rejected" -n 5 -b --help
|
||||
expect_rc 2 "short flag value rejected" -n 5 -b -h
|
||||
expect_stderr "requires a value" "short flag value message on stderr"
|
||||
expect_stderr "requires a value" "option-like value message on stderr"
|
||||
|
||||
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
|
||||
if [[ -s "$PROBE_LOG" ]]; then
|
||||
echo "FAIL: a parser-failure arm contacted a provider:" >&2
|
||||
cat "$PROBE_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
|
||||
# parsing; the run fails at credential resolution nonzero and NOT 2.
|
||||
for flag in -b -c; do
|
||||
rc=0
|
||||
run_wrapper_sandboxed -n 5 "$flag" "closing note" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
|
||||
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
|
||||
done
|
||||
|
||||
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
|
||||
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
|
||||
# comment parses, then falls back to the API. Hermeticity for this
|
||||
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
|
||||
# the arm above proves only parse acceptance and non-usage classification.
|
||||
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
|
||||
|
||||
echo "pr-close.sh usage-contract regression passed (R1/R4)"
|
||||
@@ -1,132 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Usage-error contract for pr-create.sh (R4, 2026-08-28).
|
||||
#
|
||||
# issue-edit already uses long-flag-first parsing (-i/--issue, -t/--title,
|
||||
# -b/--body, -l/--labels, -m/--milestone); this adds the rc=2 usage-error
|
||||
# contract, value checks, and the no-provider-contact proof. Required: -i.
|
||||
#
|
||||
# Arms:
|
||||
# 1. --help and -h exit 0 and print usage.
|
||||
# 2. Unknown option exits 2 with the message on stderr.
|
||||
# 3. Missing required -i exits 2 (stderr).
|
||||
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
|
||||
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
|
||||
# at credential resolution, nonzero and NOT 2) — no real token is
|
||||
# ever read and no provider is contacted.
|
||||
# 6. No arm performs any provider request (PATH shims record every
|
||||
# invocation; the probe log must stay empty).
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-create-usage}"
|
||||
BIN_DIR="$WORK_DIR/bin"
|
||||
PROBE_LOG="$WORK_DIR/provider-probes.log"
|
||||
OUT_FILE="$WORK_DIR/out.log"
|
||||
ERR_FILE="$WORK_DIR/err.log"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$WORK_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mkdir -p "$BIN_DIR"
|
||||
: > "$PROBE_LOG"
|
||||
|
||||
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
|
||||
# API fallback that treats a successful curl as a closed PR, so exit-0
|
||||
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
|
||||
for tool in gh tea curl; do
|
||||
cat > "$BIN_DIR/$tool" <<STUB
|
||||
#!/usr/bin/env bash
|
||||
echo "$tool \$*" >> "$PROBE_LOG"
|
||||
exit 99
|
||||
STUB
|
||||
chmod +x "$BIN_DIR/$tool"
|
||||
done
|
||||
|
||||
run_wrapper() {
|
||||
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/pr-create.sh" "$@" )
|
||||
}
|
||||
|
||||
# Hermetic variant: neutralizes every identity/credential source the wrapper
|
||||
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
|
||||
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
|
||||
run_wrapper_sandboxed() {
|
||||
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
|
||||
(
|
||||
cd "$WORK_DIR"
|
||||
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
|
||||
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
|
||||
"$SCRIPT_DIR/pr-create.sh" "$@"
|
||||
)
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
echo "--- stderr ---" >&2
|
||||
cat "$ERR_FILE" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
expect_rc() { # expect_rc <want> <desc> <args...>
|
||||
local want="$1" desc="$2" rc=0
|
||||
shift 2
|
||||
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
|
||||
}
|
||||
|
||||
expect_stderr() { # expect_stderr <pattern> <desc>
|
||||
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
|
||||
}
|
||||
|
||||
# 1. Help exits 0 and prints usage.
|
||||
expect_rc 0 "--help exits 0" --help
|
||||
grep -q "Usage: pr-create.sh" "$OUT_FILE" || fail "--help did not print usage"
|
||||
expect_rc 0 "-h exits 0" -h
|
||||
|
||||
# 2. Unknown option: rc 2, stderr.
|
||||
expect_rc 2 "unknown option exits 2" --bogus
|
||||
expect_stderr "[Uu]nknown option" "unknown option names itself on stderr"
|
||||
|
||||
# 3. Missing required PR number: rc 2, stderr.
|
||||
expect_rc 2 "missing -t exits 2"
|
||||
expect_stderr "Title is required" "missing -t message on stderr"
|
||||
|
||||
|
||||
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
|
||||
for flag in -t -b -B -H -l -m -i --title --body --base --head --labels --milestone --issue; do
|
||||
expect_rc 2 "value-less $flag exits 2" "$flag"
|
||||
expect_stderr "requires a value" "value-less $flag message on stderr"
|
||||
done
|
||||
|
||||
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
|
||||
# -b --help previously consumed --help as the body and performed the write).
|
||||
expect_rc 2 "option-like value rejected" -t smoke -b --help
|
||||
expect_rc 2 "short flag value rejected" -t smoke -b -h
|
||||
expect_stderr "requires a value" "short flag value message on stderr"
|
||||
expect_stderr "requires a value" "option-like value message on stderr"
|
||||
|
||||
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
|
||||
if [[ -s "$PROBE_LOG" ]]; then
|
||||
echo "FAIL: a parser-failure arm contacted a provider:" >&2
|
||||
cat "$PROBE_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
|
||||
# parsing; the run fails at credential resolution nonzero and NOT 2.
|
||||
for flag in -b; do
|
||||
rc=0
|
||||
run_wrapper_sandboxed -t "smoke" "$flag" "value" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
|
||||
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
|
||||
done
|
||||
|
||||
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
|
||||
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
|
||||
# comment parses, then falls back to the API. Hermeticity for this
|
||||
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
|
||||
# the arm above proves only parse acceptance and non-usage classification.
|
||||
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
|
||||
|
||||
echo "pr-create.sh usage-contract regression passed (R1/R4)"
|
||||
@@ -1,132 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Usage-error contract for pr-diff.sh (R4, 2026-08-28).
|
||||
#
|
||||
# issue-edit already uses long-flag-first parsing (-i/--issue, -t/--title,
|
||||
# -b/--body, -l/--labels, -m/--milestone); this adds the rc=2 usage-error
|
||||
# contract, value checks, and the no-provider-contact proof. Required: -i.
|
||||
#
|
||||
# Arms:
|
||||
# 1. --help and -h exit 0 and print usage.
|
||||
# 2. Unknown option exits 2 with the message on stderr.
|
||||
# 3. Missing required -i exits 2 (stderr).
|
||||
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
|
||||
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
|
||||
# at credential resolution, nonzero and NOT 2) — no real token is
|
||||
# ever read and no provider is contacted.
|
||||
# 6. No arm performs any provider request (PATH shims record every
|
||||
# invocation; the probe log must stay empty).
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-diff-usage}"
|
||||
BIN_DIR="$WORK_DIR/bin"
|
||||
PROBE_LOG="$WORK_DIR/provider-probes.log"
|
||||
OUT_FILE="$WORK_DIR/out.log"
|
||||
ERR_FILE="$WORK_DIR/err.log"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$WORK_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mkdir -p "$BIN_DIR"
|
||||
: > "$PROBE_LOG"
|
||||
|
||||
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
|
||||
# API fallback that treats a successful curl as a closed PR, so exit-0
|
||||
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
|
||||
for tool in gh tea curl; do
|
||||
cat > "$BIN_DIR/$tool" <<STUB
|
||||
#!/usr/bin/env bash
|
||||
echo "$tool \$*" >> "$PROBE_LOG"
|
||||
exit 99
|
||||
STUB
|
||||
chmod +x "$BIN_DIR/$tool"
|
||||
done
|
||||
|
||||
run_wrapper() {
|
||||
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/pr-diff.sh" "$@" )
|
||||
}
|
||||
|
||||
# Hermetic variant: neutralizes every identity/credential source the wrapper
|
||||
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
|
||||
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
|
||||
run_wrapper_sandboxed() {
|
||||
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
|
||||
(
|
||||
cd "$WORK_DIR"
|
||||
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
|
||||
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
|
||||
"$SCRIPT_DIR/pr-diff.sh" "$@"
|
||||
)
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
echo "--- stderr ---" >&2
|
||||
cat "$ERR_FILE" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
expect_rc() { # expect_rc <want> <desc> <args...>
|
||||
local want="$1" desc="$2" rc=0
|
||||
shift 2
|
||||
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
|
||||
}
|
||||
|
||||
expect_stderr() { # expect_stderr <pattern> <desc>
|
||||
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
|
||||
}
|
||||
|
||||
# 1. Help exits 0 and prints usage.
|
||||
expect_rc 0 "--help exits 0" --help
|
||||
grep -q "Usage: pr-diff.sh" "$OUT_FILE" || fail "--help did not print usage"
|
||||
expect_rc 0 "-h exits 0" -h
|
||||
|
||||
# 2. Unknown option: rc 2, stderr.
|
||||
expect_rc 2 "unknown option exits 2" --bogus
|
||||
expect_stderr "[Uu]nknown option" "unknown option names itself on stderr"
|
||||
|
||||
# 3. Missing required PR number: rc 2, stderr.
|
||||
expect_rc 2 "missing -n exits 2"
|
||||
expect_stderr "PR number is required" "missing -n message on stderr"
|
||||
|
||||
|
||||
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
|
||||
for flag in -n -o -r --number --output --repo --host; do
|
||||
expect_rc 2 "value-less $flag exits 2" "$flag"
|
||||
expect_stderr "requires a value" "value-less $flag message on stderr"
|
||||
done
|
||||
|
||||
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
|
||||
# -b --help previously consumed --help as the body and performed the write).
|
||||
expect_rc 2 "option-like value rejected" -n --help
|
||||
expect_rc 2 "short flag value rejected" -n -h
|
||||
expect_stderr "requires a value" "short flag value message on stderr"
|
||||
expect_stderr "requires a value" "option-like value message on stderr"
|
||||
|
||||
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
|
||||
if [[ -s "$PROBE_LOG" ]]; then
|
||||
echo "FAIL: a parser-failure arm contacted a provider:" >&2
|
||||
cat "$PROBE_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
|
||||
# parsing; the run fails at credential resolution nonzero and NOT 2.
|
||||
for flag in -n; do
|
||||
rc=0
|
||||
run_wrapper_sandboxed -n 5 >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
|
||||
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
|
||||
done
|
||||
|
||||
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
|
||||
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
|
||||
# comment parses, then falls back to the API. Hermeticity for this
|
||||
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
|
||||
# the arm above proves only parse acceptance and non-usage classification.
|
||||
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
|
||||
|
||||
echo "pr-diff.sh usage-contract regression passed (R1/R4)"
|
||||
@@ -1,140 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Usage-error contract for pr-edit.sh (R4, 2026-08-28).
|
||||
#
|
||||
# issue-edit already uses long-flag-first parsing (-i/--issue, -t/--title,
|
||||
# -b/--body, -l/--labels, -m/--milestone); this adds the rc=2 usage-error
|
||||
# contract, value checks, and the no-provider-contact proof. Required: -i.
|
||||
#
|
||||
# Arms:
|
||||
# 1. --help and -h exit 0 and print usage.
|
||||
# 2. Unknown option exits 2 with the message on stderr.
|
||||
# 3. Missing required -i exits 2 (stderr).
|
||||
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
|
||||
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
|
||||
# at credential resolution, nonzero and NOT 2) — no real token is
|
||||
# ever read and no provider is contacted.
|
||||
# 6. No arm performs any provider request (PATH shims record every
|
||||
# invocation; the probe log must stay empty).
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-edit-usage}"
|
||||
BIN_DIR="$WORK_DIR/bin"
|
||||
PROBE_LOG="$WORK_DIR/provider-probes.log"
|
||||
OUT_FILE="$WORK_DIR/out.log"
|
||||
ERR_FILE="$WORK_DIR/err.log"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$WORK_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mkdir -p "$BIN_DIR"
|
||||
: > "$PROBE_LOG"
|
||||
|
||||
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
|
||||
# API fallback that treats a successful curl as a closed PR, so exit-0
|
||||
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
|
||||
for tool in gh tea curl; do
|
||||
cat > "$BIN_DIR/$tool" <<STUB
|
||||
#!/usr/bin/env bash
|
||||
echo "$tool \$*" >> "$PROBE_LOG"
|
||||
exit 99
|
||||
STUB
|
||||
chmod +x "$BIN_DIR/$tool"
|
||||
done
|
||||
|
||||
run_wrapper() {
|
||||
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/pr-edit.sh" "$@" )
|
||||
}
|
||||
|
||||
# Hermetic variant: neutralizes every identity/credential source the wrapper
|
||||
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
|
||||
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
|
||||
run_wrapper_sandboxed() {
|
||||
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
|
||||
(
|
||||
cd "$WORK_DIR"
|
||||
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
|
||||
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
|
||||
"$SCRIPT_DIR/pr-edit.sh" "$@"
|
||||
)
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
echo "--- stderr ---" >&2
|
||||
cat "$ERR_FILE" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
expect_rc() { # expect_rc <want> <desc> <args...>
|
||||
local want="$1" desc="$2" rc=0
|
||||
shift 2
|
||||
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
|
||||
}
|
||||
|
||||
expect_stderr() { # expect_stderr <pattern> <desc>
|
||||
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
|
||||
}
|
||||
|
||||
# 1. Help exits 0 and prints usage.
|
||||
expect_rc 0 "--help exits 0" --help
|
||||
grep -q "Usage: pr-edit.sh" "$OUT_FILE" || fail "--help did not print usage"
|
||||
expect_rc 0 "-h exits 0" -h
|
||||
|
||||
# 2. Unknown option: rc 2, stderr.
|
||||
expect_rc 2 "unknown option exits 2" --bogus
|
||||
expect_stderr "[Uu]nknown option" "unknown option names itself on stderr"
|
||||
|
||||
# 3. Missing required PR number: rc 2, stderr.
|
||||
expect_rc 2 "missing -n exits 2"
|
||||
expect_stderr "Pull request number is required" "missing -n message on stderr"
|
||||
expect_rc 2 "no edit option exits 2" -n 5
|
||||
expect_stderr "At least one edit option is required" "no-edit-option message on stderr"
|
||||
expect_rc 2 "non-integer PR number exits 2" -n abc -t x
|
||||
expect_stderr "positive integer" "integer check on stderr"
|
||||
expect_rc 2 "mutually exclusive draft/ready exits 2" -n 5 --draft --ready
|
||||
expect_stderr "mutually exclusive" "mutual exclusion on stderr"
|
||||
expect_rc 2 "bad repo format exits 2" -n 5 -t x -r not-a-slug
|
||||
expect_stderr "OWNER/REPO" "repo format on stderr"
|
||||
|
||||
|
||||
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
|
||||
for flag in -n -t -b -B -l -r -H --number --title --body --base --login --repo --host; do
|
||||
expect_rc 2 "value-less $flag exits 2" "$flag"
|
||||
expect_stderr "requires a value" "value-less $flag message on stderr"
|
||||
done
|
||||
|
||||
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
|
||||
# -b --help previously consumed --help as the body and performed the write).
|
||||
expect_rc 2 "option-like value rejected" -n 5 -t smoke -b --help
|
||||
expect_rc 2 "short flag value rejected" -n 5 -t smoke -b -h
|
||||
expect_stderr "requires a value" "short flag value message on stderr"
|
||||
expect_stderr "requires a value" "option-like value message on stderr"
|
||||
|
||||
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
|
||||
if [[ -s "$PROBE_LOG" ]]; then
|
||||
echo "FAIL: a parser-failure arm contacted a provider:" >&2
|
||||
cat "$PROBE_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
|
||||
# parsing; the run fails at credential resolution nonzero and NOT 2.
|
||||
for flag in -b; do
|
||||
rc=0
|
||||
run_wrapper_sandboxed -n 5 "$flag" "value" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
|
||||
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
|
||||
done
|
||||
|
||||
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
|
||||
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
|
||||
# comment parses, then falls back to the API. Hermeticity for this
|
||||
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
|
||||
# the arm above proves only parse acceptance and non-usage classification.
|
||||
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
|
||||
|
||||
echo "pr-edit.sh usage-contract regression passed (R1/R4)"
|
||||
@@ -1,131 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Usage-error contract for pr-list.sh (R4, 2026-08-28).
|
||||
#
|
||||
# issue-edit already uses long-flag-first parsing (-i/--issue, -t/--title,
|
||||
# -b/--body, -l/--labels, -m/--milestone); this adds the rc=2 usage-error
|
||||
# contract, value checks, and the no-provider-contact proof. Required: -i.
|
||||
#
|
||||
# Arms:
|
||||
# 1. --help and -h exit 0 and print usage.
|
||||
# 2. Unknown option exits 2 with the message on stderr.
|
||||
# 3. Missing required -i exits 2 (stderr).
|
||||
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
|
||||
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
|
||||
# at credential resolution, nonzero and NOT 2) — no real token is
|
||||
# ever read and no provider is contacted.
|
||||
# 6. No arm performs any provider request (PATH shims record every
|
||||
# invocation; the probe log must stay empty).
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-list-usage}"
|
||||
BIN_DIR="$WORK_DIR/bin"
|
||||
PROBE_LOG="$WORK_DIR/provider-probes.log"
|
||||
OUT_FILE="$WORK_DIR/out.log"
|
||||
ERR_FILE="$WORK_DIR/err.log"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$WORK_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mkdir -p "$BIN_DIR"
|
||||
: > "$PROBE_LOG"
|
||||
|
||||
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
|
||||
# API fallback that treats a successful curl as a closed PR, so exit-0
|
||||
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
|
||||
for tool in gh tea curl; do
|
||||
cat > "$BIN_DIR/$tool" <<STUB
|
||||
#!/usr/bin/env bash
|
||||
echo "$tool \$*" >> "$PROBE_LOG"
|
||||
exit 99
|
||||
STUB
|
||||
chmod +x "$BIN_DIR/$tool"
|
||||
done
|
||||
|
||||
run_wrapper() {
|
||||
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/pr-list.sh" "$@" )
|
||||
}
|
||||
|
||||
# Hermetic variant: neutralizes every identity/credential source the wrapper
|
||||
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
|
||||
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
|
||||
run_wrapper_sandboxed() {
|
||||
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
|
||||
(
|
||||
cd "$WORK_DIR"
|
||||
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
|
||||
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
|
||||
"$SCRIPT_DIR/pr-list.sh" "$@"
|
||||
)
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
echo "--- stderr ---" >&2
|
||||
cat "$ERR_FILE" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
expect_rc() { # expect_rc <want> <desc> <args...>
|
||||
local want="$1" desc="$2" rc=0
|
||||
shift 2
|
||||
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
|
||||
}
|
||||
|
||||
expect_stderr() { # expect_stderr <pattern> <desc>
|
||||
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
|
||||
}
|
||||
|
||||
# 1. Help exits 0 and prints usage.
|
||||
expect_rc 0 "--help exits 0" --help
|
||||
grep -q "Usage: pr-list.sh" "$OUT_FILE" || fail "--help did not print usage"
|
||||
expect_rc 0 "-h exits 0" -h
|
||||
|
||||
# 2. Unknown option: rc 2, stderr.
|
||||
expect_rc 2 "unknown option exits 2" --bogus
|
||||
expect_stderr "[Uu]nknown option" "unknown option names itself on stderr"
|
||||
|
||||
# 3. Missing required PR number: rc 2, stderr.
|
||||
|
||||
|
||||
|
||||
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
|
||||
for flag in -s -l -a -n -r --state --label --author --limit --repo; do
|
||||
expect_rc 2 "value-less $flag exits 2" "$flag"
|
||||
expect_stderr "requires a value" "value-less $flag message on stderr"
|
||||
done
|
||||
|
||||
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
|
||||
# -b --help previously consumed --help as the body and performed the write).
|
||||
expect_rc 2 "option-like value rejected" -s --help
|
||||
expect_rc 2 "short flag value rejected" -s -h
|
||||
expect_stderr "requires a value" "short flag value message on stderr"
|
||||
expect_stderr "requires a value" "option-like value message on stderr"
|
||||
|
||||
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
|
||||
if [[ -s "$PROBE_LOG" ]]; then
|
||||
echo "FAIL: a parser-failure arm contacted a provider:" >&2
|
||||
cat "$PROBE_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
|
||||
# parsing; the run fails at credential resolution nonzero and NOT 2.
|
||||
for flag in -s; do
|
||||
rc=0
|
||||
run_wrapper_sandboxed -s open >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
|
||||
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
|
||||
done
|
||||
|
||||
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
|
||||
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
|
||||
# comment parses, then falls back to the API. Hermeticity for this
|
||||
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
|
||||
# the arm above proves only parse acceptance and non-usage classification.
|
||||
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
|
||||
|
||||
echo "pr-list.sh usage-contract regression passed (R1/R4)"
|
||||
@@ -1,68 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# B5 (ruled 2026-08-29, orch-01-adopted): pr-merge --base-line — the
|
||||
# documented intra-line exception. Arms:
|
||||
# 1. Base neither main nor next, no flag: policy refusal (rc 1), queue
|
||||
# guard NOT invoked, hint names the exception.
|
||||
# 2. Base neither main nor next, matching --base-line: authorized; the
|
||||
# queue guard IS invoked with the same args (rc from the stub proves
|
||||
# gates still run) and the audit line is emitted.
|
||||
# 3. Mismatched --base-line (different branch than the PR base): refusal
|
||||
# rc 1 with the mismatch named; queue guard NOT invoked.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-merge-baseline}"
|
||||
FIXTURE_DIR="$WORK_DIR/tools/git"
|
||||
CALL_LOG="$WORK_DIR/queue-call.log"
|
||||
OUT_LOG="$WORK_DIR/out.log"
|
||||
|
||||
rm -rf "$WORK_DIR"
|
||||
mkdir -p "$FIXTURE_DIR"
|
||||
cp "$SCRIPT_DIR/pr-merge.sh" "$FIXTURE_DIR/pr-merge.sh"
|
||||
cp "$SCRIPT_DIR/detect-platform.sh" "$FIXTURE_DIR/detect-platform.sh"
|
||||
|
||||
cat > "$FIXTURE_DIR/pr-metadata.sh" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' '{"baseRefName":"mosaic-cli-p0","baseRepository":"mosaicstack/stack","headRefName":"mosaic-cli-p2-socket-res","headRefOid":"0123456789abcdef0123456789abcdef01234567","headRepository":"mosaicstack/stack"}'
|
||||
SH
|
||||
|
||||
cat > "$FIXTURE_DIR/ci-queue-wait.sh" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' "$*" > "${MOSAIC_QUEUE_CALL_LOG:?}"
|
||||
exit 42
|
||||
SH
|
||||
chmod +x "$FIXTURE_DIR"/*.sh
|
||||
|
||||
run_case() { # run_case <extra-args...>
|
||||
: > "$CALL_LOG"
|
||||
set +e
|
||||
(
|
||||
cd "$WORK_DIR"
|
||||
export MOSAIC_QUEUE_CALL_LOG="$CALL_LOG"
|
||||
"$FIXTURE_DIR/pr-merge.sh" -n 123 "$@"
|
||||
) >"$OUT_LOG" 2>&1
|
||||
rc=$?
|
||||
set -e
|
||||
}
|
||||
|
||||
# 1. No flag: policy refusal, no gate invocation.
|
||||
run_case
|
||||
[[ "$rc" -eq 1 ]] || { echo "FAIL arm1: rc=$rc want 1" >&2; cat "$OUT_LOG" >&2; exit 1; }
|
||||
[[ ! -s "$CALL_LOG" ]] || { echo "FAIL arm1: queue guard ran without authorization" >&2; exit 1; }
|
||||
grep -q "only for PRs targeting" "$OUT_LOG" || { echo "FAIL arm1: policy message missing" >&2; exit 1; }
|
||||
grep -q -- "--base-line 'mosaic-cli-p0'" "$OUT_LOG" || { echo "FAIL arm1: hint missing" >&2; exit 1; }
|
||||
|
||||
# 2. Matching flag: authorized, audit line emitted, gates RUN (stub rc 42).
|
||||
run_case --base-line mosaic-cli-p0
|
||||
[[ "$rc" -eq 42 ]] || { echo "FAIL arm2: rc=$rc want 42 (gate stub rc must propagate)" >&2; cat "$OUT_LOG" >&2; exit 1; }
|
||||
[[ -s "$CALL_LOG" ]] || { echo "FAIL arm2: queue guard NOT invoked despite authorization" >&2; exit 1; }
|
||||
grep -q -- '-B mosaic-cli-p2-socket-res' "$CALL_LOG" || { echo "FAIL arm2: guard args wrong" >&2; cat "$CALL_LOG" >&2; exit 1; }
|
||||
grep -q "base-line exception" "$OUT_LOG" || { echo "FAIL arm2: audit line missing" >&2; exit 1; }
|
||||
|
||||
# 3. Mismatched flag: refusal, mismatch named, no gate invocation.
|
||||
run_case --base-line some-other-line
|
||||
[[ "$rc" -eq 1 ]] || { echo "FAIL arm3: rc=$rc want 1" >&2; cat "$OUT_LOG" >&2; exit 1; }
|
||||
[[ ! -s "$CALL_LOG" ]] || { echo "FAIL arm3: queue guard ran on a refused merge" >&2; exit 1; }
|
||||
grep -q "does not match the PR base" "$OUT_LOG" || { echo "FAIL arm3: mismatch message missing" >&2; exit 1; }
|
||||
|
||||
echo "pr-merge --base-line exception regression passed (B5)"
|
||||
@@ -1,132 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Usage-error contract for pr-metadata.sh (R4, 2026-08-28).
|
||||
#
|
||||
# issue-edit already uses long-flag-first parsing (-i/--issue, -t/--title,
|
||||
# -b/--body, -l/--labels, -m/--milestone); this adds the rc=2 usage-error
|
||||
# contract, value checks, and the no-provider-contact proof. Required: -i.
|
||||
#
|
||||
# Arms:
|
||||
# 1. --help and -h exit 0 and print usage.
|
||||
# 2. Unknown option exits 2 with the message on stderr.
|
||||
# 3. Missing required -i exits 2 (stderr).
|
||||
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
|
||||
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
|
||||
# at credential resolution, nonzero and NOT 2) — no real token is
|
||||
# ever read and no provider is contacted.
|
||||
# 6. No arm performs any provider request (PATH shims record every
|
||||
# invocation; the probe log must stay empty).
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-metadata-usage}"
|
||||
BIN_DIR="$WORK_DIR/bin"
|
||||
PROBE_LOG="$WORK_DIR/provider-probes.log"
|
||||
OUT_FILE="$WORK_DIR/out.log"
|
||||
ERR_FILE="$WORK_DIR/err.log"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$WORK_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mkdir -p "$BIN_DIR"
|
||||
: > "$PROBE_LOG"
|
||||
|
||||
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
|
||||
# API fallback that treats a successful curl as a closed PR, so exit-0
|
||||
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
|
||||
for tool in gh tea curl; do
|
||||
cat > "$BIN_DIR/$tool" <<STUB
|
||||
#!/usr/bin/env bash
|
||||
echo "$tool \$*" >> "$PROBE_LOG"
|
||||
exit 99
|
||||
STUB
|
||||
chmod +x "$BIN_DIR/$tool"
|
||||
done
|
||||
|
||||
run_wrapper() {
|
||||
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/pr-metadata.sh" "$@" )
|
||||
}
|
||||
|
||||
# Hermetic variant: neutralizes every identity/credential source the wrapper
|
||||
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
|
||||
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
|
||||
run_wrapper_sandboxed() {
|
||||
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
|
||||
(
|
||||
cd "$WORK_DIR"
|
||||
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
|
||||
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
|
||||
"$SCRIPT_DIR/pr-metadata.sh" "$@"
|
||||
)
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
echo "--- stderr ---" >&2
|
||||
cat "$ERR_FILE" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
expect_rc() { # expect_rc <want> <desc> <args...>
|
||||
local want="$1" desc="$2" rc=0
|
||||
shift 2
|
||||
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
|
||||
}
|
||||
|
||||
expect_stderr() { # expect_stderr <pattern> <desc>
|
||||
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
|
||||
}
|
||||
|
||||
# 1. Help exits 0 and prints usage.
|
||||
expect_rc 0 "--help exits 0" --help
|
||||
grep -q "Usage: pr-metadata.sh" "$OUT_FILE" || fail "--help did not print usage"
|
||||
expect_rc 0 "-h exits 0" -h
|
||||
|
||||
# 2. Unknown option: rc 2, stderr.
|
||||
expect_rc 2 "unknown option exits 2" --bogus
|
||||
expect_stderr "[Uu]nknown option" "unknown option names itself on stderr"
|
||||
|
||||
# 3. Missing required PR number: rc 2, stderr.
|
||||
expect_rc 2 "missing -n exits 2"
|
||||
expect_stderr "PR number is required" "missing -n message on stderr"
|
||||
|
||||
|
||||
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
|
||||
for flag in -n -o --number --output; do
|
||||
expect_rc 2 "value-less $flag exits 2" "$flag"
|
||||
expect_stderr "requires a value" "value-less $flag message on stderr"
|
||||
done
|
||||
|
||||
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
|
||||
# -b --help previously consumed --help as the body and performed the write).
|
||||
expect_rc 2 "option-like value rejected" -n --help
|
||||
expect_rc 2 "short flag value rejected" -n -h
|
||||
expect_stderr "requires a value" "short flag value message on stderr"
|
||||
expect_stderr "requires a value" "option-like value message on stderr"
|
||||
|
||||
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
|
||||
if [[ -s "$PROBE_LOG" ]]; then
|
||||
echo "FAIL: a parser-failure arm contacted a provider:" >&2
|
||||
cat "$PROBE_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
|
||||
# parsing; the run fails at credential resolution nonzero and NOT 2.
|
||||
for flag in -n; do
|
||||
rc=0
|
||||
run_wrapper_sandboxed -n 5 >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
|
||||
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
|
||||
done
|
||||
|
||||
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
|
||||
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
|
||||
# comment parses, then falls back to the API. Hermeticity for this
|
||||
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
|
||||
# the arm above proves only parse acceptance and non-usage classification.
|
||||
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
|
||||
|
||||
echo "pr-metadata.sh usage-contract regression passed (R1/R4)"
|
||||
Executable → Regular
@@ -218,7 +218,7 @@ if grep -q 'Unknown option' "$OUTPUT_FILE"; then
|
||||
cat "$OUTPUT_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -q "unknown action 'bogus-action'" "$OUTPUT_FILE"
|
||||
grep -q 'Unknown action: bogus-action' "$OUTPUT_FILE"
|
||||
|
||||
# --- Case 2: -h/--help documents both overrides.
|
||||
HELP_TEXT="$("$SCRIPT_DIR/pr-review.sh" -h)"
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Usage-error contract for pr-review.sh (R1/R4, 2026-08-28).
|
||||
#
|
||||
# R4: usage errors print to STDERR and exit 2, distinct from provider,
|
||||
# credential, and verification failures (exit 1). R1: -b/--body is the
|
||||
# canonical comment flag; -c/--comment remains a compatible alias.
|
||||
# Required: -n AND -a. The comment is required only for the
|
||||
# request-changes action (semantic usage check, also rc 2).
|
||||
#
|
||||
# Arms:
|
||||
# 1. --help and -h exit 0 and print usage.
|
||||
# 2. Unknown option exits 2 with the message on stderr.
|
||||
# 3. Missing required -i exits 2 (stderr).
|
||||
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
|
||||
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
|
||||
# at credential resolution, nonzero and NOT 2) — no real token is
|
||||
# ever read and no provider is contacted.
|
||||
# 6. No arm performs any provider request (PATH shims record every
|
||||
# invocation; the probe log must stay empty).
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-review-usage}"
|
||||
BIN_DIR="$WORK_DIR/bin"
|
||||
PROBE_LOG="$WORK_DIR/provider-probes.log"
|
||||
OUT_FILE="$WORK_DIR/out.log"
|
||||
ERR_FILE="$WORK_DIR/err.log"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$WORK_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mkdir -p "$BIN_DIR"
|
||||
: > "$PROBE_LOG"
|
||||
|
||||
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
|
||||
# API fallback that treats a successful curl as a closed PR, so exit-0
|
||||
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
|
||||
for tool in gh tea curl; do
|
||||
cat > "$BIN_DIR/$tool" <<STUB
|
||||
#!/usr/bin/env bash
|
||||
echo "$tool \$*" >> "$PROBE_LOG"
|
||||
exit 99
|
||||
STUB
|
||||
chmod +x "$BIN_DIR/$tool"
|
||||
done
|
||||
|
||||
run_wrapper() {
|
||||
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/pr-review.sh" "$@" )
|
||||
}
|
||||
|
||||
# Hermetic variant: neutralizes every identity/credential source the wrapper
|
||||
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
|
||||
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
|
||||
run_wrapper_sandboxed() {
|
||||
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
|
||||
(
|
||||
cd "$WORK_DIR"
|
||||
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
|
||||
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
|
||||
"$SCRIPT_DIR/pr-review.sh" "$@"
|
||||
)
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
echo "--- stderr ---" >&2
|
||||
cat "$ERR_FILE" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
expect_rc() { # expect_rc <want> <desc> <args...>
|
||||
local want="$1" desc="$2" rc=0
|
||||
shift 2
|
||||
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
|
||||
}
|
||||
|
||||
expect_stderr() { # expect_stderr <pattern> <desc>
|
||||
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
|
||||
}
|
||||
|
||||
# 1. Help exits 0 and prints usage.
|
||||
expect_rc 0 "--help exits 0" --help
|
||||
grep -q "Usage: pr-review.sh" "$OUT_FILE" || fail "--help did not print usage"
|
||||
expect_rc 0 "-h exits 0" -h
|
||||
|
||||
# 2. Unknown option: rc 2, stderr.
|
||||
expect_rc 2 "unknown option exits 2" --bogus
|
||||
expect_stderr "unknown option" "unknown option names itself on stderr"
|
||||
|
||||
# 3. Missing required PR number: rc 2, stderr.
|
||||
expect_rc 2 "missing -n exits 2"
|
||||
expect_stderr "PR number is required" "missing -n message on stderr"
|
||||
expect_rc 2 "missing -a exits 2" -n 5
|
||||
expect_stderr "Action is required" "missing -a message on stderr"
|
||||
expect_rc 2 "request-changes without comment exits 2" -n 5 -a request-changes
|
||||
expect_stderr "comment required for request-changes" "request-changes message on stderr"
|
||||
expect_rc 2 "comment without body exits 2 pre-detection" -n 5 -a comment
|
||||
expect_stderr "comment required for comment" "comment-without-body message on stderr"
|
||||
expect_rc 2 "invalid action exits 2 pre-detection" -n 5 -a bogus
|
||||
expect_stderr "unknown action" "invalid action message on stderr"
|
||||
# Invalid-action arms must not contact any provider (validation precedes
|
||||
# detect_platform): probe log empty at this point.
|
||||
if [[ -s "$PROBE_LOG" ]]; then
|
||||
echo "FAIL: an invalid-action arm contacted a provider:" >&2
|
||||
cat "$PROBE_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
|
||||
for flag in -n -a -b -c -l -r --number --action --body --comment --login --repo; do
|
||||
expect_rc 2 "value-less $flag exits 2" "$flag"
|
||||
expect_stderr "requires a value" "value-less $flag message on stderr"
|
||||
done
|
||||
|
||||
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
|
||||
# -b --help previously consumed --help as the body and performed the write).
|
||||
expect_rc 2 "option-like value rejected" -n 5 -a comment -b --help
|
||||
expect_rc 2 "short flag value rejected" -n 5 -a comment -b -h
|
||||
expect_stderr "requires a value" "short flag value message on stderr"
|
||||
expect_stderr "requires a value" "option-like value message on stderr"
|
||||
|
||||
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
|
||||
if [[ -s "$PROBE_LOG" ]]; then
|
||||
echo "FAIL: a parser-failure arm contacted a provider:" >&2
|
||||
cat "$PROBE_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
|
||||
# parsing; the run fails at credential resolution nonzero and NOT 2.
|
||||
for flag in -b -c; do
|
||||
rc=0
|
||||
run_wrapper_sandboxed -n 5 -a comment "$flag" "review note" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
|
||||
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
|
||||
done
|
||||
|
||||
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
|
||||
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
|
||||
# comment parses, then falls back to the API. Hermeticity for this
|
||||
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
|
||||
# the arm above proves only parse acceptance and non-usage classification.
|
||||
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
|
||||
|
||||
echo "pr-review.sh usage-contract regression passed (R1/R4)"
|
||||
@@ -1,132 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Usage-error contract for pr-view.sh (R4, 2026-08-28).
|
||||
#
|
||||
# issue-edit already uses long-flag-first parsing (-i/--issue, -t/--title,
|
||||
# -b/--body, -l/--labels, -m/--milestone); this adds the rc=2 usage-error
|
||||
# contract, value checks, and the no-provider-contact proof. Required: -i.
|
||||
#
|
||||
# Arms:
|
||||
# 1. --help and -h exit 0 and print usage.
|
||||
# 2. Unknown option exits 2 with the message on stderr.
|
||||
# 3. Missing required -i exits 2 (stderr).
|
||||
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
|
||||
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
|
||||
# at credential resolution, nonzero and NOT 2) — no real token is
|
||||
# ever read and no provider is contacted.
|
||||
# 6. No arm performs any provider request (PATH shims record every
|
||||
# invocation; the probe log must stay empty).
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-view-usage}"
|
||||
BIN_DIR="$WORK_DIR/bin"
|
||||
PROBE_LOG="$WORK_DIR/provider-probes.log"
|
||||
OUT_FILE="$WORK_DIR/out.log"
|
||||
ERR_FILE="$WORK_DIR/err.log"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$WORK_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mkdir -p "$BIN_DIR"
|
||||
: > "$PROBE_LOG"
|
||||
|
||||
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
|
||||
# API fallback that treats a successful curl as a closed PR, so exit-0
|
||||
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
|
||||
for tool in gh tea curl; do
|
||||
cat > "$BIN_DIR/$tool" <<STUB
|
||||
#!/usr/bin/env bash
|
||||
echo "$tool \$*" >> "$PROBE_LOG"
|
||||
exit 99
|
||||
STUB
|
||||
chmod +x "$BIN_DIR/$tool"
|
||||
done
|
||||
|
||||
run_wrapper() {
|
||||
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/pr-view.sh" "$@" )
|
||||
}
|
||||
|
||||
# Hermetic variant: neutralizes every identity/credential source the wrapper
|
||||
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
|
||||
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
|
||||
run_wrapper_sandboxed() {
|
||||
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
|
||||
(
|
||||
cd "$WORK_DIR"
|
||||
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
|
||||
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
|
||||
"$SCRIPT_DIR/pr-view.sh" "$@"
|
||||
)
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
echo "--- stderr ---" >&2
|
||||
cat "$ERR_FILE" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
expect_rc() { # expect_rc <want> <desc> <args...>
|
||||
local want="$1" desc="$2" rc=0
|
||||
shift 2
|
||||
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
|
||||
}
|
||||
|
||||
expect_stderr() { # expect_stderr <pattern> <desc>
|
||||
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
|
||||
}
|
||||
|
||||
# 1. Help exits 0 and prints usage.
|
||||
expect_rc 0 "--help exits 0" --help
|
||||
grep -q "Usage: pr-view.sh" "$OUT_FILE" || fail "--help did not print usage"
|
||||
expect_rc 0 "-h exits 0" -h
|
||||
|
||||
# 2. Unknown option: rc 2, stderr.
|
||||
expect_rc 2 "unknown option exits 2" --bogus
|
||||
expect_stderr "[Uu]nknown option" "unknown option names itself on stderr"
|
||||
|
||||
# 3. Missing required PR number: rc 2, stderr.
|
||||
expect_rc 2 "missing -n exits 2"
|
||||
expect_stderr "PR number is required" "missing -n message on stderr"
|
||||
|
||||
|
||||
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
|
||||
for flag in -n -r --number --repo; do
|
||||
expect_rc 2 "value-less $flag exits 2" "$flag"
|
||||
expect_stderr "requires a value" "value-less $flag message on stderr"
|
||||
done
|
||||
|
||||
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
|
||||
# -b --help previously consumed --help as the body and performed the write).
|
||||
expect_rc 2 "option-like value rejected" -n --help
|
||||
expect_rc 2 "short flag value rejected" -n -h
|
||||
expect_stderr "requires a value" "short flag value message on stderr"
|
||||
expect_stderr "requires a value" "option-like value message on stderr"
|
||||
|
||||
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
|
||||
if [[ -s "$PROBE_LOG" ]]; then
|
||||
echo "FAIL: a parser-failure arm contacted a provider:" >&2
|
||||
cat "$PROBE_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
|
||||
# parsing; the run fails at credential resolution nonzero and NOT 2.
|
||||
for flag in -n; do
|
||||
rc=0
|
||||
run_wrapper_sandboxed -n 5 >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
|
||||
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
|
||||
done
|
||||
|
||||
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
|
||||
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
|
||||
# comment parses, then falls back to the API. Hermeticity for this
|
||||
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
|
||||
# the arm above proves only parse acceptance and non-usage classification.
|
||||
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
|
||||
|
||||
echo "pr-view.sh usage-contract regression passed (R1/R4)"
|
||||
@@ -14,6 +14,7 @@
|
||||
packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh | resolves real credentials (#1007 census); joins CI after the wrapper-half hermeticity fix (git -C scoping)
|
||||
packages/mosaic/framework/tools/git/test-issue-create-interactive-auth.sh | resolves real credentials (#1007 census); joins CI after the wrapper-half hermeticity fix
|
||||
packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh | resolves real credentials (#1007 census, fourth entry via family-grep); joins CI after the wrapper-half hermeticity fix
|
||||
packages/mosaic/framework/tools/git/test-issue-comment-readback.sh | resolves real credentials (#1007 census, fifth entry); joins CI after the wrapper-half hermeticity fix
|
||||
|
||||
# --- tools/git: push guards — measured green locally, CI-image fitness unverified ---
|
||||
packages/mosaic/framework/tools/git/test-push-guard.sh | measured green at 826a8b3b (46 passed / 0 failed, one run, 2026-07-31); CI-image fitness unverified; #1017 burndown
|
||||
|
||||
@@ -35,9 +35,6 @@
|
||||
#
|
||||
# OPTIONS
|
||||
# -L NAME tmux socket name passed to `tmux -L NAME` on the target host
|
||||
#
|
||||
# Exit 4: local target session exists on multiple socket servers and no
|
||||
# -L / MOSAIC_TMUX_SOCKET disambiguated it (B1 stale-twin guard).
|
||||
# -s DST_SESSION target tmux session (or session:window.pane) [required]
|
||||
# -H SSH_TARGET ssh target (user@host) for a remote pane; omit for local
|
||||
# -n DST_HOST hostname to show in the preamble for the target.
|
||||
@@ -66,10 +63,8 @@
|
||||
# group 1 = src label group 2 = dst host:session
|
||||
# group 3 = class (absent => actionable) group 4 = message body
|
||||
#
|
||||
# EXIT CODES (passed through from send-message.sh, except 4)
|
||||
# EXIT CODES (passed through from send-message.sh)
|
||||
# 0 delivered/queued · 1 target not found · 2 still draft · 3 usage error
|
||||
# 4 agent-send refusal: local target session exists on multiple socket
|
||||
# servers and no -L / MOSAIC_TMUX_SOCKET disambiguated it (B1)
|
||||
set -uo pipefail
|
||||
|
||||
SELF_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
|
||||
@@ -159,61 +154,6 @@ FULL="${PREAMBLE} ${MSG}"
|
||||
B64=$(printf '%s' "$FULL" | base64 -w0)
|
||||
|
||||
vflag=""; [ "$VERBOSE" = 1 ] && vflag="-v"
|
||||
|
||||
# Exact session matching for the sender target (codex PR #1466): without
|
||||
# '=', tmux target syntax accepts an unambiguous PREFIX, so a delivery
|
||||
# aimed at session X can land in X-old. Compound targets (session:win.pane)
|
||||
# and already-exact ('=...') forms pass through untouched. Computed BEFORE
|
||||
# socket discovery so the discovery probes use the same target semantics
|
||||
# (probing '==name' for an already-exact input was a false-negative hit).
|
||||
DST_TARGET="$DST_SESSION"
|
||||
case "$DST_SESSION" in
|
||||
=*) ;;
|
||||
*:*)
|
||||
# Compound target (session:win.pane): pin the SESSION component exact
|
||||
# (=session:win.pane); unpinned, the session part still prefix-matches
|
||||
# (codex PR #1466: 'agent:0.0' can resolve into 'agent-old').
|
||||
DST_TARGET="=${DST_SESSION%%:*}:${DST_SESSION#*:}"
|
||||
;;
|
||||
*) DST_TARGET="=$DST_SESSION" ;;
|
||||
esac
|
||||
|
||||
# Socket default resolution (B1, 2026-08-29). Precedence: explicit -L >
|
||||
# launcher-exported MOSAIC_TMUX_SOCKET > unique socket hit > refusal on
|
||||
# ambiguity > tmux default socket. The ambiguity refusal fires ONLY when
|
||||
# no explicit or env choice exists and the session name lives on multiple
|
||||
# servers (measured 2026-08-28/29: tasking sends landed in a stale
|
||||
# default-socket twin; rc 0 reported honest delivery to the wrong pane).
|
||||
# Socket discovery scans tmux's own socket dir, ${TMUX_TMPDIR:-/tmp}/tmux-UID
|
||||
# (codex PR #1466: TMPDIR is not where tmux keeps -L sockets).
|
||||
# MOSAIC_TMUX_SOCKET is LOCAL-host state (launcher-exported): it must not
|
||||
# leak into remote sends, where -L would target a socket on the remote
|
||||
# host (codex PR #1466).
|
||||
if [ -z "$SOCKET_NAME" ] && [ -z "$SSH_TARGET" ] && [ -n "${MOSAIC_TMUX_SOCKET:-}" ]; then
|
||||
SOCKET_NAME="$MOSAIC_TMUX_SOCKET"
|
||||
fi
|
||||
if [ -z "$SOCKET_NAME" ] && [ -z "$SSH_TARGET" ]; then
|
||||
socket_dir="${TMUX_TMPDIR:-/tmp}/tmux-$(id -u)"
|
||||
hits=""
|
||||
for sf in "$socket_dir"/*; do
|
||||
[ -S "$sf" ] || continue
|
||||
sname="${sf##*/}"
|
||||
# '=' forces exact session-name matching: tmux target syntax otherwise
|
||||
# accepts an unambiguous PREFIX, so a session named X-old on a socket
|
||||
# would count as a false hit for target X (codex PR #1466).
|
||||
tmux -L "$sname" has-session -t "$DST_TARGET" 2>/dev/null && hits="$hits$sname"$'\n'
|
||||
done
|
||||
hit_count=$(printf '%s' "$hits" | grep -c . || true)
|
||||
if [ "$hit_count" -gt 1 ]; then
|
||||
echo "agent-send.sh: REFUSING - session '$DST_SESSION' exists on multiple sockets:" >&2
|
||||
printf ' %s\n' $hits >&2
|
||||
echo " Pass -L <socket> explicitly (or export MOSAIC_TMUX_SOCKET to disambiguate)." >&2
|
||||
exit 4
|
||||
elif [ "$hit_count" -eq 1 ]; then
|
||||
SOCKET_NAME="$(printf '%s' "$hits")"
|
||||
fi
|
||||
fi
|
||||
|
||||
socket_args=()
|
||||
if [ -n "$SOCKET_NAME" ]; then
|
||||
socket_args=(-L "$SOCKET_NAME")
|
||||
@@ -221,9 +161,9 @@ fi
|
||||
|
||||
if [ -z "$SSH_TARGET" ]; then
|
||||
# Local pane: call the canonical sender directly.
|
||||
exec "$SENDER" "${socket_args[@]}" -t "$DST_TARGET" -b "$B64" -r "$RETRIES" $vflag
|
||||
exec "$SENDER" "${socket_args[@]}" -t "$DST_SESSION" -b "$B64" -r "$RETRIES" $vflag
|
||||
else
|
||||
# Remote pane: ship the sender over ssh and run it local to the target.
|
||||
ssh -o ConnectTimeout=10 "$SSH_TARGET" \
|
||||
"bash -s -- ${socket_args[*]@Q} -t '$DST_TARGET' -b '$B64' -r '$RETRIES' $vflag" < "$SENDER"
|
||||
"bash -s -- ${socket_args[*]@Q} -t '$DST_SESSION' -b '$B64' -r '$RETRIES' $vflag" < "$SENDER"
|
||||
fi
|
||||
|
||||
@@ -8,15 +8,7 @@ SOCKET="mosaic-test-$RANDOM-$$"
|
||||
TARGET="target-$RANDOM"
|
||||
DEFAULT_TARGET="default-target-$RANDOM"
|
||||
TMPDIR=$(mktemp -d)
|
||||
ART_OUT=$(mktemp)
|
||||
AMB_OUT=$(mktemp)
|
||||
AMB_ERR=$(mktemp)
|
||||
A2_OUT=$(mktemp)
|
||||
A2_ERR=$(mktemp)
|
||||
UNIQ_OUT=$(mktemp)
|
||||
UNIQ_ERR=$(mktemp)
|
||||
TWIN="twin-$RANDOM-$$"
|
||||
trap 'tmux -L "$SOCKET" kill-server >/dev/null 2>&1 || true; tmux kill-session -t "$DEFAULT_TARGET" >/dev/null 2>&1 || true; tmux kill-session -t "$TWIN" >/dev/null 2>&1 || true; rm -rf "$TMPDIR" $ART_OUT $AMB_OUT $AMB_ERR $A2_OUT $A2_ERR $UNIQ_OUT $UNIQ_ERR' EXIT
|
||||
trap 'tmux -L "$SOCKET" kill-server >/dev/null 2>&1 || true; tmux kill-session -t "$DEFAULT_TARGET" >/dev/null 2>&1 || true; rm -rf "$TMPDIR"' EXIT
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
@@ -87,83 +79,4 @@ for i in $(seq 1 "$CONC_N"); do
|
||||
done
|
||||
done
|
||||
|
||||
# B1 (2026-08-29): socket default resolution in agent-send.sh. Measured
|
||||
# defect: tasking sends without -L landed in a stale default-socket twin of
|
||||
# the target seat; rc 0 reported honest delivery to the wrong pane.
|
||||
|
||||
# Arm A: session on MULTIPLE sockets, no -L -> refuse with rc 4 naming both.
|
||||
tmux -L "$SOCKET" new-session -d -s "$TWIN" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
tmux new-session -d -s "$TWIN" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
amb_rc=0
|
||||
env -u MOSAIC_TMUX_SOCKET "$AGENT_SEND" -s "$TWIN" -m "must refuse" >$AMB_OUT 2>$AMB_ERR || amb_rc=$?
|
||||
[ "$amb_rc" -eq 4 ] || fail "ambiguity refusal: rc=$amb_rc want 4 (stderr: $(cat $AMB_ERR))"
|
||||
grep -q "multiple sockets" $AMB_ERR || fail "ambiguity refusal message missing socket list"
|
||||
grep -qF "$SOCKET" $AMB_ERR || fail "ambiguity refusal message does not name the test socket"
|
||||
tmux kill-session -t "$TWIN" >/dev/null 2>&1 || true
|
||||
tmux -L "$SOCKET" kill-session -t "$TWIN" >/dev/null 2>&1 || true
|
||||
|
||||
# Arm A2: with MOSAIC_TMUX_SOCKET exported, a twin session is NOT ambiguous:
|
||||
# the env var disambiguates by precedence (codex PR #1466 blocker).
|
||||
tmux -L "$SOCKET" new-session -d -s "$TWIN" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
tmux new-session -d -s "$TWIN" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
a2_rc=0
|
||||
MOSAIC_TMUX_SOCKET="$SOCKET" "$AGENT_SEND" -s "$TWIN" -m "env disambiguated" >$A2_OUT 2>$A2_ERR || a2_rc=$?
|
||||
[ "$a2_rc" -eq 0 ] || fail "env disambiguation: rc=$a2_rc (stderr: $(cat $A2_ERR))"
|
||||
sleep 0.2
|
||||
a2_pane="$(tmux -L "$SOCKET" capture-pane -t "=$TWIN:0.0" -p)" || fail "cannot capture twin (arm A2)"
|
||||
grep -qF "env disambiguated" <<<"$a2_pane" || fail "env disambiguation did not deliver on the named socket"
|
||||
a2_default="$(tmux capture-pane -t "=$TWIN:0.0" -p)" || true
|
||||
if grep -qF "env disambiguated" <<<"$a2_default"; then
|
||||
fail "env disambiguation cross-delivered to the default-socket twin"
|
||||
fi
|
||||
tmux kill-session -t "$TWIN" >/dev/null 2>&1 || true
|
||||
tmux -L "$SOCKET" kill-session -t "$TWIN" >/dev/null 2>&1 || true
|
||||
|
||||
# Arm B: session unique to ONE socket, no -L -> auto-resolve to that socket
|
||||
# and deliver there.
|
||||
# Arm A3: prefix matching must not produce false socket hits (codex PR
|
||||
# #1466): a session named TWIN-old must not count as a hit for target
|
||||
# TWIN (tmux target syntax prefix-matches without '=').
|
||||
PSEUDO="${TWIN}-old"
|
||||
tmux new-session -d -s "$PSEUDO" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
A3_ERR=$(mktemp)
|
||||
a3_rc=0
|
||||
env -u MOSAIC_TMUX_SOCKET "$AGENT_SEND" -s "$TWIN" -m "prefix trap" >/dev/null 2>"$A3_ERR" || a3_rc=$?
|
||||
# TWIN exists nowhere (both twins killed after arm A2); with '=' the
|
||||
# PSEUDO session is not a hit, so the sender must fail target-not-found
|
||||
# (rc 1) instead of delivering into the prefix-named session.
|
||||
[ "$a3_rc" -eq 1 ] || fail "prefix false-hit: rc=$a3_rc want 1 (stderr: $(cat "$A3_ERR"))"
|
||||
a3_pane="$(tmux capture-pane -t "=$PSEUDO:0.0" -p 2>/dev/null)" || a3_pane=""
|
||||
if printf '%s' "$a3_pane" | grep -F "prefix trap" >/dev/null; then
|
||||
fail "delivery landed in the prefix-named session (false socket hit)"
|
||||
fi
|
||||
tmux kill-session -t "$PSEUDO" >/dev/null 2>&1 || true
|
||||
rm -f "$A3_ERR"
|
||||
|
||||
# Arm A4: compound targets pin the SESSION component exact (codex PR
|
||||
# #1466): 'TWIN:0.0' must not resolve into the prefix-named session.
|
||||
PSEUDO2="${TWIN}-old"
|
||||
tmux new-session -d -s "$PSEUDO2" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
A4_ERR=$(mktemp)
|
||||
a4_rc=0
|
||||
env -u MOSAIC_TMUX_SOCKET "$AGENT_SEND" -s "$TWIN:0.0" -m "compound trap" >/dev/null 2>"$A4_ERR" || a4_rc=$?
|
||||
[ "$a4_rc" -eq 1 ] || fail "compound prefix false-hit: rc=$a4_rc want 1 (stderr: $(cat "$A4_ERR"))"
|
||||
a4_pane="$(tmux capture-pane -t "=$PSEUDO2:0.0" -p 2>/dev/null)" || a4_pane=""
|
||||
if printf '%s' "$a4_pane" | grep -F "compound trap" >/dev/null; then
|
||||
fail "compound delivery landed in the prefix-named session"
|
||||
fi
|
||||
tmux kill-session -t "$PSEUDO2" >/dev/null 2>&1 || true
|
||||
rm -f "$A4_ERR"
|
||||
|
||||
uniq_rc=0
|
||||
env -u MOSAIC_TMUX_SOCKET "$AGENT_SEND" -s "$TARGET" -m "autoresolved hello" >$UNIQ_OUT 2>$UNIQ_ERR || uniq_rc=$?
|
||||
[ "$uniq_rc" -eq 0 ] || fail "unique auto-resolution: rc=$uniq_rc (stderr: $(cat $UNIQ_ERR))"
|
||||
sleep 0.2
|
||||
auto_pane="$(capture_named)" || fail "could not capture named socket pane (arm B)"
|
||||
grep -qF "autoresolved hello" <<<"$auto_pane" || fail "auto-resolution did not deliver to the named-socket pane"
|
||||
default_pane2="$(capture_default)" || fail "could not capture default socket pane (arm B)"
|
||||
if grep -qF "autoresolved hello" <<<"$default_pane2"; then
|
||||
fail "auto-resolution cross-delivered to the default socket pane"
|
||||
fi
|
||||
|
||||
echo "ok - named tmux socket send tools"
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -11,9 +11,6 @@ import { registerQualityRails } from '@mosaicstack/quality-rails';
|
||||
import { registerQueueCommand } from '@mosaicstack/queue';
|
||||
import { registerStorageCommand } from '@mosaicstack/storage';
|
||||
import { registerTelemetryCommand } from './commands/telemetry.js';
|
||||
import { registerCommsCommand } from './commands/comms.js';
|
||||
import { registerQCommand } from './commands/q.js';
|
||||
import { registerWatchCommand } from './commands/watch.js';
|
||||
import { registerAgentCommand } from './commands/agent.js';
|
||||
import { registerInteractionCommand } from './commands/interaction.js';
|
||||
import { registerConfigCommand } from './commands/config.js';
|
||||
@@ -431,9 +428,6 @@ registerSkillCommand(program);
|
||||
// ─── telemetry ───────────────────────────────────────────────────────────────
|
||||
|
||||
registerTelemetryCommand(program);
|
||||
registerWatchCommand(program);
|
||||
registerQCommand(program);
|
||||
registerCommsCommand(program);
|
||||
|
||||
// ─── update ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { accessSync, constants } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { resolveBrainHome } from '../fleet/brain-home.js';
|
||||
|
||||
/**
|
||||
* Shared brain-tool dispatch (fleet CLI integration, Jason ruling
|
||||
* 2026-08-28): the npm package embeds the COMMAND SURFACE; operator-owned
|
||||
* implementations stay in the brain (tools/). Commands resolve the brain
|
||||
* home (MOSAIC_BRAIN_HOME wins — see brain-home.ts) and exec the tool
|
||||
* there. Nothing operator-specific ships inside the package.
|
||||
*
|
||||
* Pass-through contract: arguments, stdout/stderr, and the exit code belong
|
||||
* to the tool. The CLI adds nothing on success; absent tools fail loudly
|
||||
* with the resolved path (127) instead of guessing.
|
||||
*/
|
||||
|
||||
/** Absolute path of a brain-relative tool. */
|
||||
export function resolveBrainTool(mosaicHome: string, relPath: string): string {
|
||||
return join(resolveBrainHome(mosaicHome), ...relPath.split('/'));
|
||||
}
|
||||
|
||||
/** Map a spawnSync result + tool existence to the CLI exit status. */
|
||||
export function exitStatusFor(
|
||||
result: { status: number | null; error?: NodeJS.ErrnoException },
|
||||
toolExists: boolean,
|
||||
): number {
|
||||
if (!toolExists) return 127;
|
||||
if (result.status !== null) return result.status;
|
||||
return 125; // killed by signal / could not run
|
||||
}
|
||||
|
||||
export function brainToolExists(tool: string): boolean {
|
||||
try {
|
||||
accessSync(tool, constants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exec a brain tool with full pass-through. `interpreter` runs the tool
|
||||
* through e.g. python3 (renderers); omit it for executable scripts.
|
||||
* Returns the process exit status; callers assign it to process.exitCode.
|
||||
*/
|
||||
export function execBrainTool(
|
||||
mosaicHome: string,
|
||||
relPath: string,
|
||||
args: string[],
|
||||
interpreter?: string,
|
||||
): number {
|
||||
const tool = resolveBrainTool(mosaicHome, relPath);
|
||||
if (!brainToolExists(tool)) {
|
||||
console.error(
|
||||
`mosaic: brain tool not found (expected ${tool}). ` +
|
||||
'Tool suites live in the brain tree under tools/; ' +
|
||||
'check MOSAIC_BRAIN_HOME or the brain checkout.',
|
||||
);
|
||||
return 127;
|
||||
}
|
||||
const result = interpreter
|
||||
? spawnSync(interpreter, [tool, ...args], { stdio: 'inherit', env: process.env })
|
||||
: spawnSync(tool, args, { stdio: 'inherit', env: process.env });
|
||||
return exitStatusFor(result, true);
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
writeFileSync,
|
||||
readFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { Command } from 'commander';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { fleetCommsSendArgs, registerCommsCommand, tmuxSendArgs } from './comms.js';
|
||||
|
||||
describe('arg translation', () => {
|
||||
// B2 (2026-08-29): when --socket is omitted, the CLI forwards NO -L and
|
||||
// agent-send.sh's own resolution governs (explicit -L > MOSAIC_TMUX_SOCKET
|
||||
// > unique hit > ambiguity refusal). Forwarding a guessed default here
|
||||
// would defeat that resolution and reintroduce the stale-twin defect.
|
||||
it('omits -L entirely when --socket is not given (tool-owned resolution)', () => {
|
||||
const args = tmuxSendArgs('orch-01', 'hello', {});
|
||||
expect(args).toEqual(['-s', 'orch-01', '-m', 'hello']);
|
||||
expect(args).not.toContain('-L');
|
||||
expect(args.join(' ')).not.toContain('-L');
|
||||
});
|
||||
|
||||
it('tmux path: -s/-C/-L/-f/-m per agent-send.sh getopts', () => {
|
||||
expect(tmuxSendArgs('orch-01', 'hello', {})).toEqual(['-s', 'orch-01', '-m', 'hello']);
|
||||
expect(
|
||||
tmuxSendArgs('orch-01', 'unused', {
|
||||
class: 'actionable',
|
||||
socket: 'mosaic-fleet',
|
||||
file: '/tmp/body.txt',
|
||||
}),
|
||||
).toEqual(['-s', 'orch-01', '-C', 'actionable', '-L', 'mosaic-fleet', '-f', '/tmp/body.txt']);
|
||||
});
|
||||
|
||||
it('fleet-comms path: -t site/agent and -c class', () => {
|
||||
expect(fleetCommsSendArgs('usc', 'fred', 'hi', {})).toEqual(['-t', 'usc/fred', '-m', 'hi']);
|
||||
expect(fleetCommsSendArgs('usc', 'fred', 'hi', { class: 'human' })).toEqual([
|
||||
'-t',
|
||||
'usc/fred',
|
||||
'-c',
|
||||
'human',
|
||||
'-m',
|
||||
'hi',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('registerCommsCommand routing', () => {
|
||||
const savedBrain = process.env['MOSAIC_BRAIN_HOME'];
|
||||
const savedRepo = process.env['MOSAIC_FLEET_COMMS_REPO'];
|
||||
const savedAgent = process.env['MOSAIC_AGENT_NAME'];
|
||||
afterEach(() => {
|
||||
for (const [k, v] of [
|
||||
['MOSAIC_BRAIN_HOME', savedBrain],
|
||||
['MOSAIC_FLEET_COMMS_REPO', savedRepo],
|
||||
['MOSAIC_AGENT_NAME', savedAgent],
|
||||
] as const) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
process.exitCode = undefined;
|
||||
});
|
||||
|
||||
function fixture(): { brain: string; repo: string; tmuxLog: string; commsLog: string } {
|
||||
const brain = mkdtempSync(join(tmpdir(), 'comms-brain-'));
|
||||
const repo = mkdtempSync(join(tmpdir(), 'comms-repo-'));
|
||||
mkdirSync(join(brain, 'tools', 'tmux'), { recursive: true });
|
||||
mkdirSync(join(repo, 'tools'), { recursive: true });
|
||||
const tmuxLog = join(brain, 'tmux.log');
|
||||
const commsLog = join(repo, 'comms.log');
|
||||
writeFileSync(
|
||||
join(brain, 'tools', 'tmux', 'agent-send.sh'),
|
||||
`#!/usr/bin/env bash\nprintf '%s\\n' "$*" >> ${JSON.stringify(tmuxLog)}\nexit 7\n`,
|
||||
);
|
||||
writeFileSync(
|
||||
join(repo, 'tools', 'comms-send.sh'),
|
||||
`#!/usr/bin/env bash\nprintf '%s\\n' "$*" >> ${JSON.stringify(commsLog)}\nprintf 'FLEET_COMMS_REPO=%s FLEET_COMMS_SITE=%s\\n' "$FLEET_COMMS_REPO" "$FLEET_COMMS_SITE" >> ${JSON.stringify(commsLog)}\nexit 5\n`,
|
||||
);
|
||||
chmodSync(join(brain, 'tools', 'tmux', 'agent-send.sh'), 0o755);
|
||||
chmodSync(join(repo, 'tools', 'comms-send.sh'), 0o755);
|
||||
process.env['MOSAIC_BRAIN_HOME'] = brain;
|
||||
process.env['MOSAIC_FLEET_COMMS_REPO'] = repo;
|
||||
process.env['MOSAIC_AGENT_NAME'] = 'tester';
|
||||
return { brain, repo, tmuxLog, commsLog };
|
||||
}
|
||||
|
||||
it('default routes same-host via agent-send with translated flags and passes rc through', async () => {
|
||||
const f = fixture();
|
||||
const program = new Command();
|
||||
registerCommsCommand(program);
|
||||
await program.parseAsync(
|
||||
[
|
||||
'comms',
|
||||
'send',
|
||||
'orch-01',
|
||||
'--class',
|
||||
'actionable',
|
||||
'--socket',
|
||||
'mosaic-fleet',
|
||||
'verdict',
|
||||
'landed',
|
||||
],
|
||||
{ from: 'user' },
|
||||
);
|
||||
expect(process.exitCode).toBe(7);
|
||||
expect(readFileSync(f.tmuxLog, 'utf8').trim()).toBe(
|
||||
'-s orch-01 -C actionable -L mosaic-fleet -m verdict landed',
|
||||
);
|
||||
expect(existsSync(f.commsLog)).toBe(false); // inter-site tool never invoked
|
||||
});
|
||||
|
||||
it('--site routes inter-site via comms-send with site-prefixed target and passes rc through', async () => {
|
||||
const f = fixture();
|
||||
const program = new Command();
|
||||
registerCommsCommand(program);
|
||||
await program.parseAsync(
|
||||
['comms', 'send', 'fred', '--site', 'usc', '--class', 'human', 'hello', 'there'],
|
||||
{ from: 'user' },
|
||||
);
|
||||
expect(process.exitCode).toBe(5);
|
||||
expect(readFileSync(f.commsLog, 'utf8').split('\n')[0]?.trim()).toBe(
|
||||
'-t usc/fred -c human -m hello there',
|
||||
);
|
||||
// The sender must bind comms-send.sh to the SELECTED repo (codex 9c8b6ebf).
|
||||
expect(readFileSync(f.commsLog, 'utf8')).toContain(
|
||||
`FLEET_COMMS_REPO=${f.repo} FLEET_COMMS_SITE=usc`,
|
||||
);
|
||||
expect(existsSync(f.tmuxLog)).toBe(false); // same-host tool never invoked
|
||||
});
|
||||
|
||||
it('inter-site without MOSAIC_AGENT_NAME is an invocation defect (exit 2)', async () => {
|
||||
const f = fixture();
|
||||
delete process.env['MOSAIC_AGENT_NAME'];
|
||||
const program = new Command();
|
||||
registerCommsCommand(program);
|
||||
await program.parseAsync(['comms', 'send', 'fred', '--site', 'usc', 'hi'], { from: 'user' });
|
||||
expect(process.exitCode).toBe(2);
|
||||
expect(existsSync(f.commsLog)).toBe(false); // inter-site tool never invoked
|
||||
});
|
||||
|
||||
it('missing fleet-comms repo fails 127 naming the expected path', async () => {
|
||||
fixture();
|
||||
process.env['MOSAIC_FLEET_COMMS_REPO'] = '/nonexistent-comms-repo';
|
||||
const program = new Command();
|
||||
registerCommsCommand(program);
|
||||
await program.parseAsync(['comms', 'send', 'fred', '--site', 'usc', 'hi'], { from: 'user' });
|
||||
expect(process.exitCode).toBe(127);
|
||||
});
|
||||
});
|
||||
@@ -1,138 +0,0 @@
|
||||
import type { Command } from 'commander';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { accessSync, constants } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { DEFAULT_MOSAIC_HOME } from '../constants.js';
|
||||
import { execBrainTool } from './brain-dispatch.js';
|
||||
|
||||
/**
|
||||
* `mosaic comms send` — routed agent messaging (FLEET-COMMS.md doctrine).
|
||||
*
|
||||
* Same-host (default): brain tools/tmux/agent-send.sh. Inter-site
|
||||
* (--site <site>): the fleet-comms repo's comms-send.sh — never for
|
||||
* local traffic (a git round trip per message; Jason 2026-08-28).
|
||||
*
|
||||
* Exit codes pass through BOTH paths. rc=2 (text in pane, still draft) is
|
||||
* a CONTRACT, not a failure: never retry, confirm with capture-pane.
|
||||
*/
|
||||
export interface CommsSendOptions {
|
||||
readonly class?: string;
|
||||
readonly file?: string;
|
||||
readonly socket?: string;
|
||||
readonly site?: string;
|
||||
readonly commsRepo?: string;
|
||||
}
|
||||
|
||||
export function defaultCommsRepo(): string {
|
||||
return process.env['MOSAIC_FLEET_COMMS_REPO'] ?? join(homedir(), 'src', 'fleet-comms');
|
||||
}
|
||||
|
||||
/** Build the agent-send.sh argv for the same-host path. */
|
||||
export function tmuxSendArgs(target: string, message: string, opts: CommsSendOptions): string[] {
|
||||
const args = ['-s', target];
|
||||
if (opts.class) args.push('-C', opts.class);
|
||||
if (opts.socket) args.push('-L', opts.socket);
|
||||
if (opts.file) args.push('-f', opts.file);
|
||||
else args.push('-m', message);
|
||||
return args;
|
||||
}
|
||||
|
||||
/** Build the comms-send.sh argv for the inter-site path. */
|
||||
export function fleetCommsSendArgs(
|
||||
site: string,
|
||||
target: string,
|
||||
message: string,
|
||||
opts: CommsSendOptions,
|
||||
): string[] {
|
||||
const args = ['-t', `${site}/${target}`];
|
||||
if (opts.class) args.push('-c', opts.class);
|
||||
args.push('-m', message);
|
||||
return args;
|
||||
}
|
||||
|
||||
export function registerCommsCommand(program: Command): void {
|
||||
const cmd: Command = program
|
||||
.command('comms')
|
||||
.description(
|
||||
'Routed agent messaging: tmux same-host (default), fleet-comms inter-site (--site)',
|
||||
)
|
||||
.command('send')
|
||||
.description('send <target> [message...] — same-host tmux unless --site is given')
|
||||
.option('--class <class>', 'terminal-log | actionable | human | reaction | digest')
|
||||
.option('--file <path>', 'message body from file (same-host path only)')
|
||||
.option(
|
||||
'--socket <name>',
|
||||
'tmux socket for the same-host send (e.g. mosaic-fleet). OMIT it to let agent-send.sh resolve the socket (explicit -L > MOSAIC_TMUX_SOCKET > unique hit; ambiguity refuses with its exit 4).',
|
||||
)
|
||||
.option('--site <site>', 'route via fleet-comms to <site>/<target>')
|
||||
.option('--comms-repo <path>', 'fleet-comms checkout', defaultCommsRepo())
|
||||
.argument('<target>', 'destination seat (session name)')
|
||||
.argument('[message...]', 'message text (joined; or use --file)')
|
||||
.action(
|
||||
async (
|
||||
target: string,
|
||||
messageWords: string[],
|
||||
opts: CommsSendOptions & Record<string, unknown>,
|
||||
command: Command,
|
||||
) => {
|
||||
let mosaicHome: string | undefined;
|
||||
for (let anc: Command | null = command; anc; anc = anc.parent) {
|
||||
const v = (anc.opts() as Record<string, string | undefined>)['mosaicHome'];
|
||||
if (v !== undefined) {
|
||||
mosaicHome = v;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const home = mosaicHome ?? DEFAULT_MOSAIC_HOME;
|
||||
const message = messageWords.join(' ');
|
||||
|
||||
if (opts.site) {
|
||||
const repo = opts.commsRepo ?? defaultCommsRepo();
|
||||
const tool = join(repo, 'tools', 'comms-send.sh');
|
||||
try {
|
||||
accessSync(tool, constants.X_OK);
|
||||
} catch {
|
||||
console.error(
|
||||
`mosaic comms: fleet-comms sender not found (expected ${tool}). ` +
|
||||
'Clone the fleet-comms repo or point --comms-repo at it.',
|
||||
);
|
||||
process.exitCode = 127;
|
||||
return;
|
||||
}
|
||||
if (!process.env['MOSAIC_AGENT_NAME']) {
|
||||
console.error(
|
||||
'mosaic comms: inter-site sends require MOSAIC_AGENT_NAME (sending identity).',
|
||||
);
|
||||
process.exitCode = 2; // invocation defect: fixable by the caller
|
||||
return;
|
||||
}
|
||||
// comms-send.sh locates its working repo via FLEET_COMMS_REPO
|
||||
// (default $HOME/src/fleet-comms); without this, --comms-repo
|
||||
// would select the executable but not the repository it operates
|
||||
// on (codex review of 9c8b6ebf).
|
||||
const env = { ...process.env, FLEET_COMMS_SITE: opts.site, FLEET_COMMS_REPO: repo };
|
||||
const result = spawnSync(tool, fleetCommsSendArgs(opts.site, target, message, opts), {
|
||||
stdio: 'inherit',
|
||||
env,
|
||||
});
|
||||
process.exitCode = result.status ?? 125;
|
||||
return;
|
||||
}
|
||||
|
||||
// Same-host: the brain tool owns validation (bad class -> its rc 3)
|
||||
// and absence (execBrainTool -> 127 with the resolved path).
|
||||
process.exitCode = execBrainTool(
|
||||
home,
|
||||
'tools/tmux/agent-send.sh',
|
||||
tmuxSendArgs(target, message, opts),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
cmd.addHelpText(
|
||||
'after',
|
||||
'\nExit codes pass through. rc=2 means the text reached the pane but is still a draft: NEVER retry (double-send); confirm with tmux capture-pane.',
|
||||
);
|
||||
}
|
||||
@@ -106,7 +106,6 @@ describe('registerFleetCommand', () => {
|
||||
'init',
|
||||
'install',
|
||||
'install-systemd',
|
||||
'logins',
|
||||
'migrate-v1',
|
||||
'persona',
|
||||
'plan',
|
||||
|
||||
@@ -17,7 +17,7 @@ import { homedir, hostname, userInfo } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fleetAgentEnvDir } from '../fleet/brain-home.js';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { spawn } from 'node:child_process';
|
||||
import * as readline from 'node:readline';
|
||||
import type { Command } from 'commander';
|
||||
import YAML from 'yaml';
|
||||
@@ -1606,24 +1606,6 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
||||
.option('--mosaic-home <path>', 'Mosaic home directory', paths.mosaicHome)
|
||||
.option('--roster <path>', 'Fleet roster path');
|
||||
|
||||
// fleet logins (P4 gap closure, 2026-08-29): project per-seat git tokens
|
||||
// from the brain into usable credentials. The implementation is the
|
||||
// framework fleet tool; args pass through verbatim (--apply --seat X).
|
||||
cmd
|
||||
.command('logins')
|
||||
.description('Per-seat git credentials: seat-logins.sh pass-through (--apply --seat <name>)')
|
||||
.allowUnknownOption(true)
|
||||
.allowExcessArguments(true)
|
||||
.action((...rest: unknown[]) => {
|
||||
const cmdObj = rest[rest.length - 1] as { args: string[] };
|
||||
const script = `${frameworkRoot}/tools/fleet/seat-logins.sh`;
|
||||
const result = spawnSync('bash', [script, ...cmdObj.args], {
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
});
|
||||
process.exit(result.status ?? 125);
|
||||
});
|
||||
|
||||
cmd
|
||||
.command('init')
|
||||
.description('Initialize a local fleet roster')
|
||||
|
||||
@@ -1162,8 +1162,6 @@ const COORD_SUBCMDS: Record<string, string> = {
|
||||
test: 'smoke-test.sh',
|
||||
resume: 'session-resume.sh',
|
||||
recover: 'session-resume.sh',
|
||||
board: 'board-roll.sh',
|
||||
roll: 'board-roll.sh',
|
||||
};
|
||||
|
||||
function runCoord(args: string[]): never {
|
||||
@@ -1197,7 +1195,6 @@ Commands:
|
||||
run [--project <path>] Launch runtime with mission context
|
||||
smoke Run orchestration smoke checks
|
||||
resume [--project <path>] Crash recovery
|
||||
board Roll the orchestration board (board-roll.sh)
|
||||
|
||||
Runtime: --claude (default) | --codex | --pi | --yolo`);
|
||||
process.exit(subcmd === 'help' ? 0 : 1);
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
import { mkdirSync, mkdtempSync, writeFileSync, chmodSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { Command } from 'commander';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { registerQCommand, resolveQuestionTool } from './q.js';
|
||||
import { exitStatusFor, resolveBrainTool } from './brain-dispatch.js';
|
||||
|
||||
describe('resolveBrainTool', () => {
|
||||
const saved = process.env['MOSAIC_BRAIN_HOME'];
|
||||
afterEach(() => {
|
||||
if (saved === undefined) delete process.env['MOSAIC_BRAIN_HOME'];
|
||||
else process.env['MOSAIC_BRAIN_HOME'] = saved;
|
||||
});
|
||||
|
||||
it('joins brain home with the relative tool path', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'dispatch-resolve-'));
|
||||
process.env['MOSAIC_BRAIN_HOME'] = tmp;
|
||||
expect(resolveBrainTool('/nonexistent/mosaic-home', 'tools/questions/q-new.sh')).toBe(
|
||||
join(tmp, 'tools', 'questions', 'q-new.sh'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveQuestionTool', () => {
|
||||
const saved = process.env['MOSAIC_BRAIN_HOME'];
|
||||
afterEach(() => {
|
||||
if (saved === undefined) delete process.env['MOSAIC_BRAIN_HOME'];
|
||||
else process.env['MOSAIC_BRAIN_HOME'] = saved;
|
||||
});
|
||||
|
||||
it('maps new/render subcommands to their brain tools', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'q-resolve-'));
|
||||
process.env['MOSAIC_BRAIN_HOME'] = tmp;
|
||||
expect(resolveQuestionTool(tmp, 'new')).toBe(join(tmp, 'tools', 'questions', 'q-new.sh'));
|
||||
expect(resolveQuestionTool(tmp, 'render')).toBe(join(tmp, 'tools', 'questions', 'render.py'));
|
||||
expect(resolveQuestionTool(tmp, 'bogus')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('registerQCommand usage + dispatch', () => {
|
||||
const saved = process.env['MOSAIC_BRAIN_HOME'];
|
||||
afterEach(() => {
|
||||
if (saved === undefined) delete process.env['MOSAIC_BRAIN_HOME'];
|
||||
else process.env['MOSAIC_BRAIN_HOME'] = saved;
|
||||
process.exitCode = undefined;
|
||||
});
|
||||
|
||||
it('exit 2 with the subcommand list when no/unknown subcommand', async () => {
|
||||
process.env['MOSAIC_BRAIN_HOME'] = mkdtempSync(join(tmpdir(), 'q-usage-'));
|
||||
const program = new Command();
|
||||
registerQCommand(program);
|
||||
await program.parseAsync(['q'], { from: 'user' });
|
||||
expect(process.exitCode).toBe(2);
|
||||
process.exitCode = undefined;
|
||||
await program.parseAsync(['q', 'bogus'], { from: 'user' });
|
||||
expect(process.exitCode).toBe(2);
|
||||
process.exitCode = undefined;
|
||||
// Reserved property names must not leak through the record lookup.
|
||||
await program.parseAsync(['q', 'toString'], { from: 'user' });
|
||||
expect(process.exitCode).toBe(2);
|
||||
});
|
||||
|
||||
it('execs the brain tool with pass-through args and exit code', async () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'q-live-'));
|
||||
process.env['MOSAIC_BRAIN_HOME'] = tmp;
|
||||
mkdirSync(join(tmp, 'tools', 'questions'), { recursive: true });
|
||||
const stub = join(tmp, 'tools', 'questions', 'q-new.sh');
|
||||
writeFileSync(stub, '#!/usr/bin/env bash\necho "called with: $*"\nexit 7\n');
|
||||
chmodSync(stub, 0o755);
|
||||
|
||||
const program = new Command();
|
||||
registerQCommand(program);
|
||||
await program.parseAsync(['q', 'new', '--slug', 'x', '--question', 'why'], { from: 'user' });
|
||||
expect(process.exitCode).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe('exitStatusFor (shared dispatch contract)', () => {
|
||||
it('maps absent tool to 127', () => {
|
||||
expect(exitStatusFor({ status: 0 }, false)).toBe(127);
|
||||
});
|
||||
it('passes tool status through', () => {
|
||||
expect(exitStatusFor({ status: 7 }, true)).toBe(7);
|
||||
});
|
||||
it('maps signal death to 125', () => {
|
||||
expect(exitStatusFor({ status: null }, true)).toBe(125);
|
||||
});
|
||||
});
|
||||
@@ -1,63 +0,0 @@
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { DEFAULT_MOSAIC_HOME } from '../constants.js';
|
||||
import { execBrainTool, resolveBrainTool } from './brain-dispatch.js';
|
||||
|
||||
/**
|
||||
* `mosaic q` — tracked decision questions (brain tools/questions).
|
||||
* `new` files a question file (one FILE per question, merge-conflict
|
||||
* impossible by construction); `render` regenerates the
|
||||
* docs/OPEN-QUESTIONS.md index (id allocation happens in the renderer).
|
||||
*/
|
||||
const SUBCOMMANDS: Record<string, { path: string; interpreter?: string; help: string }> = {
|
||||
new: {
|
||||
path: 'tools/questions/q-new.sh',
|
||||
help: 'file a question (--question, --slug, --owed-by, ...)',
|
||||
},
|
||||
render: {
|
||||
path: 'tools/questions/render.py',
|
||||
interpreter: 'python3',
|
||||
help: 'regenerate docs/OPEN-QUESTIONS.md (owns Q-id allocation)',
|
||||
},
|
||||
};
|
||||
|
||||
export function resolveQuestionTool(mosaicHome: string, sub: string): string | undefined {
|
||||
const entry = SUBCOMMANDS[sub];
|
||||
return entry ? resolveBrainTool(mosaicHome, entry.path) : undefined;
|
||||
}
|
||||
|
||||
export function registerQCommand(program: Command): void {
|
||||
const cmd: Command = program
|
||||
.command('q')
|
||||
.description('Tracked decision questions: file and render (brain tools/questions)')
|
||||
.allowUnknownOption()
|
||||
.argument('[args...]', 'subcommand + args passed through to the question tools')
|
||||
.action(async (args: string[], _opts: unknown, command: Command) => {
|
||||
let mosaicHome: string | undefined;
|
||||
for (let anc: Command | null = command; anc; anc = anc.parent) {
|
||||
const v = (anc.opts() as Record<string, string | undefined>)['mosaicHome'];
|
||||
if (v !== undefined) {
|
||||
mosaicHome = v;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const home = mosaicHome ?? DEFAULT_MOSAIC_HOME;
|
||||
|
||||
const sub = args[0];
|
||||
if (!sub || !Object.hasOwn(SUBCOMMANDS, sub)) {
|
||||
console.error('mosaic q: expected a subcommand:');
|
||||
for (const [name, entry] of Object.entries(SUBCOMMANDS)) {
|
||||
console.error(` mosaic q ${name} ${entry.help}`);
|
||||
}
|
||||
process.exitCode = 2; // usage error contract: invocation defect
|
||||
return;
|
||||
}
|
||||
const entry = SUBCOMMANDS[sub]!;
|
||||
process.exitCode = execBrainTool(home, entry.path, args.slice(1), entry.interpreter);
|
||||
});
|
||||
|
||||
cmd.addHelpText(
|
||||
'after',
|
||||
'\nEverything after the subcommand is passed through verbatim (args, output, exit code).',
|
||||
);
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import { mkdirSync, mkdtempSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { Command } from 'commander';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { exitStatusFor, registerWatchCommand, resolveAgentWatchTool } from './watch.js';
|
||||
|
||||
// The dispatch command execs a real process with inherited stdio; the spec
|
||||
// covers the pure resolution and exit-mapping surfaces plus the absent-tool
|
||||
// path (which exits without spawning). Live pass-through is exercised by the
|
||||
// fleet smoke test against the real brain tool.
|
||||
|
||||
describe('resolveAgentWatchTool', () => {
|
||||
const saved = process.env['MOSAIC_BRAIN_HOME'];
|
||||
afterEach(() => {
|
||||
if (saved === undefined) delete process.env['MOSAIC_BRAIN_HOME'];
|
||||
else process.env['MOSAIC_BRAIN_HOME'] = saved;
|
||||
});
|
||||
|
||||
it('honors MOSAIC_BRAIN_HOME over the canonical brain', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'watch-resolve-'));
|
||||
process.env['MOSAIC_BRAIN_HOME'] = tmp;
|
||||
expect(resolveAgentWatchTool('/nonexistent/mosaic-home')).toBe(
|
||||
join(tmp, 'tools', 'agent-watch', 'agent-watch.sh'),
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves inside the brain tools tree', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'watch-resolve-'));
|
||||
process.env['MOSAIC_BRAIN_HOME'] = tmp;
|
||||
const tool = resolveAgentWatchTool(tmp);
|
||||
expect(tool.endsWith(join('tools', 'agent-watch', 'agent-watch.sh'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('exitStatusFor', () => {
|
||||
it('maps absent tool to 127', () => {
|
||||
expect(exitStatusFor({ status: 0 }, false)).toBe(127);
|
||||
});
|
||||
|
||||
it('passes the tool exit status through', () => {
|
||||
expect(exitStatusFor({ status: 2 }, true)).toBe(2);
|
||||
expect(exitStatusFor({ status: 78 }, true)).toBe(78);
|
||||
});
|
||||
|
||||
it('maps signal death / null status to 125', () => {
|
||||
expect(exitStatusFor({ status: null }, true)).toBe(125);
|
||||
});
|
||||
});
|
||||
|
||||
describe('registerWatchCommand absent-tool path', () => {
|
||||
const saved = process.env['MOSAIC_BRAIN_HOME'];
|
||||
afterEach(() => {
|
||||
if (saved === undefined) delete process.env['MOSAIC_BRAIN_HOME'];
|
||||
else process.env['MOSAIC_BRAIN_HOME'] = saved;
|
||||
process.exitCode = undefined;
|
||||
});
|
||||
|
||||
it('sets exitCode 127 with the resolved path when the tool is missing', async () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'watch-missing-'));
|
||||
// The suite directory exists but the tool file does not.
|
||||
mkdirSync(join(tmp, 'tools', 'agent-watch'), { recursive: true });
|
||||
process.env['MOSAIC_BRAIN_HOME'] = tmp;
|
||||
|
||||
const program = new Command();
|
||||
registerWatchCommand(program);
|
||||
await program.parseAsync(['watch', 'list'], { from: 'user' });
|
||||
expect(process.exitCode).toBe(127);
|
||||
});
|
||||
});
|
||||
@@ -1,68 +0,0 @@
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { DEFAULT_MOSAIC_HOME } from '../constants.js';
|
||||
import {
|
||||
brainToolExists,
|
||||
execBrainTool,
|
||||
exitStatusFor,
|
||||
resolveBrainTool,
|
||||
} from './brain-dispatch.js';
|
||||
|
||||
export { exitStatusFor };
|
||||
|
||||
/**
|
||||
* `mosaic watch` — dispatch to the brain's agent-watch suite.
|
||||
* See brain-dispatch.ts for the architecture and pass-through contract.
|
||||
*/
|
||||
export function resolveAgentWatchTool(mosaicHome: string): string {
|
||||
return resolveBrainTool(mosaicHome, 'tools/agent-watch/agent-watch.sh');
|
||||
}
|
||||
|
||||
export function registerWatchCommand(program: Command): void {
|
||||
const cmd: Command = program
|
||||
.command('watch')
|
||||
.description('Wake-me-when watchers (agent-watch): start, list, stop')
|
||||
// allowUnknownOption + variadic = full ordered pass-through: unknown
|
||||
// options (--name, --when, ...) and their values land in args verbatim
|
||||
// (commander 13 measured behavior), so the tool owns its own flag
|
||||
// surface without the CLI needing passThroughOptions (which would
|
||||
// force enablePositionalOptions fleet-wide on the root program).
|
||||
.allowUnknownOption()
|
||||
// The tool owns help too: without this, commander would intercept
|
||||
// --help and answer with wrapper help instead of agent-watch's own
|
||||
// (codex review of 18f3dd49).
|
||||
.helpOption(false)
|
||||
.argument('[args...]', 'args passed through to agent-watch.sh')
|
||||
.action(async (args: string[], _opts: unknown, command: Command) => {
|
||||
// --mosaic-home is not global in this CLI; walk parents for it and
|
||||
// fall back to the default. MOSAIC_BRAIN_HOME (seat launchers export
|
||||
// it) wins inside resolveBrainHome regardless.
|
||||
let mosaicHome: string | undefined;
|
||||
for (let anc: Command | null = command; anc; anc = anc.parent) {
|
||||
const v = (anc.opts() as Record<string, string | undefined>)['mosaicHome'];
|
||||
if (v !== undefined) {
|
||||
mosaicHome = v;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const home = mosaicHome ?? DEFAULT_MOSAIC_HOME;
|
||||
|
||||
const tool = resolveAgentWatchTool(home);
|
||||
if (!brainToolExists(tool)) {
|
||||
console.error(
|
||||
`mosaic watch: agent-watch not found (expected ${tool}). ` +
|
||||
'The watcher suite lives in the brain tree under tools/agent-watch/; ' +
|
||||
'check MOSAIC_BRAIN_HOME or the brain checkout.',
|
||||
);
|
||||
process.exitCode = 127;
|
||||
return;
|
||||
}
|
||||
|
||||
process.exitCode = execBrainTool(home, 'tools/agent-watch/agent-watch.sh', args);
|
||||
});
|
||||
|
||||
cmd.addHelpText(
|
||||
'after',
|
||||
'\nEverything after `mosaic watch` is passed through to agent-watch.sh verbatim (args, output, exit code).',
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user