From ba1affcab26bb92e655e40ea7771c801bea3cba4 Mon Sep 17 00:00:00 2001 From: nozamdavid Date: Mon, 12 Oct 2026 17:31:06 +0200 Subject: [PATCH 01/12] feat(source-control): add Tangled support --- apps/server/package.json | 1 + .../sourceControl/sourceControlDefinitions.ts | 2 + .../components/BranchToolbar.logic.test.ts | 33 +- .../web/src/components/BranchToolbar.logic.ts | 11 + .../BranchToolbarBranchSelector.tsx | 14 +- .../PullRequestSummaryTab.browser.test.tsx | 16 + .../pullRequest/PullRequestSummaryTab.tsx | 12 +- docs/user/source-control.md | 28 +- package.json | 2 +- .../src/sourceControlClients.test.ts | 16 + .../source-control-definitions/package.json | 3 +- .../src/registry.ts | 5 +- packages/source-control-tangled/package.json | 48 ++ .../src/client/definition.ts | 123 +++ .../src/server/TangledCli.ts | 194 +++++ .../server/TangledPullRequestProvider.test.ts | 98 +++ .../src/server/TangledPullRequestProvider.ts | 377 +++++++++ .../TangledSourceControlProvider.test.ts | 762 ++++++++++++++++++ .../server/TangledSourceControlProvider.ts | 396 +++++++++ .../src/server/driver.ts | 25 + .../src/server/tangledPulls.test.ts | 172 ++++ .../src/server/tangledPulls.ts | 110 +++ packages/source-control-tangled/tsconfig.json | 7 + scripts/release-smoke.ts | 1 + 24 files changed, 2444 insertions(+), 12 deletions(-) create mode 100644 packages/source-control-tangled/package.json create mode 100644 packages/source-control-tangled/src/client/definition.ts create mode 100644 packages/source-control-tangled/src/server/TangledCli.ts create mode 100644 packages/source-control-tangled/src/server/TangledPullRequestProvider.test.ts create mode 100644 packages/source-control-tangled/src/server/TangledPullRequestProvider.ts create mode 100644 packages/source-control-tangled/src/server/TangledSourceControlProvider.test.ts create mode 100644 packages/source-control-tangled/src/server/TangledSourceControlProvider.ts create mode 100644 packages/source-control-tangled/src/server/driver.ts create mode 100644 packages/source-control-tangled/src/server/tangledPulls.test.ts create mode 100644 packages/source-control-tangled/src/server/tangledPulls.ts create mode 100644 packages/source-control-tangled/tsconfig.json diff --git a/apps/server/package.json b/apps/server/package.json index 0dacbbe90720c..130149d5c31da 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -57,6 +57,7 @@ "@t3tools/source-control-gitcafe": "workspace:*", "@t3tools/source-control-github": "workspace:*", "@t3tools/source-control-gitlab": "workspace:*", + "@t3tools/source-control-tangled": "workspace:*", "character-entities": "2.0.2", "diff": "8.0.3", "effect": "catalog:", diff --git a/apps/server/src/sourceControl/sourceControlDefinitions.ts b/apps/server/src/sourceControl/sourceControlDefinitions.ts index eaeb8dd55f110..e9aaa81867f66 100644 --- a/apps/server/src/sourceControl/sourceControlDefinitions.ts +++ b/apps/server/src/sourceControl/sourceControlDefinitions.ts @@ -4,6 +4,7 @@ import { builtInSourceControlDefinitions } from "@t3tools/source-control-definit import * as ForgejoDriver from "@t3tools/source-control-forgejo/server/driver"; import * as GitCafeDriver from "@t3tools/source-control-gitcafe/server/driver"; import * as GitHubDriver from "@t3tools/source-control-github/server/driver"; +import * as TangledDriver from "@t3tools/source-control-tangled/server/driver"; import * as GitLabDriver from "@t3tools/source-control-gitlab/server/driver"; /** In the shared registry's order, which is the order hosts are listed and probed in. */ @@ -11,6 +12,7 @@ export const BUILT_IN_SOURCE_CONTROL_DRIVER_LIST = [ GitHubDriver.driver, GitLabDriver.driver, GitCafeDriver.driver, + TangledDriver.driver, ForgejoDriver.driver, BitbucketDriver.driver, AzureDevOpsDriver.driver, diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index 08e6f7633dee3..7a0c5148018a3 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -1,4 +1,4 @@ -import { type VcsRef } from "@t3tools/contracts"; +import { type VcsStatusResult, type VcsRef } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git"; import { @@ -7,6 +7,7 @@ import { resolveDraftEnvModeAfterBranchChange, resolveEffectiveEnvMode, resolveBranchToolbarPrBranch, + resolveBranchToolbarPullRequest, resolveLocalCheckoutBranchMismatch, sanitizeNewRefName, shouldIncludeBranchPickerItem, @@ -254,3 +255,33 @@ describe("sanitizeNewRefName", () => { expect(sanitizeNewRefName("foo--bar")).toBe("foo--bar"); }); }); + +describe("resolveBranchToolbarPullRequest", () => { + const old = { + number: 1, + title: "Old PR", + url: "https://tangled.org/noz.am/t3-test/pulls/1", + headRef: "test/pull-request", + baseRef: "main", + state: "closed" as const, + }; + const current = { ...old, number: 3, headRef: "test/another-branch", state: "open" as const }; + + it("uses the displayed branch's PR instead of a linked PR from another branch", () => { + expect(resolveBranchToolbarPullRequest("test/another-branch", old, current)).toBe(current); + }); + + it("shows none when the displayed branch has no PR or is switching", () => { + expect(resolveBranchToolbarPullRequest("no-pr", old, null)).toBeNull(); + expect(resolveBranchToolbarPullRequest(null, current, current)).toBeNull(); + }); + + it("keeps a linked PR when it belongs to the displayed branch", () => { + expect(resolveBranchToolbarPullRequest("test/pull-request", old, null)).toBe(old); + }); + + it("prefers a newly detected PR when a branch has been reused", () => { + const reopened = { ...current, headRef: old.headRef }; + expect(resolveBranchToolbarPullRequest(old.headRef, old, reopened)).toBe(reopened); + }); +}); diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index e22dcfaddedeb..b6908f30973f9 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -4,6 +4,7 @@ import type { VcsRef, ProjectId, WorktreeSubmodules, + VcsStatusResult, } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import { sanitizeNewRefName } from "@t3tools/shared/git"; @@ -229,6 +230,16 @@ export function resolveBranchToolbarPrBranch(input: { return input.activeThreadBranch === input.resolvedActiveBranch ? input.activeThreadBranch : null; } +export function resolveBranchToolbarPullRequest( + branch: string | null, + linked: VcsStatusResult["pr"], + detected: VcsStatusResult["pr"], +): VcsStatusResult["pr"] { + if (branch === null) return null; + if (detected?.headRef === branch) return detected; + return linked?.headRef === branch ? linked : null; +} + export function resolveLocalCheckoutBranchMismatch(input: { effectiveEnvMode: EnvMode; activeWorktreePath: string | null; diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 8f6ffdf0a119e..097e1678bcf33 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -49,6 +49,7 @@ import { deriveLocalBranchNameFromRemoteRef, resolveBranchTriggerLabel, resolveBranchToolbarPrBranch, + resolveBranchToolbarPullRequest, resolveBranchSelectionTarget, resolveBranchToolbarValue, resolveDraftEnvModeAfterBranchChange, @@ -550,16 +551,23 @@ export function BranchToolbarBranchSelector({ serverThread?.pullRequests, serverThread?.branchPullRequest, ); - const currentLinkedPr = supportsMultiplePullRequests + const threadLinkedPr = supportsMultiplePullRequests ? resolveThreadCurrentPullRequestLink(serverThread?.pullRequests ?? []) : null; const prBadge = supportsMultiplePullRequests ? resolveThreadPullRequestBadge(serverThread?.pullRequests) : null; - const displayedPr = linkedStatus?.pr ?? (currentLinkedPr === null ? branchPr : null); + const displayedPr = + displayMode === "panel" + ? resolveBranchToolbarPullRequest(branchPrBranch, linkedStatus?.pr ?? null, branchPr) + : (linkedStatus?.pr ?? (threadLinkedPr === null ? branchPr : null)); + const currentLinkedPr = + displayMode === "panel" && threadLinkedPr?.url !== displayedPr?.url ? null : threadLinkedPr; const displayedPrStatus = prStatusIndicator( displayedPr, - linkedStatus?.sourceControlProvider ?? branchStatusQuery.data?.sourceControlProvider, + displayedPr === linkedStatus?.pr + ? linkedStatus.sourceControlProvider + : branchStatusQuery.data?.sourceControlProvider, ); const prNumber = currentLinkedPr?.number ?? displayedPr?.number; const prUrl = currentLinkedPr?.url ?? displayedPr?.url; diff --git a/apps/web/src/components/pullRequest/PullRequestSummaryTab.browser.test.tsx b/apps/web/src/components/pullRequest/PullRequestSummaryTab.browser.test.tsx index ba8cb353e8d12..3be2b4e39b941 100644 --- a/apps/web/src/components/pullRequest/PullRequestSummaryTab.browser.test.tsx +++ b/apps/web/src/components/pullRequest/PullRequestSummaryTab.browser.test.tsx @@ -108,3 +108,19 @@ it("keeps an unsaved description when collapsed and reopened", async () => { await screen.click("Description"); expect(screen.container.querySelector("textarea")!.value).toBe("Unsaved description"); }); + +it("explains that comment text is unavailable and directs to host when comments are truncated and empty", async () => { + await using screen = await render({ + ...detail, + commentCount: 1, + comments: [], + commentsTruncated: true, + }); + const renderedTexts = screen.visibleText(); + expect(renderedTexts.toLowerCase()).not.toContain("longer"); + expect(renderedTexts.toLowerCase()).not.toContain("no comments"); + expect(renderedTexts.toLowerCase()).toContain("host"); + expect(renderedTexts).toContain( + "Comments are not available here. Open this pull request on the host to read them.", + ); +}); diff --git a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx index f8084aa550ad3..6f02717d5144e 100644 --- a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx @@ -913,12 +913,18 @@ export function PullRequestSummaryTab({ <> {detail.commentsTruncated ? (

- This conversation is only partially loaded. {commentCount} comment - {commentCount === 1 ? " is" : "s are"} loaded; open it on the host to read the rest. + {commentCount === 0 ? ( + "Comments are not available here. Open this pull request on the host to read them." + ) : ( + <>This conversation is only partially loaded. {commentCount} comment + {commentCount === 1 ? " is" : "s are"} loaded; open it on the host to read the rest. + )}

) : null} {items.length === 0 ? ( -

No comments yet.

+ detail.commentCount > 0 || detail.commentsTruncated ? null : ( +

No comments yet.

+ ) ) : filteredItems.length === 0 ? (

{filteredOutCount === 1 diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 9eb2751f9315f..d8ccabf7bc1b4 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -1,7 +1,7 @@ # Source control -T3 Code integrates with GitHub, GitLab, Forgejo, Gitea, Bitbucket, Azure DevOps, and GitCafe to clone -and publish repositories, create pull requests, and review changes. +T3 Code integrates with GitHub, GitLab, Forgejo, Gitea, Bitbucket, Azure DevOps, GitCafe, and +Tangled to clone and publish repositories, create pull requests, and review changes. ## Review turn changes @@ -129,6 +129,30 @@ sent to `git.cafe`; `CAFE_TOKEN` follows `CAFE_HOST` if you set it. Repositories Line comments are not available for GitCafe pull requests yet. Merging a stack lands it through GitCafe, and updating a branch restacks the layers above it. +### Tangled + +Install the [Tangled CLI (`tg`)](https://tangled.org/aly.codes/tg) and sign in: + +```bash +tg auth login +``` + +Git push and clone use SSH, so add a key under **Settings → Keys** on tangled.org first. +**Settings → Source Control → Tangled** holds the two values `tg` needs: the **knot** that hosts +new repositories (`knot1.tangled.sh` by default) and the **appview** that resolves handles and +repositories (`https://api.next.tangled.org` by default). Leave a field blank to let `tg` use its +own configuration in `~/.config/tg/config.toml`. + +Tangled has no private repositories, so a project published to it is public. + +T3 Code creates Tangled pull requests, and checks first that the branch does not already have an +open one, so it will not make a second. The branch panel shows the pull request for the current +branch. You can also browse and link pull requests from the **Pull requests** panel. +Descriptions and authors are available; `tg` does not return comments, reviews, or commit activity. + +Checking out a pull request by reference is not available. Use +**Open on Tangled**, or run `tg pr` yourself. + ## Start, clone, or publish a project To start from nothing, choose **New project** in the command palette (`Cmd/Ctrl+K`), or diff --git a/package.json b/package.json index b2912d680c855..65dc579d654b9 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "report:renders": "node scripts/render-metrics.ts", "lint": "vp lint --report-unused-disable-directives", "knip": "knip --preprocessor ./scripts/knip-schemas.ts", - "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-acp-registry --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --workspace packages/provider-fx --workspace packages/provider-grok --workspace packages/secret-provider-core --workspace packages/secret-provider-definitions --workspace packages/secret-provider-onepassword --workspace packages/source-control-core --workspace packages/source-control-definitions --workspace packages/source-control-azure-devops --workspace packages/source-control-bitbucket --workspace packages/source-control-forgejo --workspace packages/source-control-gitcafe --workspace packages/source-control-github --workspace packages/source-control-gitlab --workspace packages/source-control-testing --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", + "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-acp-registry --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --workspace packages/provider-fx --workspace packages/provider-grok --workspace packages/secret-provider-core --workspace packages/secret-provider-definitions --workspace packages/secret-provider-onepassword --workspace packages/source-control-core --workspace packages/source-control-definitions --workspace packages/source-control-azure-devops --workspace packages/source-control-bitbucket --workspace packages/source-control-forgejo --workspace packages/source-control-gitcafe --workspace packages/source-control-github --workspace packages/source-control-gitlab --workspace packages/source-control-tangled --workspace packages/source-control-testing --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts", "lint:mobile": "node scripts/mobile-native-static-check.ts", "mobile:install": "node scripts/mobile-native-client.ts ensure", diff --git a/packages/client-runtime/src/sourceControlClients.test.ts b/packages/client-runtime/src/sourceControlClients.test.ts index 359da39b33b04..cfbffc40bbabc 100644 --- a/packages/client-runtime/src/sourceControlClients.test.ts +++ b/packages/client-runtime/src/sourceControlClients.test.ts @@ -385,4 +385,20 @@ describe("appSignInTargetForRemote", () => { expect(appSignInTargetForRemote("https://ghe.acme.test/acme/web.git")).toBeNull(); expect(appSignInTargetForRemote("https://gitlab.com/acme/web.git")).toBeNull(); }); + + it("gives Tangled its own hostnames even though Forgejo claims /pulls/ everywhere", () => { + const kindOf = (url: string) => sourceControlClients.findByChangeRequestUrl(url)?.kind; + expect(kindOf("https://tangled.org/acme.example/web/pulls/7")).toBe("tangled"); + expect(kindOf("https://knot1.tangled.sh/acme.example/web/pulls/7")).toBe("tangled"); + // A knot the appview does not serve still falls through to the host that claims any domain. + expect(kindOf("https://forge.example/acme/web/pulls/7")).toBe("forgejo"); + }); + + it("offers Tangled no change request actions, because no server provider backs them", () => { + expect(sourceControlClients.get("tangled").changeRequestActions.size).toBe(0); + // `tg pr checkout` takes a record key, not the number a pull request URL shows. + expect( + sourceControlClients.get("tangled").checkoutCommand({ number: 7, headBranch: "f" }), + ).toBeNull(); + }); }); diff --git a/packages/source-control-definitions/package.json b/packages/source-control-definitions/package.json index c845de40b8bad..8d016870ec1ea 100644 --- a/packages/source-control-definitions/package.json +++ b/packages/source-control-definitions/package.json @@ -18,6 +18,7 @@ "@t3tools/source-control-forgejo": "workspace:*", "@t3tools/source-control-gitcafe": "workspace:*", "@t3tools/source-control-github": "workspace:*", - "@t3tools/source-control-gitlab": "workspace:*" + "@t3tools/source-control-gitlab": "workspace:*", + "@t3tools/source-control-tangled": "workspace:*" } } diff --git a/packages/source-control-definitions/src/registry.ts b/packages/source-control-definitions/src/registry.ts index e44a6bb281717..d314f585c345c 100644 --- a/packages/source-control-definitions/src/registry.ts +++ b/packages/source-control-definitions/src/registry.ts @@ -4,6 +4,7 @@ import { makeSourceControlDefinitionRegistry } from "@t3tools/source-control-cor import * as Forgejo from "@t3tools/source-control-forgejo/shared/definition"; import * as GitCafe from "@t3tools/source-control-gitcafe/shared/definition"; import * as GitHub from "@t3tools/source-control-github/shared/definition"; +import * as Tangled from "@t3tools/source-control-tangled/client/definition"; import * as GitLab from "@t3tools/source-control-gitlab/shared/definition"; export { canonicalizePullRequestUrl as canonicalizeGitCafePullRequestUrl } from "@t3tools/source-control-gitcafe/shared/definition"; @@ -18,14 +19,16 @@ export const builtInSourceControlDefinitions = makeSourceControlDefinitionRegist GitHub.definition, GitLab.definition, GitCafe.definition, + Tangled.definition, Forgejo.definition, Bitbucket.definition, AzureDevOps.definition, ], { - claimChangeRequestUrlsFirst: [GitCafe.definition.kind, AzureDevOps.definition.kind], + claimChangeRequestUrlsFirst: [GitCafe.definition.kind, Tangled.definition.kind, AzureDevOps.definition.kind], recognizeRemotesIn: [ GitCafe.definition.kind, + Tangled.definition.kind, Forgejo.definition.kind, GitHub.definition.kind, GitLab.definition.kind, diff --git a/packages/source-control-tangled/package.json b/packages/source-control-tangled/package.json new file mode 100644 index 0000000000000..5847b82105293 --- /dev/null +++ b/packages/source-control-tangled/package.json @@ -0,0 +1,48 @@ +{ + "name": "@t3tools/source-control-tangled", + "private": true, + "type": "module", + "exports": { + "./client/definition": { + "types": "./src/client/definition.ts", + "import": "./src/client/definition.ts" + }, + "./server/driver": { + "types": "./src/server/driver.ts", + "import": "./src/server/driver.ts" + }, + "./server/TangledCli": { + "types": "./src/server/TangledCli.ts", + "import": "./src/server/TangledCli.ts" + }, + "./server/TangledSourceControlProvider": { + "types": "./src/server/TangledSourceControlProvider.ts", + "import": "./src/server/TangledSourceControlProvider.ts" + }, + "./server/TangledPullRequestProvider": { + "types": "./src/server/TangledPullRequestProvider.ts", + "import": "./src/server/TangledPullRequestProvider.ts" + }, + "./server/tangledPulls": { + "types": "./src/server/tangledPulls.ts", + "import": "./src/server/tangledPulls.ts" + } + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vp test run --config ../../vite.config.ts --dir ." + }, + "dependencies": { + "@t3tools/contracts": "workspace:*", + "@t3tools/provider-core": "workspace:*", + "@t3tools/shared": "workspace:*", + "@t3tools/source-control-core": "workspace:*", + "effect": "catalog:" + }, + "devDependencies": { + "@effect/vitest": "catalog:", + "@t3tools/source-control-testing": "workspace:*", + "@types/node": "catalog:", + "vite-plus": "catalog:" + } +} diff --git a/packages/source-control-tangled/src/client/definition.ts b/packages/source-control-tangled/src/client/definition.ts new file mode 100644 index 0000000000000..1be81c673e3e5 --- /dev/null +++ b/packages/source-control-tangled/src/client/definition.ts @@ -0,0 +1,123 @@ +/** + * Tangled's client definition. Browser- and React Native-safe. + * + * @module source-control-tangled/client/definition + */ +import { + makeProviderSettingsSchema, + SourceControlProviderKind, + TrimmedString, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { + defineSourceControl, + isChangeRequestInProjectRepository, + isChangeRequestOnProjectHost, + parseChangeRequestPath, + projectChangeRequestHost, +} from "@t3tools/source-control-core/shared/definition"; + +const KIND = SourceControlProviderKind.make("tangled"); + +/** + * The appview and knot `tg` reads when it has nothing else configured. Both are overridable in + * Source Control settings, which is how a self-hosted appview or a different knot is reached. + */ +export const DEFAULT_APPVIEW = "https://api.next.tangled.org"; +export const DEFAULT_KNOT = "knot1.tangled.sh"; + +export const settings = makeProviderSettingsSchema({ + knot: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_KNOT)), + Schema.annotateKey({ + title: "Knot", + description: `The knot that hosts new repositories. Leave as ${DEFAULT_KNOT}, or name your own knot.`, + providerSettingsForm: { control: "text", placeholder: DEFAULT_KNOT }, + }), + ), + appview: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_APPVIEW)), + Schema.annotateKey({ + title: "Appview", + description: `The appview that resolves handles, repositories and pull requests. Leave as ${DEFAULT_APPVIEW}, or name your own.`, + providerSettingsForm: { control: "text", placeholder: DEFAULT_APPVIEW }, + }), + ), +}); +export type TangledSettings = typeof settings.Type; + +/** Whether a hostname is one tangled.org serves its repository pages under. */ +function isTangledHost(hostname: string): boolean { + const host = hostname.toLowerCase(); + return ( + host === "tangled.org" || + host === "tangled.sh" || + host.endsWith(".tangled.org") || + host.endsWith(".tangled.sh") + ); +} + +export const definition = defineSourceControl({ + kind: KIND, + label: "Tangled", + pickerLabel: "Tangled", + icon: { + viewBox: "0 0 24 24", + paths: [{ + fill: "currentColor", + d: "M21.0971 30.866C20.0566 30.8575 19.2628 30.5542 18.4016 30.0269C17.1668 29.3753 16.2237 28.2808 15.5497 27.0739C14.4789 28.4065 13.0476 29.215 11.4453 29.6718C10.763 29.8705 9.56809 30.0721 7.58737 29.3523C4.73277 28.3905 2.65342 25.4114 2.88973 22.3758C2.8465 21.1175 3.30392 19.8825 3.95228 18.8208C2.22264 17.8897 0.81225 16.3266 0.272148 14.4098C-0.0560731 13.3604 -0.042271 12.2299 0.0787626 11.1512C0.512215 8.60429 2.41697 6.38956 4.86912 5.59294C5.8479 3.35574 7.98378 1.68743 10.4037 1.34778C12.0104 1.12338 13.6735 1.46075 15.0792 2.27979C17.1272 0.00158595 20.6952 -0.671697 23.4195 0.727793C25.4978 1.72322 26.9839 3.80003 27.3447 6.06471C29.3222 6.85928 30.9877 8.47971 31.6413 10.5368C32.0784 11.8104 32.0928 13.2132 31.8098 14.5209C31.3041 16.5615 29.8679 18.2987 28.009 19.2482C28.0135 19.6113 29.2037 22.2296 29.0047 24.2056C28.9612 26.676 27.399 29.0172 25.2325 30.1544C23.9683 30.8945 22.4702 30.8805 21.0971 30.866ZM15.1733 23.755C16.9256 23.5593 18.0743 22.0269 18.9665 20.6469C19.3883 20.0182 19.7105 19.3146 20.0306 18.6454C20.4458 19.0271 20.7975 19.7461 21.4541 19.9173C22.1457 20.1333 22.9566 19.9579 23.38 19.3277C24.1902 17.8118 23.7908 15.9827 23.319 14.4119C23.0284 13.5097 22.6472 12.5841 21.9218 11.9446C22.0765 10.85 21.4299 9.73834 20.5106 9.16542C19.7272 9.79198 18.5352 9.78821 17.7794 9.11795C16.3309 10.5997 15.0034 10.5505 13.7212 9.37618C13.4331 9.11226 12.8832 10.9871 10.9535 9.92506C9.84488 10.8567 8.98526 11.753 8.22356 13.0435C7.48342 14.4347 6.70829 15.6703 6.64151 17.1811C6.6094 18.0641 7.29731 18.9892 8.22942 18.9174C9.16105 19.0009 9.7952 18.0813 10.5006 17.6993C10.6058 18.9316 10.7243 20.2556 11.1395 21.4587C11.6161 23.0155 13.2947 24.005 14.8835 23.7784C14.9959 23.7696 15.1733 23.7549 15.1733 23.755ZM16.0828 19.1062C15.2306 18.5823 15.6407 17.4452 15.6066 16.6193C15.6914 15.6227 15.7594 14.575 16.2061 13.667C16.6788 13.0197 17.8318 13.2694 17.8827 14.0999C17.8488 14.9353 17.4664 15.767 17.5121 16.633C17.4129 17.3561 17.5839 18.1684 17.265 18.8293C17.0033 19.195 16.4703 19.3013 16.0828 19.1062ZM12.3606 18.6302C11.5578 18.1933 11.8129 17.0941 11.687 16.3298C11.7914 15.445 11.7045 14.3226 12.4431 13.7021C13.1653 13.1969 14.1485 14.0621 13.8069 14.8564C13.4426 15.8602 13.6814 16.957 13.6891 17.9748C13.5512 18.5752 12.911 18.894 12.3606 18.6302Z", + }], + }, + changeRequest: { shortLabel: "PR", shortLabelWithArticle: "a PR", singular: "pull request", plural: "pull requests" }, + repositoryPathHint: "handle/repo", + // `handle.knot.tangled.sh` remotes are addressed by the proxy, so a bare hostname does not + // name this host the way tangled.org and its knots do. + publicHost: null, + publishDescription: "tangled.org", + publishHost: () => "tangled.org", + // `tg repo create` publishes under the signed-in account unless `--knot` names another owner. + newRepositoryOwner: (account) => (account ? { owner: account } : null), + defaultCloneTransport: "ssh", + changeRequestUrl: ({ host, repository, number }) => + `https://${host}/${repository}/pulls/${number}`, + // Pull requests can be read and created, but this adapter exposes no mutation actions. + changeRequestActions: new Set(), + // `tg pr checkout` takes an AT Protocol record key, not the number a pull request URL shows, + // so no checkout command can be built from what a client knows. + checkoutCommand: () => null, + authorProfileUrl: (login) => `https://tangled.org/${login}`, + changeRequestUrlFromReferenceAutolink: () => null, + reviewSummaryRequired: () => false, + checkoutCommandArgument: () => null, + isChangeRequestReference: () => false, + checkoutChangeRequestHost: () => null, + isChangeRequestInRepository: (identity, link) => + isChangeRequestInProjectRepository(projectChangeRequestHost(identity, KIND), identity, link), + canReadChangeRequestOnHost: (identity, link) => + isChangeRequestOnProjectHost(projectChangeRequestHost(identity, KIND), identity, link), + recognizeRemote: ({ hostname }) => + isTangledHost(hostname) + ? { kind: KIND, name: "Tangled", baseUrl: `https://${hostname}` } + : null, + parseChangeRequestUrl: (url) => + isTangledHost(url.hostname) + ? parseChangeRequestPath(url, /^\/([^/]+\/[^/]+)\/pulls\/(\d+)(?:\/|$)/u) + : null, + settings, +}); + +/** The URL a repository's pages are served from, from its `handle/repo` path. */ +export function repositoryWebUrl(repository: string): string { + return `https://tangled.org/${repository}`; +} + +/** The SSH remote `tg` writes, which the tangled.org proxy resolves to the owning knot. */ +export function repositorySshUrl(repository: string): string { + return `git@tangled.org:${repository}`; +} + +/** The HTTPS remote, which redirects to the knot actually holding the repository. */ +export function repositoryCloneUrl(repository: string): string { + return `https://tangled.org/${repository}`; +} diff --git a/packages/source-control-tangled/src/server/TangledCli.ts b/packages/source-control-tangled/src/server/TangledCli.ts new file mode 100644 index 0000000000000..730be37e0146d --- /dev/null +++ b/packages/source-control-tangled/src/server/TangledCli.ts @@ -0,0 +1,194 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import type { VcsError } from "@t3tools/contracts"; +import { decodeJsonResult } from "@t3tools/shared/schemaJson"; + +import { readSourceControlHostSettings } from "@t3tools/source-control-core/shared/definition"; +import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; + +import * as TangledClient from "../client/definition.ts"; + +/** `tg` is one binary on PATH; nothing here installs or updates it. */ +export const TANGLED_CLI = "tg"; + +const DEFAULT_TIMEOUT_MS = 30_000; + +export class TangledCliError extends Schema.TaggedError()("TangledCliError", { + cwd: Schema.String, + detail: Schema.String, + reason: Schema.optional( + Schema.Literals(["missing-cli", "authentication", "invalid-response", "failed"]), + ), + cause: Schema.optional(Schema.Defect()), +}) {} + +/** `tg auth status --json` names the signed-in account, and nothing else when logged out. */ +export const TangledAuthStatusSchema = Schema.Struct({ + authenticated: Schema.Boolean, + did: Schema.optional(Schema.String), + handle: Schema.optional(Schema.String), +}); + +/** `tg repo view --json` describes a repository the way `tg repo list --json` describes each. */ +export const TangledRepositorySchema = Schema.Struct({ + name: Schema.String, + uri: Schema.optional(Schema.String), + author: Schema.optional(Schema.String), + knot: Schema.optional(Schema.String), + repoDid: Schema.optional(Schema.String), + description: Schema.optional(Schema.String), + createdAt: Schema.optional(Schema.String), +}); + +/** + * `tg` reports a failure as `Error: ` on stderr and exits non-zero. A leading + * `warning:` line is advisory — `tg repo view` prints one when a repository has no pipelines — so + * it is not what a caller is told went wrong. + */ +export function tangledErrorMessage(result: { + readonly stderr: string; + readonly stdout: string; +}): string | undefined { + for (const text of [result.stderr, result.stdout]) { + const line = text + .split(/\r?\n/u) + .map((entry) => entry.trim()) + .find((entry) => entry.length > 0 && !/^warning:/iu.test(entry)); + if (line !== undefined) return line.replace(/^error:\s*/iu, ""); + } + return undefined; +} + +const isTangledCliErrorSchema = Schema.is(TangledCliError); + +export function isTangledCliError(cause: unknown): cause is TangledCliError { + return isTangledCliErrorSchema(cause); +} + +function fromVcsError(cwd: string, cause: VcsError): TangledCliError { + if (cause._tag === "VcsProcessSpawnError") { + return new TangledCliError({ + cwd, + reason: "missing-cli", + detail: "Tangled CLI (`tg`) is required but was not found on the server PATH.", + cause, + }); + } + if (cause._tag === "VcsProcessExitError" && cause.failureKind === "authentication") { + return new TangledCliError({ + cwd, + reason: "authentication", + detail: "Tangled CLI is not signed in. Run `tg auth login` and retry.", + cause, + }); + } + return new TangledCliError({ cwd, reason: "failed", detail: "Tangled CLI failed.", cause }); +} + +export class TangledCli extends Context.Service< + TangledCli, + { + /** Runs `tg --json` and decodes stdout with `schema`. */ + readonly runJson: >(input: { + readonly cwd: string; + readonly args: ReadonlyArray; + readonly schema: S; + }) => Effect.Effect; + /** + * Runs `tg ` and reports only whether it succeeded, for a command whose output this + * build does not read — creating a pull request, which answers in a shape nothing here needs. + */ + readonly run: (input: { + readonly cwd: string; + readonly args: ReadonlyArray; + }) => Effect.Effect; + } +>()("@t3tools/source-control-tangled/server/TangledCli") {} + +/** + * The environment `tg` reads for the appview and the knot. Both go through the environment + * rather than a flag: `tg` has a global `--appview`, but no global `--knot` — that flag exists + * only on `repo create` — so an environment is what reaches every subcommand alike. A blank field + * leaves the variable unset, which is what lets `tg` fall back to its own config.toml. + */ +function environmentEnv(knot: string, appview: string): Readonly> { + const env: Record = {}; + if (appview.trim().length > 0) env.TG_APPVIEW = appview.trim(); + if (knot.trim().length > 0) env.TG_KNOT = knot.trim(); + return env; +} + +export const make = Effect.gen(function* () { + const { process, settings: hostSettings } = yield* SourceControlHost.SourceControlHost; + + /** + * Read per call rather than once: a knot or appview saved in settings applies to the next + * command without restarting the server. A settings read that fails falls back to the defaults + * the schema already carries. + */ + const environmentEnv_ = Effect.fn("TangledCli.environmentEnv")(function* () { + const saved = yield* hostSettings.get.pipe( + Effect.map((value) => value.sourceControlHosts[TangledClient.definition.kind]), + Effect.orElseSucceed(() => undefined), + ); + const { knot, appview } = readSourceControlHostSettings(TangledClient.settings, saved); + return environmentEnv(knot, appview); + }); + + /** + * Runs `tg` with the environment's appview and knot, and fails on a non-zero exit. The exit is + * read here rather than by the host, because `tg` puts the reason it failed on stderr, where the + * host's own failure detail does not carry it. + */ + const invoke = Effect.fn("TangledCli.invoke")(function* (input: { + readonly cwd: string; + readonly args: ReadonlyArray; + }) { + const env = yield* environmentEnv_(); + const result = yield* process + .run({ + operation: "TangledCli.execute", + command: TANGLED_CLI, + args: input.args, + cwd: input.cwd, + env, + timeoutMs: DEFAULT_TIMEOUT_MS, + maxOutputBytes: 2_000_000, + allowNonZeroExit: true, + }) + .pipe(Effect.mapError((cause: VcsError) => fromVcsError(input.cwd, cause))); + if (result.exitCode !== 0) { + return yield* new TangledCliError({ + cwd: input.cwd, + reason: "failed", + detail: tangledErrorMessage(result) ?? `tg ${input.args[0] ?? ""} failed.`, + }); + } + return result.stdout; + }); + + const runJson: TangledCli["Service"]["runJson"] = (input) => + invoke({ cwd: input.cwd, args: [...input.args, "--json"] }).pipe( + Effect.flatMap((stdout) => { + const decoded = decodeJsonResult(input.schema)(stdout); + if (Result.isSuccess(decoded)) return Effect.succeed(decoded.success); + return Effect.fail( + new TangledCliError({ + cwd: input.cwd, + reason: "invalid-response", + detail: `tg ${input.args[0] ?? ""} returned output this build does not read.`, + }), + ); + }), + ); + + const run: TangledCli["Service"]["run"] = (input) => + invoke({ cwd: input.cwd, args: input.args }).pipe(Effect.asVoid); + + return TangledCli.of({ runJson, run }); +}); + +export const layer = Layer.effect(TangledCli, make); diff --git a/packages/source-control-tangled/src/server/TangledPullRequestProvider.test.ts b/packages/source-control-tangled/src/server/TangledPullRequestProvider.test.ts new file mode 100644 index 0000000000000..bec28be840bf4 --- /dev/null +++ b/packages/source-control-tangled/src/server/TangledPullRequestProvider.test.ts @@ -0,0 +1,98 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { HttpClient, HttpClientResponse } from "effect/http"; +import * as Host from "@t3tools/source-control-testing/TestSourceControlHost"; +import * as Cli from "./TangledCli.ts"; +import * as Provider from "./TangledPullRequestProvider.ts"; + +it.effect("reads a closed PR's activity and details through its list number and record key", () => { + const calls: Array> = []; + const older = { + rkey: "a", + title: "Still open", + state: "open", + createdAt: "2026-10-12T12:00:00Z", + sourceBranch: "old", + targetBranch: "main", + }; + const newest = { + ...older, + rkey: "b", + title: "Closed newest PR", + state: "closed", + sourceBranch: "current", + commentCount: 2, + }; + const host = Host.layer({ + process: { + run: (input) => { + calls.push(input.args); + if (input.args[1] === "list") + return Effect.succeed(Host.processOutput(JSON.stringify({ items: [newest, older] }))); + if (input.args[1] === "view" && input.args[2] === "b") + return Effect.succeed( + Host.processOutput( + JSON.stringify({ + ...newest, + body: "Actual PR description", + author: { did: "did:plc:alice", handle: "alice.example" }, + }), + ), + ); + return Effect.die("Unexpected tg command"); + }, + }, + }); + let pageReads = 0; + const client = HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response( + ++pageReads > 1 && + (request.url.includes("state%3Aall") || request.url.includes("state:all")) + ? 'Closed newest PRStill open' + : 'Still open', + ), + ), + ), + ); + return Effect.gen(function* () { + const provider = yield* Provider.make; + const reference = { + cwd: "/repo", + host: "tangled.org", + repository: "alice.example/repo", + number: 2, + }; + const failed = yield* provider.getChangeRequestActivity(reference).pipe(Effect.flip); + assert.match(failed.detail, /cannot be listed by number/u); + const activity = yield* provider.getChangeRequestActivity(reference); + assert.strictEqual(activity.author?.login, "alice.example"); + assert.strictEqual(activity.commentCount, 2); + assert.strictEqual(activity.commentsTruncated, true); + const detail = yield* provider.getChangeRequest(reference); + assert.strictEqual(detail.body, "Actual PR description"); + assert.strictEqual(detail.number, 2); + assert.strictEqual(detail.state, "closed"); + const list = yield* provider.listChangeRequests({ + ...reference, + state: "all", + involvement: "all", + viewer: "alice.example", + limit: 10, + }); + assert.deepStrictEqual( + list.items.map((pull) => [pull.number, pull.title]), + [ + [1, "Still open"], + [2, "Closed newest PR"], + ], + ); + assert.strictEqual(calls.filter((args) => args[1] === "view" && args[2] === "b").length, 2); + }).pipe( + Effect.provide(Cli.layer.pipe(Layer.provideMerge(host))), + Effect.provideService(HttpClient.HttpClient, client), + ); +}); diff --git a/packages/source-control-tangled/src/server/TangledPullRequestProvider.ts b/packages/source-control-tangled/src/server/TangledPullRequestProvider.ts new file mode 100644 index 0000000000000..024550e68ebdd --- /dev/null +++ b/packages/source-control-tangled/src/server/TangledPullRequestProvider.ts @@ -0,0 +1,377 @@ +import { + SourceControlProviderKind, + type PullRequestCapabilities, + type PullRequestState, +} from "@t3tools/contracts"; +import * as Cache from "effect/Cache"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import { HttpClient, HttpClientRequest } from "effect/http"; +import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText"; + +import { + PullRequestProviderError, + type ProviderChangeRequest, + type ProviderChangeRequestDetail, + type ProviderChangeRequestPage, + type ProviderChangeRequestSummary, + type PullRequestProviderApi, +} from "@t3tools/source-control-core/server/PullRequestProvider"; + +import { isTangledCliError, TangledAuthStatusSchema, TangledCli } from "./TangledCli.ts"; +import { + numberPulls, + offsetFromRows, + parsePullPageRows, + type NumberedPull, + TangledPullListSchema, + TangledPullViewSchema, +} from "./tangledPulls.ts"; + +const KIND = SourceControlProviderKind.make("tangled"); + +/** + * What this build offers once a Tangled pull request can be named. Reading the list, its detail, + * its summary and who wrote it all work; nothing that writes does, so every capability that would + * reach the host is off and the panels that would offer it stay hidden. + */ +const CAPABILITIES: PullRequestCapabilities = { + diff: false, + comment: false, + actions: [], + mergeMethods: [], + // Every pull request is listed at once and narrowed here, so a text filter is honoured. + search: true, + stacks: false, + reactions: false, + labels: false, + review: { inlineComment: false, reply: false, resolve: false, verdicts: [] }, + reviewers: { request: false, listCandidates: false }, + edit: { changeRequest: false, comment: false }, +}; + +/** One reader of tangled.org's numbering, per repository, for as long as it is likely to hold. */ +const OFFSET_TTL = Duration.minutes(30); +const OFFSET_CACHE_CAPACITY = 32; + +const pullState = (state: string): PullRequestState => + state === "merged" ? "merged" : state === "closed" ? "closed" : "open"; + +export const make = Effect.gen(function* () { + const cli = yield* TangledCli; + const httpClient = yield* HttpClient.HttpClient; + + const failure = (operation: string, detail: string, reason: "failed" | "not-found" = "failed") => + new PullRequestProviderError({ provider: KIND, operation, reason, detail }); + + const unsupported = (operation: string, detail: string) => () => + Effect.fail(failure(operation, detail)); + + /** Every pull request `tg` reports, ordered and placed the way tangled.org numbered them. */ + const listed = Effect.fn("TangledPullRequestProvider.listed")(function* (input: { + readonly operation: string; + readonly cwd: string; + readonly repository: string; + }) { + return yield* cli + .runJson({ + cwd: input.cwd, + args: ["pr", "list", input.repository], + schema: TangledPullListSchema, + }) + .pipe( + Effect.map((answer) => numberPulls(answer.items)), + Effect.mapError((cause) => + failure( + input.operation, + isTangledCliError(cause) ? cause.detail : "Tangled could not list pull requests.", + ), + ), + ); + }); + + /** + * The offset tangled.org's numbering carries past the records `tg pr list` returns. It is not + * something `tg` reports, so it is read once from the repository's own page and cached; a + * repository whose page cannot be read, or whose rows imply different offsets, reads as + * unknown and its pull requests are left unnumbered rather than numbered wrongly. + */ + const offsets = yield* Cache.make({ + lookup: (repository: string) => + Effect.gen(function* () { + const pulls = yield* listed({ + operation: "listChangeRequests", + cwd: ".", + repository, + }); + if (pulls.length === 0) return 0; + const html = yield* Effect.gen(function* () { + const response = yield* httpClient.execute( + HttpClientRequest.get(`https://tangled.org/${repository}/pulls?q=state%3Aall`).pipe( + HttpClientRequest.setHeader("accept", "text/html"), + ), + ); + if (response.status < 200 || response.status >= 300) return null; + return (yield* collectUint8StreamText({ + stream: response.stream, + maxBytes: 4 * 1024 * 1024, + })).text; + }).pipe( + // A page that cannot be read leaves the numbering unknown, which reads as a repository + // whose pull requests are not listed rather than as a failure to report. + Effect.orElseSucceed(() => null), + ); + return html === null ? null : offsetFromRows(parsePullPageRows(html, repository), pulls); + }), + capacity: OFFSET_CACHE_CAPACITY, + timeToLive: OFFSET_TTL, + }); + + const toChangeRequest = ( + entry: NumberedPull, + number: number, + repository: string, + ): ProviderChangeRequest => { + const pull = entry.pull; + return { + number, + title: pull.title, + url: `https://tangled.org/${repository}/pulls/${number}`, + author: + pull.author === undefined + ? null + : { login: pull.author.handle, name: null, avatarUrl: null }, + headBranch: pull.sourceBranch ?? "", + baseBranch: pull.targetBranch ?? "", + state: pullState(pull.state), + isDraft: false, + mergeability: "unknown", + additions: 0, + deletions: 0, + createdAt: pull.createdAt, + updatedAt: pull.updatedAt ?? pull.createdAt, + reviewRequestLogins: [], + labels: [], + }; + }; + + /** Every pull request in a repository, numbered. Refuses rather than inventing a number. */ + const numbered = Effect.fn("TangledPullRequestProvider.numbered")(function* (input: { + readonly operation: string; + readonly cwd: string; + readonly repository: string; + }) { + const pulls = yield* listed(input); + if (pulls.length === 0) return []; + const offset = yield* offsets.lookup(input.repository); + if (offset === null) { + // A repository may gain enough rows to resolve its numbering on the next attempt. + yield* Cache.invalidate(offsets, input.repository); + return yield* failure( + input.operation, + `Tangled did not report how ${input.repository} numbers its pull requests, so they cannot be listed by number. Open the repository on tangled.org once, or read them with \`tg pr list\`.`, + ); + } + return pulls.map((entry) => ({ + entry, + changeRequest: toChangeRequest(entry, entry.index + offset, input.repository), + })); + }); + + const byNumber = Effect.fn("TangledPullRequestProvider.byNumber")(function* (input: { + readonly operation: string; + readonly cwd: string; + readonly number: number; + readonly repository: string; + }) { + const items = yield* numbered(input); + const found = items.find((item) => item.changeRequest.number === input.number); + if (found === undefined) { + return yield* failure( + input.operation, + `Pull request #${input.number} was not found in ${input.repository}.`, + "not-found", + ); + } + return found; + }); + + const readByNumber = Effect.fn("TangledPullRequestProvider.readByNumber")(function* (input: { + readonly operation: string; + readonly cwd: string; + readonly number: number; + readonly repository: string; + }) { + const { entry, changeRequest } = yield* byNumber(input); + const detail = yield* cli + .runJson({ + cwd: input.cwd, + args: ["pr", "view", entry.pull.rkey, "--repo", input.repository], + schema: TangledPullViewSchema, + }) + .pipe(Effect.mapError((cause) => failure(input.operation, cause.detail))); + return { + pull: toChangeRequest( + { ...entry, pull: { ...entry.pull, ...detail } }, + changeRequest.number, + input.repository, + ), + body: detail.body ?? "", + commentCount: entry.pull.commentCount ?? 0, + }; + }); + + const provider: PullRequestProviderApi = { + kind: KIND, + capabilities: CAPABILITIES, + + getViewer: (input) => + cli + .runJson({ + cwd: input.cwd, + args: ["auth", "status"], + schema: TangledAuthStatusSchema, + }) + .pipe( + Effect.map((status) => status.handle ?? status.did ?? ""), + Effect.mapError((cause) => failure("getViewer", cause.detail)), + ), + + listChangeRequests: (input) => + numbered({ + operation: "listChangeRequests", + cwd: input.cwd, + repository: input.repository, + }).pipe( + Effect.map((items) => { + const matches = items + .map((item) => item.changeRequest) + .filter((item) => { + if (input.state !== "all" && item.state !== input.state) return false; + const authored = item.author !== null && item.author.login === input.viewer; + if (input.involvement === "authored") return authored; + // Tangled reports no review requests, so a reviewer's pull request is one it does not + // name; `reviewing` therefore keeps every row rather than dropping all of them. + return true; + }); + const searched = input.query + ? matches.filter((item) => + item.title.toLowerCase().includes(input.query!.toLowerCase()), + ) + : matches; + const page = searched.slice(0, input.limit); + return { + items: page, + truncated: searched.length > page.length, + continues: false, + } satisfies ProviderChangeRequestPage; + }), + ), + + getChangeRequest: (input) => + readByNumber({ + operation: "getChangeRequest", + cwd: input.cwd, + number: input.number, + repository: input.repository, + }).pipe( + Effect.map(({ pull, body }): ProviderChangeRequestDetail => ({ + ...pull, + body, + changedFiles: 0, + mergedAt: pull.mergedAt ?? null, + closedAt: pull.closedAt ?? null, + reviewers: [], + checks: [], + mergeCapabilities: { merge: false, squash: false, rebase: false }, + viewerPermissions: { + actions: [], + comment: false, + resolve: false, + verdicts: [], + requestReviewers: false, + }, + })), + ), + + getChangeRequestSummary: (input) => + byNumber({ + operation: "getChangeRequestSummary", + cwd: input.cwd, + number: input.number, + repository: input.repository, + }).pipe( + Effect.map(({ changeRequest: pull }): ProviderChangeRequestSummary => ({ + number: pull.number, + title: pull.title, + url: pull.url, + headBranch: pull.headBranch, + baseBranch: pull.baseBranch, + state: pull.state, + closedAt: pull.closedAt ?? null, + mergedAt: pull.mergedAt ?? null, + updatedAt: pull.updatedAt, + author: pull.author, + })), + ), + + getChangeRequestActivity: (input) => + readByNumber({ + operation: "getChangeRequestActivity", + cwd: input.cwd, + number: input.number, + repository: input.repository, + }).pipe( + Effect.map(({ pull, commentCount }) => ({ + author: pull.author, + reviewers: [], + comments: [], + commentCount, + // tg reports the count but exposes no comment bodies in pr view. + commentsTruncated: commentCount > 0, + reviewThreads: [], + commits: [], + })), + ), + + getViewerPermissions: (input) => + byNumber({ + operation: "getViewerPermissions", + cwd: input.cwd, + number: input.number, + repository: input.repository, + }).pipe( + Effect.map(() => ({ + actions: [], + comment: false, + resolve: false, + verdicts: [], + requestReviewers: false, + })), + ), + + getDiff: unsupported("getDiff", "Tangled pull request diffs are not read by this build."), + runAction: unsupported("runAction", "Tangled pull requests are not acted on by this build."), + comment: unsupported("comment", "Tangled pull requests are not commented on by this build."), + submitReview: unsupported( + "submitReview", + "Tangled pull request reviews are not submitted by this build.", + ), + listReviewerCandidates: unsupported( + "listReviewerCandidates", + "Tangled reviewer requests are not supported.", + ), + setReviewerRequest: unsupported( + "setReviewerRequest", + "Tangled reviewer requests are not supported.", + ), + replyToThread: unsupported("replyToThread", "Tangled review threads are not supported."), + setReaction: unsupported("setReaction", "Tangled reactions are not supported."), + setThreadResolution: unsupported( + "setThreadResolution", + "Tangled review threads are not supported.", + ), + }; + + return provider; +}); diff --git a/packages/source-control-tangled/src/server/TangledSourceControlProvider.test.ts b/packages/source-control-tangled/src/server/TangledSourceControlProvider.test.ts new file mode 100644 index 0000000000000..1581d0e135736 --- /dev/null +++ b/packages/source-control-tangled/src/server/TangledSourceControlProvider.test.ts @@ -0,0 +1,762 @@ +import { assert, describe, it } from "@effect/vitest"; +import { DEFAULT_SERVER_SETTINGS, GitCommandError, VcsProcessSpawnError } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { ChildProcessSpawner } from "effect/process"; +import { HttpClient, HttpClientResponse } from "effect/http"; +import type * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; +import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; + +import * as TangledClient from "../client/definition.ts"; +import * as TangledCli from "./TangledCli.ts"; +import * as TangledSourceControlProvider from "./TangledSourceControlProvider.ts"; + +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + +const context = (remoteUrl: string) => + ({ + provider: { kind: TangledSourceControlProvider.KIND, name: "Tangled", baseUrl: "tangled.org" }, + remoteName: "origin", + remoteUrl, + }) as const; + +const REMOTE = context("git@tangled.org:alice.example/repo"); +const unusedHttpClient = HttpClient.make(() => Effect.die("Unexpected HTTP request")); + +interface Route { + readonly stdout: string; + readonly stderr?: string; + readonly exitCode?: number; +} + +/** + * A `tg` stand-in that answers from `routes`, keyed by the subcommand and its arguments. The + * global flags lead the invocation, so they are dropped before the key is read. Anything + * unscripted dies, so an unexpected command is visible in the failure. + */ +function fakeTg(routes: Readonly>) { + const calls: Array> = []; + const run: SourceControlHost.SourceControlHost["Service"]["process"]["run"] = (input) => { + const seen = withoutJson(input.args); + calls.push(seen); + const route = routes[seen.join(" ")]; + if (route === undefined) return Effect.die(`Unexpected tg ${seen.join(" ")}`); + return Effect.succeed( + TestSourceControlHost.processOutput(route.stdout, { + stderr: route.stderr ?? "", + exitCode: ChildProcessSpawner.ExitCode(route.exitCode ?? 0), + }), + ); + }; + return { calls, layer: TestSourceControlHost.layer({ process: { run } }) }; +} + +function withoutJson(args: ReadonlyArray): ReadonlyArray { + return args.filter((arg) => arg !== "--json"); +} + +/** The layers every test needs: the provider's own `tg`, over the fake's process host. */ +const serve = + (tg: ReturnType, client = unusedHttpClient) => + (effect: Effect.Effect) => + effect.pipe( + Effect.provide(TangledCli.layer.pipe(Layer.provideMerge(tg.layer))), + Effect.provideService(HttpClient.HttpClient, client), + ); + +describe("TangledSourceControlProvider.parseAuth", () => { + it("reads the signed-in account from tg auth status", () => { + const signedIn = TangledSourceControlProvider.parseAuth({ + stdout: encodeJson({ authenticated: true, handle: "alice.example", did: "did:plc:abc" }), + stderr: "", + exitCode: ChildProcessSpawner.ExitCode(0), + }); + assert.strictEqual(signedIn.status, "authenticated"); + assert.deepStrictEqual(signedIn.account, Option.some("alice.example")); + assert.deepStrictEqual(signedIn.host, Option.some("tangled.org")); + }); + + it("falls back to the DID when no handle is reported", () => { + const byDid = TangledSourceControlProvider.parseAuth({ + stdout: encodeJson({ authenticated: true, did: "did:plc:abc" }), + stderr: "", + exitCode: ChildProcessSpawner.ExitCode(0), + }); + assert.deepStrictEqual(byDid.account, Option.some("did:plc:abc")); + }); + + it("reads a signed-out CLI as unauthenticated, and unreadable output as unknown", () => { + const signedOut = TangledSourceControlProvider.parseAuth({ + stdout: encodeJson({ authenticated: false }), + stderr: "", + exitCode: ChildProcessSpawner.ExitCode(0), + }); + assert.strictEqual(signedOut.status, "unauthenticated"); + + const unreadable = TangledSourceControlProvider.parseAuth({ + stdout: "", + stderr: 'Error: not logged in; provide a handle or run "tg auth login"', + exitCode: ChildProcessSpawner.ExitCode(1), + }); + assert.strictEqual(unreadable.status, "unknown"); + assert.deepStrictEqual( + unreadable.detail, + Option.some('Error: not logged in; provide a handle or run "tg auth login"'), + ); + }); +}); + +describe("TangledSourceControlProvider.discovery", () => { + it("probes the tg binary on PATH the way other CLI hosts do", () => { + const { discovery } = TangledSourceControlProvider; + assert.strictEqual(discovery.type, "cli"); + assert.strictEqual(discovery.executable, "tg"); + assert.deepStrictEqual(discovery.versionArgs, ["--version"]); + assert.deepStrictEqual(discovery.authArgs, ["auth", "status", "--json"]); + assert.strictEqual(discovery.label, "Tangled"); + }); +}); + +describe("TangledSourceControlProvider.defaultBranchFromSymref", () => { + it("reads the branch git ls-remote --symref points HEAD at", () => { + assert.strictEqual( + TangledSourceControlProvider.defaultBranchFromSymref( + "ref: refs/heads/main\tHEAD\nf2460055a6527526508b0fa1bed674b917f447f7\tHEAD\n", + ), + "main", + ); + }); + + it("is null when the remote names no HEAD symref", () => { + assert.strictEqual( + TangledSourceControlProvider.defaultBranchFromSymref("f2460055\trefs/heads/main\n"), + null, + ); + }); +}); + +describe("TangledSourceControlProvider.createsUnderOwner", () => { + it("accepts the owner tg created the repository under", () => { + assert.strictEqual( + TangledSourceControlProvider.createsUnderOwner({ + requested: "alice.example", + created: "alice.example", + }), + true, + ); + }); + + it("refuses another handle, which tg would have created under the signed-in account", () => { + assert.strictEqual( + TangledSourceControlProvider.createsUnderOwner({ + requested: "someone-else", + created: "alice.example", + }), + false, + ); + }); + + it("does not read a DID and a handle for one account as a mismatch", () => { + assert.strictEqual( + TangledSourceControlProvider.createsUnderOwner({ + requested: "did:plc:abc", + created: "alice.example", + }), + true, + ); + assert.strictEqual( + TangledSourceControlProvider.createsUnderOwner({ + requested: "alice.example", + created: "did:plc:abc", + }), + true, + ); + }); + + it("accepts an answer that names no owner at all", () => { + assert.strictEqual( + TangledSourceControlProvider.createsUnderOwner({ + requested: "alice.example", + created: undefined, + }), + true, + ); + }); +}); + +describe("createChangeRequest", () => { + const pullList = (items: ReadonlyArray>) => ({ + stdout: encodeJson({ items }), + }); + + const create = () => + Effect.gen(function* () { + const provider = yield* TangledSourceControlProvider.make; + return yield* provider.createChangeRequest({ + cwd: "/repo", + context: REMOTE, + baseRefName: "main", + headSelector: "feature", + title: "Add a thing", + bodyFile: "/tmp/body.md", + }); + }); + + it.effect("creates a pull request once no open one holds the branch", () => { + const tg = fakeTg({ + "pr list alice.example/repo --state open --limit 100": pullList([]), + "pr create --title Add a thing --body-file /tmp/body.md --base main --head feature --repo alice.example/repo": + { stdout: "" }, + }); + return create().pipe( + Effect.map(() => { + assert.deepStrictEqual(tg.calls, [ + ["pr", "list", "alice.example/repo", "--state", "open", "--limit", "100"], + [ + "pr", + "create", + "--title", + "Add a thing", + "--body-file", + "/tmp/body.md", + "--base", + "main", + "--head", + "feature", + "--repo", + "alice.example/repo", + ], + ]); + }), + serve(tg), + ); + }); + + it.effect("refuses a second pull request when one already holds the branch", () => { + const tg = fakeTg({ + "pr list alice.example/repo --state open --limit 100": pullList([ + { + rkey: "3mkaewigwhi22", + title: "Already open", + state: "open", + sourceBranch: "feature", + targetBranch: "main", + }, + ]), + "pr create --title Add a thing --body-file /tmp/body.md --base main --head feature --repo alice.example/repo": + { stdout: "" }, + }); + return create().pipe( + Effect.flip, + Effect.map((error) => { + assert.match(error.detail, /already has an open pull request/u); + assert.match(error.detail, /3mkaewigwhi22/u); + assert.deepStrictEqual(tg.calls, [ + ["pr", "list", "alice.example/repo", "--state", "open", "--limit", "100"], + ]); + }), + serve(tg), + ); + }); + + it.effect("creates when the open pull request targets a different branch", () => { + const tg = fakeTg({ + "pr list alice.example/repo --state open --limit 100": pullList([ + { + rkey: "3mkaewigwhi22", + title: "Into staging", + state: "open", + sourceBranch: "feature", + targetBranch: "staging", + }, + ]), + "pr create --title Add a thing --body-file /tmp/body.md --base main --head feature --repo alice.example/repo": + { stdout: "" }, + }); + return create().pipe( + Effect.map(() => { + assert.equal(tg.calls.length, 2); + assert.strictEqual(tg.calls[1]?.[0], "pr"); + assert.strictEqual(tg.calls[1]?.[1], "create"); + }), + serve(tg), + ); + }); + + it.effect("creates from a DID remote without passing --repo, which takes no DID", () => { + const tg = fakeTg({ + "pr list did:plc:4ygmvhvc6kw3w7j4fmd4eiqp --state open --limit 100": pullList([]), + "pr create --title Add a thing --body-file /tmp/body.md --base main --head feature": { + stdout: "", + }, + }); + return Effect.gen(function* () { + const provider = yield* TangledSourceControlProvider.make; + yield* provider.createChangeRequest({ + cwd: "/repo", + context: context("git@tangled.org:did:plc:4ygmvhvc6kw3w7j4fmd4eiqp"), + baseRefName: "main", + headSelector: "feature", + title: "Add a thing", + bodyFile: "/tmp/body.md", + }); + assert.ok(!tg.calls[1]?.includes("--repo")); + }).pipe(serve(tg)); + }); + + it.effect("reports what tg said when the pull request itself fails", () => { + const tg = fakeTg({ + "pr list alice.example/repo --state open --limit 100": pullList([]), + "pr create --title Add a thing --body-file /tmp/body.md --base main --head feature --repo alice.example/repo": + { + stdout: "", + stderr: "Error: branch feature has no commits ahead of main", + exitCode: 1, + }, + }); + return create().pipe( + Effect.flip, + Effect.map((error) => { + assert.match(error.detail, /no commits ahead/u); + assert.strictEqual(error.command, "tg"); + }), + serve(tg), + ); + }); +}); + +describe("TangledSourceControlProvider.make", () => { + it.effect("returns clone URLs for the repository the remote names", () => { + const tg = fakeTg({ + "repo view alice.example/repo": { + stdout: encodeJson({ name: "repo", author: "alice.example", knot: "knot1.tangled.sh" }), + }, + }); + return Effect.gen(function* () { + const provider = yield* TangledSourceControlProvider.make; + const urls = yield* provider.getRepositoryCloneUrls({ + cwd: "/repo", + context: REMOTE, + repository: "alice.example/repo", + }); + assert.deepStrictEqual(urls, { + nameWithOwner: "alice.example/repo", + url: "https://tangled.org/alice.example/repo", + sshUrl: "git@tangled.org:alice.example/repo", + }); + assert.deepStrictEqual(tg.calls, [["repo", "view", "alice.example/repo"]]); + }).pipe(serve(tg)); + }); + + it.effect("resolves the repository from the name alone, with no checkout at all", () => { + // The clone picker types `handle/repo` and passes no context, so no remote is present here. + const tg = fakeTg({ + "repo view alice.example/repo": { + stdout: encodeJson({ name: "repo", author: "alice.example", knot: "knot1.tangled.sh" }), + }, + }); + return Effect.gen(function* () { + const provider = yield* TangledSourceControlProvider.make; + const urls = yield* provider.getRepositoryCloneUrls({ + cwd: "/workspace", + repository: "alice.example/repo", + }); + assert.deepStrictEqual(urls, { + nameWithOwner: "alice.example/repo", + url: "https://tangled.org/alice.example/repo", + sshUrl: "git@tangled.org:alice.example/repo", + }); + assert.deepStrictEqual(tg.calls, [["repo", "view", "alice.example/repo"]]); + }).pipe(serve(tg)); + }); + + it.effect("reads a pasted repository URL as the address it names", () => { + const tg = fakeTg({ + "repo view alice.example/repo": { + stdout: encodeJson({ name: "repo", author: "alice.example", knot: "knot1.tangled.sh" }), + }, + }); + return Effect.gen(function* () { + const provider = yield* TangledSourceControlProvider.make; + for (const repository of [ + "https://tangled.org/alice.example/repo", + "git@tangled.org:alice.example/repo.git", + " alice.example/repo/ ", + ]) { + const urls = yield* provider.getRepositoryCloneUrls({ + cwd: "/workspace", + repository, + }); + assert.strictEqual(urls.nameWithOwner, "alice.example/repo"); + } + }).pipe(serve(tg)); + }); + + it.effect("reports what tg said when the repository does not resolve", () => { + const tg = fakeTg({ + "repo view alice.example/missing": { + stdout: "", + stderr: 'Error: repo "missing" not found for handle "alice.example"', + exitCode: 1, + }, + }); + return Effect.gen(function* () { + const provider = yield* TangledSourceControlProvider.make; + const error = yield* provider + .getRepositoryCloneUrls({ + cwd: "/repo", + context: context("git@tangled.org:alice.example/missing"), + repository: "alice.example/missing", + }) + .pipe(Effect.flip); + assert.strictEqual(error.operation, "getRepositoryCloneUrls"); + assert.strictEqual(error.command, "tg"); + assert.match(error.detail, /not found for handle/u); + }).pipe(serve(tg)); + }); + + it.effect("accepts a repository DID remote without asking tg to resolve it", () => { + // `tg repo view` refuses a DID, and `tg` writes a DID into the remote whenever the repository + // record carries one — which is what most real Tangled remotes look like. + const tg = fakeTg({}); + return Effect.gen(function* () { + const provider = yield* TangledSourceControlProvider.make; + const urls = yield* provider.getRepositoryCloneUrls({ + cwd: "/repo", + context: context("git@tangled.org:did:plc:4ygmvhvc6kw3w7j4fmd4eiqp"), + repository: "did:plc:4ygmvhvc6kw3w7j4fmd4eiqp", + }); + assert.deepStrictEqual(urls, { + nameWithOwner: "did:plc:4ygmvhvc6kw3w7j4fmd4eiqp", + url: "https://tangled.org/did:plc:4ygmvhvc6kw3w7j4fmd4eiqp", + sshUrl: "git@tangled.org:did:plc:4ygmvhvc6kw3w7j4fmd4eiqp", + }); + assert.deepStrictEqual(tg.calls, []); + }).pipe(serve(tg)); + }); + + it.effect("refuses a remote that names neither handle/repo nor a repository DID", () => { + const tg = fakeTg({}); + return Effect.gen(function* () { + const provider = yield* TangledSourceControlProvider.make; + const error = yield* provider + .getRepositoryCloneUrls({ + cwd: "/repo", + context: context("git@tangled.org:group/nested/repo"), + repository: "group/nested/repo", + }) + .pipe(Effect.flip); + assert.match(error.detail, /handle\/repo/u); + }).pipe(serve(tg)); + }); + + it.effect("creates a repository and returns its clone URLs", () => { + const tg = fakeTg({ + "repo create repo": { + stdout: encodeJson({ name: "repo", author: "alice.example", knot: "knot1.tangled.sh" }), + }, + }); + return Effect.gen(function* () { + const provider = yield* TangledSourceControlProvider.make; + const urls = yield* provider.createRepository({ + cwd: "/repo", + repository: "alice.example/repo", + visibility: "public", + }); + assert.deepStrictEqual(urls, { + nameWithOwner: "alice.example/repo", + url: "https://tangled.org/alice.example/repo", + sshUrl: "git@tangled.org:alice.example/repo", + }); + assert.deepStrictEqual(tg.calls, [["repo", "create", "repo"]]); + }).pipe(serve(tg)); + }); + + it.effect("says so when tg created the repository under a different account", () => { + const tg = fakeTg({ + "repo create repo": { + stdout: encodeJson({ name: "repo", author: "alice.example", knot: "knot1.tangled.sh" }), + }, + }); + return Effect.gen(function* () { + const provider = yield* TangledSourceControlProvider.make; + const error = yield* provider + .createRepository({ cwd: "/repo", repository: "someone-else/repo", visibility: "public" }) + .pipe(Effect.flip); + assert.match(error.detail, /not someone-else/u); + }).pipe(serve(tg)); + }); + + it.effect("reads the default branch from git's own symref", () => { + const gitCalls: Array> = []; + const layer = TestSourceControlHost.layer({ + process: { run: () => Effect.die("tg must not run to read a default branch.") }, + git: { + execute: (input) => { + gitCalls.push(input.args); + return Effect.succeed( + TestSourceControlHost.processOutput("ref: refs/heads/trunk\tHEAD\nabc123\tHEAD\n"), + ); + }, + }, + }); + return Effect.gen(function* () { + const provider = yield* TangledSourceControlProvider.make; + const branch = yield* provider.getDefaultBranch({ cwd: "/repo", context: REMOTE }); + assert.strictEqual(branch, "trunk"); + assert.deepStrictEqual(gitCalls, [["ls-remote", "--symref", "origin", "HEAD"]]); + }).pipe( + Effect.provide(TangledCli.layer.pipe(Layer.provideMerge(layer))), + Effect.provideService(HttpClient.HttpClient, unusedHttpClient), + ); + }); + + it.effect("reports a git failure to list the remote's branches", () => { + const layer = TestSourceControlHost.layer({ + git: { + execute: () => + Effect.fail( + new GitCommandError({ + operation: "execute", + command: "git", + cwd: "/repo", + detail: "remote not found.", + }), + ), + }, + }); + return Effect.gen(function* () { + const provider = yield* TangledSourceControlProvider.make; + const error = yield* provider + .getDefaultBranch({ cwd: "/repo", context: REMOTE }) + .pipe(Effect.flip); + assert.strictEqual(error.operation, "getDefaultBranch"); + assert.strictEqual(error.detail, "Tangled operation failed."); + }).pipe( + Effect.provide(TangledCli.layer.pipe(Layer.provideMerge(layer))), + Effect.provideService(HttpClient.HttpClient, unusedHttpClient), + ); + }); + + it.effect("finds only the current branch's pull requests before applying the limit", () => { + const tg = fakeTg({ + "pr list alice.example/repo": { + stdout: encodeJson({ + items: [ + { + rkey: "a", + title: "Old PR", + state: "closed", + sourceBranch: "test/pull-request", + targetBranch: "main", + createdAt: "2026-10-12T12:00:00Z", + }, + { + rkey: "b", + title: "Current PR", + state: "open", + sourceBranch: "test/another-branch", + targetBranch: "main", + createdAt: "2026-10-12T13:00:00Z", + }, + ], + }), + }, + }); + const client = HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response('Current PR'), + ), + ), + ); + return Effect.gen(function* () { + const provider = yield* TangledSourceControlProvider.make; + const matches = yield* provider.listChangeRequests({ + cwd: "/repo", + context: REMOTE, + headSelector: "alice.example:test/another-branch", + state: "all", + limit: 1, + }); + assert.deepStrictEqual( + matches.map((pull) => [pull.number, pull.headRefName]), + [[2, "test/another-branch"]], + ); + assert.deepStrictEqual( + yield* provider.listChangeRequests({ + cwd: "/repo", + context: REMOTE, + headSelector: "no-pr", + state: "all", + }), + [], + ); + assert.deepStrictEqual( + yield* provider.listChangeRequests({ + cwd: "/repo", + context: REMOTE, + headSelector: "test/pull-request", + state: "open", + }), + [], + ); + }).pipe(serve(tg, client)); + }); + + it.effect("still refuses to read or check out a pull request it cannot number", () => { + const tg = fakeTg({}); + return Effect.gen(function* () { + const provider = yield* TangledSourceControlProvider.make; + for (const call of [ + provider.getChangeRequest({ cwd: "/repo", context: REMOTE, reference: "19" }), + provider.checkoutChangeRequest({ cwd: "/repo", context: REMOTE, reference: "19" }), + ]) { + const error = yield* call.pipe(Effect.flip); + assert.match(error.detail, /record key/u); + } + assert.deepStrictEqual(tg.calls, []); + }).pipe(serve(tg)); + }); +}); + +describe("TangledCli environment flags", () => { + const signedIn = encodeJson({ authenticated: true, handle: "alice.example" }); + + it.effect("defaults to the appview and knot the settings schema carries", () => { + const runs: Array<{ readonly args: ReadonlyArray; readonly env: unknown }> = []; + const layer = TestSourceControlHost.layer({ + process: { + run: (input) => { + runs.push({ args: input.args, env: input.env }); + return Effect.succeed(TestSourceControlHost.processOutput(signedIn)); + }, + }, + }); + return Effect.gen(function* () { + const cli = yield* TangledCli.TangledCli; + const status = yield* cli.runJson({ + cwd: "/repo", + args: ["auth", "status"], + schema: TangledCli.TangledAuthStatusSchema, + }); + assert.deepStrictEqual(status, { authenticated: true, handle: "alice.example" }); + assert.deepStrictEqual(runs[0]?.args, ["auth", "status", "--json"]); + // `tg` has no global `--knot`, so both reach it through the environment it documents. + assert.deepStrictEqual(runs[0]?.env, { + TG_APPVIEW: TangledClient.DEFAULT_APPVIEW, + TG_KNOT: TangledClient.DEFAULT_KNOT, + }); + }).pipe(Effect.provide(TangledCli.layer.pipe(Layer.provideMerge(layer)))); + }); + + it.effect("lets a saved knot and appview override the defaults", () => { + const runs: Array<{ readonly args: ReadonlyArray; readonly env: unknown }> = []; + const layer = TestSourceControlHost.layer({ + settings: { + ...DEFAULT_SERVER_SETTINGS, + sourceControlHosts: { + ...DEFAULT_SERVER_SETTINGS.sourceControlHosts, + tangled: { knot: "knot.example.com", appview: "https://appview.example.com" }, + }, + }, + process: { + run: (input) => { + runs.push({ args: input.args, env: input.env }); + return Effect.succeed(TestSourceControlHost.processOutput(signedIn)); + }, + }, + }); + return Effect.gen(function* () { + const cli = yield* TangledCli.TangledCli; + yield* cli.runJson({ + cwd: "/repo", + args: ["auth", "status"], + schema: TangledCli.TangledAuthStatusSchema, + }); + assert.deepStrictEqual(runs[0]?.env, { + TG_APPVIEW: "https://appview.example.com", + TG_KNOT: "knot.example.com", + }); + }).pipe(Effect.provide(TangledCli.layer.pipe(Layer.provideMerge(layer)))); + }); + + it.effect("leaves a cleared field unset so tg falls back to its own config", () => { + const runs: Array<{ readonly env: unknown }> = []; + const layer = TestSourceControlHost.layer({ + settings: { + ...DEFAULT_SERVER_SETTINGS, + sourceControlHosts: { + ...DEFAULT_SERVER_SETTINGS.sourceControlHosts, + tangled: { knot: " ", appview: "" }, + }, + }, + process: { + run: (input) => { + runs.push({ env: input.env }); + return Effect.succeed(TestSourceControlHost.processOutput(signedIn)); + }, + }, + }); + return Effect.gen(function* () { + const cli = yield* TangledCli.TangledCli; + yield* cli.runJson({ + cwd: "/repo", + args: ["auth", "status"], + schema: TangledCli.TangledAuthStatusSchema, + }); + assert.deepStrictEqual(runs[0]?.env, {}); + }).pipe(Effect.provide(TangledCli.layer.pipe(Layer.provideMerge(layer)))); + }); + + it.effect("decodes a signed-out CLI as authenticated:false", () => { + const layer = TestSourceControlHost.layer({ + process: { + run: () => + Effect.succeed(TestSourceControlHost.processOutput(encodeJson({ authenticated: false }))), + }, + }); + return Effect.gen(function* () { + const cli = yield* TangledCli.TangledCli; + assert.deepStrictEqual( + yield* cli.runJson({ + cwd: "/repo", + args: ["auth", "status"], + schema: TangledCli.TangledAuthStatusSchema, + }), + { authenticated: false }, + ); + }).pipe(Effect.provide(TangledCli.layer.pipe(Layer.provideMerge(layer)))); + }); + + it.effect("fails with a missing-tool error when tg is not on PATH", () => { + const layer = TestSourceControlHost.layer({ + process: { + run: () => + Effect.fail( + new VcsProcessSpawnError({ + operation: "TangledCli.execute", + command: "tg", + cwd: "/repo", + cause: new Error("spawn tg ENOENT"), + }), + ), + }, + }); + return Effect.gen(function* () { + const cli = yield* TangledCli.TangledCli; + const error = yield* cli + .runJson({ + cwd: "/repo", + args: ["repo", "view", "alice.example/repo"], + schema: TangledCli.TangledRepositorySchema, + }) + .pipe(Effect.flip); + assert.strictEqual(error.reason, "missing-cli"); + assert.match(error.detail, /not found on the server PATH/u); + }).pipe(Effect.provide(TangledCli.layer.pipe(Layer.provideMerge(layer)))); + }); +}); diff --git a/packages/source-control-tangled/src/server/TangledSourceControlProvider.ts b/packages/source-control-tangled/src/server/TangledSourceControlProvider.ts new file mode 100644 index 0000000000000..b3846a089a815 --- /dev/null +++ b/packages/source-control-tangled/src/server/TangledSourceControlProvider.ts @@ -0,0 +1,396 @@ +import { + SourceControlProviderError, + SourceControlProviderKind, + type SourceControlRepositoryCloneUrls, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as DateTime from "effect/DateTime"; +import * as Schema from "effect/Schema"; +import { decodeJsonResult } from "@t3tools/shared/schemaJson"; +import { + firstSafeAuthLine, + providerAuth, + type SourceControlAuthProbeInput, + type SourceControlCliDiscoverySpec, +} from "@t3tools/source-control-core/server/discovery"; +import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; +import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; + +import * as TangledClient from "../client/definition.ts"; +import * as TangledPullRequestProvider from "./TangledPullRequestProvider.ts"; +import { + isTangledCliError, + TangledAuthStatusSchema, + TangledCli, + TangledRepositorySchema, + TANGLED_CLI, +} from "./TangledCli.ts"; + +export const KIND = SourceControlProviderKind.make("tangled"); + +/** tangled.org serves the repository pages; `tangled.sh` and each knot serve the same trees. */ +export function isTangledHostname(hostname: string): boolean { + const host = hostname.toLowerCase(); + return ( + host === "tangled.org" || + host === "tangled.sh" || + host.endsWith(".tangled.org") || + host.endsWith(".tangled.sh") + ); +} + +/** `tg auth status --json` names the account and the DID when one is signed in. */ +export function parseAuth(input: SourceControlAuthProbeInput) { + const decoded = decodeJsonResult(TangledAuthStatusSchema)(input.stdout); + if (decoded._tag === "Success" && decoded.success.authenticated) { + return providerAuth({ + status: "authenticated", + account: decoded.success.handle ?? decoded.success.did, + host: "tangled.org", + }); + } + if (decoded._tag === "Success") { + return providerAuth({ + status: "unauthenticated", + host: "tangled.org", + detail: "Run `tg auth login` to sign the Tangled CLI in.", + }); + } + return providerAuth({ + status: "unknown", + host: "tangled.org", + detail: + firstSafeAuthLine(`${input.stderr}\n${input.stdout}`) ?? + "`tg auth status` could not be read.", + }); +} + +export const discovery = { + type: "cli", + kind: KIND, + label: "Tangled", + executable: TANGLED_CLI, + versionArgs: ["--version"], + authArgs: ["auth", "status", "--json"], + parseAuth, + installHint: + "Install the Tangled CLI (`tg`) from https://tangled.org/aly.codes/tg and run `tg auth login`.", +} satisfies SourceControlCliDiscoverySpec; + +/** Clone URLs, which `tg` never returns and every remote form is derivable from. */ +function cloneUrls(repository: string): SourceControlRepositoryCloneUrls { + return { + nameWithOwner: repository, + url: TangledClient.repositoryCloneUrl(repository), + sshUrl: TangledClient.repositorySshUrl(repository), + }; +} + +/** + * A repository address below the host, which Tangled writes in two forms: `handle/repo`, and the + * repository's own DID. `tg` prefers the DID whenever the repository record carries one, so a DID + * is the shape most real remotes have — and both forms are served by tangled.org and accepted by + * git, which is what lets every URL here be derived from the address instead of looked up. + */ +export function isTangledRepositoryDid(address: string): boolean { + return /^did:[a-z0-9]+:[a-z0-9]+$/iu.test(address.trim()); +} + +/** + * The path below a remote's host. The shared reader needs at least one `/` between segments, + * which a repository DID does not have, so a single-segment remote is read here instead. + */ +export function repositoryPathFromTangledRemote(url: string): string | null { + const shared = SourceControlProvider.repositoryPathFromRemoteUrl(url); + if (shared !== null) return shared; + const single = + /^(?:[^@/\s]+@[^:/\s]+:|(?:ssh|https?|git):\/\/[^/]+\/)([^/\s]+?)(?:\.git)?\/?$/iu.exec( + url.trim(), + ); + return single?.[1] ?? null; +} + +/** Reads `ref: refs/heads/\tHEAD` out of `git ls-remote --symref`. */ +export function defaultBranchFromSymref(stdout: string): string | null { + const match = /^ref:\s+refs\/heads\/(\S+)\s+HEAD$/mu.exec(stdout); + return match?.[1] ?? null; +} + +/** + * Tangled pull requests are addressed two ways that do not meet: `tg` takes an AT Protocol record + * key (`3mkaewigwhi22`), while tangled.org's pages are numbered from a per-knot database + * sequence (`/pulls/19`). `ChangeRequest.number` is the number, and no public endpoint maps one + * to the other, so this build cannot name a pull request without inventing an id the user would + * then see as a wrong link. These fail rather than answer with a number that does not exist. + */ +function unsupported(operation: string, cwd: string) { + return Effect.fail( + new SourceControlProviderError({ + provider: KIND, + operation, + cwd, + detail: + "This build cannot read Tangled pull requests: the tg CLI addresses them by record key, not by the number their pages show.", + }), + ); +} + +/** + * `tg pr list --json`. Its items carry an AT Protocol record key, never the number tangled.org + * gives the same pull request, which is why this build cannot turn one into a `ChangeRequest`. + * `sourceBranch` is absent on some answers, so it is not required. + */ +export const TangledPullListSchema = Schema.Struct({ + items: Schema.Array( + Schema.Struct({ + rkey: Schema.String, + uri: Schema.optional(Schema.String), + title: Schema.String, + state: Schema.String, + sourceBranch: Schema.optional(Schema.String), + targetBranch: Schema.optional(Schema.String), + }), + ), +}); + +/** + * Whether `tg repo create` honoured the owner asked for. `tg` has no way to create a repository + * under another account, so a name like `someone-else/repo` is created under the signed-in one. + * Comparing only plain handles keeps a DID and a handle for the same account from reading as a + * mismatch. + */ +export function createsUnderOwner(input: { + readonly requested: string; + readonly created: string | undefined; +}): boolean { + const requested = input.requested.trim(); + const created = input.created?.trim(); + if (requested.length === 0 || created === undefined || created.length === 0) return true; + return requested.startsWith("did:") || created.startsWith("did:") || requested === created; +} + +/** + * The address a caller typed or pasted: `handle/repo`, a repository DID, or a URL naming one, + * since the clone picker accepts whatever is pasted into it. + */ +export function repositoryAddress(repository: string): string | null { + const trimmed = repository + .trim() + .replace(/\.git$/iu, "") + .replace(/\/+$/u, ""); + if (trimmed.length === 0) return null; + const isRemote = + /^[a-z][a-z0-9+.-]*:\/\//iu.test(trimmed) || /^[^@/\s]+@[^:/\s]+:/u.test(trimmed); + return isRemote ? repositoryPathFromTangledRemote(trimmed) : trimmed; +} + +export const make = Effect.gen(function* () { + const cli = yield* TangledCli; + const { git } = yield* SourceControlHost.SourceControlHost; + const pullRequests = yield* TangledPullRequestProvider.make; + + const fail = (operation: string, cwd: string, cause: unknown) => + new SourceControlProviderError({ + provider: KIND, + operation, + cwd, + ...(isTangledCliError(cause) ? { command: TANGLED_CLI } : {}), + detail: isTangledCliError(cause) ? cause.detail : "Tangled operation failed.", + cause, + }); + + const notNamed = (operation: string, cwd: string, detail: string) => + new SourceControlProviderError({ provider: KIND, operation, cwd, detail }); + + /** + * The address a call targets. A caller that already knows the repository passes it by name and + * may pass no checkout at all — the clone picker types `handle/repo` — so the name comes first + * and the checkout's remote is only a fallback. + */ + const repositoryFrom = Effect.fn("TangledSourceControlProvider.repositoryFrom")( + function* (input: { + readonly cwd: string; + readonly repository?: string | undefined; + readonly context?: SourceControlProvider.SourceControlProviderContext | undefined; + }) { + const named = input.repository === undefined ? null : repositoryAddress(input.repository); + const address = named ?? repositoryPathFromTangledRemote(input.context?.remoteUrl ?? ""); + if ( + address !== null && + (isTangledRepositoryDid(address) || address.split("/").length === 2) + ) { + return address; + } + return yield* notNamed( + "resolveRepository", + input.cwd, + "Tangled addresses a repository as handle/repo or by its repository DID, which is what this project must name.", + ); + }, + ); + + return SourceControlProvider.SourceControlProvider.of({ + kind: KIND, + repositoryNameFromRemoteUrl: (url) => repositoryPathFromTangledRemote(url), + + getRepositoryCloneUrls: (input) => + Effect.gen(function* () { + const repository = yield* repositoryFrom({ + cwd: input.cwd, + repository: input.repository, + context: input.context, + }); + // `tg repo view` takes `handle/repo` and refuses a DID, so a typed handle is confirmed + // before its URLs are handed back and a DID — which git already holds as a working + // remote — is taken as it stands. + if (!isTangledRepositoryDid(repository)) { + yield* cli + .runJson({ + cwd: input.cwd, + args: ["repo", "view", repository], + schema: TangledRepositorySchema, + }) + .pipe(Effect.mapError((cause) => fail("getRepositoryCloneUrls", input.cwd, cause))); + } + return cloneUrls(repository); + }), + + createRepository: (input) => + Effect.gen(function* () { + const [owner, name] = input.repository.split("/"); + if (owner === undefined || name === undefined || name.length === 0) { + return yield* notNamed( + "createRepository", + input.cwd, + "Name a Tangled repository as handle/repo.", + ); + } + // Tangled has no private repositories, so `visibility` is not something `tg` can be asked + // for; a knot may still refuse the name, which surfaces as this operation's error. + const created = yield* cli + .runJson({ + cwd: input.cwd, + args: ["repo", "create", name], + schema: TangledRepositorySchema, + }) + .pipe(Effect.mapError((cause) => fail("createRepository", input.cwd, cause))); + // `tg` always creates under the signed-in account. Say so rather than hand back URLs for + // a repository that was never made where the caller asked. + if (!createsUnderOwner({ requested: owner, created: created.author })) { + return yield* notNamed( + "createRepository", + input.cwd, + `tg created ${name} under ${created.author ?? "your account"}, not ${owner}. Create it on tangled.org to own it as ${owner}.`, + ); + } + return cloneUrls(input.repository); + }), + + getDefaultBranch: (input) => + Effect.gen(function* () { + yield* repositoryFrom({ cwd: input.cwd, context: input.context }); + const remoteName = input.context?.remoteName; + if (remoteName === undefined) { + return yield* notNamed( + "getDefaultBranch", + input.cwd, + "This project has no Tangled remote.", + ); + } + // `tg repo view` reports no default branch, so git's own symref is the source. + const listing = yield* git + .execute({ + operation: "TangledSourceControlProvider.getDefaultBranch", + cwd: input.cwd, + args: ["ls-remote", "--symref", remoteName, "HEAD"], + }) + .pipe(Effect.mapError((cause) => fail("getDefaultBranch", input.cwd, cause))); + return defaultBranchFromSymref(listing.stdout); + }), + + listChangeRequests: (input) => + Effect.gen(function* () { + const repository = yield* repositoryFrom(input); + const page = yield* pullRequests + .listChangeRequests({ + cwd: input.cwd, + repository, + host: "tangled.org", + state: input.state, + involvement: "all", + viewer: "", + limit: Number.MAX_SAFE_INTEGER, + }) + .pipe( + Effect.mapError((cause) => notNamed("listChangeRequests", input.cwd, cause.detail)), + ); + // Filter before limiting: an older PR on another branch must not hide this branch's PR. + return page.items + .filter((pull) => pull.headBranch === SourceControlProvider.sourceBranch(input)) + .slice(0, input.limit) + .map((pull) => ({ + provider: KIND, + number: pull.number, + title: pull.title, + url: pull.url, + headRefName: pull.headBranch, + baseRefName: pull.baseBranch, + state: pull.state, + updatedAt: DateTime.make(pull.updatedAt), + })); + }), + + createChangeRequest: (input) => + Effect.gen(function* () { + const source = SourceControlProvider.sourceControlRefFromInput(input); + const headBranch = SourceControlProvider.sourceBranch(input); + const address = yield* repositoryFrom({ + cwd: input.cwd, + repository: input.target?.repository ?? source?.repository, + context: input.context, + }); + + const open = yield* cli + .runJson({ + cwd: input.cwd, + args: ["pr", "list", address, "--state", "open", "--limit", "100"], + schema: TangledPullListSchema, + }) + .pipe(Effect.mapError((cause) => fail("createChangeRequest", input.cwd, cause))); + // `tg pr list` answers by record key and branch, which is all a duplicate check needs. + // A pull request whose source branch is absent cannot be matched and is left alone. + const duplicate = open.items.find( + (pull) => pull.sourceBranch === headBranch && pull.targetBranch === input.baseRefName, + ); + if (duplicate) { + return yield* notNamed( + "createChangeRequest", + input.cwd, + `${headBranch} already has an open pull request into ${input.baseRefName} (${duplicate.rkey}). Open it with \`tg pr view ${duplicate.rkey}\`.`, + ); + } + + // The body is passed as a file, so it never appears in argv. + const args = [ + "pr", + "create", + "--title", + input.title, + "--body-file", + input.bodyFile, + "--base", + input.baseRefName, + "--head", + headBranch, + ]; + // `-repo` takes `handle/repo`; a repository DID is only ever resolved from the checkout. + if (!isTangledRepositoryDid(address)) args.push("--repo", address); + yield* cli + .run({ cwd: input.cwd, args }) + .pipe(Effect.mapError((cause) => fail("createChangeRequest", input.cwd, cause))); + }), + + getChangeRequest: (input) => unsupported("getChangeRequest", input.cwd), + checkoutChangeRequest: (input) => unsupported("checkoutChangeRequest", input.cwd), + }); +}); diff --git a/packages/source-control-tangled/src/server/driver.ts b/packages/source-control-tangled/src/server/driver.ts new file mode 100644 index 0000000000000..d386900171ceb --- /dev/null +++ b/packages/source-control-tangled/src/server/driver.ts @@ -0,0 +1,25 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; +/** + * The Tangled driver: repository operations and pull requests through the `tg` CLI, and default + * branches through git. Pull requests are listed, read and summarised; nothing is written to one. + * + * @module source-control-tangled/server/driver + */ +import { defineSourceControlDriver, withOwnServices } from "@t3tools/source-control-core/server/driver"; +import * as Effect from "effect/Effect"; + +import { definition } from "../client/definition.ts"; +import * as TangledCli from "./TangledCli.ts"; + +import * as TangledPullRequestProvider from "./TangledPullRequestProvider.ts"; +import * as TangledSourceControlProvider from "./TangledSourceControlProvider.ts"; + +export const driver = defineSourceControlDriver({ + kind: SourceControlProviderKind.make("tangled"), + definition, + make: withOwnServices(Effect.all({ + sourceControl: TangledSourceControlProvider.make, + discovery: Effect.succeed(TangledSourceControlProvider.discovery), + pullRequests: TangledPullRequestProvider.make, + }), TangledCli.layer), +}); diff --git a/packages/source-control-tangled/src/server/tangledPulls.test.ts b/packages/source-control-tangled/src/server/tangledPulls.test.ts new file mode 100644 index 0000000000000..25e0700f60259 --- /dev/null +++ b/packages/source-control-tangled/src/server/tangledPulls.test.ts @@ -0,0 +1,172 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { numberPulls, offsetFromRows, parsePullPageRows } from "./tangledPulls.ts"; +import type { TangledPull } from "./tangledPulls.ts"; + +const pull = (rkey: string, title: string): TangledPull => ({ + rkey, + title, + state: "open", + createdAt: "2026-05-18T10:34:32Z", +}); + +describe("numberPulls", () => { + it("places pull requests in the order tangled.org numbered them", () => { + // Record keys are time-sortable, so this order is the host's creation order whatever + // order `tg pr list` answered in. + const numbered = numberPulls([ + pull("3mm4efxfkov22", "third"), + pull("3lydhsmxfli22", "first"), + pull("3mkaewigwhi22", "second"), + ]); + assert.deepStrictEqual( + numbered.map((entry) => [entry.index, entry.pull.title]), + [ + [1, "first"], + [2, "second"], + [3, "third"], + ], + ); + }); +}); + +describe("offsetFromRows", () => { + // Six pull requests, numbered so the rows below all imply an offset of 11. + const pulls = numberPulls( + [ + "3m6bb2zk52i22", + "3m7cstxe6ee22", + "3mcaaaaaaaa22", + "3mdbbbbbbbb22", + "3mcccccccc22", + "3mhhhn33dsu22", + ].map((rkey, at) => pull(rkey, `title ${at + 1}`)), + ); + + it("reads one offset that every matched row agrees on", () => { + // index 1 -> #12, index 3 -> #14, index 6 -> #17; all three say +11. + assert.strictEqual( + offsetFromRows( + [ + { number: 12, title: "title 1" }, + { number: 14, title: "title 3" }, + { number: 17, title: "title 6" }, + ], + pulls, + ), + 11, + ); + }); + + it("reads nothing from a single row that is not the newest, which is a guess", () => { + assert.strictEqual(offsetFromRows([{ number: 12, title: "title 1" }], pulls), null); + }); + + it("takes a single row when it anchors the newest pull request", () => { + // A repository whose page shows one row: nothing `tg` omits can sit after the newest, so + // that row's offset cannot be shifted. This is what small repositories look like. + assert.strictEqual(offsetFromRows([{ number: 17, title: "title 6" }], pulls), 11); + }); + + it("reads nothing when the rows imply different offsets", () => { + assert.strictEqual( + offsetFromRows( + [ + { number: 12, title: "title 1" }, + { number: 99, title: "title 6" }, + ], + pulls, + ), + null, + ); + }); + + it("numbers separate PRs with the same title without matching both to the first record", () => { + const repeated = numberPulls([ + pull("a", "First"), + pull("b", "Second"), + pull("c", "Same title"), + pull("d", "Same title"), + ]); + assert.strictEqual( + offsetFromRows( + [ + { number: 4, title: "Same title" }, + { number: 3, title: "Same title" }, + { number: 2, title: "Second" }, + { number: 1, title: "First" }, + ], + repeated, + ), + 0, + ); + }); + + it("resolves repeated titles only when the rows identify one common offset", () => { + const repeated = numberPulls([pull("a", "Same"), pull("b", "Same")]); + assert.strictEqual( + offsetFromRows( + [ + { number: 7, title: "Same" }, + { number: 8, title: "Same" }, + ], + repeated, + ), + 6, + ); + assert.strictEqual(offsetFromRows([{ number: 8, title: "Same" }], repeated), null); + }); + + it("reads nothing when no row matches a pull request", () => { + assert.strictEqual( + offsetFromRows( + [ + { number: 3, title: "something else" }, + { number: 4, title: "another thing" }, + ], + pulls, + ), + null, + ); + }); + + it("ignores rows that do not match without giving up on the rows that do", () => { + assert.strictEqual( + offsetFromRows( + [ + { number: 3, title: "something else" }, + { number: 12, title: "title 1" }, + { number: 17, title: "title 6" }, + ], + pulls, + ), + 11, + ); + }); +}); + +describe("parsePullPageRows", () => { + it("reads the number and title a tangled.org page prints", () => { + const html = ` + + build(docker): automatically use latest tag + #19 + + + slim down image size #18 + `; + assert.deepStrictEqual(parsePullPageRows(html, "tangled.org/knot-docker"), [ + { number: 19, title: "build(docker): automatically use latest tag" }, + { number: 18, title: "slim down image size" }, + ]); + }); + + it("reads nothing from a page without pull request rows", () => { + assert.deepStrictEqual(parsePullPageRows("nothing", "a/b"), []); + }); + + it("does not read another repository's rows", () => { + const html = `elsewhere`; + assert.deepStrictEqual(parsePullPageRows(html, "tangled.org/knot-docker"), []); + }); +}); diff --git a/packages/source-control-tangled/src/server/tangledPulls.ts b/packages/source-control-tangled/src/server/tangledPulls.ts new file mode 100644 index 0000000000000..1d90ab01538e5 --- /dev/null +++ b/packages/source-control-tangled/src/server/tangledPulls.ts @@ -0,0 +1,110 @@ +/** + * What `tg pr list --json` returns, and how one pull request is placed in the order tangled.org + * numbered them in. + * + * @module source-control-tangled/server/tangledPulls + */ +import * as Schema from "effect/Schema"; + +/** + * One entry of `tg pr list`. Without a `--state` filter `tg` answers with every state, so the + * whole set comes from one command. `sourceBranch` is absent on some answers, so nothing here + * requires it. + */ +export const TangledPullSchema = Schema.Struct({ + rkey: Schema.String, + uri: Schema.optional(Schema.String), + title: Schema.String, + state: Schema.String, + author: Schema.optional( + Schema.Struct({ + did: Schema.String, + handle: Schema.String, + }), + ), + createdAt: Schema.String, + updatedAt: Schema.optional(Schema.String), + commentCount: Schema.optional(Schema.Number), + sourceBranch: Schema.optional(Schema.String), + targetBranch: Schema.optional(Schema.String), +}); +export type TangledPull = typeof TangledPullSchema.Type; + +export const TangledPullViewSchema = Schema.Struct({ + ...TangledPullSchema.fields, + body: Schema.optional(Schema.String), +}); + +export const TangledPullListSchema = Schema.Struct({ + items: Schema.Array(TangledPullSchema), +}); + +/** A pull request and its position, counting from one, among every pull request in the repo. */ +export interface NumberedPull { + readonly pull: TangledPull; + readonly index: number; +} + +/** + * Record keys are time-sortable, so ordering by them is the order the host created its records + * in — the order its numbering follows. + */ +export function numberPulls(items: ReadonlyArray): ReadonlyArray { + return [...items] + .sort((a, b) => (a.rkey < b.rkey ? -1 : a.rkey > b.rkey ? 1 : 0)) + .map((pull, at) => ({ pull, index: at + 1 })); +} + +/** + * The per-repository offset tangled.org's numbering carries beyond the records `tg pr list` + * returns, learned from the numbers it prints on the repository's own page. Two rows agreeing is + * the evidence that one offset holds across the list; one row is a guess and none is no answer, + * so both read as unknown rather than as a number that may be wrong. + */ +export function offsetFromRows( + rows: ReadonlyArray<{ readonly number: number; readonly title: string }>, + pulls: ReadonlyArray, +): number | null { + const matched = rows + .map((row) => ({ + number: row.number, + matches: pulls.filter((entry) => entry.pull.title.trim() === row.title.trim()), + })) + .filter((row) => row.matches.length > 0); + const [first, ...rest] = matched; + if (first === undefined) return null; + // Titles can repeat. Keep every possible match until the other rows disambiguate it. + let candidates = new Set(first.matches.map((entry) => first.number - entry.index)); + for (const row of rest) { + const offsets = new Set(row.matches.map((entry) => row.number - entry.index)); + candidates = new Set([...candidates].filter((offset) => offsets.has(offset))); + } + if (candidates.size !== 1) return null; + const offset = [...candidates][0]!; + if (new Set(matched.map((row) => row.number)).size >= 2) return offset; + // A single row is accepted only when it uniquely identifies the newest record. + if (first.matches.length === 1 && first.matches[0]!.index === pulls.length) return offset; + return null; +} + +/** The `(number, title)` rows a tangled.org pull request page prints for a repository. */ +export function parsePullPageRows( + html: string, + repository: string, +): ReadonlyArray<{ readonly number: number; readonly title: string }> { + const escaped = repository.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); + const pattern = new RegExp(`]*>([\\s\\S]*?)`, "gu"); + const rows: Array<{ number: number; title: string }> = []; + for (const match of html.matchAll(pattern)) { + const number = Number(match[1]); + const title = (match[2] ?? "") + .replace(/<[^>]+>/gu, "") + .replace(/\s+/gu, " ") + .trim() + .replace(/\s*#\d+$/u, ""); + if (Number.isSafeInteger(number) && number > 0 && title.length > 0) { + rows.push({ number, title }); + } + } + return rows; +} diff --git a/packages/source-control-tangled/tsconfig.json b/packages/source-control-tangled/tsconfig.json new file mode 100644 index 0000000000000..374bac55202dd --- /dev/null +++ b/packages/source-control-tangled/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src"] +} diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index 8f300533523ae..97ac6514021a9 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -50,6 +50,7 @@ const workspaceFiles = [ "packages/source-control-gitcafe/package.json", "packages/source-control-github/package.json", "packages/source-control-gitlab/package.json", + "packages/source-control-tangled/package.json", "packages/source-control-testing/package.json", "packages/effect-acp/package.json", "packages/effect-codex-app-server/package.json", From 6da4ae9b7cc4f49c6716cd25a90e6df434607a07 Mon Sep 17 00:00:00 2001 From: nozamdavid Date: Mon, 12 Oct 2026 17:37:03 +0200 Subject: [PATCH 02/12] feat(source-control): report expired Tangled authentication --- .../src/server/TangledCli.ts | 9 ++++-- .../server/TangledPullRequestProvider.test.ts | 27 ++++++++++++++++ .../src/server/TangledPullRequestProvider.ts | 31 ++++++++++++------- .../TangledSourceControlProvider.test.ts | 9 ++++++ .../server/TangledSourceControlProvider.ts | 6 +++- 5 files changed, 68 insertions(+), 14 deletions(-) diff --git a/packages/source-control-tangled/src/server/TangledCli.ts b/packages/source-control-tangled/src/server/TangledCli.ts index 730be37e0146d..d426bcec2b877 100644 --- a/packages/source-control-tangled/src/server/TangledCli.ts +++ b/packages/source-control-tangled/src/server/TangledCli.ts @@ -28,6 +28,7 @@ export class TangledCliError extends Schema.TaggedError()("Tang /** `tg auth status --json` names the signed-in account, and nothing else when logged out. */ export const TangledAuthStatusSchema = Schema.Struct({ authenticated: Schema.Boolean, + status: Schema.optional(Schema.String), did: Schema.optional(Schema.String), handle: Schema.optional(Schema.String), }); @@ -161,10 +162,14 @@ export const make = Effect.gen(function* () { }) .pipe(Effect.mapError((cause: VcsError) => fromVcsError(input.cwd, cause))); if (result.exitCode !== 0) { + const detail = tangledErrorMessage(result) ?? `tg ${input.args[0] ?? ""} failed.`; + const authentication = /invalid_grant|not authenticated|not logged in|session expired/iu.test( + detail, + ); return yield* new TangledCliError({ cwd: input.cwd, - reason: "failed", - detail: tangledErrorMessage(result) ?? `tg ${input.args[0] ?? ""} failed.`, + reason: authentication ? "authentication" : "failed", + detail: authentication ? `${detail} Run \`tg auth login\` and retry.` : detail, }); } return result.stdout; diff --git a/packages/source-control-tangled/src/server/TangledPullRequestProvider.test.ts b/packages/source-control-tangled/src/server/TangledPullRequestProvider.test.ts index bec28be840bf4..3db853a2f229b 100644 --- a/packages/source-control-tangled/src/server/TangledPullRequestProvider.test.ts +++ b/packages/source-control-tangled/src/server/TangledPullRequestProvider.test.ts @@ -5,6 +5,33 @@ import { HttpClient, HttpClientResponse } from "effect/http"; import * as Host from "@t3tools/source-control-testing/TestSourceControlHost"; import * as Cli from "./TangledCli.ts"; import * as Provider from "./TangledPullRequestProvider.ts"; +import { ChildProcessSpawner } from "effect/process"; + +it.effect("preserves OAuth refresh failures as an authentication error", () => { + const host = Host.layer({ + process: { + run: () => + Effect.succeed( + Host.processOutput("", { + exitCode: ChildProcessSpawner.ExitCode(1), + stderr: "Error: failed to refresh OAuth tokens: token refresh failed: invalid_grant", + }), + ), + }, + }); + const client = HttpClient.make(() => Effect.die("No HTTP request is expected")); + return Effect.gen(function* () { + const provider = yield* Provider.make; + const error = yield* provider + .getViewer({ cwd: "/repo", host: "tangled.org" }) + .pipe(Effect.flip); + assert.strictEqual(error.reason, "unauthenticated"); + assert.match(error.detail, /tg auth login/u); + }).pipe( + Effect.provide(Cli.layer.pipe(Layer.provideMerge(host))), + Effect.provideService(HttpClient.HttpClient, client), + ); +}); it.effect("reads a closed PR's activity and details through its list number and record key", () => { const calls: Array> = []; diff --git a/packages/source-control-tangled/src/server/TangledPullRequestProvider.ts b/packages/source-control-tangled/src/server/TangledPullRequestProvider.ts index 024550e68ebdd..6e305e737e283 100644 --- a/packages/source-control-tangled/src/server/TangledPullRequestProvider.ts +++ b/packages/source-control-tangled/src/server/TangledPullRequestProvider.ts @@ -18,7 +18,7 @@ import { type PullRequestProviderApi, } from "@t3tools/source-control-core/server/PullRequestProvider"; -import { isTangledCliError, TangledAuthStatusSchema, TangledCli } from "./TangledCli.ts"; +import { TangledAuthStatusSchema, TangledCli, type TangledCliError } from "./TangledCli.ts"; import { numberPulls, offsetFromRows, @@ -61,8 +61,22 @@ export const make = Effect.gen(function* () { const cli = yield* TangledCli; const httpClient = yield* HttpClient.HttpClient; - const failure = (operation: string, detail: string, reason: "failed" | "not-found" = "failed") => - new PullRequestProviderError({ provider: KIND, operation, reason, detail }); + const failure = ( + operation: string, + detail: string, + reason: PullRequestProviderError["reason"] = "failed", + ) => new PullRequestProviderError({ provider: KIND, operation, reason, detail }); + + const cliFailure = (operation: string, cause: TangledCliError) => + failure( + operation, + cause.detail, + cause.reason === "authentication" + ? "unauthenticated" + : cause.reason === "missing-cli" + ? "missing-tool" + : "failed", + ); const unsupported = (operation: string, detail: string) => () => Effect.fail(failure(operation, detail)); @@ -81,12 +95,7 @@ export const make = Effect.gen(function* () { }) .pipe( Effect.map((answer) => numberPulls(answer.items)), - Effect.mapError((cause) => - failure( - input.operation, - isTangledCliError(cause) ? cause.detail : "Tangled could not list pull requests.", - ), - ), + Effect.mapError((cause) => cliFailure(input.operation, cause)), ); }); @@ -209,7 +218,7 @@ export const make = Effect.gen(function* () { args: ["pr", "view", entry.pull.rkey, "--repo", input.repository], schema: TangledPullViewSchema, }) - .pipe(Effect.mapError((cause) => failure(input.operation, cause.detail))); + .pipe(Effect.mapError((cause) => cliFailure(input.operation, cause))); return { pull: toChangeRequest( { ...entry, pull: { ...entry.pull, ...detail } }, @@ -234,7 +243,7 @@ export const make = Effect.gen(function* () { }) .pipe( Effect.map((status) => status.handle ?? status.did ?? ""), - Effect.mapError((cause) => failure("getViewer", cause.detail)), + Effect.mapError((cause) => cliFailure("getViewer", cause)), ), listChangeRequests: (input) => diff --git a/packages/source-control-tangled/src/server/TangledSourceControlProvider.test.ts b/packages/source-control-tangled/src/server/TangledSourceControlProvider.test.ts index 1581d0e135736..66ac36381d0d4 100644 --- a/packages/source-control-tangled/src/server/TangledSourceControlProvider.test.ts +++ b/packages/source-control-tangled/src/server/TangledSourceControlProvider.test.ts @@ -67,6 +67,15 @@ const serve = ); describe("TangledSourceControlProvider.parseAuth", () => { + it("reports an expired saved session as unauthenticated", () => { + const expired = TangledSourceControlProvider.parseAuth({ + stdout: encodeJson({ authenticated: true, status: "expired", handle: "noz.am" }), + stderr: "", + exitCode: ChildProcessSpawner.ExitCode(0), + }); + assert.strictEqual(expired.status, "unauthenticated"); + }); + it("reads the signed-in account from tg auth status", () => { const signedIn = TangledSourceControlProvider.parseAuth({ stdout: encodeJson({ authenticated: true, handle: "alice.example", did: "did:plc:abc" }), diff --git a/packages/source-control-tangled/src/server/TangledSourceControlProvider.ts b/packages/source-control-tangled/src/server/TangledSourceControlProvider.ts index b3846a089a815..75e830e547095 100644 --- a/packages/source-control-tangled/src/server/TangledSourceControlProvider.ts +++ b/packages/source-control-tangled/src/server/TangledSourceControlProvider.ts @@ -42,7 +42,11 @@ export function isTangledHostname(hostname: string): boolean { /** `tg auth status --json` names the account and the DID when one is signed in. */ export function parseAuth(input: SourceControlAuthProbeInput) { const decoded = decodeJsonResult(TangledAuthStatusSchema)(input.stdout); - if (decoded._tag === "Success" && decoded.success.authenticated) { + if ( + decoded._tag === "Success" && + decoded.success.authenticated && + decoded.success.status !== "expired" + ) { return providerAuth({ status: "authenticated", account: decoded.success.handle ?? decoded.success.did, From a11f83a693a0225e2a0172e472895b1976c1a842 Mon Sep 17 00:00:00 2001 From: nozamdavid Date: Mon, 12 Oct 2026 17:54:58 +0200 Subject: [PATCH 03/12] feat(source-control): validate Tangled DID owners --- .../TangledSourceControlProvider.test.ts | 26 ++++++++++++++++--- .../server/TangledSourceControlProvider.ts | 5 ++-- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/packages/source-control-tangled/src/server/TangledSourceControlProvider.test.ts b/packages/source-control-tangled/src/server/TangledSourceControlProvider.test.ts index 66ac36381d0d4..af67c2815e920 100644 --- a/packages/source-control-tangled/src/server/TangledSourceControlProvider.test.ts +++ b/packages/source-control-tangled/src/server/TangledSourceControlProvider.test.ts @@ -147,11 +147,11 @@ describe("TangledSourceControlProvider.defaultBranchFromSymref", () => { }); describe("TangledSourceControlProvider.createsUnderOwner", () => { - it("accepts the owner tg created the repository under", () => { + it.each(["alice.example", "did:plc:alice"])("accepts the same owner %s", (owner) => { assert.strictEqual( TangledSourceControlProvider.createsUnderOwner({ - requested: "alice.example", - created: "alice.example", + requested: owner, + created: owner, }), true, ); @@ -498,6 +498,26 @@ describe("TangledSourceControlProvider.make", () => { }).pipe(serve(tg)); }); + it.effect("rejects clone URLs when tg created the repository under a different DID", () => { + const tg = fakeTg({ + "repo create repo": { + stdout: encodeJson({ name: "repo", author: "did:plc:bob", knot: "knot1.tangled.sh" }), + }, + }); + return Effect.gen(function* () { + const provider = yield* TangledSourceControlProvider.make; + const error = yield* provider + .createRepository({ + cwd: "/repo", + repository: "did:plc:alice/repo", + visibility: "public", + }) + .pipe(Effect.flip); + assert.match(error.detail, /did:plc:bob/u); + assert.match(error.detail, /did:plc:alice/u); + }).pipe(serve(tg)); + }); + it.effect("reads the default branch from git's own symref", () => { const gitCalls: Array> = []; const layer = TestSourceControlHost.layer({ diff --git a/packages/source-control-tangled/src/server/TangledSourceControlProvider.ts b/packages/source-control-tangled/src/server/TangledSourceControlProvider.ts index 75e830e547095..91d27202cf091 100644 --- a/packages/source-control-tangled/src/server/TangledSourceControlProvider.ts +++ b/packages/source-control-tangled/src/server/TangledSourceControlProvider.ts @@ -160,8 +160,8 @@ export const TangledPullListSchema = Schema.Struct({ /** * Whether `tg repo create` honoured the owner asked for. `tg` has no way to create a repository * under another account, so a name like `someone-else/repo` is created under the signed-in one. - * Comparing only plain handles keeps a DID and a handle for the same account from reading as a - * mismatch. + * Compare handles and pairs of DIDs directly. A mixed DID/handle pair cannot establish a + * mismatch without resolving the handle. */ export function createsUnderOwner(input: { readonly requested: string; @@ -170,6 +170,7 @@ export function createsUnderOwner(input: { const requested = input.requested.trim(); const created = input.created?.trim(); if (requested.length === 0 || created === undefined || created.length === 0) return true; + if (requested.startsWith("did:") && created.startsWith("did:")) return requested === created; return requested.startsWith("did:") || created.startsWith("did:") || requested === created; } From e3d733a52bd6d825b3332855c6cce0004b163994 Mon Sep 17 00:00:00 2001 From: nozamdavid Date: Mon, 12 Oct 2026 17:56:50 +0200 Subject: [PATCH 04/12] feat(source-control): validate Tangled repository paths --- .../TangledSourceControlProvider.test.ts | 23 +++++++++++++++++++ .../server/TangledSourceControlProvider.ts | 5 ++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/source-control-tangled/src/server/TangledSourceControlProvider.test.ts b/packages/source-control-tangled/src/server/TangledSourceControlProvider.test.ts index af67c2815e920..5abb5f49db8d1 100644 --- a/packages/source-control-tangled/src/server/TangledSourceControlProvider.test.ts +++ b/packages/source-control-tangled/src/server/TangledSourceControlProvider.test.ts @@ -483,6 +483,29 @@ describe("TangledSourceControlProvider.make", () => { }).pipe(serve(tg)); }); + it.effect("rejects malformed repository paths before invoking tg", () => { + const tg = fakeTg({}); + return Effect.gen(function* () { + const provider = yield* TangledSourceControlProvider.make; + for (const repository of [ + "alice.example/repo/extra", + "/repo", + "alice.example/", + "alice.example/repo/", + ]) { + const error = yield* provider + .createRepository({ + cwd: "/repo", + repository, + visibility: "public", + }) + .pipe(Effect.flip); + assert.match(error.detail, /handle\/repo/u); + } + assert.deepStrictEqual(tg.calls, []); + }).pipe(serve(tg)); + }); + it.effect("says so when tg created the repository under a different account", () => { const tg = fakeTg({ "repo create repo": { diff --git a/packages/source-control-tangled/src/server/TangledSourceControlProvider.ts b/packages/source-control-tangled/src/server/TangledSourceControlProvider.ts index 91d27202cf091..89d7221565d14 100644 --- a/packages/source-control-tangled/src/server/TangledSourceControlProvider.ts +++ b/packages/source-control-tangled/src/server/TangledSourceControlProvider.ts @@ -262,8 +262,9 @@ export const make = Effect.gen(function* () { createRepository: (input) => Effect.gen(function* () { - const [owner, name] = input.repository.split("/"); - if (owner === undefined || name === undefined || name.length === 0) { + const segments = input.repository.split("/"); + const [owner, name] = segments; + if (segments.length !== 2 || !owner || !name) { return yield* notNamed( "createRepository", input.cwd, From df41c46cb150fd10f57f2bdb3ca03cfee1c0f7db Mon Sep 17 00:00:00 2001 From: nozamdavid Date: Mon, 12 Oct 2026 18:00:45 +0200 Subject: [PATCH 05/12] feat(source-control): preserve Tangled branch lookup errors --- .../TangledSourceControlProvider.test.ts | 30 +++++++++++++++++++ .../server/TangledSourceControlProvider.ts | 11 ++++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/packages/source-control-tangled/src/server/TangledSourceControlProvider.test.ts b/packages/source-control-tangled/src/server/TangledSourceControlProvider.test.ts index 5abb5f49db8d1..a2743cea058a7 100644 --- a/packages/source-control-tangled/src/server/TangledSourceControlProvider.test.ts +++ b/packages/source-control-tangled/src/server/TangledSourceControlProvider.test.ts @@ -7,6 +7,7 @@ import * as Schema from "effect/Schema"; import { ChildProcessSpawner } from "effect/process"; import { HttpClient, HttpClientResponse } from "effect/http"; import type * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; +import { PullRequestProviderError } from "@t3tools/source-control-core/server/PullRequestProvider"; import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; import * as TangledClient from "../client/definition.ts"; @@ -592,6 +593,35 @@ describe("TangledSourceControlProvider.make", () => { ); }); + it.effect("preserves the immediate PR provider error when branch lookup fails", () => { + const tg = fakeTg({ + "pr list alice.example/repo": { + stdout: "", + stderr: "Error: not logged in", + exitCode: 1, + }, + }); + return Effect.gen(function* () { + const provider = yield* TangledSourceControlProvider.make; + const error = yield* provider + .listChangeRequests({ + cwd: "/repo", + context: REMOTE, + headSelector: "feature", + state: "all", + }) + .pipe(Effect.flip); + assert.strictEqual(error.provider, TangledSourceControlProvider.KIND); + assert.strictEqual(error.operation, "listChangeRequests"); + assert.strictEqual(error.cwd, "/repo"); + const cause = error.cause; + if (!Schema.is(PullRequestProviderError)(cause)) + return assert.fail("Missing immediate PR provider error"); + assert.strictEqual(error.detail, cause.detail); + assert.strictEqual(cause.reason, "unauthenticated"); + }).pipe(serve(tg)); + }); + it.effect("finds only the current branch's pull requests before applying the limit", () => { const tg = fakeTg({ "pr list alice.example/repo": { diff --git a/packages/source-control-tangled/src/server/TangledSourceControlProvider.ts b/packages/source-control-tangled/src/server/TangledSourceControlProvider.ts index 89d7221565d14..f50e78e48d310 100644 --- a/packages/source-control-tangled/src/server/TangledSourceControlProvider.ts +++ b/packages/source-control-tangled/src/server/TangledSourceControlProvider.ts @@ -328,7 +328,16 @@ export const make = Effect.gen(function* () { limit: Number.MAX_SAFE_INTEGER, }) .pipe( - Effect.mapError((cause) => notNamed("listChangeRequests", input.cwd, cause.detail)), + Effect.mapError( + (cause) => + new SourceControlProviderError({ + provider: KIND, + operation: "listChangeRequests", + cwd: input.cwd, + detail: cause.detail, + cause, + }), + ), ); // Filter before limiting: an older PR on another branch must not hide this branch's PR. return page.items From d0cc430d1d741d40930bac9ec765b43b5e00ad0a Mon Sep 17 00:00:00 2001 From: nozamdavid Date: Mon, 12 Oct 2026 18:01:49 +0200 Subject: [PATCH 06/12] feat(source-control): cache Tangled error schema guard --- .../src/server/TangledSourceControlProvider.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/source-control-tangled/src/server/TangledSourceControlProvider.test.ts b/packages/source-control-tangled/src/server/TangledSourceControlProvider.test.ts index a2743cea058a7..328b6356f6d02 100644 --- a/packages/source-control-tangled/src/server/TangledSourceControlProvider.test.ts +++ b/packages/source-control-tangled/src/server/TangledSourceControlProvider.test.ts @@ -15,6 +15,7 @@ import * as TangledCli from "./TangledCli.ts"; import * as TangledSourceControlProvider from "./TangledSourceControlProvider.ts"; const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const isPullRequestProviderError = Schema.is(PullRequestProviderError); const context = (remoteUrl: string) => ({ @@ -615,7 +616,7 @@ describe("TangledSourceControlProvider.make", () => { assert.strictEqual(error.operation, "listChangeRequests"); assert.strictEqual(error.cwd, "/repo"); const cause = error.cause; - if (!Schema.is(PullRequestProviderError)(cause)) + if (!isPullRequestProviderError(cause)) return assert.fail("Missing immediate PR provider error"); assert.strictEqual(error.detail, cause.detail); assert.strictEqual(cause.reason, "unauthenticated"); From f744fee27b245de6ae343120abaaf895a8e65ab9 Mon Sep 17 00:00:00 2001 From: nozamdavid Date: Mon, 12 Oct 2026 18:06:29 +0200 Subject: [PATCH 07/12] feat(source-control): decode Tangled PR title entities --- packages/source-control-tangled/package.json | 3 ++- .../src/server/tangledPulls.test.ts | 9 +++++++++ .../source-control-tangled/src/server/tangledPulls.ts | 4 ++-- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/source-control-tangled/package.json b/packages/source-control-tangled/package.json index 5847b82105293..9c8480dee3be3 100644 --- a/packages/source-control-tangled/package.json +++ b/packages/source-control-tangled/package.json @@ -37,7 +37,8 @@ "@t3tools/provider-core": "workspace:*", "@t3tools/shared": "workspace:*", "@t3tools/source-control-core": "workspace:*", - "effect": "catalog:" + "effect": "catalog:", + "entities": "4.5.0" }, "devDependencies": { "@effect/vitest": "catalog:", diff --git a/packages/source-control-tangled/src/server/tangledPulls.test.ts b/packages/source-control-tangled/src/server/tangledPulls.test.ts index 25e0700f60259..bdbd27dabfbc4 100644 --- a/packages/source-control-tangled/src/server/tangledPulls.test.ts +++ b/packages/source-control-tangled/src/server/tangledPulls.test.ts @@ -146,6 +146,15 @@ describe("offsetFromRows", () => { }); describe("parsePullPageRows", () => { + it("decodes title character references so numbered reads match the CLI title", () => { + const title = "Fix & \"parsing\" 'quotes' 🚀 <"; + const html = + 'Fix <tag> & "parsing" 'quotes' 🚀 &lt; #1'; + const rows = parsePullPageRows(html, "alice.example/repo"); + assert.deepStrictEqual(rows, [{ number: 1, title }]); + assert.strictEqual(offsetFromRows(rows, numberPulls([pull("a", title)])), 0); + }); + it("reads the number and title a tangled.org page prints", () => { const html = ` diff --git a/packages/source-control-tangled/src/server/tangledPulls.ts b/packages/source-control-tangled/src/server/tangledPulls.ts index 1d90ab01538e5..8953f769ac968 100644 --- a/packages/source-control-tangled/src/server/tangledPulls.ts +++ b/packages/source-control-tangled/src/server/tangledPulls.ts @@ -5,6 +5,7 @@ * @module source-control-tangled/server/tangledPulls */ import * as Schema from "effect/Schema"; +import { decodeHTML } from "entities"; /** * One entry of `tg pr list`. Without a `--state` filter `tg` answers with every state, so the @@ -97,8 +98,7 @@ export function parsePullPageRows( const rows: Array<{ number: number; title: string }> = []; for (const match of html.matchAll(pattern)) { const number = Number(match[1]); - const title = (match[2] ?? "") - .replace(/<[^>]+>/gu, "") + const title = decodeHTML((match[2] ?? "").replace(/<[^>]+>/gu, "")) .replace(/\s+/gu, " ") .trim() .replace(/\s*#\d+$/u, ""); From 6dd46e43b58b77425c2b98f9c236bcaf551616de Mon Sep 17 00:00:00 2001 From: nozamdavid Date: Mon, 12 Oct 2026 19:02:49 +0200 Subject: [PATCH 08/12] feat(source-control): confirm Tangled owner before publishing --- .../TangledSourceControlProvider.test.ts | 84 ++++++++++++++----- .../server/TangledSourceControlProvider.ts | 19 +++++ 2 files changed, 80 insertions(+), 23 deletions(-) diff --git a/packages/source-control-tangled/src/server/TangledSourceControlProvider.test.ts b/packages/source-control-tangled/src/server/TangledSourceControlProvider.test.ts index 328b6356f6d02..b1eb43407a7c2 100644 --- a/packages/source-control-tangled/src/server/TangledSourceControlProvider.test.ts +++ b/packages/source-control-tangled/src/server/TangledSourceControlProvider.test.ts @@ -463,27 +463,40 @@ describe("TangledSourceControlProvider.make", () => { }).pipe(serve(tg)); }); - it.effect("creates a repository and returns its clone URLs", () => { - const tg = fakeTg({ - "repo create repo": { - stdout: encodeJson({ name: "repo", author: "alice.example", knot: "knot1.tangled.sh" }), - }, - }); - return Effect.gen(function* () { - const provider = yield* TangledSourceControlProvider.make; - const urls = yield* provider.createRepository({ - cwd: "/repo", - repository: "alice.example/repo", - visibility: "public", - }); - assert.deepStrictEqual(urls, { - nameWithOwner: "alice.example/repo", - url: "https://tangled.org/alice.example/repo", - sshUrl: "git@tangled.org:alice.example/repo", + it.effect.each(["alice.example", "did:plc:alice"])( + "creates a repository for the confirmed owner %s", + (owner) => { + const tg = fakeTg({ + "auth status": { + stdout: encodeJson({ + authenticated: true, + handle: "alice.example", + did: "did:plc:alice", + }), + }, + "repo create repo": { + stdout: encodeJson({ name: "repo", author: "alice.example", knot: "knot1.tangled.sh" }), + }, }); - assert.deepStrictEqual(tg.calls, [["repo", "create", "repo"]]); - }).pipe(serve(tg)); - }); + return Effect.gen(function* () { + const provider = yield* TangledSourceControlProvider.make; + const urls = yield* provider.createRepository({ + cwd: "/repo", + repository: `${owner}/repo`, + visibility: "public", + }); + assert.deepStrictEqual(urls, { + nameWithOwner: `${owner}/repo`, + url: `https://tangled.org/${owner}/repo`, + sshUrl: `git@tangled.org:${owner}/repo`, + }); + assert.deepStrictEqual(tg.calls, [ + ["auth", "status"], + ["repo", "create", "repo"], + ]); + }).pipe(serve(tg)); + }, + ); it.effect("rejects malformed repository paths before invoking tg", () => { const tg = fakeTg({}); @@ -508,8 +521,9 @@ describe("TangledSourceControlProvider.make", () => { }).pipe(serve(tg)); }); - it.effect("says so when tg created the repository under a different account", () => { + it.effect("rejects a different signed-in handle before creating a repository", () => { const tg = fakeTg({ + "auth status": { stdout: encodeJson({ authenticated: true, handle: "alice.example" }) }, "repo create repo": { stdout: encodeJson({ name: "repo", author: "alice.example", knot: "knot1.tangled.sh" }), }, @@ -519,12 +533,14 @@ describe("TangledSourceControlProvider.make", () => { const error = yield* provider .createRepository({ cwd: "/repo", repository: "someone-else/repo", visibility: "public" }) .pipe(Effect.flip); - assert.match(error.detail, /not someone-else/u); + assert.match(error.detail, /someone-else/u); + assert.deepStrictEqual(tg.calls, [["auth", "status"]]); }).pipe(serve(tg)); }); - it.effect("rejects clone URLs when tg created the repository under a different DID", () => { + it.effect("rejects a different signed-in DID before creating a repository", () => { const tg = fakeTg({ + "auth status": { stdout: encodeJson({ authenticated: true, did: "did:plc:bob" }) }, "repo create repo": { stdout: encodeJson({ name: "repo", author: "did:plc:bob", knot: "knot1.tangled.sh" }), }, @@ -540,6 +556,28 @@ describe("TangledSourceControlProvider.make", () => { .pipe(Effect.flip); assert.match(error.detail, /did:plc:bob/u); assert.match(error.detail, /did:plc:alice/u); + assert.deepStrictEqual(tg.calls, [["auth", "status"]]); + }).pipe(serve(tg)); + }); + + it.effect.each([ + { authenticated: false, handle: "alice.example" }, + { authenticated: true }, + { authenticated: true, status: "expired", handle: "alice.example" }, + { authenticated: true, status: "unknown", handle: "alice.example" }, + ])("does not create when the signed-in owner cannot be confirmed: %j", (account) => { + const tg = fakeTg({ "auth status": { stdout: encodeJson(account) } }); + return Effect.gen(function* () { + const provider = yield* TangledSourceControlProvider.make; + const error = yield* provider + .createRepository({ + cwd: "/repo", + repository: "alice.example/repo", + visibility: "public", + }) + .pipe(Effect.flip); + assert.match(error.detail, /Cannot confirm/u); + assert.deepStrictEqual(tg.calls, [["auth", "status"]]); }).pipe(serve(tg)); }); diff --git a/packages/source-control-tangled/src/server/TangledSourceControlProvider.ts b/packages/source-control-tangled/src/server/TangledSourceControlProvider.ts index f50e78e48d310..e75c96b631eb3 100644 --- a/packages/source-control-tangled/src/server/TangledSourceControlProvider.ts +++ b/packages/source-control-tangled/src/server/TangledSourceControlProvider.ts @@ -271,6 +271,25 @@ export const make = Effect.gen(function* () { "Name a Tangled repository as handle/repo.", ); } + const account = yield* cli + .runJson({ + cwd: input.cwd, + args: ["auth", "status"], + schema: TangledAuthStatusSchema, + }) + .pipe(Effect.mapError((cause) => fail("createRepository", input.cwd, cause))); + if ( + !account.authenticated || + account.status === "expired" || + account.status === "unknown" || + (owner !== account.handle && owner !== account.did) + ) { + return yield* notNamed( + "createRepository", + input.cwd, + `Cannot confirm Tangled is signed in as ${owner} (reported account: ${account.handle ?? account.did ?? "unknown"}). Run \`tg auth login ${owner}\` and retry.`, + ); + } // Tangled has no private repositories, so `visibility` is not something `tg` can be asked // for; a knot may still refuse the name, which surfaces as this operation's error. const created = yield* cli From c0fb3d08ff525e59a31945090aa0f52eda74f739 Mon Sep 17 00:00:00 2001 From: nozamdavid Date: Mon, 12 Oct 2026 19:26:52 +0200 Subject: [PATCH 09/12] feat(source-control): check all open Tangled pull requests --- .../TangledSourceControlProvider.test.ts | 25 +++++++++++-------- .../server/TangledSourceControlProvider.ts | 2 +- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/packages/source-control-tangled/src/server/TangledSourceControlProvider.test.ts b/packages/source-control-tangled/src/server/TangledSourceControlProvider.test.ts index b1eb43407a7c2..6ad554e58cb51 100644 --- a/packages/source-control-tangled/src/server/TangledSourceControlProvider.test.ts +++ b/packages/source-control-tangled/src/server/TangledSourceControlProvider.test.ts @@ -217,14 +217,14 @@ describe("createChangeRequest", () => { it.effect("creates a pull request once no open one holds the branch", () => { const tg = fakeTg({ - "pr list alice.example/repo --state open --limit 100": pullList([]), + "pr list alice.example/repo --state open": pullList([]), "pr create --title Add a thing --body-file /tmp/body.md --base main --head feature --repo alice.example/repo": { stdout: "" }, }); return create().pipe( Effect.map(() => { assert.deepStrictEqual(tg.calls, [ - ["pr", "list", "alice.example/repo", "--state", "open", "--limit", "100"], + ["pr", "list", "alice.example/repo", "--state", "open"], [ "pr", "create", @@ -245,9 +245,16 @@ describe("createChangeRequest", () => { ); }); - it.effect("refuses a second pull request when one already holds the branch", () => { + it.effect("refuses a duplicate beyond the first 100 open pull requests", () => { const tg = fakeTg({ - "pr list alice.example/repo --state open --limit 100": pullList([ + "pr list alice.example/repo --state open": pullList([ + ...Array.from({ length: 100 }, (_, index) => ({ + rkey: `other-${index}`, + title: "Another branch", + state: "open", + sourceBranch: `other-${index}`, + targetBranch: "main", + })), { rkey: "3mkaewigwhi22", title: "Already open", @@ -264,9 +271,7 @@ describe("createChangeRequest", () => { Effect.map((error) => { assert.match(error.detail, /already has an open pull request/u); assert.match(error.detail, /3mkaewigwhi22/u); - assert.deepStrictEqual(tg.calls, [ - ["pr", "list", "alice.example/repo", "--state", "open", "--limit", "100"], - ]); + assert.deepStrictEqual(tg.calls, [["pr", "list", "alice.example/repo", "--state", "open"]]); }), serve(tg), ); @@ -274,7 +279,7 @@ describe("createChangeRequest", () => { it.effect("creates when the open pull request targets a different branch", () => { const tg = fakeTg({ - "pr list alice.example/repo --state open --limit 100": pullList([ + "pr list alice.example/repo --state open": pullList([ { rkey: "3mkaewigwhi22", title: "Into staging", @@ -298,7 +303,7 @@ describe("createChangeRequest", () => { it.effect("creates from a DID remote without passing --repo, which takes no DID", () => { const tg = fakeTg({ - "pr list did:plc:4ygmvhvc6kw3w7j4fmd4eiqp --state open --limit 100": pullList([]), + "pr list did:plc:4ygmvhvc6kw3w7j4fmd4eiqp --state open": pullList([]), "pr create --title Add a thing --body-file /tmp/body.md --base main --head feature": { stdout: "", }, @@ -319,7 +324,7 @@ describe("createChangeRequest", () => { it.effect("reports what tg said when the pull request itself fails", () => { const tg = fakeTg({ - "pr list alice.example/repo --state open --limit 100": pullList([]), + "pr list alice.example/repo --state open": pullList([]), "pr create --title Add a thing --body-file /tmp/body.md --base main --head feature --repo alice.example/repo": { stdout: "", diff --git a/packages/source-control-tangled/src/server/TangledSourceControlProvider.ts b/packages/source-control-tangled/src/server/TangledSourceControlProvider.ts index e75c96b631eb3..5400a2ce6e291 100644 --- a/packages/source-control-tangled/src/server/TangledSourceControlProvider.ts +++ b/packages/source-control-tangled/src/server/TangledSourceControlProvider.ts @@ -387,7 +387,7 @@ export const make = Effect.gen(function* () { const open = yield* cli .runJson({ cwd: input.cwd, - args: ["pr", "list", address, "--state", "open", "--limit", "100"], + args: ["pr", "list", address, "--state", "open"], schema: TangledPullListSchema, }) .pipe(Effect.mapError((cause) => fail("createChangeRequest", input.cwd, cause))); From 08736ecc602d8fdda66d31a9799c0606c8d74e60 Mon Sep 17 00:00:00 2001 From: David Marcos Date: Sun, 1 Nov 2026 14:05:48 +0100 Subject: [PATCH 10/12] fix(source-control): adapt Tangled to current registries --- .../components/BranchToolbar.logic.test.ts | 2 +- .../pullRequest/PullRequestSummaryTab.tsx | 7 +- .../src/sourceControlClients.test.ts | 9 + .../src/registry.ts | 8 +- packages/source-control-tangled/package.json | 22 +- .../src/server/TangledCli.ts | 6 +- .../src/server/TangledPullRequestProvider.ts | 49 +++-- .../TangledSourceControlProvider.test.ts | 2 +- .../server/TangledSourceControlProvider.ts | 196 ++++++++---------- .../src/server/driver.ts | 20 +- .../src/{client => shared}/definition.ts | 29 ++- pnpm-lock.yaml | 40 ++++ 12 files changed, 210 insertions(+), 180 deletions(-) rename packages/source-control-tangled/src/{client => shared}/definition.ts (59%) diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index 7a0c5148018a3..e894691bcd7c2 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -1,4 +1,4 @@ -import { type VcsStatusResult, type VcsRef } from "@t3tools/contracts"; +import { type VcsRef } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git"; import { diff --git a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx index 6f02717d5144e..bf154ba85d678 100644 --- a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx @@ -916,8 +916,11 @@ export function PullRequestSummaryTab({ {commentCount === 0 ? ( "Comments are not available here. Open this pull request on the host to read them." ) : ( - <>This conversation is only partially loaded. {commentCount} comment - {commentCount === 1 ? " is" : "s are"} loaded; open it on the host to read the rest. + <> + This conversation is only partially loaded. {commentCount} comment + {commentCount === 1 ? " is" : "s are"} loaded; open it on the host to read the + rest. + )}

) : null} diff --git a/packages/client-runtime/src/sourceControlClients.test.ts b/packages/client-runtime/src/sourceControlClients.test.ts index cfbffc40bbabc..362a46633c172 100644 --- a/packages/client-runtime/src/sourceControlClients.test.ts +++ b/packages/client-runtime/src/sourceControlClients.test.ts @@ -249,6 +249,15 @@ describe("sourceControlClients.recognizeRemote", () => { expect(kindOfRemote("https://bitbucket.gitlab.example/o/r")).toBe("gitlab"); expect(kindOfRemote("https://bitbucket.visualstudio.com/p/_git/r")).toBe("azure-devops"); expect(kindOfRemote("git@git.cafe:pingdotgg/t3code.git")).toBe("gitcafe"); + for (const remote of [ + "git@tangled.org:alice.example/repo", + "https://tangled.org/alice.example/repo.git", + "https://knot1.tangled.sh/alice.example/repo", + "ssh://git@knot.tangled.org/alice.example/repo.git", + "git@tangled.org:did:plc:abc123", + ]) { + expect(kindOfRemote(remote), remote).toBe("tangled"); + } }); it("reads a remote no host claims as an unknown host, and a local path as no host", () => { diff --git a/packages/source-control-definitions/src/registry.ts b/packages/source-control-definitions/src/registry.ts index d314f585c345c..eda00aa6cdba8 100644 --- a/packages/source-control-definitions/src/registry.ts +++ b/packages/source-control-definitions/src/registry.ts @@ -4,7 +4,7 @@ import { makeSourceControlDefinitionRegistry } from "@t3tools/source-control-cor import * as Forgejo from "@t3tools/source-control-forgejo/shared/definition"; import * as GitCafe from "@t3tools/source-control-gitcafe/shared/definition"; import * as GitHub from "@t3tools/source-control-github/shared/definition"; -import * as Tangled from "@t3tools/source-control-tangled/client/definition"; +import * as Tangled from "@t3tools/source-control-tangled/shared/definition"; import * as GitLab from "@t3tools/source-control-gitlab/shared/definition"; export { canonicalizePullRequestUrl as canonicalizeGitCafePullRequestUrl } from "@t3tools/source-control-gitcafe/shared/definition"; @@ -25,7 +25,11 @@ export const builtInSourceControlDefinitions = makeSourceControlDefinitionRegist AzureDevOps.definition, ], { - claimChangeRequestUrlsFirst: [GitCafe.definition.kind, Tangled.definition.kind, AzureDevOps.definition.kind], + claimChangeRequestUrlsFirst: [ + GitCafe.definition.kind, + Tangled.definition.kind, + AzureDevOps.definition.kind, + ], recognizeRemotesIn: [ GitCafe.definition.kind, Tangled.definition.kind, diff --git a/packages/source-control-tangled/package.json b/packages/source-control-tangled/package.json index 9c8480dee3be3..8e2d213f2bcf2 100644 --- a/packages/source-control-tangled/package.json +++ b/packages/source-control-tangled/package.json @@ -3,29 +3,13 @@ "private": true, "type": "module", "exports": { - "./client/definition": { - "types": "./src/client/definition.ts", - "import": "./src/client/definition.ts" + "./shared/definition": { + "types": "./src/shared/definition.ts", + "import": "./src/shared/definition.ts" }, "./server/driver": { "types": "./src/server/driver.ts", "import": "./src/server/driver.ts" - }, - "./server/TangledCli": { - "types": "./src/server/TangledCli.ts", - "import": "./src/server/TangledCli.ts" - }, - "./server/TangledSourceControlProvider": { - "types": "./src/server/TangledSourceControlProvider.ts", - "import": "./src/server/TangledSourceControlProvider.ts" - }, - "./server/TangledPullRequestProvider": { - "types": "./src/server/TangledPullRequestProvider.ts", - "import": "./src/server/TangledPullRequestProvider.ts" - }, - "./server/tangledPulls": { - "types": "./src/server/tangledPulls.ts", - "import": "./src/server/tangledPulls.ts" } }, "scripts": { diff --git a/packages/source-control-tangled/src/server/TangledCli.ts b/packages/source-control-tangled/src/server/TangledCli.ts index d426bcec2b877..71b67551d1150 100644 --- a/packages/source-control-tangled/src/server/TangledCli.ts +++ b/packages/source-control-tangled/src/server/TangledCli.ts @@ -9,7 +9,7 @@ import { decodeJsonResult } from "@t3tools/shared/schemaJson"; import { readSourceControlHostSettings } from "@t3tools/source-control-core/shared/definition"; import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; -import * as TangledClient from "../client/definition.ts"; +import * as TangledClient from "../shared/definition.ts"; /** `tg` is one binary on PATH; nothing here installs or updates it. */ export const TANGLED_CLI = "tg"; @@ -49,7 +49,7 @@ export const TangledRepositorySchema = Schema.Struct({ * `warning:` line is advisory — `tg repo view` prints one when a repository has no pipelines — so * it is not what a caller is told went wrong. */ -export function tangledErrorMessage(result: { +function tangledErrorMessage(result: { readonly stderr: string; readonly stdout: string; }): string | undefined { @@ -122,7 +122,7 @@ function environmentEnv(knot: string, appview: string): Readonly - Effect.gen(function* () { - const pulls = yield* listed({ - operation: "listChangeRequests", - cwd: ".", - repository, - }); - if (pulls.length === 0) return 0; - const html = yield* Effect.gen(function* () { - const response = yield* httpClient.execute( - HttpClientRequest.get(`https://tangled.org/${repository}/pulls?q=state%3Aall`).pipe( - HttpClientRequest.setHeader("accept", "text/html"), - ), - ); - if (response.status < 200 || response.status >= 300) return null; - return (yield* collectUint8StreamText({ - stream: response.stream, - maxBytes: 4 * 1024 * 1024, - })).text; - }).pipe( - // A page that cannot be read leaves the numbering unknown, which reads as a repository - // whose pull requests are not listed rather than as a failure to report. - Effect.orElseSucceed(() => null), + lookup: Effect.fn("TangledPullRequestProvider.offset")(function* (repository: string) { + const pulls = yield* listed({ + operation: "listChangeRequests", + cwd: ".", + repository, + }); + if (pulls.length === 0) return 0; + const html = yield* Effect.gen(function* () { + const response = yield* httpClient.execute( + HttpClientRequest.get(`https://tangled.org/${repository}/pulls?q=state%3Aall`).pipe( + HttpClientRequest.setHeader("accept", "text/html"), + ), ); - return html === null ? null : offsetFromRows(parsePullPageRows(html, repository), pulls); - }), + if (response.status < 200 || response.status >= 300) return null; + return (yield* collectUint8StreamText({ + stream: response.stream, + maxBytes: 4 * 1024 * 1024, + })).text; + }).pipe( + // A page that cannot be read leaves the numbering unknown, which reads as a repository + // whose pull requests are not listed rather than as a failure to report. + Effect.orElseSucceed(() => null), + ); + return html === null ? null : offsetFromRows(parsePullPageRows(html, repository), pulls); + }), capacity: OFFSET_CACHE_CAPACITY, timeToLive: OFFSET_TTL, }); diff --git a/packages/source-control-tangled/src/server/TangledSourceControlProvider.test.ts b/packages/source-control-tangled/src/server/TangledSourceControlProvider.test.ts index 6ad554e58cb51..58ea4ad379f8c 100644 --- a/packages/source-control-tangled/src/server/TangledSourceControlProvider.test.ts +++ b/packages/source-control-tangled/src/server/TangledSourceControlProvider.test.ts @@ -10,7 +10,7 @@ import type * as SourceControlHost from "@t3tools/source-control-core/server/Sou import { PullRequestProviderError } from "@t3tools/source-control-core/server/PullRequestProvider"; import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; -import * as TangledClient from "../client/definition.ts"; +import * as TangledClient from "../shared/definition.ts"; import * as TangledCli from "./TangledCli.ts"; import * as TangledSourceControlProvider from "./TangledSourceControlProvider.ts"; diff --git a/packages/source-control-tangled/src/server/TangledSourceControlProvider.ts b/packages/source-control-tangled/src/server/TangledSourceControlProvider.ts index 5400a2ce6e291..06e9db3afbdcc 100644 --- a/packages/source-control-tangled/src/server/TangledSourceControlProvider.ts +++ b/packages/source-control-tangled/src/server/TangledSourceControlProvider.ts @@ -16,7 +16,7 @@ import { import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; -import * as TangledClient from "../client/definition.ts"; +import * as TangledClient from "../shared/definition.ts"; import * as TangledPullRequestProvider from "./TangledPullRequestProvider.ts"; import { isTangledCliError, @@ -28,17 +28,6 @@ import { export const KIND = SourceControlProviderKind.make("tangled"); -/** tangled.org serves the repository pages; `tangled.sh` and each knot serve the same trees. */ -export function isTangledHostname(hostname: string): boolean { - const host = hostname.toLowerCase(); - return ( - host === "tangled.org" || - host === "tangled.sh" || - host.endsWith(".tangled.org") || - host.endsWith(".tangled.sh") - ); -} - /** `tg auth status --json` names the account and the DID when one is signed in. */ export function parseAuth(input: SourceControlAuthProbeInput) { const decoded = decodeJsonResult(TangledAuthStatusSchema)(input.stdout); @@ -96,7 +85,7 @@ function cloneUrls(repository: string): SourceControlRepositoryCloneUrls { * is the shape most real remotes have — and both forms are served by tangled.org and accepted by * git, which is what lets every URL here be derived from the address instead of looked up. */ -export function isTangledRepositoryDid(address: string): boolean { +function isTangledRepositoryDid(address: string): boolean { return /^did:[a-z0-9]+:[a-z0-9]+$/iu.test(address.trim()); } @@ -104,7 +93,7 @@ export function isTangledRepositoryDid(address: string): boolean { * The path below a remote's host. The shared reader needs at least one `/` between segments, * which a repository DID does not have, so a single-segment remote is read here instead. */ -export function repositoryPathFromTangledRemote(url: string): string | null { +function repositoryPathFromTangledRemote(url: string): string | null { const shared = SourceControlProvider.repositoryPathFromRemoteUrl(url); if (shared !== null) return shared; const single = @@ -120,13 +109,7 @@ export function defaultBranchFromSymref(stdout: string): string | null { return match?.[1] ?? null; } -/** - * Tangled pull requests are addressed two ways that do not meet: `tg` takes an AT Protocol record - * key (`3mkaewigwhi22`), while tangled.org's pages are numbered from a per-knot database - * sequence (`/pulls/19`). `ChangeRequest.number` is the number, and no public endpoint maps one - * to the other, so this build cannot name a pull request without inventing an id the user would - * then see as a wrong link. These fail rather than answer with a number that does not exist. - */ +// `tg pr checkout` takes record keys; this interface receives the page's numeric identifier. function unsupported(operation: string, cwd: string) { return Effect.fail( new SourceControlProviderError({ @@ -178,7 +161,7 @@ export function createsUnderOwner(input: { * The address a caller typed or pasted: `handle/repo`, a repository DID, or a URL naming one, * since the clone picker accepts whatever is pasted into it. */ -export function repositoryAddress(repository: string): string | null { +function repositoryAddress(repository: string): string | null { const trimmed = repository .trim() .replace(/\.git$/iu, "") @@ -238,8 +221,8 @@ export const make = Effect.gen(function* () { kind: KIND, repositoryNameFromRemoteUrl: (url) => repositoryPathFromTangledRemote(url), - getRepositoryCloneUrls: (input) => - Effect.gen(function* () { + getRepositoryCloneUrls: Effect.fn("TangledSourceControlProvider.getRepositoryCloneUrls")( + function* (input) { const repository = yield* repositoryFrom({ cwd: input.cwd, repository: input.repository, @@ -258,83 +241,82 @@ export const make = Effect.gen(function* () { .pipe(Effect.mapError((cause) => fail("getRepositoryCloneUrls", input.cwd, cause))); } return cloneUrls(repository); - }), + }, + ), - createRepository: (input) => - Effect.gen(function* () { - const segments = input.repository.split("/"); - const [owner, name] = segments; - if (segments.length !== 2 || !owner || !name) { - return yield* notNamed( - "createRepository", - input.cwd, - "Name a Tangled repository as handle/repo.", - ); - } - const account = yield* cli - .runJson({ - cwd: input.cwd, - args: ["auth", "status"], - schema: TangledAuthStatusSchema, - }) - .pipe(Effect.mapError((cause) => fail("createRepository", input.cwd, cause))); - if ( - !account.authenticated || - account.status === "expired" || - account.status === "unknown" || - (owner !== account.handle && owner !== account.did) - ) { - return yield* notNamed( - "createRepository", - input.cwd, - `Cannot confirm Tangled is signed in as ${owner} (reported account: ${account.handle ?? account.did ?? "unknown"}). Run \`tg auth login ${owner}\` and retry.`, - ); - } - // Tangled has no private repositories, so `visibility` is not something `tg` can be asked - // for; a knot may still refuse the name, which surfaces as this operation's error. - const created = yield* cli - .runJson({ - cwd: input.cwd, - args: ["repo", "create", name], - schema: TangledRepositorySchema, - }) - .pipe(Effect.mapError((cause) => fail("createRepository", input.cwd, cause))); - // `tg` always creates under the signed-in account. Say so rather than hand back URLs for - // a repository that was never made where the caller asked. - if (!createsUnderOwner({ requested: owner, created: created.author })) { - return yield* notNamed( - "createRepository", - input.cwd, - `tg created ${name} under ${created.author ?? "your account"}, not ${owner}. Create it on tangled.org to own it as ${owner}.`, - ); - } - return cloneUrls(input.repository); - }), + createRepository: Effect.fn("TangledSourceControlProvider.createRepository")(function* (input) { + const segments = input.repository.split("/"); + const [owner, name] = segments; + if (segments.length !== 2 || !owner || !name) { + return yield* notNamed( + "createRepository", + input.cwd, + "Name a Tangled repository as handle/repo.", + ); + } + const account = yield* cli + .runJson({ + cwd: input.cwd, + args: ["auth", "status"], + schema: TangledAuthStatusSchema, + }) + .pipe(Effect.mapError((cause) => fail("createRepository", input.cwd, cause))); + if ( + !account.authenticated || + account.status === "expired" || + account.status === "unknown" || + (owner !== account.handle && owner !== account.did) + ) { + return yield* notNamed( + "createRepository", + input.cwd, + `Cannot confirm Tangled is signed in as ${owner} (reported account: ${account.handle ?? account.did ?? "unknown"}). Run \`tg auth login ${owner}\` and retry.`, + ); + } + // Tangled has no private repositories, so `visibility` is not something `tg` can be asked + // for; a knot may still refuse the name, which surfaces as this operation's error. + const created = yield* cli + .runJson({ + cwd: input.cwd, + args: ["repo", "create", name], + schema: TangledRepositorySchema, + }) + .pipe(Effect.mapError((cause) => fail("createRepository", input.cwd, cause))); + // `tg` always creates under the signed-in account. Say so rather than hand back URLs for + // a repository that was never made where the caller asked. + if (!createsUnderOwner({ requested: owner, created: created.author })) { + return yield* notNamed( + "createRepository", + input.cwd, + `tg created ${name} under ${created.author ?? "your account"}, not ${owner}. Create it on tangled.org to own it as ${owner}.`, + ); + } + return cloneUrls(input.repository); + }), - getDefaultBranch: (input) => - Effect.gen(function* () { - yield* repositoryFrom({ cwd: input.cwd, context: input.context }); - const remoteName = input.context?.remoteName; - if (remoteName === undefined) { - return yield* notNamed( - "getDefaultBranch", - input.cwd, - "This project has no Tangled remote.", - ); - } - // `tg repo view` reports no default branch, so git's own symref is the source. - const listing = yield* git - .execute({ - operation: "TangledSourceControlProvider.getDefaultBranch", - cwd: input.cwd, - args: ["ls-remote", "--symref", remoteName, "HEAD"], - }) - .pipe(Effect.mapError((cause) => fail("getDefaultBranch", input.cwd, cause))); - return defaultBranchFromSymref(listing.stdout); - }), + getDefaultBranch: Effect.fn("TangledSourceControlProvider.getDefaultBranch")(function* (input) { + yield* repositoryFrom({ cwd: input.cwd, context: input.context }); + const remoteName = input.context?.remoteName; + if (remoteName === undefined) { + return yield* notNamed( + "getDefaultBranch", + input.cwd, + "This project has no Tangled remote.", + ); + } + // `tg repo view` reports no default branch, so git's own symref is the source. + const listing = yield* git + .execute({ + operation: "TangledSourceControlProvider.getDefaultBranch", + cwd: input.cwd, + args: ["ls-remote", "--symref", remoteName, "HEAD"], + }) + .pipe(Effect.mapError((cause) => fail("getDefaultBranch", input.cwd, cause))); + return defaultBranchFromSymref(listing.stdout); + }), - listChangeRequests: (input) => - Effect.gen(function* () { + listChangeRequests: Effect.fn("TangledSourceControlProvider.listChangeRequests")( + function* (input) { const repository = yield* repositoryFrom(input); const page = yield* pullRequests .listChangeRequests({ @@ -372,10 +354,11 @@ export const make = Effect.gen(function* () { state: pull.state, updatedAt: DateTime.make(pull.updatedAt), })); - }), + }, + ), - createChangeRequest: (input) => - Effect.gen(function* () { + createChangeRequest: Effect.fn("TangledSourceControlProvider.createChangeRequest")( + function* (input) { const source = SourceControlProvider.sourceControlRefFromInput(input); const headBranch = SourceControlProvider.sourceBranch(input); const address = yield* repositoryFrom({ @@ -422,9 +405,14 @@ export const make = Effect.gen(function* () { yield* cli .run({ cwd: input.cwd, args }) .pipe(Effect.mapError((cause) => fail("createChangeRequest", input.cwd, cause))); - }), + }, + ), - getChangeRequest: (input) => unsupported("getChangeRequest", input.cwd), - checkoutChangeRequest: (input) => unsupported("checkoutChangeRequest", input.cwd), + getChangeRequest: Effect.fn("TangledSourceControlProvider.getChangeRequest")((input) => + unsupported("getChangeRequest", input.cwd), + ), + checkoutChangeRequest: Effect.fn("TangledSourceControlProvider.checkoutChangeRequest")( + (input) => unsupported("checkoutChangeRequest", input.cwd), + ), }); }); diff --git a/packages/source-control-tangled/src/server/driver.ts b/packages/source-control-tangled/src/server/driver.ts index d386900171ceb..271e07cdda974 100644 --- a/packages/source-control-tangled/src/server/driver.ts +++ b/packages/source-control-tangled/src/server/driver.ts @@ -5,10 +5,13 @@ import { SourceControlProviderKind } from "@t3tools/contracts"; * * @module source-control-tangled/server/driver */ -import { defineSourceControlDriver, withOwnServices } from "@t3tools/source-control-core/server/driver"; +import { + defineSourceControlDriver, + withOwnServices, +} from "@t3tools/source-control-core/server/driver"; import * as Effect from "effect/Effect"; -import { definition } from "../client/definition.ts"; +import { definition } from "../shared/definition.ts"; import * as TangledCli from "./TangledCli.ts"; import * as TangledPullRequestProvider from "./TangledPullRequestProvider.ts"; @@ -17,9 +20,12 @@ import * as TangledSourceControlProvider from "./TangledSourceControlProvider.ts export const driver = defineSourceControlDriver({ kind: SourceControlProviderKind.make("tangled"), definition, - make: withOwnServices(Effect.all({ - sourceControl: TangledSourceControlProvider.make, - discovery: Effect.succeed(TangledSourceControlProvider.discovery), - pullRequests: TangledPullRequestProvider.make, - }), TangledCli.layer), + make: withOwnServices( + Effect.all({ + sourceControl: TangledSourceControlProvider.make, + discovery: Effect.succeed(TangledSourceControlProvider.discovery), + pullRequests: TangledPullRequestProvider.make, + }), + TangledCli.layer, + ), }); diff --git a/packages/source-control-tangled/src/client/definition.ts b/packages/source-control-tangled/src/shared/definition.ts similarity index 59% rename from packages/source-control-tangled/src/client/definition.ts rename to packages/source-control-tangled/src/shared/definition.ts index 1be81c673e3e5..0e4529cc91edd 100644 --- a/packages/source-control-tangled/src/client/definition.ts +++ b/packages/source-control-tangled/src/shared/definition.ts @@ -1,8 +1,3 @@ -/** - * Tangled's client definition. Browser- and React Native-safe. - * - * @module source-control-tangled/client/definition - */ import { makeProviderSettingsSchema, SourceControlProviderKind, @@ -63,13 +58,20 @@ export const definition = defineSourceControl({ label: "Tangled", pickerLabel: "Tangled", icon: { - viewBox: "0 0 24 24", - paths: [{ - fill: "currentColor", - d: "M21.0971 30.866C20.0566 30.8575 19.2628 30.5542 18.4016 30.0269C17.1668 29.3753 16.2237 28.2808 15.5497 27.0739C14.4789 28.4065 13.0476 29.215 11.4453 29.6718C10.763 29.8705 9.56809 30.0721 7.58737 29.3523C4.73277 28.3905 2.65342 25.4114 2.88973 22.3758C2.8465 21.1175 3.30392 19.8825 3.95228 18.8208C2.22264 17.8897 0.81225 16.3266 0.272148 14.4098C-0.0560731 13.3604 -0.042271 12.2299 0.0787626 11.1512C0.512215 8.60429 2.41697 6.38956 4.86912 5.59294C5.8479 3.35574 7.98378 1.68743 10.4037 1.34778C12.0104 1.12338 13.6735 1.46075 15.0792 2.27979C17.1272 0.00158595 20.6952 -0.671697 23.4195 0.727793C25.4978 1.72322 26.9839 3.80003 27.3447 6.06471C29.3222 6.85928 30.9877 8.47971 31.6413 10.5368C32.0784 11.8104 32.0928 13.2132 31.8098 14.5209C31.3041 16.5615 29.8679 18.2987 28.009 19.2482C28.0135 19.6113 29.2037 22.2296 29.0047 24.2056C28.9612 26.676 27.399 29.0172 25.2325 30.1544C23.9683 30.8945 22.4702 30.8805 21.0971 30.866ZM15.1733 23.755C16.9256 23.5593 18.0743 22.0269 18.9665 20.6469C19.3883 20.0182 19.7105 19.3146 20.0306 18.6454C20.4458 19.0271 20.7975 19.7461 21.4541 19.9173C22.1457 20.1333 22.9566 19.9579 23.38 19.3277C24.1902 17.8118 23.7908 15.9827 23.319 14.4119C23.0284 13.5097 22.6472 12.5841 21.9218 11.9446C22.0765 10.85 21.4299 9.73834 20.5106 9.16542C19.7272 9.79198 18.5352 9.78821 17.7794 9.11795C16.3309 10.5997 15.0034 10.5505 13.7212 9.37618C13.4331 9.11226 12.8832 10.9871 10.9535 9.92506C9.84488 10.8567 8.98526 11.753 8.22356 13.0435C7.48342 14.4347 6.70829 15.6703 6.64151 17.1811C6.6094 18.0641 7.29731 18.9892 8.22942 18.9174C9.16105 19.0009 9.7952 18.0813 10.5006 17.6993C10.6058 18.9316 10.7243 20.2556 11.1395 21.4587C11.6161 23.0155 13.2947 24.005 14.8835 23.7784C14.9959 23.7696 15.1733 23.7549 15.1733 23.755ZM16.0828 19.1062C15.2306 18.5823 15.6407 17.4452 15.6066 16.6193C15.6914 15.6227 15.7594 14.575 16.2061 13.667C16.6788 13.0197 17.8318 13.2694 17.8827 14.0999C17.8488 14.9353 17.4664 15.767 17.5121 16.633C17.4129 17.3561 17.5839 18.1684 17.265 18.8293C17.0033 19.195 16.4703 19.3013 16.0828 19.1062ZM12.3606 18.6302C11.5578 18.1933 11.8129 17.0941 11.687 16.3298C11.7914 15.445 11.7045 14.3226 12.4431 13.7021C13.1653 13.1969 14.1485 14.0621 13.8069 14.8564C13.4426 15.8602 13.6814 16.957 13.6891 17.9748C13.5512 18.5752 12.911 18.894 12.3606 18.6302Z", - }], + viewBox: "0 0 32 32", + paths: [ + { + fill: "currentColor", + d: "M21.0971 30.866C20.0566 30.8575 19.2628 30.5542 18.4016 30.0269C17.1668 29.3753 16.2237 28.2808 15.5497 27.0739C14.4789 28.4065 13.0476 29.215 11.4453 29.6718C10.763 29.8705 9.56809 30.0721 7.58737 29.3523C4.73277 28.3905 2.65342 25.4114 2.88973 22.3758C2.8465 21.1175 3.30392 19.8825 3.95228 18.8208C2.22264 17.8897 0.81225 16.3266 0.272148 14.4098C-0.0560731 13.3604 -0.042271 12.2299 0.0787626 11.1512C0.512215 8.60429 2.41697 6.38956 4.86912 5.59294C5.8479 3.35574 7.98378 1.68743 10.4037 1.34778C12.0104 1.12338 13.6735 1.46075 15.0792 2.27979C17.1272 0.00158595 20.6952 -0.671697 23.4195 0.727793C25.4978 1.72322 26.9839 3.80003 27.3447 6.06471C29.3222 6.85928 30.9877 8.47971 31.6413 10.5368C32.0784 11.8104 32.0928 13.2132 31.8098 14.5209C31.3041 16.5615 29.8679 18.2987 28.009 19.2482C28.0135 19.6113 29.2037 22.2296 29.0047 24.2056C28.9612 26.676 27.399 29.0172 25.2325 30.1544C23.9683 30.8945 22.4702 30.8805 21.0971 30.866ZM15.1733 23.755C16.9256 23.5593 18.0743 22.0269 18.9665 20.6469C19.3883 20.0182 19.7105 19.3146 20.0306 18.6454C20.4458 19.0271 20.7975 19.7461 21.4541 19.9173C22.1457 20.1333 22.9566 19.9579 23.38 19.3277C24.1902 17.8118 23.7908 15.9827 23.319 14.4119C23.0284 13.5097 22.6472 12.5841 21.9218 11.9446C22.0765 10.85 21.4299 9.73834 20.5106 9.16542C19.7272 9.79198 18.5352 9.78821 17.7794 9.11795C16.3309 10.5997 15.0034 10.5505 13.7212 9.37618C13.4331 9.11226 12.8832 10.9871 10.9535 9.92506C9.84488 10.8567 8.98526 11.753 8.22356 13.0435C7.48342 14.4347 6.70829 15.6703 6.64151 17.1811C6.6094 18.0641 7.29731 18.9892 8.22942 18.9174C9.16105 19.0009 9.7952 18.0813 10.5006 17.6993C10.6058 18.9316 10.7243 20.2556 11.1395 21.4587C11.6161 23.0155 13.2947 24.005 14.8835 23.7784C14.9959 23.7696 15.1733 23.7549 15.1733 23.755ZM16.0828 19.1062C15.2306 18.5823 15.6407 17.4452 15.6066 16.6193C15.6914 15.6227 15.7594 14.575 16.2061 13.667C16.6788 13.0197 17.8318 13.2694 17.8827 14.0999C17.8488 14.9353 17.4664 15.767 17.5121 16.633C17.4129 17.3561 17.5839 18.1684 17.265 18.8293C17.0033 19.195 16.4703 19.3013 16.0828 19.1062ZM12.3606 18.6302C11.5578 18.1933 11.8129 17.0941 11.687 16.3298C11.7914 15.445 11.7045 14.3226 12.4431 13.7021C13.1653 13.1969 14.1485 14.0621 13.8069 14.8564C13.4426 15.8602 13.6814 16.957 13.6891 17.9748C13.5512 18.5752 12.911 18.894 12.3606 18.6302Z", + }, + ], + }, + changeRequest: { + shortLabel: "PR", + shortLabelWithArticle: "a PR", + singular: "pull request", + plural: "pull requests", }, - changeRequest: { shortLabel: "PR", shortLabelWithArticle: "a PR", singular: "pull request", plural: "pull requests" }, repositoryPathHint: "handle/repo", // `handle.knot.tangled.sh` remotes are addressed by the proxy, so a bare hostname does not // name this host the way tangled.org and its knots do. @@ -107,11 +109,6 @@ export const definition = defineSourceControl({ settings, }); -/** The URL a repository's pages are served from, from its `handle/repo` path. */ -export function repositoryWebUrl(repository: string): string { - return `https://tangled.org/${repository}`; -} - /** The SSH remote `tg` writes, which the tangled.org proxy resolves to the owning knot. */ export function repositorySshUrl(repository: string): string { return `git@tangled.org:${repository}`; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 93fc3a9060c30..91b9f0754e6f6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -901,6 +901,9 @@ importers: '@t3tools/source-control-gitlab': specifier: workspace:* version: link:../../packages/source-control-gitlab + '@t3tools/source-control-tangled': + specifier: workspace:* + version: link:../../packages/source-control-tangled character-entities: specifier: 2.0.2 version: 2.0.2 @@ -2190,6 +2193,9 @@ importers: '@t3tools/source-control-gitlab': specifier: workspace:* version: link:../source-control-gitlab + '@t3tools/source-control-tangled': + specifier: workspace:* + version: link:../source-control-tangled packages/source-control-forgejo: dependencies: @@ -2321,6 +2327,40 @@ importers: specifier: 1.4.0 version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + packages/source-control-tangled: + dependencies: + '@t3tools/contracts': + specifier: workspace:* + version: link:../contracts + '@t3tools/provider-core': + specifier: workspace:* + version: link:../provider-core + '@t3tools/shared': + specifier: workspace:* + version: link:../shared + '@t3tools/source-control-core': + specifier: workspace:* + version: link:../source-control-core + effect: + specifier: 4.0.5 + version: 4.0.5 + entities: + specifier: 4.5.0 + version: 4.5.0 + devDependencies: + '@effect/vitest': + specifier: 4.0.5 + version: 4.0.5(patch_hash=2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.0.5)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + '@t3tools/source-control-testing': + specifier: workspace:* + version: link:../source-control-testing + '@types/node': + specifier: 24.12.4 + version: 24.12.4 + vite-plus: + specifier: 1.4.0 + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + packages/source-control-testing: dependencies: '@effect/platform-node': From f05cf61ba153a5dde14ddbcedfefd49c44361a5e Mon Sep 17 00:00:00 2001 From: David Marcos Date: Mon, 2 Nov 2026 11:08:28 +0100 Subject: [PATCH 11/12] fix(source-control): support updated Tangled lexicons --- docs/user/source-control.md | 7 +- .../src/server/TangledCli.ts | 7 +- .../server/TangledPullRequestProvider.test.ts | 70 ++++++++++++++++++- .../src/server/TangledPullRequestProvider.ts | 13 ++++ .../TangledSourceControlProvider.test.ts | 4 +- .../src/shared/definition.ts | 2 +- 6 files changed, 93 insertions(+), 10 deletions(-) diff --git a/docs/user/source-control.md b/docs/user/source-control.md index d8ccabf7bc1b4..2c418f1e13e0b 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -131,16 +131,19 @@ Merging a stack lands it through GitCafe, and updating a branch restacks the lay ### Tangled -Install the [Tangled CLI (`tg`)](https://tangled.org/aly.codes/tg) and sign in: +Install v0.8.0 or later of the [Tangled CLI (`tg`)](https://tangled.org/aly.codes/tg) and sign in: ```bash tg auth login ``` +After upgrading from an older release, sign in again if `tg` reports that OAuth permissions changed. + Git push and clone use SSH, so add a key under **Settings → Keys** on tangled.org first. **Settings → Source Control → Tangled** holds the two values `tg` needs: the **knot** that hosts new repositories (`knot1.tangled.sh` by default) and the **appview** that resolves handles and -repositories (`https://api.next.tangled.org` by default). Leave a field blank to let `tg` use its +repositories (`https://api.tangled.org` by default). If you previously saved +`https://api.next.tangled.org`, update the Appview field. Leave a field blank to let `tg` use its own configuration in `~/.config/tg/config.toml`. Tangled has no private repositories, so a project published to it is public. diff --git a/packages/source-control-tangled/src/server/TangledCli.ts b/packages/source-control-tangled/src/server/TangledCli.ts index 71b67551d1150..6648b71fa42b1 100644 --- a/packages/source-control-tangled/src/server/TangledCli.ts +++ b/packages/source-control-tangled/src/server/TangledCli.ts @@ -163,9 +163,10 @@ const make = Effect.gen(function* () { .pipe(Effect.mapError((cause: VcsError) => fromVcsError(input.cwd, cause))); if (result.exitCode !== 0) { const detail = tangledErrorMessage(result) ?? `tg ${input.args[0] ?? ""} failed.`; - const authentication = /invalid_grant|not authenticated|not logged in|session expired/iu.test( - detail, - ); + const authentication = + /invalid_grant|not authenticated|not logged in|session expired|OAuth permissions changed/iu.test( + detail, + ); return yield* new TangledCliError({ cwd: input.cwd, reason: authentication ? "authentication" : "failed", diff --git a/packages/source-control-tangled/src/server/TangledPullRequestProvider.test.ts b/packages/source-control-tangled/src/server/TangledPullRequestProvider.test.ts index 3db853a2f229b..d76f1be82ec2b 100644 --- a/packages/source-control-tangled/src/server/TangledPullRequestProvider.test.ts +++ b/packages/source-control-tangled/src/server/TangledPullRequestProvider.test.ts @@ -7,14 +7,17 @@ import * as Cli from "./TangledCli.ts"; import * as Provider from "./TangledPullRequestProvider.ts"; import { ChildProcessSpawner } from "effect/process"; -it.effect("preserves OAuth refresh failures as an authentication error", () => { +it.effect.each([ + "Error: failed to refresh OAuth tokens: token refresh failed: invalid_grant", + 'Error: OAuth permissions changed; signed out alice.example; run "tg auth login alice.example" again', +])("preserves authentication failures: %s", (stderr) => { const host = Host.layer({ process: { run: () => Effect.succeed( Host.processOutput("", { exitCode: ChildProcessSpawner.ExitCode(1), - stderr: "Error: failed to refresh OAuth tokens: token refresh failed: invalid_grant", + stderr, }), ), }, @@ -33,6 +36,69 @@ it.effect("preserves OAuth refresh failures as an authentication error", () => { ); }); +it.effect("uses numeric ticket keys for PR numbers without reading the web page", () => { + const older = { + rkey: "9", + uri: "at://did:plc:repo/org.tangled.track.ticket/9", + title: "Same title", + state: "open", + createdAt: "2026-10-12T12:00:00Z", + sourceBranch: "old", + targetBranch: "main", + }; + const newest = { + ...older, + rkey: "11", + uri: "at://did:plc:repo/org.tangled.track.ticket/11", + state: "closed", + commentCount: 2, + }; + const host = Host.layer({ + process: { + run: (input) => { + if (input.args[1] === "list") + return Effect.succeed(Host.processOutput(JSON.stringify({ items: [newest, older] }))); + if (input.args[1] === "view" && input.args[2] === "11") + return Effect.succeed( + Host.processOutput(JSON.stringify({ ...newest, body: "Actual PR description" })), + ); + return Effect.die("Unexpected tg command"); + }, + }, + }); + const client = HttpClient.make(() => Effect.die("Ticket numbers require no HTML lookup")); + return Effect.gen(function* () { + const provider = yield* Provider.make; + const reference = { + cwd: "/repo", + host: "tangled.org", + repository: "alice.example/repo", + number: 11, + }; + const list = yield* provider.listChangeRequests({ + ...reference, + state: "all", + involvement: "all", + viewer: "alice.example", + limit: 10, + }); + assert.deepStrictEqual( + list.items.map((pull) => [pull.number, pull.url]), + [ + [9, "https://tangled.org/alice.example/repo/pulls/9"], + [11, "https://tangled.org/alice.example/repo/pulls/11"], + ], + ); + const detail = yield* provider.getChangeRequest(reference); + assert.strictEqual(detail.body, "Actual PR description"); + assert.strictEqual(detail.number, 11); + assert.strictEqual(detail.state, "closed"); + }).pipe( + Effect.provide(Cli.layer.pipe(Layer.provideMerge(host))), + Effect.provideService(HttpClient.HttpClient, client), + ); +}); + it.effect("reads a closed PR's activity and details through its list number and record key", () => { const calls: Array> = []; const older = { diff --git a/packages/source-control-tangled/src/server/TangledPullRequestProvider.ts b/packages/source-control-tangled/src/server/TangledPullRequestProvider.ts index a2a793b1492dd..c2df83e1854f9 100644 --- a/packages/source-control-tangled/src/server/TangledPullRequestProvider.ts +++ b/packages/source-control-tangled/src/server/TangledPullRequestProvider.ts @@ -171,6 +171,19 @@ export const make = Effect.gen(function* () { }) { const pulls = yield* listed(input); if (pulls.length === 0) return []; + // org.tangled.track.ticket keys are repository-local numbers, with gaps for issues. + const tickets = pulls.map((entry) => ({ + entry, + number: /^[1-9]\d*$/u.test(entry.pull.rkey) ? Number(entry.pull.rkey) : NaN, + })); + if (tickets.every(({ number }) => Number.isSafeInteger(number))) { + return tickets + .sort((a, b) => a.number - b.number) + .map(({ entry, number }) => ({ + entry, + changeRequest: toChangeRequest(entry, number, input.repository), + })); + } const offset = yield* offsets.lookup(input.repository); if (offset === null) { // A repository may gain enough rows to resolve its numbering on the next attempt. diff --git a/packages/source-control-tangled/src/server/TangledSourceControlProvider.test.ts b/packages/source-control-tangled/src/server/TangledSourceControlProvider.test.ts index 58ea4ad379f8c..e4ebb8cbdb456 100644 --- a/packages/source-control-tangled/src/server/TangledSourceControlProvider.test.ts +++ b/packages/source-control-tangled/src/server/TangledSourceControlProvider.test.ts @@ -752,7 +752,7 @@ describe("TangledSourceControlProvider.make", () => { describe("TangledCli environment flags", () => { const signedIn = encodeJson({ authenticated: true, handle: "alice.example" }); - it.effect("defaults to the appview and knot the settings schema carries", () => { + it.effect("defaults to the current Tangled appview and knot", () => { const runs: Array<{ readonly args: ReadonlyArray; readonly env: unknown }> = []; const layer = TestSourceControlHost.layer({ process: { @@ -773,7 +773,7 @@ describe("TangledCli environment flags", () => { assert.deepStrictEqual(runs[0]?.args, ["auth", "status", "--json"]); // `tg` has no global `--knot`, so both reach it through the environment it documents. assert.deepStrictEqual(runs[0]?.env, { - TG_APPVIEW: TangledClient.DEFAULT_APPVIEW, + TG_APPVIEW: "https://api.tangled.org", TG_KNOT: TangledClient.DEFAULT_KNOT, }); }).pipe(Effect.provide(TangledCli.layer.pipe(Layer.provideMerge(layer)))); diff --git a/packages/source-control-tangled/src/shared/definition.ts b/packages/source-control-tangled/src/shared/definition.ts index 0e4529cc91edd..53293389b4df8 100644 --- a/packages/source-control-tangled/src/shared/definition.ts +++ b/packages/source-control-tangled/src/shared/definition.ts @@ -19,7 +19,7 @@ const KIND = SourceControlProviderKind.make("tangled"); * The appview and knot `tg` reads when it has nothing else configured. Both are overridable in * Source Control settings, which is how a self-hosted appview or a different knot is reached. */ -export const DEFAULT_APPVIEW = "https://api.next.tangled.org"; +const DEFAULT_APPVIEW = "https://api.tangled.org"; export const DEFAULT_KNOT = "knot1.tangled.sh"; export const settings = makeProviderSettingsSchema({ From 5c1be46f62229c4b0f340f370a85e2ab6cfd1509 Mon Sep 17 00:00:00 2001 From: David Marcos Date: Mon, 2 Nov 2026 22:37:27 +0100 Subject: [PATCH 12/12] test(web): wait for pull request summary text assertions --- .../PullRequestSummaryTab.browser.test.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestSummaryTab.browser.test.tsx b/apps/web/src/components/pullRequest/PullRequestSummaryTab.browser.test.tsx index 3be2b4e39b941..856f27143b75f 100644 --- a/apps/web/src/components/pullRequest/PullRequestSummaryTab.browser.test.tsx +++ b/apps/web/src/components/pullRequest/PullRequestSummaryTab.browser.test.tsx @@ -116,11 +116,13 @@ it("explains that comment text is unavailable and directs to host when comments comments: [], commentsTruncated: true, }); - const renderedTexts = screen.visibleText(); - expect(renderedTexts.toLowerCase()).not.toContain("longer"); - expect(renderedTexts.toLowerCase()).not.toContain("no comments"); - expect(renderedTexts.toLowerCase()).toContain("host"); - expect(renderedTexts).toContain( - "Comments are not available here. Open this pull request on the host to read them.", - ); + await vi.waitFor(() => { + const renderedTexts = screen.visibleText(); + expect(renderedTexts.toLowerCase()).not.toContain("longer"); + expect(renderedTexts.toLowerCase()).not.toContain("no comments"); + expect(renderedTexts.toLowerCase()).toContain("host"); + expect(renderedTexts).toContain( + "Comments are not available here. Open this pull request on the host to read them.", + ); + }); });