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' ); }); });