From 1df72346ab7afe661f7e8b3812f040dc2bc9f47b Mon Sep 17 00:00:00 2001 From: Cameron Date: Mon, 6 Jul 2026 16:12:54 -0700 Subject: [PATCH] Fix pull/issue records: repo fields are bare DIDs, not AT-URIs; pulls use rounds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refreshed the vendored lexicons from tangled.org/tangled.org/core (bun run update-lexicons && bun run codegen). This turned up two real bugs, both of which meant records created by this CLI were invisible on tangled.org even though they wrote successfully and this CLI's own read paths (via constellation) could see them fine: - sh.tangled.repo.pull's target.repo/source.repo and sh.tangled.repo.issue's repo are all lexicon format "did" — a bare DID, never an AT-URI. Every record this CLI wrote populated them with an AT-URI instead (built via the old buildRepoAtUri, which for owner/name remotes resolved a full at://did/sh.tangled.repo/rkey and for the common Tangled stable-DID-remote case fetched https://tangled.org/ and regex-scraped an AT-URI out of the page HTML — fragile, and observed to sometimes pick up an unrelated AT-URI embedded on the page, e.g. the viewer's own account). - sh.tangled.repo.pull replaced its flat patchBlob field with a required rounds: [{createdAt, patchBlob}] array at some point; this CLI still wrote the old flat shape. Fixes, in src/utils/at-uri.ts and src/lib/{pulls,issues}-api.ts: - buildRepoAtUri -> resolveRepoDid: for stable-DID remotes, the DID in the remote URL already *is* the repo's own DID, returned directly with no network call; for owner/name remotes, resolves owner to a DID, lists their sh.tangled.repo records, and prefers that record's own repoDid field ("DID of the repo itself, if assigned") over the owner DID. Deletes the HTML-scraping fallback entirely. - createPull writes rounds: [{createdAt, patchBlob}] and target.repo as the resolved DID; also sets target.repoDid (not in the published lexicon, but present on every appview-indexed record observed in the wild — the appview's ingestion pipeline appears to key off it). - source.repo/source.sha dropped from what gets written: source.repo is for cross-repo (fork) pulls this CLI doesn't create; sha was never in the lexicon and wasn't read back anywhere. - issues-api.ts's repo field gets the same bare-DID fix. Also adds `tang pr delete `: there was no way to remove a botched pull request record via the CLI at all, which is exactly what surfaced this bug (bad records had to be deleted and recreated to verify the fix). Author-only, confirms unless --yes. Checks: bun run typecheck, bun run test (329 passing), bun run lint:fix, bun run build all clean. --- lexicons/sh/tangled/actor/profile.json | 11 +- lexicons/sh/tangled/feed/star.json | 25 +- lexicons/sh/tangled/git/refUpdate.json | 32 ++- lexicons/sh/tangled/issue/issue.json | 2 +- lexicons/sh/tangled/issue/state.json | 7 +- lexicons/sh/tangled/knot/version.json | 7 + .../sh/tangled/pipeline/cancelPipeline.json | 2 +- lexicons/sh/tangled/pipeline/pipeline.json | 40 ++- lexicons/sh/tangled/pipeline/status.json | 1 + lexicons/sh/tangled/pulls/pull.json | 64 +++-- lexicons/sh/tangled/pulls/state.json | 7 +- lexicons/sh/tangled/repo/artifact.json | 5 +- lexicons/sh/tangled/repo/blob.json | 6 +- lexicons/sh/tangled/repo/collaborator.json | 4 +- lexicons/sh/tangled/repo/create.json | 24 +- lexicons/sh/tangled/repo/repo.json | 10 +- src/commands/issue.ts | 60 ++--- src/commands/pr.ts | 111 ++++++-- src/lexicon/lexicons.ts | 243 ++++++++++++++---- src/lexicon/types/sh/tangled/actor/profile.ts | 4 +- src/lexicon/types/sh/tangled/feed/star.ts | 32 ++- src/lexicon/types/sh/tangled/git/refUpdate.ts | 16 +- src/lexicon/types/sh/tangled/knot/version.ts | 2 + src/lexicon/types/sh/tangled/pipeline.ts | 15 +- .../types/sh/tangled/pipeline/status.ts | 1 + src/lexicon/types/sh/tangled/repo.ts | 6 +- src/lexicon/types/sh/tangled/repo/artifact.ts | 3 +- src/lexicon/types/sh/tangled/repo/blob.ts | 3 +- .../types/sh/tangled/repo/collaborator.ts | 2 +- src/lexicon/types/sh/tangled/repo/create.ts | 9 + .../types/sh/tangled/repo/issue/state.ts | 1 + src/lexicon/types/sh/tangled/repo/pull.ts | 26 +- .../types/sh/tangled/repo/pull/status.ts | 1 + src/lib/issues-api.ts | 26 +- src/lib/pulls-api.ts | 95 +++++-- src/utils/at-uri.ts | 75 ++---- tests/commands/issue.test.ts | 22 +- tests/commands/pr.test.ts | 101 +++++++- tests/lib/issues-api.test.ts | 16 +- tests/lib/pulls-api.test.ts | 84 ++++-- tests/utils/at-uri.test.ts | 104 ++++---- 41 files changed, 927 insertions(+), 378 deletions(-) diff --git a/lexicons/sh/tangled/actor/profile.json b/lexicons/sh/tangled/actor/profile.json index d712721..991a0c7 100644 --- a/lexicons/sh/tangled/actor/profile.json +++ b/lexicons/sh/tangled/actor/profile.json @@ -62,18 +62,23 @@ }, "pinnedRepositories": { "type": "array", - "description": "Any ATURI, it is up to appviews to validate these fields.", + "description": "Pinned repositories. Values are repo DIDs for repos that have them, or AT-URIs for legacy repos.", "minLength": 0, "maxLength": 6, "items": { - "type": "string", - "format": "at-uri" + "type": "string" } }, "pronouns": { "type": "string", "description": "Preferred gender pronouns.", "maxLength": 40 + }, + "preferredHandle": { + "type": "string", + "description": "A handle the user prefers to be displayed as.", + "format": "handle", + "maxLength": 253 } } } diff --git a/lexicons/sh/tangled/feed/star.json b/lexicons/sh/tangled/feed/star.json index dd19992..a9bcfd4 100644 --- a/lexicons/sh/tangled/feed/star.json +++ b/lexicons/sh/tangled/feed/star.json @@ -15,8 +15,9 @@ ], "properties": { "subject": { - "type": "string", - "format": "at-uri" + "type": "union", + "refs": ["#repo", "#string"], + "closed": true }, "createdAt": { "type": "string", @@ -24,6 +25,26 @@ } } } + }, + "repo": { + "type": "object", + "required": ["did"], + "properties": { + "did": { + "type": "string", + "format": "did" + } + } + }, + "string": { + "type": "object", + "required": ["uri"], + "properties": { + "uri": { + "type": "string", + "format": "at-uri" + } + } } } } diff --git a/lexicons/sh/tangled/git/refUpdate.json b/lexicons/sh/tangled/git/refUpdate.json index 6d32fcb..c37f611 100644 --- a/lexicons/sh/tangled/git/refUpdate.json +++ b/lexicons/sh/tangled/git/refUpdate.json @@ -4,15 +4,14 @@ "defs": { "main": { "type": "record", - "description": "An update to a git repository, emitted by knots.", + "description": "An event record representing git-push operation to git repository, emitted by knots.", "key": "tid", "record": { "type": "object", "required": [ "ref", "committerDid", - "repoDid", - "repoName", + "repo", "oldSha", "newSha", "meta" @@ -29,14 +28,15 @@ "description": "did of the user that pushed this ref", "format": "did" }, - "repoDid": { + "ownerDid": { "type": "string", "description": "did of the owner of the repo", "format": "did" }, - "repoName": { + "repo": { "type": "string", - "description": "name of the repo" + "description": "DID of the repo itself", + "format": "did" }, "oldSha": { "type": "string", @@ -50,6 +50,26 @@ "minLength": 40, "maxLength": 40 }, + "changedFiles": { + "type": "array", + "description": "files changed between commits", + "items": { "type": "string" } + }, + "pushOptions": { + "type": "array", + "description": "push options passed on git-push", + "maxLength": 50, + "items": { + "type": "string", + "maxLength": 1024, + "knownValues": [ + "ci-skip", + "ci-verbose", + "skip-ci", + "verbose-ci" + ] + } + }, "meta": { "type": "ref", "ref": "#meta" diff --git a/lexicons/sh/tangled/issue/issue.json b/lexicons/sh/tangled/issue/issue.json index e576e78..cd484fe 100644 --- a/lexicons/sh/tangled/issue/issue.json +++ b/lexicons/sh/tangled/issue/issue.json @@ -13,7 +13,7 @@ "properties": { "repo": { "type": "string", - "format": "at-uri" + "format": "did" }, "title": { "type": "string" diff --git a/lexicons/sh/tangled/issue/state.json b/lexicons/sh/tangled/issue/state.json index ba9d0ec..ebd9107 100644 --- a/lexicons/sh/tangled/issue/state.json +++ b/lexicons/sh/tangled/issue/state.json @@ -11,13 +11,18 @@ "type": "object", "required": [ "issue", - "state" + "state", + "createdAt" ], "properties": { "issue": { "type": "string", "format": "at-uri" }, + "createdAt": { + "type": "string", + "format": "datetime" + }, "state": { "type": "string", "description": "state of the issue", diff --git a/lexicons/sh/tangled/knot/version.json b/lexicons/sh/tangled/knot/version.json index 23486f4..4111ab5 100644 --- a/lexicons/sh/tangled/knot/version.json +++ b/lexicons/sh/tangled/knot/version.json @@ -15,6 +15,13 @@ "properties": { "version": { "type": "string" + }, + "capabilities": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Protocol capability tokens this knot implements, such as knot-acl. Knots that omit this field are treated as legacy." } } } diff --git a/lexicons/sh/tangled/pipeline/cancelPipeline.json b/lexicons/sh/tangled/pipeline/cancelPipeline.json index f1ecf48..12b9b95 100644 --- a/lexicons/sh/tangled/pipeline/cancelPipeline.json +++ b/lexicons/sh/tangled/pipeline/cancelPipeline.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "procedure", - "description": "Cancel a running pipeline", + "description": "DEPRECATED: use sh.tangled.ci.cancelPipeline instead - Cancel a running pipeline", "input": { "encoding": "application/json", "schema": { diff --git a/lexicons/sh/tangled/pipeline/pipeline.json b/lexicons/sh/tangled/pipeline/pipeline.json index 9c1138b..9deb76f 100644 --- a/lexicons/sh/tangled/pipeline/pipeline.json +++ b/lexicons/sh/tangled/pipeline/pipeline.json @@ -9,6 +9,7 @@ "key": "tid", "record": { "type": "object", + "description": "DEPRECATED: use sh.tangled.ci.pipeline instead", "required": [ "triggerMetadata", "workflows" @@ -58,6 +59,11 @@ "manual": { "type": "ref", "ref": "#manualTriggerData" + }, + "sourceRepo": { + "type": "string", + "format": "did", + "description": "Repository DID that code and workflow definitions are checked out from, when different from repo (e.g. a fork's commit for a fork-based manual trigger). If absent, source uses repo itself." } } }, @@ -66,7 +72,6 @@ "required": [ "knot", "did", - "repo", "defaultBranch" ], "properties": { @@ -77,6 +82,11 @@ "type": "string", "format": "did" }, + "repoDid": { + "type": "string", + "description": "DID of the repo itself", + "format": "did" + }, "repo": { "type": "string" }, @@ -113,8 +123,7 @@ "required": [ "sourceBranch", "targetBranch", - "sourceSha", - "action" + "sourceSha" ], "properties": { "sourceBranch": { @@ -128,14 +137,29 @@ "minLength": 40, "maxLength": 40 }, - "action": { - "type": "string" + "pull": { + "type": "string", + "format": "at-uri", + "description": "AT-URI of the sh.tangled.repo.pull record this run belongs to" } } }, "manualTriggerData": { "type": "object", + "required": [ + "sha" + ], "properties": { + "sha": { + "type": "string", + "description": "commit SHA the manual run targets", + "minLength": 40, + "maxLength": 40 + }, + "ref": { + "type": "string", + "description": "optional ref the SHA was resolved from, for display and TANGLED_REF" + }, "inputs": { "type": "array", "items": { @@ -174,7 +198,8 @@ "required": [ "skip", "depth", - "submodules" + "submodules", + "tags" ], "properties": { "skip": { @@ -185,6 +210,9 @@ }, "submodules": { "type": "boolean" + }, + "tags": { + "type": "boolean" } } }, diff --git a/lexicons/sh/tangled/pipeline/status.json b/lexicons/sh/tangled/pipeline/status.json index 4f93318..a9689f7 100644 --- a/lexicons/sh/tangled/pipeline/status.json +++ b/lexicons/sh/tangled/pipeline/status.json @@ -9,6 +9,7 @@ "key": "tid", "record": { "type": "object", + "description": "DEPRECATED: use sh.tangled.ci.pipeline instead", "required": ["pipeline", "workflow", "status", "createdAt"], "properties": { "pipeline": { diff --git a/lexicons/sh/tangled/pulls/pull.json b/lexicons/sh/tangled/pulls/pull.json index 58b55ae..e493480 100644 --- a/lexicons/sh/tangled/pulls/pull.json +++ b/lexicons/sh/tangled/pulls/pull.json @@ -12,35 +12,31 @@ "required": [ "target", "title", - "patchBlob", - "createdAt" + "createdAt", + "rounds" ], "properties": { - "target": { - "type": "ref", - "ref": "#target" - }, "title": { "type": "string" }, "body": { "type": "string" }, - "patch": { - "type": "string", - "description": "(deprecated) use patchBlob instead" - }, - "patchBlob": { - "type": "blob", - "accept": [ - "text/x-patch" - ], - "description": "patch content" + "rounds": { + "type": "array", + "items": { + "type": "ref", + "ref": "#round" + } }, "source": { "type": "ref", "ref": "#source" }, + "target": { + "type": "ref", + "ref": "#target" + }, "createdAt": { "type": "string", "format": "datetime" @@ -58,6 +54,10 @@ "type": "string", "format": "at-uri" } + }, + "dependentOn": { + "type": "string", + "format": "at-uri" } } } @@ -71,7 +71,7 @@ "properties": { "repo": { "type": "string", - "format": "at-uri" + "format": "did" }, "branch": { "type": "string" @@ -81,21 +81,35 @@ "source": { "type": "object", "required": [ - "branch", - "sha" + "branch" ], "properties": { "branch": { "type": "string" }, - "sha": { - "type": "string", - "minLength": 40, - "maxLength": 40 - }, "repo": { "type": "string", - "format": "at-uri" + "format": "did" + } + } + }, + "round": { + "type": "object", + "required": [ + "patchBlob", + "createdAt" + ], + "description": "revisions of this pull request, newer rounds are appended to this array. appviews may reject records do not treat this field as append-only. the blob format is gzipped text-based git-format-patches.", + "properties": { + "createdAt": { + "type": "string", + "format": "datetime" + }, + "patchBlob": { + "type": "blob", + "accept": [ + "application/gzip" + ] } } } diff --git a/lexicons/sh/tangled/pulls/state.json b/lexicons/sh/tangled/pulls/state.json index d33422f..f8b7e80 100644 --- a/lexicons/sh/tangled/pulls/state.json +++ b/lexicons/sh/tangled/pulls/state.json @@ -11,13 +11,18 @@ "type": "object", "required": [ "pull", - "status" + "status", + "createdAt" ], "properties": { "pull": { "type": "string", "format": "at-uri" }, + "createdAt": { + "type": "string", + "format": "datetime" + }, "status": { "type": "string", "description": "status of the pull request", diff --git a/lexicons/sh/tangled/repo/artifact.json b/lexicons/sh/tangled/repo/artifact.json index 686810e..c1e5cfd 100644 --- a/lexicons/sh/tangled/repo/artifact.json +++ b/lexicons/sh/tangled/repo/artifact.json @@ -11,7 +11,6 @@ "type": "object", "required": [ "name", - "repo", "tag", "createdAt", "artifact" @@ -26,6 +25,10 @@ "format": "at-uri", "description": "repo that this artifact is being uploaded to" }, + "repoDid": { + "type": "string", + "format": "did" + }, "tag": { "type": "bytes", "description": "hash of the tag object that this artifact is attached to (only annotated tags are supported)", diff --git a/lexicons/sh/tangled/repo/blob.json b/lexicons/sh/tangled/repo/blob.json index 6b4877a..cf6541a 100644 --- a/lexicons/sh/tangled/repo/blob.json +++ b/lexicons/sh/tangled/repo/blob.json @@ -14,7 +14,8 @@ "properties": { "repo": { "type": "string", - "description": "Repository identifier in format 'did:plc:.../repoName'" + "format": "did", + "description": "DID of the repository" }, "ref": { "type": "string", @@ -80,6 +81,9 @@ "lastCommit": { "type": "ref", "ref": "#lastCommit" + }, + "fileTooLarge": { + "type": "boolean" } } } diff --git a/lexicons/sh/tangled/repo/collaborator.json b/lexicons/sh/tangled/repo/collaborator.json index b05db96..727c14d 100644 --- a/lexicons/sh/tangled/repo/collaborator.json +++ b/lexicons/sh/tangled/repo/collaborator.json @@ -21,8 +21,8 @@ }, "repo": { "type": "string", - "description": "repo to add this user to", - "format": "at-uri" + "description": "repo DID to add this user to", + "format": "did" }, "createdAt": { "type": "string", diff --git a/lexicons/sh/tangled/repo/create.json b/lexicons/sh/tangled/repo/create.json index 5941c61..44515e6 100644 --- a/lexicons/sh/tangled/repo/create.json +++ b/lexicons/sh/tangled/repo/create.json @@ -10,13 +10,18 @@ "schema": { "type": "object", "required": [ - "rkey" + "rkey", + "name" ], "properties": { "rkey": { "type": "string", "description": "Rkey of the repository record" }, + "name": { + "type": "string", + "description": "Name of the repository" + }, "defaultBranch": { "type": "string", "description": "Default branch to push to" @@ -24,6 +29,23 @@ "source": { "type": "string", "description": "A source URL to clone from, populate this when forking or importing a repository." + }, + "repoDid": { + "type": "string", + "format": "did", + "description": "Optional user-provided did:web to use as the repo identity instead of minting a did:plc." + } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "properties": { + "repoDid": { + "type": "string", + "format": "did" } } } diff --git a/lexicons/sh/tangled/repo/repo.json b/lexicons/sh/tangled/repo/repo.json index 34a4e28..9e9527b 100644 --- a/lexicons/sh/tangled/repo/repo.json +++ b/lexicons/sh/tangled/repo/repo.json @@ -6,18 +6,17 @@ "defs": { "main": { "type": "record", - "key": "tid", + "key": "any", "record": { "type": "object", "required": [ - "name", "knot", "createdAt" ], "properties": { "name": { "type": "string", - "description": "name of the repo" + "description": "Cosmetic name of the repo." }, "knot": { "type": "string", @@ -60,6 +59,11 @@ "format": "at-uri" } }, + "repoDid": { + "type": "string", + "format": "did", + "description": "DID of the repo itself, if assigned" + }, "createdAt": { "type": "string", "format": "datetime" diff --git a/src/commands/issue.ts b/src/commands/issue.ts index db82339..e4f1f27 100644 --- a/src/commands/issue.ts +++ b/src/commands/issue.ts @@ -13,7 +13,7 @@ import { resolveSequentialNumber, updateIssue, } from '../lib/issues-api.js'; -import { buildRepoAtUri } from '../utils/at-uri.js'; +import { resolveRepoDid } from '../utils/at-uri.js'; import { ensureAuthenticated, requireAuth } from '../utils/auth-helpers.js'; import { readBodyInput } from '../utils/body-input.js'; import { formatDate, formatIssueState, outputJson } from '../utils/formatting.js'; @@ -31,13 +31,13 @@ function extractRkey(uri: string): string { * Resolve issue 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 + * @param repoDid - Repository DID * @returns Object with full issue AT-URI and display identifier */ async function resolveIssueUri( input: string, client: TangledApiClient, - repoAtUri: string + repoDid: string ): Promise<{ uri: string; displayId: string }> { // Strip # prefix if present const normalized = input.startsWith('#') ? input.slice(1) : input; @@ -53,7 +53,7 @@ async function resolveIssueUri( // Query all issues for this repo const { issues } = await listIssues({ client, - repoAtUri, + repoDid, limit: 100, // Adjust if needed for large repos }); @@ -121,14 +121,14 @@ function createViewCommand(): Command { process.exit(1); } - // 3. Build repo AT-URI - const repoAtUri = await buildRepoAtUri(context.owner, context.name, client); + // 3. Resolve repo DID + const repoDid = await resolveRepoDid(context.owner, context.name, client); // 4. Resolve issue ID to URI - const { uri: issueUri, displayId } = await resolveIssueUri(issueId, client, repoAtUri); + const { uri: issueUri, displayId } = await resolveIssueUri(issueId, client, repoDid); // 5. Fetch complete issue data (record, sequential number, state) - const issueData = await getCompleteIssueData(client, issueUri, displayId, repoAtUri); + const issueData = await getCompleteIssueData(client, issueUri, displayId, repoDid); // 6. Output result if (options.json !== undefined) { @@ -194,11 +194,11 @@ function createEditCommand(): Command { process.exit(1); } - // 4. Build repo AT-URI - const repoAtUri = await buildRepoAtUri(context.owner, context.name, client); + // 4. Resolve repo DID + const repoDid = await resolveRepoDid(context.owner, context.name, client); // 5. Resolve issue ID to URI - const { uri: issueUri, displayId } = await resolveIssueUri(issueId, client, repoAtUri); + const { uri: issueUri, displayId } = await resolveIssueUri(issueId, client, repoDid); // 6. Handle body input const body = await readBodyInput(options.body, options.bodyFile); @@ -218,7 +218,7 @@ function createEditCommand(): Command { // 9. Output result if (options.json !== undefined) { const [number, state] = await Promise.all([ - resolveSequentialNumber(displayId, updatedIssue.uri, client, repoAtUri), + resolveSequentialNumber(displayId, updatedIssue.uri, client, repoDid), getIssueState({ client, issueUri: updatedIssue.uri }), ]); const issueData: IssueData = { @@ -274,18 +274,18 @@ function createCloseCommand(): Command { process.exit(1); } - // 3. Build repo AT-URI - const repoAtUri = await buildRepoAtUri(context.owner, context.name, client); + // 3. Resolve repo DID + const repoDid = await resolveRepoDid(context.owner, context.name, client); // 4. Resolve issue ID to URI - const { uri: issueUri, displayId } = await resolveIssueUri(issueId, client, repoAtUri); + const { uri: issueUri, displayId } = await resolveIssueUri(issueId, client, repoDid); // 5. Fetch complete issue data (state will be 'closed' after operation) const issueData = await getCompleteIssueData( client, issueUri, displayId, - repoAtUri, + repoDid, 'closed' ); @@ -331,20 +331,14 @@ function createReopenCommand(): Command { process.exit(1); } - // 3. Build repo AT-URI - const repoAtUri = await buildRepoAtUri(context.owner, context.name, client); + // 3. Resolve repo DID + const repoDid = await resolveRepoDid(context.owner, context.name, client); // 4. Resolve issue ID to URI - const { uri: issueUri, displayId } = await resolveIssueUri(issueId, client, repoAtUri); + const { uri: issueUri, displayId } = await resolveIssueUri(issueId, client, repoDid); // 5. Fetch complete issue data (state will be 'open' after operation) - const issueData = await getCompleteIssueData( - client, - issueUri, - displayId, - repoAtUri, - 'open' - ); + const issueData = await getCompleteIssueData(client, issueUri, displayId, repoDid, 'open'); // 6. Reopen issue await reopenIssue({ client, issueUri }); @@ -420,8 +414,8 @@ function createCreateCommand(): Command { validateIssueBody(body); } - // 5. Build repo AT-URI - const repoAtUri = await buildRepoAtUri(context.owner, context.name, client); + // 5. Resolve repo DID + const repoDid = await resolveRepoDid(context.owner, context.name, client); // 6. Create issue (suppress progress message in JSON mode) if (options.json === undefined) { @@ -429,13 +423,13 @@ function createCreateCommand(): Command { } const issue = await createIssue({ client, - repoAtUri, + repoDid, title: validTitle, body, }); // 7. Compute sequential number - const { issues: allIssues } = await listIssues({ client, repoAtUri, limit: 100 }); + const { issues: allIssues } = await listIssues({ client, repoDid, limit: 100 }); const sortedAll = allIssues.sort( (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() ); @@ -495,8 +489,8 @@ function createListCommand(): Command { process.exit(1); } - // 3. Build repo AT-URI - const repoAtUri = await buildRepoAtUri(context.owner, context.name, client); + // 3. Resolve repo DID + const repoDid = await resolveRepoDid(context.owner, context.name, client); // 4. Fetch issues const limit = Number.parseInt(options.limit, 10); @@ -507,7 +501,7 @@ function createListCommand(): Command { const { issues } = await listIssues({ client, - repoAtUri, + repoDid, limit, }); diff --git a/src/commands/pr.ts b/src/commands/pr.ts index 1aad3ee..b6fa54e 100644 --- a/src/commands/pr.ts +++ b/src/commands/pr.ts @@ -6,8 +6,14 @@ 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 { + createPull, + deletePull, + getCompletePullData, + getPullState, + listPulls, +} from '../lib/pulls-api.js'; +import { resolveRepoDid } 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'; @@ -40,12 +46,12 @@ function extractRkey(uri: string): string { * 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 + * @param repoDid - Repository DID */ async function resolvePullUri( input: string, client: ReturnType, - repoAtUri: string + repoDid: string ): Promise<{ uri: string; displayId: string }> { // Strip # prefix if present const normalized = input.startsWith('#') ? input.slice(1) : input; @@ -60,7 +66,7 @@ async function resolvePullUri( const { pulls } = await listPulls({ client, - repoAtUri, + repoDid, limit: 100, }); @@ -95,7 +101,7 @@ async function resolvePullUri( throw new Error(`Invalid pull request identifier: ${input}`); } - const { pulls } = await listPulls({ client, repoAtUri, limit: 100 }); + const { pulls } = await listPulls({ client, repoDid, limit: 100 }); const matches = pulls.filter((pull) => extractRkey(pull.uri).startsWith(normalized)); if (matches.length === 0) { throw new Error(`Pull request '${input}' not found`); @@ -164,10 +170,7 @@ function createCreateCommand(): Command { // 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 + // 4. Behind-base check if (!options.skipBehindCheck) { const behindLog = await git.log([`${headBranch}..${baseBranch}`]); const behindCount = behindLog.total; @@ -192,7 +195,7 @@ function createCreateCommand(): Command { } } - // 6. Generate patch + // 5. Generate patch const patchContent = await git.diff([`${baseBranch}..${headBranch}`]); if (!patchContent) { console.error( @@ -201,32 +204,31 @@ function createCreateCommand(): Command { process.exit(1); } - // 7. Gzip the patch + // 6. Gzip the patch const patchBuffer = await gzip(Buffer.from(patchContent, 'utf-8')); - // 8. Handle body input + // 7. 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); + // 8. Resolve repo DID + const repoDid = await resolveRepoDid(context.owner, context.name, client); - // 10. Create pull request + // 9. Create pull request if (options.json === undefined) { console.log('Creating pull request...'); } const pull = await createPull({ client, - repoAtUri, + repoDid, title, body, targetBranch: baseBranch, sourceBranch: headBranch, - sourceSha, patchBuffer, }); - // 11. Compute sequential number - const { pulls: allPulls } = await listPulls({ client, repoAtUri, limit: 100 }); + // 10. Compute sequential number + const { pulls: allPulls } = await listPulls({ client, repoDid, limit: 100 }); const sortedAll = allPulls.sort( (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() ); @@ -289,8 +291,8 @@ function createListCommand(): Command { process.exit(1); } - // 3. Build repo AT-URI - const repoAtUri = await buildRepoAtUri(context.owner, context.name, client); + // 3. Resolve repo DID + const repoDid = await resolveRepoDid(context.owner, context.name, client); // 4. Fetch pull requests const limit = Number.parseInt(options.limit, 10); @@ -301,7 +303,7 @@ function createListCommand(): Command { const { pulls } = await listPulls({ client, - repoAtUri, + repoDid, limit, }); @@ -391,14 +393,14 @@ function createViewCommand(): Command { process.exit(1); } - // 3. Build repo AT-URI - const repoAtUri = await buildRepoAtUri(context.owner, context.name, client); + // 3. Resolve repo DID + const repoDid = await resolveRepoDid(context.owner, context.name, client); // 4. Resolve PR ID to URI - const { uri: pullUri, displayId } = await resolvePullUri(prId, client, repoAtUri); + const { uri: pullUri, displayId } = await resolvePullUri(prId, client, repoDid); // 5. Fetch complete pull request data - const pullData = await getCompletePullData(client, pullUri, displayId, repoAtUri); + const pullData = await getCompletePullData(client, pullUri, displayId, repoDid); // 6. Output result if (options.json !== undefined) { @@ -433,6 +435,60 @@ function createViewCommand(): Command { }); } +/** + * PR delete subcommand + */ +function createDeleteCommand(): Command { + return new Command('delete') + .description('Delete a pull request you authored') + .argument('', 'Pull request number (e.g., 1, #2) or rkey') + .option('-y, --yes', 'Skip the confirmation prompt') + .action(async (prId: string, options: { yes?: boolean }) => { + 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:/.git'); + process.exit(1); + } + + // 3. Resolve repo DID + const repoDid = await resolveRepoDid(context.owner, context.name, client); + + // 4. Resolve PR ID to URI + const { uri: pullUri, displayId } = await resolvePullUri(prId, client, repoDid); + const pull = await getCompletePullData(client, pullUri, displayId, repoDid); + + // 5. Confirm + if (!options.yes) { + const proceed = await confirm({ + message: `Delete pull request ${displayId} ("${pull.title}")? This cannot be undone.`, + default: false, + }); + if (!proceed) { + console.log('Aborted.'); + return; + } + } + + // 6. Delete + await deletePull({ client, pullUri }); + console.log(`✓ Pull request ${displayId} deleted`); + } catch (error) { + console.error( + `✗ Failed to delete pull request: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + process.exit(1); + } + }); +} + /** * Create the pr command with all subcommands */ @@ -443,6 +499,7 @@ export function createPrCommand(): Command { pr.addCommand(createCreateCommand()); pr.addCommand(createListCommand()); pr.addCommand(createViewCommand()); + pr.addCommand(createDeleteCommand()); return pr; } diff --git a/src/lexicon/lexicons.ts b/src/lexicon/lexicons.ts index 68d0367..0b04997 100644 --- a/src/lexicon/lexicons.ts +++ b/src/lexicon/lexicons.ts @@ -77,12 +77,11 @@ export const schemaDict = { pinnedRepositories: { type: 'array', description: - 'Any ATURI, it is up to appviews to validate these fields.', + 'Pinned repositories. Values are repo DIDs for repos that have them, or AT-URIs for legacy repos.', minLength: 0, maxLength: 6, items: { type: 'string', - format: 'at-uri', }, }, pronouns: { @@ -90,6 +89,12 @@ export const schemaDict = { description: 'Preferred gender pronouns.', maxLength: 40, }, + preferredHandle: { + type: 'string', + description: 'A handle the user prefers to be displayed as.', + format: 'handle', + maxLength: 253, + }, }, }, }, @@ -139,8 +144,12 @@ export const schemaDict = { required: ['subject', 'createdAt'], properties: { subject: { - type: 'string', - format: 'at-uri', + type: 'union', + refs: [ + 'lex:sh.tangled.feed.star#repo', + 'lex:sh.tangled.feed.star#string', + ], + closed: true, }, createdAt: { type: 'string', @@ -149,6 +158,26 @@ export const schemaDict = { }, }, }, + repo: { + type: 'object', + required: ['did'], + properties: { + did: { + type: 'string', + format: 'did', + }, + }, + }, + string: { + type: 'object', + required: ['uri'], + properties: { + uri: { + type: 'string', + format: 'at-uri', + }, + }, + }, }, }, ShTangledGitRefUpdate: { @@ -157,19 +186,12 @@ export const schemaDict = { defs: { main: { type: 'record', - description: 'An update to a git repository, emitted by knots.', + description: + 'An event record representing git-push operation to git repository, emitted by knots.', key: 'tid', record: { type: 'object', - required: [ - 'ref', - 'committerDid', - 'repoDid', - 'repoName', - 'oldSha', - 'newSha', - 'meta', - ], + required: ['ref', 'committerDid', 'repo', 'oldSha', 'newSha', 'meta'], properties: { ref: { type: 'string', @@ -182,14 +204,15 @@ export const schemaDict = { description: 'did of the user that pushed this ref', format: 'did', }, - repoDid: { + ownerDid: { type: 'string', description: 'did of the owner of the repo', format: 'did', }, - repoName: { + repo: { type: 'string', - description: 'name of the repo', + description: 'DID of the repo itself', + format: 'did', }, oldSha: { type: 'string', @@ -203,6 +226,23 @@ export const schemaDict = { minLength: 40, maxLength: 40, }, + changedFiles: { + type: 'array', + description: 'files changed between commits', + items: { + type: 'string', + }, + }, + pushOptions: { + type: 'array', + description: 'push options passed on git-push', + maxLength: 50, + items: { + type: 'string', + maxLength: 1024, + knownValues: ['ci-skip', 'ci-verbose', 'skip-ci', 'verbose-ci'], + }, + }, meta: { type: 'ref', ref: 'lex:sh.tangled.git.refUpdate#meta', @@ -379,7 +419,7 @@ export const schemaDict = { properties: { repo: { type: 'string', - format: 'at-uri', + format: 'did', }, title: { type: 'string', @@ -433,12 +473,16 @@ export const schemaDict = { key: 'tid', record: { type: 'object', - required: ['issue', 'state'], + required: ['issue', 'state', 'createdAt'], properties: { issue: { type: 'string', format: 'at-uri', }, + createdAt: { + type: 'string', + format: 'datetime', + }, state: { type: 'string', description: 'state of the issue', @@ -594,6 +638,14 @@ export const schemaDict = { version: { type: 'string', }, + capabilities: { + type: 'array', + items: { + type: 'string', + }, + description: + 'Protocol capability tokens this knot implements, such as knot-acl. Knots that omit this field are treated as legacy.', + }, }, }, }, @@ -742,7 +794,8 @@ export const schemaDict = { defs: { main: { type: 'procedure', - description: 'Cancel a running pipeline', + description: + 'DEPRECATED: use sh.tangled.ci.cancelPipeline instead - Cancel a running pipeline', input: { encoding: 'application/json', schema: { @@ -781,6 +834,7 @@ export const schemaDict = { key: 'tid', record: { type: 'object', + description: 'DEPRECATED: use sh.tangled.ci.pipeline instead', required: ['triggerMetadata', 'workflows'], properties: { triggerMetadata: { @@ -821,11 +875,17 @@ export const schemaDict = { type: 'ref', ref: 'lex:sh.tangled.pipeline#manualTriggerData', }, + sourceRepo: { + type: 'string', + format: 'did', + description: + "Repository DID that code and workflow definitions are checked out from, when different from repo (e.g. a fork's commit for a fork-based manual trigger). If absent, source uses repo itself.", + }, }, }, triggerRepo: { type: 'object', - required: ['knot', 'did', 'repo', 'defaultBranch'], + required: ['knot', 'did', 'defaultBranch'], properties: { knot: { type: 'string', @@ -834,6 +894,11 @@ export const schemaDict = { type: 'string', format: 'did', }, + repoDid: { + type: 'string', + description: 'DID of the repo itself', + format: 'did', + }, repo: { type: 'string', }, @@ -863,7 +928,7 @@ export const schemaDict = { }, pullRequestTriggerData: { type: 'object', - required: ['sourceBranch', 'targetBranch', 'sourceSha', 'action'], + required: ['sourceBranch', 'targetBranch', 'sourceSha'], properties: { sourceBranch: { type: 'string', @@ -876,14 +941,29 @@ export const schemaDict = { minLength: 40, maxLength: 40, }, - action: { + pull: { type: 'string', + format: 'at-uri', + description: + 'AT-URI of the sh.tangled.repo.pull record this run belongs to', }, }, }, manualTriggerData: { type: 'object', + required: ['sha'], properties: { + sha: { + type: 'string', + description: 'commit SHA the manual run targets', + minLength: 40, + maxLength: 40, + }, + ref: { + type: 'string', + description: + 'optional ref the SHA was resolved from, for display and TANGLED_REF', + }, inputs: { type: 'array', items: { @@ -914,7 +994,7 @@ export const schemaDict = { }, cloneOpts: { type: 'object', - required: ['skip', 'depth', 'submodules'], + required: ['skip', 'depth', 'submodules', 'tags'], properties: { skip: { type: 'boolean', @@ -925,6 +1005,9 @@ export const schemaDict = { submodules: { type: 'boolean', }, + tags: { + type: 'boolean', + }, }, }, pair: { @@ -952,6 +1035,7 @@ export const schemaDict = { key: 'tid', record: { type: 'object', + description: 'DEPRECATED: use sh.tangled.ci.pipeline instead', required: ['pipeline', 'workflow', 'status', 'createdAt'], properties: { pipeline: { @@ -1084,31 +1168,29 @@ export const schemaDict = { key: 'tid', record: { type: 'object', - required: ['target', 'title', 'patchBlob', 'createdAt'], + required: ['target', 'title', 'createdAt', 'rounds'], properties: { - target: { - type: 'ref', - ref: 'lex:sh.tangled.repo.pull#target', - }, title: { type: 'string', }, body: { type: 'string', }, - patch: { - type: 'string', - description: '(deprecated) use patchBlob instead', - }, - patchBlob: { - type: 'blob', - accept: ['text/x-patch'], - description: 'patch content', + rounds: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:sh.tangled.repo.pull#round', + }, }, source: { type: 'ref', ref: 'lex:sh.tangled.repo.pull#source', }, + target: { + type: 'ref', + ref: 'lex:sh.tangled.repo.pull#target', + }, createdAt: { type: 'string', format: 'datetime', @@ -1127,6 +1209,10 @@ export const schemaDict = { format: 'at-uri', }, }, + dependentOn: { + type: 'string', + format: 'at-uri', + }, }, }, }, @@ -1136,7 +1222,7 @@ export const schemaDict = { properties: { repo: { type: 'string', - format: 'at-uri', + format: 'did', }, branch: { type: 'string', @@ -1145,19 +1231,30 @@ export const schemaDict = { }, source: { type: 'object', - required: ['branch', 'sha'], + required: ['branch'], properties: { branch: { type: 'string', }, - sha: { + repo: { type: 'string', - minLength: 40, - maxLength: 40, + format: 'did', }, - repo: { + }, + }, + round: { + type: 'object', + required: ['patchBlob', 'createdAt'], + description: + 'revisions of this pull request, newer rounds are appended to this array. appviews may reject records do not treat this field as append-only. the blob format is gzipped text-based git-format-patches.', + properties: { + createdAt: { type: 'string', - format: 'at-uri', + format: 'datetime', + }, + patchBlob: { + type: 'blob', + accept: ['application/gzip'], }, }, }, @@ -1174,12 +1271,16 @@ export const schemaDict = { key: 'tid', record: { type: 'object', - required: ['pull', 'status'], + required: ['pull', 'status', 'createdAt'], properties: { pull: { type: 'string', format: 'at-uri', }, + createdAt: { + type: 'string', + format: 'datetime', + }, status: { type: 'string', description: 'status of the pull request', @@ -1295,7 +1396,7 @@ export const schemaDict = { key: 'tid', record: { type: 'object', - required: ['name', 'repo', 'tag', 'createdAt', 'artifact'], + required: ['name', 'tag', 'createdAt', 'artifact'], properties: { name: { type: 'string', @@ -1306,6 +1407,10 @@ export const schemaDict = { format: 'at-uri', description: 'repo that this artifact is being uploaded to', }, + repoDid: { + type: 'string', + format: 'did', + }, tag: { type: 'bytes', description: @@ -1341,8 +1446,8 @@ export const schemaDict = { properties: { repo: { type: 'string', - description: - "Repository identifier in format 'did:plc:.../repoName'", + format: 'did', + description: 'DID of the repository', }, ref: { type: 'string', @@ -1403,6 +1508,9 @@ export const schemaDict = { type: 'ref', ref: 'lex:sh.tangled.repo.blob#lastCommit', }, + fileTooLarge: { + type: 'boolean', + }, }, }, }, @@ -1645,8 +1753,8 @@ export const schemaDict = { }, repo: { type: 'string', - description: 'repo to add this user to', - format: 'at-uri', + description: 'repo DID to add this user to', + format: 'did', }, createdAt: { type: 'string', @@ -1718,12 +1826,16 @@ export const schemaDict = { encoding: 'application/json', schema: { type: 'object', - required: ['rkey'], + required: ['rkey', 'name'], properties: { rkey: { type: 'string', description: 'Rkey of the repository record', }, + name: { + type: 'string', + description: 'Name of the repository', + }, defaultBranch: { type: 'string', description: 'Default branch to push to', @@ -1733,6 +1845,24 @@ export const schemaDict = { description: 'A source URL to clone from, populate this when forking or importing a repository.', }, + repoDid: { + type: 'string', + format: 'did', + description: + 'Optional user-provided did:web to use as the repo identity instead of minting a did:plc.', + }, + }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + properties: { + repoDid: { + type: 'string', + format: 'did', + }, }, }, }, @@ -2478,14 +2608,14 @@ export const schemaDict = { defs: { main: { type: 'record', - key: 'tid', + key: 'any', record: { type: 'object', - required: ['name', 'knot', 'createdAt'], + required: ['knot', 'createdAt'], properties: { name: { type: 'string', - description: 'name of the repo', + description: 'Cosmetic name of the repo.', }, knot: { type: 'string', @@ -2528,6 +2658,11 @@ export const schemaDict = { format: 'at-uri', }, }, + repoDid: { + type: 'string', + format: 'did', + description: 'DID of the repo itself, if assigned', + }, createdAt: { type: 'string', format: 'datetime', diff --git a/src/lexicon/types/sh/tangled/actor/profile.ts b/src/lexicon/types/sh/tangled/actor/profile.ts index 88d67fd..f44de03 100644 --- a/src/lexicon/types/sh/tangled/actor/profile.ts +++ b/src/lexicon/types/sh/tangled/actor/profile.ts @@ -34,10 +34,12 @@ export interface Main { bluesky: boolean /** Free-form location text. */ location?: string - /** Any ATURI, it is up to appviews to validate these fields. */ + /** Pinned repositories. Values are repo DIDs for repos that have them, or AT-URIs for legacy repos. */ pinnedRepositories?: string[] /** Preferred gender pronouns. */ pronouns?: string + /** A handle the user prefers to be displayed as. */ + preferredHandle?: string [k: string]: unknown } diff --git a/src/lexicon/types/sh/tangled/feed/star.ts b/src/lexicon/types/sh/tangled/feed/star.ts index 6373c1e..904c752 100644 --- a/src/lexicon/types/sh/tangled/feed/star.ts +++ b/src/lexicon/types/sh/tangled/feed/star.ts @@ -16,7 +16,7 @@ const id = 'sh.tangled.feed.star' export interface Main { $type: 'sh.tangled.feed.star' - subject: string + subject: $Typed | $Typed createdAt: string [k: string]: unknown } @@ -36,3 +36,33 @@ export { isMain as isRecord, validateMain as validateRecord, } + +export interface Repo { + $type?: 'sh.tangled.feed.star#repo' + did: string +} + +const hashRepo = 'repo' + +export function isRepo(v: V) { + return is$typed(v, id, hashRepo) +} + +export function validateRepo(v: V) { + return validate(v, id, hashRepo) +} + +export interface String { + $type?: 'sh.tangled.feed.star#string' + uri: string +} + +const hashString = 'string' + +export function isString(v: V) { + return is$typed(v, id, hashString) +} + +export function validateString(v: V) { + return validate(v, id, hashString) +} diff --git a/src/lexicon/types/sh/tangled/git/refUpdate.ts b/src/lexicon/types/sh/tangled/git/refUpdate.ts index b7b290b..6e051be 100644 --- a/src/lexicon/types/sh/tangled/git/refUpdate.ts +++ b/src/lexicon/types/sh/tangled/git/refUpdate.ts @@ -21,13 +21,23 @@ export interface Main { /** did of the user that pushed this ref */ committerDid: string /** did of the owner of the repo */ - repoDid: string - /** name of the repo */ - repoName: string + ownerDid?: string + /** DID of the repo itself */ + repo: string /** old SHA of this ref */ oldSha: string /** new SHA of this ref */ newSha: string + /** files changed between commits */ + changedFiles?: string[] + /** push options passed on git-push */ + pushOptions?: ( + | 'ci-skip' + | 'ci-verbose' + | 'skip-ci' + | 'verbose-ci' + | (string & {}) + )[] meta: Meta [k: string]: unknown } diff --git a/src/lexicon/types/sh/tangled/knot/version.ts b/src/lexicon/types/sh/tangled/knot/version.ts index 4888b4a..a5664d6 100644 --- a/src/lexicon/types/sh/tangled/knot/version.ts +++ b/src/lexicon/types/sh/tangled/knot/version.ts @@ -20,6 +20,8 @@ export type InputSchema = undefined export interface OutputSchema { version: string + /** Protocol capability tokens this knot implements, such as knot-acl. Knots that omit this field are treated as legacy. */ + capabilities?: string[] } export interface CallOptions { diff --git a/src/lexicon/types/sh/tangled/pipeline.ts b/src/lexicon/types/sh/tangled/pipeline.ts index 663fbca..d4eb6d4 100644 --- a/src/lexicon/types/sh/tangled/pipeline.ts +++ b/src/lexicon/types/sh/tangled/pipeline.ts @@ -10,6 +10,7 @@ const is$typed = _is$typed, validate = _validate const id = 'sh.tangled.pipeline' +/** DEPRECATED: use sh.tangled.ci.pipeline instead */ export interface Main { $type: 'sh.tangled.pipeline' triggerMetadata: TriggerMetadata @@ -40,6 +41,8 @@ export interface TriggerMetadata { push?: PushTriggerData pullRequest?: PullRequestTriggerData manual?: ManualTriggerData + /** Repository DID that code and workflow definitions are checked out from, when different from repo (e.g. a fork's commit for a fork-based manual trigger). If absent, source uses repo itself. */ + sourceRepo?: string } const hashTriggerMetadata = 'triggerMetadata' @@ -56,7 +59,9 @@ export interface TriggerRepo { $type?: 'sh.tangled.pipeline#triggerRepo' knot: string did: string - repo: string + /** DID of the repo itself */ + repoDid?: string + repo?: string defaultBranch: string } @@ -92,7 +97,8 @@ export interface PullRequestTriggerData { sourceBranch: string targetBranch: string sourceSha: string - action: string + /** AT-URI of the sh.tangled.repo.pull record this run belongs to */ + pull?: string } const hashPullRequestTriggerData = 'pullRequestTriggerData' @@ -107,6 +113,10 @@ export function validatePullRequestTriggerData(v: V) { export interface ManualTriggerData { $type?: 'sh.tangled.pipeline#manualTriggerData' + /** commit SHA the manual run targets */ + sha: string + /** optional ref the SHA was resolved from, for display and TANGLED_REF */ + ref?: string inputs?: Pair[] } @@ -143,6 +153,7 @@ export interface CloneOpts { skip: boolean depth: number submodules: boolean + tags: boolean } const hashCloneOpts = 'cloneOpts' diff --git a/src/lexicon/types/sh/tangled/pipeline/status.ts b/src/lexicon/types/sh/tangled/pipeline/status.ts index 56ab93f..c461768 100644 --- a/src/lexicon/types/sh/tangled/pipeline/status.ts +++ b/src/lexicon/types/sh/tangled/pipeline/status.ts @@ -14,6 +14,7 @@ const is$typed = _is$typed, validate = _validate const id = 'sh.tangled.pipeline.status' +/** DEPRECATED: use sh.tangled.ci.pipeline instead */ export interface Main { $type: 'sh.tangled.pipeline.status' /** ATURI of the pipeline */ diff --git a/src/lexicon/types/sh/tangled/repo.ts b/src/lexicon/types/sh/tangled/repo.ts index d7942c0..1e971bd 100644 --- a/src/lexicon/types/sh/tangled/repo.ts +++ b/src/lexicon/types/sh/tangled/repo.ts @@ -12,8 +12,8 @@ const id = 'sh.tangled.repo' export interface Main { $type: 'sh.tangled.repo' - /** name of the repo */ - name: string + /** Cosmetic name of the repo. */ + name?: string /** knot where the repo was created */ knot: string /** CI runner to send jobs to and receive results from */ @@ -27,6 +27,8 @@ export interface Main { source?: string /** List of labels that this repo subscribes to */ labels?: string[] + /** DID of the repo itself, if assigned */ + repoDid?: string createdAt: string [k: string]: unknown } diff --git a/src/lexicon/types/sh/tangled/repo/artifact.ts b/src/lexicon/types/sh/tangled/repo/artifact.ts index f6a381a..8414d40 100644 --- a/src/lexicon/types/sh/tangled/repo/artifact.ts +++ b/src/lexicon/types/sh/tangled/repo/artifact.ts @@ -19,7 +19,8 @@ export interface Main { /** name of the artifact */ name: string /** repo that this artifact is being uploaded to */ - repo: string + repo?: string + repoDid?: string /** hash of the tag object that this artifact is attached to (only annotated tags are supported) */ tag: Uint8Array /** time of creation of this artifact */ diff --git a/src/lexicon/types/sh/tangled/repo/blob.ts b/src/lexicon/types/sh/tangled/repo/blob.ts index bf08d6f..c9c11dc 100644 --- a/src/lexicon/types/sh/tangled/repo/blob.ts +++ b/src/lexicon/types/sh/tangled/repo/blob.ts @@ -16,7 +16,7 @@ const is$typed = _is$typed, const id = 'sh.tangled.repo.blob' export type QueryParams = { - /** Repository identifier in format 'did:plc:.../repoName' */ + /** DID of the repository */ repo: string /** Git reference (branch, tag, or commit SHA) */ ref: string @@ -44,6 +44,7 @@ export interface OutputSchema { mimeType?: string submodule?: Submodule lastCommit?: LastCommit + fileTooLarge?: boolean } export interface CallOptions { diff --git a/src/lexicon/types/sh/tangled/repo/collaborator.ts b/src/lexicon/types/sh/tangled/repo/collaborator.ts index 6dfbff9..189dfc8 100644 --- a/src/lexicon/types/sh/tangled/repo/collaborator.ts +++ b/src/lexicon/types/sh/tangled/repo/collaborator.ts @@ -17,7 +17,7 @@ const id = 'sh.tangled.repo.collaborator' export interface Main { $type: 'sh.tangled.repo.collaborator' subject: string - /** repo to add this user to */ + /** repo DID to add this user to */ repo: string createdAt: string [k: string]: unknown diff --git a/src/lexicon/types/sh/tangled/repo/create.ts b/src/lexicon/types/sh/tangled/repo/create.ts index d89b3f6..a5aea61 100644 --- a/src/lexicon/types/sh/tangled/repo/create.ts +++ b/src/lexicon/types/sh/tangled/repo/create.ts @@ -20,10 +20,18 @@ export type QueryParams = {} export interface InputSchema { /** Rkey of the repository record */ rkey: string + /** Name of the repository */ + name: string /** Default branch to push to */ defaultBranch?: string /** A source URL to clone from, populate this when forking or importing a repository. */ source?: string + /** Optional user-provided did:web to use as the repo identity instead of minting a did:plc. */ + repoDid?: string +} + +export interface OutputSchema { + repoDid?: string } export interface CallOptions { @@ -36,6 +44,7 @@ export interface CallOptions { export interface Response { success: boolean headers: HeadersMap + data: OutputSchema } export function toKnownErr(e: any) { diff --git a/src/lexicon/types/sh/tangled/repo/issue/state.ts b/src/lexicon/types/sh/tangled/repo/issue/state.ts index d8cb776..f3e6d9f 100644 --- a/src/lexicon/types/sh/tangled/repo/issue/state.ts +++ b/src/lexicon/types/sh/tangled/repo/issue/state.ts @@ -17,6 +17,7 @@ const id = 'sh.tangled.repo.issue.state' export interface Main { $type: 'sh.tangled.repo.issue.state' issue: string + createdAt: string /** state of the issue */ state: | 'sh.tangled.repo.issue.state.open' diff --git a/src/lexicon/types/sh/tangled/repo/pull.ts b/src/lexicon/types/sh/tangled/repo/pull.ts index 91cd013..7539193 100644 --- a/src/lexicon/types/sh/tangled/repo/pull.ts +++ b/src/lexicon/types/sh/tangled/repo/pull.ts @@ -16,17 +16,15 @@ const id = 'sh.tangled.repo.pull' export interface Main { $type: 'sh.tangled.repo.pull' - target: Target title: string body?: string - /** (deprecated) use patchBlob instead */ - patch?: string - /** patch content */ - patchBlob: BlobRef + rounds: Round[] source?: Source + target: Target createdAt: string mentions?: string[] references?: string[] + dependentOn?: string [k: string]: unknown } @@ -65,7 +63,6 @@ export function validateTarget(v: V) { export interface Source { $type?: 'sh.tangled.repo.pull#source' branch: string - sha: string repo?: string } @@ -78,3 +75,20 @@ export function isSource(v: V) { export function validateSource(v: V) { return validate(v, id, hashSource) } + +/** revisions of this pull request, newer rounds are appended to this array. appviews may reject records do not treat this field as append-only. the blob format is gzipped text-based git-format-patches. */ +export interface Round { + $type?: 'sh.tangled.repo.pull#round' + createdAt: string + patchBlob: BlobRef +} + +const hashRound = 'round' + +export function isRound(v: V) { + return is$typed(v, id, hashRound) +} + +export function validateRound(v: V) { + return validate(v, id, hashRound) +} diff --git a/src/lexicon/types/sh/tangled/repo/pull/status.ts b/src/lexicon/types/sh/tangled/repo/pull/status.ts index 9d394cf..c01f6ba 100644 --- a/src/lexicon/types/sh/tangled/repo/pull/status.ts +++ b/src/lexicon/types/sh/tangled/repo/pull/status.ts @@ -17,6 +17,7 @@ const id = 'sh.tangled.repo.pull.status' export interface Main { $type: 'sh.tangled.repo.pull.status' pull: string + createdAt: string /** status of the pull request */ status: | 'sh.tangled.repo.pull.status.open' diff --git a/src/lib/issues-api.ts b/src/lib/issues-api.ts index 8e0fd7f..7abd9d9 100644 --- a/src/lib/issues-api.ts +++ b/src/lib/issues-api.ts @@ -32,7 +32,7 @@ export interface IssueWithMetadata extends IssueRecord { */ export interface CreateIssueParams { client: TangledApiClient; - repoAtUri: string; + repoDid: string; title: string; body?: string; } @@ -42,7 +42,7 @@ export interface CreateIssueParams { */ export interface ListIssuesParams { client: TangledApiClient; - repoAtUri: string; + repoDid: string; limit?: number; cursor?: string; } @@ -115,7 +115,7 @@ function parseIssueUri(issueUri: string): { * Create a new issue */ export async function createIssue(params: CreateIssueParams): Promise { - const { client, repoAtUri, title, body } = params; + const { client, repoDid, title, body } = params; // Validate authentication const session = await requireAuth(client); @@ -123,7 +123,7 @@ export async function createIssue(params: CreateIssueParams): Promise { - const { client, repoAtUri, limit = 50, cursor } = params; + const { client, repoDid, limit = 50, cursor } = params; // Validate authentication await requireAuth(client); try { // Query constellation for all issues that reference this repo across all PDSs - const backlinks = await getBacklinks( - repoAtUri, - 'sh.tangled.repo.issue', - '.repo', - limit, - cursor - ); + const backlinks = await getBacklinks(repoDid, 'sh.tangled.repo.issue', '.repo', limit, cursor); // Fetch each issue record individually (constellation only gives us the AT-URI components) const issuePromises = backlinks.records.map(async ({ did, collection, rkey }) => { @@ -387,12 +381,12 @@ export async function resolveSequentialNumber( displayId: string, issueUri: string, client: TangledApiClient, - repoAtUri: string + repoDid: string ): Promise { const match = displayId.match(/^#(\d+)$/); if (match) return Number.parseInt(match[1], 10); - const { issues } = await listIssues({ client, repoAtUri, limit: 100 }); + const { issues } = await listIssues({ client, repoDid, limit: 100 }); const sorted = issues.sort( (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() ); @@ -424,12 +418,12 @@ export async function getCompleteIssueData( client: TangledApiClient, issueUri: string, displayId: string, - repoAtUri: string, + repoDid: string, stateOverride?: 'open' | 'closed' ): Promise { const [issue, number] = await Promise.all([ getIssue({ client, issueUri }), - resolveSequentialNumber(displayId, issueUri, client, repoAtUri), + resolveSequentialNumber(displayId, issueUri, client, repoDid), ]); const state = stateOverride ?? (await getIssueState({ client, issueUri })); return { diff --git a/src/lib/pulls-api.ts b/src/lib/pulls-api.ts index 5bbf40b..4e4a0a5 100644 --- a/src/lib/pulls-api.ts +++ b/src/lib/pulls-api.ts @@ -5,15 +5,30 @@ import type { TangledApiClient } from './api-client.js'; import { getBacklinks } from './constellation.js'; /** - * Pull request record type based on sh.tangled.repo.pull lexicon + * A single revision of a pull request: the lexicon's `#round`. Newer rounds + * are appended; the last element is the current diff. + */ +export interface PullRound { + createdAt: string; + patchBlob: BlobRef; +} + +/** + * Pull request record type based on sh.tangled.repo.pull lexicon. + * `target.repo` is the bare repo DID (lexicon format "did"), never an + * AT-URI. `target.repoDid` duplicates it — not in the published lexicon, + * but present on every appview-indexed record observed in the wild; the + * appview's own ingestion pipeline appears to key off it. `source.repo` is + * for cross-repo (fork) pulls, which this CLI does not yet create, so it + * is only ever set when explicitly provided. */ export interface PullRecord { $type: 'sh.tangled.repo.pull'; - target: { repo: string; branch: string }; + target: { repo: string; branch: string; repoDid: string }; title: string; body?: string; - patchBlob: BlobRef; - source?: { branch: string; sha: string; repo?: string }; + rounds: PullRound[]; + source?: { branch: string; repo?: string }; createdAt: string; mentions?: string[]; references?: string[]; @@ -34,12 +49,11 @@ export interface PullWithMetadata extends PullRecord { */ export interface CreatePullParams { client: TangledApiClient; - repoAtUri: string; + repoDid: string; title: string; body?: string; targetBranch: string; sourceBranch: string; - sourceSha: string; patchBuffer: Buffer; } @@ -48,7 +62,7 @@ export interface CreatePullParams { */ export interface ListPullsParams { client: TangledApiClient; - repoAtUri: string; + repoDid: string; limit?: number; cursor?: string; } @@ -69,6 +83,14 @@ export interface GetPullStateParams { pullUri: string; } +/** + * Parameters for deleting a pull request + */ +export interface DeletePullParams { + client: TangledApiClient; + pullUri: string; +} + /** * Canonical JSON shape for a single pull request, used by all pr commands. */ @@ -110,8 +132,7 @@ function parsePullUri(pullUri: string): { * Create a new pull request */ export async function createPull(params: CreatePullParams): Promise { - const { client, repoAtUri, title, body, targetBranch, sourceBranch, sourceSha, patchBuffer } = - params; + const { client, repoDid, title, body, targetBranch, sourceBranch, patchBuffer } = params; // Validate authentication const session = await requireAuth(client); @@ -122,23 +143,25 @@ export async function createPull(params: CreatePullParams): Promise { - const { client, repoAtUri, limit = 50, cursor } = params; + const { client, repoDid, limit = 50, cursor } = params; // Validate authentication await requireAuth(client); @@ -177,7 +200,7 @@ export async function listPulls(params: ListPullsParams): Promise<{ try { // Query constellation for all pull requests that reference this repo const backlinks = await getBacklinks( - repoAtUri, + repoDid, 'sh.tangled.repo.pull', '.target.repo', limit, @@ -251,6 +274,36 @@ export async function getPull(params: GetPullParams): Promise } } +/** + * Delete a pull request. Only the author may delete their own pull request. + */ +export async function deletePull(params: DeletePullParams): Promise { + const { client, pullUri } = params; + + // Validate authentication + const session = await requireAuth(client); + + // Parse pull URI + const { did, collection, rkey } = parsePullUri(pullUri); + + if (did !== session.did) { + throw new Error('Cannot delete pull request: you are not the author'); + } + + try { + await client.getAgent().com.atproto.repo.deleteRecord({ + repo: did, + collection, + rkey, + }); + } catch (error) { + if (error instanceof Error) { + throw new Error(`Failed to delete pull request: ${error.message}`); + } + throw new Error('Failed to delete 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) @@ -320,12 +373,12 @@ export async function resolveSequentialPullNumber( displayId: string, pullUri: string, client: TangledApiClient, - repoAtUri: string + repoDid: string ): Promise { const match = displayId.match(/^#(\d+)$/); if (match) return Number.parseInt(match[1], 10); - const { pulls } = await listPulls({ client, repoAtUri, limit: 100 }); + const { pulls } = await listPulls({ client, repoDid, limit: 100 }); const sorted = pulls.sort( (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() ); @@ -341,11 +394,11 @@ export async function getCompletePullData( client: TangledApiClient, pullUri: string, displayId: string, - repoAtUri: string + repoDid: string ): Promise { const [pull, number, state] = await Promise.all([ getPull({ client, pullUri }), - resolveSequentialPullNumber(displayId, pullUri, client, repoAtUri), + resolveSequentialPullNumber(displayId, pullUri, client, repoDid), getPullState({ client, pullUri }), ]); return { diff --git a/src/utils/at-uri.ts b/src/utils/at-uri.ts index cc8923b..65555ed 100644 --- a/src/utils/at-uri.ts +++ b/src/utils/at-uri.ts @@ -1,7 +1,5 @@ import type { TangledApiClient } from '../lib/api-client.js'; -const REPO_AT_URI_PATTERN = /at:\/\/did:plc:[a-z0-9]+\/sh\.tangled\.repo\/[a-zA-Z0-9._~-]+/g; - /** * Parse an AT-URI into its components * @param uri - AT-URI string (e.g., "at://did:plc:abc/collection/rkey") @@ -62,75 +60,48 @@ export async function resolveHandleToDid( } /** - * Resolve a Tangled stable repository DID permalink to the canonical - * sh.tangled.repo AT-URI used by issues, stars, and backlinks. + * Resolve the repository DID used to populate repo-scoped record fields + * (e.g. `sh.tangled.repo.issue#repo`, `sh.tangled.repo.pull#target.repo`). + * Per the current lexicons, these fields hold a bare DID — never an AT-URI. + * + * Two cases: + * - Stable DID-only remotes (`git@tangled.org:did:plc:...`) set owner and + * name to the same DID in git.ts; that DID already *is* the repo's stable + * identity, so it is returned directly with no network call. + * - Owner/name remotes resolve the owner (handle or DID) to a DID, list + * their `sh.tangled.repo` records, and find the one matching `repoName`. + * If that record carries its own dedicated `repoDid` ("DID of the repo + * itself, if assigned" per the lexicon), that is returned; otherwise the + * owner's account DID is the best available identifier. * - * Stable clone URLs look like `git@tangled.org:did:plc:...`; that DID is a - * repository permalink, not the owner DID + repo record key pair. The Tangled - * HTML page already exposes the canonical repo AT-URI for social records, so - * use it as a compatibility fallback until the CLI has a first-class AppView - * resolver endpoint. - */ -export async function resolveStableRepoDidToAtUri(repoDid: string): Promise { - const response = await fetch(`https://tangled.org/${repoDid}`, { - headers: { accept: 'text/html' }, - }); - - if (!response.ok) { - throw new Error(`Tangled permalink lookup failed: ${response.status} ${response.statusText}`); - } - - const html = await response.text(); - const matches = Array.from(new Set(html.match(REPO_AT_URI_PATTERN) ?? [])); - - if (matches.length === 0) { - throw new Error(`No repository AT-URI found for stable DID ${repoDid}`); - } - - return matches[0]; -} - -/** - * Build a repository AT-URI from owner and repository name * @param ownerDidOrHandle - DID (e.g., "did:plc:abc") or handle (e.g., "mark.bsky.social") * @param repoName - Repository name * @param client - Authenticated API client - * @returns AT-URI string (e.g., "at://did:plc:abc/sh.tangled.repo/3mef23waqwq22") - * @throws Error if repository not found + * @returns Bare DID string (e.g., "did:plc:abc123") + * @throws Error if the repository cannot be found */ -export async function buildRepoAtUri( +export async function resolveRepoDid( ownerDidOrHandle: string, repoName: string, client: TangledApiClient ): Promise { - // Resolve owner to DID const isDid = ownerDidOrHandle.startsWith('did:'); - // Stable DID-only remotes set owner and name to the same DID in git.ts. - // Resolve the permalink directly instead of treating the repo DID as an - // owner account and trying to list sh.tangled.repo records under it. + // Stable DID-only remotes: the DID in the remote URL is already the + // repo's own stable identity DID. if (isDid && ownerDidOrHandle === repoName) { - try { - return await resolveStableRepoDidToAtUri(ownerDidOrHandle); - } catch (error) { - if (error instanceof Error) { - throw new Error(`Failed to resolve repository AT-URI: ${error.message}`); - } - throw new Error('Failed to resolve repository AT-URI: Unknown error'); - } + return ownerDidOrHandle; } const did = isDid ? ownerDidOrHandle : await resolveHandleToDid(ownerDidOrHandle, client); try { - // Query for sh.tangled.repo records const response = await client.getAgent().com.atproto.repo.listRecords({ repo: did, collection: 'sh.tangled.repo', limit: 100, // Reasonable limit for most users }); - // Find the record matching the repo name const repoRecord = response.data.records.find((record) => { const recordData = record.value as { name?: string }; return recordData.name === repoName; @@ -140,12 +111,12 @@ export async function buildRepoAtUri( throw new Error(`Repository '${repoName}' not found for ${ownerDidOrHandle}`); } - // Return the record's URI (which includes the correct rkey) - return repoRecord.uri; + const recordData = repoRecord.value as { repoDid?: string }; + return recordData.repoDid ?? did; } catch (error) { if (error instanceof Error) { - throw new Error(`Failed to resolve repository AT-URI: ${error.message}`); + throw new Error(`Failed to resolve repository DID: ${error.message}`); } - throw new Error('Failed to resolve repository AT-URI: Unknown error'); + throw new Error('Failed to resolve repository DID: Unknown error'); } } diff --git a/tests/commands/issue.test.ts b/tests/commands/issue.test.ts index 0f01b76..f479d35 100644 --- a/tests/commands/issue.test.ts +++ b/tests/commands/issue.test.ts @@ -49,7 +49,7 @@ describe('issue create command', () => { }); // Mock AT-URI builder - vi.mocked(atUri.buildRepoAtUri).mockResolvedValue( + vi.mocked(atUri.resolveRepoDid).mockResolvedValue( 'at://did:plc:abc123/sh.tangled.repo/test-repo' ); @@ -86,7 +86,7 @@ describe('issue create command', () => { expect(issuesApi.createIssue).toHaveBeenCalledWith({ client: mockClient, - repoAtUri: 'at://did:plc:abc123/sh.tangled.repo/test-repo', + repoDid: 'at://did:plc:abc123/sh.tangled.repo/test-repo', title: 'Test Issue', body: 'Test body', }); @@ -126,7 +126,7 @@ describe('issue create command', () => { expect(bodyInput.readBodyInput).toHaveBeenCalledWith(undefined, '/tmp/body.txt'); expect(issuesApi.createIssue).toHaveBeenCalledWith({ client: mockClient, - repoAtUri: 'at://did:plc:abc123/sh.tangled.repo/test-repo', + repoDid: 'at://did:plc:abc123/sh.tangled.repo/test-repo', title: 'Test Issue', body: 'Body from file', }); @@ -154,7 +154,7 @@ describe('issue create command', () => { expect(issuesApi.createIssue).toHaveBeenCalledWith({ client: mockClient, - repoAtUri: 'at://did:plc:abc123/sh.tangled.repo/test-repo', + repoDid: 'at://did:plc:abc123/sh.tangled.repo/test-repo', title: 'Test Issue', body: undefined, }); @@ -341,7 +341,7 @@ describe('issue list command', () => { }); // Mock AT-URI builder - vi.mocked(atUri.buildRepoAtUri).mockResolvedValue('at://did:plc:abc123/sh.tangled.repo/xyz789'); + vi.mocked(atUri.resolveRepoDid).mockResolvedValue('at://did:plc:abc123/sh.tangled.repo/xyz789'); }); afterEach(() => { @@ -382,7 +382,7 @@ describe('issue list command', () => { expect(issuesApi.listIssues).toHaveBeenCalledWith({ client: mockClient, - repoAtUri: 'at://did:plc:abc123/sh.tangled.repo/xyz789', + repoDid: 'at://did:plc:abc123/sh.tangled.repo/xyz789', limit: 50, }); @@ -402,7 +402,7 @@ describe('issue list command', () => { expect(issuesApi.listIssues).toHaveBeenCalledWith({ client: mockClient, - repoAtUri: 'at://did:plc:abc123/sh.tangled.repo/xyz789', + repoDid: 'at://did:plc:abc123/sh.tangled.repo/xyz789', limit: 25, }); }); @@ -604,7 +604,7 @@ describe('issue view command', () => { protocol: 'ssh', }); - vi.mocked(atUri.buildRepoAtUri).mockResolvedValue('at://did:plc:abc123/sh.tangled.repo/xyz789'); + vi.mocked(atUri.resolveRepoDid).mockResolvedValue('at://did:plc:abc123/sh.tangled.repo/xyz789'); vi.mocked(authHelpers.requireAuth).mockResolvedValue({ did: 'did:plc:abc123', @@ -830,7 +830,7 @@ describe('issue edit command', () => { protocol: 'ssh', }); - vi.mocked(atUri.buildRepoAtUri).mockResolvedValue('at://did:plc:abc123/sh.tangled.repo/xyz789'); + vi.mocked(atUri.resolveRepoDid).mockResolvedValue('at://did:plc:abc123/sh.tangled.repo/xyz789'); vi.mocked(bodyInput.readBodyInput).mockResolvedValue(undefined); vi.mocked(authHelpers.requireAuth).mockResolvedValue({ @@ -1004,7 +1004,7 @@ describe('issue close command', () => { protocol: 'ssh', }); - vi.mocked(atUri.buildRepoAtUri).mockResolvedValue('at://did:plc:abc123/sh.tangled.repo/xyz789'); + vi.mocked(atUri.resolveRepoDid).mockResolvedValue('at://did:plc:abc123/sh.tangled.repo/xyz789'); vi.mocked(issuesApi.getCompleteIssueData).mockResolvedValue({ number: 1, title: mockIssue.title, @@ -1121,7 +1121,7 @@ describe('issue reopen command', () => { protocol: 'ssh', }); - vi.mocked(atUri.buildRepoAtUri).mockResolvedValue('at://did:plc:abc123/sh.tangled.repo/xyz789'); + vi.mocked(atUri.resolveRepoDid).mockResolvedValue('at://did:plc:abc123/sh.tangled.repo/xyz789'); vi.mocked(issuesApi.getCompleteIssueData).mockResolvedValue({ number: 1, title: mockIssue.title, diff --git a/tests/commands/pr.test.ts b/tests/commands/pr.test.ts index 8e7dd79..dfe8c85 100644 --- a/tests/commands/pr.test.ts +++ b/tests/commands/pr.test.ts @@ -1,3 +1,4 @@ +import { confirm } from '@inquirer/prompts'; 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'; @@ -20,15 +21,15 @@ 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 REPO_DID = 'did:plc:abc123'; const PULL_AT_URI = 'at://did:plc:abc123/sh.tangled.repo.pull/pull123'; const makePull = (overrides: Partial = {}): PullWithMetadata => ({ $type: 'sh.tangled.repo.pull', - target: { repo: REPO_AT_URI, branch: 'main' }, + target: { repo: REPO_DID, branch: 'main', repoDid: REPO_DID }, title: 'Test PR', - patchBlob: {} as never, - source: { branch: 'feature/test', sha: 'abc123sha', repo: REPO_AT_URI }, + rounds: [{ createdAt: '2024-01-01T00:00:00.000Z', patchBlob: {} as never }], + source: { branch: 'feature/test' }, createdAt: '2024-01-01T00:00:00.000Z', uri: PULL_AT_URI, cid: 'bafyreiabc123', @@ -59,7 +60,7 @@ describe('pr list command', () => { protocol: 'ssh', }); - vi.mocked(atUri.buildRepoAtUri).mockResolvedValue(REPO_AT_URI); + vi.mocked(atUri.resolveRepoDid).mockResolvedValue(REPO_DID); vi.mocked(pullsApi.listPulls).mockResolvedValue({ pulls: [], cursor: undefined }); vi.mocked(pullsApi.getPullState).mockResolvedValue('open'); vi.mocked(authHelpers.ensureAuthenticated).mockResolvedValue(undefined); @@ -148,7 +149,7 @@ describe('pr view command', () => { protocol: 'ssh', }); - vi.mocked(atUri.buildRepoAtUri).mockResolvedValue(REPO_AT_URI); + vi.mocked(atUri.resolveRepoDid).mockResolvedValue(REPO_DID); vi.mocked(authHelpers.ensureAuthenticated).mockResolvedValue(undefined); vi.mocked(pullsApi.listPulls).mockResolvedValue({ pulls: [makePull()], cursor: undefined }); @@ -202,7 +203,7 @@ describe('pr view command', () => { mockClient, PULL_AT_URI, 'pull123', - REPO_AT_URI + REPO_DID ); }); @@ -240,7 +241,7 @@ describe('pr create command', () => { protocol: 'ssh', }); - vi.mocked(atUri.buildRepoAtUri).mockResolvedValue(REPO_AT_URI); + vi.mocked(atUri.resolveRepoDid).mockResolvedValue(REPO_DID); vi.mocked(authHelpers.ensureAuthenticated).mockResolvedValue(undefined); vi.mocked(bodyInput.readBodyInput).mockResolvedValue(undefined); @@ -402,3 +403,87 @@ describe('pr create command', () => { expect(pullsApi.createPull).toHaveBeenCalled(); }); }); + +describe('pr delete command', () => { + let mockClient: TangledApiClient; + let consoleLogSpy: ReturnType; + let consoleErrorSpy: ReturnType; + + 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.resolveRepoDid).mockResolvedValue(REPO_DID); + 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', + state: 'open', + author: 'did:plc:abc123', + createdAt: '2024-01-01T00:00:00.000Z', + uri: PULL_AT_URI, + cid: 'bafyreiabc123', + sourceBranch: 'feature/test', + targetBranch: 'main', + }); + vi.mocked(pullsApi.deletePull).mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should delete after confirmation', async () => { + vi.mocked(confirm).mockResolvedValue(true); + + const command = createPrCommand(); + await command.parseAsync(['node', 'test', 'delete', '1']); + + expect(pullsApi.deletePull).toHaveBeenCalledWith({ client: mockClient, pullUri: PULL_AT_URI }); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('deleted')); + }); + + it('should abort without deleting when confirmation is declined', async () => { + vi.mocked(confirm).mockResolvedValue(false); + + const command = createPrCommand(); + await command.parseAsync(['node', 'test', 'delete', '1']); + + expect(pullsApi.deletePull).not.toHaveBeenCalled(); + expect(consoleLogSpy).toHaveBeenCalledWith('Aborted.'); + }); + + it('should skip the prompt with --yes', async () => { + const command = createPrCommand(); + await command.parseAsync(['node', 'test', 'delete', '1', '--yes']); + + expect(confirm).not.toHaveBeenCalled(); + expect(pullsApi.deletePull).toHaveBeenCalledWith({ client: mockClient, pullUri: PULL_AT_URI }); + }); + + it('should exit 1 when deletion fails', async () => { + vi.mocked(pullsApi.deletePull).mockRejectedValue(new Error('you are not the author')); + + const command = createPrCommand(); + await expect(command.parseAsync(['node', 'test', 'delete', '1', '--yes'])).rejects.toThrow( + 'process.exit(1)' + ); + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('not the author')); + }); +}); diff --git a/tests/lib/issues-api.test.ts b/tests/lib/issues-api.test.ts index 4f6d66b..35b747d 100644 --- a/tests/lib/issues-api.test.ts +++ b/tests/lib/issues-api.test.ts @@ -67,7 +67,7 @@ describe('createIssue', () => { const result = await createIssue({ client: mockClient, - repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', + repoDid: 'at://did:plc:owner/sh.tangled.repo/my-repo', title: 'Bug: Login fails', body: 'Detailed description of the bug', }); @@ -114,7 +114,7 @@ describe('createIssue', () => { const result = await createIssue({ client: mockClient, - repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', + repoDid: 'at://did:plc:owner/sh.tangled.repo/my-repo', title: 'Simple issue', }); @@ -128,7 +128,7 @@ describe('createIssue', () => { await expect( createIssue({ client: mockClient, - repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', + repoDid: 'at://did:plc:owner/sh.tangled.repo/my-repo', title: 'Test', }) ).rejects.toThrow('Must be authenticated'); @@ -150,7 +150,7 @@ describe('createIssue', () => { await expect( createIssue({ client: mockClient, - repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', + repoDid: 'at://did:plc:owner/sh.tangled.repo/my-repo', title: 'Test', }) ).rejects.toThrow('Failed to create issue: API error'); @@ -208,7 +208,7 @@ describe('listIssues', () => { const result = await listIssues({ client: mockClient, - repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', + repoDid: 'at://did:plc:owner/sh.tangled.repo/my-repo', }); expect(result.issues).toHaveLength(2); @@ -238,7 +238,7 @@ describe('listIssues', () => { const result = await listIssues({ client: mockClient, - repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', + repoDid: 'at://did:plc:owner/sh.tangled.repo/my-repo', }); expect(result.issues).toEqual([]); @@ -249,7 +249,7 @@ describe('listIssues', () => { const result = await listIssues({ client: mockClient, - repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', + repoDid: 'at://did:plc:owner/sh.tangled.repo/my-repo', }); expect(result.cursor).toBe('nextpage'); @@ -261,7 +261,7 @@ describe('listIssues', () => { await expect( listIssues({ client: mockClient, - repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', + repoDid: 'at://did:plc:owner/sh.tangled.repo/my-repo', }) ).rejects.toThrow('Must be authenticated'); }); diff --git a/tests/lib/pulls-api.test.ts b/tests/lib/pulls-api.test.ts index 09cba3b..fe36511 100644 --- a/tests/lib/pulls-api.test.ts +++ b/tests/lib/pulls-api.test.ts @@ -3,6 +3,7 @@ import type { TangledApiClient } from '../../src/lib/api-client.js'; import { getBacklinks } from '../../src/lib/constellation.js'; import { createPull, + deletePull, getPull, getPullState, listPulls, @@ -34,7 +35,7 @@ const createMockClient = (authenticated = true): TangledApiClient => { } as unknown as TangledApiClient; }; -const REPO_AT_URI = 'at://did:plc:owner/sh.tangled.repo/my-repo'; +const REPO_DID = 'did:plc:owner'; const PULL_AT_URI = 'at://did:plc:test123/sh.tangled.repo.pull/abc123'; describe('createPull', () => { @@ -73,12 +74,11 @@ describe('createPull', () => { const patchBuffer = Buffer.from('fake gzip content'); const result = await createPull({ client: mockClient, - repoAtUri: REPO_AT_URI, + repoDid: REPO_DID, title: 'Add new feature', body: 'Description', targetBranch: 'main', sourceBranch: 'feature/new-thing', - sourceSha: 'abc123sha', patchBuffer, }); @@ -88,11 +88,11 @@ describe('createPull', () => { collection: 'sh.tangled.repo.pull', record: expect.objectContaining({ $type: 'sh.tangled.repo.pull', - target: { repo: REPO_AT_URI, branch: 'main' }, + target: { repo: REPO_DID, branch: 'main', repoDid: REPO_DID }, title: 'Add new feature', body: 'Description', - patchBlob: mockBlob, - source: { branch: 'feature/new-thing', sha: 'abc123sha', repo: REPO_AT_URI }, + rounds: [{ createdAt: expect.any(String), patchBlob: mockBlob }], + source: { branch: 'feature/new-thing' }, createdAt: expect.any(String), }), }); @@ -123,11 +123,10 @@ describe('createPull', () => { const result = await createPull({ client: mockClient, - repoAtUri: REPO_AT_URI, + repoDid: REPO_DID, title: 'Fix bug', targetBranch: 'main', sourceBranch: 'fix/bug', - sourceSha: 'deadbeef', patchBuffer: Buffer.from('patch'), }); @@ -140,11 +139,10 @@ describe('createPull', () => { await expect( createPull({ client: unauthClient, - repoAtUri: REPO_AT_URI, + repoDid: REPO_DID, title: 'Test', targetBranch: 'main', sourceBranch: 'feature', - sourceSha: 'abc', patchBuffer: Buffer.from('patch'), }) ).rejects.toThrow(); @@ -164,15 +162,15 @@ describe('listPulls', () => { }); it('should return empty list when no pulls exist', async () => { - const result = await listPulls({ client: mockClient, repoAtUri: REPO_AT_URI }); + const result = await listPulls({ client: mockClient, repoDid: REPO_DID }); 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 }); + await listPulls({ client: mockClient, repoDid: REPO_DID, limit: 25 }); expect(getBacklinks).toHaveBeenCalledWith( - REPO_AT_URI, + REPO_DID, 'sh.tangled.repo.pull', '.target.repo', 25, @@ -189,10 +187,10 @@ describe('listPulls', () => { const mockRecord = { $type: 'sh.tangled.repo.pull', - target: { repo: REPO_AT_URI, branch: 'main' }, + target: { repo: REPO_DID, branch: 'main' }, title: 'Test PR', - patchBlob: {}, - source: { branch: 'feature', sha: 'abc', repo: REPO_AT_URI }, + rounds: [{ createdAt: '2024-01-01T00:00:00.000Z', patchBlob: {} }], + source: { branch: 'feature', sha: 'abc', repo: REPO_DID }, createdAt: '2024-01-01T00:00:00.000Z', }; @@ -204,7 +202,7 @@ describe('listPulls', () => { com: { atproto: { repo: { getRecord: mockGetRecord } } }, } as never); - const result = await listPulls({ client: mockClient, repoAtUri: REPO_AT_URI }); + const result = await listPulls({ client: mockClient, repoDid: REPO_DID }); expect(result.pulls).toHaveLength(1); expect(result.pulls[0].title).toBe('Test PR'); expect(result.pulls[0].uri).toBe(PULL_AT_URI); @@ -222,9 +220,9 @@ describe('getPull', () => { it('should fetch pull record by AT-URI', async () => { const mockRecord = { $type: 'sh.tangled.repo.pull', - target: { repo: REPO_AT_URI, branch: 'main' }, + target: { repo: REPO_DID, branch: 'main' }, title: 'Test PR', - patchBlob: {}, + rounds: [{ createdAt: '2024-01-01T00:00:00.000Z', patchBlob: {} }], createdAt: '2024-01-01T00:00:00.000Z', }; @@ -254,6 +252,42 @@ describe('getPull', () => { }); }); +describe('deletePull', () => { + let mockClient: TangledApiClient; + + beforeEach(() => { + mockClient = createMockClient(true); + }); + + it('should delete the record when the caller is the author', async () => { + const mockDeleteRecord = vi.fn().mockResolvedValue({}); + vi.mocked(mockClient.getAgent).mockReturnValue({ + com: { atproto: { repo: { deleteRecord: mockDeleteRecord } } }, + } as never); + + await deletePull({ client: mockClient, pullUri: PULL_AT_URI }); + + expect(mockDeleteRecord).toHaveBeenCalledWith({ + repo: 'did:plc:test123', + collection: 'sh.tangled.repo.pull', + rkey: 'abc123', + }); + }); + + it('should throw when the caller is not the author', async () => { + const otherAuthorUri = 'at://did:plc:someoneelse/sh.tangled.repo.pull/abc123'; + await expect(deletePull({ client: mockClient, pullUri: otherAuthorUri })).rejects.toThrow( + 'Cannot delete pull request: you are not the author' + ); + }); + + it('should throw for invalid AT-URI', async () => { + await expect(deletePull({ client: mockClient, pullUri: 'not-a-uri' })).rejects.toThrow( + 'Invalid pull request AT-URI' + ); + }); +}); + describe('getPullState', () => { let mockClient: TangledApiClient; @@ -348,7 +382,7 @@ describe('resolveSequentialPullNumber', () => { }); it('should use fast path for #N displayId', async () => { - const num = await resolveSequentialPullNumber('#3', PULL_AT_URI, mockClient, REPO_AT_URI); + const num = await resolveSequentialPullNumber('#3', PULL_AT_URI, mockClient, REPO_DID); expect(num).toBe(3); expect(getBacklinks).not.toHaveBeenCalled(); }); @@ -372,9 +406,9 @@ describe('resolveSequentialPullNumber', () => { data: { value: { $type: 'sh.tangled.repo.pull', - target: { repo: REPO_AT_URI, branch: 'main' }, + target: { repo: REPO_DID, branch: 'main' }, title: 'First', - patchBlob: {}, + rounds: [{ createdAt: '2024-01-01T00:00:00.000Z', patchBlob: {} }], createdAt: '2024-01-01T00:00:00.000Z', }, uri: pullUri1, @@ -385,9 +419,9 @@ describe('resolveSequentialPullNumber', () => { data: { value: { $type: 'sh.tangled.repo.pull', - target: { repo: REPO_AT_URI, branch: 'main' }, + target: { repo: REPO_DID, branch: 'main' }, title: 'Second', - patchBlob: {}, + rounds: [{ createdAt: '2024-01-01T00:00:00.000Z', patchBlob: {} }], createdAt: '2024-01-02T00:00:00.000Z', }, uri: pullUri2, @@ -399,7 +433,7 @@ describe('resolveSequentialPullNumber', () => { com: { atproto: { repo: { getRecord: mockGetRecord } } }, } as never); - const num = await resolveSequentialPullNumber('rkey2', pullUri2, mockClient, REPO_AT_URI); + const num = await resolveSequentialPullNumber('rkey2', pullUri2, mockClient, REPO_DID); expect(num).toBe(2); }); }); diff --git a/tests/utils/at-uri.test.ts b/tests/utils/at-uri.test.ts index e5ce3ac..28c7625 100644 --- a/tests/utils/at-uri.test.ts +++ b/tests/utils/at-uri.test.ts @@ -1,11 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { TangledApiClient } from '../../src/lib/api-client.js'; -import { - buildRepoAtUri, - parseAtUri, - resolveHandleToDid, - resolveStableRepoDidToAtUri, -} from '../../src/utils/at-uri.js'; +import { parseAtUri, resolveHandleToDid, resolveRepoDid } from '../../src/utils/at-uri.js'; // Mock API client const createMockClient = (): TangledApiClient => { @@ -79,37 +74,6 @@ describe('resolveHandleToDid', () => { beforeEach(() => { mockClient = createMockClient(); - vi.unstubAllGlobals(); - }); - - it('should resolve stable repo DID permalinks from Tangled page markup', async () => { - const html = ` -
- git@tangled.org:did:plc:t53fxjacrmulx3e5d3sbdfui - `; - const fetchMock = vi.fn().mockResolvedValue(new Response(html, { status: 200 })); - vi.stubGlobal('fetch', fetchMock); - - const result = await resolveStableRepoDidToAtUri('did:plc:t53fxjacrmulx3e5d3sbdfui'); - - expect(result).toBe('at://did:plc:gfrmhdmjvxn2sjedzboeudef/sh.tangled.repo/misaligned'); - expect(fetchMock).toHaveBeenCalledWith('https://tangled.org/did:plc:t53fxjacrmulx3e5d3sbdfui', { - headers: { accept: 'text/html' }, - }); - }); - - it('should build repo AT-URI directly for stable DID-only remotes', async () => { - const html = `data-star-subject-at="at://did:plc:gfrmhdmjvxn2sjedzboeudef/sh.tangled.repo/misaligned"`; - vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(html, { status: 200 }))); - - const result = await buildRepoAtUri( - 'did:plc:t53fxjacrmulx3e5d3sbdfui', - 'did:plc:t53fxjacrmulx3e5d3sbdfui', - mockClient - ); - - expect(result).toBe('at://did:plc:gfrmhdmjvxn2sjedzboeudef/sh.tangled.repo/misaligned'); - expect(mockClient.getAgent).not.toHaveBeenCalled(); }); it('should resolve handle to DID', async () => { @@ -192,14 +156,25 @@ describe('resolveHandleToDid', () => { }); }); -describe('buildRepoAtUri', () => { +describe('resolveRepoDid', () => { let mockClient: TangledApiClient; beforeEach(() => { mockClient = createMockClient(); }); - it('should query PDS and use repo record rkey', async () => { + it('should return the DID directly for stable DID-only remotes, with no network call', async () => { + const result = await resolveRepoDid( + 'did:plc:t53fxjacrmulx3e5d3sbdfui', + 'did:plc:t53fxjacrmulx3e5d3sbdfui', + mockClient + ); + + expect(result).toBe('did:plc:t53fxjacrmulx3e5d3sbdfui'); + expect(mockClient.getAgent).not.toHaveBeenCalled(); + }); + + it('should query the PDS and return the owner DID when no repoDid is assigned', async () => { const mockListRecords = vi.fn().mockResolvedValue({ data: { records: [ @@ -221,9 +196,9 @@ describe('buildRepoAtUri', () => { }, } as never); - const result = await buildRepoAtUri('did:plc:abc123', 'my-repo', mockClient); + const result = await resolveRepoDid('did:plc:abc123', 'my-repo', mockClient); - expect(result).toBe('at://did:plc:abc123/sh.tangled.repo/3mef23waqwq22'); + expect(result).toBe('did:plc:abc123'); expect(mockListRecords).toHaveBeenCalledWith({ repo: 'did:plc:abc123', collection: 'sh.tangled.repo', @@ -231,7 +206,34 @@ describe('buildRepoAtUri', () => { }); }); - it('should resolve handle then query for repo record', async () => { + it("should prefer the repo record's own repoDid when one is assigned", async () => { + const mockListRecords = vi.fn().mockResolvedValue({ + data: { + records: [ + { + uri: 'at://did:plc:abc123/sh.tangled.repo/3mef23waqwq22', + value: { name: 'my-repo', repoDid: 'did:plc:stablerepoxyz' }, + }, + ], + }, + }); + + vi.mocked(mockClient.getAgent).mockReturnValue({ + com: { + atproto: { + repo: { + listRecords: mockListRecords, + }, + }, + }, + } as never); + + const result = await resolveRepoDid('did:plc:abc123', 'my-repo', mockClient); + + expect(result).toBe('did:plc:stablerepoxyz'); + }); + + it('should resolve handle then query for the repo record', async () => { const mockResolve = vi.fn().mockResolvedValue({ data: { did: 'did:plc:abc123' }, }); @@ -260,9 +262,9 @@ describe('buildRepoAtUri', () => { }, } as never); - const result = await buildRepoAtUri('mark.bsky.social', 'my-repo', mockClient); + const result = await resolveRepoDid('mark.bsky.social', 'my-repo', mockClient); - expect(result).toBe('at://did:plc:abc123/sh.tangled.repo/xyz789'); + expect(result).toBe('did:plc:abc123'); expect(mockResolve).toHaveBeenCalledWith({ handle: 'mark.bsky.social' }); expect(mockListRecords).toHaveBeenCalledWith({ repo: 'did:plc:abc123', @@ -281,7 +283,7 @@ describe('buildRepoAtUri', () => { }, { uri: 'at://did:plc:abc123/sh.tangled.repo/bbb222', - value: { name: 'target-repo' }, + value: { name: 'target-repo', repoDid: 'did:plc:targetrepodid' }, }, { uri: 'at://did:plc:abc123/sh.tangled.repo/ccc333', @@ -301,9 +303,9 @@ describe('buildRepoAtUri', () => { }, } as never); - const result = await buildRepoAtUri('did:plc:abc123', 'target-repo', mockClient); + const result = await resolveRepoDid('did:plc:abc123', 'target-repo', mockClient); - expect(result).toBe('at://did:plc:abc123/sh.tangled.repo/bbb222'); + expect(result).toBe('did:plc:targetrepodid'); }); it('should throw error when repository not found', async () => { @@ -328,7 +330,7 @@ describe('buildRepoAtUri', () => { }, } as never); - await expect(buildRepoAtUri('did:plc:abc123', 'nonexistent-repo', mockClient)).rejects.toThrow( + await expect(resolveRepoDid('did:plc:abc123', 'nonexistent-repo', mockClient)).rejects.toThrow( "Repository 'nonexistent-repo' not found for did:plc:abc123" ); }); @@ -346,7 +348,7 @@ describe('buildRepoAtUri', () => { }, } as never); - await expect(buildRepoAtUri('mark.bsky.social', 'my-repo', mockClient)).rejects.toThrow( + await expect(resolveRepoDid('mark.bsky.social', 'my-repo', mockClient)).rejects.toThrow( "Failed to resolve handle 'mark.bsky.social': Resolution failed" ); }); @@ -364,8 +366,8 @@ describe('buildRepoAtUri', () => { }, } as never); - await expect(buildRepoAtUri('did:plc:abc123', 'my-repo', mockClient)).rejects.toThrow( - 'Failed to resolve repository AT-URI: API error' + await expect(resolveRepoDid('did:plc:abc123', 'my-repo', mockClient)).rejects.toThrow( + 'Failed to resolve repository DID: API error' ); }); }); -- 2.51.2