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..e894691bcd7c2 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -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..856f27143b75f 100644 --- a/apps/web/src/components/pullRequest/PullRequestSummaryTab.browser.test.tsx +++ b/apps/web/src/components/pullRequest/PullRequestSummaryTab.browser.test.tsx @@ -108,3 +108,21 @@ 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, + }); + 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.", + ); + }); +}); diff --git a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx index f8084aa550ad3..bf154ba85d678 100644 --- a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx @@ -913,12 +913,21 @@ 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..2c418f1e13e0b 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,33 @@ 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 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.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. + +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..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", () => { @@ -385,4 +394,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..eda00aa6cdba8 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/shared/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,20 @@ 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..8e2d213f2bcf2 --- /dev/null +++ b/packages/source-control-tangled/package.json @@ -0,0 +1,33 @@ +{ + "name": "@t3tools/source-control-tangled", + "private": true, + "type": "module", + "exports": { + "./shared/definition": { + "types": "./src/shared/definition.ts", + "import": "./src/shared/definition.ts" + }, + "./server/driver": { + "types": "./src/server/driver.ts", + "import": "./src/server/driver.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:", + "entities": "4.5.0" + }, + "devDependencies": { + "@effect/vitest": "catalog:", + "@t3tools/source-control-testing": "workspace:*", + "@types/node": "catalog:", + "vite-plus": "catalog:" + } +} 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..6648b71fa42b1 --- /dev/null +++ b/packages/source-control-tangled/src/server/TangledCli.ts @@ -0,0 +1,200 @@ +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 "../shared/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, + status: Schema.optional(Schema.String), + 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. + */ +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; +} + +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) { + const detail = tangledErrorMessage(result) ?? `tg ${input.args[0] ?? ""} failed.`; + 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", + detail: authentication ? `${detail} Run \`tg auth login\` and retry.` : detail, + }); + } + 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..d76f1be82ec2b --- /dev/null +++ b/packages/source-control-tangled/src/server/TangledPullRequestProvider.test.ts @@ -0,0 +1,191 @@ +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"; +import { ChildProcessSpawner } from "effect/process"; + +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, + }), + ), + }, + }); + 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("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 = { + 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..c2df83e1854f9 --- /dev/null +++ b/packages/source-control-tangled/src/server/TangledPullRequestProvider.ts @@ -0,0 +1,398 @@ +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 { TangledAuthStatusSchema, TangledCli, type TangledCliError } 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: 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)); + + /** 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) => cliFailure(input.operation, cause)), + ); + }); + + /** + * 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: 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"), + ), + ); + 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 []; + // 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. + 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) => cliFailure(input.operation, cause))); + 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) => cliFailure("getViewer", cause)), + ), + + 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..e4ebb8cbdb456 --- /dev/null +++ b/packages/source-control-tangled/src/server/TangledSourceControlProvider.test.ts @@ -0,0 +1,888 @@ +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 { PullRequestProviderError } from "@t3tools/source-control-core/server/PullRequestProvider"; +import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; + +import * as TangledClient from "../shared/definition.ts"; +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) => + ({ + 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("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" }), + 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.each(["alice.example", "did:plc:alice"])("accepts the same owner %s", (owner) => { + assert.strictEqual( + TangledSourceControlProvider.createsUnderOwner({ + requested: owner, + created: owner, + }), + 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": 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"], + [ + "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 duplicate beyond the first 100 open pull requests", () => { + const tg = fakeTg({ + "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", + 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"]]); + }), + serve(tg), + ); + }); + + it.effect("creates when the open pull request targets a different branch", () => { + const tg = fakeTg({ + "pr list alice.example/repo --state open": 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": 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": 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.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" }), + }, + }); + 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({}); + 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("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" }), + }, + }); + 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, /someone-else/u); + assert.deepStrictEqual(tg.calls, [["auth", "status"]]); + }).pipe(serve(tg)); + }); + + 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" }), + }, + }); + 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); + 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)); + }); + + 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("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 (!isPullRequestProviderError(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": { + 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 current Tangled appview and knot", () => { + 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: "https://api.tangled.org", + 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..06e9db3afbdcc --- /dev/null +++ b/packages/source-control-tangled/src/server/TangledSourceControlProvider.ts @@ -0,0 +1,418 @@ +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 "../shared/definition.ts"; +import * as TangledPullRequestProvider from "./TangledPullRequestProvider.ts"; +import { + isTangledCliError, + TangledAuthStatusSchema, + TangledCli, + TangledRepositorySchema, + TANGLED_CLI, +} from "./TangledCli.ts"; + +export const KIND = SourceControlProviderKind.make("tangled"); + +/** `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 && + decoded.success.status !== "expired" + ) { + 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. + */ +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. + */ +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; +} + +// `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({ + 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. + * 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; + 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; + if (requested.startsWith("did:") && created.startsWith("did:")) return requested === created; + 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. + */ +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: Effect.fn("TangledSourceControlProvider.getRepositoryCloneUrls")( + function* (input) { + 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: 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: 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: Effect.fn("TangledSourceControlProvider.listChangeRequests")( + function* (input) { + 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) => + 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 + .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: Effect.fn("TangledSourceControlProvider.createChangeRequest")( + function* (input) { + 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"], + 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: 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 new file mode 100644 index 0000000000000..271e07cdda974 --- /dev/null +++ b/packages/source-control-tangled/src/server/driver.ts @@ -0,0 +1,31 @@ +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 "../shared/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..bdbd27dabfbc4 --- /dev/null +++ b/packages/source-control-tangled/src/server/tangledPulls.test.ts @@ -0,0 +1,181 @@ +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("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 = ` + + 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..8953f769ac968 --- /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"; +import { decodeHTML } from "entities"; + +/** + * 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 = decodeHTML((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/src/shared/definition.ts b/packages/source-control-tangled/src/shared/definition.ts new file mode 100644 index 0000000000000..53293389b4df8 --- /dev/null +++ b/packages/source-control-tangled/src/shared/definition.ts @@ -0,0 +1,120 @@ +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. + */ +const DEFAULT_APPVIEW = "https://api.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 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", + }, + 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 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/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/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': 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",