From 3de09aed2f657b56929fcfb6b1070050170410cc Mon Sep 17 00:00:00 2001 From: Cameron Date: Wed, 17 Jun 2026 12:09:25 -0700 Subject: [PATCH 1/3] fix: parse repo DID remotes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Accept bare Tangled repo DID Git remotes so repositories cloned or pushed with git@tangled.org: resolve in tang context. ๐Ÿ‘พ Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta Code --- src/commands/context.ts | 2 ++ src/utils/git.ts | 12 ++++++++++++ tests/utils/git.test.ts | 24 ++++++++++++++++++++++-- 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/commands/context.ts b/src/commands/context.ts index ab49120..15fd094 100644 --- a/src/commands/context.ts +++ b/src/commands/context.ts @@ -16,8 +16,10 @@ export function createContextCommand(): Command { console.log('โœ— Not in a Tangled repository'); console.log('\nTo use this repository with Tangled, add a tangled.org remote:'); console.log(' git remote add origin git@tangled.org:/.git'); + console.log(' # or, for repo-DID remotes: git remote add origin git@tangled.org:'); console.log('\nOr clone from tangled.org:'); console.log(' git clone git@tangled.org:/.git'); + console.log(' # or: git clone git@tangled.org:'); process.exit(1); } diff --git a/src/utils/git.ts b/src/utils/git.ts index ea52578..503f2bb 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -62,6 +62,18 @@ export function parseTangledRemote(url: string): ParsedTangledRemote | null { // Remove .git extension if present path = path.replace(/\.git$/, ''); + // Tangled's hosted knot accepts a bare repository DID as a Git remote, + // e.g. git@tangled.org:did:plc:... . This does not encode the human + // owner/repo slug, but it is still a valid Tangled repository remote. + if (isValidTangledDid(path)) { + return { + owner: path, + ownerType: 'did', + name: path, + protocol, + }; + } + // Split path into owner and repo name const parts = path.split('/'); if (parts.length < 2) { diff --git a/tests/utils/git.test.ts b/tests/utils/git.test.ts index c0cc531..e261c74 100644 --- a/tests/utils/git.test.ts +++ b/tests/utils/git.test.ts @@ -56,6 +56,16 @@ describe('Git Utilities', () => { protocol: 'ssh', }); }); + + it('should parse bare SSH repo-DID remotes', () => { + const result = parseTangledRemote('git@tangled.org:did:plc:ll5woixehdm2aq4tqr7pkgcr'); + expect(result).toEqual({ + owner: 'did:plc:ll5woixehdm2aq4tqr7pkgcr', + ownerType: 'did', + name: 'did:plc:ll5woixehdm2aq4tqr7pkgcr', + protocol: 'ssh', + }); + }); }); describe('SSH URLs with handles', () => { @@ -109,6 +119,16 @@ describe('Git Utilities', () => { protocol: 'https', }); }); + + it('should parse bare HTTPS repo-DID remotes', () => { + const result = parseTangledRemote('https://tangled.org/did:plc:ll5woixehdm2aq4tqr7pkgcr'); + expect(result).toEqual({ + owner: 'did:plc:ll5woixehdm2aq4tqr7pkgcr', + ownerType: 'did', + name: 'did:plc:ll5woixehdm2aq4tqr7pkgcr', + protocol: 'https', + }); + }); }); describe('edge cases', () => { @@ -134,8 +154,8 @@ describe('Git Utilities', () => { expect(result).toBeNull(); }); - it('should return null for missing repo name', () => { - const result = parseTangledRemote('git@tangled.org:did:plc:abc123'); + it('should return null for missing repo name when path is not a DID', () => { + const result = parseTangledRemote('git@tangled.org:not-a-valid-owner'); expect(result).toBeNull(); }); -- 2.51.2 From 12158e9379d810bce529ddbb016251d192c01d0a Mon Sep 17 00:00:00 2001 From: Cameron Pfiffer Date: Mon, 6 Jul 2026 09:17:19 -0700 Subject: [PATCH 2/3] Add pull request commands. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose create, list, and view commands for Tangled pull requests so the CLI can manage branch patches from the terminal while keeping the renamed tang package shape intact. ๐Ÿ‘พ Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta Code --- README.md | 48 ++-- package-lock.json | 6 +- src/commands/context.ts | 4 +- src/commands/pr.ts | 448 +++++++++++++++++++++++++++++++ src/index.ts | 4 +- src/lib/pulls-api.ts | 363 +++++++++++++++++++++++++ src/utils/auth-helpers.ts | 4 +- tests/commands/pr.test.ts | 404 ++++++++++++++++++++++++++++ tests/index.test.ts | 3 +- tests/lib/pulls-api.test.ts | 405 ++++++++++++++++++++++++++++ tests/utils/auth-helpers.test.ts | 4 +- 11 files changed, 1658 insertions(+), 35 deletions(-) create mode 100644 src/commands/pr.ts create mode 100644 src/lib/pulls-api.ts create mode 100644 tests/commands/pr.test.ts create mode 100644 tests/lib/pulls-api.test.ts diff --git a/README.md b/README.md index 0b4eda1..0351a24 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,9 @@ tang ssh-key add ~/.ssh/id_ed25519.pub | `tang issue view ` | View an issue | | `tang issue close ` | Close an issue | | `tang issue reopen ` | Reopen an issue | +| `tang pr create --base --head --title ` | Create a pull request from a pushed branch | +| `tang pr list` | List pull requests for the current repo | +| `tang pr view <n>` | View a pull request | | `tang ssh-key add <path>` | Upload a public SSH key to your account | | `tang context` | Show resolved repo context (DID, handle, name) | | `tang config` | View or set CLI configuration | @@ -133,46 +136,41 @@ Following `gh`'s pattern, `tangled issue create` will support various ways to pr ## Examples Tangled CLI Usage ```bash -tangled auth login (opens a browser for auth) -tangled repo create my-new-repo +tang auth login +tang repo create my-new-repo cd my-new-repo -tangled issue create "Bug: Something is broken" --body "Detailed description of the bug here." -echo "Another bug description from stdin." | tangled issue create "Bug: From stdin" --body-file - -tangled issue list --json "id,title" -tangled pr create --base main --head my-feature --title "Add new feature" --body-file ./pr_description.md -tangled pr view 123 -tangled pr comment 123 --body "Looks good, small change needed." +tang issue create "Bug: Something is broken" --body "Detailed description of the bug here." +echo "Another bug description from stdin." | tang issue create "Bug: From stdin" --body-file - +tang issue list --json "id,title" +tang pr create --base main --head my-feature --title "Add new feature" --body-file ./pr_description.md +tang pr view 123 ``` ## Basic Commands Basic commands include auth, key management, repo creation, issue management, and pull request management. -`tangled auth login` +`tang auth login` - Logs in the user, ideally through a web browser flow for security. - `tangled auth logout` + `tang auth logout` - Logs out the user, clearing the session. - `tangled ssh-key add <public-key-path>` + `tang ssh-key add <public-key-path>` - Uploads the provided public SSH key to the user's tangled.org account via the API. - `tangled ssh-key verify` + `tang ssh-key verify` - Verifies that the user's SSH key is correctly set up and can authenticate with tangled.org. Returns the associated DID and handle if successful. - `tangled repo create <repo-name>` + `tang repo create <repo-name>` - Creates a new repository under the user's account. - `tangled repo view [--json <fields>]` + `tang repo view [--json <fields>]` - Displays details about the current repository. If `--json` is provided, outputs only the specified fields in JSON format. - `tangled issue create "<title>" [--body "<body>" | --body-file <file> | -F -]` + `tang issue create "<title>" [--body "<body>" | --body-file <file> | -F -]` - Creates a new issue in the current repository with the given title and optional body, which can be provided via flag, file, or stdin. - `tangled pr create --base <base-branch> --head <head-branch> --title <title> [--body <body> | --body-file <file> | -F -]` + `tang pr create --base <base-branch> --head <head-branch> --title <title> [--body <body> | --body-file <file> | -F -]` - Creates a new pull request in the current repository from a head branch to a base branch. - `tangled pr list [--json <fields>]` + `tang pr list [--json <fields>]` - Lists pull requests for the current repository. - `tangled pr view <id> [--json <fields>]` -- Displays detailed information about a specific pull request, including comments. - `tangled pr comment <id> [--body <body> | --body-file <file> | -F -]` -- Adds a comment to a pull request. - `tangled pr review <id> --comment <comment> [--approve | --request-changes]` -- Submits a review for a pull request, with optional approval or request for changes. + `tang pr view <id> [--json <fields>]` +- Displays detailed information about a specific pull request. ## Design Decisions & Outstanding Issues @@ -204,7 +202,7 @@ This section documents key design decisions and tracks outstanding architectural - **Original Question:** Can we allow auth through a web browser? - **Resolution:** For the initial version, the CLI will use **App Passwords** for authentication. This is the standard and simplest method for third-party AT Protocol clients and aligns with existing practices. -- **`tangled auth login` Flow:** When running `tangled auth login`, the CLI will prompt the user for their **PDS handle** (e.g., `@mark.bsky.social`) and an **App Password**. +- **`tang auth login` Flow:** When running `tang auth login`, the CLI will prompt the user for their **PDS handle** (e.g., `@mark.bsky.social`) and an **App Password**. - **Generating an App Password:** Users typically generate App Passwords from their PDS's settings (e.g., in the official Bluesky app under "Settings -> App Passwords", or on their self-hosted PDS web interface). The CLI **does not** generate app passwords. - **Session Management:** The session established is with the user's PDS, and this authenticated session is then used to interact with `tangled.org`'s App View/Service. - **OAuth Support:** Implementing a web-based OAuth flow (similar to `gh`'s approach) is more complex and not a standard part of the AT Protocol client authentication flow. This approach is deferred for future consideration. @@ -225,7 +223,7 @@ The analysis of the `tangled.org` API revealed a rich set of features that are n ## Task Management -Tasks are tracked in the [Tangled issue tracker](https://tangled.org/markbennett.ca/tangled-cli/issues). Use `tangled issue list` or `tangled issue view <n>` to browse tasks. +Tasks are tracked in the [Tangled issue tracker](https://tangled.org/markbennett.ca/tangled-cli/issues). Use `tang issue list` or `tang issue view <n>` to browse tasks. ## Development diff --git a/package-lock.json b/package-lock.json index 346e356..adba6d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "tangled-cli", + "name": "@markbennett/tang", "version": "0.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "tangled-cli", + "name": "@markbennett/tang", "version": "0.0.1", "license": "MIT", "dependencies": { @@ -20,7 +20,7 @@ "zod": "^4.3.6" }, "bin": { - "tangled": "dist/index.js" + "tang": "dist/index.js" }, "devDependencies": { "@atproto/lex-cli": "^0.9.8", diff --git a/src/commands/context.ts b/src/commands/context.ts index 15fd094..d0c641e 100644 --- a/src/commands/context.ts +++ b/src/commands/context.ts @@ -16,7 +16,9 @@ export function createContextCommand(): Command { console.log('โœ— Not in a Tangled repository'); console.log('\nTo use this repository with Tangled, add a tangled.org remote:'); console.log(' git remote add origin git@tangled.org:<did>/<repo>.git'); - console.log(' # or, for repo-DID remotes: git remote add origin git@tangled.org:<repo-did>'); + console.log( + ' # or, for repo-DID remotes: git remote add origin git@tangled.org:<repo-did>' + ); console.log('\nOr clone from tangled.org:'); console.log(' git clone git@tangled.org:<did>/<repo>.git'); console.log(' # or: git clone git@tangled.org:<repo-did>'); diff --git a/src/commands/pr.ts b/src/commands/pr.ts new file mode 100644 index 0000000..1aad3ee --- /dev/null +++ b/src/commands/pr.ts @@ -0,0 +1,448 @@ +import { promisify } from 'node:util'; +import { gzip as gzipCallback } from 'node:zlib'; +import { confirm } from '@inquirer/prompts'; +import { Command } from 'commander'; +import { simpleGit } from 'simple-git'; +import { createApiClient } from '../lib/api-client.js'; +import { getCurrentRepoContext } from '../lib/context.js'; +import type { PullData } from '../lib/pulls-api.js'; +import { createPull, getCompletePullData, getPullState, listPulls } from '../lib/pulls-api.js'; +import { buildRepoAtUri } from '../utils/at-uri.js'; +import { ensureAuthenticated } from '../utils/auth-helpers.js'; +import { readBodyInput } from '../utils/body-input.js'; +import { formatDate, outputJson } from '../utils/formatting.js'; + +const gzip = promisify(gzipCallback); + +/** + * Format pull request state as a badge + */ +function formatPullState(state: 'open' | 'closed' | 'merged'): string { + switch (state) { + case 'open': + return '[OPEN]'; + case 'closed': + return '[CLOSED]'; + case 'merged': + return '[MERGED]'; + } +} + +/** + * Extract rkey from AT-URI + */ +function extractRkey(uri: string): string { + const parts = uri.split('/'); + return parts[parts.length - 1] || 'unknown'; +} + +/** + * Resolve PR number or rkey to full AT-URI + * @param input - User input: number ("1"), hash ("#1"), or rkey ("3mef...") + * @param client - API client + * @param repoAtUri - Repository AT-URI + */ +async function resolvePullUri( + input: string, + client: ReturnType<typeof createApiClient>, + repoAtUri: string +): Promise<{ uri: string; displayId: string }> { + // Strip # prefix if present + const normalized = input.startsWith('#') ? input.slice(1) : input; + + // Check if numeric + if (/^\d+$/.test(normalized)) { + const num = Number.parseInt(normalized, 10); + + if (num < 1) { + throw new Error('Pull request number must be greater than 0'); + } + + const { pulls } = await listPulls({ + client, + repoAtUri, + limit: 100, + }); + + // Sort by creation time (oldest first) + const sorted = pulls.sort( + (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() + ); + + const pull = sorted[num - 1]; + if (!pull) { + throw new Error(`Pull request #${num} not found`); + } + + return { + uri: pull.uri, + displayId: `#${num}`, + }; + } + + // Accept a full pull AT-URI directly. + if (normalized.startsWith('at://')) { + return { + uri: normalized, + displayId: extractRkey(normalized), + }; + } + + // Treat as rkey or unique rkey prefix. Pulls may be authored by a different DID, + // so do not build at://<current-session-did>/... here; find the matching record + // from the repository's pull backlinks instead. + if (!/^[a-zA-Z0-9._-]+$/.test(normalized)) { + throw new Error(`Invalid pull request identifier: ${input}`); + } + + const { pulls } = await listPulls({ client, repoAtUri, limit: 100 }); + const matches = pulls.filter((pull) => extractRkey(pull.uri).startsWith(normalized)); + if (matches.length === 0) { + throw new Error(`Pull request '${input}' not found`); + } + if (matches.length > 1) { + throw new Error(`Pull request identifier '${input}' is ambiguous`); + } + + return { + uri: matches[0].uri, + displayId: extractRkey(matches[0].uri), + }; +} + +/** + * PR create subcommand + */ +function createCreateCommand(): Command { + return new Command('create') + .description('Create a new pull request') + .argument('[title]', 'Pull request title (deprecated; prefer --title)') + .option('-B, --base <branch>', 'Target branch to merge into', 'main') + .option('-H, --head <branch>', 'Source branch with changes (default: current branch)') + .option('-t, --title <title>', 'Pull request title') + .option('-b, --body <string>', 'Pull request body text') + .option('-F, --body-file <path>', 'Read body from file (- for stdin)') + .option('--skip-behind-check', 'Skip the check for unmerged base branch commits') + .option('--json [fields]', 'Output JSON; optionally specify comma-separated fields') + .action( + async ( + titleArg: string | undefined, + options: { + base: string; + head?: string; + title?: string; + body?: string; + bodyFile?: string; + skipBehindCheck?: boolean; + json?: string | true; + } + ) => { + try { + // 1. Validate auth + const client = createApiClient(); + await ensureAuthenticated(client); + + const title = options.title ?? titleArg; + if (!title) { + console.error('โœ— Missing required pull request title. Use --title <title>.'); + process.exit(1); + } + + // 2. Get repo context + const context = await getCurrentRepoContext(); + if (!context) { + console.error('โœ— Not in a Tangled repository'); + console.error('\nTo use this repository with Tangled, add a remote:'); + console.error(' git remote add origin git@tangled.org:<did>/<repo>.git'); + process.exit(1); + } + + const cwd = process.cwd(); + const git = simpleGit(cwd); + const baseBranch = options.base; + + // 3. Determine head branch + const headBranch = options.head ?? (await git.revparse(['--abbrev-ref', 'HEAD'])).trim(); + + // 4. Get source SHA + const sourceSha = (await git.revparse([headBranch])).trim(); + + // 5. Behind-base check + if (!options.skipBehindCheck) { + const behindLog = await git.log([`${headBranch}..${baseBranch}`]); + const behindCount = behindLog.total; + if (behindCount > 0) { + const msg = `Head branch '${headBranch}' is ${behindCount} commit(s) behind '${baseBranch}'.`; + if (options.json !== undefined) { + // Non-interactive: fail with error + console.error(`โœ— ${msg} Merge base into head first, or use --skip-behind-check.`); + process.exit(1); + } else { + // Interactive: prompt user + console.warn(`โš  ${msg}`); + const proceed = await confirm({ + message: 'Proceed anyway?', + default: false, + }); + if (!proceed) { + console.log('Aborted.'); + process.exit(0); + } + } + } + } + + // 6. Generate patch + const patchContent = await git.diff([`${baseBranch}..${headBranch}`]); + if (!patchContent) { + console.error( + `โœ— No diff found between '${baseBranch}' and '${headBranch}'. Branches may be identical.` + ); + process.exit(1); + } + + // 7. Gzip the patch + const patchBuffer = await gzip(Buffer.from(patchContent, 'utf-8')); + + // 8. Handle body input + const body = await readBodyInput(options.body, options.bodyFile); + + // 9. Build repo AT-URI + const repoAtUri = await buildRepoAtUri(context.owner, context.name, client); + + // 10. Create pull request + if (options.json === undefined) { + console.log('Creating pull request...'); + } + const pull = await createPull({ + client, + repoAtUri, + title, + body, + targetBranch: baseBranch, + sourceBranch: headBranch, + sourceSha, + patchBuffer, + }); + + // 11. Compute sequential number + const { pulls: allPulls } = await listPulls({ client, repoAtUri, limit: 100 }); + const sortedAll = allPulls.sort( + (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() + ); + const idx = sortedAll.findIndex((p) => p.uri === pull.uri); + const number = idx >= 0 ? idx + 1 : undefined; + + // 12. Output result + if (options.json !== undefined) { + const pullData: PullData = { + number, + title: pull.title, + body: pull.body, + state: 'open', + author: pull.author, + createdAt: pull.createdAt, + uri: pull.uri, + cid: pull.cid, + sourceBranch: pull.source?.branch, + targetBranch: pull.target.branch, + }; + outputJson(pullData, typeof options.json === 'string' ? options.json : undefined); + return; + } + + const displayNumber = number !== undefined ? `#${number}` : extractRkey(pull.uri); + console.log(`\nโœ“ Pull request ${displayNumber} created`); + console.log(` Title: ${pull.title}`); + console.log(` ${headBranch} โ†’ ${baseBranch}`); + console.log(` URI: ${pull.uri}`); + } catch (error) { + console.error( + `โœ— Failed to create pull request: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + process.exit(1); + } + } + ); +} + +/** + * PR list subcommand + */ +function createListCommand(): Command { + return new Command('list') + .description('List pull requests for the current repository') + .option('-l, --limit <number>', 'Maximum number of pull requests to fetch', '50') + .option('--json [fields]', 'Output JSON; optionally specify comma-separated fields') + .action(async (options: { limit: string; json?: string | true }) => { + try { + // 1. Validate auth + const client = createApiClient(); + await ensureAuthenticated(client); + + // 2. Get repo context + const context = await getCurrentRepoContext(); + if (!context) { + console.error('โœ— Not in a Tangled repository'); + console.error('\nTo use this repository with Tangled, add a remote:'); + console.error(' git remote add origin git@tangled.org:<did>/<repo>.git'); + process.exit(1); + } + + // 3. Build repo AT-URI + const repoAtUri = await buildRepoAtUri(context.owner, context.name, client); + + // 4. Fetch pull requests + const limit = Number.parseInt(options.limit, 10); + if (Number.isNaN(limit) || limit < 1 || limit > 100) { + console.error('โœ— Invalid limit. Must be between 1 and 100.'); + process.exit(1); + } + + const { pulls } = await listPulls({ + client, + repoAtUri, + limit, + }); + + // 5. Handle empty results + if (pulls.length === 0) { + if (options.json !== undefined) { + console.log('[]'); + } else { + console.log('No pull requests found for this repository.'); + } + return; + } + + // Sort pull requests by creation time (oldest first) for consistent numbering + const sortedPulls = pulls.sort( + (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() + ); + + // Build pull data with states (in parallel for performance) + const pullData = await Promise.all( + sortedPulls.map(async (pull, i) => { + const state = await getPullState({ client, pullUri: pull.uri }); + return { + number: i + 1, + title: pull.title, + body: pull.body, + state, + author: pull.author, + createdAt: pull.createdAt, + uri: pull.uri, + cid: pull.cid, + sourceBranch: pull.source?.branch, + targetBranch: pull.target.branch, + }; + }) + ); + + // 6. Output results + if (options.json !== undefined) { + outputJson(pullData, typeof options.json === 'string' ? options.json : undefined); + return; + } + + console.log( + `\nFound ${pullData.length} pull request${pullData.length === 1 ? '' : 's'}:\n` + ); + + for (const item of pullData) { + const stateBadge = formatPullState(item.state); + const date = formatDate(item.createdAt); + const branches = item.sourceBranch + ? `${item.sourceBranch} โ†’ ${item.targetBranch}` + : item.targetBranch; + console.log(` #${item.number} ${stateBadge} ${item.title}`); + console.log(` ${branches} ยท Created ${date}`); + console.log(); + } + } catch (error) { + console.error( + `โœ— Failed to list pull requests: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + process.exit(1); + } + }); +} + +/** + * PR view subcommand + */ +function createViewCommand(): Command { + return new Command('view') + .description('View details of a specific pull request') + .argument('<pr-id>', 'Pull request number (e.g., 1, #2) or rkey') + .option('--json [fields]', 'Output JSON; optionally specify comma-separated fields') + .action(async (prId: string, options: { json?: string | true }) => { + try { + // 1. Validate auth + const client = createApiClient(); + await ensureAuthenticated(client); + + // 2. Get repo context + const context = await getCurrentRepoContext(); + if (!context) { + console.error('โœ— Not in a Tangled repository'); + console.error('\nTo use this repository with Tangled, add a remote:'); + console.error(' git remote add origin git@tangled.org:<did>/<repo>.git'); + process.exit(1); + } + + // 3. Build repo AT-URI + const repoAtUri = await buildRepoAtUri(context.owner, context.name, client); + + // 4. Resolve PR ID to URI + const { uri: pullUri, displayId } = await resolvePullUri(prId, client, repoAtUri); + + // 5. Fetch complete pull request data + const pullData = await getCompletePullData(client, pullUri, displayId, repoAtUri); + + // 6. Output result + if (options.json !== undefined) { + outputJson(pullData, typeof options.json === 'string' ? options.json : undefined); + return; + } + + const branches = pullData.sourceBranch + ? `${pullData.sourceBranch} โ†’ ${pullData.targetBranch}` + : pullData.targetBranch; + + console.log(`\nPR ${displayId} ${formatPullState(pullData.state)}`); + console.log(`Title: ${pullData.title}`); + console.log(`Branches: ${branches}`); + console.log(`Author: ${pullData.author}`); + console.log(`Created: ${formatDate(pullData.createdAt)}`); + console.log(`Repo: ${context.name}`); + console.log(`URI: ${pullData.uri}`); + + if (pullData.body) { + console.log('\nBody:'); + console.log(pullData.body); + } + + console.log(); // Empty line at end + } catch (error) { + console.error( + `โœ— Failed to view pull request: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + process.exit(1); + } + }); +} + +/** + * Create the pr command with all subcommands + */ +export function createPrCommand(): Command { + const pr = new Command('pr'); + pr.description('Manage pull requests in Tangled repositories'); + + pr.addCommand(createCreateCommand()); + pr.addCommand(createListCommand()); + pr.addCommand(createViewCommand()); + + return pr; +} diff --git a/src/index.ts b/src/index.ts index 31ab476..cf37322 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ import { createAuthCommand } from './commands/auth.js'; import { createConfigCommand } from './commands/config.js'; import { createContextCommand } from './commands/context.js'; import { createIssueCommand } from './commands/issue.js'; +import { createPrCommand } from './commands/pr.js'; import { createSshKeyCommand } from './commands/ssh-key.js'; // Get package.json for version @@ -17,7 +18,7 @@ const packageJson = JSON.parse(readFileSync(join(__dirname, '../package.json'), const program = new Command(); program - .name('tangled') + .name('tang') .description('A CLI for Tangled.org - AT Protocol-based Git hosting') .version(packageJson.version, '-v, --version', 'Output the current version'); @@ -27,5 +28,6 @@ program.addCommand(createSshKeyCommand()); program.addCommand(createConfigCommand()); program.addCommand(createContextCommand()); program.addCommand(createIssueCommand()); +program.addCommand(createPrCommand()); program.parse(process.argv); diff --git a/src/lib/pulls-api.ts b/src/lib/pulls-api.ts new file mode 100644 index 0000000..5bbf40b --- /dev/null +++ b/src/lib/pulls-api.ts @@ -0,0 +1,363 @@ +import type { BlobRef } from '@atproto/lexicon'; +import { parseAtUri } from '../utils/at-uri.js'; +import { requireAuth } from '../utils/auth-helpers.js'; +import type { TangledApiClient } from './api-client.js'; +import { getBacklinks } from './constellation.js'; + +/** + * Pull request record type based on sh.tangled.repo.pull lexicon + */ +export interface PullRecord { + $type: 'sh.tangled.repo.pull'; + target: { repo: string; branch: string }; + title: string; + body?: string; + patchBlob: BlobRef; + source?: { branch: string; sha: string; repo?: string }; + createdAt: string; + mentions?: string[]; + references?: string[]; + [key: string]: unknown; +} + +/** + * Pull request record with metadata + */ +export interface PullWithMetadata extends PullRecord { + uri: string; // AT-URI of the pull request + cid: string; // Content ID + author: string; // Creator's DID +} + +/** + * Parameters for creating a pull request + */ +export interface CreatePullParams { + client: TangledApiClient; + repoAtUri: string; + title: string; + body?: string; + targetBranch: string; + sourceBranch: string; + sourceSha: string; + patchBuffer: Buffer; +} + +/** + * Parameters for listing pull requests + */ +export interface ListPullsParams { + client: TangledApiClient; + repoAtUri: string; + limit?: number; + cursor?: string; +} + +/** + * Parameters for getting a specific pull request + */ +export interface GetPullParams { + client: TangledApiClient; + pullUri: string; +} + +/** + * Parameters for getting pull request state + */ +export interface GetPullStateParams { + client: TangledApiClient; + pullUri: string; +} + +/** + * Canonical JSON shape for a single pull request, used by all pr commands. + */ +export interface PullData { + number: number | undefined; + title: string; + body?: string; + state: 'open' | 'closed' | 'merged'; + author: string; + createdAt: string; + uri: string; + cid: string; + sourceBranch?: string; + targetBranch: string; +} + +/** + * Parse and validate a pull request AT-URI + * @throws Error if URI is invalid or missing rkey + */ +function parsePullUri(pullUri: string): { + did: string; + collection: string; + rkey: string; +} { + const parsed = parseAtUri(pullUri); + if (!parsed || !parsed.rkey) { + throw new Error(`Invalid pull request AT-URI: ${pullUri}`); + } + + return { + did: parsed.did, + collection: parsed.collection, + rkey: parsed.rkey, + }; +} + +/** + * Create a new pull request + */ +export async function createPull(params: CreatePullParams): Promise<PullWithMetadata> { + const { client, repoAtUri, title, body, targetBranch, sourceBranch, sourceSha, patchBuffer } = + params; + + // Validate authentication + const session = await requireAuth(client); + + try { + // Upload the gzip-compressed patch as a blob + const blobResponse = await client.getAgent().com.atproto.repo.uploadBlob(patchBuffer, { + encoding: 'application/gzip', + }); + const patchBlob = blobResponse.data.blob; + + // Build pull request record + const record: PullRecord = { + $type: 'sh.tangled.repo.pull', + target: { + repo: repoAtUri, + branch: targetBranch, + }, + title, + body, + patchBlob, + source: { + branch: sourceBranch, + sha: sourceSha, + repo: repoAtUri, + }, + createdAt: new Date().toISOString(), + }; + + // Create record via AT Protocol + const response = await client.getAgent().com.atproto.repo.createRecord({ + repo: session.did, + collection: 'sh.tangled.repo.pull', + record, + }); + + return { + ...record, + uri: response.data.uri, + cid: response.data.cid, + author: session.did, + }; + } catch (error) { + if (error instanceof Error) { + throw new Error(`Failed to create pull request: ${error.message}`); + } + throw new Error('Failed to create pull request: Unknown error'); + } +} + +/** + * List pull requests for a repository + */ +export async function listPulls(params: ListPullsParams): Promise<{ + pulls: PullWithMetadata[]; + cursor?: string; +}> { + const { client, repoAtUri, limit = 50, cursor } = params; + + // Validate authentication + await requireAuth(client); + + try { + // Query constellation for all pull requests that reference this repo + const backlinks = await getBacklinks( + repoAtUri, + 'sh.tangled.repo.pull', + '.target.repo', + limit, + cursor + ); + + // Fetch each pull request record individually + const pullPromises = backlinks.records.map(async ({ did, collection, rkey }) => { + const response = await client.getAgent().com.atproto.repo.getRecord({ + repo: did, + collection, + rkey, + }); + return { + ...(response.data.value as PullRecord), + uri: response.data.uri, + cid: response.data.cid as string, + author: did, + }; + }); + + const pulls = await Promise.all(pullPromises); + + return { + pulls, + cursor: backlinks.cursor ?? undefined, + }; + } catch (error) { + if (error instanceof Error) { + throw new Error(`Failed to list pull requests: ${error.message}`); + } + throw new Error('Failed to list pull requests: Unknown error'); + } +} + +/** + * Get a specific pull request + */ +export async function getPull(params: GetPullParams): Promise<PullWithMetadata> { + const { client, pullUri } = params; + + // Validate authentication + await requireAuth(client); + + // Parse pull URI + const { did, collection, rkey } = parsePullUri(pullUri); + + try { + const response = await client.getAgent().com.atproto.repo.getRecord({ + repo: did, + collection, + rkey, + }); + + const record = response.data.value as PullRecord; + + return { + ...record, + uri: response.data.uri, + cid: response.data.cid as string, + author: did, + }; + } catch (error) { + if (error instanceof Error) { + if (error.message.includes('not found')) { + throw new Error(`Pull request not found: ${pullUri}`); + } + throw new Error(`Failed to get pull request: ${error.message}`); + } + throw new Error('Failed to get pull request: Unknown error'); + } +} + +/** + * Get the state of a pull request (open, closed, or merged) + * @returns 'open', 'closed', or 'merged' (defaults to 'open' if no state record exists) + */ +export async function getPullState( + params: GetPullStateParams +): Promise<'open' | 'closed' | 'merged'> { + const { client, pullUri } = params; + + // Validate authentication + await requireAuth(client); + + try { + // Query constellation for all state records that reference this pull request + const backlinks = await getBacklinks(pullUri, 'sh.tangled.repo.pull.status', '.pull', 100); + + if (backlinks.records.length === 0) { + return 'open'; + } + + // Fetch each state record in parallel + const statePromises = backlinks.records.map(async ({ did, collection, rkey }) => { + const response = await client.getAgent().com.atproto.repo.getRecord({ + repo: did, + collection, + rkey, + }); + return { + rkey, + value: response.data.value as { + status?: + | 'sh.tangled.repo.pull.status.open' + | 'sh.tangled.repo.pull.status.closed' + | 'sh.tangled.repo.pull.status.merged'; + }, + }; + }); + + const stateRecords = await Promise.all(statePromises); + + // Sort by rkey ascending โ€” TID rkeys are time-ordered, so the last is most recent + stateRecords.sort((a, b) => a.rkey.localeCompare(b.rkey)); + const latestState = stateRecords[stateRecords.length - 1]; + + if (latestState.value.status === 'sh.tangled.repo.pull.status.closed') { + return 'closed'; + } + if (latestState.value.status === 'sh.tangled.repo.pull.status.merged') { + return 'merged'; + } + + return 'open'; + } catch (error) { + if (error instanceof Error) { + throw new Error(`Failed to get pull request state: ${error.message}`); + } + throw new Error('Failed to get pull request state: Unknown error'); + } +} + +/** + * Resolve a sequential pull request number from a displayId or by scanning the pull list. + * Fast path: if displayId is "#N", return N directly. + * Fallback: fetch all pulls, sort oldest-first, return 1-based position. + */ +export async function resolveSequentialPullNumber( + displayId: string, + pullUri: string, + client: TangledApiClient, + repoAtUri: string +): Promise<number | undefined> { + const match = displayId.match(/^#(\d+)$/); + if (match) return Number.parseInt(match[1], 10); + + const { pulls } = await listPulls({ client, repoAtUri, limit: 100 }); + const sorted = pulls.sort( + (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() + ); + const idx = sorted.findIndex((p) => p.uri === pullUri); + return idx >= 0 ? idx + 1 : undefined; +} + +/** + * Fetch a complete PullData object ready for JSON output. + * Fetches the pull record and sequential number in parallel. + */ +export async function getCompletePullData( + client: TangledApiClient, + pullUri: string, + displayId: string, + repoAtUri: string +): Promise<PullData> { + const [pull, number, state] = await Promise.all([ + getPull({ client, pullUri }), + resolveSequentialPullNumber(displayId, pullUri, client, repoAtUri), + getPullState({ client, pullUri }), + ]); + return { + number, + title: pull.title, + body: pull.body, + state, + author: pull.author, + createdAt: pull.createdAt, + uri: pull.uri, + cid: pull.cid, + sourceBranch: pull.source?.branch, + targetBranch: pull.target.branch, + }; +} diff --git a/src/utils/auth-helpers.ts b/src/utils/auth-helpers.ts index 2eca2bd..6ce5c57 100644 --- a/src/utils/auth-helpers.ts +++ b/src/utils/auth-helpers.ts @@ -12,7 +12,7 @@ export async function requireAuth(client: TangledApiClient): Promise<{ handle: string; }> { if (!client.isAuthenticated()) { - throw new Error('Must be authenticated. Run "tangled auth login" first.'); + throw new Error('Must be authenticated. Run "tang auth login" first.'); } const session = client.getSession(); @@ -43,7 +43,7 @@ export async function ensureAuthenticated(client: TangledApiClient): Promise<voi try { const authenticated = await client.resumeSession(); if (!authenticated) { - console.error('โœ— Not authenticated. Run "tangled auth login" first.'); + console.error('โœ— Not authenticated. Run "tang auth login" first.'); process.exit(1); } } catch (error) { diff --git a/tests/commands/pr.test.ts b/tests/commands/pr.test.ts new file mode 100644 index 0000000..8e7dd79 --- /dev/null +++ b/tests/commands/pr.test.ts @@ -0,0 +1,404 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPrCommand } from '../../src/commands/pr.js'; +import type { TangledApiClient } from '../../src/lib/api-client.js'; +import * as apiClient from '../../src/lib/api-client.js'; +import * as context from '../../src/lib/context.js'; +import type { PullWithMetadata } from '../../src/lib/pulls-api.js'; +import * as pullsApi from '../../src/lib/pulls-api.js'; +import * as atUri from '../../src/utils/at-uri.js'; +import * as authHelpers from '../../src/utils/auth-helpers.js'; +import * as bodyInput from '../../src/utils/body-input.js'; + +// Mock dependencies +vi.mock('../../src/lib/api-client.js'); +vi.mock('../../src/lib/pulls-api.js'); +vi.mock('../../src/lib/context.js'); +vi.mock('../../src/utils/at-uri.js'); +vi.mock('../../src/utils/body-input.js'); +vi.mock('../../src/utils/auth-helpers.js'); +vi.mock('@inquirer/prompts'); +vi.mock('simple-git'); +vi.mock('node:zlib'); + +const REPO_AT_URI = 'at://did:plc:abc123/sh.tangled.repo/test-repo'; +const PULL_AT_URI = 'at://did:plc:abc123/sh.tangled.repo.pull/pull123'; + +const makePull = (overrides: Partial<PullWithMetadata> = {}): PullWithMetadata => ({ + $type: 'sh.tangled.repo.pull', + target: { repo: REPO_AT_URI, branch: 'main' }, + title: 'Test PR', + patchBlob: {} as never, + source: { branch: 'feature/test', sha: 'abc123sha', repo: REPO_AT_URI }, + createdAt: '2024-01-01T00:00:00.000Z', + uri: PULL_AT_URI, + cid: 'bafyreiabc123', + author: 'did:plc:abc123', + ...overrides, +}); + +describe('pr list command', () => { + let mockClient: TangledApiClient; + let consoleLogSpy: ReturnType<typeof vi.spyOn>; + + beforeEach(() => { + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) as never; + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(process, 'exit').mockImplementation((code) => { + throw new Error(`process.exit(${code})`); + }) as never; + + mockClient = { resumeSession: vi.fn(async () => true) } as unknown as TangledApiClient; + vi.mocked(apiClient.createApiClient).mockReturnValue(mockClient); + + vi.mocked(context.getCurrentRepoContext).mockResolvedValue({ + owner: 'test.bsky.social', + ownerType: 'handle', + name: 'test-repo', + remoteName: 'origin', + remoteUrl: 'git@tangled.org:test.bsky.social/test-repo.git', + protocol: 'ssh', + }); + + vi.mocked(atUri.buildRepoAtUri).mockResolvedValue(REPO_AT_URI); + vi.mocked(pullsApi.listPulls).mockResolvedValue({ pulls: [], cursor: undefined }); + vi.mocked(pullsApi.getPullState).mockResolvedValue('open'); + vi.mocked(authHelpers.ensureAuthenticated).mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should show empty message when no pull requests exist', async () => { + const command = createPrCommand(); + await command.parseAsync(['node', 'test', 'list']); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('No pull requests found')); + }); + + it('should list pull requests with number and title', async () => { + const pull = makePull(); + vi.mocked(pullsApi.listPulls).mockResolvedValue({ pulls: [pull], cursor: undefined }); + + const command = createPrCommand(); + await command.parseAsync(['node', 'test', 'list']); + + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('#1')); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Test PR')); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('[OPEN]')); + }); + + it('should output JSON when --json flag is provided', async () => { + const pull = makePull(); + vi.mocked(pullsApi.listPulls).mockResolvedValue({ pulls: [pull], cursor: undefined }); + + const command = createPrCommand(); + await command.parseAsync(['node', 'test', 'list', '--json']); + + const jsonOutput = consoleLogSpy.mock.calls.find((call) => { + try { + const parsed = JSON.parse(String(call[0])); + return Array.isArray(parsed); + } catch { + return false; + } + }); + expect(jsonOutput).toBeDefined(); + }); + + it('should output [] JSON when no pulls and --json', async () => { + const command = createPrCommand(); + await command.parseAsync(['node', 'test', 'list', '--json']); + expect(consoleLogSpy).toHaveBeenCalledWith('[]'); + }); + + it('should exit 1 when not in a Tangled repository', async () => { + vi.mocked(context.getCurrentRepoContext).mockResolvedValue(null); + const command = createPrCommand(); + await expect(command.parseAsync(['node', 'test', 'list'])).rejects.toThrow('process.exit(1)'); + }); + + it('should exit 1 when auth fails', async () => { + vi.mocked(authHelpers.ensureAuthenticated).mockRejectedValue(new Error('Not authenticated')); + const command = createPrCommand(); + await expect(command.parseAsync(['node', 'test', 'list'])).rejects.toThrow('process.exit(1)'); + }); +}); + +describe('pr view command', () => { + let mockClient: TangledApiClient; + let consoleLogSpy: ReturnType<typeof vi.spyOn>; + let consoleErrorSpy: ReturnType<typeof vi.spyOn>; + + beforeEach(() => { + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) as never; + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) as never; + vi.spyOn(process, 'exit').mockImplementation((code) => { + throw new Error(`process.exit(${code})`); + }) as never; + + mockClient = { resumeSession: vi.fn(async () => true) } as unknown as TangledApiClient; + vi.mocked(apiClient.createApiClient).mockReturnValue(mockClient); + + vi.mocked(context.getCurrentRepoContext).mockResolvedValue({ + owner: 'test.bsky.social', + ownerType: 'handle', + name: 'test-repo', + remoteName: 'origin', + remoteUrl: 'git@tangled.org:test.bsky.social/test-repo.git', + protocol: 'ssh', + }); + + vi.mocked(atUri.buildRepoAtUri).mockResolvedValue(REPO_AT_URI); + vi.mocked(authHelpers.ensureAuthenticated).mockResolvedValue(undefined); + + vi.mocked(pullsApi.listPulls).mockResolvedValue({ pulls: [makePull()], cursor: undefined }); + vi.mocked(pullsApi.getCompletePullData).mockResolvedValue({ + number: 1, + title: 'Test PR', + body: 'Description', + state: 'open', + author: 'did:plc:abc123', + createdAt: '2024-01-01T00:00:00.000Z', + uri: PULL_AT_URI, + cid: 'bafyreiabc123', + sourceBranch: 'feature/test', + targetBranch: 'main', + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should display pull request details', async () => { + const command = createPrCommand(); + await command.parseAsync(['node', 'test', 'view', '1']); + + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Test PR')); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('[OPEN]')); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('feature/test โ†’ main')); + }); + + it('should output JSON when --json flag is provided', async () => { + const command = createPrCommand(); + await command.parseAsync(['node', 'test', 'view', '1', '--json']); + + const jsonOutput = consoleLogSpy.mock.calls.find((call) => { + try { + const parsed = JSON.parse(String(call[0])); + return typeof parsed === 'object' && parsed !== null; + } catch { + return false; + } + }); + expect(jsonOutput).toBeDefined(); + }); + + it('should resolve rkey identifiers from repository pull records', async () => { + const command = createPrCommand(); + await command.parseAsync(['node', 'test', 'view', 'pull123']); + + expect(pullsApi.getCompletePullData).toHaveBeenCalledWith( + mockClient, + PULL_AT_URI, + 'pull123', + REPO_AT_URI + ); + }); + + it('should exit 1 for pull request not found', async () => { + vi.mocked(pullsApi.listPulls).mockResolvedValue({ pulls: [], cursor: undefined }); + const command = createPrCommand(); + await expect(command.parseAsync(['node', 'test', 'view', '99'])).rejects.toThrow( + 'process.exit(1)' + ); + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('not found')); + }); +}); + +describe('pr create command', () => { + let mockClient: TangledApiClient; + let consoleLogSpy: ReturnType<typeof vi.spyOn>; + let consoleErrorSpy: ReturnType<typeof vi.spyOn>; + + beforeEach(async () => { + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) as never; + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) as never; + vi.spyOn(process, 'exit').mockImplementation((code) => { + throw new Error(`process.exit(${code})`); + }) as never; + + mockClient = { resumeSession: vi.fn(async () => true) } as unknown as TangledApiClient; + vi.mocked(apiClient.createApiClient).mockReturnValue(mockClient); + + vi.mocked(context.getCurrentRepoContext).mockResolvedValue({ + owner: 'test.bsky.social', + ownerType: 'handle', + name: 'test-repo', + remoteName: 'origin', + remoteUrl: 'git@tangled.org:test.bsky.social/test-repo.git', + protocol: 'ssh', + }); + + vi.mocked(atUri.buildRepoAtUri).mockResolvedValue(REPO_AT_URI); + vi.mocked(authHelpers.ensureAuthenticated).mockResolvedValue(undefined); + vi.mocked(bodyInput.readBodyInput).mockResolvedValue(undefined); + + // Mock simple-git + const { simpleGit } = await import('simple-git'); + vi.mocked(simpleGit).mockReturnValue({ + revparse: vi.fn().mockResolvedValue('feature/test\n'), + log: vi.fn().mockResolvedValue({ total: 0, all: [] }), + diff: vi.fn().mockResolvedValue('diff --git a/file.ts b/file.ts\n+new line\n'), + } as never); + + // Mock gzip + const zlib = await import('node:zlib'); + vi.mocked(zlib.gzip).mockImplementation((_buf, cb) => { + (cb as (err: null, result: Buffer) => void)(null, Buffer.from('gzip-compressed')); + }); + + const pull = makePull(); + vi.mocked(pullsApi.createPull).mockResolvedValue(pull); + vi.mocked(pullsApi.listPulls).mockResolvedValue({ pulls: [pull], cursor: undefined }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should create a pull request and display success', async () => { + const command = createPrCommand(); + await command.parseAsync([ + 'node', + 'test', + 'create', + '--title', + 'Test PR', + '--base', + 'main', + '--head', + 'feature/test', + ]); + + expect(pullsApi.createPull).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Test PR', + targetBranch: 'main', + sourceBranch: 'feature/test', + }) + ); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('โœ“')); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Test PR')); + }); + + it('should exit 1 when no diff between branches', async () => { + const { simpleGit } = await import('simple-git'); + vi.mocked(simpleGit).mockReturnValue({ + revparse: vi.fn().mockResolvedValue('feature/test\n'), + log: vi.fn().mockResolvedValue({ total: 0, all: [] }), + diff: vi.fn().mockResolvedValue(''), + } as never); + + const command = createPrCommand(); + await expect( + command.parseAsync([ + 'node', + 'test', + 'create', + '--title', + 'Empty PR', + '--base', + 'main', + '--head', + 'feature/test', + ]) + ).rejects.toThrow('process.exit(1)'); + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('No diff found')); + }); + + it('should output JSON when --json flag is provided', async () => { + const command = createPrCommand(); + await command.parseAsync([ + 'node', + 'test', + 'create', + '--title', + 'Test PR', + '--base', + 'main', + '--head', + 'feature/test', + '--json', + ]); + + const jsonOutput = consoleLogSpy.mock.calls.find((call) => { + try { + const parsed = JSON.parse(String(call[0])); + return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed); + } catch { + return false; + } + }); + expect(jsonOutput).toBeDefined(); + }); + + it('should exit 1 in non-interactive mode when behind base', async () => { + const { simpleGit } = await import('simple-git'); + vi.mocked(simpleGit).mockReturnValue({ + revparse: vi.fn().mockResolvedValue('feature/test\n'), + log: vi.fn().mockResolvedValue({ total: 3, all: [] }), + diff: vi.fn().mockResolvedValue('some diff'), + } as never); + + const command = createPrCommand(); + await expect( + command.parseAsync([ + 'node', + 'test', + 'create', + '--title', + 'Test PR', + '--base', + 'main', + '--head', + 'feature/test', + '--json', + ]) + ).rejects.toThrow('process.exit(1)'); + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('behind')); + }); + + it('should exit 1 when title is missing', async () => { + const command = createPrCommand(); + await expect( + command.parseAsync(['node', 'test', 'create', '--base', 'main', '--head', 'feature/test']) + ).rejects.toThrow('process.exit(1)'); + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Missing required')); + }); + + it('should proceed when --skip-behind-check is provided even if behind', async () => { + const { simpleGit } = await import('simple-git'); + vi.mocked(simpleGit).mockReturnValue({ + revparse: vi.fn().mockResolvedValue('feature/test\n'), + log: vi.fn().mockResolvedValue({ total: 3, all: [] }), + diff: vi.fn().mockResolvedValue('some diff content'), + } as never); + + const command = createPrCommand(); + await command.parseAsync([ + 'node', + 'test', + 'create', + '--title', + 'Test PR', + '--base', + 'main', + '--head', + 'feature/test', + '--skip-behind-check', + ]); + + expect(pullsApi.createPull).toHaveBeenCalled(); + }); +}); diff --git a/tests/index.test.ts b/tests/index.test.ts index a57ae79..84c7144 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -23,10 +23,11 @@ describe('Tangled CLI', () => { cwd: join(__dirname, '..'), }); expect(output).toContain('A CLI for Tangled.org'); + expect(output).toContain('Usage: tang'); expect(output).toContain('Usage:'); }); it('package.json should have correct name', () => { - expect(packageJson.name).toBe('tangled-cli'); + expect(packageJson.name).toBe('@markbennett/tang'); }); }); diff --git a/tests/lib/pulls-api.test.ts b/tests/lib/pulls-api.test.ts new file mode 100644 index 0000000..09cba3b --- /dev/null +++ b/tests/lib/pulls-api.test.ts @@ -0,0 +1,405 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { TangledApiClient } from '../../src/lib/api-client.js'; +import { getBacklinks } from '../../src/lib/constellation.js'; +import { + createPull, + getPull, + getPullState, + listPulls, + resolveSequentialPullNumber, +} from '../../src/lib/pulls-api.js'; + +vi.mock('../../src/lib/constellation.js'); + +// Mock API client factory +const createMockClient = (authenticated = true): TangledApiClient => { + const mockAgent = { + com: { + atproto: { + repo: { + createRecord: vi.fn(), + getRecord: vi.fn(), + uploadBlob: vi.fn(), + }, + }, + }, + }; + + return { + isAuthenticated: vi.fn(() => authenticated), + getSession: vi.fn(() => + authenticated ? { did: 'did:plc:test123', handle: 'test.bsky.social' } : null + ), + getAgent: vi.fn(() => mockAgent), + } as unknown as TangledApiClient; +}; + +const REPO_AT_URI = 'at://did:plc:owner/sh.tangled.repo/my-repo'; +const PULL_AT_URI = 'at://did:plc:test123/sh.tangled.repo.pull/abc123'; + +describe('createPull', () => { + let mockClient: TangledApiClient; + + beforeEach(() => { + mockClient = createMockClient(true); + }); + + it('should upload blob and create pull record', async () => { + const mockBlob = { + $type: 'blob', + ref: { $link: 'bafyreiabc123' }, + mimeType: 'application/gzip', + size: 42, + }; + const mockUploadBlob = vi.fn().mockResolvedValue({ data: { blob: mockBlob } }); + const mockCreateRecord = vi.fn().mockResolvedValue({ + data: { + uri: PULL_AT_URI, + cid: 'cid123', + }, + }); + + vi.mocked(mockClient.getAgent).mockReturnValue({ + com: { + atproto: { + repo: { + uploadBlob: mockUploadBlob, + createRecord: mockCreateRecord, + }, + }, + }, + } as never); + + const patchBuffer = Buffer.from('fake gzip content'); + const result = await createPull({ + client: mockClient, + repoAtUri: REPO_AT_URI, + title: 'Add new feature', + body: 'Description', + targetBranch: 'main', + sourceBranch: 'feature/new-thing', + sourceSha: 'abc123sha', + patchBuffer, + }); + + expect(mockUploadBlob).toHaveBeenCalledWith(patchBuffer, { encoding: 'application/gzip' }); + expect(mockCreateRecord).toHaveBeenCalledWith({ + repo: 'did:plc:test123', + collection: 'sh.tangled.repo.pull', + record: expect.objectContaining({ + $type: 'sh.tangled.repo.pull', + target: { repo: REPO_AT_URI, branch: 'main' }, + title: 'Add new feature', + body: 'Description', + patchBlob: mockBlob, + source: { branch: 'feature/new-thing', sha: 'abc123sha', repo: REPO_AT_URI }, + createdAt: expect.any(String), + }), + }); + + expect(result).toMatchObject({ + uri: PULL_AT_URI, + cid: 'cid123', + author: 'did:plc:test123', + title: 'Add new feature', + }); + }); + + it('should create pull without body', async () => { + const mockBlob = { + $type: 'blob', + ref: { $link: 'bafyreiabc123' }, + mimeType: 'application/gzip', + size: 10, + }; + const mockUploadBlob = vi.fn().mockResolvedValue({ data: { blob: mockBlob } }); + const mockCreateRecord = vi.fn().mockResolvedValue({ + data: { uri: PULL_AT_URI, cid: 'cid123' }, + }); + + vi.mocked(mockClient.getAgent).mockReturnValue({ + com: { atproto: { repo: { uploadBlob: mockUploadBlob, createRecord: mockCreateRecord } } }, + } as never); + + const result = await createPull({ + client: mockClient, + repoAtUri: REPO_AT_URI, + title: 'Fix bug', + targetBranch: 'main', + sourceBranch: 'fix/bug', + sourceSha: 'deadbeef', + patchBuffer: Buffer.from('patch'), + }); + + expect(result.body).toBeUndefined(); + expect(result.title).toBe('Fix bug'); + }); + + it('should throw when not authenticated', async () => { + const unauthClient = createMockClient(false); + await expect( + createPull({ + client: unauthClient, + repoAtUri: REPO_AT_URI, + title: 'Test', + targetBranch: 'main', + sourceBranch: 'feature', + sourceSha: 'abc', + patchBuffer: Buffer.from('patch'), + }) + ).rejects.toThrow(); + }); +}); + +describe('listPulls', () => { + let mockClient: TangledApiClient; + + beforeEach(() => { + mockClient = createMockClient(true); + vi.mocked(getBacklinks).mockResolvedValue({ + total: 0, + records: [], + cursor: null, + }); + }); + + it('should return empty list when no pulls exist', async () => { + const result = await listPulls({ client: mockClient, repoAtUri: REPO_AT_URI }); + expect(result.pulls).toHaveLength(0); + expect(result.cursor).toBeUndefined(); + }); + + it('should query constellation with correct parameters', async () => { + await listPulls({ client: mockClient, repoAtUri: REPO_AT_URI, limit: 25 }); + expect(getBacklinks).toHaveBeenCalledWith( + REPO_AT_URI, + 'sh.tangled.repo.pull', + '.target.repo', + 25, + undefined + ); + }); + + it('should fetch records from backlinks and return pulls', async () => { + vi.mocked(getBacklinks).mockResolvedValue({ + total: 1, + records: [{ did: 'did:plc:test123', collection: 'sh.tangled.repo.pull', rkey: 'abc123' }], + cursor: null, + }); + + const mockRecord = { + $type: 'sh.tangled.repo.pull', + target: { repo: REPO_AT_URI, branch: 'main' }, + title: 'Test PR', + patchBlob: {}, + source: { branch: 'feature', sha: 'abc', repo: REPO_AT_URI }, + createdAt: '2024-01-01T00:00:00.000Z', + }; + + const mockGetRecord = vi.fn().mockResolvedValue({ + data: { value: mockRecord, uri: PULL_AT_URI, cid: 'cid123' }, + }); + + vi.mocked(mockClient.getAgent).mockReturnValue({ + com: { atproto: { repo: { getRecord: mockGetRecord } } }, + } as never); + + const result = await listPulls({ client: mockClient, repoAtUri: REPO_AT_URI }); + expect(result.pulls).toHaveLength(1); + expect(result.pulls[0].title).toBe('Test PR'); + expect(result.pulls[0].uri).toBe(PULL_AT_URI); + expect(result.pulls[0].author).toBe('did:plc:test123'); + }); +}); + +describe('getPull', () => { + let mockClient: TangledApiClient; + + beforeEach(() => { + mockClient = createMockClient(true); + }); + + it('should fetch pull record by AT-URI', async () => { + const mockRecord = { + $type: 'sh.tangled.repo.pull', + target: { repo: REPO_AT_URI, branch: 'main' }, + title: 'Test PR', + patchBlob: {}, + createdAt: '2024-01-01T00:00:00.000Z', + }; + + const mockGetRecord = vi.fn().mockResolvedValue({ + data: { value: mockRecord, uri: PULL_AT_URI, cid: 'cid123' }, + }); + + vi.mocked(mockClient.getAgent).mockReturnValue({ + com: { atproto: { repo: { getRecord: mockGetRecord } } }, + } as never); + + const result = await getPull({ client: mockClient, pullUri: PULL_AT_URI }); + expect(result.title).toBe('Test PR'); + expect(result.uri).toBe(PULL_AT_URI); + expect(result.author).toBe('did:plc:test123'); + expect(mockGetRecord).toHaveBeenCalledWith({ + repo: 'did:plc:test123', + collection: 'sh.tangled.repo.pull', + rkey: 'abc123', + }); + }); + + it('should throw for invalid AT-URI', async () => { + await expect(getPull({ client: mockClient, pullUri: 'not-a-uri' })).rejects.toThrow( + 'Invalid pull request AT-URI' + ); + }); +}); + +describe('getPullState', () => { + let mockClient: TangledApiClient; + + beforeEach(() => { + mockClient = createMockClient(true); + vi.mocked(getBacklinks).mockResolvedValue({ total: 0, records: [], cursor: null }); + }); + + it('should return open when no state records exist', async () => { + const state = await getPullState({ client: mockClient, pullUri: PULL_AT_URI }); + expect(state).toBe('open'); + expect(getBacklinks).toHaveBeenCalledWith( + PULL_AT_URI, + 'sh.tangled.repo.pull.status', + '.pull', + 100 + ); + }); + + it('should return closed for closed status', async () => { + vi.mocked(getBacklinks).mockResolvedValue({ + total: 1, + records: [ + { did: 'did:plc:test123', collection: 'sh.tangled.repo.pull.status', rkey: 'rkey1' }, + ], + cursor: null, + }); + const mockGetRecord = vi.fn().mockResolvedValue({ + data: { value: { status: 'sh.tangled.repo.pull.status.closed' }, uri: '', cid: '' }, + }); + vi.mocked(mockClient.getAgent).mockReturnValue({ + com: { atproto: { repo: { getRecord: mockGetRecord } } }, + } as never); + + const state = await getPullState({ client: mockClient, pullUri: PULL_AT_URI }); + expect(state).toBe('closed'); + }); + + it('should return merged for merged status', async () => { + vi.mocked(getBacklinks).mockResolvedValue({ + total: 1, + records: [ + { did: 'did:plc:test123', collection: 'sh.tangled.repo.pull.status', rkey: 'rkey1' }, + ], + cursor: null, + }); + const mockGetRecord = vi.fn().mockResolvedValue({ + data: { value: { status: 'sh.tangled.repo.pull.status.merged' }, uri: '', cid: '' }, + }); + vi.mocked(mockClient.getAgent).mockReturnValue({ + com: { atproto: { repo: { getRecord: mockGetRecord } } }, + } as never); + + const state = await getPullState({ client: mockClient, pullUri: PULL_AT_URI }); + expect(state).toBe('merged'); + }); + + it('should use latest rkey when multiple state records exist', async () => { + vi.mocked(getBacklinks).mockResolvedValue({ + total: 2, + records: [ + { did: 'did:plc:test123', collection: 'sh.tangled.repo.pull.status', rkey: 'rkey2' }, + { did: 'did:plc:test123', collection: 'sh.tangled.repo.pull.status', rkey: 'rkey1' }, + ], + cursor: null, + }); + const mockGetRecord = vi + .fn() + .mockResolvedValueOnce({ + data: { value: { status: 'sh.tangled.repo.pull.status.closed' }, uri: '', cid: '' }, + }) + .mockResolvedValueOnce({ + data: { value: { status: 'sh.tangled.repo.pull.status.open' }, uri: '', cid: '' }, + }); + vi.mocked(mockClient.getAgent).mockReturnValue({ + com: { atproto: { repo: { getRecord: mockGetRecord } } }, + } as never); + + // rkey2 > rkey1 alphabetically, so rkey2 (closed) should win + const state = await getPullState({ client: mockClient, pullUri: PULL_AT_URI }); + expect(state).toBe('closed'); + }); +}); + +describe('resolveSequentialPullNumber', () => { + let mockClient: TangledApiClient; + + beforeEach(() => { + mockClient = createMockClient(true); + vi.mocked(getBacklinks).mockClear(); + vi.mocked(getBacklinks).mockResolvedValue({ total: 0, records: [], cursor: null }); + }); + + it('should use fast path for #N displayId', async () => { + const num = await resolveSequentialPullNumber('#3', PULL_AT_URI, mockClient, REPO_AT_URI); + expect(num).toBe(3); + expect(getBacklinks).not.toHaveBeenCalled(); + }); + + it('should scan pulls when displayId is not #N', async () => { + const pullUri1 = 'at://did:plc:test123/sh.tangled.repo.pull/rkey1'; + const pullUri2 = 'at://did:plc:test123/sh.tangled.repo.pull/rkey2'; + + vi.mocked(getBacklinks).mockResolvedValue({ + total: 2, + records: [ + { did: 'did:plc:test123', collection: 'sh.tangled.repo.pull', rkey: 'rkey1' }, + { did: 'did:plc:test123', collection: 'sh.tangled.repo.pull', rkey: 'rkey2' }, + ], + cursor: null, + }); + + const mockGetRecord = vi + .fn() + .mockResolvedValueOnce({ + data: { + value: { + $type: 'sh.tangled.repo.pull', + target: { repo: REPO_AT_URI, branch: 'main' }, + title: 'First', + patchBlob: {}, + createdAt: '2024-01-01T00:00:00.000Z', + }, + uri: pullUri1, + cid: 'cid1', + }, + }) + .mockResolvedValueOnce({ + data: { + value: { + $type: 'sh.tangled.repo.pull', + target: { repo: REPO_AT_URI, branch: 'main' }, + title: 'Second', + patchBlob: {}, + createdAt: '2024-01-02T00:00:00.000Z', + }, + uri: pullUri2, + cid: 'cid2', + }, + }); + + vi.mocked(mockClient.getAgent).mockReturnValue({ + com: { atproto: { repo: { getRecord: mockGetRecord } } }, + } as never); + + const num = await resolveSequentialPullNumber('rkey2', pullUri2, mockClient, REPO_AT_URI); + expect(num).toBe(2); + }); +}); diff --git a/tests/utils/auth-helpers.test.ts b/tests/utils/auth-helpers.test.ts index 28718b5..713715e 100644 --- a/tests/utils/auth-helpers.test.ts +++ b/tests/utils/auth-helpers.test.ts @@ -33,7 +33,7 @@ describe('requireAuth', () => { const mockClient = createMockClient(false, null); await expect(requireAuth(mockClient)).rejects.toThrow( - 'Must be authenticated. Run "tangled auth login" first.' + 'Must be authenticated. Run "tang auth login" first.' ); }); @@ -79,7 +79,7 @@ describe('ensureAuthenticated', () => { await expect(ensureAuthenticated(mockClient)).rejects.toThrow('process.exit called'); expect(mockConsoleError).toHaveBeenCalledWith( - 'โœ— Not authenticated. Run "tangled auth login" first.' + 'โœ— Not authenticated. Run "tang auth login" first.' ); expect(mockExit).toHaveBeenCalledWith(1); }); -- 2.51.2 From 29e0f037feaf0e1d11f351bba8aa48773f1ca0fc Mon Sep 17 00:00:00 2001 From: Cameron Pfiffer <cameron@pfiffer.org> Date: Mon, 6 Jul 2026 10:49:50 -0700 Subject: [PATCH 3/3] docs: update README for current tang workflow. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document Cameron fork installation, implemented issue and pull request commands, repo-context behavior, JSON output, and development checks so the README matches the current CLI instead of inherited upstream plans. ๐Ÿ‘พ Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta Code <noreply@letta.com> --- README.md | 460 +++++++++++++++++++++--------------------------------- 1 file changed, 179 insertions(+), 281 deletions(-) diff --git a/README.md b/README.md index 0351a24..be17578 100644 --- a/README.md +++ b/README.md @@ -1,360 +1,258 @@ # tang -A CLI for [Tangled.org](https://tangled.org) โ€” manage issues and repository context from the terminal. Designed to be usable by both humans and AI agents. +`tang` is a small TypeScript CLI for working with [Tangled](https://tangled.org) repositories from a terminal. It is designed for two overlapping users: humans who want `gh`-style repository commands, and agents that need predictable JSON output, repo-context inference, and loud failures instead of mystery web UI state. -## Installation - -```bash -npm install -g @markbennett/tang -``` - -## Quick Start - -```bash -# Authenticate with your Tangled PDS handle and an App Password -tang auth login - -# From inside a git repo cloned from tangled.org: -tang issue list -tang issue create "Bug: something is broken" --body "Detailed description" -tang issue view 1 -tang issue close 1 +Source of truth for this fork: <https://tangled.org/cameron.stream/tangled-cli> -# SSH key management -tang ssh-key add ~/.ssh/id_ed25519.pub -``` - -## Commands +## Status -| Command | Description | -| :--- | :--- | -| `tang auth login` | Authenticate with your PDS handle and App Password | -| `tang auth logout` | Log out and clear stored session | -| `tang issue list` | List issues for the current repo | -| `tang issue create <title>` | Create a new issue | -| `tang issue view <n>` | View an issue | -| `tang issue close <n>` | Close an issue | -| `tang issue reopen <n>` | Reopen an issue | -| `tang pr create --base <base> --head <head> --title <title>` | Create a pull request from a pushed branch | -| `tang pr list` | List pull requests for the current repo | -| `tang pr view <n>` | View a pull request | -| `tang ssh-key add <path>` | Upload a public SSH key to your account | -| `tang context` | Show resolved repo context (DID, handle, name) | -| `tang config` | View or set CLI configuration | +Implemented today: -Most commands accept `--json [fields]` for machine-readable output, useful for scripting and LLM integrations. +- AT Protocol auth/session commands +- SSH key upload/verification helpers +- local/global CLI config +- repository context inference from `tangled.org` git remotes +- issue create/list/view/edit/close/reopen +- pull request create/list/view +- `--json [fields]` output for script/agent use on supported commands ---- +Not implemented yet: -# Architecture & Implementation Notes +- repository create/view commands +- pull request comments, reviews, merge, close/reopen +- CI/pipeline, labels, reactions, collaborator, fork, and secret-management commands -**Goal:** Create a context-aware CLI for tangled.org that bridges the gap between the AT Protocol (XRPC) and standard Git. +## Installation -**Philosophy:** Follow the **GitHub CLI (gh)** standard: act as a wrapper that creates a seamless experience where the API and local Git repo feel like one unified tool. +This fork is currently installed from the repo, not from a registry package. -## Prior Art Analysis: GitHub CLI (gh) vs. Tangled CLI +```bash +git clone git@tangled.org:cameron.stream/tangled-cli +cd tangled-cli +npm install +npm run build +npm link +``` -| Feature | GitHub CLI (gh) Approach | Tangled CLI Strategy | -| :------------- | :--------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------- | -| **Context** | Infers repo from .git/config remote URL. | **Must-Have:** Parse .git/config to resolve did:plc:... from the remote URL. | -| **Auth** | Stores oauth token; acts as a git-credential-helper. | **Plan:** Store AT Proto session; inject auth headers into git operations if possible, or manage SSH keys via API. | -| **Output** | TTY \= Tables. Pipe \= Text. \--json \= Structured. | **Plan:** Use is-interactive check. Default to "Human Mode". Force "Machine Mode" via flags. | -| **Filtering** | \--json name,url (filters fields). | **Plan:** Support basic \--json flag first. Add field filtering (--json "cloneUrl,did") to save LLM context window tokens. | -| **Extensions** | Allows custom subcommands. | _Out of Scope for V1._ | +Then verify: -## High-Level Architecture (Refined) +```bash +tang --version +tang --help +``` -The CLI acts as a "Context Engine" before it even hits the API. -`graph TD` -`User[User / LLM] -->|Command| CLI` +For development without linking: - `subgraph "Context Engine"` - `Git[Local .git/config] -->|Read Remote| Resolver[Context Resolver]` - `Resolver -->|Inferred DID| Payload` - `end` +```bash +npm run dev -- --help +npm run dev -- issue list +``` - `subgraph "Execution"` - `Payload -->|XRPC Request| API[Tangled AppView]` - `Payload -->|Git Command| Shell[Git Shell]` - `end` +## Authentication - `API --> Output` - `Shell --> Output` +`tang` uses AT Protocol app-password auth and stores session data through the local keychain/session helpers. -## Tech Stack (TypeScript) +```bash +tang auth login +tang auth status +tang auth logout +``` -| Component | Library | Purpose | -| :---------------- | :---------------------- | :--------------------------------------------------------------------------------------------- | -| **Framework** | **commander** | CLI routing and command parsing (e.g., `tangled repo create`). | -| **API Client** | **@atproto/api** | Official AT Protocol XRPC client, session management, and record operations. | -| **Lexicon Tools** | **@atproto/lexicon** | Schema validation for custom Tangled.org lexicons (e.g., `sh.tangled.publicKey`). | -| **Git Context** | **git-url-parse** | Parses remote URLs to extract the Tangled DID/NSID from `.git/config`. | -| **Git Ops** | **simple-git** | Wraps local git operations safely. | -| **Validation** | **zod** | Input validation and schema generation for LLMs. | -| **Interactivity** | **@inquirer/prompts** | Modern, user-friendly prompts for interactive flows. | -| **Formatting** | **cli-table3** | Pretty tables for "Human Mode" output (following gh CLI patterns). | -| **OS Keychain** | **@napi-rs/keyring** | Cross-platform secure storage for AT Protocol session tokens (macOS, Windows, Linux). | -| **TypeScript** | **tsx** | Fast TypeScript execution for development and testing. | +Most issue and pull request commands require auth. If auth is missing, the CLI exits with a direct error: -## Agent Integration (The "LLM Friendly" Layer) +```text +โœ— Not authenticated. Run "tang auth login" first. +``` -To make this tool accessible to Claude Code/Gemini, we adopt gh's best patterns: +## Repository context -### Rule 1: Context is King +Run repo-scoped commands from inside a git repository whose remote points at Tangled: -LLMs often hallucinate repo IDs. +```bash +git remote -v +# origin git@tangled.org:cameron.stream/example-repo (fetch) +``` -- **Design:** If the user/LLM runs tangled issue list inside a folder, **do not** ask for the repo DID. Infer it. -- **Fallback:** Only error if no git remote is found. +Then: -### Rule 2: Precision JSON (--json \<fields\>) +```bash +tang context +``` -LLMs have token limits. Returning a 50KB repo object is wasteful. +`tang context` resolves the Tangled owner, repo name, protocol, and remote. Commands use that context instead of asking the user or agent to manually supply repo DIDs. -- **Feature:** tangled repo view \--json name,cloneUrl,description -- **Implementation:** Use lodash/pick to filter the API response before printing to stdout. +Bare repo-DID remotes are also supported: -### Rule 3: Fail Fast, Fail Loud +```bash +git remote add origin git@tangled.org:did:plc:... +``` -LLMs can't read error messages buried in HTML or long stack traces. Provide a `--no-input` flag that forces the CLI to error if it can't resolve context or if required flags are missing. +## Commands -### Rule 4: Flexible Input for Issue Bodies +| Command | Description | +| :--- | :--- | +| `tang auth login` | Authenticate with an AT Protocol handle and app password | +| `tang auth status` | Show whether a session is available | +| `tang auth logout` | Clear stored credentials | +| `tang ssh-key add <path>` | Upload a public SSH key | +| `tang ssh-key verify` | Verify SSH auth against Tangled | +| `tang config list` | List configurable keys | +| `tang config get [key]` | Read config | +| `tang config set <key> <value>` | Set config | +| `tang config unset <key>` | Clear config | +| `tang context` | Show resolved repo context | +| `tang issue create <title>` | Create an issue | +| `tang issue list` | List issues for the current repo | +| `tang issue view <issue-id>` | View an issue by number/rkey | +| `tang issue edit <issue-id>` | Edit issue title/body | +| `tang issue close <issue-id>` | Close an issue | +| `tang issue reopen <issue-id>` | Reopen an issue | +| `tang pr create --base <base> --head <head> --title <title>` | Create a pull request record from a branch diff | +| `tang pr list` | List pull requests for the current repo | +| `tang pr view <pr-id>` | View a pull request by number/rkey | -Following `gh`'s pattern, `tangled issue create` will support various ways to provide the issue body, making it LLM-friendly and flexible for scripting. It will accept: +Use `--help` on any command for exact flags: -- `--body "Text"` or `-b "Text"` for a direct string. -- `--body-file ./file.md` or `-F ./file.md` to read from a file. -- `--body-file -` or `-F -` to read from standard input (stdin). +```bash +tang issue create --help +tang pr create --help +``` -### Summary of Improvements +## Issue workflow -- **Context Inference:** This is the "killer feature" of gh that we are copying. It makes the tool usable for humans and safer for LLMs (less typing = fewer errors). -- **Filtered JSON:** Saves tokens for LLM context windows. -- **Git Config Integration:** Treats the local .git folder as a database of configuration, reducing the need for environment variables or complex flags. -- **Flexible Issue Body Input:** Improves usability for both humans and LLMs by allowing diverse input methods for issue descriptions. +```bash +# from inside a Tangled-backed git repo +tang issue list +tang issue create "Bug: context resolution fails" --body "Steps and expected behavior." +tang issue view 1 +tang issue edit 1 --title "Bug: repo DID context resolution fails" +tang issue close 1 +tang issue reopen 1 +``` -## Examples Tangled CLI Usage +Issue bodies can come from a flag, a file, or stdin: ```bash -tang auth login -tang repo create my-new-repo -cd my-new-repo -tang issue create "Bug: Something is broken" --body "Detailed description of the bug here." -echo "Another bug description from stdin." | tang issue create "Bug: From stdin" --body-file - -tang issue list --json "id,title" -tang pr create --base main --head my-feature --title "Add new feature" --body-file ./pr_description.md -tang pr view 123 +tang issue create "Bug from file" --body-file ./issue.md +echo "stdin body" | tang issue create "Bug from stdin" --body-file - ``` -## Basic Commands +## Pull request workflow -Basic commands include auth, key management, repo creation, issue management, and pull request management. +Create a normal git branch, commit your changes, and push the branch first: -`tang auth login` - -- Logs in the user, ideally through a web browser flow for security. - `tang auth logout` -- Logs out the user, clearing the session. - `tang ssh-key add <public-key-path>` -- Uploads the provided public SSH key to the user's tangled.org account via the API. - `tang ssh-key verify` -- Verifies that the user's SSH key is correctly set up and can authenticate with tangled.org. Returns the associated DID and handle if successful. - `tang repo create <repo-name>` -- Creates a new repository under the user's account. - `tang repo view [--json <fields>]` -- Displays details about the current repository. If `--json` is provided, outputs only the specified fields in JSON format. - `tang issue create "<title>" [--body "<body>" | --body-file <file> | -F -]` -- Creates a new issue in the current repository with the given title and optional body, which can be provided via flag, file, or stdin. - `tang pr create --base <base-branch> --head <head-branch> --title <title> [--body <body> | --body-file <file> | -F -]` -- Creates a new pull request in the current repository from a head branch to a base branch. - `tang pr list [--json <fields>]` -- Lists pull requests for the current repository. - `tang pr view <id> [--json <fields>]` -- Displays detailed information about a specific pull request. +```bash +git switch -c my-feature +# edit, test, commit +git push -u origin my-feature +``` -## Design Decisions & Outstanding Issues +Then create the Tangled pull request record: -This section documents key design decisions and tracks outstanding architectural questions. +```bash +tang pr create --base main --head my-feature --title "Add feature" --body-file ./pr.md +``` -### (Resolved) SSH Key Management (`gh` Compatibility) +What `pr create` does: -- **Original Question:** How does `gh` manage SSH keys, and can we follow that pattern? -- **Resolution:** Analysis shows that `gh` does _not_ manage private keys. It facilitates uploading the user's _public_ key to their GitHub account. The local SSH agent handles the private key. -- **Our Approach:** The `tangled ssh-key add` command follows this exact pattern. It provides a user-friendly way to upload a public key to `tangled.org`. This resolves the core of this issue, as it is compatible with external key managers like 1Password's SSH agent. +1. Resolves the current Tangled repo from git remotes. +2. Checks whether the head branch is behind the base branch unless `--skip-behind-check` is set. +3. Generates `git diff <base>..<head>`. +4. Gzip-compresses the patch. +5. Uploads the patch blob through AT Protocol. +6. Creates a `sh.tangled.repo.pull` record. -### (Decided) Secure Session Storage +List and view pull requests: -- **Original Question:** How should we securely store the AT Proto session token? -- **Resolution:** Storing sensitive tokens in plaintext files is not secure. -- **Our Approach:** The CLI will use the operating system's native keychain for secure storage (e.g., macOS Keychain, Windows Credential Manager, or Secret Service on Linux). A library like `keytar` will be used to abstract the platform differences. +```bash +tang pr list +tang pr list --json number,title,state,sourceBranch,targetBranch +tang pr view 1 +tang pr view <rkey> +``` -### (Decided) Configuration Resolution Order +## JSON output -- **Original Question:** How should settings be resolved from different sources? -- **Resolution:** A clear precedence order is necessary. -- **Our Approach:** The CLI will resolve settings in the following order of precedence (highest first): - 1. Command-line flags (e.g., `--repo-did ...`) - 2. Environment variables (e.g., `TANGLED_REPO_DID=...`) - 3. Project-specific config file (e.g., `.tangled/config.yml` in the current directory) - 4. Global user config file (e.g., `~/.config/tangled/config.yml`) +Supported commands accept `--json [fields]`. -### (Decided for V1) Authentication Flow: App Passwords (PDS) +```bash +tang issue list --json number,title,state +tang pr list --json number,title,state,sourceBranch,targetBranch +``` -- **Original Question:** Can we allow auth through a web browser? -- **Resolution:** For the initial version, the CLI will use **App Passwords** for authentication. This is the standard and simplest method for third-party AT Protocol clients and aligns with existing practices. -- **`tang auth login` Flow:** When running `tang auth login`, the CLI will prompt the user for their **PDS handle** (e.g., `@mark.bsky.social`) and an **App Password**. -- **Generating an App Password:** Users typically generate App Passwords from their PDS's settings (e.g., in the official Bluesky app under "Settings -> App Passwords", or on their self-hosted PDS web interface). The CLI **does not** generate app passwords. -- **Session Management:** The session established is with the user's PDS, and this authenticated session is then used to interact with `tangled.org`'s App View/Service. -- **OAuth Support:** Implementing a web-based OAuth flow (similar to `gh`'s approach) is more complex and not a standard part of the AT Protocol client authentication flow. This approach is deferred for future consideration. +Without a field list, JSON commands return the command's full canonical object shape. With a comma-separated field list, output is filtered for smaller agent/context payloads. -## Future Expansion Opportunities +## Architecture -The analysis of the `tangled.org` API revealed a rich set of features that are not yet part of the initial CLI plan but represent significant opportunities for future expansion. These include: +`src/index.ts` registers the Commander command tree. The code is split by responsibility: -- **CI/CD Pipelines:** Commands to view pipeline status and manage CI/CD jobs. -- **Repository Secrets:** A dedicated command set for managing CI/CD secrets within a repository (`tangled repo secret ...`). -- **Advanced Git Operations:** Commands to interact with the commit log, diffs, branches, and tags directly via the API, augmenting local `git` commands. -- **Social & Feed Interactions:** Commands for starring repositories, reacting to feed items, and managing the user's social graph (following/unfollowing). -- **Label Management:** Commands to create, apply, and remove labels from issues and pull requests. -- **Collaboration:** Commands to manage repository collaborators. -- **Fork Management:** Commands for forking repositories and managing the sync status of forks. -- **Reactions**: Commands to add and remove reactions on issues, pull requests, and comments. -- **Commenting on Issues**: Commands to add comments to issues. +- `src/commands/` โ€” CLI command factories and terminal output +- `src/lib/` โ€” API/business logic with no Commander dependency +- `src/utils/` โ€” validation, AT-URI parsing, formatting, body input, auth helpers, git remote parsing +- `src/lexicon/` โ€” generated Tangled/AT Protocol lexicon types +- `tests/` โ€” Vitest coverage mirroring the source tree -## Task Management +Important implementation notes: -Tasks are tracked in the [Tangled issue tracker](https://tangled.org/markbennett.ca/tangled-cli/issues). Use `tang issue list` or `tang issue view <n>` to browse tasks. +- Issue and PR display numbers are not stored in records. They are computed by sorting records by `createdAt` and using the 1-based index. +- Issue state is stored as separate `sh.tangled.repo.issue.state` records; the newest state record wins. +- PR state is stored as separate `sh.tangled.repo.pull.status` records; no status record means open. +- Cross-PDS issue/PR discovery uses Constellation backlinks to find records that reference the current repo AT-URI. +- All validation helpers belong in `src/utils/validation.ts`. ## Development -### Prerequisites +Prerequisites: -- Node.js 22.0.0 or higher (latest LTS) -- npm (comes with Node.js) +- Node.js 22+ +- npm -### Installation - -Clone the repository and install dependencies: +Install dependencies: ```bash npm install ``` -### Available Scripts - -- `npm run dev` - Run the CLI in development mode (with hot reload via tsx) -- `npm run build` - Build TypeScript to JavaScript (output to `dist/`) -- `npm test` - Run tests once -- `npm run test:watch` - Run tests in watch mode -- `npm run test:coverage` - Run tests with coverage report -- `npm run lint` - Check code with Biome linter -- `npm run lint:fix` - Auto-fix linting issues -- `npm run format` - Format code with Biome -- `npm run typecheck` - Type check without building - -### Running Locally - -When running commands against the development version, use `npm run dev` with the `--` separator to pass arguments to the CLI: +Useful scripts: ```bash -# Run the CLI in development mode -npm run dev -- --version npm run dev -- --help -npm run dev -- issue list -npm run dev -- issue create "My issue title" --body "Issue body" - -# Build and run the production version +npm run typecheck npm run build -node dist/index.js --version - -# Install globally for local testing -npm link -tang --version -tang --help -npm unlink -g @markbennett/tang # Unlink when done +npm test +npm run lint +npm run lint:fix +npm run format ``` -### Project Structure +Run a single test file: +```bash +npx vitest run tests/commands/pr.test.ts ``` -tangled-cli/ -โ”œโ”€โ”€ src/ -โ”‚ โ”œโ”€โ”€ index.ts # Main CLI entry point -โ”‚ โ”œโ”€โ”€ commands/ # Command implementations -โ”‚ โ”œโ”€โ”€ lib/ # Core business logic -โ”‚ โ””โ”€โ”€ utils/ # Helper functions -โ”œโ”€โ”€ tests/ # Test files -โ”œโ”€โ”€ dist/ # Build output (gitignored) -โ””โ”€โ”€ package.json # Package configuration -``` - -### Coding Guidelines - -**IMPORTANT: These guidelines must be followed for all code contributions.** - -#### Validation Functions Location -**ALL validation logic belongs in `src/utils/validation.ts`** +Before pushing a change: -- Use Zod schemas for all input validation -- Boolean validation helpers (e.g., `isValidHandle()`, `isValidTangledDid()`) go in `validation.ts` -- Never define validation functions in other files - import from `validation.ts` -- Validation functions should return `true/false` or use Zod's `safeParse()` pattern - -Example: -```typescript -// โœ… CORRECT: validation.ts -export function isValidHandle(handle: string): boolean { - return handleSchema.safeParse(handle).success; -} - -// โŒ WRONG: Don't define validators in other files -// git.ts should import isValidHandle, not define it -``` - -#### Test Coverage Requirements - -**ALL code must have comprehensive test coverage** - -- Every new feature requires tests in the corresponding `tests/` directory -- Commands must have test files (e.g., `src/commands/foo.ts` โ†’ `tests/commands/foo.test.ts`) -- Utilities must have test files (e.g., `src/utils/bar.ts` โ†’ `tests/utils/bar.test.ts`) -- Tests should cover: - - Success cases (happy path) - - Error cases (validation failures, network errors, etc.) - - Edge cases (empty input, boundary values, etc.) -- Aim for high test coverage - tests are not optional - -Example test structure: -```typescript -describe('MyFeature', () => { - describe('successfulOperation', () => { - it('should handle valid input', async () => { /* ... */ }); - it('should handle edge case', async () => { /* ... */ }); - }); - - describe('errorHandling', () => { - it('should reject invalid input', async () => { /* ... */ }); - it('should handle network errors', async () => { /* ... */ }); - }); -}); +```bash +npm run typecheck +npm run build +npm test +npm run lint ``` -#### Pull Request Checklist +## Project structure -Before submitting code, verify: -- [ ] All validation functions are in `validation.ts` -- [ ] Comprehensive tests are written and passing -- [ ] TypeScript compilation passes (`npm run typecheck`) -- [ ] Linting passes (`npm run lint`) -- [ ] All tests pass (`npm test`) - -### Technology Stack - -- **TypeScript 5.7.2** - Latest stable with strict mode enabled -- **Node.js 22+** - Latest LTS target -- **ES2023** - Latest stable ECMAScript target -- **Biome** - Fast linter and formatter (replaces ESLint + Prettier) -- **Vitest** - Fast unit test framework -- **Commander.js** - CLI framework -- **tsx** - Fast TypeScript execution for development +```text +tangled-cli/ +โ”œโ”€โ”€ src/ +โ”‚ โ”œโ”€โ”€ commands/ +โ”‚ โ”œโ”€โ”€ lib/ +โ”‚ โ”œโ”€โ”€ lexicon/ +โ”‚ โ”œโ”€โ”€ utils/ +โ”‚ โ””โ”€โ”€ index.ts +โ”œโ”€โ”€ tests/ +โ”œโ”€โ”€ scripts/ +โ”œโ”€โ”€ lexicons/ +โ”œโ”€โ”€ package.json +โ””โ”€โ”€ README.md +``` -- 2.51.2