diff --git a/.github/workflows/api-sync.yml b/.github/workflows/api-sync.yml index 83977c1..df3f9f9 100644 --- a/.github/workflows/api-sync.yml +++ b/.github/workflows/api-sync.yml @@ -55,76 +55,63 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile - - name: Apply changes with Claude Code - uses: anthropics/claude-code-action@v1 - with: - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - claude_args: '--model claude-opus-5 --allowedTools "Bash(*),Read,Edit,Write,Glob,Grep"' - prompt: | - You are updating this CLI to match BlindPay API changes. - - Read CLAUDE.md in this repo first — it describes the file - layout, command naming conventions, output patterns, and the - decision rules for translating API changes into CLI changes. - - The changelog is at /tmp/api-sync/changelog.md. It is the - authoritative list of API changes since the last sync. - - Process: - 1. Read CLAUDE.md thoroughly. - 2. Read /tmp/api-sync/changelog.md. - 3. For each change, apply the decision rules in CLAUDE.md - (section "Sync workflow conventions"). Skip changes that - don't map to any CLI surface — silence is fine. - 4. Implement the chosen changes: - - New commands: add the action function to - src/commands/resources.ts and wire it into - src/index.ts under the right group banner. - - New flags: add to the option list in src/index.ts and - pass through to the action in src/commands/resources.ts. - - Removed endpoints: remove the command and the action. - - Enum changes: only update help text in src/index.ts. - - Dynamic / arbitrary-object request bodies (e.g. - `Record` or `z.record(z.string(), - z.any())`): do NOT ship the command with an empty - `{}` body and a TODO. Accept the body as a single - `--body ` flag, parse with `JSON.parse`, and - call `exitWithError` on a parse failure. Use - `--body` (not `--response`, `--payload`, etc.) for - consistency across commands. See the - "Dynamic request bodies" section in CLAUDE.md for - the exact pattern. - - Do NOT leave `// TODO(api-sync):` markers in shipped - commands. Use the JSON-string fallback above instead - of TODOs. TODOs are only acceptable for low-signal - cleanups (e.g. column tuning). - 5. Update src/__tests__/resources.test.ts to match. For every - action you added: add at least one happy-path test - asserting the URL, method, and body. For every action you - modified: update the existing test's expected body/URL. - For every action you removed: remove its test. Follow the - existing pattern (setupTestEnv/teardownTestEnv, lastCall(), - mockResponse.body). See the "Testing" section of CLAUDE.md. - **Place new tests inside the existing `describe(...)` - block that matches the command's top-level CLI group** — - a `receivers submit_rfi` test goes in - `describe('Receivers', ...)`, not in a new - `describe('RFI', ...)`. Only create a new describe block - when introducing a brand-new top-level CLI group. - 6. Bump the `version` field in package.json (patch for - additive changes, minor if anything was removed). - CLI_VERSION is derived from package.json at build time — - do not edit src/utils/constants.ts for version bumps. - 7. Run `bun run typecheck`, `bun run lint:fix`, and - `bun run test`. Fix any errors until all three are clean. - 8. Do NOT touch .github/workflows/. - 9. Do NOT create commits — leave changes in the working tree. - - If a change is ambiguous, leave a TODO comment with - `// TODO(api-sync):` so a human reviewer can address it. + # The entire "what changed, and can this repo express it without a + # human" decision is a pure script: scripts/api-sync/generate.ts. It + # parses the changelog, classifies every change as either mechanically + # applicable (an additive request-body field on a known resource path) + # or needs-human, applies only the former, and bumps package.json's + # version. No LLM, no best-guessing: an unrecognized changelog shape + # makes the parser throw and this step fails the run. + - name: Run deterministic generator + id: generate + run: | + set -o pipefail + bun scripts/api-sync/generate.ts \ + --changelog /tmp/api-sync/changelog.md \ + --repo-root . \ + | tee /tmp/api-sync/generate.log + + SUMMARY_JSON=$(grep '^SUMMARY_JSON:' /tmp/api-sync/generate.log | tail -1 | sed 's/^SUMMARY_JSON://') + echo "$SUMMARY_JSON" > /tmp/api-sync/summary.json + echo "summary_path=/tmp/api-sync/summary.json" >> "$GITHUB_OUTPUT" + echo "has_changes=$(jq -r '.hasChanges' /tmp/api-sync/summary.json)" >> "$GITHUB_OUTPUT" + echo "can_automerge=$(jq -r '.canAutoMerge' /tmp/api-sync/summary.json)" >> "$GITHUB_OUTPUT" + echo "needs_human_count=$(jq -r '.needsHumanCount' /tmp/api-sync/summary.json)" >> "$GITHUB_OUTPUT" + echo "bump_type=$(jq -r '.bumpType' /tmp/api-sync/summary.json)" >> "$GITHUB_OUTPUT" + + # A changelog can contain ONLY changes the generator cannot express + # (new endpoint, removed field, enum-only, etc.) with nothing + # mechanically applicable. There is then no code diff to commit and no + # PR to open — but staying silent would bury a real API change a human + # needs to see, so this opens an issue instead. + - name: Open an issue when nothing was applicable but something needs a human + if: steps.generate.outputs.has_changes == 'false' && steps.generate.outputs.needs_human_count != '0' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + { + echo "The latest API changelog contains changes this repo's deterministic" + echo "api-sync generator (\`scripts/api-sync/\`) cannot express. Nothing was" + echo "applied or committed. A human needs to decide the CLI surface for:" + echo + jq -r '.needsHuman[] | "- " + .' /tmp/api-sync/summary.json + } > /tmp/api-sync/issue-body.md + gh issue create \ + --title "api-sync: API changes need human review (no automatic changes applied)" \ + --body-file /tmp/api-sync/issue-body.md \ + --label api-sync + + - name: Run CI checks against the generated changes + if: steps.generate.outputs.has_changes == 'true' + run: | + bun run typecheck + bun run lint + bun test + bun run build - name: Commit and push id: commit + if: steps.generate.outputs.has_changes == 'true' run: | git remote set-url origin "https://x-access-token:${{ secrets.SDK_SYNC_PAT }}@github.com/${{ github.repository }}.git" git checkout -- .github/workflows/ 2>/dev/null || true @@ -132,17 +119,38 @@ jobs: git reset HEAD .github/workflows/ 2>/dev/null || true if git diff --staged --quiet; then echo "No changes to commit" - echo "has_changes=false" >> $GITHUB_OUTPUT + echo "committed=false" >> "$GITHUB_OUTPUT" exit 0 fi - echo "has_changes=true" >> $GITHUB_OUTPUT git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git commit -m "feat: sync CLI with API changes" git push --force-with-lease origin api-sync + echo "committed=true" >> "$GITHUB_OUTPUT" + + - name: Write PR body + id: pr-body + if: steps.commit.outputs.committed == 'true' + run: | + { + echo "Automated, script-generated CLI update from API changes." + echo "No LLM was involved in producing this diff — see \`scripts/api-sync/\` in this repo." + echo + echo "### Applied automatically" + jq -r '.applied[] | "- " + .' /tmp/api-sync/summary.json + NEEDS_HUMAN_COUNT=$(jq -r '.needsHumanCount' /tmp/api-sync/summary.json) + if [ "$NEEDS_HUMAN_COUNT" != "0" ]; then + echo + echo "### Needs a human — NOT applied, NOT auto-merged" + jq -r '.needsHuman[] | "- " + .' /tmp/api-sync/summary.json + fi + echo + echo "Version bump: \`$(jq -r '.bumpType' /tmp/api-sync/summary.json)\`" + } > /tmp/api-sync/pr-body.md - name: Create or update PR - if: steps.commit.outputs.has_changes == 'true' + id: pr + if: steps.commit.outputs.committed == 'true' env: GH_TOKEN: ${{ secrets.SDK_SYNC_PAT }} run: | @@ -150,12 +158,30 @@ jobs: if [ -n "$EXISTING_PR" ]; then echo "Updating existing PR #$EXISTING_PR" - gh pr comment "$EXISTING_PR" --body "Updated with latest API changes." + gh pr comment "$EXISTING_PR" --body-file /tmp/api-sync/pr-body.md + PR_NUMBER="$EXISTING_PR" else gh pr create \ --title "feat: sync CLI with API changes" \ - --body "Automated CLI update from API changes." \ + --body-file /tmp/api-sync/pr-body.md \ --base main \ --head api-sync \ --label api-sync + PR_NUMBER=$(gh pr list --head api-sync --json number --jq '.[0].number') + fi + echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT" + + # Auto-merge only when the generator reports nothing was left for a + # human to decide. A needs-human PR stays open for manual review and is + # never auto-merged — this is the honest-failure path, not an error. + - name: Auto-merge or flag for human review + if: steps.commit.outputs.committed == 'true' + env: + GH_TOKEN: ${{ secrets.SDK_SYNC_PAT }} + run: | + PR_NUMBER="${{ steps.pr.outputs.pr_number }}" + if [ "${{ steps.generate.outputs.can_automerge }}" = "true" ]; then + gh pr merge "$PR_NUMBER" --auto --squash + else + gh pr comment "$PR_NUMBER" --body "Auto-merge NOT enabled: this changelog contains ${{ steps.generate.outputs.needs_human_count }} change(s) the generator cannot express (see the needs-human section above). A human needs to review and merge this manually." fi diff --git a/CLAUDE.md b/CLAUDE.md index 492af15..ac2bd3b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,14 +21,70 @@ This CLI is auto-synced with the BlindPay API. When SDK-eligible from the OpenAPI spec diff and pushes it to this repo's `api-sync-data` branch at `.api-sync/changelog.md`. It then fires a `repository_dispatch` `api-sync` event. -2. `api-sync.yml` consumes the event, runs Claude with the changelog as - input, and asks Claude to read this CLAUDE.md plus the codebase to - decide what (if any) CLI changes are needed. -3. A PR is opened/updated on the `api-sync` branch for human review. +2. `api-sync.yml` consumes the event and runs a pure script, + `scripts/api-sync/generate.ts` — no LLM, no `claude-code-action`. See + "The deterministic api-sync pipeline" below for what it does and does + not touch. +3. If everything the changelog contains was mechanically applicable, the + PR auto-merges once CI passes. If any change needed a human, the PR + (or, if there was nothing applicable at all, a plain issue) stays open + for manual review and is never auto-merged. Note: not every API change needs a CLI change. The CLI is hand-curated UX — only commands a human would actually want to run from a terminal. +## The deterministic api-sync pipeline + +`scripts/api-sync/generate.ts` is the only thing that runs in CI for a +sync. It is a straight pipeline, in `scripts/api-sync/`: + +- `parse-changelog.ts` — parses the exact markdown format blindpay-v2's + `scripts/spec-diff.ts` emits into structured events (field added/removed, + enum changed, endpoint/method/schema added/removed). This is a format + parser, not a heuristic: an unrecognized bullet makes it throw rather + than silently drop content. +- `known-resources.ts` — the generator's entire "what can I touch" map: + each entry pairs a `schema.ts` resource name with the OpenAPI path(s) + and the exact `src/commands/resources.ts` function name that build its + create/update request body. A path NOT listed here is unmappable by + construction. **Extending this map to a genuinely new resource is a + hand-written change to make deliberately** — the generator will never + infer it from a changelog. +- `classify.ts` — splits parsed events into `applicable` (CAN be expressed + by the generator) and `needsHuman` (cannot). Today the CAN-express + surface is deliberately narrow: **an additive, optional field on the + REQUEST body of a create/update path listed in `known-resources.ts`.** + Everything else — removed fields, enum value changes, response-only + field changes, new/removed endpoints, methods, or schemas — is routed + to `needsHuman` with a plain-English reason, even where a human + historically handled it mechanically too (see the comment at the top of + `classify.ts` for why each of those categories isn't safe to script + today). +- `apply.ts` — applies one field addition: adds `?: ` to the + matching function's options type in `resources.ts` (anchored on its + `json?: boolean` prop), a pass-through statement before its + `apiPost ` option in + `index.ts` (anchored on that command's `--json` option), and a mirrored + `FieldDef` in `schema.ts`. Every insertion is anchor-based and + idempotent — re-running it against already-patched source is a no-op, + which is what makes the pipeline safe to re-run and byte-identical + across runs of the same input. +- `version-bump.ts` — minor if the changelog added/removed any + endpoint, method, or enum value; patch otherwise. Scripted from the + parsed events, never guessed. +- `generate.ts` — orchestrates all of the above, writes the patched + files plus the bumped `package.json` version, and prints a + `SUMMARY_JSON:` line the workflow reads to decide whether to run CI, + open a PR, and whether that PR is eligible for auto-merge. + +If the changelog contains ONLY changes the generator can't express, no +files change and the workflow opens a plain GitHub issue listing them +instead of a PR — there's no code diff to review, but staying silent +would bury a real API change. + +Tests for the pipeline itself live in `scripts/api-sync/__tests__/` and +run via the same `bun test` CI uses for the CLI's own tests. + ## Project structure ``` @@ -192,34 +248,33 @@ recorded `url`/`method`/`body`. Error-path tests assert that the action throws `__test_exit__` (the stubbed `process.exit` re-throws so the test runner sees the exit code). -## Sync workflow conventions - -When responding to an api-sync event: - -1. Read `.api-sync/changelog.md`. It lists every API change since the - last sync. -2. For each change, decide: - - **New endpoint** → Add a CLI command only if a terminal user is - plausibly going to run it. Usually yes for CRUD-style endpoints, - no for internal/read-only diagnostics. When in doubt, add it. - - **New field on an input** → Add a corresponding `--` flag - to the command's option list and pass it through. - - **New field on an output** → Update the default `columns` array if - the field is interesting; don't add columns for low-signal fields. - - **Removed endpoint/field** → Remove the corresponding command/flag. - - **Enum value added** → Update help text only (CLI doesn't validate - enum values client-side). -3. Add or update tests in `src/__tests__/resources.test.ts` for every - action you added or modified. New action → new happy-path test - (URL + method + body). Modified body shape → update the matching - test's expected body. Removed action → remove its test. See the - "Testing" section above for the helper pattern. -4. Bump the `version` field in `package.json` — patch for additive - changes, minor if you removed anything. `CLI_VERSION` is derived - from `package.json` at build time; don't edit `constants.ts`. -5. Run `bun run typecheck`, `bun run lint:fix`, and `bun run test`. Fix any errors. -6. Do NOT touch `.github/workflows/`. -7. Do NOT create commits — leave changes in the working tree. - -If a change in the changelog doesn't map to any CLI surface (e.g. a -schema-only change with no field added), skip it silently. +## Reviewing a needs-human api-sync PR or issue + +`scripts/api-sync/generate.ts` (see "The deterministic api-sync +pipeline" above) already applied everything it safely could. What's left +in the PR/issue body's "Needs a human" section is exactly what it +couldn't express. When picking one up by hand: + +- **New endpoint** → Add a CLI command only if a terminal user is + plausibly going to run it. Usually yes for CRUD-style endpoints, no + for internal/read-only diagnostics. When in doubt, add it. +- **New field on an input, on a path not in `known-resources.ts`** → + Add a corresponding `--` flag to the command's option list and + pass it through. Consider also adding the path to + `scripts/api-sync/known-resources.ts` so future additions on it are + handled automatically. +- **New field on an output** → Update the default `columns` array if + the field is interesting; don't add columns for low-signal fields. +- **Removed endpoint/field** → Remove the corresponding command/flag. +- **Enum value added** → Update help text only (CLI doesn't validate + enum values client-side). +- Add or update tests in `src/__tests__/resources.test.ts` for every + action you add, modify, or remove by hand. See "Testing" above. +- Bump the `version` field in `package.json` if you're adding to a PR + the generator already bumped — patch for additive changes, minor if + you removed anything. +- Run `bun run typecheck`, `bun run lint:fix`, and `bun run test`. + +If a change in the changelog doesn't map to any CLI surface at all +(e.g. a schema-only change with no field added), the generator already +skipped it silently — that's expected, not a bug to report. diff --git a/package.json b/package.json index d86cbef..f214a72 100644 --- a/package.json +++ b/package.json @@ -41,8 +41,8 @@ "dev": "bun run src/index.ts", "build": "bun build src/index.ts --outdir dist --target node --minify && node -e \"const fs=require('fs');const f='dist/index.js';const c=fs.readFileSync(f,'utf8');fs.writeFileSync(f,'#!/usr/bin/env node\\n'+c);fs.chmodSync(f,0o755)\"", "typecheck": "tsc --noEmit", - "lint": "oxlint -c oxlint.json src/", - "lint:fix": "oxlint -c oxlint.json --fix src/", + "lint": "oxlint -c oxlint.json src/ scripts/", + "lint:fix": "oxlint -c oxlint.json --fix src/ scripts/", "test": "bun test", "prepublishOnly": "bun run build" }, diff --git a/scripts/api-sync/__tests__/apply.test.ts b/scripts/api-sync/__tests__/apply.test.ts new file mode 100644 index 0000000..a1f6237 --- /dev/null +++ b/scripts/api-sync/__tests__/apply.test.ts @@ -0,0 +1,82 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, test } from 'bun:test' +import { applyIndexOption, applyResourcesField, applySchemaField } from '../apply' +import type { ApplicableChange } from '../classify' + +const ROOT = join(import.meta.dir, '../../..') + +function real(path: string): string { + return readFileSync(join(ROOT, path), 'utf8') +} + +describe('applyResourcesField', () => { + test('adds a new optional field to createBankAccount, mirroring PR #6', () => { + const change: ApplicableChange = { resource: 'bank_accounts', op: 'create', fn: 'createBankAccount', field: 'clabe', type: 'string', required: false } + const patched = applyResourcesField(real('src/commands/resources.ts'), change) + expect(patched).toContain('clabe?: string') + expect(patched).toContain('if (options.clabe !== undefined) body.clabe = options.clabe') + // Property inserted before the json prop, inside createBankAccount only. + const fnStart = patched.indexOf('export async function createBankAccount(') + const fnRegion = patched.slice(fnStart, patched.indexOf('export async function', fnStart + 1)) + expect(fnRegion).toContain('clabe?: string') + }) + + test('is idempotent: applying the same change twice produces the same output', () => { + const change: ApplicableChange = { resource: 'bank_accounts', op: 'create', fn: 'createBankAccount', field: 'clabe', type: 'string', required: false } + const once = applyResourcesField(real('src/commands/resources.ts'), change) + const twice = applyResourcesField(once, change) + expect(twice).toBe(once) + }) + + test('adds a new optional field to updateCustomer (multi-line options signature)', () => { + const change: ApplicableChange = { resource: 'customers', op: 'update', fn: 'updateCustomer', field: 'external_id', type: 'string', required: false } + const patched = applyResourcesField(real('src/commands/resources.ts'), change) + expect(patched).toContain('externalId?: string') + expect(patched).toContain('if (options.externalId !== undefined) body.external_id = options.externalId') + }) +}) + +describe('applyIndexOption', () => { + test('adds a new --clabe option right before --json on the bank_accounts create command', () => { + const change: ApplicableChange = { resource: 'bank_accounts', op: 'create', fn: 'createBankAccount', field: 'clabe', type: 'string', required: false } + const patched = applyIndexOption(real('src/index.ts'), change) + const lines = patched.split('\n') + const jsonIdx = lines.findIndex(l => l.includes("createBankAccount(opts))")) + const clabeIdx = lines.findIndex(l => l.includes("--clabe ")) + expect(clabeIdx).toBeGreaterThan(-1) + expect(clabeIdx).toBeLessThan(jsonIdx) + }) + + test('is idempotent', () => { + const change: ApplicableChange = { resource: 'bank_accounts', op: 'create', fn: 'createBankAccount', field: 'clabe', type: 'string', required: false } + const once = applyIndexOption(real('src/index.ts'), change) + const twice = applyIndexOption(once, change) + expect(twice).toBe(once) + }) +}) + +describe('applySchemaField', () => { + test('adds a FieldDef to the bank_accounts create fields array', () => { + const change: ApplicableChange = { resource: 'bank_accounts', op: 'create', fn: 'createBankAccount', field: 'clabe', type: 'string', required: false } + const patched = applySchemaField(real('src/commands/schema.ts'), change) + const inserted = patched.split('\n').find(l => l.includes("name: 'clabe'")) + expect(inserted).toBeDefined() + // The inserted line must sit at the same indent as its sibling FieldDefs, not at + // the shallower indent of the closing bracket it was anchored on. + const sibling = patched.split('\n').find(l => l.includes("name: '") && !l.includes('clabe')) + expect(inserted!.match(/^\s*/)![0]).toBe(sibling!.match(/^\s*/)![0]) + }) + + test('is idempotent', () => { + const change: ApplicableChange = { resource: 'bank_accounts', op: 'create', fn: 'createBankAccount', field: 'clabe', type: 'string', required: false } + const once = applySchemaField(real('src/commands/schema.ts'), change) + const twice = applySchemaField(once, change) + expect(twice).toBe(once) + }) + + test('throws (needs-human) for an op/resource with no matching block, instead of guessing', () => { + const change: ApplicableChange = { resource: 'webhook_endpoints', op: 'update' as any, fn: 'updateWebhookEndpoint', field: 'x', type: 'string', required: false } + expect(() => applySchemaField(real('src/commands/schema.ts'), change)).toThrow() + }) +}) diff --git a/scripts/api-sync/__tests__/classify.test.ts b/scripts/api-sync/__tests__/classify.test.ts new file mode 100644 index 0000000..06888a2 --- /dev/null +++ b/scripts/api-sync/__tests__/classify.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from 'bun:test' +import { classify } from '../classify' +import type { ChangelogEvent } from '../types' + +describe('classify', () => { + test('routes a request-field addition on a known resource path to applicable', () => { + const events: ChangelogEvent[] = [ + { + kind: 'field-added', + path: '/v1/instances/{instance_id}/customers/{customer_id}/bank-accounts', + method: 'POST', + section: 'request', + field: 'swift_ifsc_branch_code', + type: 'string', + required: false, + }, + ] + const { applicable, needsHuman } = classify(events) + expect(applicable).toEqual([ + { resource: 'bank_accounts', op: 'create', fn: 'createBankAccount', field: 'swift_ifsc_branch_code', type: 'string', required: false }, + ]) + expect(needsHuman).toEqual([]) + }) + + test('routes a request-field addition on an unknown path to needs-human', () => { + const events: ChangelogEvent[] = [ + { kind: 'field-added', path: '/v1/instances/{instance_id}/transfers', method: 'POST', section: 'request', field: 'memo', type: 'string', required: false }, + ] + const { applicable, needsHuman } = classify(events) + expect(applicable).toEqual([]) + expect(needsHuman).toHaveLength(1) + }) + + test('routes response field additions, removals, enum changes, and endpoint/schema churn to needs-human', () => { + const events: ChangelogEvent[] = [ + { kind: 'field-added', path: '/v1/instances/{instance_id}/customers', method: 'POST', section: 'response', field: 'risk_score', type: 'number', required: false }, + { kind: 'field-removed', path: '/v1/instances/{instance_id}/customers', method: 'POST', section: 'request', field: 'kyc_status' }, + { kind: 'enum-changed', path: '/v1/instances/{instance_id}/customers', method: 'POST', section: 'request', field: 'kyc_type', added: ['enhanced_plus'], removed: [] }, + { kind: 'global-enum-changed', field: 'network', added: ['avalanche'], removed: [] }, + { kind: 'endpoint-added', method: 'POST', path: '/v1/instances/{instance_id}/transfers' }, + { kind: 'endpoint-removed', method: 'DELETE', path: '/v1/instances/{instance_id}/api-keys/{id}' }, + { kind: 'method-added', method: 'PUT', path: '/v1/instances/{instance_id}/quotes' }, + { kind: 'method-removed', method: 'DELETE', path: '/v1/instances/{instance_id}/quotes' }, + { kind: 'schema-added', name: 'TransferBody' }, + { kind: 'schema-removed', name: 'ApiKeyBody' }, + { kind: 'type-changed', path: '/v1/instances/{instance_id}/customers', method: 'POST', section: 'request', field: 'tax_id', detail: 'string -> number' }, + { kind: 'required-changed', path: '/v1/instances/{instance_id}/customers', method: 'POST', section: 'request', field: 'email', detail: 'email became optional' }, + ] + const { applicable, needsHuman } = classify(events) + expect(applicable).toEqual([]) + expect(needsHuman).toHaveLength(events.length) + }) +}) diff --git a/scripts/api-sync/__tests__/parse-changelog.test.ts b/scripts/api-sync/__tests__/parse-changelog.test.ts new file mode 100644 index 0000000..479a151 --- /dev/null +++ b/scripts/api-sync/__tests__/parse-changelog.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, test } from 'bun:test' +import { parseChangelog } from '../parse-changelog' + +describe('parseChangelog', () => { + test('parses an added request field on a modified endpoint', () => { + const md = `# API Changes (SDK-relevant) + +## Modified Endpoints + +### /v1/instances/{instance_id}/customers/{customer_id}/bank-accounts + + Request body (CreateBankAccountBody) [POST]: + - ADDED field: swift_ifsc_branch_code (string, optional) +` + const events = parseChangelog(md) + expect(events).toEqual([ + { + kind: 'field-added', + path: '/v1/instances/{instance_id}/customers/{customer_id}/bank-accounts', + method: 'POST', + section: 'request', + field: 'swift_ifsc_branch_code', + type: 'string', + required: false, + }, + ]) + }) + + test('parses removed field, enum change, new/removed endpoints and schemas', () => { + const md = `# API Changes (SDK-relevant) + +## New Endpoints + +- **POST /v1/instances/{instance_id}/transfers** — Create a transfer + +## Removed Endpoints + +- **DELETE /v1/instances/{instance_id}/api-keys/{id}** + +## Modified Endpoints + +### /v1/instances/{instance_id}/customers + + Request body (CreateCustomerBody) [POST]: + - REMOVED field: kyc_status + - ENUM kyc_type: added 1 values: enhanced_plus + +## Enum Value Changes + +These enum fields gained or lost values across all schemas: + + - network: ADDED 1 values: avalanche; REMOVED 1 values: legacy_chain + +## New Schemas + +- **TransferBody** (3 fields) + +## Removed Schemas + +- **ApiKeyBody** +` + const events = parseChangelog(md) + expect(events).toContainEqual({ kind: 'endpoint-added', method: 'POST', path: '/v1/instances/{instance_id}/transfers' }) + expect(events).toContainEqual({ kind: 'endpoint-removed', method: 'DELETE', path: '/v1/instances/{instance_id}/api-keys/{id}' }) + expect(events).toContainEqual({ + kind: 'field-removed', + path: '/v1/instances/{instance_id}/customers', + method: 'POST', + section: 'request', + field: 'kyc_status', + }) + expect(events).toContainEqual({ + kind: 'enum-changed', + path: '/v1/instances/{instance_id}/customers', + method: 'POST', + section: 'request', + field: 'kyc_type', + added: ['enhanced_plus'], + removed: [], + }) + expect(events).toContainEqual({ + kind: 'global-enum-changed', + field: 'network', + added: ['avalanche'], + removed: ['legacy_chain'], + }) + expect(events).toContainEqual({ kind: 'schema-added', name: 'TransferBody' }) + expect(events).toContainEqual({ kind: 'schema-removed', name: 'ApiKeyBody' }) + }) + + test('fails loudly on an unrecognized bullet instead of silently dropping it', () => { + const md = `# API Changes (SDK-relevant) + +## Modified Endpoints + +### /v1/instances/{instance_id}/customers + + Request body (X) [POST]: + - SOMETHING WEIRD: kyc_status +` + expect(() => parseChangelog(md)).toThrow() + }) + + test('returns no events for an empty changelog', () => { + expect(parseChangelog('# API Changes (SDK-relevant)\n')).toEqual([]) + }) +}) diff --git a/scripts/api-sync/apply.ts b/scripts/api-sync/apply.ts new file mode 100644 index 0000000..8ab09c4 --- /dev/null +++ b/scripts/api-sync/apply.ts @@ -0,0 +1,153 @@ +import type { ApplicableChange } from './classify' +import { snakeToCamel, snakeToKebab, snakeToTitle } from './case' + +export interface SourceFiles { + resources: string + index: string + schema: string +} + +function leadingWhitespace(line: string): string { + return /^(\s*)/.exec(line)![1] +} + +/** + * Bounds a search to one function/block: from `startIdx` up to (but not + * including) the next top-level `export ` declaration, or EOF. Every anchor + * search below is scoped this way so a match can never leak into an + * unrelated function later in the file. + */ +function sliceToNextExport(src: string, startIdx: number): { text: string, end: number } { + const rest = src.slice(startIdx) + const nextExport = rest.slice(1).search(/^export /m) + const end = nextExport === -1 ? src.length : startIdx + 1 + nextExport + return { text: src.slice(startIdx, end), end } +} + +function insertLineBefore(src: string, region: { start: number, end: number }, anchorRe: RegExp, newLine: (indent: string) => string, opts?: { indentFromPreviousLine?: boolean }): string { + const region_text = src.slice(region.start, region.end) + const lines = region_text.split('\n') + const idx = lines.findIndex(l => anchorRe.test(l)) + if (idx === -1) + throw new Error(`apply: anchor ${anchorRe} not found in region`) + // A closing-bracket anchor sits 2 spaces shallower than the array elements, so an + // inserted sibling must take its indent from the element above, not the anchor. + const indent = opts?.indentFromPreviousLine && idx > 0 && lines[idx - 1].trim() !== '' + ? leadingWhitespace(lines[idx - 1]) + : leadingWhitespace(lines[idx]) + lines.splice(idx, 0, newLine(indent)) + const patchedRegion = lines.join('\n') + return src.slice(0, region.start) + patchedRegion + src.slice(region.end) +} + +/** Adds `?: ` to the function's options type, right before its `json` prop. */ +export function applyResourcesField(src: string, change: ApplicableChange): string { + const fieldCamel = snakeToCamel(change.field) + const declRe = new RegExp(`export async function ${change.fn}\\(`) + const declMatch = declRe.exec(src) + if (!declMatch) + throw new Error(`apply: function "${change.fn}" not found in resources.ts`) + const declIdx = declMatch.index + + const { text: fnBody, end: fnEnd } = sliceToNextExport(src, declIdx) + + // Idempotent: if this run (or a previous one) already added the field, don't add it twice. + if (new RegExp(`\\b${fieldCamel}\\??:\\s*(string|number)\\b`).test(fnBody)) + return src + + const tsType = change.type?.includes('number') || change.type?.includes('integer') ? 'number' : 'string' + const jsonPropRe = /^(\s*)json\??:\s*boolean\b/ + + const lines = fnBody.split('\n') + const jsonIdx = lines.findIndex(l => jsonPropRe.test(l)) + if (jsonIdx === -1) + throw new Error(`apply: could not find a "json?: boolean" options property in ${change.fn} to anchor the new field before`) + const indent = leadingWhitespace(lines[jsonIdx]) + lines.splice(jsonIdx, 0, `${indent}${fieldCamel}?: ${tsType}`) + + // Now find the request line (apiPost requestLineRe.test(l)) + if (requestIdx === -1) + throw new Error(`apply: could not find an "await apiPost ` right before the command's `--json` option. */ +export function applyIndexOption(src: string, change: ApplicableChange): string { + const fieldKebab = snakeToKebab(change.field) + const title = snakeToTitle(change.field) + + const actionRe = new RegExp(`\\.action\\([^)]*=>\\s*${change.fn}\\(`) + const actionMatch = actionRe.exec(src) + if (!actionMatch) + throw new Error(`apply: no ".action(... => ${change.fn}(...))" wiring found in index.ts`) + + // Bound the search for the command block backwards to the previous blank + // line or `.command(` call, so we never touch an unrelated command that + // happens to also end in `.option('--json', ...)`. + const before = src.slice(0, actionMatch.index) + const blockStart = before.lastIndexOf('\n\n') + 1 + const optionRe = /^(\s*)\.option\('--json',/m + + if (new RegExp(`--${fieldKebab}\\b`).test(before.slice(blockStart))) + return src // idempotent: already added + + return insertLineBefore( + src, + { start: blockStart, end: actionMatch.index }, + optionRe, + indent => `${indent}.option('--${fieldKebab} ', '${title} (added by api-sync)')`, + ) +} + +/** Adds a FieldDef entry to schema.ts's declarative mirror for `blindpay schema get `. */ +export function applySchemaField(src: string, change: ApplicableChange): string { + const resourceRe = new RegExp(`resource: '${change.resource}',`) + const resourceMatch = resourceRe.exec(src) + if (!resourceMatch) + throw new Error(`apply: resource "${change.resource}" not found in schema.ts`) + + const { text: resourceBlock, end: resourceEnd } = sliceToNextResourceEntry(src, resourceMatch.index) + + const opRe = new RegExp(`${change.op}: \\{`) + const opMatch = opRe.exec(resourceBlock) + if (!opMatch) { + // The resource has no `create`/`update` block yet in schema.ts (e.g. it's + // list-only today). Adding one from scratch needs a human: schema.ts's + // FieldDef list for a brand-new operation isn't a single-line insert. + throw new Error(`apply: resource "${change.resource}" has no "${change.op}:" block in schema.ts to extend`) + } + + if (new RegExp(`name: '${change.field}'`).test(resourceBlock)) + return src // idempotent + + const tsType = change.type?.includes('number') || change.type?.includes('integer') ? 'number' : 'string' + const requiredLiteral = change.required ? 'true' : 'false' + const title = snakeToTitle(change.field) + const newFieldLine = (indent: string) => `${indent}{ name: '${change.field}', type: '${tsType}', required: ${requiredLiteral}, description: '${title} (added by api-sync)' },` + + const patchedBlock = insertLineBefore( + resourceBlock, + { start: opMatch.index, end: resourceBlock.length }, + /^(\s*)\],\s*$/, + newFieldLine, + { indentFromPreviousLine: true }, + ) + + return src.slice(0, resourceMatch.index) + patchedBlock + src.slice(resourceEnd) +} + +function sliceToNextResourceEntry(src: string, startIdx: number): { text: string, end: number } { + const rest = src.slice(startIdx) + const nextResource = rest.slice(1).search(/resource: '/) + const end = nextResource === -1 ? src.length : startIdx + 1 + nextResource + return { text: src.slice(startIdx, end), end } +} diff --git a/scripts/api-sync/case.ts b/scripts/api-sync/case.ts new file mode 100644 index 0000000..9587033 --- /dev/null +++ b/scripts/api-sync/case.ts @@ -0,0 +1,17 @@ +/** snake_case -> camelCase, matching commander's own flag->property casing. */ +export function snakeToCamel(s: string): string { + return s.replace(/_([a-z0-9])/gi, (_, c: string) => c.toUpperCase()) +} + +/** snake_case -> kebab-case, for `--flag-name` option strings. */ +export function snakeToKebab(s: string): string { + return s.replace(/_/g, '-') +} + +/** snake_case -> "Title Case Words", for auto-generated help text / descriptions. */ +export function snakeToTitle(s: string): string { + return s + .split('_') + .map(w => w.charAt(0).toUpperCase() + w.slice(1)) + .join(' ') +} diff --git a/scripts/api-sync/classify.ts b/scripts/api-sync/classify.ts new file mode 100644 index 0000000..2e9da3c --- /dev/null +++ b/scripts/api-sync/classify.ts @@ -0,0 +1,100 @@ +import type { ChangelogEvent, FieldChangeEvent } from './types' +import { findKnownResourceByPath } from './known-resources' + +/** + * A field-added event on the REQUEST body of a create/update path this + * generator recognizes. This is the entire CAN-express surface today: + * one new optional CLI flag, passed through verbatim to the request body, + * mirrors exactly how every historical additive api-sync PR touched this + * codebase (e.g. #6's `swift_ifsc_branch_code`). + * + * Deliberately NOT expressed by the generator (routed to needs-human + * instead), even though a human syncing the CLI by hand might handle them + * mechanically too: + * - field-removed: removing a flag can break scripts already depending on + * it; that's a judgment call, not a mechanical one. + * - response-only field/enum changes: whether to surface a new response + * field as a table column is explicitly a "when in doubt" judgment call + * in CLAUDE.md, not a deterministic rule. + * - enum-changed / global-enum-changed: CLAUDE.md says "update help text + * only" for these, but there is no stable anchor for what the help text + * currently says, so it can't be regenerated safely by a script. + * - endpoint/method/schema added or removed: always needs hand-written + * command wiring (or removal) in src/index.ts. + */ +export interface ApplicableChange { + resource: string + op: 'create' | 'update' + fn: string + field: string + type?: string + required?: boolean +} + +export interface ClassifyResult { + applicable: ApplicableChange[] + needsHuman: string[] +} + +export function classify(events: ChangelogEvent[]): ClassifyResult { + const applicable: ApplicableChange[] = [] + const needsHuman: string[] = [] + + for (const event of events) { + switch (event.kind) { + case 'field-added': { + const fe = event as FieldChangeEvent + if (fe.section !== 'request') { + needsHuman.push(`Response field added on ${fe.method} ${fe.path}: "${fe.field}" — whether to surface it as a CLI output column is a judgment call (CLAUDE.md), not scripted.`) + break + } + const known = findKnownResourceByPath(fe.path) + if (!known) { + needsHuman.push(`Field "${fe.field}" added to the request body of ${fe.method} ${fe.path}, a path this generator does not recognize. Needs a human to decide the CLI surface (new command, or extend known-resources.ts).`) + break + } + applicable.push({ resource: known.resource, op: known.op, fn: known.fn, field: fe.field, type: fe.type, required: fe.required }) + break + } + case 'field-removed': { + const fe = event + needsHuman.push(`Field "${fe.field}" removed from the request body of ${fe.method} ${fe.path} (${fe.section}). Removing a CLI flag is a breaking change for scripts using it and needs a human call.`) + break + } + case 'enum-changed': { + needsHuman.push(`Enum values changed for "${event.field}" on ${event.method} ${event.path} (${event.section}): +[${event.added.join(', ')}] -[${event.removed.join(', ')}]. CLAUDE.md says update help text only, but there is no safe anchor to regenerate that text from.`) + break + } + case 'global-enum-changed': { + needsHuman.push(`Enum values changed for "${event.field}" across schemas: +[${event.added.join(', ')}] -[${event.removed.join(', ')}].`) + break + } + case 'endpoint-added': + needsHuman.push(`New endpoint ${event.method} ${event.path}. Needs a human to decide whether it warrants a new CLI command.`) + break + case 'endpoint-removed': + needsHuman.push(`Endpoint removed: ${event.method} ${event.path}. Needs a human to decide whether to remove the corresponding CLI command.`) + break + case 'method-added': + needsHuman.push(`New method ${event.method} on existing path ${event.path}. Needs a human to decide the CLI surface.`) + break + case 'method-removed': + needsHuman.push(`Method removed: ${event.method} ${event.path}.`) + break + case 'schema-added': + needsHuman.push(`New schema "${event.name}" in the spec.`) + break + case 'schema-removed': + needsHuman.push(`Schema removed: "${event.name}".`) + break + case 'type-changed': + needsHuman.push(`Type changed for "${event.field}" on ${event.method} ${event.path} (${event.section}): ${event.detail}`) + break + case 'required-changed': + needsHuman.push(`Requiredness changed for "${event.field}" on ${event.method} ${event.path} (${event.section}): ${event.detail}`) + break + } + } + + return { applicable, needsHuman } +} diff --git a/scripts/api-sync/generate.ts b/scripts/api-sync/generate.ts new file mode 100644 index 0000000..2078bdc --- /dev/null +++ b/scripts/api-sync/generate.ts @@ -0,0 +1,90 @@ +#!/usr/bin/env bun +/** + * Deterministic api-sync entry point. Reads the changelog pushed by + * blindpay-v2's sdk-sync workflow, classifies every change as either + * mechanically applicable or needs-human, applies the applicable ones to + * src/commands/resources.ts, src/index.ts, src/commands/schema.ts, and + * bumps package.json's version. Writes a machine-readable summary to + * stdout as the last line (prefixed `SUMMARY_JSON:`) for the workflow to + * pick up. + * + * Usage: bun scripts/api-sync/generate.ts --changelog --repo-root + */ +import { readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { parseArgs } from 'node:util' +import { applyIndexOption, applyResourcesField, applySchemaField } from './apply' +import { classify } from './classify' +import { parseChangelog } from './parse-changelog' +import { bumpVersion, deriveVersionBump } from './version-bump' + +function main() { + const { values } = parseArgs({ + options: { + changelog: { type: 'string' }, + 'repo-root': { type: 'string', default: '.' }, + }, + }) + + if (!values.changelog) + throw new Error('usage: generate.ts --changelog [--repo-root ]') + + const root = values['repo-root']! + const changelogMd = readFileSync(values.changelog, 'utf8') + const events = parseChangelog(changelogMd) + const { applicable, needsHuman } = classify(events) + + const resourcesPath = join(root, 'src/commands/resources.ts') + const indexPath = join(root, 'src/index.ts') + const schemaPath = join(root, 'src/commands/schema.ts') + const pkgPath = join(root, 'package.json') + + let resourcesSrc = readFileSync(resourcesPath, 'utf8') + let indexSrc = readFileSync(indexPath, 'utf8') + let schemaSrc = readFileSync(schemaPath, 'utf8') + + const applied: string[] = [] + for (const change of applicable) { + resourcesSrc = applyResourcesField(resourcesSrc, change) + indexSrc = applyIndexOption(indexSrc, change) + // Schema.ts field mirroring is best-effort documentation: some known + // resources don't have a matching create/update fields block to extend + // yet (e.g. update on a resource that's create-only in schema.ts today). + // That's a schema.ts staleness issue pre-existing this generator, not a + // reason to fail the whole run — the CLI still works without it. + try { + schemaSrc = applySchemaField(schemaSrc, change) + } + catch (e) { + needsHuman.push(`schema.ts mirror not updated for "${change.field}" on ${change.resource}.${change.op}: ${(e as Error).message}`) + } + applied.push(`${change.resource}.${change.op}: +${change.field}`) + } + + const { type: bumpType, reasons: bumpReasons } = deriveVersionBump(events) + + if (applied.length > 0) { + const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) + const nextVersion = bumpVersion(pkg.version, bumpType) + pkg.version = nextVersion + writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`) + writeFileSync(resourcesPath, resourcesSrc) + writeFileSync(indexPath, indexSrc) + writeFileSync(schemaPath, schemaSrc) + } + + const summary = { + appliedCount: applied.length, + applied, + needsHumanCount: needsHuman.length, + needsHuman, + bumpType, + bumpReasons, + canAutoMerge: needsHuman.length === 0 && applied.length > 0, + hasChanges: applied.length > 0, + } + + console.log(`SUMMARY_JSON:${JSON.stringify(summary)}`) +} + +main() diff --git a/scripts/api-sync/known-resources.ts b/scripts/api-sync/known-resources.ts new file mode 100644 index 0000000..662c570 --- /dev/null +++ b/scripts/api-sync/known-resources.ts @@ -0,0 +1,145 @@ +/** + * The deterministic generator's entire "what can I touch" surface. + * + * Each entry maps a schema.ts `resource` name to the exact OpenAPI path(s) + * whose request body corresponds to that resource's `create`/`update` + * field list. Multiple paths for the same operation are aliases that share + * one request schema (e.g. the API exposes both /receivers and /customers + * for the same underlying resource) — a change reported on any alias path + * is treated as one event for the resource. + * + * A path NOT listed here is, by construction, unmappable: the classifier + * in classify.ts routes any changelog event on an unlisted path straight + * to the "needs a human" bucket. This is intentional — new commands and + * new top-level resources always require hand-written wiring in + * src/index.ts and src/commands/resources.ts, which this generator does + * not touch. Extending this map to a genuinely new resource is itself a + * hand-written change to make deliberately, not something to automate. + */ + +export interface OperationRef { + /** OpenAPI path templates that alias the same request schema. */ + paths: string[] + method: 'post' | 'put' + /** + * Exported function name in src/commands/resources.ts that builds this + * request body. The generator locates this function by name and never + * guesses it from the resource name, since a handful of resources + * (customers/receivers) alias to non-obvious function names. + */ + fn: string +} + +export interface KnownResource { + /** Matches `resource` in src/commands/schema.ts. */ + resource: string + create?: OperationRef + update?: OperationRef +} + +export const KNOWN_RESOURCES: KnownResource[] = [ + { + resource: 'customers', + create: { + method: 'post', + fn: 'createCustomer', + paths: [ + '/v1/instances/{instance_id}/customers', + '/v1/instances/{instance_id}/receivers', + ], + }, + update: { + method: 'put', + fn: 'updateCustomer', + paths: [ + '/v1/instances/{instance_id}/customers/{id}', + '/v1/instances/{instance_id}/receivers/{id}', + ], + }, + }, + { + resource: 'bank_accounts', + create: { + method: 'post', + fn: 'createBankAccount', + paths: [ + '/v1/instances/{instance_id}/customers/{customer_id}/bank-accounts', + '/v1/instances/{instance_id}/receivers/{receiver_id}/bank-accounts', + ], + }, + }, + { + resource: 'blockchain_wallets', + create: { + method: 'post', + fn: 'createBlockchainWallet', + paths: [ + '/v1/instances/{instance_id}/customers/{customer_id}/blockchain-wallets', + '/v1/instances/{instance_id}/receivers/{receiver_id}/blockchain-wallets', + ], + }, + }, + { + resource: 'quotes', + create: { + method: 'post', + fn: 'createQuote', + paths: ['/v1/instances/{instance_id}/quotes'], + }, + }, + { + resource: 'payin_quotes', + create: { + method: 'post', + fn: 'createPayinQuote', + paths: ['/v1/instances/{instance_id}/payin-quotes'], + }, + }, + { + resource: 'webhook_endpoints', + create: { + method: 'post', + fn: 'createWebhookEndpoint', + paths: ['/v1/instances/{instance_id}/webhook-endpoints'], + }, + }, + { + resource: 'partner_fees', + create: { + method: 'post', + fn: 'createPartnerFee', + paths: ['/v1/instances/{instance_id}/partner-fees'], + }, + }, + { + resource: 'virtual_accounts', + create: { + method: 'post', + fn: 'createVirtualAccount', + paths: [ + '/v1/instances/{instance_id}/customers/{customer_id}/virtual-accounts', + '/v1/instances/{instance_id}/receivers/{receiver_id}/virtual-accounts', + ], + }, + }, + // `api_keys` was removed from the CLI (PR #17) after the API dropped the + // feature from the CLI-relevant surface; deliberately no entry here. + // `payouts` and `payins` deliberately have NO entry: schema.ts models + // them with a single generic `network` field abstracting over the + // network-specific endpoints (POST .../payouts/evm|solana|stellar, + // POST .../payins/evm). That abstraction is a hand-curated union, not a + // 1:1 mapping to one request schema, so field/enum diffs on any of + // those paths are intentionally routed to the needs-human bucket. + // `offramp_wallets` has no `create` in the CLI schema (list-only today) + // so it is likewise omitted. +] + +export function findKnownResourceByPath(path: string): { resource: string, op: 'create' | 'update', fn: string } | null { + for (const kr of KNOWN_RESOURCES) { + if (kr.create?.paths.includes(path)) + return { resource: kr.resource, op: 'create', fn: kr.create.fn } + if (kr.update?.paths.includes(path)) + return { resource: kr.resource, op: 'update', fn: kr.update.fn } + } + return null +} diff --git a/scripts/api-sync/parse-changelog.ts b/scripts/api-sync/parse-changelog.ts new file mode 100644 index 0000000..b997354 --- /dev/null +++ b/scripts/api-sync/parse-changelog.ts @@ -0,0 +1,231 @@ +import type { ChangelogEvent } from './types' + +/** + * Parses the exact markdown format emitted by blindpay-v2's + * scripts/spec-diff.ts into structured events. This is a format parser, + * not a heuristic: every pattern here corresponds 1:1 to a `lines.push(...)` + * call in that script. If spec-diff.ts's output format ever changes, this + * parser must fail loudly (see `parseChangelog`'s unrecognized-line check) + * rather than silently drop events. + */ + +const NEW_ENDPOINT_RE = /^- \*\*(\w+) (\S+)\*\*/ +const REMOVED_ENDPOINT_RE = /^- \*\*(\w+) (\S+)\*\*\s*$/ +const MODIFIED_PATH_RE = /^### (\S+)\s*$/ +const NEW_METHOD_RE = /^ {2}\*\*New method: (\w+)\*\*/ +const REMOVED_METHOD_RE = /^ {2}\*\*Removed method: (\w+)\*\*\s*$/ +const SECTION_HEADER_RE = /^ {2}(Request body|Response) \([^)]*\) \[(\w+)\]:\s*$/ +const ADDED_FIELD_RE = /^ {2}- ADDED field: (\S+) \(([^,]*(?:, [^,]*)*), (required|optional)\)\s*$/ +const REMOVED_FIELD_RE = /^ {2}- REMOVED field: (\S+)\s*$/ +const ENUM_FIELD_RE = /^ {2}- ENUM (\S+): (.+)$/ +const TYPE_CHANGED_RE = /^ {2}- CHANGED type: (\S+): (.+)$/ +const REQUIRED_CHANGED_RE = /^ {2}- CHANGED: (\S+) became (optional|required) \(was \w+\)\s*$/ +const GLOBAL_ENUM_RE = /^ {2}- (\S+): (.+)$/ +const NEW_SCHEMA_RE = /^- \*\*(\S+)\*\* \(\d+ fields\)\s*$/ +const REMOVED_SCHEMA_RE = /^- \*\*(\S+)\*\*\s*$/ + +function parseValueList(text: string): string[] { + const idx = text.indexOf(':') + if (idx === -1) + return [] + return text + .slice(idx + 1) + .split(',') + .map(s => s.trim()) + .filter(Boolean) +} + +type Section = '## New Endpoints' | '## Removed Endpoints' | '## Modified Endpoints' | '## Enum Value Changes' | '## New Schemas' | '## Removed Schemas' | null + +export function parseChangelog(markdown: string): ChangelogEvent[] { + const events: ChangelogEvent[] = [] + const lines = markdown.split('\n') + + let section: Section = null + let currentPath: string | null = null + let currentMethod: string | null = null + let currentSection: 'request' | 'response' | null = null + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + + if (line.startsWith('## ')) { + const heading = line.trim() + if ( + heading === '## New Endpoints' + || heading === '## Removed Endpoints' + || heading === '## Modified Endpoints' + || heading === '## Enum Value Changes' + || heading === '## New Schemas' + || heading === '## Removed Schemas' + ) { + section = heading as Section + } + else { + // Unknown top-level section (e.g. "# API Changes" title, or a future + // section spec-diff.ts doesn't emit yet). Reset context but don't fail: + // only bullet lines under a recognized section are meaningful. + section = null + } + currentPath = null + currentMethod = null + currentSection = null + continue + } + + if (line.trim() === '' || line.startsWith('# ')) + continue + + if (section === '## New Endpoints') { + const m = NEW_ENDPOINT_RE.exec(line) + if (m) { + events.push({ kind: 'endpoint-added', method: m[1], path: m[2] }) + continue + } + // Sub-bullets describing the new endpoint's request/response fields + // carry no independent information for the generator: the endpoint + // itself is already unmappable (never in KNOWN_RESOURCES until a human + // adds it), so its field list doesn't need separate events. + continue + } + + if (section === '## Removed Endpoints') { + const m = REMOVED_ENDPOINT_RE.exec(line) + if (m) { + events.push({ kind: 'endpoint-removed', method: m[1], path: m[2] }) + continue + } + throw new Error(`parse-changelog: unrecognized line under "## Removed Endpoints": ${JSON.stringify(line)}`) + } + + if (section === '## Modified Endpoints') { + const pathMatch = MODIFIED_PATH_RE.exec(line) + if (pathMatch) { + currentPath = pathMatch[1] + currentMethod = null + currentSection = null + continue + } + if (!currentPath) + throw new Error(`parse-changelog: bullet before any "### /path" header: ${JSON.stringify(line)}`) + + const newMethod = NEW_METHOD_RE.exec(line) + if (newMethod) { + events.push({ kind: 'method-added', method: newMethod[1], path: currentPath }) + currentSection = null + continue + } + const removedMethod = REMOVED_METHOD_RE.exec(line) + if (removedMethod) { + events.push({ kind: 'method-removed', method: removedMethod[1], path: currentPath }) + currentSection = null + continue + } + const sectionHeader = SECTION_HEADER_RE.exec(line) + if (sectionHeader) { + currentSection = sectionHeader[1] === 'Request body' ? 'request' : 'response' + currentMethod = sectionHeader[2] + continue + } + if (!currentMethod || !currentSection) + throw new Error(`parse-changelog: field bullet before a "Request body/Response [...]:" header: ${JSON.stringify(line)}`) + + const added = ADDED_FIELD_RE.exec(line) + if (added) { + events.push({ + kind: 'field-added', + path: currentPath, + method: currentMethod, + section: currentSection, + field: added[1], + type: added[2], + required: added[3] === 'required', + }) + continue + } + const removed = REMOVED_FIELD_RE.exec(line) + if (removed) { + events.push({ kind: 'field-removed', path: currentPath, method: currentMethod, section: currentSection, field: removed[1] }) + continue + } + const enumChange = ENUM_FIELD_RE.exec(line) + if (enumChange) { + const field = enumChange[1] + const rest = enumChange[2] + const added2 = /added (\d+) values?: ([^;]*)/.exec(rest) + const removed2 = /removed (\d+) values?: (.*)$/.exec(rest) + events.push({ + kind: 'enum-changed', + path: currentPath, + method: currentMethod, + section: currentSection, + field, + added: added2 ? added2[2].split(',').map(s => s.trim()).filter(Boolean) : [], + removed: removed2 ? removed2[2].split(',').map(s => s.trim()).filter(Boolean) : [], + }) + continue + } + const typeChanged = TYPE_CHANGED_RE.exec(line) + if (typeChanged) { + events.push({ kind: 'type-changed', path: currentPath, method: currentMethod, section: currentSection, field: typeChanged[1], detail: typeChanged[2] }) + continue + } + const requiredChanged = REQUIRED_CHANGED_RE.exec(line) + if (requiredChanged) { + events.push({ kind: 'required-changed', path: currentPath, method: currentMethod, section: currentSection, field: requiredChanged[1], detail: line.trim() }) + continue + } + throw new Error(`parse-changelog: unrecognized field bullet under ${currentPath} [${currentMethod}]: ${JSON.stringify(line)}`) + } + + if (section === '## Enum Value Changes') { + if (line.trim() === 'These enum fields gained or lost values across all schemas:') + continue + const m = GLOBAL_ENUM_RE.exec(line) + if (m) { + const field = m[1] + const rest = m[2] + const addedPart = /ADDED (\d+) values?: ([^;]*)/.exec(rest) + const removedPart = /REMOVED (\d+) values?: (.*)$/.exec(rest) + events.push({ + kind: 'global-enum-changed', + field, + added: addedPart ? addedPart[2].split(',').map(s => s.trim()).filter(Boolean) : [], + removed: removedPart ? removedPart[2].split(',').map(s => s.trim()).filter(Boolean) : [], + }) + continue + } + throw new Error(`parse-changelog: unrecognized line under "## Enum Value Changes": ${JSON.stringify(line)}`) + } + + if (section === '## New Schemas') { + const m = NEW_SCHEMA_RE.exec(line) + if (m) { + events.push({ kind: 'schema-added', name: m[1] }) + continue + } + throw new Error(`parse-changelog: unrecognized line under "## New Schemas": ${JSON.stringify(line)}`) + } + + if (section === '## Removed Schemas') { + const m = REMOVED_SCHEMA_RE.exec(line) + if (m) { + events.push({ kind: 'schema-removed', name: m[1] }) + continue + } + throw new Error(`parse-changelog: unrecognized line under "## Removed Schemas": ${JSON.stringify(line)}`) + } + + // A bullet outside any recognized section (e.g. malformed changelog, + // or a section this parser doesn't know about yet). Fail loud per the + // honest-failure requirement instead of silently ignoring content. + if (line.trim().startsWith('-') && section === null) + throw new Error(`parse-changelog: bullet outside any recognized section: ${JSON.stringify(line)}`) + } + + return events +} + +// Re-export the value-list helper for tests that want to exercise it in +// isolation without depending on the full parser. +export { parseValueList } diff --git a/scripts/api-sync/types.ts b/scripts/api-sync/types.ts new file mode 100644 index 0000000..857a973 --- /dev/null +++ b/scripts/api-sync/types.ts @@ -0,0 +1,60 @@ +/** Structured events extracted from the changelog produced by blindpay-v2's + * `scripts/spec-diff.ts`. Every event kind maps 1:1 to a bullet format + * that script emits — see parse-changelog.ts for the exact patterns. */ + +export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | string + +export interface FieldChangeEvent { + kind: 'field-added' | 'field-removed' + path: string + method: HttpMethod + section: 'request' | 'response' + field: string + type?: string + required?: boolean +} + +export interface EnumChangeEvent { + kind: 'enum-changed' + path: string + method: HttpMethod + section: 'request' | 'response' + field: string + added: string[] + removed: string[] +} + +export interface GlobalEnumChangeEvent { + kind: 'global-enum-changed' + field: string + added: string[] + removed: string[] +} + +export interface EndpointChangeEvent { + kind: 'endpoint-added' | 'endpoint-removed' | 'method-added' | 'method-removed' + path: string + method: HttpMethod +} + +export interface SchemaChangeEvent { + kind: 'schema-added' | 'schema-removed' + name: string +} + +export interface OtherFieldChangeEvent { + kind: 'type-changed' | 'required-changed' + path: string + method: HttpMethod + section: 'request' | 'response' + field: string + detail: string +} + +export type ChangelogEvent = + | FieldChangeEvent + | EnumChangeEvent + | GlobalEnumChangeEvent + | EndpointChangeEvent + | SchemaChangeEvent + | OtherFieldChangeEvent diff --git a/scripts/api-sync/version-bump.ts b/scripts/api-sync/version-bump.ts new file mode 100644 index 0000000..ebd3462 --- /dev/null +++ b/scripts/api-sync/version-bump.ts @@ -0,0 +1,53 @@ +import type { ChangelogEvent } from './types' + +export type BumpType = 'minor' | 'patch' + +/** + * Minor if the spec diff added/removed any command-shaped surface (an + * endpoint, a method on an existing path) or any enum value anywhere; + * patch otherwise. Matches the owner's stated rule and mirrors the + * equivalent decision in blindpay-mcp's derive-version-bump.ts, adapted to + * the events this repo's changelog format actually carries. + */ +export function deriveVersionBump(events: ChangelogEvent[]): { type: BumpType, reasons: string[] } { + const reasons: string[] = [] + + for (const e of events) { + switch (e.kind) { + case 'endpoint-added': + reasons.push(`endpoint added: ${e.method} ${e.path}`) + break + case 'endpoint-removed': + reasons.push(`endpoint removed: ${e.method} ${e.path}`) + break + case 'method-added': + reasons.push(`method added: ${e.method} ${e.path}`) + break + case 'method-removed': + reasons.push(`method removed: ${e.method} ${e.path}`) + break + case 'enum-changed': + if (e.added.length) reasons.push(`enum values added on ${e.field} (${e.method} ${e.path}): ${e.added.join(', ')}`) + if (e.removed.length) reasons.push(`enum values removed on ${e.field} (${e.method} ${e.path}): ${e.removed.join(', ')}`) + break + case 'global-enum-changed': + if (e.added.length) reasons.push(`enum values added on ${e.field}: ${e.added.join(', ')}`) + if (e.removed.length) reasons.push(`enum values removed on ${e.field}: ${e.removed.join(', ')}`) + break + default: + break + } + } + + return { type: reasons.length > 0 ? 'minor' : 'patch', reasons } +} + +export function bumpVersion(currentVersion: string, type: BumpType): string { + const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(currentVersion) + if (!match) + throw new Error(`Cannot parse semver version "${currentVersion}".`) + const major = Number(match[1]) + const minor = Number(match[2]) + const patch = Number(match[3]) + return type === 'minor' ? `${major}.${minor + 1}.0` : `${major}.${minor}.${patch + 1}` +} diff --git a/tsconfig.json b/tsconfig.json index aac43d5..186932c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,6 +15,6 @@ "isolatedModules": true, "skipLibCheck": true }, - "include": ["src/**/*", "package.json"], + "include": ["src/**/*", "scripts/**/*", "package.json"], "exclude": ["node_modules", "dist"] }