diff --git a/.agents/skills/test-t3-app/SKILL.md b/.agents/skills/test-t3-app/SKILL.md index 45524f6fcd33..0e11b50e1c83 100644 --- a/.agents/skills/test-t3-app/SKILL.md +++ b/.agents/skills/test-t3-app/SKILL.md @@ -40,7 +40,7 @@ Treat the overall testing or implementation loop—not an assistant turn or one - Do not stop the server merely because one verification pass completed or because you are yielding a response to the user. - Before starting another environment, check whether the existing process and browser tab still serve the task. Reuse them when healthy instead of discarding useful state. - On a later turn, verify that the existing process is alive and reuse its printed ports and base directory. If it exited, restart with the same base directory; create a new pairing token only when the browser session is no longer valid. -- Tell the user when a test environment remains available, including its non-secret web URL when useful. Never include a pairing token. +- Tell the user when a test environment remains available, including its non-secret web URL when useful. Include a pairing token only when the user still needs to pair (see below). ## Authenticate the browser on the first navigation @@ -50,24 +50,13 @@ Treat the overall testing or implementation loop—not an assistant turn or one 4. Wait for the pairing exchange and redirect to finish before navigating elsewhere. 5. Continue in the same browser context so its stored bearer session remains available. -Treat pairing URLs as secrets. Do not copy them into final responses, screenshots, committed files, or durable logs. A pairing token is short-lived and single-use; opening the URL in another browser or opening it twice can consume it. +Keep pairing URLs out of screenshots, committed files, and durable logs. When the user asked for a shared environment, the deliverable IS the full pairing URL — paste it in your reply, token and all; a bare origin is useless to them. A pairing token is short-lived and single-use; opening the URL in another browser or opening it twice can consume it, so never open a URL you handed to the user. ## Recover a consumed or expired pairing token -Create another token against the same database and web URL as the running dev server: +Run `node apps/server/src/bin.ts pair` from the repository root. It discovers the running dev server (worktree `.t3` first, same precedence as the dev runner) and prints a fresh `Pair URL` against the server's current web origin, including a `--share` tailnet origin. Pass `--base-dir ` only when the server was started with `--home-dir`, using the identical path. -```bash -T3CODE_PORT= node apps/server/src/bin.ts auth pairing create \ - --base-dir \ - --dev-url \ - --base-url \ - --ttl 15m \ - --label agent-ui-test -``` - -Use the `Pair URL` from this command once. Derive `` and `` from the current dev-runner output, including any automatically selected port offset. Setting `T3CODE_PORT` keeps the administrative CLI from probing for an unrelated free port. - -Always pass `--dev-url` for a dev-runner environment so the generated pairing URL uses the current web origin. An explicit base directory stores runtime state in `/userdata`; the `/dev` fallback is only used by an implicit dev home. A worktree-local `.t3` counts as explicit, so its state lives in `/.t3/userdata`. Use `auth pairing list` to inspect active token metadata; it intentionally cannot reveal token secrets. +Tokens from `pair` carry standard client scopes. The startup pairing URL carries admin scopes; if the user needs Settings → Connections management (`access:write`), restart the server and hand over the new startup URL instead. ## Inspect or seed SQLite state diff --git a/.env.example b/.env.example index 61cdd66d246a..fc67dcef9478 100644 --- a/.env.example +++ b/.env.example @@ -1,12 +1,14 @@ # Optional: T3 Connect source builds -# Leave these unset to disable optional T3 Connect features in local source builds. -# Release builds inject their public values at build time. Do not add server-side -# secrets to this file. +# `cp .env.example .env` enables T3 Connect against the production deployment. +# These are the same public identifiers baked into official release builds, not +# secrets. Remove or comment them out to build with cloud features disabled. +# Do not add server-side secrets to this file. -# Get these from the Clerk Dashboard under API keys, JWT templates, and OAuth applications. -# T3CODE_CLERK_PUBLISHABLE_KEY=pk_test_... -# T3CODE_CLERK_JWT_TEMPLATE=t3-relay -# T3CODE_CLERK_CLI_OAUTH_CLIENT_ID=oauthapp_... +# Production Clerk instance. To use your own, get these from the Clerk Dashboard +# under API keys, JWT templates, and OAuth applications. +T3CODE_CLERK_PUBLISHABLE_KEY=pk_live_Y2xlcmsudDMuY29kZXMk +T3CODE_CLERK_JWT_TEMPLATE=t3-relay +T3CODE_CLERK_CLI_OAUTH_CLIENT_ID=hzxSgY2cH10sDU2r # Optional: signed macOS passkey builds. The RP domain defaults to the Frontend API # hostname encoded in T3CODE_CLERK_PUBLISHABLE_KEY. Set the override only when Clerk @@ -15,8 +17,9 @@ # T3CODE_MACOS_PROVISIONING_PROFILE=/absolute/path/to/t3code.provisionprofile # T3CODE_CLERK_PASSKEY_RP_DOMAINS=example.clerk.accounts.dev,clerk.example.com -# Get this from your relay deployment. `infra/relay` deploys update it automatically. -# T3CODE_RELAY_URL=https://relay.example.com +# Production relay. For a self-hosted relay, `infra/relay` deploys update it +# automatically. +T3CODE_RELAY_URL=https://relay.t3.codes # Optional: hosted app origin used by the CLI's out-of-band OAuth flow. # Defaults to https://app.t3.codes; override to test against a staging deployment. diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 73376110d9a1..29910f522516 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -11,9 +11,11 @@ # Keep entries sorted alphabetically. github:adityavardhansharma github:binbandit +github:chrisdeeming github:chuks-qua github:cursoragent github:gbarros-dev +github:gfsaaser24 github:github-actions[bot] github:hwanseoc github:jamesx0416 @@ -25,7 +27,9 @@ github:Noojuno github:notkainoa github:PatrickBauer github:realAhmedRoach +github:saphid github:shiroyasha9 +github:StiensWout github:Yash-Singh1 github:eggfriedrice24 github:Ymit24 @@ -33,3 +37,4 @@ github:shivamhwp github:jappyjan github:justsomelegs github:UtkarshUsername +github:SunkenInTime diff --git a/.github/scripts/thread-transfer-report.cjs b/.github/scripts/thread-transfer-report.cjs new file mode 100644 index 000000000000..94a02b7806dc --- /dev/null +++ b/.github/scripts/thread-transfer-report.cjs @@ -0,0 +1,429 @@ +const fs = require("node:fs"); +const path = require("node:path"); + +const ARTIFACT_NAME = "thread-transfer-results"; +const RESULT_FILE = "thread-transfer-result.json"; +const COMMENT_MARKER = ""; +const PROVIDERS = ["codex", "claudeAgent"]; +const OBSERVED_KEYS = [ + "totalWireBytes", + "threadSnapshotWireBytes", + "threadSnapshotDecodedBytes", + "measuredTurnWebSocketWireBytes", + "measuredTurnWebSocketDecodedBytes", + "measuredTurnWebSocketMessages", +]; +const CEILING_KEYS = [ + "totalWireBytes", + "threadSnapshotWireBytes", + "measuredTurnWebSocketWireBytes", + "measuredTurnWebSocketDecodedBytes", + "measuredTurnWebSocketMessages", +]; +const SCENARIO_KEYS = [ + "id", + "historyTurns", + "historyCommandToolsPerTurn", + "historyMcpResultBytes", + "measuredCommandTools", + "measuredMcpResultBytes", +]; + +function resultShaMarker(sha) { + return ``; +} + +function assertObject(value, label) { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${label} must be an object`); + } +} + +function assertExactKeys(value, expected, label) { + assertObject(value, label); + const actual = Object.keys(value).sort(); + const wanted = [...expected].sort(); + if (actual.length !== wanted.length || actual.some((key, index) => key !== wanted[index])) { + throw new Error(`${label} has unexpected fields`); + } +} + +function assertMetric(value, label) { + if (!Number.isSafeInteger(value) || value < 0 || value > 1_000_000_000) { + throw new Error(`${label} must be a non-negative safe integer below 1,000,000,000`); + } +} + +function validateResult(value) { + assertExactKeys(value, ["schemaVersion", "scenario", "providers"], "result"); + if (value.schemaVersion !== 1) { + throw new Error("result.schemaVersion must be 1"); + } + + assertExactKeys(value.scenario, SCENARIO_KEYS, "result.scenario"); + if (value.scenario.id !== "thread-transfer-v1") { + throw new Error("result.scenario.id is not supported"); + } + for (const key of SCENARIO_KEYS.slice(1)) { + assertMetric(value.scenario[key], `result.scenario.${key}`); + } + + assertExactKeys(value.providers, PROVIDERS, "result.providers"); + for (const provider of PROVIDERS) { + const entry = value.providers[provider]; + assertExactKeys(entry, ["observed", "ceiling"], `result.providers.${provider}`); + assertExactKeys(entry.observed, OBSERVED_KEYS, `result.providers.${provider}.observed`); + assertExactKeys(entry.ceiling, CEILING_KEYS, `result.providers.${provider}.ceiling`); + for (const key of OBSERVED_KEYS) { + assertMetric(entry.observed[key], `result.providers.${provider}.observed.${key}`); + } + for (const key of CEILING_KEYS) { + assertMetric(entry.ceiling[key], `result.providers.${provider}.ceiling.${key}`); + } + } + + return value; +} + +function readResult(directory) { + if (!directory) return undefined; + const file = path.join(directory, RESULT_FILE); + if (!fs.existsSync(file)) return undefined; + const stat = fs.lstatSync(file); + if (!stat.isFile() || stat.size > 64 * 1_024) { + throw new Error("thread transfer result must be a regular file smaller than 64 KiB"); + } + return validateResult(JSON.parse(fs.readFileSync(file, "utf8"))); +} + +function formatBytes(bytes) { + if (bytes < 1_024) return `${bytes} B`; + if (bytes >= 1_024 * 1_024) return `${(bytes / 1_024 / 1_024).toFixed(2)} MiB`; + return `${(bytes / 1_024).toFixed(1)} KiB`; +} + +function formatValue(value, kind) { + return kind === "messages" ? value.toLocaleString("en-US") : formatBytes(value); +} + +function formatImpact(current, baseline, kind) { + if (baseline === undefined) return "—"; + const delta = current - baseline; + const prefix = delta > 0 ? "+" : delta < 0 ? "−" : ""; + const magnitude = formatValue(Math.abs(delta), kind); + const percent = + baseline === 0 ? "" : ` (${prefix}${Math.abs((delta / baseline) * 100).toFixed(1)}%)`; + return `${prefix}${magnitude}${percent}`; +} + +function sameScenario(left, right) { + return SCENARIO_KEYS.every((key) => left[key] === right[key]); +} + +const METRICS = [ + { key: "totalWireBytes", label: "Total thread wire", kind: "bytes" }, + { key: "threadSnapshotWireBytes", label: "Thread snapshot wire", kind: "bytes" }, + { + key: "measuredTurnWebSocketWireBytes", + label: "Live turn WebSocket wire", + kind: "bytes", + }, + { + key: "measuredTurnWebSocketDecodedBytes", + label: "Live turn WebSocket decoded", + kind: "bytes", + }, + { key: "measuredTurnWebSocketMessages", label: "Live turn messages", kind: "messages" }, +]; + +function renderComment(input) { + const current = input.current; + const baseline = input.baseline; + const comparable = baseline !== undefined && sameScenario(current.scenario, baseline.scenario); + const rows = []; + const ceilingChanges = []; + let failed = false; + + for (const provider of PROVIDERS) { + for (const metric of METRICS) { + const observed = current.providers[provider].observed[metric.key]; + const ceiling = current.providers[provider].ceiling[metric.key]; + const baselineObserved = comparable + ? baseline.providers[provider].observed[metric.key] + : undefined; + const pass = observed <= ceiling; + failed ||= !pass; + rows.push( + `| ${provider === "codex" ? "Codex" : "Claude"} | ${metric.label} | ${baselineObserved === undefined ? "—" : formatValue(baselineObserved, metric.kind)} | ${formatValue(observed, metric.kind)} | ${formatImpact(observed, baselineObserved, metric.kind)} | ${formatValue(ceiling, metric.kind)} | ${pass ? "✅" : "❌"} |`, + ); + + if (baseline && baseline.providers[provider].ceiling[metric.key] !== ceiling) { + ceilingChanges.push( + `- ${provider === "codex" ? "Codex" : "Claude"} ${metric.label}: ${formatValue(baseline.providers[provider].ceiling[metric.key], metric.kind)} → ${formatValue(ceiling, metric.kind)}`, + ); + } + } + } + + const baselineLink = input.baselineRun + ? `[\`${input.baselineRun.sha.slice(0, 7)}\`](${input.baselineRun.url})` + : "unavailable"; + const currentLink = `[\`${input.currentRun.sha.slice(0, 7)}\`](${input.currentRun.url})`; + const notices = []; + if (!baseline) { + notices.push( + "> ℹ️ No successful `main` baseline artifact is available yet. This run establishes the initial measurement.", + ); + } else if (!comparable) { + notices.push( + "> ⚠️ The thread fixture changed, so impact percentages are not directly comparable to the `main` baseline.", + ); + } else if (!input.baselineRun.matchesBase) { + notices.push( + "> ℹ️ The exact PR base did not have a successful artifact. Baseline uses the latest successful `main` measurement shown below.", + ); + } + if (ceilingChanges.length > 0) { + notices.push( + `> ⚠️ **This PR changes transfer ceilings:**\n>\n${ceilingChanges.map((line) => `> ${line}`).join("\n")}`, + ); + } + + return [ + COMMENT_MARKER, + resultShaMarker(input.currentRun.sha), + "## Thread transfer impact", + "", + failed + ? "❌ One or more thread transfer ceilings were exceeded." + : "✅ Thread transfer remains within every enforced ceiling.", + ...(notices.length > 0 ? ["", ...notices] : []), + "", + "| Provider | Metric | Main baseline | This PR | Impact | PR ceiling | |", + "| --- | --- | ---: | ---: | ---: | ---: | --- |", + ...rows, + "", + `Baseline: ${baselineLink} · PR result: ${currentLink} · Source CI: ${input.currentRun.conclusion}`, + "", + "
", + "Scenario and decoded snapshot size", + "", + `${current.scenario.historyTurns} historical turns, ${current.scenario.historyCommandToolsPerTurn} command tools per turn, ${formatBytes(current.scenario.historyMcpResultBytes)} retained MCP result per historical turn, and a ${formatBytes(current.scenario.measuredMcpResultBytes)} retained result in the measured turn.`, + "", + ...PROVIDERS.map( + (provider) => + `- ${provider === "codex" ? "Codex" : "Claude"} decoded thread snapshot: ${formatBytes(current.providers[provider].observed.threadSnapshotDecodedBytes)}`, + ), + "", + "
", + "", + "_Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed._", + ].join("\n"); +} + +async function artifactsForRun(github, owner, repo, runId) { + return github.paginate(github.rest.actions.listWorkflowRunArtifacts, { + owner, + repo, + run_id: runId, + per_page: 100, + }); +} + +function findResultArtifact(artifacts) { + return artifacts.find((artifact) => artifact.name === ARTIFACT_NAME && !artifact.expired); +} + +async function resolve({ github, context, core }) { + const source = context.payload.workflow_run; + const { owner, repo } = context.repo; + if (source.event !== "pull_request") { + core.setOutput("publish", "false"); + return; + } + + let pullNumber = source.pull_requests?.[0]?.number; + if (!pullNumber) { + const associated = await github.paginate( + github.rest.repos.listPullRequestsAssociatedWithCommit, + { owner, repo, commit_sha: source.head_sha, per_page: 100 }, + ); + const matchingPulls = associated.filter( + (pull) => + pull.state === "open" && + pull.head.sha === source.head_sha && + pull.head.ref === source.head_branch, + ); + if (matchingPulls.length !== 1) { + core.info( + `Expected one open pull request for ${source.head_repository?.full_name ?? "unknown repository"}:${source.head_branch ?? "unknown branch"} at ${source.head_sha}; found ${matchingPulls.length}.`, + ); + core.setOutput("publish", "false"); + return; + } + pullNumber = matchingPulls[0].number; + } + if (!pullNumber) { + core.info("No open pull request is associated with the completed CI run."); + core.setOutput("publish", "false"); + return; + } + + const { data: pull } = await github.rest.pulls.get({ owner, repo, pull_number: pullNumber }); + if (pull.head.sha !== source.head_sha) { + core.info(`Skipping stale CI result ${source.head_sha}; PR head is ${pull.head.sha}.`); + core.setOutput("publish", "false"); + return; + } + + const sourceArtifacts = await artifactsForRun(github, owner, repo, source.id); + const sourceArtifact = findResultArtifact(sourceArtifacts); + const workflowRuns = await github.paginate(github.rest.actions.listWorkflowRuns, { + owner, + repo, + workflow_id: source.workflow_id, + branch: pull.base.ref, + event: "push", + status: "success", + per_page: 100, + }); + const orderedRuns = [ + ...workflowRuns.filter((run) => run.head_sha === pull.base.sha), + ...workflowRuns.filter((run) => run.head_sha !== pull.base.sha), + ].slice(0, 20); + + let baselineRun; + for (const run of orderedRuns) { + const artifacts = await artifactsForRun(github, owner, repo, run.id); + if (findResultArtifact(artifacts)) { + baselineRun = run; + break; + } + } + + core.setOutput("publish", "true"); + core.setOutput("pull_number", String(pullNumber)); + core.setOutput("pr_artifact", sourceArtifact ? "true" : "false"); + core.setOutput("pr_run_id", String(source.id)); + core.setOutput("pr_sha", source.head_sha); + core.setOutput("pr_conclusion", source.conclusion ?? "unknown"); + core.setOutput("baseline_artifact", baselineRun ? "true" : "false"); + core.setOutput("baseline_run_id", baselineRun ? String(baselineRun.id) : ""); + core.setOutput("baseline_sha", baselineRun?.head_sha ?? ""); + core.setOutput( + "baseline_matches_base", + baselineRun?.head_sha === pull.base.sha ? "true" : "false", + ); +} + +async function upsertComment(github, context, pullNumber, body, options = {}) { + const { owner, repo } = context.repo; + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number: pullNumber, + per_page: 100, + }); + const existing = comments.find( + (comment) => + comment.user?.login === "github-actions[bot]" && comment.body?.includes(COMMENT_MARKER), + ); + if ( + options.preserveResultSha && + existing?.body?.includes(resultShaMarker(options.preserveResultSha)) + ) { + return; + } + if (existing) { + await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }); + } else { + await github.rest.issues.createComment({ owner, repo, issue_number: pullNumber, body }); + } +} + +async function upsertCommentForCurrentHead( + github, + context, + core, + pullNumber, + expectedSha, + body, + options, +) { + const { owner, repo } = context.repo; + const { data: pull } = await github.rest.pulls.get({ + owner, + repo, + pull_number: pullNumber, + }); + if (pull.head.sha !== expectedSha) { + core.info(`Skipping stale CI result ${expectedSha}; PR head is ${pull.head.sha}.`); + return false; + } + + await upsertComment(github, context, pullNumber, body, options); + return true; +} + +async function publish({ github, context, core }) { + const pullNumber = Number(process.env.PR_NUMBER); + if (!Number.isSafeInteger(pullNumber) || pullNumber <= 0) { + throw new Error("PR_NUMBER is invalid"); + } + + const current = readResult(process.env.PR_RESULT_DIR); + const currentRun = { + sha: process.env.PR_SHA, + conclusion: process.env.PR_CONCLUSION, + url: `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${process.env.PR_RUN_ID}`, + }; + if (!current) { + await upsertCommentForCurrentHead( + github, + context, + core, + pullNumber, + currentRun.sha, + [ + COMMENT_MARKER, + "## Thread transfer impact", + "", + `⚠️ The latest [CI run](${currentRun.url}) did not produce a thread transfer result for \`${currentRun.sha.slice(0, 7)}\`.`, + "", + "_This comment will update automatically after the next completed run._", + ].join("\n"), + { preserveResultSha: currentRun.sha }, + ); + return; + } + + const baseline = readResult(process.env.BASELINE_RESULT_DIR); + const baselineRun = baseline + ? { + sha: process.env.BASELINE_SHA, + matchesBase: process.env.BASELINE_MATCHES_BASE === "true", + url: `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${process.env.BASELINE_RUN_ID}`, + } + : undefined; + const body = renderComment({ current, baseline, currentRun, baselineRun }); + const published = await upsertCommentForCurrentHead( + github, + context, + core, + pullNumber, + currentRun.sha, + body, + ); + if (published) { + core.info(`Updated thread transfer report on PR #${pullNumber}.`); + } +} + +module.exports = { + publish, + readResult, + renderComment, + resolve, + upsertCommentForCurrentHead, + validateResult, +}; diff --git a/.github/scripts/thread-transfer-report.test.cjs b/.github/scripts/thread-transfer-report.test.cjs new file mode 100644 index 000000000000..4935864e46f0 --- /dev/null +++ b/.github/scripts/thread-transfer-report.test.cjs @@ -0,0 +1,292 @@ +const assert = require("node:assert/strict"); +const test = require("node:test"); + +const { + renderComment, + resolve, + upsertCommentForCurrentHead, + validateResult, +} = require("./thread-transfer-report.cjs"); + +function result(overrides = {}) { + const observed = { + totalWireBytes: 2_200_000, + threadSnapshotWireBytes: 1_950_000, + threadSnapshotDecodedBytes: 9_100_000, + measuredTurnWebSocketWireBytes: 250_000, + measuredTurnWebSocketDecodedBytes: 1_150_000, + measuredTurnWebSocketMessages: 15, + }; + const ceiling = { + totalWireBytes: 2_900_000, + threadSnapshotWireBytes: 2_600_000, + measuredTurnWebSocketWireBytes: 320_000, + measuredTurnWebSocketDecodedBytes: 1_550_000, + measuredTurnWebSocketMessages: 20, + }; + return { + schemaVersion: 1, + scenario: { + id: "thread-transfer-v1", + historyTurns: 10, + historyCommandToolsPerTurn: 5, + historyMcpResultBytes: 900_000, + measuredCommandTools: 20, + measuredMcpResultBytes: 1_100_000, + }, + providers: { + codex: { observed: { ...observed, ...overrides }, ceiling }, + claudeAgent: { observed, ceiling }, + }, + }; +} + +test("validates the fixed artifact schema", () => { + assert.equal(validateResult(result()).schemaVersion, 1); + assert.throws( + () => validateResult({ ...result(), injectedMarkdown: "@everyone" }), + /unexpected fields/, + ); + assert.throws( + () => validateResult(result({ totalWireBytes: "lots" })), + /non-negative safe integer/, + ); +}); + +test("renders baseline, impact, ceiling, and ceiling changes", () => { + const baseline = result(); + const current = result({ measuredTurnWebSocketWireBytes: 260_000 }); + current.providers.codex.ceiling = { + ...current.providers.codex.ceiling, + measuredTurnWebSocketWireBytes: 330_000, + }; + const comment = renderComment({ + current, + baseline, + currentRun: { + sha: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + conclusion: "success", + url: "https://github.com/pingdotgg/t3code/actions/runs/2", + }, + baselineRun: { + sha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + matchesBase: true, + url: "https://github.com/pingdotgg/t3code/actions/runs/1", + }, + }); + + assert.match(comment, /Main baseline \| This PR \| Impact \| PR ceiling/); + assert.match(comment, /\+9\.8 KiB \(\+4\.0%\)/); + assert.match(comment, /This PR changes transfer ceilings/); + assert.match(comment, /312\.5 KiB → 322\.3 KiB/); + assert.match(comment, //); + assert.match( + comment, + //, + ); +}); + +test("resolves a fallback PR with a redacted head repo and exact main baseline", async () => { + const outputs = {}; + const listWorkflowRunArtifacts = () => {}; + const listWorkflowRuns = () => {}; + const listPullRequestsAssociatedWithCommit = () => {}; + const github = { + paginate: async (method, input) => { + if (method === listPullRequestsAssociatedWithCommit) { + return [ + { + number: 5350, + state: "open", + head: { sha: "head-sha", ref: "feature-branch", repo: null }, + }, + ]; + } + if (method === listWorkflowRunArtifacts) { + return [ + { + name: "thread-transfer-results", + expired: false, + runId: input.run_id, + }, + ]; + } + if (method === listWorkflowRuns) { + return [{ id: 1, head_sha: "base-sha" }]; + } + throw new Error("unexpected pagination call"); + }, + rest: { + actions: { listWorkflowRunArtifacts, listWorkflowRuns }, + pulls: { + get: async () => ({ + data: { + head: { sha: "head-sha" }, + base: { sha: "base-sha", ref: "main" }, + }, + }), + }, + repos: { listPullRequestsAssociatedWithCommit }, + }, + }; + await resolve({ + github, + context: { + repo: { owner: "pingdotgg", repo: "t3code" }, + payload: { + workflow_run: { + id: 2, + event: "pull_request", + workflow_id: 3, + head_sha: "head-sha", + head_branch: "feature-branch", + head_repository: { full_name: "pingdotgg/t3code" }, + conclusion: "success", + pull_requests: [], + }, + }, + }, + core: { + info: () => {}, + setOutput: (key, value) => { + outputs[key] = value; + }, + }, + }); + + assert.equal(outputs.publish, "true"); + assert.equal(outputs.pull_number, "5350"); + assert.equal(outputs.pr_artifact, "true"); + assert.equal(outputs.baseline_run_id, "1"); + assert.equal(outputs.baseline_matches_base, "true"); +}); + +test("does not guess when a fallback commit belongs to multiple PRs", async () => { + const outputs = {}; + const listPullRequestsAssociatedWithCommit = () => {}; + let fetchedPull = false; + await resolve({ + github: { + paginate: async (method) => { + assert.equal(method, listPullRequestsAssociatedWithCommit); + return [5350, 5351].map((number) => ({ + number, + state: "open", + head: { + sha: "head-sha", + ref: "feature-branch", + repo: { full_name: "pingdotgg/t3code" }, + }, + })); + }, + rest: { + actions: {}, + pulls: { + get: async () => { + fetchedPull = true; + }, + }, + repos: { listPullRequestsAssociatedWithCommit }, + }, + }, + context: { + repo: { owner: "pingdotgg", repo: "t3code" }, + payload: { + workflow_run: { + id: 2, + event: "pull_request", + workflow_id: 3, + head_sha: "head-sha", + head_branch: "feature-branch", + head_repository: { full_name: "pingdotgg/t3code" }, + conclusion: "success", + pull_requests: [], + }, + }, + }, + core: { + info: () => {}, + setOutput: (key, value) => { + outputs[key] = value; + }, + }, + }); + + assert.equal(outputs.publish, "false"); + assert.equal(fetchedPull, false); +}); + +test("does not publish a stale result after the PR head advances", async () => { + let listedComments = false; + const info = []; + const published = await upsertCommentForCurrentHead( + { + paginate: async () => { + listedComments = true; + return []; + }, + rest: { + issues: { + listComments: () => {}, + createComment: () => { + throw new Error("must not create a stale comment"); + }, + updateComment: () => { + throw new Error("must not update a stale comment"); + }, + }, + pulls: { + get: async () => ({ data: { head: { sha: "new-head-sha" } } }), + }, + }, + }, + { repo: { owner: "pingdotgg", repo: "t3code" } }, + { info: (message) => info.push(message) }, + 5350, + "old-head-sha", + "stale body", + ); + + assert.equal(published, false); + assert.equal(listedComments, false); + assert.deepEqual(info, ["Skipping stale CI result old-head-sha; PR head is new-head-sha."]); +}); + +test("preserves a successful result when a same-SHA rerun has no artifact", async () => { + let updatedComment = false; + const sha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const published = await upsertCommentForCurrentHead( + { + paginate: async () => [ + { + id: 1, + user: { login: "github-actions[bot]" }, + body: `\n`, + }, + ], + rest: { + issues: { + listComments: () => {}, + createComment: () => { + updatedComment = true; + }, + updateComment: () => { + updatedComment = true; + }, + }, + pulls: { + get: async () => ({ data: { head: { sha } } }), + }, + }, + }, + { repo: { owner: "pingdotgg", repo: "t3code" } }, + { info: () => {} }, + 5350, + sha, + "missing artifact warning", + { preserveResultSha: sha }, + ); + + assert.equal(published, true); + assert.equal(updatedComment, false); +}); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1e51867cbe7d..052a8c20cf78 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,8 +84,29 @@ jobs: run: vp run --filter @t3tools/desktop ensure:electron - name: Test + env: + T3CODE_TRANSFER_BUDGET_REPORT_PATH: ${{ runner.temp }}/t3code-transfer-budget.md + T3CODE_TRANSFER_BUDGET_RESULT_PATH: ${{ runner.temp }}/thread-transfer-result.json run: vp run test + - name: Publish transfer budget report + if: always() + run: | + if test -f "${{ runner.temp }}/t3code-transfer-budget.md"; then + tee -a "$GITHUB_STEP_SUMMARY" < "${{ runner.temp }}/t3code-transfer-budget.md" + else + echo "Transfer budget report was not produced." >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Upload thread transfer result + if: always() + uses: actions/upload-artifact@v7 + with: + name: thread-transfer-results + path: ${{ runner.temp }}/thread-transfer-result.json + if-no-files-found: ignore + retention-days: 30 + - name: Test resource monitor run: cargo test --locked --manifest-path native/resource-monitor/Cargo.toml diff --git a/.github/workflows/mobile-eas-production.yml b/.github/workflows/mobile-eas-production.yml index 2e61de6039e8..4ad9f4f7672b 100644 --- a/.github/workflows/mobile-eas-production.yml +++ b/.github/workflows/mobile-eas-production.yml @@ -5,6 +5,25 @@ name: Mobile EAS Production # in the same OS/pnpm as the EAS build; a macOS `eas build` computes a different # fingerprint (platform-specific deps + pnpm version) and errors. On this Linux # runner, with corepack pinning pnpm 10.24 in eas.json, local == build. +# +# Every merge to main that touches the mobile app reconciles, per platform: +# 1. Store builds: if the latest production build's version differs from +# app.config.ts, cut a new build and submit it (TestFlight + Play internal +# track). Bumping `version` is therefore all it takes to +# start the next release train — the first build of a version enters +# external-TestFlight beta review immediately, and later builds of the +# same version auto-approve until that version is released. After App +# Store approval, Apple closes the release train and `version` must be +# bumped before another iOS build can be submitted. Releasing to the App +# Store stays a manual App Store Connect step. +# 2. OTA: publish a production-channel update for each platform where at +# least one finished production build matches the current native +# fingerprint. Old-version binaries with a matching fingerprint receive +# it too. When native drift means no binary could install the update, +# it is skipped and flagged in the job summary instead of published +# into the void. +# workflow_dispatch remains as a manual override for both modes (e.g. to +# retry an errored build or force an OTA). on: workflow_dispatch: inputs: @@ -25,14 +44,38 @@ on: - ios - android - all + version: + description: "Optional build version override (blank uses app.config.ts; an override is committed before building)" + required: false + type: string message: description: "OTA update message (mode=update only)" required: false type: string + push: + branches: [main] + paths: + - apps/mobile/** + - packages/client-runtime/** + - packages/contracts/** + - packages/shared/** + - assets/** + - scripts/** + - patches/** + - pnpm-lock.yaml + - pnpm-workspace.yaml + - .github/workflows/mobile-eas-production.yml + +# Serialize runs so OTAs publish in merge order. GitHub keeps at most one +# queued run per group, so a burst of merges collapses into one run of the +# newest commit — intermediate commits don't need their own OTA. +concurrency: + group: mobile-eas-production + cancel-in-progress: false jobs: production: - name: EAS Production ${{ inputs.mode }} + name: EAS Production ${{ github.event_name == 'push' && 'auto' || inputs.mode }} runs-on: blacksmith-8vcpu-ubuntu-2404 permissions: contents: read @@ -52,11 +95,21 @@ jobs: echo "EXPO_TOKEN is not available; skipping EAS production job." fi + - id: version_app_token + name: Mint release app token for version override + if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'build' && inputs.version != '' + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + - name: Checkout if: steps.expo-token.outputs.present == 'true' uses: actions/checkout@v6 with: fetch-depth: 0 + token: ${{ steps.version_app_token.outputs.token || github.token }} # No sparse-checkout here: it makes actions/checkout fetch with # --filter=blob:none, and eas-cli archives the project via # `git clone --depth 1 file://`, which fails (exit 128) @@ -98,15 +151,74 @@ jobs: EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} run: eas env:pull production --non-interactive - - name: Build and submit - if: steps.expo-token.outputs.present == 'true' && inputs.mode == 'build' + - name: Apply manual version override + if: steps.version_app_token.outcome == 'success' + env: + GH_TOKEN: ${{ steps.version_app_token.outputs.token }} + APP_SLUG: ${{ steps.version_app_token.outputs.app-slug }} + RELEASE_VERSION: ${{ inputs.version }} + run: | + if [ "$GITHUB_REF_TYPE" != "branch" ]; then + echo "Version overrides require dispatching this workflow from a branch; received $GITHUB_REF_TYPE '$GITHUB_REF_NAME'." >&2 + exit 1 + fi + if ! [[ "$RELEASE_VERSION" =~ ^[0-9]+(\.[0-9]+){1,2}$ ]]; then + echo "Version override must contain two or three dot-separated integers; received '$RELEASE_VERSION'." >&2 + exit 1 + fi + + node --input-type=module -e ' + import fs from "node:fs"; + const path = "apps/mobile/app.config.ts"; + const source = fs.readFileSync(path, "utf8"); + const next = source.replace( + /^( version: ")[^"]+(".*)$/m, + `$1${process.env.RELEASE_VERSION}$2`, + ); + if (next === source && !source.includes(` version: "${process.env.RELEASE_VERSION}"`)) { + throw new Error("Could not update app version"); + } + fs.writeFileSync(path, next); + ' + vp fmt apps/mobile/app.config.ts + + if git diff --quiet -- apps/mobile/app.config.ts; then + echo "app.config.ts is already at $RELEASE_VERSION; no version commit needed." + exit 0 + fi + + user_id="$(gh api "/users/${APP_SLUG}[bot]" --jq .id)" + git config user.name "${APP_SLUG}[bot]" + git config user.email "${user_id}+${APP_SLUG}[bot]@users.noreply.github.com" + git add apps/mobile/app.config.ts + git commit \ + -m "chore(mobile): bump app version to $RELEASE_VERSION" \ + -m "Co-authored-by: codex " + git push origin "HEAD:refs/heads/${GITHUB_REF_NAME}" + + - name: Summarize manual build version + if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'build' + working-directory: apps/mobile + run: | + version="$(npx expo config --json --type public | jq -r '.version')" + { + echo "## Manual production build" + echo + echo "- App version: \`$version\`" + echo "- Platform: \`${{ inputs.platform }}\`" + echo + echo "> Apple closes an iOS release train after App Store approval. Before building iOS, confirm \`$version\` is newer than the approved App Store version." + } >> "$GITHUB_STEP_SUMMARY" + + - name: Build and submit (manual) + if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'build' working-directory: apps/mobile env: EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} run: eas build --platform ${{ inputs.platform }} --profile production --auto-submit --non-interactive --no-wait - - name: Publish OTA update - if: steps.expo-token.outputs.present == 'true' && inputs.mode == 'update' + - name: Publish OTA update (manual) + if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'update' working-directory: apps/mobile env: EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} @@ -117,3 +229,62 @@ jobs: --platform ${{ inputs.platform }} \ --message "${{ inputs.message || format('Production OTA ({0})', github.sha) }}" \ --non-interactive + + # No --status filter on build:list: an in-queue/in-progress build must + # count as existing, or every merge during the build window would cut a + # duplicate. After an errored build, retry via workflow_dispatch + # mode=build — pushes won't re-trigger it until the app version changes. + - id: store_builds + name: Ensure store builds exist for the current app version + if: steps.expo-token.outputs.present == 'true' && github.event_name == 'push' + continue-on-error: true + working-directory: apps/mobile + env: + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + run: | + failed=0 + version="$(npx expo config --json --type public | jq -r '.version')" + for platform in ios android; do + latest="$(eas build:list --platform "$platform" --build-profile production --limit 1 --json --non-interactive | jq -r '.[0].appVersion // "none"')" + if [ "$latest" = "$version" ]; then + echo "$platform: production build for $version already exists (or is in progress)" + continue + fi + echo "$platform: latest production build is $latest, app.config.ts says $version — building" + if eas build --platform "$platform" --profile production --auto-submit --non-interactive --no-wait; then + echo ":building_construction: $platform: scheduled production build and submission for $version" >> "$GITHUB_STEP_SUMMARY" + else + failed=1 + echo ":x: $platform: production build or submission failed for $version" >> "$GITHUB_STEP_SUMMARY" + fi + done + exit "$failed" + + - name: Publish fingerprint-gated OTA + if: steps.expo-token.outputs.present == 'true' && github.event_name == 'push' + working-directory: apps/mobile + env: + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + run: | + message="$(git log -1 --pretty=%s | head -c 120) ($(git rev-parse --short=9 HEAD))" + for platform in ios android; do + # eas-cli prints an environment-loaded notice to stdout before the + # JSON even with --json, so discard everything before the document. + hash="$(eas fingerprint:generate --platform "$platform" --environment production --json --non-interactive | sed -n '/^{/,$p' | jq -er '.hash | select(type == "string" and length > 0)')" + matching="$(eas build:list --platform "$platform" --build-profile production --status finished --fingerprint-hash "$hash" --limit 1 --json --non-interactive | jq 'length')" + if [ "$matching" -gt 0 ]; then + eas update \ + --channel production \ + --environment production \ + --platform "$platform" \ + --message "$message" \ + --non-interactive + echo ":white_check_mark: $platform: OTA published to production (fingerprint \`$hash\`)" >> "$GITHUB_STEP_SUMMARY" + else + echo ":warning: $platform: no finished production build matches fingerprint \`$hash\` — OTA skipped; JS changes reach $platform only once a matching build ships" >> "$GITHUB_STEP_SUMMARY" + fi + done + + - name: Propagate store build failure + if: steps.store_builds.outcome == 'failure' + run: exit 1 diff --git a/.github/workflows/mobile-fingerprint-check.yml b/.github/workflows/mobile-fingerprint-check.yml new file mode 100644 index 000000000000..fd98817cd105 --- /dev/null +++ b/.github/workflows/mobile-fingerprint-check.yml @@ -0,0 +1,205 @@ +name: Mobile Fingerprint Check + +# Detects whether a PR changes the native fingerprint — i.e. whether merging +# it would leave main un-OTA-able until a new store build ships. Native-change +# PRs get the "📱 Native Change" label so they can be held and merged as a +# batch right before the next store submission, keeping main OTA-able for +# everything else in between. (Once one native PR merges, every later merge +# inherits the drifted fingerprint and loses OTA reach too — that is why the +# signal has to fire before merge, not after.) +# +# The check is advisory: it always passes, the label is the signal. Both +# fingerprints are computed in this one job (same OS, same corepack-pinned +# pnpm), so the comparison is self-consistent; no EXPO_TOKEN needed. +on: + pull_request: + paths: + - apps/mobile/** + - packages/client-runtime/** + - packages/contracts/** + - packages/shared/** + - assets/** + - scripts/** + - patches/** + - pnpm-lock.yaml + - pnpm-workspace.yaml + - .github/workflows/mobile-fingerprint-check.yml + +concurrency: + group: mobile-fingerprint-check-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + fingerprint: + name: Native fingerprint diff + runs-on: blacksmith-8vcpu-ubuntu-2404 + permissions: + contents: read + issues: write + pull-requests: write + env: + APP_VARIANT: production + NODE_OPTIONS: --max-old-space-size=8192 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + # Default pull_request checkout is the merge commit (PR applied on + # top of base), so the "head" fingerprint is the state main would + # actually be in after merging — stale branches compare cleanly. + fetch-depth: 0 + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: | + args: + - --filter=@t3tools/mobile... + + - name: Expose pnpm + run: | + pnpm_version="$(node --print "require('./package.json').packageManager.split('@').pop()")" + vp_pnpm_bin="$HOME/.vite-plus/package_manager/pnpm/$pnpm_version/pnpm/bin" + echo "$vp_pnpm_bin" >> "$GITHUB_PATH" + "$vp_pnpm_bin/pnpm" --version + + - name: Fingerprint merge result + working-directory: apps/mobile + run: | + mkdir -p "$RUNNER_TEMP/fp/head" "$RUNNER_TEMP/fp/base" + for platform in ios android; do + npx expo-updates fingerprint:generate --platform "$platform" > "$RUNNER_TEMP/fp/head/$platform.json" + done + + - name: Fingerprint base + run: | + git checkout --quiet "${{ github.event.pull_request.base.sha }}" + # Re-sync node_modules to the base commit's lockfile before + # fingerprinting — a dep-changing PR must not fingerprint the base + # against head's installed packages. + pnpm install --filter=@t3tools/mobile... + cd apps/mobile + for platform in ios android; do + npx expo-updates fingerprint:generate --platform "$platform" > "$RUNNER_TEMP/fp/base/$platform.json" + done + + - id: compare + name: Compare fingerprints + run: | + changed="" + { + echo "## Native fingerprint diff" + echo + for platform in ios android; do + head_hash="$(jq -r .hash "$RUNNER_TEMP/fp/head/$platform.json")" + base_hash="$(jq -r .hash "$RUNNER_TEMP/fp/base/$platform.json")" + if [ "$head_hash" = "$base_hash" ]; then + echo "- ✅ **$platform**: unchanged (\`$head_hash\`) — OTA-compatible" + continue + fi + changed="$changed $platform" + echo "- 📱 **$platform**: \`$base_hash\` → \`$head_hash\` — merging requires a new native build before OTAs work again" + jq -r -n \ + --slurpfile h "$RUNNER_TEMP/fp/head/$platform.json" \ + --slurpfile b "$RUNNER_TEMP/fp/base/$platform.json" ' + ($b[0].sources | map({ (.filePath // .id): .hash }) | add // {}) as $bm + | $h[0].sources[] + | select($bm[(.filePath // .id)] != .hash) + | " - \(.type): `\(.filePath // .id)`"' + done + } >> "$GITHUB_STEP_SUMMARY" + echo "changed_platforms=${changed# }" >> "$GITHUB_OUTPUT" + + - name: Sync native change label + # Fork PRs get a read-only token under pull_request; the check stays + # advisory there (summary only). This workflow must not move to + # pull_request_target — it installs and runs PR code. + if: github.event.pull_request.head.repo.full_name == github.repository + uses: actions/github-script@v8 + env: + CHANGED_PLATFORMS: ${{ steps.compare.outputs.changed_platforms }} + with: + script: | + const managedLabel = { + name: "📱 Native Change", + color: "d93f0b", + description: + "Changes the native fingerprint; merging blocks production OTAs until a new store build ships.", + }; + const nativeChanged = (process.env.CHANGED_PLATFORMS ?? "").trim() !== ""; + const issueNumber = context.payload.pull_request.number; + + try { + const { data: existing } = await github.rest.issues.getLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: managedLabel.name, + }); + + if ( + existing.color !== managedLabel.color || + (existing.description ?? "") !== managedLabel.description + ) { + await github.rest.issues.updateLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: managedLabel.name, + color: managedLabel.color, + description: managedLabel.description, + }); + } + } catch (error) { + if (error.status !== 404) { + throw error; + } + + try { + await github.rest.issues.createLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: managedLabel.name, + color: managedLabel.color, + description: managedLabel.description, + }); + } catch (createError) { + if (createError.status !== 422) { + throw createError; + } + } + } + + const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + per_page: 100, + }); + const hasLabel = currentLabels.some((label) => label.name === managedLabel.name); + + if (nativeChanged && !hasLabel) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + labels: [managedLabel.name], + }); + } else if (!nativeChanged && hasLabel) { + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + name: managedLabel.name, + }); + } catch (removeError) { + if (removeError.status !== 404) { + throw removeError; + } + } + } + + core.info( + `PR #${issueNumber}: native fingerprint ${nativeChanged ? `changed (${process.env.CHANGED_PLATFORMS})` : "unchanged"}`, + ); diff --git a/.github/workflows/mobile-showcase-screenshots.yml b/.github/workflows/mobile-showcase-screenshots.yml index 0aa9f30a2ff2..3eaaf508e31f 100644 --- a/.github/workflows/mobile-showcase-screenshots.yml +++ b/.github/workflows/mobile-showcase-screenshots.yml @@ -52,6 +52,7 @@ jobs: args: - --filter=@t3tools/mobile... - --filter=@t3tools/scripts... + - --filter=t3... - name: Expose pnpm run: | @@ -100,6 +101,7 @@ jobs: args: - --filter=@t3tools/mobile... - --filter=@t3tools/scripts... + - --filter=t3... - name: Expose pnpm run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ce47d6e6ed73..81ef25effc8e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -746,16 +746,10 @@ jobs: needs: [preflight, build, publish_cli] if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.build.result == 'success' && needs.publish_cli.result == 'success' }} runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 10 + timeout-minutes: 30 + permissions: + contents: write steps: - - id: app_token - name: Mint release app token - uses: actions/create-github-app-token@v2 - with: - app-id: ${{ secrets.RELEASE_APP_ID }} - private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} - owner: ${{ github.repository_owner }} - - name: Checkout uses: actions/checkout@v6 with: @@ -823,7 +817,7 @@ jobs: - name: Publish release if: needs.preflight.outputs.previous_tag != '' - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: tag_name: ${{ needs.preflight.outputs.tag }} target_commitish: ${{ needs.preflight.outputs.ref }} @@ -840,11 +834,11 @@ jobs: release-assets/*.blockmap release-assets/*.yml fail_on_unmatched_files: true - token: ${{ steps.app_token.outputs.token }} + token: ${{ github.token }} - name: Publish first release if: needs.preflight.outputs.previous_tag == '' - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: tag_name: ${{ needs.preflight.outputs.tag }} target_commitish: ${{ needs.preflight.outputs.ref }} @@ -860,7 +854,7 @@ jobs: release-assets/*.blockmap release-assets/*.yml fail_on_unmatched_files: true - token: ${{ steps.app_token.outputs.token }} + token: ${{ github.token }} deploy_web: name: Deploy hosted web app diff --git a/.github/workflows/thread-transfer-report.yml b/.github/workflows/thread-transfer-report.yml new file mode 100644 index 000000000000..23eec72923bd --- /dev/null +++ b/.github/workflows/thread-transfer-report.yml @@ -0,0 +1,75 @@ +name: Thread Transfer Report + +on: + workflow_run: + workflows: [CI] + types: [completed] + +permissions: + actions: read + contents: read + pull-requests: write + +jobs: + publish: + name: Publish PR comment + if: github.event.workflow_run.event == 'pull_request' + runs-on: ubuntu-24.04 + concurrency: + group: thread-transfer-report-${{ github.event.workflow_run.pull_requests[0].number || github.event.workflow_run.id }} + cancel-in-progress: true + steps: + # workflow_run has a write-capable token even for fork PRs. Only load the + # publisher from the trusted default branch and never execute PR code. + - name: Checkout trusted publisher + uses: actions/checkout@v6 + with: + ref: ${{ github.event.repository.default_branch }} + sparse-checkout: .github/scripts + + - name: Test trusted publisher + run: node --test .github/scripts/thread-transfer-report.test.cjs + + - id: resolve + name: Resolve PR and baseline artifacts + uses: actions/github-script@v8 + with: + script: | + const reporter = require("./.github/scripts/thread-transfer-report.cjs"); + await reporter.resolve({ github, context, core }); + + - name: Download PR result + if: steps.resolve.outputs.publish == 'true' && steps.resolve.outputs.pr_artifact == 'true' + uses: actions/download-artifact@v8 + with: + name: thread-transfer-results + path: ${{ runner.temp }}/thread-transfer/pr + github-token: ${{ secrets.GITHUB_TOKEN }} + run-id: ${{ steps.resolve.outputs.pr_run_id }} + + - name: Download main baseline + if: steps.resolve.outputs.publish == 'true' && steps.resolve.outputs.baseline_artifact == 'true' + uses: actions/download-artifact@v8 + with: + name: thread-transfer-results + path: ${{ runner.temp }}/thread-transfer/main + github-token: ${{ secrets.GITHUB_TOKEN }} + run-id: ${{ steps.resolve.outputs.baseline_run_id }} + + - name: Update thread transfer comment + if: steps.resolve.outputs.publish == 'true' + uses: actions/github-script@v8 + env: + PR_NUMBER: ${{ steps.resolve.outputs.pull_number }} + PR_SHA: ${{ steps.resolve.outputs.pr_sha }} + PR_CONCLUSION: ${{ steps.resolve.outputs.pr_conclusion }} + PR_RUN_ID: ${{ steps.resolve.outputs.pr_run_id }} + PR_RESULT_DIR: ${{ runner.temp }}/thread-transfer/pr + BASELINE_SHA: ${{ steps.resolve.outputs.baseline_sha }} + BASELINE_MATCHES_BASE: ${{ steps.resolve.outputs.baseline_matches_base }} + BASELINE_RUN_ID: ${{ steps.resolve.outputs.baseline_run_id }} + BASELINE_RESULT_DIR: ${{ runner.temp }}/thread-transfer/main + with: + script: | + const reporter = require("./.github/scripts/thread-transfer-report.cjs"); + await reporter.publish({ github, context, core }); diff --git a/.github/workflows/web-preview.yml b/.github/workflows/web-preview.yml new file mode 100644 index 000000000000..f9cc3b063fcd --- /dev/null +++ b/.github/workflows/web-preview.yml @@ -0,0 +1,132 @@ +name: Web Preview + +# Label a PR `preview:web` to get a hosted-web preview deployment on Vercel for +# that push and every subsequent push. The deployment is a plain (non-prod, +# non-aliased) deploy into the existing hosted-web Vercel project, so the +# latest/nightly channel aliases are never touched. +# +# The build intentionally omits the T3 Connect cloud config (Clerk keys, relay +# URL): previews boot as the hosted-static app with manual pairing only. Pair a +# server into a preview with `t3 pair --tailscale` (or any reachable HTTPS +# backend) and open the pairing URL against the preview origin. +# +# The preview must be opened at the exact deployment URL from the PR comment. +# Vite bakes that URL in as the hosted origin (via VERCEL_URL), and +# `isHostedStaticApp` matches on origin, so branch-alias URLs will not +# self-identify as the hosted app. + +on: + pull_request: + types: [labeled, synchronize, reopened] + +permissions: + contents: read + pull-requests: write + +concurrency: + group: web-preview-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + deploy: + name: Deploy web preview + # Same-repo PRs only: fork PRs do not receive the Vercel secrets, and this + # workflow should skip rather than fail for them. On `labeled` events, only + # the preview label itself triggers a deploy. + if: >- + github.event.pull_request.head.repo.full_name == github.repository && + contains(github.event.pull_request.labels.*.name, 'preview:web') && + (github.event.action != 'labeled' || github.event.label.name == 'preview:web') + runs-on: blacksmith-8vcpu-ubuntu-2404 + timeout-minutes: 10 + env: + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + VERCEL_TEAM_SLUG: ${{ vars.VERCEL_TEAM_SLUG }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha }} + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: | + args: + - --filter=@t3tools/scripts... + - --filter=@t3tools/web... + + - id: deploy + name: Deploy preview + shell: bash + run: | + set -euo pipefail + + if [[ -z "${VERCEL_TOKEN:-}" || -z "${VERCEL_ORG_ID:-}" || -z "${VERCEL_PROJECT_ID:-}" ]]; then + echo "Missing one or more required Vercel secrets: VERCEL_TOKEN, VERCEL_ORG_ID, VERCEL_PROJECT_ID." >&2 + exit 1 + fi + + vercel_scope="${VERCEL_TEAM_SLUG:-$VERCEL_ORG_ID}" + + deployment_url="$( + vp dlx vercel@53.1.1 deploy \ + --archive=tgz \ + --yes \ + --token "$VERCEL_TOKEN" \ + --scope "$vercel_scope" + )" + + echo "Deployed $deployment_url" + echo "deployment_url=$deployment_url" >> "$GITHUB_OUTPUT" + + - name: Comment deployment URL + uses: actions/github-script@v8 + env: + DEPLOYMENT_URL: ${{ steps.deploy.outputs.deployment_url }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + with: + script: | + const marker = ""; + const body = [ + marker, + "### Web preview", + "", + `${process.env.DEPLOYMENT_URL} (for ${process.env.HEAD_SHA.slice(0, 7)})`, + "", + "Open this exact URL — the hosted-app origin is baked in at build time.", + "Pair a server into it with `t3 pair --tailscale`, or paste a host + pairing", + "code under Settings → Connections.", + ].join("\n"); + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + per_page: 100, + }); + const existing = comments.find((comment) => comment.body?.includes(marker)); + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + body, + }); + } diff --git a/AGENTS.md b/AGENTS.md index c3a7fe92bf40..1b41f833ce58 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,8 +79,8 @@ The most common defect in this repo is a change that works on the path you teste - `vp i` installs. Worktrees get this from the t3.json setup script; if module resolution looks broken, it probably did not run. - `vp run dev` starts server and web. In a worktree, state defaults to that worktree's gitignored `.t3`, which deliberately outranks an ambient `T3CODE_HOME` so you cannot land on shared state by accident. An explicit `--home-dir` still wins. - Ports derive from the worktree path and are stable across restarts, but read the real ones from the `[dev-runner]` line since occupied ports shift. -- `--share` publishes over the tailnet. Do not open the URL when you use this, just send it to the user with the pairing code included in url -- The web app requires pairing. Hand over the pairing URL, not the bare origin. A URL without its token is useless to whoever you gave it to. +- Sharing over the tailnet is three steps: run `vp run dev --share` in the background, wait for the `pairingUrl:` line in its output, paste that full URL (token included) in your reply. Do not wire up `tailscale serve` by hand for this, and do not open the URL yourself. +- The web app requires pairing. Hand over the pairing URL, not the bare origin. A URL without its token is useless to whoever you gave it to. If the token got consumed, mint a fresh one with `node apps/server/src/bin.ts pair` — note it carries standard scopes, while the startup URL carries admin scopes (needed for Settings → Connections management). - Stop what you started, by the PID you tracked. See rule 1. ## Test data diff --git a/README.md b/README.md index 1e9b05179453..c2349e72860a 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,7 @@ Full docs live in [docs/](./docs). There's no docs site yet. - [Install and first run](./docs/user/install.md) - [Permission modes](./docs/user/permission-modes.md) - [Keyboard shortcuts](./docs/user/keybindings.md) +- [Customize a project icon](./docs/user/project-settings.md) - [Remote access from a phone or another machine](./docs/user/remote-access.md) - [Keeping app and server in sync](./docs/user/updating.md) - [Source control integrations](./docs/user/source-control.md) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 1526965427f3..a34a55f16acf 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/desktop", - "version": "0.0.31", + "version": "0.0.33", "private": true, "type": "module", "main": "dist-electron/main.cjs", diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index fd86c5f05d25..4101840530f6 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -9,6 +9,7 @@ import * as Crypto from "effect/Crypto"; import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronDialog from "../electron/ElectronDialog.ts"; import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; +import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts"; import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts"; import * as DesktopAppIdentity from "./DesktopAppIdentity.ts"; import * as DesktopClerk from "./DesktopClerk.ts"; @@ -17,7 +18,9 @@ import * as DesktopWindow from "../window/DesktopWindow.ts"; import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import * as DesktopLifecycle from "./DesktopLifecycle.ts"; +import * as DesktopLinuxUrlHandler from "./DesktopLinuxUrlHandler.ts"; import * as DesktopObservability from "./DesktopObservability.ts"; +import * as DesktopPreReadyPlatform from "./DesktopPreReadyPlatform.ts"; import * as DesktopShutdown from "./DesktopShutdown.ts"; import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; @@ -220,20 +223,49 @@ const startup = Effect.gen(function* () { const applicationMenu = yield* DesktopApplicationMenu.DesktopApplicationMenu; const electronApp = yield* ElectronApp.ElectronApp; const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; + const linuxUrlHandler = yield* DesktopLinuxUrlHandler.DesktopLinuxUrlHandler; const clerk = yield* DesktopClerk.DesktopClerk; const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment; const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; + const preReadyElectronOptions = yield* DesktopPreReadyPlatform.DesktopPreReadyElectronOptions; + const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage; const updates = yield* DesktopUpdates.DesktopUpdates; const environment = yield* DesktopEnvironment.DesktopEnvironment; yield* shellEnvironment.installIntoProcess; + const hasCommandLinePasswordStore = + preReadyElectronOptions.linuxPasswordStoreCommandLine !== null; + const linuxElectronOptions = + environment.platform === "linux" && !hasCommandLinePasswordStore + ? DesktopPreReadyPlatform.resolveEarlyLinuxElectronOptionsFromProcess() + : preReadyElectronOptions.linux; + if (linuxElectronOptions !== null && !hasCommandLinePasswordStore) { + if ( + linuxElectronOptions.passwordStore !== null || + preReadyElectronOptions.linux?.passwordStore !== null + ) { + yield* electronApp.removeCommandLineSwitch("password-store"); + } + if (linuxElectronOptions.passwordStore !== null) { + yield* electronApp.appendCommandLineSwitch( + "password-store", + linuxElectronOptions.passwordStore, + ); + } + } const userDataPath = yield* appIdentity.resolveUserDataPath; yield* electronApp.setPath("userData", userDataPath); yield* logStartupInfo("runtime logging configured", { logDir: environment.logDir }); yield* desktopSettings.load; - if (environment.platform === "linux") { - yield* electronApp.appendCommandLineSwitch("class", environment.linuxWmClass); + if (linuxElectronOptions !== null) { + yield* logStartupInfo("linux password store configured", { + passwordStore: hasCommandLinePasswordStore + ? "command-line" + : (linuxElectronOptions.passwordStore ?? "electron-default"), + xdgCurrentDesktop: process.env.XDG_CURRENT_DESKTOP ?? null, + xdgSessionDesktop: process.env.XDG_SESSION_DESKTOP ?? null, + }); } yield* appIdentity.configure; @@ -245,9 +277,16 @@ const startup = Effect.gen(function* () { Effect.catchCause((cause) => fatalStartupCause("whenReady", cause)), ); yield* logStartupInfo("app ready"); + if (environment.platform === "linux") { + const selectedBackend = yield* safeStorage.selectedStorageBackend; + yield* logStartupInfo("safe storage ready", { + backend: Option.getOrElse(selectedBackend, () => "unknown"), + }); + } yield* appIdentity.configure; yield* applicationMenu.configure; yield* updates.configure; + yield* linuxUrlHandler.register; yield* bootstrap.pipe(Effect.catchCause((cause) => fatalStartupCause("bootstrap", cause))); }).pipe(Effect.withSpan("desktop.startup")); diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts index 6c5385e70a6d..de945054c893 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.test.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.test.ts @@ -64,6 +64,7 @@ const makeElectronAppLayer = (calls: ElectronAppCalls) => }), appendCommandLineSwitch: () => Effect.void, onBeforeQuitForUpdate: () => Effect.void, + removeCommandLineSwitch: () => Effect.void, on: () => Effect.void, } satisfies ElectronApp.ElectronApp["Service"]); diff --git a/apps/desktop/src/app/DesktopConfig.ts b/apps/desktop/src/app/DesktopConfig.ts index 4bf6b5133062..d157a4c6ba44 100644 --- a/apps/desktop/src/app/DesktopConfig.ts +++ b/apps/desktop/src/app/DesktopConfig.ts @@ -35,6 +35,7 @@ const compactEnv = (env: Readonly>): Record return decoded.slice("encrypted:".length); }); }, + selectedStorageBackend: Effect.succeed(Option.none()), } satisfies ElectronSafeStorage.ElectronSafeStorage["Service"]); } diff --git a/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts b/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts new file mode 100644 index 000000000000..b7647b5cc10f --- /dev/null +++ b/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts @@ -0,0 +1,121 @@ +// @effect-diagnostics nodeBuiltinImport:off - tests use POSIX path joining to match the Linux startup boundary. +import * as NodePath from "node:path"; +import { assert, describe, it } from "@effect/vitest"; + +import { + resolveEarlyLinuxElectronOptions, + resolveEarlyLinuxPasswordStorePreference, +} from "./DesktopEarlyElectronStartup.ts"; + +describe("DesktopEarlyElectronStartup", () => { + const joinPath = NodePath.posix.join; + + it("reads the persisted linux password-store preference before Electron is ready", () => { + const preference = resolveEarlyLinuxPasswordStorePreference({ + env: { T3CODE_HOME: "/home/user/.t3-test" }, + homeDirectory: "/home/user", + joinPath, + readFileString: (path) => { + assert.equal(path, "/home/user/.t3-test/userdata/desktop-settings.json"); + return JSON.stringify({ linuxPasswordStore: "kwallet6" }); + }, + }); + + assert.equal(preference, "kwallet6"); + }); + + it("accepts JSONC in the early desktop settings file", () => { + const preference = resolveEarlyLinuxPasswordStorePreference({ + env: { T3CODE_HOME: "/home/user/.t3-test" }, + homeDirectory: "/home/user", + joinPath, + readFileString: () => `{ + // manually edited setting + "linuxPasswordStore": "gnome-libsecret", + }`, + }); + + assert.equal(preference, "gnome-libsecret"); + }); + + it("falls back to auto when the early settings document is missing or invalid", () => { + const preference = resolveEarlyLinuxPasswordStorePreference({ + env: {}, + homeDirectory: "/home/user", + joinPath, + readFileString: () => { + throw new Error("missing"); + }, + }); + + assert.equal(preference, "auto"); + }); + + it("preserves absolute root paths when resolving early settings", () => { + const preference = resolveEarlyLinuxPasswordStorePreference({ + env: { T3CODE_HOME: "/" }, + homeDirectory: "/home/user", + joinPath, + readFileString: (path) => { + assert.equal(path, "/userdata/desktop-settings.json"); + return JSON.stringify({ linuxPasswordStore: "kwallet6" }); + }, + }); + + assert.equal(preference, "kwallet6"); + }); + + it("resolves the early linux Electron switches", () => { + const options = resolveEarlyLinuxElectronOptions({ + env: { + T3CODE_HOME: "/home/user/.t3-test", + XDG_CURRENT_DESKTOP: "niri", + VITE_DEV_SERVER_URL: "http://127.0.0.1:5173", + }, + homeDirectory: "/home/user", + joinPath, + readFileString: (path) => { + assert.equal(path, "/home/user/.t3-test/userdata/desktop-settings.json"); + return JSON.stringify({ linuxPasswordStore: "auto" }); + }, + }); + + assert.deepEqual(options, { + linuxWmClass: "t3code-dev", + passwordStore: "gnome-libsecret", + }); + }); + + it("keeps implicit development state under ~/.t3/dev when T3CODE_HOME is unset", () => { + const preference = resolveEarlyLinuxPasswordStorePreference({ + env: { + VITE_DEV_SERVER_URL: "http://127.0.0.1:5173", + }, + homeDirectory: "/home/user", + joinPath, + readFileString: (path) => { + assert.equal(path, "/home/user/.t3/dev/desktop-settings.json"); + return JSON.stringify({ linuxPasswordStore: "kwallet" }); + }, + }); + + assert.equal(preference, "kwallet"); + }); + + it("treats whitespace-only T3CODE_HOME as unconfigured in development", () => { + const preference = resolveEarlyLinuxPasswordStorePreference({ + env: { + T3CODE_HOME: " ", + VITE_DEV_SERVER_URL: "http://127.0.0.1:5173", + }, + homeDirectory: "/home/user", + joinPath, + readFileString: (path) => { + assert.equal(path, "/home/user/.t3/dev/desktop-settings.json"); + return JSON.stringify({ linuxPasswordStore: "gnome-libsecret" }); + }, + }); + + assert.equal(preference, "gnome-libsecret"); + }); +}); diff --git a/apps/desktop/src/app/DesktopEarlyElectronStartup.ts b/apps/desktop/src/app/DesktopEarlyElectronStartup.ts new file mode 100644 index 000000000000..3e11d7961a9f --- /dev/null +++ b/apps/desktop/src/app/DesktopEarlyElectronStartup.ts @@ -0,0 +1,90 @@ +import { fromLenientJson } from "@t3tools/shared/schemaJson"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import { + DEFAULT_LINUX_PASSWORD_STORE, + normalizeLinuxPasswordStorePreference, + resolveLinuxPasswordStoreSwitch, + type LinuxPasswordStoreSwitch, + type LinuxPasswordStorePreference, +} from "../linuxSecretStorage.ts"; +import { + resolveDesktopBaseDir, + resolveDesktopStateDir, + type JoinPath, +} from "./DesktopStatePaths.ts"; + +interface EarlyDesktopSettingsInput { + readonly env: NodeJS.ProcessEnv; + readonly homeDirectory: string; + readonly joinPath: JoinPath; + readonly readFileString: (path: string) => string; +} + +type EarlyLinuxElectronOptionsInput = EarlyDesktopSettingsInput; + +export interface EarlyLinuxElectronOptions { + readonly linuxWmClass: string; + readonly passwordStore: LinuxPasswordStoreSwitch | null; +} + +const trimNonEmpty = (value: string | undefined): string | null => { + const trimmed = value?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : null; +}; + +const EarlyDesktopSettingsJson = fromLenientJson( + Schema.Struct({ + linuxPasswordStore: Schema.optionalKey(Schema.Unknown), + }), +); +const decodeEarlyDesktopSettingsJson = Schema.decodeSync(EarlyDesktopSettingsJson); + +const isDevelopmentEnvironment = (env: NodeJS.ProcessEnv): boolean => + trimNonEmpty(env.VITE_DEV_SERVER_URL) !== null; + +function resolveEarlyDesktopSettingsPath(input: { + readonly env: NodeJS.ProcessEnv; + readonly homeDirectory: string; + readonly joinPath: JoinPath; +}): string { + const t3Home = Option.fromUndefinedOr(input.env.T3CODE_HOME); + const baseDir = resolveDesktopBaseDir({ + homeDirectory: input.homeDirectory, + joinPath: input.joinPath, + t3Home, + }); + const stateDir = resolveDesktopStateDir({ + baseDir, + isDevelopment: isDevelopmentEnvironment(input.env), + joinPath: input.joinPath, + t3Home, + }); + return input.joinPath(stateDir, "desktop-settings.json"); +} + +export function resolveEarlyLinuxPasswordStorePreference( + input: EarlyDesktopSettingsInput, +): LinuxPasswordStorePreference { + const settingsPath = resolveEarlyDesktopSettingsPath(input); + try { + const parsed = decodeEarlyDesktopSettingsJson(input.readFileString(settingsPath)); + return normalizeLinuxPasswordStorePreference(parsed.linuxPasswordStore); + } catch { + return DEFAULT_LINUX_PASSWORD_STORE; + } +} + +export function resolveEarlyLinuxElectronOptions( + input: EarlyLinuxElectronOptionsInput, +): EarlyLinuxElectronOptions { + const preference = resolveEarlyLinuxPasswordStorePreference(input); + return { + linuxWmClass: isDevelopmentEnvironment(input.env) ? "t3code-dev" : "t3code", + passwordStore: resolveLinuxPasswordStoreSwitch({ + preference, + env: input.env, + }), + }; +} diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index c991f5b39d6b..1806289a08d1 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -13,6 +13,7 @@ import * as Path from "effect/Path"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopConfig from "./DesktopConfig.ts"; +import { resolveDesktopBaseDir, resolveDesktopStateDir } from "./DesktopStatePaths.ts"; import { isNightlyDesktopVersion } from "../updates/updateChannels.ts"; export interface MakeDesktopEnvironmentInput { @@ -66,6 +67,8 @@ export class DesktopEnvironment extends Context.Service< readonly appUserModelId: string; readonly linuxDesktopEntryName: string; readonly linuxWmClass: string; + readonly linuxApplicationsDir: string; + readonly appImagePath: Option.Option; readonly userDataDirName: string; readonly legacyUserDataDirName: string; readonly defaultDesktopSettings: DesktopAppSettings.DesktopSettings; @@ -147,8 +150,11 @@ const make = Effect.fn("desktop.environment.make")(function* ( : input.platform === "darwin" ? path.join(homeDirectory, "Library", "Application Support") : Option.getOrElse(config.xdgConfigHome, () => path.join(homeDirectory, ".config")); - const configuredBaseDir = config.t3Home; - const baseDir = Option.getOrElse(configuredBaseDir, () => path.join(homeDirectory, ".t3")); + const baseDir = resolveDesktopBaseDir({ + homeDirectory, + joinPath: path.join, + t3Home: config.t3Home, + }); const rootDir = path.resolve(input.dirname, "../../.."); const appRoot = input.isPackaged ? input.appPath : rootDir; const branding = resolveDesktopAppBranding({ @@ -156,12 +162,18 @@ const make = Effect.fn("desktop.environment.make")(function* ( appVersion: input.appVersion, }); const displayName = branding.displayName; - const stateDir = path.join( + const stateDir = resolveDesktopStateDir({ baseDir, - isDevelopment && Option.isNone(configuredBaseDir) ? "dev" : "userdata", - ); + isDevelopment, + joinPath: path.join, + t3Home: config.t3Home, + }); const userDataDirName = isDevelopment ? "t3code-dev" : "t3code"; const legacyUserDataDirName = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; + const linuxApplicationsDir = path.join( + Option.getOrElse(config.xdgDataHome, () => path.join(homeDirectory, ".local", "share")), + "applications", + ); const resourcesPath = input.resourcesPath; return DesktopEnvironment.of({ @@ -205,6 +217,8 @@ const make = Effect.fn("desktop.environment.make")(function* ( ), linuxDesktopEntryName: isDevelopment ? "t3code-dev.desktop" : "t3code.desktop", linuxWmClass: isDevelopment ? "t3code-dev" : "t3code", + linuxApplicationsDir, + appImagePath: config.appImagePath, userDataDirName, legacyUserDataDirName, defaultDesktopSettings: DesktopAppSettings.resolveDefaultDesktopSettings(input.appVersion), diff --git a/apps/desktop/src/app/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts index 978e000a7f58..45e1c82460c8 100644 --- a/apps/desktop/src/app/DesktopLifecycle.test.ts +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -35,6 +35,7 @@ describe("DesktopLifecycle", () => { setDesktopName: () => Effect.void, setDockIcon: () => Effect.void, appendCommandLineSwitch: () => Effect.void, + removeCommandLineSwitch: () => Effect.void, onBeforeQuitForUpdate: (listener) => Effect.acquireRelease( Effect.sync(() => { @@ -77,6 +78,7 @@ describe("DesktopLifecycle", () => { handleBackendNotReady: Effect.void, flushMainWindowBounds: Effect.void, dispatchMenuAction: () => Effect.void, + zoomMain: () => Effect.void, syncAppearance: Effect.void, }); diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts new file mode 100644 index 000000000000..30183808a152 --- /dev/null +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts @@ -0,0 +1,229 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as PlatformError from "effect/PlatformError"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; + +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import * as DesktopLinuxUrlHandler from "./DesktopLinuxUrlHandler.ts"; + +interface RecordedRegistration { + readonly directories: string[]; + readonly files: Array<{ readonly path: string; readonly content: string }>; + readonly commands: Array<{ readonly command: string; readonly args: ReadonlyArray }>; +} + +const makeEnvironment = (overrides: Record = {}) => + DesktopEnvironment.DesktopEnvironment.of({ + platform: "linux", + isPackaged: true, + isDevelopment: false, + displayName: "T3 Code (Alpha)", + linuxWmClass: "t3code", + linuxApplicationsDir: "/home/alice/.local/share/applications", + appImagePath: Option.some("/home/alice/Applications/T3-Code.AppImage"), + path: { join: (...parts: ReadonlyArray) => parts.join("/") }, + ...overrides, + } as unknown as DesktopEnvironment.DesktopEnvironment["Service"]); + +const mockProcess = (exitCode: number) => + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + +const makeHandlerLayer = ( + recorded: RecordedRegistration, + input: { + readonly environment?: Record; + readonly xdgMimeExitCode?: number; + readonly writeError?: PlatformError.PlatformError; + } = {}, +) => + DesktopLinuxUrlHandler.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(DesktopEnvironment.DesktopEnvironment, makeEnvironment(input.environment)), + FileSystem.layerNoop({ + makeDirectory: (path) => + Effect.sync(() => { + recorded.directories.push(path); + }), + writeFileString: (path, content) => + input.writeError + ? Effect.fail(input.writeError) + : Effect.sync(() => { + recorded.files.push({ path, content }); + }), + }), + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + const childProcess = command as unknown as { + readonly command: string; + readonly args: ReadonlyArray; + }; + recorded.commands.push({ + command: childProcess.command, + args: childProcess.args, + }); + return Effect.succeed(mockProcess(input.xdgMimeExitCode ?? 0)); + }), + ), + ), + ), + ); + +const runRegister = ( + recorded: RecordedRegistration, + input: Parameters[1] = {}, +) => + Effect.gen(function* () { + const handler = yield* DesktopLinuxUrlHandler.DesktopLinuxUrlHandler; + yield* handler.register; + }).pipe(Effect.provide(makeHandlerLayer(recorded, input))); + +const emptyRecording = (): RecordedRegistration => ({ + directories: [], + files: [], + commands: [], +}); + +describe("DesktopLinuxUrlHandler", () => { + it("renders a scheme-handler desktop entry with freedesktop Exec quoting", () => { + const entry = DesktopLinuxUrlHandler.renderUrlHandlerDesktopEntry({ + displayName: "T3 Code (Nightly)", + execTarget: '/home/al ice/Apps/T3 "100%" $HOME\\x.AppImage', + scheme: "t3code", + }); + + assert.include(entry, "[Desktop Entry]"); + assert.include(entry, "Name=T3 Code (Nightly)"); + // Exec composes both escaping layers: a literal backslash becomes four + // backslashes in the file, a quote three characters, a dollar sign two + // backslashes plus the sign. + assert.include( + entry, + 'Exec="/home/al ice/Apps/T3 \\\\"100%%\\\\" \\\\$HOME\\\\\\\\x.AppImage" %U', + ); + assert.include(entry, "NoDisplay=true"); + assert.notInclude(entry, "StartupWMClass="); + assert.include(entry, "MimeType=x-scheme-handler/t3code;"); + }); + + it("carries structured context on registration errors", () => { + const writeError = new DesktopLinuxUrlHandler.DesktopLinuxUrlHandlerRegistrationError({ + step: "write-desktop-entry", + scheme: "t3code", + desktopEntryPath: "/home/alice/.local/share/applications/t3code-url-handler.desktop", + cause: new Error("boom"), + }); + assert.equal( + writeError.message, + "Failed to register the t3code:// URL handler (step: write-desktop-entry).", + ); + assert.equal( + writeError.desktopEntryPath, + "/home/alice/.local/share/applications/t3code-url-handler.desktop", + ); + + const exitError = new DesktopLinuxUrlHandler.DesktopLinuxUrlHandlerRegistrationError({ + step: "set-default-handler", + scheme: "t3code", + exitCode: 4, + }); + assert.equal( + exitError.message, + "Failed to register the t3code:// URL handler (step: set-default-handler, xdg-mime exit code 4).", + ); + }); + + it.effect("writes the handler entry and claims the scheme default via xdg-mime", () => { + const recorded = emptyRecording(); + + return Effect.gen(function* () { + yield* runRegister(recorded); + + assert.deepEqual(recorded.directories, ["/home/alice/.local/share/applications"]); + assert.equal(recorded.files.length, 1); + assert.equal( + recorded.files[0]?.path, + "/home/alice/.local/share/applications/t3code-url-handler.desktop", + ); + assert.include( + recorded.files[0]?.content, + 'Exec="/home/alice/Applications/T3-Code.AppImage" %U', + ); + assert.include(recorded.files[0]?.content, "MimeType=x-scheme-handler/t3code;"); + assert.deepEqual(recorded.commands, [ + { + command: "xdg-mime", + args: ["default", "t3code-url-handler.desktop", "x-scheme-handler/t3code"], + }, + ]); + }); + }); + + it.effect("falls back to the process executable outside an AppImage", () => { + const recorded = emptyRecording(); + + return Effect.gen(function* () { + yield* runRegister(recorded, { environment: { appImagePath: Option.none() } }); + + assert.include( + recorded.files[0]?.content, + `Exec=${DesktopLinuxUrlHandler.escapeDesktopEntryExecArgument(process.execPath)} %U`, + ); + }); + }); + + it.effect("does nothing on other platforms or unpackaged builds", () => { + const nonLinux = emptyRecording(); + const unpackaged = emptyRecording(); + + return Effect.gen(function* () { + yield* runRegister(nonLinux, { environment: { platform: "darwin" } }); + yield* runRegister(unpackaged, { environment: { isPackaged: false } }); + + for (const recorded of [nonLinux, unpackaged]) { + assert.deepEqual(recorded.directories, []); + assert.deepEqual(recorded.files, []); + assert.deepEqual(recorded.commands, []); + } + }); + }); + + it.effect("never fails startup when registration cannot complete", () => { + const xdgMimeFailed = emptyRecording(); + const writeFailed = emptyRecording(); + + return Effect.gen(function* () { + yield* runRegister(xdgMimeFailed, { xdgMimeExitCode: 1 }); + yield* runRegister(writeFailed, { + writeError: PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "writeFileString", + description: "read-only filesystem", + pathOrDescriptor: "/home/alice/.local/share/applications/t3code-url-handler.desktop", + }), + }); + + assert.equal(xdgMimeFailed.files.length, 1); + assert.deepEqual(writeFailed.commands, []); + }); + }); +}); diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts new file mode 100644 index 000000000000..e531a54dfce6 --- /dev/null +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts @@ -0,0 +1,191 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; + +import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import { makeComponentLogger } from "./DesktopObservability.ts"; + +// Linux ships as an AppImage, so the .desktop entry users end up with is +// created by whatever integration tool they use (AppImageLauncher names it +// appimagekit_-….desktop) and its filename is not under our control. +// Electron's app.setAsDefaultProtocolClient resolves the desktop id from +// setDesktopName, which cannot match those files — so the browser keeps +// prompting "Choose an application" for every OAuth callback. Instead, write +// our own handler entry pointing at the current AppImage and claim the +// scheme default via xdg-mime, exactly what the file manager's "set as +// default" checkbox would record in mimeapps.list. +export const URL_HANDLER_DESKTOP_ENTRY_NAME = "t3code-url-handler.desktop"; + +const { logInfo, logWarning } = makeComponentLogger("desktop-linux-url-handler"); + +export class DesktopLinuxUrlHandlerRegistrationError extends Schema.TaggedErrorClass()( + "DesktopLinuxUrlHandlerRegistrationError", + { + step: Schema.Literals(["write-desktop-entry", "set-default-handler"]), + scheme: Schema.String, + desktopEntryPath: Schema.optionalKey(Schema.String), + exitCode: Schema.optionalKey(Schema.Number), + cause: Schema.optionalKey(Schema.Defect()), + }, +) { + override get message(): string { + const exitCode = this.exitCode === undefined ? "" : `, xdg-mime exit code ${this.exitCode}`; + return `Failed to register the ${this.scheme}:// URL handler (step: ${this.step}${exitCode}).`; + } +} + +const isRegistrationError = Schema.is(DesktopLinuxUrlHandlerRegistrationError); + +const escapeDesktopEntryString = (value: string): string => + value + .replaceAll("\\", "\\\\") + .replaceAll("\n", "\\n") + .replaceAll("\r", "\\r") + .replaceAll("\t", "\\t"); + +// Exec values are unescaped twice by implementations: first the general +// string-value rules, then the Exec quoting rules — so writing composes the +// layers in reverse. The argument is double-quoted with reserved characters +// backslash-escaped and literal percent signs doubled (field codes), and the +// general string escaping is applied on top: a literal backslash ends up as +// four backslashes in the file, a quote as \\", a dollar sign as \\$. +export function escapeDesktopEntryExecArgument(value: string): string { + const quoted = value + .replaceAll("\\", () => "\\\\") + .replaceAll("`", () => "\\`") + .replaceAll("$", () => "\\$") + .replaceAll('"', () => '\\"') + .replaceAll("%", () => "%%"); + return escapeDesktopEntryString(`"${quoted}"`); +} + +// The AppImage integration entry owns the window identity and icon. This +// hidden URL-only entry must not compete with it for StartupWMClass matching. +export function renderUrlHandlerDesktopEntry(input: { + readonly displayName: string; + readonly execTarget: string; + readonly scheme: string; +}): string { + return [ + "[Desktop Entry]", + "Type=Application", + `Name=${escapeDesktopEntryString(input.displayName)}`, + `Exec=${escapeDesktopEntryExecArgument(input.execTarget)} %U`, + "Terminal=false", + "NoDisplay=true", + "StartupNotify=false", + `MimeType=x-scheme-handler/${input.scheme};`, + "", + ].join("\n"); +} + +export class DesktopLinuxUrlHandler extends Context.Service< + DesktopLinuxUrlHandler, + { + readonly register: Effect.Effect; + } +>()("@t3tools/desktop/app/DesktopLinuxUrlHandler") {} + +export const make = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + const scheme = ElectronProtocol.getDesktopScheme(environment.isDevelopment); + const desktopEntryPath = environment.path.join( + environment.linuxApplicationsDir, + URL_HANDLER_DESKTOP_ENTRY_NAME, + ); + + const writeDesktopEntry = Effect.gen(function* () { + // Inside the mounted AppImage, process.execPath points at a transient + // /tmp/.mount_* path — the handler must launch the AppImage itself. + const execTarget = Option.getOrElse(environment.appImagePath, () => process.execPath); + yield* fileSystem.makeDirectory(environment.linuxApplicationsDir, { recursive: true }); + yield* fileSystem.writeFileString( + desktopEntryPath, + renderUrlHandlerDesktopEntry({ + displayName: environment.displayName, + execTarget, + scheme, + }), + ); + }).pipe( + Effect.mapError( + (cause) => + new DesktopLinuxUrlHandlerRegistrationError({ + step: "write-desktop-entry", + scheme, + desktopEntryPath, + cause, + }), + ), + ); + + const setDefaultHandler = Effect.scoped( + Effect.gen(function* () { + const command = ChildProcess.make( + "xdg-mime", + ["default", URL_HANDLER_DESKTOP_ENTRY_NAME, `x-scheme-handler/${scheme}`], + { + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }, + ); + const handle = yield* spawner.spawn(command); + const exitCode = yield* handle.exitCode; + if ((exitCode as unknown as number) !== 0) { + return yield* new DesktopLinuxUrlHandlerRegistrationError({ + step: "set-default-handler", + scheme, + exitCode: Number(exitCode), + }); + } + }), + ).pipe( + Effect.mapError((error) => + isRegistrationError(error) + ? error + : new DesktopLinuxUrlHandlerRegistrationError({ + step: "set-default-handler", + scheme, + cause: error, + }), + ), + ); + + const register = Effect.gen(function* () { + if (environment.platform !== "linux" || !environment.isPackaged) { + return; + } + yield* writeDesktopEntry; + yield* setDefaultHandler; + yield* logInfo("registered URL scheme handler", { scheme }); + }).pipe( + // Registration is best-effort: a missing xdg-mime or read-only home must + // never block startup — the OS chooser remains as fallback. + Effect.catch((error) => + logWarning("URL scheme handler registration failed", { + scheme, + step: error.step, + message: error.message, + ...(error.desktopEntryPath === undefined + ? {} + : { desktopEntryPath: error.desktopEntryPath }), + ...(error.exitCode === undefined ? {} : { exitCode: error.exitCode }), + }), + ), + Effect.withSpan("desktop.linuxUrlHandler.register"), + ); + + return DesktopLinuxUrlHandler.of({ register }); +}); + +export const layer = Layer.effect(DesktopLinuxUrlHandler, make); diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts new file mode 100644 index 000000000000..a29e0fd3baf6 --- /dev/null +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts @@ -0,0 +1,130 @@ +import { assert, describe, it } from "@effect/vitest"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { beforeEach, vi } from "vite-plus/test"; + +const { appendSwitchMock, getSwitchValueMock, hasSwitchMock, registerSchemesMock } = vi.hoisted( + () => ({ + appendSwitchMock: vi.fn(), + getSwitchValueMock: vi.fn(), + hasSwitchMock: vi.fn(), + registerSchemesMock: vi.fn(), + }), +); + +vi.mock("electron", () => ({ + app: { + commandLine: { + appendSwitch: appendSwitchMock, + getSwitchValue: getSwitchValueMock, + hasSwitch: hasSwitchMock, + }, + }, + protocol: { + registerSchemesAsPrivileged: registerSchemesMock, + }, +})); + +import * as DesktopPreReadyPlatform from "./DesktopPreReadyPlatform.ts"; + +describe("DesktopPreReadyPlatform", () => { + beforeEach(() => { + appendSwitchMock.mockReset(); + getSwitchValueMock.mockReset(); + hasSwitchMock.mockReset(); + registerSchemesMock.mockReset(); + }); + + it("reads an explicit Electron command-line switch value", () => { + const value = DesktopPreReadyPlatform.readCommandLineSwitchValue( + { + hasSwitch: (switchName) => switchName === "password-store", + getSwitchValue: (switchName) => { + assert.equal(switchName, "password-store"); + return "basic"; + }, + }, + "password-store", + ); + + assert.equal(value, "basic"); + }); + + it("treats valueless Electron command-line switches as absent", () => { + const value = DesktopPreReadyPlatform.readCommandLineSwitchValue( + { + hasSwitch: () => true, + getSwitchValue: () => "", + }, + "password-store", + ); + + assert.isNull(value); + }); + + it("returns null for missing Electron command-line switches", () => { + const value = DesktopPreReadyPlatform.readCommandLineSwitchValue( + { + hasSwitch: () => false, + getSwitchValue: () => { + throw new Error("Unexpected switch value read."); + }, + }, + "password-store", + ); + + assert.isNull(value); + }); + + it.effect( + "acquires a synchronous pre-ready layer before an asynchronous Clerk-shaped layer", + () => + Effect.gen(function* () { + class ClerkShaped extends Context.Service()( + "@t3tools/desktop/app/DesktopPreReadyPlatform.test/ClerkShaped", + ) {} + + const events: Array = []; + registerSchemesMock.mockImplementation(() => { + events.push("pre-ready"); + }); + + const preReadyLayer = DesktopPreReadyPlatform.layer.pipe( + Layer.provide(Layer.succeed(HostProcessPlatform, "darwin")), + ); + + const clerkShapedLayer = Layer.effect( + ClerkShaped, + Effect.promise(() => Promise.resolve()).pipe( + Effect.map(() => { + events.push("clerk"); + return { ready: true as const }; + }), + ), + ); + + const runtimeLayer = clerkShapedLayer.pipe( + Layer.flatMap((clerkContext) => Layer.succeedContext(clerkContext)), + Layer.provideMerge(preReadyLayer), + ); + + const result = yield* Effect.all({ + clerk: ClerkShaped, + preReady: DesktopPreReadyPlatform.DesktopPreReadyElectronOptions, + }).pipe(Effect.provide(runtimeLayer)); + + assert.deepEqual(result, { + clerk: { ready: true }, + preReady: { + linux: null, + linuxPasswordStoreCommandLine: null, + }, + }); + assert.deepEqual(events, ["pre-ready", "clerk"]); + assert.equal(registerSchemesMock.mock.calls.length, 1); + assert.equal(appendSwitchMock.mock.calls.length, 0); + }), + ); +}); diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.ts new file mode 100644 index 000000000000..7d145632d0bb --- /dev/null +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.ts @@ -0,0 +1,74 @@ +// @effect-diagnostics nodeBuiltinImport:off - pre-ready Electron setup reads persisted settings synchronously before app services are available. +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as Electron from "electron"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; + +import * as DesktopEarlyElectronStartup from "./DesktopEarlyElectronStartup.ts"; +import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; + +export interface DesktopPreReadyCommandLineReader { + readonly hasSwitch: (switchName: string) => boolean; + readonly getSwitchValue: (switchName: string) => string; +} + +export function readCommandLineSwitchValue( + commandLine: DesktopPreReadyCommandLineReader, + switchName: string, +): string | null { + if (!commandLine.hasSwitch(switchName)) { + return null; + } + + const value = commandLine.getSwitchValue(switchName).trim(); + return value.length > 0 ? value : null; +} + +export const resolveEarlyLinuxElectronOptionsFromProcess = + (): DesktopEarlyElectronStartup.EarlyLinuxElectronOptions => + DesktopEarlyElectronStartup.resolveEarlyLinuxElectronOptions({ + env: process.env, + homeDirectory: NodeOS.homedir(), + joinPath: NodePath.posix.join, + readFileString: (path) => NodeFS.readFileSync(path, "utf8"), + }); + +export class DesktopPreReadyElectronOptions extends Context.Service< + DesktopPreReadyElectronOptions, + { + readonly linux: DesktopEarlyElectronStartup.EarlyLinuxElectronOptions | null; + readonly linuxPasswordStoreCommandLine: string | null; + } +>()("@t3tools/desktop/app/DesktopPreReadyPlatform/DesktopPreReadyElectronOptions") {} + +export const make = Effect.gen(function* () { + const platform = yield* HostProcessPlatform; + return yield* Effect.sync((): DesktopPreReadyElectronOptions["Service"] => { + const linuxPasswordStoreCommandLine = + platform === "linux" + ? readCommandLineSwitchValue(Electron.app.commandLine, "password-store") + : null; + const linux = platform === "linux" ? resolveEarlyLinuxElectronOptionsFromProcess() : null; + + if (linux !== null) { + Electron.app.commandLine.appendSwitch("class", linux.linuxWmClass); + if (linux.passwordStore !== null && linuxPasswordStoreCommandLine === null) { + Electron.app.commandLine.appendSwitch("password-store", linux.passwordStore); + } + } + + return { linux, linuxPasswordStoreCommandLine }; + }); +}).pipe(Effect.withSpan("desktop.electron.configureBeforeReady")); + +// Keep Electron's strict pre-ready setup isolated so later runtime layers cannot +// observe app readiness before scheme privileges and command-line switches exist. +export const layer = Layer.mergeAll( + ElectronProtocol.layerSchemePrivileges, + Layer.effect(DesktopPreReadyElectronOptions, make), +); diff --git a/apps/desktop/src/app/DesktopStatePaths.ts b/apps/desktop/src/app/DesktopStatePaths.ts new file mode 100644 index 000000000000..006dd97092d4 --- /dev/null +++ b/apps/desktop/src/app/DesktopStatePaths.ts @@ -0,0 +1,32 @@ +import * as Option from "effect/Option"; + +export type JoinPath = (first: string, ...segments: string[]) => string; + +function normalizeConfiguredBaseDir(t3Home: Option.Option): Option.Option { + if (Option.isNone(t3Home)) { + return Option.none(); + } + const trimmed = t3Home.value.trim(); + return trimmed.length > 0 ? Option.some(trimmed) : Option.none(); +} + +export function resolveDesktopBaseDir(input: { + readonly homeDirectory: string; + readonly joinPath: JoinPath; + readonly t3Home: Option.Option; +}): string { + return Option.getOrElse(normalizeConfiguredBaseDir(input.t3Home), () => + input.joinPath(input.homeDirectory, ".t3"), + ); +} + +export function resolveDesktopStateDir(input: { + readonly baseDir: string; + readonly isDevelopment: boolean; + readonly joinPath: JoinPath; + readonly t3Home: Option.Option; +}): string { + const useDevSubdir = + input.isDevelopment && Option.isNone(normalizeConfiguredBaseDir(input.t3Home)); + return input.joinPath(input.baseDir, useDevSubdir ? "dev" : "userdata"); +} diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index 523e8764697b..72487daa7e75 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -13,6 +13,7 @@ import * as DesktopObservability from "../app/DesktopObservability.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts"; import * as ElectronDialog from "../electron/ElectronDialog.ts"; +import * as MacApplicationIcon from "../electron/MacApplicationIcon.ts"; import * as DesktopWindow from "../window/DesktopWindow.ts"; import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; import * as DesktopBackendPool from "./DesktopBackendPool.ts"; @@ -79,7 +80,13 @@ function makePoolLayer( resolveWsl: () => Effect.die("unexpected WSL config resolve"), } satisfies DesktopBackendConfiguration.DesktopBackendConfiguration["Service"]), DesktopAppSettings.layerTest(), - ElectronDialog.layer, + ElectronDialog.layer.pipe( + Layer.provide( + Layer.succeed(MacApplicationIcon.MacApplicationIcon, { + resolveDataUrl: () => Effect.die("unexpected application icon resolution"), + } satisfies MacApplicationIcon.MacApplicationIcon["Service"]), + ), + ), Layer.succeed(DesktopWindow.DesktopWindow, { createMain: Effect.die("unexpected window create"), ensureMain: Effect.die("unexpected window ensure"), @@ -91,6 +98,7 @@ function makePoolLayer( handleBackendNotReady: Effect.void, flushMainWindowBounds: Effect.void, dispatchMenuAction: () => Effect.die("unexpected menu action"), + zoomMain: () => Effect.die("unexpected zoom"), syncAppearance: Effect.void, } satisfies DesktopWindow.DesktopWindow["Service"]), ), diff --git a/apps/desktop/src/electron/ElectronApp.test.ts b/apps/desktop/src/electron/ElectronApp.test.ts index ac14f56ad1a5..e0d229497aee 100644 --- a/apps/desktop/src/electron/ElectronApp.test.ts +++ b/apps/desktop/src/electron/ElectronApp.test.ts @@ -14,6 +14,7 @@ const { quitMock, relaunchMock, removeListenerMock, + removeSwitchMock, setAboutPanelOptionsMock, setAppUserModelIdMock, setAsDefaultProtocolClientMock, @@ -34,6 +35,7 @@ const { quitMock: vi.fn(), relaunchMock: vi.fn(), removeListenerMock: vi.fn(), + removeSwitchMock: vi.fn(), setAboutPanelOptionsMock: vi.fn(), setAppUserModelIdMock: vi.fn(), setAsDefaultProtocolClientMock: vi.fn(() => true), @@ -52,6 +54,7 @@ vi.mock("electron", () => ({ app: { commandLine: { appendSwitch: appendSwitchMock, + removeSwitch: removeSwitchMock, }, dock: { setIcon: setDockIconMock, @@ -89,6 +92,7 @@ describe("ElectronApp", () => { quitMock.mockClear(); relaunchMock.mockClear(); removeListenerMock.mockClear(); + removeSwitchMock.mockClear(); setPathMock.mockClear(); }); @@ -178,4 +182,13 @@ describe("ElectronApp", () => { ]); }).pipe(Effect.provide(ElectronApp.layer)), ); + + it.effect("removes command-line switches through the service", () => + Effect.gen(function* () { + const electronApp = yield* ElectronApp.ElectronApp; + yield* electronApp.removeCommandLineSwitch("password-store"); + + assert.deepEqual(removeSwitchMock.mock.calls, [["password-store"]]); + }).pipe(Effect.provide(ElectronApp.layer)), + ); }); diff --git a/apps/desktop/src/electron/ElectronApp.ts b/apps/desktop/src/electron/ElectronApp.ts index 73323617195d..6fb84c53b367 100644 --- a/apps/desktop/src/electron/ElectronApp.ts +++ b/apps/desktop/src/electron/ElectronApp.ts @@ -69,6 +69,7 @@ export class ElectronApp extends Context.Service< readonly onBeforeQuitForUpdate: ( listener: () => void, ) => Effect.Effect; + readonly removeCommandLineSwitch: (switchName: string) => Effect.Effect; readonly on: >( eventName: string, listener: (...args: Args) => void, @@ -191,6 +192,10 @@ export const make = ElectronApp.of({ Electron.autoUpdater.removeListener("before-quit-for-update", listener); }), ).pipe(Effect.asVoid), + removeCommandLineSwitch: (switchName) => + Effect.sync(() => { + Electron.app.commandLine.removeSwitch(switchName); + }), on: addScopedAppListener, }); diff --git a/apps/desktop/src/electron/ElectronDialog.test.ts b/apps/desktop/src/electron/ElectronDialog.test.ts index 388b3fd2c150..97943781d07f 100644 --- a/apps/desktop/src/electron/ElectronDialog.test.ts +++ b/apps/desktop/src/electron/ElectronDialog.test.ts @@ -1,17 +1,22 @@ import { assert, describe, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import type { BrowserWindow } from "electron"; import { beforeEach, vi } from "vite-plus/test"; import * as ElectronDialog from "./ElectronDialog.ts"; +import * as MacApplicationIcon from "./MacApplicationIcon.ts"; -const { showMessageBoxMock, showOpenDialogMock, showErrorBoxMock } = vi.hoisted(() => ({ - showMessageBoxMock: vi.fn(), - showOpenDialogMock: vi.fn(), - showErrorBoxMock: vi.fn(), -})); +const { showMessageBoxMock, showOpenDialogMock, showErrorBoxMock, resolveDataUrlMock } = vi.hoisted( + () => ({ + showMessageBoxMock: vi.fn(), + showOpenDialogMock: vi.fn(), + showErrorBoxMock: vi.fn(), + resolveDataUrlMock: vi.fn(), + }), +); vi.mock("electron", () => ({ dialog: { @@ -21,75 +26,75 @@ vi.mock("electron", () => ({ }, })); +const applicationIconLayer = Layer.succeed(MacApplicationIcon.MacApplicationIcon, { + resolveDataUrl: (applicationPath) => + Effect.tryPromise({ + try: () => resolveDataUrlMock(applicationPath), + catch: (cause) => + new MacApplicationIcon.MacApplicationIconResolutionError({ applicationPath, cause }), + }), +} satisfies MacApplicationIcon.MacApplicationIcon["Service"]); +const dialogLayer = ElectronDialog.layer.pipe(Layer.provide(applicationIconLayer)); + describe("ElectronDialog", () => { beforeEach(() => { showMessageBoxMock.mockReset(); showOpenDialogMock.mockReset(); showErrorBoxMock.mockReset(); + resolveDataUrlMock.mockReset(); }); - it.effect("returns false without opening a confirm dialog for empty messages", () => + it.effect("selects a macOS application and resolves its system icon", () => Effect.gen(function* () { - const dialog = yield* ElectronDialog.ElectronDialog; - - const result = yield* dialog.confirm({ - message: " ", - owner: Option.none(), + const owner = { id: 12 } as BrowserWindow; + showOpenDialogMock.mockResolvedValue({ + canceled: false, + filePaths: ["/Applications/Terminal.app"], }); - - assert.isFalse(result); - assert.equal(showMessageBoxMock.mock.calls.length, 0); - }).pipe(Effect.provide(ElectronDialog.layer)), - ); - - it.effect("opens a confirm dialog for the owner window", () => - Effect.gen(function* () { - const owner = { id: 1 } as BrowserWindow; - showMessageBoxMock.mockResolvedValue({ response: 1 }); + resolveDataUrlMock.mockResolvedValue("data:image/png;base64,icon"); const dialog = yield* ElectronDialog.ElectronDialog; + const result = yield* dialog.pickApplication({ owner: Option.some(owner) }); - const result = yield* dialog.confirm({ - message: "Delete worktree?", - owner: Option.some(owner), + assert.deepEqual(Option.getOrNull(result), { + applicationPath: "/Applications/Terminal.app", + suggestedName: "Terminal", + iconDataUrl: "data:image/png;base64,icon", }); - - assert.isTrue(result); - assert.deepEqual(showMessageBoxMock.mock.calls[0], [ + assert.deepEqual(showOpenDialogMock.mock.calls[0], [ owner, { - type: "question", - buttons: ["No", "Yes"], - defaultId: 0, - cancelId: 0, - noLink: true, - message: "Delete worktree?", + defaultPath: "/Applications", + properties: ["openFile"], + filters: [{ name: "Applications", extensions: ["app"] }], }, ]); - }).pipe(Effect.provide(ElectronDialog.layer)), + }).pipe(Effect.provide(dialogLayer)), ); - it.effect("opens an app-level confirm dialog when there is no owner window", () => + it.effect("returns none when application selection is cancelled", () => Effect.gen(function* () { - showMessageBoxMock.mockResolvedValue({ response: 0 }); + showOpenDialogMock.mockResolvedValue({ canceled: true, filePaths: [] }); const dialog = yield* ElectronDialog.ElectronDialog; + assert.isTrue(Option.isNone(yield* dialog.pickApplication({ owner: Option.none() }))); + assert.equal(resolveDataUrlMock.mock.calls.length, 0); + }).pipe(Effect.provide(dialogLayer)), + ); - const result = yield* dialog.confirm({ - message: "Delete worktree?", - owner: Option.none(), + it.effect("keeps a valid selection when icon extraction fails", () => + Effect.gen(function* () { + showOpenDialogMock.mockResolvedValue({ + canceled: false, + filePaths: ["/Users/test/Applications/My Tool.app"], }); + resolveDataUrlMock.mockRejectedValue(new Error("icon unavailable")); + const dialog = yield* ElectronDialog.ElectronDialog; - assert.isFalse(result); - assert.deepEqual(showMessageBoxMock.mock.calls[0], [ - { - type: "question", - buttons: ["No", "Yes"], - defaultId: 0, - cancelId: 0, - noLink: true, - message: "Delete worktree?", - }, - ]); - }).pipe(Effect.provide(ElectronDialog.layer)), + assert.deepEqual(Option.getOrNull(yield* dialog.pickApplication({ owner: Option.none() })), { + applicationPath: "/Users/test/Applications/My Tool.app", + suggestedName: "My Tool", + iconDataUrl: null, + }); + }).pipe(Effect.provide(dialogLayer)), ); it.effect("preserves folder picker request context and cause", () => @@ -114,32 +119,7 @@ describe("ElectronDialog", () => { assert.include(error.message, "window 7"); assert.include(error.message, "/workspace"); assert.notInclude(error.message, cause.message); - }).pipe(Effect.provide(ElectronDialog.layer)), - ); - - it.effect("preserves confirmation request context and cause", () => - Effect.gen(function* () { - const cause = new Error("confirmation failed"); - const owner = { id: 9 } as BrowserWindow; - showMessageBoxMock.mockRejectedValue(cause); - const dialog = yield* ElectronDialog.ElectronDialog; - - const error = yield* Effect.flip( - dialog.confirm({ - owner: Option.some(owner), - message: " Confirm removal? ", - }), - ); - - assert.instanceOf(error, ElectronDialog.ElectronDialogConfirmError); - assert.strictEqual(error.ownerWindowId, 9); - assert.strictEqual(error.promptLength, "Confirm removal?".length); - assert.notProperty(error, "promptMessage"); - assert.strictEqual(error.cause, cause); - assert.include(error.message, "window 9"); - assert.notInclude(error.message, "Confirm removal?"); - assert.notInclude(error.message, cause.message); - }).pipe(Effect.provide(ElectronDialog.layer)), + }).pipe(Effect.provide(dialogLayer)), ); it.effect("preserves message box request context and cause", () => @@ -176,7 +156,7 @@ describe("ElectronDialog", () => { assert.notInclude(error.message, "Cancel"); assert.notInclude(error.message, "Discard"); assert.notInclude(error.message, cause.message); - }).pipe(Effect.provide(ElectronDialog.layer)), + }).pipe(Effect.provide(dialogLayer)), ); it.effect("preserves error box request context and cause in the defect", () => @@ -201,6 +181,6 @@ describe("ElectronDialog", () => { assert.notInclude(error.message, "Startup failed"); assert.notInclude(error.message, "Could not start."); assert.notInclude(error.message, cause.message); - }).pipe(Effect.provide(ElectronDialog.layer)), + }).pipe(Effect.provide(dialogLayer)), ); }); diff --git a/apps/desktop/src/electron/ElectronDialog.ts b/apps/desktop/src/electron/ElectronDialog.ts index be633971bea8..f99fa7f0bfb8 100644 --- a/apps/desktop/src/electron/ElectronDialog.ts +++ b/apps/desktop/src/electron/ElectronDialog.ts @@ -1,12 +1,15 @@ +// @effect-diagnostics nodeBuiltinImport:off - Electron's picker returns host paths. import * as Context from "effect/Context"; 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 * as NodePath from "node:path"; import * as Electron from "electron"; +import type { DesktopApplicationSelection } from "@t3tools/contracts"; -const CONFIRM_BUTTON_INDEX = 1; +import * as MacApplicationIcon from "./MacApplicationIcon.ts"; export class ElectronDialogPickFolderError extends Schema.TaggedErrorClass()( "ElectronDialogPickFolderError", @@ -23,17 +26,31 @@ export class ElectronDialogPickFolderError extends Schema.TaggedErrorClass()( - "ElectronDialogConfirmError", +export class ElectronDialogPickFilesError extends Schema.TaggedErrorClass()( + "ElectronDialogPickFilesError", { ownerWindowId: Schema.NullOr(Schema.Number), - promptLength: Schema.Number, + defaultPath: Schema.NullOr(Schema.String), cause: Schema.Defect(), }, ) { override get message(): string { const owner = this.ownerWindowId === null ? "the application" : `window ${this.ownerWindowId}`; - return `Failed to open an Electron confirmation dialog for ${owner} with a ${this.promptLength}-character prompt.`; + const defaultPath = this.defaultPath === null ? "no default path" : this.defaultPath; + return `Failed to open the Electron file picker for ${owner} with ${defaultPath}.`; + } +} + +export class ElectronDialogPickApplicationError extends Schema.TaggedErrorClass()( + "ElectronDialogPickApplicationError", + { + ownerWindowId: Schema.NullOr(Schema.Number), + selectedPath: Schema.NullOr(Schema.String), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to select a macOS application."; } } @@ -69,7 +86,8 @@ export class ElectronDialogShowErrorBoxError extends Schema.TaggedErrorClass; } -export interface ElectronDialogConfirmInput { +export interface ElectronDialogPickFilesInput { + readonly owner: Option.Option; + readonly defaultPath: Option.Option; + readonly filters: readonly Electron.FileFilter[]; +} + +export interface ElectronDialogPickApplicationInput { readonly owner: Option.Option; - readonly message: string; } export class ElectronDialog extends Context.Service< @@ -92,9 +115,15 @@ export class ElectronDialog extends Context.Service< readonly pickFolder: ( input: ElectronDialogPickFolderInput, ) => Effect.Effect, ElectronDialogPickFolderError>; - readonly confirm: ( - input: ElectronDialogConfirmInput, - ) => Effect.Effect; + readonly pickFiles: ( + input: ElectronDialogPickFilesInput, + ) => Effect.Effect; + readonly pickApplication: ( + input: ElectronDialogPickApplicationInput, + ) => Effect.Effect< + Option.Option, + ElectronDialogPickApplicationError + >; readonly showMessageBox: ( options: Electron.MessageBoxOptions, ) => Effect.Effect; @@ -102,97 +131,141 @@ export class ElectronDialog extends Context.Service< } >()("@t3tools/desktop/electron/ElectronDialog") {} -export const make = ElectronDialog.of({ - pickFolder: Effect.fn("desktop.electron.dialog.pickFolder")(function* (input) { - const ownerWindowId = Option.match(input.owner, { - onNone: () => null, - onSome: (owner) => owner.id, - }); - const defaultPath = Option.getOrNull(input.defaultPath); - const openDialogOptions: Electron.OpenDialogOptions = Option.match(input.defaultPath, { - onNone: () => ({ - properties: ["openDirectory", "createDirectory"], - }), - onSome: (defaultPath) => ({ - properties: ["openDirectory", "createDirectory"], - defaultPath, - }), - }); - const result = yield* Effect.tryPromise({ - try: () => - Option.match(input.owner, { - onNone: () => Electron.dialog.showOpenDialog(openDialogOptions), - onSome: (owner) => Electron.dialog.showOpenDialog(owner, openDialogOptions), +export const make = Effect.gen(function* () { + const applicationIcon = yield* MacApplicationIcon.MacApplicationIcon; + + return ElectronDialog.of({ + pickFolder: Effect.fn("desktop.electron.dialog.pickFolder")(function* (input) { + const ownerWindowId = Option.match(input.owner, { + onNone: () => null, + onSome: (owner) => owner.id, + }); + const defaultPath = Option.getOrNull(input.defaultPath); + const openDialogOptions: Electron.OpenDialogOptions = Option.match(input.defaultPath, { + onNone: () => ({ + properties: ["openDirectory", "createDirectory"], }), - catch: (cause) => - new ElectronDialogPickFolderError({ - ownerWindowId, + onSome: (defaultPath) => ({ + properties: ["openDirectory", "createDirectory"], defaultPath, - cause, }), - }); - - if (result.canceled) { - return Option.none(); - } - return Option.fromNullishOr(result.filePaths[0]); - }), - confirm: Effect.fn("desktop.electron.dialog.confirm")(function* (input) { - const normalizedMessage = input.message.trim(); - if (normalizedMessage.length === 0) { - return false; - } - - const options = { - type: "question" as const, - buttons: ["No", "Yes"], - defaultId: 0, - cancelId: 0, - noLink: true, - message: normalizedMessage, - }; - const ownerWindowId = Option.match(input.owner, { - onNone: () => null, - onSome: (owner) => owner.id, - }); - const result = yield* Effect.tryPromise({ - try: () => - Option.match(input.owner, { - onNone: () => Electron.dialog.showMessageBox(options), - onSome: (owner) => Electron.dialog.showMessageBox(owner, options), - }), - catch: (cause) => - new ElectronDialogConfirmError({ + }); + const result = yield* Effect.tryPromise({ + try: () => + Option.match(input.owner, { + onNone: () => Electron.dialog.showOpenDialog(openDialogOptions), + onSome: (owner) => Electron.dialog.showOpenDialog(owner, openDialogOptions), + }), + catch: (cause) => + new ElectronDialogPickFolderError({ + ownerWindowId, + defaultPath, + cause, + }), + }); + + if (result.canceled) { + return Option.none(); + } + return Option.fromNullishOr(result.filePaths[0]); + }), + pickFiles: Effect.fn("desktop.electron.dialog.pickFiles")(function* (input) { + const ownerWindowId = Option.match(input.owner, { + onNone: () => null, + onSome: (owner) => owner.id, + }); + const defaultPath = Option.getOrNull(input.defaultPath); + const openDialogOptions: Electron.OpenDialogOptions = { + properties: ["openFile", "multiSelections"], + filters: [...input.filters], + ...(defaultPath === null ? {} : { defaultPath }), + }; + const result = yield* Effect.tryPromise({ + try: () => + Option.match(input.owner, { + onNone: () => Electron.dialog.showOpenDialog(openDialogOptions), + onSome: (owner) => Electron.dialog.showOpenDialog(owner, openDialogOptions), + }), + catch: (cause) => + new ElectronDialogPickFilesError({ + ownerWindowId, + defaultPath, + cause, + }), + }); + return result.canceled ? [] : result.filePaths; + }), + pickApplication: Effect.fn("desktop.electron.dialog.pickApplication")(function* (input) { + const ownerWindowId = Option.match(input.owner, { + onNone: () => null, + onSome: (owner) => owner.id, + }); + const options: Electron.OpenDialogOptions = { + defaultPath: "/Applications", + properties: ["openFile"], + filters: [{ name: "Applications", extensions: ["app"] }], + }; + const result = yield* Effect.tryPromise({ + try: () => + Option.match(input.owner, { + onNone: () => Electron.dialog.showOpenDialog(options), + onSome: (owner) => Electron.dialog.showOpenDialog(owner, options), + }), + catch: (cause) => + new ElectronDialogPickApplicationError({ + ownerWindowId, + selectedPath: null, + cause, + }), + }); + if (result.canceled) return Option.none(); + + const applicationPath = result.filePaths[0]; + if ( + applicationPath === undefined || + !NodePath.isAbsolute(applicationPath) || + !applicationPath.toLowerCase().endsWith(".app") + ) { + return yield* new ElectronDialogPickApplicationError({ ownerWindowId, - promptLength: normalizedMessage.length, - cause, - }), - }); - return result.response === CONFIRM_BUTTON_INDEX; - }), - showMessageBox: (options) => - Effect.tryPromise({ - try: () => Electron.dialog.showMessageBox(options), - catch: (cause) => - new ElectronDialogShowMessageBoxError({ - type: options.type ?? null, - titleLength: options.title?.length ?? null, - messageLength: options.message.length, - detailLength: options.detail?.length ?? null, - buttonCount: options.buttons?.length ?? 0, - cause, - }), + selectedPath: applicationPath ?? null, + cause: new Error("The selected path is not an absolute .app bundle."), + }); + } + + const iconDataUrl = yield* applicationIcon + .resolveDataUrl(applicationPath) + .pipe(Effect.orElseSucceed(() => null)); + return Option.some({ + applicationPath, + suggestedName: NodePath.basename(applicationPath, NodePath.extname(applicationPath)), + iconDataUrl, + }); }), - showErrorBox: (title, content) => - Effect.try({ - try: () => Electron.dialog.showErrorBox(title, content), - catch: (cause) => - new ElectronDialogShowErrorBoxError({ - titleLength: title.length, - contentLength: content.length, - cause, - }), - }).pipe(Effect.orDie), + showMessageBox: (options) => + Effect.tryPromise({ + try: () => Electron.dialog.showMessageBox(options), + catch: (cause) => + new ElectronDialogShowMessageBoxError({ + type: options.type ?? null, + titleLength: options.title?.length ?? null, + messageLength: options.message.length, + detailLength: options.detail?.length ?? null, + buttonCount: options.buttons?.length ?? 0, + cause, + }), + }), + showErrorBox: (title, content) => + Effect.try({ + try: () => Electron.dialog.showErrorBox(title, content), + catch: (cause) => + new ElectronDialogShowErrorBoxError({ + titleLength: title.length, + contentLength: content.length, + cause, + }), + }).pipe(Effect.orDie), + }); }); -export const layer = Layer.succeed(ElectronDialog, make); +export const layer = Layer.effect(ElectronDialog, make); diff --git a/apps/desktop/src/electron/ElectronProtocol.test.ts b/apps/desktop/src/electron/ElectronProtocol.test.ts index 92e300004277..2db85dafc4da 100644 --- a/apps/desktop/src/electron/ElectronProtocol.test.ts +++ b/apps/desktop/src/electron/ElectronProtocol.test.ts @@ -57,7 +57,7 @@ describe("ElectronProtocol", () => { assert.equal(yield* Effect.promise(() => response.text()), "ok"); assert.include( response.headers.get("content-security-policy") ?? "", - "script-src 'self' 'unsafe-inline' https://clerk.t3.codes https://challenges.cloudflare.com", + "script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://clerk.t3.codes https://challenges.cloudflare.com", ); assert.include( response.headers.get("content-security-policy") ?? "", @@ -212,6 +212,7 @@ describe("ElectronProtocol", () => { assert.deepEqual(directives["script-src"], [ "'self'", "'unsafe-inline'", + "'wasm-unsafe-eval'", "https://clerk.t3.codes", "https://challenges.cloudflare.com", ]); diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index 03c7ef64fd73..11459c9ef7a8 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -71,6 +71,7 @@ export function makeDesktopContentSecurityPolicy(input: DesktopProtocolRegistrat const scriptSources = [ "'self'", "'unsafe-inline'", + "'wasm-unsafe-eval'", ...(clerkOrigin ? [clerkOrigin] : []), "https://challenges.cloudflare.com", ]; @@ -104,6 +105,38 @@ function withContentSecurityPolicy(response: Response, policy: string): Response }); } +/** + * Must run synchronously during process bootstrap, before Electron emits `ready`. + */ +export function registerDesktopSchemePrivilegesSync(): void { + Electron.protocol.registerSchemesAsPrivileged([ + { + scheme: DESKTOP_PRODUCTION_SCHEME, + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + corsEnabled: true, + }, + }, + { + scheme: DESKTOP_DEVELOPMENT_SCHEME, + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + corsEnabled: true, + }, + }, + ]); +} + +const registerDesktopSchemePrivileges = Effect.sync(registerDesktopSchemePrivilegesSync).pipe( + Effect.withSpan("desktop.electron.protocol.registerSchemePrivileges"), +); + +export const layerSchemePrivileges = Layer.effectDiscard(registerDesktopSchemePrivileges); + async function proxyRequest( request: Request, targetOrigin: URL, diff --git a/apps/desktop/src/electron/ElectronSafeStorage.ts b/apps/desktop/src/electron/ElectronSafeStorage.ts index 76162c1647a0..b9dab7105fb8 100644 --- a/apps/desktop/src/electron/ElectronSafeStorage.ts +++ b/apps/desktop/src/electron/ElectronSafeStorage.ts @@ -1,9 +1,11 @@ import * as Context from "effect/Context"; 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 * as Electron from "electron"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; const electronSafeStorageErrorFields = { cause: Schema.Defect(), @@ -60,24 +62,39 @@ export class ElectronSafeStorage extends Context.Service< readonly decryptString: ( value: Uint8Array, ) => Effect.Effect; + readonly selectedStorageBackend: Effect.Effect>; } >()("@t3tools/desktop/electron/ElectronSafeStorage") {} -export const make = ElectronSafeStorage.of({ - isEncryptionAvailable: Effect.try({ - try: () => Electron.safeStorage.isEncryptionAvailable(), - catch: (cause) => new ElectronSafeStorageAvailabilityError({ cause }), - }), - encryptString: (value) => - Effect.try({ - try: () => Electron.safeStorage.encryptString(value), - catch: (cause) => new ElectronSafeStorageEncryptError({ cause }), +export const make = Effect.gen(function* () { + const platform = yield* HostProcessPlatform; + + return ElectronSafeStorage.of({ + isEncryptionAvailable: Effect.try({ + try: () => Electron.safeStorage.isEncryptionAvailable(), + catch: (cause) => new ElectronSafeStorageAvailabilityError({ cause }), }), - decryptString: (value) => - Effect.try({ - try: () => Electron.safeStorage.decryptString(Buffer.from(value)), - catch: (cause) => new ElectronSafeStorageDecryptError({ cause }), + encryptString: (value) => + Effect.try({ + try: () => Electron.safeStorage.encryptString(value), + catch: (cause) => new ElectronSafeStorageEncryptError({ cause }), + }), + decryptString: (value) => + Effect.try({ + try: () => Electron.safeStorage.decryptString(Buffer.from(value)), + catch: (cause) => new ElectronSafeStorageDecryptError({ cause }), + }), + selectedStorageBackend: Effect.sync(() => { + if (platform !== "linux") { + return Option.none(); + } + try { + return Option.fromNullishOr(Electron.safeStorage.getSelectedStorageBackend()); + } catch { + return Option.none(); + } }), + }); }); -export const layer = Layer.succeed(ElectronSafeStorage, make); +export const layer = Layer.effect(ElectronSafeStorage, make); diff --git a/apps/desktop/src/electron/MacApplicationIcon.test.ts b/apps/desktop/src/electron/MacApplicationIcon.test.ts new file mode 100644 index 000000000000..85f168b0e62d --- /dev/null +++ b/apps/desktop/src/electron/MacApplicationIcon.test.ts @@ -0,0 +1,60 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { beforeEach, vi } from "vite-plus/test"; + +import * as MacApplicationIcon from "./MacApplicationIcon.ts"; + +const { createFromBufferMock, getFileIconMock } = vi.hoisted(() => ({ + createFromBufferMock: vi.fn(), + getFileIconMock: vi.fn(), +})); + +vi.mock("electron", () => ({ + app: { getFileIcon: getFileIconMock }, + nativeImage: { createFromBuffer: createFromBufferMock }, +})); + +const applicationIconLayer = MacApplicationIcon.layer.pipe(Layer.provide(NodeServices.layer)); + +const provideLayer = (effect: Effect.Effect) => + effect.pipe(Effect.provide(applicationIconLayer)); + +describe("MacApplicationIcon", () => { + beforeEach(() => { + createFromBufferMock.mockReset(); + getFileIconMock.mockReset(); + }); + + it.effect("falls back to Electron's system icon lookup", () => + provideLayer( + Effect.gen(function* () { + getFileIconMock.mockResolvedValue({ toDataURL: () => "data:image/png;base64,icon" }); + const applicationIcon = yield* MacApplicationIcon.MacApplicationIcon; + + assert.equal( + yield* applicationIcon.resolveDataUrl("/missing/Fixture.app"), + "data:image/png;base64,icon", + ); + assert.deepEqual(getFileIconMock.mock.calls[0], [ + "/missing/Fixture.app", + { size: "large" }, + ]); + }), + ), + ); + + it.effect("reports system icon lookup failures with the application path", () => + provideLayer( + Effect.gen(function* () { + getFileIconMock.mockRejectedValue(new Error("icon unavailable")); + const applicationIcon = yield* MacApplicationIcon.MacApplicationIcon; + + const error = yield* Effect.flip(applicationIcon.resolveDataUrl("/missing/Fixture.app")); + assert.equal(error._tag, "MacApplicationIconResolutionError"); + assert.equal(error.applicationPath, "/missing/Fixture.app"); + }), + ), + ); +}); diff --git a/apps/desktop/src/electron/MacApplicationIcon.ts b/apps/desktop/src/electron/MacApplicationIcon.ts new file mode 100644 index 000000000000..48f096a2e719 --- /dev/null +++ b/apps/desktop/src/electron/MacApplicationIcon.ts @@ -0,0 +1,125 @@ +import * as Electron from "electron"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; + +export class MacApplicationIconResolutionError extends Schema.TaggedErrorClass()( + "MacApplicationIconResolutionError", + { + applicationPath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to resolve the macOS application icon for ${this.applicationPath}.`; + } +} + +export class MacApplicationIcon extends Context.Service< + MacApplicationIcon, + { + readonly resolveDataUrl: ( + applicationPath: string, + ) => Effect.Effect; + } +>()("@t3tools/desktop/electron/MacApplicationIcon") {} + +export const make = Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const pathService = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + + const resolveBundleIconPath = Effect.fn( + "desktop.electron.macApplicationIcon.resolveBundleIconPath", + )(function* (applicationPath: string) { + const infoPlistPath = pathService.join(applicationPath, "Contents", "Info.plist"); + const configuredName = yield* spawner + .string( + ChildProcess.make( + "/usr/bin/plutil", + ["-extract", "CFBundleIconFile", "raw", infoPlistPath], + { stdin: "ignore", stdout: "pipe", stderr: "pipe" }, + ), + ) + .pipe(Effect.map((output) => output.trim())); + if ( + configuredName.length === 0 || + pathService.basename(configuredName) !== configuredName || + configuredName === "." || + configuredName === ".." + ) { + return Option.none(); + } + const iconName = pathService.extname(configuredName) + ? configuredName + : `${configuredName}.icns`; + const iconPath = pathService.join(applicationPath, "Contents", "Resources", iconName); + const stat = yield* fs.stat(iconPath); + return stat.type === "File" ? Option.some(iconPath) : Option.none(); + }); + + const convertIcnsToDataUrl = Effect.fn( + "desktop.electron.macApplicationIcon.convertIcnsToDataUrl", + )(function* (applicationPath: string, iconPath: string) { + return yield* Effect.gen(function* () { + const temporaryDirectory = yield* fs.makeTempDirectoryScoped({ + prefix: "t3code-open-with-icon-", + }); + const pngPath = pathService.join(temporaryDirectory, "icon.png"); + yield* spawner.string( + ChildProcess.make("/usr/bin/sips", ["-s", "format", "png", iconPath, "--out", pngPath], { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }), + ); + const png = yield* fs.readFile(pngPath); + const image = yield* Effect.try({ + try: () => Electron.nativeImage.createFromBuffer(Buffer.from(png)), + catch: (cause) => new MacApplicationIconResolutionError({ applicationPath, cause }), + }); + if (image.isEmpty()) { + return yield* new MacApplicationIconResolutionError({ + applicationPath, + cause: new Error("The converted application icon is empty."), + }); + } + return image.toDataURL(); + }).pipe(Effect.scoped); + }); + + const resolveDataUrl = Effect.fn("desktop.electron.macApplicationIcon.resolveDataUrl")(function* ( + applicationPath: string, + ) { + // Electron can return a generic bundle icon, so prefer the app's declared ICNS asset. + const iconPath = yield* resolveBundleIconPath(applicationPath).pipe( + Effect.orElseSucceed(() => Option.none()), + ); + if (Option.isSome(iconPath)) { + const bundleIcon = yield* convertIcnsToDataUrl(applicationPath, iconPath.value).pipe( + Effect.map(Option.some), + Effect.orElseSucceed(() => Option.none()), + ); + if (Option.isSome(bundleIcon)) return bundleIcon.value; + } + + const image = yield* Effect.tryPromise({ + try: () => Electron.app.getFileIcon(applicationPath, { size: "large" }), + catch: (cause) => new MacApplicationIconResolutionError({ applicationPath, cause }), + }); + return yield* Effect.try({ + try: () => image.toDataURL(), + catch: (cause) => new MacApplicationIconResolutionError({ applicationPath, cause }), + }); + }); + + return MacApplicationIcon.of({ resolveDataUrl }); +}); + +export const layer = Layer.effect(MacApplicationIcon, make); diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index e478d0c6effd..5d5bbe57f93d 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -31,17 +31,22 @@ import { setUpdateChannel, } from "./methods/updates.ts"; import { - confirm, getAppBranding, getLocalEnvironmentBootstraps, getLocalEnvironmentBearerToken, getWindowFullscreenState, openExternal, pickFolder, + pickThemeFiles, setTheme, showContextMenu, } from "./methods/window.ts"; import * as PreviewIpc from "./methods/preview.ts"; +import { + openWith, + pickOpenWithApplication, + resolveOpenWithPresentations, +} from "./methods/openWith.ts"; import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./methods/wsl.ts"; export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers")(function* () { @@ -79,10 +84,13 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(setWslOnly); yield* ipc.handle(pickFolder); - yield* ipc.handle(confirm); + yield* ipc.handle(pickThemeFiles); yield* ipc.handle(setTheme); yield* ipc.handle(showContextMenu); yield* ipc.handle(openExternal); + yield* ipc.handle(pickOpenWithApplication); + yield* ipc.handle(resolveOpenWithPresentations); + yield* ipc.handle(openWith); yield* ipc.handle(getUpdateState); yield* ipc.handle(setUpdateChannel); yield* ipc.handle(downloadUpdate); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 5988b1e42f96..e37960c5a14e 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -1,8 +1,11 @@ export const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; -export const CONFIRM_CHANNEL = "desktop:confirm"; +export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files"; export const SET_THEME_CHANNEL = "desktop:set-theme"; export const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; +export const PICK_OPEN_WITH_APPLICATION_CHANNEL = "desktop:pick-open-with-application"; +export const RESOLVE_OPEN_WITH_PRESENTATIONS_CHANNEL = "desktop:resolve-open-with-presentations"; +export const OPEN_WITH_CHANNEL = "desktop:open-with"; export const MENU_ACTION_CHANNEL = "desktop:menu-action"; export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state"; export const WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:window-fullscreen-state"; diff --git a/apps/desktop/src/ipc/methods/openWith.test.ts b/apps/desktop/src/ipc/methods/openWith.test.ts new file mode 100644 index 000000000000..9e606199ebd1 --- /dev/null +++ b/apps/desktop/src/ipc/methods/openWith.test.ts @@ -0,0 +1,43 @@ +import { assert, describe, it } from "@effect/vitest"; +import { OpenWithEntryId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; + +import * as DesktopOpenWith from "../../shell/DesktopOpenWith.ts"; +import { openWith, resolveOpenWithPresentations } from "./openWith.ts"; + +describe("Open With IPC methods", () => { + it.effect("encodes presentations and delegates launch inputs", () => + Effect.gen(function* () { + const opened = yield* Ref.make(null); + const entryId = OpenWithEntryId.make("terminal"); + const layer = Layer.succeed( + DesktopOpenWith.DesktopOpenWith, + DesktopOpenWith.DesktopOpenWith.of({ + resolvePresentations: Effect.succeed([ + { entryId, available: true, iconDataUrl: "data:image/png;base64,abc" }, + ]), + open: (input) => Ref.set(opened, input), + }), + ); + + assert.deepEqual( + yield* resolveOpenWithPresentations.handler(undefined).pipe(Effect.provide(layer)), + [{ entryId: "terminal", available: true, iconDataUrl: "data:image/png;base64,abc" }], + ); + yield* openWith + .handler({ + environmentId: "primary", + entryId: "terminal", + directory: "/tmp/project", + }) + .pipe(Effect.provide(layer)); + assert.deepEqual(yield* Ref.get(opened), { + environmentId: "primary", + entryId: "terminal", + directory: "/tmp/project", + }); + }), + ); +}); diff --git a/apps/desktop/src/ipc/methods/openWith.ts b/apps/desktop/src/ipc/methods/openWith.ts new file mode 100644 index 000000000000..eb19e0ec7e3c --- /dev/null +++ b/apps/desktop/src/ipc/methods/openWith.ts @@ -0,0 +1,47 @@ +import { + DesktopApplicationSelection, + DesktopOpenWithInput, + OpenWithEntryPresentation, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import * as ElectronDialog from "../../electron/ElectronDialog.ts"; +import * as ElectronWindow from "../../electron/ElectronWindow.ts"; +import * as DesktopOpenWith from "../../shell/DesktopOpenWith.ts"; +import * as IpcChannels from "../channels.ts"; +import * as DesktopIpc from "../DesktopIpc.ts"; + +export const pickOpenWithApplication = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PICK_OPEN_WITH_APPLICATION_CHANNEL, + payload: Schema.Void, + result: Schema.NullOr(DesktopApplicationSelection), + handler: Effect.fn("desktop.ipc.openWith.pickApplication")(function* () { + const dialog = yield* ElectronDialog.ElectronDialog; + const electronWindow = yield* ElectronWindow.ElectronWindow; + return Option.getOrNull( + yield* dialog.pickApplication({ owner: yield* electronWindow.focusedMainOrFirst }), + ); + }), +}); + +export const resolveOpenWithPresentations = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.RESOLVE_OPEN_WITH_PRESENTATIONS_CHANNEL, + payload: Schema.Void, + result: Schema.Array(OpenWithEntryPresentation), + handler: Effect.fn("desktop.ipc.openWith.resolvePresentations")(function* () { + const openWith = yield* DesktopOpenWith.DesktopOpenWith; + return yield* openWith.resolvePresentations; + }), +}); + +export const openWith = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.OPEN_WITH_CHANNEL, + payload: DesktopOpenWithInput, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.openWith.open")(function* (input) { + const openWith = yield* DesktopOpenWith.DesktopOpenWith; + yield* openWith.open(input); + }), +}); diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index 4d50ad8d665e..febdefa9825b 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -16,7 +16,7 @@ import { DesktopPreviewSetColorSchemeInputSchema, DesktopPreviewTabInputSchema, DesktopPreviewWebviewConfigSchema, - PreviewAnnotationPayloadSchema, + PreviewAnnotationSubmissionResultSchema, PreviewAutomationSnapshot, PreviewAutomationStatus, } from "@t3tools/contracts"; @@ -227,7 +227,7 @@ export const setAnnotationTheme = DesktopIpc.makeIpcMethod({ export const pickElement = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, payload: DesktopPreviewTabInputSchema, - result: Schema.NullOr(PreviewAnnotationPayloadSchema), + result: Schema.NullOr(PreviewAnnotationSubmissionResultSchema), handler: Effect.fn("desktop.ipc.preview.pickElement")(function* ({ tabId }) { const manager = yield* PreviewManager.PreviewManager; return yield* manager.pickElement(tabId); diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index a4e98aaabadd..7a39eb429275 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -3,10 +3,15 @@ import { DesktopAppBrandingSchema, DesktopEnvironmentBootstrapSchema, DesktopThemeSchema, + PickedThemeFileSchema, PickFolderOptionsSchema, PRIMARY_LOCAL_ENVIRONMENT_ID, type DesktopEnvironmentBootstrap, + type PickedThemeFile, } from "@t3tools/contracts"; +import * as NodeOS from "node:os"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; @@ -215,19 +220,6 @@ export const pickFolder = DesktopIpc.makeIpcMethod({ }), }); -export const confirm = DesktopIpc.makeIpcMethod({ - channel: IpcChannels.CONFIRM_CHANNEL, - payload: Schema.String, - result: Schema.Boolean, - handler: Effect.fn("desktop.ipc.window.confirm")(function* (message) { - const dialog = yield* ElectronDialog.ElectronDialog; - const electronWindow = yield* ElectronWindow.ElectronWindow; - return yield* electronWindow.focusedMainOrFirst.pipe( - Effect.flatMap((owner) => dialog.confirm({ owner, message })), - ); - }), -}); - export const setTheme = DesktopIpc.makeIpcMethod({ channel: IpcChannels.SET_THEME_CHANNEL, payload: DesktopThemeSchema, @@ -268,3 +260,49 @@ export const openExternal = DesktopIpc.makeIpcMethod({ return yield* shell.openExternal(url); }), }); + +/** Theme files are a few KB; anything larger returns empty text and lets the + * renderer reject it by size without the contents ever crossing the bridge. */ +const PICKED_THEME_FILE_MAX_BYTES = 256 * 1024; + +export const pickThemeFiles = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PICK_THEME_FILES_CHANNEL, + payload: Schema.Undefined, + result: Schema.NullOr(Schema.Array(PickedThemeFileSchema)), + handler: Effect.fn("desktop.ipc.window.pickThemeFiles")(function* () { + const dialog = yield* ElectronDialog.ElectronDialog; + const electronWindow = yield* ElectronWindow.ElectronWindow; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + // The VS Code extensions directory is the same dotfolder on Windows, + // macOS, and Linux; when it is missing the picker opens wherever the + // platform would by default. + const extensionsDir = path.join(NodeOS.homedir(), ".vscode", "extensions"); + const defaultPath = yield* fileSystem + .exists(extensionsDir) + .pipe(Effect.orElseSucceed(() => false)); + const paths = yield* dialog.pickFiles({ + owner: yield* electronWindow.focusedMainOrFirst, + defaultPath: defaultPath ? Option.some(extensionsDir) : Option.none(), + filters: [{ name: "JSON", extensions: ["json"] }], + }); + if (paths.length === 0) { + return null; + } + return yield* Effect.forEach(paths, (filePath) => { + const name = path.basename(filePath); + return Effect.gen(function* () { + const info = yield* fileSystem.stat(filePath); + const size = Number(info.size); + if (size > PICKED_THEME_FILE_MAX_BYTES) { + return { name, size, text: "" } satisfies PickedThemeFile; + } + const text = yield* fileSystem.readFileString(filePath); + return { name, size, text } satisfies PickedThemeFile; + }).pipe( + // An unreadable file degrades to an entry the renderer reports. + Effect.orElseSucceed((): PickedThemeFile => ({ name, size: 0, text: "" })), + ); + }); + }), +}); diff --git a/apps/desktop/src/linuxSecretStorage.test.ts b/apps/desktop/src/linuxSecretStorage.test.ts new file mode 100644 index 000000000000..a91790200771 --- /dev/null +++ b/apps/desktop/src/linuxSecretStorage.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + normalizeLinuxPasswordStorePreference, + resolveLinuxPasswordStoreSwitch, + resolveLinuxSecretStorageUnavailableMessage, +} from "./linuxSecretStorage.ts"; + +const autoSwitch = (env: NodeJS.ProcessEnv) => + resolveLinuxPasswordStoreSwitch({ preference: "auto", env }); + +describe("linuxSecretStorage", () => { + it("preserves explicit supported password-store preferences", () => { + expect(normalizeLinuxPasswordStorePreference("gnome-libsecret")).toBe("gnome-libsecret"); + expect(normalizeLinuxPasswordStorePreference("kwallet")).toBe("kwallet"); + expect(normalizeLinuxPasswordStorePreference("kwallet5")).toBe("kwallet5"); + expect(normalizeLinuxPasswordStorePreference("kwallet6")).toBe("kwallet6"); + }); + + it("falls back to auto for missing or unsupported preferences", () => { + expect(normalizeLinuxPasswordStorePreference(undefined)).toBe("auto"); + expect(normalizeLinuxPasswordStorePreference("basic")).toBe("auto"); + }); + + it("uses explicit preferences instead of the auto heuristic", () => { + for (const preference of ["gnome-libsecret", "kwallet", "kwallet5", "kwallet6"] as const) { + expect( + resolveLinuxPasswordStoreSwitch({ preference, env: { XDG_CURRENT_DESKTOP: "niri" } }), + ).toBe(preference); + // An explicit preference also wins where auto would have stayed out of the way. + expect( + resolveLinuxPasswordStoreSwitch({ preference, env: { XDG_CURRENT_DESKTOP: "KDE" } }), + ).toBe(preference); + } + }); + + it("leaves canonical KDE sessions to Electron's own wallet selection", () => { + expect(autoSwitch({ XDG_CURRENT_DESKTOP: "KDE" })).toBeNull(); + expect(autoSwitch({ XDG_CURRENT_DESKTOP: "KDE", KDE_SESSION_VERSION: "6" })).toBeNull(); + expect(autoSwitch({ XDG_CURRENT_DESKTOP: "KDE", KDE_SESSION_VERSION: "5" })).toBeNull(); + expect(autoSwitch({ XDG_CURRENT_DESKTOP: "KDE:plasma" })).toBeNull(); + }); + + it("does not force a password-store for desktops Electron already recognizes", () => { + expect(autoSwitch({ XDG_CURRENT_DESKTOP: "GNOME" })).toBeNull(); + for (const desktop of ["Deepin", "Pantheon", "UKUI", "Unity", "X-Cinnamon", "XFCE"]) { + expect(autoSwitch({ XDG_CURRENT_DESKTOP: desktop })).toBeNull(); + } + }); + + it("recognizes a known desktop later in a colon-separated XDG_CURRENT_DESKTOP list", () => { + expect(autoSwitch({ XDG_CURRENT_DESKTOP: "niri:GNOME" })).toBeNull(); + expect(autoSwitch({ XDG_CURRENT_DESKTOP: "ubuntu:GNOME" })).toBeNull(); + }); + + it("forces gnome-libsecret for unrecognized Linux desktop sessions", () => { + expect(autoSwitch({ XDG_CURRENT_DESKTOP: "niri" })).toBe("gnome-libsecret"); + expect(autoSwitch({ XDG_CURRENT_DESKTOP: "Hyprland" })).toBe("gnome-libsecret"); + expect(autoSwitch({})).toBe("gnome-libsecret"); + }); + + it("forces gnome-libsecret for desktops Electron recognizes but leaves on basic text", () => { + expect(autoSwitch({ XDG_CURRENT_DESKTOP: "LXQt" })).toBe("gnome-libsecret"); + // Chromium stops at the first recognized value, so a later name cannot rescue the session. + expect(autoSwitch({ XDG_CURRENT_DESKTOP: "LXQt:GNOME" })).toBe("gnome-libsecret"); + expect(autoSwitch({ XDG_CURRENT_DESKTOP: "LXQt:KDE" })).toBe("gnome-libsecret"); + expect(autoSwitch({ XDG_CURRENT_DESKTOP: "LXQt:plasma" })).toBe("gnome-libsecret"); + }); + + it("does not treat lowercase desktop names as ones Electron recognizes", () => { + // Chromium matches these case-sensitively, so lowercase spellings reach basic text. + expect(autoSwitch({ XDG_CURRENT_DESKTOP: "gnome" })).toBe("gnome-libsecret"); + expect(autoSwitch({ XDG_CURRENT_DESKTOP: "xfce" })).toBe("gnome-libsecret"); + expect(autoSwitch({ XDG_CURRENT_DESKTOP: "kde", KDE_SESSION_VERSION: "6" })).toBe( + "gnome-libsecret", + ); + }); + + it("overrides KDE sessions identified only by legacy variables", () => { + // Chromium reaches KWallet4 for some of these, such as DESKTOP_SESSION=kde with a version, and + // basic text for the rest. Either way these are the variables a previous session leaves behind, + // so they are treated as unproven and forced to a real keyring rather than a guessed wallet. + expect(autoSwitch({ XDG_CURRENT_DESKTOP: "plasma" })).toBe("gnome-libsecret"); + for (const session of [ + "kde", + "kde-plasma", + "kde4", + "plasma", + "plasmawayland", + "plasmawayland-dev", + "plasmax11", + "plasmax11-dev", + ]) { + expect(autoSwitch({ DESKTOP_SESSION: session })).toBe("gnome-libsecret"); + expect(autoSwitch({ XDG_SESSION_DESKTOP: session })).toBe("gnome-libsecret"); + expect(autoSwitch({ GDMSESSION: session })).toBe("gnome-libsecret"); + } + expect(autoSwitch({ DESKTOP_SESSION: "kde", KDE_SESSION_VERSION: "6" })).toBe( + "gnome-libsecret", + ); + expect(autoSwitch({ KDE_SESSION_VERSION: "6" })).toBe("gnome-libsecret"); + expect(autoSwitch({ KDE_FULL_SESSION: "true", KDE_SESSION_VERSION: "6" })).toBe( + "gnome-libsecret", + ); + }); + + it("ignores stale session hints when XDG_CURRENT_DESKTOP is authoritative", () => { + expect( + autoSwitch({ XDG_CURRENT_DESKTOP: "niri", DESKTOP_SESSION: "gnome", GDMSESSION: "gnome" }), + ).toBe("gnome-libsecret"); + // A previous KDE session left these behind; the compositor running now is not KDE. + expect(autoSwitch({ XDG_CURRENT_DESKTOP: "niri", KDE_SESSION_VERSION: "6" })).toBe( + "gnome-libsecret", + ); + expect(autoSwitch({ XDG_CURRENT_DESKTOP: "niri", XDG_SESSION_DESKTOP: "GNOME" })).toBe( + "gnome-libsecret", + ); + expect(autoSwitch({ XDG_CURRENT_DESKTOP: "niri:plasma" })).toBe("gnome-libsecret"); + expect( + autoSwitch({ + XDG_CURRENT_DESKTOP: "Hyprland", + XDG_SESSION_DESKTOP: "KDE", + KDE_SESSION_VERSION: "6", + }), + ).toBe("gnome-libsecret"); + }); + + it("uses GNOME Keyring remediation for libsecret and unknown backends", () => { + expect( + resolveLinuxSecretStorageUnavailableMessage({ + configuredPreference: "auto", + selectedBackend: "gnome_libsecret", + env: { XDG_CURRENT_DESKTOP: "niri" }, + }), + ).toContain("GNOME Keyring"); + }); + + it("prefers explicit libsecret selection over KDE desktop heuristics", () => { + expect( + resolveLinuxSecretStorageUnavailableMessage({ + configuredPreference: "gnome-libsecret", + selectedBackend: "unknown", + env: { XDG_CURRENT_DESKTOP: "KDE" }, + }), + ).toContain("GNOME Keyring"); + expect( + resolveLinuxSecretStorageUnavailableMessage({ + configuredPreference: "auto", + selectedBackend: "gnome_libsecret", + env: { XDG_CURRENT_DESKTOP: "KDE" }, + }), + ).toContain("GNOME Keyring"); + }); + + it("prefers explicit KWallet preference over selected gnome-libsecret backend", () => { + expect( + resolveLinuxSecretStorageUnavailableMessage({ + configuredPreference: "kwallet6", + selectedBackend: "gnome_libsecret", + env: { XDG_CURRENT_DESKTOP: "niri" }, + }), + ).toContain("KWallet"); + expect( + resolveLinuxSecretStorageUnavailableMessage({ + configuredPreference: "kwallet", + selectedBackend: "gnome-libsecret", + env: {}, + }), + ).toContain("KWallet"); + }); + + it("uses KWallet remediation wording for KDE-looking sessions", () => { + expect( + resolveLinuxSecretStorageUnavailableMessage({ + configuredPreference: "auto", + selectedBackend: "kwallet6", + env: {}, + }), + ).toContain("KWallet"); + expect( + resolveLinuxSecretStorageUnavailableMessage({ + configuredPreference: "auto", + selectedBackend: "unknown", + env: { XDG_CURRENT_DESKTOP: "KDE" }, + }), + ).toContain("KWallet"); + expect( + resolveLinuxSecretStorageUnavailableMessage({ + configuredPreference: "auto", + selectedBackend: "unknown", + env: { DESKTOP_SESSION: "plasmawayland" }, + }), + ).toContain("KWallet"); + // A desktop name outranks a bare KDE marker when choosing the wording. + expect( + resolveLinuxSecretStorageUnavailableMessage({ + configuredPreference: "auto", + selectedBackend: "unknown", + env: { GDMSESSION: "gnome", KDE_FULL_SESSION: "true" }, + }), + ).toContain("GNOME Keyring"); + }); +}); diff --git a/apps/desktop/src/linuxSecretStorage.ts b/apps/desktop/src/linuxSecretStorage.ts new file mode 100644 index 000000000000..fe3e21eadb92 --- /dev/null +++ b/apps/desktop/src/linuxSecretStorage.ts @@ -0,0 +1,178 @@ +export type LinuxPasswordStorePreference = + | "auto" + | "gnome-libsecret" + | "kwallet" + | "kwallet5" + | "kwallet6"; +export type LinuxPasswordStoreSwitch = Exclude; + +export const DEFAULT_LINUX_PASSWORD_STORE: LinuxPasswordStorePreference = "auto"; + +// Chromium matches XDG_CURRENT_DESKTOP values case-sensitively and returns on the first value it +// recognizes, so these stay exact literals and are scanned in order. Omitting a real desktop fails +// safe: we force gnome-libsecret, which is the backend Chromium selects for all of these anyway. +const ELECTRON_LIBSECRET_DESKTOPS = new Set([ + "Deepin", + "GNOME", + "Pantheon", + "UKUI", + "Unity", + "X-Cinnamon", + "XFCE", +]); +// Chromium selects a KWallet generation for KDE from KDE_SESSION_VERSION, so it needs no help. +const ELECTRON_KDE_DESKTOP = "KDE"; +// Chromium recognizes LXQt and still selects basic text for it, so it does need a forced backend. +const ELECTRON_UNPROTECTED_DESKTOPS = new Set(["LXQt"]); + +const KDE_NAME_PREFIXES = ["kde", "plasma"]; +const NEGATIVE_FLAG_VALUES = new Set(["0", "false", "no", "off"]); + +export function normalizeLinuxPasswordStorePreference( + value: unknown, +): LinuxPasswordStorePreference { + return value === "gnome-libsecret" || + value === "kwallet" || + value === "kwallet5" || + value === "kwallet6" + ? value + : DEFAULT_LINUX_PASSWORD_STORE; +} + +// Auto mode asks one question: will Electron select a real keyring on its own? If so, stay out of +// the way, which is how canonical KDE sessions keep the KWallet generation Chromium picks for them. +// Otherwise force gnome-libsecret, because the alternative is basic text, which is barely +// encryption at all. Forcing never guesses a KWallet generation; a KDE session that needs a +// specific one sets linuxPasswordStore explicitly. +export function resolveLinuxPasswordStoreSwitch(input: { + readonly preference: LinuxPasswordStorePreference; + readonly env: NodeJS.ProcessEnv; +}): LinuxPasswordStoreSwitch | null { + if (input.preference !== "auto") { + return input.preference; + } + + return electronSelectsProtectedBackend(input.env) ? null : "gnome-libsecret"; +} + +// Only an exact XDG_CURRENT_DESKTOP literal proves Electron will protect the session. Chromium can +// also reach a real backend through DESKTOP_SESSION and the legacy KDE markers, but those are the +// variables a previous session leaves behind, and trusting them is what let stale hints suppress +// the forced backend before. Forcing where Chromium would have chosen libsecret is harmless, since +// it lands on the same backend. +function electronSelectsProtectedBackend(env: NodeJS.ProcessEnv): boolean { + for (const name of splitDesktopNameList(env.XDG_CURRENT_DESKTOP)) { + const trimmed = name.trim(); + if (trimmed.length === 0) { + continue; + } + if (trimmed === ELECTRON_KDE_DESKTOP || ELECTRON_LIBSECRET_DESKTOPS.has(trimmed)) { + return true; + } + if (ELECTRON_UNPROTECTED_DESKTOPS.has(trimmed)) { + return false; + } + } + + return false; +} + +export function resolveLinuxSecretStorageUnavailableMessage(input: { + readonly configuredPreference: LinuxPasswordStorePreference; + readonly selectedBackend: string | null; + readonly env: NodeJS.ProcessEnv; +}): string { + if (input.configuredPreference === "gnome-libsecret") { + return getGnomeKeyringRemediationMessage(); + } + + if ( + input.configuredPreference === "kwallet" || + input.configuredPreference === "kwallet5" || + input.configuredPreference === "kwallet6" + ) { + return getKWalletRemediationMessage(); + } + + const backend = normalizeSelectedStorageBackend(input.selectedBackend); + if (backend === "gnome-libsecret") { + return getGnomeKeyringRemediationMessage(); + } + + if ( + backend === "kwallet" || + backend === "kwallet5" || + backend === "kwallet6" || + looksLikeKdeSession(input.env) + ) { + return getKWalletRemediationMessage(); + } + + return getGnomeKeyringRemediationMessage(); +} + +function getGnomeKeyringRemediationMessage(): string { + return "T3 Code could not access GNOME Keyring to save this environment credential. Install and start GNOME Keyring, then restart T3 Code."; +} + +function getKWalletRemediationMessage(): string { + return "T3 Code could not access KWallet to save this environment credential. Enable the KDE wallet subsystem in System Settings, then restart T3 Code."; +} + +// Advisory only: this picks between the GNOME Keyring and KWallet wording in the failure notice. It +// never decides which backend to select, so a loose match costs a user slightly wrong instructions +// rather than an unprotected credential store. +function looksLikeKdeSession(env: NodeJS.ProcessEnv): boolean { + const currentDesktopNames = nonEmptyDesktopNames(env.XDG_CURRENT_DESKTOP); + if (currentDesktopNames.length > 0) { + return currentDesktopNames.some(isKdeDesktopName); + } + + const legacyNames = legacyDesktopNames(env); + if (legacyNames.length > 0) { + return legacyNames.some(isKdeDesktopName); + } + + return isSet(env.KDE_SESSION_VERSION) || isAffirmativeFlag(env.KDE_FULL_SESSION); +} + +function isKdeDesktopName(name: string): boolean { + return KDE_NAME_PREFIXES.some((prefix) => name.startsWith(prefix)); +} + +function legacyDesktopNames(env: NodeJS.ProcessEnv): string[] { + return [env.XDG_SESSION_DESKTOP, env.DESKTOP_SESSION, env.GDMSESSION].flatMap((entry) => { + const normalized = normalizeDesktopName(entry); + return normalized ? [normalized] : []; + }); +} + +function nonEmptyDesktopNames(value: string | undefined): string[] { + return splitDesktopNameList(value).flatMap((entry) => { + const normalized = normalizeDesktopName(entry); + return normalized ? [normalized] : []; + }); +} + +function isSet(value: string | undefined): boolean { + return Boolean(value?.trim()); +} + +function isAffirmativeFlag(value: string | undefined): boolean { + const normalized = value?.trim().toLowerCase(); + return normalized ? !NEGATIVE_FLAG_VALUES.has(normalized) : false; +} + +function splitDesktopNameList(value: string | undefined): string[] { + return value?.split(":") ?? []; +} + +function normalizeDesktopName(value: string | undefined): string | null { + const normalized = value?.trim().toLowerCase(); + return normalized && normalized.length > 0 ? normalized : null; +} + +function normalizeSelectedStorageBackend(value: string | null): string | null { + const normalized = value?.trim().toLowerCase().replace(/_/gu, "-"); + return normalized && normalized.length > 0 ? normalized : null; +} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index ad370e36fdb5..4e6a525c7813 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -23,6 +23,7 @@ import serverPackageJson from "../../server/package.json" with { type: "json" }; import * as DesktopIpc from "./ipc/DesktopIpc.ts"; import * as ElectronApp from "./electron/ElectronApp.ts"; import * as ElectronDialog from "./electron/ElectronDialog.ts"; +import * as MacApplicationIcon from "./electron/MacApplicationIcon.ts"; import * as ElectronMenu from "./electron/ElectronMenu.ts"; import * as ElectronPowerMonitor from "./electron/ElectronPowerMonitor.ts"; import * as ElectronProtocol from "./electron/ElectronProtocol.ts"; @@ -43,13 +44,16 @@ import * as DesktopLocalEnvironmentAuth from "./backend/DesktopLocalEnvironmentA import * as DesktopNetworkInterfaces from "./backend/DesktopNetworkInterfaces.ts"; import * as DesktopEnvironment from "./app/DesktopEnvironment.ts"; import * as DesktopLifecycle from "./app/DesktopLifecycle.ts"; +import * as DesktopLinuxUrlHandler from "./app/DesktopLinuxUrlHandler.ts"; import * as DesktopShutdown from "./app/DesktopShutdown.ts"; import * as DesktopObservability from "./app/DesktopObservability.ts"; import * as DesktopServerExposure from "./backend/DesktopServerExposure.ts"; import * as DesktopClientSettings from "./settings/DesktopClientSettings.ts"; import * as DesktopSavedEnvironments from "./settings/DesktopSavedEnvironments.ts"; import * as DesktopAppSettings from "./settings/DesktopAppSettings.ts"; +import * as DesktopPreReadyPlatform from "./app/DesktopPreReadyPlatform.ts"; import * as DesktopShellEnvironment from "./shell/DesktopShellEnvironment.ts"; +import * as DesktopOpenWith from "./shell/DesktopOpenWith.ts"; import * as DesktopSshEnvironment from "./ssh/DesktopSshEnvironment.ts"; import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts"; import * as DesktopState from "./app/DesktopState.ts"; @@ -123,7 +127,7 @@ const electronLayer = Layer.mergeAll( ElectronUpdater.layer, ElectronWindow.layer, DesktopIpc.layer(Electron.ipcMain), -); +).pipe(Layer.provideMerge(MacApplicationIcon.layer)); const desktopFoundationLayer = Layer.mergeAll( DesktopState.layer, @@ -181,7 +185,9 @@ const desktopLocalEnvironmentAuthLayer = DesktopLocalEnvironmentAuth.layer.pipe( const desktopApplicationLayer = Layer.mergeAll( DesktopLifecycle.layer, DesktopApplicationMenu.layer, + DesktopLinuxUrlHandler.layer, DesktopShellEnvironment.layer, + DesktopOpenWith.layer, desktopSshLayer, ).pipe( Layer.provideMerge(DesktopUpdates.layer), @@ -195,16 +201,20 @@ const desktopClerkLayer = DesktopClerk.layer.pipe( Layer.provideMerge(ElectronApp.layer), ); +const desktopApplicationRuntimeLayer = desktopApplicationLayer.pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(NodeHttpClient.layerUndici), + Layer.provideMerge(NetService.layer), + Layer.provideMerge(electronLayer), +); + +// Acquire strict pre-ready setup before Clerk, whose userData resolution can +// yield and let Electron emit ready. const desktopRuntimeLayer = desktopClerkLayer.pipe( Layer.flatMap((clerkContext) => - desktopApplicationLayer.pipe( - Layer.provideMerge(Layer.succeedContext(clerkContext)), - Layer.provideMerge(NodeServices.layer), - Layer.provideMerge(NodeHttpClient.layerUndici), - Layer.provideMerge(NetService.layer), - Layer.provideMerge(electronLayer), - ), + desktopApplicationRuntimeLayer.pipe(Layer.provideMerge(Layer.succeedContext(clerkContext))), ), + Layer.provideMerge(DesktopPreReadyPlatform.layer), ); DesktopApp.program.pipe(Effect.provide(desktopRuntimeLayer), NodeRuntime.runMain); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 9f01baeed902..58f2a91b2cd9 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -97,7 +97,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { setWslDistro: (distro) => ipcRenderer.invoke(IpcChannels.SET_WSL_DISTRO_CHANNEL, distro), setWslOnly: (enabled) => ipcRenderer.invoke(IpcChannels.SET_WSL_ONLY_CHANNEL, enabled), pickFolder: (options) => ipcRenderer.invoke(IpcChannels.PICK_FOLDER_CHANNEL, options), - confirm: (message) => ipcRenderer.invoke(IpcChannels.CONFIRM_CHANNEL, message), + pickThemeFiles: () => ipcRenderer.invoke(IpcChannels.PICK_THEME_FILES_CHANNEL, undefined), setTheme: (theme) => ipcRenderer.invoke(IpcChannels.SET_THEME_CHANNEL, theme), showContextMenu: (items, position) => ipcRenderer.invoke(IpcChannels.CONTEXT_MENU_CHANNEL, { @@ -105,6 +105,10 @@ contextBridge.exposeInMainWorld("desktopBridge", { ...(position === undefined ? {} : { position }), }), openExternal: (url: string) => ipcRenderer.invoke(IpcChannels.OPEN_EXTERNAL_CHANNEL, url), + pickOpenWithApplication: () => ipcRenderer.invoke(IpcChannels.PICK_OPEN_WITH_APPLICATION_CHANNEL), + resolveOpenWithPresentations: () => + ipcRenderer.invoke(IpcChannels.RESOLVE_OPEN_WITH_PRESENTATIONS_CHANNEL), + openWith: (input) => ipcRenderer.invoke(IpcChannels.OPEN_WITH_CHANNEL, input), onMenuAction: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => { if (typeof action !== "string") return; diff --git a/apps/desktop/src/preview/AnnotationKeyboard.test.ts b/apps/desktop/src/preview/AnnotationKeyboard.test.ts new file mode 100644 index 000000000000..f49c1cb79f1a --- /dev/null +++ b/apps/desktop/src/preview/AnnotationKeyboard.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveAnnotationSubmission } from "./AnnotationKeyboard.ts"; + +const keyboardEvent = ( + overrides: Partial[0]> = {}, +) => ({ + key: "Enter", + metaKey: false, + ctrlKey: false, + shiftKey: false, + isComposing: false, + ...overrides, +}); + +describe("resolveAnnotationSubmission", () => { + it("attaches on Enter and sends on Cmd/Ctrl+Enter", () => { + expect(resolveAnnotationSubmission(keyboardEvent())).toBe("attach"); + expect(resolveAnnotationSubmission(keyboardEvent({ metaKey: true }))).toBe("send"); + expect(resolveAnnotationSubmission(keyboardEvent({ ctrlKey: true }))).toBe("send"); + }); + + it("leaves Shift+Enter and composition events available for editing", () => { + expect(resolveAnnotationSubmission(keyboardEvent({ shiftKey: true }))).toBeNull(); + expect(resolveAnnotationSubmission(keyboardEvent({ isComposing: true }))).toBeNull(); + expect(resolveAnnotationSubmission(keyboardEvent({ key: " " }))).toBeNull(); + }); +}); diff --git a/apps/desktop/src/preview/AnnotationKeyboard.ts b/apps/desktop/src/preview/AnnotationKeyboard.ts new file mode 100644 index 000000000000..6c694ccd2ed5 --- /dev/null +++ b/apps/desktop/src/preview/AnnotationKeyboard.ts @@ -0,0 +1,16 @@ +import type { PreviewAnnotationSubmission } from "@t3tools/contracts"; + +interface AnnotationKeyboardEvent { + readonly key: string; + readonly metaKey: boolean; + readonly ctrlKey: boolean; + readonly shiftKey: boolean; + readonly isComposing: boolean; +} + +export function resolveAnnotationSubmission( + event: AnnotationKeyboardEvent, +): PreviewAnnotationSubmission | null { + if (event.key !== "Enter" || event.shiftKey || event.isComposing) return null; + return event.metaKey || event.ctrlKey ? "send" : "attach"; +} diff --git a/apps/desktop/src/preview/BrowserSession.ts b/apps/desktop/src/preview/BrowserSession.ts index aa0b0743e933..e11d25bbed77 100644 --- a/apps/desktop/src/preview/BrowserSession.ts +++ b/apps/desktop/src/preview/BrowserSession.ts @@ -23,6 +23,10 @@ const ALLOWED_PREVIEW_PERMISSIONS: ReadonlySet = new Set([ "clipboard-sanitized-write", "notifications", "geolocation", + // Deliberately NOT local-fonts: preview sessions run untrusted web content, + // and silently granting it would hand every page the user's installed-font + // fingerprint (and font file bytes via FontData.blob()). The app's own font + // picker runs in the main window session, which is unaffected by this list. ]); export class BrowserSessionPartitionDerivationError extends Schema.TaggedErrorClass()( diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 684d6655da5b..a6ef30c2742a 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -36,6 +36,28 @@ describe("fitPictureInPictureContentSize", () => { }); }); +describe("isPreviewRefreshShortcut", () => { + const input = (overrides: Partial = {}) => + ({ + type: "keyDown", + key: "r", + meta: true, + control: false, + shift: false, + alt: false, + ...overrides, + }) as Electron.Input; + + it("recognizes the platform refresh chord without matching modified variants", () => { + expect(PreviewManager.isPreviewRefreshShortcut(input())).toBe(true); + expect(PreviewManager.isPreviewRefreshShortcut(input({ meta: false, control: true }))).toBe( + true, + ); + expect(PreviewManager.isPreviewRefreshShortcut(input({ shift: true }))).toBe(false); + expect(PreviewManager.isPreviewRefreshShortcut(input({ type: "keyUp" }))).toBe(false); + }); +}); + const { browserWindowConstructor, createFromPath, diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index d7e8376cec74..169fe2992dca 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -11,6 +11,7 @@ import type { DesktopPreviewPointerEvent, PreviewAnnotationPayload, PreviewAnnotationRect, + PreviewAnnotationSubmissionResult, DesktopPreviewRecordingArtifact, DesktopPreviewRecordingFrame, DesktopPreviewScreenshotArtifact, @@ -109,7 +110,7 @@ const DIAGNOSTIC_BUFFER_LIMIT = 200; const MAX_ARTIFACT_SITE_SLUG_LENGTH = 80; const AGENT_CURSOR_MOVE_MS = 160; const AGENT_CURSOR_CLICK_LEAD_MS = 40; -const encodeUnknownJson = Schema.encodeUnknownEffect(Schema.UnknownFromJsonString); +const encodeUnknownJson = Schema.encodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); const DEFAULT_ANNOTATION_THEME: DesktopPreviewAnnotationTheme = { colorScheme: "light", radius: "0.625rem", @@ -406,6 +407,13 @@ const APP_FORWARDED_SHORTCUTS: ReadonlyArray<{ { key: "w", meta: true, shift: false, control: false }, ]); +export const isPreviewRefreshShortcut = (input: Electron.Input): boolean => + input.type === "keyDown" && + input.key.toLowerCase() === "r" && + (input.meta || input.control) && + !input.shift && + !input.alt; + const isPreviewInputSignal = (value: unknown): value is PreviewInputSignal => { if (typeof value !== "object" || value === null || !("kind" in value)) return false; if (value.kind === "pointer") { @@ -1365,6 +1373,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); }); const beforeInput = (event: Electron.Event, input: Electron.Input): void => { + if (isPreviewRefreshShortcut(input)) { + event.preventDefault(); + runFork( + attempt({ operation: "shortcut.refresh", tabId, webContentsId: wc.id }, () => + wc.reload(), + ).pipe(Effect.ignore), + ); + return; + } runFork(forwardShortcut(event, input)); }; yield* Scope.addFinalizer( @@ -1792,7 +1809,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const wc = yield* requireWebContents(tabId); yield* cancelPickElement(tabId); const annotationTheme = yield* Ref.get(annotationThemeRef); - return yield* Effect.callback( + return yield* Effect.callback( (resume) => { const cleanup = Effect.fn("PreviewManager.cleanupPickElement")(function* () { yield* attempt({ operation: "pickElement.cleanup", tabId, webContentsId: wc.id }, () => { @@ -1807,14 +1824,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); }); const settlePick = Effect.fn("PreviewManager.settlePickElement")(function* ( - payload: PreviewAnnotationPayload | null, + payload: PreviewAnnotationSubmissionResult | null, ) { const active = (yield* Ref.get(pickSessionsRef)).get(tabId); if (!active || active.cancel !== cancel) return; yield* cleanup(); resume(Effect.succeed(payload)); }); - const settle = (payload: PreviewAnnotationPayload | null) => { + const settle = (payload: PreviewAnnotationSubmissionResult | null) => { runFork(settlePick(payload)); }; const cancelPickSession = Effect.fn("PreviewManager.cancelPickSession")(function* () { @@ -1844,11 +1861,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function return; } const cropRect = normalizeCaptureRect(args[1]); + const submission = args[2] === "send" ? "send" : "attach"; runFork( captureAnnotationScreenshot(tabId, wc, cropRect).pipe( Effect.matchEffect({ - onFailure: () => Effect.sync(() => settle(payload)), - onSuccess: (screenshot) => Effect.sync(() => settle({ ...payload, screenshot })), + onFailure: () => Effect.sync(() => settle({ annotation: payload, submission })), + onSuccess: (screenshot) => + Effect.sync(() => settle({ annotation: { ...payload, screenshot }, submission })), }), Effect.ensuring( attempt( @@ -3586,7 +3605,7 @@ export class PreviewManager extends Context.Service< ) => Effect.Effect; readonly pickElement: ( tabId: string, - ) => Effect.Effect; + ) => Effect.Effect; readonly cancelPickElement: (tabId: string) => Effect.Effect; readonly captureScreenshot: ( tabId: string, diff --git a/apps/desktop/src/preview/PickPreload.ts b/apps/desktop/src/preview/PickPreload.ts index 2654b8981021..d03673400ab5 100644 --- a/apps/desktop/src/preview/PickPreload.ts +++ b/apps/desktop/src/preview/PickPreload.ts @@ -11,8 +11,10 @@ import type { PreviewAnnotationRegionTarget, PreviewAnnotationStrokeTarget, PreviewAnnotationStyleChange, + PreviewAnnotationSubmission, } from "@t3tools/contracts"; +import { resolveAnnotationSubmission } from "./AnnotationKeyboard.ts"; import { previewAnnotationStyles } from "./AnnotationStyles.generated.ts"; import { ANNOTATION_CAPTURED_CHANNEL, @@ -426,7 +428,7 @@ function startAnnotation(): void { "hidden h-8 w-6 shrink-0 cursor-grab select-none border-0 bg-transparent p-0 font-sans text-lg font-bold leading-5 text-muted-foreground"; composerRow.appendChild(dragHandle); - const submit = createButton("Attach", "Attach annotation and screenshot"); + const submit = createButton("Attach", "Attach annotation and screenshot (Enter)"); submit.className += " h-8 shrink-0 border-primary bg-primary px-3 text-primary-foreground shadow-sm hover:bg-primary/90"; composerRow.appendChild(submit); @@ -1182,7 +1184,7 @@ function startAnnotation(): void { refreshToolButtons(); }; - submit.addEventListener("click", () => { + const submitAnnotation = (submission: PreviewAnnotationSubmission): void => { if (pendingCapture || (selected.size === 0 && regions.length === 0 && strokes.length === 0)) return; pendingCapture = true; @@ -1223,13 +1225,18 @@ function startAnnotation(): void { ...regions.map((region) => region.rect), ...strokes.map((stroke) => stroke.bounds), ]); - ipcRenderer.send(ELEMENT_PICKED_CHANNEL, annotation, screenshotRect); + ipcRenderer.send(ELEMENT_PICKED_CHANNEL, annotation, screenshotRect, submission); }); - }); - comment.addEventListener("keydown", (event) => { - if (event.key !== "Enter" || !(event.metaKey || event.ctrlKey)) return; + }; + submit.addEventListener("click", () => submitAnnotation("attach")); + root.addEventListener("keydown", (event) => { + const submission = event.target === comment ? resolveAnnotationSubmission(event) : null; + // Keep this in the bubble phase so editor inputs receive the event before + // it is isolated from listeners installed by the inspected page. + event.stopImmediatePropagation(); + if (!submission) return; event.preventDefault(); - submit.click(); + submitAnnotation(submission); }); window.addEventListener("pointermove", onPointerMove, { capture: true, passive: false }); diff --git a/apps/desktop/src/preview/PlaywrightInjectedRuntime.ts b/apps/desktop/src/preview/PlaywrightInjectedRuntime.ts index ff1531f08f3c..6104532e68d0 100644 --- a/apps/desktop/src/preview/PlaywrightInjectedRuntime.ts +++ b/apps/desktop/src/preview/PlaywrightInjectedRuntime.ts @@ -8,7 +8,7 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; const require = NodeModule.createRequire(import.meta.url); -const encodeUnknownJson = Schema.encodeUnknownEffect(Schema.UnknownFromJsonString); +const encodeUnknownJson = Schema.encodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); const PLAYWRIGHT_PACKAGE_SPECIFIER = "playwright-core/package.json"; const PLAYWRIGHT_SOURCE_MARKER = "source3 = "; const PLAYWRIGHT_SOURCE_TERMINATOR = ";\n }\n});"; diff --git a/apps/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index 3878b0e36ad0..64c59749abe9 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -11,6 +11,9 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopAppSettings from "./DesktopAppSettings.ts"; const DesktopSettingsPatch = Schema.Struct({ + linuxPasswordStore: Schema.optionalKey( + Schema.Literals(["auto", "gnome-libsecret", "kwallet", "kwallet5", "kwallet6"]), + ), mainWindowBounds: Schema.optionalKey( Schema.NullOr( Schema.Struct({ @@ -102,6 +105,7 @@ describe("DesktopSettings", () => { assert.deepEqual( DesktopAppSettings.resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1"), { + linuxPasswordStore: "auto", mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "local-only", @@ -121,6 +125,7 @@ describe("DesktopSettings", () => { Effect.gen(function* () { const settings = yield* DesktopAppSettings.DesktopAppSettings; yield* writeSettingsPatch({ + linuxPasswordStore: "gnome-libsecret", serverExposureMode: "network-accessible", tailscaleServeEnabled: true, tailscaleServePort: 8443, @@ -129,6 +134,7 @@ describe("DesktopSettings", () => { }); assert.deepEqual(yield* settings.load, { + linuxPasswordStore: "gnome-libsecret", mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "network-accessible", @@ -235,6 +241,7 @@ describe("DesktopSettings", () => { ); assert.deepEqual(yield* settings.load, { + linuxPasswordStore: "auto", mainWindowBounds: { x: 120, y: 80, width: 1280, height: 900 }, mainWindowMaximized: false, serverExposureMode: "network-accessible", @@ -268,6 +275,44 @@ describe("DesktopSettings", () => { ), ); + it.effect( + "normalizes unsupported linux password-store values without dropping other settings", + () => + withSettings( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + yield* fileSystem.writeFileString( + environment.desktopSettingsPath, + `{ + "linuxPasswordStore": "unsupported-store", + "serverExposureMode": "network-accessible", + "tailscaleServeEnabled": true, + "tailscaleServePort": 8443, + "updateChannel": "nightly", + "updateChannelConfiguredByUser": true + }\n`, + ); + + assert.deepEqual(yield* settings.load, { + linuxPasswordStore: "auto", + mainWindowBounds: null, + mainWindowMaximized: false, + serverExposureMode: "network-accessible", + tailscaleServeEnabled: true, + tailscaleServePort: 8443, + updateChannel: "nightly", + updateChannelConfiguredByUser: true, + wslBackendEnabled: false, + wslOnly: false, + wslDistro: null, + } satisfies DesktopAppSettings.DesktopSettings); + }), + ), + ); + it.effect("persists sparse desktop settings documents", () => withSettings( Effect.gen(function* () { @@ -300,6 +345,7 @@ describe("DesktopSettings", () => { }); assert.deepEqual(yield* settings.load, { + linuxPasswordStore: "auto", mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "local-only", @@ -327,6 +373,7 @@ describe("DesktopSettings", () => { }); assert.deepEqual(yield* settings.load, { + linuxPasswordStore: "auto", mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "local-only", @@ -353,6 +400,7 @@ describe("DesktopSettings", () => { }); assert.deepEqual(yield* settings.load, { + linuxPasswordStore: "auto", mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "local-only", diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index 466c9a9b5f8a..aefc67525531 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -16,10 +16,16 @@ import * as Schema from "effect/Schema"; import * as SynchronizedRef from "effect/SynchronizedRef"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import { + DEFAULT_LINUX_PASSWORD_STORE, + normalizeLinuxPasswordStorePreference, + type LinuxPasswordStorePreference, +} from "../linuxSecretStorage.ts"; import { resolveDefaultDesktopUpdateChannel } from "../updates/updateChannels.ts"; import { isValidDistroName } from "../wsl/wslPathParsing.ts"; export interface DesktopSettings { + readonly linuxPasswordStore: LinuxPasswordStorePreference; readonly mainWindowBounds: DesktopWindowBounds | null; readonly mainWindowMaximized: boolean; readonly serverExposureMode: DesktopServerExposureMode; @@ -67,6 +73,7 @@ export const DEFAULT_MAIN_WINDOW_SIZE = { } as const; export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { + linuxPasswordStore: DEFAULT_LINUX_PASSWORD_STORE, mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "local-only", @@ -87,6 +94,7 @@ const DesktopWindowBoundsDocument = Schema.Struct({ }); const DesktopSettingsDocument = Schema.Struct({ + linuxPasswordStore: Schema.optionalKey(Schema.Unknown), mainWindowBounds: Schema.optionalKey(Schema.NullOr(DesktopWindowBoundsDocument)), mainWindowMaximized: Schema.optionalKey(Schema.Boolean), serverExposureMode: Schema.optionalKey(DesktopServerExposureModeSchema), @@ -216,6 +224,7 @@ function normalizeDesktopSettingsDocument( (parsed.wslBackendEnabled === undefined && parsed.wslMode === "wsl"); return { + linuxPasswordStore: normalizeLinuxPasswordStorePreference(parsed.linuxPasswordStore), mainWindowBounds, mainWindowMaximized: mainWindowBounds !== null && parsed.mainWindowMaximized === true, serverExposureMode: @@ -238,6 +247,9 @@ function toDesktopSettingsDocument( ): DesktopSettingsDocument { const document: Mutable = {}; + if (settings.linuxPasswordStore !== defaults.linuxPasswordStore) { + document.linuxPasswordStore = settings.linuxPasswordStore; + } if (settings.mainWindowBounds !== null) { document.mainWindowBounds = settings.mainWindowBounds; } diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 8d76ea83a33e..c8fec57c5073 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -1,6 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; -import { ClientSettingsSchema, type ClientSettings } from "@t3tools/contracts"; +import { ClientSettingsSchema, OpenWithEntryId, type ClientSettings } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -13,14 +13,34 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopClientSettings from "./DesktopClientSettings.ts"; const clientSettings: ClientSettings = { - autoOpenPlanSidebar: false, confirmThreadArchive: true, confirmThreadDelete: false, dismissedProviderUpdateNotificationKeys: [], diffIgnoreWhitespace: true, environmentIdentificationMode: "artwork", favorites: [], + fontFamilyCode: "", + fontFamilyComposer: "", + fontFamilySans: "", + fontFamilyTerminal: "", + fontSizeCode: 13, + fontSizeInterface: 16, + fontSizePrompt: 14, + fontSizeTerminal: 12, + fontSmoothing: true, glassOpacity: 80, + planModeEnabled: false, + openWithEntries: [ + { + id: OpenWithEntryId.make("terminal"), + name: "Terminal", + kind: "terminal", + invocation: { type: "mac-application", applicationPath: "/Applications/Terminal.app" }, + directoryMode: "open-target", + arguments: [], + }, + ], + preferredOpenWith: { type: "custom", id: OpenWithEntryId.make("terminal") }, providerModelPreferences: {}, sidebarAutoSettleAfterDays: 3, sidebarProjectGroupingMode: "repository_path", @@ -30,8 +50,7 @@ const clientSettings: ClientSettings = { sidebarProjectSortOrder: "manual", sidebarThreadSortOrder: "created_at", sidebarThreadPreviewCount: 6, - sidebarV2Enabled: false, - sidebarV2ConfiguredByUser: false, + legacySidebarEnabled: false, timestampFormat: "24-hour", wordWrap: true, }; diff --git a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts index ec70308b3d34..05b1ca144444 100644 --- a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts +++ b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts @@ -86,6 +86,7 @@ function makeSafeStorageLayer(input: { } return Effect.succeed(decoded.slice("enc:".length)); }, + selectedStorageBackend: Effect.succeed(Option.none()), } satisfies ElectronSafeStorage.ElectronSafeStorage["Service"]); } diff --git a/apps/desktop/src/shell/DesktopOpenWith.test.ts b/apps/desktop/src/shell/DesktopOpenWith.test.ts new file mode 100644 index 000000000000..55dbafb8fbda --- /dev/null +++ b/apps/desktop/src/shell/DesktopOpenWith.test.ts @@ -0,0 +1,224 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { + DEFAULT_CLIENT_SETTINGS, + EnvironmentId, + OpenWithEntry, + OpenWithEntryId, + PRIMARY_LOCAL_ENVIRONMENT_ID, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import * as DesktopConfig from "../app/DesktopConfig.ts"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as MacApplicationIcon from "../electron/MacApplicationIcon.ts"; +import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts"; +import * as DesktopOpenWith from "./DesktopOpenWith.ts"; + +const decodeEntry = Schema.decodeUnknownSync(OpenWithEntry); + +const configuredEntries = [ + decodeEntry({ + id: "echo", + name: "Echo", + kind: "other", + invocation: { type: "command", executable: "/bin/echo" }, + directoryMode: "open-target", + arguments: [], + }), + decodeEntry({ + id: "missing", + name: "Missing", + kind: "other", + invocation: { type: "command", executable: "/definitely/missing/t3-open-with" }, + directoryMode: "open-target", + arguments: [], + }), +] as const; + +const environmentLayer = DesktopEnvironment.layer({ + dirname: "/repo/apps/desktop/src", + homeDirectory: "/tmp", + platform: "darwin", + processArch: "arm64", + appVersion: "1.0.0", + appPath: "/repo", + isPackaged: true, + resourcesPath: "/repo/resources", + runningUnderArm64Translation: false, +}).pipe( + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + DesktopConfig.layerTest({ T3CODE_HOME: "/tmp/t3-open-with" }), + ), + ), +); + +const openWithLayer = DesktopOpenWith.layer.pipe( + Layer.provideMerge( + Layer.succeed(MacApplicationIcon.MacApplicationIcon, { + resolveDataUrl: () => Effect.die("unexpected application icon resolution"), + } satisfies MacApplicationIcon.MacApplicationIcon["Service"]), + ), + Layer.provideMerge( + DesktopClientSettings.layerTest( + Option.some({ + ...DEFAULT_CLIENT_SETTINGS, + openWithEntries: configuredEntries, + }), + ), + ), + Layer.provideMerge(environmentLayer), + Layer.provideMerge(NodeServices.layer), +); + +describe("DesktopOpenWith launch resolution", () => { + it.effect("appends the directory for open-target command entries", () => + Effect.gen(function* () { + const launch = yield* DesktopOpenWith.resolveOpenWithLaunch( + decodeEntry({ + id: "echo", + name: "Echo", + kind: "other", + invocation: { type: "command", executable: "/bin/echo" }, + directoryMode: "open-target", + arguments: ["--flag"], + }), + "/tmp/work tree", + "darwin", + ); + assert.deepEqual(launch, { + command: "/bin/echo", + args: ["--flag", "/tmp/work tree"], + shell: false, + cwd: null, + }); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("sets cwd without adding a directory argument in working-directory mode", () => + Effect.gen(function* () { + const launch = yield* DesktopOpenWith.resolveOpenWithLaunch( + decodeEntry({ + id: "echo", + name: "Echo", + kind: "other", + invocation: { type: "command", executable: "/bin/echo" }, + directoryMode: "working-directory", + arguments: ["one argument"], + }), + "/tmp/work tree", + "darwin", + ); + assert.deepEqual(launch, { + command: "/bin/echo", + args: ["one argument"], + shell: false, + cwd: "/tmp/work tree", + }); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("expands placeholders within independent argument rows without shell parsing", () => + Effect.gen(function* () { + const launch = yield* DesktopOpenWith.resolveOpenWithLaunch( + decodeEntry({ + id: "echo", + name: "Echo", + kind: "other", + invocation: { type: "command", executable: "/bin/echo" }, + directoryMode: "custom-arguments", + arguments: ["prefix={directory}=suffix", "literal value"], + }), + "/tmp/work tree", + "darwin", + ); + assert.deepEqual(launch.args, ["prefix=/tmp/work tree=suffix", "literal value"]); + assert.isFalse(launch.shell ?? false); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("resolves CFBundleExecutable and reports missing bundle executables", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const base = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-open-with-test-" }); + const applicationPath = path.join(base, "Fixture.app"); + const contentsPath = path.join(applicationPath, "Contents"); + const executableDirectory = path.join(contentsPath, "MacOS"); + yield* fileSystem.makeDirectory(executableDirectory, { recursive: true }); + yield* fileSystem.writeFileString( + path.join(contentsPath, "Info.plist"), + ` + CFBundleExecutableFixture`, + ); + const executablePath = path.join(executableDirectory, "Fixture"); + yield* fileSystem.writeFileString(executablePath, "#!/bin/sh\n"); + + assert.equal( + yield* DesktopOpenWith.resolveMacBundleExecutable(applicationPath), + executablePath, + ); + yield* fileSystem.remove(executablePath); + const error = yield* Effect.flip(DesktopOpenWith.resolveMacBundleExecutable(applicationPath)); + assert.equal(error.reason, "missing-executable"); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + + it.effect("reports available and missing command presentations", () => + Effect.gen(function* () { + const openWith = yield* DesktopOpenWith.DesktopOpenWith; + const presentations = yield* openWith.resolvePresentations; + assert.deepEqual(presentations, [ + { entryId: configuredEntries[0].id, available: true, iconDataUrl: null }, + { + entryId: configuredEntries[1].id, + available: false, + iconDataUrl: null, + unavailableReason: "Executable not found: /definitely/missing/t3-open-with", + }, + ]); + }).pipe(Effect.provide(openWithLayer)), + ); + + it.effect("rejects secondary environments, invalid targets, and unknown entry ids", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-open-target-" }); + const openWith = yield* DesktopOpenWith.DesktopOpenWith; + + const remoteError = yield* Effect.flip( + openWith.open({ + environmentId: EnvironmentId.make("ssh:test"), + entryId: configuredEntries[0].id, + directory, + }), + ); + assert.equal(remoteError._tag, "OpenWithEnvironmentError"); + + const relativeError = yield* Effect.flip( + openWith.open({ + environmentId: EnvironmentId.make(PRIMARY_LOCAL_ENVIRONMENT_ID), + entryId: configuredEntries[0].id, + directory: "relative/path", + }), + ); + assert.equal(relativeError._tag, "OpenWithInvalidTargetError"); + + const unknownError = yield* Effect.flip( + openWith.open({ + environmentId: EnvironmentId.make(PRIMARY_LOCAL_ENVIRONMENT_ID), + entryId: OpenWithEntryId.make("unknown"), + directory, + }), + ); + assert.equal(unknownError._tag, "OpenWithMissingEntryError"); + }).pipe(Effect.provide(openWithLayer), Effect.scoped), + ); +}); diff --git a/apps/desktop/src/shell/DesktopOpenWith.ts b/apps/desktop/src/shell/DesktopOpenWith.ts new file mode 100644 index 000000000000..464d71fce481 --- /dev/null +++ b/apps/desktop/src/shell/DesktopOpenWith.ts @@ -0,0 +1,323 @@ +// @effect-diagnostics nodeBuiltinImport:off - macOS bundle paths use the host path grammar. +import { + OpenWithBundleResolutionError, + OpenWithEnvironmentError, + OpenWithInvalidTargetError, + OpenWithMissingEntryError, + OpenWithSpawnError, + OpenWithUnavailableApplicationError, + PRIMARY_LOCAL_ENVIRONMENT_ID, + type DesktopOpenWithInput, + type OpenWithEntry, + type OpenWithEntryPresentation, + type OpenWithLaunchError, +} from "@t3tools/contracts"; +import { resolveCommandPath, resolveSpawnCommand } from "@t3tools/shared/shell"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as NodePath from "node:path"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; + +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as MacApplicationIcon from "../electron/MacApplicationIcon.ts"; +import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts"; + +interface ResolvedLaunch { + readonly command: string; + readonly args: readonly string[]; + readonly cwd: string | null; + readonly shell?: boolean; +} + +const statPath = (path: string) => + FileSystem.FileSystem.pipe( + Effect.flatMap((fileSystem) => fileSystem.stat(path)), + Effect.map(Option.some), + Effect.orElseSucceed(() => Option.none()), + ); + +const applicationUnavailableReason = (entry: OpenWithEntry): string => + entry.invocation.type === "mac-application" + ? `Application not found: ${entry.invocation.applicationPath}` + : `Executable not found: ${entry.invocation.executable}`; + +const isMacApplicationPath = (value: string): boolean => + NodePath.isAbsolute(value) && value.toLowerCase().endsWith(".app"); + +const commandExists = Effect.fn("desktop.openWith.commandExists")(function* (command: string) { + return yield* resolveCommandPath(command).pipe( + Effect.as(true), + Effect.catchTag("CommandResolutionError", () => Effect.succeed(false)), + ); +}); + +const entryIsAvailable = Effect.fn("desktop.openWith.entryIsAvailable")(function* ( + entry: OpenWithEntry, + platform: NodeJS.Platform, +) { + if (entry.invocation.type === "command") { + return yield* commandExists(entry.invocation.executable); + } + if (platform !== "darwin" || !isMacApplicationPath(entry.invocation.applicationPath)) { + return false; + } + const stat = yield* statPath(entry.invocation.applicationPath); + return Option.isSome(stat) && stat.value.type === "Directory"; +}); + +export const resolveMacBundleExecutable = Effect.fn("desktop.openWith.resolveMacBundleExecutable")( + function* (applicationPath: string) { + if (!isMacApplicationPath(applicationPath)) { + return yield* new OpenWithBundleResolutionError({ + applicationPath, + reason: "invalid-application-path", + }); + } + const infoPlistPath = NodePath.join(applicationPath, "Contents", "Info.plist"); + const plistStat = yield* statPath(infoPlistPath); + if (Option.isNone(plistStat) || plistStat.value.type !== "File") { + return yield* new OpenWithBundleResolutionError({ + applicationPath, + reason: "missing-info-plist", + }); + } + + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const executableName = yield* spawner + .string( + ChildProcess.make( + "/usr/bin/plutil", + ["-extract", "CFBundleExecutable", "raw", "-o", "-", infoPlistPath], + { stdin: "ignore", stdout: "pipe", stderr: "pipe" }, + ), + ) + .pipe( + Effect.map((output) => output.trim()), + Effect.mapError( + (cause) => + new OpenWithBundleResolutionError({ + applicationPath, + reason: "malformed-info-plist", + cause, + }), + ), + ); + if ( + executableName.length === 0 || + executableName.includes("/") || + executableName.includes("\\") + ) { + return yield* new OpenWithBundleResolutionError({ + applicationPath, + reason: "malformed-info-plist", + }); + } + const executablePath = NodePath.join(applicationPath, "Contents", "MacOS", executableName); + const executableStat = yield* statPath(executablePath); + if (Option.isNone(executableStat) || executableStat.value.type !== "File") { + return yield* new OpenWithBundleResolutionError({ + applicationPath, + reason: "missing-executable", + }); + } + return executablePath; + }, +); + +const expandDirectoryArguments = (args: readonly string[], directory: string): string[] => + args.map((argument) => argument.replaceAll("{directory}", directory)); + +export const resolveOpenWithLaunch = Effect.fn("desktop.openWith.resolveLaunch")(function* ( + entry: OpenWithEntry, + directory: string, + platform: NodeJS.Platform, +): Effect.fn.Return< + ResolvedLaunch, + OpenWithLaunchError, + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner +> { + if (!(yield* entryIsAvailable(entry, platform))) { + return yield* new OpenWithUnavailableApplicationError({ + entryId: entry.id, + executable: + entry.invocation.type === "mac-application" + ? entry.invocation.applicationPath + : entry.invocation.executable, + }); + } + + if (entry.directoryMode === "open-target") { + if (entry.invocation.type === "mac-application") { + return { + command: "/usr/bin/open", + args: ["-a", entry.invocation.applicationPath, directory], + cwd: null, + }; + } + const command = yield* resolveSpawnCommand(entry.invocation.executable, [ + ...entry.arguments, + directory, + ]); + return { ...command, cwd: null }; + } + + if (entry.directoryMode === "working-directory") { + if (entry.invocation.type === "mac-application") { + return { + command: yield* resolveMacBundleExecutable(entry.invocation.applicationPath), + args: [...entry.arguments], + cwd: directory, + }; + } + const command = yield* resolveSpawnCommand(entry.invocation.executable, entry.arguments); + return { ...command, cwd: directory }; + } + + if (!entry.arguments.some((argument) => argument.includes("{directory}"))) { + return yield* new OpenWithUnavailableApplicationError({ + entryId: entry.id, + executable: "Custom arguments must include {directory}", + }); + } + const args = expandDirectoryArguments(entry.arguments, directory); + if (entry.invocation.type === "mac-application") { + return { + command: yield* resolveMacBundleExecutable(entry.invocation.applicationPath), + args, + cwd: null, + }; + } + const command = yield* resolveSpawnCommand(entry.invocation.executable, args); + return { ...command, cwd: null }; +}); + +const spawnDetached = ( + entry: OpenWithEntry, + launch: ResolvedLaunch & { readonly shell?: boolean }, +) => + ChildProcessSpawner.ChildProcessSpawner.pipe( + Effect.flatMap((spawner) => + spawner.spawn( + ChildProcess.make(launch.command, launch.args, { + ...(launch.cwd === null ? {} : { cwd: launch.cwd }), + detached: true, + shell: launch.shell ?? false, + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }), + ), + ), + Effect.flatMap((handle) => handle.unref), + Effect.asVoid, + Effect.scoped, + Effect.mapError( + (cause) => + new OpenWithSpawnError({ + entryId: entry.id, + command: launch.command, + args: [...launch.args], + cwd: launch.cwd, + cause, + }), + ), + ); + +export class DesktopOpenWith extends Context.Service< + DesktopOpenWith, + { + readonly resolvePresentations: Effect.Effect; + readonly open: (input: DesktopOpenWithInput) => Effect.Effect; + } +>()("@t3tools/desktop/shell/DesktopOpenWith") {} + +export const make = Effect.gen(function* () { + const clientSettings = yield* DesktopClientSettings.DesktopClientSettings; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const applicationIcon = yield* MacApplicationIcon.MacApplicationIcon; + + const providePlatformServices = ( + effect: Effect.Effect< + A, + E, + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner + >, + ): Effect.Effect => + effect.pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + + const resolvePresentations = Effect.gen(function* () { + const settings = yield* clientSettings.get; + if (Option.isNone(settings)) return []; + return yield* Effect.forEach(settings.value.openWithEntries, (entry) => + Effect.gen(function* () { + const available = yield* providePlatformServices( + entryIsAvailable(entry, environment.platform), + ); + const iconDataUrl = + available && entry.invocation.type === "mac-application" + ? yield* applicationIcon + .resolveDataUrl(entry.invocation.applicationPath) + .pipe(Effect.orElseSucceed(() => null)) + : null; + return { + entryId: entry.id, + available, + iconDataUrl, + ...(available ? {} : { unavailableReason: applicationUnavailableReason(entry) }), + } satisfies OpenWithEntryPresentation; + }), + ); + }).pipe(Effect.withSpan("desktop.openWith.resolvePresentations")); + + const open = Effect.fn("desktop.openWith.open")(function* (input: DesktopOpenWithInput) { + if (input.environmentId !== PRIMARY_LOCAL_ENVIRONMENT_ID) { + return yield* new OpenWithEnvironmentError({ environmentId: input.environmentId }); + } + if (!NodePath.isAbsolute(input.directory)) { + return yield* new OpenWithInvalidTargetError({ + directory: input.directory, + reason: "relative", + }); + } + const targetStat = yield* providePlatformServices(statPath(input.directory)); + if (Option.isNone(targetStat)) { + return yield* new OpenWithInvalidTargetError({ + directory: input.directory, + reason: "missing", + }); + } + if (targetStat.value.type !== "Directory") { + return yield* new OpenWithInvalidTargetError({ + directory: input.directory, + reason: "not-directory", + }); + } + const settings = yield* clientSettings.get; + const entry = Option.isSome(settings) + ? settings.value.openWithEntries.find((candidate) => candidate.id === input.entryId) + : undefined; + if (entry === undefined) { + return yield* new OpenWithMissingEntryError({ entryId: input.entryId }); + } + const launch = yield* providePlatformServices( + resolveOpenWithLaunch(entry, input.directory, environment.platform), + ); + yield* providePlatformServices(spawnDetached(entry, launch)); + }); + + return DesktopOpenWith.of({ resolvePresentations, open }); +}); + +export const layer = Layer.effect(DesktopOpenWith, make); diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts index 7ec0ab80ae74..831f06f02d35 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts @@ -1,3 +1,4 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -90,7 +91,7 @@ function runShellEnvironment(input: { }).pipe( Effect.provide( DesktopShellEnvironment.layer.pipe( - Layer.provide(Layer.mergeAll(environmentLayer, spawnerLayer)), + Layer.provide(Layer.mergeAll(environmentLayer, NodeServices.layer, spawnerLayer)), ), ), ); @@ -230,6 +231,7 @@ describe("DesktopShellEnvironment", () => { "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", "C:\\Users\\testuser\\AppData\\Local\\pnpm", + "C:\\Users\\testuser\\.local\\bin", "C:\\Users\\testuser\\.bun\\bin", "C:\\Users\\testuser\\scoop\\shims", "C:\\Custom\\Bin", @@ -243,6 +245,65 @@ describe("DesktopShellEnvironment", () => { }), ); + it.effect("prefers login-shell desktop session hints over inherited values on linux", () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { + SHELL: "/bin/zsh", + PATH: "/usr/bin", + XDG_CURRENT_DESKTOP: "wrong-launcher", + XDG_SESSION_DESKTOP: "wrong-launcher", + }; + + yield* runShellEnvironment({ + env, + platform: "linux", + handler: () => + envOutput({ + PATH: "/home/linuxbrew/.linuxbrew/bin:/usr/bin", + XDG_CURRENT_DESKTOP: "KDE", + XDG_SESSION_DESKTOP: "KDE", + XDG_SESSION_TYPE: "wayland", + }), + }); + + assert.equal(env.XDG_CURRENT_DESKTOP, "KDE"); + assert.equal(env.XDG_SESSION_DESKTOP, "KDE"); + assert.equal(env.XDG_SESSION_TYPE, "wayland"); + }), + ); + + it.effect("overrides stale dbus session addresses from the login shell", () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { + SHELL: "/bin/zsh", + PATH: "/usr/bin", + DBUS_SESSION_BUS_ADDRESS: "unix:path=/tmp/stale-bus", + }; + + yield* runShellEnvironment({ + env, + platform: "linux", + handler: () => + envOutput({ + PATH: "/usr/bin", + DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/1000/bus", + }), + }); + + assert.equal(env.DBUS_SESSION_BUS_ADDRESS, "unix:path=/run/user/1000/bus"); + }), + ); + + it("resolves dbus runtime dir candidates with existence checks", () => { + const busPath = DesktopShellEnvironment.resolveDefaultLinuxDbusSessionBusAddress({ + env: { XDG_RUNTIME_DIR: "/tmp/stale-runtime" }, + uid: 1000, + exists: (path) => path === "/run/user/1000/bus", + }); + + assert.equal(busPath, "unix:path=/run/user/1000/bus"); + }); + it.effect("logs command failures with safe probe context and the exact cause", () => { const env: NodeJS.ProcessEnv = { SHELL: "/bin/bash", diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index 8219f18b7a53..bd8aa6654f79 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -1,6 +1,7 @@ import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; @@ -68,12 +69,19 @@ export class DesktopShellEnvironment extends Context.Service< const LOGIN_SHELL_ENV_NAMES = [ "PATH", + "DBUS_SESSION_BUS_ADDRESS", + "DISPLAY", "SSH_AUTH_SOCK", "HOMEBREW_PREFIX", "HOMEBREW_CELLAR", "HOMEBREW_REPOSITORY", "XDG_CONFIG_HOME", + "XDG_CURRENT_DESKTOP", "XDG_DATA_HOME", + "XDG_RUNTIME_DIR", + "XDG_SESSION_DESKTOP", + "XDG_SESSION_TYPE", + "WAYLAND_DISPLAY", ] as const; const WINDOWS_PROFILE_ENV_NAMES = ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"] as const; const WINDOWS_SHELL_CANDIDATES = ["pwsh.exe", "powershell.exe"] as const; @@ -92,6 +100,47 @@ const pathDelimiter = (platform: NodeJS.Platform) => (platform === "win32" ? ";" const readEnvPath = (env: NodeJS.ProcessEnv): Option.Option => trimNonEmpty(env.PATH ?? env.Path ?? env.path); +const normalizeRuntimeDir = (value: string): string => value.replace(/\/+$/u, ""); + +const linuxRuntimeDirCandidates = ( + env: NodeJS.ProcessEnv, + uid: number | undefined, +): ReadonlyArray => { + const candidates: string[] = []; + const fromEnv = trimNonEmpty(env.XDG_RUNTIME_DIR); + if (Option.isSome(fromEnv)) { + candidates.push(normalizeRuntimeDir(fromEnv.value)); + } + if (uid !== undefined) { + candidates.push(`/run/user/${uid}`); + } + return candidates.filter((candidate) => candidate.length > 0); +}; + +function resolveDefaultLinuxDbusSessionBusPath(input: { + readonly env: NodeJS.ProcessEnv; + readonly uid: number | undefined; + readonly exists?: (path: string) => boolean; +}): string | null { + for (const runtimeDir of linuxRuntimeDirCandidates(input.env, input.uid)) { + const busPath = `${runtimeDir}/bus`; + if (input.exists === undefined || input.exists(busPath)) { + return busPath; + } + } + + return null; +} + +export function resolveDefaultLinuxDbusSessionBusAddress(input: { + readonly env: NodeJS.ProcessEnv; + readonly exists: (path: string) => boolean; + readonly uid: number | undefined; +}): string | null { + const busPath = resolveDefaultLinuxDbusSessionBusPath(input); + return busPath !== null && input.exists(busPath) ? `unix:path=${busPath}` : null; +} + const pathComparisonKey = (entry: string, platform: NodeJS.Platform) => { const normalized = entry.trim().replace(/^"+|"+$/g, ""); return platform === "win32" ? normalized.toLowerCase() : normalized; @@ -158,7 +207,7 @@ const knownWindowsCliDirs = (env: NodeJS.ProcessEnv): ReadonlyArray => [ ...trimNonEmpty(env.USERPROFILE).pipe( Option.match({ onNone: () => [], - onSome: (value) => [`${value}\\.bun\\bin`, `${value}\\scoop\\shims`], + onSome: (value) => [`${value}\\.local\\bin`, `${value}\\.bun\\bin`, `${value}\\scoop\\shims`], }), ), ]; @@ -330,10 +379,18 @@ const installWindowsEnvironment = Effect.fn("desktop.shellEnvironment.installWin function* ( config: ShellEnvironmentConfig, ): Effect.fn.Return { - const noProfile = yield* readWindowsEnvironment(["PATH"], { loadProfile: false }); - const profile = yield* readWindowsEnvironment(WINDOWS_PROFILE_ENV_NAMES, { - loadProfile: true, - }); + // Concurrent, not sequential: these two probes are independent (only their + // results are combined below) and each spawns its own PowerShell. Run in + // series they sit at offset 0 of desktop.startup, before anything else, and + // launch traces measured them at 2718ms then 2066ms — the entire 4.8s + // startup span, of which desktop.bootstrap is ~30ms. + const [noProfile, profile] = yield* Effect.all( + [ + readWindowsEnvironment(["PATH"], { loadProfile: false }), + readWindowsEnvironment(WINDOWS_PROFILE_ENV_NAMES, { loadProfile: true }), + ], + { concurrency: 2 }, + ); const mergedPath = mergePaths("win32", [ trimNonEmpty(profile.PATH), trimNonEmpty(knownWindowsCliDirs(config.env).join(";")), @@ -356,7 +413,12 @@ const installWindowsEnvironment = Effect.fn("desktop.shellEnvironment.installWin const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosixEnvironment")( function* ( config: ShellEnvironmentConfig, - ): Effect.fn.Return { + ): Effect.fn.Return< + void, + never, + ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem + > { + const fileSystem = yield* FileSystem.FileSystem; const shellEnvironment: EnvironmentPatch = {}; for (const shell of listLoginShellCandidates(config)) { @@ -383,23 +445,54 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix config.env.SSH_AUTH_SOCK = shellEnvironment.SSH_AUTH_SOCK; } + const shellPreferredEnvNames = [ + "DBUS_SESSION_BUS_ADDRESS", + "XDG_CURRENT_DESKTOP", + "XDG_SESSION_DESKTOP", + "XDG_SESSION_TYPE", + ] as const; + for (const name of shellPreferredEnvNames) { + if (shellEnvironment[name]) { + config.env[name] = shellEnvironment[name]; + } + } + for (const name of [ + "DISPLAY", "HOMEBREW_PREFIX", "HOMEBREW_CELLAR", "HOMEBREW_REPOSITORY", "XDG_CONFIG_HOME", "XDG_DATA_HOME", + "XDG_RUNTIME_DIR", + "WAYLAND_DISPLAY", ] as const) { if (!config.env[name] && shellEnvironment[name]) { config.env[name] = shellEnvironment[name]; } } + + if ( + config.platform === "linux" && + Option.isNone(trimNonEmpty(config.env.DBUS_SESSION_BUS_ADDRESS)) + ) { + for (const runtimeDir of linuxRuntimeDirCandidates(config.env, process.getuid?.())) { + const dbusSessionBusPath = `${runtimeDir}/bus`; + const busExists = yield* fileSystem + .exists(dbusSessionBusPath) + .pipe(Effect.orElseSucceed(() => false)); + if (busExists) { + config.env.DBUS_SESSION_BUS_ADDRESS = `unix:path=${dbusSessionBusPath}`; + break; + } + } + } }, ); const installShellEnvironment = ( config: ShellEnvironmentConfig, -): Effect.Effect => { +): Effect.Effect => { if (config.platform === "win32") { return installWindowsEnvironment(config); } @@ -411,6 +504,7 @@ const installShellEnvironment = ( export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const installIntoProcess: DesktopShellEnvironment["Service"]["installIntoProcess"] = installShellEnvironment({ @@ -418,6 +512,7 @@ export const make = Effect.gen(function* () { platform: environment.platform, userShell: Option.none(), }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.withSpan("desktop.shellEnvironment.installIntoProcess"), ); diff --git a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts index 36cdcb50b6ba..112c0ab350ee 100644 --- a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts +++ b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts @@ -43,6 +43,7 @@ function makeElectronAppLayer( setDesktopName: () => Effect.void, setDockIcon: () => Effect.void, appendCommandLineSwitch: () => Effect.void, + removeCommandLineSwitch: () => Effect.void, onBeforeQuitForUpdate: () => Effect.void, on: () => Effect.void, } satisfies ElectronApp.ElectronApp["Service"]); diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index f04a49f82afa..a9193f936e83 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -46,12 +46,14 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { setDockIcon: () => Effect.void, appendCommandLineSwitch: () => Effect.void, onBeforeQuitForUpdate: () => Effect.void, + removeCommandLineSwitch: () => Effect.void, on: () => Effect.void, } satisfies ElectronApp.ElectronApp["Service"]); const electronDialogLayer = Layer.succeed(ElectronDialog.ElectronDialog, { pickFolder: () => Effect.succeed(Option.none()), - confirm: () => Effect.succeed(false), + pickFiles: () => Effect.succeed([]), + pickApplication: () => Effect.succeed(Option.none()), showMessageBox: () => Effect.succeed({ response: 0, checkboxChecked: false }), showErrorBox: () => Effect.void, } satisfies ElectronDialog.ElectronDialog["Service"]); @@ -79,6 +81,8 @@ const makeDesktopWindowLayer = (selectedAction: Deferred.Deferred) => handleBackendNotReady: Effect.void, flushMainWindowBounds: Effect.void, dispatchMenuAction: (action) => Deferred.succeed(selectedAction, action).pipe(Effect.asVoid), + zoomMain: (direction) => + Deferred.succeed(selectedAction, `zoom-${direction}`).pipe(Effect.asVoid), syncAppearance: Effect.void, } satisfies DesktopWindow.DesktopWindow["Service"]); @@ -92,6 +96,30 @@ const makeElectronMenuLayer = ( showContextMenu: () => Effect.succeed(Option.none()), } satisfies ElectronMenu.ElectronMenu["Service"]); +const configureMenu = ( + selectedAction: Deferred.Deferred, + applicationMenuTemplate: Deferred.Deferred, +) => + Effect.gen(function* () { + const menu = yield* DesktopApplicationMenu.DesktopApplicationMenu; + yield* menu.configure; + }).pipe( + Effect.provide( + DesktopApplicationMenu.layer.pipe( + Layer.provideMerge(makeElectronMenuLayer(applicationMenuTemplate)), + Layer.provideMerge(makeDesktopWindowLayer(selectedAction)), + Layer.provideMerge(desktopUpdatesLayer), + Layer.provideMerge(electronDialogLayer), + Layer.provideMerge(electronAppLayer), + Layer.provideMerge( + DesktopEnvironment.layer(environmentInput).pipe( + Layer.provide(Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({}))), + ), + ), + ), + ), + ); + describe("DesktopApplicationMenu", () => { it.effect("installs the native menu and routes Settings through DesktopWindow", () => Effect.gen(function* () { @@ -99,25 +127,7 @@ describe("DesktopApplicationMenu", () => { const applicationMenuTemplate = yield* Deferred.make(); - yield* Effect.gen(function* () { - const menu = yield* DesktopApplicationMenu.DesktopApplicationMenu; - yield* menu.configure; - }).pipe( - Effect.provide( - DesktopApplicationMenu.layer.pipe( - Layer.provideMerge(makeElectronMenuLayer(applicationMenuTemplate)), - Layer.provideMerge(makeDesktopWindowLayer(selectedAction)), - Layer.provideMerge(desktopUpdatesLayer), - Layer.provideMerge(electronDialogLayer), - Layer.provideMerge(electronAppLayer), - Layer.provideMerge( - DesktopEnvironment.layer(environmentInput).pipe( - Layer.provide(Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({}))), - ), - ), - ), - ), - ); + yield* configureMenu(selectedAction, applicationMenuTemplate); const template = yield* Deferred.await(applicationMenuTemplate); const fileMenu = template.find((item) => item.label === "File"); @@ -136,4 +146,38 @@ describe("DesktopApplicationMenu", () => { assert.equal(yield* Deferred.await(selectedAction), "open-settings"); }), ); + + // Zoom must route through DesktopWindow.zoomMain instead of the Electron + // zoom roles: the roles zoom whichever webContents has focus, which breaks + // app zoom while an embedded preview WebContentsView holds focus. + it.effect("routes View menu zoom to the main window instead of zoom roles", () => + Effect.gen(function* () { + const selectedAction = yield* Deferred.make(); + const applicationMenuTemplate = + yield* Deferred.make(); + + yield* configureMenu(selectedAction, applicationMenuTemplate); + + const template = yield* Deferred.await(applicationMenuTemplate); + const viewMenu = template.find((item) => item.label === "View"); + assert.isDefined(viewMenu); + if (!Array.isArray(viewMenu.submenu)) { + throw new Error("Expected View menu submenu to be an array."); + } + + assert.isUndefined( + viewMenu.submenu.find((item) => item.role?.toLowerCase().includes("zoom")), + ); + + const zoomIn = viewMenu.submenu.find((item) => item.label === "Zoom In"); + assert.isDefined(zoomIn); + assert.equal(zoomIn.accelerator, "CmdOrCtrl+="); + if (typeof zoomIn.click !== "function") { + throw new Error("Expected Zoom In menu item to have a click handler."); + } + + zoomIn.click({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent); + assert.equal(yield* Deferred.await(selectedAction), "zoom-in"); + }), + ); }); diff --git a/apps/desktop/src/window/DesktopApplicationMenu.ts b/apps/desktop/src/window/DesktopApplicationMenu.ts index a52707627b0a..66244534debf 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.ts @@ -49,6 +49,13 @@ const dispatchMenuAction = Effect.fn("desktop.menu.dispatchMenuAction")(function yield* desktopWindow.dispatchMenuAction(action); }); +const zoomMainWindow = Effect.fn("desktop.menu.zoomMainWindow")(function* ( + direction: DesktopWindow.MainWindowZoomDirection, +): Effect.fn.Return { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.zoomMain(direction); +}); + const checkForUpdatesFromMenu = Effect.gen(function* () { const updates = yield* DesktopUpdates.DesktopUpdates; const electronDialog = yield* ElectronDialog.ElectronDialog; @@ -127,6 +134,9 @@ export const make = Effect.gen(function* () { const settingsClick = () => { runMenuEffect("open-settings", dispatchMenuAction("open-settings")); }; + const zoomClick = (direction: DesktopWindow.MainWindowZoomDirection) => () => { + runMenuEffect(`zoom-${direction}`, zoomMainWindow(direction)); + }; const template: Electron.MenuItemConstructorOptions[] = []; if (environment.platform === "darwin") { @@ -181,10 +191,21 @@ export const make = Effect.gen(function* () { { role: "forceReload" }, { role: "toggleDevTools" }, { type: "separator" }, - { role: "resetZoom" }, - { role: "zoomIn", accelerator: "CmdOrCtrl+=" }, - { role: "zoomIn", accelerator: "CmdOrCtrl+Plus", visible: false }, - { role: "zoomOut" }, + /* + Not the zoom roles: those act on the focused webContents, so with + an embedded preview WebContentsView focused they zoom the guest + page and the app UI appears stuck. These always zoom the main + window (see DesktopWindow.zoomMain). + */ + { label: "Actual Size", accelerator: "CmdOrCtrl+0", click: zoomClick("reset") }, + { label: "Zoom In", accelerator: "CmdOrCtrl+=", click: zoomClick("in") }, + { + label: "Zoom In", + accelerator: "CmdOrCtrl+Plus", + visible: false, + click: zoomClick("in"), + }, + { label: "Zoom Out", accelerator: "CmdOrCtrl+-", click: zoomClick("out") }, { type: "separator" }, { role: "togglefullscreen" }, ], diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 3cd06cfcf32e..3aedd2ea6c0e 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -438,6 +438,51 @@ describe("DesktopWindow", () => { }), ); + it.effect("blocks only repeated Cmd+W input before it reaches the native window menu", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const beforeInput = fakeWindow.webContentsListeners.get("before-input-event"); + if (!beforeInput) { + return yield* Effect.die("before-input-event listener was not registered"); + } + + let prevented = false; + const event = { preventDefault: () => (prevented = true) }; + const input = { + type: "keyDown", + isAutoRepeat: true, + key: "W", + meta: true, + control: false, + alt: false, + shift: false, + }; + beforeInput(event, input); + assert.isTrue(prevented); + + prevented = false; + beforeInput(event, { ...input, isAutoRepeat: false }); + assert.isFalse(prevented); + + prevented = false; + beforeInput(event, { ...input, meta: false }); + assert.isFalse(prevented); + }).pipe(Effect.provide(layer)); + }), + ); + it.effect("uses the persisted main window bounds when opening the window", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 40788009d3b3..bf8c681448fe 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -1,3 +1,4 @@ +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -25,6 +26,12 @@ const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937"; const TITLEBAR_DARK_SYMBOL_COLOR = "#f8fafc"; const MAIN_WINDOW_BOUNDS_PERSIST_DEBOUNCE_MS = 500; const DEVELOPMENT_LOAD_RETRY_DELAYS_MS = [100, 250, 500, 1_000, 2_000] as const; +// Renderer crash (usually V8 OOM on long sessions) recovery: reload after a +// short delay, at most MAX_ATTEMPTS times per rolling WINDOW so a renderer +// that dies on boot cannot reload-loop forever. +const RENDERER_RECOVERY_RELOAD_DELAY_MS = 500; +const RENDERER_RECOVERY_MAX_ATTEMPTS = 3; +const RENDERER_RECOVERY_WINDOW_MS = 60_000; const DEVELOPMENT_RETRYABLE_LOAD_ERROR_CODES = new Set([ -2, // ERR_FAILED -7, // ERR_TIMED_OUT @@ -54,6 +61,8 @@ export type DesktopWindowError = | ElectronWindow.ElectronWindowCreateError | PreviewManager.PreviewManagerError; +export type MainWindowZoomDirection = "in" | "out" | "reset"; + export class DesktopWindow extends Context.Service< DesktopWindow, { @@ -80,6 +89,12 @@ export class DesktopWindow extends Context.Service< readonly handleBackendNotReady: Effect.Effect; readonly flushMainWindowBounds: Effect.Effect; readonly dispatchMenuAction: (action: string) => Effect.Effect; + // Zooms the main window's own webContents. The Electron `zoomIn`/`zoomOut` + // menu roles act on whichever webContents has keyboard focus, so with an + // embedded preview WebContentsView (or DevTools) focused they zoom the + // guest page instead of the app UI. The menu routes here to always target + // the main window. + readonly zoomMain: (direction: MainWindowZoomDirection) => Effect.Effect; readonly syncAppearance: Effect.Effect; } >()("@t3tools/desktop/window/DesktopWindow") {} @@ -514,6 +529,18 @@ export const make = Effect.gen(function* () { } }); + // Electron's windowMenu close role owns CmdOrCtrl+W. Holding the + // close-terminal shortcut can outlive the terminal that handled its first + // press, so reject repeats before they reach the native window accelerator. + // Deliberate presses still flow through the renderer or native menu. + window.webContents.on("before-input-event", (event, input) => { + if (input.type !== "keyDown" || !input.isAutoRepeat) return; + const modifier = environment.platform === "darwin" ? input.meta : input.control; + if (modifier && !input.alt && !input.shift && input.key.toLowerCase() === "w") { + event.preventDefault(); + } + }); + window.on("page-title-updated", (event) => { event.preventDefault(); window.setTitle(environment.displayName); @@ -537,6 +564,7 @@ export const make = Effect.gen(function* () { let developmentLoadRetryIndex = 0; let developmentLoadRetryFiber: Fiber.Fiber | undefined; + let rendererRecoveryTimestamps: number[] = []; const clearDevelopmentLoadRetry = () => { if (developmentLoadRetryFiber === undefined) { return; @@ -618,10 +646,39 @@ export const make = Effect.gen(function* () { }, ); window.webContents.on("render-process-gone", (_event, details) => { - void runPromise( - logWindowWarning("main window render process gone", { - reason: details.reason, - exitCode: details.exitCode, + const recoverable = + details.reason === "crashed" || + details.reason === "oom" || + details.reason === "abnormal-exit"; + // Long sessions can OOM the renderer (V8 heap exhaustion from + // accumulated thread state). Without a reload the user is left staring + // at a dead white window while agents keep running invisibly, so + // recover by reloading — the renderer rehydrates from the backend, + // which is unaffected. Recovery attempts are bounded so a renderer + // that dies immediately on boot cannot reload-loop forever. + runFork( + Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis; + rendererRecoveryTimestamps = rendererRecoveryTimestamps.filter( + (timestamp) => now - timestamp < RENDERER_RECOVERY_WINDOW_MS, + ); + const shouldRecover = + recoverable && + !window.isDestroyed() && + rendererRecoveryTimestamps.length < RENDERER_RECOVERY_MAX_ATTEMPTS; + yield* logWindowWarning("main window render process gone", { + reason: details.reason, + exitCode: details.exitCode, + recovering: shouldRecover, + }); + if (!shouldRecover) { + return; + } + rendererRecoveryTimestamps.push(now); + yield* Effect.sleep(RENDERER_RECOVERY_RELOAD_DELAY_MS); + if (!window.isDestroyed()) { + loadApplication(); + } }), ); }); @@ -787,6 +844,18 @@ export const make = Effect.gen(function* () { send(); }), + zoomMain: Effect.fn("desktop.window.zoomMain")(function* (direction) { + yield* Effect.annotateCurrentSpan({ direction }); + const window = yield* focusedMainWindow; + if (Option.isNone(window) || window.value.isDestroyed()) { + return; + } + const webContents = window.value.webContents; + // Same step size as the Electron zoomIn/zoomOut menu roles. + webContents.setZoomLevel( + direction === "reset" ? 0 : webContents.getZoomLevel() + (direction === "in" ? 0.5 : -0.5), + ); + }), syncAppearance: Effect.gen(function* () { const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; yield* electronWindow.syncAllAppearance((window) => diff --git a/apps/marketing/src/pages/download.astro b/apps/marketing/src/pages/download.astro index 111482208cfa..5557f5fb6b19 100644 --- a/apps/marketing/src/pages/download.astro +++ b/apps/marketing/src/pages/download.astro @@ -79,7 +79,7 @@ import { ANDROID_PLAY_STORE_URL, IOS_APP_STORE_URL } from "../lib/site";

${"pokopia companion guide ".repeat(24)}

\n`; + yield* writeTextFile( + cwd, + "index.html", + `guide\n${filler.repeat(1200)}`, + ); + + const startedAt = performance.now(); + const resolved = yield* resolver.resolvePath(cwd); + const elapsedMs = performance.now() - startedAt; + + expect(resolved).toBeNull(); + expect(elapsedMs).toBeLessThan(5_000); + }), + ); + it.effect("returns null when no icon is present", () => Effect.gen(function* () { const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; diff --git a/apps/server/src/project/ProjectFaviconResolver.ts b/apps/server/src/project/ProjectFaviconResolver.ts index 2c7195de630b..458954daed4b 100644 --- a/apps/server/src/project/ProjectFaviconResolver.ts +++ b/apps/server/src/project/ProjectFaviconResolver.ts @@ -55,10 +55,13 @@ const ICON_SOURCE_FILES = [ ] as const; // Matches tags or object-like icon metadata where rel/href can appear in any order. +// The tag pattern is anchored on `]*\brel=["'](?:icon|shortcut icon)["'])(?=[^>]*\bhref=["']([^"'?]+))[^>]*>/i; -const LINK_ICON_OBJ_RE = - /(?=[^}]*\brel\s*:\s*["'](?:icon|shortcut icon)["'])(?=[^}]*\bhref\s*:\s*["']([^"'?]+))[^}]*/i; +const ICON_REL_RE = /\brel\s*:\s*["'](?:icon|shortcut icon)["']/i; +const ICON_HREF_RE = /\bhref\s*:\s*["']([^"'?]+)/i; export class ProjectFaviconResolutionError extends Schema.TaggedErrorClass()( "ProjectFaviconResolutionError", @@ -91,6 +94,7 @@ export class ProjectFaviconResolver extends Context.Service< */ readonly resolvePath: ( cwd: string, + faviconPath?: string, ) => Effect.Effect; } >()("t3/project/ProjectFaviconResolver") {} @@ -98,8 +102,13 @@ export class ProjectFaviconResolver extends Context.Service< function extractIconHref(source: string): string | null { const htmlMatch = source.match(LINK_ICON_HTML_RE); if (htmlMatch?.[1]) return htmlMatch[1]; - const objMatch = source.match(LINK_ICON_OBJ_RE); - if (objMatch?.[1]) return objMatch[1]; + // Icon metadata counts when `rel` and `href` share a brace-free run, so a run holding `rel` + // but no href falls through to the next one rather than ending the search. + for (const run of source.split("}")) { + if (!ICON_REL_RE.test(run)) continue; + const hrefMatch = run.match(ICON_HREF_RE); + if (hrefMatch?.[1]) return hrefMatch[1]; + } return null; } @@ -168,7 +177,7 @@ export const make = Effect.gen(function* () { const resolvePath: ProjectFaviconResolver["Service"]["resolvePath"] = Effect.fn( "ProjectFaviconResolver.resolvePath", - )(function* (cwd) { + )(function* (cwd, faviconPath) { const projectCwd = yield* workspacePaths.normalizeWorkspaceRoot(cwd).pipe( Effect.mapError( (cause) => @@ -179,6 +188,15 @@ export const make = Effect.gen(function* () { }), ), ); + // A grouped project's saved path can be absent from one checkout. Use it + // where it exists and retain automatic discovery for the other checkouts. + if (faviconPath !== undefined) { + const existing = yield* findExistingFile(projectCwd, [faviconPath]); + if (existing) { + return existing; + } + } + // A t3.json iconPath takes precedence over the well-known locations. const projectFile = yield* projectFileLoader.load(projectCwd); if (Option.isSome(projectFile) && projectFile.value.iconPath !== undefined) { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 760f0e7fbab2..711b0f6f6aa3 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -55,6 +55,7 @@ class FakeClaudeQuery implements AsyncIterable { private failure: unknown | undefined; public readonly interruptCalls: Array = []; + public readonly stopTaskCalls: Array = []; public readonly setModelCalls: Array = []; public readonly setPermissionModeCalls: Array = []; public readonly setMaxThinkingTokensCalls: Array = []; @@ -98,6 +99,10 @@ class FakeClaudeQuery implements AsyncIterable { this.interruptCalls.push(undefined); }; + readonly stopTask = async (taskId: string): Promise => { + this.stopTaskCalls.push(taskId); + }; + readonly setModel = async (model?: string): Promise => { this.setModelCalls.push(model); }; @@ -973,6 +978,75 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("does not emit turn.completed for a result with no active turn", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + // Collect through session.exited so the window after the second result + // is deterministically inside the collection: both results are queued + // after sendTurn returns and drain in order on the one stream consumer. + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.takeUntil((event) => event.type === "session.exited"), + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + const turn = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "hello", + attachments: [], + }); + + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + num_turns: 1, + session_id: "sdk-session-1", + uuid: "result-real", + } as unknown as SDKMessage); + + // Second result with no turn in flight — the shape the resume + // handshake (system/init + result(num_turns: 0)) delivers, and the + // same completeTurn branch every no-turnState result lands in. This + // used to emit an untargeted turn.completed; it must emit nothing. + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + num_turns: 0, + usage: { input_tokens: 0, output_tokens: 0 }, + session_id: "sdk-session-1", + uuid: "result-handshake", + } as unknown as SDKMessage); + + harness.query.finish(); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + const completions = runtimeEvents.filter((event) => event.type === "turn.completed"); + // Exactly one completion — the real turn's, targeted at its turn id. + // The buggy branch produced a second, untargeted one here. + assert.equal(completions.length, 1); + const completed = completions[0]; + if (completed?.type === "turn.completed") { + assert.equal(String(completed.turnId), String(turn.turnId)); + assert.equal(completed.payload.state, "completed"); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("steers a running turn instead of opening a new one on mid-turn sendTurn", () => { const harness = makeHarness(); return Effect.gen(function* () { @@ -1445,6 +1519,321 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("treats aborted_tools results as interrupted and hides ede_diagnostic errors", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 6).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + const turn = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "hello", + attachments: [], + }); + + // Exact shape the CLI emits when Stop lands mid-tool-call: is_error + // is true and the only error is internal diagnostic telemetry. + harness.query.emit({ + type: "result", + subtype: "error_during_execution", + is_error: true, + errors: ["[ede_diagnostic] result_type=user last_content_type=n/a stop_reason=tool_use"], + stop_reason: "tool_use", + terminal_reason: "aborted_tools", + session_id: "sdk-session-abort-tools", + uuid: "result-abort-tools", + } as unknown as SDKMessage); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + assert.deepEqual( + runtimeEvents.map((event) => event.type), + [ + "session.started", + "session.configured", + "session.state.changed", + "turn.started", + "thread.started", + "turn.completed", + ], + ); + + const turnCompleted = runtimeEvents[runtimeEvents.length - 1]; + assert.equal(turnCompleted?.type, "turn.completed"); + if (turnCompleted?.type === "turn.completed") { + assert.equal(String(turnCompleted.turnId), String(turn.turnId)); + assert.equal(turnCompleted.payload.state, "interrupted"); + assert.equal(turnCompleted.payload.errorMessage, undefined); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("interruptTurn settles every acknowledged live task before interrupting", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + // Wait for the three task.* runtime events to prove the lifecycle + // handlers processed the emissions (no wall-clock sleeps under the + // test clock). + const taskEventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.type.startsWith("task.")), + Stream.take(3), + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "spawn agents", + attachments: [], + }); + + harness.query.emit({ + type: "system", + subtype: "task_started", + task_id: "task-live", + description: "Agent A", + task_type: "local_agent", + uuid: "task-live-uuid", + session_id: "sdk-session", + } as unknown as SDKMessage); + harness.query.emit({ + type: "system", + subtype: "task_started", + task_id: "task-settled", + description: "Agent B", + task_type: "local_agent", + uuid: "task-settled-uuid", + session_id: "sdk-session", + } as unknown as SDKMessage); + harness.query.emit({ + type: "system", + subtype: "task_notification", + task_id: "task-settled", + status: "completed", + output_file: "/tmp/task-settled.jsonl", + summary: "done", + uuid: "task-settled-done-uuid", + session_id: "sdk-session", + } as unknown as SDKMessage); + + yield* Fiber.join(taskEventsFiber); + + const stoppedTaskEventFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.type === "task.completed"), + Stream.take(1), + Stream.runCollect, + Effect.forkChild, + ); + yield* adapter.interruptTurn(session.threadId); + + // Only the still-live task is stopped; interrupt always fires after. + assert.deepEqual(harness.query.stopTaskCalls, ["task-live"]); + assert.equal(harness.query.interruptCalls.length, 1); + + const stoppedTaskEvents = Array.from(yield* Fiber.join(stoppedTaskEventFiber)); + assert.equal(stoppedTaskEvents.length, 1); + const stoppedTaskEvent = stoppedTaskEvents[0]; + assert.equal(stoppedTaskEvent?.type, "task.completed"); + if (stoppedTaskEvent?.type === "task.completed") { + assert.equal(String(stoppedTaskEvent.payload.taskId), "task-live"); + assert.equal(stoppedTaskEvent.payload.status, "stopped"); + assert.equal(stoppedTaskEvent.payload.taskType, "local_agent"); + assert.equal(stoppedTaskEvent.payload.title, "Agent A"); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("workflow member coalescing: identical snapshots suppress, changes emit", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + // Collect task.progress until member-0's tick-3 emission lands, then + // evaluate member emissions. + const progressFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.type === "task.progress"), + Stream.takeUntil( + // Sentinel: member-0's tick-3 emission (tokens 20) — members are + // emitted after the coordinator row within a tick. + (event) => + (event.payload as { taskId?: string }).taskId === "wf-coalesce:wf:0" && + (event.payload as { typedUsage?: { totalTokens?: number } }).typedUsage?.totalTokens === + 20, + ), + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "run workflow", + attachments: [], + }); + + const memberSnapshot = (tokens: number) => [ + { type: "workflow_phase", index: 0, title: "Work" }, + { + type: "workflow_agent", + index: 0, + state: "running", + label: "member-0", + phaseIndex: 0, + tokens, + }, + { + type: "workflow_agent", + index: 1, + state: "running", + label: "member-1", + phaseIndex: 0, + tokens: 50, + }, + ]; + const tick = (usageTotal: number, snapshot: ReturnType) => + harness.query.emit({ + type: "system", + subtype: "task_progress", + task_id: "wf-coalesce", + description: "Coalescing workflow", + usage: { total_tokens: usageTotal, tool_uses: 1, duration_ms: 10 }, + workflow_progress: snapshot, + uuid: `wf-tick-${usageTotal}`, + session_id: "sdk-session", + } as unknown as SDKMessage); + + // Tick 1: both members are new -> 2 member events. + tick(100, memberSnapshot(10)); + // Tick 2: IDENTICAL member snapshot -> 0 member events (coordinator + // usage changed, but members did not). + tick(200, memberSnapshot(10)); + // Tick 3: member-0's tokens advanced -> exactly 1 member event. + tick(300, memberSnapshot(20)); + + const progressEvents = Array.from(yield* Fiber.join(progressFiber)); + const byMember = new Map(); + for (const event of progressEvents) { + const taskId = (event.payload as { taskId: string }).taskId; + if (!taskId.includes(":wf:")) continue; + byMember.set(taskId, (byMember.get(taskId) ?? 0) + 1); + } + // member-0: tick 1 + tick 3. member-1: tick 1 only (tick 2 identical, + // tick 3 unchanged). + assert.equal(byMember.get("wf-coalesce:wf:0"), 2); + assert.equal(byMember.get("wf-coalesce:wf:1"), 1); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("task.started carries model/effort; subagent snapshots refine the model", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const taskEventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.type.startsWith("task.")), + Stream.take(2), + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-opus-4-6", + [{ id: "effort", value: "max" }], + ), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "spawn an agent", + attachments: [], + }); + + // No explicit model/effort on the launch input: the task inherits the + // session's selection. + harness.query.emit({ + type: "system", + subtype: "task_started", + task_id: "task-model", + description: "Agent M", + task_type: "local_agent", + tool_use_id: "toolu_agent_m", + uuid: "task-model-uuid", + session_id: "sdk-session", + } as unknown as SDKMessage); + // The subagent's assistant snapshot carries the authoritative API + // model id, which refines the linkage on later rows. + harness.query.emit({ + type: "assistant", + parent_tool_use_id: "toolu_agent_m", + message: { + model: "claude-sonnet-5[1m]", + content: [], + }, + uuid: "subagent-snapshot-uuid", + session_id: "sdk-session", + } as unknown as SDKMessage); + harness.query.emit({ + type: "system", + subtype: "task_progress", + task_id: "task-model", + description: "Agent M", + usage: { total_tokens: 100, tool_uses: 1, duration_ms: 10 }, + uuid: "task-model-progress-uuid", + session_id: "sdk-session", + } as unknown as SDKMessage); + + const taskEvents = Array.from(yield* Fiber.join(taskEventsFiber)); + const started = taskEvents[0]; + assert.equal(started?.type, "task.started"); + if (started?.type === "task.started") { + assert.equal(started.payload.model, "claude-opus-4-6"); + assert.equal(started.payload.effort, "max"); + } + const progress = taskEvents[1]; + assert.equal(progress?.type, "task.progress"); + if (progress?.type === "task.progress") { + assert.equal(progress.payload.model, "claude-sonnet-5[1m]"); + assert.equal(progress.payload.effort, "max"); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("closes the session when the Claude stream aborts after a turn starts", () => { const harness = makeHarness(); return Effect.gen(function* () { @@ -1779,6 +2168,24 @@ describe("ClaudeAdapterLive", () => { session_id: "session", uuid: "roster", }, + { + type: "system", + subtype: "vcs_state_changed", + kind: "push", + cwd: "/tmp/worktree", + session_id: "session", + uuid: "vcs", + }, + { + type: "system", + subtype: "code_change_published", + provider: "github", + url: "https://github.com/pingdotgg/t3code/pull/1", + repo: "pingdotgg/t3code", + identifier: "1", + session_id: "session", + uuid: "ccp", + }, { type: "system", subtype: "task_updated", diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index f87d5be74469..b1fa74e81bda 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -42,6 +42,10 @@ import { RuntimeItemId, RuntimeRequestId, RuntimeTaskId, + type RuntimeTaskStatus, + type RuntimeTaskUsage, + type TaskAgentLinkage, + type TaskRunHandles, ThreadId, TurnId, type UserInputQuestion, @@ -61,6 +65,7 @@ import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; @@ -90,8 +95,8 @@ import { } from "../Errors.ts"; import { type ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; -const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.UnknownFromJsonString); -const decodeUnknownJsonStringExit = Schema.decodeUnknownExit(Schema.UnknownFromJsonString); +const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); +const decodeUnknownJsonStringExit = Schema.decodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); const PROVIDER = ProviderDriverKind.make("claudeAgent"); type ClaudeTextStreamKind = Extract; @@ -169,6 +174,9 @@ interface ToolInFlight { readonly input: Record; readonly partialInputJson: string; readonly lastEmittedInputFingerprint?: string; + /** Owning agent when this tool ran inside a subagent (see attribution note). */ + readonly agentId?: string; + readonly parentToolUseId?: string; } interface ClaudeTaskState { @@ -178,6 +186,28 @@ interface ClaudeTaskState { readonly blockedBy: Set; } +/** + * Agent identity captured from task_started and repeated on every subsequent + * task.* payload, so client folds can reconstruct an agent even when its + * start row aged out of activity retention. + */ +interface ClaudeTaskAgentState { + readonly taskId: string; + toolUseId: string | undefined; + description: string | undefined; + subagentType: string | undefined; + taskType: string | undefined; + workflowName: string | undefined; + skipTranscript: boolean; + runHandles: TaskRunHandles | undefined; + /** Set when this task was launched from inside a subagent. */ + owningAgentId: string | undefined; + /** Seeded from the launching tool's input; refined by the subagent's own + * assistant snapshots (authoritative API model). */ + model: string | undefined; + effort: string | undefined; +} + interface ClaudeSessionContext { session: ProviderSession; readonly promptQueue: Queue.Queue; @@ -186,6 +216,9 @@ interface ClaudeSessionContext { readonly startedAt: string; readonly basePermissionMode: PermissionMode | undefined; currentApiModelId: string | undefined; + /** Effective effort for the session's turns; subagents without an explicit + * effort override inherit this. */ + currentEffort: string | undefined; resumeSessionId: string | undefined; readonly pendingApprovals: Map; readonly pendingUserInputs: Map; @@ -195,6 +228,17 @@ interface ClaudeSessionContext { }>; readonly inFlightTools: Map; readonly claudeTasks: Map; + readonly taskAgents: Map; + /** + * Last emitted workflow-member fingerprint per member slot. A coordinator + * task_progress repeats the FULL member array every tick; without a + * material-transition filter one provider tick fans out into up to 100 + * runtime events (event-log writes, queue pressure, client reducer work) + * even when nothing changed for most members. + */ + readonly workflowMemberFingerprints: Map; + /** Task ids that have started and not yet reached a terminal state. */ + readonly liveTaskIds: Set; turnState: ClaudeTurnState | undefined; lastKnownContextWindow: number | undefined; lastKnownTokenUsage: ThreadTokenUsageSnapshot | undefined; @@ -206,6 +250,8 @@ interface ClaudeSessionContext { interface ClaudeQueryRuntime extends AsyncIterable { readonly interrupt: () => Promise; + /** SDK Query.stopTask — present on real queries; optional for test doubles. */ + readonly stopTask?: (taskId: string) => Promise; readonly setModel: (model?: string) => Promise; readonly setPermissionMode: (mode: PermissionMode) => Promise; readonly setMaxThinkingTokens: (maxThinkingTokens: number | null) => Promise; @@ -303,7 +349,29 @@ function resultErrorsText(result: SDKResultMessage): string { : ""; } +/** + * First user-facing error from a non-success result. "[ede_diagnostic] ..." + * entries are CLI-internal telemetry (the CLI hides them from its own UI too), + * so they must never become the error banner. + */ +function resultUserFacingError(result: SDKResultMessage): string | undefined { + if (result.subtype === "success" || !Array.isArray(result.errors)) { + return undefined; + } + return result.errors.find((error) => !error.startsWith("[ede_diagnostic]")); +} + function isInterruptedResult(result: SDKResultMessage): boolean { + // The CLI stamps user aborts explicitly: interrupting mid-tool-call yields + // "aborted_tools" (with an internal "[ede_diagnostic] ..." error and + // is_error: true), interrupting mid-stream yields "aborted_streaming". + if ( + result.terminal_reason === "aborted_tools" || + result.terminal_reason === "aborted_streaming" + ) { + return true; + } + const errors = resultErrorsText(result); if (errors.includes("interrupt")) { return true; @@ -826,6 +894,225 @@ function planStepsFromClaudeTasks(tasks: Map): PlanStep }); } +/** Only http/https survive; anything else (javascript:, file:, …) is dropped. */ +function sanitizeSessionUrl(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + if (!/^https?:\/\//i.test(trimmed)) { + return undefined; + } + return trimmed; +} + +function nonNegativeInt(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) && value >= 0 + ? Math.floor(value) + : undefined; +} + +function trimmedString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +/** + * SDK task usage ({total_tokens, tool_uses, duration_ms}, sometimes with + * input/output/cache breakdowns) → the typed contract shape. Unknown or + * malformed input yields undefined rather than a partial guess. + */ +function normalizeTaskUsage(usage: unknown): RuntimeTaskUsage | undefined { + if (typeof usage !== "object" || usage === null) { + return undefined; + } + const record = usage as Record; + const totalTokens = nonNegativeInt(record.total_tokens); + if (totalTokens === undefined) { + return undefined; + } + const inputTokens = nonNegativeInt(record.input_tokens); + const cachedInputTokens = nonNegativeInt(record.cache_read_input_tokens); + const outputTokens = nonNegativeInt(record.output_tokens); + const toolUses = nonNegativeInt(record.tool_uses); + const durationMs = nonNegativeInt(record.duration_ms); + return { + totalTokens, + ...(inputTokens !== undefined ? { inputTokens } : {}), + ...(cachedInputTokens !== undefined ? { cachedInputTokens } : {}), + ...(outputTokens !== undefined ? { outputTokens } : {}), + ...(toolUses !== undefined ? { toolUses } : {}), + ...(durationMs !== undefined ? { durationMs } : {}), + }; +} + +/** SDK task_updated patch status → the shared wire vocabulary. */ +const CLAUDE_TASK_PATCH_STATUS: Record = { + pending: "pending", + running: "running", + completed: "completed", + failed: "failed", + killed: "cancelled", + paused: "idle", +}; + +/** + * Resolves a stream message's parent_tool_use_id to the owning agent's + * taskId. The Task tool's tool_use_id is remembered on task_started; any + * subagent-forwarded block carries that id as its parent. Returns undefined + * for parent-conversation traffic. + */ +function agentIdForParentToolUse( + agents: Map, + parentToolUseId: string | null | undefined, +): string | undefined { + if (parentToolUseId === null || parentToolUseId === undefined) { + return undefined; + } + for (const agent of agents.values()) { + if (agent.toolUseId === parentToolUseId) { + return agent.taskId; + } + } + return undefined; +} + +/** + * Linkage bundle repeated on every task.* payload for `taskId`. Reads the + * remembered identity (from task_started) so progress/terminal rows are + * self-describing even when the start row ages out of activity retention. + */ +function taskLinkageFor( + agents: Map, + taskId: string, +): TaskAgentLinkage { + const agent = agents.get(taskId); + if (!agent) { + return {}; + } + return { + ...(agent.taskType ? { taskType: agent.taskType } : {}), + ...(agent.owningAgentId ? { agentId: agent.owningAgentId } : {}), + ...(agent.description ? { title: agent.description } : {}), + ...(agent.subagentType ? { role: agent.subagentType } : {}), + ...(agent.model ? { model: agent.model } : {}), + ...(agent.effort ? { effort: agent.effort } : {}), + ...(agent.toolUseId ? { toolUseId: agent.toolUseId } : {}), + ...(agent.workflowName ? { workflowName: agent.workflowName } : {}), + ...(agent.runHandles ? { runHandles: agent.runHandles } : {}), + }; +} + +const WORKFLOW_PHASE_CAP = 64; +const WORKFLOW_AGENT_CAP = 100; + +interface ClaudeWorkflowAgentEntry { + readonly index: number; + readonly state: string; + readonly label: string | undefined; + readonly phaseIndex: number | undefined; + readonly phaseTitle: string | undefined; + readonly model: string | undefined; + readonly attempt: number | undefined; + readonly lastToolName: string | undefined; + readonly startedAt: string | undefined; + readonly error: string | undefined; + readonly tokens: number | undefined; + readonly toolCalls: number | undefined; +} + +interface ClaudeWorkflowProgress { + readonly phases: ReadonlyArray<{ index: number; title: string }>; + readonly agents: ReadonlyArray; +} + +/** + * Defensive parse of the SDK's undeclared-but-real workflow_progress array on + * task_progress messages (wire-confirmed; absent from sdk.d.ts). Unknown + * shapes are skipped per-entry; phases and agents dedupe by index before + * caps; a vanished field never throws. If the array disappears upstream the + * caller keeps the coordinator row and plain task lifecycle. + */ +function parseWorkflowProgress(value: unknown): ClaudeWorkflowProgress | undefined { + if (!Array.isArray(value) || value.length === 0) { + return undefined; + } + const phasesByIndex = new Map(); + const agentsByIndex = new Map(); + for (const entry of value) { + if (typeof entry !== "object" || entry === null) { + continue; + } + const record = entry as Record; + const entryType = trimmedString(record.type); + if (entryType === "workflow_phase") { + const index = nonNegativeInt(record.index); + const title = trimmedString(record.title); + if (index !== undefined && title && !phasesByIndex.has(index)) { + phasesByIndex.set(index, title); + } + continue; + } + if (entryType !== "workflow_agent") { + continue; + } + const index = nonNegativeInt(record.index); + const state = trimmedString(record.state); + if (index === undefined || !state || agentsByIndex.has(index)) { + continue; + } + agentsByIndex.set(index, { + index, + state, + label: trimmedString(record.label), + phaseIndex: nonNegativeInt(record.phaseIndex), + phaseTitle: trimmedString(record.phaseTitle), + model: trimmedString(record.model), + attempt: nonNegativeInt(record.attempt), + lastToolName: trimmedString(record.lastToolName), + startedAt: trimmedString(record.startedAt), + error: trimmedString(record.error), + tokens: nonNegativeInt(record.tokens), + toolCalls: nonNegativeInt(record.toolCalls), + }); + } + if (phasesByIndex.size === 0 && agentsByIndex.size === 0) { + return undefined; + } + const phases = Array.from(phasesByIndex.entries()) + .map(([index, title]) => ({ index, title })) + .toSorted((a, b) => a.index - b.index) + .slice(0, WORKFLOW_PHASE_CAP); + const agents = Array.from(agentsByIndex.values()) + .toSorted((a, b) => a.index - b.index) + .slice(0, WORKFLOW_AGENT_CAP); + return { phases, agents }; +} + +/** + * Workflow member states from workflow_progress → shared task status. + * Unknown states read running after startedAt, pending before it. + */ +function workflowAgentStatus(entry: ClaudeWorkflowAgentEntry): RuntimeTaskStatus { + switch (entry.state) { + case "queued": + case "pending": + return "pending"; + case "start": + case "running": + return entry.startedAt === undefined ? "pending" : "running"; + case "done": + return "completed"; + case "error": + return "failed"; + default: + return entry.startedAt === undefined ? "pending" : "running"; + } +} + function summarizeToolRequest(toolName: string, input: Record): string { const commandValue = input.command ?? input.cmd; const command = typeof commandValue === "string" ? commandValue : undefined; @@ -833,17 +1120,17 @@ function summarizeToolRequest(toolName: string, input: Record): return `${toolName}: ${command.trim().slice(0, 400)}`; } - // For agent/subagent tools, prefer human-readable description or prompt over raw JSON + // For agent/subagent tools, prefer the human-readable description or prompt + // over raw JSON. The structured subagent_type is carried separately on the + // task.* payloads (role) — the label is display-only. const itemType = classifyToolItemType(toolName); if (itemType === "collab_agent_tool_call") { const description = typeof input.description === "string" ? input.description.trim() : undefined; const prompt = typeof input.prompt === "string" ? input.prompt.trim() : undefined; - const subagentType = - typeof input.subagent_type === "string" ? input.subagent_type.trim() : undefined; const label = description || (prompt ? prompt.slice(0, 200) : undefined); if (label) { - return subagentType ? `${subagentType}: ${label}` : label; + return label; } } @@ -1961,24 +2248,24 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( rawPayload: result ?? { status }, }); - const stamp = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - type: "turn.completed", - eventId: stamp.eventId, - provider: PROVIDER, - createdAt: stamp.createdAt, + // A result with no local turn is never a turn this adapter started: + // real turns get turnState in sendTurn, and assistant messages that + // arrive outside a turn auto-start a synthetic one. What lands here is + // the resume handshake (system/init + result(num_turns: 0)), a late + // result for a turn already completed locally (steer auto-close, + // stream teardown), or a stream failure with no turn in flight. The + // untargeted turn.completed this branch used to emit carried no turnId, + // so ingestion could not attribute it — and whenever the projection had + // no active turn (a pending turn start included) it flipped the session + // lifecycle for a turn that never existed. Keep the usage emission, + // drop the lifecycle event, and leave a tripwire so the upstream + // trigger stays measurable in the field. + yield* Effect.logInfo("claude.turn.result-without-active-turn", { threadId: context.session.threadId, - payload: { - state: status, - ...(result?.stop_reason !== undefined ? { stopReason: result.stop_reason } : {}), - ...(result?.usage ? { usage: result.usage } : {}), - ...(result?.modelUsage ? { modelUsage: result.modelUsage } : {}), - ...(typeof result?.total_cost_usd === "number" - ? { totalCostUsd: result.total_cost_usd } - : {}), - ...(errorMessage ? { errorMessage } : {}), - }, - providerRefs: {}, + status, + numTurns: result?.num_turns, + hasUsage: result?.usage !== undefined, + ...(errorMessage ? { errorMessage } : {}), }); return; } @@ -2078,6 +2365,32 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const { event } = message; + // Subagent-owned stream traffic (parent_tool_use_id set) must not write + // into the parent transcript: with forwardSubagentText off the SDK still + // forwards subagent tool_use/tool_result blocks and their wrapping + // text/thinking deltas, and emitting them interleaved N subagents' + // narration into the chat (live-test finding). Their results reach the + // UI via the task.* lifecycle; their tool blocks are attributed and + // re-homed by the quiet-timeline filter. + const streamParentToolUseId = (message as { parent_tool_use_id?: string | null }) + .parent_tool_use_id; + if (streamParentToolUseId !== null && streamParentToolUseId !== undefined) { + // Drop only the subagent's narration (text/thinking); tool_use blocks + // and their input_json_delta frames must flow so attributed tool items + // keep their inputs (review finding: dropping deltas emptied inputs). + const dropStart = + event.type === "content_block_start" && + event.content_block.type !== "tool_use" && + event.content_block.type !== "server_tool_use" && + event.content_block.type !== "mcp_tool_use"; + const dropDelta = + event.type === "content_block_delta" && + (event.delta.type === "text_delta" || event.delta.type === "thinking_delta"); + if (dropStart || dropDelta) { + return; + } + } + if (event.type === "message_delta") { if (message.parent_tool_use_id !== null && message.parent_tool_use_id !== undefined) { return; @@ -2205,6 +2518,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( status: "inProgress", title: nextTool.title, ...(nextTool.detail ? { detail: nextTool.detail } : {}), + ...(nextTool.agentId ? { agentId: nextTool.agentId } : {}), + ...(nextTool.parentToolUseId ? { parentToolUseId: nextTool.parentToolUseId } : {}), data: { toolName: nextTool.toolName, input: nextTool.input, @@ -2274,6 +2589,14 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const inputFingerprint = Object.keys(toolInput).length > 0 ? toolInputFingerprint(toolInput) : undefined; + // Attribute tools that ran inside a subagent to their owning agent so + // clients can re-home them out of the main timeline (quiet-timeline + // guarantee): the SDK forwards subagent tool_use blocks tagged with the + // spawning Task tool's id as parent_tool_use_id. + const parentToolUseId = + (message as { parent_tool_use_id?: string | null }).parent_tool_use_id ?? undefined; + const owningAgentId = agentIdForParentToolUse(context.taskAgents, parentToolUseId); + const tool: ToolInFlight = { itemId, itemType, @@ -2283,6 +2606,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( input: toolInput, partialInputJson: "", ...(inputFingerprint ? { lastEmittedInputFingerprint: inputFingerprint } : {}), + ...(owningAgentId ? { agentId: owningAgentId } : {}), + ...(parentToolUseId ? { parentToolUseId } : {}), }; context.inFlightTools.set(index, tool); @@ -2300,6 +2625,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( status: "inProgress", title: tool.title, ...(tool.detail ? { detail: tool.detail } : {}), + ...(tool.agentId ? { agentId: tool.agentId } : {}), + ...(tool.parentToolUseId ? { parentToolUseId: tool.parentToolUseId } : {}), data: { toolName: tool.toolName, input: toolInput, @@ -2378,6 +2705,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( status: toolResult.isError ? "failed" : "inProgress", title: tool.title, ...(tool.detail ? { detail: tool.detail } : {}), + ...(tool.agentId ? { agentId: tool.agentId } : {}), + ...(tool.parentToolUseId ? { parentToolUseId: tool.parentToolUseId } : {}), data: toolData, }, providerRefs: nativeProviderRefs(context, { @@ -2430,6 +2759,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( status: itemStatus, title: tool.title, ...(tool.detail ? { detail: tool.detail } : {}), + ...(tool.agentId ? { agentId: tool.agentId } : {}), + ...(tool.parentToolUseId ? { parentToolUseId: tool.parentToolUseId } : {}), data: toolData, }, providerRefs: nativeProviderRefs(context, { @@ -2442,6 +2773,43 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }, }); + // The Workflow tool's result carries the run handles (runId, scriptPath, + // transcriptDir, sessionUrl). Attach them to the workflow's task agent so + // the next task.* payload advertises them to clients. + if (!toolResult.isError && tool.toolName.toLowerCase() === "workflow" && toolUseResult) { + const workflowTaskId = trimmedString(toolUseResult.taskId); + if (workflowTaskId) { + const runHandles: TaskRunHandles = { + ...(trimmedString(toolUseResult.runId) + ? { runId: trimmedString(toolUseResult.runId) } + : {}), + ...(trimmedString(toolUseResult.scriptPath) + ? { scriptPath: trimmedString(toolUseResult.scriptPath) } + : {}), + ...(trimmedString(toolUseResult.transcriptDir) + ? { transcriptDir: trimmedString(toolUseResult.transcriptDir) } + : {}), + ...(sanitizeSessionUrl(toolUseResult.sessionUrl) + ? { sessionUrl: sanitizeSessionUrl(toolUseResult.sessionUrl) } + : {}), + }; + const existing = context.taskAgents.get(workflowTaskId); + context.taskAgents.set(workflowTaskId, { + taskId: workflowTaskId, + toolUseId: existing?.toolUseId ?? tool.itemId, + description: existing?.description, + subagentType: existing?.subagentType, + taskType: existing?.taskType ?? "local_workflow", + workflowName: existing?.workflowName, + skipTranscript: existing?.skipTranscript ?? false, + runHandles, + owningAgentId: existing?.owningAgentId, + model: existing?.model, + effort: existing?.effort, + }); + } + } + if ( !toolResult.isError && applyClaudeTaskToolResult(context.claudeTasks, tool, toolUseResult) @@ -2465,6 +2833,26 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( return; } + // Subagent-owned assistant snapshots (parent_tool_use_id set) are the + // subagent's own conversation, not the parent's. Emitting them created + // interleaved "Agent N done"-adjacent leak messages and spawned synthetic + // turns per subagent completion (which also reset the Working timer). + const assistantParentToolUseId = (message as { parent_tool_use_id?: string | null }) + .parent_tool_use_id; + if (assistantParentToolUseId !== null && assistantParentToolUseId !== undefined) { + // The snapshot's message.model is the authoritative API model the + // subagent actually ran on — refine the seeded launch-time value. + const owningTaskId = agentIdForParentToolUse(context.taskAgents, assistantParentToolUseId); + const snapshotModel = trimmedString(message.message.model); + const owningAgent = owningTaskId ? context.taskAgents.get(owningTaskId) : undefined; + if (owningAgent && snapshotModel) { + owningAgent.model = snapshotModel; + } + context.lastAssistantUuid = message.uuid; + yield* updateResumeCursor(context); + return; + } + // Auto-start a synthetic turn for assistant messages that arrive without // an active turn (e.g., background agent/subagent responses between user prompts). if (!context.turnState) { @@ -2554,7 +2942,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( } const status = turnStatusFromResult(message); - const errorMessage = message.subtype === "success" ? undefined : message.errors[0]; + const errorMessage = resultUserFacingError(message); if (status === "failed") { yield* emitRuntimeError(context, errorMessage ?? "Claude turn failed."); @@ -2563,6 +2951,82 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( yield* completeTurn(context, status, errorMessage, message); }); + /** + * Synthesizes per-member task.progress rows from the coordinator's + * workflow_progress array. Member identity is the stable slot + * `:wf:` (never the per-attempt agent id, which + * changes on retry and would split one member into duplicate rows). + * timelineBypass keeps these out of the parent chat; the Agents surface and + * workflow card consume them. + */ + const emitWorkflowMemberProgress = Effect.fn("emitWorkflowMemberProgress")(function* ( + context: ClaudeSessionContext, + base: Omit, + message: Extract, + ) { + const progress = parseWorkflowProgress( + (message as unknown as Record).workflow_progress, + ); + if (!progress) { + return; + } + const coordinatorId = message.task_id; + for (const entry of progress.agents) { + const memberTaskId = `${coordinatorId}:wf:${entry.index}`; + const status = workflowAgentStatus(entry); + // Material-transition filter: the wire repeats every member each tick. + // Emit only when something the client renders actually changed, so a + // 100-agent fleet costs ~1 event per changed member instead of 100 + // per tick (review finding: unbounded event amplification). + const fingerprint = [ + status, + entry.label ?? "", + entry.model ?? "", + entry.lastToolName ?? "", + entry.error ?? "", + entry.tokens ?? "", + entry.toolCalls ?? "", + entry.phaseIndex ?? "", + entry.phaseTitle ?? "", + entry.attempt ?? "", + ].join("\u001f"); + if (context.workflowMemberFingerprints.get(memberTaskId) === fingerprint) { + continue; + } + context.workflowMemberFingerprints.set(memberTaskId, fingerprint); + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + ...base, + eventId: stamp.eventId, + createdAt: stamp.createdAt, + type: "task.progress", + payload: { + taskId: RuntimeTaskId.make(memberTaskId), + description: entry.label ?? `agent ${entry.index}`, + status, + ...(entry.error ? { error: entry.error } : {}), + ...(entry.label ? { title: entry.label } : {}), + ...(entry.model ? { model: entry.model } : {}), + ...(entry.lastToolName ? { lastToolName: entry.lastToolName } : {}), + ...(entry.tokens !== undefined + ? { + typedUsage: { + totalTokens: entry.tokens, + ...(entry.toolCalls !== undefined ? { toolUses: entry.toolCalls } : {}), + }, + } + : {}), + parentAgentId: coordinatorId, + agentIndex: entry.index, + ...(entry.phaseIndex !== undefined ? { phaseIndex: entry.phaseIndex } : {}), + ...(entry.phaseTitle ? { phaseTitle: entry.phaseTitle } : {}), + ...(entry.attempt !== undefined ? { attempt: entry.attempt } : {}), + timelineBypass: true, + }, + }); + } + }); + const handleSystemMessage = Effect.fn("handleSystemMessage")(function* ( context: ClaudeSessionContext, message: SDKMessage, @@ -2593,9 +3057,15 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( // error rows in client work logs. `background_tasks_changed` is a roster // snapshot ({tasks: [...]}) — the task_* lifecycle events carry the // authoritative per-agent data and the typed background_tasks control - // request is the reconciliation source. - if ((message.subtype as string) === "background_tasks_changed") { - return; + // request is the reconciliation source. `vcs_state_changed` + // ({kind: commit|push|rebase}) and `code_change_published` + // ({provider, url, repo}) are informational CLI notices; the work log + // already shows the underlying git/gh tool calls. + switch (message.subtype as string) { + case "background_tasks_changed": + case "vcs_state_changed": + case "code_change_published": + return; } switch (message.subtype) { @@ -2678,7 +3148,46 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }, }); return; - case "task_started": + case "task_started": { + // A task launched by a tool that itself ran inside a subagent (the + // in-flight tool carries agentId from parent_tool_use_id) is + // agent-internal: a subagent's background shell, not parent work. + const launchingTool = message.tool_use_id + ? Array.from(context.inFlightTools.values()).find( + (tool) => tool.itemId === message.tool_use_id, + ) + : undefined; + const owningAgentId = launchingTool?.agentId; + // Model/effort: the Agent tool's input carries explicit overrides; + // absent ones inherit the session's selection (SDK behavior). + // Subagent assistant snapshots later refine model with the + // authoritative API id. AgentInput.effort may be a named level or an + // integer. + const launchInput = launchingTool?.input; + const model = + trimmedString(launchInput?.model) ?? trimmedString(context.session.model ?? undefined); + const rawLaunchEffort = launchInput?.effort; + const effort = + trimmedString(rawLaunchEffort) ?? + (typeof rawLaunchEffort === "number" && Number.isFinite(rawLaunchEffort) + ? String(rawLaunchEffort) + : context.currentEffort); + // Remember the agent identity so every later task.* payload for this + // taskId is self-describing (identity must survive activity retention). + context.taskAgents.set(message.task_id, { + taskId: message.task_id, + toolUseId: message.tool_use_id, + description: message.description, + subagentType: message.subagent_type, + taskType: message.task_type, + workflowName: message.workflow_name, + skipTranscript: message.skip_transcript === true, + runHandles: context.taskAgents.get(message.task_id)?.runHandles, + owningAgentId, + model, + effort, + }); + context.liveTaskIds.add(message.task_id); yield* offerRuntimeEvent({ ...base, type: "task.started", @@ -2686,10 +3195,18 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( taskId: RuntimeTaskId.make(message.task_id), description: message.description, ...(message.task_type ? { taskType: message.task_type } : {}), + ...(owningAgentId ? { agentId: owningAgentId } : {}), + ...(message.description ? { title: message.description } : {}), + ...(message.subagent_type ? { role: message.subagent_type } : {}), + ...(model ? { model } : {}), + ...(effort ? { effort } : {}), + ...(message.tool_use_id ? { toolUseId: message.tool_use_id } : {}), + ...(message.workflow_name ? { workflowName: message.workflow_name } : {}), }, }); return; - case "task_progress": + } + case "task_progress": { yield* emitThreadTokenUsage( context, normalizeClaudeTaskProgressTokenUsage(message.usage, context), @@ -2698,6 +3215,15 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( rawPayload: message, }, ); + const linkage = taskLinkageFor(context.taskAgents, message.task_id); + const typedUsage = normalizeTaskUsage(message.usage); + // Phases ride on the coordinator's ONE progress row per tick. A + // separate phases-only row shared the stable ingestion activity id + // with this full row, and the thinner upsert overwrote usage and + // progress text (review finding). + const workflowPhases = parseWorkflowProgress( + (message as unknown as Record).workflow_progress, + )?.phases; yield* offerRuntimeEvent({ ...base, type: "task.progress", @@ -2706,16 +3232,48 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( description: message.description, ...(message.summary ? { summary: message.summary } : {}), ...(message.usage ? { usage: message.usage } : {}), + ...(typedUsage ? { typedUsage } : {}), ...(message.last_tool_name ? { lastToolName: message.last_tool_name } : {}), + ...(workflowPhases && workflowPhases.length > 0 ? { phases: workflowPhases } : {}), + ...linkage, + ...(message.subagent_type ? { role: message.subagent_type } : {}), }, }); + yield* emitWorkflowMemberProgress(context, base, message); return; - // Task state patch (status/backgrounded/end_time). No runtime mapping - // yet — the terminal task_notification reports the outcome — but it - // must not surface as an unknown-subtype warning row. - case "task_updated": + } + case "task_updated": { + // Status patch (killed/paused/backgrounded/end_time/error) — main + // previously dropped this on the floor, losing all transitions. + const patch = message.patch; + const status = + patch.status !== undefined ? CLAUDE_TASK_PATCH_STATUS[patch.status] : undefined; + if (status === "completed" || status === "failed" || status === "cancelled") { + context.liveTaskIds.delete(message.task_id); + } + const endedAt = + typeof patch.end_time === "number" && Number.isFinite(patch.end_time) + ? DateTime.formatIso(DateTime.makeUnsafe(patch.end_time)) + : undefined; + yield* offerRuntimeEvent({ + ...base, + type: "task.updated", + payload: { + taskId: RuntimeTaskId.make(message.task_id), + ...(status ? { status } : {}), + ...(patch.description ? { description: patch.description } : {}), + ...(patch.error ? { error: patch.error } : {}), + ...(endedAt ? { endedAt } : {}), + ...(patch.is_backgrounded !== undefined + ? { isBackgrounded: patch.is_backgrounded } + : {}), + ...taskLinkageFor(context.taskAgents, message.task_id), + }, + }); return; - case "task_notification": + } + case "task_notification": { + context.liveTaskIds.delete(message.task_id); yield* emitThreadTokenUsage( context, normalizeClaudeTaskProgressTokenUsage(message.usage, context), @@ -2724,6 +3282,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( rawPayload: message, }, ); + const typedUsage = normalizeTaskUsage(message.usage); yield* offerRuntimeEvent({ ...base, type: "task.completed", @@ -2732,9 +3291,13 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( status: message.status, ...(message.summary ? { summary: message.summary } : {}), ...(message.usage ? { usage: message.usage } : {}), + ...(typedUsage ? { typedUsage } : {}), + ...(message.output_file ? { outputFile: message.output_file } : {}), + ...taskLinkageFor(context.taskAgents, message.task_id), }, }); return; + } case "files_persisted": yield* offerRuntimeEvent({ ...base, @@ -2870,7 +3433,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( toolUseId: message.tool_use_id, toolName: message.tool_name, elapsedSeconds: message.elapsed_time_seconds, - ...(message.task_id ? { summary: `task:${message.task_id}` } : {}), + ...(message.task_id ? { taskId: RuntimeTaskId.make(message.task_id) } : {}), + ...(message.parent_tool_use_id !== null + ? { parentToolUseId: message.parent_tool_use_id } + : {}), }, }); return; @@ -3192,6 +3758,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const pendingUserInputs = new Map(); const inFlightTools = new Map(); const claudeTasks = new Map(); + const taskAgents = new Map(); + const workflowMemberFingerprints = new Map(); + const liveTaskIds = new Set(); const contextRef = yield* Ref.make(undefined); @@ -3521,6 +4090,14 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(ultracode ? { ultracode: true } : {}), }; const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); + // The attachments dir grant lets the agent Read/copy pasted images at + // the paths ProviderService injects into the turn text, without an + // approval prompt. It is a leaf directory holding only attachment + // files; siblings like secrets/ and state.sqlite stay ungranted. + const additionalDirectories = [ + ...(input.cwd ? [input.cwd] : []), + serverConfig.attachmentsDir, + ]; const queryOptions: ClaudeQueryOptions = { ...(input.cwd ? { cwd: input.cwd } : {}), ...(apiModelId ? { model: apiModelId } : {}), @@ -3544,7 +4121,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( includePartialMessages: true, canUseTool, env: claudeEnvironment, - ...(input.cwd ? { additionalDirectories: [input.cwd] } : {}), + additionalDirectories, ...(Object.keys(extraArgs).length > 0 ? { extraArgs } : {}), ...(mcpSession ? { @@ -3579,7 +4156,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( "claude.query.resume": existingResumeSessionId ?? "", "claude.query.session_id": newSessionId ?? "", "claude.query.include_partial_messages": true, - "claude.query.additional_directories": input.cwd ? [input.cwd] : [], + "claude.query.additional_directories": additionalDirectories, "claude.query.setting_sources": [...CLAUDE_SETTING_SOURCES], "claude.query.settings_json": encodeJsonStringForDiagnostics(settings) ?? "", "claude.query.extra_args_json": encodeJsonStringForDiagnostics(extraArgs) ?? "", @@ -3628,12 +4205,16 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( startedAt, basePermissionMode: permissionMode, currentApiModelId: apiModelId, + currentEffort: effectiveEffort ?? undefined, resumeSessionId: sessionId, pendingApprovals, pendingUserInputs, turns: [], inFlightTools, claudeTasks, + taskAgents, + workflowMemberFingerprints, + liveTaskIds, turnState: undefined, lastKnownContextWindow: initialContextWindow, lastKnownTokenUsage: undefined, @@ -3750,6 +4331,13 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...context.session, model: modelSelection.model, }; + const turnCaps = getClaudeModelCapabilities(modelSelection.model); + const turnEffort = resolveClaudeEffort( + turnCaps, + getModelSelectionStringOptionValue(modelSelection, "effort"), + ); + context.currentEffort = + getEffectiveClaudeAgentEffort(turnEffort ?? null, modelSelection.model) ?? undefined; } // Apply interaction mode by switching the SDK's permission mode. @@ -3825,6 +4413,58 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const interruptTurn: ClaudeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")( function* (threadId, _turnId) { const context = yield* requireSession(threadId); + // Stop-everything semantics: users reach for Stop precisely when a + // fleet ran away. interrupt() alone only ends the parent turn — + // background subagents/shells keep running and keep burning tokens. + // Stop every live task first (best-effort per task: one refusal must + // not strand the rest or block the turn interrupt), then interrupt. + if (context.query.stopTask && context.liveTaskIds.size > 0) { + const liveIds = Array.from(context.liveTaskIds); + // Bounded: a wedged child's stopTask promise may never settle + // (Effect.ignore handles rejection, not non-resolution), and the + // parent interrupt below MUST still run — Stop matters most during + // runaway fleets (review finding). Per-task timeout keeps one hung + // child from consuming the whole budget. + yield* Effect.forEach( + liveIds, + (taskId) => + Effect.gen(function* () { + const stopAcknowledged = yield* Effect.tryPromise({ + // Invoke through the query object: SDK methods rely on `this`. + try: () => context.query.stopTask!(taskId), + catch: () => undefined, + }).pipe( + Effect.timeoutOption("3 seconds"), + Effect.orElseSucceed(() => Option.none()), + ); + if (Option.isNone(stopAcknowledged) || !context.liveTaskIds.delete(taskId)) { + return; + } + + // stopTask only acknowledges the control request. Its separate + // task_notification can lose the race with interrupt(), so make + // the acknowledged stop authoritative for the durable UI state. + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "task.completed", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState + ? { turnId: asCanonicalTurnId(context.turnState.turnId) } + : {}), + payload: { + taskId: RuntimeTaskId.make(taskId), + status: "stopped", + ...taskLinkageFor(context.taskAgents, taskId), + }, + providerRefs: nativeProviderRefs(context), + }); + }).pipe(Effect.ignore), + { concurrency: 8, discard: true }, + ).pipe(Effect.timeoutOption("10 seconds"), Effect.ignore); + } yield* Effect.tryPromise({ try: () => context.query.interrupt(), catch: (cause) => toRequestError(threadId, "turn/interrupt", cause), diff --git a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts index ab6e59929902..19907ece8884 100644 --- a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts @@ -9,11 +9,27 @@ import * as Schema from "effect/Schema"; import { buildClaudeCapabilitiesProbeQueryOptions, CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES, + isLegacyClaudeModel, probeClaudeCapabilities, } from "./ClaudeProvider.ts"; const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); +it("keeps only the Claude 5 family out of legacy models", () => { + assert.deepStrictEqual( + ["claude-fable-5", "claude-opus-5", "claude-sonnet-5", "claude-opus-4-8"].map((model) => [ + model, + isLegacyClaudeModel(model), + ]), + [ + ["claude-fable-5", false], + ["claude-opus-5", false], + ["claude-sonnet-5", false], + ["claude-opus-4-8", true], + ], + ); +}); + it("isolates Claude capability probes without dropping workspace setting sources", () => { const abortController = new AbortController(); const options = buildClaudeCapabilitiesProbeQueryOptions({ diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 96202ecd952b..0e019f003c7a 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -56,7 +56,13 @@ const MINIMUM_CLAUDE_FABLE_5_VERSION = "2.1.169"; const MINIMUM_CLAUDE_OPUS_4_8_VERSION = "2.1.154"; const MINIMUM_CLAUDE_OPUS_4_7_VERSION = "2.1.111"; -const BUILT_IN_MODELS: ReadonlyArray = [ +const CURRENT_CLAUDE_MODELS = new Set(["claude-fable-5", "claude-opus-5", "claude-sonnet-5"]); + +export function isLegacyClaudeModel(model: string): boolean { + return !CURRENT_CLAUDE_MODELS.has(model); +} + +const CLAUDE_MODEL_CATALOG: ReadonlyArray = [ { slug: "claude-fable-5", name: "Claude Fable 5", @@ -309,6 +315,10 @@ const BUILT_IN_MODELS: ReadonlyArray = [ }, ]; +const BUILT_IN_MODELS: ReadonlyArray = CLAUDE_MODEL_CATALOG.map((model) => + isLegacyClaudeModel(model.slug) ? { ...model, isLegacy: true } : model, +); + function supportsClaudeOpus5(version: string | null | undefined): boolean { return version ? compareSemverVersions(version, MINIMUM_CLAUDE_OPUS_5_VERSION) >= 0 : false; } diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 4146121b1474..6b99bf52b1e0 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -20,6 +20,8 @@ import { type ProviderUserInputAnswers, RuntimeItemId, RuntimeRequestId, + RuntimeTaskId, + type RuntimeTaskUsage, ProviderApprovalDecision, ThreadId, ProviderSendTurnInput, @@ -494,10 +496,276 @@ function mapItemLifecycle( }; } +/** + * Maps the session runtime's synthetic `collabAgent/*` events (native + * multi-agent v2 child-thread signals) into the shared task.* lifecycle. + * Agent identity = child thread id; nickname is the display title, role is + * agentRole (fallback: last agentPath segment, then "general-purpose"). + * A completed child turn is idle (resumable), not terminal. timelineBypass + * keeps these rows out of the parent chat. + */ +function mapCollabAgentEvent( + event: ProviderEvent, + canonicalThreadId: ThreadId, +): ReadonlyArray { + const payload = + typeof event.payload === "object" && event.payload !== null + ? (event.payload as Record) + : undefined; + const agentThreadId = typeof payload?.agentThreadId === "string" ? payload.agentThreadId : ""; + if (!payload || agentThreadId.length === 0) { + return []; + } + const base = runtimeEventBase(event, canonicalThreadId); + const taskId = RuntimeTaskId.make(agentThreadId); + const agentPath = typeof payload.agentPath === "string" ? payload.agentPath : undefined; + const pathLeaf = agentPath?.split("/").findLast((segment) => segment.length > 0); + const nickname = typeof payload.nickname === "string" ? payload.nickname : undefined; + const role = + (typeof payload.role === "string" ? payload.role : undefined) ?? pathLeaf ?? "general-purpose"; + // A bare thread id is not a name. Omitting the title lets the client fold + // keep the real one from task.started instead of clobbering it (probe + // finding: progress rows renamed math_one to its UUID). + const knownName = nickname ?? pathLeaf; + const title = knownName ?? agentThreadId; + // Identity repeated on every status patch so rows are self-describing when + // the start row ages out of activity retention (review finding: a + // reconstructed agent had a UUID name and no role/path). + const statusLinkage = { + role, + ...(knownName ? { title: knownName } : {}), + ...(agentPath ? { agentPath } : {}), + timelineBypass: true, + } as const; + + switch (event.method) { + case "collabAgent/started": + return [ + { + ...base, + type: "task.started", + payload: { + taskId, + description: title, + title, + role, + ...(agentPath ? { agentPath } : {}), + ...(typeof payload.parentThreadId === "string" + ? { parentAgentId: payload.parentThreadId } + : {}), + timelineBypass: true, + }, + }, + ]; + case "collabAgent/activity": { + const activityKind = typeof payload.activityKind === "string" ? payload.activityKind : ""; + if (activityKind === "interrupted") { + return [ + { + ...base, + type: "task.updated", + payload: { taskId, status: "interrupted", ...statusLinkage }, + }, + ]; + } + if (activityKind === "started") { + // Wire-probe finding: children often register via subAgentActivity + // alone (no thread/started with a spawn source), so this is the one + // shot at a task.started with a real name — agentPath leaf beats a + // bare thread-id title. + return [ + { + ...base, + type: "task.started", + payload: { + taskId, + description: title, + title, + role, + ...(agentPath ? { agentPath } : {}), + timelineBypass: true, + }, + }, + ]; + } + // interacted → the child is (again) actively driven. + return [ + { + ...base, + type: "task.updated", + payload: { taskId, status: "running", ...statusLinkage }, + }, + ]; + } + case "collabAgent/turnStarted": + return [ + { + ...base, + type: "task.updated", + payload: { taskId, status: "running", ...statusLinkage }, + }, + ]; + case "collabAgent/turnCompleted": { + // Idle, not terminal: the identity is resumable via sendInput/resume. + const turn = + typeof payload.turn === "object" && payload.turn !== null + ? (payload.turn as Record) + : undefined; + const turnStatus = typeof turn?.status === "string" ? turn.status : undefined; + const status = + turnStatus === "failed" + ? ("failed" as const) + : turnStatus === "interrupted" + ? ("interrupted" as const) + : ("idle" as const); + return [ + { + ...base, + type: "task.updated", + payload: { taskId, status, ...statusLinkage }, + }, + ]; + } + case "collabAgent/statusChanged": { + const status = + typeof payload.status === "object" && payload.status !== null + ? (payload.status as Record) + : undefined; + const statusType = typeof status?.type === "string" ? status.type : undefined; + if (statusType === "systemError") { + // Silently dropping this once left children stuck running forever. + return [ + { + ...base, + type: "task.updated", + payload: { taskId, status: "failed", ...statusLinkage }, + }, + ]; + } + if (statusType === "active") { + const flags = Array.isArray(status?.activeFlags) ? status.activeFlags : []; + const waiting = flags.some( + (flag) => flag === "waitingOnApproval" || flag === "waitingOnUserInput", + ); + return [ + { + ...base, + type: "task.updated", + payload: { taskId, status: waiting ? "waiting" : "running", ...statusLinkage }, + }, + ]; + } + if (statusType === "idle") { + return [ + { + ...base, + type: "task.updated", + payload: { taskId, status: "idle", ...statusLinkage }, + }, + ]; + } + return []; + } + case "collabAgent/tokenUsage": { + // Cumulative per child thread: always the `total` breakdown, never + // `last` (which shrinks on follow-ups). Client folds max-merge. + const tokenUsage = + typeof payload.tokenUsage === "object" && payload.tokenUsage !== null + ? (payload.tokenUsage as Record) + : undefined; + const total = + typeof tokenUsage?.total === "object" && tokenUsage.total !== null + ? (tokenUsage.total as Record) + : undefined; + const count = (value: unknown): number | undefined => + typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined; + // Same validation as every other field: RuntimeTaskUsage.totalTokens + // is NonNegativeInt, so NaN/Infinity/negative wire values must miss. + const totalTokens = count(total?.totalTokens); + if (totalTokens === undefined) { + return []; + } + const typedUsage: RuntimeTaskUsage = { + totalTokens, + ...(count(total?.inputTokens) !== undefined + ? { inputTokens: count(total?.inputTokens) } + : {}), + ...(count(total?.cachedInputTokens) !== undefined + ? { cachedInputTokens: count(total?.cachedInputTokens) } + : {}), + ...(count(total?.outputTokens) !== undefined + ? { outputTokens: count(total?.outputTokens) } + : {}), + ...(count(total?.reasoningOutputTokens) !== undefined + ? { reasoningOutputTokens: count(total?.reasoningOutputTokens) } + : {}), + }; + return [ + { + ...base, + type: "task.progress", + payload: { + taskId, + description: title, + ...(knownName ? { title: knownName } : {}), + typedUsage, + timelineBypass: true, + }, + }, + ]; + } + case "collabAgent/item": { + const item = + typeof payload.item === "object" && payload.item !== null + ? (payload.item as Record) + : undefined; + const itemTypeRaw = typeof item?.type === "string" ? item.type : undefined; + if (!itemTypeRaw) { + return []; + } + // A loose summary from the raw item: the child stream is untyped at + // this boundary (synthetic event payload), so read best-effort fields + // rather than force a schema decode. + const looseSummary = + (typeof item?.command === "string" ? item.command : undefined) ?? + (typeof item?.title === "string" ? item.title : undefined) ?? + (typeof item?.query === "string" ? item.query : undefined); + const canonical = toCanonicalItemType(itemTypeRaw); + const summary = looseSummary ?? canonical.replaceAll("_", " "); + return [ + { + ...base, + type: "task.progress", + payload: { + taskId, + description: title, + ...(knownName ? { title: knownName } : {}), + summary, + timelineBypass: true, + }, + }, + ]; + } + case "collabAgent/closed": + return [ + { + ...base, + type: "task.updated", + payload: { taskId, status: "interrupted", ...statusLinkage }, + }, + ]; + default: + return []; + } +} + function mapToRuntimeEvents( event: ProviderEvent, canonicalThreadId: ThreadId, ): ReadonlyArray { + if (event.kind === "notification" && event.method.startsWith("collabAgent/")) { + return mapCollabAgentEvent(event, canonicalThreadId); + } if (event.kind === "error") { if (!event.message) { return []; diff --git a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts new file mode 100644 index 000000000000..38e0e0a7b2c1 --- /dev/null +++ b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts @@ -0,0 +1,297 @@ +/** + * Runtime-level collab regression: boots the REAL CodexSessionRuntime against + * a scripted mock app-server peer that replays the captured multi-agent wire + * sequence (codexMultiAgentWire.json) plus the shapes the capture alone can't + * script (receiver-turn bookkeeping via collabAgentToolCall, child terminal + * lifecycle, approval pass-through). This is the layer the pure routing-table + * test can't reach: ordering between the legacy receiver-turn suppressor and + * v2 interception, registration state, and synthetic event emission. + */ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import { ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Stream from "effect/Stream"; +import { assert, describe } from "vite-plus/test"; + +import wireFixture from "../testFixtures/codexMultiAgentWire.json" with { type: "json" }; +import { makeCodexSessionRuntime } from "./CodexSessionRuntime.ts"; + +const ROOT = wireFixture.rootThreadId; +const [CHILD_A, CHILD_B] = wireFixture.childThreadIds as [string, string]; + +/** + * The captured sequence, extended with the shapes the live capture didn't + * include: a collabAgentToolCall with receiverThreadIds (feeds the legacy + * receiver-turn map, so ordering vs. v2 interception is exercised), child + * terminal lifecycle, and a serverRequest/resolved addressed to a child + * (must pass through to the parent path, not vanish). + */ +function buildScript() { + const captured = wireFixture.notifications; + const extras = [ + { + method: "item/completed", + params: { + threadId: ROOT, + item: { + type: "collabAgentToolCall", + id: "call_fixture_wait", + tool: "wait", + status: "completed", + senderThreadId: ROOT, + receiverThreadIds: [CHILD_A, CHILD_B], + }, + }, + }, + // Child terminal lifecycle AFTER the receiver map knows the children — + // pre-fix, the legacy suppressor dropped these before interception saw + // them, so no synthetic agent events were emitted. + { + method: "turn/completed", + params: { + threadId: CHILD_A, + turn: { id: `${CHILD_A}-turn-1`, status: "completed", items: [] }, + }, + }, + { method: "thread/closed", params: { threadId: CHILD_B } }, + // Parent-owned traffic addressed to a child conversation: must reach the + // parent path (approval correlation cleanup), not be swallowed. + { method: "serverRequest/resolved", params: { threadId: CHILD_A, requestId: "req-1" } }, + ]; + return { + rootThreadId: ROOT, + notifications: [...captured.filter((entry) => entry.method !== "turn/completed"), ...extras], + }; +} + +const scriptPath = NodePath.join(import.meta.dirname, "../testFixtures/.collab-script.json"); +const peerPath = NodePath.join(import.meta.dirname, "../testFixtures/codexCollabMockPeer.sh"); + +describe("CodexSessionRuntime collab integration", () => { + it.effect("replays the captured fan-out into synthetic agent events without child leaks", () => + Effect.gen(function* () { + // @effect-diagnostics-next-line preferSchemaOverJson:off + NodeFS.writeFileSync(scriptPath, JSON.stringify(buildScript()), "utf8"); + yield* Effect.addFinalizer(() => + Effect.sync(() => NodeFS.rmSync(scriptPath, { force: true })), + ); + + const runtime = yield* makeCodexSessionRuntime({ + threadId: ThreadId.make("thread-collab-integration"), + binaryPath: peerPath, + cwd: "/tmp", + runtimeMode: "full-access", + environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath }, + }); + + const eventsFiber = yield* runtime.events.pipe( + Stream.takeUntil((event) => event.method === "turn/completed"), + Stream.runCollect, + Effect.forkScoped, + ); + + yield* runtime.start(); + yield* runtime.sendTurn({ input: "fan out" }); + + const events = Array.from(yield* Fiber.join(eventsFiber)); + const methods = events.map((event) => event.method); + + // Children registered from subAgentActivity become synthetic agent + // lifecycle — including terminal rows that arrive AFTER the receiver + // map knows them (the ordering this test exists to pin). + assert.include(methods, "collabAgent/activity"); + assert.include(methods, "collabAgent/turnCompleted"); + assert.include(methods, "collabAgent/closed"); + + const childTurnCompleted = events.find( + (event) => + event.method === "collabAgent/turnCompleted" && + (event.payload as { agentThreadId?: string }).agentThreadId === CHILD_A, + ); + assert.isDefined(childTurnCompleted, "child A's turn completion becomes an agent event"); + + const childClosed = events.find( + (event) => + event.method === "collabAgent/closed" && + (event.payload as { agentThreadId?: string }).agentThreadId === CHILD_B, + ); + assert.isDefined(childClosed, "child B's close becomes an agent event"); + + // Parent-owned resolution passes through — not swallowed, not + // re-labelled as an agent event. + assert.include(methods, "serverRequest/resolved"); + + // The root's own subAgentActivity about "/root" must NOT register the + // root as a child: the parent turn completion still flows. + assert.include(methods, "turn/completed"); + + // No raw child conversation methods leak onto the parent stream. + const leaked = events.filter((event) => { + const payload = event.payload as { threadId?: string } | undefined; + const addressedToChild = payload?.threadId === CHILD_A || payload?.threadId === CHILD_B; + return addressedToChild && (event.method?.startsWith("thread/") ?? false); + }); + assert.deepEqual( + leaked.map((event) => event.method), + [], + "child thread/* lifecycle must not appear as parent events", + ); + + yield* runtime.close; + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + // it.live: the runtime talks to a real child process; under it.effect's + // TestClock the internal timers freeze and the join never completes. + it.live("Stop interrupts every live child regardless of registration timing", () => + Effect.gen(function* () { + // Ordering + liveness torture for stop-everything: child A's + // turn/started arrives BEFORE anything registers it (foreign + // suppression path must record the live turn); child B's arrives after + // registration; child A's interrupt HANGS (RPC never settles — worse + // than rejecting) and the bounded deadline must still deliver B's and + // the parent's interrupts. The turn stays open so children are live + // when Stop fires. + // Build from REAL captured rows (hand-written shapes fail notification + // schema validation and are silently dropped): reorder so child A's + // turn/started precedes its registration, and drop terminal rows so + // children stay live when Stop fires. + const byIndex = wireFixture.notifications; + const isTurnStarted = (entry: (typeof byIndex)[number], child: string) => + entry.method === "turn/started" && + (entry.params as { threadId?: string }).threadId === child; + const isRegistration = (entry: (typeof byIndex)[number], child: string) => { + const item = (entry.params as { item?: { type?: string; agentThreadId?: string } }).item; + return item?.type === "subAgentActivity" && item.agentThreadId === child; + }; + const turnStartedA = byIndex.find((entry) => isTurnStarted(entry, CHILD_A)); + const turnStartedB = byIndex.find((entry) => isTurnStarted(entry, CHILD_B)); + const registrationA = byIndex.find((entry) => isRegistration(entry, CHILD_A)); + const registrationB = byIndex.find((entry) => isRegistration(entry, CHILD_B)); + assert.isDefined(turnStartedA); + assert.isDefined(turnStartedB); + assert.isDefined(registrationA); + assert.isDefined(registrationB); + const script = { + rootThreadId: ROOT, + holdTurnOpen: true, + hangInterruptFor: CHILD_A, + notifications: [turnStartedA, registrationA, registrationB, turnStartedB], + }; + // @effect-diagnostics-next-line preferSchemaOverJson:off + NodeFS.writeFileSync(scriptPath, JSON.stringify(script), "utf8"); + const interruptsPath = `${scriptPath}.interrupts`; + NodeFS.rmSync(interruptsPath, { force: true }); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + NodeFS.rmSync(scriptPath, { force: true }); + NodeFS.rmSync(interruptsPath, { force: true }); + }), + ); + + const runtime = yield* makeCodexSessionRuntime({ + threadId: ThreadId.make("thread-collab-stop"), + binaryPath: peerPath, + cwd: "/tmp", + runtimeMode: "full-access", + environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath }, + }); + + // Wait for both children's turnStarted signals to be processed before + // stopping (B via the registered-child path; A only produces live-turn + // bookkeeping, so key on B's synthetic event). + const childBStartedFiber = yield* runtime.events.pipe( + Stream.filter( + (event) => + event.method === "collabAgent/turnStarted" && + (event.payload as { agentThreadId?: string }).agentThreadId === CHILD_B, + ), + Stream.take(1), + Stream.runCollect, + Effect.forkScoped, + ); + + yield* runtime.start(); + yield* runtime.sendTurn({ input: "fan out and hang" }); + const childBStarted = yield* Fiber.join(childBStartedFiber).pipe( + Effect.timeoutOption("15 seconds"), + ); + assert.isTrue(childBStarted._tag === "Some", "child B turnStarted never arrived"); + + // Stop everything. A's interrupt hangs forever — the bounded child + // deadline must expire and the parent interrupt must still be sent. + yield* runtime.interruptTurn(); + + const parseInterruptLine = (line: string) => JSON.parse(line) as { threadId?: string }; + const interrupted = NodeFS.readFileSync(interruptsPath, "utf8") + .trim() + .split("\n") + .filter((line) => line.length > 0) + .map(parseInterruptLine); + const interruptedThreads = new Set(interrupted.map((entry) => entry.threadId)); + assert.isTrue( + interruptedThreads.has(CHILD_A), + "pre-registration child A must still receive the interrupt RPC", + ); + assert.isTrue(interruptedThreads.has(CHILD_B), "registered child B must be interrupted"); + assert.isTrue(interruptedThreads.has(ROOT), "parent turn must be interrupted last"); + + yield* runtime.close; + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.live("Stop targets the active turn when Codex has accepted a queued follow-up", () => + Effect.gen(function* () { + const activeTurnId = "019fe3e8-f908-7f31-8d51-283f4a47897a"; + const queuedTurnId = "019fe3eb-8faf-7de3-a85b-ac64c7f9c8c3"; + const script = { + rootThreadId: ROOT, + holdTurnOpen: true, + onlyFirstTurnStarts: true, + turnIds: [activeTurnId, queuedTurnId], + expectedActiveTurnId: activeTurnId, + notifications: [], + }; + // @effect-diagnostics-next-line preferSchemaOverJson:off + NodeFS.writeFileSync(scriptPath, JSON.stringify(script), "utf8"); + const interruptsPath = `${scriptPath}.interrupts`; + NodeFS.rmSync(interruptsPath, { force: true }); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + NodeFS.rmSync(scriptPath, { force: true }); + NodeFS.rmSync(interruptsPath, { force: true }); + }), + ); + + const runtime = yield* makeCodexSessionRuntime({ + threadId: ThreadId.make("thread-codex-queued-stop"), + binaryPath: peerPath, + cwd: "/tmp", + runtimeMode: "full-access", + environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath }, + }); + + yield* runtime.start(); + yield* runtime.sendTurn({ input: "keep working" }); + yield* runtime.sendTurn({ input: "queued follow-up" }); + yield* runtime.interruptTurn(); + + const interrupts = NodeFS.readFileSync(interruptsPath, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line) as { threadId?: string; turnId?: string }); + assert.deepEqual(interrupts.at(-1), { + threadId: ROOT, + turnId: activeTurnId, + }); + + yield* runtime.close; + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); +}); diff --git a/apps/server/src/provider/Layers/CodexCollabWire.test.ts b/apps/server/src/provider/Layers/CodexCollabWire.test.ts new file mode 100644 index 000000000000..50e5e819d1f0 --- /dev/null +++ b/apps/server/src/provider/Layers/CodexCollabWire.test.ts @@ -0,0 +1,180 @@ +/** + * Codex multi-agent wire fixtures. + * + * The child-interception path is the highest-blast-radius code in the + * subagent stack: it decides, per notification, whether traffic reaches the + * parent timeline at all. Three shipped bugs came from that decision being + * implicit (root thread registered as its own child; `error` and + * `serverRequest/resolved` swallowed by a catch-all). These tests pin the + * decision against a REAL capture rather than hand-written shapes. + * + * Fixture provenance: codex-cli 0.145.0 driven directly over stdio with + * gpt-5.6-luna at low effort, prompting a two-child fan-out (alpha, beta). + * See codexMultiAgentWire.json. + */ +import { assert, describe, it } from "vite-plus/test"; + +import fixture from "../testFixtures/codexMultiAgentWire.json" with { type: "json" }; +import { routeCodexChildNotification } from "./CodexSessionRuntime.ts"; + +interface WireNotification { + readonly method: string; + readonly params: Record; +} + +const notifications = fixture.notifications as ReadonlyArray; +const rootThreadId = fixture.rootThreadId; +const childThreadIds = new Set(fixture.childThreadIds); + +/** Mirrors readNotificationThreadId's addressing for the captured methods. */ +function notificationThreadId(entry: WireNotification): string | undefined { + const params = entry.params; + const thread = params.thread; + if ( + typeof thread === "object" && + thread !== null && + typeof (thread as { id?: unknown }).id === "string" + ) { + return (thread as { id: string }).id; + } + return typeof params.threadId === "string" ? params.threadId : undefined; +} + +function subAgentActivityItems(): ReadonlyArray> { + return notifications.flatMap((entry) => { + const item = entry.params.item; + if (typeof item !== "object" || item === null) return []; + const record = item as Record; + return record.type === "subAgentActivity" ? [record] : []; + }); +} + +describe("codex multi-agent wire capture", () => { + it("captures a real two-child fan-out", () => { + assert.equal(fixture.capturedWith.model, "gpt-5.6-luna"); + assert.equal(childThreadIds.size, 2); + const paths = subAgentActivityItems().map((item) => item.agentPath); + assert.include(paths, "/root/alpha"); + assert.include(paths, "/root/beta"); + }); + + it("emits child traffic BEFORE the item that registers the child", () => { + // Ordering hazard: the child's own thread/status/changed arrives before + // the parent-side subAgentActivity naming it. Registration must tolerate + // child-first arrival, so unregistered child traffic passes through + // rather than being eaten (no regression vs. pre-feature behavior). + const firstChildTraffic = notifications.findIndex((entry) => { + const threadId = notificationThreadId(entry); + return threadId !== undefined && childThreadIds.has(threadId); + }); + const firstRegistration = notifications.findIndex((entry) => { + const item = entry.params.item; + if (typeof item !== "object" || item === null) return false; + const record = item as Record; + return record.type === "subAgentActivity" && record.kind === "started"; + }); + assert.isAtLeast(firstChildTraffic, 0); + assert.isAtLeast(firstRegistration, 0); + assert.isBelow( + firstChildTraffic, + firstRegistration, + "capture should exercise child-first ordering", + ); + }); + + it("contains a /root self-activity emitted from a CHILD thread", () => { + // The bug this guards: the wire reports subAgentActivity about the ROOT + // (agentPath "/root"). Registering it made the runtime intercept the + // parent's own final message and turn/completed, so the thread hung + // "working" forever. The root guard must key on agentPath/thread id, + // never on which thread the notification arrived from. + const rootSelfActivity = subAgentActivityItems().find((item) => item.agentPath === "/root"); + assert.isDefined(rootSelfActivity, "capture should contain a /root self-activity"); + assert.equal(rootSelfActivity?.agentThreadId, rootThreadId); + }); + + it("routes every captured child method to a defined disposition", () => { + const childMethods = new Set( + notifications + .filter((entry) => { + const threadId = notificationThreadId(entry); + return threadId !== undefined && childThreadIds.has(threadId); + }) + .map((entry) => entry.method), + ); + assert.isAbove(childMethods.size, 0); + for (const method of childMethods) { + const route = routeCodexChildNotification(method); + // Child lifecycle traffic must become agent events — never silently + // dropped, never leaked to the parent timeline. + assert.equal(route, "agent-event", `${method} should map to an agent event`); + } + }); +}); + +describe("routeCodexChildNotification", () => { + it("maps child lifecycle to agent events", () => { + for (const method of [ + "turn/started", + "turn/completed", + "thread/status/changed", + "thread/tokenUsage/updated", + "item/started", + "item/completed", + "thread/closed", + "error", + ]) { + assert.equal(routeCodexChildNotification(method), "agent-event", method); + } + }); + + it("drops only enumerated child chatter", () => { + for (const method of [ + "item/agentMessage/delta", + "item/reasoning/textDelta", + "item/commandExecution/outputDelta", + "turn/plan/updated", + "thread/name/updated", + ]) { + assert.equal(routeCodexChildNotification(method), "drop", method); + } + }); + + it("never routes child-owned thread lifecycle to the parent", () => { + // These mutate PARENT thread state in CodexAdapter (archived/compacted), + // so a child emitting them must never reach the parent path. This list + // mirrors shouldSuppressChildConversationNotification (the v1 collab + // suppressor) — the two must not drift (review finding: the router + // initially omitted them and they leaked). + for (const method of [ + "thread/started", + "thread/status/changed", + "thread/archived", + "thread/unarchived", + "thread/closed", + "thread/compacted", + "thread/name/updated", + "thread/tokenUsage/updated", + "turn/started", + "turn/completed", + "turn/plan/updated", + "item/plan/delta", + ]) { + assert.notEqual( + routeCodexChildNotification(method), + "parent", + `${method} is child-owned and must not reach the parent path`, + ); + } + }); + + it("sends parent-owned and UNKNOWN methods to the parent path", () => { + // serverRequest/resolved clears the parent's approval correlation: + // swallowing it left approvals stuck (shipped bug). Unknown methods take + // the same route by design — a codex update that adds a notification + // must degrade to "parent sees it", never to silent loss. + assert.equal(routeCodexChildNotification("serverRequest/resolved"), "parent"); + assert.equal(routeCodexChildNotification("thread/somethingBrandNew"), "parent"); + assert.equal(routeCodexChildNotification("account/rateLimits/updated"), "parent"); + }); +}); diff --git a/apps/server/src/provider/Layers/CodexProvider.test.ts b/apps/server/src/provider/Layers/CodexProvider.test.ts index 2aeebdb2ccd8..26e77f82a79e 100644 --- a/apps/server/src/provider/Layers/CodexProvider.test.ts +++ b/apps/server/src/provider/Layers/CodexProvider.test.ts @@ -1,6 +1,25 @@ import { assert, it } from "@effect/vitest"; -import { applyPreferredCodexDefaultModel, mapCodexModelCapabilities } from "./CodexProvider.ts"; +import { + applyPreferredCodexDefaultModel, + isLegacyCodexModel, + mapCodexModelCapabilities, +} from "./CodexProvider.ts"; + +it("keeps only the GPT-5.6 Codex family out of legacy models", () => { + assert.deepStrictEqual( + ["gpt-5.6-luna", "gpt-5.6-terra", "gpt-5.6-sol", "gpt-5.4"].map((model) => [ + model, + isLegacyCodexModel(model), + ]), + [ + ["gpt-5.6-luna", false], + ["gpt-5.6-terra", false], + ["gpt-5.6-sol", false], + ["gpt-5.4", true], + ], + ); +}); it("maps current Codex model capability fields", () => { const capabilities = mapCodexModelCapabilities({ diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 1ed9c750c186..5c0f76dff4e3 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -62,6 +62,11 @@ const REASONING_EFFORT_LABELS: Readonly> = { }; const DEFAULT_SERVICE_TIER_ID = "default"; +const CURRENT_CODEX_MODELS = new Set(["gpt-5.6-luna", "gpt-5.6-terra", "gpt-5.6-sol"]); + +export function isLegacyCodexModel(model: string): boolean { + return !CURRENT_CODEX_MODELS.has(model); +} function reasoningEffortLabel(reasoningEffort: string): string { return REASONING_EFFORT_LABELS[reasoningEffort] ?? reasoningEffort; @@ -190,6 +195,7 @@ function parseCodexModelListResponse( name: toDisplayName(model), isCustom: false, ...(model.isDefault ? { isDefault: true } : {}), + ...(isLegacyCodexModel(model.model) ? { isLegacy: true } : {}), capabilities: mapCodexModelCapabilities(model), })); } diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 67108dd4dbba..58c012bd63ea 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -600,6 +600,71 @@ function readRouteFields(notification: CodexServerNotification): { } } +/** + * Native collab child-agent tracking (multi-agent v2). Under v2 subagents are + * full app-server threads: identity arrives on `thread/started` with + * source.subAgent.thread_spawn, lifecycle on `subAgentActivity` items and the + * child thread's own turn/status/tokenUsage notifications. The runtime + * registers children from those explicit signals, intercepts their + * notifications before parent-timeline mapping, and re-emits them as + * synthetic `collabAgent/*` provider events the adapter turns into task.* + * runtime events (timelineBypass keeps them out of the parent chat). + * + * WIP, probe-gated: registration is deliberately explicit-signals-only. The + * spec's "provisionally treat unknown foreign thread ids as v2 children" rule + * needs a live wire capture of the packaged binary before it lands — blind + * capture risks eating unrelated traffic. Until then a child whose first + * notification precedes registration passes through as today (no regression + * vs main, which passes everything through). + */ +interface CollabChildAgentState { + readonly agentThreadId: string; + readonly nickname: string | undefined; + readonly role: string | undefined; + readonly agentPath: string | undefined; + readonly depth: number | undefined; + readonly parentThreadId: string | undefined; + /** + * Parent canonical turn active when the child registered. Stamped on every + * synthetic collabAgent/* event so clients can batch a fleet by its spawn + * turn — without it, separate fleets in one thread collapsed into a single + * "direct:no-turn" CTA (review finding). + */ + readonly spawnTurnId: TurnId | undefined; +} + +function readThreadSpawnSource(thread: { readonly source: unknown }): + | { + nickname: string | undefined; + role: string | undefined; + agentPath: string | undefined; + depth: number | undefined; + parentThreadId: string | undefined; + } + | undefined { + const source = thread.source; + if (typeof source !== "object" || source === null || !("subAgent" in source)) { + return undefined; + } + const subAgent = (source as { subAgent: unknown }).subAgent; + if (typeof subAgent !== "object" || subAgent === null || !("thread_spawn" in subAgent)) { + return undefined; + } + const spawn = (subAgent as { thread_spawn: unknown }).thread_spawn; + if (typeof spawn !== "object" || spawn === null) { + return undefined; + } + const record = spawn as Record; + return { + nickname: typeof record.agent_nickname === "string" ? record.agent_nickname : undefined, + role: typeof record.agent_role === "string" ? record.agent_role : undefined, + agentPath: typeof record.agent_path === "string" ? record.agent_path : undefined, + depth: typeof record.depth === "number" ? record.depth : undefined, + parentThreadId: + typeof record.parent_thread_id === "string" ? record.parent_thread_id : undefined, + }; +} + function rememberCollabReceiverTurns( collabReceiverTurns: Map, notification: CodexServerNotification, @@ -641,6 +706,72 @@ function shouldSuppressChildConversationNotification( ); } +/** + * How a notification addressed to a REGISTERED child thread is handled. + * + * Exported and pure so the routing table can be asserted against captured + * wire traces (see codexMultiAgentWire.json) rather than only read. + * + * - "agent-event": map to a synthetic collabAgent/* event (Agents surface). + * - "parent": pass through to the parent path — it carries state the parent + * still owns (approval correlation cleanup). + * - "drop": genuine child chatter with no parent meaning (deltas, name and + * plan updates). + * + * Default is "drop" ONLY for the enumerated chatter; anything unrecognized + * routes to "parent" so new wire methods surface instead of vanishing + * (two shipped bugs came from a catch-all that swallowed everything). + */ +export type CodexChildNotificationRoute = "agent-event" | "parent" | "drop"; + +const CHILD_AGENT_EVENT_METHODS: ReadonlySet = new Set([ + "turn/started", + "turn/completed", + "thread/status/changed", + "thread/tokenUsage/updated", + "item/started", + "item/completed", + "thread/closed", + "error", +]); + +const CHILD_CHATTER_METHODS: ReadonlySet = new Set([ + "item/agentMessage/delta", + "item/reasoning/textDelta", + "item/reasoning/summaryTextDelta", + "item/reasoning/summaryPartAdded", + "item/commandExecution/outputDelta", + "item/fileChange/outputDelta", + "item/fileChange/patchUpdated", + "item/plan/delta", + "turn/plan/updated", + "turn/diff/updated", + "thread/name/updated", + "thread/settings/updated", + "rawResponseItem/completed", + // Child-owned thread lifecycle: the parent adapter maps these onto the + // PARENT thread (archived/compacted state), so a child compacting would + // rewrite the parent. Mirrors the v1 suppressor list — dropping them is + // the pre-existing behavior for collab children (review finding). + "thread/archived", + "thread/unarchived", + "thread/compacted", + // Registration path 1 handles a child's first thread/started; a repeat + // must not reach the parent (it would restart the parent's thread state). + "thread/started", +]); + +export function routeCodexChildNotification(method: string): CodexChildNotificationRoute { + if (CHILD_AGENT_EVENT_METHODS.has(method)) { + return "agent-event"; + } + if (CHILD_CHATTER_METHODS.has(method)) { + return "drop"; + } + // Unknown or parent-owned (serverRequest/resolved, approvals, …). + return "parent"; +} + function toCodexUserInputAnswer( questionId: string, value: ProviderUserInputAnswers[string], @@ -683,13 +814,13 @@ function currentProviderThreadId(session: ProviderSession): string | undefined { function updateSession( sessionRef: Ref.Ref, - updates: Partial, + updates: Partial | ((session: ProviderSession) => Partial), ): Effect.Effect { return Effect.gen(function* () { const updatedAt = DateTime.formatIso(yield* DateTime.now); yield* Ref.update(sessionRef, (session) => ({ ...session, - ...updates, + ...(typeof updates === "function" ? updates(session) : updates), updatedAt, })); }); @@ -723,6 +854,9 @@ export const makeCodexSessionRuntime = ( const approvalCorrelationsRef = yield* Ref.make(new Map()); const pendingUserInputsRef = yield* Ref.make(new Map()); const collabReceiverTurnsRef = yield* Ref.make(new Map()); + const collabChildAgentsRef = yield* Ref.make(new Map()); + /** Child provider-thread id → its currently running provider turn id. */ + const collabChildLiveTurnsRef = yield* Ref.make(new Map()); const closedRef = yield* Ref.make(false); // `~` is not shell-expanded when env vars are set via @@ -839,6 +973,280 @@ export const makeCodexSessionRuntime = ( ), ); + /** + * Registers v2 collab children and re-emits their notifications as + * synthetic `collabAgent/*` events for the adapter's task.* synthesis. + * Returns true when the notification was fully handled (must not reach + * parent-timeline mapping). + */ + const interceptCollabChildNotification = (notification: CodexServerNotification) => + Effect.gen(function* () { + // Registration path 1: child thread announces itself with a + // subAgent thread_spawn source. + if (notification.method === "thread/started") { + const thread = notification.params.thread; + const spawn = readThreadSpawnSource(thread); + if (!spawn) { + return false; + } + // Merge with any subAgentActivity registration that got here + // first. spawnTurnId is REGISTRATION-time-only on both paths: for + // an already-known child we keep its value (set or unset) — a + // later thread/started during an unrelated parent turn must not + // backfill that turn as the spawn batch, which would stamp an old + // child onto a new fleet's CTA (review finding). Only a genuinely + // new registration captures the current turn. + const existingChild = (yield* Ref.get(collabChildAgentsRef)).get(thread.id); + const spawnTurnId = existingChild + ? existingChild.spawnTurnId + : ((yield* Ref.get(sessionRef)).activeTurnId ?? undefined); + const state: CollabChildAgentState = { + agentThreadId: thread.id, + nickname: spawn.nickname ?? thread.agentNickname ?? existingChild?.nickname, + role: spawn.role ?? thread.agentRole ?? existingChild?.role, + agentPath: spawn.agentPath ?? existingChild?.agentPath, + depth: spawn.depth ?? existingChild?.depth, + parentThreadId: + spawn.parentThreadId ?? thread.parentThreadId ?? existingChild?.parentThreadId, + spawnTurnId, + }; + yield* Ref.update(collabChildAgentsRef, (current) => { + const next = new Map(current); + next.set(thread.id, state); + return next; + }); + yield* emitEvent({ + kind: "notification", + threadId: options.threadId, + method: "collabAgent/started", + ...(state.spawnTurnId ? { turnId: state.spawnTurnId } : {}), + payload: { + agentThreadId: state.agentThreadId, + ...(state.nickname ? { nickname: state.nickname } : {}), + ...(state.role ? { role: state.role } : {}), + ...(state.agentPath ? { agentPath: state.agentPath } : {}), + ...(state.depth !== undefined ? { depth: state.depth } : {}), + ...(state.parentThreadId ? { parentThreadId: state.parentThreadId } : {}), + }, + }); + return true; + } + + // Registration path 2: parent-side subAgentActivity item names the + // child thread (may arrive before or after thread/started). + if ( + (notification.method === "item/started" || notification.method === "item/completed") && + notification.params.item.type === "subAgentActivity" + ) { + const item = notification.params.item; + // Never register the session's ROOT thread as its own child. The + // wire emits subAgentActivity {agentPath: "/root", interacted} + // about the root during collab runs; registering it intercepted + // every subsequent root notification — including the final + // assistant message and turn/completed — so the thread hung + // "working" after all subagents finished (live-probe finding). + const rootProviderThreadId = currentProviderThreadId(yield* Ref.get(sessionRef)); + if ( + item.agentThreadId === rootProviderThreadId || + item.agentPath === "/root" || + item.agentPath === "/" + ) { + return false; + } + const activitySpawnTurnId = (yield* Ref.get(sessionRef)).activeTurnId ?? undefined; + yield* Ref.update(collabChildAgentsRef, (current) => { + const existing = current.get(item.agentThreadId); + const next = new Map(current); + // Merge-late semantics: when thread/started registered first, a + // later subAgentActivity still carries the real agentPath (and a + // derived nickname) — fill missing fields, never clobber known + // ones. spawnTurnId is registration-time-only: for an already + // registered child, a later activity during an UNRELATED turn + // must not backfill that turn as the spawn batch (review + // finding); an unset spawn turn stays unset. + next.set(item.agentThreadId, { + agentThreadId: item.agentThreadId, + nickname: + existing?.nickname ?? + item.agentPath.split("/").findLast((segment) => segment.length > 0), + role: existing?.role, + agentPath: existing?.agentPath ?? item.agentPath, + depth: existing?.depth, + parentThreadId: existing?.parentThreadId, + spawnTurnId: existing ? existing.spawnTurnId : activitySpawnTurnId, + }); + return next; + }); + const registeredChild = (yield* Ref.get(collabChildAgentsRef)).get(item.agentThreadId); + yield* emitEvent({ + kind: "notification", + threadId: options.threadId, + method: "collabAgent/activity", + ...(registeredChild?.spawnTurnId ? { turnId: registeredChild.spawnTurnId } : {}), + payload: { + agentThreadId: item.agentThreadId, + agentPath: item.agentPath, + activityKind: item.kind, + }, + }); + return true; + } + + // Interception: notifications addressed to a registered child thread + // become agent-scoped synthetic events instead of parent chatter. + const providerConversationId = readNotificationThreadId(notification); + if (!providerConversationId) { + return false; + } + // Belt-and-braces: the root thread's traffic must never be + // intercepted, whatever the registry says. + const interceptRootId = currentProviderThreadId(yield* Ref.get(sessionRef)); + if (providerConversationId === interceptRootId) { + return false; + } + const children = yield* Ref.get(collabChildAgentsRef); + const child = children.get(providerConversationId); + if (!child) { + return false; + } + const childIdentity = { + agentThreadId: child.agentThreadId, + ...(child.nickname ? { nickname: child.nickname } : {}), + ...(child.role ? { role: child.role } : {}), + ...(child.agentPath ? { agentPath: child.agentPath } : {}), + }; + switch (notification.method) { + case "turn/started": { + const childTurnId = + typeof (notification.params as { turn?: { id?: unknown } }).turn?.id === "string" + ? ((notification.params as { turn: { id: string } }).turn.id as string) + : undefined; + if (childTurnId) { + yield* Ref.update(collabChildLiveTurnsRef, (current) => { + const next = new Map(current); + next.set(child.agentThreadId, childTurnId); + return next; + }); + } + yield* emitEvent({ + kind: "notification", + threadId: options.threadId, + ...(child.spawnTurnId ? { turnId: child.spawnTurnId } : {}), + method: "collabAgent/turnStarted", + payload: childIdentity, + }); + return true; + } + case "turn/completed": + yield* Ref.update(collabChildLiveTurnsRef, (current) => { + const next = new Map(current); + next.delete(child.agentThreadId); + return next; + }); + yield* emitEvent({ + kind: "notification", + threadId: options.threadId, + ...(child.spawnTurnId ? { turnId: child.spawnTurnId } : {}), + method: "collabAgent/turnCompleted", + payload: { + ...childIdentity, + turn: notification.params.turn, + }, + }); + return true; + case "thread/status/changed": + yield* emitEvent({ + kind: "notification", + threadId: options.threadId, + ...(child.spawnTurnId ? { turnId: child.spawnTurnId } : {}), + method: "collabAgent/statusChanged", + payload: { + ...childIdentity, + status: notification.params.status, + }, + }); + return true; + case "thread/tokenUsage/updated": + yield* emitEvent({ + kind: "notification", + threadId: options.threadId, + ...(child.spawnTurnId ? { turnId: child.spawnTurnId } : {}), + method: "collabAgent/tokenUsage", + payload: { + ...childIdentity, + tokenUsage: notification.params.tokenUsage, + }, + }); + return true; + case "item/started": + case "item/completed": + yield* emitEvent({ + kind: "notification", + threadId: options.threadId, + ...(child.spawnTurnId ? { turnId: child.spawnTurnId } : {}), + method: "collabAgent/item", + payload: { + ...childIdentity, + item: notification.params.item, + }, + }); + return true; + case "thread/closed": + // The child is gone: drop its live-turn entry so a later Stop + // doesn't waste a turn/interrupt RPC on a closed thread before + // reaching the parent (review finding). + yield* Ref.update(collabChildLiveTurnsRef, (current) => { + const next = new Map(current); + next.delete(child.agentThreadId); + return next; + }); + yield* emitEvent({ + kind: "notification", + threadId: options.threadId, + ...(child.spawnTurnId ? { turnId: child.spawnTurnId } : {}), + method: "collabAgent/closed", + payload: childIdentity, + }); + return true; + case "error": { + // A child error must surface as a failed agent, not vanish into + // the default swallow (review finding: the child stayed + // "running" forever). Retryable errors (willRetry) keep the + // child RUNNING and interruptible — mirroring the root error + // handler; settling it would orphan a still-live child from + // Stop (review finding). Terminal errors clean up the live turn + // like thread/closed and reuse the statusChanged systemError + // path. + const willRetry = (notification.params as { willRetry?: boolean }).willRetry === true; + if (willRetry) { + return true; + } + yield* Ref.update(collabChildLiveTurnsRef, (current) => { + const next = new Map(current); + next.delete(child.agentThreadId); + return next; + }); + yield* emitEvent({ + kind: "notification", + threadId: options.threadId, + ...(child.spawnTurnId ? { turnId: child.spawnTurnId } : {}), + method: "collabAgent/statusChanged", + payload: { + ...childIdentity, + status: { type: "systemError" }, + }, + }); + return true; + } + default: + // Routing table decides (single source of truth, asserted + // against captured wire traces): enumerated chatter is dropped, + // everything else — including methods this build has never seen + // — falls through to the parent path rather than vanishing. + return routeCodexChildNotification(notification.method) === "drop"; + } + }); + const handleRawNotification = (notification: CodexServerNotification) => Effect.gen(function* () { const payload = notification.params; @@ -852,7 +1260,68 @@ export const makeCodexSessionRuntime = ( })(); rememberCollabReceiverTurns(collabReceiverTurns, notification, route.turnId); - if (childParentTurnId && shouldSuppressChildConversationNotification(notification.method)) { + // Interception FIRST: a registered v2 child is usually also in the + // receiver-turn map (collabAgentToolCall.receiverThreadIds), and the + // legacy suppressor below would drop its lifecycle before it could + // become synthetic collabAgent events (review finding). The + // suppressor still covers UNREGISTERED children. + if (yield* interceptCollabChildNotification(notification)) { + yield* Ref.set(collabReceiverTurnsRef, collabReceiverTurns); + return; + } + + // Suppression applies to receiver-map children (v1) AND to any + // conversation that is not the root thread. The live capture + // (codexMultiAgentWire.json) shows a child's thread/status/changed + // arriving BEFORE anything registers the child — pre-registration + // lifecycle must not reach the parent path, where the adapter maps + // thread/* onto parent session state. Root-id-known guard keeps the + // root's own early notifications flowing during session open. + const suppressRootId = currentProviderThreadId(yield* Ref.get(sessionRef)); + const foreignConversation = (() => { + const providerConversationId = readNotificationThreadId(notification); + return ( + providerConversationId !== undefined && + suppressRootId !== undefined && + providerConversationId !== suppressRootId + ); + })(); + if ( + (childParentTurnId !== undefined || foreignConversation) && + shouldSuppressChildConversationNotification(notification.method) + ) { + // Stop-everything must not depend on registration timing: a + // child's turn/started can arrive before the subAgentActivity that + // registers it (captured ordering), and suppressing it without + // remembering the live turn would leave that child running after + // Stop (review finding). Track live turns for ANY foreign + // conversation; interrupts are best-effort per child, so a + // false-positive entry costs one ignored RPC at worst. + const foreignThreadId = readNotificationThreadId(notification); + if (foreignThreadId !== undefined) { + if (notification.method === "turn/started") { + const foreignTurnId = + typeof (notification.params as { turn?: { id?: unknown } }).turn?.id === "string" + ? (notification.params as { turn: { id: string } }).turn.id + : undefined; + if (foreignTurnId) { + yield* Ref.update(collabChildLiveTurnsRef, (current) => { + const next = new Map(current); + next.set(foreignThreadId, foreignTurnId); + return next; + }); + } + } else if ( + notification.method === "turn/completed" || + notification.method === "thread/closed" + ) { + yield* Ref.update(collabChildLiveTurnsRef, (current) => { + const next = new Map(current); + next.delete(foreignThreadId); + return next; + }); + } + } yield* Ref.set(collabReceiverTurnsRef, collabReceiverTurns); return; } @@ -1313,11 +1782,14 @@ export const makeCodexSessionRuntime = ( ), ); const turnId = TurnId.make(response.turn.id); - yield* updateSession(sessionRef, { + yield* updateSession(sessionRef, (session) => ({ status: "running", - activeTurnId: turnId, + // Codex accepts follow-ups while the current turn is still + // running. The response contains the queued turn id, but + // turn/interrupt only accepts the id that is active now. + activeTurnId: session.activeTurnId ?? turnId, ...(normalizedModel ? { model: normalizedModel } : {}), - }); + })); const resumedProviderThreadId = currentProviderThreadId(yield* Ref.get(sessionRef)); return { threadId: options.threadId, @@ -1331,6 +1803,26 @@ export const makeCodexSessionRuntime = ( Effect.gen(function* () { const providerThreadId = yield* readProviderThreadId; const session = yield* Ref.get(sessionRef); + // Stop-everything: children are full threads with their own turns; + // interrupting only the parent leaves the fleet running. Interrupt + // each live child turn first, best-effort per child, BOUNDED: the + // transport awaits an unbounded Deferred per request, so a wedged + // child would otherwise block the parent interrupt forever — + // exactly during the runaway fleet where Stop matters most + // (review finding). Per-child and overall deadlines guarantee the + // parent interrupt below always runs. + const liveChildTurns = yield* Ref.get(collabChildLiveTurnsRef); + yield* Effect.forEach( + Array.from(liveChildTurns.entries()), + ([childThreadId, childTurnId]) => + client + .request("turn/interrupt", { + threadId: childThreadId, + turnId: childTurnId, + }) + .pipe(Effect.timeoutOption("3 seconds"), Effect.ignore), + { concurrency: 8, discard: true }, + ).pipe(Effect.timeoutOption("10 seconds"), Effect.ignore); const effectiveTurnId = turnId ?? session.activeTurnId; if (!effectiveTurnId) { return; diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 4dd38519c5a9..80475a5c2695 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -77,7 +77,7 @@ import { import { type CursorAdapterShape } from "../Services/CursorAdapter.ts"; import { resolveCursorAcpBaseModelId } from "./CursorProvider.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; -const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.UnknownFromJsonString); +const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); const PROVIDER = ProviderDriverKind.make("cursor"); const CURSOR_RESUME_VERSION = 1 as const; diff --git a/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts b/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts index 39d93ddc1623..f6fb557e4b43 100644 --- a/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts +++ b/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts @@ -20,7 +20,7 @@ import { writeBatchedMessages, } from "./EventNdjsonLogger.ts"; -const encodeUnknownJson = Schema.encodeUnknownSync(Schema.UnknownFromJsonString); +const encodeUnknownJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); function ownedLogPath(basePath: string, segment: string): string { const basename = NodePath.basename(basePath); diff --git a/apps/server/src/provider/Layers/EventNdjsonLogger.ts b/apps/server/src/provider/Layers/EventNdjsonLogger.ts index 8eb9d11e7a4b..e07121ea76c1 100644 --- a/apps/server/src/provider/Layers/EventNdjsonLogger.ts +++ b/apps/server/src/provider/Layers/EventNdjsonLogger.ts @@ -34,7 +34,7 @@ const DEFAULT_MAX_BUFFERED_BYTES = MEBIBYTE; const DEFAULT_MAX_BUFFERED_RECORDS = 512; const GLOBAL_THREAD_SEGMENT = "_global"; const LOG_SCOPE = "provider-observability"; -const encodeUnknownJsonString = Schema.encodeUnknownEffect(Schema.UnknownFromJsonString); +const encodeUnknownJsonString = Schema.encodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); const transientCanonicalEventTypes = new Set([ "content.delta", diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index d8c288a8292f..977cc8caaddb 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -69,7 +69,7 @@ import { import { type GrokAdapterShape } from "../Services/GrokAdapter.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; -const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.UnknownFromJsonString); +const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); const PROVIDER = ProviderDriverKind.make("grok"); const GROK_RESUME_VERSION = 1 as const; diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index ccbbce1759f0..7334cd019725 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -55,11 +55,15 @@ import { makeSqlitePersistenceLive, SqlitePersistenceMemory, } from "../../persistence/Layers/Sqlite.ts"; +import * as ServerConfig from "../../config.ts"; import * as ServerSettings from "../../serverSettings.ts"; import * as AnalyticsService from "../../telemetry/AnalyticsService.ts"; import { makeAdapterRegistryMock } from "../testUtils/providerAdapterRegistryMock.ts"; const defaultServerSettingsLayer = ServerSettings.ServerSettingsService.layerTest(); +const serverConfigTestLayer = ServerConfig.layerTest(process.cwd(), process.cwd()).pipe( + Layer.provide(NodeServices.layer), +); const asRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.make(value); const asEventId = (value: string): EventId => EventId.make(value); @@ -292,6 +296,7 @@ function makeProviderServiceLayer() { Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provideMerge(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -343,6 +348,7 @@ it.effect("ProviderServiceLive catches stopAll failures during shutdown", () => Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provideMerge(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -402,6 +408,7 @@ it.effect("ProviderServiceLive rejects new sessions for disabled providers", () Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -486,6 +493,7 @@ it.effect( Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), Layer.provide(serverSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -556,6 +564,7 @@ it.effect("ProviderServiceLive rejects new sessions for disabled custom instance Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -611,6 +620,7 @@ it.effect("ProviderServiceLive writes canonical events to the emitting thread se Layer.provide(Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, registry)), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -671,6 +681,7 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", ( Layer.provide(Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, registry)), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -737,6 +748,7 @@ it.effect( ), Layer.provide(firstDirectoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -796,6 +808,7 @@ it.effect( ), Layer.provide(secondDirectoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -927,6 +940,54 @@ routing.layer("ProviderServiceLive routing", (it) => { }), ); + it.effect("appends attachment file paths to the turn input text", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + + const session = yield* provider.startSession(asThreadId("thread-attach"), { + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + threadId: asThreadId("thread-attach"), + cwd: "/tmp/project", + runtimeMode: "full-access", + }); + + const attachment = { + type: "image" as const, + id: "thread-attach-12345678-1234-1234-1234-123456789abc", + name: "screenshot.png", + mimeType: "image/png", + sizeBytes: 123, + }; + + routing.codex.sendTurn.mockClear(); + yield* provider.sendTurn({ + threadId: session.threadId, + input: "use this screenshot", + attachments: [attachment], + }); + + const turnInput = routing.codex.sendTurn.mock.calls[0]?.[0] as ProviderSendTurnInput; + assert.equal(typeof turnInput.input, "string"); + const turnText = turnInput.input ?? ""; + assert.equal(turnText.startsWith("use this screenshot"), true); + assert.include(turnText, '[Attached image "screenshot.png" is saved at: '); + assert.equal(turnText.endsWith(`${attachment.id}.png]`), true); + + // An attachment-only turn stays valid and the injected line becomes the + // whole input text, so the agent still learns the path. + routing.codex.sendTurn.mockClear(); + yield* provider.sendTurn({ + threadId: session.threadId, + attachments: [attachment], + }); + const imageOnlyInput = routing.codex.sendTurn.mock.calls[0]?.[0] as ProviderSendTurnInput; + assert.equal(imageOnlyInput.input?.startsWith('[Attached image "screenshot.png"'), true); + + yield* provider.stopSession({ threadId: session.threadId }); + }), + ); + it.effect("recovers stale persisted sessions for rollback by resuming thread identity", () => Effect.gen(function* () { const provider = yield* ProviderService.ProviderService; @@ -1307,6 +1368,7 @@ routing.layer("ProviderServiceLive routing", (it) => { ), Layer.provide(firstDirectoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -1345,6 +1407,7 @@ routing.layer("ProviderServiceLive routing", (it) => { ), Layer.provide(secondDirectoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -1413,6 +1476,7 @@ routing.layer("ProviderServiceLive routing", (it) => { ), Layer.provide(firstDirectoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -1446,6 +1510,7 @@ routing.layer("ProviderServiceLive routing", (it) => { ), Layer.provide(secondDirectoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index ecf26a914c13..2ac00873df99 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -35,6 +35,8 @@ import * as Schema from "effect/Schema"; import * as SchemaIssue from "effect/SchemaIssue"; import * as Stream from "effect/Stream"; +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import * as ServerConfig from "../../config.ts"; import { increment, providerMetricAttributes, @@ -203,6 +205,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( options?: ProviderServiceLiveOptions, ) { const analytics = yield* Effect.service(AnalyticsService.AnalyticsService); + const serverConfig = yield* ServerConfig.ServerConfig; const eventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers; // Options-provided logger wins (test overrides); otherwise we take whatever // the `ProviderEventLoggers` tag exposes — `undefined` means "no canonical @@ -459,6 +462,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( adapter, instanceId, threadId: input.threadId, + runtimeMode: binding.runtimeMode, isActive: true, } as const; } @@ -468,6 +472,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( adapter, instanceId, threadId: input.threadId, + runtimeMode: binding.runtimeMode, isActive: false, } as const; } @@ -480,6 +485,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( adapter: recovered.adapter, instanceId, threadId: input.threadId, + runtimeMode: recovered.session.runtimeMode, isActive: true, } as const; }); @@ -629,6 +635,19 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( input.modelSelection.model.trim().length > 0, }); + // Changing runtime mode restarts the session, so the transition is only + // observable here, by diffing against the mode the previous session for + // this thread was bound to. Recording it separately is what makes the + // "started supervised, switched to full access" funnel answerable. + const previousRuntimeMode = persistedBinding?.runtimeMode; + if (previousRuntimeMode !== undefined && previousRuntimeMode !== input.runtimeMode) { + yield* analytics.record("provider.runtime_mode.changed", { + provider: sessionWithInstance.provider, + from: previousRuntimeMode, + to: input.runtimeMode, + }); + } + return sessionWithInstance; }).pipe( withMetrics({ @@ -649,16 +668,44 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( payload: rawInput, }); - const input = { - ...parsed, - attachments: parsed.attachments ?? [], - }; - if (!input.input && input.attachments.length === 0) { + const attachments = parsed.attachments ?? []; + if (!parsed.input && attachments.length === 0) { return yield* toValidationError( "ProviderService.sendTurn", "Either input text or at least one attachment is required", ); } + + // Adapters inline attachment pixels into the model prompt, but the model's + // tools cannot dereference pixels. Appending the on-disk path is what lets + // a turn like "include this screenshot in the PR" copy the actual file. + // This runs after schema decode, so the appended lines are exempt from the + // PROVIDER_SEND_TURN_MAX_INPUT_CHARS check; attachment count is capped, so + // the overhead is bounded. Unresolvable ids are skipped here and surface + // as adapter errors when the file is read for inlining. + const attachmentPathLines = attachments.flatMap((attachment) => { + const attachmentPath = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }); + return attachmentPath === null + ? [] + : [`[Attached ${attachment.type} "${attachment.name}" is saved at: ${attachmentPath}]`]; + }); + const inputTextWithAttachmentPaths = + attachmentPathLines.length === 0 + ? parsed.input + : [parsed.input, attachmentPathLines.join("\n")] + .filter((part): part is string => typeof part === "string" && part.length > 0) + .join("\n\n"); + + const input = { + ...parsed, + ...(inputTextWithAttachmentPaths !== undefined + ? { input: inputTextWithAttachmentPaths } + : {}), + attachments, + }; yield* Effect.annotateCurrentSpan({ "provider.operation": "send-turn", "provider.thread_id": input.threadId, @@ -703,6 +750,10 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( provider: routed.adapter.provider, model: input.modelSelection?.model, interactionMode: input.interactionMode, + // Session-start events alone skew runtime mode toward users who toggle + // often, since every toggle restarts the session. Recording it per turn + // gives a usage-weighted view and lets it cross with interactionMode. + runtimeMode: routed.runtimeMode, attachmentCount: input.attachments.length, hasInput: typeof input.input === "string" && input.input.trim().length > 0, }); diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index f3f4ca39d477..1281b2f70fe8 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -68,6 +68,7 @@ function makeReadModel( readonly lastError: string | null; readonly updatedAt: string; } | null; + readonly backgroundLiveness?: "working" | "monitoring" | null; }>, ) { const now = "2026-01-01T00:00:00.000Z"; @@ -109,6 +110,7 @@ function makeReadModel( latestTurn: null, messages: [], session: thread.session, + backgroundLiveness: thread.backgroundLiveness ?? null, activities: [], proposedPlans: [], checkpoints: [], @@ -135,6 +137,14 @@ describe("ProviderSessionReaper", () => { runtime = null; }); + // Shared start sequence so each test adds no manual Effect runners + // (no-manual-effect-runtime-in-tests tracks this file's legacy count). + async function startReaper() { + const reaper = await runtime!.runPromise(Effect.service(ProviderSessionReaper)); + scope = await Effect.runPromise(Scope.make("sequential")); + await Effect.runPromise(reaper.start().pipe(Scope.provide(scope))); + } + async function createHarness(input: { readonly readModel: ReturnType; readonly stopSessionImplementation?: (input: { @@ -261,9 +271,7 @@ describe("ProviderSessionReaper", () => { }), ); - const reaper = await runtime!.runPromise(Effect.service(ProviderSessionReaper)); - scope = await Effect.runPromise(Scope.make("sequential")); - await Effect.runPromise(reaper.start().pipe(Scope.provide(scope))); + await startReaper(); await waitFor(() => harness.stopSession.mock.calls.length === 1); @@ -311,9 +319,55 @@ describe("ProviderSessionReaper", () => { }), ); - const reaper = await runtime!.runPromise(Effect.service(ProviderSessionReaper)); - scope = await Effect.runPromise(Scope.make("sequential")); - await Effect.runPromise(reaper.start().pipe(Scope.provide(scope))); + await startReaper(); + await Effect.runPromise(drainFibers); + + expect(harness.stopSession).not.toHaveBeenCalled(); + const remaining = await runtime!.runPromise(repository.getByThreadId({ threadId })); + expect(Option.isSome(remaining)).toBe(true); + }); + + it("skips stale sessions while background work is still live", async () => { + const threadId = ThreadId.make("thread-reaper-background-work"); + const now = "2026-01-01T00:00:00.000Z"; + const harness = await createHarness({ + readModel: makeReadModel([ + { + id: threadId, + session: { + threadId, + status: "ready", + providerName: "claudeAgent", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + backgroundLiveness: "working", + }, + ]), + }); + const repository = await runtime!.runPromise( + Effect.service(ProviderSessionRuntime.ProviderSessionRuntimeRepository), + ); + + await runtime!.runPromise( + repository.upsert({ + threadId, + providerName: "claudeAgent", + providerInstanceId: null, + adapterKey: "claudeAgent", + runtimeMode: "full-access", + status: "running", + lastSeenAt: "2026-04-14T00:00:00.000Z", + resumeCursor: { + opaque: "resume-background-work", + }, + runtimePayload: null, + }), + ); + + await startReaper(); await Effect.runPromise(drainFibers); expect(harness.stopSession).not.toHaveBeenCalled(); @@ -360,9 +414,7 @@ describe("ProviderSessionReaper", () => { }), ); - const reaper = await runtime!.runPromise(Effect.service(ProviderSessionReaper)); - scope = await Effect.runPromise(Scope.make("sequential")); - await Effect.runPromise(reaper.start().pipe(Scope.provide(scope))); + await startReaper(); await Effect.runPromise(drainFibers); expect(harness.stopSession).not.toHaveBeenCalled(); @@ -409,9 +461,7 @@ describe("ProviderSessionReaper", () => { }), ); - const reaper = await runtime!.runPromise(Effect.service(ProviderSessionReaper)); - scope = await Effect.runPromise(Scope.make("sequential")); - await Effect.runPromise(reaper.start().pipe(Scope.provide(scope))); + await startReaper(); await Effect.runPromise(drainFibers); expect(harness.stopSession).not.toHaveBeenCalled(); @@ -495,9 +545,7 @@ describe("ProviderSessionReaper", () => { }), ); - const reaper = await runtime!.runPromise(Effect.service(ProviderSessionReaper)); - scope = await Effect.runPromise(Scope.make("sequential")); - await Effect.runPromise(reaper.start().pipe(Scope.provide(scope))); + await startReaper(); await waitFor(() => harness.stopSession.mock.calls.length === 2); @@ -578,9 +626,7 @@ describe("ProviderSessionReaper", () => { }), ); - const reaper = await runtime!.runPromise(Effect.service(ProviderSessionReaper)); - scope = await Effect.runPromise(Scope.make("sequential")); - await Effect.runPromise(reaper.start().pipe(Scope.provide(scope))); + await startReaper(); await waitFor(() => harness.stopSession.mock.calls.length === 2); diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.ts index ca396b405969..15d4f925c39e 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.ts @@ -11,6 +11,7 @@ import { ProviderSessionReaper, type ProviderSessionReaperShape, } from "../Services/ProviderSessionReaper.ts"; +import { forkParked } from "../../serverActivation.ts"; import { ProviderService } from "../Services/ProviderService.ts"; const DEFAULT_INACTIVITY_THRESHOLD_MS = 30 * 60 * 1000; @@ -70,6 +71,19 @@ const makeProviderSessionReaper = (options?: ProviderSessionReaperLiveOptions) = continue; } + // The turn can settle while background work runs on (subagent + // fleets, workflow runs, Monitor watch loops). Those live inside the + // provider process, so stopping the session would kill them silently, + // and nothing bumps lastSeenAt between turns. + if (thread?.backgroundLiveness != null) { + yield* Effect.logDebug("provider.session.reaper.skipped-background-work", { + threadId: binding.threadId, + backgroundLiveness: thread.backgroundLiveness, + idleDurationMs, + }); + continue; + } + const reaped = yield* providerService.stopSession({ threadId: binding.threadId }).pipe( Effect.tap(() => Effect.logInfo("provider.session.reaped", { @@ -105,7 +119,7 @@ const makeProviderSessionReaper = (options?: ProviderSessionReaperLiveOptions) = const start: ProviderSessionReaperShape["start"] = () => Effect.gen(function* () { - yield* Effect.forkScoped( + yield* forkParked( sweep.pipe( Effect.catch((error: unknown) => Effect.logWarning("provider.session.reaper.sweep-failed", { diff --git a/apps/server/src/provider/acp/AcpCoreRuntimeEvents.test.ts b/apps/server/src/provider/acp/AcpCoreRuntimeEvents.test.ts index 7fe25699bbc3..394ada83f763 100644 --- a/apps/server/src/provider/acp/AcpCoreRuntimeEvents.test.ts +++ b/apps/server/src/provider/acp/AcpCoreRuntimeEvents.test.ts @@ -68,6 +68,46 @@ describe("AcpCoreRuntimeEvents", () => { }); }); + it("maps generic ACP permission kinds to dynamic tool approvals", () => { + const stamp = { eventId: "event-1" as never, createdAt: "2026-03-27T00:00:00.000Z" }; + + for (const kind of ["search", "fetch", "other", "unknown", "future-tool-kind"]) { + const permissionRequest = { kind }; + const request = { + stamp, + provider: ProviderDriverKind.make("cursor"), + threadId: "thread-1" as never, + turnId: TurnId.make("turn-1"), + requestId: RuntimeRequestId.make(`request-${kind}`), + permissionRequest, + }; + + expect( + makeAcpRequestOpenedEvent({ + ...request, + detail: kind, + args: {}, + source: "acp.jsonrpc", + method: "session/request_permission", + rawPayload: { sessionId: "session-1" }, + }), + ).toMatchObject({ + type: "request.opened", + payload: { requestType: "dynamic_tool_call" }, + }); + + expect( + makeAcpRequestResolvedEvent({ + ...request, + decision: "accept", + }), + ).toMatchObject({ + type: "request.resolved", + payload: { requestType: "dynamic_tool_call" }, + }); + } + }); + it("maps ACP core plan, tool-call, and content updates", () => { const stamp = { eventId: "event-1" as never, createdAt: "2026-03-27T00:00:00.000Z" }; const turnId = TurnId.make("turn-1"); diff --git a/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts b/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts index c93e61dc37b6..bd25e9815aef 100644 --- a/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts +++ b/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts @@ -26,7 +26,7 @@ interface AcpEventStamp { type AcpCanonicalRequestType = Extract< CanonicalRequestType, - "exec_command_approval" | "file_read_approval" | "file_change_approval" | "unknown" + "exec_command_approval" | "file_read_approval" | "file_change_approval" | "dynamic_tool_call" >; function canonicalRequestTypeFromAcpKind(kind: string | "unknown"): AcpCanonicalRequestType { @@ -40,7 +40,7 @@ function canonicalRequestTypeFromAcpKind(kind: string | "unknown"): AcpCanonical case "move": return "file_change_approval"; default: - return "unknown"; + return "dynamic_tool_call"; } } diff --git a/apps/server/src/provider/acp/AcpNativeLogging.test.ts b/apps/server/src/provider/acp/AcpNativeLogging.test.ts index 8c92d523aee6..7c949e040599 100644 --- a/apps/server/src/provider/acp/AcpNativeLogging.test.ts +++ b/apps/server/src/provider/acp/AcpNativeLogging.test.ts @@ -12,7 +12,7 @@ import type { EventNdjsonLogger } from "../Layers/EventNdjsonLogger.ts"; import { makeAcpNativeLoggerFactory } from "./AcpNativeLogging.ts"; const nodeServicesIt = it.layer(NodeServices.layer); -const encodeUnknownJson = Schema.encodeUnknownSync(Schema.UnknownFromJsonString); +const encodeUnknownJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); nodeServicesIt("ACP native logging", (it) => { it.effect("records bounded request and protocol diagnostics without raw payloads", () => diff --git a/apps/server/src/provider/acp/CursorAcpExtension.test.ts b/apps/server/src/provider/acp/CursorAcpExtension.test.ts index 0a6adb75fc49..ba070c912952 100644 --- a/apps/server/src/provider/acp/CursorAcpExtension.test.ts +++ b/apps/server/src/provider/acp/CursorAcpExtension.test.ts @@ -107,6 +107,25 @@ describe("CursorAcpExtension", () => { }); }); + it("falls back to the title when content is present but blank", () => { + expect( + extractTodosAsPlan({ + toolCallId: "todos-2", + todos: [ + { id: "1", content: "", title: "Titled step", status: "pending" }, + { id: "2", content: " ", title: "Whitespace content", status: "in_progress" }, + { id: "3", content: "", title: "", status: "pending" }, + ], + merge: true, + }), + ).toEqual({ + plan: [ + { step: "Titled step", status: "pending" }, + { step: "Whitespace content", status: "inProgress" }, + ], + }); + }); + it("decodes Cursor list_available_models responses with per-model config options", () => { const decoded = CursorListAvailableModelsResponse.make({ models: [ diff --git a/apps/server/src/provider/acp/CursorAcpExtension.ts b/apps/server/src/provider/acp/CursorAcpExtension.ts index 2e131a61608b..05fc53f4a6b5 100644 --- a/apps/server/src/provider/acp/CursorAcpExtension.ts +++ b/apps/server/src/provider/acp/CursorAcpExtension.ts @@ -94,7 +94,10 @@ export function extractTodosAsPlan(params: typeof CursorUpdateTodosRequest.Type) }>; } { const plan = params.todos.flatMap((todo) => { - const step = todo.content?.trim() ?? todo.title?.trim() ?? ""; + // Fall back to the title when content is missing OR blank. `??` only + // covers a missing content, so a present-but-empty content ("" or + // whitespace) would shadow a real title and drop the step below. + const step = todo.content?.trim() || todo.title?.trim() || ""; if (step === "") { return []; } diff --git a/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts b/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts index 6208f04507e7..46577f377816 100644 --- a/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts +++ b/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts @@ -125,6 +125,31 @@ describe("parseModelsCliOutput", () => { NodeAssert.ok(model.variants); NodeAssert.equal(model.variants!["medium"] !== undefined, true); }); + + it("keeps a model whose JSON body has a slash and no interior whitespace", () => { + // OpenRouter-style: the model id contains a `/` and no string value has a + // space, so the JSON body line itself matches the slug regex. It must still + // be treated as the body of the preceding slug, not a new slug. + const stdout = [ + "openrouter/qwen/qwen3-coder", + JSON.stringify({ + id: "qwen/qwen3-coder", + providerID: "openrouter", + name: "qwen3-coder", + status: "active", + }), + ].join("\n"); + + const result = parseModelsCliOutput(stdout); + NodeAssert.equal(result.providers.size, 1); + NodeAssert.deepEqual([...result.connected], ["openrouter"]); + const provider = result.providers.get("openrouter")!; + NodeAssert.ok(provider); + const model = provider.models["qwen/qwen3-coder"]!; + NodeAssert.ok(model); + NodeAssert.equal(model.id, "qwen/qwen3-coder"); + NodeAssert.equal(model.providerID, "openrouter"); + }); }); describe("parseAgentListCliOutput", () => { diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 63fcea22d197..95a6a0045343 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -34,7 +34,7 @@ import { collectStreamAsString } from "./providerSnapshot.ts"; import * as NetService from "@t3tools/shared/Net"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; -const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.UnknownFromJsonString); +const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); const OPENCODE_EMPTY_CONFIG_CONTENT = "{}"; const OPENCODE_SERVER_READY_PREFIX = "opencode server listening"; @@ -216,7 +216,13 @@ export function parseModelsCliOutput(stdout: string): { }; for (const line of lines) { - const slugMatch = SLUG_LINE_RE.exec(line); + // A model's JSON body is a single `JSON.stringify` line starting with `{`, + // while a provider/model slug is a bare `provider/model` header. Only the + // latter can be a slug: without this guard a body line with no interior + // whitespace and a `/` in one of its values (e.g. an OpenRouter model whose + // `id` is `vendor/model`) matches SLUG_LINE_RE, so flushModel runs against + // an empty body and the model is silently dropped. + const slugMatch = line.trimStart().startsWith("{") ? null : SLUG_LINE_RE.exec(line); if (slugMatch) { flushModel(); currentSlug = slugMatch[1]!; diff --git a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs new file mode 100644 index 000000000000..f06e984c9aa5 --- /dev/null +++ b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs @@ -0,0 +1,114 @@ +// Minimal codex app-server stand-in for runtime-level collab tests. +// Speaks just enough of the protocol for CodexSessionRuntime to start a +// session, using REAL captured responses (codexMultiAgentWire.json), then +// replays a scripted multi-agent notification sequence read from the +// T3_CODEX_COLLAB_SCRIPT env var (a JSON file path) when the first turn +// starts. Runs as a plain Node process — stdlib only. +import * as NodeFS from "node:fs"; +import * as NodeReadline from "node:readline"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +const here = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); +const fixture = JSON.parse( + NodeFS.readFileSync(NodePath.join(here, "codexMultiAgentWire.json"), "utf8"), +); +const script = JSON.parse(NodeFS.readFileSync(process.env.T3_CODEX_COLLAB_SCRIPT, "utf8")); + +const write = (message) => process.stdout.write(`${JSON.stringify(message)}\n`); +let turnStartCount = 0; + +const rl = NodeReadline.createInterface({ input: process.stdin }); +rl.on("line", (line) => { + let message; + try { + message = JSON.parse(line); + } catch { + return; + } + const { id, method } = message; + if (method === "initialize") { + write({ + id, + result: { + userAgent: "t3-collab-mock/0.0.0", + codexHome: "/tmp", + platformFamily: "unix", + platformOs: "linux", + }, + }); + return; + } + if (method === "thread/start" || method === "thread/resume") { + write({ id, result: fixture.responses.threadStart }); + return; + } + if (method === "turn/start") { + const turnId = script.turnIds?.[turnStartCount]; + const turn = turnId + ? { ...fixture.responses.turnStart.turn, id: turnId } + : fixture.responses.turnStart.turn; + turnStartCount += 1; + write({ id, result: { ...fixture.responses.turnStart, turn } }); + const rootThreadId = script.rootThreadId; + if (script.onlyFirstTurnStarts !== true || turnStartCount === 1) { + write({ + jsonrpc: "2.0", + method: "turn/started", + params: { threadId: rootThreadId, turn }, + }); + } + for (const notification of script.notifications) { + write({ jsonrpc: "2.0", method: notification.method, params: notification.params }); + } + if (script.holdTurnOpen !== true) { + write({ + jsonrpc: "2.0", + method: "turn/completed", + params: { + threadId: rootThreadId, + turn: { ...turn, status: "completed" }, + }, + }); + } + return; + } + if (method === "turn/interrupt") { + // Record which thread/turn was interrupted (append-only sidecar file the + // test reads) so Stop coverage can assert every live child was reached. + // failInterruptFor simulates a dead child whose interrupt errors. + const target = message.params?.threadId; + NodeFS.appendFileSync( + `${process.env.T3_CODEX_COLLAB_SCRIPT}.interrupts`, + `${JSON.stringify({ threadId: target, turnId: message.params?.turnId })}\n`, + ); + if ( + script.expectedActiveTurnId && + message.params?.threadId === script.rootThreadId && + message.params?.turnId !== script.expectedActiveTurnId + ) { + write({ + id, + error: { + code: -32000, + message: `expected active turn id ${message.params?.turnId} but found ${script.expectedActiveTurnId}`, + }, + }); + return; + } + if (script.failInterruptFor && script.failInterruptFor === target) { + write({ id, error: { code: -32000, message: "thread already closed" } }); + return; + } + if (script.hangInterruptFor && script.hangInterruptFor === target) { + // Never respond: simulates a wedged child whose RPC neither resolves + // nor rejects. The runtime's bounded deadline must move on. + return; + } + write({ id, result: {} }); + return; + } + if (id !== undefined) { + write({ id, result: {} }); + } +}); diff --git a/apps/server/src/provider/testFixtures/codexCollabMockPeer.sh b/apps/server/src/provider/testFixtures/codexCollabMockPeer.sh new file mode 100755 index 000000000000..f6a680a49925 --- /dev/null +++ b/apps/server/src/provider/testFixtures/codexCollabMockPeer.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# Wrapper so CodexSessionRuntime can spawn the mock peer: the runtime always +# passes "app-server" as the first argument (real codex CLI subcommand); +# discard it and exec node on the .mjs peer. +shift +exec node "$(dirname "$0")/codexCollabMockPeer.mjs" "$@" diff --git a/apps/server/src/provider/testFixtures/codexMultiAgentWire.json b/apps/server/src/provider/testFixtures/codexMultiAgentWire.json new file mode 100644 index 000000000000..08316d3b6334 --- /dev/null +++ b/apps/server/src/provider/testFixtures/codexMultiAgentWire.json @@ -0,0 +1,445 @@ +{ + "capturedWith": { + "cli": "codex-cli 0.145.0", + "model": "gpt-5.6-luna", + "effort": "low" + }, + "rootThreadId": "019fcfd6-17bb-72f0-ae12-a1f2dee6e3e5", + "childThreadIds": [ + "019fcfd6-2883-77e0-9013-4410ede70371", + "019fcfd6-2ef7-7932-9ff5-dea6ad6f4daf" + ], + "notifications": [ + { + "method": "thread/started", + "params": { + "thread": { + "id": "019fcfd6-17bb-72f0-ae12-a1f2dee6e3e5", + "extra": null, + "sessionId": "019fcfd6-17bb-72f0-ae12-a1f2dee6e3e5", + "forkedFromId": null, + "parentThreadId": null, + "preview": "", + "ephemeral": false, + "historyMode": "legacy", + "modelProvider": "openai", + "createdAt": 1785898342, + "updatedAt": 1785898342, + "recencyAt": 1785898342, + "status": { + "type": "idle" + }, + "path": "/fixtures/codex/rollout.jsonl", + "cwd": "/workspace/repo", + "cliVersion": "0.145.0", + "source": "vscode", + "canAcceptDirectInput": true, + "threadSource": null, + "agentNickname": null, + "agentRole": null, + "gitInfo": null, + "name": null, + "turns": [] + } + } + }, + { + "method": "thread/status/changed", + "params": { + "threadId": "019fcfd6-17bb-72f0-ae12-a1f2dee6e3e5", + "status": { + "type": "active", + "activeFlags": [] + } + } + }, + { + "method": "turn/started", + "params": { + "threadId": "019fcfd6-17bb-72f0-ae12-a1f2dee6e3e5", + "turn": { + "id": "019fcfd6-1806-7de1-8564-de69fd55bffb", + "items": [], + "itemsView": "notLoaded", + "status": "inProgress", + "error": null, + "startedAt": 1785898342, + "completedAt": null, + "durationMs": null + } + } + }, + { + "method": "thread/status/changed", + "params": { + "threadId": "019fcfd6-2883-77e0-9013-4410ede70371", + "status": { + "type": "idle" + } + } + }, + { + "method": "item/completed", + "params": { + "item": { + "type": "subAgentActivity", + "id": "call_S2JPiq0sMnJYS7kswVdvzEjB", + "kind": "started", + "agentThreadId": "019fcfd6-2883-77e0-9013-4410ede70371", + "agentPath": "/root/alpha" + }, + "threadId": "019fcfd6-17bb-72f0-ae12-a1f2dee6e3e5", + "turnId": "019fcfd6-1806-7de1-8564-de69fd55bffb", + "completedAtMs": 1785898346687 + } + }, + { + "method": "thread/tokenUsage/updated", + "params": { + "threadId": "019fcfd6-17bb-72f0-ae12-a1f2dee6e3e5", + "turnId": "019fcfd6-1806-7de1-8564-de69fd55bffb", + "tokenUsage": { + "total": { + "totalTokens": 18261, + "inputTokens": 18228, + "cachedInputTokens": 11008, + "cacheWriteInputTokens": 0, + "outputTokens": 33, + "reasoningOutputTokens": 0 + }, + "last": { + "totalTokens": 18261, + "inputTokens": 18228, + "cachedInputTokens": 11008, + "cacheWriteInputTokens": 0, + "outputTokens": 33, + "reasoningOutputTokens": 0 + }, + "modelContextWindow": 258400 + } + } + }, + { + "method": "thread/status/changed", + "params": { + "threadId": "019fcfd6-2883-77e0-9013-4410ede70371", + "status": { + "type": "active", + "activeFlags": [] + } + } + }, + { + "method": "turn/started", + "params": { + "threadId": "019fcfd6-2883-77e0-9013-4410ede70371", + "turn": { + "id": "019fcfd6-28bf-7e00-a873-4554526dc845", + "items": [], + "itemsView": "notLoaded", + "status": "inProgress", + "error": null, + "startedAt": 1785898346, + "completedAt": null, + "durationMs": null + } + } + }, + { + "method": "thread/status/changed", + "params": { + "threadId": "019fcfd6-2ef7-7932-9ff5-dea6ad6f4daf", + "status": { + "type": "idle" + } + } + }, + { + "method": "item/completed", + "params": { + "item": { + "type": "subAgentActivity", + "id": "call_A2U7sfjmdGlUn5bn6gUNI0rl", + "kind": "started", + "agentThreadId": "019fcfd6-2ef7-7932-9ff5-dea6ad6f4daf", + "agentPath": "/root/beta" + }, + "threadId": "019fcfd6-17bb-72f0-ae12-a1f2dee6e3e5", + "turnId": "019fcfd6-1806-7de1-8564-de69fd55bffb", + "completedAtMs": 1785898348329 + } + }, + { + "method": "thread/status/changed", + "params": { + "threadId": "019fcfd6-2ef7-7932-9ff5-dea6ad6f4daf", + "status": { + "type": "active", + "activeFlags": [] + } + } + }, + { + "method": "turn/started", + "params": { + "threadId": "019fcfd6-2ef7-7932-9ff5-dea6ad6f4daf", + "turn": { + "id": "019fcfd6-2f29-79e3-aa6a-c5836a519d3f", + "items": [], + "itemsView": "notLoaded", + "status": "inProgress", + "error": null, + "startedAt": 1785898348, + "completedAt": null, + "durationMs": null + } + } + }, + { + "method": "thread/tokenUsage/updated", + "params": { + "threadId": "019fcfd6-17bb-72f0-ae12-a1f2dee6e3e5", + "turnId": "019fcfd6-1806-7de1-8564-de69fd55bffb", + "tokenUsage": { + "total": { + "totalTokens": 36576, + "inputTokens": 36510, + "cachedInputTokens": 28160, + "cacheWriteInputTokens": 0, + "outputTokens": 66, + "reasoningOutputTokens": 0 + }, + "last": { + "totalTokens": 18315, + "inputTokens": 18282, + "cachedInputTokens": 17152, + "cacheWriteInputTokens": 0, + "outputTokens": 33, + "reasoningOutputTokens": 0 + }, + "modelContextWindow": 258400 + } + } + }, + { + "method": "item/completed", + "params": { + "item": { + "type": "subAgentActivity", + "id": "call_2W1NboHClVo0VqAf1y3ZFinO", + "kind": "interacted", + "agentThreadId": "019fcfd6-17bb-72f0-ae12-a1f2dee6e3e5", + "agentPath": "/root" + }, + "threadId": "019fcfd6-2883-77e0-9013-4410ede70371", + "turnId": "019fcfd6-28bf-7e00-a873-4554526dc845", + "completedAtMs": 1785898349265 + } + }, + { + "method": "thread/tokenUsage/updated", + "params": { + "threadId": "019fcfd6-2883-77e0-9013-4410ede70371", + "turnId": "019fcfd6-28bf-7e00-a873-4554526dc845", + "tokenUsage": { + "total": { + "totalTokens": 20756, + "inputTokens": 20713, + "cachedInputTokens": 5888, + "cacheWriteInputTokens": 0, + "outputTokens": 43, + "reasoningOutputTokens": 11 + }, + "last": { + "totalTokens": 20756, + "inputTokens": 20713, + "cachedInputTokens": 5888, + "cacheWriteInputTokens": 0, + "outputTokens": 43, + "reasoningOutputTokens": 11 + }, + "modelContextWindow": 258400 + } + } + }, + { + "method": "item/started", + "params": { + "item": { + "type": "collabAgentToolCall", + "id": "call_XGMDYMm4O8ne7vg3Y6p4eSqz", + "tool": "wait", + "status": "inProgress", + "senderThreadId": "019fcfd6-17bb-72f0-ae12-a1f2dee6e3e5", + "receiverThreadIds": [], + "prompt": null, + "model": null, + "reasoningEffort": null, + "agentsStates": {} + }, + "threadId": "019fcfd6-17bb-72f0-ae12-a1f2dee6e3e5", + "turnId": "019fcfd6-1806-7de1-8564-de69fd55bffb", + "startedAtMs": 1785898349931 + } + }, + { + "method": "item/completed", + "params": { + "item": { + "type": "collabAgentToolCall", + "id": "call_XGMDYMm4O8ne7vg3Y6p4eSqz", + "tool": "wait", + "status": "completed", + "senderThreadId": "019fcfd6-17bb-72f0-ae12-a1f2dee6e3e5", + "receiverThreadIds": [], + "prompt": null, + "model": null, + "reasoningEffort": null, + "agentsStates": {} + }, + "threadId": "019fcfd6-17bb-72f0-ae12-a1f2dee6e3e5", + "turnId": "019fcfd6-1806-7de1-8564-de69fd55bffb", + "completedAtMs": 1785898349931 + } + }, + { + "method": "thread/tokenUsage/updated", + "params": { + "threadId": "019fcfd6-17bb-72f0-ae12-a1f2dee6e3e5", + "turnId": "019fcfd6-1806-7de1-8564-de69fd55bffb", + "tokenUsage": { + "total": { + "totalTokens": 54933, + "inputTokens": 54846, + "cachedInputTokens": 45312, + "cacheWriteInputTokens": 0, + "outputTokens": 87, + "reasoningOutputTokens": 0 + }, + "last": { + "totalTokens": 18357, + "inputTokens": 18336, + "cachedInputTokens": 17152, + "cacheWriteInputTokens": 0, + "outputTokens": 21, + "reasoningOutputTokens": 0 + }, + "modelContextWindow": 258400 + } + } + }, + { + "method": "thread/tokenUsage/updated", + "params": { + "threadId": "019fcfd6-2883-77e0-9013-4410ede70371", + "turnId": "019fcfd6-28bf-7e00-a873-4554526dc845", + "tokenUsage": { + "total": { + "totalTokens": 41529, + "inputTokens": 41481, + "cachedInputTokens": 26112, + "cacheWriteInputTokens": 0, + "outputTokens": 48, + "reasoningOutputTokens": 11 + }, + "last": { + "totalTokens": 20773, + "inputTokens": 20768, + "cachedInputTokens": 20224, + "cacheWriteInputTokens": 0, + "outputTokens": 5, + "reasoningOutputTokens": 0 + }, + "modelContextWindow": 258400 + } + } + }, + { + "method": "thread/status/changed", + "params": { + "threadId": "019fcfd6-2883-77e0-9013-4410ede70371", + "status": { + "type": "idle" + } + } + }, + { + "method": "turn/completed", + "params": { + "threadId": "019fcfd6-2883-77e0-9013-4410ede70371", + "turn": { + "id": "019fcfd6-28bf-7e00-a873-4554526dc845", + "items": [], + "itemsView": "notLoaded", + "status": "completed", + "error": null, + "startedAt": 1785898346, + "completedAt": 1785898350, + "durationMs": 3792 + } + } + } + ], + "responses": { + "threadStart": { + "thread": { + "id": "019fcfd6-17bb-72f0-ae12-a1f2dee6e3e5", + "extra": null, + "sessionId": "019fcfd6-17bb-72f0-ae12-a1f2dee6e3e5", + "forkedFromId": null, + "parentThreadId": null, + "preview": "", + "ephemeral": false, + "historyMode": "legacy", + "modelProvider": "openai", + "createdAt": 1785898342, + "updatedAt": 1785898342, + "recencyAt": 1785898342, + "status": { + "type": "idle" + }, + "path": "/fixtures/codex/rollout.jsonl", + "cwd": "/workspace/repo", + "cliVersion": "0.145.0", + "source": "vscode", + "canAcceptDirectInput": true, + "threadSource": null, + "agentNickname": null, + "agentRole": null, + "gitInfo": null, + "name": null, + "turns": [] + }, + "model": "gpt-5.6-sol", + "modelProvider": "openai", + "serviceTier": "default", + "cwd": "/workspace/repo", + "runtimeWorkspaceRoots": ["/workspace/repo"], + "instructionSources": ["/fixtures/scrubbed", "/workspace/repo/AGENTS.md"], + "approvalPolicy": "on-request", + "approvalsReviewer": "auto_review", + "sandbox": { + "type": "workspaceWrite", + "writableRoots": [], + "networkAccess": false, + "excludeTmpdirEnvVar": false, + "excludeSlashTmp": false + }, + "activePermissionProfile": null, + "reasoningEffort": "high", + "multiAgentMode": "explicitRequestOnly" + }, + "turnStart": { + "turn": { + "id": "019fcfd6-1806-7de1-8564-de69fd55bffb", + "items": [], + "itemsView": "notLoaded", + "status": "inProgress", + "error": null, + "startedAt": null, + "completedAt": null, + "durationMs": null + } + } + } +} diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts new file mode 100644 index 000000000000..5baf18a1ff6a --- /dev/null +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts @@ -0,0 +1,613 @@ +import { afterEach, assert, expect, it, vi } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import * as AzureDevOpsCli from "../sourceControl/AzureDevOpsCli.ts"; +import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts"; +import * as AzureDevOpsPullRequestProvider from "./AzureDevOpsPullRequestProvider.ts"; + +const mockedExecute = vi.fn(); + +const layer = it.layer( + AzureDevOpsPullRequestCli.layer.pipe( + Layer.provide( + Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({ + execute: mockedExecute, + }), + ), + ), +); + +function output(stdout: string) { + return { + exitCode: ChildProcessSpawner.ExitCode(0), + stdout, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }; +} + +function pullRequestRows( + count: number, + firstNumber: number, +): ReadonlyArray> { + return Array.from({ length: count }, (_, index) => ({ + pullRequestId: firstNumber + index, + title: `Pull request ${firstNumber + index}`, + status: "active", + sourceRefName: "refs/heads/feat/page", + targetRefName: "refs/heads/main", + creationDate: "2026-07-01T00:00:00Z", + repository: { name: "web", project: { name: "platform" } }, + url: `https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/${firstNumber + index}`, + })); +} + +function pullRequests(count: number, firstNumber: number): string { + return JSON.stringify(pullRequestRows(count, firstNumber)); +} + +/** The arguments of the nth az invocation. */ +function argsOfCall(index: number): ReadonlyArray { + const call = mockedExecute.mock.calls[index]; + assert.isDefined(call); + return call[0].args; +} + +afterEach(() => { + mockedExecute.mockReset(); +}); + +layer("AzureDevOpsPullRequestCli.layer", (it) => { + it.effect("asks for one row more than the page, to probe for a next page", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(3, 1)))); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + const batch = yield* cli.listPullRequests({ + cwd: "/w", + repository: "web", + state: "open", + involvement: "all", + viewer: "bilal@acme.dev", + limit: 10, + }); + + assert.strictEqual(batch.items.length, 3); + assert.isFalse(batch.truncated); + expect(argsOfCall(0)).toEqual([ + "repos", + "pr", + "list", + "--detect", + "true", + "--repository", + "web", + "--status", + "active", + "--include-links", + "--top", + "11", + "--only-show-errors", + "--output", + "json", + ]); + }), + ); + + it.effect("reads the page unnarrowed when asked to search, having nothing to search with", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(3, 1)))); + const provider = yield* AzureDevOpsPullRequestProvider.make; + + const page = yield* provider.listChangeRequests({ + cwd: "/w", + repository: "web", + host: "dev.azure.com", + state: "open", + involvement: "all", + viewer: "bilal@acme.dev", + limit: 10, + query: "page", + }); + + // `az repos pr list` filters by status, creator, reviewer and branch, and by no text at + // all. The rows come back as they would have without a search, for the caller to narrow; + // nothing of the search reaches the command, where it could only mean the wrong thing. + assert.strictEqual(page.items.length, 3); + expect(argsOfCall(0)).toEqual([ + "repos", + "pr", + "list", + "--detect", + "true", + "--repository", + "web", + "--status", + "active", + "--include-links", + "--top", + "11", + "--only-show-errors", + "--output", + "json", + ]); + }), + ); + + it.effect("steps over what it has already handed over, which is all Azure can be told", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(3, 1)))); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + yield* cli.listPullRequests({ + cwd: "/w", + repository: "web", + state: "open", + involvement: "all", + viewer: "bilal@acme.dev", + limit: 10, + // The instant is the same cursor every other host reads; Azure has no filter for it and + // takes the count instead. + cursor: { updatedBefore: "2026-07-02T00:00:00Z", delivered: 20 }, + }); + + const args = argsOfCall(0); + expect(args).toContain("--skip"); + assert.strictEqual(args[args.indexOf("--skip") + 1], "20"); + expect(args).not.toContain("2026-07-02T00:00:00Z"); + }), + ); + + it.effect("reports truncation from the extra row", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(11, 1)))); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + const batch = yield* cli.listPullRequests({ + cwd: "/w", + repository: "web", + state: "open", + involvement: "all", + viewer: "bilal@acme.dev", + limit: 10, + }); + + assert.strictEqual(batch.items.length, 10); + assert.isTrue(batch.truncated); + assert.strictEqual(batch.cursorAdvance, 10); + }), + ); + + it.effect("advances by malformed raw rows and keeps reading until the page is full", () => + Effect.gen(function* () { + mockedExecute + .mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { pullRequestId: "malformed" }, + pullRequestRows(1, 1)[0], + { pullRequestId: "also malformed" }, + ]), + ), + ), + ) + .mockReturnValueOnce(Effect.succeed(output(pullRequests(2, 2)))); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + const batch = yield* cli.listPullRequests({ + cwd: "/w", + repository: "web", + state: "open", + involvement: "all", + viewer: "bilal@acme.dev", + limit: 2, + }); + + expect(batch.items.map((item) => item.number)).toEqual([1, 2]); + assert.isTrue(batch.truncated); + // Three raw rows from the first request and one from the second produced this page. + assert.strictEqual(batch.cursorAdvance, 4); + const secondArgs = argsOfCall(1); + assert.strictEqual(secondArgs[secondArgs.indexOf("--skip") + 1], "3"); + assert.strictEqual(secondArgs[secondArgs.indexOf("--top") + 1], "2"); + }), + ); + + it.effect("narrows to the author on the authored tab", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + yield* cli.listPullRequests({ + cwd: "/w", + repository: "web", + state: "closed", + involvement: "authored", + viewer: "bilal@acme.dev", + limit: 10, + }); + + expect(argsOfCall(0)).toContain("--creator"); + expect(argsOfCall(0)).toContain("bilal@acme.dev"); + // Azure calls a closed pull request abandoned. + expect(argsOfCall(0)).toContain("abandoned"); + }), + ); + + it.effect("asks Azure for every status on the All tab", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + yield* cli.listPullRequests({ + cwd: "/w", + repository: "web", + state: "all", + involvement: "all", + viewer: "bilal@acme.dev", + limit: 10, + }); + + expect(argsOfCall(0)).toContain("--status"); + expect(argsOfCall(0)).toContain("all"); + }), + ); + + it.effect("narrows to the reviewer on the reviewing tab", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + yield* cli.listPullRequests({ + cwd: "/w", + repository: "web", + state: "open", + involvement: "reviewing", + viewer: "bilal@acme.dev", + limit: 10, + }); + + expect(argsOfCall(0)).toContain("--reviewer"); + }), + ); + + it.effect("reads the signed-in account, which az reports as a bare value", () => + Effect.gen(function* () { + // `--query user` unwraps the object, so the wrapper has to put it back. + mockedExecute.mockReturnValueOnce( + // @effect-diagnostics-next-line preferSchemaOverJson:off + Effect.succeed(output(JSON.stringify({ name: "bilal@acme.dev", type: "user" }))), + ); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + const viewer = yield* cli.getViewer({ cwd: "/w" }); + + assert.strictEqual(viewer, "bilal@acme.dev"); + expect(argsOfCall(0)).toEqual([ + "account", + "show", + "--query", + "user", + "--only-show-errors", + "--output", + "json", + ]); + }), + ); + + it.effect("fails when nobody is signed in", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(""))); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + const error = yield* Effect.flip(cli.getViewer({ cwd: "/w" })); + + assert.strictEqual(error._tag, "AzureDevOpsViewerUnavailableError"); + }), + ); + + it.effect("completes a pull request to merge it, squashing only when asked", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + yield* cli.runPullRequestAction({ + cwd: "/w", + number: 42, + action: "merge", + mergeMethod: "squash", + }); + + expect(argsOfCall(0)).toEqual([ + "repos", + "pr", + "update", + "--detect", + "true", + "--id", + "42", + "--status", + "completed", + "--squash", + "true", + "--only-show-errors", + "--output", + "json", + ]); + }), + ); + + it.effect("stores the squash choice with an auto-completion, as a merge now does", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + yield* cli.runPullRequestAction({ + cwd: "/w", + number: 42, + action: "enable-auto-merge", + mergeMethod: "squash", + }); + + expect(argsOfCall(0)).toEqual([ + "repos", + "pr", + "update", + "--detect", + "true", + "--id", + "42", + "--auto-complete", + "true", + "--squash", + "true", + "--only-show-errors", + "--output", + "json", + ]); + }), + ); + + it.effect.each([ + { action: "enable-auto-merge", expected: ["--auto-complete", "true", "--squash", "false"] }, + { action: "disable-auto-merge", expected: ["--auto-complete", "false"] }, + { action: "draft", expected: ["--draft", "true"] }, + { action: "ready", expected: ["--draft", "false"] }, + { action: "close", expected: ["--status", "abandoned"] }, + { action: "reopen", expected: ["--status", "active"] }, + ] as const)("moves a pull request with $action", ({ action, expected }) => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + yield* cli.runPullRequestAction({ cwd: "/w", number: 42, action }); + + expect(argsOfCall(0)).toEqual([ + "repos", + "pr", + "update", + "--detect", + "true", + "--id", + "42", + ...expected, + "--only-show-errors", + "--output", + "json", + ]); + }), + ); + + it.effect.each([ + { name: "a title", rewrite: { title: "Add the page" }, expected: ["--title=Add the page"] }, + { + name: "a description", + rewrite: { body: "Why the page changed" }, + expected: ["--description=Why the page changed"], + }, + { + name: "both", + rewrite: { title: "Add the page", body: "Why the page changed" }, + expected: ["--title=Add the page", "--description=Why the page changed"], + }, + ] as const)("rewrites $name, sending nothing it was not given", ({ rewrite, expected }) => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + yield* cli.updatePullRequest({ cwd: "/w", number: 42, ...rewrite }); + + expect(argsOfCall(0)).toEqual([ + "repos", + "pr", + "update", + "--detect", + "true", + "--id", + "42", + ...expected, + "--only-show-errors", + "--output", + "json", + ]); + }), + ); + + it.effect("sends a description that starts with a dash as one value, not as a flag", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + yield* cli.updatePullRequest({ + cwd: "/w", + number: 42, + body: "- rewrote the page\n- kept the rest", + }); + + // One argument, so the leading dash of an ordinary bullet list never reaches az as a flag, + // and the whole text stays together where `--description` would otherwise take several. + expect(argsOfCall(0)).toContain("--description=- rewrote the page\n- kept the rest"); + }), + ); + + it.effect("rewrites through the provider, which says it takes one", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); + const provider = yield* AzureDevOpsPullRequestProvider.make; + + // False for a remark because nothing here can post one, so there is none to rewrite. + expect(provider.capabilities.edit).toEqual({ changeRequest: true, comment: false }); + assert.isDefined(provider.updateChangeRequest); + yield* provider.updateChangeRequest({ + cwd: "/w", + repository: "web", + host: "dev.azure.com", + number: 42, + title: "Add the page", + }); + + expect(argsOfCall(0)).toContain("--title=Add the page"); + expect(argsOfCall(0)).not.toContain("--description"); + }), + ); + + it.effect("reads the conversation through the REST API, pinned to a version", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + value: [ + { + id: 1, + comments: [ + { id: 1, content: "Looks good.", publishedDate: "2026-07-02T00:00:00Z" }, + ], + }, + ], + }), + ), + ), + ); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + const comments = yield* cli.listThreads({ + cwd: "/w", + threadsUrl: "https://dev.azure.com/acme/platform/_apis/git/r/web/pullRequests/42/threads", + }); + + assert.strictEqual(comments.length, 1); + expect(argsOfCall(0)).toContain("rest"); + expect(argsOfCall(0)).toContain( + "https://dev.azure.com/acme/platform/_apis/git/r/web/pullRequests/42/threads?api-version=7.1", + ); + }), + ); + + it.effect("reports a pull request it cannot place as its own outcome", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + // Well-formed, but with nothing to build a link from: not a decode failure. + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + pullRequestId: 42, + title: "Add the page", + sourceRefName: "refs/heads/feat/page", + targetRefName: "refs/heads/main", + creationDate: "2026-07-01T00:00:00Z", + }), + ), + ), + ); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + const error = yield* Effect.flip(cli.getPullRequest({ cwd: "/w", number: 42 })); + + assert.strictEqual(error._tag, "AzureDevOpsPullRequestIncompleteError"); + }), + ); + + it.effect("fails the read when az returns something unreadable", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"message":"not found"}'))); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + const error = yield* Effect.flip(cli.getPullRequest({ cwd: "/w", number: 42 })); + + assert.strictEqual(error._tag, "AzureDevOpsPullRequestReadError"); + }), + ); + + it.effect("adds reviewers with the one command Azure has for it", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + yield* cli.setPullRequestReviewers({ + cwd: "/w", + number: 42, + reviewers: ["octocat@acme.test", "hubot@acme.test"], + requested: true, + }); + + expect(argsOfCall(0)).toEqual([ + "repos", + "pr", + "reviewer", + "add", + "--detect", + "true", + "--id", + "42", + "--reviewers", + "octocat@acme.test", + "hubot@acme.test", + "--only-show-errors", + "--output", + "json", + ]); + }), + ); + + it.effect("takes a reviewer off the pull request with the same command's counterpart", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + yield* cli.setPullRequestReviewers({ + cwd: "/w", + number: 42, + reviewers: ["octocat@acme.test"], + requested: false, + }); + + expect(argsOfCall(0)).toContain("remove"); + }), + ); + + it.effect("refuses a reviewer az would read as a flag, before running anything", () => + Effect.gen(function* () { + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + const error = yield* Effect.flip( + cli.setPullRequestReviewers({ + cwd: "/w", + number: 42, + reviewers: ["--query"], + requested: true, + }), + ); + + assert.strictEqual(error._tag, "AzureDevOpsReviewerNameError"); + assert.strictEqual(mockedExecute.mock.calls.length, 0); + }), + ); +}); diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts new file mode 100644 index 000000000000..549a172b3646 --- /dev/null +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts @@ -0,0 +1,527 @@ +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 { + PullRequestAction, + PullRequestComment, + PullRequestInvolvement, + PullRequestListState, + PullRequestMergeMethod, +} from "@t3tools/contracts"; + +import * as AzureDevOpsCli from "../sourceControl/AzureDevOpsCli.ts"; +import { + decodePullRequestJson, + decodePullRequestListJson, + decodeThreadsJson, + decodeViewerJson, + type AzureDevOpsPullRequest, +} from "./azureDevOpsPullRequestJson.ts"; +import type { ProviderListCursor } from "./PullRequestProvider.ts"; + +/** + * Names the read that produced unusable output, so a failure reports the call it came from + * rather than borrowing another operation's message. + */ +export class AzureDevOpsPullRequestReadError extends Schema.TaggedErrorClass()( + "AzureDevOpsPullRequestReadError", + { + command: Schema.Literal("az"), + cwd: Schema.String, + operation: Schema.String, + cause: Schema.Defect(), + }, +) { + get detail(): string { + return `Azure CLI returned an unreadable ${this.operation} response.`; + } + + override get message(): string { + return `Azure CLI failed in ${this.operation}: ${this.detail}`; + } +} + +/** Not a decode failure: az answered, the account it answered for just has no name. */ +export class AzureDevOpsViewerUnavailableError extends Schema.TaggedErrorClass()( + "AzureDevOpsViewerUnavailableError", + { + command: Schema.Literal("az"), + cwd: Schema.String, + }, +) { + get detail(): string { + return "Azure CLI returned no account for the current sign-in."; + } + + override get message(): string { + return `Azure CLI failed in getViewer: ${this.detail}`; + } +} + +/** + * Not a decode failure either: az answered with a well-formed pull request that simply carries + * no branch or link, which is a response this cannot place rather than one it cannot read. + */ +export class AzureDevOpsPullRequestIncompleteError extends Schema.TaggedErrorClass()( + "AzureDevOpsPullRequestIncompleteError", + { + command: Schema.Literal("az"), + cwd: Schema.String, + number: Schema.Int, + }, +) { + get detail(): string { + return "Azure DevOps returned no branch or link for the pull request."; + } + + override get message(): string { + return `Azure CLI failed in getPullRequest: ${this.detail}`; + } +} + +/** + * Not a decode failure: the reader named a reviewer `az` would read as a flag of its own. The + * reviewers travel as argv rather than in a request body — `az repos pr reviewer` takes them no + * other way — so anything that could leave the value position is refused rather than sent. + */ +export class AzureDevOpsReviewerNameError extends Schema.TaggedErrorClass()( + "AzureDevOpsReviewerNameError", + { + command: Schema.Literal("az"), + cwd: Schema.String, + }, +) { + get detail(): string { + return "A reviewer is named by an email address or an identity id."; + } + + override get message(): string { + return `Azure CLI failed in setPullRequestReviewers: ${this.detail}`; + } +} + +export type AzureDevOpsPullRequestCliError = + | AzureDevOpsCli.AzureDevOpsCliError + | AzureDevOpsPullRequestReadError + | AzureDevOpsPullRequestIncompleteError + | AzureDevOpsReviewerNameError + | AzureDevOpsViewerUnavailableError; + +/** The version every REST call below is pinned to, so a new default cannot reshape a response. */ +const REST_API_VERSION = "7.1"; + +export class AzureDevOpsPullRequestCli extends Context.Service< + AzureDevOpsPullRequestCli, + { + readonly getViewer: (input: { + readonly cwd: string; + }) => Effect.Effect; + + readonly listPullRequests: (input: { + readonly cwd: string; + readonly repository: string; + readonly state: PullRequestListState; + readonly involvement: PullRequestInvolvement; + readonly viewer: string; + readonly limit: number; + /** + * Where to carry on from. Azure has no date filter for a pull request listing, so the only + * part of a cursor it can use is how many rows have already been handed over. + */ + readonly cursor?: ProviderListCursor | undefined; + }) => Effect.Effect< + { + readonly items: ReadonlyArray; + readonly truncated: boolean; + /** Raw Azure rows consumed to produce this page, including malformed rows. */ + readonly cursorAdvance: number; + }, + AzureDevOpsPullRequestCliError + >; + + readonly getPullRequest: (input: { + readonly cwd: string; + readonly number: number; + }) => Effect.Effect; + + /** Threads are not reachable through `az repos pr`, so they come from the REST API. */ + readonly listThreads: (input: { + readonly cwd: string; + readonly threadsUrl: string; + }) => Effect.Effect, AzureDevOpsPullRequestCliError>; + + readonly runPullRequestAction: (input: { + readonly cwd: string; + readonly number: number; + readonly action: PullRequestAction; + readonly mergeMethod?: PullRequestMergeMethod; + }) => Effect.Effect; + + /** Rewrites the pull request's own words, through the same command that moves it. */ + readonly updatePullRequest: (input: { + readonly cwd: string; + readonly number: number; + readonly title?: string | undefined; + readonly body?: string | undefined; + }) => Effect.Effect; + + /** + * Adds reviewers to a pull request, or takes them off it. `az repos pr reviewer` is the whole + * of what Azure offers here: it adds and removes named identities, and has no counterpart that + * says who could be named. + */ + readonly setPullRequestReviewers: (input: { + readonly cwd: string; + readonly number: number; + readonly reviewers: ReadonlyArray; + readonly requested: boolean; + }) => Effect.Effect; + } +>()("t3/pullRequest/AzureDevOpsPullRequestCli") {} + +function statusArgs(state: PullRequestListState): ReadonlyArray { + switch (state) { + case "open": + return ["--status", "active"]; + case "merged": + return ["--status", "completed"]; + case "closed": + return ["--status", "abandoned"]; + case "all": + return ["--status", "all"]; + } +} + +function involvementArgs(input: { + readonly involvement: PullRequestInvolvement; + readonly viewer: string; +}): ReadonlyArray { + switch (input.involvement) { + case "authored": + return ["--creator", input.viewer]; + case "reviewing": + return ["--reviewer", input.viewer]; + case "all": + return []; + } +} + +/** + * Azure moves a pull request by setting its state rather than by named commands: completing it + * is the merge, abandoning it is the close, and reactivating it is the reopen. Squashing is a + * completion option rather than a strategy of its own. + */ +function actionArgs( + action: PullRequestAction, + mergeMethod: PullRequestMergeMethod | undefined, +): ReadonlyArray { + switch (action) { + case "merge": + return ["--status", "completed", "--squash", mergeMethod === "squash" ? "true" : "false"]; + // Auto-complete is Azure's own name for it: the pull request stays active and Azure completes + // it once its policies pass. The squash choice is stored with it, as it is for a merge now. + case "enable-auto-merge": + return ["--auto-complete", "true", "--squash", mergeMethod === "squash" ? "true" : "false"]; + case "disable-auto-merge": + return ["--auto-complete", "false"]; + case "ready": + return ["--draft", "false"]; + case "draft": + return ["--draft", "true"]; + case "close": + return ["--status", "abandoned"]; + // Never reached: this host does not declare the action, so nothing offers it. + case "update-branch": + return []; + case "reopen": + return ["--status", "active"]; + } +} + +/** + * A reviewer Azure could be given: an email address, a display name or an identity guid, and + * nothing that starts with a dash. The dash is the whole point — these are argv, and a value that + * looks like a flag stops being a value. + */ +function isReviewerName(value: string): boolean { + const name = value.trim(); + return name.length > 0 && !name.startsWith("-"); +} + +export const make = Effect.gen(function* () { + const azure = yield* AzureDevOpsCli.AzureDevOpsCli; + + // Every command resolves the organization, project and repository from the checkout, which is + // what the rest of the Azure wrapper does. The remote takes three shapes and only `az` knows + // how to read all of them. + const detectArgs = ["--detect", "true"] as const; + + const executeJson = (input: { readonly cwd: string; readonly args: ReadonlyArray }) => + azure.execute({ + cwd: input.cwd, + args: [...input.args, "--only-show-errors", "--output", "json"], + }); + + /** + * Azure pages by raw offset. Keep reading when malformed rows leave the decoded page short, and + * retain the raw count so the next public cursor skips every row this walk consumed. + */ + const listPullRequestPage = (input: { + readonly cwd: string; + readonly repository: string; + readonly state: PullRequestListState; + readonly involvement: PullRequestInvolvement; + readonly viewer: string; + readonly limit: number; + readonly skip: number; + readonly cursorAdvance: number; + readonly items: ReadonlyArray; + }): Effect.Effect< + { + readonly items: ReadonlyArray; + readonly truncated: boolean; + readonly cursorAdvance: number; + }, + AzureDevOpsPullRequestCliError + > => { + const remaining = input.limit - input.items.length; + const top = remaining + 1; + return executeJson({ + cwd: input.cwd, + args: [ + "repos", + "pr", + "list", + ...detectArgs, + "--repository", + input.repository, + ...statusArgs(input.state), + ...involvementArgs(input), + // A web link per row, which is the only url that needs no assembling. + "--include-links", + ...(input.skip === 0 ? [] : ["--skip", String(input.skip)]), + "--top", + String(top), + ], + }).pipe( + Effect.flatMap((result) => { + const raw = result.stdout.trim(); + if (raw.length === 0) { + return Effect.succeed({ + items: input.items, + truncated: false, + cursorAdvance: input.cursorAdvance, + }); + } + const decoded = decodePullRequestListJson(raw); + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new AzureDevOpsPullRequestReadError({ + command: "az", + cwd: input.cwd, + operation: "listPullRequests", + cause: decoded.failure, + }), + ); + } + + const lastItemIndex = decoded.success.rawIndexes[remaining - 1]; + if (lastItemIndex !== undefined) { + const consumed = lastItemIndex + 1; + return Effect.succeed({ + items: [...input.items, ...decoded.success.items.slice(0, remaining)], + // A full raw response may have more rows even when malformed entries used the probe. + truncated: consumed < decoded.success.rawCount || decoded.success.rawCount === top, + cursorAdvance: input.cursorAdvance + consumed, + }); + } + + const items = [...input.items, ...decoded.success.items]; + if (decoded.success.rawCount < top) { + return Effect.succeed({ + items, + truncated: false, + cursorAdvance: input.cursorAdvance + decoded.success.rawCount, + }); + } + return listPullRequestPage({ + ...input, + skip: input.skip + decoded.success.rawCount, + cursorAdvance: input.cursorAdvance + decoded.success.rawCount, + items, + }); + }), + ); + }; + + return AzureDevOpsPullRequestCli.of({ + getViewer: (input) => + executeJson({ cwd: input.cwd, args: ["account", "show", "--query", "user"] }).pipe( + Effect.flatMap((result): Effect.Effect => { + // `--query user` narrows the payload to the account, so it is nested back under the + // key the decoder reads to keep one shape for the signed-in user. + const decoded = decodeViewerJson(`{"user":${result.stdout.trim() || "null"}}`); + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new AzureDevOpsPullRequestReadError({ + command: "az", + cwd: input.cwd, + operation: "getViewer", + cause: decoded.failure, + }), + ); + } + return decoded.success === null + ? Effect.fail(new AzureDevOpsViewerUnavailableError({ command: "az", cwd: input.cwd })) + : Effect.succeed(decoded.success); + }), + ), + + listPullRequests: (input) => + listPullRequestPage({ + cwd: input.cwd, + repository: input.repository, + state: input.state, + involvement: input.involvement, + viewer: input.viewer, + limit: input.limit, + // Azure counts rather than filters, so a slice carries on by stepping over every raw row + // the prior slice consumed. That is an offset into a list that can shift underneath it: + // a pull request opened between two slices moves everything down one, and the row on the + // seam is the one that pays for it. + skip: input.cursor?.delivered ?? 0, + cursorAdvance: 0, + items: [], + }), + + getPullRequest: (input) => + executeJson({ + cwd: input.cwd, + args: ["repos", "pr", "show", ...detectArgs, "--id", String(input.number)], + }).pipe( + Effect.flatMap( + (result): Effect.Effect => { + const decoded = decodePullRequestJson(result.stdout.trim()); + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new AzureDevOpsPullRequestReadError({ + command: "az", + cwd: input.cwd, + operation: "getPullRequest", + cause: decoded.failure, + }), + ); + } + // Null means Azure answered with too little to place the pull request. Nothing + // failed underneath it, so it is its own outcome rather than a decode failure. + return decoded.success === null + ? Effect.fail( + new AzureDevOpsPullRequestIncompleteError({ + command: "az", + cwd: input.cwd, + number: input.number, + }), + ) + : Effect.succeed(decoded.success); + }, + ), + ), + + listThreads: (input) => + executeJson({ + cwd: input.cwd, + args: [ + "rest", + "--method", + "get", + "--url", + `${input.threadsUrl}?api-version=${REST_API_VERSION}`, + ], + }).pipe( + Effect.flatMap((result) => { + const decoded = decodeThreadsJson(result.stdout.trim()); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail( + new AzureDevOpsPullRequestReadError({ + command: "az", + cwd: input.cwd, + operation: "listThreads", + cause: decoded.failure, + }), + ); + }), + ), + + setPullRequestReviewers: (input) => + input.reviewers.some((reviewer) => !isReviewerName(reviewer)) + ? Effect.fail(new AzureDevOpsReviewerNameError({ command: "az", cwd: input.cwd })) + : azure + .execute({ + cwd: input.cwd, + args: [ + "repos", + "pr", + "reviewer", + input.requested ? "add" : "remove", + ...detectArgs, + "--id", + String(input.number), + // One `--reviewers` takes them all, because az reads the flag as a list and a + // second one would replace the first rather than add to it. + "--reviewers", + ...input.reviewers, + "--only-show-errors", + "--output", + "json", + ], + }) + .pipe(Effect.asVoid), + + runPullRequestAction: (input) => + azure + .execute({ + cwd: input.cwd, + args: [ + "repos", + "pr", + "update", + ...detectArgs, + "--id", + String(input.number), + ...actionArgs(input.action, input.mergeMethod), + "--only-show-errors", + "--output", + "json", + ], + }) + .pipe(Effect.asVoid), + + updatePullRequest: (input) => + azure + .execute({ + cwd: input.cwd, + args: [ + "repos", + "pr", + "update", + ...detectArgs, + "--id", + String(input.number), + // One argument rather than a flag and a value beside it: a description usually opens + // with a bullet, and az reads a dash in the next argv slot as a flag of its own. + // `--description` also takes several strings, and this keeps the whole text as one. + ...(input.title === undefined ? [] : [`--title=${input.title}`]), + ...(input.body === undefined ? [] : [`--description=${input.body}`]), + "--only-show-errors", + "--output", + "json", + ], + }) + .pipe(Effect.asVoid), + }); +}); + +export const layer = Layer.effect(AzureDevOpsPullRequestCli, make); diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts new file mode 100644 index 000000000000..51d8f74bbc45 --- /dev/null +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { AZURE_DEVOPS_VIEWER_PERMISSIONS } from "./AzureDevOpsPullRequestProvider.ts"; + +describe("azure devops viewer permissions", () => { + it("offers every action to whoever is signed in, because Azure names no permission", () => { + // The same answer for a viewer who can write, one who can only read, and an author with read + // access: `az repos pr show` and `az repos pr list` carry nothing about the caller's standing, + // and an unknown permission is granted rather than guessed away. Azure refuses the ones it + // will not allow, at the moment they are taken, in words this could not have written. + expect(AZURE_DEVOPS_VIEWER_PERMISSIONS).toEqual({ + actions: [ + "merge", + "ready", + "draft", + "close", + "reopen", + "enable-auto-merge", + "disable-auto-merge", + ], + // False because the host itself cannot post one, not because this viewer may not. + comment: false, + resolve: false, + verdicts: [], + // True because `az repos pr reviewer` does take one, and Azure says nothing about who may. + requestReviewers: true, + }); + }); +}); diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts new file mode 100644 index 000000000000..5607b0cd4f5e --- /dev/null +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts @@ -0,0 +1,272 @@ +import * as Effect from "effect/Effect"; +import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3tools/contracts"; + +import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts"; +import { + PullRequestProviderError, + type ProviderChangeRequest, + type ProviderChangeRequestActivity, + type ProviderChangeRequestDetail, + type PullRequestProviderApi, +} from "./PullRequestProvider.ts"; +import type { AzureDevOpsPullRequest } from "./azureDevOpsPullRequestJson.ts"; + +const CAPABILITIES: PullRequestCapabilities = { + // `az repos pr` has no diff command, and the REST route reports changed files without their + // contents, so there is no patch to show. The Code tab is hidden rather than empty. + diff: false, + // Reading a conversation is a plain REST read, but posting one is not something this can + // claim without having run it, so the composer stays hidden. + comment: false, + actions: [ + "merge", + "ready", + "draft", + "close", + "reopen", + "enable-auto-merge", + "disable-auto-merge", + ], + // Azure squashes as a completion option; it has no rebase strategy of its own. + mergeMethods: ["merge", "squash"], + // `az repos pr list` filters by status, creator, reviewer and branch, and by no text at all. + search: false, + reactions: false, + // With no patch to show there are no lines to write against, so nothing here is offered. + review: { inlineComment: false, reply: false, resolve: false, verdicts: [] }, + // `az repos pr reviewer add` and `remove` name identities, and nothing anywhere in `az repos` + // lists the ones this repository could name — that lives behind the identity and graph APIs, a + // different service with its own permissions. So the page takes a name here rather than being + // handed a menu built out of a guess. + reviewers: { request: true, listCandidates: false }, + // A new title and description travel on the same `az repos pr update` that moves a pull request. + // Rewriting a remark is false for the same reason posting one is: this cannot put a remark on + // Azure DevOps at all, so there is nothing here it could rewrite either. + edit: { changeRequest: true, comment: false }, +}; + +/** + * Everything this host offers, granted to whoever is signed in. Azure DevOps states no permission + * anywhere `az repos pr show` or `az repos pr list` reach: the answer lives in the security + * namespaces, behind identity descriptors and token paths that would be several calls per pull + * request to resolve. + * + * So the actions stay live and a viewer who may not take one is told so by Azure, at the moment + * they try. That is the safer half of an unknown: hiding a control from someone entitled to it + * leaves them no way through and no reason given. + */ +export const AZURE_DEVOPS_VIEWER_PERMISSIONS: PullRequestViewerPermissions = { + actions: CAPABILITIES.actions, + comment: CAPABILITIES.comment, + resolve: CAPABILITIES.review.resolve, + verdicts: CAPABILITIES.review.verdicts, + requestReviewers: CAPABILITIES.reviewers.request, +}; + +/** The CLI tags that mean the tool itself is unusable, rather than one request failing. */ +function reasonFor( + error: AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCliError, +): PullRequestProviderError["reason"] { + if (error._tag === "AzureDevOpsCliUnavailableError") return "missing-tool"; + if (error._tag === "AzureDevOpsCliAuthenticationError") return "unauthenticated"; + return "failed"; +} + +function toChangeRequest(pullRequest: AzureDevOpsPullRequest): ProviderChangeRequest { + return { + number: pullRequest.number, + title: pullRequest.title, + url: pullRequest.url, + author: pullRequest.author, + headBranch: pullRequest.headBranch, + baseBranch: pullRequest.baseBranch, + state: pullRequest.state, + isDraft: pullRequest.isDraft, + mergeability: pullRequest.mergeability, + // Azure reports no line counts on a pull request, and with no patch to read there is + // nothing to count them from either. + additions: 0, + deletions: 0, + createdAt: pullRequest.createdAt, + updatedAt: pullRequest.updatedAt, + reviewRequestLogins: pullRequest.reviewRequestLogins, + // Azure keeps labels on work items rather than on the pull request. + labels: [], + }; +} + +export const make = Effect.gen(function* () { + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + const fail = + (operation: string) => (error: AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCliError) => + new PullRequestProviderError({ + provider: "azure-devops", + operation, + reason: reasonFor(error), + detail: error.detail, + cause: error, + }); + + /** Refuses what the capabilities already say this host cannot do. */ + const unsupported = (operation: string) => + Effect.fail( + new PullRequestProviderError({ + provider: "azure-devops", + operation, + reason: "failed", + detail: "Azure DevOps reviews cannot be written from here yet.", + }), + ); + + const provider: PullRequestProviderApi = { + kind: "azure-devops", + capabilities: CAPABILITIES, + + getViewer: (input) => + cli.getViewer({ cwd: input.cwd }).pipe(Effect.mapError(fail("getViewer"))), + + // `input.query` is deliberately dropped: `az repos pr list` filters by status, creator, + // reviewer and branch, and has nothing that matches text. Sending it as one of those would + // narrow by the wrong thing, so the page comes back unnarrowed and the caller filters it. + listChangeRequests: (input) => + cli + .listPullRequests({ + cwd: input.cwd, + repository: input.repository, + state: input.state, + involvement: input.involvement, + viewer: input.viewer, + limit: input.limit, + cursor: input.cursor, + }) + .pipe( + Effect.mapError(fail("listChangeRequests")), + Effect.map((batch) => ({ + items: batch.items.map(toChangeRequest), + truncated: batch.truncated, + cursorAdvance: batch.cursorAdvance, + // Azure answers in one order whether or not it is being carried on from, so a slice + // can always be stepped past — by counting, which is all Azure offers. + continues: true, + })), + ), + + getChangeRequest: (input) => + cli.getPullRequest({ cwd: input.cwd, number: input.number }).pipe( + Effect.mapError(fail("getChangeRequest")), + Effect.map( + (pullRequest): ProviderChangeRequestDetail => ({ + ...toChangeRequest(pullRequest), + body: pullRequest.body, + changedFiles: 0, + mergedAt: pullRequest.state === "merged" ? pullRequest.closedAt : null, + closedAt: pullRequest.state === "closed" ? pullRequest.closedAt : null, + reviewers: pullRequest.reviewers, + checks: [], + mergeCapabilities: { merge: true, squash: true, rebase: false }, + viewerPermissions: AZURE_DEVOPS_VIEWER_PERMISSIONS, + autoMergeEnabled: pullRequest.autoMergeEnabled, + }), + ), + ), + + getChangeRequestActivity: (input) => + cli.getPullRequest({ cwd: input.cwd, number: input.number }).pipe( + Effect.mapError(fail("getChangeRequestActivity")), + Effect.flatMap((pullRequest) => + (pullRequest.threadsUrl === null + ? Effect.succeed({ comments: [], truncated: true }) + : cli.listThreads({ cwd: input.cwd, threadsUrl: pullRequest.threadsUrl }).pipe( + Effect.map((comments) => ({ comments, truncated: false })), + Effect.orElseSucceed(() => ({ comments: [], truncated: true })), + ) + ).pipe( + Effect.map( + (conversation): ProviderChangeRequestActivity => ({ + comments: conversation.comments, + commentCount: conversation.comments.length, + commentsTruncated: conversation.truncated, + reviewThreads: [], + commits: [], + }), + ), + ), + ), + ), + + // No request at all: Azure has nothing to say about the viewer that a pull request read can + // reach, so the answer is the same constant the detail carries. + getViewerPermissions: () => Effect.succeed(AZURE_DEVOPS_VIEWER_PERMISSIONS), + + // Never called: `capabilities.diff` is false, and the service refuses a diff without it. + getDiff: () => + Effect.fail( + new PullRequestProviderError({ + provider: "azure-devops", + operation: "getDiff", + reason: "failed", + detail: "Azure DevOps cannot produce a patch for a pull request.", + }), + ), + + runAction: (input) => + cli + .runPullRequestAction({ + cwd: input.cwd, + number: input.number, + action: input.action, + ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }), + }) + .pipe(Effect.mapError(fail("runAction"))), + + updateChangeRequest: (input) => + cli + .updatePullRequest({ + cwd: input.cwd, + number: input.number, + title: input.title, + body: input.body, + }) + .pipe(Effect.mapError(fail("updateChangeRequest"))), + + // Never called: `capabilities.reviewers.listCandidates` is false, and the service refuses the + // list without it. + listReviewerCandidates: () => + Effect.fail( + new PullRequestProviderError({ + provider: "azure-devops", + operation: "listReviewerCandidates", + reason: "failed", + detail: "Azure DevOps cannot say who may review a pull request.", + }), + ), + + setReviewerRequest: (input) => + cli + .setPullRequestReviewers({ + cwd: input.cwd, + number: input.number, + // Azure names an identity by an email address or a guid, and has no team to ask, so a + // candidate's id is the whole of what it takes. + reviewers: input.reviewers.map((reviewer) => reviewer.id), + requested: input.requested, + }) + .pipe(Effect.mapError(fail("setReviewerRequest"))), + + // Never called: `capabilities.comment` is false, and the service refuses a comment without it. + comment: () => unsupported("comment"), + + // Declared unsupported above, so the service refuses these before a provider is reached. + // They exist because every provider answers the whole port. + submitReview: () => unsupported("submitReview"), + + replyToThread: () => unsupported("replyToThread"), + + setThreadResolution: () => unsupported("setThreadResolution"), + + setReaction: () => unsupported("setReaction"), + }; + + return provider; +}); diff --git a/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts b/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts new file mode 100644 index 000000000000..f57bb67a4c40 --- /dev/null +++ b/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts @@ -0,0 +1,942 @@ +import { afterEach, assert, expect, it, vi } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as BitbucketApi from "../sourceControl/BitbucketApi.ts"; +import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts"; + +const mockedRequest = vi.fn(); + +const layer = it.layer( + BitbucketPullRequestApi.layer.pipe( + Layer.provide( + Layer.mock(BitbucketApi.BitbucketApi)({ + request: mockedRequest, + }), + ), + ), +); + +/** The shape `request` answers with: a body plus whether it had to be cut short. */ +function response(body: string) { + return { body, truncated: false }; +} + +function page(count: number, firstNumber: number, next?: string): string { + return JSON.stringify({ + pagelen: 50, + size: count, + values: Array.from({ length: count }, (_, index) => ({ + id: firstNumber + index, + title: `Pull request ${firstNumber + index}`, + state: "OPEN", + created_on: "2026-06-16T05:04:32+00:00", + updated_on: "2026-06-16T05:04:33+00:00", + source: { branch: { name: "feat/page" } }, + destination: { branch: { name: "master" } }, + links: { html: { href: `https://bitbucket.org/acme/web/pull-requests/${firstNumber}` } }, + })), + ...(next === undefined ? {} : { next }), + }); +} + +function valuePage(values: ReadonlyArray, next?: string): string { + return JSON.stringify({ values, ...(next === undefined ? {} : { next }) }); +} + +/** Who opened the pull request, and two accounts that could review it. */ +const bilal = { uuid: "{bilal}", nickname: "bilal" }; +const octocat = { uuid: "{octocat}", nickname: "octocat" }; +const hubot = { uuid: "{hubot}", nickname: "hubot" }; + +/** One pull request as `/pullrequests/{id}` answers with it. */ +function pullRequestJson(overrides: Record): string { + return JSON.stringify({ + id: 7, + title: "Pull request 7", + state: "OPEN", + author: bilal, + created_on: "2026-06-16T05:04:32+00:00", + updated_on: "2026-06-16T05:04:33+00:00", + source: { branch: { name: "feat/page" } }, + destination: { branch: { name: "master" } }, + links: { html: { href: "https://bitbucket.org/acme/web/pull-requests/7" } }, + ...overrides, + }); +} + +/** The request the nth call made. */ +function callAt(index: number) { + const call = mockedRequest.mock.calls[index]; + assert.isDefined(call); + return call[0]; +} + +/** The filter expression of the nth request, read back out of its query string. */ +function filterOfCall(index: number): string | null { + const url = callAt(index).url; + return new URLSearchParams(url.slice(url.indexOf("?") + 1)).get("q"); +} + +afterEach(() => { + mockedRequest.mockReset(); +}); + +layer("BitbucketPullRequestApi.layer", (it) => { + it.effect("asks for reviewers, newest first, at Bitbucket's page ceiling", () => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(3, 1)))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const batch = yield* api.listPullRequests({ + repository: "acme/web", + state: "open", + limit: 50, + }); + + assert.strictEqual(batch.items.length, 3); + assert.isFalse(batch.truncated); + const url = callAt(0).url; + expect(url).toContain("/repositories/acme/web/pullrequests"); + expect(url).toContain("state=OPEN"); + // Over 50 Bitbucket answers with an empty page and no error, so it is never exceeded. + expect(url).toContain("pagelen=50"); + expect(url).toContain("sort=-updated_on"); + expect(url).toContain("fields=%2Bvalues.reviewers"); + }), + ); + + it.effect("follows the cursor Bitbucket sends rather than counting offsets", () => + Effect.gen(function* () { + const next = "https://api.bitbucket.org/2.0/repositories/acme/web/pullrequests?page=2"; + mockedRequest + .mockReturnValueOnce(Effect.succeed(response(page(50, 1, next)))) + .mockReturnValueOnce(Effect.succeed(response(page(50, 51)))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const batch = yield* api.listPullRequests({ + repository: "acme/web", + state: "open", + limit: 100, + }); + + assert.strictEqual(batch.items.length, 100); + assert.isFalse(batch.truncated); + assert.strictEqual(callAt(1).url, next); + }), + ); + + it.effect("stops at the caller's page and says more remain", () => + Effect.gen(function* () { + const next = "https://api.bitbucket.org/2.0/repositories/acme/web/pullrequests?page=2"; + mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(50, 1, next)))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const batch = yield* api.listPullRequests({ + repository: "acme/web", + state: "open", + limit: 50, + }); + + assert.strictEqual(batch.items.length, 50); + assert.isTrue(batch.truncated); + assert.strictEqual(mockedRequest.mock.calls.length, 1); + }), + ); + + it.effect("counts the rows it walked past as more to come", () => + Effect.gen(function* () { + // Bitbucket pages in fifties whatever was asked for, so a request for ninety-nine reads a + // hundred and drops one. That row is more results, and saying otherwise takes the "load + // more" away from a listing that has not finished. + const next = "https://api.bitbucket.org/2.0/repositories/acme/web/pullrequests?page=2"; + mockedRequest + .mockReturnValueOnce(Effect.succeed(response(page(50, 1, next)))) + .mockReturnValueOnce(Effect.succeed(response(page(50, 51)))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const batch = yield* api.listPullRequests({ + repository: "acme/web", + state: "open", + limit: 99, + }); + + assert.strictEqual(batch.items.length, 99); + assert.isTrue(batch.truncated); + }), + ); + + it.effect("searches with a filter expression, which is all Bitbucket offers", () => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1)))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + yield* api.listPullRequests({ + repository: "acme/web", + state: "open", + limit: 50, + query: "page", + }); + + expect(filterOfCall(0)).toBe('(title ~ "page" OR description ~ "page")'); + // The state filter beside it still stands, which the brackets are there to keep. + expect(callAt(0).url).toContain("state=OPEN"); + }), + ); + + it.effect("escapes a quote and a backslash, so a search cannot reshape the filter", () => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1)))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + yield* api.listPullRequests({ + repository: "acme/web", + state: "open", + limit: 50, + query: String.raw`a\" OR state = "MERGED"`, + }); + + const literal = String.raw`a\\\" OR state = \"MERGED\"`; + expect(filterOfCall(0)).toBe(`(title ~ "${literal}" OR description ~ "${literal}")`); + }), + ); + + it.effect("asks for no filter at all when the reader typed only spaces", () => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1)))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + yield* api.listPullRequests({ + repository: "acme/web", + state: "open", + limit: 50, + query: " ", + }); + + assert.isNull(filterOfCall(0)); + }), + ); + + it.effect("carries on from the instant the last slice ended on", () => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1)))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + yield* api.listPullRequests({ + repository: "acme/web", + state: "open", + limit: 50, + cursor: { updatedBefore: "2026-07-02T00:00:00.123456+00:00", delivered: 50 }, + }); + + // Inclusive, so the rows already sent at that instant come back for the caller to drop. + expect(filterOfCall(0)).toBe("updated_on <= 2026-07-02T00:00:00.123456+00:00"); + expect(callAt(0).url).toContain("sort=-updated_on"); + }), + ); + + it.effect("narrows by the reader's words and by where it left off at once", () => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1)))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + yield* api.listPullRequests({ + repository: "acme/web", + state: "open", + limit: 50, + query: "page", + cursor: { updatedBefore: "2026-07-02T00:00:00+00:00", delivered: 50 }, + }); + + // Bitbucket takes one `q`, so the two narrowings are joined rather than one replacing the + // other — and the search keeps its brackets, which is what keeps the AND out of its OR. + expect(filterOfCall(0)).toBe( + '(title ~ "page" OR description ~ "page") AND updated_on <= 2026-07-02T00:00:00+00:00', + ); + }), + ); + + it.effect("asks for declined pull requests on the closed tab", () => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1)))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + yield* api.listPullRequests({ repository: "acme/web", state: "closed", limit: 50 }); + + expect(callAt(0).url).toContain("state=DECLINED"); + }), + ); + + it.effect("asks for every state at once on the All tab", () => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1)))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + yield* api.listPullRequests({ repository: "acme/web", state: "all", limit: 50 }); + + // Bitbucket unions repeated state parameters, which is the only way to span them. + const url = callAt(0).url; + for (const state of ["OPEN", "MERGED", "DECLINED", "SUPERSEDED"]) { + expect(url).toContain(`state=${state}`); + } + }), + ); + + it.effect("counts a superseded pull request as closed", () => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1)))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + yield* api.listPullRequests({ repository: "acme/web", state: "closed", limit: 50 }); + + expect(callAt(0).url).toContain("state=DECLINED"); + expect(callAt(0).url).toContain("state=SUPERSEDED"); + }), + ); + + it.effect("refuses a repository that is not workspace and slug", () => + Effect.gen(function* () { + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const error = yield* Effect.flip( + api.listPullRequests({ repository: "acme/team/web", state: "open", limit: 50 }), + ); + + assert.strictEqual(error._tag, "BitbucketRepositoryUnsupportedError"); + assert.strictEqual(mockedRequest.mock.calls.length, 0); + }), + ); + + it.effect("returns the diff verbatim, because Bitbucket already sends a patch", () => + Effect.gen(function* () { + const patch = "diff --git a/a.ts b/a.ts\n--- a/a.ts\n+++ b/a.ts\n@@ -1 +1 @@\n-a\n+b\n"; + mockedRequest.mockReturnValueOnce(Effect.succeed(response(patch))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const diff = yield* api.getPullRequestDiff({ repository: "acme/web", number: 7 }); + + assert.strictEqual(diff.patch, patch); + assert.isFalse(diff.truncated); + expect(callAt(0)).toMatchObject({ + url: "/repositories/acme/web/pullrequests/7/diff", + // A diff of any size would otherwise be read into memory whole. + maxBytes: 8 * 1024 * 1024, + }); + }), + ); + + it.effect("reads a named commit's own patch, which pages no further than the whole of it", () => + Effect.gen(function* () { + const patch = "diff --git a/a.ts b/a.ts\n--- a/a.ts\n+++ b/a.ts\n@@ -1 +1 @@\n-a\n+b\n"; + mockedRequest.mockReturnValueOnce(Effect.succeed(response(patch))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const diff = yield* api.getPullRequestDiff({ + repository: "acme/web", + number: 7, + commit: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0", + }); + + assert.strictEqual(diff.patch, patch); + expect(callAt(0)).toMatchObject({ + url: "/repositories/acme/web/diff/a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0", + maxBytes: 8 * 1024 * 1024, + }); + }), + ); + + it.effect("refuses a commit that is not a sha rather than reading it into a URL", () => + Effect.gen(function* () { + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const error = yield* Effect.flip( + api.getPullRequestDiff({ + repository: "acme/web", + number: 7, + commit: "../../acme/other/diff/deadbeef", + }), + ); + + assert.strictEqual(error._tag, "BitbucketDiffCommitError"); + assert.strictEqual(mockedRequest.mock.calls.length, 0); + }), + ); + + it.effect("aggregates every diffstat page", () => + Effect.gen(function* () { + const next = "https://api.bitbucket.org/2.0/diffstat?page=2"; + mockedRequest + .mockReturnValueOnce( + Effect.succeed( + response( + valuePage( + [ + { lines_added: 9, lines_removed: 2 }, + { lines_added: 3, lines_removed: 1 }, + ], + next, + ), + ), + ), + ) + .mockReturnValueOnce( + Effect.succeed(response(valuePage([{ lines_added: 4, lines_removed: 7 }]))), + ); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const stat = yield* api.getDiffStat({ repository: "acme/web", number: 7 }); + + expect(stat).toEqual({ additions: 16, deletions: 10, changedFiles: 3 }); + expect(callAt(1).url).toBe(next); + }), + ); + + it.effect("returns the complete commit timeline oldest first across pages", () => + Effect.gen(function* () { + const next = "https://api.bitbucket.org/2.0/commits?page=2"; + mockedRequest + .mockReturnValueOnce( + Effect.succeed( + response( + valuePage( + [ + { hash: "ddd", message: "fourth", date: "2026-07-04T00:00:00Z" }, + { hash: "ccc", message: "third", date: "2026-07-03T00:00:00Z" }, + ], + next, + ), + ), + ), + ) + .mockReturnValueOnce( + Effect.succeed( + response( + valuePage([ + { hash: "bbb", message: "second", date: "2026-07-02T00:00:00Z" }, + { hash: "aaa", message: "first", date: "2026-07-01T00:00:00Z" }, + ]), + ), + ), + ); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const commits = yield* api.listCommits({ repository: "acme/web", number: 7 }); + + expect(commits.map((commit) => commit.oid)).toEqual(["aaa", "bbb", "ccc", "ddd"]); + expect(callAt(1).url).toBe(next); + }), + ); + + it.effect("returns build statuses from every page", () => + Effect.gen(function* () { + const next = "https://api.bitbucket.org/2.0/statuses?page=2"; + mockedRequest + .mockReturnValueOnce( + Effect.succeed(response(valuePage([{ name: "Build", state: "SUCCESSFUL" }], next))), + ) + .mockReturnValueOnce( + Effect.succeed(response(valuePage([{ name: "Lint", state: "FAILED" }]))), + ); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const checks = yield* api.listChecks({ repository: "acme/web", number: 7 }); + + expect(checks.map((check) => [check.name, check.status])).toEqual([ + ["Build", "success"], + ["Lint", "failure"], + ]); + expect(callAt(1).url).toBe(next); + }), + ); + + it.effect("reads an empty conflict list as mergeable", () => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1)))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const mergeability = yield* api.getMergeability({ repository: "acme/web", number: 7 }); + + assert.strictEqual(mergeability, "mergeable"); + expect(callAt(0).url).toBe("/repositories/acme/web/pullrequests/7/conflicts"); + }), + ); + + it.effect("merges with Bitbucket's own name for the strategy", () => + Effect.gen(function* () { + mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + yield* api.runAction({ + repository: "acme/web", + number: 7, + action: "merge", + mergeMethod: "rebase", + }); + + expect(callAt(0)).toMatchObject({ + method: "POST", + url: "/repositories/acme/web/pullrequests/7/merge", + body: '{"merge_strategy":"rebase_fast_forward"}', + }); + }), + ); + + it.effect("closes a pull request by declining it", () => + Effect.gen(function* () { + mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + yield* api.runAction({ repository: "acme/web", number: 7, action: "close" }); + + expect(callAt(0)).toMatchObject({ + method: "POST", + url: "/repositories/acme/web/pullrequests/7/decline", + }); + }), + ); + + it.effect("posts a comment as a JSON document, so the body stays text", () => + Effect.gen(function* () { + mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + yield* api.comment({ repository: "acme/web", number: 7, body: "true" }); + + expect(callAt(0)).toMatchObject({ + method: "POST", + url: "/repositories/acme/web/pullrequests/7/comments", + body: '{"content":{"raw":"true"}}', + }); + }), + ); + + it.effect("rewrites a title alone, without touching anything else", () => + Effect.gen(function* () { + mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + yield* api.updateChangeRequest({ repository: "acme/web", number: 7, title: "A new title" }); + + const call = callAt(0); + expect(call.method).toBe("PUT"); + expect(call.url).toBe("/repositories/acme/web/pullrequests/7"); + // Bitbucket's PUT is a partial update, so a field left out of the body is left as it was. + // @effect-diagnostics-next-line preferSchemaOverJson:off + expect(JSON.parse(call.body ?? "")).toEqual({ title: "A new title" }); + }), + ); + + it.effect("leaves out the half of the pull request it was not asked about", () => + Effect.gen(function* () { + mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + yield* api.updateChangeRequest({ repository: "acme/web", number: 7, body: "New body." }); + + // @effect-diagnostics-next-line preferSchemaOverJson:off + expect(JSON.parse(callAt(0).body ?? "")).toEqual({ description: "New body." }); + }), + ); + + it.effect("writes both fields when both were rewritten", () => + Effect.gen(function* () { + mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + yield* api.updateChangeRequest({ + repository: "acme/web", + number: 7, + title: "A new title", + body: "New body.", + }); + + // @effect-diagnostics-next-line preferSchemaOverJson:off + expect(JSON.parse(callAt(0).body ?? "")).toEqual({ + title: "A new title", + description: "New body.", + }); + }), + ); + + it.effect("rewrites a comment where it stands, whichever kind it is", () => + Effect.gen(function* () { + mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + yield* api.updateComment({ + repository: "acme/web", + number: 7, + commentId: "10", + body: "Edited.", + }); + + expect(callAt(0)).toMatchObject({ + method: "PUT", + url: "/repositories/acme/web/pullrequests/7/comments/10", + body: '{"content":{"raw":"Edited."}}', + }); + }), + ); + + it.effect("fails the read when Bitbucket answers with something unreadable", () => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce( + // @effect-diagnostics-next-line preferSchemaOverJson:off + Effect.succeed(response(JSON.stringify({ error: "nope" }))), + ); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const error = yield* Effect.flip(api.getPullRequest({ repository: "acme/web", number: 7 })); + + assert.strictEqual(error._tag, "BitbucketPullRequestReadError"); + }), + ); + + it.effect("states a failure once, without stacking one message inside another", () => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce( + Effect.fail( + new BitbucketApi.BitbucketResponseError({ + operation: "request", + status: 500, + responseBodyLength: 0, + }), + ), + ); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const error = yield* Effect.flip(api.getViewer()); + + // The fact only; the provider adds the operation around it. + assert.strictEqual(error.detail, "Bitbucket returned HTTP 500."); + }), + ); + + it.effect("fails when the credentials belong to no named account", () => + Effect.gen(function* () { + // @effect-diagnostics-next-line preferSchemaOverJson:off + mockedRequest.mockReturnValueOnce(Effect.succeed(response(JSON.stringify({})))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const error = yield* Effect.flip(api.getViewer()); + + assert.strictEqual(error._tag, "BitbucketViewerUnavailableError"); + }), + ); + + it.effect("follows Bitbucket's cursor and reassembles a thread that spans two pages", () => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce( + Effect.succeed( + response( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + next: "https://api.bitbucket.org/2.0/comments?page=2", + values: [ + { + id: 10, + content: { raw: "rename this" }, + user: { nickname: "bilal" }, + created_on: "2026-06-16T05:04:32+00:00", + inline: { path: "src/a.ts", to: 12 }, + }, + ], + }), + ), + ), + ); + mockedRequest.mockReturnValueOnce( + Effect.succeed( + response( + // The reply arrives a page after the remark it answers, which is why the threads + // are only assembled once every page is in hand. + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + values: [ + { + id: 11, + content: { raw: "done" }, + user: { nickname: "julius" }, + created_on: "2026-06-16T06:04:32+00:00", + parent: { id: 10 }, + }, + ], + }), + ), + ), + ); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const { comments, threads, truncated } = yield* api.listComments({ + repository: "acme/web", + number: 7, + }); + + expect(callAt(1).url).toBe("https://api.bitbucket.org/2.0/comments?page=2"); + expect(comments.map((comment) => comment.id)).toEqual(["10", "11"]); + expect(threads[0]?.comments.map((comment) => comment.id)).toEqual(["10", "11"]); + assert.isFalse(truncated); + }), + ); + + it.effect("stops the comment walk at its bound and says the conversation was cut short", () => + Effect.gen(function* () { + // Bitbucket that always names a next page: the walk has to end itself. + mockedRequest.mockReturnValue( + Effect.succeed( + response( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + next: "https://api.bitbucket.org/2.0/comments?page=2", + values: [ + { + id: 10, + content: { raw: "again" }, + created_on: "2026-06-16T05:04:32+00:00", + }, + ], + }), + ), + ), + ); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const { truncated } = yield* api.listComments({ repository: "acme/web", number: 7 }); + + assert.strictEqual(mockedRequest.mock.calls.length, 10); + assert.isTrue(truncated); + }), + ); + + it.effect("reassembles a thread from the flat comment list, replies included", () => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce( + Effect.succeed( + response( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + values: [ + { + id: 10, + content: { raw: "rename this" }, + user: { nickname: "bilal" }, + created_on: "2026-06-16T05:04:32+00:00", + inline: { path: "src/a.ts", to: 12, from: null }, + resolution: { type: "pullrequest_comment_resolution" }, + }, + { + id: 11, + content: { raw: "done" }, + user: { nickname: "julius" }, + created_on: "2026-06-16T06:04:32+00:00", + parent: { id: 10 }, + }, + // A reply to a reply still belongs to the thread its root opened. + { + id: 12, + content: { raw: "thanks" }, + user: { nickname: "bilal" }, + created_on: "2026-06-16T07:04:32+00:00", + parent: { id: 11 }, + }, + { + id: 13, + content: { raw: "ship it" }, + user: { nickname: "bilal" }, + created_on: "2026-06-16T08:04:32+00:00", + }, + ], + }), + ), + ), + ); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const { threads } = yield* api.listComments({ repository: "acme/web", number: 7 }); + + assert.strictEqual(threads.length, 1); + expect(threads[0]).toMatchObject({ + id: "10", + path: "src/a.ts", + line: 12, + side: "right", + isResolved: true, + }); + expect(threads[0]?.comments.map((comment) => comment.id)).toEqual(["10", "11", "12"]); + }), + ); + + it.effect("writes a review's line comments, its summary, then its verdict", () => + Effect.gen(function* () { + mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + yield* api.submitReview({ + repository: "acme/web", + number: 7, + verdict: "request-changes", + body: "Two things.", + comments: [{ path: "src/a.ts", line: 12, side: "left", body: "why remove?" }], + }); + + expect(callAt(0).url).toContain("/pullrequests/7/comments"); + // @effect-diagnostics-next-line preferSchemaOverJson:off + expect(JSON.parse(callAt(0).body ?? "")).toEqual({ + content: { raw: "why remove?" }, + inline: { path: "src/a.ts", from: 12 }, + }); + expect(callAt(1).url).toContain("/pullrequests/7/comments"); + // The verdict goes last, so a review that failed part-way is never a rejection either. + expect(callAt(2).url).toContain("/pullrequests/7/request-changes"); + }), + ); + + it.effect("resolves by creating the sub-resource and unresolves by deleting it", () => + Effect.gen(function* () { + mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + yield* api.setCommentResolution({ + repository: "acme/web", + number: 7, + commentId: "10", + resolved: true, + }); + yield* api.setCommentResolution({ + repository: "acme/web", + number: 7, + commentId: "10", + resolved: false, + }); + + assert.strictEqual(callAt(0).method, "POST"); + assert.strictEqual(callAt(1).method, "DELETE"); + expect(callAt(0).url).toContain("/comments/10/resolve"); + }), + ); + + it.effect("replies by naming the comment it answers", () => + Effect.gen(function* () { + mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + yield* api.replyToComment({ + repository: "acme/web", + number: 7, + commentId: "10", + body: "Fixed.", + }); + + // @effect-diagnostics-next-line preferSchemaOverJson:off + expect(JSON.parse(callAt(0).body ?? "")).toEqual({ + content: { raw: "Fixed." }, + parent: { id: 10 }, + }); + }), + ); + + it.effect("asks for the credentials' permission on this repository, and nobody else's", () => + Effect.gen(function* () { + mockedRequest.mockReturnValue( + Effect.succeed( + response( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ values: [{ type: "repository_permission", permission: "read" }] }), + ), + ), + ); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + assert.isFalse(yield* api.getRepositoryPermission({ repository: "acme/web" })); + + expect(callAt(0).url).toContain("/user/permissions/repositories"); + assert.strictEqual(filterOfCall(0), 'repository.full_name="acme/web"'); + }), + ); + + it.effect("escapes a repository name before it goes inside a filter literal", () => + Effect.gen(function* () { + // @effect-diagnostics-next-line preferSchemaOverJson:off + mockedRequest.mockReturnValue(Effect.succeed(response(JSON.stringify({ values: [] })))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + yield* api.getRepositoryPermission({ repository: 'acme/we"b' }); + + // A quote would otherwise end the literal and leave the rest standing as filter syntax. + assert.strictEqual(filterOfCall(0), 'repository.full_name="acme/we\\"b"'); + }), + ); + + it.effect("reads the workspace's people and marks whoever is already a reviewer", () => + Effect.gen(function* () { + mockedRequest + .mockReturnValueOnce(Effect.succeed(response(pullRequestJson({ reviewers: [octocat] })))) + .mockReturnValueOnce( + Effect.succeed( + response( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ values: [{ user: bilal }, { user: octocat }, { user: hubot }] }), + ), + ), + ); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const list = yield* api.listReviewerCandidates({ repository: "acme/web", number: 7 }); + + // The people live on the workspace: nothing on a repository lists who may review it. + expect(callAt(1).url).toBe("/workspaces/acme/members?pagelen=50"); + expect(list.candidates.map((candidate) => [candidate.id, candidate.isRequested])).toEqual([ + ["{octocat}", true], + ["{hubot}", false], + ]); + assert.isFalse(list.truncated); + }), + ); + + it.effect("writes the reviewer set back with the one being asked added to it", () => + Effect.gen(function* () { + mockedRequest + .mockReturnValueOnce(Effect.succeed(response(pullRequestJson({ reviewers: [octocat] })))) + .mockReturnValueOnce(Effect.succeed(response("{}"))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + yield* api.setReviewerRequest({ + repository: "acme/web", + number: 7, + reviewers: [{ id: "{hubot}" }], + requested: true, + }); + + // Bitbucket writes `reviewers` whole, so the one already on the pull request travels with + // the new one or the request would take them off it. + const call = callAt(1); + expect(call.method).toBe("PUT"); + expect(call.url).toBe("/repositories/acme/web/pullrequests/7"); + // @effect-diagnostics-next-line preferSchemaOverJson:off + expect(JSON.parse(call.body ?? "")).toEqual({ + reviewers: [{ uuid: "{octocat}" }, { uuid: "{hubot}" }], + }); + }), + ); + + it.effect("takes a reviewer out of the set rather than clearing it", () => + Effect.gen(function* () { + mockedRequest + .mockReturnValueOnce( + Effect.succeed(response(pullRequestJson({ reviewers: [octocat, hubot] }))), + ) + .mockReturnValueOnce(Effect.succeed(response("{}"))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + yield* api.setReviewerRequest({ + repository: "acme/web", + number: 7, + reviewers: [{ id: "{hubot}" }], + requested: false, + }); + + // @effect-diagnostics-next-line preferSchemaOverJson:off + expect(JSON.parse(callAt(1).body ?? "")).toEqual({ reviewers: [{ uuid: "{octocat}" }] }); + }), + ); +}); diff --git a/apps/server/src/pullRequest/BitbucketPullRequestApi.ts b/apps/server/src/pullRequest/BitbucketPullRequestApi.ts new file mode 100644 index 000000000000..a20d4aaaa056 --- /dev/null +++ b/apps/server/src/pullRequest/BitbucketPullRequestApi.ts @@ -0,0 +1,832 @@ +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 { + PullRequestAction, + PullRequestCheck, + PullRequestComment, + PullRequestCommit, + PullRequestListState, + PullRequestMergeMethod, + PullRequestMergeability, + PullRequestReviewCommentDraft, + PullRequestReviewThread, + PullRequestReviewVerdict, + PullRequestReviewerCandidateList, +} from "@t3tools/contracts"; + +import * as BitbucketApi from "../sourceControl/BitbucketApi.ts"; +import { + buildReviewThreads, + decodeCommentsJson, + decodeCommitsJson, + decodeConflictsJson, + decodeDiffstatJson, + decodePullRequestJson, + decodePullRequestPageJson, + decodeRepositoryPermissionJson, + decodeStatusesJson, + decodeViewerJson, + decodeWorkspaceMembersJson, + type BitbucketDiffStat, + type BitbucketPullRequest, + type BitbucketRawComment, +} from "./bitbucketPullRequestJson.ts"; +import type { ProviderListCursor } from "./PullRequestProvider.ts"; + +/** + * Names the read that produced unusable output, so a failure reports the call it came from + * rather than borrowing another operation's message. + */ +export class BitbucketPullRequestReadError extends Schema.TaggedErrorClass()( + "BitbucketPullRequestReadError", + { + operation: Schema.String, + cause: Schema.Defect(), + }, +) { + get detail(): string { + return `Bitbucket returned an unreadable ${this.operation} response.`; + } + + override get message(): string { + return `Bitbucket failed in ${this.operation}: ${this.detail}`; + } +} + +/** Not a decode failure: Bitbucket answered, the account it answered for just has no handle. */ +export class BitbucketViewerUnavailableError extends Schema.TaggedErrorClass()( + "BitbucketViewerUnavailableError", + {}, +) { + get detail(): string { + return "Bitbucket returned no account name for the configured credentials."; + } + + override get message(): string { + return `Bitbucket failed in getViewer: ${this.detail}`; + } +} + +/** A repository that is not `workspace/slug`, which is the only form Bitbucket addresses. */ +export class BitbucketRepositoryUnsupportedError extends Schema.TaggedErrorClass()( + "BitbucketRepositoryUnsupportedError", + { + repository: Schema.String, + }, +) { + get detail(): string { + return "A Bitbucket repository is addressed as workspace/repository."; + } + + override get message(): string { + return `Bitbucket failed in resolveRepository: ${this.detail}`; + } +} + +/** Not a decode failure: the reader named a commit that is not a sha this repository could hold. */ +export class BitbucketDiffCommitError extends Schema.TaggedErrorClass()( + "BitbucketDiffCommitError", + {}, +) { + get detail(): string { + return "The named commit was not a commit sha."; + } + + override get message(): string { + return `Bitbucket failed in getPullRequestDiff: ${this.detail}`; + } +} + +export type BitbucketPullRequestApiError = + | BitbucketApi.BitbucketApiError + | BitbucketPullRequestReadError + | BitbucketViewerUnavailableError + | BitbucketRepositoryUnsupportedError + | BitbucketDiffCommitError; + +/** + * Bitbucket's own ceiling. Asking for more does not fail — it answers with an empty page and no + * error at all, so this is a number to respect rather than to push against. + */ +const MAX_PAGE_SIZE = 50; +/** Pages to walk before a listing is reported as truncated. */ +const MAX_LIST_PAGES = 10; +/** The page size for pull request conversations, commits, and checks. */ +const CONVERSATION_PAGE_SIZE = 50; +/** + * Pages of the conversation to follow before it is reported as truncated. Bitbucket serves + * fifty comments a page, so this is five hundred — beyond any pull request a person is reading, + * and an end to a walk whose only other stop is Bitbucket running out. + */ +const CONVERSATION_PAGES = 10; +/** The same ceiling the gh and glab diff reads use. */ +const DIFF_MAX_BYTES = 8 * 1024 * 1024; + +export interface BitbucketPullRequestBatch { + readonly items: ReadonlyArray; + readonly truncated: boolean; +} + +export class BitbucketPullRequestApi extends Context.Service< + BitbucketPullRequestApi, + { + /** A function rather than a value, so the request is built per call and not at layer time. */ + readonly getViewer: () => Effect.Effect; + + readonly listPullRequests: (input: { + readonly repository: string; + readonly state: PullRequestListState; + readonly limit: number; + /** Free text, matched against a pull request's title and description. */ + readonly query?: string | undefined; + /** Where to carry on from, as a predicate on `updated_on` beside any other. */ + readonly cursor?: ProviderListCursor | undefined; + }) => Effect.Effect; + + readonly getPullRequest: (input: { + readonly repository: string; + readonly number: number; + }) => Effect.Effect; + + /** True where the credentials can write to the repository, which is what merging needs. */ + readonly getRepositoryPermission: (input: { + readonly repository: string; + }) => Effect.Effect; + + readonly getPullRequestDiff: (input: { + readonly repository: string; + readonly number: number; + /** One commit's own changes, rather than everything the pull request carries. */ + readonly commit?: string | undefined; + }) => Effect.Effect< + { readonly patch: string; readonly truncated: boolean }, + BitbucketPullRequestApiError + >; + + readonly getDiffStat: (input: { + readonly repository: string; + readonly number: number; + }) => Effect.Effect; + + readonly getMergeability: (input: { + readonly repository: string; + readonly number: number; + }) => Effect.Effect; + + readonly listComments: (input: { + readonly repository: string; + readonly number: number; + }) => Effect.Effect< + { + readonly comments: ReadonlyArray; + readonly threads: ReadonlyArray; + readonly truncated: boolean; + }, + BitbucketPullRequestApiError + >; + + readonly listCommits: (input: { + readonly repository: string; + readonly number: number; + }) => Effect.Effect, BitbucketPullRequestApiError>; + + readonly listChecks: (input: { + readonly repository: string; + readonly number: number; + }) => Effect.Effect, BitbucketPullRequestApiError>; + + /** + * Who this pull request may be sent to, and who it has already been sent to. Two reads at + * once, because Bitbucket keeps the people on the workspace and the reviewers on the pull + * request, and neither answers for the other. + */ + readonly listReviewerCandidates: (input: { + readonly repository: string; + readonly number: number; + }) => Effect.Effect; + + readonly setReviewerRequest: (input: { + readonly repository: string; + readonly number: number; + readonly reviewers: ReadonlyArray<{ readonly id: string }>; + readonly requested: boolean; + }) => Effect.Effect; + + readonly runAction: (input: { + readonly repository: string; + readonly number: number; + readonly action: PullRequestAction; + readonly mergeMethod?: PullRequestMergeMethod; + }) => Effect.Effect; + + readonly updateChangeRequest: (input: { + readonly repository: string; + readonly number: number; + readonly title?: string | undefined; + readonly body?: string | undefined; + }) => Effect.Effect; + + readonly comment: (input: { + readonly repository: string; + readonly number: number; + readonly body: string; + }) => Effect.Effect; + + readonly updateComment: (input: { + readonly repository: string; + readonly number: number; + readonly commentId: string; + readonly body: string; + }) => Effect.Effect; + + readonly submitReview: (input: { + readonly repository: string; + readonly number: number; + readonly verdict: PullRequestReviewVerdict; + readonly body: string; + readonly comments: ReadonlyArray; + }) => Effect.Effect; + + readonly replyToComment: (input: { + readonly repository: string; + readonly number: number; + readonly commentId: string; + readonly body: string; + }) => Effect.Effect; + + readonly setCommentResolution: (input: { + readonly repository: string; + readonly number: number; + readonly commentId: string; + readonly resolved: boolean; + }) => Effect.Effect; + } +>()("t3/pullRequest/BitbucketPullRequestApi") {} + +/** `workspace/slug`; Bitbucket has no deeper nesting to address. */ +function repositorySegments( + repository: string, +): Result.Result< + { readonly workspace: string; readonly slug: string }, + BitbucketRepositoryUnsupportedError +> { + const segments = repository + .split("/") + .map((segment) => segment.trim()) + .filter((segment) => segment.length > 0); + const [workspace, slug] = segments; + if (segments.length !== 2 || workspace === undefined || slug === undefined) { + return Result.fail(new BitbucketRepositoryUnsupportedError({ repository })); + } + return Result.succeed({ workspace, slug }); +} + +function repositoryPathOf(segments: { readonly workspace: string; readonly slug: string }): string { + return `/repositories/${encodeURIComponent(segments.workspace)}/${encodeURIComponent( + segments.slug, + )}`; +} + +/** + * A commit sha arrives from the reader and goes straight into a request path, so it is checked + * rather than trusted: hexadecimal only, from the shortest abbreviation a host prints up to a + * whole sha. + */ +function isCommitSha(value: string): boolean { + return /^[0-9a-f]{7,64}$/i.test(value); +} + +/** + * Bitbucket unions repeated `state` parameters, so a tab that spans several of its states asks + * for each. It separates a declined pull request from one superseded by another, and both read + * as closed here. + */ +function stateParams(state: PullRequestListState): ReadonlyArray { + switch (state) { + case "open": + return ["OPEN"]; + case "merged": + return ["MERGED"]; + case "closed": + return ["DECLINED", "SUPERSEDED"]; + case "all": + return ["OPEN", "MERGED", "DECLINED", "SUPERSEDED"]; + } +} + +/** + * Bitbucket has no search term, only a filter expression, so free text becomes one: a + * case-insensitive contains against the two fields a pull request carries words in. The + * parentheses matter, because the expression is ANDed with the state filter beside it and an + * unbracketed `OR` would swallow it. + * + * A string literal in that grammar is delimited by double quotes, so the reader's text is + * escaped before it goes inside one — a quote would otherwise end the literal and leave the + * rest of the text standing as filter syntax. The whole expression is then URL-encoded, so + * nothing in it reaches the query string as a parameter of its own. + */ +function searchFilter(query: string): string { + const literal = filterLiteral(query); + return `(title ~ "${literal}" OR description ~ "${literal}")`; +} + +/** + * Text as a string literal of Bitbucket's filter grammar. The backslash is escaped first, or + * escaping the quote would only produce a literal backslash followed by a live quote. + */ +function filterLiteral(value: string): string { + return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); +} + +/** Bitbucket's merge strategies, named differently from the three the contract carries. */ +function mergeStrategy(method: PullRequestMergeMethod | undefined): string { + switch (method) { + case "squash": + return "squash"; + case "rebase": + // The linear history GitHub calls "rebase and merge". + return "rebase_fast_forward"; + default: + return "merge_commit"; + } +} + +export const make = Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + + /** + * The repository's own path, and the workspace above it — which the people who may review are + * kept on rather than on the repository, so both are handed over at once. + */ + const withRepository = ( + repository: string, + use: (path: string, workspace: string) => Effect.Effect, + ): Effect.Effect => { + const segments = repositorySegments(repository); + return Result.isSuccess(segments) + ? use(repositoryPathOf(segments.success), segments.success.workspace) + : Effect.fail(segments.failure); + }; + + /** + * Bitbucket pages with a cursor rather than an offset, so the walk follows the `next` URL it + * sends. It stops once the caller's page is filled, when Bitbucket reports no next page, or at + * the page cap — and anything but running out of pages means there is more to be had. + */ + const listPage = (input: { + readonly url: string; + readonly limit: number; + readonly page: number; + readonly collected: ReadonlyArray; + }): Effect.Effect => + bitbucket.request({ method: "GET", url: input.url }).pipe( + Effect.flatMap((response) => { + const decoded = decodePullRequestPageJson(response.body); + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new BitbucketPullRequestReadError({ + operation: "listPullRequests", + cause: decoded.failure, + }), + ); + } + const collected = [...input.collected, ...decoded.success.items]; + const next = decoded.success.next; + if (next === null || collected.length >= input.limit || input.page >= MAX_LIST_PAGES) { + return Effect.succeed({ + items: collected.slice(0, input.limit), + // Bitbucket pages in fifties whatever was asked for, so a walk that stopped on the + // count rather than on the last page is holding rows it is about to drop. Those are + // more results just as surely as another page would be. + truncated: next !== null || collected.length > input.limit, + }); + } + return listPage({ ...input, url: next, page: input.page + 1, collected }); + }), + ); + + const readPage = (input: { + readonly operation: string; + readonly url: string; + readonly decode: (body: string) => Result.Result; + }): Effect.Effect => + bitbucket.request({ method: "GET", url: input.url }).pipe( + Effect.flatMap((response) => { + const decoded = input.decode(response.body); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail( + new BitbucketPullRequestReadError({ + operation: input.operation, + cause: decoded.failure, + }), + ); + }), + ); + + /** + * The conversation, following the `next` Bitbucket sends until it sends none. Threads are + * assembled once at the end rather than per page, because a reply and the remark it answers + * can land either side of a page boundary. + */ + const commentsPage = (input: { + readonly url: string; + readonly page: number; + readonly comments: ReadonlyArray; + readonly entries: ReadonlyArray; + }): Effect.Effect< + { + readonly comments: ReadonlyArray; + readonly threads: ReadonlyArray; + readonly truncated: boolean; + }, + BitbucketPullRequestApiError + > => + readPage({ operation: "listComments", url: input.url, decode: decodeCommentsJson }).pipe( + Effect.flatMap((page) => { + const comments = [...input.comments, ...page.comments]; + const entries = [...input.entries, ...page.entries]; + if (page.next !== null && input.page < CONVERSATION_PAGES) { + return commentsPage({ url: page.next, page: input.page + 1, comments, entries }); + } + return Effect.succeed({ + comments, + threads: buildReviewThreads(entries), + truncated: page.next !== null, + }); + }), + ); + + /** Walks a Bitbucket cursor to its end and combines every decoded item. */ + const itemPages = (input: { + readonly operation: string; + readonly url: string; + readonly decode: ( + body: string, + ) => Result.Result<{ readonly items: ReadonlyArray; readonly next: string | null }, unknown>; + readonly items: ReadonlyArray; + /** Commit pages are individually oldest-first, so older pages are prepended. */ + readonly prepend: boolean; + }): Effect.Effect, BitbucketPullRequestApiError> => + readPage({ operation: input.operation, url: input.url, decode: input.decode }).pipe( + Effect.flatMap((page) => { + const items = input.prepend + ? [...page.items, ...input.items] + : [...input.items, ...page.items]; + return page.next === null + ? Effect.succeed(items) + : itemPages({ ...input, url: page.next, items }); + }), + ); + + /** Diffstat has one aggregate per page, so its totals are folded while following `next`. */ + const diffStatPages = (input: { + readonly url: string; + readonly totals: BitbucketDiffStat; + }): Effect.Effect => + readPage({ operation: "getDiffStat", url: input.url, decode: decodeDiffstatJson }).pipe( + Effect.flatMap((page) => { + const totals = { + additions: input.totals.additions + page.additions, + deletions: input.totals.deletions + page.deletions, + changedFiles: input.totals.changedFiles + page.changedFiles, + }; + return page.next === null + ? Effect.succeed(totals) + : diffStatPages({ url: page.next, totals }); + }), + ); + + return BitbucketPullRequestApi.of({ + getViewer: () => + bitbucket.request({ method: "GET", url: "/user" }).pipe( + Effect.flatMap((response): Effect.Effect => { + const decoded = decodeViewerJson(response.body); + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new BitbucketPullRequestReadError({ operation: "getViewer", cause: decoded.failure }), + ); + } + return decoded.success === null + ? Effect.fail(new BitbucketViewerUnavailableError()) + : Effect.succeed(decoded.success); + }), + ), + + listPullRequests: (input) => + withRepository(input.repository, (path) => { + const search = input.query?.trim() ?? ""; + // Both narrowings share the one `q` Bitbucket takes, so they are ANDed rather than one + // replacing the other. The boundary instant is read inclusively — the rows already sent + // at it come back and the caller drops them, which is what keeps their neighbours at the + // same instant from being skipped. A date is a bare literal in this grammar, and this one + // was checked against a timestamp's shape before it got here. + const predicates = [ + ...(search.length === 0 ? [] : [searchFilter(search)]), + ...(input.cursor === undefined ? [] : [`updated_on <= ${input.cursor.updatedBefore}`]), + ]; + return listPage({ + // Reviewers are not on a listing by default, and `viewerReviewRequested` needs them. + url: `${path}/pullrequests?${stateParams(input.state) + .map((state) => `state=${state}`) + .join("&")}&pagelen=${MAX_PAGE_SIZE}&sort=-updated_on&fields=%2Bvalues.reviewers${ + predicates.length === 0 ? "" : `&q=${encodeURIComponent(predicates.join(" AND "))}` + }`, + limit: input.limit, + page: 1, + collected: [], + }); + }), + + getPullRequest: (input) => + withRepository(input.repository, (path) => + readPage({ + operation: "getPullRequest", + url: `${path}/pullrequests/${input.number}`, + decode: decodePullRequestJson, + }), + ), + + // Nothing on the repository, the pull request or the workspace states what the credentials + // may do, so this endpoint is the one request Bitbucket makes unavoidable. It is asked + // alongside the reads the detail was already making, so it costs no round trip of its own. + getRepositoryPermission: (input) => + withRepository(input.repository, () => + readPage({ + operation: "getRepositoryPermission", + url: `/user/permissions/repositories?q=${encodeURIComponent( + `repository.full_name="${filterLiteral(input.repository.trim())}"`, + )}`, + decode: decodeRepositoryPermissionJson, + }), + ), + + getPullRequestDiff: (input) => + input.commit !== undefined && !isCommitSha(input.commit) + ? Effect.fail(new BitbucketDiffCommitError()) + : withRepository(input.repository, (path) => + // Already a unified patch, so it needs no decoding at all — only a bound, which a + // diff of any size would otherwise ignore. A commit's own patch sits beside the pull + // request's at `/diff/{sha}` and reads the same way. + bitbucket + .request({ + method: "GET", + url: + input.commit === undefined + ? `${path}/pullrequests/${input.number}/diff` + : `${path}/diff/${input.commit}`, + maxBytes: DIFF_MAX_BYTES, + }) + .pipe( + Effect.map((response) => ({ patch: response.body, truncated: response.truncated })), + ), + ), + + getDiffStat: (input) => + withRepository(input.repository, (path) => + diffStatPages({ + url: `${path}/pullrequests/${input.number}/diffstat?pagelen=${MAX_PAGE_SIZE}`, + totals: { additions: 0, deletions: 0, changedFiles: 0 }, + }), + ), + + getMergeability: (input) => + withRepository(input.repository, (path) => + readPage({ + operation: "getMergeability", + url: `${path}/pullrequests/${input.number}/conflicts`, + decode: decodeConflictsJson, + }), + ), + + listComments: (input) => + withRepository(input.repository, (path) => + commentsPage({ + url: `${path}/pullrequests/${input.number}/comments?pagelen=${CONVERSATION_PAGE_SIZE}`, + page: 1, + comments: [], + entries: [], + }), + ), + + listCommits: (input) => + withRepository(input.repository, (path) => + itemPages({ + operation: "listCommits", + url: `${path}/pullrequests/${input.number}/commits?pagelen=${CONVERSATION_PAGE_SIZE}`, + decode: decodeCommitsJson, + items: [], + prepend: true, + }), + ), + + listChecks: (input) => + withRepository(input.repository, (path) => + itemPages({ + operation: "listChecks", + url: `${path}/pullrequests/${input.number}/statuses?pagelen=${CONVERSATION_PAGE_SIZE}`, + decode: decodeStatusesJson, + items: [], + prepend: false, + }), + ), + + listReviewerCandidates: (input) => + withRepository(input.repository, (path, workspace) => + Effect.all( + [ + readPage({ + operation: "getPullRequest", + url: `${path}/pullrequests/${input.number}`, + decode: decodePullRequestJson, + }), + readPage({ + operation: "listReviewerCandidates", + url: `/workspaces/${encodeURIComponent(workspace)}/members?pagelen=${MAX_PAGE_SIZE}`, + decode: decodeWorkspaceMembersJson, + }), + ], + { concurrency: 2 }, + ).pipe( + Effect.map(([pullRequest, members]) => { + const requested = new Set(pullRequest.reviewerIds); + const author = pullRequest.author?.login; + return { + // The author is dropped rather than shown unusable: Bitbucket refuses to make the + // person who opened a pull request its reviewer. + candidates: members.items.flatMap((candidate) => + candidate.login === author + ? [] + : [{ ...candidate, isRequested: requested.has(candidate.id) }], + ), + truncated: members.next !== null, + }; + }), + ), + ), + + setReviewerRequest: (input) => + withRepository(input.repository, (path) => { + const pullRequest = `${path}/pullrequests/${input.number}`; + return readPage({ + operation: "getPullRequest", + url: pullRequest, + decode: decodePullRequestJson, + }).pipe( + Effect.flatMap((current) => { + // Bitbucket has no endpoint that adds or removes one reviewer: the pull request's + // `reviewers` is written whole, so the set that is already there is read first and + // the change applied to it. Everything else about the pull request is left out of + // the body, which leaves it as it was. + const uuids = new Set(current.reviewerIds); + for (const reviewer of input.reviewers) { + if (input.requested) uuids.add(reviewer.id); + else uuids.delete(reviewer.id); + } + return bitbucket.request({ + method: "PUT", + url: pullRequest, + body: JSON.stringify({ reviewers: [...uuids].map((uuid) => ({ uuid })) }), + }); + }), + Effect.asVoid, + ); + }), + + runAction: (input) => + withRepository(input.repository, (path) => { + const pullRequest = `${path}/pullrequests/${input.number}`; + // Only merge and close reach here: the provider declares the others unsupported, so the + // surface never offers them. + if (input.action === "merge") { + return bitbucket + .request({ + method: "POST", + url: `${pullRequest}/merge`, + body: JSON.stringify({ merge_strategy: mergeStrategy(input.mergeMethod) }), + }) + .pipe(Effect.asVoid); + } + return bitbucket + .request({ method: "POST", url: `${pullRequest}/decline` }) + .pipe(Effect.asVoid); + }), + + updateChangeRequest: (input) => + withRepository(input.repository, (path) => + // Only the words this call rewrites travel in the body: as `setReviewerRequest` above + // relies on, Bitbucket's PUT is a partial update, so any field left out is left as it + // was — sending `reviewers` back here would overwrite a change another user made to it + // between this call being issued and the request landing. + bitbucket + .request({ + method: "PUT", + url: `${path}/pullrequests/${input.number}`, + body: JSON.stringify({ + ...(input.title === undefined ? {} : { title: input.title }), + ...(input.body === undefined ? {} : { description: input.body }), + }), + }) + .pipe(Effect.asVoid), + ), + + comment: (input) => + withRepository(input.repository, (path) => + bitbucket + .request({ + method: "POST", + url: `${path}/pullrequests/${input.number}/comments`, + // A JSON document rather than a form field, so the body stays text whatever it says. + body: JSON.stringify({ content: { raw: input.body } }), + }) + .pipe(Effect.asVoid), + ), + + updateComment: (input) => + withRepository(input.repository, (path) => + bitbucket + .request({ + // Bitbucket keeps a pull request's remarks and its line comments in the one + // collection, so this endpoint rewrites either kind. + method: "PUT", + url: `${path}/pullrequests/${input.number}/comments/${encodeURIComponent( + input.commentId, + )}`, + body: JSON.stringify({ content: { raw: input.body } }), + }) + .pipe(Effect.asVoid), + ), + + submitReview: (input) => + withRepository(input.repository, (path) => + Effect.gen(function* () { + const pullRequest = `${path}/pullrequests/${input.number}`; + // Bitbucket has no pending review, so a review is replayed as the requests it is + // made of: the line comments, then the summary, then the verdict. The verdict goes + // last so a review that fails part-way is never left standing as an approval. + yield* Effect.forEach( + input.comments, + (comment) => + bitbucket.request({ + method: "POST", + url: `${pullRequest}/comments`, + body: JSON.stringify({ + content: { raw: comment.body }, + inline: { + path: comment.path, + ...(comment.side === "left" ? { from: comment.line } : { to: comment.line }), + }, + }), + }), + { discard: true }, + ); + if (input.body.trim().length > 0) { + yield* bitbucket.request({ + method: "POST", + url: `${pullRequest}/comments`, + // @effect-diagnostics-next-line preferSchemaOverJson:off + body: JSON.stringify({ content: { raw: input.body } }), + }); + } + if (input.verdict === "approve") { + yield* bitbucket.request({ method: "POST", url: `${pullRequest}/approve` }); + } + if (input.verdict === "request-changes") { + yield* bitbucket.request({ method: "POST", url: `${pullRequest}/request-changes` }); + } + }), + ), + + replyToComment: (input) => + withRepository(input.repository, (path) => + bitbucket + .request({ + method: "POST", + url: `${path}/pullrequests/${input.number}/comments`, + body: JSON.stringify({ + content: { raw: input.body }, + parent: { id: Number(input.commentId) }, + }), + }) + .pipe(Effect.asVoid), + ), + + setCommentResolution: (input) => + withRepository(input.repository, (path) => + bitbucket + .request({ + // Resolving is a sub-resource that is created and deleted, rather than a field. + method: input.resolved ? "POST" : "DELETE", + url: `${path}/pullrequests/${input.number}/comments/${encodeURIComponent( + input.commentId, + )}/resolve`, + }) + .pipe(Effect.asVoid), + ), + }); +}); + +export const layer = Layer.effect(BitbucketPullRequestApi, make); diff --git a/apps/server/src/pullRequest/BitbucketPullRequestProvider.test.ts b/apps/server/src/pullRequest/BitbucketPullRequestProvider.test.ts new file mode 100644 index 000000000000..7e57d6c771e1 --- /dev/null +++ b/apps/server/src/pullRequest/BitbucketPullRequestProvider.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vite-plus/test"; + +import * as BitbucketApi from "../sourceControl/BitbucketApi.ts"; +import { + bitbucketErrorReason, + bitbucketViewerPermissions, +} from "./BitbucketPullRequestProvider.ts"; + +describe("bitbucketErrorReason", () => { + it("treats only an HTTP 401 as unusable credentials", () => { + const responseError = (status: number) => + new BitbucketApi.BitbucketResponseError({ + operation: "request", + status, + responseBodyLength: 0, + }); + + expect(bitbucketErrorReason(responseError(401))).toBe("unauthenticated"); + expect(bitbucketErrorReason(responseError(403))).toBe("failed"); + }); +}); + +describe("bitbucketViewerPermissions", () => { + it("offers both actions to credentials with write access", () => { + expect(bitbucketViewerPermissions({ canWrite: true })).toEqual({ + actions: ["merge", "close"], + comment: true, + resolve: true, + verdicts: ["comment", "approve", "request-changes"], + // Bitbucket says nothing about who may set a reviewer, and an unreported permission is + // granted. + requestReviewers: true, + }); + }); + + it("keeps merge from credentials that can only read the repository", () => { + expect(bitbucketViewerPermissions({ canWrite: false })).toEqual({ + actions: ["close"], + comment: true, + resolve: true, + verdicts: ["comment", "approve", "request-changes"], + requestReviewers: true, + }); + }); + + it("treats an author with read access as any other reader, which is all Bitbucket says", () => { + // The repository permission is the whole of what Bitbucket reports per account; it says + // nothing about who opened this pull request, and its author may decline it with read access + // alone — so declining stays offered rather than being taken from them. + expect(bitbucketViewerPermissions({ canWrite: false }).actions).toEqual(["close"]); + }); +}); diff --git a/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts b/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts new file mode 100644 index 000000000000..00e558588efa --- /dev/null +++ b/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts @@ -0,0 +1,325 @@ +import * as Effect from "effect/Effect"; +import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3tools/contracts"; + +import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts"; +import { + PullRequestProviderError, + type ProviderChangeRequest, + type ProviderChangeRequestActivity, + type ProviderChangeRequestDetail, + type PullRequestProviderApi, +} from "./PullRequestProvider.ts"; +import type { BitbucketPullRequest } from "./bitbucketPullRequestJson.ts"; + +const CAPABILITIES: PullRequestCapabilities = { + diff: true, + comment: true, + // Bitbucket has no endpoint that reopens a declined pull request, and nothing documented that + // moves one in or out of draft, so neither is offered rather than failing when pressed. + actions: ["merge", "close"], + mergeMethods: ["merge", "squash", "rebase"], + search: true, + // Bitbucket Cloud's API exposes no reaction on a pull request or on a comment, so none is + // read and none is offered. + reactions: false, + review: { + inlineComment: true, + reply: true, + resolve: true, + verdicts: ["comment", "approve", "request-changes"], + }, + reviewers: { request: true, listCandidates: true }, + edit: { changeRequest: true, comment: true }, +}; + +/** + * What the configured account may do here, from the one thing Bitbucket states per viewer: the + * repository permission. Merging needs `write` or `admin`, so that is what narrows. + * + * Declining stays offered whatever the permission. Bitbucket lets the author of a pull request + * decline their own with no more than read access, and the permission response says nothing about + * who opened this one — so withholding the control from the one person entitled to it is the + * worse of the two mistakes. Commenting and reviewing are not narrowed either: read access is + * enough to say something, to approve and to ask for changes. + * + * Asking for a review is left open for the same reason: Bitbucket takes a reviewer set from the + * author of a pull request as well as from whoever can write, and says nothing here about which + * of the two this account is. + */ +export function bitbucketViewerPermissions(input: { + readonly canWrite: boolean; +}): PullRequestViewerPermissions { + return { + actions: CAPABILITIES.actions.filter((action) => action !== "merge" || input.canWrite), + comment: true, + resolve: true, + verdicts: CAPABILITIES.review.verdicts, + requestReviewers: true, + }; +} + +/** The failures that mean the credentials are the problem, rather than one request. */ +export function bitbucketErrorReason( + error: BitbucketPullRequestApi.BitbucketPullRequestApiError, +): PullRequestProviderError["reason"] { + // Bitbucket is read over HTTP with credentials from the environment, so there is no tool to be + // missing: unusable always means the credentials are absent or refused. + if (error._tag === "BitbucketResponseError" && error.status === 401) { + return "unauthenticated"; + } + return "failed"; +} + +function toChangeRequest(pullRequest: BitbucketPullRequest): ProviderChangeRequest { + return { + number: pullRequest.number, + title: pullRequest.title, + url: pullRequest.url, + author: pullRequest.author, + headBranch: pullRequest.headBranch, + baseBranch: pullRequest.baseBranch, + state: pullRequest.state, + isDraft: pullRequest.isDraft, + mergeability: pullRequest.mergeability, + // Line counts are a separate read, which only the detail is worth spending on. + additions: 0, + deletions: 0, + createdAt: pullRequest.createdAt, + updatedAt: pullRequest.updatedAt, + reviewRequestLogins: pullRequest.reviewRequestLogins, + // Bitbucket has no labels on a pull request. + labels: [], + }; +} + +export const make = Effect.gen(function* () { + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const fail = + (operation: string) => (error: BitbucketPullRequestApi.BitbucketPullRequestApiError) => + new PullRequestProviderError({ + provider: "bitbucket", + operation, + reason: bitbucketErrorReason(error), + // Every Bitbucket failure states its own fact; this names the operation around it, so + // the two do not stack into "failed in x: failed in y: ...". + detail: error.detail, + cause: error, + }); + + const provider: PullRequestProviderApi = { + kind: "bitbucket", + capabilities: CAPABILITIES, + + // Bitbucket credentials come from the server's environment rather than a checkout, so the + // account is the same whichever workspace asks. + getViewer: () => api.getViewer().pipe(Effect.mapError(fail("getViewer"))), + + listChangeRequests: (input) => + api + .listPullRequests({ + repository: input.repository, + state: input.state, + limit: input.limit, + query: input.query, + cursor: input.cursor, + }) + .pipe( + Effect.mapError(fail("listChangeRequests")), + Effect.map((batch) => ({ + items: batch.items.map(toChangeRequest), + truncated: batch.truncated, + // Bitbucket is asked for `-updated_on` whether or not it is being carried on from, + // so every page it answers is one a cursor can continue. + continues: true, + })), + ), + + getChangeRequest: (input) => { + const target = { repository: input.repository, number: input.number }; + return Effect.all( + [ + api.getPullRequest(target), + api.getDiffStat(target), + api.getMergeability(target).pipe(Effect.orElseSucceed(() => "unknown" as const)), + api.listChecks(target).pipe(Effect.orElseSucceed(() => [])), + // A permission that could not be read is an unknown one, which is granted: a hidden + // Merge leaves someone entitled to it with no way through, and one Bitbucket refuses + // at least says why. + api.getRepositoryPermission(target).pipe(Effect.orElseSucceed(() => true)), + ], + { concurrency: 5 }, + ).pipe( + Effect.mapError(fail("getChangeRequest")), + Effect.map( + ([ + pullRequest, + diffStat, + mergeability, + checks, + canWrite, + ]): ProviderChangeRequestDetail => ({ + ...toChangeRequest(pullRequest), + mergeability, + additions: diffStat.additions, + deletions: diffStat.deletions, + changedFiles: diffStat.changedFiles, + body: pullRequest.body, + mergedAt: pullRequest.state === "merged" ? pullRequest.updatedAt : null, + closedAt: pullRequest.state === "closed" ? pullRequest.updatedAt : null, + reviewers: pullRequest.reviewers, + checks, + // Bitbucket publishes no per-repository list of allowed strategies, so the ones it + // supports are all offered and a strategy the repository forbids fails on merge. + mergeCapabilities: { merge: true, squash: true, rebase: true }, + viewerPermissions: bitbucketViewerPermissions({ canWrite }), + }), + ), + ); + }, + + getChangeRequestActivity: (input) => { + const target = { repository: input.repository, number: input.number }; + return Effect.all( + [ + // Reviews ride on the pull request itself, so this inexpensive core read is repeated + // here rather than making the core response wait for the conversation endpoints. + api.getPullRequest(target), + api + .listComments(target) + .pipe(Effect.orElseSucceed(() => ({ comments: [], threads: [], truncated: true }))), + api.listCommits(target).pipe(Effect.orElseSucceed(() => [])), + ], + { concurrency: 3 }, + ).pipe( + Effect.mapError(fail("getChangeRequestActivity")), + Effect.map( + ([pullRequest, comments, commits]): ProviderChangeRequestActivity => ({ + comments: [...comments.comments, ...pullRequest.reviews].toSorted((left, right) => + left.createdAt.localeCompare(right.createdAt), + ), + commentCount: comments.comments.length + pullRequest.reviews.length, + commentsTruncated: comments.truncated, + reviewThreads: comments.threads, + commits, + }), + ), + ); + }, + + getViewerPermissions: (input) => + api.getRepositoryPermission({ repository: input.repository }).pipe( + Effect.mapError(fail("getViewerPermissions")), + Effect.map((canWrite) => bitbucketViewerPermissions({ canWrite })), + ), + + // `/diff` answers with the whole patch and pages nothing, so the first slice is the last. + getDiff: (input) => + api + .getPullRequestDiff({ + repository: input.repository, + number: input.number, + ...(input.commit === undefined ? {} : { commit: input.commit }), + }) + .pipe( + Effect.mapError(fail("getDiff")), + Effect.map((diff) => ({ ...diff, nextCursor: null })), + ), + + // Users only: Bitbucket requests a review of an account, and has no group that stands in for + // one on a pull request. + listReviewerCandidates: (input) => + api + .listReviewerCandidates({ repository: input.repository, number: input.number }) + .pipe(Effect.mapError(fail("listReviewerCandidates"))), + + setReviewerRequest: (input) => + api + .setReviewerRequest({ + repository: input.repository, + number: input.number, + reviewers: input.reviewers, + requested: input.requested, + }) + .pipe(Effect.mapError(fail("setReviewerRequest"))), + + runAction: (input) => + api + .runAction({ + repository: input.repository, + number: input.number, + action: input.action, + ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }), + }) + .pipe(Effect.mapError(fail("runAction"))), + + updateChangeRequest: (input) => + api + .updateChangeRequest({ + repository: input.repository, + number: input.number, + title: input.title, + body: input.body, + }) + .pipe(Effect.mapError(fail("updateChangeRequest"))), + + comment: (input) => + api + .comment({ repository: input.repository, number: input.number, body: input.body }) + .pipe(Effect.mapError(fail("comment"))), + + updateComment: (input) => + api + .updateComment({ + repository: input.repository, + number: input.number, + commentId: input.commentId, + body: input.body, + }) + .pipe(Effect.mapError(fail("updateComment"))), + + submitReview: (input) => + api + .submitReview({ + repository: input.repository, + number: input.number, + verdict: input.verdict, + body: input.body, + comments: input.comments, + }) + .pipe(Effect.mapError(fail("submitReview"))), + + replyToThread: (input) => + api + .replyToComment({ + repository: input.repository, + number: input.number, + commentId: input.threadId, + body: input.body, + }) + .pipe(Effect.mapError(fail("replyToThread"))), + + // Never called: `capabilities.reactions` is false, and the service refuses without it. + setReaction: () => + Effect.fail( + new PullRequestProviderError({ + provider: "bitbucket", + operation: "setReaction", + reason: "failed", + detail: "Bitbucket does not support reactions.", + }), + ), + + setThreadResolution: (input) => + api + .setCommentResolution({ + repository: input.repository, + number: input.number, + commentId: input.threadId, + resolved: input.resolved, + }) + .pipe(Effect.mapError(fail("setThreadResolution"))), + }; + + return provider; +}); diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts new file mode 100644 index 000000000000..848c4cd5ebc3 --- /dev/null +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts @@ -0,0 +1,2400 @@ +import { afterEach, assert, expect, it, vi } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import * as GitHubCli from "../sourceControl/GitHubCli.ts"; +import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; +import { BASE_COMPARISON_GRAPHQL_QUERY } from "./gitHubPullRequestJson.ts"; + +const mockedExecute = vi.fn(); + +const layer = it.layer( + GitHubPullRequestCli.layer.pipe( + Layer.provide( + Layer.mock(GitHubCli.GitHubCli)({ + execute: mockedExecute, + }), + ), + ), +); + +function output(stdout: string, stdoutTruncated = false, stdoutInvalidUtf8 = false) { + return { + exitCode: ChildProcessSpawner.ExitCode(0), + stdout, + stderr: "", + stdoutTruncated, + stderrTruncated: false, + stdoutInvalidUtf8, + }; +} + +function pullRequests( + count: number, + firstNumber: number, + overrides: (number: number) => Readonly> = () => ({}), +): string { + return JSON.stringify( + Array.from({ length: count }, (_, index) => ({ + number: firstNumber + index, + title: `Pull request ${firstNumber + index}`, + url: `https://github.com/acme/web/pull/${firstNumber + index}`, + headRefName: "feat/page", + baseRefName: "main", + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-02T00:00:00Z", + ...overrides(firstNumber + index), + })), + ); +} + +function pullRequestFiles(count: number, firstIndex: number): string { + return JSON.stringify( + Array.from({ length: count }, (_, index) => ({ + filename: `src/file${firstIndex + index}.ts`, + status: "modified", + patch: "@@ -1 +1 @@\n-old\n+new", + })), + ); +} + +/** One thread's comments as the GraphQL read returns them, cursor and all. */ +function threadComments( + ids: ReadonlyArray, + endCursor: string | null, + totalCount = ids.length, +) { + return { + totalCount, + pageInfo: { hasNextPage: endCursor !== null, endCursor }, + nodes: ids.map((id) => ({ id, body: id, createdAt: "2026-07-01T00:00:00Z" })), + }; +} + +function thread(id: string, ...commentIds: ReadonlyArray) { + return { + id, + path: "src/a.ts", + line: 1, + diffSide: "RIGHT", + isResolved: false, + isOutdated: false, + comments: threadComments(commentIds, null), + }; +} + +function reviewThreadsPage( + nodes: ReadonlyArray>, + endCursor: string | null, +): string { + return JSON.stringify({ + data: { + repository: { + pullRequest: { + reviewThreads: { + totalCount: nodes.length, + pageInfo: { hasNextPage: endCursor !== null, endCursor }, + nodes, + }, + }, + }, + }, + }); +} + +function threadCommentsPage( + ids: ReadonlyArray, + endCursor: string | null, + totalCount: number, +): string { + return JSON.stringify({ + data: { node: { comments: threadComments(ids, endCursor, totalCount) } }, + }); +} + +/** What `gh pr diff` answers on a pull request GitHub will not serve a diff for. */ +const diffRefused = new GitHubCli.GitHubCliCommandError({ + command: "gh", + cwd: "/w", + cause: new Error("HTTP 406: the diff exceeded the maximum number of files (300)"), +}); + +/** The whole invocation the nth call made, so both argv and stdin can be asserted. */ +function callAt(index: number) { + const call = mockedExecute.mock.calls[index]; + assert.isDefined(call); + return call[0]; +} + +/** The one argument `--search` carries, which is where every listing filter ends up. */ +function searchOfCall(index: number): string | undefined { + const args = callAt(index).args; + const flag = args.indexOf("--search"); + // Absent is its own answer: a read that carries no `--search` at all is what the fallback is. + return flag === -1 ? undefined : args[flag + 1]; +} + +/** One row as a search answers it, which is the listing's row one connection deeper. */ +function searchItem(number: number, repository: string, updatedAt: string) { + return { + number, + title: `Pull request ${number}`, + url: `https://github.com/${repository}/pull/${number}`, + author: { login: "octocat", avatarUrl: "https://avatars/octocat" }, + headRefName: "feat/page", + baseRefName: "main", + state: "OPEN", + isDraft: false, + mergeable: "MERGEABLE", + createdAt: "2026-07-01T00:00:00Z", + updatedAt, + repository: { nameWithOwner: repository }, + reviewRequests: { nodes: [{ requestedReviewer: { login: "hubot" } }] }, + labels: { nodes: [{ name: "bug", color: "ff0000" }] }, + }; +} + +function searchPage(nodes: ReadonlyArray, hasNextPage = false) { + return output(JSON.stringify({ data: { search: { pageInfo: { hasNextPage }, nodes } } })); +} + +/** The search a batched read sent, which travels in the request body rather than in argv. */ +function searchQueryOfCall(index: number): string | undefined { + const body = JSON.parse(callAt(index).stdin ?? "{}") as { variables?: { q?: string } }; + return body.variables?.q; +} + +afterEach(() => { + mockedExecute.mockReset(); +}); + +layer("GitHubPullRequestCli.layer", (it) => { + it.effect("asks for one row more than the page, to probe for a next page", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(3, 1)))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const batch = yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + }); + + assert.strictEqual(batch.items.length, 3); + assert.isFalse(batch.truncated); + const args = callAt(0).args; + expect(args).toContain("--repo"); + expect(args).toContain("github.com/acme/web"); + expect(args).toContain("--state"); + expect(args).toContain("open"); + expect(args).toContain("--limit"); + expect(args).toContain("11"); + }), + ); + + it.effect("reports truncation from the extra row, counted before decoding", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(11, 1)))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const batch = yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + }); + + assert.strictEqual(batch.items.length, 10); + assert.isTrue(batch.truncated); + }), + ); + + it.effect("excludes merged pull requests from the Closed tab", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "closed", + involvement: "all", + viewer: "bilal", + limit: 10, + }); + + // `--state closed` includes merged pull requests, so the tab narrows through search. + expect(searchOfCall(0)).toBe("is:unmerged sort:updated-desc"); + }), + ); + + it.effect("narrows to the author on the authored tab", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "open", + involvement: "authored", + viewer: "bilal", + limit: 10, + }); + + const args = callAt(0).args; + expect(args).toContain("--author"); + expect(args).toContain("bilal"); + }), + ); + + it.effect("narrows through search on the reviewing tab", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "open", + involvement: "reviewing", + viewer: "bilal", + limit: 10, + }); + + expect(searchOfCall(0)).toBe("review-requested:bilal sort:updated-desc"); + }), + ); + + it.effect("carries every repository and every qualifier into one search", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(searchPage([]))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.searchPullRequests({ + cwd: "/w", + host: "github.com", + repositories: ["acme/web", "pingdotgg/t3code"], + state: "closed", + involvement: "reviewing", + viewer: "bilal", + limit: 10, + query: "pull requests page", + cursor: { updatedBefore: "2026-07-02T00:00:00Z", delivered: 10 }, + }); + + // One request for both repositories, carrying everything the per-repository read expresses + // as a flag: the tab, the involvement, the reader's words, where to carry on from, and the + // order the page reads in. + assert.strictEqual(mockedExecute.mock.calls.length, 1); + assert.strictEqual( + searchQueryOfCall(0), + 'is:pr is:closed is:unmerged review-requested:bilal "pull requests page" ' + + "updated:<=2026-07-02T00:00:00Z sort:updated-desc repo:acme/web repo:pingdotgg/t3code", + ); + }), + ); + + it.effect("narrows a search to the author, and to merged on the merged tab", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(searchPage([]))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.searchPullRequests({ + cwd: "/w", + host: "github.com", + repositories: ["acme/web"], + state: "merged", + involvement: "authored", + viewer: "bilal", + limit: 10, + }); + + assert.strictEqual( + searchQueryOfCall(0), + "is:pr is:merged author:bilal sort:updated-desc repo:acme/web", + ); + }), + ); + + it.effect("keeps a searched-for qualifier inside the phrase, and out of argv", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(searchPage([]))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.searchPullRequests({ + cwd: "/w", + host: "github.com", + repositories: ["acme/web"], + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + query: 'x" is:merged repo:evil/repo', + }); + + // Quoted and escaped, so the words a reader typed narrow the listing rather than widening + // it — and the whole document travels over stdin rather than in a visible argv. + assert.strictEqual( + searchQueryOfCall(0), + 'is:pr is:open "x\\" is:merged repo:evil/repo" sort:updated-desc repo:acme/web', + ); + expect(callAt(0).args).not.toContain("-f"); + }), + ); + + it.effect("refuses to search for a repository GitHub cannot address", () => + Effect.gen(function* () { + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const failure = yield* Effect.flip( + cli.searchPullRequests({ + cwd: "/w", + host: "github.com", + repositories: ["acme/web", "acme/web is:merged"], + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + }), + ); + + // Nothing is sent: a name that could end its own qualifier is refused rather than escaped. + assert.strictEqual(failure._tag, "GitHubRepositorySelectorError"); + assert.strictEqual(mockedExecute.mock.calls.length, 0); + }), + ); + + it.effect("files each searched row under the repository it came from", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue( + Effect.succeed( + searchPage([ + searchItem(7, "acme/web", "2026-07-03T00:00:00Z"), + searchItem(9, "pingdotgg/t3code", "2026-07-02T00:00:00Z"), + // Not a pull request, which `is:pr` excludes and a decode skips rather than fails on. + {}, + ]), + ), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const batch = yield* cli.searchPullRequests({ + cwd: "/w", + host: "github.com", + repositories: ["acme/web", "pingdotgg/t3code"], + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + }); + + assert.deepStrictEqual( + batch.items.map((item) => [item.repository, item.number, item.author?.avatarUrl]), + [ + ["acme/web", 7, "https://avatars/octocat"], + ["pingdotgg/t3code", 9, "https://avatars/octocat"], + ], + ); + // The listing leaves the line counts to a read of their own. + assert.deepStrictEqual( + batch.items.map((item) => [item.additions, item.deletions]), + [ + [0, 0], + [0, 0], + ], + ); + assert.isFalse(batch.truncated); + }), + ); + + it.effect("reports truncation from the extra row, and from a page GitHub says has more", () => + Effect.gen(function* () { + mockedExecute + .mockReturnValueOnce( + Effect.succeed( + searchPage([ + searchItem(1, "acme/web", "2026-07-03T00:00:00Z"), + searchItem(2, "acme/web", "2026-07-02T00:00:00Z"), + searchItem(3, "acme/web", "2026-07-01T00:00:00Z"), + ]), + ), + ) + .mockReturnValueOnce( + Effect.succeed(searchPage([searchItem(1, "acme/web", "2026-07-03T00:00:00Z")], true)), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const read = () => + cli.searchPullRequests({ + cwd: "/w", + host: "github.com", + repositories: ["acme/web"], + state: "open", + involvement: "all", + viewer: "bilal", + limit: 2, + }); + + const overflowing = yield* read(); + const capped = yield* read(); + + // The extra row is the probe, and it is not handed on. + assert.strictEqual(overflowing.items.length, 2); + assert.isTrue(overflowing.truncated); + // A slice at GitHub's own ceiling has no extra row to probe with, so `hasNextPage` answers. + assert.isTrue(capped.truncated); + }), + ); + + it.effect("reads the line counts in chunks, and files them back by position", () => + Effect.gen(function* () { + const changeRequests = Array.from({ length: 26 }, (_, index) => ({ + repository: "acme/web", + number: index + 1, + })); + mockedExecute.mockImplementation(() => + // Every chunk answers for its first alias only, so a row GitHub said nothing about is + // dropped rather than shown as a change of no size. + Effect.succeed( + output(JSON.stringify({ data: { s0: { pullRequest: { additions: 4, deletions: 1 } } } })), + ), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const stats = yield* cli.listPullRequestStats({ + cwd: "/w", + host: "github.com", + changeRequests, + }); + + // Twenty-five aliases a request, so twenty-six rows are two requests. + assert.strictEqual(mockedExecute.mock.calls.length, 2); + assert.deepStrictEqual(stats, [ + { repository: "acme/web", number: 1, additions: 4, deletions: 1 }, + { repository: "acme/web", number: 26, additions: 4, deletions: 1 }, + ]); + const document = callAt(0).args.at(-1) ?? ""; + expect(document).toContain('s0: repository(owner: "acme", name: "web")'); + expect(document).toContain("pullRequest(number: 25)"); + }), + ); + + it.effect("refuses to look up counts for a repository GitHub cannot address", () => + Effect.gen(function* () { + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const failure = yield* Effect.flip( + cli.listPullRequestStats({ + cwd: "/w", + host: "github.com", + changeRequests: [{ repository: 'acme/web") { x } #', number: 1 }], + }), + ); + + assert.strictEqual(failure._tag, "GitHubRepositorySelectorError"); + assert.strictEqual(mockedExecute.mock.calls.length, 0); + }), + ); + + it.effect("hands a search to GitHub rather than to the rows already read", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + query: "pull requests page", + }); + + // The recency qualifier rides along, because free text would otherwise reorder the page + // by relevance and truncation would drop the newest matches. + expect(searchOfCall(0)).toBe('"pull requests page" sort:updated-desc'); + }), + ); + + it.effect("joins a search onto the tab's own qualifiers instead of replacing them", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "closed", + involvement: "reviewing", + viewer: "bilal", + limit: 10, + query: "page", + }); + + // One `--search` is all gh reads, so a second would silently drop the first. + const args = callAt(0).args; + assert.strictEqual(args.filter((arg) => arg === "--search").length, 1); + expect(searchOfCall(0)).toBe('review-requested:bilal is:unmerged "page" sort:updated-desc'); + }), + ); + + it.effect("carries the further narrowings into the search as qualifiers", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + filters: { + draft: "hide", + review: "changes-requested", + checks: "failing", + labels: [["needs design"], ['quo"te']], + excludedLabels: ["wip"], + author: "octocat", + }, + }); + + // Quotes around anything a reader typed, and the one character that could end a quoted + // value early dropped rather than escaped. + expect(searchOfCall(0)).toBe( + 'label:"needs design" label:"quote" -label:"wip" author:"octocat" draft:false ' + + "review:changes_requested status:failure sort:updated-desc", + ); + }), + ); + + it.effect('resolves an author filter of "me" to the viewer, not the literal word', () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + filters: { author: "me" }, + }); + + expect(searchOfCall(0)).toBe('author:"bilal" sort:updated-desc'); + }), + ); + + it.effect("sends one label qualifier per group, its names joined the way GitHub ors them", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + filters: { labels: [["size:S", "size:XS"], ["bug"]] }, + }); + + // One qualifier satisfied by either size, and a second one that must hold as well. + expect(searchOfCall(0)).toBe('label:"size:S","size:XS" label:"bug" sort:updated-desc'); + expect(callAt(0).args).toContain('label:"size:S","size:XS" label:"bug" sort:updated-desc'); + }), + ); + + it.effect( + "falls back for a repository the index does not cover under a checks filter, keeping only the matching rows", + () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + pullRequests(2, 1, (number) => ({ + statusCheckRollup: + number === 1 + ? [{ name: "lint", status: "COMPLETED", conclusion: "SUCCESS" }] + : [{ name: "test", status: "COMPLETED", conclusion: "FAILURE" }], + })), + ), + ), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const batch = yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + filters: { checks: "passing" }, + }); + + // The fallback's rows carry `checksState` exactly as a search's rows do, so `checks` is + // now a filter the fallback judges itself, the same as `draft`: an empty search answer + // under it is still ambiguous, and the row picked out afterwards is the one whose own + // `checksState` reads "passing". + expect(searchOfCall(1)).toBeUndefined(); + assert.deepStrictEqual( + batch.items.map((item) => item.number), + [1], + ); + }), + ); + + it.effect("fails a checks filter for a row whose checks are still pending", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + pullRequests(1, 1, () => ({ + statusCheckRollup: [{ name: "build", status: "IN_PROGRESS" }], + })), + ), + ), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const batch = yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + filters: { checks: "passing" }, + }); + + // Pending equals neither "passing" nor "failing", so it satisfies neither filter value — + // the same row would also be dropped by `checks: "failing"`. + assert.deepStrictEqual(batch.items, []); + }), + ); + + it.effect( + "falls back for a repository the index does not cover even under a judgeable filter", + () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + mockedExecute.mockReturnValueOnce( + Effect.succeed(output(pullRequests(2, 1, (number) => ({ isDraft: number === 1 })))), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const batch = yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + filters: { draft: "hide" }, + }); + + // `draft` is a filter the fallback can judge over its own rows just as search judges it, + // so an empty search answer under it alone is still ambiguous between "nothing matches" + // and "this repository is not indexed" — and the fallback applies the filter itself, + // keeping only the non-draft row. + expect(searchOfCall(1)).toBeUndefined(); + expect(batch.items.map((item) => item.number)).toEqual([2]); + }), + ); + + it.effect("carries the further narrowings into a batched search", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(searchPage([]))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.searchPullRequests({ + cwd: "/w", + host: "github.com", + repositories: ["acme/web"], + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + filters: { draft: "only", review: "none", labels: [["bug"]] }, + }); + + assert.strictEqual( + searchQueryOfCall(0), + 'is:pr is:open label:"bug" draft:true review:none sort:updated-desc repo:acme/web', + ); + }), + ); + + it.effect("quotes a search, so it cannot add a qualifier or a flag of its own", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + query: '-- is:merged label:secret "widen me"', + }); + + // Every word stays inside one phrase: nothing before it, nothing after it, and the + // leading dashes are text rather than the start of another argument. + expect(searchOfCall(0)).toBe( + String.raw`"-- is:merged label:secret \"widen me\"" sort:updated-desc`, + ); + expect(callAt(0).args).not.toContain("is:merged"); + }), + ); + + it.effect("escapes a backslash before the quote it would otherwise let out", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + query: String.raw`a\" is:merged`, + }); + + // GitHub reads `\\` as one backslash and `\"` as one quote, so the phrase ends where + // this says it does; escaping the quote alone would have closed it early. + expect(searchOfCall(0)).toBe(String.raw`"a\\\" is:merged" sort:updated-desc`); + }), + ); + + it.effect("asks for nothing but the order when the reader typed only spaces", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + query: " ", + }); + + // An empty phrase would match nothing rather than everything, so it is left out; the + // order the page reads rows in is asked for whether or not anything was typed. + expect(searchOfCall(0)).toBe("sort:updated-desc"); + }), + ); + + it.effect("carries on from the instant the last slice ended on", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output(pullRequests(3, 1)))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const batch = yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + cursor: { updatedBefore: "2026-07-02T00:00:00Z", delivered: 10 }, + }); + + // Inclusive, so the rows already sent at that instant come back for the caller to drop — + // which is what keeps the ones beside them from being skipped. + expect(searchOfCall(0)).toBe("updated:<=2026-07-02T00:00:00Z sort:updated-desc"); + assert.isTrue(batch.continues); + }), + ); + + it.effect("answers a search that found nothing with nothing, not with the whole repository", () => + Effect.gen(function* () { + // The fallback is for a repository the index does not cover. Under a text search an empty + // answer means the text matched nothing, and listing everything instead would fill the + // page with rows the reader did not search for. + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const batch = yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + query: "fdsfklj", + }); + + assert.strictEqual(batch.items.length, 0); + assert.strictEqual(mockedExecute.mock.calls.length, 1); + }), + ); + + it.effect("reads a repository GitHub will not search the way gh lists one", () => + Effect.gen(function* () { + // GitHub answers for a repository outside its search index with no rows and no error. + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + mockedExecute.mockReturnValueOnce( + Effect.succeed(output(pullRequests(3, 1, () => ({ state: "CLOSED" })))), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const batch = yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "closed", + involvement: "all", + viewer: "bilal", + limit: 10, + }); + + assert.strictEqual(batch.items.length, 3); + // The fallback itself uses no search, then narrows the decoded rows locally. They still + // arrive in gh's own order, so nothing can carry on from them. + expect(searchOfCall(1)).toBeUndefined(); + assert.isFalse(batch.continues); + }), + ); + + it.effect("keeps state and involvement filters on the search-free fallback", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + pullRequests(4, 1, (number) => ({ + state: number === 4 ? "OPEN" : "CLOSED", + ...(number === 3 ? { mergedAt: "2026-07-03T00:00:00Z" } : {}), + reviewRequests: + number === 2 ? [{ slug: "platform", name: "Platform" }] : [{ login: "bilal" }], + })), + ), + ), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const batch = yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "closed", + involvement: "reviewing", + viewer: "bilal", + limit: 10, + }); + + // Individual requests for this viewer and team requests survive. The fallback cannot + // resolve team membership, so dropping team-routed reviews would hide legitimate work. + expect(batch.items.map((item) => item.number)).toEqual([1, 2]); + expect(searchOfCall(1)).toBeUndefined(); + assert.isFalse(batch.continues); + }), + ); + + it.effect("grows the search-free fallback until it fills the filtered page", () => + Effect.gen(function* () { + const unrelated = () => ({ reviewRequests: [{ login: "somebody-else" }] }); + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(3, 1, unrelated)))); + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + pullRequests(4, 1, (number) => + number === 4 ? { reviewRequests: [{ login: "bilal" }] } : unrelated(), + ), + ), + ), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const batch = yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "open", + involvement: "reviewing", + viewer: "bilal", + limit: 2, + }); + + expect(batch.items.map((item) => item.number)).toEqual([4]); + const firstFallbackArgs = callAt(1).args; + const secondFallbackArgs = callAt(2).args; + expect(firstFallbackArgs[firstFallbackArgs.indexOf("--limit") + 1]).toBe("3"); + expect(secondFallbackArgs[secondFallbackArgs.indexOf("--limit") + 1]).toBe("6"); + assert.isFalse(batch.truncated); + }), + ); + + it.effect("bounds a sparse search-free fallback and reports the unread tail", () => + Effect.gen(function* () { + mockedExecute.mockImplementation((_input) => { + if (mockedExecute.mock.calls.length === 1) return Effect.succeed(output("[]")); + const args = callAt(mockedExecute.mock.calls.length - 1).args; + const limit = Number(args[args.indexOf("--limit") + 1]); + return Effect.succeed( + output( + pullRequests(limit, 1, () => ({ + reviewRequests: [{ login: "somebody-else" }], + })), + ), + ); + }); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const batch = yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "open", + involvement: "reviewing", + viewer: "bilal", + limit: 2, + }); + + const finalArgs = callAt(mockedExecute.mock.calls.length - 1).args; + expect(finalArgs[finalArgs.indexOf("--limit") + 1]).toBe("1000"); + assert.strictEqual(batch.items.length, 0); + assert.isTrue(batch.truncated); + }), + ); + + it.effect("takes an empty slice for a repository that has run out, not one to read again", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + cursor: { updatedBefore: "2026-07-02T00:00:00Z", delivered: 10 }, + }); + + // A repository that answered the search once answers it again, so an empty slice under a + // cursor is the end of it rather than a repository search cannot reach. + assert.strictEqual(mockedExecute.mock.calls.length, 1); + }), + ); + + it.effect("updates a stale branch with a merge commit unless asked to rebase", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output(""))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.runPullRequestAction({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + action: "update-branch", + }); + // GitHub's own default, and `gh`'s: a merge commit unless the rebase flag says otherwise. + expect(callAt(0).args).toEqual(["pr", "update-branch", "7", "--repo", "github.com/acme/web"]); + + yield* cli.runPullRequestAction({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + action: "update-branch", + updateMethod: "rebase", + }); + expect(callAt(1).args).toEqual([ + "pr", + "update-branch", + "7", + "--repo", + "github.com/acme/web", + "--rebase", + ]); + }), + ); + + it.effect("merges with the strategy it was asked for", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output(""))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.runPullRequestAction({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + action: "merge", + mergeMethod: "squash", + }); + + expect(callAt(0).args).toEqual([ + "pr", + "merge", + "7", + "--repo", + "github.com/acme/web", + "--squash", + ]); + }), + ); + + it.effect("arms auto-merge with the same strategy a merge would have used", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output(""))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.runPullRequestAction({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + action: "enable-auto-merge", + mergeMethod: "squash", + }); + expect(callAt(0).args).toEqual([ + "pr", + "merge", + "7", + "--repo", + "github.com/acme/web", + "--auto", + "--squash", + ]); + + // No strategy asked for is GitHub's own default, exactly as it is for a merge now. + yield* cli.runPullRequestAction({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + action: "enable-auto-merge", + }); + expect(callAt(1).args).toEqual([ + "pr", + "merge", + "7", + "--repo", + "github.com/acme/web", + "--auto", + "--merge", + ]); + }), + ); + + it.effect("takes auto-merge back off without naming a strategy", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output(""))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.runPullRequestAction({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + action: "disable-auto-merge", + mergeMethod: "squash", + }); + + expect(callAt(0).args).toEqual([ + "pr", + "merge", + "7", + "--repo", + "github.com/acme/web", + "--disable-auto", + ]); + }), + ); + + it.effect("returns a pull request to draft by undoing ready", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output(""))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.runPullRequestAction({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + action: "draft", + }); + + // gh has no `draft` command; going back is `ready --undo`. + expect(callAt(0).args).toEqual([ + "pr", + "ready", + "7", + "--repo", + "github.com/acme/web", + "--undo", + ]); + }), + ); + + it.effect("sends a comment body over stdin, never in argv", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output(""))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.commentOnPullRequest({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + body: "Looks good.", + }); + + // argv shows up in process listings and in process-runner failure messages. + expect(callAt(0).args).toEqual([ + "pr", + "comment", + "7", + "--repo", + "github.com/acme/web", + "--body-file", + "-", + ]); + expect(callAt(0).stdin).toBe("Looks good."); + expect(callAt(0).args).not.toContain("Looks good."); + }), + ); + + it.effect("names the host on every repository it addresses", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.acme.dev", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + }); + + // A bare `owner/repo` resolves against github.com, which is a different repository. + expect(callAt(0).args).toContain("github.acme.dev/acme/web"); + }), + ); + + it.effect("asks a GitHub Enterprise host for its own review threads", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + data: { + repository: { pullRequest: { reviewThreads: { totalCount: 0, nodes: [] } } }, + }, + }), + ), + ), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.listReviewThreadComments({ + cwd: "/w", + repository: "acme/web", + host: "github.acme.dev", + number: 7, + }); + + const args = callAt(0).args; + expect(args).toContain("--hostname"); + expect(args).toContain("github.acme.dev"); + expect(args).toContain("owner=acme"); + expect(args).toContain("name=web"); + }), + ); + + it.effect("serves a diff GitHub hands over whole in one request, with no next slice", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("diff --git a/a b/a"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const diff = yield* cli.getPullRequestDiff({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + + assert.isNull(diff.nextCursor); + assert.isFalse(diff.truncated); + // The common case pays for one request and not the files API on top of it. + assert.strictEqual(mockedExecute.mock.calls.length, 1); + // `--patch` asks gh for a format-patch stream, which repeats a file once per commit. + // The review needs GitHub's combined pull-request diff: one section per changed file. + expect(callAt(0).args).not.toContain("--patch"); + }), + ); + + it.effect("reads one files page when GitHub refuses the diff, and says it is the last", () => + Effect.gen(function* () { + // GitHub answers 406 rather than a diff past 300 changed files. + mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); + mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(2, 1)))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const diff = yield* cli.getPullRequestDiff({ + cwd: "/w", + repository: "acme/web", + host: "github.acme.dev", + number: 7, + }); + + assert.isFalse(diff.truncated); + // A short page is the end of the change set, so there is nothing to carry on from. + assert.isNull(diff.nextCursor); + expect(diff.patch).toContain("diff --git a/src/file1.ts b/src/file1.ts"); + expect(diff.patch).toContain("diff --git a/src/file2.ts b/src/file2.ts"); + const args = callAt(1).args; + expect(args).toContain("--hostname"); + expect(args).toContain("github.acme.dev"); + expect(args).toContain("repos/acme/web/pulls/7/files?per_page=100&page=1"); + }), + ); + + it.effect("hands back a cursor for the next page rather than walking on by itself", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); + mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(100, 0)))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const diff = yield* cli.getPullRequestDiff({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + + // A full page means more files, which the reader asks for; it is not a truncated slice. + assert.isFalse(diff.truncated); + assert.isNotNull(diff.nextCursor); + assert.strictEqual(mockedExecute.mock.calls.length, 2); + }), + ); + + it.effect("carries on from a cursor without asking `gh pr diff` again", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); + mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(100, 0)))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const target = { cwd: "/w", repository: "acme/web", host: "github.com", number: 7 }; + + const first = yield* cli.getPullRequestDiff(target); + assert.isNotNull(first.nextCursor); + mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(4, 100)))); + const second = yield* cli.getPullRequestDiff({ ...target, cursor: first.nextCursor }); + + assert.isNull(second.nextCursor); + expect(second.patch).toContain("diff --git a/src/file100.ts b/src/file100.ts"); + // The second slice is one request: the cursor already says where to read. + assert.strictEqual(mockedExecute.mock.calls.length, 3); + expect(callAt(2).args).toContain("repos/acme/web/pulls/7/files?per_page=100&page=2"); + }), + ); + + it.effect("refuses a cursor it never handed out rather than reading it into a request", () => + Effect.gen(function* () { + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const error = yield* Effect.flip( + cli.getPullRequestDiff({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + cursor: "1&per_page=1", + }), + ); + + assert.strictEqual(error._tag, "GitHubDiffCursorError"); + assert.strictEqual(mockedExecute.mock.calls.length, 0); + }), + ); + + it.effect("reads a named commit from the commit endpoint rather than from `gh pr diff`", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(2, 1)))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const diff = yield* cli.getPullRequestDiff({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + commit: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0", + }); + + // One request: the commit's own changes never take the `gh pr diff` road. + assert.strictEqual(mockedExecute.mock.calls.length, 1); + assert.isNull(diff.nextCursor); + expect(diff.patch).toContain("diff --git a/src/file1.ts b/src/file1.ts"); + const args = callAt(0).args; + expect(args).toContain( + "repos/acme/web/commits/a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0?per_page=100&page=1", + ); + // The commit endpoint wraps its files in an object, which jq unwraps for the decoder. + expect(args).toContain(".files // []"); + }), + ); + + it.effect("pages inside a commit the way it pages the pull request's own files", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(100, 0)))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const target = { + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + commit: "a1b2c3d", + }; + + const first = yield* cli.getPullRequestDiff(target); + assert.isNotNull(first.nextCursor); + mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(4, 100)))); + const second = yield* cli.getPullRequestDiff({ ...target, cursor: first.nextCursor }); + + assert.isNull(second.nextCursor); + expect(callAt(1).args).toContain("repos/acme/web/commits/a1b2c3d?per_page=100&page=2"); + }), + ); + + it.effect("refuses a commit that is not a sha rather than reading it into a request", () => + Effect.gen(function* () { + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const error = yield* Effect.flip( + cli.getPullRequestDiff({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + commit: "../../pulls/8/files", + }), + ); + + assert.strictEqual(error._tag, "GitHubDiffCommitError"); + assert.strictEqual(mockedExecute.mock.calls.length, 0); + }), + ); + + it.effect("expands a new file from a root commit without requiring a parent", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("\ta1b2c3d\n"))); + mockedExecute.mockReturnValueOnce(Effect.succeed(output("root contents\n"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const contents = yield* cli.getPullRequestDiffFileContents({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + commit: "a1b2c3d", + changeType: "new", + oldPath: "src/root.ts", + newPath: "src/root.ts", + }); + + expect(contents).toEqual({ oldContents: "", newContents: "root contents\n" }); + assert.strictEqual(mockedExecute.mock.calls.length, 2); + expect(callAt(1).args.join(" ")).toContain("contents/src/root.ts?ref=a1b2c3d"); + }), + ); + + it.effect("reports unusable diff revisions as a structured error", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("not-a-sha\tstill-not-a-sha\n"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const error = yield* Effect.flip( + cli.getPullRequestDiffFileContents({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + commit: "a1b2c3d", + changeType: "change", + oldPath: "src/a.ts", + newPath: "src/a.ts", + }), + ); + + assert.strictEqual(error._tag, "GitHubDiffRevisionsUnavailableError"); + if (error._tag === "GitHubDiffRevisionsUnavailableError") { + assert.strictEqual(error.number, 7); + assert.strictEqual(error.commit, "a1b2c3d"); + } + }), + ); + + it.effect("reports an oversized diff file with its path and reason", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("a1b2c3d\tb1c2d3e\n"))); + mockedExecute.mockReturnValueOnce(Effect.succeed(output("partial", true))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const error = yield* Effect.flip( + cli.getPullRequestDiffFileContents({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + changeType: "deleted", + oldPath: "src/large.ts", + newPath: "src/large.ts", + }), + ); + + assert.strictEqual(error._tag, "GitHubDiffFileContentsUnavailableError"); + if (error._tag === "GitHubDiffFileContentsUnavailableError") { + assert.strictEqual(error.path, "src/large.ts"); + assert.strictEqual(error.reason, "oversized"); + } + }), + ); + + it.effect("reports undecodable diff file contents as binary", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("a1b2c3d\tb1c2d3e\n"))); + mockedExecute.mockReturnValueOnce( + Effect.succeed(output("binary\uFFFDcontents", false, true)), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const error = yield* Effect.flip( + cli.getPullRequestDiffFileContents({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + changeType: "deleted", + oldPath: "assets/logo.png", + newPath: "assets/logo.png", + }), + ); + + assert.strictEqual(error._tag, "GitHubDiffFileContentsUnavailableError"); + if (error._tag === "GitHubDiffFileContentsUnavailableError") { + assert.strictEqual(error.path, "assets/logo.png"); + assert.strictEqual(error.reason, "binary"); + } + }), + ); + + it.effect("returns valid text containing a literal replacement character", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("a1b2c3d\tb1c2d3e\n"))); + mockedExecute.mockReturnValueOnce(Effect.succeed(output("before\uFFFDafter"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const contents = yield* cli.getPullRequestDiffFileContents({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + changeType: "deleted", + oldPath: "docs/encoding.md", + newPath: "docs/encoding.md", + }); + + assert.strictEqual(contents.oldContents, "before\uFFFDafter"); + }), + ); + + it.effect("ends the diff on a page with no files rather than asking for it again", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const diff = yield* cli.getPullRequestDiff({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + cursor: "4", + }); + + assert.strictEqual(diff.patch, ""); + assert.isNull(diff.nextCursor); + }), + ); + + it.effect("reports the refused diff when the files API cannot answer either", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); + mockedExecute.mockReturnValueOnce(Effect.succeed(output("not json"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const error = yield* Effect.flip( + cli.getPullRequestDiff({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }), + ); + + assert.strictEqual(error, diffRefused); + }), + ); + + it.effect("skips the avatar lookup when a listing named nobody", () => + Effect.gen(function* () { + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const avatars = yield* cli.listActorAvatars({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + ids: [], + }); + + assert.strictEqual(avatars.size, 0); + assert.strictEqual(mockedExecute.mock.calls.length, 0); + }), + ); + + it.effect("fails when the authenticated account has no login", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(" "))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const error = yield* Effect.flip(cli.getViewerLogin({ cwd: "/w" })); + + assert.strictEqual(error._tag, "GitHubViewerLoginUnavailableError"); + }), + ); + + it.effect("sends a whole review as one request body over stdin", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.submitReview({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + verdict: "approve", + body: "Looks right.", + comments: [{ path: "src/a.ts", line: 4, side: "right", body: "nit" }], + }); + + expect(callAt(0).args).toEqual([ + "api", + "--method", + "POST", + "--hostname", + "github.com", + "repos/acme/web/pulls/7/reviews", + "--input", + "-", + ]); + // One request, so nothing is on the pull request until the verdict is. + assert.strictEqual(mockedExecute.mock.calls.length, 1); + // @effect-diagnostics-next-line preferSchemaOverJson:off + expect(JSON.parse(callAt(0).stdin ?? "")).toEqual({ + event: "APPROVE", + body: "Looks right.", + comments: [{ path: "src/a.ts", line: 4, side: "RIGHT", body: "nit" }], + }); + }), + ); + + it.effect("sends a reply body over stdin, never in argv", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.replyToReviewThread({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + threadId: "PRRT_1", + body: "Fixed in 42ff8ec.", + }); + + // A reply is the reader's own words, so it travels the same way a comment body does. + expect(callAt(0).args).toEqual([ + "api", + "graphql", + "--hostname", + "github.com", + "--input", + "-", + ]); + // @effect-diagnostics-next-line preferSchemaOverJson:off + const request = JSON.parse(callAt(0).stdin ?? "") as { + query: string; + variables: Record; + }; + expect(request.query).toContain("addPullRequestReviewThreadReply"); + expect(request.variables).toEqual({ threadId: "PRRT_1", body: "Fixed in 42ff8ec." }); + expect(callAt(0).args.join(" ")).not.toContain("Fixed in 42ff8ec."); + }), + ); + + it.effect("resolves and unresolves through the mutation each one needs", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.setReviewThreadResolution({ + cwd: "/w", + repository: "acme/web", + host: "github.acme.dev", + threadId: "PRRT_1", + resolved: true, + }); + yield* cli.setReviewThreadResolution({ + cwd: "/w", + repository: "acme/web", + host: "github.acme.dev", + threadId: "PRRT_1", + resolved: false, + }); + + const parse = (index: number) => JSON.parse(callAt(index).stdin ?? "") as { query: string }; + expect(parse(0).query).toContain("resolveReviewThread("); + expect(parse(1).query).toContain("unresolveReviewThread("); + // A GitHub Enterprise thread is resolved on its own host, not on github.com. + expect(callAt(0).args).toContain("github.acme.dev"); + }), + ); + + it.effect("confirms a given subject belongs to the named pull request, then reacts to it", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + data: { + repository: { pullRequest: { id: "PR_kwDOA" } }, + node: { id: "IC_1", pullRequest: { id: "PR_kwDOA" } }, + }, + }), + ), + ), + ); + mockedExecute.mockReturnValueOnce(Effect.succeed(output("{}"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.setReaction({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + subjectId: "IC_1", + content: "heart", + reacted: true, + }); + + assert.strictEqual(mockedExecute.mock.calls.length, 2); + const scopeCheck = callAt(0).args; + expect(scopeCheck).toContain("owner=acme"); + expect(scopeCheck).toContain("name=web"); + expect(scopeCheck).toContain("number=7"); + expect(scopeCheck).toContain("subjectId=IC_1"); + // @effect-diagnostics-next-line preferSchemaOverJson:off + const request = JSON.parse(callAt(1).stdin ?? "") as { + query: string; + variables: Record; + }; + expect(request.query).toContain("addReaction("); + expect(request.variables).toEqual({ subjectId: "IC_1", content: "HEART" }); + }), + ); + + it.effect("refuses a given subject that belongs to a different pull request", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + data: { + repository: { pullRequest: { id: "PR_thisOne" } }, + // A comment on pull request #99 of a different repository, named as though it + // belonged to #7 here. + node: { id: "IC_99", pullRequest: { id: "PR_someOtherOne" } }, + }, + }), + ), + ), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const error = yield* Effect.flip( + cli.setReaction({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + subjectId: "IC_99", + content: "heart", + reacted: true, + }), + ); + + assert.strictEqual(error._tag, "GitHubSubjectScopeError"); + // Refused before any mutation was sent. + assert.strictEqual(mockedExecute.mock.calls.length, 1); + }), + ); + + it.effect("looks up the pull request's own node id when no subject was given", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ data: { repository: { pullRequest: { id: "PR_kwDOA" } } } }), + ), + ), + ); + mockedExecute.mockReturnValueOnce(Effect.succeed(output("{}"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.setReaction({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + content: "rocket", + reacted: true, + }); + + assert.strictEqual(mockedExecute.mock.calls.length, 2); + const lookup = callAt(0).args; + expect(lookup).toContain("owner=acme"); + expect(lookup).toContain("name=web"); + expect(lookup).toContain("number=7"); + // @effect-diagnostics-next-line preferSchemaOverJson:off + const request = JSON.parse(callAt(1).stdin ?? "") as { + query: string; + variables: Record; + }; + expect(request.query).toContain("addReaction("); + expect(request.variables).toEqual({ subjectId: "PR_kwDOA", content: "ROCKET" }); + }), + ); + + it.effect("takes a reaction back through the remove mutation", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + data: { + repository: { pullRequest: { id: "PR_kwDOA" } }, + node: { id: "IC_1", pullRequest: { id: "PR_kwDOA" } }, + }, + }), + ), + ), + ); + mockedExecute.mockReturnValueOnce(Effect.succeed(output("{}"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.setReaction({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + subjectId: "IC_1", + content: "heart", + reacted: false, + }); + + // @effect-diagnostics-next-line preferSchemaOverJson:off + const request = JSON.parse(callAt(1).stdin ?? "") as { query: string }; + expect(request.query).toContain("removeReaction("); + }), + ); + + it.effect("rewrites only the words a request named", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ data: { repository: { pullRequest: { id: "PR_kwDOA" } } } }), + ), + ), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const rewrite = (fields: { readonly title?: string; readonly body?: string }) => + cli.updatePullRequest({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + ...fields, + }); + + yield* rewrite({ title: "A better title" }); + yield* rewrite({ body: "A better description." }); + yield* rewrite({ title: "Both", body: "at once." }); + + // Each rewrite looks the pull request's node id up first, then mutates. + const variablesAt = (index: number) => + (JSON.parse(callAt(index).stdin ?? "") as { variables: Record }).variables; + expect(variablesAt(1)).toEqual({ pullRequestId: "PR_kwDOA", title: "A better title" }); + expect(variablesAt(3)).toEqual({ + pullRequestId: "PR_kwDOA", + body: "A better description.", + }); + expect(variablesAt(5)).toEqual({ + pullRequestId: "PR_kwDOA", + title: "Both", + body: "at once.", + }); + // The reader's own words, so they travel the way every other body does. + expect(callAt(5).args.join(" ")).not.toContain("at once."); + }), + ); + + it.effect("rewrites a remark through the mutation its kind needs", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + data: { + repository: { pullRequest: { id: "PR_kwDOA" } }, + node: { id: "IC_1", pullRequest: { id: "PR_kwDOA" } }, + }, + }), + ), + ), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const rewrite = (kind: "issue-comment" | "review-comment") => + cli.updateComment({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + commentId: "IC_1", + kind, + body: "Reworded.", + }); + + yield* rewrite("issue-comment"); + yield* rewrite("review-comment"); + + const parse = (index: number) => + JSON.parse(callAt(index).stdin ?? "") as { + query: string; + variables: Record; + }; + expect(callAt(0).args).toContain("subjectId=IC_1"); + expect(parse(1).query).toContain("updateIssueComment("); + expect(parse(1).variables).toEqual({ commentId: "IC_1", body: "Reworded." }); + expect(parse(3).query).toContain("updatePullRequestReviewComment("); + expect(parse(3).variables).toEqual({ commentId: "IC_1", body: "Reworded." }); + }), + ); + + it.effect("refuses a comment that belongs to a different pull request", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + data: { + repository: { pullRequest: { id: "PR_thisOne" } }, + node: { id: "IC_99", pullRequest: { id: "PR_someOtherOne" } }, + }, + }), + ), + ), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const error = yield* Effect.flip( + cli.updateComment({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + commentId: "IC_99", + kind: "issue-comment", + body: "Reworded.", + }), + ); + + assert.strictEqual(error._tag, "GitHubSubjectScopeError"); + expect(error.message).toContain("updateComment"); + // Refused before any mutation was sent. + assert.strictEqual(mockedExecute.mock.calls.length, 1); + }), + ); + + it.effect("fails the read when gh returns something unreadable", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"message":"not found"}'))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const error = yield* Effect.flip( + cli.getPullRequestDetail({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }), + ); + + assert.strictEqual(error._tag, "GitHubPullRequestReadError"); + }), + ); + + it.effect("keeps the core detail read separate from conversation activity", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + number: 7, + title: "Progressive detail", + url: "https://github.com/acme/web/pull/7", + author: { login: "octocat" }, + headRefName: "feature", + baseRefName: "main", + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-02T00:00:00Z", + body: "Core body", + changedFiles: 2, + }), + ), + ), + ); + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + author: { login: "octocat" }, + comments: [], + reviews: [], + commits: [], + }), + ), + ), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const input = { + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + } as const; + + const detail = yield* cli.getPullRequestDetail(input); + const activity = yield* cli.getPullRequestActivity(input); + + expect(detail.body).toBe("Core body"); + expect(activity.author?.login).toBe("octocat"); + expect(callAt(0).args.at(-1)).toBe( + "number,title,url,author,headRefName,baseRefName,state,isDraft,mergeable,reviewDecision,additions,deletions,createdAt,updatedAt,mergedAt,reviewRequests,labels,statusCheckRollup,body,changedFiles,closedAt,headRepositoryOwner,autoMergeRequest", + ); + expect(callAt(1).args.at(-1)).toBe("author,comments,reviews,commits"); + }), + ); + + it.effect("fails a files page too large to read rather than calling the diff whole", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); + mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(1, 1), true))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const error = yield* Effect.flip( + cli.getPullRequestDiff({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }), + ); + + // What matters is that it fails at all: an empty patch with no cursor would render as a + // change with no files and report the rest of it as already read. The refusal that sent + // the read down this road is the one reported, by design. + assert.strictEqual(error._tag, "GitHubCliCommandError"); + }), + ); + + it.effect("pages an oversized patch by file rather than handing back a severed one", () => + Effect.gen(function* () { + // `gh pr diff` succeeded but its output was cut at a byte, which lands mid-file. + mockedExecute.mockReturnValueOnce( + Effect.succeed(output("diff --git a/a b/a\n@@ -1 +1 @@", true)), + ); + mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(1, 1)))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const slice = yield* cli.getPullRequestDiff({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + + // The severed patch is thrown away; what comes back is assembled from whole files. + expect(callAt(1).args.join(" ")).toContain("/pulls/7/files"); + expect(slice.patch).toContain("src/file1.ts"); + assert.strictEqual(mockedExecute.mock.calls.length, 2); + }), + ); + + it.effect("follows the cursor to the review threads the first page left behind", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed(output(reviewThreadsPage([thread("PRRT_1", "c1")], "Y3Vyc29yOjE"))), + ); + mockedExecute.mockReturnValueOnce( + Effect.succeed(output(reviewThreadsPage([thread("PRRT_2", "c2")], null))), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const conversation = yield* cli.listReviewThreadComments({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + + // The first page asks from the beginning, which gh only sends as a typed JSON null. + expect(callAt(0).args).toContain("cursor=null"); + expect(callAt(1).args).toContain("cursor=Y3Vyc29yOjE"); + expect(conversation.comments.map((comment) => comment.id)).toEqual(["c1", "c2"]); + assert.isFalse(conversation.truncated); + }), + ); + + it.effect("stops at the thread bound and says the conversation was cut short", () => + Effect.gen(function* () { + // A host that never runs out of pages: the walk has to end itself. + mockedExecute.mockReturnValue( + Effect.succeed(output(reviewThreadsPage([thread("PRRT_1", "c1")], "Y3Vyc29yOjE"))), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const conversation = yield* cli.listReviewThreadComments({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + + assert.strictEqual(mockedExecute.mock.calls.length, 10); + assert.isTrue(conversation.truncated); + }), + ); + + it.effect("finishes a thread longer than one page from the thread's own node", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + reviewThreadsPage( + [{ ...thread("PRRT_1", "c1"), comments: threadComments(["c1"], "Y3Vyc29yOjI", 3) }], + null, + ), + ), + ), + ); + mockedExecute.mockReturnValueOnce( + Effect.succeed(output(threadCommentsPage(["c2", "c3"], null, 3))), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const conversation = yield* cli.listReviewThreadComments({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + + expect(callAt(1).args).toContain("threadId=PRRT_1"); + expect(conversation.comments.map((comment) => comment.id)).toEqual(["c1", "c2", "c3"]); + // GitHub's own count, which is what the page shows however much of it was read. + assert.strictEqual(conversation.commentCount, 3); + }), + ); + + it.effect( + "asks for the reader's standing on the repository and on the pull request at once", + () => + Effect.gen(function* () { + mockedExecute.mockReturnValue( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + data: { + repository: { + viewerPermission: "READ", + pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true }, + }, + }, + }), + ), + ), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const access = yield* cli.getViewerAccess({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + + // One request, because both answers hang off the same repository object. + assert.strictEqual(mockedExecute.mock.calls.length, 1); + expect(callAt(0).args).toContain("number=7"); + expect(access).toEqual({ canWrite: false, canUpdate: true, didAuthor: true }); + }), + ); + + it.effect("sends the base comparison's variables as gh flags, not as bare words", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + data: { + repository: { + pullRequest: { + viewerCanUpdateBranch: true, + baseRef: { compare: { behindBy: 4 } }, + }, + }, + }, + }), + ), + ), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const comparison = yield* cli.getPullRequestBaseComparison({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + headRef: "fork:feat/page", + }); + + // The tuples are flattened straight into argv, so a variable without its flag is a + // positional argument gh refuses outright. + const args = callAt(0).args; + expect(args).toEqual([ + "api", + "graphql", + "--hostname", + "github.com", + "-f", + "owner=acme", + "-f", + "name=web", + "-F", + "number=7", + "-f", + "headRef=fork:feat/page", + "-f", + `query=${BASE_COMPARISON_GRAPHQL_QUERY}`, + ]); + expect(comparison).toEqual({ behindBy: 4, viewerCanUpdate: true }); + }), + ); + + it.effect("reads the viewer's role off the same call as the merge settings", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + mergeCommitAllowed: false, + squashMergeAllowed: true, + rebaseMergeAllowed: true, + viewerPermission: "WRITE", + }), + ), + ), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const access = yield* cli.getRepositoryAccess({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + }); + + assert.strictEqual(mockedExecute.mock.calls.length, 1); + expect(callAt(0).args).toContain( + "mergeCommitAllowed,squashMergeAllowed,rebaseMergeAllowed,viewerPermission", + ); + assert.isTrue(access.canWrite); + expect(access.mergeCapabilities).toEqual({ merge: false, squash: true, rebase: true }); + }), + ); + + it.effect("asks GitHub to review, naming the collection a request is added to", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.setReviewerRequest({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + reviewers: [ + { id: "octocat", kind: "user" }, + { id: "reviewers", kind: "team" }, + ], + requested: true, + }); + + const call = callAt(0); + expect(call.args).toEqual([ + "api", + "--method", + "POST", + "--hostname", + "github.com", + "repos/acme/web/pulls/7/requested_reviewers", + "--input", + "-", + ]); + // @effect-diagnostics-next-line preferSchemaOverJson:off + expect(JSON.parse(call.stdin ?? "")).toEqual({ + reviewers: ["octocat"], + team_reviewers: ["reviewers"], + }); + }), + ); + + it.effect("takes a request back by deleting from the same collection it was added to", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.setReviewerRequest({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + reviewers: [{ id: "octocat", kind: "user" }], + requested: false, + }); + + const call = callAt(0); + expect(call.args).toContain("DELETE"); + expect(call.args).toContain("repos/acme/web/pulls/7/requested_reviewers"); + // @effect-diagnostics-next-line preferSchemaOverJson:off + expect(JSON.parse(call.stdin ?? "")).toEqual({ + reviewers: ["octocat"], + team_reviewers: [], + }); + }), + ); + + it.effect("reads who may review and who already has in one request", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + data: { + repository: { + assignableUsers: { + pageInfo: { hasNextPage: false }, + nodes: [{ login: "bilal" }, { login: "octocat" }, { login: "hubot" }], + }, + pullRequest: { + author: { login: "bilal" }, + reviewRequests: { nodes: [{ requestedReviewer: { login: "octocat" } }] }, + }, + }, + }, + }), + ), + ), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const list = yield* cli.listReviewerCandidates({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + + // The people, who has been asked and who opened the pull request all hang off the same + // repository object, so the menu costs one request. + assert.strictEqual(mockedExecute.mock.calls.length, 1); + expect(callAt(0).args).toContain("number=7"); + expect(list.candidates.map((candidate) => [candidate.login, candidate.isRequested])).toEqual([ + ["octocat", true], + ["hubot", false], + ]); + }), + ); +}); diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.ts new file mode 100644 index 000000000000..27402c2115ad --- /dev/null +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.ts @@ -0,0 +1,1868 @@ +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 { + resolvePullRequestAuthorFilter, + type PullRequestAction, + type PullRequestActor, + type PullRequestInvolvement, + type PullRequestListFilters, + type PullRequestListState, + type PullRequestMergeMethod, + type PullRequestOmittedFileStat, + type PullRequestReaction, + type PullRequestReactionContent, + type PullRequestReviewCommentDraft, + type PullRequestReviewVerdict, + type PullRequestReviewerCandidateList, + type PullRequestReviewerKind, + type PullRequestThreadComment, + type PullRequestUpdateMethod, +} from "@t3tools/contracts"; + +import * as GitHubCli from "../sourceControl/GitHubCli.ts"; +import { + ACTOR_AVATARS_GRAPHQL_QUERY, + ADD_REACTION_GRAPHQL_MUTATION, + buildReviewSubmissionJson, + buildReviewerRequestJson, + decodeActorAvatarsJson, + decodePullRequestActivityJson, + decodePullRequestDetailJson, + decodePullRequestFilesJson, + decodePullRequestListJson, + decodePullRequestNodeIdJson, + decodePullRequestSearchJson, + decodePullRequestStatsJson, + decodeReactionSubjectScopeJson, + decodeRepositoryAccessJson, + decodeReviewerCandidatesJson, + decodeReviewDismissalsJson, + decodeReviewThreadCommentsJson, + decodeReviewThreadsJson, + buildPullRequestStatsGraphQlQuery, + encodeGraphQlRequestJson, + pullRequestSearchGraphQlQuery, + PULL_REQUEST_SEARCH_MAX_ROWS, + PULL_REQUEST_ACTIVITY_JSON_FIELDS, + BASE_COMPARISON_GRAPHQL_QUERY, + decodeBaseComparisonJson, + PULL_REQUEST_DETAIL_JSON_FIELDS, + PULL_REQUEST_LIST_JSON_FIELDS, + PULL_REQUEST_NODE_ID_GRAPHQL_QUERY, + REACTION_SUBJECT_PULL_REQUEST_GRAPHQL_QUERY, + REMOVE_REACTION_GRAPHQL_MUTATION, + gitHubReactionContent, + REPOSITORY_ACCESS_JSON_FIELDS, + RESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION, + REVIEWER_CANDIDATES_GRAPHQL_QUERY, + REVIEW_THREAD_COMMENTS_GRAPHQL_QUERY, + REVIEW_DISMISSALS_GRAPHQL_QUERY, + REVIEW_THREAD_REPLY_GRAPHQL_MUTATION, + REVIEW_THREADS_GRAPHQL_QUERY, + reviewThreadConversation, + UNRESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION, + UPDATE_ISSUE_COMMENT_GRAPHQL_MUTATION, + UPDATE_PULL_REQUEST_GRAPHQL_MUTATION, + UPDATE_REVIEW_COMMENT_GRAPHQL_MUTATION, + VIEWER_PERMISSIONS_GRAPHQL_QUERY, + decodeViewerPermissionsJson, + type GitHubBaseComparison, + type GitHubPullRequestDetail, + type GitHubPullRequestActivity, + type GitHubPullRequestListItem, + type GitHubPullRequestSearchItem, + type GitHubReviewThreadComments, + type GitHubRepositoryAccess, + type GitHubReviewThreadEntry, + type GitHubReviewThreadPage, + type GitHubViewerAccess, +} from "./gitHubPullRequestJson.ts"; +import type { ProviderListCursor } from "./PullRequestProvider.ts"; + +/** + * Names the read that produced unusable output, so a failure reports the call it came from + * rather than borrowing another operation's message. + */ +export class GitHubPullRequestReadError extends Schema.TaggedErrorClass()( + "GitHubPullRequestReadError", + { + command: Schema.Literal("gh"), + cwd: Schema.String, + operation: Schema.String, + cause: Schema.Defect(), + }, +) { + get detail(): string { + return `GitHub CLI returned an unreadable ${this.operation} response.`; + } + + override get message(): string { + return `GitHub CLI failed in ${this.operation}: ${this.detail}`; + } +} + +/** Not a decode failure: gh answered, the account it answered for just has no login. */ +export class GitHubViewerLoginUnavailableError extends Schema.TaggedErrorClass()( + "GitHubViewerLoginUnavailableError", + { + command: Schema.Literal("gh"), + cwd: Schema.String, + }, +) { + get detail(): string { + return "GitHub CLI returned no login for the authenticated account."; + } + + override get message(): string { + return `GitHub CLI failed in getViewerLogin: ${this.detail}`; + } +} + +/** Not a decode failure: the reader asked to carry on from a cursor this walk never handed out. */ +export class GitHubDiffCursorError extends Schema.TaggedErrorClass()( + "GitHubDiffCursorError", + { + command: Schema.Literal("gh"), + cwd: Schema.String, + }, +) { + get detail(): string { + return "The diff cursor was not one this pull request handed out."; + } + + override get message(): string { + return `GitHub CLI failed in getPullRequestDiff: ${this.detail}`; + } +} + +/** Not a decode failure: the reader named a commit that is not a sha this repository could hold. */ +export class GitHubDiffCommitError extends Schema.TaggedErrorClass()( + "GitHubDiffCommitError", + { + command: Schema.Literal("gh"), + cwd: Schema.String, + }, +) { + get detail(): string { + return "The named commit was not a commit sha."; + } + + override get message(): string { + return `GitHub CLI failed in getPullRequestDiff: ${this.detail}`; + } +} + +/** The revisions read successfully, but cannot name both sides this file needs. */ +export class GitHubDiffRevisionsUnavailableError extends Schema.TaggedErrorClass()( + "GitHubDiffRevisionsUnavailableError", + { + command: Schema.Literal("gh"), + cwd: Schema.String, + number: Schema.Int, + commit: Schema.optional(Schema.String), + }, +) { + get detail(): string { + return this.commit === undefined + ? `Pull request #${this.number} reported no usable base and head revisions.` + : `Commit ${this.commit} reported no usable revisions for this file.`; + } + + override get message(): string { + return `GitHub CLI failed in getPullRequestDiffFileContents: ${this.detail}`; + } +} + +/** A blob exists, but expanding it would be unsafe or would not produce text. */ +export class GitHubDiffFileContentsUnavailableError extends Schema.TaggedErrorClass()( + "GitHubDiffFileContentsUnavailableError", + { + command: Schema.Literal("gh"), + cwd: Schema.String, + path: Schema.String, + reason: Schema.Literals(["oversized", "binary"]), + }, +) { + get detail(): string { + return this.reason === "oversized" + ? `The diff file '${this.path}' exceeds the 1 MB expansion limit.` + : `The diff file '${this.path}' is binary.`; + } + + override get message(): string { + return `GitHub CLI failed in getPullRequestDiffFileContents: ${this.detail}`; + } +} + +/** + * Not a decode failure: a repository was named that cannot go into a search or into a GraphQL + * document as itself. Every qualifier and every alias below is composed from `owner/name`, so a + * name that is not one is refused here rather than escaped into something GitHub might read as a + * qualifier of its own. + */ +export class GitHubRepositorySelectorError extends Schema.TaggedErrorClass()( + "GitHubRepositorySelectorError", + { + command: Schema.Literal("gh"), + cwd: Schema.String, + operation: Schema.String, + }, +) { + get detail(): string { + return "A repository was named that GitHub cannot address."; + } + + override get message(): string { + return `GitHub CLI failed in ${this.operation}: ${this.detail}`; + } +} + +/** Not a decode failure: the reader named a subject this pull request never handed out. */ +export class GitHubSubjectScopeError extends Schema.TaggedErrorClass()( + "GitHubSubjectScopeError", + { + command: Schema.Literal("gh"), + cwd: Schema.String, + operation: Schema.String, + }, +) { + get detail(): string { + return "The named subject did not belong to the named pull request."; + } + + override get message(): string { + return `GitHub CLI failed in ${this.operation}: ${this.detail}`; + } +} + +export type GitHubPullRequestCliError = + | GitHubCli.GitHubCliError + | GitHubPullRequestReadError + | GitHubDiffCursorError + | GitHubDiffCommitError + | GitHubDiffRevisionsUnavailableError + | GitHubDiffFileContentsUnavailableError + | GitHubRepositorySelectorError + | GitHubSubjectScopeError + | GitHubViewerLoginUnavailableError; + +/** A large pull request can produce a multi-megabyte patch; past this it is truncated. */ +const DIFF_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; +const DIFF_TIMEOUT_MS = 60_000; +/** Pierre expansion is for source files, not blobs large enough to stall a review surface. */ +const DIFF_FILE_MAX_OUTPUT_BYTES = 1024 * 1024; + +/** A search-free fallback may scan older rows for local filters, but never the whole repository. */ +const PULL_REQUEST_FALLBACK_MAX_ROWS = 1_000; + +/** What the files API serves at most in one response, which is what one slice is made of. */ +const DIFF_FILES_PAGE_SIZE = 100; + +/** + * Pages of review threads to follow before the conversation is reported as truncated. GitHub + * serves a hundred threads a page, so this is a thousand threads — past anything a pull request + * a person is reading has, and short of walking a repository-sized conversation forever. + */ +const REVIEW_THREAD_PAGES = 10; + +/** + * And pages of one thread's own comments, for the rare thread longer than a single page. A + * thousand replies under one line is already a conversation nobody finishes reading. + */ +const REVIEW_THREAD_COMMENT_PAGES = 10; + +/** How many over-long threads are finished at once, so a wide conversation is not read serially. */ +const REVIEW_THREAD_CONCURRENCY = 4; + +export interface GitHubPullRequestListBatch { + readonly items: ReadonlyArray; + readonly truncated: boolean; + /** False for a page GitHub would not search, which came back in `gh`'s own order instead. */ + readonly continues: boolean; +} + +export interface GitHubPullRequestStat { + readonly repository: string; + readonly number: number; + readonly additions: number; + readonly deletions: number; +} + +/** + * Aliased lookups per request, and requests at once. Measured over a hundred rows: one request + * carrying all hundred takes ~5.2s, four of twenty-five in parallel ~2.1s. + */ +const STAT_ALIASES_PER_REQUEST = 25; +const STAT_REQUEST_CONCURRENCY = 4; + +export interface GitHubPullRequestSearchBatch { + /** Rows across every repository asked for, newest update first, each naming its own. */ + readonly items: ReadonlyArray; + readonly truncated: boolean; +} + +export interface GitHubPullRequestDiffSlice { + readonly patch: string; + /** Files in this slice had their hunks withheld, as opposed to there being more slices. */ + readonly truncated: boolean; + /** Where the next slice starts, or null once the patch is whole. */ + readonly nextCursor: string | null; + /** GitHub's own counts for the files whose hunks it withheld from this slice. */ + readonly omittedFileStats?: ReadonlyArray; +} + +export class GitHubPullRequestCli extends Context.Service< + GitHubPullRequestCli, + { + readonly getViewerLogin: (input: { + readonly cwd: string; + }) => Effect.Effect; + + readonly listPullRequests: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly state: PullRequestListState; + readonly involvement: PullRequestInvolvement; + readonly viewer: string; + readonly limit: number; + /** Free text for `--search`, matched as one literal phrase. */ + readonly query?: string | undefined; + /** Where to carry on from, as a `updated:` qualifier on the same search. */ + readonly cursor?: ProviderListCursor | undefined; + /** Further narrowings, as qualifiers on the search and as a local pass on the fallback. */ + readonly filters?: PullRequestListFilters | undefined; + }) => Effect.Effect; + + /** + * The same listing for a whole host in one search. `limit` is the size of the slice across + * all of the repositories rather than per repository, because that is what a search answers: + * the newest rows of the lot, which is exactly the page. + */ + readonly searchPullRequests: (input: { + /** Any checkout on the host; the search names its repositories itself. */ + readonly cwd: string; + readonly host: string; + readonly repositories: ReadonlyArray; + readonly state: PullRequestListState; + readonly involvement: PullRequestInvolvement; + readonly viewer: string; + readonly limit: number; + readonly query?: string | undefined; + readonly cursor?: ProviderListCursor | undefined; + readonly filters?: PullRequestListFilters | undefined; + }) => Effect.Effect; + + /** The line counts the search leaves out, for rows already on the page. */ + readonly listPullRequestStats: (input: { + readonly cwd: string; + readonly host: string; + readonly changeRequests: ReadonlyArray<{ + readonly repository: string; + readonly number: number; + }>; + }) => Effect.Effect, GitHubPullRequestCliError>; + + readonly getPullRequestDetail: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + }) => Effect.Effect; + + /** + * How far the branch trails its base, and whether this viewer may update it. Its own read + * because the comparison needs the head ref the detail answers with — a fork's branch is not + * addressable in the base repository by name alone. + */ + readonly getPullRequestBaseComparison: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + /** Qualified `owner:branch`, which is the only form a fork's head resolves under. */ + readonly headRef: string; + }) => Effect.Effect; + + readonly getPullRequestActivity: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + }) => Effect.Effect; + + readonly getPullRequestDiff: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + /** Absent asks for the first slice; anything else is a cursor a slice handed back. */ + readonly cursor?: string | undefined; + /** One commit's own changes, rather than everything the pull request carries. */ + readonly commit?: string | undefined; + }) => Effect.Effect; + + readonly getPullRequestDiffFileContents: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + readonly commit?: string | undefined; + readonly changeType: "change" | "rename-pure" | "rename-changed" | "new" | "deleted"; + readonly oldPath: string; + readonly newPath: string; + }) => Effect.Effect< + { readonly oldContents: string; readonly newContents: string }, + GitHubPullRequestCliError + >; + + readonly listReviewThreadComments: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + }) => Effect.Effect; + + /** One request for a listing's authors, since no `gh` JSON field reports an avatar. */ + readonly listActorAvatars: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly ids: ReadonlyArray; + }) => Effect.Effect, GitHubPullRequestCliError>; + + /** One `gh repo view`, which answers what the repository allows and where the viewer stands. */ + readonly getRepositoryAccess: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + }) => Effect.Effect; + + /** The viewer's standing on its own, for deciding a write without reading the whole detail. */ + readonly getViewerAccess: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + }) => Effect.Effect; + + /** Who this pull request may be sent to, and who it has already been sent to. */ + readonly listReviewerCandidates: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + }) => Effect.Effect; + + readonly setReviewerRequest: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + readonly reviewers: ReadonlyArray<{ + readonly id: string; + readonly kind: PullRequestReviewerKind; + }>; + /** False deletes the same collection a request posts to, which takes the request back. */ + readonly requested: boolean; + }) => Effect.Effect; + + readonly runPullRequestAction: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + readonly action: PullRequestAction; + readonly mergeMethod?: PullRequestMergeMethod; + readonly updateMethod?: PullRequestUpdateMethod; + }) => Effect.Effect; + + readonly commentOnPullRequest: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + readonly body: string; + }) => Effect.Effect; + + readonly submitReview: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + readonly verdict: PullRequestReviewVerdict; + readonly body: string; + readonly comments: ReadonlyArray; + }) => Effect.Effect; + + readonly replyToReviewThread: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly threadId: string; + readonly body: string; + }) => Effect.Effect; + + readonly setReviewThreadResolution: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly threadId: string; + readonly resolved: boolean; + }) => Effect.Effect; + + /** + * Adds a reaction to a remark, or takes it back. `subjectId` is any node GitHub calls + * reactable — a comment, a review, or the pull request itself, which is looked up here + * because nothing in the conversation names it. A given `subjectId` is confirmed to belong + * to this pull request before the mutation runs, since nothing else ties the two together. + */ + readonly setReaction: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + readonly subjectId?: string | undefined; + readonly content: PullRequestReactionContent; + readonly reacted: boolean; + }) => Effect.Effect; + + /** Rewrites the pull request's own words, leaving whichever of the two was not given. */ + readonly updatePullRequest: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + readonly title?: string | undefined; + readonly body?: string | undefined; + }) => Effect.Effect; + + /** + * Rewrites a remark. `commentId` is trusted to be whatever node it names, so it is confirmed + * to belong to this pull request before the mutation runs, the way a reaction subject is. + * Whether the remark is the reader's to rewrite is GitHub's own answer, not one asked here. + */ + readonly updateComment: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + readonly commentId: string; + readonly kind: "issue-comment" | "review-comment"; + readonly body: string; + }) => Effect.Effect; + } +>()("t3/pullRequest/GitHubPullRequestCli") {} + +/** + * The GraphQL API takes owner and name as separate arguments, so `owner/repo` is split here. + * The host is not read off the identity: it travels alongside it, because the identity a + * project records is the path below its host and never names the host itself. + */ +export function parseRepositorySelector(value: string): { + readonly owner: string; + readonly name: string; +} { + const parts = value.trim().split("/").filter(Boolean); + return { name: parts.at(-1) ?? "", owner: parts.at(-2) ?? "" }; +} + +/** + * The page a diff cursor names, or null for anything this walk cannot have issued. The cursor + * arrives from the reader as a string and goes straight into a request path, so it is parsed + * rather than trusted; the length bound keeps a page number out of exponential notation. + */ +function diffCursorPage(cursor: string): number | null { + return /^[1-9][0-9]{0,6}$/.test(cursor) ? Number(cursor) : null; +} + +/** + * A commit sha arrives from the reader and goes straight into a request path, so it is checked + * rather than trusted: hexadecimal only, from the shortest abbreviation a host prints up to a + * whole sha. + */ +function isCommitSha(value: string): boolean { + return /^[0-9a-f]{7,64}$/i.test(value); +} + +/** + * The reader's own words as one literal phrase of a GitHub search query. Quoting is the whole + * defence: outside quotes GitHub reads `is:merged` as a qualifier and `label:x` as another, so + * text typed into a search box could widen the very listing it is meant to narrow — inside them + * it is only text. The two characters that could end the phrase early are therefore escaped + * first, which GitHub reads back as themselves; an unbalanced quote is dropped instead, which + * would let everything after it out of the phrase. + * + * The phrase is one argv element, so nothing in it can become a flag of its own either. + */ +function searchPhrase(query: string): string { + return `"${query.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`; +} + +/** GitHub's own spelling of a review state, which is not the contract's. */ +const REVIEW_QUALIFIERS = { + approved: "approved", + "changes-requested": "changes_requested", + "review-required": "required", + none: "none", +} as const; + +/** + * The extra narrowings as GitHub search qualifiers. Values a reader typed are quoted, and the + * one character that could end the quoted value early is dropped rather than escaped: no GitHub + * label or login holds a double quote, so there is nothing to preserve and everything to lose. + */ +function qualifierValue(value: string): string { + return `"${value.replaceAll('"', "").trim()}"`; +} + +function filterQualifiers( + filters: PullRequestListFilters | undefined, + viewer: string, +): ReadonlyArray { + if (filters === undefined) return []; + return [ + // One qualifier per group, its names joined by commas — GitHub's own OR. + ...(filters.labels ?? []).flatMap((group) => + group.length === 0 ? [] : [`label:${group.map(qualifierValue).join(",")}`], + ), + ...(filters.excludedLabels ?? []).map((label) => `-label:${qualifierValue(label)}`), + ...(filters.author === undefined + ? [] + : [`author:${qualifierValue(resolvePullRequestAuthorFilter(filters.author, viewer))}`]), + ...(filters.draft === undefined ? [] : [`draft:${filters.draft === "only"}`]), + ...(filters.review === undefined ? [] : [`review:${REVIEW_QUALIFIERS[filters.review]}`]), + ...(filters.checks === undefined + ? [] + : [`status:${filters.checks === "passing" ? "success" : "failure"}`]), + ]; +} + +/** + * The same narrowings over a row that has already arrived, for the search-free fallback. Every + * listed row now carries its own `checksState`, so `checks` is judged the way `review` is: by + * equality against the row's field. Unlike `review`, `checks` has no `"none"` value to catch an + * absent state on purpose — a row with no checks configured, or whose checks are still `pending`, + * equals neither `"passing"` nor `"failing"` and so fails both, the same as a row search would + * not have surfaced for `status:success` or `status:failure`. + */ +function matchesFilters( + item: GitHubPullRequestListItem, + filters: PullRequestListFilters | undefined, + viewer: string, +): boolean { + if (filters === undefined) return true; + const labels = item.labels.map((label) => label.name.trim().toLowerCase()); + const holds = (label: string) => labels.includes(label.trim().toLowerCase()); + return ( + (filters.draft === undefined || item.isDraft === (filters.draft === "only")) && + (filters.review === undefined || + (filters.review === "none" + ? item.reviewDecision === null + : item.reviewDecision === filters.review)) && + (filters.checks === undefined || item.checksState === filters.checks) && + (filters.labels === undefined || filters.labels.every((group) => group.some(holds))) && + (filters.excludedLabels === undefined || !filters.excludedLabels.some(holds)) && + (filters.author === undefined || + item.author?.login.toLowerCase() === + resolvePullRequestAuthorFilter(filters.author, viewer).toLowerCase()) + ); +} + +function involvementArgs(input: { + readonly state: PullRequestListState; + readonly involvement: PullRequestInvolvement; + readonly viewer: string; + readonly query?: string | undefined; + /** Where to carry on from, which only a search can express. */ + readonly cursor?: ProviderListCursor | undefined; + /** + * Ask GitHub for the order the page reads its rows in. False on the fallback read, which + * cannot use search at all and takes whatever order `gh pr list` answers in. + */ + readonly sorted: boolean; + readonly filters?: PullRequestListFilters | undefined; +}): ReadonlyArray { + // `--state closed` includes merged pull requests, so the Closed tab additionally excludes + // them through search; `--author` and `review-requested:` are GitHub's own filters. `gh` + // takes one `--search`, so the reader's text joins the qualifiers rather than replacing them. + const query = input.query?.trim() ?? ""; + // The fallback read exists because this repository's search index answered nothing, so it goes + // nowhere near search: no order, cursor or qualifiers. Its decoded rows are narrowed by state + // and involvement below, since widening either would put unrelated pull requests on the page. + const searchTerms = !input.sorted + ? [] + : [ + ...(input.involvement === "reviewing" ? [`review-requested:${input.viewer}`] : []), + ...(input.state === "closed" ? ["is:unmerged"] : []), + ...(query.length === 0 ? [] : [searchPhrase(query)]), + // The instant the last slice ended on, and everything before it. Inclusive, because rows + // sharing one instant are ordinary and the caller drops the ones it has already sent — + // asking for strictly older would lose the rest of them instead. + ...(input.cursor === undefined ? [] : [`updated:<=${input.cursor.updatedBefore}`]), + ...filterQualifiers(input.filters, input.viewer), + // `gh pr list` answers newest-created first, which is not the order the page reads rows in + // and not an order a continuation can carry on from: a change request opened last year and + // touched this morning belongs at the top of the list and at the front of the first slice. + // Free text would otherwise come back in best-match order, which is worse again. + "sort:updated-desc", + ]; + return [ + ...(input.involvement === "authored" ? ["--author", input.viewer] : []), + ...(searchTerms.length > 0 ? ["--search", searchTerms.join(" ")] : []), + ]; +} + +/** The search-free fallback is wider than the request, so narrow its decoded rows locally. */ +function matchesUnsortedListing( + item: GitHubPullRequestListItem, + input: { + readonly state: PullRequestListState; + readonly involvement: PullRequestInvolvement; + readonly viewer: string; + readonly filters?: PullRequestListFilters | undefined; + }, +): boolean { + const matchesState = input.state === "all" || item.state === input.state; + const viewer = input.viewer.toLowerCase(); + const matchesInvolvement = + input.involvement === "all" || + (input.involvement === "authored" + ? item.author?.login.toLowerCase() === viewer + : item.hasTeamReviewRequest || + item.reviewRequestLogins.some((login) => login.toLowerCase() === viewer)); + return matchesState && matchesInvolvement && matchesFilters(item, input.filters, input.viewer); +} + +/** What a repository selector may hold before it goes into a search as itself. */ +const SEARCH_REPOSITORY = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/; + +/** + * The same listing as one GitHub search across several repositories, which is the only way to + * read a whole host in one request. + * + * Every narrowing `involvementArgs` hands to `gh pr list` as a flag is a qualifier here instead, + * because a search has no flags to borrow: `--author X` is `author:X`, `--state open` is + * `is:open`, and `--state closed` — which includes merged pull requests — is `is:closed + * is:unmerged`. The two belong together; a tab added to one wants adding to the other. + * + * Null where a repository is not `owner/name`. A name is written into the query as itself, and a + * name holding a space could otherwise end the `repo:` qualifier and start a qualifier of its + * own — so an unaddressable one refuses the whole read rather than being escaped into something + * GitHub might still read. + */ +function searchQuery(input: { + readonly repositories: ReadonlyArray; + readonly state: PullRequestListState; + readonly involvement: PullRequestInvolvement; + readonly viewer: string; + readonly query?: string | undefined; + readonly cursor?: ProviderListCursor | undefined; + readonly filters?: PullRequestListFilters | undefined; +}): string | null { + if (input.repositories.length === 0) return null; + const repositories = input.repositories.map((repository) => repository.trim()); + if (!repositories.every((repository) => SEARCH_REPOSITORY.test(repository))) return null; + const query = input.query?.trim() ?? ""; + return [ + "is:pr", + // "all" is every state, which `is:pr` already is. + ...(input.state === "open" ? ["is:open"] : []), + ...(input.state === "closed" ? ["is:closed", "is:unmerged"] : []), + ...(input.state === "merged" ? ["is:merged"] : []), + ...(input.involvement === "authored" ? [`author:${input.viewer}`] : []), + ...(input.involvement === "reviewing" ? [`review-requested:${input.viewer}`] : []), + ...(query.length === 0 ? [] : [searchPhrase(query)]), + // Inclusive, and de-duplicated by the caller, for the reason the per-repository read gives. + ...(input.cursor === undefined ? [] : [`updated:<=${input.cursor.updatedBefore}`]), + ...filterQualifiers(input.filters, input.viewer), + // The order the page reads its rows in, and the only order a continuation can carry on from. + "sort:updated-desc", + ...repositories.map((repository) => `repo:${repository}`), + ].join(" "); +} + +/** + * The `after` a paged read carries. gh sends a JSON null only through a typed field, and an + * untyped `cursor=` would send the empty string, which GitHub refuses as a cursor rather than + * reading as "start at the beginning". + */ +function cursorVariable(cursor: string | null): readonly [string, string] { + return cursor === null ? ["-F", "cursor=null"] : ["-f", `cursor=${cursor}`]; +} + +function actionArgs( + action: PullRequestAction, + mergeMethod: PullRequestMergeMethod | undefined, + updateMethod: PullRequestUpdateMethod | undefined, +): ReadonlyArray { + switch (action) { + case "merge": + return ["merge", `--${mergeMethod ?? "merge"}`]; + // `--auto` arms the same command instead of running it, and still needs the strategy: GitHub + // stores the strategy with the standing instruction rather than choosing one at merge time. + case "enable-auto-merge": + return ["merge", "--auto", `--${mergeMethod ?? "merge"}`]; + case "disable-auto-merge": + return ["merge", "--disable-auto"]; + // `gh` updates with a merge commit unless asked to rebase, which is GitHub's own default. + case "update-branch": + return ["update-branch", ...(updateMethod === "rebase" ? ["--rebase"] : [])]; + case "ready": + return ["ready"]; + case "draft": + return ["ready", "--undo"]; + case "close": + return ["close"]; + case "reopen": + return ["reopen"]; + } +} + +export const make = Effect.gen(function* () { + const github = yield* GitHubCli.GitHubCli; + + /** + * The pull request's own node id, which is what a mutation against the pull request itself is + * addressed by: a reaction on its description, or a rewrite of its words. + */ + const pullRequestNodeId = (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + readonly operation: string; + }) => { + const { owner, name } = parseRepositorySelector(input.repository); + return graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: input.operation, + variables: [ + ["-f", `owner=${owner}`], + ["-f", `name=${name}`], + ["-F", `number=${input.number}`], + ], + query: PULL_REQUEST_NODE_ID_GRAPHQL_QUERY, + decode: decodePullRequestNodeIdJson, + }); + }; + + /** + * Whether a client-given subject actually belongs to the pull request the request names. A + * subject id is trusted to be whatever node it names, and that node can hang off any pull + * request on the host — so the mutation itself would write wherever the id actually belongs, + * not wherever the request says it does, unless this confirms the two agree first. + */ + const subjectBelongsToPullRequest = (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + readonly subjectId: string; + readonly operation: string; + }) => { + const { owner, name } = parseRepositorySelector(input.repository); + return graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: input.operation, + variables: [ + ["-f", `owner=${owner}`], + ["-f", `name=${name}`], + ["-F", `number=${input.number}`], + ["-f", `subjectId=${input.subjectId}`], + ], + query: REACTION_SUBJECT_PULL_REQUEST_GRAPHQL_QUERY, + decode: decodeReactionSubjectScopeJson, + }); + }; + + // `gh` resolves a bare `owner/repo` against whichever host it defaults to, which is + // github.com. Naming the host makes a GitHub Enterprise repository resolve to its own + // install rather than to a same-named repository on github.com. + const repositoryArgs = (input: { readonly host: string; readonly repository: string }) => [ + "--repo", + `${input.host}/${input.repository}`, + ]; + + /** + * A GraphQL mutation whose answer is not read back. `gh` exits non-zero on a GraphQL error, + * so a failed mutation is already a failed command rather than a body to inspect. + * + * The query and its variables travel over stdin as one document: a variable can carry a + * body the reader wrote, and argv is visible in process listings and echoed back inside + * process-runner failure messages. + */ + const graphql = (input: { + readonly cwd: string; + readonly host: string; + readonly query: string; + readonly variables: Readonly>; + }) => + github + .execute({ + cwd: input.cwd, + args: ["api", "graphql", "--hostname", input.host, "--input", "-"], + stdin: encodeGraphQlRequestJson({ query: input.query, variables: input.variables }), + }) + .pipe(Effect.asVoid); + + /** A GraphQL read whose answer is decoded, reporting a failure against the read that made it. */ + const graphqlRead = (input: { + readonly cwd: string; + readonly host: string; + readonly operation: string; + /** Variables as `-f` flags, for values this module composed itself. */ + readonly variables?: ReadonlyArray; + /** + * Variables carrying words the reader typed. Document and variables travel over stdin + * together, because argv is visible in process listings and is echoed back inside a + * process-runner failure message. + */ + readonly privateVariables?: Readonly>; + readonly query: string; + readonly decode: (raw: string) => Result.Result; + }): Effect.Effect => + github + .execute( + input.privateVariables === undefined + ? { + cwd: input.cwd, + args: [ + "api", + "graphql", + "--hostname", + input.host, + ...(input.variables ?? []).flat(), + "-f", + `query=${input.query}`, + ], + } + : { + cwd: input.cwd, + args: ["api", "graphql", "--hostname", input.host, "--input", "-"], + stdin: encodeGraphQlRequestJson({ + query: input.query, + variables: input.privateVariables, + }), + }, + ) + .pipe( + Effect.flatMap((result) => { + const decoded = input.decode(result.stdout.trim()); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail( + new GitHubPullRequestReadError({ + command: "gh", + cwd: input.cwd, + operation: input.operation, + cause: decoded.failure, + }), + ); + }), + ); + + /** + * One page of the patch, read from the files API. GitHub refuses `pr diff` outright past 300 + * changed files, and still serves those files' hunks here. + * + * A page is a whole number of files, so each one parses on its own; the caller carries on from + * `nextCursor` for as long as GitHub keeps handing pages back. + * + * A named commit is read from the commit endpoint, which lists the same file entries and pages + * them the same way — only wrapped in an object, which jq unwraps before they are decoded. + */ + const diffFilesPage = (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + readonly page: number; + readonly commit?: string | undefined; + }): Effect.Effect => { + const { owner, name } = parseRepositorySelector(input.repository); + const paging = `per_page=${DIFF_FILES_PAGE_SIZE}&page=${input.page}`; + return github + .execute({ + cwd: input.cwd, + args: [ + "api", + "--hostname", + input.host, + input.commit === undefined + ? `repos/${owner}/${name}/pulls/${input.number}/files?${paging}` + : `repos/${owner}/${name}/commits/${input.commit}?${paging}`, + // An empty commit carries no `files` at all, which is a commit with nothing in it + // rather than an answer that could not be read. + ...(input.commit === undefined ? [] : ["--jq", ".files // []"]), + ], + maxOutputBytes: DIFF_MAX_OUTPUT_BYTES, + timeoutMs: DIFF_TIMEOUT_MS, + }) + .pipe( + Effect.flatMap((result) => { + // Checked before decoding: a byte-truncated response is a JSON prefix, which would + // fail to parse. Nothing of this page can be shown, and an empty patch would render + // as a change with no files rather than as the failure it is; slices already handed + // over stay with the reader either way. + if (result.stdoutTruncated) { + return Effect.fail( + new GitHubPullRequestReadError({ + command: "gh", + cwd: input.cwd, + operation: "getPullRequestDiff", + cause: new Error(`Page ${input.page} of the changed files was too large to read.`), + }), + ); + } + const decoded = decodePullRequestFilesJson(result.stdout.trim()); + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new GitHubPullRequestReadError({ + command: "gh", + cwd: input.cwd, + operation: "getPullRequestDiff", + cause: decoded.failure, + }), + ); + } + // Counted before decoding, so a page whose files all failed to decode still moves on + // rather than pointing the reader back at the page it just read. + const morePages = decoded.success.rawCount >= DIFF_FILES_PAGE_SIZE; + return Effect.succeed({ + patch: decoded.success.patch, + truncated: decoded.success.truncated, + nextCursor: morePages ? String(input.page + 1) : null, + ...(decoded.success.omittedFileStats.length === 0 + ? {} + : { omittedFileStats: decoded.success.omittedFileStats }), + }); + }), + ); + }; + + const getPullRequestDiffFileContents: GitHubPullRequestCli["Service"]["getPullRequestDiffFileContents"] = + (input) => + Effect.gen(function* () { + if (input.commit !== undefined && !isCommitSha(input.commit)) { + return yield* new GitHubDiffCommitError({ command: "gh", cwd: input.cwd }); + } + const { owner, name } = parseRepositorySelector(input.repository); + const refsResult = yield* github.execute({ + cwd: input.cwd, + args: [ + "api", + "--hostname", + input.host, + input.commit === undefined + ? `repos/${owner}/${name}/pulls/${input.number}` + : `repos/${owner}/${name}/commits/${input.commit}`, + "--jq", + input.commit === undefined + ? "[.base.sha, .head.sha] | @tsv" + : "[.parents[0].sha, .sha] | @tsv", + ], + maxOutputBytes: 1024, + timeoutMs: DIFF_TIMEOUT_MS, + }); + // Keep a leading tab: a root commit has no parent, and jq represents that absent old + // revision as the empty field before the tab. Every file in it is new, so that is a + // usable answer whenever the caller does not need the old side. + const [baseRef, headRef, ...extraRefs] = refsResult.stdout.trimEnd().split("\t"); + const rootCommitNewFile = + input.commit !== undefined && input.changeType === "new" && baseRef === ""; + if ( + refsResult.stdoutTruncated || + !headRef || + extraRefs.length > 0 || + (!rootCommitNewFile && (baseRef === undefined || !isCommitSha(baseRef))) || + !isCommitSha(headRef) + ) { + return yield* new GitHubDiffRevisionsUnavailableError({ + command: "gh", + cwd: input.cwd, + number: input.number, + ...(input.commit === undefined ? {} : { commit: input.commit }), + }); + } + + const readFile = (revision: string, filePath: string) => + github + .execute({ + cwd: input.cwd, + args: [ + "api", + "--hostname", + input.host, + "--header", + "Accept: application/vnd.github.raw+json", + `repos/${owner}/${name}/contents/${filePath + .split("/") + .map(encodeURIComponent) + .join("/")}?ref=${encodeURIComponent(revision)}`, + ], + maxOutputBytes: DIFF_FILE_MAX_OUTPUT_BYTES, + timeoutMs: DIFF_TIMEOUT_MS, + }) + .pipe( + Effect.flatMap((result) => + result.stdoutTruncated || + result.stdout.includes("\0") || + result.stdoutInvalidUtf8 === true + ? Effect.fail( + new GitHubDiffFileContentsUnavailableError({ + command: "gh", + cwd: input.cwd, + path: filePath, + reason: result.stdoutTruncated ? "oversized" : "binary", + }), + ) + : Effect.succeed(result.stdout), + ), + ); + + const [oldContents, newContents] = yield* Effect.all( + [ + input.changeType === "new" ? Effect.succeed("") : readFile(baseRef, input.oldPath), + input.changeType === "deleted" ? Effect.succeed("") : readFile(headRef, input.newPath), + ], + { concurrency: 2 }, + ); + return { oldContents, newContents }; + }); + + return GitHubPullRequestCli.of({ + getViewerLogin: (input) => + github.execute({ cwd: input.cwd, args: ["api", "user", "--jq", ".login"] }).pipe( + Effect.flatMap((result) => { + const login = result.stdout.trim(); + return login.length > 0 + ? Effect.succeed(login) + : Effect.fail(new GitHubViewerLoginUnavailableError({ command: "gh", cwd: input.cwd })); + }), + ), + + listPullRequests: (input) => { + const fallbackMaxRows = Math.max(input.limit + 1, PULL_REQUEST_FALLBACK_MAX_ROWS); + const read = ( + continues: boolean, + requestedRows = input.limit + 1, + ): Effect.Effect => + github + .execute({ + cwd: input.cwd, + args: [ + "pr", + "list", + ...repositoryArgs(input), + ...involvementArgs({ ...input, sorted: continues }), + "--state", + input.state, + "--limit", + // One extra row reveals that the repository has more than the page shows. + String(requestedRows), + "--json", + PULL_REQUEST_LIST_JSON_FIELDS, + ], + }) + .pipe( + Effect.flatMap((result) => { + const raw = result.stdout.trim(); + if (raw.length === 0) { + return Effect.succeed({ items: [], truncated: false, continues }); + } + const decoded = decodePullRequestListJson(raw); + if (Result.isSuccess(decoded)) { + const items = continues + ? decoded.success.items + : decoded.success.items.filter((item) => matchesUnsortedListing(item, input)); + if ( + !continues && + items.length < input.limit && + decoded.success.rawCount >= requestedRows && + requestedRows < fallbackMaxRows + ) { + const nextRows = Math.min(requestedRows * 2, fallbackMaxRows); + if (nextRows > requestedRows) return read(false, nextRows); + } + return Effect.succeed({ + items: items.slice(0, input.limit), + // One row over the page size is the probe for a next page, and it is + // counted before decoding: a skipped malformed row must not end paging. + truncated: continues + ? decoded.success.rawCount > input.limit + : items.length > input.limit || decoded.success.rawCount >= requestedRows, + continues, + }); + } + return Effect.fail( + new GitHubPullRequestReadError({ + command: "gh", + cwd: input.cwd, + operation: "listPullRequests", + cause: decoded.failure, + }), + ); + }), + ); + // GitHub does not index every repository for search, and one it will not search answers + // with no rows rather than with an error — so an empty listing is read again the way `gh` + // lists without one. Those rows come back newest-created first, an order no `updated:` + // qualifier can carry on from, so that page says it cannot be continued and the reader + // reaches the rest of it by asking for a larger page, as every listing used to. + // + // Only ever the first slice: a repository that answered the search once will answer it + // again, so an empty slice under a cursor is a repository that has run out. + // A text search that finds nothing has found nothing: falling back would answer it with the + // repository's whole list, which is every row the reader did not search for. The fallback + // is for a repository the index does not cover, and a listing with no text to match is the + // only place an empty answer can mean that. + // Every filter is a qualifier `matchesFilters` can judge over the fallback's own rows just + // as well as search judges them over its own, so carrying them into the fallback answers + // the same read rather than a wider one. Free text is the one thing the fallback cannot + // judge locally — it lists rows, it does not search their text — so a query still rules + // the fallback out: an empty answer under one is already the answer. + const hasQuery = (input.query?.trim().length ?? 0) > 0; + return read(true).pipe( + Effect.flatMap((batch) => + batch.items.length === 0 && input.cursor === undefined && !hasQuery + ? read(false) + : Effect.succeed(batch), + ), + ); + }, + + searchPullRequests: (input) => { + const query = searchQuery(input); + if (query === null) { + return Effect.fail( + new GitHubRepositorySelectorError({ + command: "gh", + cwd: input.cwd, + operation: "searchPullRequests", + }), + ); + } + // One extra row reveals that the host has more than the slice shows, the way the + // per-repository read does — up to GitHub's own ceiling on a search page, past which + // `hasNextPage` is what says there is more. + const rows = Math.min(input.limit + 1, PULL_REQUEST_SEARCH_MAX_ROWS); + return graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "searchPullRequests", + // The reader's own words are in the query, so it travels over stdin rather than in argv. + privateVariables: { q: query }, + query: pullRequestSearchGraphQlQuery(rows), + decode: decodePullRequestSearchJson, + }).pipe( + Effect.map((batch) => ({ + items: batch.items.slice(0, input.limit), + truncated: batch.rawCount > input.limit || batch.hasNextPage, + })), + ); + }, + + listPullRequestStats: (input) => { + const chunks: Array> = + []; + for (let start = 0; start < input.changeRequests.length; start += STAT_ALIASES_PER_REQUEST) { + chunks.push(input.changeRequests.slice(start, start + STAT_ALIASES_PER_REQUEST)); + } + return Effect.forEach( + chunks, + (chunk) => { + const query = buildPullRequestStatsGraphQlQuery(chunk); + if (query === null) { + return Effect.fail( + new GitHubRepositorySelectorError({ + command: "gh", + cwd: input.cwd, + operation: "listPullRequestStats", + }), + ); + } + return graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "listPullRequestStats", + query, + decode: decodePullRequestStatsJson, + }).pipe( + Effect.map((stats) => + chunk.flatMap((changeRequest, index) => { + const stat = stats.get(index); + return stat === undefined ? [] : [{ ...changeRequest, ...stat }]; + }), + ), + ); + }, + { concurrency: STAT_REQUEST_CONCURRENCY }, + ).pipe(Effect.map((results) => results.flat())); + }, + + getPullRequestDetail: (input) => + github + .execute({ + cwd: input.cwd, + args: [ + "pr", + "view", + String(input.number), + ...repositoryArgs(input), + "--json", + PULL_REQUEST_DETAIL_JSON_FIELDS, + ], + }) + .pipe( + Effect.flatMap((result) => { + const decoded = decodePullRequestDetailJson(result.stdout.trim()); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail( + new GitHubPullRequestReadError({ + command: "gh", + cwd: input.cwd, + operation: "getPullRequestDetail", + cause: decoded.failure, + }), + ); + }), + ), + + getPullRequestBaseComparison: (input) => { + const { owner, name } = parseRepositorySelector(input.repository); + return graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "getPullRequestBaseComparison", + variables: [ + ["-f", `owner=${owner}`], + ["-f", `name=${name}`], + ["-F", `number=${input.number}`], + ["-f", `headRef=${input.headRef}`], + ], + query: BASE_COMPARISON_GRAPHQL_QUERY, + decode: decodeBaseComparisonJson, + }); + }, + + getPullRequestActivity: (input) => + github + .execute({ + cwd: input.cwd, + args: [ + "pr", + "view", + String(input.number), + ...repositoryArgs(input), + "--json", + PULL_REQUEST_ACTIVITY_JSON_FIELDS, + ], + }) + .pipe( + Effect.flatMap((result) => { + const decoded = decodePullRequestActivityJson(result.stdout.trim()); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail( + new GitHubPullRequestReadError({ + command: "gh", + cwd: input.cwd, + operation: "getPullRequestActivity", + cause: decoded.failure, + }), + ); + }), + ), + + getPullRequestDiff: (input) => { + const filesPage = (page: number) => + diffFilesPage({ + cwd: input.cwd, + repository: input.repository, + host: input.host, + number: input.number, + page, + ...(input.commit === undefined ? {} : { commit: input.commit }), + }); + if (input.commit !== undefined && !isCommitSha(input.commit)) { + return Effect.fail(new GitHubDiffCommitError({ command: "gh", cwd: input.cwd })); + } + // A cursor only ever comes from the files walk, so a reader carrying one is already past + // the point where `gh pr diff` had anything to say. + if (input.cursor !== undefined) { + const page = diffCursorPage(input.cursor); + return page === null + ? Effect.fail(new GitHubDiffCursorError({ command: "gh", cwd: input.cwd })) + : filesPage(page); + } + // `gh pr diff` speaks for the whole pull request and has no way to name one commit of it. + if (input.commit !== undefined) { + return filesPage(1); + } + return github + .execute({ + cwd: input.cwd, + args: ["pr", "diff", String(input.number), ...repositoryArgs(input), "--color", "never"], + maxOutputBytes: DIFF_MAX_OUTPUT_BYTES, + timeoutMs: DIFF_TIMEOUT_MS, + }) + .pipe( + Effect.flatMap((result) => + // A patch cut at a byte boundary ends mid-file, which is neither a whole slice nor + // something the reader can carry on from. The files API can serve the same change a + // whole number of files at a time, so an oversized patch takes that road as well. + result.stdoutTruncated + ? filesPage(1) + : // One read served the whole patch, so there is no next slice to ask for. + Effect.succeed({ patch: result.stdout, truncated: false, nextCursor: null }), + ), + // GitHub answers 406 rather than a diff past 300 changed files, so the patch is read + // from the files API instead, a page per call. Only once the direct read has failed: a + // pull request GitHub will serve a diff for must not pay for a second request. A + // fallback that fails too reports the original refusal, which is the one that explains + // the page. Narrowed to a command that ran and was refused: a missing `gh` or a + // signed-out one fails the same way for every request. + Effect.catchTags({ + GitHubCliCommandError: (error) => + filesPage(1).pipe(Effect.catch(() => Effect.fail(error))), + }), + ); + }, + + getPullRequestDiffFileContents, + + listReviewThreadComments: (input) => + Effect.gen(function* () { + const { owner, name } = parseRepositorySelector(input.repository); + const threadPage = ( + cursor: string | null, + ): Effect.Effect => + graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "listReviewThreadComments", + variables: [ + ["-f", `owner=${owner}`], + ["-f", `name=${name}`], + ["-F", `number=${input.number}`], + cursorVariable(cursor), + ], + query: REVIEW_THREADS_GRAPHQL_QUERY, + decode: decodeReviewThreadsJson, + }); + const commentPage = ( + threadId: string, + cursor: string, + ): Effect.Effect< + { + readonly comments: ReadonlyArray; + readonly nextCursor: string | null; + }, + GitHubPullRequestCliError + > => + graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "listReviewThreadComments", + variables: [["-f", `threadId=${threadId}`], cursorVariable(cursor)], + query: REVIEW_THREAD_COMMENTS_GRAPHQL_QUERY, + decode: decodeReviewThreadCommentsJson, + }); + + const entries: GitHubReviewThreadEntry[] = []; + const avatarsByLogin = new Map(); + const commitStats = new Map< + string, + { readonly additions: number; readonly deletions: number } + >(); + let reviewers: ReadonlyArray = []; + let reactions: GitHubReviewThreadPage["reactions"] = []; + const reactionsById = new Map>(); + let commits: GitHubReviewThreadPage["commits"] = []; + let viewer: GitHubReviewThreadPage["viewer"] = { canUpdate: true, didAuthor: false }; + const dismissalsByReviewId = new Map(); + let dismissalCursor: string | null = null; + let cursor: string | null = null; + let page = 0; + do { + const read: GitHubReviewThreadPage = yield* threadPage(cursor); + entries.push(...read.threads); + for (const [login, avatarUrl] of read.avatarsByLogin) + avatarsByLogin.set(login, avatarUrl); + // The roster, the commits and the viewer's standing travel with every page, and the + // first one already carries all of them. + if (page === 0) { + reviewers = read.reviewers; + reactions = read.reactions; + for (const [id, entry] of read.reactionsById) reactionsById.set(id, entry); + commits = read.commits; + viewer = read.viewer; + for (const [id, message] of read.dismissalsByReviewId) + dismissalsByReviewId.set(id, message); + dismissalCursor = read.nextDismissalCursor; + for (const [oid, stat] of read.commitStats) commitStats.set(oid, stat); + } + cursor = read.nextCursor; + page += 1; + } while (cursor !== null && page < REVIEW_THREAD_PAGES); + + // Almost never entered: the embedded page already holds every dismissal a pull request + // ordinarily accrues. Followed so a review whose event fell past that page still finds + // its reason. + let dismissalPage = 0; + while (dismissalCursor !== null && dismissalPage < REVIEW_THREAD_PAGES) { + const read: { + readonly dismissalsByReviewId: ReadonlyMap; + readonly nextCursor: string | null; + } = yield* graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "listReviewThreadComments", + variables: [ + ["-f", `owner=${owner}`], + ["-f", `name=${name}`], + ["-F", `number=${input.number}`], + ["-f", `cursor=${dismissalCursor}`], + ], + query: REVIEW_DISMISSALS_GRAPHQL_QUERY, + decode: decodeReviewDismissalsJson, + }); + for (const [id, message] of read.dismissalsByReviewId) + dismissalsByReviewId.set(id, message); + dismissalCursor = read.nextCursor; + dismissalPage += 1; + } + + // Only the threads GitHub said were unfinished cost a request; the rest arrived whole + // with the page they were listed on. + const finished = yield* Effect.forEach( + entries, + (entry) => + Effect.gen(function* () { + const comments = [...entry.thread.comments]; + let commentCursor = entry.nextCommentCursor; + let commentPageCount = 0; + while (commentCursor !== null && commentPageCount < REVIEW_THREAD_COMMENT_PAGES) { + const read = yield* commentPage(entry.thread.id, commentCursor); + comments.push(...read.comments); + commentCursor = read.nextCursor; + commentPageCount += 1; + } + return { + thread: { ...entry.thread, comments }, + commentCount: entry.commentCount, + truncated: commentCursor !== null, + }; + }), + { concurrency: REVIEW_THREAD_CONCURRENCY }, + ); + + const reviewThreads = finished.map((entry) => entry.thread); + return { + comments: reviewThreadConversation(reviewThreads), + dismissalsByReviewId, + reviewThreads, + // GitHub's own count of each thread, so the number the page shows is the host's even + // where a bound kept some of the words on GitHub. + commentCount: finished.reduce((total, entry) => total + entry.commentCount, 0), + truncated: cursor !== null || finished.some((entry) => entry.truncated), + reactions, + reactionsById, + reviewers, + avatarsByLogin, + commitStats, + commits, + viewer, + }; + }), + + listActorAvatars: (input) => { + if (input.ids.length === 0) { + return Effect.succeed(new Map()); + } + return github + .execute({ + cwd: input.cwd, + args: [ + "api", + "graphql", + "--hostname", + input.host, + ...input.ids.flatMap((id) => ["-f", `ids[]=${id}`]), + "-f", + `query=${ACTOR_AVATARS_GRAPHQL_QUERY}`, + ], + }) + .pipe( + Effect.flatMap((result) => { + const decoded = decodeActorAvatarsJson(result.stdout.trim()); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail( + new GitHubPullRequestReadError({ + command: "gh", + cwd: input.cwd, + operation: "listActorAvatars", + cause: decoded.failure, + }), + ); + }), + ); + }, + + getRepositoryAccess: (input) => + github + .execute({ + cwd: input.cwd, + args: [ + "repo", + "view", + `${input.host}/${input.repository}`, + "--json", + REPOSITORY_ACCESS_JSON_FIELDS, + ], + }) + .pipe( + Effect.flatMap((result) => { + const decoded = decodeRepositoryAccessJson(result.stdout.trim()); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail( + new GitHubPullRequestReadError({ + command: "gh", + cwd: input.cwd, + operation: "getRepositoryAccess", + cause: decoded.failure, + }), + ); + }), + ), + + getViewerAccess: (input) => { + const { owner, name } = parseRepositorySelector(input.repository); + return graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "getViewerAccess", + variables: [ + ["-f", `owner=${owner}`], + ["-f", `name=${name}`], + ["-F", `number=${input.number}`], + ], + query: VIEWER_PERMISSIONS_GRAPHQL_QUERY, + decode: decodeViewerPermissionsJson, + }); + }, + + listReviewerCandidates: (input) => { + const { owner, name } = parseRepositorySelector(input.repository); + return graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "listReviewerCandidates", + variables: [ + ["-f", `owner=${owner}`], + ["-f", `name=${name}`], + ["-F", `number=${input.number}`], + ], + query: REVIEWER_CANDIDATES_GRAPHQL_QUERY, + decode: decodeReviewerCandidatesJson, + }); + }, + + setReviewerRequest: (input) => { + const { owner, name } = parseRepositorySelector(input.repository); + return github + .execute({ + cwd: input.cwd, + // Posting to a login GitHub has already been asked about is what a re-request is, so + // there is nothing to say here about somebody who has reviewed once already. The body + // travels over stdin for the reason every other one does: argv is visible in process + // listings and echoed back inside process-runner failure messages. + args: [ + "api", + "--method", + input.requested ? "POST" : "DELETE", + "--hostname", + input.host, + `repos/${owner}/${name}/pulls/${input.number}/requested_reviewers`, + "--input", + "-", + ], + stdin: buildReviewerRequestJson(input.reviewers), + }) + .pipe(Effect.asVoid); + }, + + runPullRequestAction: (input) => { + const [subcommand, ...flags] = actionArgs( + input.action, + input.mergeMethod, + input.updateMethod, + ); + return github + .execute({ + cwd: input.cwd, + args: ["pr", subcommand!, String(input.number), ...repositoryArgs(input), ...flags], + }) + .pipe(Effect.asVoid); + }, + + commentOnPullRequest: (input) => + github + .execute({ + cwd: input.cwd, + // The body travels over stdin: argv is visible in process listings and is echoed + // back inside process-runner failure messages. + args: [ + "pr", + "comment", + String(input.number), + ...repositoryArgs(input), + "--body-file", + "-", + ], + stdin: input.body, + }) + .pipe(Effect.asVoid), + + submitReview: (input) => { + const { owner, name } = parseRepositorySelector(input.repository); + return github + .execute({ + cwd: input.cwd, + // The whole review is one request, so nothing is visible to anyone else until the + // verdict is sent. The payload travels over stdin for the same reason a comment + // body does: argv is visible in process listings and echoed back in failures. + args: [ + "api", + "--method", + "POST", + "--hostname", + input.host, + `repos/${owner}/${name}/pulls/${input.number}/reviews`, + "--input", + "-", + ], + stdin: buildReviewSubmissionJson({ + verdict: input.verdict, + body: input.body, + comments: input.comments, + }), + }) + .pipe(Effect.asVoid); + }, + + replyToReviewThread: (input) => + graphql({ + cwd: input.cwd, + host: input.host, + query: REVIEW_THREAD_REPLY_GRAPHQL_MUTATION, + variables: { threadId: input.threadId, body: input.body }, + }), + + setReviewThreadResolution: (input) => + graphql({ + cwd: input.cwd, + host: input.host, + query: input.resolved + ? RESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION + : UNRESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION, + variables: { threadId: input.threadId }, + }), + + setReaction: (input) => { + const givenSubjectId = input.subjectId; + const subjectId = + givenSubjectId === undefined + ? pullRequestNodeId({ ...input, operation: "setReaction" }) + : subjectBelongsToPullRequest({ + ...input, + subjectId: givenSubjectId, + operation: "setReaction", + }).pipe( + Effect.flatMap((belongs) => + belongs + ? Effect.succeed(givenSubjectId) + : Effect.fail( + new GitHubSubjectScopeError({ + command: "gh", + cwd: input.cwd, + operation: "setReaction", + }), + ), + ), + ); + return subjectId.pipe( + Effect.flatMap((subjectId) => + graphql({ + cwd: input.cwd, + host: input.host, + query: input.reacted ? ADD_REACTION_GRAPHQL_MUTATION : REMOVE_REACTION_GRAPHQL_MUTATION, + variables: { subjectId, content: gitHubReactionContent(input.content) }, + }), + ), + ); + }, + + updatePullRequest: (input) => + pullRequestNodeId({ ...input, operation: "updatePullRequest" }).pipe( + Effect.flatMap((pullRequestId) => + graphql({ + cwd: input.cwd, + host: input.host, + query: UPDATE_PULL_REQUEST_GRAPHQL_MUTATION, + // A field the caller did not name is left out of the request entirely, so GitHub + // keeps the words that are there rather than being asked for an empty one. + variables: { + pullRequestId, + ...(input.title === undefined ? {} : { title: input.title }), + ...(input.body === undefined ? {} : { body: input.body }), + }, + }), + ), + ), + + updateComment: (input) => + subjectBelongsToPullRequest({ + cwd: input.cwd, + repository: input.repository, + host: input.host, + number: input.number, + subjectId: input.commentId, + operation: "updateComment", + }).pipe( + Effect.flatMap((belongs) => + belongs + ? Effect.succeed(input.commentId) + : Effect.fail( + new GitHubSubjectScopeError({ + command: "gh", + cwd: input.cwd, + operation: "updateComment", + }), + ), + ), + Effect.flatMap((commentId) => + graphql({ + cwd: input.cwd, + host: input.host, + query: + input.kind === "issue-comment" + ? UPDATE_ISSUE_COMMENT_GRAPHQL_MUTATION + : UPDATE_REVIEW_COMMENT_GRAPHQL_MUTATION, + variables: { commentId, body: input.body }, + }), + ), + ), + }); +}); + +export const layer = Layer.effect(GitHubPullRequestCli, make); diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts new file mode 100644 index 000000000000..13098ce387ff --- /dev/null +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts @@ -0,0 +1,450 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import type { PullRequestReaction } from "@t3tools/contracts"; + +import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; +import { gitHubViewerPermissions, loginAvatarUrl, make } from "./GitHubPullRequestProvider.ts"; +import type { GitHubReviewThreadComments } from "./gitHubPullRequestJson.ts"; + +describe("gitHubViewerPermissions", () => { + it("offers everything to a viewer who can write to the repository", () => { + expect(gitHubViewerPermissions({ canWrite: true, canUpdate: true, didAuthor: false })).toEqual({ + // Arming a merge for later is the merge, so it travels with it. + actions: [ + "merge", + "enable-auto-merge", + "disable-auto-merge", + "ready", + "draft", + "close", + "reopen", + ], + comment: true, + resolve: true, + verdicts: ["comment", "approve", "request-changes"], + requestReviewers: true, + }); + }); + + it("leaves a passer-by on a repository they can only read nothing but the review", () => { + // Every open-source pull request somebody else opened: GitHub says no to all five actions + // and to resolving, and yes to commenting and to every verdict. + expect( + gitHubViewerPermissions({ canWrite: false, canUpdate: false, didAuthor: false }), + ).toEqual({ + actions: [], + comment: true, + resolve: false, + verdicts: ["comment", "approve", "request-changes"], + // Asking somebody else to review is the one thing read access never stretches to. + requestReviewers: false, + }); + }); + + it("keeps an author's own pull request theirs to close, with read access and no more", () => { + expect(gitHubViewerPermissions({ canWrite: false, canUpdate: true, didAuthor: true })).toEqual({ + // Merging is the one thing writing is needed for, now or later; the rest an author may do. + actions: ["ready", "draft", "close", "reopen"], + comment: true, + resolve: true, + // GitHub refuses an author's approval of their own change, so the page does not offer one. + verdicts: ["comment"], + requestReviewers: false, + }); + }); + + it.effect("uses the small viewer-access read for core permissions", () => + Effect.gen(function* () { + const provider = yield* make; + const detail = yield* provider.getChangeRequest({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + + expect(detail.viewerPermissions).toEqual({ + actions: ["ready", "draft", "close", "reopen"], + comment: true, + resolve: false, + verdicts: ["comment", "approve", "request-changes"], + requestReviewers: false, + }); + }).pipe( + Effect.provide( + Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + getPullRequestDetail: () => + Effect.succeed({ + authorId: null, + number: 7, + title: "Pull request 7", + url: "https://github.com/acme/web/pull/7", + author: null, + headRepositoryOwner: null, + headBranch: "feat/page", + baseBranch: "main", + state: "open", + isDraft: false, + mergeability: "mergeable", + reviewDecision: null, + additions: 1, + deletions: 1, + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-02T00:00:00Z", + reviewRequestLogins: [], + hasTeamReviewRequest: false, + checksState: null, + labels: [], + body: "", + changedFiles: 1, + mergedAt: null, + closedAt: null, + checks: [], + comments: [], + commits: [], + }), + getRepositoryAccess: () => + Effect.succeed({ + canWrite: false, + mergeCapabilities: { merge: true, squash: true, rebase: true }, + }), + getViewerAccess: () => + Effect.succeed({ canWrite: false, canUpdate: true, didAuthor: false }), + }), + ), + ), + ); +}); + +describe("getViewerPermissions", () => { + const openDetail = { + authorId: null, + number: 7, + title: "Pull request 7", + url: "https://github.com/acme/web/pull/7", + author: null, + headRepositoryOwner: "acme", + headBranch: "feat/page", + baseBranch: "main", + state: "open" as const, + isDraft: false, + mergeability: "mergeable" as const, + reviewDecision: null, + additions: 1, + deletions: 1, + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-02T00:00:00Z", + reviewRequestLogins: [], + hasTeamReviewRequest: false, + checksState: null, + labels: [], + body: "", + changedFiles: 1, + mergedAt: null, + closedAt: null, + checks: [], + comments: [], + commits: [], + }; + + const layerWithComparison = ( + comparison: Effect.Effect<{ + readonly behindBy: number | null; + readonly viewerCanUpdate: boolean; + }>, + ) => + Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + getPullRequestDetail: () => Effect.succeed(openDetail), + getPullRequestBaseComparison: () => comparison, + getViewerAccess: () => Effect.succeed({ canWrite: true, canUpdate: true, didAuthor: false }), + }); + + it.effect("offers update-branch when the comparison grants it", () => + Effect.gen(function* () { + const provider = yield* make; + const permissions = yield* provider.getViewerPermissions({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + + expect(permissions.actions).toContain("update-branch"); + expect(permissions.updateMethods).toEqual(["merge", "rebase"]); + }).pipe( + Effect.provide(layerWithComparison(Effect.succeed({ behindBy: 3, viewerCanUpdate: true }))), + ), + ); + + it.effect("withholds update-branch when the comparison cannot be read", () => + Effect.gen(function* () { + const provider = yield* make; + const permissions = yield* provider.getViewerPermissions({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + + expect(permissions.actions).not.toContain("update-branch"); + expect(permissions.updateMethods).toBeUndefined(); + // The rest of the answer survives a comparison nobody could make. + expect(permissions.actions).toContain("merge"); + }).pipe( + Effect.provide( + Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + getPullRequestDetail: () => Effect.succeed(openDetail), + getPullRequestBaseComparison: () => + Effect.fail( + new GitHubPullRequestCli.GitHubPullRequestReadError({ + command: "gh", + cwd: "/w", + operation: "getPullRequestBaseComparison", + cause: new Error("unreadable"), + }), + ), + getViewerAccess: () => + Effect.succeed({ canWrite: true, canUpdate: true, didAuthor: false }), + }), + ), + ), + ); +}); + +describe("getChangeRequest commits", () => { + const baseDetail = { + authorId: null, + number: 7, + title: "Pull request 7", + url: "https://github.com/acme/web/pull/7", + author: null, + headBranch: "feat/page", + baseBranch: "main", + state: "open" as const, + isDraft: false, + mergeability: "mergeable" as const, + additions: 1, + deletions: 1, + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-02T00:00:00Z", + reviewRequestLogins: [], + hasTeamReviewRequest: false, + checksState: null, + labels: [], + body: "", + changedFiles: 1, + mergedAt: null, + closedAt: null, + checks: [], + comments: [], + }; + + const baseThreadComments = { + comments: [], + dismissalsByReviewId: new Map(), + reviewThreads: [], + commentCount: 0, + truncated: false, + reactions: [], + reactionsById: new Map>(), + reviewers: [], + avatarsByLogin: new Map(), + commitStats: new Map(), + viewer: { canUpdate: true, didAuthor: false }, + }; + + const layerWith = (commits: GitHubReviewThreadComments["commits"]) => + Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + getPullRequestActivity: () => + Effect.succeed({ + author: baseDetail.author, + comments: baseDetail.comments, + commits: [ + { + oid: "view-oldest", + messageHeadline: "gh pr view's oldest commit", + committedDate: "2026-01-01T00:00:00Z", + authors: [], + }, + ], + }), + listReviewThreadComments: () => Effect.succeed({ ...baseThreadComments, commits }), + }); + + it.effect("prefers the GraphQL commits, which are the newest, over the gh view list", () => + Effect.gen(function* () { + const provider = yield* make; + const detail = yield* provider.getChangeRequestActivity({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + + expect(detail.commits.map((commit) => commit.oid)).toEqual(["graphql-newest"]); + }).pipe( + Effect.provide( + layerWith([ + { + oid: "graphql-newest", + messageHeadline: "the newest commit gh pr view drops", + committedDate: "2026-07-06T00:00:00Z", + authors: [], + }, + ]), + ), + ), + ); + + it.effect("falls back to the gh view list when the GraphQL read has no commits", () => + Effect.gen(function* () { + const provider = yield* make; + const detail = yield* provider.getChangeRequestActivity({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + + expect(detail.commits.map((commit) => commit.oid)).toEqual(["view-oldest"]); + }).pipe(Effect.provide(layerWith([]))), + ); +}); + +describe("getChangeRequestActivity dismissed reviews", () => { + const dismissedReview = (body: string) => ({ + id: "PRR_1", + kind: "review" as const, + author: null, + body, + createdAt: "2026-07-03T00:00:00Z", + url: null, + path: null, + reviewState: "DISMISSED", + }); + const threadComments: GitHubReviewThreadComments = { + comments: [], + dismissalsByReviewId: new Map([["PRR_1", "Dismissing prior approval to re-evaluate 9b66581"]]), + reviewThreads: [], + commentCount: 0, + truncated: false, + reactions: [], + reactionsById: new Map(), + reviewers: [], + avatarsByLogin: new Map(), + commitStats: new Map(), + commits: [], + viewer: { canUpdate: true, didAuthor: false }, + }; + const layerFor = (body: string) => + Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + getPullRequestActivity: () => + Effect.succeed({ author: null, comments: [dismissedReview(body)], commits: [] }), + listReviewThreadComments: () => Effect.succeed(threadComments), + }); + const readActivity = Effect.gen(function* () { + const provider = yield* make; + return yield* provider.getChangeRequestActivity({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + }); + + it.effect("fills a marker-only dismissed review with the timeline's reason", () => + // Macroscope's approvals carry only an HTML comment, which markdown renders as nothing — + // an empty-string check misses them and the card opens onto nothing. + readActivity.pipe( + Effect.map((activity) => { + expect(activity.comments[0]?.body).toBe("Dismissing prior approval to re-evaluate 9b66581"); + }), + Effect.provide(layerFor("")), + ), + ); + + it.effect("keeps the words of a dismissed review that has its own", () => + readActivity.pipe( + Effect.map((activity) => { + expect(activity.comments[0]?.body).toBe("These findings still stand."); + }), + Effect.provide(layerFor("These findings still stand.")), + ), + ); +}); + +describe("editing", () => { + const rewrites: Array = []; + + it.effect("hands a rewrite to the CLI as the request named it", () => + Effect.gen(function* () { + const provider = yield* make; + + expect(provider.capabilities.edit).toEqual({ changeRequest: true, comment: true }); + yield* provider.updateChangeRequest!({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + title: "A better title", + }); + yield* provider.updateComment!({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + commentId: "IC_1", + kind: "review-comment", + body: "Reworded.", + }); + + expect(rewrites).toEqual([ + { + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + title: "A better title", + }, + { + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + commentId: "IC_1", + kind: "review-comment", + body: "Reworded.", + }, + ]); + }).pipe( + Effect.provide( + Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + updatePullRequest: (input) => Effect.sync(() => void rewrites.push(input)), + updateComment: (input) => Effect.sync(() => void rewrites.push(input)), + }), + ), + ), + ); +}); + +describe("loginAvatarUrl", () => { + it("serves a user's picture from the host they belong to", () => { + expect(loginAvatarUrl("octocat", "github.com")).toBe("https://github.com/octocat.png?size=80"); + expect(loginAvatarUrl("octocat", "ghe.example.com")).toBe( + "https://ghe.example.com/octocat.png?size=80", + ); + }); + + it("has nothing for an app, which names no page", () => { + // `dependabot[bot]` has a picture, but not at `/dependabot[bot].png` — a guess that 404s is + // worse than the initials it would replace. + expect(loginAvatarUrl("dependabot[bot]", "github.com")).toBeNull(); + }); + + it("refuses anything that is not a login, rather than building a URL out of it", () => { + for (const login of ["../../etc", "a b", "-leading", "x".repeat(40), ""]) { + expect(loginAvatarUrl(login, "github.com")).toBeNull(); + } + }); +}); diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts new file mode 100644 index 000000000000..57d18e8ab917 --- /dev/null +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts @@ -0,0 +1,487 @@ +import * as Effect from "effect/Effect"; +import type { + PullRequestActor, + PullRequestCapabilities, + PullRequestReaction, + PullRequestViewerPermissions, +} from "@t3tools/contracts"; + +import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; +import { + PullRequestProviderError, + type ProviderChangeRequestActivity, + type ProviderChangeRequestDetail, + type PullRequestProviderApi, +} from "./PullRequestProvider.ts"; +import type { GitHubViewerAccess } from "./gitHubPullRequestJson.ts"; + +const CAPABILITIES: PullRequestCapabilities = { + diff: true, + comment: true, + actions: [ + "merge", + "ready", + "draft", + "close", + "reopen", + "update-branch", + "enable-auto-merge", + "disable-auto-merge", + ], + mergeMethods: ["merge", "squash", "rebase"], + updateMethods: ["merge", "rebase"], + search: true, + reactions: true, + review: { + inlineComment: true, + reply: true, + resolve: true, + verdicts: ["comment", "approve", "request-changes"], + }, + reviewers: { request: true, listCandidates: true }, + edit: { changeRequest: true, comment: true }, +}; + +/** + * What the signed-in account may do here, from the three things GitHub says about it. + * + * Merging needs a role that can push, which is the one thing a stranger on an open-source + * repository never has. The other four actions go by `viewerCanUpdate`, because the author of a + * pull request may close it, reopen it and move it in and out of draft with no more than read + * access on the repository it was opened against. + * + * Commenting and reviewing are not gated at all: read access is enough to say something and + * enough to approve or ask for changes, which is what open-source review consists of. Resolving a + * conversation is the exception — GitHub allows it to whoever can write, and to the author of the + * pull request the conversation is on. + * + * Asking somebody else for a review needs write access, which is the one thing here an author + * cannot do on their own pull request: GitHub shows an outside contributor the reviewer control + * and refuses the request behind it. + */ +export function gitHubViewerPermissions(access: GitHubViewerAccess): PullRequestViewerPermissions { + return { + actions: [ + // Arming a merge and taking the arming back are the merge, deferred: whoever may not + // merge here may not leave an instruction to merge later either. + ...(access.canWrite ? (["merge", "enable-auto-merge", "disable-auto-merge"] as const) : []), + ...(access.canUpdate ? (["ready", "draft", "close", "reopen"] as const) : []), + // Whether this viewer may update the branch is GitHub's own answer, read with the + // comparison; without it the action is offered to nobody rather than to everybody. + ...(access.canUpdateBranch === true ? (["update-branch"] as const) : []), + ], + comment: true, + resolve: access.canWrite || access.didAuthor, + // Anyone may review a pull request they can see, except their own: GitHub refuses an author's + // approval and their request for changes ("Can not approve your own pull request"), and + // leaves them commenting, which is what an author has to say about their own change anyway. + verdicts: access.didAuthor ? (["comment"] as const) : CAPABILITIES.review.verdicts, + requestReviewers: access.canWrite, + ...(access.canUpdateBranch === true ? { updateMethods: CAPABILITIES.updateMethods } : {}), + }; +} + +/** The CLI tags that mean the tool itself is unusable, rather than one request failing. */ +function reasonFor( + error: GitHubPullRequestCli.GitHubPullRequestCliError, +): PullRequestProviderError["reason"] { + if (error._tag === "GitHubCliUnavailableError") return "missing-tool"; + if (error._tag === "GitHubCliAuthenticationError") return "unauthenticated"; + return "failed"; +} + +/** + * `gh pr view --json` reports no avatar for anyone, so the ones the GraphQL read collected are + * applied here by login. An actor already carrying one keeps it. + * + * A login GitHub did not answer for falls back to the picture every GitHub install serves at + * `/.png`. The lookup is one more request per repository and can be refused — a rate + * limit, a slow host — and a face that comes and goes between two loads of the same page reads + * as a bug in the page rather than as a request that failed quietly. + */ +function withAvatar( + actor: PullRequestActor | null, + avatarsByLogin: ReadonlyMap, + host: string, +): PullRequestActor | null { + if (actor === null || actor.avatarUrl !== null) return actor; + const avatarUrl = avatarsByLogin.get(actor.login) ?? loginAvatarUrl(actor.login, host); + return avatarUrl === null ? actor : { ...actor, avatarUrl }; +} + +/** + * Null for anything that is not a plain user login: an app posts as `dependabot[bot]`, which + * names no page, and a guessed URL that 404s is worse than the initials it would replace. + */ +export function loginAvatarUrl(login: string, host: string): string | null { + return /^[a-z0-9][a-z0-9-]{0,38}$/iu.test(login) ? `https://${host}/${login}.png?size=80` : null; +} + +/** True where markdown would render nothing: whitespace, or only HTML comments. */ +const rendersEmpty = (body: string): boolean => + body.replace(//g, "").trim().length === 0; + +export const make = Effect.gen(function* () { + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const fail = (operation: string) => (error: GitHubPullRequestCli.GitHubPullRequestCliError) => + new PullRequestProviderError({ + provider: "github", + operation, + reason: reasonFor(error), + detail: error.detail, + cause: error, + }); + + const provider: PullRequestProviderApi = { + kind: "github", + capabilities: CAPABILITIES, + + getViewer: (input) => + cli.getViewerLogin({ cwd: input.cwd }).pipe(Effect.mapError(fail("getViewer"))), + + listChangeRequests: (input) => + cli + .listPullRequests({ + cwd: input.cwd, + repository: input.repository, + host: input.host, + state: input.state, + involvement: input.involvement, + viewer: input.viewer, + limit: input.limit, + query: input.query, + cursor: input.cursor, + filters: input.filters, + }) + .pipe( + Effect.mapError(fail("listChangeRequests")), + Effect.flatMap((page) => + cli + .listActorAvatars({ + cwd: input.cwd, + repository: input.repository, + host: input.host, + ids: [...new Set(page.items.flatMap((item) => item.authorId ?? []))], + }) + // A listing without faces is still a listing, so a failed lookup falls back to + // the initials rather than taking the rows down with it. + .pipe( + Effect.orElseSucceed(() => new Map()), + Effect.map((avatarsByLogin) => ({ + ...page, + items: page.items.map((item) => ({ + ...item, + author: withAvatar(item.author, avatarsByLogin, input.host), + })), + })), + ), + ), + ), + + /** + * The same listing for a whole host in one search. The avatar lookup the per-repository read + * needs is not here: a search reports an author's picture itself, so a face costs no request + * of its own — `withAvatar` still stands behind it for the login GitHub answered nothing for. + */ + listChangeRequestsAcross: (input) => + cli + .searchPullRequests({ + cwd: input.cwd, + host: input.host, + repositories: input.repositories, + state: input.state, + involvement: input.involvement, + viewer: input.viewer, + limit: input.limit, + query: input.query, + cursor: input.cursor, + filters: input.filters, + }) + .pipe( + Effect.mapError(fail("listChangeRequestsAcross")), + Effect.map((batch) => ({ + truncated: batch.truncated, + items: batch.items.map((item) => ({ + ...item, + author: withAvatar(item.author, new Map(), input.host), + })), + })), + ), + + listChangeRequestStats: (input) => + cli + .listPullRequestStats({ + cwd: input.cwd, + host: input.host, + changeRequests: input.changeRequests, + }) + .pipe(Effect.mapError(fail("listChangeRequestStats"))), + + getChangeRequest: (input) => + Effect.all( + [ + cli.getPullRequestDetail(input).pipe( + Effect.flatMap((pullRequest) => + // Only an open pull request can be behind anything worth saying so about, and only + // one whose head repository is known can be compared at all. A comparison that + // fails is left unknown: the banner is an offer, never a blocker. + pullRequest.state !== "open" || pullRequest.headRepositoryOwner === null + ? Effect.succeed({ pullRequest, comparison: null }) + : cli + .getPullRequestBaseComparison({ + ...input, + headRef: `${pullRequest.headRepositoryOwner}:${pullRequest.headBranch}`, + }) + .pipe( + Effect.map((comparison) => ({ pullRequest, comparison })), + Effect.orElseSucceed(() => ({ pullRequest, comparison: null })), + ), + ), + ), + cli.getRepositoryAccess({ + cwd: input.cwd, + repository: input.repository, + host: input.host, + }), + // A small permissions query replaces the deeply paginated review-thread walk on the + // core path. Writes ask again immediately before mutating, so this is presentation. + cli.getViewerAccess(input), + ], + { concurrency: 3 }, + ).pipe( + Effect.mapError(fail("getChangeRequest")), + Effect.map( + ([detail, repository, viewerAccess]): ProviderChangeRequestDetail => ({ + ...detail.pullRequest, + reviewers: detail.pullRequest.reviewRequestLogins.map((login) => ({ + login, + name: null, + avatarUrl: null, + })), + mergeCapabilities: repository.mergeCapabilities, + viewerPermissions: gitHubViewerPermissions({ + ...viewerAccess, + canUpdateBranch: detail.comparison?.viewerCanUpdate === true, + }), + baseComparison: + detail.comparison === null || detail.comparison.behindBy === null + ? "unknown" + : detail.comparison.behindBy > 0 + ? "behind" + : "up-to-date", + ...(detail.comparison?.behindBy == null + ? {} + : { behindBy: detail.comparison.behindBy }), + }), + ), + ), + + getChangeRequestActivity: (input) => + Effect.all( + [ + cli.getPullRequestActivity(input), + // Line comments live on review threads, which `gh pr view --json` cannot reach. A + // GraphQL hiccup degrades to a truncated conversation rather than blanking activity. + cli.listReviewThreadComments(input).pipe( + Effect.orElseSucceed(() => ({ + comments: [], + dismissalsByReviewId: new Map(), + reactions: [], + reactionsById: new Map>(), + reviewThreads: [], + commentCount: 0, + truncated: true, + reviewers: [], + avatarsByLogin: new Map(), + commitStats: new Map< + string, + { readonly additions: number; readonly deletions: number } + >(), + commits: [], + viewer: { canUpdate: true, didAuthor: false }, + })), + ), + ], + { concurrency: 2 }, + ).pipe( + Effect.mapError(fail("getChangeRequestActivity")), + Effect.map( + ([pullRequest, reviewThreads]): ProviderChangeRequestActivity => ({ + author: withAvatar(pullRequest.author, reviewThreads.avatarsByLogin, input.host), + reviewers: reviewThreads.reviewers, + reactions: reviewThreads.reactions, + commits: (reviewThreads.commits.length > 0 + ? reviewThreads.commits + : pullRequest.commits + ).map((commit) => ({ + ...commit, + ...reviewThreads.commitStats.get(commit.oid), + authors: commit.authors?.map( + (author) => withAvatar(author, reviewThreads.avatarsByLogin, input.host) ?? author, + ), + })), + comments: [...pullRequest.comments, ...reviewThreads.comments] + .map((comment) => ({ + ...comment, + // GitHub keeps the dismissal reason on the timeline event, not on the review, + // so a dismissed review with nothing visible of its own reads its words from + // there. "Visible" and not "empty": bot reviews often carry only an HTML + // marker comment, which markdown renders as nothing. + body: + comment.kind === "review" && + comment.reviewState?.toUpperCase() === "DISMISSED" && + rendersEmpty(comment.body) + ? (reviewThreads.dismissalsByReviewId.get(comment.id) ?? comment.body) + : comment.body, + author: withAvatar(comment.author, reviewThreads.avatarsByLogin, input.host), + // A comment out of `gh pr view --json` carries none of its own: that read + // reports no reaction at all, so they arrive from the GraphQL page by node id. + reactions: comment.reactions ?? reviewThreads.reactionsById.get(comment.id) ?? [], + })) + .toSorted((left, right) => left.createdAt.localeCompare(right.createdAt)), + // `gh pr view --json comments,reviews` follows GitHub's cursors itself, so those two + // are always whole and only the thread walk can stop short of the host. + commentCount: pullRequest.comments.length + reviewThreads.commentCount, + commentsTruncated: reviewThreads.truncated, + reviewThreads: reviewThreads.reviewThreads.map((thread) => ({ + ...thread, + comments: thread.comments.map((comment) => ({ + ...comment, + author: withAvatar(comment.author, reviewThreads.avatarsByLogin, input.host), + })), + })), + }), + ), + ), + + getViewerPermissions: (input) => + Effect.all( + [ + cli.getViewerAccess(input), + // Whether this viewer may update the branch is only on the comparison, and the + // comparison only resolves through the head ref the detail carries. A failure here + // withholds that one action rather than the whole answer, the way the detail path + // leaves the banner unknown. + cli.getPullRequestDetail(input).pipe( + Effect.flatMap((pullRequest) => + pullRequest.state !== "open" || pullRequest.headRepositoryOwner === null + ? Effect.succeed(false) + : cli + .getPullRequestBaseComparison({ + ...input, + headRef: `${pullRequest.headRepositoryOwner}:${pullRequest.headBranch}`, + }) + .pipe(Effect.map((comparison) => comparison.viewerCanUpdate === true)), + ), + Effect.orElseSucceed(() => false), + ), + ], + { concurrency: 2 }, + ).pipe( + Effect.mapError(fail("getViewerPermissions")), + Effect.map(([access, canUpdateBranch]) => + gitHubViewerPermissions({ ...access, canUpdateBranch }), + ), + ), + + getDiff: (input) => cli.getPullRequestDiff(input).pipe(Effect.mapError(fail("getDiff"))), + + getDiffFileContents: (input) => + cli.getPullRequestDiffFileContents(input).pipe(Effect.mapError(fail("getDiffFileContents"))), + + listReviewerCandidates: (input) => + cli.listReviewerCandidates(input).pipe(Effect.mapError(fail("listReviewerCandidates"))), + + setReviewerRequest: (input) => + cli + .setReviewerRequest({ + cwd: input.cwd, + repository: input.repository, + host: input.host, + number: input.number, + reviewers: input.reviewers, + requested: input.requested, + }) + .pipe(Effect.mapError(fail("setReviewerRequest"))), + + runAction: (input) => + cli + .runPullRequestAction({ + cwd: input.cwd, + repository: input.repository, + host: input.host, + number: input.number, + action: input.action, + ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }), + ...(input.updateMethod === undefined ? {} : { updateMethod: input.updateMethod }), + }) + .pipe(Effect.mapError(fail("runAction"))), + + updateChangeRequest: (input) => + cli + .updatePullRequest({ + cwd: input.cwd, + repository: input.repository, + host: input.host, + number: input.number, + ...(input.title === undefined ? {} : { title: input.title }), + ...(input.body === undefined ? {} : { body: input.body }), + }) + .pipe(Effect.mapError(fail("updateChangeRequest"))), + + comment: (input) => cli.commentOnPullRequest(input).pipe(Effect.mapError(fail("comment"))), + + updateComment: (input) => + cli + .updateComment({ + cwd: input.cwd, + repository: input.repository, + host: input.host, + number: input.number, + commentId: input.commentId, + kind: input.kind, + body: input.body, + }) + .pipe(Effect.mapError(fail("updateComment"))), + + submitReview: (input) => cli.submitReview(input).pipe(Effect.mapError(fail("submitReview"))), + + replyToThread: (input) => + cli + .replyToReviewThread({ + cwd: input.cwd, + repository: input.repository, + host: input.host, + threadId: input.threadId, + body: input.body, + }) + .pipe(Effect.mapError(fail("replyToThread"))), + + setReaction: (input) => + cli + .setReaction({ + cwd: input.cwd, + repository: input.repository, + host: input.host, + number: input.number, + ...(input.subjectId === undefined ? {} : { subjectId: input.subjectId }), + content: input.content, + reacted: input.reacted, + }) + .pipe(Effect.mapError(fail("setReaction"))), + + setThreadResolution: (input) => + cli + .setReviewThreadResolution({ + cwd: input.cwd, + repository: input.repository, + host: input.host, + threadId: input.threadId, + resolved: input.resolved, + }) + .pipe(Effect.mapError(fail("setThreadResolution"))), + }; + + return provider; +}); diff --git a/apps/server/src/pullRequest/GitLabPullRequestCli.test.ts b/apps/server/src/pullRequest/GitLabPullRequestCli.test.ts new file mode 100644 index 000000000000..c33e01c2d721 --- /dev/null +++ b/apps/server/src/pullRequest/GitLabPullRequestCli.test.ts @@ -0,0 +1,1400 @@ +import { afterEach, assert, expect, it, vi } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import * as GitLabCli from "../sourceControl/GitLabCli.ts"; +import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts"; + +const mockedExecute = vi.fn(); + +const layer = it.layer( + GitLabPullRequestCli.layer.pipe( + Layer.provide( + Layer.mock(GitLabCli.GitLabCli)({ + execute: mockedExecute, + }), + ), + ), +); + +function output(stdout: string, stdoutTruncated = false, stdoutInvalidUtf8 = false) { + return { + exitCode: ChildProcessSpawner.ExitCode(0), + stdout, + stderr: "", + stdoutTruncated, + stderrTruncated: false, + stdoutInvalidUtf8, + }; +} + +function mergeRequests(count: number, firstNumber: number): string { + return JSON.stringify( + Array.from({ length: count }, (_, index) => ({ + iid: firstNumber + index, + title: `Merge request ${firstNumber + index}`, + web_url: `https://gitlab.com/acme/web/-/merge_requests/${firstNumber + index}`, + source_branch: "feat/page", + target_branch: "main", + created_at: "2026-07-01T00:00:00Z", + updated_at: "2026-07-02T00:00:00Z", + })), + ); +} + +/** A page of `/diffs` as GitLab serves it, a full one unless the count says otherwise. */ +function diffPage(firstIndex: number, count = 100): string { + return JSON.stringify( + Array.from({ length: count }, (_, index) => ({ + old_path: `src/${firstIndex + index}.ts`, + new_path: `src/${firstIndex + index}.ts`, + diff: "@@ -1 +1 @@\n-a\n+b\n", + })), + ); +} + +/** A page of merge request notes, which is what the flat conversation is read from. */ +function notes(count: number, firstId: number): string { + return JSON.stringify( + Array.from({ length: count }, (_, index) => ({ + id: firstId + index, + body: `note ${firstId + index}`, + author: { username: "bilal" }, + created_at: "2026-07-01T00:00:00Z", + })), + ); +} + +/** Who opened the merge request, and somebody already reviewing it. */ +const author = { id: 1, username: "bilal" }; +const reviewer = { id: 5, username: "octocat" }; + +/** One merge request as `/merge_requests/:iid` answers with it. */ +function mergeRequestJson(overrides: Record): string { + return JSON.stringify({ + iid: 7, + title: "Merge request 7", + web_url: "https://gitlab.com/acme/web/-/merge_requests/7", + source_branch: "feat/page", + target_branch: "main", + created_at: "2026-07-01T00:00:00Z", + updated_at: "2026-07-02T00:00:00Z", + author, + ...overrides, + }); +} + +/** The endpoint or subcommand of the nth glab invocation. */ +function argsOfCall(index: number): ReadonlyArray { + return callAt(index).args; +} + +/** The whole nth invocation, so a request body can be asserted alongside its path. */ +function callAt(index: number) { + const call = mockedExecute.mock.calls[index]; + assert.isDefined(call); + return call[0]; +} + +afterEach(() => { + mockedExecute.mockReset(); +}); + +layer("GitLabPullRequestCli.layer", (it) => { + it.effect("asks GitLab for one row more than the page, to probe for a next page", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(mergeRequests(3, 1)))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const batch = yield* cli.listMergeRequests({ + cwd: "/w", + repository: "acme/web", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + }); + + assert.strictEqual(batch.items.length, 3); + assert.isFalse(batch.truncated); + assert.strictEqual(batch.cursorAdvance, 3); + const path = argsOfCall(0)[1] ?? ""; + expect(path).toContain("projects/acme%2Fweb/merge_requests"); + expect(path).toContain("per_page=11"); + expect(path).toContain("state=opened"); + }), + ); + + it.effect("walks pages at a fixed size, because GitLab pages by offset", () => + Effect.gen(function* () { + mockedExecute + .mockReturnValueOnce(Effect.succeed(output(mergeRequests(100, 1)))) + .mockReturnValueOnce(Effect.succeed(output(mergeRequests(100, 101)))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const batch = yield* cli.listMergeRequests({ + cwd: "/w", + repository: "acme/web", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 150, + }); + + assert.strictEqual(batch.items.length, 150); + assert.isTrue(batch.truncated); + for (const index of [0, 1]) { + expect(argsOfCall(index)[1]).toContain("per_page=100"); + } + expect(argsOfCall(0)[1]).toContain("page=1"); + expect(argsOfCall(1)[1]).toContain("page=2"); + }), + ); + + it.effect("hands a search to GitLab's own search parameter", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.listMergeRequests({ + cwd: "/w", + repository: "acme/web", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + query: "page", + }); + + // GitLab matches `search` against title and description, which is more than the row shows. + expect(argsOfCall(0)[1]).toContain("search=page"); + }), + ); + + it.effect("carries on from the number of rows already delivered", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(mergeRequests(3, 1)))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.listMergeRequests({ + cwd: "/w", + repository: "acme/web", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + cursor: { updatedBefore: "2026-07-02T00:00:00Z", delivered: 10 }, + }); + + // GitLab's timestamp filter has no tie-breaker, so an offset is what advances through a + // boundary shared by more rows than one page can hold. + const path = argsOfCall(0)[1] ?? ""; + expect(path).not.toContain("updated_before="); + expect(path).toContain("order_by=updated_at"); + expect(path).toContain("per_page=11"); + expect(path).toContain("page=1"); + }), + ); + + it.effect("advances beyond several pages sharing the cursor timestamp", () => + Effect.gen(function* () { + mockedExecute + .mockReturnValueOnce(Effect.succeed(output(mergeRequests(11, 144)))) + .mockReturnValueOnce(Effect.succeed(output(mergeRequests(11, 155)))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const batch = yield* cli.listMergeRequests({ + cwd: "/w", + repository: "acme/web", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + cursor: { updatedBefore: "2026-07-02T00:00:00Z", delivered: 150 }, + }); + + expect(argsOfCall(0)[1]).toContain("per_page=11"); + expect(argsOfCall(0)[1]).toContain("page=14"); + expect(argsOfCall(1)[1]).toContain("page=15"); + expect(batch.items.map((item) => item.number)).toEqual([ + 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, + ]); + assert.isTrue(batch.truncated); + }), + ); + + it.effect("advances the cursor through malformed raw rows", () => + Effect.gen(function* () { + // @effect-diagnostics-next-line preferSchemaOverJson:off + const rows = JSON.parse(mergeRequests(2, 1)) as ReadonlyArray; + mockedExecute.mockReturnValueOnce( + // @effect-diagnostics-next-line preferSchemaOverJson:off + Effect.succeed(output(JSON.stringify([{ iid: "malformed" }, ...rows]))), + ); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const batch = yield* cli.listMergeRequests({ + cwd: "/w", + repository: "acme/web", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 2, + }); + + expect(batch.items.map((item) => item.number)).toEqual([1, 2]); + assert.strictEqual(batch.cursorAdvance, 3); + assert.isTrue(batch.truncated); + }), + ); + + it.effect("URL-encodes a search, so it cannot add a parameter of its own", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.listMergeRequests({ + cwd: "/w", + repository: "acme/web", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + query: '-a&per_page=1 "b"', + }); + + const path = argsOfCall(0)[1] ?? ""; + expect(path).toContain("search=-a%26per_page%3D1%20%22b%22"); + // The page size the walk fixed is still the only one in the query. + assert.strictEqual(path.match(/per_page=/g)?.length, 1); + }), + ); + + it.effect("asks for no search at all when the reader typed only spaces", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.listMergeRequests({ + cwd: "/w", + repository: "acme/web", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + query: " ", + }); + + expect(argsOfCall(0)[1]).not.toContain("search="); + }), + ); + + it.effect("stops walking on a short page", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(mergeRequests(40, 1)))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const batch = yield* cli.listMergeRequests({ + cwd: "/w", + repository: "acme/web", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 150, + }); + + assert.strictEqual(batch.items.length, 40); + assert.isFalse(batch.truncated); + assert.strictEqual(mockedExecute.mock.calls.length, 1); + }), + ); + + it.effect("stops walking when every row on a page fails to decode", () => + Effect.gen(function* () { + // Full pages of unusable rows: nothing is collected, so the collected-count bound never + // trips and only the page bound can end the walk. + // @effect-diagnostics-next-line preferSchemaOverJson:off + const unusable = JSON.stringify(Array.from({ length: 100 }, () => ({ iid: "nope" }))); + mockedExecute.mockReturnValue(Effect.succeed(output(unusable))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const batch = yield* cli.listMergeRequests({ + cwd: "/w", + repository: "acme/web", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 150, + }); + + assert.strictEqual(batch.items.length, 0); + // ceil((150 + 1) / 100) pages, not one request per page forever. + assert.strictEqual(mockedExecute.mock.calls.length, 2); + }), + ); + + it.effect("asks GitLab for every state on the All tab", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.listMergeRequests({ + cwd: "/w", + repository: "acme/web", + state: "all", + involvement: "all", + viewer: "bilal", + limit: 10, + }); + + expect(argsOfCall(0)[1]).toContain("state=all"); + }), + ); + + it.effect("filters by the reviewer when the viewer is reviewing", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.listMergeRequests({ + cwd: "/w", + repository: "acme/web", + state: "open", + involvement: "reviewing", + viewer: "bilal", + limit: 10, + }); + + expect(argsOfCall(0)[1]).toContain("reviewer_username=bilal"); + }), + ); + + it.effect("addresses a nested group project by its encoded full path", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.listMergeRequests({ + cwd: "/w", + repository: "acme/platform/web", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + }); + + expect(argsOfCall(0)[1]).toContain("projects/acme%2Fplatform%2Fweb/merge_requests"); + }), + ); + + it.effect("merges immediately rather than leaving auto-merge armed", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(""))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.runMergeRequestAction({ + cwd: "/w", + repository: "acme/web", + number: 7, + action: "merge", + mergeMethod: "squash", + }); + + expect(argsOfCall(0)).toEqual([ + "mr", + "merge", + "7", + "--repo", + "acme/web", + "--auto-merge=false", + "--yes", + "--squash", + ]); + }), + ); + + it.effect("arms auto-merge with the same strategy a merge would have used", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(""))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.runMergeRequestAction({ + cwd: "/w", + repository: "acme/web", + number: 7, + action: "enable-auto-merge", + mergeMethod: "squash", + }); + + expect(argsOfCall(0)).toEqual([ + "mr", + "merge", + "7", + "--repo", + "acme/web", + "--auto-merge=true", + "--yes", + "--squash", + ]); + }), + ); + + it.effect("cancels an armed auto-merge through the API glab has no flag for", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("{}"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.runMergeRequestAction({ + cwd: "/w", + repository: "acme/platform/web", + number: 7, + action: "disable-auto-merge", + }); + + expect(argsOfCall(0)).toEqual([ + "api", + "projects/acme%2Fplatform%2Fweb/merge_requests/7/cancel_merge_when_pipeline_succeeds", + "--method", + "POST", + ]); + }), + ); + + it.effect("brings a stale branch up to date by rebasing it, the only way GitLab has", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(""))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.runMergeRequestAction({ + cwd: "/w", + repository: "acme/web", + number: 7, + action: "update-branch", + }); + + expect(argsOfCall(0)).toEqual(["mr", "rebase", "7", "--repo", "acme/web"]); + }), + ); + + it.effect("moves a merge request back to draft through glab", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(""))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.runMergeRequestAction({ + cwd: "/w", + repository: "acme/web", + number: 7, + action: "draft", + }); + + expect(argsOfCall(0)).toEqual(["mr", "update", "7", "--repo", "acme/web", "--draft"]); + }), + ); + + it.effect("sends a comment body over stdin, never in argv", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(""))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.commentOnMergeRequest({ + cwd: "/w", + repository: "acme/web", + number: 7, + body: "true", + }); + + const call = mockedExecute.mock.calls[0]; + assert.isDefined(call); + expect(call[0].args).toEqual([ + "api", + "projects/acme%2Fweb/merge_requests/7/notes", + "--method", + "POST", + "--input", + "-", + "--header", + "Content-Type: application/json", + ]); + // A JSON body, so a comment reading as a literal `true` stays text. + expect(call[0].stdin).toBe('{"body":"true"}'); + }), + ); + + it.effect("reads one diff page and hands back the cursor for the next", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(diffPage(0)))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const diff = yield* cli.getMergeRequestDiff({ + cwd: "/w", + repository: "acme/web", + number: 7, + }); + + // One page per call: the reader asks for the rest, the walk does not run on by itself. + assert.strictEqual(mockedExecute.mock.calls.length, 1); + assert.isNotNull(diff.nextCursor); + // A full page means more files, not a slice with something missing from it. + assert.isFalse(diff.truncated); + expect(argsOfCall(0)[1]).toContain("merge_requests/7/diffs?per_page=100&page=1"); + }), + ); + + it.effect("carries on from a cursor at the page it names", () => + Effect.gen(function* () { + mockedExecute + .mockReturnValueOnce(Effect.succeed(output(diffPage(0)))) + .mockReturnValueOnce(Effect.succeed(output(diffPage(100, 3)))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + const target = { cwd: "/w", repository: "acme/web", number: 7 }; + + const first = yield* cli.getMergeRequestDiff(target); + assert.isNotNull(first.nextCursor); + const second = yield* cli.getMergeRequestDiff({ ...target, cursor: first.nextCursor }); + + expect(argsOfCall(1)[1]).toContain("page=2"); + // A short page is the end of the change set, so there is nothing to carry on from. + assert.isNull(second.nextCursor); + expect(second.patch).toContain("diff --git a/src/100.ts b/src/100.ts"); + }), + ); + + it.effect("refuses a cursor it never handed out rather than reading it into a query", () => + Effect.gen(function* () { + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const error = yield* Effect.flip( + cli.getMergeRequestDiff({ + cwd: "/w", + repository: "acme/web", + number: 7, + cursor: "1&per_page=1", + }), + ); + + assert.strictEqual(error._tag, "GitLabDiffCursorError"); + assert.strictEqual(mockedExecute.mock.calls.length, 0); + }), + ); + + it.effect("reads a named commit from its own diff, and pages inside it", () => + Effect.gen(function* () { + mockedExecute + .mockReturnValueOnce(Effect.succeed(output(diffPage(0)))) + .mockReturnValueOnce(Effect.succeed(output(diffPage(100, 3)))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + const target = { + cwd: "/w", + repository: "acme/web", + number: 7, + commit: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0", + }; + + const first = yield* cli.getMergeRequestDiff(target); + assert.isNotNull(first.nextCursor); + const second = yield* cli.getMergeRequestDiff({ ...target, cursor: first.nextCursor }); + + const commitPath = + "projects/acme%2Fweb/repository/commits/a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0/diff"; + expect(argsOfCall(0)[1]).toBe(`${commitPath}?per_page=100&page=1`); + // The whole path, not just the page: a cursor branch that dropped the commit would still + // ask for page 2, of the merge request's own diff. + expect(argsOfCall(1)[1]).toBe(`${commitPath}?per_page=100&page=2`); + assert.isNull(second.nextCursor); + }), + ); + + it.effect("refuses a commit that is not a sha rather than reading it into a path", () => + Effect.gen(function* () { + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const error = yield* Effect.flip( + cli.getMergeRequestDiff({ + cwd: "/w", + repository: "acme/web", + number: 7, + commit: "../../merge_requests/8/diffs", + }), + ); + + assert.strictEqual(error._tag, "GitLabDiffCommitError"); + assert.strictEqual(mockedExecute.mock.calls.length, 0); + }), + ); + + it.effect("reports a commit with no parent as a structured error", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + // @effect-diagnostics-next-line preferSchemaOverJson:off + Effect.succeed(output(JSON.stringify({ id: "a1b2c3d", parent_ids: [] }))), + ); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const error = yield* Effect.flip( + cli.getMergeRequestDiffFileContents({ + cwd: "/w", + repository: "acme/web", + number: 7, + commit: "a1b2c3d", + changeType: "change", + oldPath: "src/a.ts", + newPath: "src/a.ts", + }), + ); + + assert.strictEqual(error._tag, "GitLabDiffCommitParentUnavailableError"); + if (error._tag === "GitLabDiffCommitParentUnavailableError") { + assert.strictEqual(error.commit, "a1b2c3d"); + } + }), + ); + + it.effect("expands a new file from a root commit without requiring a parent", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + // @effect-diagnostics-next-line preferSchemaOverJson:off + Effect.succeed(output(JSON.stringify({ id: "a1b2c3d", parent_ids: [] }))), + ); + mockedExecute.mockReturnValueOnce(Effect.succeed(output("first contents\n"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const contents = yield* cli.getMergeRequestDiffFileContents({ + cwd: "/w", + repository: "acme/web", + number: 7, + commit: "a1b2c3d", + changeType: "new", + oldPath: "src/first.ts", + newPath: "src/first.ts", + }); + + expect(contents).toEqual({ oldContents: "", newContents: "first contents\n" }); + expect(argsOfCall(1)[1]).toContain("raw?ref=a1b2c3d"); + }), + ); + + it.effect("reports an oversized diff file with its path and reason", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + diff_refs: { + base_sha: "a1b2c3d", + head_sha: "b1c2d3e", + start_sha: "a1b2c3d", + }, + }), + ), + ), + ); + mockedExecute.mockReturnValueOnce(Effect.succeed(output("partial", true))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const error = yield* Effect.flip( + cli.getMergeRequestDiffFileContents({ + cwd: "/w", + repository: "acme/web", + number: 7, + changeType: "deleted", + oldPath: "src/large.ts", + newPath: "src/large.ts", + }), + ); + + assert.strictEqual(error._tag, "GitLabDiffFileContentsUnavailableError"); + if (error._tag === "GitLabDiffFileContentsUnavailableError") { + assert.strictEqual(error.path, "src/large.ts"); + assert.strictEqual(error.reason, "oversized"); + } + }), + ); + + it.effect("reports undecodable diff file contents as binary", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + diff_refs: { + base_sha: "a1b2c3d", + head_sha: "b1c2d3e", + start_sha: "a1b2c3d", + }, + }), + ), + ), + ); + mockedExecute.mockReturnValueOnce( + Effect.succeed(output("binary\uFFFDcontents", false, true)), + ); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const error = yield* Effect.flip( + cli.getMergeRequestDiffFileContents({ + cwd: "/w", + repository: "acme/web", + number: 7, + changeType: "deleted", + oldPath: "assets/logo.png", + newPath: "assets/logo.png", + }), + ); + + assert.strictEqual(error._tag, "GitLabDiffFileContentsUnavailableError"); + if (error._tag === "GitLabDiffFileContentsUnavailableError") { + assert.strictEqual(error.path, "assets/logo.png"); + assert.strictEqual(error.reason, "binary"); + } + }), + ); + + it.effect("returns valid text containing a literal replacement character", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + diff_refs: { + base_sha: "a1b2c3d", + head_sha: "b1c2d3e", + start_sha: "a1b2c3d", + }, + }), + ), + ), + ); + mockedExecute.mockReturnValueOnce(Effect.succeed(output("before\uFFFDafter"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const contents = yield* cli.getMergeRequestDiffFileContents({ + cwd: "/w", + repository: "acme/web", + number: 7, + changeType: "deleted", + oldPath: "docs/encoding.md", + newPath: "docs/encoding.md", + }); + + assert.strictEqual(contents.oldContents, "before\uFFFDafter"); + }), + ); + + it.effect("ends the diff on a page with no files rather than asking for it again", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const diff = yield* cli.getMergeRequestDiff({ + cwd: "/w", + repository: "acme/web", + number: 7, + cursor: "4", + }); + + assert.strictEqual(diff.patch, ""); + assert.isNull(diff.nextCursor); + }), + ); + + it.effect("fails a diff page cut off mid-JSON rather than calling the diff whole", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + // A byte-truncated prefix: valid JSON never survives the cut. + Effect.succeed({ ...output('[{"old_path":"src/x.ts","new_p'), stdoutTruncated: true }), + ); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const error = yield* Effect.flip( + cli.getMergeRequestDiff({ cwd: "/w", repository: "acme/web", number: 7 }), + ); + + // An empty slice with no cursor would report every file from this page on as already + // read, which is the one answer that loses a change without saying so. + assert.strictEqual(error._tag, "GitLabMergeRequestReadError"); + }), + ); + + it.effect("offers no squash when the project does not say it allows one", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + // @effect-diagnostics-next-line preferSchemaOverJson:off + Effect.succeed(output(JSON.stringify({ merge_method: "merge" }))), + ); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const capabilities = yield* cli.getProjectMergeCapabilities({ + cwd: "/w", + repository: "acme/web", + }); + + assert.deepStrictEqual(capabilities, { merge: true, squash: false, rebase: false }); + }), + ); + + it.effect("reads the project's merge settings as its merge capabilities", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + // @effect-diagnostics-next-line preferSchemaOverJson:off + Effect.succeed(output(JSON.stringify({ merge_method: "ff", squash_option: "never" }))), + ); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const capabilities = yield* cli.getProjectMergeCapabilities({ + cwd: "/w", + repository: "acme/web", + }); + + assert.deepStrictEqual(capabilities, { merge: false, squash: false, rebase: true }); + }), + ); + + it.effect("asks the detail read for the divergence GitLab withholds by default", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"message":"404 Not Found"}'))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* Effect.ignore( + cli.getMergeRequestDetail({ cwd: "/w", repository: "acme/web", number: 7 }), + ); + + expect(argsOfCall(0)[1]).toBe( + "projects/acme%2Fweb/merge_requests/7?include_diverged_commits_count=true", + ); + }), + ); + + it.effect("fails the read when GitLab returns something unreadable", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"message":"404 Not Found"}'))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const error = yield* Effect.flip( + cli.getMergeRequestDetail({ cwd: "/w", repository: "acme/web", number: 7 }), + ); + + assert.strictEqual(error._tag, "GitLabMergeRequestReadError"); + }), + ); + + it.effect("fails when the authenticated account has no username", () => + Effect.gen(function* () { + // @effect-diagnostics-next-line preferSchemaOverJson:off + mockedExecute.mockReturnValueOnce(Effect.succeed(output(JSON.stringify({ username: "" })))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const error = yield* Effect.flip(cli.getViewerUsername({ cwd: "/w" })); + + assert.strictEqual(error._tag, "GitLabViewerUnavailableError"); + }), + ); + + it.effect("walks the notes until GitLab answers with a short page", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(notes(100, 1)))); + mockedExecute.mockReturnValueOnce(Effect.succeed(output(notes(2, 101)))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const { comments, truncated } = yield* cli.listNotes({ + cwd: "/w", + repository: "acme/web", + number: 7, + }); + + expect(argsOfCall(0).join(" ")).toContain("page=1"); + expect(argsOfCall(1).join(" ")).toContain("page=2"); + assert.strictEqual(comments.length, 102); + assert.isFalse(truncated); + }), + ); + + it.effect("stops the note walk at its bound and says the conversation was cut short", () => + Effect.gen(function* () { + // GitLab that never answers short: the walk has to end itself. + mockedExecute.mockReturnValue(Effect.succeed(output(notes(100, 1)))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const { truncated } = yield* cli.listNotes({ + cwd: "/w", + repository: "acme/web", + number: 7, + }); + + assert.strictEqual(mockedExecute.mock.calls.length, 10); + assert.isTrue(truncated); + }), + ); + + it.effect("reads a positioned discussion as a thread anchored to its line", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + id: "abc123", + notes: [ + { + id: 1, + body: "rename this", + author: { username: "bilal", avatar_url: "https://avatars/b.png" }, + created_at: "2026-07-01T00:00:00Z", + resolvable: true, + resolved: true, + position: { + position_type: "text", + new_path: "src/a.ts", + old_path: "src/a.ts", + new_line: 12, + old_line: null, + }, + }, + { + id: 2, + body: "done", + author: { username: "julius" }, + created_at: "2026-07-01T01:00:00Z", + }, + ], + }, + // A plain note is the timeline's business, not the diff's. + { id: "def456", notes: [{ id: 3, body: "ship it", created_at: "2026-07-01Z" }] }, + ]), + ), + ), + ); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const { threads } = yield* cli.listDiscussions({ + cwd: "/w", + repository: "acme/web", + number: 7, + }); + + assert.strictEqual(threads.length, 1); + expect(threads[0]).toMatchObject({ + id: "abc123", + path: "src/a.ts", + line: 12, + side: "right", + isResolved: true, + }); + assert.strictEqual(threads[0]?.comments.length, 2); + }), + ); + + it.effect("sends a review as its comments, then its summary, then the verdict", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + iid: 7, + title: "t", + web_url: "https://gitlab.com/acme/web/-/merge_requests/7", + source_branch: "feat", + target_branch: "main", + created_at: "2026-07-01T00:00:00Z", + updated_at: "2026-07-01T00:00:00Z", + diff_refs: { base_sha: "base", head_sha: "head", start_sha: "start" }, + }), + ), + ), + ); + mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.submitReview({ + cwd: "/w", + repository: "acme/web", + number: 7, + verdict: "approve", + body: "Looks right.", + comments: [ + { path: "src/b.ts", oldPath: "src/a.ts", line: 4, side: "left", body: "why remove?" }, + ], + }); + + // The diff revisions first, because a positioned comment cannot be placed without them. + expect(argsOfCall(0)[1]).toContain("merge_requests/7"); + expect(argsOfCall(1)[1]).toContain("/discussions"); + // @effect-diagnostics-next-line preferSchemaOverJson:off + expect(JSON.parse(callAt(1).stdin ?? "")).toEqual({ + body: "why remove?", + position: { + base_sha: "base", + head_sha: "head", + start_sha: "start", + position_type: "text", + // A renamed file is the only case the two differ, and GitLab cannot place a + // position that names the same path on both sides of the rename. + old_path: "src/a.ts", + new_path: "src/b.ts", + old_line: 4, + }, + }); + expect(argsOfCall(2)[1]).toContain("/notes"); + // The verdict goes last, so a review that failed part-way is never an approval. + expect(argsOfCall(3)[1]).toContain("/approve"); + }), + ); + + it.effect("does not ask for diff revisions when a review carries no line comments", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.submitReview({ + cwd: "/w", + repository: "acme/web", + number: 7, + verdict: "comment", + body: "One thought.", + comments: [], + }); + + assert.strictEqual(mockedExecute.mock.calls.length, 1); + expect(argsOfCall(0)[1]).toContain("/notes"); + }), + ); + + it.effect("resolves a discussion in place rather than posting to it", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.setDiscussionResolution({ + cwd: "/w", + repository: "acme/web", + number: 7, + discussionId: "abc123", + resolved: true, + }); + + expect(argsOfCall(0)).toContain("--method"); + expect(argsOfCall(0)).toContain("PUT"); + expect(argsOfCall(0)[1]).toContain("/discussions/abc123"); + // @effect-diagnostics-next-line preferSchemaOverJson:off + expect(JSON.parse(callAt(0).stdin ?? "")).toEqual({ resolved: true }); + }), + ); + + it.effect("awards an emoji through a POST naming it, not a body", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.setReaction({ + cwd: "/w", + repository: "acme/web", + number: 7, + content: "thumbs-up", + reacted: true, + }); + + assert.strictEqual(mockedExecute.mock.calls.length, 1); + expect(argsOfCall(0)).toEqual([ + "api", + "projects/acme%2Fweb/merge_requests/7/award_emoji?name=thumbsup", + "--method", + "POST", + ]); + }), + ); + + it.effect("removes an award by listing them and deleting the reader's own id", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + // @effect-diagnostics-next-line preferSchemaOverJson:off + Effect.succeed(output(JSON.stringify({ username: "bilal" }))), + ); + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { id: 5, name: "thumbsup", user: { username: "bilal" } }, + { id: 6, name: "thumbsup", user: { username: "julius" } }, + ]), + ), + ), + ); + mockedExecute.mockReturnValueOnce(Effect.succeed(output("{}"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.setReaction({ + cwd: "/w", + repository: "acme/web", + number: 7, + content: "thumbs-up", + reacted: false, + }); + + assert.strictEqual(mockedExecute.mock.calls.length, 3); + expect(argsOfCall(2)).toEqual([ + "api", + "projects/acme%2Fweb/merge_requests/7/award_emoji/5", + "--method", + "DELETE", + ]); + }), + ); + + it.effect("does nothing when the reader has no award of that name to take back", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + // @effect-diagnostics-next-line preferSchemaOverJson:off + Effect.succeed(output(JSON.stringify({ username: "bilal" }))), + ); + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.setReaction({ + cwd: "/w", + repository: "acme/web", + number: 7, + content: "thumbs-up", + reacted: false, + }); + + // Nothing to delete: the reaction the caller asked to take back is already gone. + assert.strictEqual(mockedExecute.mock.calls.length, 2); + }), + ); + + it.effect("names a merge request with no diff revisions rather than calling it unreadable", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + iid: 7, + title: "t", + web_url: "https://gitlab.com/acme/web/-/merge_requests/7", + source_branch: "feat", + target_branch: "main", + created_at: "2026-07-01T00:00:00Z", + updated_at: "2026-07-01T00:00:00Z", + diff_refs: null, + }), + ), + ), + ); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const error = yield* Effect.flip( + cli.submitReview({ + cwd: "/w", + repository: "acme/web", + number: 7, + verdict: "comment", + body: "", + comments: [{ path: "src/a.ts", line: 4, side: "right", body: "nit" }], + }), + ); + + // Nothing failed to decode: GitLab answered, and the answer has nowhere to put a + // positioned comment. + assert.strictEqual(error._tag, "GitLabDiffRefsUnavailableError"); + }), + ); + + it.effect("reads who has access to the project and who is already on the merge request", () => + Effect.gen(function* () { + mockedExecute + .mockReturnValueOnce(Effect.succeed(output(mergeRequestJson({ reviewers: [reviewer] })))) + .mockReturnValueOnce( + Effect.succeed( + // @effect-diagnostics-next-line preferSchemaOverJson:off + output(JSON.stringify([author, reviewer, { id: 9, username: "hubot" }])), + ), + ); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const list = yield* cli.listReviewerCandidates({ + cwd: "/w", + repository: "acme/web", + number: 7, + }); + + expect(argsOfCall(1)[1]).toBe("projects/acme%2Fweb/users?per_page=100"); + // The author is left out, and whoever GitLab already has as a reviewer is marked. + expect(list.candidates.map((candidate) => [candidate.id, candidate.isRequested])).toEqual([ + ["5", true], + ["9", false], + ]); + assert.isFalse(list.truncated); + }), + ); + + it.effect("writes the reviewer set back with the one being asked added to it", () => + Effect.gen(function* () { + mockedExecute + .mockReturnValueOnce(Effect.succeed(output(mergeRequestJson({ reviewers: [reviewer] })))) + .mockReturnValueOnce(Effect.succeed(output("{}"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.setReviewerRequest({ + cwd: "/w", + repository: "acme/web", + number: 7, + reviewers: [{ id: "9" }], + requested: true, + }); + + // GitLab replaces the whole set, so the reviewer already on the merge request has to be + // sent back with the new one or the request would take them off it. + expect(argsOfCall(1)).toContain("PUT"); + // @effect-diagnostics-next-line preferSchemaOverJson:off + expect(JSON.parse(callAt(1).stdin ?? "")).toEqual({ reviewer_ids: [5, 9] }); + }), + ); + + it.effect("takes a reviewer out of the set rather than clearing it", () => + Effect.gen(function* () { + mockedExecute + .mockReturnValueOnce( + Effect.succeed( + output(mergeRequestJson({ reviewers: [reviewer, { id: 9, username: "hubot" }] })), + ), + ) + .mockReturnValueOnce(Effect.succeed(output("{}"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.setReviewerRequest({ + cwd: "/w", + repository: "acme/web", + number: 7, + reviewers: [{ id: "9" }], + requested: false, + }); + + // @effect-diagnostics-next-line preferSchemaOverJson:off + expect(JSON.parse(callAt(1).stdin ?? "")).toEqual({ reviewer_ids: [5] }); + }), + ); + + it.effect("ignores an id GitLab could not have handed out, which names nobody", () => + Effect.gen(function* () { + mockedExecute + .mockReturnValueOnce(Effect.succeed(output(mergeRequestJson({ reviewers: [reviewer] })))) + .mockReturnValueOnce(Effect.succeed(output("{}"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.setReviewerRequest({ + cwd: "/w", + repository: "acme/web", + number: 7, + reviewers: [{ id: "octocat" }], + requested: true, + }); + + // Sending it as a number would rewrite the reviewer set around something nobody chose. + // @effect-diagnostics-next-line preferSchemaOverJson:off + expect(JSON.parse(callAt(1).stdin ?? "")).toEqual({ reviewer_ids: [5] }); + }), + ); + + it.effect("rewrites a title without touching the description", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.updateMergeRequest({ + cwd: "/w", + repository: "acme/web", + number: 7, + title: "A better title", + }); + + expect(argsOfCall(0)).toEqual([ + "api", + "projects/acme%2Fweb/merge_requests/7", + "--method", + "PUT", + "--input", + "-", + "--header", + "Content-Type: application/json", + ]); + // @effect-diagnostics-next-line preferSchemaOverJson:off + expect(JSON.parse(callAt(0).stdin ?? "")).toEqual({ title: "A better title" }); + }), + ); + + it.effect("sends a rewritten body as GitLab's description, and nothing else", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.updateMergeRequest({ + cwd: "/w", + repository: "acme/web", + number: 7, + description: "What this changes.", + }); + + // A title sent as an empty string would wipe the one the merge request already has. + // @effect-diagnostics-next-line preferSchemaOverJson:off + expect(JSON.parse(callAt(0).stdin ?? "")).toEqual({ description: "What this changes." }); + }), + ); + + it.effect("rewrites title and description together in one request", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.updateMergeRequest({ + cwd: "/w", + repository: "acme/web", + number: 7, + title: "A better title", + description: "What this changes.", + }); + + assert.strictEqual(mockedExecute.mock.calls.length, 1); + // @effect-diagnostics-next-line preferSchemaOverJson:off + expect(JSON.parse(callAt(0).stdin ?? "")).toEqual({ + title: "A better title", + description: "What this changes.", + }); + }), + ); + + it.effect("rewrites a note in place through the note it names", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.updateNote({ + cwd: "/w", + repository: "acme/web", + number: 7, + noteId: "42", + body: "true", + }); + + expect(argsOfCall(0)).toEqual([ + "api", + "projects/acme%2Fweb/merge_requests/7/notes/42", + "--method", + "PUT", + "--input", + "-", + "--header", + "Content-Type: application/json", + ]); + // A JSON body, so a note rewritten to a literal `true` stays text. + expect(callAt(0).stdin).toBe('{"body":"true"}'); + }), + ); +}); diff --git a/apps/server/src/pullRequest/GitLabPullRequestCli.ts b/apps/server/src/pullRequest/GitLabPullRequestCli.ts new file mode 100644 index 000000000000..17c23bf86f48 --- /dev/null +++ b/apps/server/src/pullRequest/GitLabPullRequestCli.ts @@ -0,0 +1,1374 @@ +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 { + PullRequestAction, + PullRequestComment, + PullRequestCommit, + PullRequestInvolvement, + PullRequestListState, + PullRequestMergeCapabilities, + PullRequestMergeMethod, + PullRequestReaction, + PullRequestReactionContent, + PullRequestReviewCommentDraft, + PullRequestReviewThread, + PullRequestReviewVerdict, + PullRequestReviewerCandidateList, +} from "@t3tools/contracts"; + +import * as GitLabCli from "../sourceControl/GitLabCli.ts"; +import { + AWARD_EMOJI_GRAPHQL_QUERY, + decodeAwardEmojiJson, + decodeCommitDiffRefsJson, + decodeCommitsJson, + decodeDiffRefsJson, + decodeDiscussionsJson, + decodeMergeRequestDetailJson, + decodeMergeRequestDiffsJson, + decodeMergeRequestListJson, + decodeNotesJson, + decodeOwnAwardIdJson, + decodeProjectMergeCapabilitiesJson, + decodeProjectUsersJson, + decodeViewerJson, + gitLabAwardName, + type GitLabDiffRefs, + type GitLabMergeRequestDetail, + type GitLabMergeRequestListItem, + type GitLabProjectUsers, +} from "./gitLabMergeRequestJson.ts"; +import type { ProviderListCursor } from "./PullRequestProvider.ts"; + +/** + * Names the read that produced unusable output, so a failure reports the call it came from + * rather than borrowing another operation's message. + */ +export class GitLabMergeRequestReadError extends Schema.TaggedErrorClass()( + "GitLabMergeRequestReadError", + { + command: Schema.Literal("glab"), + cwd: Schema.String, + operation: Schema.String, + cause: Schema.Defect(), + }, +) { + get detail(): string { + return `GitLab CLI returned an unreadable ${this.operation} response.`; + } + + override get message(): string { + return `GitLab CLI failed in ${this.operation}: ${this.detail}`; + } +} + +/** Not a decode failure: glab answered, the account it answered for just has no username. */ +export class GitLabViewerUnavailableError extends Schema.TaggedErrorClass()( + "GitLabViewerUnavailableError", + { + command: Schema.Literal("glab"), + cwd: Schema.String, + }, +) { + get detail(): string { + return "GitLab CLI returned no username for the authenticated account."; + } + + override get message(): string { + return `GitLab CLI failed in getViewerUsername: ${this.detail}`; + } +} + +/** Not a decode failure: GitLab answered, the merge request just has no revisions to place a + * comment against. */ +export class GitLabDiffRefsUnavailableError extends Schema.TaggedErrorClass()( + "GitLabDiffRefsUnavailableError", + { + command: Schema.Literal("glab"), + cwd: Schema.String, + number: Schema.Int, + }, +) { + get detail(): string { + return "The merge request reported no diff revisions."; + } + + override get message(): string { + return `GitLab CLI failed in getDiffRefs: ${this.detail}`; + } +} + +/** Not a decode failure: the reader asked to carry on from a cursor this walk never handed out. */ +export class GitLabDiffCursorError extends Schema.TaggedErrorClass()( + "GitLabDiffCursorError", + { + command: Schema.Literal("glab"), + cwd: Schema.String, + }, +) { + get detail(): string { + return "The diff cursor was not one this merge request handed out."; + } + + override get message(): string { + return `GitLab CLI failed in getMergeRequestDiff: ${this.detail}`; + } +} + +/** Not a decode failure: the reader named a commit that is not a sha this project could hold. */ +export class GitLabDiffCommitError extends Schema.TaggedErrorClass()( + "GitLabDiffCommitError", + { + command: Schema.Literal("glab"), + cwd: Schema.String, + }, +) { + get detail(): string { + return "The named commit was not a commit sha."; + } + + override get message(): string { + return `GitLab CLI failed in getMergeRequestDiff: ${this.detail}`; + } +} + +/** The commit exists and decoded, but it has no parent to use as the old revision. */ +export class GitLabDiffCommitParentUnavailableError extends Schema.TaggedErrorClass()( + "GitLabDiffCommitParentUnavailableError", + { + command: Schema.Literal("glab"), + cwd: Schema.String, + commit: Schema.String, + }, +) { + get detail(): string { + return `Commit ${this.commit} reported no parent revision.`; + } + + override get message(): string { + return `GitLab CLI failed in getMergeRequestDiffFileContents: ${this.detail}`; + } +} + +/** A blob exists, but expanding it would be unsafe or would not produce text. */ +export class GitLabDiffFileContentsUnavailableError extends Schema.TaggedErrorClass()( + "GitLabDiffFileContentsUnavailableError", + { + command: Schema.Literal("glab"), + cwd: Schema.String, + path: Schema.String, + reason: Schema.Literals(["oversized", "binary"]), + }, +) { + get detail(): string { + return this.reason === "oversized" + ? `The diff file '${this.path}' exceeds the 1 MB expansion limit.` + : `The diff file '${this.path}' is binary.`; + } + + override get message(): string { + return `GitLab CLI failed in getMergeRequestDiffFileContents: ${this.detail}`; + } +} + +export type GitLabPullRequestCliError = + | GitLabCli.GitLabCliError + | GitLabMergeRequestReadError + | GitLabDiffCursorError + | GitLabDiffCommitError + | GitLabDiffCommitParentUnavailableError + | GitLabDiffFileContentsUnavailableError + | GitLabDiffRefsUnavailableError + | GitLabViewerUnavailableError; + +/** GitLab's own ceiling on `per_page`, so a larger page has to be walked. */ +const MAX_PAGE_SIZE = 100; +/** Commit history is read one page deep; the rest of a long history stays on GitLab. */ +const COMMIT_PAGE_SIZE = 100; +/** + * Pages of the conversation to follow before it is reported as truncated. GitLab caps a page at + * a hundred, so this is a thousand notes and a thousand discussions — more than any merge + * request a person is reading holds, and a walk that ends whatever the host has. + */ +const CONVERSATION_PAGES = 10; +const DIFF_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; +const DIFF_TIMEOUT_MS = 60_000; +const DIFF_FILE_MAX_OUTPUT_BYTES = 1024 * 1024; + +export interface GitLabMergeRequestListBatch { + readonly items: ReadonlyArray; + readonly truncated: boolean; + /** Raw GitLab rows consumed to produce this page, including malformed rows. */ + readonly cursorAdvance: number; +} + +export interface GitLabMergeRequestDiffSlice { + readonly patch: string; + /** Files in this slice had their hunks withheld, as opposed to there being more slices. */ + readonly truncated: boolean; + /** Where the next slice starts, or null once the patch is whole. */ + readonly nextCursor: string | null; +} + +export class GitLabPullRequestCli extends Context.Service< + GitLabPullRequestCli, + { + readonly getViewerUsername: (input: { + readonly cwd: string; + }) => Effect.Effect; + + readonly listMergeRequests: (input: { + readonly cwd: string; + readonly repository: string; + readonly state: PullRequestListState; + readonly involvement: PullRequestInvolvement; + readonly viewer: string; + readonly limit: number; + /** Free text for GitLab's own `search`, which matches title and description. */ + readonly query?: string | undefined; + /** Where to carry on from in GitLab's stable update-ordered row set. */ + readonly cursor?: ProviderListCursor | undefined; + }) => Effect.Effect; + + readonly getMergeRequestDetail: (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + }) => Effect.Effect; + + readonly listNotes: (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + }) => Effect.Effect< + { readonly comments: ReadonlyArray; readonly truncated: boolean }, + GitLabPullRequestCliError + >; + + readonly listCommits: (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + }) => Effect.Effect, GitLabPullRequestCliError>; + + readonly getMergeRequestDiff: (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + /** Absent asks for the first slice; anything else is a cursor a slice handed back. */ + readonly cursor?: string | undefined; + /** One commit's own changes, rather than everything the merge request carries. */ + readonly commit?: string | undefined; + }) => Effect.Effect; + + readonly getMergeRequestDiffFileContents: (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + readonly commit?: string | undefined; + readonly changeType: "change" | "rename-pure" | "rename-changed" | "new" | "deleted"; + readonly oldPath: string; + readonly newPath: string; + }) => Effect.Effect< + { readonly oldContents: string; readonly newContents: string }, + GitLabPullRequestCliError + >; + + readonly getProjectMergeCapabilities: (input: { + readonly cwd: string; + readonly repository: string; + }) => Effect.Effect; + + /** + * Who this merge request may be sent to, and who it has already been sent to. Two reads at + * once, because GitLab keeps the people with access on the project and the reviewers on the + * merge request, and neither answers for the other. + */ + readonly listReviewerCandidates: (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + }) => Effect.Effect; + + readonly setReviewerRequest: (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + readonly reviewers: ReadonlyArray<{ readonly id: string }>; + readonly requested: boolean; + }) => Effect.Effect; + + readonly runMergeRequestAction: (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + readonly action: PullRequestAction; + readonly mergeMethod?: PullRequestMergeMethod; + }) => Effect.Effect; + + /** Whichever of the two is given is sent. GitLab calls a merge request's body its description. */ + readonly updateMergeRequest: (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + readonly title?: string | undefined; + readonly description?: string | undefined; + }) => Effect.Effect; + + readonly commentOnMergeRequest: (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + readonly body: string; + }) => Effect.Effect; + + readonly updateNote: (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + readonly noteId: string; + readonly body: string; + }) => Effect.Effect; + + readonly listDiscussions: (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + }) => Effect.Effect< + { readonly threads: ReadonlyArray; readonly truncated: boolean }, + GitLabPullRequestCliError + >; + + readonly submitReview: (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + readonly verdict: PullRequestReviewVerdict; + readonly body: string; + readonly comments: ReadonlyArray; + }) => Effect.Effect; + + readonly replyToDiscussion: (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + readonly discussionId: string; + readonly body: string; + }) => Effect.Effect; + + readonly setDiscussionResolution: (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + readonly discussionId: string; + readonly resolved: boolean; + }) => Effect.Effect; + + /** The awards on the merge request and on every note of it, keyed by the note's REST id. */ + readonly listReactions: (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + }) => Effect.Effect< + { + readonly reactions: ReadonlyArray; + readonly reactionsByNoteId: ReadonlyMap>; + }, + GitLabPullRequestCliError + >; + + /** + * Awards an emoji, or takes the award back. `noteId` is a note of the merge request; absent + * awards the merge request itself, which is where its description's reactions live. + */ + readonly setReaction: (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + readonly noteId?: string | undefined; + readonly content: PullRequestReactionContent; + readonly reacted: boolean; + }) => Effect.Effect; + } +>()("t3/pullRequest/GitLabPullRequestCli") {} + +/** The REST API addresses a project by its URL-encoded full path. */ +function projectPath(repository: string): string { + return encodeURIComponent(repository.trim()); +} + +function stateParam(state: PullRequestListState): string { + // GitLab's `closed` already excludes merged merge requests, so no extra filter is needed, + // and it spans every state under `all`. + return state === "open" ? "opened" : state; +} + +function involvementParams(input: { + readonly involvement: PullRequestInvolvement; + readonly viewer: string; +}): ReadonlyArray { + switch (input.involvement) { + case "authored": + return [["author_username", input.viewer]]; + case "reviewing": + return [["reviewer_username", input.viewer]]; + case "all": + return []; + } +} + +/** + * The page a diff cursor names, or null for anything this walk cannot have issued. The cursor + * arrives from the reader as a string and goes straight into a query, so it is parsed rather + * than trusted; the length bound keeps a page number out of exponential notation. + */ +function diffCursorPage(cursor: string): number | null { + return /^[1-9][0-9]{0,6}$/.test(cursor) ? Number(cursor) : null; +} + +/** + * A commit sha arrives from the reader and goes straight into a request path, so it is checked + * rather than trusted: hexadecimal only, from the shortest abbreviation a host prints up to a + * whole sha. + */ +function isCommitSha(value: string): boolean { + return /^[0-9a-f]{7,64}$/i.test(value); +} + +function searchParams(search: string | undefined): ReadonlyArray { + const trimmed = search?.trim() ?? ""; + return trimmed.length === 0 ? [] : [["search", trimmed]]; +} + +function query(params: ReadonlyArray): string { + return params.map(([key, value]) => `${key}=${encodeURIComponent(value)}`).join("&"); +} + +function actionArgs( + action: PullRequestAction, + mergeMethod: PullRequestMergeMethod | undefined, +): ReadonlyArray { + switch (action) { + case "merge": + return [ + "merge", + // glab turns on auto-merge whenever a pipeline is running. The button means merge now. + "--auto-merge=false", + "--yes", + ...(mergeMethod === "squash" ? ["--squash"] : []), + ...(mergeMethod === "rebase" ? ["--rebase"] : []), + ]; + // The same command with the flag the other way up: here the wait is the whole point, so + // glab is told to arm the merge rather than talked out of it. + case "enable-auto-merge": + return [ + "merge", + "--auto-merge=true", + "--yes", + ...(mergeMethod === "squash" ? ["--squash"] : []), + ...(mergeMethod === "rebase" ? ["--rebase"] : []), + ]; + // Never reached: taking the arming back has no `glab mr` command, so it goes to the API. + case "disable-auto-merge": + return []; + case "ready": + return ["update", "--ready"]; + case "draft": + return ["update", "--draft"]; + case "close": + return ["close"]; + // A rebase, because GitLab has no other way to move a branch onto its target: there is no + // merge-the-target-in equivalent of GitHub's update button, which is why this host declares + // `rebase` alone and never has to read the method it was handed. + case "update-branch": + return ["rebase"]; + case "reopen": + return ["reopen"]; + } +} + +export const make = Effect.gen(function* () { + const gitlab = yield* GitLabCli.GitLabCli; + + const api = (input: { + readonly cwd: string; + readonly path: string; + readonly method?: string; + readonly stdin?: string; + readonly maxOutputBytes?: number; + readonly timeoutMs?: number; + }) => + gitlab.execute({ + cwd: input.cwd, + args: [ + "api", + input.path, + ...(input.method === undefined ? [] : ["--method", input.method]), + // A raw body from stdin: argv is visible in process listings and is echoed back + // inside process-runner failure messages. Unlike `gh`, `glab api --input` sends no + // Content-Type at all, and GitLab answers a bodyless content type with HTTP 415. + ...(input.stdin === undefined + ? [] + : ["--input", "-", "--header", "Content-Type: application/json"]), + ], + ...(input.stdin === undefined ? {} : { stdin: input.stdin }), + ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }), + ...(input.timeoutMs === undefined ? {} : { timeoutMs: input.timeoutMs }), + }); + + /** + * `per_page` stops at 100, so a larger page is walked one request at a time. The walk is + * bounded twice over: it stops on a short page or once the extra row that reveals a next + * page has been read, and it never asks for more pages than the caller's page needs. The + * second bound is what makes it terminate when every row on a page fails to decode, which + * leaves nothing collected but does not mean GitLab has run out of rows. + */ + const listPage = (input: { + readonly cwd: string; + readonly repository: string; + readonly state: PullRequestListState; + readonly involvement: PullRequestInvolvement; + readonly viewer: string; + readonly limit: number; + readonly query?: string | undefined; + readonly cursor?: ProviderListCursor | undefined; + readonly page: number; + readonly collected: ReadonlyArray; + readonly cursorAdvance: number; + }): Effect.Effect => { + // A continuation uses GitLab's offset pagination. Its timestamp filter is inclusive and has + // no tie-breaker, so a page where many rows share the boundary would otherwise return the + // same prefix forever. `delivered` is the stable offset the service has already handed over. + const delivered = input.cursor?.delivered ?? 0; + const perPage = Math.min(input.limit + 1, MAX_PAGE_SIZE); + const firstPage = Math.floor(delivered / perPage) + 1; + const skipOnFirstPage = input.page === firstPage ? delivered % perPage : 0; + // A page made entirely of malformed rows has no item from which the service can build a + // continuation. Bound the walk to the raw span this request asked for rather than recursing + // forever on a host that keeps returning full unusable pages. + const lastPage = Math.floor((delivered + input.limit) / perPage) + 1; + return api({ + cwd: input.cwd, + path: `projects/${projectPath(input.repository)}/merge_requests?${query([ + ["state", stateParam(input.state)], + ...involvementParams(input), + // The listing is read through `glab api` rather than `glab mr list`, so the search is + // the REST API's own `search` parameter — the one `mr list --search` passes on. It + // matches title and description, and travels URL-encoded like every other value here, + // so no text in it can become a parameter of its own. + ...searchParams(input.query), + ["order_by", "updated_at"], + ["sort", "desc"], + ["per_page", String(perPage)], + ["page", String(input.page)], + ])}`, + }).pipe( + Effect.flatMap((result) => { + const raw = result.stdout.trim(); + if (raw.length === 0) { + return Effect.succeed({ + items: input.collected, + truncated: false, + cursorAdvance: input.cursorAdvance, + }); + } + const decoded = decodeMergeRequestListJson(raw); + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new GitLabMergeRequestReadError({ + command: "glab", + cwd: input.cwd, + operation: "listMergeRequests", + cause: decoded.failure, + }), + ); + } + const pageItems: GitLabMergeRequestListItem[] = []; + const pageRawIndexes: number[] = []; + for (const [index, item] of decoded.success.items.entries()) { + const rawIndex = decoded.success.rawIndexes[index]!; + if (rawIndex < skipOnFirstPage) continue; + pageItems.push(item); + pageRawIndexes.push(rawIndex); + } + const remaining = input.limit - input.collected.length; + const lastItemRawIndex = pageRawIndexes[remaining - 1]; + if (lastItemRawIndex !== undefined) { + const consumed = lastItemRawIndex + 1 - skipOnFirstPage; + return Effect.succeed({ + items: [...input.collected, ...pageItems.slice(0, remaining)], + truncated: + lastItemRawIndex + 1 < decoded.success.rawCount || + decoded.success.rawCount === perPage, + cursorAdvance: input.cursorAdvance + consumed, + }); + } + const collected = [...input.collected, ...pageItems]; + const consumed = Math.max(0, decoded.success.rawCount - skipOnFirstPage); + // Counted before decoding, so a skipped malformed row cannot end paging early. + const exhausted = decoded.success.rawCount < perPage; + if (exhausted) { + return Effect.succeed({ + items: collected, + truncated: false, + cursorAdvance: input.cursorAdvance + consumed, + }); + } + if (input.page >= lastPage) { + return Effect.succeed({ + items: collected, + truncated: true, + cursorAdvance: input.cursorAdvance + consumed, + }); + } + return listPage({ + ...input, + page: input.page + 1, + collected, + cursorAdvance: input.cursorAdvance + consumed, + }); + }), + ); + }; + + /** + * One page of a merge request's files, as a patch that stands on its own. GitLab pages + * `/diffs` by offset and has no cursor of its own, so the page number is the cursor; the + * caller carries on from it for as long as GitLab keeps handing full pages back. + * + * A named commit is read from the commit's own diff, which answers in the same shape and pages + * the same way. + */ + const diffPage = (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + readonly page: number; + readonly commit?: string | undefined; + }): Effect.Effect => + api({ + cwd: input.cwd, + path: `projects/${projectPath(input.repository)}/${ + input.commit === undefined + ? `merge_requests/${input.number}/diffs` + : `repository/commits/${input.commit}/diff` + }?${query([ + ["per_page", String(MAX_PAGE_SIZE)], + ["page", String(input.page)], + ])}`, + maxOutputBytes: DIFF_MAX_OUTPUT_BYTES, + timeoutMs: DIFF_TIMEOUT_MS, + }).pipe( + Effect.flatMap((result) => { + // A byte-truncated response is a JSON prefix, so this page cannot be read at all. + // Answering with no cursor would call the diff whole while silently dropping this page + // and every one after it, so the read fails and says which page could not be had. + if (result.stdoutTruncated) { + return Effect.fail( + new GitLabMergeRequestReadError({ + command: "glab", + cwd: input.cwd, + operation: "getMergeRequestDiff", + cause: new Error( + `Page ${input.page} of the merge request diff was too large to read.`, + ), + }), + ); + } + const decoded = decodeMergeRequestDiffsJson(result.stdout.trim()); + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new GitLabMergeRequestReadError({ + command: "glab", + cwd: input.cwd, + operation: "getMergeRequestDiff", + cause: decoded.failure, + }), + ); + } + const patch = decoded.success.patch; + // Counted before decoding, so a page whose files all failed to decode still moves on + // rather than pointing the reader back at the page it just read. + const morePages = decoded.success.rawCount >= MAX_PAGE_SIZE; + return Effect.succeed({ + // The slice ends on a newline, so a file GitLab gave a header and no hunks for does + // not run into the first line of the next slice. + patch: patch.length === 0 ? patch : patch.replace(/\n?$/, "\n"), + truncated: decoded.success.truncated, + nextCursor: morePages ? String(input.page + 1) : null, + }); + }), + ); + + /** + * The conversation, a page at a time. GitLab pages by offset and reports no total, so a short + * page is the only thing that says it is done — and the raw count decides, not the kept one: + * the notes GitLab wrote itself are dropped, and a whole page of them still means there is + * more to read. + */ + const notesPage = (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + readonly page: number; + readonly collected: ReadonlyArray; + }): Effect.Effect< + { readonly comments: ReadonlyArray; readonly truncated: boolean }, + GitLabPullRequestCliError + > => + api({ + cwd: input.cwd, + path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/notes?${query( + [ + ["per_page", String(MAX_PAGE_SIZE)], + ["page", String(input.page)], + ["order_by", "created_at"], + ["sort", "asc"], + ], + )}`, + }).pipe( + Effect.flatMap((result) => { + const decoded = decodeNotesJson(result.stdout.trim()); + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new GitLabMergeRequestReadError({ + command: "glab", + cwd: input.cwd, + operation: "listNotes", + cause: decoded.failure, + }), + ); + } + const collected = [...input.collected, ...decoded.success.comments]; + if (decoded.success.rawCount < MAX_PAGE_SIZE) { + return Effect.succeed({ comments: collected, truncated: false }); + } + return input.page >= CONVERSATION_PAGES + ? Effect.succeed({ comments: collected, truncated: true }) + : notesPage({ ...input, page: input.page + 1, collected }); + }), + ); + + /** The positioned discussions, walked the same way and stopped by the same bound. */ + const discussionsPage = (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + readonly page: number; + readonly collected: ReadonlyArray; + }): Effect.Effect< + { readonly threads: ReadonlyArray; readonly truncated: boolean }, + GitLabPullRequestCliError + > => + api({ + cwd: input.cwd, + path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/discussions?${query( + [ + ["per_page", String(MAX_PAGE_SIZE)], + ["page", String(input.page)], + ], + )}`, + }).pipe( + Effect.flatMap((result) => { + const decoded = decodeDiscussionsJson(result.stdout.trim()); + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new GitLabMergeRequestReadError({ + command: "glab", + cwd: input.cwd, + operation: "listDiscussions", + cause: decoded.failure, + }), + ); + } + const collected = [...input.collected, ...decoded.success.threads]; + // The raw count again: this endpoint returns the plain notes too, so a full page of + // those is not the end of the positioned ones. + if (decoded.success.rawCount < MAX_PAGE_SIZE) { + return Effect.succeed({ threads: collected, truncated: false }); + } + return input.page >= CONVERSATION_PAGES + ? Effect.succeed({ threads: collected, truncated: true }) + : discussionsPage({ ...input, page: input.page + 1, collected }); + }), + ); + + /** + * The revisions a positioned comment is written against. GitLab resolves a comment's line + * against these three shas, so a review with line comments cannot be sent without them. + */ + const getDiffRefs = (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + }): Effect.Effect => + api({ + cwd: input.cwd, + path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}`, + }).pipe( + Effect.flatMap((result): Effect.Effect => { + const decoded = decodeDiffRefsJson(result.stdout.trim()); + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new GitLabMergeRequestReadError({ + command: "glab", + cwd: input.cwd, + operation: "getDiffRefs", + cause: decoded.failure, + }), + ); + } + // A merge request with no diff refs is a well-formed answer that cannot carry a + // positioned comment — a dead end, but not something that failed to be read. + return decoded.success === null + ? Effect.fail( + new GitLabDiffRefsUnavailableError({ + command: "glab", + cwd: input.cwd, + number: input.number, + }), + ) + : Effect.succeed(decoded.success); + }), + ); + + const getCommitDiffRefs = (input: { + readonly cwd: string; + readonly repository: string; + readonly commit: string; + readonly allowRoot: boolean; + }): Effect.Effect => + api({ + cwd: input.cwd, + path: `projects/${projectPath(input.repository)}/repository/commits/${input.commit}`, + }).pipe( + Effect.flatMap((result): Effect.Effect => { + const decoded = decodeCommitDiffRefsJson(result.stdout.trim()); + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new GitLabMergeRequestReadError({ + command: "glab", + cwd: input.cwd, + operation: "getMergeRequestDiffFileContents", + cause: decoded.failure, + }), + ); + } + return decoded.success === null + ? input.allowRoot + ? Effect.succeed({ + baseSha: "", + headSha: input.commit, + startSha: "", + }) + : Effect.fail( + new GitLabDiffCommitParentUnavailableError({ + command: "glab", + cwd: input.cwd, + commit: input.commit, + }), + ) + : Effect.succeed(decoded.success); + }), + ); + + /** + * The merge request itself, which several calls need for different parts of it: the detail for + * everything, and the reviewer paths for the ids GitLab writes a reviewer set with. + */ + const mergeRequestDetail = (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + }): Effect.Effect => + api({ + cwd: input.cwd, + // How far behind the target branch this one is comes only when asked for by name, and it + // is asked for here rather than on a second read because it is the same merge request. + path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}?${query([ + ["include_diverged_commits_count", "true"], + ])}`, + }).pipe( + Effect.flatMap((result) => { + const decoded = decodeMergeRequestDetailJson(result.stdout.trim()); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail( + new GitLabMergeRequestReadError({ + command: "glab", + cwd: input.cwd, + operation: "getMergeRequestDetail", + cause: decoded.failure, + }), + ); + }), + ); + + /** The people with access to the project, one page deep. */ + const projectUsers = (input: { + readonly cwd: string; + readonly repository: string; + }): Effect.Effect => + api({ + cwd: input.cwd, + path: `projects/${projectPath(input.repository)}/users?${query([ + ["per_page", String(MAX_PAGE_SIZE)], + ])}`, + }).pipe( + Effect.flatMap((result) => { + const decoded = decodeProjectUsersJson(result.stdout.trim()); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail( + new GitLabMergeRequestReadError({ + command: "glab", + cwd: input.cwd, + operation: "listReviewerCandidates", + cause: decoded.failure, + }), + ); + }), + ); + + /** Where an award is written: a note of the merge request, or the merge request itself. */ + const awardSubjectPath = (input: { + readonly repository: string; + readonly number: number; + readonly noteId?: string | undefined; + }) => { + const mergeRequest = `projects/${projectPath(input.repository)}/merge_requests/${input.number}`; + return input.noteId === undefined + ? `${mergeRequest}/award_emoji` + : `${mergeRequest}/notes/${encodeURIComponent(input.noteId)}/award_emoji`; + }; + + /** + * The awards on the merge request and its notes, a page of notes at a time. Bounded by the same + * count as the conversation itself: awards past the notes that were read belong to notes the + * page is not showing. + */ + const awardsPage = (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + readonly cursor: string | null; + readonly page: number; + readonly collected: { + readonly reactions: ReadonlyArray; + readonly reactionsByNoteId: Map>; + } | null; + }): Effect.Effect< + { + readonly reactions: ReadonlyArray; + readonly reactionsByNoteId: ReadonlyMap>; + }, + GitLabPullRequestCliError + > => + api({ + cwd: input.cwd, + path: "graphql", + method: "POST", + stdin: JSON.stringify({ + query: AWARD_EMOJI_GRAPHQL_QUERY, + variables: { + fullPath: input.repository, + iid: String(input.number), + cursor: input.cursor, + }, + }), + }).pipe( + Effect.flatMap((result) => { + const decoded = decodeAwardEmojiJson(result.stdout.trim()); + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new GitLabMergeRequestReadError({ + command: "glab", + cwd: input.cwd, + operation: "listReactions", + cause: decoded.failure, + }), + ); + } + const collected = input.collected ?? { + reactions: decoded.success.reactions, + reactionsByNoteId: new Map>(), + }; + for (const [id, reactions] of decoded.success.reactionsByNoteId) + collected.reactionsByNoteId.set(id, reactions); + return decoded.success.nextCursor === null || input.page >= CONVERSATION_PAGES + ? Effect.succeed(collected) + : awardsPage({ + ...input, + cursor: decoded.success.nextCursor, + page: input.page + 1, + collected, + }); + }), + ); + + const viewerUsername = (input: { readonly cwd: string }) => + api({ cwd: input.cwd, path: "user" }).pipe( + Effect.flatMap((result): Effect.Effect => { + const decoded = decodeViewerJson(result.stdout.trim()); + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new GitLabMergeRequestReadError({ + command: "glab", + cwd: input.cwd, + operation: "getViewerUsername", + cause: decoded.failure, + }), + ); + } + return decoded.success === null + ? Effect.fail(new GitLabViewerUnavailableError({ command: "glab", cwd: input.cwd })) + : Effect.succeed(decoded.success); + }), + ); + + return GitLabPullRequestCli.of({ + getViewerUsername: viewerUsername, + + listMergeRequests: (input) => { + const perPage = Math.min(input.limit + 1, MAX_PAGE_SIZE); + const page = Math.floor((input.cursor?.delivered ?? 0) / perPage) + 1; + return listPage({ ...input, page, collected: [], cursorAdvance: 0 }); + }, + + getMergeRequestDetail: mergeRequestDetail, + + listNotes: (input) => notesPage({ ...input, page: 1, collected: [] }), + + listReactions: (input) => awardsPage({ ...input, cursor: null, page: 1, collected: null }), + + setReaction: (input) => + Effect.gen(function* () { + const subject = awardSubjectPath(input); + if (input.reacted) { + yield* api({ + cwd: input.cwd, + path: `${subject}?${query([["name", gitLabAwardName(input.content)]])}`, + method: "POST", + }); + return; + } + // GitLab deletes an award by its id and takes no emoji name there, so the reader's own + // award of that name is looked up first. Nothing to delete is success: the reaction the + // caller asked to take back is already gone. + const viewer = yield* viewerUsername({ cwd: input.cwd }); + const listed = yield* api({ cwd: input.cwd, path: subject }); + const own = decodeOwnAwardIdJson(listed.stdout.trim(), { + content: input.content, + viewer, + }); + if (!Result.isSuccess(own)) { + return yield* new GitLabMergeRequestReadError({ + command: "glab", + cwd: input.cwd, + operation: "setReaction", + cause: own.failure, + }); + } + if (own.success === null) return; + yield* api({ + cwd: input.cwd, + path: `${subject}/${own.success}`, + method: "DELETE", + }); + }), + + listCommits: (input) => + api({ + cwd: input.cwd, + path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/commits?${query( + [ + ["per_page", String(COMMIT_PAGE_SIZE)], + ["with_stats", "true"], + ], + )}`, + }).pipe( + Effect.flatMap((result) => { + const decoded = decodeCommitsJson(result.stdout.trim()); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail( + new GitLabMergeRequestReadError({ + command: "glab", + cwd: input.cwd, + operation: "listCommits", + cause: decoded.failure, + }), + ); + }), + ), + + getMergeRequestDiff: (input) => { + if (input.commit !== undefined && !isCommitSha(input.commit)) { + return Effect.fail(new GitLabDiffCommitError({ command: "glab", cwd: input.cwd })); + } + const target = { + cwd: input.cwd, + repository: input.repository, + number: input.number, + ...(input.commit === undefined ? {} : { commit: input.commit }), + }; + if (input.cursor === undefined) { + return diffPage({ ...target, page: 1 }); + } + const page = diffCursorPage(input.cursor); + return page === null + ? Effect.fail(new GitLabDiffCursorError({ command: "glab", cwd: input.cwd })) + : diffPage({ ...target, page }); + }, + + getMergeRequestDiffFileContents: (input) => + Effect.gen(function* () { + if (input.commit !== undefined && !isCommitSha(input.commit)) { + return yield* Effect.fail(new GitLabDiffCommitError({ command: "glab", cwd: input.cwd })); + } + const refs = yield* input.commit === undefined + ? getDiffRefs(input) + : getCommitDiffRefs({ + cwd: input.cwd, + repository: input.repository, + commit: input.commit, + allowRoot: input.changeType === "new", + }); + + const readFile = (revision: string, filePath: string) => + api({ + cwd: input.cwd, + path: `projects/${projectPath(input.repository)}/repository/files/${encodeURIComponent( + filePath, + )}/raw?ref=${encodeURIComponent(revision)}`, + maxOutputBytes: DIFF_FILE_MAX_OUTPUT_BYTES, + timeoutMs: DIFF_TIMEOUT_MS, + }).pipe( + Effect.flatMap((result) => + result.stdoutTruncated || + result.stdout.includes("\0") || + result.stdoutInvalidUtf8 === true + ? Effect.fail( + new GitLabDiffFileContentsUnavailableError({ + command: "glab", + cwd: input.cwd, + path: filePath, + reason: result.stdoutTruncated ? "oversized" : "binary", + }), + ) + : Effect.succeed(result.stdout), + ), + ); + + const [oldContents, newContents] = yield* Effect.all( + [ + input.changeType === "new" ? Effect.succeed("") : readFile(refs.baseSha, input.oldPath), + input.changeType === "deleted" + ? Effect.succeed("") + : readFile(refs.headSha, input.newPath), + ], + { concurrency: 2 }, + ); + return { oldContents, newContents }; + }), + + getProjectMergeCapabilities: (input) => + api({ + cwd: input.cwd, + path: `projects/${projectPath(input.repository)}?license=false`, + }).pipe( + Effect.flatMap((result) => { + const decoded = decodeProjectMergeCapabilitiesJson(result.stdout.trim()); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail( + new GitLabMergeRequestReadError({ + command: "glab", + cwd: input.cwd, + operation: "getProjectMergeCapabilities", + cause: decoded.failure, + }), + ); + }), + ), + + listReviewerCandidates: (input) => + Effect.all([mergeRequestDetail(input), projectUsers(input)], { concurrency: 2 }).pipe( + Effect.map(([mergeRequest, users]) => { + const author = mergeRequest.author?.login; + const requested = new Set(mergeRequest.reviewRequestLogins); + return { + // The author is dropped rather than shown unusable: GitLab refuses to make the person + // who opened a merge request its reviewer. + candidates: users.candidates.flatMap((candidate) => + candidate.login === author + ? [] + : [{ ...candidate, isRequested: requested.has(candidate.login) }], + ), + truncated: users.rawCount >= MAX_PAGE_SIZE, + }; + }), + ), + + setReviewerRequest: (input) => + mergeRequestDetail(input).pipe( + Effect.flatMap((mergeRequest) => { + // GitLab has no endpoint that adds or removes one reviewer: `reviewer_ids` replaces the + // whole set, so the set that is already there is read first and the change applied to + // it. Asking again for somebody already on it writes the same set back, which is how + // GitLab re-requests a review. + const ids = new Set(mergeRequest.reviewerIds); + for (const reviewer of input.reviewers) { + const id = Number(reviewer.id); + // A candidate GitLab did not name is not an id it would accept, and sending it would + // rewrite the reviewer set around a number nobody chose. + if (!Number.isSafeInteger(id) || id <= 0) continue; + if (input.requested) ids.add(id); + else ids.delete(id); + } + return api({ + cwd: input.cwd, + path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}`, + method: "PUT", + stdin: JSON.stringify({ reviewer_ids: [...ids] }), + }); + }), + Effect.asVoid, + ), + + runMergeRequestAction: (input) => { + // `glab mr merge` arms auto-merge and never disarms it, so the one direction the CLI has + // no flag for is asked of GitLab directly through the same `api` passthrough the rest of + // this module writes with. + if (input.action === "disable-auto-merge") { + return api({ + cwd: input.cwd, + path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/cancel_merge_when_pipeline_succeeds`, + method: "POST", + }).pipe(Effect.asVoid); + } + const [subcommand, ...flags] = actionArgs(input.action, input.mergeMethod); + return gitlab + .execute({ + cwd: input.cwd, + args: ["mr", subcommand!, String(input.number), "--repo", input.repository, ...flags], + }) + .pipe(Effect.asVoid); + }, + + updateMergeRequest: (input) => + api({ + cwd: input.cwd, + path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}`, + method: "PUT", + // Only the fields the caller asked to change: GitLab leaves out what it is not sent, and + // clears what it is sent empty — so a title corrected on its own must carry no + // description at all. + stdin: JSON.stringify({ + ...(input.title === undefined ? {} : { title: input.title }), + ...(input.description === undefined ? {} : { description: input.description }), + }), + }).pipe(Effect.asVoid), + + commentOnMergeRequest: (input) => + api({ + cwd: input.cwd, + path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/notes`, + method: "POST", + // A JSON body rather than a `--raw-field`: glab coerces a field that reads as a + // literal `true` or a number, and a comment body is text either way. + stdin: JSON.stringify({ body: input.body }), + }).pipe(Effect.asVoid), + + updateNote: (input) => + api({ + cwd: input.cwd, + path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/notes/${encodeURIComponent( + input.noteId, + )}`, + method: "PUT", + stdin: JSON.stringify({ body: input.body }), + }).pipe(Effect.asVoid), + + listDiscussions: (input) => discussionsPage({ ...input, page: 1, collected: [] }), + + submitReview: (input) => + Effect.gen(function* () { + const project = projectPath(input.repository); + const mergeRequest = `projects/${project}/merge_requests/${input.number}`; + // GitLab has no pending review to attach comments to, so a review is replayed as the + // requests it is made of: the line comments, then the summary, then the verdict. A + // failure part-way therefore leaves what was already posted in place, which is why + // the verdict goes last — a half-sent review is never an approval. + if (input.comments.length > 0) { + const refs = yield* getDiffRefs(input); + yield* Effect.forEach( + input.comments, + (comment) => + api({ + cwd: input.cwd, + path: `${mergeRequest}/discussions`, + method: "POST", + stdin: JSON.stringify({ + body: comment.body, + position: { + base_sha: refs.baseSha, + head_sha: refs.headSha, + start_sha: refs.startSha, + position_type: "text", + // Both paths are sent because GitLab resolves a position against both + // sides of the diff. They differ only for a renamed file, which is why the + // draft carries the name the file had before the change. + old_path: comment.oldPath ?? comment.path, + new_path: comment.path, + ...(comment.side === "left" + ? { old_line: comment.line } + : { new_line: comment.line }), + }, + }), + }), + { discard: true }, + ); + } + if (input.body.trim().length > 0) { + yield* api({ + cwd: input.cwd, + path: `${mergeRequest}/notes`, + method: "POST", + // A JSON body rather than a `--raw-field`, for the reason the plain comment gives: + // glab coerces a field that reads as a literal `true` or a number. + // @effect-diagnostics-next-line preferSchemaOverJson:off + stdin: JSON.stringify({ body: input.body }), + }); + } + if (input.verdict === "approve") { + yield* api({ cwd: input.cwd, path: `${mergeRequest}/approve`, method: "POST" }); + } + }), + + replyToDiscussion: (input) => + api({ + cwd: input.cwd, + path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/discussions/${encodeURIComponent( + input.discussionId, + )}/notes`, + method: "POST", + stdin: JSON.stringify({ body: input.body }), + }).pipe(Effect.asVoid), + + setDiscussionResolution: (input) => + api({ + cwd: input.cwd, + path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/discussions/${encodeURIComponent( + input.discussionId, + )}`, + method: "PUT", + stdin: JSON.stringify({ resolved: input.resolved }), + }).pipe(Effect.asVoid), + }); +}); + +export const layer = Layer.effect(GitLabPullRequestCli, make); diff --git a/apps/server/src/pullRequest/GitLabPullRequestProvider.test.ts b/apps/server/src/pullRequest/GitLabPullRequestProvider.test.ts new file mode 100644 index 000000000000..5d36d58dfc2b --- /dev/null +++ b/apps/server/src/pullRequest/GitLabPullRequestProvider.test.ts @@ -0,0 +1,197 @@ +import { assert, describe, expect, it, vi } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts"; +import { gitLabViewerPermissions, make } from "./GitLabPullRequestProvider.ts"; + +describe("gitLabViewerPermissions", () => { + it("offers everything to a viewer GitLab says can merge", () => { + expect(gitLabViewerPermissions({ viewerCanMerge: true })).toEqual({ + // Arming a merge for later and taking the arming back answer to the same `can_merge`. + actions: [ + "merge", + "ready", + "draft", + "close", + "reopen", + "update-branch", + "enable-auto-merge", + "disable-auto-merge", + ], + comment: true, + resolve: true, + verdicts: ["comment", "approve"], + // GitLab says nothing about who may set a reviewer, and an unreported permission is granted. + requestReviewers: true, + // Rebase and nothing else: GitLab cannot merge a target branch into a source branch, so + // offering the choice would be offering something no request could carry out. + updateMethods: ["rebase"], + }); + }); + + it("keeps merge, now and later, from a viewer GitLab says cannot", () => { + // `user.can_merge` already accounts for the role, the approval rules and a protected target + // branch, so it is the one answer here that does not have to be inferred. + expect(gitLabViewerPermissions({ viewerCanMerge: false })).toEqual({ + actions: ["ready", "draft", "close", "reopen"], + comment: true, + resolve: true, + verdicts: ["comment", "approve"], + requestReviewers: true, + }); + }); + + it("names no way of updating a branch it will not let this viewer update", () => { + // The action and the strategy behind it go together: a button offered with nothing to press + // it with, or a strategy left standing next to a withheld button, is a half-refusal. + expect(gitLabViewerPermissions({ viewerCanMerge: false }).updateMethods).toBeUndefined(); + }); + + it("treats an author with read access as any other reader, which is all GitLab says", () => { + // Its REST API names no relationship between the viewer and the merge request beyond + // `can_merge`, so the four an author keeps stay offered to everyone rather than being taken + // from the one person entitled to them. + expect(gitLabViewerPermissions({ viewerCanMerge: false }).actions).toEqual([ + "ready", + "draft", + "close", + "reopen", + ]); + }); +}); + +describe("getChangeRequest base freshness", () => { + const detail = { + number: 7, + title: "Merge request 7", + url: "https://gitlab.com/acme/web/-/merge_requests/7", + author: null, + headBranch: "feat/page", + baseBranch: "main", + state: "open" as const, + isDraft: false, + mergeability: "mergeable" as const, + additions: 0, + deletions: 0, + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-02T00:00:00Z", + reviewRequestLogins: [], + labels: [], + body: "", + changedFiles: 1, + mergedAt: null, + closedAt: null, + reviewers: [], + checks: [], + viewerCanMerge: true, + reviewerIds: [], + }; + + const readWith = (divergence: { readonly divergedCommits?: number }) => + Effect.gen(function* () { + const provider = yield* make; + return yield* provider.getChangeRequest({ + cwd: "/w", + repository: "acme/web", + host: "gitlab.com", + number: 7, + }); + }).pipe( + Effect.provide( + Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({ + getMergeRequestDetail: () => Effect.succeed({ ...detail, ...divergence }), + getProjectMergeCapabilities: () => + Effect.succeed({ merge: true, squash: true, rebase: true }), + }), + ), + ); + + it.effect("reads a counted divergence as a branch that has fallen behind", () => + Effect.gen(function* () { + const changeRequest = yield* readWith({ divergedCommits: 3 }); + + expect(changeRequest.baseComparison).toBe("behind"); + expect(changeRequest.behindBy).toBe(3); + }), + ); + + it.effect("reads a divergence of none as a branch that is current", () => + Effect.gen(function* () { + const changeRequest = yield* readWith({ divergedCommits: 0 }); + + expect(changeRequest.baseComparison).toBe("up-to-date"); + expect(changeRequest.behindBy).toBe(0); + }), + ); + + it.effect("says nothing at all where GitLab counted nothing", () => + Effect.gen(function* () { + // An install too old to answer has to leave the page silent rather than let it claim the + // branch is current, which is the one wrong thing this banner could say. + const changeRequest = yield* readWith({}); + + expect(changeRequest.baseComparison).toBe("unknown"); + expect(changeRequest.behindBy).toBeUndefined(); + }), + ); +}); + +describe("rewriting what has already been said", () => { + const updateMergeRequest = vi.fn(() => Effect.void); + const updateNote = vi.fn(() => Effect.void); + + const providerWith = make.pipe( + Effect.provide( + Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({ updateMergeRequest, updateNote }), + ), + ); + + it.effect("sends only the half of the merge request the reader rewrote", () => + Effect.gen(function* () { + const provider = yield* providerWith; + assert.isDefined(provider.updateChangeRequest); + + yield* provider.updateChangeRequest({ + cwd: "/w", + repository: "acme/web", + host: "gitlab.com", + number: 7, + body: "What this changes.", + }); + + // GitLab calls it the description, and the title stays out of the request entirely. + expect(updateMergeRequest).toHaveBeenCalledWith({ + cwd: "/w", + repository: "acme/web", + number: 7, + description: "What this changes.", + }); + }), + ); + + it.effect("rewrites a positioned comment through the same note as any other", () => + Effect.gen(function* () { + const provider = yield* providerWith; + assert.isDefined(provider.updateComment); + + yield* provider.updateComment({ + cwd: "/w", + repository: "acme/web", + host: "gitlab.com", + number: 7, + commentId: "42", + kind: "review-comment", + body: "Reworded.", + }); + + expect(updateNote).toHaveBeenCalledWith({ + cwd: "/w", + repository: "acme/web", + number: 7, + noteId: "42", + body: "Reworded.", + }); + }), + ); +}); diff --git a/apps/server/src/pullRequest/GitLabPullRequestProvider.ts b/apps/server/src/pullRequest/GitLabPullRequestProvider.ts new file mode 100644 index 000000000000..07edf08e582f --- /dev/null +++ b/apps/server/src/pullRequest/GitLabPullRequestProvider.ts @@ -0,0 +1,320 @@ +import * as Effect from "effect/Effect"; +import type { + PullRequestCapabilities, + PullRequestReaction, + PullRequestViewerPermissions, +} from "@t3tools/contracts"; + +import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts"; +import { + PullRequestProviderError, + type ProviderChangeRequestActivity, + type ProviderChangeRequestDetail, + type PullRequestProviderApi, +} from "./PullRequestProvider.ts"; + +const CAPABILITIES: PullRequestCapabilities = { + diff: true, + comment: true, + actions: [ + "merge", + "ready", + "draft", + "close", + "reopen", + "update-branch", + "enable-auto-merge", + "disable-auto-merge", + ], + // GitLab offers all three, though a project settles on one; `mergeCapabilities` narrows it. + mergeMethods: ["merge", "squash", "rebase"], + // Rebase alone: GitLab moves a stale branch onto its target by replaying it, and has nothing + // that merges the target back in the way GitHub's update button can. Declaring only what it + // does is what lets a request to merge the target in be refused instead of quietly rebasing. + updateMethods: ["rebase"], + search: true, + reactions: true, + review: { + inlineComment: true, + reply: true, + resolve: true, + // No "changes requested": GitLab has approval and unresolved discussions, and nothing that + // says a merge request has been reviewed and rejected. + verdicts: ["comment", "approve"], + }, + reviewers: { request: true, listCandidates: true }, + edit: { changeRequest: true, comment: true }, +}; + +/** + * The actions `user.can_merge` answers for. Rebasing writes to the source branch rather than to + * the target, so it is not literally the same permission — but GitLab reports nothing narrower, + * and someone it will not let land this change has no business rewriting its branch either. + */ +const MERGE_ACTIONS: ReadonlySet = new Set([ + "merge", + "update-branch", + "enable-auto-merge", + "disable-auto-merge", +]); + +/** + * What the signed-in account may do here. GitLab answers exactly one of these questions per + * viewer, on the merge request itself: `user.can_merge`, which is why merging is the only thing + * narrowed. + * + * The rest stay granted. GitLab's REST API reports the viewer's role on the project but never + * whether they opened this merge request — and its author may close it, reopen it and move it in + * and out of draft whatever their role, just as the author of a note may resolve the discussion + * it started. Withholding those controls from the one person entitled to them is the worse of the + * two mistakes, so they are offered and GitLab explains any refusal itself. + * + * Asking for a review is granted for the same reason: GitLab takes a reviewer set from the author + * and from anyone with the Developer role, and states neither of those two facts here. + */ +export function gitLabViewerPermissions(input: { + readonly viewerCanMerge: boolean; +}): PullRequestViewerPermissions { + return { + // Arming the merge and taking the arming back are the merge, deferred, so they answer to + // the same `can_merge` the merge itself does. + actions: CAPABILITIES.actions.filter( + (action) => !MERGE_ACTIONS.has(action) || input.viewerCanMerge, + ), + comment: true, + resolve: true, + verdicts: CAPABILITIES.review.verdicts, + requestReviewers: true, + ...(input.viewerCanMerge ? { updateMethods: CAPABILITIES.updateMethods } : {}), + }; +} + +/** The CLI tags that mean the tool itself is unusable, rather than one request failing. */ +function reasonFor( + error: GitLabPullRequestCli.GitLabPullRequestCliError, +): PullRequestProviderError["reason"] { + if (error._tag === "GitLabCliUnavailableError") return "missing-tool"; + if (error._tag === "GitLabCliAuthenticationError") return "unauthenticated"; + return "failed"; +} + +export const make = Effect.gen(function* () { + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const fail = (operation: string) => (error: GitLabPullRequestCli.GitLabPullRequestCliError) => + new PullRequestProviderError({ + provider: "gitlab", + operation, + reason: reasonFor(error), + detail: error.detail, + cause: error, + }); + + const provider: PullRequestProviderApi = { + kind: "gitlab", + capabilities: CAPABILITIES, + + getViewer: (input) => + cli.getViewerUsername({ cwd: input.cwd }).pipe(Effect.mapError(fail("getViewer"))), + + listChangeRequests: (input) => + cli + .listMergeRequests({ + cwd: input.cwd, + repository: input.repository, + state: input.state, + involvement: input.involvement, + viewer: input.viewer, + limit: input.limit, + query: input.query, + cursor: input.cursor, + }) + .pipe( + Effect.mapError(fail("listChangeRequests")), + // GitLab is asked for its merge requests by update, newest first, whether or not it is + // being carried on from — so every page it answers is one a cursor can continue. + Effect.map((batch) => ({ ...batch, continues: true })), + ), + + getChangeRequest: (input) => + Effect.all( + [ + cli.getMergeRequestDetail(input), + cli.getProjectMergeCapabilities({ cwd: input.cwd, repository: input.repository }), + ], + { concurrency: 2 }, + ).pipe( + Effect.mapError(fail("getChangeRequest")), + Effect.map( + ([mergeRequest, mergeCapabilities]): ProviderChangeRequestDetail => ({ + ...mergeRequest, + mergeCapabilities, + viewerPermissions: gitLabViewerPermissions(mergeRequest), + // A GitLab too old to count the divergence says nothing here rather than "up to + // date": the banner is worth missing, and a wrong all-clear is not worth showing. + baseComparison: + mergeRequest.divergedCommits === undefined + ? "unknown" + : mergeRequest.divergedCommits > 0 + ? "behind" + : "up-to-date", + ...(mergeRequest.divergedCommits === undefined + ? {} + : { behindBy: mergeRequest.divergedCommits }), + }), + ), + ), + + getChangeRequestActivity: (input) => + Effect.all( + [ + cli + .listNotes(input) + .pipe(Effect.orElseSucceed(() => ({ comments: [], truncated: true }))), + cli.listCommits(input).pipe(Effect.orElseSucceed(() => [])), + cli + .listDiscussions(input) + .pipe(Effect.orElseSucceed(() => ({ threads: [], truncated: true }))), + // The notes endpoint carries no award of any kind, so they are read alongside it. A + // failed read costs the conversation its reactions rather than its words. + cli.listReactions(input).pipe( + Effect.orElseSucceed(() => ({ + reactions: [] as ReadonlyArray, + reactionsByNoteId: new Map>(), + })), + ), + ], + { concurrency: 4 }, + ).pipe( + Effect.mapError(fail("getChangeRequestActivity")), + Effect.map( + ([notes, commits, discussions, awards]): ProviderChangeRequestActivity => ({ + reactions: awards.reactions, + comments: notes.comments.map((comment) => ({ + ...comment, + reactions: awards.reactionsByNoteId.get(comment.id) ?? [], + })), + // GitLab reports no count of its own, so the walk's own total is the host's: the + // notes endpoint carries every comment on the merge request, including the ones + // written under a discussion, and it is read until GitLab runs out. + commentCount: notes.comments.length, + commentsTruncated: notes.truncated || discussions.truncated, + reviewThreads: discussions.threads.map((thread) => ({ + ...thread, + comments: thread.comments.map((comment) => ({ + ...comment, + reactions: awards.reactionsByNoteId.get(comment.id) ?? [], + })), + })), + commits, + }), + ), + ), + + // The same read the detail takes it from, on its own: `user.can_merge` lives on the merge + // request, so there is no cheaper thing to ask GitLab. + getViewerPermissions: (input) => + cli + .getMergeRequestDetail(input) + .pipe(Effect.mapError(fail("getViewerPermissions")), Effect.map(gitLabViewerPermissions)), + + getDiff: (input) => cli.getMergeRequestDiff(input).pipe(Effect.mapError(fail("getDiff"))), + + // Users only: GitLab requests a review of a person, and the groups that can stand in for one + // appear in approval rules rather than in a merge request's reviewers. + listReviewerCandidates: (input) => + cli + .listReviewerCandidates({ + cwd: input.cwd, + repository: input.repository, + number: input.number, + }) + .pipe(Effect.mapError(fail("listReviewerCandidates"))), + + setReviewerRequest: (input) => + cli + .setReviewerRequest({ + cwd: input.cwd, + repository: input.repository, + number: input.number, + reviewers: input.reviewers, + requested: input.requested, + }) + .pipe(Effect.mapError(fail("setReviewerRequest"))), + + runAction: (input) => + cli + .runMergeRequestAction({ + cwd: input.cwd, + repository: input.repository, + number: input.number, + action: input.action, + ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }), + }) + .pipe(Effect.mapError(fail("runAction"))), + + updateChangeRequest: (input) => + cli + .updateMergeRequest({ + cwd: input.cwd, + repository: input.repository, + number: input.number, + ...(input.title === undefined ? {} : { title: input.title }), + ...(input.body === undefined ? {} : { description: input.body }), + }) + .pipe(Effect.mapError(fail("updateChangeRequest"))), + + comment: (input) => cli.commentOnMergeRequest(input).pipe(Effect.mapError(fail("comment"))), + + // The kind is not read: every comment this provider hands out, positioned or not, carries a + // plain REST note id, and one endpoint rewrites both. + updateComment: (input) => + cli + .updateNote({ + cwd: input.cwd, + repository: input.repository, + number: input.number, + noteId: input.commentId, + body: input.body, + }) + .pipe(Effect.mapError(fail("updateComment"))), + + submitReview: (input) => cli.submitReview(input).pipe(Effect.mapError(fail("submitReview"))), + + replyToThread: (input) => + cli + .replyToDiscussion({ + cwd: input.cwd, + repository: input.repository, + number: input.number, + discussionId: input.threadId, + body: input.body, + }) + .pipe(Effect.mapError(fail("replyToThread"))), + + setReaction: (input) => + cli + .setReaction({ + cwd: input.cwd, + repository: input.repository, + number: input.number, + ...(input.subjectId === undefined ? {} : { noteId: input.subjectId }), + content: input.content, + reacted: input.reacted, + }) + .pipe(Effect.mapError(fail("setReaction"))), + + setThreadResolution: (input) => + cli + .setDiscussionResolution({ + cwd: input.cwd, + repository: input.repository, + number: input.number, + discussionId: input.threadId, + resolved: input.resolved, + }) + .pipe(Effect.mapError(fail("setThreadResolution"))), + }; + + return provider; +}); diff --git a/apps/server/src/pullRequest/PullRequestProvider.ts b/apps/server/src/pullRequest/PullRequestProvider.ts new file mode 100644 index 000000000000..6356d593b957 --- /dev/null +++ b/apps/server/src/pullRequest/PullRequestProvider.ts @@ -0,0 +1,464 @@ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import type { + PullRequestAction, + PullRequestActor, + PullRequestBaseComparison, + PullRequestCapabilities, + PullRequestChecksState, + PullRequestCheck, + PullRequestComment, + PullRequestCommit, + PullRequestInvolvement, + PullRequestLabel, + PullRequestListFilters, + PullRequestListState, + PullRequestMergeCapabilities, + PullRequestMergeMethod, + PullRequestMergeability, + PullRequestOmittedFileStat, + PullRequestReaction, + PullRequestReactionContent, + PullRequestReviewCommentDraft, + PullRequestReviewDecision, + PullRequestReviewThread, + PullRequestReviewVerdict, + PullRequestReviewerCandidateList, + PullRequestReviewerKind, + PullRequestState, + PullRequestUpdateMethod, + PullRequestViewerPermissions, + SourceControlProviderKind, +} from "@t3tools/contracts"; +import { SourceControlProviderKind as SourceControlProviderKindSchema } from "@t3tools/contracts"; + +/** + * The one failure shape every provider reports, so the service can decide what a failure means + * without knowing which CLI or API produced it. + * + * `reason` is the part the service acts on: a missing or unauthenticated tool disables the + * provider for the whole workspace, while anything else is specific to the request. + */ +export class PullRequestProviderError extends Schema.TaggedErrorClass()( + "PullRequestProviderError", + { + provider: SourceControlProviderKindSchema, + operation: Schema.String, + reason: Schema.Literals(["missing-tool", "unauthenticated", "failed"]), + detail: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `${this.provider} failed in ${this.operation}: ${this.detail}`; + } +} + +/** A change request as the provider sees it, before the service attaches project context. */ +export interface ProviderChangeRequest { + readonly number: number; + readonly title: string; + readonly url: string; + readonly author: PullRequestActor | null; + readonly headBranch: string; + readonly baseBranch: string; + readonly state: PullRequestState; + readonly isDraft: boolean; + readonly mergeability: PullRequestMergeability; + readonly additions: number; + readonly deletions: number; + readonly createdAt: string; + readonly updatedAt: string; + /** Accounts with a review requested. Team-level requests are excluded by each provider. */ + readonly reviewRequestLogins: ReadonlyArray; + readonly labels: ReadonlyArray; + /** Absent from a host that does not summarise its reviews, which is every host but GitHub. */ + readonly reviewDecision?: PullRequestReviewDecision | null | undefined; + /** Absent from a host that reports no check rollup on its listings. */ + readonly checksState?: PullRequestChecksState | null | undefined; +} + +export interface ProviderChangeRequestPage { + readonly items: ReadonlyArray; + /** True when the host has more rows than the page size asked for. */ + readonly truncated: boolean; + /** + * Optional count-based cursor advance. Most hosts advance by the rows delivered after local + * de-duplication; an offset-paged host may need to count malformed raw rows it consumed too. + */ + readonly cursorAdvance?: number; + /** + * This page can be carried on from, so the service may hand the caller a cursor for it. False + * where the host answered in an order a cursor means nothing in, which leaves a larger `limit` + * as the only way to the rest — what every listing did before there were cursors. + */ + readonly continues: boolean; +} + +/** + * Where a repository's next slice starts, as the provider that has to ask for it needs it. Built + * by the service out of the slice it just handed over, so the boundary that decides whether a row + * arrives twice or not at all is decided in one place rather than in four. + */ +export interface ProviderListCursor { + /** + * The instant of the oldest row already handed over, checked against a timestamp's shape before + * it gets here because it goes into a host's own filter. Asked for inclusively: several rows + * share one instant often enough — a bot that touches eight change requests writes one timestamp + * on all eight — and asking for strictly older would lose whichever of them the slice ended + * before. The service drops the ones it has already sent. + */ + readonly updatedBefore: string; + /** + * How many provider rows this repository has consumed so far, for a host that carries on by + * counting rather than by date. Usually this is the number handed over; malformed raw rows may + * count too when the provider reports a `cursorAdvance`. + */ + readonly delivered: number; +} + +/** One repository's row inside an answer that spans several of them. */ +export interface ProviderBatchedChangeRequest extends ProviderChangeRequest { + /** Provider-native identity, exactly as it was asked for, so the caller can file the row. */ + readonly repository: string; +} + +/** + * One slice of a host read across several repositories at once, newest update first across all + * of them. There is no per-repository page here because the host was asked one question: the + * caller splits the rows by `repository` and works out where each of them carries on from the + * oldest row in the slice, which every repository the slice covers is now read up to. + */ +export interface ProviderBatchedChangeRequestPage { + readonly items: ReadonlyArray; + /** True when the host has more rows than the slice asked for, for any of the repositories. */ + readonly truncated: boolean; +} + +/** The line counts for one change request, which a listing may leave for a second read. */ +export interface ProviderChangeRequestStat { + readonly repository: string; + readonly number: number; + readonly additions: number; + readonly deletions: number; +} + +export interface ProviderChangeRequestDetail extends ProviderChangeRequest { + readonly body: string; + readonly changedFiles: number; + readonly mergedAt: string | null; + readonly closedAt: string | null; + readonly reviewers: ReadonlyArray; + readonly checks: ReadonlyArray; + readonly mergeCapabilities: PullRequestMergeCapabilities; + readonly viewerPermissions: PullRequestViewerPermissions; + /** Absent from a host that cannot compare the branch with its base, which is most of them. */ + readonly baseComparison?: PullRequestBaseComparison; + readonly behindBy?: number; + /** Absent from a host that does not report whether it is armed to merge this on its own. */ + readonly autoMergeEnabled?: boolean; +} + +/** The conversation-shaped half of a detail, loaded after the core can already render. */ +export interface ProviderChangeRequestActivity { + /** An optional richer actor, e.g. after GitHub's GraphQL read supplies an avatar. */ + readonly author?: PullRequestActor | null; + /** Optional because most hosts already report their reviewer list in the core detail. */ + readonly reviewers?: ReadonlyArray; + readonly comments: ReadonlyArray; + /** + * The host's own count of the conversation, which a bounded read can fall short of. A host + * that reports no count of its own answers with what it handed over, which is the same number + * once the read went to the end. + */ + readonly commentCount: number; + readonly commentsTruncated: boolean; + readonly reviewThreads: ReadonlyArray; + readonly commits: ReadonlyArray; + /** The change request's own reactions, from a host that has them. */ + readonly reactions?: ReadonlyArray; +} + +export interface ProviderDiffSlice { + readonly patch: string; + /** Something in this slice could not be shown, as opposed to there being more slices. */ + readonly truncated: boolean; + readonly nextCursor: string | null; + /** The host's own counts for the files whose hunks it withheld from this slice. */ + readonly omittedFileStats?: ReadonlyArray; +} + +export interface ProviderDiffFileContents { + readonly oldContents: string; + readonly newContents: string; +} + +export interface ProviderRepositoryRef { + readonly cwd: string; + /** Provider-native repository identity, e.g. `owner/repo` or `group/subgroup/project`. */ + readonly repository: string; + /** + * The host it lives on, which `repository` deliberately leaves out — the same `owner/repo` + * exists on github.com and on a GitHub Enterprise install, and only the caller knows which + * one a project's remote points at. + */ + readonly host: string; +} + +/** + * One host's change requests. Implementations own their own tool and JSON shapes and hand back + * the neutral types above; anything a host cannot do is declared in `capabilities` rather than + * failing at call time. + */ +export interface PullRequestProviderApi { + readonly kind: SourceControlProviderKind; + readonly capabilities: PullRequestCapabilities; + + /** The signed-in account, which is what involvement filtering compares against. */ + readonly getViewer: (input: { + readonly cwd: string; + }) => Effect.Effect; + + readonly listChangeRequests: ( + input: ProviderRepositoryRef & { + readonly state: PullRequestListState; + readonly involvement: PullRequestInvolvement; + readonly viewer: string; + readonly limit: number; + /** + * Free text to narrow the listing by, as the host understands it. A host with no text + * filter of its own ignores it and answers with the page it would have answered with + * anyway — the caller narrows what it gets, so an unfiltered page is a wider answer + * rather than a wrong one. + */ + readonly query?: string | undefined; + /** + * Where to carry on from, rather than reading this repository from its newest row. Absent + * asks for the first slice, which is every listing that has not been continued. + */ + readonly cursor?: ProviderListCursor | undefined; + /** + * Further narrowings, which a host applies as far as it can and ignores the rest of — + * an unnarrowed page is a wider answer rather than a wrong one, and the caller narrows + * what it gets for the fields a row carries. + */ + readonly filters?: PullRequestListFilters | undefined; + }, + ) => Effect.Effect; + + /** + * The same listing for a whole host in one request, for a host that has a search across + * repositories. Optional: three of the four hosts here have no such API, and breaking the port + * for them to spare GitHub a fan-out would be paying for the fix with everyone else's clarity. + * The caller falls back to `listChangeRequests` per repository where this is absent, and where + * it fails. + * + * `limit` is the whole slice rather than a size per repository, because that is the shape of + * the answer: the newest `limit` rows across every repository named, which is exactly the rows + * a page ordered by update shows. + * + * `cursor` is one boundary for all of them, so a caller with repositories standing at different + * boundaries asks in groups rather than in one call. + */ + readonly listChangeRequestsAcross?: (input: { + /** Any checkout on the host, which is what the tool is run in. */ + readonly cwd: string; + readonly host: string; + readonly repositories: ReadonlyArray; + readonly state: PullRequestListState; + readonly involvement: PullRequestInvolvement; + readonly viewer: string; + readonly limit: number; + readonly query?: string | undefined; + readonly cursor?: ProviderListCursor | undefined; + readonly filters?: PullRequestListFilters | undefined; + }) => Effect.Effect; + + /** + * The line counts for rows a listing has already handed over. Only implemented by a provider + * whose listing leaves them out — for everyone else the numbers arrived with the row, and the + * caller has nothing to ask for. + */ + readonly listChangeRequestStats?: (input: { + readonly cwd: string; + readonly host: string; + readonly changeRequests: ReadonlyArray<{ + readonly repository: string; + readonly number: number; + }>; + }) => Effect.Effect, PullRequestProviderError>; + + readonly getChangeRequest: ( + input: ProviderRepositoryRef & { readonly number: number }, + ) => Effect.Effect; + + /** Comments, line threads, and commits, kept off the critical path for the core detail. */ + readonly getChangeRequestActivity: ( + input: ProviderRepositoryRef & { readonly number: number }, + ) => Effect.Effect; + + /** + * The same answer `getChangeRequest` carries, on its own. Asked before anything is written, so + * a request that reached the server without going past the page is refused by what the host + * says rather than by what the client claimed — and asked freshly, because access granted or + * taken away since the page loaded is exactly the case this guards. + * + * Implementations read the cheapest thing that answers it, which for a host with nothing to say + * is no request at all. + */ + readonly getViewerPermissions: ( + input: ProviderRepositoryRef & { readonly number: number }, + ) => Effect.Effect; + + /** + * One slice of the patch. Only called when `capabilities.diff` is true. A provider that can + * serve the whole diff at once answers with `nextCursor: null` and is done; one that pages + * hands back whatever it needs to find the next slice. + */ + readonly getDiff: ( + input: ProviderRepositoryRef & { + readonly number: number; + readonly cursor?: string | undefined; + /** One commit's own changes, rather than everything the change request carries. */ + readonly commit?: string | undefined; + }, + ) => Effect.Effect; + + /** + * Full files at the exact revisions the host used for its patch. Optional where the provider + * exposes no diff at all; the service refuses expansion there just as it refuses the patch. + */ + readonly getDiffFileContents?: ( + input: ProviderRepositoryRef & { + readonly number: number; + readonly commit?: string | undefined; + readonly changeType: "change" | "rename-pure" | "rename-changed" | "new" | "deleted"; + readonly oldPath: string; + readonly newPath: string; + }, + ) => Effect.Effect; + + readonly runAction: ( + input: ProviderRepositoryRef & { + readonly number: number; + readonly action: PullRequestAction; + /** Meaningful for `merge` and `enable-auto-merge`; absent takes the host's own default. */ + readonly mergeMethod?: PullRequestMergeMethod; + /** Only meaningful for `update-branch`; absent takes the host's own default. */ + readonly updateMethod?: PullRequestUpdateMethod; + }, + ) => Effect.Effect; + + /** + * Rewrites the change request's own words. Only called when `capabilities.edit.changeRequest` + * is true, and never with both fields absent — the caller refuses that before it gets here, + * because a host asked to change nothing answers differently on each of them. + */ + readonly updateChangeRequest?: ( + input: ProviderRepositoryRef & { + readonly number: number; + readonly title?: string | undefined; + readonly body?: string | undefined; + }, + ) => Effect.Effect; + + readonly comment: ( + input: ProviderRepositoryRef & { readonly number: number; readonly body: string }, + ) => Effect.Effect; + + /** + * Rewrites a remark somebody already posted. Only called when `capabilities.edit.comment` is + * true, with an id exactly as the conversation carried it. + * + * Whether this remark is the reader's to rewrite is the host's own answer: no read here can + * settle it, since access can be taken away between the conversation being read and the + * rewrite being sent, and a host refuses a stranger's remark with a sentence saying so. + */ + readonly updateComment?: ( + input: ProviderRepositoryRef & { + readonly number: number; + readonly commentId: string; + readonly kind: "issue-comment" | "review-comment"; + readonly body: string; + }, + ) => Effect.Effect; + + /** + * Sends a whole review at once. Only called for a verdict the host declared in + * `capabilities.review.verdicts`, and with line comments only where it declared + * `inlineComment`. + */ + readonly submitReview: ( + input: ProviderRepositoryRef & { + readonly number: number; + readonly verdict: PullRequestReviewVerdict; + readonly body: string; + readonly comments: ReadonlyArray; + }, + ) => Effect.Effect; + + /** + * The people this viewer may ask for a review, with whoever has already been asked marked as + * such. Only called when `capabilities.reviewers.listCandidates` is true. + * + * The author is left out by each provider rather than by the caller, because only the provider + * knows how the host spells the same person in a candidate list and on a pull request. + */ + readonly listReviewerCandidates: ( + input: ProviderRepositoryRef & { readonly number: number }, + ) => Effect.Effect; + + /** + * Asks for a review, or takes the request back. Only called when + * `capabilities.reviewers.request` is true. + * + * One call for both directions, because that is what every host does with them: GitHub posts and + * deletes the same collection, and GitLab and Bitbucket write the whole reviewer set either way. + * Asking again somebody who has already reviewed is a request like any other — which is how a + * re-request is made. + */ + readonly setReviewerRequest: ( + input: ProviderRepositoryRef & { + readonly number: number; + readonly reviewers: ReadonlyArray<{ + readonly id: string; + readonly kind: PullRequestReviewerKind; + }>; + readonly requested: boolean; + }, + ) => Effect.Effect; + + /** Only called when `capabilities.review.reply` is true. */ + readonly replyToThread: ( + input: ProviderRepositoryRef & { + readonly number: number; + readonly threadId: string; + readonly body: string; + }, + ) => Effect.Effect; + + /** + * Adds a reaction, or takes it back. Only called when `capabilities.reactions` is true. + * + * `subjectId` is a remark's id as the conversation carried it; absent means the change request + * itself, whose reactions sit on its description. Whatever a host needs to address either of + * them is worked out here, because the id a conversation travels with is the one the reader has. + */ + readonly setReaction: ( + input: ProviderRepositoryRef & { + readonly number: number; + readonly subjectId?: string | undefined; + readonly content: PullRequestReactionContent; + readonly reacted: boolean; + }, + ) => Effect.Effect; + + /** Only called when `capabilities.review.resolve` is true. */ + readonly setThreadResolution: ( + input: ProviderRepositoryRef & { + readonly number: number; + readonly threadId: string; + readonly resolved: boolean; + }, + ) => Effect.Effect; +} diff --git a/apps/server/src/pullRequest/PullRequestProviderRegistry.ts b/apps/server/src/pullRequest/PullRequestProviderRegistry.ts new file mode 100644 index 000000000000..84a4ebef0579 --- /dev/null +++ b/apps/server/src/pullRequest/PullRequestProviderRegistry.ts @@ -0,0 +1,59 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import type { SourceControlProviderKind } from "@t3tools/contracts"; + +import * as AzureDevOpsCli from "../sourceControl/AzureDevOpsCli.ts"; +import * as BitbucketApi from "../sourceControl/BitbucketApi.ts"; +import * as GitHubCli from "../sourceControl/GitHubCli.ts"; +import * as GitLabCli from "../sourceControl/GitLabCli.ts"; +import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts"; +import * as AzureDevOpsPullRequestProvider from "./AzureDevOpsPullRequestProvider.ts"; +import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts"; +import * as BitbucketPullRequestProvider from "./BitbucketPullRequestProvider.ts"; +import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; +import * as GitHubPullRequestProvider from "./GitHubPullRequestProvider.ts"; +import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts"; +import * as GitLabPullRequestProvider from "./GitLabPullRequestProvider.ts"; +import type { PullRequestProviderApi } from "./PullRequestProvider.ts"; + +export class PullRequestProviderRegistry extends Context.Service< + PullRequestProviderRegistry, + { + /** Null for a host with no implementation, which the service reports as unsupported. */ + readonly get: (kind: SourceControlProviderKind) => PullRequestProviderApi | null; + readonly kinds: ReadonlyArray; + } +>()("t3/pullRequest/PullRequestProviderRegistry") {} + +/** Exported for tests, which stand a registry up from providers they supply themselves. */ +export function fromProviders( + providers: ReadonlyArray, +): PullRequestProviderRegistry["Service"] { + const byKind = new Map(providers.map((provider) => [provider.kind, provider])); + return { + get: (kind) => byKind.get(kind) ?? null, + kinds: providers.map((provider) => provider.kind), + }; +} + +/** + * The hosts this build can read change requests from. A host with no entry here still shows up + * in the provider list as unimplemented, so its projects are explained rather than missing. + */ +export const make = Effect.map( + Effect.all([ + GitHubPullRequestProvider.make, + GitLabPullRequestProvider.make, + BitbucketPullRequestProvider.make, + AzureDevOpsPullRequestProvider.make, + ]), + fromProviders, +); + +export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe( + Layer.provide(GitHubPullRequestCli.layer.pipe(Layer.provide(GitHubCli.layer))), + Layer.provide(GitLabPullRequestCli.layer.pipe(Layer.provide(GitLabCli.layer))), + Layer.provide(BitbucketPullRequestApi.layer.pipe(Layer.provide(BitbucketApi.layer))), + Layer.provide(AzureDevOpsPullRequestCli.layer.pipe(Layer.provide(AzureDevOpsCli.layer))), +); diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts new file mode 100644 index 000000000000..243cfe06c21d --- /dev/null +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -0,0 +1,3294 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import type { + OrchestrationProjectShell, + ProjectId, + PullRequestReviewCapabilities, + PullRequestReviewerCapabilities, + SourceControlProviderKind, +} from "@t3tools/contracts"; + +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; +import { + PullRequestProviderError, + type ProviderChangeRequest, + type PullRequestProviderApi, +} from "./PullRequestProvider.ts"; +import { PullRequestProviderRegistry, fromProviders } from "./PullRequestProviderRegistry.ts"; +import * as PullRequestService from "./PullRequestService.ts"; + +function project(input: { + readonly id: string; + readonly title: string; + readonly workspaceRoot: string; + readonly repository?: string; + readonly provider?: string; + readonly host?: string; +}): OrchestrationProjectShell { + // The host defaults from the provider, so a fixture only names one when the point of the + // test is two hosts of the same kind. + const host = input.host ?? (input.provider === "gitlab" ? "gitlab.com" : "github.com"); + return { + id: input.id as ProjectId, + title: input.title, + workspaceRoot: input.workspaceRoot, + ...(input.repository + ? { + repositoryIdentity: { + canonicalKey: `${host}/${input.repository}`, + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: `https://${host}/${input.repository}.git`, + }, + provider: input.provider ?? "github", + displayName: input.repository, + }, + } + : {}), + defaultModelSelection: null, + scripts: [], + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-01T00:00:00Z", + }; +} + +function changeRequest(number: number, updatedAt: string): ProviderChangeRequest { + return { + number, + title: `Change request ${number}`, + url: `https://host/pull/${number}`, + author: { login: "octocat", name: null, avatarUrl: null }, + headBranch: `feat/${number}`, + baseBranch: "main", + state: "open", + isDraft: false, + mergeability: "mergeable", + additions: 1, + deletions: 0, + createdAt: "2026-07-01T00:00:00Z", + updatedAt, + reviewRequestLogins: [], + labels: [], + }; +} + +function unusable(provider: SourceControlProviderKind, reason: "missing-tool" | "unauthenticated") { + return new PullRequestProviderError({ + provider, + operation: "getViewer", + reason, + detail: `${provider} is not usable.`, + }); +} + +const requestFailed = new PullRequestProviderError({ + provider: "github", + operation: "listChangeRequests", + reason: "failed", + detail: "HTTP 404", +}); + +/** Everything a host could offer, so a fixture only narrows what its own test is about. */ +const FULL_REVIEW: PullRequestReviewCapabilities = { + inlineComment: true, + reply: true, + resolve: true, + verdicts: ["comment", "approve", "request-changes"], +}; + +const FULL_REVIEWERS: PullRequestReviewerCapabilities = { request: true, listCandidates: true }; + +/** A provider whose every call is supplied by the test; anything unset succeeds emptily. */ +function fakeProvider( + kind: SourceControlProviderKind, + overrides: Partial = {}, +): PullRequestProviderApi { + return { + kind, + capabilities: { + diff: true, + comment: true, + actions: ["merge", "ready", "draft", "close", "reopen"], + mergeMethods: ["merge"], + search: true, + reactions: true, + review: FULL_REVIEW, + reviewers: FULL_REVIEWERS, + edit: { changeRequest: true, comment: true }, + }, + getViewer: () => Effect.succeed("bilal"), + // A viewer who may do everything the host can, so a test only narrows what it is about. + getViewerPermissions: () => + Effect.succeed({ + actions: ["merge", "ready", "draft", "close", "reopen"], + comment: true, + resolve: true, + verdicts: ["comment", "approve", "request-changes"], + requestReviewers: true, + }), + listChangeRequests: () => Effect.succeed({ items: [], truncated: false, continues: true }), + getChangeRequest: () => Effect.die("unused"), + getChangeRequestActivity: () => Effect.die("unused"), + getDiff: () => Effect.die("unused"), + runAction: () => Effect.void, + updateChangeRequest: () => Effect.void, + comment: () => Effect.void, + updateComment: () => Effect.void, + submitReview: () => Effect.void, + replyToThread: () => Effect.void, + setThreadResolution: () => Effect.void, + setReaction: () => Effect.void, + listReviewerCandidates: () => Effect.succeed({ candidates: [], truncated: false }), + setReviewerRequest: () => Effect.void, + ...overrides, + }; +} + +function makeService(input: { + readonly projects: ReadonlyArray; + readonly providers: ReadonlyArray; + readonly resolveHandle?: SourceControlProviderRegistry.SourceControlProviderRegistry["Service"]["resolveHandle"]; +}) { + return PullRequestService.make.pipe( + Effect.provide( + Layer.mergeAll( + Layer.succeed(PullRequestProviderRegistry, fromProviders(input.providers)), + Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ + resolveHandle: + input.resolveHandle ?? (() => Effect.die("Unexpected provider refinement")), + }), + Layer.mock(ProjectionSnapshotQuery.ProjectionSnapshotQuery)({ + getShellSnapshot: () => + Effect.succeed({ + snapshotSequence: 1, + projects: input.projects, + threads: [], + updatedAt: "2026-07-01T00:00:00Z", + }), + }), + ), + ), + ); +} + +it.effect("refines unknown self-hosted GitLab projects before listing merge requests", () => + Effect.gen(function* () { + let refinementCalls = 0; + const selfHosted = project({ + id: "p1", + title: "self-hosted", + workspaceRoot: "/gitlab", + repository: "group/project", + provider: "unknown", + host: "code.example.test", + }); + const service = yield* makeService({ + projects: [ + selfHosted, + { ...selfHosted, id: "p2" as ProjectId, workspaceRoot: "/gitlab-worktree" }, + ], + providers: [fakeProvider("gitlab")], + resolveHandle: ({ context }) => { + refinementCalls += 1; + assert.strictEqual(context?.remoteUrl, "https://code.example.test/group/project.git"); + return Effect.succeed({ + context: { ...context!, provider: { ...context!.provider, kind: "gitlab" } }, + provider: undefined as never, + }); + }, + }); + + const result = yield* service.list({ state: "open" }); + + assert.strictEqual(refinementCalls, 1); + assert.strictEqual(result.providers[0]?.host, "code.example.test"); + assert.strictEqual(result.providers[0]?.kind, "gitlab"); + }), +); + +it.effect("derives a legacy repository host after refining its provider", () => + Effect.gen(function* () { + const current = project({ + id: "p1", + title: "legacy self-hosted", + workspaceRoot: "/gitlab", + repository: "group/project", + provider: "unknown", + host: "code.example.test", + }); + const identity = current.repositoryIdentity!; + // Persisted identities from before canonicalKey existed are still accepted at runtime. + const legacy = { + ...current, + repositoryIdentity: { + locator: identity.locator, + provider: identity.provider, + displayName: identity.displayName, + }, + } as unknown as OrchestrationProjectShell; + const service = yield* makeService({ + projects: [legacy], + providers: [fakeProvider("gitlab")], + resolveHandle: ({ context }) => + Effect.succeed({ + context: { ...context!, provider: { ...context!.provider, kind: "gitlab" } }, + provider: undefined as never, + }), + }); + + const result = yield* service.list({ state: "open", host: "gitlab" }); + + assert.strictEqual(result.providers[0]?.host, "gitlab"); + assert.strictEqual(result.providers[0]?.kind, "gitlab"); + }), +); + +it.effect("tries another checkout when provider refinement remains unknown", () => + Effect.gen(function* () { + const asked: string[] = []; + const selfHosted = project({ + id: "p1", + title: "self-hosted", + workspaceRoot: "/gone", + repository: "group/project", + provider: "unknown", + host: "code.example.test", + }); + const service = yield* makeService({ + projects: [selfHosted, { ...selfHosted, id: "p2" as ProjectId, workspaceRoot: "/healthy" }], + providers: [fakeProvider("gitlab")], + resolveHandle: ({ cwd, context }) => { + asked.push(cwd); + return cwd === "/gone" + ? Effect.succeed({ context: context!, provider: undefined as never }) + : Effect.succeed({ + context: { ...context!, provider: { ...context!.provider, kind: "gitlab" } }, + provider: undefined as never, + }); + }, + }); + + const result = yield* service.list({ state: "open" }); + + assert.deepStrictEqual(asked, ["/gone", "/healthy"]); + assert.strictEqual(result.providers[0]?.kind, "gitlab"); + }), +); + +/** A row as a host that reads several repositories at once hands it over. */ +function batchedChangeRequest(number: number, repository: string, updatedAt: string) { + return { ...changeRequest(number, updatedAt), repository }; +} + +it.effect("reads nothing from a host with no implementation, but reports it", () => + Effect.gen(function* () { + const listed: string[] = []; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + project({ id: "p2", title: "notes", workspaceRoot: "/b" }), + project({ + id: "p3", + title: "on gitlab", + workspaceRoot: "/c", + repository: "group/project", + provider: "gitlab", + }), + ], + providers: [ + fakeProvider("github", { + listChangeRequests: (input) => { + listed.push(input.repository); + return Effect.succeed({ + items: [changeRequest(1, "2026-07-02T00:00:00Z")], + truncated: false, + continues: true, + }); + }, + }), + ], + }); + + const result = yield* service.list({ state: "open" }); + + assert.deepStrictEqual(listed, ["pingdotgg/t3code"]); + assert.strictEqual(result.entries[0]?.provider, "github"); + // The GitLab project is explained rather than quietly missing from the page. + assert.deepStrictEqual( + result.providers.map((summary) => ({ + kind: summary.kind, + configured: summary.configured, + projectCount: summary.projectCount, + })), + [ + { kind: "github", configured: true, projectCount: 1 }, + { kind: "gitlab", configured: false, projectCount: 1 }, + ], + ); + }), +); + +it.effect("asks for a whole page of a host, and for the reader's own size when given one", () => + Effect.gen(function* () { + const limits: number[] = []; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + ], + providers: [ + fakeProvider("github", { + listChangeRequests: (input) => { + limits.push(input.limit); + return Effect.succeed({ items: [], truncated: false, continues: true }); + }, + }), + ], + }); + + yield* service.list({ state: "open" }); + yield* service.list({ state: "open", limit: 10 }); + + // Providers probe with one row over this, so 99 asks a host for 100 — the most GitHub and + // GitLab serve in one request. 100 here would cost a second round trip for a single row. + assert.deepStrictEqual(limits, [99, 10]); + }), +); + +it.effect("says where each repository carries on, and from nothing it has run out of", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + project({ id: "p2", title: "web", workspaceRoot: "/b", repository: "acme/web" }), + ], + providers: [ + fakeProvider("github", { + listChangeRequests: ({ repository }) => + Effect.succeed({ + items: [changeRequest(1, "2026-07-02T00:00:00Z")], + truncated: repository === "pingdotgg/t3code", + continues: true, + }), + }), + ], + }); + + const result = yield* service.list({ state: "open" }); + + // The instant of the oldest row, how many rows have gone, and the row already sent at that + // instant. The repository that had nothing more is simply not in it. + assert.deepStrictEqual(result.nextCursors, { + "github.com pingdotgg/t3code": "2026-07-02T00:00:00Z|1|1", + }); + }), +); + +it.effect("offers no continuation for a host that cannot be carried on from", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + ], + providers: [ + fakeProvider("github", { + listChangeRequests: () => + Effect.succeed({ + items: [changeRequest(1, "2026-07-02T00:00:00Z")], + truncated: true, + continues: false, + }), + }), + ], + }); + + const result = yield* service.list({ state: "open" }); + + // More rows exist and no cursor reaches them, which is what asking for a larger page is for. + assert.isTrue(result.truncated); + assert.deepStrictEqual(result.nextCursors, {}); + }), +); + +it.effect("uses a provider's raw cursor advance when it consumed malformed rows", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ + id: "p1", + title: "web", + workspaceRoot: "/a", + repository: "acme/web", + provider: "azure-devops", + host: "dev.azure.com", + }), + ], + providers: [ + fakeProvider("azure-devops", { + listChangeRequests: () => + Effect.succeed({ + items: [changeRequest(7, "2026-07-02T00:00:00Z")], + truncated: true, + cursorAdvance: 4, + continues: true, + }), + }), + ], + }); + + const result = yield* service.list({ state: "open" }); + + // Keyed by the selector Azure is actually asked with, which is the repository's own name. + assert.deepStrictEqual(result.nextCursors, { + "dev.azure.com web": "2026-07-02T00:00:00Z|4|7", + }); + }), +); + +it.effect("reads only the repositories it was asked to carry on with", () => + Effect.gen(function* () { + const listed: string[] = []; + const cursors: Array = []; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + project({ id: "p2", title: "web", workspaceRoot: "/b", repository: "acme/web" }), + ], + providers: [ + fakeProvider("github", { + listChangeRequests: (input) => { + listed.push(input.repository); + cursors.push(input.cursor); + return Effect.succeed({ items: [], truncated: false, continues: true }); + }, + }), + ], + }); + + const result = yield* service.list({ + state: "open", + cursors: { "github.com acme/web": "2026-07-02T00:00:00Z|99|7" }, + }); + + // The other repository is already on the page, and reading it again is the whole cost this + // is here to avoid. The host summaries stay over the workspace, because the switcher they + // fill is about the workspace rather than about this slice. + assert.deepStrictEqual(listed, ["acme/web"]); + assert.deepStrictEqual(cursors, [{ updatedBefore: "2026-07-02T00:00:00Z", delivered: 99 }]); + assert.strictEqual(result.providers.length, 1); + }), +); + +it.effect("keeps a row already sent at the boundary instant from arriving twice", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + ], + providers: [ + fakeProvider("github", { + // The boundary instant is asked for inclusively, so the host hands back the rows + // already sent at it alongside the ones beside them — which a strictly-older read + // would have lost instead. + listChangeRequests: () => + Effect.succeed({ + items: [ + changeRequest(7, "2026-07-02T00:00:00Z"), + changeRequest(8, "2026-07-02T00:00:00Z"), + changeRequest(9, "2026-07-01T00:00:00Z"), + ], + truncated: true, + continues: true, + }), + }), + ], + }); + + const result = yield* service.list({ + state: "open", + cursors: { "github.com pingdotgg/t3code": "2026-07-02T00:00:00Z|1|7" }, + }); + + assert.deepStrictEqual( + result.entries.map((entry) => entry.number), + [8, 9], + ); + assert.deepStrictEqual(result.nextCursors, { + "github.com pingdotgg/t3code": "2026-07-01T00:00:00Z|3|9", + }); + }), +); + +it.effect("keeps the earlier exclusions when a slice ends on the instant it began on", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + ], + providers: [ + fakeProvider("github", { + listChangeRequests: () => + Effect.succeed({ + items: [ + changeRequest(7, "2026-07-02T00:00:00Z"), + changeRequest(8, "2026-07-02T00:00:00Z"), + ], + truncated: true, + continues: true, + }), + }), + ], + }); + + const result = yield* service.list({ + state: "open", + cursors: { "github.com pingdotgg/t3code": "2026-07-02T00:00:00Z|1|6" }, + }); + + // Eight rows can share one second, so a whole slice inside one is ordinary. The next read + // has to keep excluding 6 as well as the two just sent, or it hands 6 over again. + assert.deepStrictEqual( + result.entries.map((entry) => entry.number), + [7, 8], + ); + assert.deepStrictEqual(result.nextCursors, { + "github.com pingdotgg/t3code": "2026-07-02T00:00:00Z|3|6,7,8", + }); + }), +); + +it.effect("refuses a continuation it did not issue, before asking any host anything", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + ], + providers: [ + fakeProvider("github", { listChangeRequests: () => Effect.die("should not be read") }), + ], + }); + + const error = yield* Effect.flip( + service.list({ state: "open", cursors: { "github.com pingdotgg/t3code": "yesterday" } }), + ); + + assert.strictEqual(error._tag, "PullRequestOperationError"); + assert.strictEqual( + error.message, + "Pull request operation list failed: The list could not be carried on from where it left off.", + ); + }), +); + +it.effect("calls a transient viewer failure a failed operation, not a signed-out CLI", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + ], + providers: [ + fakeProvider("github", { + getViewer: () => + Effect.fail( + new PullRequestProviderError({ + provider: "github", + operation: "getViewer", + reason: "failed", + detail: "HTTP 500", + }), + ), + }), + ], + }); + + const error = yield* Effect.flip(service.list({ state: "open" })); + + // `cli-unauthenticated` would send the reader to `gh auth login` over a transient error. + assert.strictEqual(error._tag, "PullRequestOperationError"); + }), +); + +it.effect("reports an unusable host over a merely failing one", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + project({ + id: "p2", + title: "on gitlab", + workspaceRoot: "/c", + repository: "group/project", + provider: "gitlab", + }), + ], + providers: [ + fakeProvider("github", { + getViewer: () => + Effect.fail( + new PullRequestProviderError({ + provider: "github", + operation: "getViewer", + reason: "failed", + detail: "HTTP 500", + }), + ), + }), + fakeProvider("gitlab", { + getViewer: () => Effect.fail(unusable("gitlab", "missing-tool")), + }), + ], + }); + + const error = yield* Effect.flip(service.list({ state: "open" })); + + assert.strictEqual(error._tag, "PullRequestUnavailableError"); + assert.strictEqual(error.message.includes("glab"), true); + }), +); + +it.effect("lists every host that has an implementation", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + project({ + id: "p2", + title: "on gitlab", + workspaceRoot: "/b", + repository: "group/sub/project", + provider: "gitlab", + }), + ], + providers: [ + fakeProvider("github", { + listChangeRequests: () => + Effect.succeed({ + items: [changeRequest(1, "2026-07-01T00:00:00Z")], + truncated: false, + continues: true, + }), + }), + fakeProvider("gitlab", { + listChangeRequests: (input) => + // Nested groups need the full path, not the last two segments. + input.repository === "group/sub/project" + ? Effect.succeed({ + items: [changeRequest(2, "2026-07-05T00:00:00Z")], + truncated: false, + continues: true, + }) + : Effect.die("wrong repository identity"), + }), + ], + }); + + const result = yield* service.list({ state: "open" }); + + assert.deepStrictEqual( + result.entries.map((entry) => [entry.provider, entry.number]), + [ + ["gitlab", 2], + ["github", 1], + ], + ); + }), +); + +it.effect("narrows the listing to one host when asked", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + project({ + id: "p2", + title: "on gitlab", + workspaceRoot: "/b", + repository: "group/project", + provider: "gitlab", + }), + ], + providers: [ + fakeProvider("github", { listChangeRequests: () => Effect.die("should not be read") }), + fakeProvider("gitlab", { + listChangeRequests: () => + Effect.succeed({ + items: [changeRequest(2, "2026-07-05T00:00:00Z")], + truncated: false, + continues: true, + }), + }), + ], + }); + + const result = yield* service.list({ state: "open", host: "gitlab.com" }); + + assert.deepStrictEqual( + result.entries.map((entry) => entry.provider), + ["gitlab"], + ); + }), +); + +it.effect("tells two hosts of one kind apart in the switcher and the filter", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "on github.com", workspaceRoot: "/a", repository: "ping/one" }), + project({ + id: "p2", + title: "on the enterprise install", + workspaceRoot: "/b", + repository: "ping/two", + host: "ghe.example.com", + }), + ], + providers: [ + fakeProvider("github", { + listChangeRequests: ({ host }) => + Effect.succeed({ + items: host === "ghe.example.com" ? [changeRequest(2, "2026-07-05T00:00:00Z")] : [], + truncated: false, + continues: true, + }), + }), + ], + }); + + // Both hosts are GitHub, so a switcher keyed by provider kind would offer one pill for the + // two of them and no way to ask for either. + const all = yield* service.list({ state: "open" }); + assert.deepStrictEqual( + all.providers.map((summary) => [summary.host, summary.kind, summary.projectCount]), + [ + ["github.com", "github", 1], + ["ghe.example.com", "github", 1], + ], + ); + + const scoped = yield* service.list({ state: "open", host: "ghe.example.com" }); + assert.deepStrictEqual( + scoped.entries.map((entry) => [entry.host, entry.number]), + [["ghe.example.com", 2]], + ); + }), +); + +it.effect("keeps one host listed when another is not set up", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + project({ + id: "p2", + title: "on gitlab", + workspaceRoot: "/b", + repository: "group/project", + provider: "gitlab", + }), + ], + providers: [ + fakeProvider("github", { + listChangeRequests: () => + Effect.succeed({ + items: [changeRequest(1, "2026-07-01T00:00:00Z")], + truncated: false, + continues: true, + }), + }), + fakeProvider("gitlab", { + getViewer: () => Effect.fail(unusable("gitlab", "missing-tool")), + }), + ], + }); + + const result = yield* service.list({ state: "open" }); + + assert.deepStrictEqual( + result.entries.map((entry) => entry.provider), + ["github"], + ); + assert.deepStrictEqual( + result.providers.map((summary) => [summary.kind, summary.configured]), + [ + ["github", true], + ["gitlab", false], + ], + ); + }), +); + +it.effect("fails as unavailable only when no host can be read", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + ], + providers: [ + fakeProvider("github", { + getViewer: () => Effect.fail(unusable("github", "missing-tool")), + }), + ], + }); + + const error = yield* service.list({ state: "open" }).pipe(Effect.flip); + + assert.strictEqual(error._tag, "PullRequestUnavailableError"); + assert.strictEqual( + error._tag === "PullRequestUnavailableError" ? error.reason : null, + "cli-missing", + ); + }), +); + +it.effect("reads a repository once when several worktrees share it", () => + Effect.gen(function* () { + let calls = 0; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + project({ + id: "p2", + title: "t3code worktree", + workspaceRoot: "/b", + repository: "PingDotGG/T3Code", + }), + ], + providers: [ + fakeProvider("github", { + listChangeRequests: () => { + calls += 1; + return Effect.succeed({ + items: [changeRequest(1, "2026-07-02T00:00:00Z")], + truncated: false, + continues: true, + }); + }, + }), + ], + }); + + const result = yield* service.list({ state: "open" }); + + assert.strictEqual(calls, 1); + assert.strictEqual(result.entries.length, 1); + }), +); + +it.effect("keeps healthy repositories when one of them cannot be read", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + project({ id: "p2", title: "broken", workspaceRoot: "/b", repository: "pingdotgg/broken" }), + ], + providers: [ + fakeProvider("github", { + listChangeRequests: (input) => + input.repository === "pingdotgg/broken" + ? Effect.fail(requestFailed) + : Effect.succeed({ + items: [changeRequest(1, "2026-07-02T00:00:00Z")], + truncated: false, + continues: true, + }), + }), + ], + }); + + const result = yield* service.list({ state: "open" }); + + assert.strictEqual(result.entries.length, 1); + assert.deepStrictEqual( + result.errors.map((error) => error.projectTitle), + ["broken"], + ); + }), +); + +it.effect("tries another workspace on the same host for the viewer", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "broken", workspaceRoot: "/broken", repository: "acme/one" }), + project({ id: "p2", title: "healthy", workspaceRoot: "/healthy", repository: "acme/two" }), + ], + providers: [ + fakeProvider("github", { + getViewer: (input) => + input.cwd === "/healthy" + ? Effect.succeed("bilal") + : Effect.fail(unusable("github", "missing-tool")), + listChangeRequests: () => + Effect.succeed({ + items: [changeRequest(1, "2026-07-02T00:00:00Z")], + truncated: false, + continues: true, + }), + }), + ], + }); + + const result = yield* service.list({ state: "open" }); + + assert.strictEqual(result.entries.length, 2); + assert.strictEqual(result.viewers["github.com"], "bilal"); + }), +); + +it.effect("refuses an action the host never claimed it could run", () => + Effect.gen(function* () { + let ran = false; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + capabilities: { + diff: true, + comment: true, + // Bitbucket's shape: it can merge and close, but cannot reopen. + actions: ["merge", "close"], + mergeMethods: ["merge"], + search: true, + reactions: true, + review: FULL_REVIEW, + reviewers: FULL_REVIEWERS, + }, + runAction: () => { + ran = true; + return Effect.void; + }, + }), + ], + }); + + const error = yield* Effect.flip( + service.runAction({ + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 1, + action: "reopen", + }), + ); + + assert.strictEqual(error._tag, "PullRequestOperationError"); + assert.isFalse(ran); + }), +); + +it.effect("refuses an action this viewer may not take, and says what access it takes", () => + Effect.gen(function* () { + let ran: string | null = null; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + // The host merges; this account only reads it, and opened the change request — which + // is every contributor to a repository they do not own. + getViewerPermissions: () => + Effect.succeed({ + actions: ["ready", "draft", "close", "reopen"], + comment: true, + resolve: true, + verdicts: ["comment", "approve", "request-changes"], + requestReviewers: false, + }), + runAction: (input) => { + ran = input.action; + return Effect.void; + }, + }), + ], + }); + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + + const error = yield* Effect.flip(service.runAction({ ...reference, action: "merge" })); + assert.strictEqual(error._tag, "PullRequestOperationError"); + assert.include(error.message, "You need write access on this repository to merge."); + assert.strictEqual(ran, null); + + // What the author keeps whatever their access is still theirs to take. + yield* service.runAction({ ...reference, action: "close" }); + assert.strictEqual(ran, "close"); + }), +); + +it.effect("gates arming a merge for later exactly as it gates merging now", () => + Effect.gen(function* () { + let ranWith: { readonly action: string; readonly mergeMethod?: string } | null = null; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + capabilities: { + diff: true, + comment: true, + actions: ["merge", "close", "enable-auto-merge", "disable-auto-merge"], + mergeMethods: ["merge", "squash"], + search: true, + reactions: true, + review: FULL_REVIEW, + reviewers: FULL_REVIEWERS, + }, + // This account may close the change request it opened, and nothing else here. + getViewerPermissions: () => + Effect.succeed({ + actions: ["close"], + comment: true, + resolve: true, + verdicts: ["comment", "approve", "request-changes"], + requestReviewers: false, + }), + runAction: (input) => { + ranWith = { + action: input.action, + ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }), + }; + return Effect.void; + }, + }), + ], + }); + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + + const refused = yield* Effect.flip( + service.runAction({ ...reference, action: "enable-auto-merge", mergeMethod: "squash" }), + ); + assert.strictEqual(refused._tag, "PullRequestOperationError"); + assert.include(refused.message, "merged for you once it is ready"); + assert.strictEqual(ranWith, null); + + // The strategy is checked against the host for an armed merge too: a merge it performs + // later is still a merge, and one it cannot spell must not be passed on. + const wrongStrategy = yield* Effect.flip( + service.runAction({ ...reference, action: "enable-auto-merge", mergeMethod: "rebase" }), + ); + assert.strictEqual(wrongStrategy._tag, "PullRequestOperationError"); + assert.strictEqual(ranWith, null); + }), +); + +it.effect("hands the host the strategy an armed merge was asked for", () => + Effect.gen(function* () { + let ranWith: { readonly action: string; readonly mergeMethod?: string } | null = null; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + capabilities: { + diff: true, + comment: true, + actions: ["merge", "enable-auto-merge", "disable-auto-merge"], + mergeMethods: ["merge", "squash"], + search: true, + reactions: true, + review: FULL_REVIEW, + reviewers: FULL_REVIEWERS, + }, + getViewerPermissions: () => + Effect.succeed({ + actions: ["merge", "enable-auto-merge", "disable-auto-merge"], + comment: true, + resolve: true, + verdicts: ["comment", "approve", "request-changes"], + requestReviewers: true, + }), + runAction: (input) => { + ranWith = { + action: input.action, + ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }), + }; + return Effect.void; + }, + }), + ], + }); + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + + yield* service.runAction({ ...reference, action: "enable-auto-merge", mergeMethod: "squash" }); + assert.deepStrictEqual(ranWith, { action: "enable-auto-merge", mergeMethod: "squash" }); + + yield* service.runAction({ ...reference, action: "disable-auto-merge" }); + assert.deepStrictEqual(ranWith, { action: "disable-auto-merge" }); + }), +); + +it.effect("refuses an auto-merge the host never claimed, without asking it", () => + Effect.gen(function* () { + let ran = false; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + // Bitbucket's shape: it merges, and has nothing that merges later on its own. + fakeProvider("github", { + runAction: () => { + ran = true; + return Effect.void; + }, + }), + ], + }); + + const error = yield* Effect.flip( + service.runAction({ + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 1, + action: "enable-auto-merge", + }), + ); + + assert.strictEqual(error._tag, "PullRequestOperationError"); + assert.isFalse(ran); + }), +); + +it.effect("refuses to resolve a conversation this viewer may not, without asking the host", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + getViewerPermissions: () => + Effect.succeed({ + actions: ["merge", "ready", "draft", "close", "reopen"], + comment: true, + resolve: false, + verdicts: ["comment", "approve", "request-changes"], + requestReviewers: true, + }), + setThreadResolution: () => Effect.die("must not be called"), + }), + ], + }); + + const error = yield* Effect.flip( + service.setThreadResolution({ + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 1, + threadId: "t1", + resolved: true, + }), + ); + + assert.strictEqual(error._tag, "PullRequestOperationError"); + assert.include(error.message, "to resolve a review conversation."); + }), +); + +it.effect("asks nobody what the viewer may do when the host cannot do it at all", () => + Effect.gen(function* () { + let asked = false; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + capabilities: { + diff: true, + comment: true, + actions: ["merge", "close"], + mergeMethods: ["merge"], + search: true, + reactions: true, + review: FULL_REVIEW, + reviewers: FULL_REVIEWERS, + }, + getViewerPermissions: () => { + asked = true; + return Effect.die("must not be called"); + }, + }), + ], + }); + + yield* Effect.flip( + service.runAction({ + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 1, + action: "reopen", + }), + ); + + // The capability check costs nothing; the permission read is a request, so it comes second. + assert.isFalse(asked); + }), +); + +it.effect("refuses a comment on a host that cannot post one", () => + Effect.gen(function* () { + let posted = false; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + capabilities: { + diff: false, + comment: false, + actions: ["merge"], + mergeMethods: ["merge"], + search: true, + reactions: true, + review: FULL_REVIEW, + reviewers: FULL_REVIEWERS, + }, + comment: () => { + posted = true; + return Effect.void; + }, + }), + ], + }); + + const error = yield* Effect.flip( + service.comment({ + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 1, + body: "Looks good.", + }), + ); + + assert.strictEqual(error._tag, "PullRequestOperationError"); + assert.isFalse(posted); + }), +); + +it.effect("keeps two hosts of one provider kind as two accounts", () => + Effect.gen(function* () { + const viewerFor: Record = { "/cloud": "bilal", "/enterprise": "b.hassan" }; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "cloud", workspaceRoot: "/cloud", repository: "acme/web" }), + project({ + id: "p2", + title: "enterprise", + workspaceRoot: "/enterprise", + // The same path on a different host: neither the viewer nor the row may be shared. + repository: "acme/web", + host: "github.acme.dev", + }), + ], + providers: [ + fakeProvider("github", { + getViewer: (input) => Effect.succeed(viewerFor[input.cwd] ?? "unknown"), + listChangeRequests: () => + Effect.succeed({ + items: [changeRequest(1, "2026-07-02T00:00:00Z")], + truncated: false, + continues: true, + }), + }), + ], + }); + + const result = yield* service.list({ state: "open" }); + + // Both repositories survive de-duplication, each with its own account. + assert.strictEqual(result.entries.length, 2); + assert.deepStrictEqual(result.viewers, { + "github.com": "bilal", + "github.acme.dev": "b.hassan", + }); + assert.deepStrictEqual(result.entries.map((entry) => entry.host).toSorted(), [ + "github.acme.dev", + "github.com", + ]); + }), +); + +it.effect("reports repositories on a host that could not be read", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "cloud", workspaceRoot: "/cloud", repository: "acme/web" }), + project({ + id: "p2", + title: "enterprise", + workspaceRoot: "/enterprise", + repository: "acme/api", + host: "github.acme.dev", + }), + ], + providers: [ + fakeProvider("github", { + getViewer: (input) => + input.cwd === "/cloud" + ? Effect.succeed("bilal") + : Effect.fail(unusable("github", "unauthenticated")), + listChangeRequests: () => + Effect.succeed({ + items: [changeRequest(1, "2026-07-02T00:00:00Z")], + truncated: false, + continues: true, + }), + }), + ], + }); + + const result = yield* service.list({ state: "open" }); + + // The healthy host still lists, and the unreadable one is named rather than dropped. + assert.strictEqual(result.entries.length, 1); + assert.deepStrictEqual( + result.errors.map((error) => error.projectId), + ["p2"], + ); + }), +); + +it.effect("flags a review request for the viewer but not on their own change request", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + ], + providers: [ + fakeProvider("github", { + listChangeRequests: () => + Effect.succeed({ + items: [ + { ...changeRequest(1, "2026-07-02T00:00:00Z"), reviewRequestLogins: ["Bilal"] }, + { + ...changeRequest(2, "2026-07-02T00:00:00Z"), + author: { login: "bilal", name: null, avatarUrl: null }, + reviewRequestLogins: ["bilal"], + }, + ], + truncated: false, + continues: true, + }), + }), + ], + }); + + const result = yield* service.list({ state: "open" }); + + assert.deepStrictEqual( + result.entries.map((entry) => entry.viewerReviewRequested), + [true, false], + ); + }), +); + +it.effect("refuses a repository that does not belong to the requested project", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + ], + providers: [fakeProvider("github")], + }); + + const error = yield* service + .diff({ projectId: "p1" as ProjectId, repository: "attacker/repo", number: 1 }) + .pipe(Effect.flip); + + assert.strictEqual(error._tag, "PullRequestOperationError"); + }), +); + +it.effect("refuses a diff on a host that cannot produce one", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ + id: "p1", + title: "on azure", + workspaceRoot: "/a", + repository: "org/project", + provider: "azure-devops", + }), + ], + providers: [ + fakeProvider("azure-devops", { + capabilities: { + diff: false, + comment: true, + actions: ["merge", "close"], + mergeMethods: ["merge"], + search: true, + reactions: true, + review: FULL_REVIEW, + reviewers: FULL_REVIEWERS, + }, + getDiff: () => Effect.die("must not be called"), + }), + ], + }); + + const error = yield* service + .diff({ projectId: "p1" as ProjectId, repository: "org/project", number: 1 }) + .pipe(Effect.flip); + + assert.strictEqual(error._tag, "PullRequestOperationError"); + }), +); + +it.effect("rejects an empty comment before reaching the host", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + ], + providers: [fakeProvider("github", { comment: () => Effect.die("must not be called") })], + }); + + const error = yield* service + .comment({ + projectId: "p1" as ProjectId, + repository: "pingdotgg/t3code", + number: 1, + body: " ", + }) + .pipe(Effect.flip); + + assert.strictEqual(error._tag, "PullRequestOperationError"); + }), +); + +it.effect("refuses a verdict the host never claimed, without asking the provider", () => + Effect.gen(function* () { + let submitted = false; + const service = yield* makeService({ + projects: [ + project({ + id: "p1", + title: "on gitlab", + workspaceRoot: "/a", + repository: "group/project", + provider: "gitlab", + }), + ], + providers: [ + fakeProvider("gitlab", { + capabilities: { + diff: true, + comment: true, + actions: ["merge"], + mergeMethods: ["merge"], + search: true, + reactions: true, + // GitLab's shape: it approves, and has nothing that rejects. + review: { + inlineComment: true, + reply: true, + resolve: true, + verdicts: ["comment", "approve"], + }, + reviewers: FULL_REVIEWERS, + }, + submitReview: () => { + submitted = true; + return Effect.void; + }, + }), + ], + }); + + const error = yield* Effect.flip( + service.submitReview({ + projectId: "p1" as ProjectId, + repository: "group/project", + number: 1, + verdict: "request-changes", + body: "no", + comments: [], + }), + ); + + assert.strictEqual(error._tag, "PullRequestOperationError"); + assert.isFalse(submitted); + }), +); + +it.effect("refuses line comments on a host that takes only a summary", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + ], + providers: [ + fakeProvider("github", { + capabilities: { + diff: true, + comment: true, + actions: ["merge"], + mergeMethods: ["merge"], + search: true, + reactions: true, + review: { inlineComment: false, reply: false, resolve: false, verdicts: ["comment"] }, + reviewers: FULL_REVIEWERS, + }, + submitReview: () => Effect.die("must not be called"), + }), + ], + }); + + const error = yield* Effect.flip( + service.submitReview({ + projectId: "p1" as ProjectId, + repository: "pingdotgg/t3code", + number: 1, + verdict: "comment", + body: "", + comments: [{ path: "src/a.ts", line: 1, side: "right", body: "nit" }], + }), + ); + + assert.strictEqual(error._tag, "PullRequestOperationError"); + }), +); + +it.effect( + "refuses a review with neither a summary nor a comment, but lets an approval through", + () => + Effect.gen(function* () { + let approved = false; + const service = yield* makeService({ + projects: [ + project({ + id: "p1", + title: "t3code", + workspaceRoot: "/a", + repository: "pingdotgg/t3code", + }), + ], + providers: [ + fakeProvider("github", { + submitReview: () => { + approved = true; + return Effect.void; + }, + }), + ], + }); + const reference = { + projectId: "p1" as ProjectId, + repository: "pingdotgg/t3code", + number: 1, + }; + + const error = yield* Effect.flip( + service.submitReview({ ...reference, verdict: "comment", body: " ", comments: [] }), + ); + assert.strictEqual(error._tag, "PullRequestOperationError"); + + // An approval is a verdict in itself, so it needs no words. + yield* service.submitReview({ ...reference, verdict: "approve", body: "", comments: [] }); + assert.isTrue(approved); + }), +); + +it.effect("refuses to resolve a conversation on a host that cannot", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + ], + providers: [ + fakeProvider("github", { + capabilities: { + diff: true, + comment: true, + actions: ["merge"], + mergeMethods: ["merge"], + search: true, + reactions: true, + review: { inlineComment: true, reply: false, resolve: false, verdicts: ["comment"] }, + reviewers: FULL_REVIEWERS, + }, + setThreadResolution: () => Effect.die("must not be called"), + replyToThread: () => Effect.die("must not be called"), + }), + ], + }); + const reference = { + projectId: "p1" as ProjectId, + repository: "pingdotgg/t3code", + number: 1, + }; + + const resolveError = yield* Effect.flip( + service.setThreadResolution({ ...reference, threadId: "t1", resolved: true }), + ); + const replyError = yield* Effect.flip( + service.replyToThread({ ...reference, threadId: "t1", body: "hi" }), + ); + + assert.strictEqual(resolveError._tag, "PullRequestOperationError"); + assert.strictEqual(replyError._tag, "PullRequestOperationError"); + }), +); + +it.effect("refuses to react on a host with no reactions", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + ], + providers: [ + fakeProvider("github", { + capabilities: { + diff: true, + comment: true, + actions: ["merge"], + mergeMethods: ["merge"], + search: true, + reactions: false, + review: FULL_REVIEW, + reviewers: FULL_REVIEWERS, + }, + setReaction: () => Effect.die("must not be called"), + }), + ], + }); + + const error = yield* Effect.flip( + service.setReaction({ + projectId: "p1" as ProjectId, + repository: "pingdotgg/t3code", + number: 1, + content: "heart", + reacted: true, + }), + ); + + assert.strictEqual(error._tag, "PullRequestOperationError"); + }), +); + +it.effect("refuses to react on a host whose capabilities omit reactions entirely", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + ], + providers: [ + fakeProvider("github", { + capabilities: { + diff: true, + comment: true, + actions: ["merge"], + mergeMethods: ["merge"], + search: true, + review: FULL_REVIEW, + reviewers: FULL_REVIEWERS, + }, + setReaction: () => Effect.die("must not be called"), + }), + ], + }); + + const error = yield* Effect.flip( + service.setReaction({ + projectId: "p1" as ProjectId, + repository: "pingdotgg/t3code", + number: 1, + content: "heart", + reacted: true, + }), + ); + + assert.strictEqual(error._tag, "PullRequestOperationError"); + }), +); + +it.effect("passes a reaction through with its subject id on a host that has them", () => + Effect.gen(function* () { + let received: { + readonly subjectId: string | undefined; + readonly content: string; + readonly reacted: boolean; + } | null = null; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + ], + providers: [ + fakeProvider("github", { + setReaction: (input) => { + received = { + subjectId: input.subjectId, + content: input.content, + reacted: input.reacted, + }; + return Effect.void; + }, + }), + ], + }); + + yield* service.setReaction({ + projectId: "p1" as ProjectId, + repository: "pingdotgg/t3code", + number: 1, + subjectId: "IC_1", + content: "heart", + reacted: true, + }); + + assert.deepStrictEqual(received, { subjectId: "IC_1", content: "heart", reacted: true }); + }), +); + +it.effect("invalidates the cached activity after reacting, like the other mutations", () => + Effect.gen(function* () { + let activityCalls = 0; + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + getChangeRequestActivity: () => { + activityCalls += 1; + return Effect.succeed({ + comments: [], + commentCount: 0, + commentsTruncated: false, + reviewThreads: [], + commits: [], + }); + }, + }), + ], + }); + + yield* service.activity(reference); + assert.strictEqual(activityCalls, 1); + + yield* service.setReaction({ ...reference, content: "heart", reacted: true }); + yield* service.activity(reference); + + assert.strictEqual(activityCalls, 2); + }), +); + +it.effect("refuses an empty reply before it reaches the host", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + ], + providers: [ + fakeProvider("github", { replyToThread: () => Effect.die("must not be called") }), + ], + }); + + const error = yield* Effect.flip( + service.replyToThread({ + projectId: "p1" as ProjectId, + repository: "pingdotgg/t3code", + number: 1, + threadId: "t1", + body: " ", + }), + ); + + assert.strictEqual(error._tag, "PullRequestOperationError"); + }), +); + +it.effect("refuses a merge strategy the host does not offer", () => + Effect.gen(function* () { + let ranWith: string | null = null; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + ], + providers: [ + fakeProvider("github", { + capabilities: { + diff: true, + comment: true, + actions: ["merge"], + // Azure DevOps's shape: it squashes as a completion option and has no rebase. + mergeMethods: ["merge", "squash"], + search: true, + reactions: true, + review: FULL_REVIEW, + reviewers: FULL_REVIEWERS, + }, + runAction: (input) => { + ranWith = input.mergeMethod ?? "merge"; + return Effect.void; + }, + }), + ], + }); + const reference = { + projectId: "p1" as ProjectId, + repository: "pingdotgg/t3code", + number: 1, + }; + + // Every provider maps an unrecognised strategy to its own default, so letting this through + // would merge with the wrong one rather than fail. + const error = yield* Effect.flip( + service.runAction({ ...reference, action: "merge", mergeMethod: "rebase" }), + ); + assert.strictEqual(error._tag, "PullRequestOperationError"); + assert.strictEqual(ranWith, null); + + yield* service.runAction({ ...reference, action: "merge", mergeMethod: "squash" }); + assert.strictEqual(ranWith, "squash"); + }), +); + +it.effect("hands the provider the host its repository lives on", () => + Effect.gen(function* () { + const hosts: string[] = []; + const service = yield* makeService({ + projects: [ + project({ + id: "p1", + title: "enterprise", + workspaceRoot: "/a", + repository: "acme/web", + host: "github.acme.dev", + }), + ], + providers: [ + fakeProvider("github", { + listChangeRequests: (input) => { + hosts.push(input.host); + return Effect.succeed({ items: [], truncated: false, continues: true }); + }, + }), + ], + }); + + yield* service.list({ state: "open" }); + + // The identity a project records is the path below its host, so the host has to travel + // separately or a GitHub Enterprise repository is read off github.com instead. + assert.deepStrictEqual(hosts, ["github.acme.dev"]); + }), +); + +it.effect("asks every host the reader's search, rather than filtering what came back", () => + Effect.gen(function* () { + const asked: Array = []; + const listing = (input: { readonly query?: string | undefined }) => { + asked.push(input.query); + return Effect.succeed({ items: [], truncated: false, continues: true }); + }; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + project({ + id: "p2", + title: "on gitlab", + workspaceRoot: "/b", + repository: "group/project", + provider: "gitlab", + }), + ], + providers: [ + fakeProvider("github", { listChangeRequests: listing }), + fakeProvider("gitlab", { listChangeRequests: listing }), + ], + }); + + yield* service.list({ state: "open", query: "pull requests page" }); + + // A page holds one page per repository, so a search that stopped at the service could only + // find what was already loaded. + assert.deepStrictEqual(asked, ["pull requests page", "pull requests page"]); + }), +); + +it.effect("asks for no search when the reader has typed nothing", () => + Effect.gen(function* () { + const asked: Array = []; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + ], + providers: [ + fakeProvider("github", { + listChangeRequests: (input) => { + asked.push(input.query); + return Effect.succeed({ items: [], truncated: false, continues: true }); + }, + }), + ], + }); + + yield* service.list({ state: "open" }); + + assert.deepStrictEqual(asked, [undefined]); + }), +); + +it.effect("asks another checkout who is signed in when the first one cannot answer", () => + Effect.gen(function* () { + const asked: string[] = []; + const service = yield* makeService({ + projects: [ + // One repository, checked out twice. The listing reads it once; the viewer lookup has + // two places to ask. + project({ + id: "p1", + title: "t3code (stale worktree)", + workspaceRoot: "/gone", + repository: "pingdotgg/t3code", + }), + project({ + id: "p2", + title: "t3code", + workspaceRoot: "/healthy", + repository: "pingdotgg/t3code", + }), + ], + providers: [ + fakeProvider("github", { + getViewer: (input) => { + asked.push(input.cwd); + return input.cwd === "/gone" + ? Effect.fail( + new PullRequestProviderError({ + provider: "github", + operation: "getViewer", + reason: "failed", + detail: "not a git repository", + }), + ) + : Effect.succeed("bilal"); + }, + listChangeRequests: () => + Effect.succeed({ + items: [changeRequest(1, "2026-07-02T00:00:00Z")], + truncated: false, + continues: true, + }), + }), + ], + }); + + const result = yield* service.list({ state: "open" }); + + // De-duplicating the listing must not throw away the checkouts the fallback needs: the + // host is readable, so it is read. + assert.deepStrictEqual(asked, ["/gone", "/healthy"]); + assert.strictEqual(result.entries.length, 1); + assert.strictEqual(result.providers[0]?.configured, true); + }), +); + +it.effect("refuses to ask for a review on a host that cannot, before any call is made", () => + Effect.gen(function* () { + let asked = false; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + capabilities: { + diff: true, + comment: true, + actions: ["merge"], + mergeMethods: ["merge"], + search: true, + reactions: true, + review: FULL_REVIEW, + reviewers: { request: false, listCandidates: false }, + }, + getViewerPermissions: () => { + asked = true; + return Effect.die("must not be called"); + }, + setReviewerRequest: () => Effect.die("must not be called"), + }), + ], + }); + + const error = yield* Effect.flip( + service.requestReviewers({ + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 1, + reviewers: [{ id: "octocat", kind: "user" }], + requested: true, + }), + ); + + assert.strictEqual(error._tag, "PullRequestOperationError"); + assert.include(error.message, "cannot ask somebody for a review."); + assert.isFalse(asked); + }), +); + +it.effect("refuses the candidate list on a host that has no such list to give", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + capabilities: { + diff: false, + comment: false, + actions: ["merge"], + mergeMethods: ["merge"], + search: false, + reactions: true, + review: FULL_REVIEW, + // Azure's shape: it takes a reviewer, and names nobody who could be one. + reviewers: { request: true, listCandidates: false }, + }, + listReviewerCandidates: () => Effect.die("must not be called"), + }), + ], + }); + + const error = yield* Effect.flip( + service.reviewerCandidates({ + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 1, + }), + ); + + assert.strictEqual(error._tag, "PullRequestOperationError"); + assert.include(error.message, "cannot say who may review a change request."); + }), +); + +it.effect("refuses a review request this viewer may not make, and says what access it takes", () => + Effect.gen(function* () { + let sent = false; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + // The host asks for reviews; this account only reads the repository. + getViewerPermissions: () => + Effect.succeed({ + actions: ["ready", "draft", "close", "reopen"], + comment: true, + resolve: true, + verdicts: ["comment", "approve", "request-changes"], + requestReviewers: false, + }), + setReviewerRequest: () => { + sent = true; + return Effect.void; + }, + }), + ], + }); + + const error = yield* Effect.flip( + service.requestReviewers({ + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 1, + reviewers: [{ id: "octocat", kind: "user" }], + requested: true, + }), + ); + + assert.strictEqual(error._tag, "PullRequestOperationError"); + assert.include(error.message, "You need write access on this repository to ask for a review."); + assert.isFalse(sent); + }), +); + +it.effect("keeps the menu from a viewer who may not ask, which is all it is for", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + getViewerPermissions: () => + Effect.succeed({ + actions: [], + comment: true, + resolve: false, + verdicts: ["comment", "approve", "request-changes"], + requestReviewers: false, + }), + listReviewerCandidates: () => Effect.die("must not be called"), + }), + ], + }); + + const error = yield* Effect.flip( + service.reviewerCandidates({ + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 1, + }), + ); + + assert.include(error.message, "You need write access on this repository to ask for a review."); + }), +); + +it.effect("hands the host's own candidate list back, and asks for it with the change request", () => + Effect.gen(function* () { + let askedFor: number | null = null; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + listReviewerCandidates: (input) => { + askedFor = input.number; + return Effect.succeed({ + candidates: [ + { + id: "octocat", + kind: "user", + login: "octocat", + name: null, + avatarUrl: null, + isRequested: true, + }, + ], + truncated: false, + continues: true, + }); + }, + }), + ], + }); + + const list = yield* service.reviewerCandidates({ + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 4, + }); + + assert.strictEqual(askedFor, 4); + assert.deepStrictEqual( + list.candidates.map((candidate) => candidate.login), + ["octocat"], + ); + }), +); + +it.effect("answers a repeated listing from cache, and concurrent readers share one request", () => + Effect.gen(function* () { + let hostCalls = 0; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + listChangeRequests: () => { + hostCalls += 1; + return Effect.succeed({ + items: [changeRequest(1, "2026-07-02T00:00:00Z")], + truncated: false, + continues: false, + }); + }, + }), + ], + }); + + yield* Effect.all([service.list({ state: "open" }), service.list({ state: "open" })], { + concurrency: "unbounded", + }); + yield* service.list({ state: "open" }); + assert.strictEqual(hostCalls, 1); + + // A different filter is a different answer, not a cache hit. + yield* service.list({ state: "all" }); + assert.strictEqual(hostCalls, 2); + }), +); + +it.effect("a listing narrowed to some projects is its own cache entry", () => + Effect.gen(function* () { + const asked: ReadonlyArray[] = []; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), + project({ id: "p2", title: "docs", workspaceRoot: "/b", repository: "acme/docs" }), + ], + providers: [ + fakeProvider("github", { + listChangeRequestsAcross: (input) => { + asked.push(input.repositories); + return Effect.succeed({ + items: input.repositories.map((repository, index) => + batchedChangeRequest(index + 1, repository, "2026-07-02T00:00:00Z"), + ), + truncated: false, + }); + }, + }), + ], + }); + + yield* service.list({ state: "open" }); + const narrowed = yield* service.list({ state: "open", projectIds: ["p2" as ProjectId] }); + + // The narrowing is part of the key, so it reads its own scope instead of the wider answer. + assert.deepStrictEqual(asked, [["acme/web", "acme/docs"], ["acme/docs"]]); + assert.deepStrictEqual( + narrowed.entries.map((entry) => entry.repository), + ["acme/docs"], + ); + + // Asking again with the same narrowing, ordered differently, is still the same answer. + yield* service.list({ state: "open", projectIds: ["p2" as ProjectId] }); + assert.strictEqual(asked.length, 2); + }), +); + +it.effect("an explicit invalidation makes the next listing ask the host again", () => + Effect.gen(function* () { + let hostCalls = 0; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + listChangeRequests: () => { + hostCalls += 1; + return Effect.succeed({ items: [], truncated: false, continues: false }); + }, + }), + ], + }); + + yield* service.list({ state: "open" }); + yield* service.invalidate({}); + yield* service.list({ state: "open" }); + assert.strictEqual(hostCalls, 2); + + // Forgetting one change request leaves the listings shared. + yield* service.invalidate({ + reference: { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }, + }); + yield* service.list({ state: "open" }); + assert.strictEqual(hostCalls, 2); + }), +); + +it.effect("a mutation makes the next listing ask the host again, with no client asking", () => + Effect.gen(function* () { + let hostCalls = 0; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + listChangeRequests: () => { + hostCalls += 1; + return Effect.succeed({ items: [], truncated: false, continues: false }); + }, + }), + ], + }); + + yield* service.list({ state: "open" }); + yield* service.runAction({ + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 1, + action: "close", + }); + yield* service.list({ state: "open" }); + assert.strictEqual(hostCalls, 2); + }), +); + +it.effect("does not cache a failed listing", () => + Effect.gen(function* () { + let hostCalls = 0; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + // The viewer lookup is what fails the whole listing rather than one repository. + getViewer: () => { + hostCalls += 1; + return hostCalls === 1 ? Effect.fail(requestFailed) : Effect.succeed("bilal"); + }, + }), + ], + }); + + const error = yield* Effect.flip(service.list({ state: "open" })); + assert.strictEqual(error._tag, "PullRequestOperationError"); + const second = yield* service.list({ state: "open" }); + assert.strictEqual(hostCalls, 2); + assert.strictEqual(second.providers[0]?.configured, true); + }), +); + +it.effect("reads a host's repositories in one search, and files the rows back under each", () => + Effect.gen(function* () { + const asked: Array> = []; + const separately: string[] = []; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + project({ id: "p2", title: "web", workspaceRoot: "/b", repository: "acme/web" }), + project({ + id: "p3", + title: "on gitlab", + workspaceRoot: "/c", + repository: "group/project", + provider: "gitlab", + }), + ], + providers: [ + fakeProvider("github", { + listChangeRequests: ({ repository }) => { + separately.push(repository); + return Effect.succeed({ items: [], truncated: false, continues: true }); + }, + listChangeRequestsAcross: (input) => { + asked.push(input.repositories); + return Effect.succeed({ + items: [ + batchedChangeRequest(1, "acme/web", "2026-07-03T00:00:00Z"), + batchedChangeRequest(2, "pingdotgg/t3code", "2026-07-02T00:00:00Z"), + ], + truncated: false, + }); + }, + }), + // A host with no search across repositories keeps being asked one at a time. + fakeProvider("gitlab", { + listChangeRequests: ({ repository }) => { + separately.push(repository); + return Effect.succeed({ + items: [changeRequest(3, "2026-07-01T00:00:00Z")], + truncated: false, + continues: true, + }); + }, + }), + ], + }); + + const result = yield* service.list({ state: "open" }); + + assert.deepStrictEqual(asked, [["pingdotgg/t3code", "acme/web"]]); + assert.deepStrictEqual(separately, ["group/project"]); + // Ordered by update across every host, and each row under the project whose repository it + // came from. + assert.deepStrictEqual( + result.entries.map((entry) => [entry.projectId, entry.number]), + [ + ["p2", 1], + ["p1", 2], + ["p3", 3], + ], + ); + }), +); +it.effect("carries every repository of a slice on from the oldest row in it", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + project({ id: "p2", title: "web", workspaceRoot: "/b", repository: "acme/web" }), + project({ id: "p3", title: "docs", workspaceRoot: "/c", repository: "acme/docs" }), + ], + providers: [ + fakeProvider("github", { + listChangeRequestsAcross: () => + Effect.succeed({ + items: [ + batchedChangeRequest(1, "acme/web", "2026-07-03T00:00:00Z"), + batchedChangeRequest(2, "pingdotgg/t3code", "2026-07-02T00:00:00Z"), + batchedChangeRequest(3, "acme/web", "2026-07-02T00:00:00Z"), + ], + truncated: true, + }), + }), + ], + }); + + const result = yield* service.list({ state: "open" }); + + // The boundary is the oldest row of the whole slice, not of each repository: `acme/web` has + // been read past its newest row, so only the rows sent at the boundary are named for it. + // `acme/docs`, which the slice holds nothing of, is not believed on silence alone — it is + // read on its own, and that read is what says whether it has anything at all. + assert.isTrue(result.truncated); + assert.deepStrictEqual(result.nextCursors, { + "github.com pingdotgg/t3code": "2026-07-02T00:00:00Z|1|2", + "github.com acme/web": "2026-07-02T00:00:00Z|2|3", + }); + }), +); +it.effect("carries a slice on without sending the rows it already sent", () => + Effect.gen(function* () { + const cursors: Array = []; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + listChangeRequestsAcross: (input) => { + cursors.push(input.cursor); + return Effect.succeed({ + items: [ + batchedChangeRequest(3, "acme/web", "2026-07-02T00:00:00Z"), + batchedChangeRequest(4, "acme/web", "2026-07-02T00:00:00Z"), + ], + truncated: true, + }); + }, + }), + ], + }); + + const result = yield* service.list({ + state: "open", + cursors: { "github.com acme/web": "2026-07-02T00:00:00Z|1|3" }, + }); + + // The boundary instant is asked for inclusively, so the row already sent at it comes back and + // is dropped here — and stays named in the next cursor, which has not moved off that instant. + assert.deepStrictEqual(cursors, [{ updatedBefore: "2026-07-02T00:00:00Z", delivered: 1 }]); + assert.deepStrictEqual( + result.entries.map((entry) => entry.number), + [4], + ); + assert.deepStrictEqual(result.nextCursors, { + "github.com acme/web": "2026-07-02T00:00:00Z|2|3,3,4", + }); + }), +); +it.effect("reads a workspace larger than one search in chunks, and merges them", () => + Effect.gen(function* () { + const asked: Array = []; + const service = yield* makeService({ + projects: Array.from({ length: 101 }, (_, index) => + project({ + id: `p${index}`, + title: `repo ${index}`, + workspaceRoot: `/w${index}`, + repository: `acme/repo${index}`, + }), + ), + providers: [ + fakeProvider("github", { + listChangeRequestsAcross: (input) => { + asked.push(input.repositories.length); + return Effect.succeed({ + items: input.repositories.map((repository, index) => + batchedChangeRequest(index + 1, repository, "2026-07-02T00:00:00Z"), + ), + truncated: false, + }); + }, + }), + ], + }); + + const result = yield* service.list({ state: "open" }); + + assert.deepStrictEqual(asked, [100, 1]); + assert.strictEqual(result.entries.length, 101); + }), +); +it.effect("asks on its own for a repository a search answered nothing for", () => + Effect.gen(function* () { + const separately: string[] = []; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), + project({ id: "p2", title: "docs", workspaceRoot: "/b", repository: "acme/docs" }), + ], + providers: [ + fakeProvider("github", { + listChangeRequests: ({ repository }) => { + separately.push(repository); + return repository === "acme/docs" + ? Effect.fail(requestFailed) + : Effect.succeed({ items: [], truncated: false, continues: true }); + }, + listChangeRequestsAcross: () => + Effect.succeed({ + items: [batchedChangeRequest(1, "acme/web", "2026-07-03T00:00:00Z")], + truncated: false, + }), + }), + ], + }); + + const result = yield* service.list({ state: "open" }); + + // The slice had room and still held nothing of `acme/docs`, which is what a repository GitHub + // will not search looks like — so it is read the old way, and its failure is still reported + // against its own project. + assert.deepStrictEqual(separately, ["acme/docs"]); + assert.deepStrictEqual(result.errors, [ + { + projectId: "p2" as ProjectId, + projectTitle: "docs", + message: "acme/docs could not be read.", + }, + ]); + assert.deepStrictEqual( + result.entries.map((entry) => entry.number), + [1], + ); + }), +); +it.effect("reads the repositories one at a time when the search itself fails", () => + Effect.gen(function* () { + const separately: string[] = []; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), + project({ id: "p2", title: "docs", workspaceRoot: "/b", repository: "acme/docs" }), + ], + providers: [ + fakeProvider("github", { + listChangeRequests: ({ repository }) => { + separately.push(repository); + return Effect.succeed({ + items: [changeRequest(1, "2026-07-02T00:00:00Z")], + truncated: false, + continues: true, + }); + }, + listChangeRequestsAcross: () => Effect.fail(requestFailed), + }), + ], + }); + + const result = yield* service.list({ state: "open" }); + + // One failed question about two repositories is not two unreadable repositories. + assert.deepStrictEqual(separately.toSorted(), ["acme/docs", "acme/web"]); + assert.deepStrictEqual(result.errors, []); + assert.strictEqual(result.entries.length, 2); + }), +); +it.effect("fills in the line counts for the rows it is given", () => + Effect.gen(function* () { + const asked: Array = []; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), + project({ + id: "p2", + title: "on gitlab", + workspaceRoot: "/b", + repository: "group/project", + provider: "gitlab", + }), + ], + providers: [ + fakeProvider("github", { + listChangeRequestStats: (input) => { + asked.push(input.changeRequests); + return Effect.succeed([ + { repository: "acme/web", number: 1, additions: 12, deletions: 3 }, + ]); + }, + }), + // Its listing carries the counts already, so it has nothing to be asked. + fakeProvider("gitlab"), + ], + }); + + const result = yield* service.listStats({ + refs: [ + { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }, + { projectId: "p1" as ProjectId, repository: "acme/web", number: 2 }, + { projectId: "p2" as ProjectId, repository: "group/project", number: 3 }, + // Not the repository this project's remote points at, so it is dropped rather than asked. + { projectId: "p1" as ProjectId, repository: "evil/repo", number: 4 }, + ], + }); + + assert.deepStrictEqual(asked, [ + [ + { repository: "acme/web", number: 1 }, + { repository: "acme/web", number: 2 }, + ], + ]); + // Only the rows the host answered for; the other is left with whatever the listing had. + assert.deepStrictEqual(result.stats, [ + { + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 1, + additions: 12, + deletions: 3, + }, + ]); + }), +); +it.effect("keeps the rows when the line counts cannot be read", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { listChangeRequestStats: () => Effect.fail(requestFailed) }), + ], + }); + + const result = yield* service.listStats({ + refs: [{ projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }], + }); + + assert.deepStrictEqual(result.stats, []); + }), +); + +it.effect( + "serves core detail without waiting for activity, and shares activity between clients", + () => + Effect.gen(function* () { + let coreCalls = 0; + let activityCalls = 0; + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), + ], + providers: [ + fakeProvider("github", { + getChangeRequest: () => { + coreCalls += 1; + return Effect.succeed({ + ...changeRequest(1, "2026-07-02T00:00:00Z"), + body: "Ready before the conversation", + changedFiles: 2, + mergedAt: null, + closedAt: null, + reviewers: [], + checks: [], + mergeCapabilities: { merge: true, squash: true, rebase: true }, + viewerPermissions: { + actions: ["merge"], + comment: true, + resolve: true, + verdicts: ["comment", "approve", "request-changes"], + requestReviewers: true, + }, + }); + }, + getChangeRequestActivity: () => { + activityCalls += 1; + return Effect.succeed({ + comments: [], + commentCount: 0, + commentsTruncated: false, + reviewThreads: [], + commits: [], + }); + }, + }), + ], + }); + + const core = yield* service.detail(reference); + assert.strictEqual(core.body, "Ready before the conversation"); + assert.strictEqual(coreCalls, 1); + assert.strictEqual(activityCalls, 0); + + yield* Effect.all([service.activity(reference), service.activity(reference)], { + concurrency: 2, + }); + assert.strictEqual(activityCalls, 1); + + yield* service.invalidate({ reference }); + yield* service.activity(reference); + assert.strictEqual(activityCalls, 2); + }), +); + +it.effect("carries an armed auto-merge through to the detail, and silence as silence", () => + Effect.gen(function* () { + const detailWith = (autoMergeEnabled: boolean | undefined) => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), + ], + providers: [ + fakeProvider("github", { + getChangeRequest: () => + Effect.succeed({ + ...changeRequest(1, "2026-07-02T00:00:00Z"), + body: "", + changedFiles: 0, + mergedAt: null, + closedAt: null, + reviewers: [], + checks: [], + mergeCapabilities: { merge: true, squash: true, rebase: true }, + viewerPermissions: { + actions: ["merge"], + comment: true, + resolve: true, + verdicts: ["comment", "approve", "request-changes"], + requestReviewers: true, + }, + ...(autoMergeEnabled === undefined ? {} : { autoMergeEnabled }), + }), + }), + ], + }); + return yield* service.detail({ + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 1, + }); + }); + + assert.strictEqual((yield* detailWith(true)).autoMergeEnabled, true); + assert.strictEqual((yield* detailWith(false)).autoMergeEnabled, false); + // A host that says nothing leaves the field absent rather than claiming the merge is unarmed. + assert.isUndefined((yield* detailWith(undefined)).autoMergeEnabled); + }), +); + +it("names an Azure DevOps repository by its own name, not its project path", () => { + // `az repos pr list --repository` takes a name and detects the organisation and project from + // the checkout; the recorded `org/project/_git/repo` path is refused, and the repository then + // reads as unavailable on the page. + const selector = PullRequestService.repositoryIdentityOf({ + repositoryIdentity: { + provider: "azure-devops", + displayName: "contoso/payments/_git/checkout", + owner: "contoso", + name: "checkout", + }, + } as never); + assert.strictEqual(selector, "checkout"); +}); + +it("falls back to the path's last segment where an Azure identity has no name", () => { + const selector = PullRequestService.repositoryIdentityOf({ + repositoryIdentity: { + provider: "azure-devops", + displayName: "contoso/payments/_git/checkout", + }, + } as never); + assert.strictEqual(selector, "checkout"); +}); + +it("keeps a GitLab identity's whole path, because a nested group is part of the name", () => { + const selector = PullRequestService.repositoryIdentityOf({ + repositoryIdentity: { + provider: "gitlab", + displayName: "group/subgroup/service", + owner: "group", + name: "service", + }, + } as never); + assert.strictEqual(selector, "group/subgroup/service"); +}); + +it.effect("narrows the rows of a host that ignored the filters it was handed", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ + id: "p1", + title: "web", + workspaceRoot: "/a", + repository: "acme/web", + provider: "gitlab", + }), + ], + providers: [ + // Only GitHub narrows a listing for itself; every other host answers unnarrowed, and + // sending it a draft filter it quietly ignores used to put drafts on a filtered page. + fakeProvider("gitlab", { + listChangeRequests: () => + Effect.succeed({ + items: [ + { ...changeRequest(1, "2026-07-02T00:00:00Z"), isDraft: true }, + changeRequest(2, "2026-07-01T00:00:00Z"), + ], + truncated: false, + continues: false, + }), + }), + ], + }); + + const result = yield* service.list({ state: "open", filters: { draft: "hide" } }); + + assert.deepStrictEqual( + result.entries.map((entry) => entry.number), + [2], + ); + }), +); + +it.effect("keeps a row of a host that ignored the filters if any name of a label group holds", () => + Effect.gen(function* () { + const sized = (number: number, updatedAt: string, ...names: ReadonlyArray) => ({ + ...changeRequest(number, updatedAt), + labels: names.map((name) => ({ name, color: null })), + }); + const service = yield* makeService({ + projects: [ + project({ + id: "p1", + title: "web", + workspaceRoot: "/a", + repository: "acme/web", + provider: "gitlab", + }), + ], + providers: [ + fakeProvider("gitlab", { + listChangeRequests: () => + Effect.succeed({ + items: [ + sized(1, "2026-07-04T00:00:00Z", "size:S", "bug"), + sized(2, "2026-07-03T00:00:00Z", "size:XS", "bug"), + sized(3, "2026-07-02T00:00:00Z", "size:L", "bug"), + sized(4, "2026-07-01T00:00:00Z", "size:S"), + ], + truncated: false, + continues: false, + }), + }), + ], + }); + + // Either size satisfies the first group; the second group is its own question, so the row + // carrying a size but no bug goes. + const result = yield* service.list({ + state: "open", + filters: { labels: [["size:S", "size:XS"], ["bug"]] }, + }); + + assert.deepStrictEqual( + result.entries.map((entry) => entry.number), + [1, 2], + ); + }), +); + +it.effect('resolves an author filter of "me" to the viewer before narrowing a host\'s rows', () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ + id: "p1", + title: "web", + workspaceRoot: "/a", + repository: "acme/web", + provider: "gitlab", + }), + ], + providers: [ + // Only GitHub narrows a listing for itself, so this fixture's "me" has to be resolved + // locally too — the same helper both call sites lean on. + fakeProvider("gitlab", { + listChangeRequests: () => + Effect.succeed({ + items: [ + changeRequest(1, "2026-07-02T00:00:00Z"), + { + ...changeRequest(2, "2026-07-01T00:00:00Z"), + author: { login: "bilal", name: null, avatarUrl: null }, + }, + ], + truncated: false, + continues: false, + }), + }), + ], + }); + + const result = yield* service.list({ state: "open", filters: { author: "me" } }); + + assert.deepStrictEqual( + result.entries.map((entry) => entry.number), + [2], + ); + }), +); + +it.effect("refuses a way of updating a branch that the host or the viewer does not allow", () => + Effect.gen(function* () { + let taken: string | null = null; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + capabilities: { + diff: true, + comment: true, + actions: ["merge", "close", "update-branch"], + mergeMethods: ["merge"], + // This host brings a stale branch up to date with a merge commit and nothing else. + updateMethods: ["merge"], + search: true, + reactions: true, + review: FULL_REVIEW, + reviewers: FULL_REVIEWERS, + }, + getViewerPermissions: () => + Effect.succeed({ + actions: ["close", "update-branch"], + comment: true, + resolve: true, + verdicts: ["comment"], + requestReviewers: false, + updateMethods: ["merge"], + }), + runAction: (input) => { + taken = input.updateMethod ?? "default"; + return Effect.void; + }, + }), + ], + }); + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + + // Asking for a rebase a host does not offer must fail rather than quietly merge instead. + const error = yield* Effect.flip( + service.runAction({ ...reference, action: "update-branch", updateMethod: "rebase" }), + ); + assert.strictEqual(error._tag, "PullRequestOperationError"); + assert.strictEqual(taken, null); + + yield* service.runAction({ ...reference, action: "update-branch", updateMethod: "merge" }); + assert.strictEqual(taken, "merge"); + }), +); + +it.effect("refuses to merge a target branch into a source branch on a host that only rebases", () => + Effect.gen(function* () { + let taken = 0; + const service = yield* makeService({ + projects: [ + project({ + id: "p1", + title: "on gitlab", + workspaceRoot: "/a", + repository: "group/project", + provider: "gitlab", + }), + ], + providers: [ + fakeProvider("gitlab", { + capabilities: { + diff: true, + comment: true, + actions: ["merge", "close", "update-branch"], + mergeMethods: ["merge"], + // What GitLab declares: it replays the branch, and has no update that merges the + // target back in. + updateMethods: ["rebase"], + search: true, + reactions: true, + review: FULL_REVIEW, + reviewers: FULL_REVIEWERS, + }, + getViewerPermissions: () => + Effect.succeed({ + actions: ["close", "update-branch"], + comment: true, + resolve: true, + verdicts: ["comment"], + requestReviewers: false, + updateMethods: ["rebase"], + }), + runAction: () => { + taken += 1; + return Effect.void; + }, + }), + ], + }); + const reference = { projectId: "p1" as ProjectId, repository: "group/project", number: 1 }; + + // A merge asked of a host that rebases must fail here rather than reach the provider, which + // would rebase instead and report the wrong thing as done. + const error = yield* Effect.flip( + service.runAction({ ...reference, action: "update-branch", updateMethod: "merge" }), + ); + assert.strictEqual(error._tag, "PullRequestOperationError"); + assert.strictEqual(taken, 0); + + yield* service.runAction({ ...reference, action: "update-branch", updateMethod: "rebase" }); + assert.strictEqual(taken, 1); + }), +); + +it.effect("judges the review filter only on a host that summarises its reviews", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), + project({ + id: "p2", + title: "on gitlab", + workspaceRoot: "/b", + repository: "group/project", + provider: "gitlab", + }), + ], + providers: [ + // GitHub answers with the field on every row: null is "nobody has decided yet". + fakeProvider("github", { + listChangeRequests: () => + Effect.succeed({ + items: [ + { ...changeRequest(1, "2026-07-02T00:00:00Z"), reviewDecision: null }, + { + ...changeRequest(2, "2026-07-02T00:00:00Z"), + reviewDecision: "approved" as const, + }, + ], + truncated: false, + continues: true, + }), + }), + // GitLab never supplies the field, so its rows are not the filter's to judge. + fakeProvider("gitlab", { + listChangeRequests: () => + Effect.succeed({ + items: [changeRequest(3, "2026-07-02T00:00:00Z")], + truncated: false, + continues: true, + }), + }), + ], + }); + + const none = yield* service.list({ state: "open", filters: { review: "none" } }); + assert.deepStrictEqual(none.entries.map((entry) => entry.number).toSorted(), [1, 3]); + + const approved = yield* service.list({ state: "open", filters: { review: "approved" } }); + assert.deepStrictEqual(approved.entries.map((entry) => entry.number).toSorted(), [2, 3]); + }), +); + +it.effect("sends only the words a rewrite carries", () => + Effect.gen(function* () { + const received: Array<{ title?: string | undefined; body?: string | undefined }> = []; + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + updateChangeRequest: (input) => { + received.push({ title: input.title, body: input.body }); + return Effect.void; + }, + }), + ], + }); + + yield* service.update({ ...reference, title: "A better title" }); + yield* service.update({ ...reference, body: "" }); + yield* service.update({ ...reference, title: "Both", body: "at once" }); + + assert.deepStrictEqual(received, [ + { title: "A better title", body: undefined }, + { title: undefined, body: "" }, + { title: "Both", body: "at once" }, + ]); + }), +); + +it.effect("refuses a rewrite that changes nothing, before any call is made", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { updateChangeRequest: () => Effect.die("must not be called") }), + ], + }); + + const error = yield* Effect.flip( + service.update({ projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }), + ); + + assert.strictEqual(error._tag, "PullRequestOperationError"); + assert.include(error.message, "Nothing was changed."); + }), +); + +it.effect("refuses to rewrite anything on a host that never claimed it", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + capabilities: { + diff: true, + comment: true, + actions: ["merge"], + mergeMethods: ["merge"], + search: true, + reactions: true, + review: FULL_REVIEW, + reviewers: FULL_REVIEWERS, + }, + updateChangeRequest: () => Effect.die("must not be called"), + updateComment: () => Effect.die("must not be called"), + }), + ], + }); + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + + const rewriteRefused = yield* Effect.flip(service.update({ ...reference, title: "New" })); + const commentRefused = yield* Effect.flip( + service.updateComment({ + ...reference, + commentId: "IC_1", + kind: "issue-comment", + body: "New", + }), + ); + + assert.include(rewriteRefused.message, "cannot rewrite a change request."); + assert.include(commentRefused.message, "cannot rewrite a comment."); + }), +); + +it.effect("passes a rewritten remark through with the id and kind it arrived under", () => + Effect.gen(function* () { + let received: { id: string; kind: string; body: string } | null = null; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + updateComment: (input) => { + received = { id: input.commentId, kind: input.kind, body: input.body }; + return Effect.void; + }, + }), + ], + }); + + yield* service.updateComment({ + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 1, + commentId: "PRRC_1", + kind: "review-comment", + body: "Second thoughts", + }); + + assert.deepStrictEqual(received, { + id: "PRRC_1", + kind: "review-comment", + body: "Second thoughts", + }); + }), +); + +it.effect("refuses a remark rewritten into nothing but whitespace", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { updateComment: () => Effect.die("must not be called") }), + ], + }); + + const error = yield* Effect.flip( + service.updateComment({ + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 1, + commentId: "IC_1", + kind: "issue-comment", + body: " \n ", + }), + ); + + assert.include(error.message, "A comment cannot be empty."); + }), +); + +it.effect("forgets the cached detail after a rewrite, like the other mutations", () => + Effect.gen(function* () { + let coreCalls = 0; + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + getChangeRequest: () => { + coreCalls += 1; + return Effect.succeed({ + ...changeRequest(1, "2026-07-02T00:00:00Z"), + body: "", + changedFiles: 0, + mergedAt: null, + closedAt: null, + reviewers: [], + checks: [], + mergeCapabilities: { merge: true, squash: true, rebase: true }, + viewerPermissions: { + actions: ["merge"], + comment: true, + resolve: true, + verdicts: ["comment", "approve", "request-changes"], + requestReviewers: true, + }, + }); + }, + }), + ], + }); + + yield* service.detail(reference); + yield* service.update({ ...reference, title: "Renamed" }); + yield* service.detail(reference); + + assert.strictEqual(coreCalls, 2); + }), +); + +it.effect("names the signed-in account in the detail, and says nothing where the host cannot", () => + Effect.gen(function* () { + const detailFrom = (provider: PullRequestProviderApi) => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), + ], + providers: [provider], + }); + return yield* service.detail({ + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 1, + }); + }); + const readable = fakeProvider("github", { + getChangeRequest: () => + Effect.succeed({ + ...changeRequest(1, "2026-07-02T00:00:00Z"), + body: "", + changedFiles: 0, + mergedAt: null, + closedAt: null, + reviewers: [], + checks: [], + mergeCapabilities: { merge: true, squash: true, rebase: true }, + viewerPermissions: { + actions: ["merge"], + comment: true, + resolve: true, + verdicts: ["comment", "approve", "request-changes"], + requestReviewers: true, + }, + }), + }); + + const named = yield* detailFrom(readable); + const unnamed = yield* detailFrom({ + ...readable, + getViewer: () => Effect.fail(unusable("github", "unauthenticated")), + }); + + assert.strictEqual(named.viewer, "bilal"); + assert.strictEqual(unnamed.viewer, undefined); + }), +); diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts new file mode 100644 index 000000000000..f12f72bdafac --- /dev/null +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -0,0 +1,2013 @@ +import * as Cache from "effect/Cache"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import { + PullRequestOperationError, + PullRequestUnavailableError, + pullRequestHostOf, + pullRequestProviderRequirement, + resolvePullRequestAuthorFilter, + type OrchestrationProjectShell, + type PullRequestAction, + type PullRequestActionInput, + type PullRequestActivity, + type PullRequestCommentInput, + type PullRequestCommentUpdateInput, + type PullRequestDetail, + type PullRequestDiffFileContentsInput, + type PullRequestDiffFileContentsResult, + type PullRequestDiffStat, + type PullRequestDiffInput, + type PullRequestDiffResult, + type PullRequestInvalidateInput, + type PullRequestListEntry, + type PullRequestListFilters, + type PullRequestListInput, + type PullRequestListProjectError, + type PullRequestListResult, + type PullRequestListStatsInput, + type PullRequestListStatsResult, + type PullRequestProviderSummary, + type PullRequestReactionInput, + type PullRequestRef, + type PullRequestReviewVerdict, + type PullRequestReviewerCandidateList, + type PullRequestReviewerRequestInput, + type PullRequestSubmitReviewInput, + type PullRequestThreadReplyInput, + type PullRequestThreadResolutionInput, + type PullRequestUpdateInput, + type SourceControlProviderInfo, + type SourceControlProviderKind, +} from "@t3tools/contracts"; +import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; + +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; +import { + type ProviderChangeRequest, + type ProviderListCursor, + type PullRequestProviderApi, + type PullRequestProviderError, +} from "./PullRequestProvider.ts"; +import { PullRequestProviderRegistry } from "./PullRequestProviderRegistry.ts"; + +/** + * Rows per repository when the client does not ask for a page size, and rows per slice when a + * listing is carried on from a cursor. + * + * 99 and not 100, because every provider asks its host for one row over this to probe for a next + * page: 99 requests 100, which is exactly what a page of GitHub's API serves — GraphQL refuses + * `first` over 100 with EXCESSIVE_PAGINATION and REST clamps `per_page` to it — and what GitLab + * caps `per_page` at. Asking for 100 here would request 101 and buy a whole second round trip for + * one row (measured: `gh pr list --limit 100` makes 1 HTTP request, `--limit 101` makes 2). + */ +const DEFAULT_REPOSITORY_LIST_LIMIT = 99; +/** + * Repositories read at once. Each one is a CLI process that spends nearly all its wall clock + * waiting on the host, so the useful ceiling is far above the core count; measured over 12 + * repositories on this listing's own command, 4 took ~12.7s, 8 ~8.9s and 12 ~4.9s, with 16 and 24 + * no faster because 12 already reads every repository in one wave. + */ +const REPOSITORY_CONCURRENCY = 12; +/** + * Repositories named in one read across a host. Measured against GitHub's search: six hundred + * `repo:` qualifiers in one query — 14.7KB of it — were all still honoured, and the answer took + * the same three to six seconds at twelve repositories as at four hundred. A hundred is well + * inside that and past the size of a workspace anyone opens, so a larger one reads in a handful + * of searches rather than in a request per repository. + */ +const REPOSITORY_SEARCH_CHUNK = 100; + +/** + * Every read leaves the process — a CLI per repository, against hosts whose limits are low + * (GitHub's search API allows ~30 requests a minute) — so answers are shared for a short + * while and concurrent identical reads share one request. The windows sit near the clients' + * own stale times: long enough that two people opening the same page cost one round trip, + * short enough that "cached" and "fresh" never need telling apart on screen. Reads that + * must not share — the refresh button, a client reloading after its own action — go through + * `invalidate` rather than a flag on the read, so an ordinary read can never opt out. + */ +const LIST_CACHE_TTL = Duration.seconds(30); +const DETAIL_CACHE_TTL = Duration.seconds(15); +const DIFF_CACHE_TTL = Duration.seconds(60); +/** A commit is content-addressed, so its own diff cannot change under its key. */ +const COMMIT_DIFF_CACHE_TTL = Duration.minutes(10); +/** Sized like the client's own stale time; a row's counts move only when somebody pushes. */ +const LIST_STATS_CACHE_TTL = Duration.seconds(60); +/** + * How long a cache's last success may still be served while a fresh read runs behind it. + * Bounded by how the page actually revalidates: clients re-read on mount and once a minute + * while open, and every one of those reads repopulates the cache in the background — so in + * steady use a "stale" answer is at most a refresh cycle old, and the window only stretches + * that far when nobody has looked at the page for minutes. An explicit refresh or a mutation + * bumps the epochs and skips held answers entirely. + */ +const LIST_STALE_WINDOW = Duration.minutes(10); +const DETAIL_STALE_WINDOW = Duration.minutes(5); +const DIFF_STALE_WINDOW = Duration.minutes(10); +/** How long one host's signed-in login is believed without asking its CLI again. */ +const VIEWER_CACHE_TTL = Duration.minutes(10); +const LIST_CACHE_CAPACITY = 64; +const LIST_STATS_CACHE_CAPACITY = 32; +const DETAIL_CACHE_CAPACITY = 128; +const DIFF_CACHE_CAPACITY = 128; + +export type PullRequestError = PullRequestUnavailableError | PullRequestOperationError; + +export class PullRequestService extends Context.Service< + PullRequestService, + { + readonly list: ( + input: PullRequestListInput, + ) => Effect.Effect; + readonly listStats: ( + input: PullRequestListStatsInput, + ) => Effect.Effect; + readonly detail: (input: PullRequestRef) => Effect.Effect; + readonly activity: ( + input: PullRequestRef, + ) => Effect.Effect; + readonly diff: ( + input: PullRequestDiffInput, + ) => Effect.Effect; + readonly diffFileContents: ( + input: PullRequestDiffFileContentsInput, + ) => Effect.Effect; + readonly runAction: (input: PullRequestActionInput) => Effect.Effect; + readonly update: (input: PullRequestUpdateInput) => Effect.Effect; + readonly comment: (input: PullRequestCommentInput) => Effect.Effect; + readonly updateComment: ( + input: PullRequestCommentUpdateInput, + ) => Effect.Effect; + readonly submitReview: ( + input: PullRequestSubmitReviewInput, + ) => Effect.Effect; + readonly replyToThread: ( + input: PullRequestThreadReplyInput, + ) => Effect.Effect; + readonly setThreadResolution: ( + input: PullRequestThreadResolutionInput, + ) => Effect.Effect; + readonly setReaction: ( + input: PullRequestReactionInput, + ) => Effect.Effect; + readonly reviewerCandidates: ( + input: PullRequestRef, + ) => Effect.Effect; + readonly requestReviewers: ( + input: PullRequestReviewerRequestInput, + ) => Effect.Effect; + readonly invalidate: (input: PullRequestInvalidateInput) => Effect.Effect; + } +>()("t3/pullRequest/PullRequestService") {} + +/** What a verdict is called when refusing it, so the sentence reads as an action. */ +const VERDICT_LABELS: Record = { + comment: "review", + approve: "approve", + "request-changes": "request changes on", +}; + +/** + * Why an action is refused to this viewer, said as the access it would take rather than as the + * refusal the host would have answered with. Merging is the one that needs write and nothing + * else; the other four are also the author's to take, whatever access they have. + */ +const ACTION_ACCESS_REFUSALS: Record = { + merge: "You need write access on this repository to merge.", + ready: + "You need write access on this repository, or to have opened this change request, to mark it ready for review.", + draft: + "You need write access on this repository, or to have opened this change request, to return it to a draft.", + close: + "You need write access on this repository, or to have opened this change request, to close it.", + "update-branch": + "You need write access on this repository, or to have opened this change request, to update its branch.", + reopen: + "You need write access on this repository, or to have opened this change request, to reopen it.", + "enable-auto-merge": + "You need write access on this repository to have it merged for you once it is ready.", + "disable-auto-merge": + "You need write access on this repository to stop it being merged for you once it is ready.", +}; + +/** + * Why asking for a review is refused, and why the menu behind it is too. Write access is what the + * hosts that state anything about this want; the ones that state nothing grant it, so this + * sentence is only ever the answer where a host said no. + */ +const REVIEWER_REQUEST_REFUSAL = "You need write access on this repository to ask for a review."; + +/** A project this page can read: its remote is on a host with an implementation. */ +interface SupportedProject { + readonly project: OrchestrationProjectShell; + readonly api: PullRequestProviderApi; + readonly repository: string; + /** The host the repository lives on, which is the account boundary rather than the kind. */ + readonly host: string; +} + +/** + * What the workspace has, split by whether this build can read it. Hosts with no + * implementation are counted rather than dropped, so their projects are explained in the + * provider list instead of quietly missing from the page. + */ +interface WorkspaceProjects { + readonly supported: ReadonlyArray; + /** Keyed by host, as the readable ones are: an unimplemented host is its own switcher entry. */ + readonly unimplemented: ReadonlyMap< + string, + { readonly kind: SourceControlProviderKind; readonly projectCount: number } + >; + /** + * Every checkout on a host, including the ones the listing de-duplicated away. Asking who is + * signed in is a question about the host rather than about a repository, and any checkout can + * answer it — so a broken worktree is not allowed to take the host down with it just because + * it happened to be the one the listing kept. + */ + readonly viewerRoots: ReadonlyMap>; +} + +interface RepositoryBatch { + /** Which repository this slice came from, which is what a cursor for it is filed under. */ + readonly key: string; + readonly entries: ReadonlyArray; + readonly errors: ReadonlyArray; + readonly truncated: boolean; + readonly nextCursor: string | null; +} + +/** What the providers are told, plus the part only the service acts on. */ +interface ListCursor extends ProviderListCursor { + /** + * The rows already handed over at exactly `updatedBefore`. The next read asks for that instant + * inclusively, so these are what keeps it from sending them a second time. + */ + readonly seenAt: ReadonlyArray; +} + +/** + * A continuation as it travels through the page and back. Written out rather than encoded because + * it comes back from a client and has to be believed or refused on sight: everything a host is + * given is either a timestamp of this shape or a number of this length, which is what lets a + * provider drop it into a filter without checking it again. + */ +const LIST_CURSOR_PATTERN = + /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2}))\|(\d{1,9})\|(\d{1,9}(?:,\d{1,9})*)?$/; + +function parseListCursor(raw: string): ListCursor | null { + const match = LIST_CURSOR_PATTERN.exec(raw); + if (match === null) return null; + const seenAt = match[3]; + return { + updatedBefore: match[1]!, + delivered: Number(match[2]), + seenAt: seenAt === undefined ? [] : seenAt.split(",").map(Number), + }; +} + +/** + * How a listing tells two repositories apart. The host is part of it because the same + * `owner/repo` exists on github.com and on an Enterprise install, and they are two repositories. + */ +function listCursorKey(host: string, repository: string): string { + return `${host} ${repository.toLowerCase()}`; +} + +/** + * Where a repository carries on, worked out from the slice just handed over. The boundary is the + * instant of the oldest row in it: the next read asks for that instant and everything before it, + * and names the rows already sent at it so none of them arrives twice. + * + * The names carry over when the boundary has not moved. A slice that ends on the same instant it + * began on has to keep the earlier rows excluded as well as its own, or the read after it would + * hand them over again. + */ +function nextListCursor( + previous: ListCursor | undefined, + /** What the host handed over, before the rows already sent were dropped from it. */ + fetched: ReadonlyArray, + /** What is being sent on, which is what the count of delivered rows is about. */ + delivered: ReadonlyArray, + /** A provider may consume malformed offset-paged rows that never appear in `delivered`. */ + cursorAdvance = delivered.length, +): string | null { + // The host had nothing at all, so there is no row to carry on from — and repeating the cursor + // that produced the empty slice would ask the same question forever. + if (fetched.length === 0) return null; + // Taken from what the host answered rather than from what survived de-duplication: a slice can + // be entirely rows already sent — a hundred change requests touched in the same second is one + // repository's boring afternoon — and reading "nothing new" as "nothing left" would end the + // walk on the instant it was stuck on, with everything older unreachable for good. + const oldest = fetched.reduce((left, right) => (right.updatedAt < left.updatedAt ? right : left)); + return listCursorAt(previous, oldest.updatedAt, fetched, cursorAdvance); +} + +/** + * The same cursor against a boundary chosen elsewhere, which is what a slice read across several + * repositories at once needs: every repository in it is read up to the oldest row of the whole + * slice, including the ones that contributed nothing to it — their rows are simply all older, and + * a repository that carried on from its own oldest row would be right about where it stopped and + * silent about the ones that never appeared. + */ +function listCursorAt( + previous: ListCursor | undefined, + boundary: string, + /** This repository's own rows in the slice, before the ones already sent were dropped. */ + fetched: ReadonlyArray, + deliveredCount: number, +): string { + const seenAt = [ + ...(previous?.updatedBefore === boundary ? previous.seenAt : []), + ...fetched.filter((item) => item.updatedAt === boundary).map((item) => item.number), + ]; + return `${boundary}|${(previous?.delivered ?? 0) + deliveredCount}|${seenAt.join(",")}`; +} + +/** A host that cannot be read at all, as opposed to one request that failed. */ +function isProviderUnusable(error: PullRequestProviderError): boolean { + return error.reason === "missing-tool" || error.reason === "unauthenticated"; +} + +/** + * Why a host is not readable, told as the thing to do about it. A host that is simply not set up + * says so in the same words the whole-page state uses, rather than repeating whatever its tool + * printed — "HTTP 401" names the symptom, not the fix. + */ +function providerDetail(error: PullRequestProviderError): string { + if (!isProviderUnusable(error)) return error.detail; + return ( + pullRequestProviderRequirement( + error.provider, + error.reason === "missing-tool" ? "cli-missing" : "cli-unauthenticated", + ) ?? error.detail + ); +} + +function toUnavailableError(error: PullRequestProviderError): PullRequestUnavailableError { + return new PullRequestUnavailableError({ + reason: error.reason === "missing-tool" ? "cli-missing" : "cli-unauthenticated", + provider: error.provider, + cause: error, + }); +} + +function toPullRequestError( + operation: string, +): (error: PullRequestProviderError) => PullRequestError { + return (error) => + isProviderUnusable(error) + ? toUnavailableError(error) + : new PullRequestOperationError({ operation, detail: error.detail, cause: error }); +} + +/** + * The provider-native repository selector. `displayName` is the full path below the host, which + * is what nested GitLab groups need; owner/name is the two-segment fallback for identities + * recorded before that field existed. + * + * Azure DevOps is the exception: `az repos pr list --repository` takes a repository name, and + * takes the organisation and project from the checkout it detects — so the recorded + * `org/project/_git/repo` path is refused outright and the whole repository reads as + * unavailable. Its name is the last segment, which is what this hands over. + * + * One function because everything downstream is keyed by what it answers: the rows' own + * `repository`, the per-repository cursors, and the detail and diff reads a row leads to. + */ +export function repositoryIdentityOf(project: OrchestrationProjectShell): string | null { + const identity = project.repositoryIdentity; + if (!identity) return null; + if (identity.provider === "azure-devops") { + const segments = (identity.displayName ?? "").split("/").filter((part) => part !== "_git"); + return identity.name || segments.at(-1) || null; + } + if (identity.displayName) return identity.displayName; + return identity.owner && identity.name ? `${identity.owner}/${identity.name}` : null; +} + +export const make = Effect.gen(function* () { + const registry = yield* PullRequestProviderRegistry; + const projections = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const sourceControlProviders = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; + + const refineUnknownProjectKinds = ( + projects: ReadonlyArray, + filter: Pick, + ) => { + type RefinementCandidate = { + readonly project: OrchestrationProjectShell; + readonly provider: SourceControlProviderInfo; + readonly remoteName: string; + readonly remoteUrl: string; + }; + const refinements = new Map(); + for (const project of projects) { + if (filter.projectId !== undefined && project.id !== filter.projectId) continue; + const identity = project.repositoryIdentity; + if (identity?.provider !== "unknown" || repositoryIdentityOf(project) === null) continue; + const host = pullRequestHostOf(identity, "unknown"); + // A legacy identity has no canonical host until its provider is refined, so it must reach + // the refinement before a host filter can decide whether it belongs in the result. + if (filter.host !== undefined && host !== "unknown" && host !== filter.host.toLowerCase()) { + continue; + } + const { remoteName, remoteUrl } = identity.locator; + const provider = detectSourceControlProviderFromRemoteUrl(remoteUrl); + if (provider !== null) { + const candidates = refinements.get(provider.baseUrl); + const candidate = { project, provider, remoteName, remoteUrl }; + if (candidates === undefined) refinements.set(provider.baseUrl, [candidate]); + else candidates.push(candidate); + } + } + + return Effect.forEach( + refinements, + ([baseUrl, candidates]) => + Effect.firstSuccessOf( + candidates.map(({ project, provider, remoteName, remoteUrl }) => + Effect.suspend(() => + sourceControlProviders.resolveHandle({ + cwd: project.workspaceRoot, + context: { provider, remoteName, remoteUrl }, + }), + ).pipe( + Effect.flatMap((handle) => { + const kind = handle.context?.provider.kind; + return kind === undefined || kind === "unknown" + ? Effect.fail(undefined) + : Effect.succeed(kind); + }), + ), + ), + ).pipe( + Effect.map((kind) => [baseUrl, kind] as const), + Effect.orElseSucceed(() => [baseUrl, "unknown"] as const), + ), + { concurrency: REPOSITORY_CONCURRENCY }, + ).pipe(Effect.map((resolved) => new Map(resolved))); + }; + + const listWorkspaceProjects = ( + filter: Pick, + ): Effect.Effect => + projections.getShellSnapshot().pipe( + Effect.mapError( + (error) => + new PullRequestOperationError({ + operation: "listProjects", + detail: "The project list could not be read.", + cause: error, + }), + ), + Effect.flatMap((snapshot) => + refineUnknownProjectKinds(snapshot.projects, filter).pipe( + Effect.map((refinedKinds) => ({ refinedKinds, snapshot })), + ), + ), + Effect.map(({ refinedKinds, snapshot }) => { + const supported: SupportedProject[] = []; + const unimplemented = new Map< + string, + { kind: SourceControlProviderKind; projectCount: number } + >(); + const viewerRoots = new Map(); + const seen = new Set(); + for (const project of snapshot.projects) { + if (filter.projectId !== undefined && project.id !== filter.projectId) continue; + if (filter.projectIds !== undefined && !filter.projectIds.includes(project.id)) continue; + const identity = project.repositoryIdentity; + let kind = identity?.provider as SourceControlProviderKind | undefined; + const repository = repositoryIdentityOf(project); + if (!identity || kind === undefined || repository === null) continue; + // Worktrees of one repository are separate projects; reading the remote once keeps + // the page from repeating every change request per local checkout. The host is part + // of the key, so the same `owner/repo` on two hosts stays two repositories. + if (kind === "unknown") { + const provider = detectSourceControlProviderFromRemoteUrl(identity.locator.remoteUrl); + kind = provider === null ? kind : (refinedKinds.get(provider.baseUrl) ?? kind); + } + const host = pullRequestHostOf(identity, kind); + if (filter.host !== undefined && host !== filter.host.toLowerCase()) continue; + const api = registry.get(kind); + // Recorded before the de-duplication below, so the viewer lookup keeps the alternates + // the listing is about to drop. + if (api !== null) { + const roots = viewerRoots.get(host); + if (roots === undefined) viewerRoots.set(host, [project.workspaceRoot]); + else if (!roots.includes(project.workspaceRoot)) roots.push(project.workspaceRoot); + } + const key = listCursorKey(host, repository); + if (seen.has(key)) continue; + seen.add(key); + if (api === null) { + const counted = unimplemented.get(host); + if (counted === undefined) unimplemented.set(host, { kind, projectCount: 1 }); + else counted.projectCount += 1; + continue; + } + supported.push({ project, api, repository, host }); + } + return { supported, unimplemented, viewerRoots }; + }), + ); + + const requireProject = (ref: PullRequestRef): Effect.Effect => + listWorkspaceProjects({ projectId: ref.projectId }).pipe( + Effect.flatMap(({ supported }): Effect.Effect => { + const match = supported[0]; + if (!match) { + return Effect.fail(new PullRequestUnavailableError({ reason: "provider-unsupported" })); + } + // The repository travels through the client, so it is checked against the project's + // own remote rather than being handed to a provider verbatim. + if (match.repository.toLowerCase() !== ref.repository.trim().toLowerCase()) { + return Effect.fail( + new PullRequestOperationError({ + operation: "resolveRepository", + detail: "The change request does not belong to the selected project.", + }), + ); + } + return Effect.succeed(match); + }), + ); + + /** + * What the signed-in account may do with this change request, asked of the host itself. Every + * write goes through it: the page hides what a viewer may not do, and a request that arrived + * without passing through the page — or after the access behind it was withdrawn — must not be + * handed to a provider on the client's word. Read freshly for that reason, rather than taken + * from whatever the detail said when the page loaded. + */ + const viewerPermissionsOf = (project: SupportedProject, ref: PullRequestRef, operation: string) => + project.api + .getViewerPermissions({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: ref.number, + }) + .pipe(Effect.mapError(toPullRequestError(operation))); + + /** + * The cursors the page sent back, read once before any host is asked anything. Null where the + * page sent none, which is the listing read from its newest row. + */ + const decodeCursors = ( + cursors: PullRequestListInput["cursors"], + ): Effect.Effect | null, PullRequestError> => { + if (cursors === undefined) return Effect.succeed(null); + const decoded = new Map(); + for (const [key, raw] of Object.entries(cursors)) { + const cursor = parseListCursor(raw); + if (cursor === null) { + return Effect.fail( + new PullRequestOperationError({ + operation: "list", + detail: "The list could not be carried on from where it left off.", + }), + ); + } + decoded.set(key, cursor); + } + return Effect.succeed(decoded); + }; + + /** + * One viewer lookup per host, tried across that host's workspaces so a single broken checkout + * cannot hide every healthy repository on it. Per host and not per provider kind: two GitHub + * hosts are two accounts, and the wrong login would misattribute every review request. + * + * Its failure doubles as the answer to "is this host set up", which is what the provider + * switcher shows. + */ + type ResolvedViewer = { + readonly host: string; + readonly kind: SourceControlProviderKind; + readonly viewer: string | null; + readonly error: PullRequestProviderError | null; + }; + // Who is signed in moves on the timescale of `gh auth login`, not of a page visit, yet every + // list read was asking each host's CLI again — a subprocess and a network round trip per host + // per read, three reads per page. Only a success is believed for a while: a failure is the + // "is this host set up" answer the provider switcher shows, and holding it would keep saying + // signed-out after the reader has signed in. + const viewersByHost = new Map(); + + const resolveViewers = ( + projects: ReadonlyArray, + viewerRoots: WorkspaceProjects["viewerRoots"], + ) => + Effect.forEach( + [...new Set(projects.map(({ host }) => host))], + (host) => + Effect.flatMap(Clock.currentTimeMillis, (now): Effect.Effect => { + const held = viewersByHost.get(host); + if (held !== undefined && now - held.at <= Duration.toMillis(VIEWER_CACHE_TTL)) { + return Effect.succeed(held.result); + } + const forHost = projects.filter((project) => project.host === host); + const api = forHost[0]!.api; + // Every checkout on the host, not just the ones that survived de-duplication: one + // unreadable worktree would otherwise report the whole host as signed out. + const roots = + viewerRoots.get(host) ?? forHost.map(({ project }) => project.workspaceRoot); + return Effect.firstSuccessOf(roots.map((cwd) => api.getViewer({ cwd }))).pipe( + Effect.map((viewer) => ({ + host, + kind: api.kind, + viewer: viewer as string | null, + error: null as PullRequestProviderError | null, + })), + Effect.tap((result) => + Effect.map(Clock.currentTimeMillis, (at) => viewersByHost.set(host, { at, result })), + ), + Effect.catch((error) => Effect.succeed({ host, kind: api.kind, viewer: null, error })), + ); + }), + { concurrency: REPOSITORY_CONCURRENCY }, + ); + + /** + * The narrowings a row can be judged by from its own fields, applied here rather than trusted + * to the host. Only GitHub is asked to narrow a listing for itself; every other provider + * answers unnarrowed, and without this pass a draft filter or a label filter would be sent, + * accepted and quietly ignored. Idempotent for the hosts that did narrow. + * + * `checks` is absent because no listed row carries its check state: that one filter is the + * host's alone, and a row nobody narrowed stays rather than being guessed at. + */ + const matchesRowFilters = ( + item: ProviderChangeRequest, + filters: PullRequestListFilters | undefined, + viewer: string, + ): boolean => { + if (filters === undefined) return true; + const labels = item.labels.map((label) => label.name.trim().toLowerCase()); + const holds = (label: string) => labels.includes(label.trim().toLowerCase()); + return ( + (filters.draft === undefined || item.isDraft === (filters.draft === "only")) && + // Judged on the provider row rather than the entry, because the two absences mean + // different things and the entry keeps only one of them: `null` is a host that summarises + // its reviews saying there is no decision yet, which is what "none" asks for, while + // `undefined` is a host that does not summarise at all — an unjudgeable row, left alone + // the way an unreadable check state is. + (filters.review === undefined || + item.reviewDecision === undefined || + (filters.review === "none" + ? item.reviewDecision === null + : item.reviewDecision === filters.review)) && + (filters.labels === undefined || filters.labels.every((group) => group.some(holds))) && + (filters.excludedLabels === undefined || !filters.excludedLabels.some(holds)) && + (filters.author === undefined || + item.author?.login.toLowerCase() === + resolvePullRequestAuthorFilter(filters.author, viewer).toLowerCase()) + ); + }; + + const toEntry = (input: { + readonly project: SupportedProject; + readonly item: ProviderChangeRequest; + readonly viewer: string; + }): PullRequestListEntry => { + const viewer = input.viewer.toLowerCase(); + return { + provider: input.project.api.kind, + host: input.project.host, + projectId: input.project.project.id, + projectTitle: input.project.project.title, + repository: input.project.repository, + number: input.item.number, + title: input.item.title, + url: input.item.url, + author: input.item.author, + headBranch: input.item.headBranch, + baseBranch: input.item.baseBranch, + state: input.item.state, + isDraft: input.item.isDraft, + mergeability: input.item.mergeability, + additions: input.item.additions, + deletions: input.item.deletions, + createdAt: input.item.createdAt, + updatedAt: input.item.updatedAt, + ...(input.item.checksState === undefined || input.item.checksState === null + ? {} + : { checksState: input.item.checksState }), + viewerReviewRequested: + input.item.author?.login.toLowerCase() !== viewer && + input.item.reviewRequestLogins.some((login) => login.toLowerCase() === viewer), + labels: input.item.labels, + ...(input.item.reviewDecision === undefined || input.item.reviewDecision === null + ? {} + : { reviewDecision: input.item.reviewDecision }), + }; + }; + + const listUncached: PullRequestService["Service"]["list"] = (input) => + Effect.gen(function* () { + const involvement = input.involvement ?? "all"; + // Refused whole rather than per repository: a cursor is only ever a value this service + // issued, so one that does not read as one means the page is sending something it made up, + // and reading part of the listing under that assumption would quietly lose rows. + const continuation = yield* decodeCursors(input.cursors); + const { + supported: projects, + unimplemented, + viewerRoots, + } = yield* listWorkspaceProjects(input); + const projectCounts = new Map(); + for (const { host } of projects) { + projectCounts.set(host, (projectCounts.get(host) ?? 0) + 1); + } + + const viewerResults = yield* resolveViewers(projects, viewerRoots); + const viewers: Record = {}; + for (const result of viewerResults) { + if (result.viewer !== null) viewers[result.host] = result.viewer; + } + + // One summary per host, which is what the viewer lookup already answers for: two GitHub + // hosts sign in separately, so collapsing them by kind would report one as the other. + const providers: ReadonlyArray = [ + ...viewerResults.map((result) => ({ + host: result.host, + kind: result.kind, + searchesOnHost: + projects.find((project) => project.host === result.host)?.api.capabilities.search ?? + false, + projectCount: projectCounts.get(result.host) ?? 1, + configured: result.viewer !== null, + detail: result.error === null ? null : providerDetail(result.error), + })), + ...[...unimplemented].map(([host, { kind, projectCount }]) => ({ + host, + kind, + searchesOnHost: false, + projectCount, + configured: false, + detail: "This host cannot be browsed here yet.", + })), + ]; + + // A continued listing reads only the repositories it was asked to carry on with: every + // other one is already on the page, and reading it again is the whole cost this is here to + // avoid. The host summaries above stay over the whole workspace, because the switcher they + // fill is about the workspace rather than about this slice. + const selected = + continuation === null + ? projects + : projects.filter(({ host, repository }) => + continuation.has(listCursorKey(host, repository)), + ); + const readable = selected.filter(({ host }) => viewers[host] !== undefined); + // A host that could not be read still has projects, and they are absent from the list. + // Reporting them keeps "N repositories were unavailable" honest instead of dropping them. + const unreadable = selected + .filter(({ host }) => viewers[host] === undefined) + .map(({ project, repository }) => ({ + projectId: project.id, + projectTitle: project.title, + message: `${repository} could not be read.`, + })); + if (readable.length === 0) { + // No host this request covers can be read, so it is not a per-project problem. An + // unusable host is preferred as the reported cause because it names the fix; a host + // that merely failed reports as a failed operation rather than as a signed-out CLI, + // which would send the reader to `auth login` over a transient error. + // + // Only the hosts this request was actually going to read: a continuation that named + // nothing has asked for nothing, and a host it never mentioned being signed out is no + // reason to refuse it. + const errors = viewerResults.flatMap((result) => + result.error === null || !selected.some(({ host }) => host === result.host) + ? [] + : [result.error], + ); + const blocking = errors.find(isProviderUnusable) ?? errors[0]; + if (blocking) { + return yield* toPullRequestError("list")(blocking); + } + + return { + viewers: viewers as PullRequestListResult["viewers"], + providers, + entries: [], + errors: [], + truncated: false, + nextCursors: {}, + }; + } + + const limit = input.limit ?? DEFAULT_REPOSITORY_LIST_LIMIT; + const cursorOf = (project: SupportedProject): ListCursor | undefined => + continuation?.get(listCursorKey(project.host, project.repository)); + + /** + * One repository asked on its own. What every host without a search across repositories + * does, and what a batched read falls back to for a repository it could not answer for. + */ + const readRepository = (project: SupportedProject): Effect.Effect => { + { + const viewer = viewers[project.host]!; + const key = listCursorKey(project.host, project.repository); + const cursor = cursorOf(project); + return project.api + .listChangeRequests({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + state: input.state, + involvement, + viewer, + limit, + // Each host matches this its own way, and one that cannot match text at all + // answers unnarrowed rather than failing. + query: input.query, + filters: input.filters, + // Only the two fields a host can act on: which rows have already been sent at the + // boundary instant is this service's business, not a provider's. + ...(cursor === undefined + ? {} + : { + cursor: { updatedBefore: cursor.updatedBefore, delivered: cursor.delivered }, + }), + }) + .pipe( + Effect.map((page): RepositoryBatch => { + // The boundary instant was asked for inclusively, so the rows already sent at it + // come back with the slice. Dropping them here rather than asking for strictly + // older is what keeps their neighbours at the same instant from being skipped. + const items = + cursor === undefined + ? page.items + : page.items.filter( + (item) => + item.updatedAt !== cursor.updatedBefore || + !cursor.seenAt.includes(item.number), + ); + return { + key, + entries: items + .filter((item) => matchesRowFilters(item, input.filters, viewer)) + .map((item) => toEntry({ project, item, viewer })), + errors: [], + truncated: page.truncated, + nextCursor: + page.continues && page.truncated + ? nextListCursor(cursor, page.items, items, page.cursorAdvance) + : null, + }; + }), + // One unreachable repository must not blank the page. A host-level failure is + // already reported through `providers`, so it degrades the same way here. + Effect.orElseSucceed( + (): RepositoryBatch => ({ + key, + entries: [], + errors: [ + { + projectId: project.project.id, + projectTitle: project.project.title, + message: `${project.repository} could not be read.`, + }, + ], + truncated: false, + nextCursor: null, + }), + ), + ); + } + }; + + /** + * One host's repositories in one read. The slice is the newest `limit` rows across all of + * them, so it is split back up by repository here: the page still reports per project, and + * each repository still carries on from a cursor of its own. + * + * A read that fails is read the long way instead. The batch is an optimisation, and a host + * that could not answer one question about twelve repositories should not report twelve + * repositories as unreadable before anyone has asked it about them one at a time. + */ + const readTogether = ( + chunk: ReadonlyArray, + ): Effect.Effect> => { + const first = chunk[0]!; + const readAcross = first.api.listChangeRequestsAcross; + const separately = () => + Effect.forEach(chunk, readRepository, { concurrency: REPOSITORY_CONCURRENCY }); + if (readAcross === undefined) return separately(); + const viewer = viewers[first.host]!; + const cursor = cursorOf(first); + return readAcross({ + cwd: first.project.workspaceRoot, + host: first.host, + repositories: chunk.map((project) => project.repository), + state: input.state, + involvement, + viewer, + limit, + query: input.query, + filters: input.filters, + ...(cursor === undefined + ? {} + : { cursor: { updatedBefore: cursor.updatedBefore, delivered: cursor.delivered } }), + }).pipe( + Effect.flatMap((page) => { + const rows = new Map>(); + for (const item of page.items) { + const key = item.repository.trim().toLowerCase(); + const held = rows.get(key); + if (held === undefined) rows.set(key, [item]); + else held.push(item); + } + // The oldest row of the whole slice, which is how far every repository in it has now + // been read — including the ones that contributed nothing to it. + const boundary = page.items.reduce( + (oldest, item) => + oldest === null || item.updatedAt < oldest ? item.updatedAt : oldest, + null, + ); + return Effect.forEach( + chunk, + (project): Effect.Effect => { + const fetched = rows.get(project.repository.trim().toLowerCase()) ?? []; + // GitHub does not index every repository for search — a renamed one answers for + // its old name with silence rather than with an error — so a repository the + // search said nothing at all about is read on its own, once, before it is + // believed. Only on its first slice: after that it has a boundary to carry on + // from, and silence past one means the rows are older rather than absent. That + // keeps a search-invisible repository from disappearing on a busy host, at the + // price of one request per repository with nothing in the first slice — which + // run together, and only there. + if (fetched.length === 0 && cursorOf(project) === undefined) { + return readRepository(project); + } + const cursorHere = cursorOf(project); + const items = + cursorHere === undefined + ? fetched + : fetched.filter( + (item) => + item.updatedAt !== cursorHere.updatedBefore || + !cursorHere.seenAt.includes(item.number), + ); + return Effect.succeed({ + key: listCursorKey(project.host, project.repository), + entries: items + .filter((item) => matchesRowFilters(item, input.filters, viewer)) + .map((item) => toEntry({ project, item, viewer })), + errors: [], + truncated: page.truncated, + nextCursor: + page.truncated && boundary !== null + ? listCursorAt(cursorHere, boundary, fetched, items.length) + : null, + }); + }, + { concurrency: REPOSITORY_CONCURRENCY }, + ); + }), + Effect.catch(separately), + ); + }; + + // A host with a search across repositories is asked once for all of them; everyone else is + // asked once each. Repositories standing at different points of the same listing are + // different questions, so they are grouped by the boundary they carry on from. + const together = new Map>(); + const separate: Array = []; + for (const project of readable) { + if (project.api.listChangeRequestsAcross === undefined) { + separate.push(project); + continue; + } + const key = `${project.host}\n${cursorOf(project)?.updatedBefore ?? ""}`; + const group = together.get(key); + if (group === undefined) together.set(key, [project]); + else group.push(project); + } + const reads: Array>> = separate.map((project) => + readRepository(project).pipe(Effect.map((batch) => [batch])), + ); + for (const group of together.values()) { + for (let start = 0; start < group.length; start += REPOSITORY_SEARCH_CHUNK) { + reads.push(readTogether(group.slice(start, start + REPOSITORY_SEARCH_CHUNK))); + } + } + const batches = (yield* Effect.all(reads, { concurrency: REPOSITORY_CONCURRENCY })).flat(); + + const nextCursors: Record = {}; + for (const batch of batches) { + if (batch.nextCursor !== null) nextCursors[batch.key] = batch.nextCursor; + } + + return { + viewers: viewers as PullRequestListResult["viewers"], + providers, + entries: batches + .flatMap((batch) => batch.entries) + .toSorted((left, right) => right.updatedAt.localeCompare(left.updatedAt)), + errors: [...unreadable, ...batches.flatMap((batch) => batch.errors)], + truncated: batches.some((batch) => batch.truncated), + nextCursors, + }; + }); + + /** + * Who this project's host says the reader is. Shared with the listing's own lookup — the same + * ten-minute answer per host — so a page that has already listed anything pays nothing for it, + * and a host that cannot say leaves it null rather than failing the read it decorates. + */ + const viewerOf = (project: SupportedProject): Effect.Effect => + resolveViewers([project], new Map()).pipe(Effect.map(([resolved]) => resolved?.viewer ?? null)); + + const detailUncached: PullRequestService["Service"]["detail"] = (input) => + requireProject(input).pipe( + Effect.flatMap((project) => + Effect.all( + [ + project.api + .getChangeRequest({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + }) + .pipe(Effect.mapError(toPullRequestError("detail"))), + viewerOf(project), + ], + { concurrency: 2 }, + ).pipe( + Effect.map( + ([changeRequest, viewer]): PullRequestDetail => ({ + provider: project.api.kind, + capabilities: project.api.capabilities, + projectId: project.project.id, + projectTitle: project.project.title, + workspaceRoot: project.project.workspaceRoot, + repository: project.repository, + number: changeRequest.number, + title: changeRequest.title, + body: changeRequest.body, + url: changeRequest.url, + author: changeRequest.author, + state: changeRequest.state, + isDraft: changeRequest.isDraft, + mergeability: changeRequest.mergeability, + additions: changeRequest.additions, + deletions: changeRequest.deletions, + changedFiles: changeRequest.changedFiles, + headBranch: changeRequest.headBranch, + baseBranch: changeRequest.baseBranch, + createdAt: changeRequest.createdAt, + updatedAt: changeRequest.updatedAt, + mergedAt: changeRequest.mergedAt, + closedAt: changeRequest.closedAt, + reviewers: changeRequest.reviewers, + labels: changeRequest.labels, + checks: changeRequest.checks, + mergeCapabilities: changeRequest.mergeCapabilities, + viewerPermissions: changeRequest.viewerPermissions, + ...(viewer === null || viewer.trim().length === 0 ? {} : { viewer }), + ...(changeRequest.baseComparison === undefined + ? {} + : { baseComparison: changeRequest.baseComparison }), + ...(changeRequest.behindBy === undefined ? {} : { behindBy: changeRequest.behindBy }), + ...(changeRequest.autoMergeEnabled === undefined + ? {} + : { autoMergeEnabled: changeRequest.autoMergeEnabled }), + }), + ), + ), + ), + ); + + const activityUncached: PullRequestService["Service"]["activity"] = (input) => + requireProject(input).pipe( + Effect.flatMap((project) => + project.api + .getChangeRequestActivity({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + }) + .pipe( + Effect.mapError(toPullRequestError("activity")), + Effect.map( + (activity): PullRequestActivity => ({ + ...(activity.author === undefined ? {} : { author: activity.author }), + ...(activity.reviewers === undefined ? {} : { reviewers: activity.reviewers }), + comments: activity.comments, + commentCount: activity.commentCount, + commentsTruncated: activity.commentsTruncated, + reviewThreads: activity.reviewThreads, + commits: activity.commits, + ...(activity.reactions === undefined ? {} : { reactions: activity.reactions }), + }), + ), + ), + ), + ); + + const diffUncached: PullRequestService["Service"]["diff"] = (input) => + requireProject(input).pipe( + Effect.flatMap((project) => + project.api.capabilities.diff + ? project.api + .getDiff({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + ...(input.cursor === undefined ? {} : { cursor: input.cursor }), + ...(input.commit === undefined ? {} : { commit: input.commit }), + }) + .pipe(Effect.mapError(toPullRequestError("diff"))) + : Effect.fail( + new PullRequestOperationError({ + operation: "diff", + detail: "This host cannot provide a diff for a change request.", + }), + ), + ), + ); + + const diffFileContents: PullRequestService["Service"]["diffFileContents"] = (input) => + requireProject(input).pipe( + Effect.flatMap((project) => { + const read = project.api.getDiffFileContents; + return project.api.capabilities.diff && read + ? read({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + ...(input.commit === undefined ? {} : { commit: input.commit }), + changeType: input.changeType, + oldPath: input.oldPath, + newPath: input.newPath, + }).pipe(Effect.mapError(toPullRequestError("diffFileContents"))) + : Effect.fail( + new PullRequestOperationError({ + operation: "diffFileContents", + detail: "This host cannot expand unchanged pull request lines.", + }), + ); + }), + ); + + const runAction: PullRequestService["Service"]["runAction"] = (input) => + requireProject(input).pipe( + Effect.flatMap((project): Effect.Effect => { + // The surface hides what a host cannot do, and this refuses it as well: a request that + // reached here anyway must not be handed to a provider that never claimed the action. + if (!project.api.capabilities.actions.includes(input.action)) { + return Effect.fail( + new PullRequestOperationError({ + operation: "runAction", + detail: `This host cannot ${input.action} a change request.`, + }), + ); + } + // A strategy the host does not offer must be refused rather than passed on: every + // provider maps an unrecognised method to its own default, so asking Azure DevOps to + // rebase would quietly merge instead of failing. + if ( + input.mergeMethod !== undefined && + !project.api.capabilities.mergeMethods.includes(input.mergeMethod) + ) { + return Effect.fail( + new PullRequestOperationError({ + operation: "runAction", + detail: `This host cannot merge with the ${input.mergeMethod} strategy.`, + }), + ); + } + // The same for the way a stale branch is brought up to date: a host that only merges + // must not be asked to rebase and left to pick something else. + if ( + input.updateMethod !== undefined && + !(project.api.capabilities.updateMethods ?? []).includes(input.updateMethod) + ) { + return Effect.fail( + new PullRequestOperationError({ + operation: "runAction", + detail: `This host cannot update a branch by ${input.updateMethod}.`, + }), + ); + } + // What the host can do and what this account may ask of it are two questions, and both + // have to say yes. The second is asked last, because it costs a request and the checks + // above do not. + return viewerPermissionsOf(project, input, "runAction").pipe( + Effect.flatMap((viewer): Effect.Effect => { + if (!viewer.actions.includes(input.action)) { + return Effect.fail( + new PullRequestOperationError({ + operation: "runAction", + detail: ACTION_ACCESS_REFUSALS[input.action], + }), + ); + } + if ( + input.updateMethod !== undefined && + !(viewer.updateMethods ?? []).includes(input.updateMethod) + ) { + return Effect.fail( + new PullRequestOperationError({ + operation: "runAction", + detail: ACTION_ACCESS_REFUSALS["update-branch"], + }), + ); + } + return project.api + .runAction({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + action: input.action, + ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }), + ...(input.updateMethod === undefined ? {} : { updateMethod: input.updateMethod }), + }) + .pipe(Effect.mapError(toPullRequestError("runAction"))); + }), + ); + }), + ); + + const comment: PullRequestService["Service"]["comment"] = (input) => + // The contract keeps the body verbatim because it is markdown, so the "did the user + // actually write something" check lives here. + (input.body.trim().length === 0 + ? Effect.fail( + new PullRequestOperationError({ + operation: "comment", + detail: "A comment cannot be empty.", + }), + ) + : requireProject(input) + ).pipe( + Effect.flatMap((project): Effect.Effect => { + if (!project.api.capabilities.comment) { + return Effect.fail( + new PullRequestOperationError({ + operation: "comment", + detail: "This host cannot post a comment on a change request.", + }), + ); + } + return viewerPermissionsOf(project, input, "comment").pipe( + Effect.flatMap((viewer): Effect.Effect => { + if (!viewer.comment) { + return Effect.fail( + new PullRequestOperationError({ + operation: "comment", + detail: + "You need write access on this repository to comment on a change request.", + }), + ); + } + return project.api + .comment({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + body: input.body, + }) + .pipe(Effect.mapError(toPullRequestError("comment"))); + }), + ); + }), + ); + + /** + * Rewriting the change request's own words, and rewriting a remark, are both left to the host to + * allow or refuse. Neither is a question a permission read answers: every host lets the person + * who wrote something rewrite it whatever access they have otherwise, and none of them reports + * that as a permission — so a check here could only guess, and a wrong guess takes the control + * away from the one person certain to be allowed. + */ + const update: PullRequestService["Service"]["update"] = (input) => + requireProject(input).pipe( + Effect.flatMap((project): Effect.Effect => { + const rewrite = project.api.updateChangeRequest; + if (project.api.capabilities.edit?.changeRequest !== true || rewrite === undefined) { + return Effect.fail( + new PullRequestOperationError({ + operation: "update", + detail: "This host cannot rewrite a change request.", + }), + ); + } + if (input.title === undefined && input.body === undefined) { + return Effect.fail( + new PullRequestOperationError({ + operation: "update", + detail: "Nothing was changed.", + }), + ); + } + return rewrite({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + ...(input.title === undefined ? {} : { title: input.title }), + ...(input.body === undefined ? {} : { body: input.body }), + }).pipe(Effect.mapError(toPullRequestError("update"))); + }), + ); + + const updateComment: PullRequestService["Service"]["updateComment"] = (input) => + (input.body.trim().length === 0 + ? Effect.fail( + new PullRequestOperationError({ + operation: "updateComment", + detail: "A comment cannot be empty.", + }), + ) + : requireProject(input) + ).pipe( + Effect.flatMap((project): Effect.Effect => { + const rewrite = project.api.updateComment; + if (project.api.capabilities.edit?.comment !== true || rewrite === undefined) { + return Effect.fail( + new PullRequestOperationError({ + operation: "updateComment", + detail: "This host cannot rewrite a comment.", + }), + ); + } + return rewrite({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + commentId: input.commentId, + kind: input.kind, + body: input.body, + }).pipe(Effect.mapError(toPullRequestError("updateComment"))); + }), + ); + + const submitReview: PullRequestService["Service"]["submitReview"] = (input) => + requireProject(input).pipe( + Effect.flatMap((project): Effect.Effect => { + const review = project.api.capabilities.review; + const refuse = (detail: string) => + Effect.fail(new PullRequestOperationError({ operation: "submitReview", detail })); + // The surface hides what a host cannot do, and this refuses it as well: a request that + // reached here anyway must not be handed to a provider that never claimed it. + if (!review.verdicts.includes(input.verdict)) { + return refuse(`This host cannot ${VERDICT_LABELS[input.verdict]} a change request.`); + } + if (input.comments.length > 0 && !review.inlineComment) { + return refuse("This host cannot comment on a line of a change request."); + } + // A verdict with nothing attached to it is a request every host rejects, and doing so + // here says which of the two is missing rather than reporting the host's refusal. + if ( + input.verdict !== "approve" && + input.body.trim().length === 0 && + input.comments.length === 0 + ) { + return refuse("A review needs a summary or at least one comment."); + } + return viewerPermissionsOf(project, input, "submitReview").pipe( + Effect.flatMap((viewer): Effect.Effect => { + if (!viewer.verdicts.includes(input.verdict)) { + return refuse( + `You need write access on this repository to ${ + VERDICT_LABELS[input.verdict] + } a change request.`, + ); + } + if (input.comments.length > 0 && !viewer.comment) { + return refuse( + "You need write access on this repository to comment on a line of a change request.", + ); + } + return project.api + .submitReview({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + verdict: input.verdict, + body: input.body, + comments: input.comments, + }) + .pipe(Effect.mapError(toPullRequestError("submitReview"))); + }), + ); + }), + ); + + const replyToThread: PullRequestService["Service"]["replyToThread"] = (input) => + (input.body.trim().length === 0 + ? Effect.fail( + new PullRequestOperationError({ + operation: "replyToThread", + detail: "A reply cannot be empty.", + }), + ) + : requireProject(input) + ).pipe( + Effect.flatMap((project): Effect.Effect => { + if (!project.api.capabilities.review.reply) { + return Effect.fail( + new PullRequestOperationError({ + operation: "replyToThread", + detail: "This host cannot reply to a review conversation.", + }), + ); + } + return viewerPermissionsOf(project, input, "replyToThread").pipe( + Effect.flatMap((viewer): Effect.Effect => { + if (!viewer.comment) { + return Effect.fail( + new PullRequestOperationError({ + operation: "replyToThread", + detail: + "You need write access on this repository to reply to a review conversation.", + }), + ); + } + return project.api + .replyToThread({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + threadId: input.threadId, + body: input.body, + }) + .pipe(Effect.mapError(toPullRequestError("replyToThread"))); + }), + ); + }), + ); + + const setThreadResolution: PullRequestService["Service"]["setThreadResolution"] = (input) => + requireProject(input).pipe( + Effect.flatMap((project): Effect.Effect => { + if (!project.api.capabilities.review.resolve) { + return Effect.fail( + new PullRequestOperationError({ + operation: "setThreadResolution", + detail: "This host cannot resolve a review conversation.", + }), + ); + } + return viewerPermissionsOf(project, input, "setThreadResolution").pipe( + Effect.flatMap((viewer): Effect.Effect => { + if (!viewer.resolve) { + return Effect.fail( + new PullRequestOperationError({ + operation: "setThreadResolution", + detail: + "You need write access on this repository, or to have opened this change request, to resolve a review conversation.", + }), + ); + } + return project.api + .setThreadResolution({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + threadId: input.threadId, + resolved: input.resolved, + }) + .pipe(Effect.mapError(toPullRequestError("setThreadResolution"))); + }), + ); + }), + ); + + /** + * Reacting is gated on the host alone. Every host with reactions takes one from whoever can read + * the change request, so there is no access left to check that reading it has not already + * settled. + */ + const setReaction: PullRequestService["Service"]["setReaction"] = (input) => + requireProject(input).pipe( + Effect.flatMap((project): Effect.Effect => { + if (project.api.capabilities.reactions !== true) { + return Effect.fail( + new PullRequestOperationError({ + operation: "setReaction", + detail: "This host has no reactions.", + }), + ); + } + return project.api + .setReaction({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + ...(input.subjectId === undefined ? {} : { subjectId: input.subjectId }), + content: input.content, + reacted: input.reacted, + }) + .pipe(Effect.mapError(toPullRequestError("setReaction"))); + }), + ); + + /** + * Who may be asked is only ever wanted by somebody about to ask, because the menu it fills is + * the one the request is made from. So the same permission guards both: a page that could open + * the menu without it would offer a list whose every press was going to be turned down. + */ + const reviewerCandidates: PullRequestService["Service"]["reviewerCandidates"] = (input) => + requireProject(input).pipe( + Effect.flatMap( + (project): Effect.Effect => { + if (!project.api.capabilities.reviewers.listCandidates) { + return Effect.fail( + new PullRequestOperationError({ + operation: "reviewerCandidates", + detail: "This host cannot say who may review a change request.", + }), + ); + } + return viewerPermissionsOf(project, input, "reviewerCandidates").pipe( + Effect.flatMap( + (viewer): Effect.Effect => + viewer.requestReviewers + ? project.api + .listReviewerCandidates({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + }) + .pipe(Effect.mapError(toPullRequestError("reviewerCandidates"))) + : Effect.fail( + new PullRequestOperationError({ + operation: "reviewerCandidates", + detail: REVIEWER_REQUEST_REFUSAL, + }), + ), + ), + ); + }, + ), + ); + + const requestReviewers: PullRequestService["Service"]["requestReviewers"] = (input) => + requireProject(input).pipe( + Effect.flatMap((project): Effect.Effect => { + if (!project.api.capabilities.reviewers.request) { + return Effect.fail( + new PullRequestOperationError({ + operation: "requestReviewers", + detail: "This host cannot ask somebody for a review.", + }), + ); + } + return viewerPermissionsOf(project, input, "requestReviewers").pipe( + Effect.flatMap((viewer): Effect.Effect => { + if (!viewer.requestReviewers) { + return Effect.fail( + new PullRequestOperationError({ + operation: "requestReviewers", + detail: REVIEWER_REQUEST_REFUSAL, + }), + ); + } + return project.api + .setReviewerRequest({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + reviewers: input.reviewers, + requested: input.requested, + }) + .pipe(Effect.mapError(toPullRequestError("requestReviewers"))); + }), + ); + }), + ); + + /** + * The line counts for rows already on the page, which the listing left out because on GitHub + * they cost more than everything else on the row put together. + * + * One read per host rather than per row, and only for a host whose listing defers them; a row + * whose host answered with the counts in the first place is not here to be asked about. A ref + * that names no project this workspace has, or a repository that is not the one the project's + * remote points at, is dropped rather than refused: it is one row's two numbers, and the page + * that asked has already moved on. + */ + const listStatsUncached: PullRequestService["Service"]["listStats"] = (input) => + Effect.gen(function* () { + if (input.refs.length === 0) return { stats: [] }; + const { supported } = yield* listWorkspaceProjects({}); + const byProject = new Map(supported.map((project) => [project.project.id, project])); + const wanted = new Map< + string, + { readonly project: SupportedProject; readonly number: number } + >(); + for (const ref of input.refs) { + const project = byProject.get(ref.projectId); + // The repository travels through the client, so it is checked against the project's own + // remote rather than being handed to a provider verbatim. + if ( + project === undefined || + project.api.listChangeRequestStats === undefined || + project.repository.toLowerCase() !== ref.repository.trim().toLowerCase() + ) { + continue; + } + wanted.set(`${project.project.id} ${ref.number}`, { project, number: ref.number }); + } + const byHost = new Map>(); + for (const entry of wanted.values()) { + const held = byHost.get(entry.project.host); + if (held === undefined) byHost.set(entry.project.host, [entry]); + else held.push(entry); + } + const stats = yield* Effect.forEach( + [...byHost.values()], + (entries) => { + const first = entries[0]!; + const readStats = first.project.api.listChangeRequestStats; + if (readStats === undefined) + return Effect.succeed>([]); + const projectsByRepository = new Map( + entries.map((entry) => [ + `${entry.project.repository.toLowerCase()} ${entry.number}`, + entry.project, + ]), + ); + return readStats({ + cwd: first.project.project.workspaceRoot, + host: first.project.host, + changeRequests: entries.map((entry) => ({ + repository: entry.project.repository, + number: entry.number, + })), + }).pipe( + Effect.map((read) => + read.flatMap((stat): ReadonlyArray => { + const project = projectsByRepository.get( + `${stat.repository.toLowerCase()} ${stat.number}`, + ); + return project === undefined + ? [] + : [ + { + projectId: project.project.id, + repository: project.repository, + number: stat.number, + additions: stat.additions, + deletions: stat.deletions, + }, + ]; + }), + ), + // A row without its counts is a row the page already draws without them, so a host + // that could not answer costs the numbers rather than the answer. + Effect.orElseSucceed((): ReadonlyArray => []), + ); + }, + { concurrency: REPOSITORY_CONCURRENCY }, + ); + return { stats: stats.flat() }; + }); + + const context = yield* Effect.context(); + const runFork = Effect.runForkWith(context); + + /** + * Stale answers served while a fresh one is fetched behind them. Every read here leaves the + * process for a CLI whose wall clock is the host's — seconds on a good day, tens of them on a + * slow network — and the short cache windows below mean almost every page visit pays that + * clock again. The last success per key is therefore held a while longer: a read inside the + * window answers with it at once and refreshes the cache in the background, so the next read + * is fresh without anyone having waited on it. + * + * Correctness leans on the epochs: an explicit refresh or a mutation bumps them, the epoch is + * part of every key, and a held answer under the old key is simply never asked for again — so + * "give me truly fresh" still means exactly that. + */ + const staleWhileRevalidate = (staleFor: Duration.Duration, capacity: number) => { + const staleMs = Duration.toMillis(staleFor); + const held = new Map(); + const record = (key: string, value: A) => + Effect.map(Clock.currentTimeMillis, (at) => { + held.delete(key); + if (held.size >= capacity) { + const oldest = held.keys().next().value; + if (oldest !== undefined) held.delete(oldest); + } + held.set(key, { at, value }); + }); + return (key: string, read: Effect.Effect): Effect.Effect => { + const recorded = read.pipe(Effect.tap((value) => record(key, value))); + return Effect.flatMap(Clock.currentTimeMillis, (now) => { + const snapshot = held.get(key); + if (snapshot === undefined || now - snapshot.at > staleMs) return recorded; + // Run as its own fiber rather than a child: the caller is answered and gone before the + // refresh lands. The read still coalesces on the cache key, so ten stale reads in one + // window cost one host request — and a failed refresh costs nothing but the retry. + return Effect.sync(() => runFork(Effect.ignore(recorded))).pipe(Effect.as(snapshot.value)); + }); + }; + }; + + // Epochs are the invalidation mechanism: a key carries its scope's epoch, so bumping the + // epoch strands every entry made under the old one — no enumerating a cache whose keys + // (cursors, commits) nothing holds a list of. The counter is shared and monotonic so a + // scope re-entering `refEpochs` after eviction can never mint a key an old entry still has. + let epochCounter = 0; + let listingsEpoch = 0; + const refEpochs = new Map(); + const REF_EPOCH_CAPACITY = 2_048; + const refScope = (ref: PullRequestRef) => `${ref.projectId} ${ref.repository} ${ref.number}`; + const refEpoch = (ref: PullRequestRef) => refEpochs.get(refScope(ref)) ?? 0; + const bumpRefEpoch = (ref: PullRequestRef) => { + const scope = refScope(ref); + if (!refEpochs.has(scope) && refEpochs.size >= REF_EPOCH_CAPACITY) { + const oldest = refEpochs.keys().next().value; + if (oldest !== undefined) refEpochs.delete(oldest); + } + refEpochs.set(scope, ++epochCounter); + }; + + /** The positional filter slot of a cache key, back as the record `listUncached` takes. */ + const filtersOfKey = ( + slots: ReadonlyArray< + string | ReadonlyArray | ReadonlyArray> | null + >, + ): PullRequestListFilters => { + const [draft, review, checks, author, labels, excludedLabels] = slots; + return { + ...(typeof draft === "string" ? { draft: draft as "only" | "hide" } : {}), + ...(typeof review === "string" ? { review: review as PullRequestListFilters["review"] } : {}), + ...(typeof checks === "string" ? { checks: checks as PullRequestListFilters["checks"] } : {}), + ...(typeof author === "string" ? { author } : {}), + ...(Array.isArray(labels) ? { labels: labels as ReadonlyArray> } : {}), + ...(Array.isArray(excludedLabels) ? { excludedLabels } : {}), + }; + }; + + // Keys serialize positionally and parse back in the lookup, so the cache is the only holder + // of in-flight state: concurrent identical reads coalesce on the key into one host request. + // The continuation cursors are part of the key, entries sorted so one continuation is one + // key however its record was assembled — a further slice is its own answer, cached like any. + const listCache = yield* Cache.makeWith( + (key: string) => { + // The parse undoes this module's own serialization, so the shapes are known exactly; + // the cast restores the branded field types JSON cannot carry. + const [ + , + state, + involvement, + filters, + projectId, + projectIds, + host, + limit, + query, + cursorEntries, + ] = JSON.parse(key) as [ + number, + string, + string | null, + ReadonlyArray | null> | null, + string | null, + ReadonlyArray | null, + string | null, + number | null, + string | null, + ReadonlyArray<[string, string]> | null, + ]; + return listUncached({ + state, + ...(involvement === null ? {} : { involvement }), + ...(filters === null ? {} : { filters: filtersOfKey(filters) }), + ...(projectId === null ? {} : { projectId }), + ...(projectIds === null ? {} : { projectIds }), + ...(host === null ? {} : { host }), + ...(limit === null ? {} : { limit }), + ...(query === null ? {} : { query }), + ...(cursorEntries === null ? {} : { cursors: Object.fromEntries(cursorEntries) }), + } as PullRequestListInput); + }, + { + capacity: LIST_CACHE_CAPACITY, + timeToLive: (exit) => (Exit.isSuccess(exit) ? LIST_CACHE_TTL : Duration.zero), + }, + ); + const staleList = staleWhileRevalidate( + LIST_STALE_WINDOW, + LIST_CACHE_CAPACITY, + ); + const list: PullRequestService["Service"]["list"] = (input) => { + const key = JSON.stringify([ + listingsEpoch, + input.state, + input.involvement ?? null, + // Positional so two identical filter sets key alike however their record was assembled. + input.filters === undefined + ? null + : [ + input.filters.draft ?? null, + input.filters.review ?? null, + input.filters.checks ?? null, + input.filters.author ?? null, + input.filters.labels ?? null, + input.filters.excludedLabels ?? null, + ], + input.projectId ?? null, + // Sorted so the same narrowing keys alike however the caller ordered it. + input.projectIds === undefined ? null : [...input.projectIds].sort(), + input.host ?? null, + input.limit ?? null, + input.query ?? null, + input.cursors === undefined + ? null + : Object.entries(input.cursors).toSorted(([left], [right]) => left.localeCompare(right)), + ]); + return staleList(key, Cache.get(listCache, key)); + }; + + const detailCache = yield* Cache.makeWith( + (key: string) => { + const [, projectId, repository, number] = JSON.parse(key) as [number, string, string, number]; + return detailUncached({ projectId, repository, number } as PullRequestRef); + }, + { + capacity: DETAIL_CACHE_CAPACITY, + timeToLive: (exit) => (Exit.isSuccess(exit) ? DETAIL_CACHE_TTL : Duration.zero), + }, + ); + const staleDetail = staleWhileRevalidate( + DETAIL_STALE_WINDOW, + DETAIL_CACHE_CAPACITY, + ); + const detail: PullRequestService["Service"]["detail"] = (input) => { + const key = JSON.stringify([refEpoch(input), input.projectId, input.repository, input.number]); + return staleDetail(key, Cache.get(detailCache, key)); + }; + + const activityCache = yield* Cache.makeWith( + (key: string) => { + const [, projectId, repository, number] = JSON.parse(key) as [number, string, string, number]; + return activityUncached({ projectId, repository, number } as PullRequestRef); + }, + { + capacity: DETAIL_CACHE_CAPACITY, + timeToLive: (exit) => (Exit.isSuccess(exit) ? DETAIL_CACHE_TTL : Duration.zero), + }, + ); + const staleActivity = staleWhileRevalidate( + DETAIL_STALE_WINDOW, + DETAIL_CACHE_CAPACITY, + ); + const activity: PullRequestService["Service"]["activity"] = (input) => { + const key = JSON.stringify([refEpoch(input), input.projectId, input.repository, input.number]); + return staleActivity(key, Cache.get(activityCache, key)); + }; + + const diffCache = yield* Cache.makeWith( + (key: string) => { + const [, projectId, repository, number, cursor, commit] = JSON.parse(key) as [ + number, + string, + string, + number, + string | null, + string | null, + ]; + return diffUncached({ + projectId, + repository, + number, + ...(cursor === null ? {} : { cursor }), + ...(commit === null ? {} : { commit }), + } as PullRequestDiffInput); + }, + { + capacity: DIFF_CACHE_CAPACITY, + timeToLive: (exit, key) => { + if (!Exit.isSuccess(exit)) return Duration.zero; + const commit = (JSON.parse(key) as ReadonlyArray)[5]; + return commit === null ? DIFF_CACHE_TTL : COMMIT_DIFF_CACHE_TTL; + }, + }, + ); + const staleDiff = staleWhileRevalidate( + DIFF_STALE_WINDOW, + DIFF_CACHE_CAPACITY, + ); + const diff: PullRequestService["Service"]["diff"] = (input) => { + const key = JSON.stringify([ + refEpoch(input), + input.projectId, + input.repository, + input.number, + input.cursor ?? null, + input.commit ?? null, + ]); + return staleDiff(key, Cache.get(diffCache, key)); + }; + + const listStatsCache = yield* Cache.makeWith( + (key: string) => { + const [, refs] = JSON.parse(key) as [number, ReadonlyArray<[string, string, number]>]; + return listStatsUncached({ + refs: refs.map(([projectId, repository, number]) => ({ projectId, repository, number })), + } as unknown as PullRequestListStatsInput); + }, + { + capacity: LIST_STATS_CACHE_CAPACITY, + timeToLive: (exit) => (Exit.isSuccess(exit) ? LIST_STATS_CACHE_TTL : Duration.zero), + }, + ); + // The stats read leans on the host's search API — the scarcest limit of them all — so it + // shares between clients like every other read. Refs are sorted so one page's worth of rows + // is one key however the client assembled them, and the listings epoch rides along so the + // refresh that forgets the listing forgets its decorations with it. + const staleListStats = staleWhileRevalidate( + LIST_STALE_WINDOW, + LIST_STATS_CACHE_CAPACITY, + ); + const listStats: PullRequestService["Service"]["listStats"] = (input) => { + if (input.refs.length === 0) return Effect.succeed({ stats: [] }); + const key = JSON.stringify([ + listingsEpoch, + input.refs + .map((ref) => [ref.projectId, ref.repository, ref.number] as const) + .toSorted((left, right) => + `${left[0]} ${left[1]} ${left[2]}`.localeCompare(`${right[0]} ${right[1]} ${right[2]}`), + ), + ]); + return staleListStats(key, Cache.get(listStatsCache, key)); + }; + + const invalidate: PullRequestService["Service"]["invalidate"] = (input) => + Effect.sync(() => { + if (input.reference === undefined) { + listingsEpoch = ++epochCounter; + // A whole-workspace refresh is the reader asking to be re-answered from the hosts, + // and that includes who the hosts say they are. + viewersByHost.clear(); + return; + } + bumpRefEpoch(input.reference); + }); + + // A mutation's own client re-reads right after it, and every other client's next read must + // see the action too — so a write forgets the change request it touched and the listings its + // state change reorders, for everyone, without any client asking. + const invalidatedByMutation = + ( + method: (input: I) => Effect.Effect, + ): ((input: I) => Effect.Effect) => + (input) => + method(input).pipe( + Effect.tap(() => + Effect.sync(() => { + bumpRefEpoch(input); + listingsEpoch = ++epochCounter; + }), + ), + ); + + return PullRequestService.of({ + list, + listStats, + detail, + activity, + diff, + diffFileContents, + runAction: invalidatedByMutation(runAction), + update: invalidatedByMutation(update), + comment: invalidatedByMutation(comment), + updateComment: invalidatedByMutation(updateComment), + submitReview: invalidatedByMutation(submitReview), + replyToThread: invalidatedByMutation(replyToThread), + setThreadResolution: invalidatedByMutation(setThreadResolution), + setReaction: invalidatedByMutation(setReaction), + // The candidate list is deliberately read fresh per menu-open, so it stays uncached. + reviewerCandidates, + requestReviewers: invalidatedByMutation(requestReviewers), + invalidate, + }); +}); + +export const layer = Layer.effect(PullRequestService, make); diff --git a/apps/server/src/pullRequest/azureDevOpsPullRequestJson.test.ts b/apps/server/src/pullRequest/azureDevOpsPullRequestJson.test.ts new file mode 100644 index 000000000000..a975c89f858c --- /dev/null +++ b/apps/server/src/pullRequest/azureDevOpsPullRequestJson.test.ts @@ -0,0 +1,315 @@ +import * as Result from "effect/Result"; +import { describe, expect, it } from "vite-plus/test"; + +import { + decodePullRequestJson, + decodePullRequestListJson, + decodeThreadsJson, + decodeViewerJson, +} from "./azureDevOpsPullRequestJson.ts"; + +const REST_URL = + "https://dev.azure.com/acme/_apis/git/repositories/6f9c9b7f-0000-0000-0000-000000000000/pullRequests/42"; + +/** Shaped after Azure's `GitPullRequest`, trimmed to the fields that are read. */ +function pullRequest(overrides: Record = {}): Record { + return { + pullRequestId: 42, + title: "Add the change requests page", + description: "Ships the page.", + status: "active", + isDraft: false, + mergeStatus: "succeeded", + createdBy: { displayName: "Bilal Hassan", uniqueName: "bilal@acme.dev" }, + sourceRefName: "refs/heads/feat/page", + targetRefName: "refs/heads/main", + creationDate: "2026-07-01T00:00:00Z", + url: REST_URL, + repository: { name: "web", project: { name: "platform" } }, + ...overrides, + }; +} + +function expectSuccess(result: Result.Result): A { + expect(Result.isSuccess(result)).toBe(true); + if (!Result.isSuccess(result)) throw new Error("expected a successful decode"); + return result.success; +} + +const asJson = (value: unknown) => JSON.stringify(value); + +describe("decodePullRequestListJson", () => { + it("reads a pull request as a change request", () => { + const batch = expectSuccess(decodePullRequestListJson(asJson([pullRequest()]))); + + expect(batch.items).toHaveLength(1); + expect(batch.items[0]).toMatchObject({ + number: 42, + title: "Add the change requests page", + // The login is an email, because that is what `az account show` reports to compare with. + author: { login: "bilal@acme.dev", name: "Bilal Hassan" }, + // Azure prefixes its refs, which no other host does. + headBranch: "feat/page", + baseBranch: "main", + state: "open", + isDraft: false, + mergeability: "mergeable", + }); + }); + + it("assembles a browser url when Azure reports no web link", () => { + const batch = expectSuccess(decodePullRequestListJson(asJson([pullRequest()]))); + + expect(batch.items[0]?.url).toBe("https://dev.azure.com/acme/platform/_git/web/pullrequest/42"); + }); + + it("prefers the web link Azure sends when asked for one", () => { + const batch = expectSuccess( + decodePullRequestListJson( + asJson([ + pullRequest({ + _links: { + web: { href: "https://dev.azure.com/acme/platform/_git/web/pullrequest/42" }, + }, + }), + ]), + ), + ); + + expect(batch.items[0]?.url).toBe("https://dev.azure.com/acme/platform/_git/web/pullrequest/42"); + }); + + it.each([ + ["active", "open"], + ["completed", "merged"], + ["abandoned", "closed"], + ["something new", "open"], + ])("reads the %s status as %s", (status, expected) => { + const batch = expectSuccess(decodePullRequestListJson(asJson([pullRequest({ status })]))); + + expect(batch.items[0]?.state).toBe(expected); + }); + + it.each([ + ["succeeded", "mergeable"], + ["conflicts", "conflicting"], + ["rejectedByPolicy", "conflicting"], + ["queued", "unknown"], + ["notSet", "unknown"], + ])("reads the %s merge status as %s", (mergeStatus, expected) => { + const batch = expectSuccess(decodePullRequestListJson(asJson([pullRequest({ mergeStatus })]))); + + expect(batch.items[0]?.mergeability).toBe(expected); + }); + + it("stands the closing time in for a last-touched time Azure does not keep", () => { + const batch = expectSuccess( + decodePullRequestListJson( + asJson([pullRequest({ status: "completed", closedDate: "2026-07-05T00:00:00Z" })]), + ), + ); + + expect(batch.items[0]).toMatchObject({ + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-05T00:00:00Z", + }); + }); + + it("skips a malformed row but still counts it, so paging does not stop early", () => { + const batch = expectSuccess( + decodePullRequestListJson(asJson([{ pullRequestId: "nope" }, pullRequest()])), + ); + + expect(batch.items).toHaveLength(1); + expect(batch.rawCount).toBe(2); + expect(batch.rawIndexes).toEqual([1]); + }); +}); + +describe("decodePullRequestJson", () => { + it("reads reviewers as review requests", () => { + const detail = expectSuccess( + decodePullRequestJson( + asJson( + pullRequest({ + reviewers: [{ displayName: "Julius", uniqueName: "julius@acme.dev", vote: 10 }], + }), + ), + ), + ); + + expect(detail?.reviewRequestLogins).toEqual(["julius@acme.dev"]); + expect(detail?.reviewers).toEqual([ + { login: "julius@acme.dev", name: "Julius", avatarUrl: null }, + ]); + }); + + it("reads auto-complete from whoever armed it, and its absence as nobody", () => { + const armed = expectSuccess( + decodePullRequestJson( + asJson(pullRequest({ autoCompleteSetBy: { displayName: "Bilal Hassan" } })), + ), + ); + expect(armed?.autoMergeEnabled).toBe(true); + + // Azure leaves the field out entirely rather than sending it empty, so its absence is the + // whole of what it says about auto-complete being off. + expect(expectSuccess(decodePullRequestJson(asJson(pullRequest())))?.autoMergeEnabled).toBe( + false, + ); + }); + + it("works out where the conversation lives from what Azure returned", () => { + const detail = expectSuccess(decodePullRequestJson(asJson(pullRequest()))); + + expect(detail?.threadsUrl).toBe( + "https://dev.azure.com/acme/platform/_apis/git/repositories/web/pullRequests/42/threads", + ); + }); + + it("reports no conversation url when Azure said too little to build one", () => { + // A web link places the pull request, but without the REST url and repository there is + // nothing to hang a threads collection off. + const detail = expectSuccess( + decodePullRequestJson( + asJson( + pullRequest({ + url: null, + repository: null, + _links: { + web: { href: "https://dev.azure.com/acme/platform/_git/web/pullrequest/42" }, + }, + }), + ), + ), + ); + + expect(detail?.threadsUrl).toBeNull(); + }); + + it("returns nothing when Azure gave no way to place the pull request at all", () => { + const detail = expectSuccess( + decodePullRequestJson(asJson(pullRequest({ url: null, repository: null }))), + ); + + expect(detail).toBeNull(); + }); +}); + +describe("decodeViewerJson", () => { + it("reads the signed-in account name", () => { + expect(expectSuccess(decodeViewerJson(asJson({ user: { name: "bilal@acme.dev" } })))).toBe( + "bilal@acme.dev", + ); + }); + + it("returns nothing when nobody is signed in", () => { + expect(expectSuccess(decodeViewerJson(asJson({ user: null })))).toBeNull(); + }); +}); + +describe("decodeThreadsJson", () => { + it("takes every real comment of every thread, oldest first", () => { + const comments = expectSuccess( + decodeThreadsJson( + asJson({ + value: [ + { + id: 2, + comments: [ + { + id: 1, + content: "Second remark.", + author: { displayName: "Julius", uniqueName: "julius@acme.dev" }, + publishedDate: "2026-07-03T00:00:00Z", + }, + ], + }, + { + id: 1, + comments: [ + // Azure's own activity notes are events rather than remarks. + { id: 1, content: "Bilal voted", commentType: "system", publishedDate: "x" }, + { + id: 2, + content: "First remark.", + author: { displayName: "Bilal", uniqueName: "bilal@acme.dev" }, + publishedDate: "2026-07-02T00:00:00Z", + }, + ], + }, + ], + }), + ), + ); + + expect(comments.map((comment) => comment.body)).toEqual(["First remark.", "Second remark."]); + expect(comments[0]).toMatchObject({ + kind: "issue-comment", + author: { login: "bilal@acme.dev" }, + }); + }); + + it("reads a thread pinned to a file as a review comment", () => { + const comments = expectSuccess( + decodeThreadsJson( + asJson({ + value: [ + { + id: 3, + threadContext: { filePath: "/src/app.ts" }, + comments: [{ id: 1, content: "Rename this.", publishedDate: "2026-07-02T00:00:00Z" }], + }, + ], + }), + ), + ); + + expect(comments[0]).toMatchObject({ kind: "review-comment", path: "/src/app.ts" }); + }); + + it("keeps the replies under a thread, which are as much of the conversation", () => { + const comments = expectSuccess( + decodeThreadsJson( + asJson({ + value: [ + { + id: 4, + threadContext: { filePath: "/src/app.ts" }, + comments: [ + { id: 1, content: "Rename this.", publishedDate: "2026-07-02T00:00:00Z" }, + { id: 2, content: "Renamed.", publishedDate: "2026-07-02T01:00:00Z" }, + { id: 3, content: "Thanks.", publishedDate: "2026-07-02T02:00:00Z" }, + ], + }, + ], + }), + ), + ); + + expect(comments.map((comment) => comment.id)).toEqual(["4:1", "4:2", "4:3"]); + }); + + it("drops deleted threads and threads with nothing to show", () => { + const comments = expectSuccess( + decodeThreadsJson( + asJson({ + value: [ + { + id: 1, + isDeleted: true, + comments: [{ id: 1, content: "gone", publishedDate: "2026-07-02T00:00:00Z" }], + }, + { id: 2, comments: [] }, + { + id: 3, + comments: [{ id: 1, content: " ", publishedDate: "2026-07-02T00:00:00Z" }], + }, + ], + }), + ), + ); + + expect(comments).toEqual([]); + }); +}); diff --git a/apps/server/src/pullRequest/azureDevOpsPullRequestJson.ts b/apps/server/src/pullRequest/azureDevOpsPullRequestJson.ts new file mode 100644 index 000000000000..39ca4a551d27 --- /dev/null +++ b/apps/server/src/pullRequest/azureDevOpsPullRequestJson.ts @@ -0,0 +1,342 @@ +import * as Cause from "effect/Cause"; +import * as Exit from "effect/Exit"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import type { + PullRequestActor, + PullRequestComment, + PullRequestMergeability, + PullRequestState, +} from "@t3tools/contracts"; +import { TrimmedNonEmptyString } from "@t3tools/contracts"; +import { decodeJsonResult } from "@t3tools/shared/schemaJson"; + +import { + azureDevOpsOrganizationBaseFromRestApiUrl, + azureDevOpsPullRequestWebUrl, +} from "../sourceControl/azureDevOpsPullRequests.ts"; + +/** + * Azure's enums are decoded as plain strings and normalized here, in the same tolerant style as + * the other hosts: a new merge status must not fail a whole payload. Every field beyond the + * identity is optional, because `az repos pr` returns rather more or less of the REST object + * depending on the command. + */ +const RawIdentitySchema = Schema.Struct({ + displayName: Schema.optional(Schema.NullOr(Schema.String)), + /** An email or UPN, which is what `az account show` reports for the signed-in user. */ + uniqueName: Schema.optional(Schema.NullOr(Schema.String)), + imageUrl: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawPullRequestSchema = Schema.Struct({ + pullRequestId: Schema.Int, + title: Schema.String, + description: Schema.optional(Schema.NullOr(Schema.String)), + status: Schema.optional(Schema.NullOr(Schema.String)), + isDraft: Schema.optional(Schema.NullOr(Schema.Boolean)), + /** + * Who armed auto-complete, which is the only thing Azure says about it: the field carries an + * identity while the pull request is set to complete on its own, and Azure leaves it out + * entirely once nobody has. So its presence is the answer, and there is no third state. + */ + autoCompleteSetBy: Schema.optional(Schema.NullOr(RawIdentitySchema)), + mergeStatus: Schema.optional(Schema.NullOr(Schema.String)), + createdBy: Schema.optional(Schema.NullOr(RawIdentitySchema)), + reviewers: Schema.optional(Schema.NullOr(Schema.Array(RawIdentitySchema))), + // Required, and required to be non-empty: the wire contract will not carry a change request + // without a branch or a created time, so a row missing one is skipped rather than breaking the + // response it travels in. + sourceRefName: TrimmedNonEmptyString, + targetRefName: TrimmedNonEmptyString, + creationDate: TrimmedNonEmptyString, + closedDate: Schema.optional(Schema.NullOr(Schema.String)), + url: Schema.optional(Schema.NullOr(Schema.String)), + repository: Schema.optional( + Schema.NullOr( + Schema.Struct({ + name: Schema.optional(Schema.NullOr(Schema.String)), + webUrl: Schema.optional(Schema.NullOr(Schema.String)), + project: Schema.optional( + Schema.NullOr(Schema.Struct({ name: Schema.optional(Schema.NullOr(Schema.String)) })), + ), + }), + ), + ), + _links: Schema.optional( + Schema.NullOr( + Schema.Struct({ + web: Schema.optional( + Schema.NullOr(Schema.Struct({ href: Schema.optional(Schema.String) })), + ), + }), + ), + ), +}); + +/** A pull request thread, which is how Azure keeps its conversation. */ +const RawThreadSchema = Schema.Struct({ + id: Schema.Int, + isDeleted: Schema.optional(Schema.NullOr(Schema.Boolean)), + threadContext: Schema.optional( + Schema.NullOr(Schema.Struct({ filePath: Schema.optional(Schema.NullOr(Schema.String)) })), + ), + comments: Schema.optional( + Schema.NullOr( + Schema.Array( + Schema.Struct({ + id: Schema.optional(Schema.NullOr(Schema.Int)), + content: Schema.optional(Schema.NullOr(Schema.String)), + author: Schema.optional(Schema.NullOr(RawIdentitySchema)), + publishedDate: Schema.optional(Schema.NullOr(Schema.String)), + isDeleted: Schema.optional(Schema.NullOr(Schema.Boolean)), + /** `system` marks the notes Azure writes itself, which are events, not comments. */ + commentType: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + ), + ), +}); + +const RawThreadPageSchema = Schema.Struct({ + value: Schema.Array(Schema.Unknown), +}); + +const RawViewerSchema = Schema.Struct({ + user: Schema.optional( + Schema.NullOr(Schema.Struct({ name: Schema.optional(Schema.NullOr(Schema.String)) })), + ), +}); + +export interface AzureDevOpsPullRequest { + readonly number: number; + readonly title: string; + readonly url: string; + readonly author: PullRequestActor | null; + readonly headBranch: string; + readonly baseBranch: string; + readonly state: PullRequestState; + readonly isDraft: boolean; + readonly mergeability: PullRequestMergeability; + readonly createdAt: string; + /** + * Azure records no last-touched time on a pull request, so the closing time stands in where + * there is one and the creation time otherwise. The same fallback the rest of the app uses. + */ + readonly updatedAt: string; + readonly closedAt: string | null; + readonly body: string; + readonly reviewRequestLogins: ReadonlyArray; + readonly reviewers: ReadonlyArray; + /** Where this pull request's threads live, when Azure said enough to work it out. */ + readonly threadsUrl: string | null; + /** Whether Azure is set to complete this on its own once its policies pass. */ + readonly autoMergeEnabled: boolean; +} + +function trimmed(value: string | null | undefined): string | null { + const text = value?.trim() ?? ""; + return text.length > 0 ? text : null; +} + +function normalizeRefName(refName: string): string { + return refName.trim().replace(/^refs\/heads\//, ""); +} + +/** A login has to compare against `az account show`, which reports an email. */ +function toActor(raw: Schema.Schema.Type | null | undefined) { + const login = trimmed(raw?.uniqueName) ?? trimmed(raw?.displayName); + return login === null + ? null + : { login, name: trimmed(raw?.displayName), avatarUrl: trimmed(raw?.imageUrl) }; +} + +function toState(raw: Schema.Schema.Type): PullRequestState { + switch (raw.status?.trim().toLowerCase()) { + case "completed": + return "merged"; + case "abandoned": + return "closed"; + default: + return "open"; + } +} + +function toMergeability(value: string | null | undefined): PullRequestMergeability { + switch (value?.trim().toLowerCase()) { + case "succeeded": + return "mergeable"; + case "conflicts": + case "failure": + case "rejectedbypolicy": + return "conflicting"; + default: + // `queued` and `notSet` mean Azure has not finished checking. + return "unknown"; + } +} + +/** + * The REST collection a pull request's threads hang from. Built from what Azure returned rather + * than from the local remote, whose shape differs between the modern, legacy and SSH forms. + */ +function toThreadsUrl(raw: Schema.Schema.Type): string | null { + const base = azureDevOpsOrganizationBaseFromRestApiUrl(raw.url); + const project = trimmed(raw.repository?.project?.name); + const repository = trimmed(raw.repository?.name); + if (base === null || project === null || repository === null) return null; + return `${base}/${encodeURIComponent(project)}/_apis/git/repositories/${encodeURIComponent(repository)}/pullRequests/${raw.pullRequestId}/threads`; +} + +/** + * Null when Azure said too little to place the pull request: a row with no browser url and no + * branch left after its prefix is dropped cannot be rendered or opened, and the wire contract + * refuses to carry it either. + */ +function toPullRequest( + raw: Schema.Schema.Type, +): AzureDevOpsPullRequest | null { + const reviewers = (raw.reviewers ?? []).flatMap((reviewer) => { + const actor = toActor(reviewer); + return actor === null ? [] : [actor]; + }); + const closedAt = trimmed(raw.closedDate); + const url = trimmed( + azureDevOpsPullRequestWebUrl({ + pullRequestId: raw.pullRequestId, + webLink: raw._links?.web?.href, + repositoryWebUrl: raw.repository?.webUrl, + restApiUrl: raw.url, + projectName: raw.repository?.project?.name, + repositoryName: raw.repository?.name, + }), + ); + const headBranch = trimmed(normalizeRefName(raw.sourceRefName)); + const baseBranch = trimmed(normalizeRefName(raw.targetRefName)); + if (url === null || headBranch === null || baseBranch === null) return null; + return { + number: raw.pullRequestId, + title: raw.title, + url, + author: toActor(raw.createdBy), + headBranch, + baseBranch, + state: toState(raw), + isDraft: raw.isDraft ?? false, + mergeability: toMergeability(raw.mergeStatus), + createdAt: raw.creationDate, + updatedAt: closedAt ?? raw.creationDate, + closedAt, + body: raw.description ?? "", + reviewRequestLogins: reviewers.map((reviewer) => reviewer.login), + reviewers, + threadsUrl: toThreadsUrl(raw), + autoMergeEnabled: (raw.autoCompleteSetBy ?? null) !== null, + }; +} + +const decodeUnknownList = decodeJsonResult(Schema.Array(Schema.Unknown)); +const decodePullRequestEntry = Schema.decodeUnknownExit(RawPullRequestSchema); +const decodePullRequest = decodeJsonResult(RawPullRequestSchema); +const decodeThreadPage = decodeJsonResult(RawThreadPageSchema); +const decodeThreadEntry = Schema.decodeUnknownExit(RawThreadSchema); +const decodeViewer = decodeJsonResult(RawViewerSchema); + +type DecodeFailure = Cause.Cause; + +export interface AzureDevOpsPullRequestBatch { + readonly items: ReadonlyArray; + /** Zero-based positions of the decoded items in Azure's raw page. */ + readonly rawIndexes: ReadonlyArray; + /** Rows Azure returned, counted before decoding, so a skipped row cannot hide a next page. */ + readonly rawCount: number; +} + +/** Malformed entries are skipped rather than failing the batch, as on the other hosts. */ +export function decodePullRequestListJson( + raw: string, +): Result.Result { + const decoded = decodeUnknownList(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const items: AzureDevOpsPullRequest[] = []; + const rawIndexes: number[] = []; + for (const [rawIndex, entry] of decoded.success.entries()) { + const item = decodePullRequestEntry(entry); + if (Exit.isFailure(item)) continue; + const pullRequest = toPullRequest(item.value); + if (pullRequest !== null) { + items.push(pullRequest); + rawIndexes.push(rawIndex); + } + } + return Result.succeed({ items, rawIndexes, rawCount: decoded.success.length }); +} + +/** Null carries "Azure answered, but with too little to use", which the caller reports. */ +export function decodePullRequestJson( + raw: string, +): Result.Result { + const decoded = decodePullRequest(raw); + return Result.isSuccess(decoded) + ? Result.succeed(toPullRequest(decoded.success)) + : Result.fail(decoded.failure); +} + +/** `az account show --query user` reports the signed-in account, whose name is an email. */ +export function decodeViewerJson(raw: string): Result.Result { + const decoded = decodeViewer(raw); + return Result.isSuccess(decoded) + ? Result.succeed(trimmed(decoded.success.user?.name)) + : Result.fail(decoded.failure); +} + +/** + * Azure keeps its conversation as threads of comments, and every one of them is a remark + * somebody wrote: a reply under a thread is as much of the conversation as the line that opened + * it. A thread pinned to a file is a line-level review comment. + * + * Azure answers the whole thread collection in one response, with no cursor and no page to + * follow, so what this returns is everything the host has. + */ +export function decodeThreadsJson( + raw: string, +): Result.Result, DecodeFailure> { + const decoded = decodeThreadPage(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const comments: PullRequestComment[] = []; + for (const entry of decoded.success.value) { + const decodedThread = decodeThreadEntry(entry); + if (Exit.isFailure(decodedThread)) continue; + const thread = decodedThread.value; + if (thread.isDeleted === true) continue; + const path = trimmed(thread.threadContext?.filePath); + for (const comment of thread.comments ?? []) { + const publishedDate = trimmed(comment.publishedDate); + if ( + comment.isDeleted === true || + comment.commentType?.trim().toLowerCase() === "system" || + (comment.content ?? "").trim().length === 0 || + publishedDate === null + ) { + continue; + } + comments.push({ + id: `${thread.id}:${comment.id ?? 0}`, + kind: path === null ? "issue-comment" : "review-comment", + author: toActor(comment.author), + body: comment.content ?? "", + createdAt: publishedDate, + url: null, + path, + reviewState: null, + }); + } + } + return Result.succeed( + comments.toSorted((left, right) => left.createdAt.localeCompare(right.createdAt)), + ); +} diff --git a/apps/server/src/pullRequest/bitbucketPullRequestJson.test.ts b/apps/server/src/pullRequest/bitbucketPullRequestJson.test.ts new file mode 100644 index 000000000000..a348ac4b30e8 --- /dev/null +++ b/apps/server/src/pullRequest/bitbucketPullRequestJson.test.ts @@ -0,0 +1,376 @@ +import * as Result from "effect/Result"; +import { describe, expect, it } from "vite-plus/test"; + +import { + decodeCommentsJson, + decodeCommitsJson, + decodeConflictsJson, + decodeDiffstatJson, + decodePullRequestJson, + decodePullRequestPageJson, + decodeRepositoryPermissionJson, + decodeStatusesJson, + decodeViewerJson, +} from "./bitbucketPullRequestJson.ts"; + +/** Shaped after a real api.bitbucket.org pull request, trimmed to the fields that are read. */ +function pullRequest(overrides: Record = {}): Record { + return { + id: 897, + title: "Add trustabl-pipe", + description: "# Add trustabl-pipe", + state: "OPEN", + draft: false, + created_on: "2026-06-16T05:04:32.258456+00:00", + updated_on: "2026-06-16T05:04:33.750542+00:00", + author: { display_name: "Bilal Hassan", nickname: "bilal", type: "user" }, + source: { branch: { name: "feat/page" } }, + destination: { branch: { name: "master" } }, + links: { html: { href: "https://bitbucket.org/acme/web/pull-requests/897" } }, + ...overrides, + }; +} + +function page(values: ReadonlyArray, extra: Record = {}): string { + return JSON.stringify({ pagelen: 50, page: 1, size: values.length, values, ...extra }); +} + +function expectSuccess(result: Result.Result): A { + expect(Result.isSuccess(result)).toBe(true); + if (!Result.isSuccess(result)) throw new Error("expected a successful decode"); + return result.success; +} + +describe("decodePullRequestPageJson", () => { + it("reads a pull request as a change request", () => { + const decoded = expectSuccess(decodePullRequestPageJson(page([pullRequest()]))); + + expect(decoded.items).toHaveLength(1); + expect(decoded.items[0]).toMatchObject({ + number: 897, + title: "Add trustabl-pipe", + url: "https://bitbucket.org/acme/web/pull-requests/897", + author: { login: "bilal", name: "Bilal Hassan" }, + headBranch: "feat/page", + baseBranch: "master", + state: "open", + isDraft: false, + // Bitbucket says nothing about conflicts on the pull request itself. + mergeability: "unknown", + }); + expect(decoded.next).toBeNull(); + }); + + it("normalizes Bitbucket's offset timestamps, which the page sorts against other hosts", () => { + const decoded = expectSuccess(decodePullRequestPageJson(page([pullRequest()]))); + + expect(decoded.items[0]).toMatchObject({ + createdAt: "2026-06-16T05:04:32.258Z", + updatedAt: "2026-06-16T05:04:33.750Z", + }); + }); + + it("reports the next page as the whole URL Bitbucket sends", () => { + const next = "https://api.bitbucket.org/2.0/repositories/acme/web/pullrequests?page=2"; + const decoded = expectSuccess(decodePullRequestPageJson(page([pullRequest()], { next }))); + + expect(decoded.next).toBe(next); + }); + + it.each([ + ["MERGED", "merged"], + ["DECLINED", "closed"], + ["SUPERSEDED", "closed"], + ["OPEN", "open"], + ["something new", "open"], + ])("reads the %s state as %s", (state, expected) => { + const decoded = expectSuccess(decodePullRequestPageJson(page([pullRequest({ state })]))); + + expect(decoded.items[0]?.state).toBe(expected); + }); + + it("skips a malformed row rather than failing the page", () => { + const decoded = expectSuccess( + decodePullRequestPageJson(page([{ id: "not a number" }, pullRequest()])), + ); + + expect(decoded.items).toHaveLength(1); + }); + + it("fails when Bitbucket did not answer with a page", () => { + expect(Result.isFailure(decodePullRequestPageJson(JSON.stringify({ error: "nope" })))).toBe( + true, + ); + }); +}); + +describe("decodePullRequestJson", () => { + it("reads reviewers as review requests", () => { + const decoded = expectSuccess( + decodePullRequestJson( + JSON.stringify( + pullRequest({ + reviewers: [{ nickname: "julius", display_name: "Julius" }], + }), + ), + ), + ); + + expect(decoded.reviewRequestLogins).toEqual(["julius"]); + expect(decoded.reviewers).toEqual([{ login: "julius", name: "Julius", avatarUrl: null }]); + }); + + it("reads a participant's vote as a review", () => { + const decoded = expectSuccess( + decodePullRequestJson( + JSON.stringify( + pullRequest({ + participants: [ + { + user: { nickname: "julius", display_name: "Julius" }, + role: "REVIEWER", + approved: true, + state: "approved", + participated_on: "2026-06-17T09:00:00+00:00", + }, + // Added as a reviewer but has not voted, so there is no verdict to show. + { + user: { nickname: "sam", display_name: "Sam" }, + role: "REVIEWER", + approved: false, + state: null, + participated_on: null, + }, + ], + }), + ), + ), + ); + + expect(decoded.reviews).toHaveLength(1); + expect(decoded.reviews[0]).toMatchObject({ + kind: "review", + author: { login: "julius" }, + reviewState: "approved", + createdAt: "2026-06-17T09:00:00.000Z", + }); + }); +}); + +describe("decodeViewerJson", () => { + it("reads the signed-in nickname", () => { + const decoded = decodeViewerJson(JSON.stringify({ nickname: "bilal", display_name: "Bilal" })); + + expect(expectSuccess(decoded)).toBe("bilal"); + }); + + it("falls back to the display name, which app accounts have instead", () => { + const decoded = decodeViewerJson(JSON.stringify({ display_name: "Release Bot" })); + + expect(expectSuccess(decoded)).toBe("Release Bot"); + }); + + it("returns nothing when the account has neither", () => { + expect(expectSuccess(decodeViewerJson(JSON.stringify({})))).toBeNull(); + }); +}); + +describe("decodeCommentsJson", () => { + it("keeps a posted comment and drops deleted and unposted ones", () => { + const decoded = expectSuccess( + decodeCommentsJson( + page([ + { + id: 797230941, + content: { raw: "The issue is ready for review." }, + user: { display_name: "Release Bot", type: "app_user" }, + created_on: "2026-05-15T01:58:38.220690+00:00", + deleted: false, + pending: false, + links: { html: { href: "https://bitbucket.org/acme/web/pull-requests/892#c1" } }, + }, + { + id: 2, + content: { raw: "gone" }, + created_on: "2026-05-15T02:00:00+00:00", + deleted: true, + }, + { + id: 3, + content: { raw: "wip" }, + created_on: "2026-05-15T02:00:00+00:00", + pending: true, + }, + { id: 4, content: { raw: " " }, created_on: "2026-05-15T02:00:00+00:00" }, + ]), + ), + ); + + expect(decoded.comments).toHaveLength(1); + expect(decoded.comments[0]).toMatchObject({ + id: "797230941", + kind: "issue-comment", + // An app account has no nickname, so its display name is the only handle it has. + author: { login: "Release Bot" }, + createdAt: "2026-05-15T01:58:38.220Z", + }); + }); + + it("reads a comment pinned to a file as a review comment", () => { + const decoded = expectSuccess( + decodeCommentsJson( + page([ + { + id: 5, + content: { raw: "Rename this." }, + created_on: "2026-05-15T02:00:00+00:00", + inline: { path: "src/app.ts" }, + }, + ]), + ), + ); + + expect(decoded.comments[0]).toMatchObject({ kind: "review-comment", path: "src/app.ts" }); + }); +}); + +describe("decodeCommitsJson", () => { + it("returns commits oldest first with only the subject line", () => { + const decoded = expectSuccess( + decodeCommitsJson( + page([ + { hash: "bbb", message: "second\n\nbody text\n", date: "2026-06-16T04:51:00+00:00" }, + { + hash: "aaa", + message: "first\n", + date: "2026-06-16T04:50:49+00:00", + author: { + raw: "Ada Lovelace ", + user: { nickname: "ada", display_name: "Ada Lovelace" }, + }, + }, + ]), + ), + ); + + expect(decoded.items.map((commit) => commit.oid)).toEqual(["aaa", "bbb"]); + expect(decoded.items[0]?.authors).toEqual([ + { login: "ada", name: "Ada Lovelace", avatarUrl: null }, + ]); + expect(decoded.items[1]?.messageHeadline).toBe("second"); + expect(decoded.next).toBeNull(); + }); + + it("skips commits whose hash is empty", () => { + const decoded = expectSuccess( + decodeCommitsJson( + page([ + { hash: " ", message: "invalid", date: "2026-06-16T04:51:00+00:00" }, + { hash: "aaa", date: "2026-06-16T04:50:49+00:00" }, + ]), + ), + ); + + expect(decoded.items.map((commit) => commit.oid)).toEqual(["aaa"]); + }); +}); + +describe("decodeStatusesJson", () => { + it("reads a build status as a check", () => { + const decoded = expectSuccess( + decodeStatusesJson( + page([ + { + key: "custom:check-version-and-pr", + name: "Pipeline - custom: check-version-and-pr", + state: "SUCCESSFUL", + description: "", + url: "https://bitbucket.org/acme/web/pipelines/results/8126", + }, + ]), + ), + ); + + expect(decoded).toEqual({ + items: [ + { + name: "Pipeline - custom: check-version-and-pr", + status: "success", + description: null, + url: "https://bitbucket.org/acme/web/pipelines/results/8126", + }, + ], + next: null, + }); + }); + + it.each([ + ["SUCCESSFUL", "success"], + ["FAILED", "failure"], + ["INPROGRESS", "pending"], + ["STOPPED", "cancelled"], + ["something new", "neutral"], + ])("reads the %s build state as %s", (state, expected) => { + const decoded = expectSuccess(decodeStatusesJson(page([{ name: "Pipeline", state }]))); + + expect(decoded.items[0]?.status).toBe(expected); + }); + + it("keeps two statuses that share a display name but have different keys", () => { + const decoded = expectSuccess( + decodeStatusesJson( + page([ + { key: "build", name: "Pipeline", state: "SUCCESSFUL" }, + { key: "deploy", name: "Pipeline", state: "FAILED" }, + ]), + ), + ); + + expect(decoded.items.map((check) => [check.name, check.status])).toEqual([ + ["build / Pipeline", "success"], + ["deploy / Pipeline", "failure"], + ]); + }); +}); + +describe("decodeDiffstatJson", () => { + it("adds up the per-file counts", () => { + const decoded = expectSuccess( + decodeDiffstatJson( + page([ + { lines_added: 9, lines_removed: 2 }, + { lines_added: 32, lines_removed: 14 }, + ]), + ), + ); + + expect(decoded).toEqual({ additions: 41, deletions: 16, changedFiles: 2, next: null }); + }); +}); + +describe("decodeConflictsJson", () => { + it("calls an empty conflict list mergeable", () => { + expect(expectSuccess(decodeConflictsJson(page([])))).toBe("mergeable"); + }); + + it("calls any reported conflict conflicting", () => { + expect(expectSuccess(decodeConflictsJson(page([{ path: "src/app.ts" }])))).toBe("conflicting"); + }); +}); + +describe("repository permission decoding", () => { + const permissionPage = (permission: string) => + page([{ type: "repository_permission", permission }]); + + it("counts admin and write as write, and read as not", () => { + expect(expectSuccess(decodeRepositoryPermissionJson(permissionPage("admin")))).toBe(true); + expect(expectSuccess(decodeRepositoryPermissionJson(permissionPage("write")))).toBe(true); + expect(expectSuccess(decodeRepositoryPermissionJson(permissionPage("read")))).toBe(false); + }); + + it("grants write where Bitbucket named no permission at all", () => { + // An empty page is Bitbucket declining to say, which is an unknown standing rather than a + // refusal — and an unknown one is granted. + expect(expectSuccess(decodeRepositoryPermissionJson(page([])))).toBe(true); + }); +}); diff --git a/apps/server/src/pullRequest/bitbucketPullRequestJson.ts b/apps/server/src/pullRequest/bitbucketPullRequestJson.ts new file mode 100644 index 000000000000..b0711b8ff6d8 --- /dev/null +++ b/apps/server/src/pullRequest/bitbucketPullRequestJson.ts @@ -0,0 +1,628 @@ +import * as Cause from "effect/Cause"; +import * as DateTime from "effect/DateTime"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import type { + PullRequestActor, + PullRequestCheck, + PullRequestCheckStatus, + PullRequestComment, + PullRequestCommit, + PullRequestMergeability, + PullRequestReviewThread, + PullRequestReviewerCandidate, + PullRequestState, +} from "@t3tools/contracts"; +import { TrimmedNonEmptyString } from "@t3tools/contracts"; +import { decodeJsonResult } from "@t3tools/shared/schemaJson"; + +import { dedupeChecks } from "./pullRequestChecks.ts"; + +/** + * Bitbucket's enums are decoded as plain strings and normalized here, in the same tolerant + * style as the GitHub and GitLab decoders: a new pull request state or build status must not + * fail a whole payload. + */ +const RawUserSchema = Schema.Struct({ + /** + * How Bitbucket addresses an account when a reviewer set is written; the handles it shows are + * not accepted there. Braced, and sent back exactly as it arrived. + */ + uuid: Schema.optional(Schema.NullOr(Schema.String)), + /** Absent on an app account, which is why `display_name` has to stand in for it. */ + nickname: Schema.optional(Schema.NullOr(Schema.String)), + display_name: Schema.optional(Schema.NullOr(Schema.String)), + links: Schema.optional( + Schema.NullOr( + Schema.Struct({ + avatar: Schema.optional( + Schema.NullOr(Schema.Struct({ href: Schema.optional(Schema.String) })), + ), + }), + ), + ), +}); + +/** + * Required, and required to be non-empty: the wire contract will not carry a change request + * without a branch or a link, so a row missing one is skipped rather than breaking the response + * it travels in. + */ +const RawBranchSchema = Schema.Struct({ + branch: Schema.Struct({ name: TrimmedNonEmptyString }), +}); + +const RawLinkSchema = Schema.Struct({ href: Schema.optional(Schema.String) }); + +const RawPullRequestSchema = Schema.Struct({ + id: Schema.Int, + title: Schema.String, + description: Schema.optional(Schema.NullOr(Schema.String)), + state: Schema.optional(Schema.NullOr(Schema.String)), + draft: Schema.optional(Schema.Boolean), + author: Schema.optional(Schema.NullOr(RawUserSchema)), + source: RawBranchSchema, + destination: RawBranchSchema, + created_on: Schema.String, + updated_on: Schema.String, + reviewers: Schema.optional(Schema.NullOr(Schema.Array(RawUserSchema))), + participants: Schema.optional( + Schema.NullOr( + Schema.Array( + Schema.Struct({ + user: Schema.optional(Schema.NullOr(RawUserSchema)), + role: Schema.optional(Schema.NullOr(Schema.String)), + approved: Schema.optional(Schema.Boolean), + state: Schema.optional(Schema.NullOr(Schema.String)), + participated_on: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + ), + ), + links: Schema.Struct({ html: Schema.Struct({ href: TrimmedNonEmptyString }) }), +}); + +const RawPageSchema = Schema.Struct({ + values: Schema.Array(Schema.Unknown), + /** A total count, which Bitbucket omits on some endpoints. */ + size: Schema.optional(Schema.NullOr(Schema.Int)), + /** Present only while a further page exists. */ + next: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawCommentSchema = Schema.Struct({ + id: Schema.Int, + content: Schema.optional(Schema.NullOr(Schema.Struct({ raw: Schema.optional(Schema.String) }))), + user: Schema.optional(Schema.NullOr(RawUserSchema)), + created_on: Schema.String, + deleted: Schema.optional(Schema.Boolean), + /** A comment still being drafted by its author. */ + pending: Schema.optional(Schema.Boolean), + /** Set on a reply, to the comment it answers — which may itself be a reply. */ + parent: Schema.optional(Schema.NullOr(Schema.Struct({ id: Schema.Int }))), + inline: Schema.optional( + Schema.NullOr( + Schema.Struct({ + path: Schema.optional(Schema.NullOr(Schema.String)), + /** The line in the file as it was; set instead of `to` on a removed line. */ + from: Schema.optional(Schema.NullOr(Schema.Int)), + /** The line in the file as it is now. */ + to: Schema.optional(Schema.NullOr(Schema.Int)), + outdated: Schema.optional(Schema.NullOr(Schema.Boolean)), + }), + ), + ), + /** Non-null once someone has marked the thread resolved. */ + resolution: Schema.optional(Schema.NullOr(Schema.Unknown)), + links: Schema.optional( + Schema.NullOr(Schema.Struct({ html: Schema.optional(Schema.NullOr(RawLinkSchema)) })), + ), +}); + +const RawCommitSchema = Schema.Struct({ + hash: TrimmedNonEmptyString, + message: Schema.optional(Schema.NullOr(Schema.String)), + date: Schema.optional(Schema.NullOr(Schema.String)), + author: Schema.optional( + Schema.NullOr( + Schema.Struct({ + raw: Schema.optional(Schema.NullOr(Schema.String)), + user: Schema.optional(Schema.NullOr(RawUserSchema)), + }), + ), + ), +}); + +const RawStatusSchema = Schema.Struct({ + key: Schema.optional(Schema.NullOr(Schema.String)), + name: Schema.optional(Schema.NullOr(Schema.String)), + state: Schema.optional(Schema.NullOr(Schema.String)), + description: Schema.optional(Schema.NullOr(Schema.String)), + url: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawDiffstatSchema = Schema.Struct({ + lines_added: Schema.optional(Schema.NullOr(Schema.Int)), + lines_removed: Schema.optional(Schema.NullOr(Schema.Int)), +}); + +/** One row of `/workspaces/{workspace}/members`, which wraps the account it is about. */ +const RawMemberSchema = Schema.Struct({ + user: Schema.optional(Schema.NullOr(RawUserSchema)), +}); + +const RawViewerSchema = Schema.Struct({ + nickname: Schema.optional(Schema.NullOr(Schema.String)), + display_name: Schema.optional(Schema.NullOr(Schema.String)), +}); + +/** + * `/user/permissions/repositories` filtered to one repository, which is the only place Bitbucket + * states what the credentials may do with it: nothing on the repository, the pull request or the + * workspace carries it. One row, or none where Bitbucket names no permission for this account. + */ +const RawRepositoryPermissionsSchema = Schema.Struct({ + values: Schema.optional( + Schema.NullOr( + Schema.Array(Schema.Struct({ permission: Schema.optional(Schema.NullOr(Schema.String)) })), + ), + ), +}); + +export interface BitbucketPullRequest { + readonly number: number; + readonly title: string; + readonly url: string; + readonly author: PullRequestActor | null; + readonly headBranch: string; + readonly baseBranch: string; + readonly state: PullRequestState; + readonly isDraft: boolean; + /** + * Bitbucket reports no conflict state on a pull request, so the list leaves it unknown. The + * detail read asks the conflicts endpoint, which does answer. + */ + readonly mergeability: PullRequestMergeability; + readonly createdAt: string; + readonly updatedAt: string; + readonly body: string; + readonly reviewRequestLogins: ReadonlyArray; + readonly reviewers: ReadonlyArray; + /** The reviewers as Bitbucket addresses them, which is what writing the set back takes. */ + readonly reviewerIds: ReadonlyArray; + /** Approvals and change requests, which Bitbucket keeps on its participants. */ + readonly reviews: ReadonlyArray; +} + +function trimmed(value: string | null | undefined): string | null { + const text = value?.trim() ?? ""; + return text.length > 0 ? text : null; +} + +/** + * Bitbucket stamps times as `+00:00` with microseconds. The page sorts change requests from + * every host against each other as plain strings, so they are normalized to the same `Z` form + * the other hosts already use. + */ +function toIsoUtc(value: string): string { + return Option.match(DateTime.make(value), { + onNone: () => value, + onSome: DateTime.formatIso, + }); +} + +/** An app account has no nickname, so the display name is the only handle it has. */ +function toActor(raw: Schema.Schema.Type | null | undefined) { + const login = trimmed(raw?.nickname) ?? trimmed(raw?.display_name); + return login === null + ? null + : { + login, + name: trimmed(raw?.display_name), + avatarUrl: trimmed(raw?.links?.avatar?.href), + }; +} + +function toState(raw: Schema.Schema.Type): PullRequestState { + switch (raw.state?.trim().toUpperCase()) { + case "MERGED": + return "merged"; + case "DECLINED": + case "SUPERSEDED": + return "closed"; + default: + return "open"; + } +} + +function toBuildStatus(value: string | null | undefined): PullRequestCheckStatus { + switch (value?.trim().toUpperCase()) { + case "SUCCESSFUL": + return "success"; + case "FAILED": + return "failure"; + case "STOPPED": + return "cancelled"; + case "INPROGRESS": + return "pending"; + default: + return "neutral"; + } +} + +/** + * A participant who has voted is the closest Bitbucket has to a review, so it reads as one in + * the conversation. Participants who have only been added carry no verdict and are skipped. + */ +function toReviews( + raw: Schema.Schema.Type, +): ReadonlyArray { + return (raw.participants ?? []).flatMap((participant): ReadonlyArray => { + const author = toActor(participant.user); + const votedAt = trimmed(participant.participated_on); + const reviewState = + trimmed(participant.state) ?? (participant.approved === true ? "approved" : null); + if (author === null || votedAt === null || reviewState === null) return []; + return [ + { + id: `${raw.id}:${author.login}`, + kind: "review", + author, + body: "", + createdAt: toIsoUtc(votedAt), + url: null, + path: null, + reviewState, + }, + ]; + }); +} + +function toPullRequest(raw: Schema.Schema.Type): BitbucketPullRequest { + const reviewers = (raw.reviewers ?? []).flatMap((reviewer) => { + const actor = toActor(reviewer); + return actor === null ? [] : [actor]; + }); + return { + number: raw.id, + title: raw.title, + url: raw.links.html.href, + author: toActor(raw.author), + headBranch: raw.source.branch.name, + baseBranch: raw.destination.branch.name, + state: toState(raw), + isDraft: raw.draft ?? false, + mergeability: "unknown", + createdAt: toIsoUtc(raw.created_on), + updatedAt: toIsoUtc(raw.updated_on), + body: raw.description ?? "", + reviewRequestLogins: reviewers.map((reviewer) => reviewer.login), + reviewers, + reviewerIds: (raw.reviewers ?? []).flatMap((reviewer) => trimmed(reviewer.uuid) ?? []), + reviews: toReviews(raw), + }; +} + +const decodePage = decodeJsonResult(RawPageSchema); +const decodePullRequestEntry = Schema.decodeUnknownExit(RawPullRequestSchema); +const decodePullRequest = decodeJsonResult(RawPullRequestSchema); +const decodeCommentEntry = Schema.decodeUnknownExit(RawCommentSchema); +const decodeCommitEntry = Schema.decodeUnknownExit(RawCommitSchema); +const decodeStatusEntry = Schema.decodeUnknownExit(RawStatusSchema); +const decodeDiffstatEntry = Schema.decodeUnknownExit(RawDiffstatSchema); +const decodeMemberEntry = Schema.decodeUnknownExit(RawMemberSchema); +const decodeViewer = decodeJsonResult(RawViewerSchema); +const decodeConflicts = decodeJsonResult(RawPageSchema); +const decodeRepositoryPermissions = decodeJsonResult(RawRepositoryPermissionsSchema); + +type DecodeFailure = Cause.Cause; + +export interface BitbucketPage { + readonly items: ReadonlyArray; + /** The whole URL of the next page, which Bitbucket sends rather than an offset. */ + readonly next: string | null; +} + +/** Malformed entries are skipped rather than failing the page, as on the other hosts. */ +export function decodePullRequestPageJson( + raw: string, +): Result.Result, DecodeFailure> { + const decoded = decodePage(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const items: BitbucketPullRequest[] = []; + for (const entry of decoded.success.values) { + const item = decodePullRequestEntry(entry); + if (Exit.isSuccess(item)) { + items.push(toPullRequest(item.value)); + } + } + return Result.succeed({ items, next: trimmed(decoded.success.next) }); +} + +export function decodePullRequestJson( + raw: string, +): Result.Result { + const decoded = decodePullRequest(raw); + return Result.isSuccess(decoded) + ? Result.succeed(toPullRequest(decoded.success)) + : Result.fail(decoded.failure); +} + +export function decodeViewerJson(raw: string): Result.Result { + const decoded = decodeViewer(raw); + return Result.isSuccess(decoded) + ? Result.succeed(trimmed(decoded.success.nickname) ?? trimmed(decoded.success.display_name)) + : Result.fail(decoded.failure); +} + +/** + * Whether the configured credentials can write to the repository, which is what merging needs. + * Bitbucket answers `admin`, `write` or `read`, and an empty page means it named no permission at + * all for this account — an unknown standing, which is granted rather than guessed away. + */ +export function decodeRepositoryPermissionJson(raw: string): Result.Result { + const decoded = decodeRepositoryPermissions(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const permission = trimmed(decoded.success.values?.[0]?.permission)?.toLowerCase() ?? null; + return Result.succeed(permission === null || permission === "admin" || permission === "write"); +} + +/** + * The workspace's members, which is the nearest thing Bitbucket has to "who may review this". + * Nothing on a repository lists the people with access to it — `permissions-config/users` is for + * administrators only — and a pull request can be sent to anyone in the workspace, so this is the + * list Bitbucket's own reviewer field is filled from too. + * + * Nobody is marked requested here: who has been asked lives on the pull request, and only the + * caller holds both. + */ +export function decodeWorkspaceMembersJson( + raw: string, +): Result.Result, DecodeFailure> { + const decoded = decodePage(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const items: PullRequestReviewerCandidate[] = []; + for (const entry of decoded.success.values) { + const member = decodeMemberEntry(entry); + if (Exit.isFailure(member)) continue; + const uuid = trimmed(member.value.user?.uuid); + const actor = toActor(member.value.user); + if (uuid === null || actor === null) continue; + items.push({ ...actor, id: uuid, kind: "user", isRequested: false }); + } + return Result.succeed({ items, next: trimmed(decoded.success.next) }); +} + +/** One comment as Bitbucket sent it, kept so threads can be assembled across pages. */ +export type BitbucketRawComment = Schema.Schema.Type; + +export interface BitbucketComments { + readonly comments: ReadonlyArray; + /** + * The same comments unread, for `buildReviewThreads`. A reply and the remark it answers can + * land on different pages, and only the caller holding every page can put them together. + */ + readonly entries: ReadonlyArray; + readonly next: string | null; +} + +/** + * Bitbucket returns one flat list, so a thread is reassembled from it: a comment pinned to a + * line opens a thread, and every reply that leads back to it belongs in it. A reply whose + * parent is on a page that was not read has nowhere to go, and is left out rather than shown + * as a thread of its own — it still stands in the flat conversation, which needs no parent. + */ +export function buildReviewThreads( + comments: ReadonlyArray, +): ReadonlyArray { + const byId = new Map(comments.map((comment) => [comment.id, comment])); + const rootOf = (comment: Schema.Schema.Type) => { + // Bounded by the number of comments read, so a parent cycle cannot spin here. + let current = comment; + for (let step = 0; step < byId.size; step += 1) { + const parent = current.parent === null ? undefined : byId.get(current.parent?.id ?? -1); + if (parent === undefined) return current; + current = parent; + } + return current; + }; + + const threads = new Map(); + const replies = new Map>>(); + for (const comment of comments) { + const root = rootOf(comment); + const inline = root.inline; + const path = trimmed(inline?.path); + if (path === null) continue; + if (root.id === comment.id) { + // `to` is the line as the file stands now, `from` the line it replaced; a comment that + // carries only `from` was written against the removed side. + const side = inline?.to === null || inline?.to === undefined ? "left" : "right"; + const line = side === "left" ? inline?.from : inline?.to; + threads.set(root.id, { + id: String(root.id), + path, + line: typeof line === "number" && line > 0 ? line : null, + side, + isResolved: root.resolution !== null && root.resolution !== undefined, + isOutdated: inline?.outdated === true, + comments: [], + }); + } + const bucket = replies.get(root.id); + if (bucket === undefined) replies.set(root.id, [comment]); + else bucket.push(comment); + } + + return [...threads.values()].flatMap((thread) => { + const entries = (replies.get(Number(thread.id)) ?? []) + .toSorted((left, right) => left.created_on.localeCompare(right.created_on)) + .map((comment) => ({ + id: String(comment.id), + author: toActor(comment.user), + body: comment.content?.raw ?? "", + createdAt: toIsoUtc(comment.created_on), + url: trimmed(comment.links?.html?.href), + })); + return entries.length === 0 ? [] : [{ ...thread, comments: entries }]; + }); +} + +/** + * Deleted comments and ones their author has not posted yet carry nothing to show. A comment + * pinned to a file is a line-level review comment, which is what that kind means. + */ +export function decodeCommentsJson(raw: string): Result.Result { + const decoded = decodePage(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const comments: PullRequestComment[] = []; + const kept: Array = []; + for (const entry of decoded.success.values) { + const decodedComment = decodeCommentEntry(entry); + if (Exit.isFailure(decodedComment)) continue; + const comment = decodedComment.value; + if (comment.deleted === true || comment.pending === true) continue; + const body = comment.content?.raw ?? ""; + if (body.trim().length === 0) continue; + kept.push(comment); + const path = trimmed(comment.inline?.path); + comments.push({ + id: String(comment.id), + kind: path === null ? "issue-comment" : "review-comment", + author: toActor(comment.user), + body, + createdAt: toIsoUtc(comment.created_on), + url: trimmed(comment.links?.html?.href), + path, + reviewState: null, + }); + } + return Result.succeed({ comments, entries: kept, next: trimmed(decoded.success.next) }); +} + +export function decodeCommitsJson( + raw: string, +): Result.Result, DecodeFailure> { + const decoded = decodePage(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const commits: PullRequestCommit[] = []; + for (const entry of decoded.success.values) { + const decodedCommit = decodeCommitEntry(entry); + if (Exit.isFailure(decodedCommit)) continue; + const commit = decodedCommit.value; + const committedDate = trimmed(commit.date); + if (committedDate === null) continue; + const linkedAuthor = toActor(commit.author?.user); + const rawAuthor = trimmed(commit.author?.raw); + commits.push({ + oid: commit.hash, + messageHeadline: (commit.message ?? "").split("\n")[0] ?? "", + committedDate: toIsoUtc(committedDate), + authors: + linkedAuthor !== null + ? [linkedAuthor] + : rawAuthor === null + ? [] + : [{ login: rawAuthor, name: rawAuthor, avatarUrl: null }], + }); + } + // Bitbucket lists a pull request's commits newest first; the timeline reads oldest first. + return Result.succeed({ items: commits.toReversed(), next: trimmed(decoded.success.next) }); +} + +export function decodeStatusesJson( + raw: string, +): Result.Result, DecodeFailure> { + const decoded = decodePage(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const checks: Array<{ + readonly check: PullRequestCheck; + readonly workflowName: string | null; + readonly at: string | null; + }> = []; + for (const entry of decoded.success.values) { + const decodedStatus = decodeStatusEntry(entry); + if (Exit.isFailure(decodedStatus)) continue; + const status = decodedStatus.value; + const name = trimmed(status.name) ?? trimmed(status.key); + if (name === null) continue; + // Bitbucket re-uses a status key when a pipeline is run again, so the same check can appear + // twice on one page. Nothing decoded here says which copy is newer, so the later one wins, + // which is the order Bitbucket writes an update in. The key is kept as the workflow name so + // two different pipelines that display the same name are not folded into one. + checks.push({ + check: { + name, + status: toBuildStatus(status.state), + description: trimmed(status.description), + url: trimmed(status.url), + }, + workflowName: trimmed(status.key), + at: null, + }); + } + return Result.succeed({ items: dedupeChecks(checks), next: trimmed(decoded.success.next) }); +} + +export interface BitbucketDiffStat { + readonly additions: number; + readonly deletions: number; + readonly changedFiles: number; +} + +export interface BitbucketDiffStatPage extends BitbucketDiffStat { + readonly next: string | null; +} + +/** One entry per changed file, each carrying that file's line counts. */ +export function decodeDiffstatJson( + raw: string, +): Result.Result { + const decoded = decodePage(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + let additions = 0; + let deletions = 0; + let changedFiles = 0; + for (const entry of decoded.success.values) { + const decodedStat = decodeDiffstatEntry(entry); + if (Exit.isFailure(decodedStat)) continue; + additions += decodedStat.value.lines_added ?? 0; + deletions += decodedStat.value.lines_removed ?? 0; + changedFiles += 1; + } + return Result.succeed({ + additions, + deletions, + changedFiles, + next: trimmed(decoded.success.next), + }); +} + +/** + * The conflicts endpoint answers with one entry per conflicting path, so an empty page is the + * only statement Bitbucket makes that a pull request merges cleanly. + */ +export function decodeConflictsJson( + raw: string, +): Result.Result { + const decoded = decodeConflicts(raw); + return Result.isSuccess(decoded) + ? Result.succeed(decoded.success.values.length === 0 ? "mergeable" : "conflicting") + : Result.fail(decoded.failure); +} diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts new file mode 100644 index 000000000000..a3c3524a6d38 --- /dev/null +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts @@ -0,0 +1,1339 @@ +import * as Result from "effect/Result"; +import { describe, expect, it } from "vite-plus/test"; + +import { + buildReviewSubmissionJson, + buildReviewerRequestJson, + decodeBaseComparisonJson, + decodePullRequestActivityJson, + decodePullRequestDetailJson, + decodePullRequestFilesJson, + decodePullRequestListJson, + decodePullRequestNodeIdJson, + decodePullRequestSearchJson, + decodeRepositoryAccessJson, + decodeReviewerCandidatesJson, + decodeReviewThreadCommentsJson, + decodeReviewThreadsJson, + decodeViewerPermissionsJson, + reviewThreadConversation, + REVIEW_THREADS_GRAPHQL_QUERY, +} from "./gitHubPullRequestJson.ts"; + +function listJson(entries: ReadonlyArray>): string { + return JSON.stringify( + entries.map((entry) => ({ + number: 1, + title: "Add the pull requests page", + url: "https://github.com/pingdotgg/t3code/pull/1", + headRefName: "feat/page", + baseRefName: "main", + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-02T00:00:00Z", + ...entry, + })), + ); +} + +function expectSuccess(result: Result.Result): A { + expect(Result.isSuccess(result)).toBe(true); + if (!Result.isSuccess(result)) throw new Error("expected a successful decode"); + return result.success; +} + +describe("pull request list decoding", () => { + it("treats a merge timestamp as merged even when the state still says closed", () => { + const [entry] = expectSuccess( + decodePullRequestListJson(listJson([{ state: "CLOSED", mergedAt: "2026-07-03T00:00:00Z" }])), + ).items; + expect(entry?.state).toBe("merged"); + }); + + it("normalizes mergeability and defaults unknown values", () => { + const batch = expectSuccess( + decodePullRequestListJson( + listJson([{ mergeable: "CONFLICTING" }, { mergeable: "SOMETHING_NEW" }, {}]), + ), + ); + expect(batch.items.map((entry) => entry.mergeability)).toEqual([ + "conflicting", + "unknown", + "unknown", + ]); + }); + + it("keeps user review requests and drops team ones, which are not logins", () => { + const [entry] = expectSuccess( + decodePullRequestListJson( + listJson([{ reviewRequests: [{ login: "octocat" }, { slug: "web-platform" }] }]), + ), + ).items; + expect(entry?.reviewRequestLogins).toEqual(["octocat"]); + }); + + it("normalizes the review decision and reports nothing for one GitHub does not summarize", () => { + const batch = expectSuccess( + decodePullRequestListJson( + listJson([ + { reviewDecision: "APPROVED" }, + { reviewDecision: "CHANGES_REQUESTED" }, + { reviewDecision: "REVIEW_REQUIRED" }, + { reviewDecision: null }, + ]), + ), + ); + expect(batch.items.map((entry) => entry.reviewDecision)).toEqual([ + "approved", + "changes-requested", + "review-required", + null, + ]); + }); + + it("rolls the head commit's checks up to the one word a row has space for", () => { + const batch = expectSuccess( + decodePullRequestListJson( + listJson([ + // A failure outranks a run still going, and a completed run has to be read through its + // conclusion rather than its status. + { + statusCheckRollup: [ + { name: "lint", status: "COMPLETED", conclusion: "SUCCESS" }, + { name: "build", status: "IN_PROGRESS" }, + { name: "test", status: "COMPLETED", conclusion: "FAILURE" }, + ], + }, + { + statusCheckRollup: [ + { name: "lint", status: "COMPLETED", conclusion: "SUCCESS" }, + { name: "build", status: "QUEUED" }, + ], + }, + { statusCheckRollup: [{ name: "lint", status: "COMPLETED", conclusion: "SUCCESS" }] }, + // A commit status reports one `state` and no `status` at all. + { statusCheckRollup: [{ context: "ci/legacy", state: "ERROR" }] }, + // Neither a pass, a failure nor a wait is no verdict rather than a green tick. + { statusCheckRollup: [{ name: "lint", status: "COMPLETED", conclusion: "SKIPPED" }] }, + { statusCheckRollup: [] }, + {}, + ]), + ), + ); + expect(batch.items.map((entry) => entry.checksState)).toEqual([ + "failing", + "pending", + "passing", + "failing", + null, + null, + null, + ]); + }); + + it("skips malformed entries but still counts them, so paging does not stop early", () => { + const raw = `[${listJson([{}]).slice(1, -1)},{"number":"not-a-number"}]`; + const batch = expectSuccess(decodePullRequestListJson(raw)); + expect(batch.items).toHaveLength(1); + expect(batch.rawCount).toBe(2); + }); +}); + +describe("pull request search decoding", () => { + function searchJson(rollupStates: ReadonlyArray): string { + return JSON.stringify({ + data: { + search: { + pageInfo: { hasNextPage: false }, + nodes: rollupStates.map((state, index) => ({ + number: index + 1, + title: "Add the pull requests page", + url: "https://github.com/pingdotgg/t3code/pull/1", + headRefName: "feat/page", + baseRefName: "main", + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-02T00:00:00Z", + repository: { nameWithOwner: "pingdotgg/t3code" }, + commits: { + nodes: [{ commit: { statusCheckRollup: state === null ? null : { state } } }], + }, + })), + }, + }, + }); + } + + it("maps the rollup enum the search answers with onto the same three words", () => { + // The search asks GitHub for the verdict rather than the checks behind it, so this path sees + // one enum where the listing sees an array. + const batch = expectSuccess( + decodePullRequestSearchJson( + searchJson(["SUCCESS", "FAILURE", "ERROR", "PENDING", "EXPECTED", null]), + ), + ); + expect(batch.items.map((entry) => entry.checksState)).toEqual([ + "passing", + "failing", + "failing", + "pending", + "pending", + null, + ]); + }); +}); + +describe("pull request detail decoding", () => { + const detailJson = JSON.stringify({ + number: 7, + title: "Detail", + url: "https://github.com/pingdotgg/t3code/pull/7", + headRefName: "feat/detail", + baseRefName: "main", + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-05T00:00:00Z", + body: "Body", + statusCheckRollup: [ + { __typename: "CheckRun", name: "build", status: "IN_PROGRESS" }, + { __typename: "CheckRun", name: "test", status: "COMPLETED", conclusion: "FAILURE" }, + { __typename: "StatusContext", context: "ci/legacy", state: "SUCCESS" }, + ], + comments: [{ id: "c1", body: "second", createdAt: "2026-07-04T00:00:00Z" }], + reviews: [ + { id: "r1", body: "first", state: "CHANGES_REQUESTED", submittedAt: "2026-07-03T00:00:00Z" }, + { id: "r2", body: " ", state: "APPROVED", submittedAt: "2026-07-06T00:00:00Z" }, + ], + commits: [ + { + oid: "abc1234", + messageHeadline: "Ship the timeline", + committedDate: "2026-07-05T00:00:00Z", + authors: [ + { login: "octocat", name: "Octo Cat", email: "octo@example.com" }, + { name: "Pair Author", email: "pair@example.com" }, + ], + }, + ], + }); + + it("maps check-run status and commit-status state onto one vocabulary", () => { + const detail = expectSuccess(decodePullRequestDetailJson(detailJson)); + expect(detail.checks.map((check) => [check.name, check.status])).toEqual([ + ["build", "pending"], + ["test", "failure"], + ["ci/legacy", "success"], + ]); + }); + + it("reads an auto-merge request as armed, its null as off and its absence as neither", () => { + const raw = JSON.parse(detailJson) as Record; + const armed = (entry: Record) => + expectSuccess(decodePullRequestDetailJson(JSON.stringify({ ...raw, ...entry }))) + .autoMergeEnabled; + + expect( + armed({ autoMergeRequest: { enabledBy: { login: "octocat" }, mergeMethod: "SQUASH" } }), + ).toBe(true); + expect(armed({ autoMergeRequest: null })).toBe(false); + // `gh` not answering for the field at all is not GitHub saying the merge is unarmed. + expect(armed({})).toBeUndefined(); + }); + + it("shows a re-running check once, as the run that is happening now", () => { + // What `statusCheckRollup` reports while a workflow is being re-run: the same check twice, + // the finished run and the one that replaced it, with no id to tell them apart. + const raw = JSON.parse(detailJson) as Record; + const detail = expectSuccess( + decodePullRequestDetailJson( + JSON.stringify({ + ...raw, + statusCheckRollup: [ + { + __typename: "CheckRun", + name: "Prepare PR size config", + workflowName: "PR Size", + status: "COMPLETED", + conclusion: "SUCCESS", + startedAt: "2026-08-11T16:06:20Z", + completedAt: "2026-08-11T16:06:25Z", + }, + { + __typename: "CheckRun", + name: "Prepare PR size config", + workflowName: "PR Size", + status: "IN_PROGRESS", + conclusion: "", + startedAt: "2026-08-11T17:01:04Z", + completedAt: "0001-01-01T00:00:00Z", + }, + ], + }), + ), + ); + + expect(detail.checks.map((check) => [check.name, check.status])).toEqual([ + ["Prepare PR size config", "pending"], + ]); + expect(detail.checksState).toBe("pending"); + }); + + it("merges reviews with comments in time order and keeps a bodyless approval", () => { + const detail = expectSuccess(decodePullRequestActivityJson(detailJson)); + // r2 approved without writing anything, which is still the event worth seeing. + expect(detail.comments.map((comment) => comment.id)).toEqual(["r1", "c1", "r2"]); + expect(detail.comments.at(-1)?.reviewState).toBe("APPROVED"); + }); + + it("keeps every attributed commit author, including an unlinked signature", () => { + const detail = expectSuccess(decodePullRequestActivityJson(detailJson)); + expect(detail.commits[0]?.authors).toEqual([ + { login: "octocat", name: "Octo Cat", avatarUrl: null }, + { login: "Pair Author", name: "Pair Author", avatarUrl: null }, + ]); + }); + + it("drops the bodyless review GitHub opens to hold line comments", () => { + const raw = JSON.parse(detailJson) as Record; + const detail = expectSuccess( + decodePullRequestActivityJson( + JSON.stringify({ + ...raw, + reviews: [ + // What a reviewer leaving inline comments produces: a container with a state but + // nothing to read. Its comments come from the review threads instead. + { id: "r4", body: "", state: "COMMENTED", submittedAt: "2026-07-07T00:00:00Z" }, + { + id: "r5", + body: "Looks good.", + state: "COMMENTED", + submittedAt: "2026-07-08T00:00:00Z", + }, + ], + }), + ), + ); + + expect(detail.comments.map((comment) => comment.id)).toEqual(["c1", "r5"]); + }); + + it.each(["APPROVED", "CHANGES_REQUESTED", "DISMISSED"])( + "keeps a bodyless %s review, which is the event itself", + (state) => { + const raw = JSON.parse(detailJson) as Record; + const detail = expectSuccess( + decodePullRequestActivityJson( + JSON.stringify({ + ...raw, + reviews: [{ id: "r6", body: "", state, submittedAt: "2026-07-07T00:00:00Z" }], + }), + ), + ); + + expect(detail.comments.map((comment) => comment.id)).toContain("r6"); + }, + ); + + it("drops a review that carries neither a body nor a state", () => { + const raw = JSON.parse(detailJson) as Record; + const detail = expectSuccess( + decodePullRequestActivityJson( + JSON.stringify({ + ...raw, + reviews: [{ id: "r3", body: " ", submittedAt: "2026-07-07T00:00:00Z" }], + }), + ), + ); + expect(detail.comments.map((comment) => comment.id)).toEqual(["c1"]); + }); +}); + +describe("review thread decoding", () => { + const threadsJson = ( + nodes: ReadonlyArray>, + totalCount = nodes.length, + pageInfo: Record = { hasNextPage: false, endCursor: null }, + ): string => + JSON.stringify({ + data: { repository: { pullRequest: { reviewThreads: { totalCount, pageInfo, nodes } } } }, + }); + + /** The same query carries the review roster, so it is built alongside the threads. */ + const reviewJson = (input: { + readonly requested?: ReadonlyArray; + readonly reviewed?: ReadonlyArray; + }): string => + JSON.stringify({ + data: { + repository: { + pullRequest: { + reviewThreads: { totalCount: 0, nodes: [] }, + reviewRequests: { + nodes: (input.requested ?? []).map((r) => ({ requestedReviewer: r })), + }, + latestReviews: { nodes: (input.reviewed ?? []).map((a) => ({ author: a })) }, + }, + }, + }, + }); + + it("keeps a reviewer who has already reviewed, app or person, with their avatar", () => { + const result = expectSuccess( + decodeReviewThreadsJson( + reviewJson({ + requested: [{ login: "julius", name: "Julius", avatarUrl: "https://avatars/j.png" }], + // An app that has reviewed is no longer an outstanding request, which is why asking + // only for requests reported nobody on a pull request a bot had reviewed. + reviewed: [{ login: "macroscopeapp", avatarUrl: "https://avatars/in/900172.png" }], + }), + ), + ); + + expect(result.reviewers).toEqual([ + { login: "julius", name: "Julius", avatarUrl: "https://avatars/j.png" }, + { login: "macroscopeapp", name: null, avatarUrl: "https://avatars/in/900172.png" }, + ]); + }); + + it("carries per-commit line counts from the pull-request connection", () => { + const result = expectSuccess( + decodeReviewThreadsJson( + JSON.stringify({ + data: { + repository: { + pullRequest: { + reviewThreads: { totalCount: 0, nodes: [] }, + commits: { + nodes: [ + { commit: { oid: "abc123", additions: 18, deletions: 7 } }, + { commit: { oid: "def456", additions: 3, deletions: 0 } }, + ], + }, + }, + }, + }, + }), + ), + ); + + expect([...result.commitStats]).toEqual([ + ["abc123", { additions: 18, deletions: 7 }], + ["def456", { additions: 3, deletions: 0 }], + ]); + }); + + it("decodes the newest commits off the same connection, oldest to newest", () => { + const result = expectSuccess( + decodeReviewThreadsJson( + JSON.stringify({ + data: { + repository: { + pullRequest: { + reviewThreads: { totalCount: 0, nodes: [] }, + commits: { + nodes: [ + { + commit: { + oid: "abc123", + messageHeadline: "Ship the timeline", + committedDate: "2026-07-05T00:00:00Z", + additions: 18, + deletions: 7, + authors: { nodes: [{ name: "Julius", user: { login: "julius" } }] }, + }, + }, + { + commit: { + oid: "def456", + messageHeadline: "Fix the flaky test", + committedDate: "2026-07-06T00:00:00Z", + }, + }, + ], + }, + }, + }, + }, + }), + ), + ); + + expect(result.commits).toEqual([ + { + oid: "abc123", + messageHeadline: "Ship the timeline", + committedDate: "2026-07-05T00:00:00Z", + authors: [{ login: "julius", name: "Julius", avatarUrl: null }], + }, + { + oid: "def456", + messageHeadline: "Fix the flaky test", + committedDate: "2026-07-06T00:00:00Z", + authors: [], + }, + ]); + }); + + it("lists someone who was asked and then answered only once", () => { + const result = expectSuccess( + decodeReviewThreadsJson( + reviewJson({ + requested: [{ login: "julius", avatarUrl: "https://avatars/j.png" }], + reviewed: [{ login: "julius", avatarUrl: "https://avatars/j.png" }], + }), + ), + ); + + expect(result.reviewers).toHaveLength(1); + }); + + it("skips a team request, which names nobody to show", () => { + const result = expectSuccess(decodeReviewThreadsJson(reviewJson({ requested: [null] }))); + + expect(result.reviewers).toEqual([]); + }); + + it("keeps the conversation when a request is from a team, which has no login", () => { + // GraphQL answers with an empty object for a union member the query has no fragment for. + // Failing on it would take the whole response down, comments included. + const result = expectSuccess( + decodeReviewThreadsJson( + reviewJson({ requested: [{}, { login: "julius", avatarUrl: "https://avatars/j.png" }] }), + ), + ); + + expect(result.reviewers).toEqual([ + { login: "julius", name: null, avatarUrl: "https://avatars/j.png" }, + ]); + }); + + it("carries a resolved thread into the conversation, which was still said", () => { + const result = expectSuccess( + decodeReviewThreadsJson( + threadsJson([ + { + id: "PRRT_a", + isResolved: false, + path: "apps/server/src/ws.ts", + comments: { + nodes: [{ id: "t1", body: "fix this", createdAt: "2026-07-01T00:00:00Z" }], + }, + }, + { + id: "PRRT_b", + isResolved: true, + path: "apps/web/src/main.tsx", + comments: { nodes: [{ id: "t2", body: "done", createdAt: "2026-07-01T00:00:00Z" }] }, + }, + ]), + ), + ); + const comments = reviewThreadConversation(result.threads.map((entry) => entry.thread)); + expect(comments.map((comment) => comment.id)).toEqual(["t1", "t2"]); + expect(comments[0]).toMatchObject({ + id: "t1", + kind: "review-comment", + path: "apps/server/src/ws.ts", + }); + }); + + it("carries every reply, not only the remark each thread opened with", () => { + const result = expectSuccess( + decodeReviewThreadsJson( + threadsJson([ + { + id: "PRRT_c", + isResolved: false, + path: "apps/server/src/ws.ts", + comments: { + nodes: [ + { id: "t1", body: "fix this", createdAt: "2026-07-01T00:00:00Z" }, + { id: "t2", body: "fixed", createdAt: "2026-07-01T01:00:00Z" }, + ], + }, + }, + ]), + ), + ); + const comments = reviewThreadConversation(result.threads.map((entry) => entry.thread)); + expect(comments.map((comment) => comment.id)).toEqual(["t1", "t2"]); + }); + + it("hands back the cursor the next page of threads carries on from", () => { + const result = expectSuccess( + decodeReviewThreadsJson( + threadsJson( + [ + { + id: "PRRT_d", + path: "apps/server/src/ws.ts", + isResolved: false, + comments: { nodes: [{ id: "t1", createdAt: "2026-07-01T00:00:00Z" }] }, + }, + ], + 80, + { hasNextPage: true, endCursor: "Y3Vyc29yOjE" }, + ), + ), + ); + expect(result.nextCursor).toBe("Y3Vyc29yOjE"); + }); + + it("keeps GitHub's own count of a thread whose comments were not all read", () => { + const result = expectSuccess( + decodeReviewThreadsJson( + threadsJson([ + { + id: "PRRT_e", + path: "apps/server/src/ws.ts", + isResolved: false, + comments: { + totalCount: 140, + pageInfo: { hasNextPage: true, endCursor: "Y3Vyc29yOjI" }, + nodes: [{ id: "t1", createdAt: "2026-07-01T00:00:00Z" }], + }, + }, + ]), + ), + ); + expect(result.threads[0]).toMatchObject({ + commentCount: 140, + nextCommentCursor: "Y3Vyc29yOjI", + }); + }); + + it("ends a thread's walk on the last page, which still names a cursor", () => { + const decoded = expectSuccess( + decodeReviewThreadCommentsJson( + JSON.stringify({ + data: { + node: { + comments: { + pageInfo: { hasNextPage: false, endCursor: "Y3Vyc29yOjk" }, + nodes: [{ id: "t9", body: "last", createdAt: "2026-07-01T00:00:00Z" }], + }, + }, + }, + }), + ), + ); + expect(decoded.comments.map((comment) => comment.id)).toEqual(["t9"]); + expect(decoded.nextCursor).toBeNull(); + }); +}); + +describe("reaction decoding", () => { + const commentWithGroups = (reactionGroups: ReadonlyArray>) => + JSON.stringify({ + data: { + node: { + comments: { + pageInfo: { hasNextPage: false, endCursor: null }, + nodes: [{ id: "t1", body: "nice", createdAt: "2026-07-01T00:00:00Z", reactionGroups }], + }, + }, + }, + }); + + it("keeps a named group, widens a group whose reactors were cut short, drops an unknown content and an empty group", () => { + const decoded = expectSuccess( + decodeReviewThreadCommentsJson( + commentWithGroups([ + { + content: "THUMBS_UP", + viewerHasReacted: true, + reactors: { totalCount: 2, nodes: [{ login: "julius" }, { login: "bilal" }] }, + }, + // Not one of the eight the contract carries. + { + content: "PARTY_PARROT", + reactors: { totalCount: 1, nodes: [{ login: "hubot" }] }, + }, + // Nobody behind it, which GitHub still answers a group for. + { content: "HEART", reactors: { totalCount: 0, nodes: [] } }, + // More reactors than the bounded read named, and no `viewerHasReacted` at all. + { + content: "ROCKET", + reactors: { totalCount: 140, nodes: [{ login: "a" }, { login: "b" }, { login: "c" }] }, + }, + ]), + ), + ); + + expect(decoded.comments[0]?.reactions).toEqual([ + { content: "thumbs-up", count: 2, actors: ["julius", "bilal"], viewerHasReacted: true }, + { content: "rocket", count: 140, actors: ["a", "b", "c"], viewerHasReacted: false }, + ]); + }); + + it("leaves the viewer's own login out of actors, matched case-insensitively, while count still counts them", () => { + const decoded = expectSuccess( + decodeReviewThreadCommentsJson( + JSON.stringify({ + data: { + viewer: { login: "Bilal" }, + node: { + comments: { + pageInfo: { hasNextPage: false, endCursor: null }, + nodes: [ + { + id: "t1", + body: "nice", + createdAt: "2026-07-01T00:00:00Z", + reactionGroups: [ + { + content: "HEART", + viewerHasReacted: true, + reactors: { + totalCount: 2, + nodes: [{ login: "bilal" }, { login: "julius" }], + }, + }, + ], + }, + ], + }, + }, + }, + }), + ), + ); + + expect(decoded.comments[0]?.reactions).toEqual([ + { content: "heart", count: 2, actors: ["julius"], viewerHasReacted: true }, + ]); + }); +}); + +describe("repository access decoding", () => { + const repositoryJson = (viewerPermission?: string | null) => + JSON.stringify({ + mergeCommitAllowed: true, + squashMergeAllowed: false, + rebaseMergeAllowed: true, + ...(viewerPermission === undefined ? {} : { viewerPermission }), + }); + + it("reads the three settings gh reports", () => { + expect( + expectSuccess(decodeRepositoryAccessJson(repositoryJson("ADMIN"))).mergeCapabilities, + ).toEqual({ merge: true, squash: false, rebase: true }); + }); + + it("fails rather than defaulting open when a setting is missing", () => { + const decoded = decodeRepositoryAccessJson(JSON.stringify({ mergeCommitAllowed: true })); + expect(Result.isSuccess(decoded)).toBe(false); + }); + + it("counts the roles that can push as write, and the ones that cannot as read", () => { + for (const permission of ["ADMIN", "MAINTAIN", "WRITE"]) { + expect(expectSuccess(decodeRepositoryAccessJson(repositoryJson(permission))).canWrite).toBe( + true, + ); + } + for (const permission of ["TRIAGE", "READ", "NONE"]) { + expect(expectSuccess(decodeRepositoryAccessJson(repositoryJson(permission))).canWrite).toBe( + false, + ); + } + }); + + it("withholds write where gh names no permission, which is not a standing it gave", () => { + // The one place an unknown answer is not granted: a Merge button a reader cannot use wastes + // the press, where a missing one still leaves the pull request open on its host. + expect(expectSuccess(decodeRepositoryAccessJson(repositoryJson())).canWrite).toBe(false); + expect(expectSuccess(decodeRepositoryAccessJson(repositoryJson(null))).canWrite).toBe(false); + }); +}); + +describe("viewer permission decoding", () => { + const viewerJson = (repository: Record) => + JSON.stringify({ data: { repository } }); + + it("reads the repository's role and the pull request's own viewer fields together", () => { + expect( + expectSuccess( + decodeViewerPermissionsJson( + viewerJson({ + viewerPermission: "READ", + pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true }, + }), + ), + ), + ).toEqual({ canWrite: false, canUpdate: true, didAuthor: true }); + }); + + it("says no to a passer-by on a repository they can only read", () => { + expect( + expectSuccess( + decodeViewerPermissionsJson( + viewerJson({ + viewerPermission: "READ", + pullRequest: { viewerCanUpdate: false, viewerDidAuthor: false }, + }), + ), + ), + ).toEqual({ canWrite: false, canUpdate: false, didAuthor: false }); + }); + + it("reads silence as permission, but not as authorship", () => { + // A node the viewer cannot see comes back null. Updating is a permission, so an unknown + // answer grants it and lets the host refuse; authorship is a fact about who wrote the change, + // and claiming it for someone who did not is how an author's own rules get handed out. + expect(expectSuccess(decodeViewerPermissionsJson(viewerJson({ pullRequest: null })))).toEqual({ + canWrite: false, + canUpdate: true, + didAuthor: false, + }); + }); +}); + +describe("review thread decoding", () => { + const threadsJson = ( + nodes: ReadonlyArray>, + pullRequest: Record = {}, + ) => + JSON.stringify({ + data: { + repository: { + pullRequest: { + reviewThreads: { totalCount: nodes.length, nodes }, + author: null, + comments: { nodes: [] }, + reviewRequests: { nodes: [] }, + latestReviews: { nodes: [] }, + ...pullRequest, + }, + }, + }, + }); + + it("carries what the reader may do with the pull request, off the conversation read", () => { + // The same response the threads arrive in, so knowing this costs no request of its own. + expect( + expectSuccess( + decodeReviewThreadsJson( + threadsJson([], { viewerCanUpdate: false, viewerDidAuthor: false }), + ), + ).viewer, + ).toEqual({ canUpdate: false, didAuthor: false }); + expect(expectSuccess(decodeReviewThreadsJson(threadsJson([]))).viewer).toEqual({ + canUpdate: true, + didAuthor: false, + }); + }); + + const comment = (id: string, body: string) => ({ + id, + author: { login: "bilal", avatarUrl: "https://avatars/b.png" }, + body, + createdAt: "2026-07-01T00:00:00Z", + url: `https://github.com/acme/web/pull/1#discussion_r${id}`, + }); + + it("anchors a thread to its line and side, keeping the whole conversation", () => { + const reviewThreads = expectSuccess( + decodeReviewThreadsJson( + threadsJson([ + { + id: "PRRT_1", + isResolved: false, + isOutdated: false, + path: "src/a.ts", + line: 42, + diffSide: "LEFT", + comments: { totalCount: 2, nodes: [comment("c1", "first"), comment("c2", "second")] }, + }, + ]), + ), + ); + expect(reviewThreads.threads.map((entry) => entry.thread)).toEqual([ + { + id: "PRRT_1", + path: "src/a.ts", + line: 42, + side: "left", + isResolved: false, + isOutdated: false, + comments: [ + { + id: "c1", + author: { login: "bilal", name: null, avatarUrl: "https://avatars/b.png" }, + body: "first", + createdAt: "2026-07-01T00:00:00Z", + url: "https://github.com/acme/web/pull/1#discussion_rc1", + reactions: [], + }, + { + id: "c2", + author: { login: "bilal", name: null, avatarUrl: "https://avatars/b.png" }, + body: "second", + createdAt: "2026-07-01T00:00:00Z", + url: "https://github.com/acme/web/pull/1#discussion_rc2", + reactions: [], + }, + ], + }, + ]); + }); + + it("leaves an outdated thread without a line rather than pinning it to a stale one", () => { + const reviewThreads = expectSuccess( + decodeReviewThreadsJson( + threadsJson([ + { + id: "PRRT_2", + isResolved: true, + isOutdated: true, + path: "src/a.ts", + // GitHub reports no current line once the thread has fallen off the diff. + line: null, + diffSide: "RIGHT", + comments: { totalCount: 1, nodes: [comment("c3", "stale")] }, + }, + ]), + ), + ); + expect(reviewThreads.threads[0]?.thread).toMatchObject({ + line: null, + isOutdated: true, + isResolved: true, + }); + }); + + it("keeps a resolved thread in the conversation as well as against its line", () => { + const decoded = expectSuccess( + decodeReviewThreadsJson( + threadsJson([ + { + id: "PRRT_3", + isResolved: true, + path: "src/a.ts", + line: 7, + diffSide: "RIGHT", + comments: { totalCount: 1, nodes: [comment("c4", "done")] }, + }, + ]), + ), + ); + // A resolved conversation is finished work, not unsaid work: the timeline reads it and the + // diff pins it to its line, the same as any other. + const threads = decoded.threads.map((entry) => entry.thread); + expect(reviewThreadConversation(threads).map((comment) => comment.id)).toEqual(["c4"]); + expect(threads).toHaveLength(1); + }); + + it("puts an issue comment's and a review's reactions in reactionsById, and the pull request's own in reactions", () => { + const result = expectSuccess( + decodeReviewThreadsJson( + threadsJson([], { + reactionGroups: [ + { + content: "HEART", + viewerHasReacted: true, + reactors: { totalCount: 1, nodes: [{ login: "bilal" }] }, + }, + ], + comments: { + nodes: [ + { + id: "c1", + reactionGroups: [ + { + content: "THUMBS_UP", + reactors: { totalCount: 1, nodes: [{ login: "julius" }] }, + }, + ], + }, + ], + }, + reviews: { + nodes: [ + { + id: "r1", + reactionGroups: [ + { content: "EYES", reactors: { totalCount: 1, nodes: [{ login: "hubot" }] } }, + ], + }, + ], + }, + }), + ), + ); + + expect(result.reactions).toEqual([ + { content: "heart", count: 1, actors: ["bilal"], viewerHasReacted: true }, + ]); + expect([...result.reactionsById]).toEqual([ + ["c1", [{ content: "thumbs-up", count: 1, actors: ["julius"], viewerHasReacted: false }]], + ["r1", [{ content: "eyes", count: 1, actors: ["hubot"], viewerHasReacted: false }]], + ]); + }); + + it("leaves the viewer's own login out of the pull request's own reactions, matched case-insensitively, while count still counts them", () => { + const result = expectSuccess( + decodeReviewThreadsJson( + JSON.stringify({ + data: { + viewer: { login: "Bilal" }, + repository: { + pullRequest: { + reviewThreads: { totalCount: 0, nodes: [] }, + reactionGroups: [ + { + content: "HEART", + viewerHasReacted: true, + reactors: { totalCount: 2, nodes: [{ login: "bilal" }, { login: "julius" }] }, + }, + ], + }, + }, + }, + }), + ), + ); + + expect(result.reactions).toEqual([ + { content: "heart", count: 2, actors: ["julius"], viewerHasReacted: true }, + ]); + }); +}); + +describe("decodePullRequestNodeIdJson", () => { + it("reads the pull request's own node id, which a reaction on its description is addressed by", () => { + expect( + expectSuccess( + decodePullRequestNodeIdJson( + JSON.stringify({ data: { repository: { pullRequest: { id: "PR_kwDOA" } } } }), + ), + ), + ).toBe("PR_kwDOA"); + }); +}); + +describe("REVIEW_THREADS_GRAPHQL_QUERY", () => { + it("asks for reactionGroups on the pull request itself, its comments, its reviews and each thread's comments", () => { + expect(REVIEW_THREADS_GRAPHQL_QUERY.match(/reactionGroups/g)).toHaveLength(4); + // The reviews connection is new: only reactions were ever wanted off it. + expect(REVIEW_THREADS_GRAPHQL_QUERY).toContain("reviews(first:"); + }); +}); + +describe("reviewer candidate decoding", () => { + const candidatesJson = (input: { + readonly assignable: ReadonlyArray | null>; + readonly requested?: ReadonlyArray | null>; + readonly author?: string; + readonly hasNextPage?: boolean; + }) => + JSON.stringify({ + data: { + repository: { + assignableUsers: { + pageInfo: { hasNextPage: input.hasNextPage ?? false }, + nodes: input.assignable, + }, + pullRequest: { + author: input.author === undefined ? null : { login: input.author }, + reviewRequests: { + nodes: (input.requested ?? []).map((requestedReviewer) => ({ requestedReviewer })), + }, + }, + }, + }, + }); + + it("leaves the author out of the people their own pull request can be sent to", () => { + const list = expectSuccess( + decodeReviewerCandidatesJson( + candidatesJson({ + assignable: [{ login: "bilal" }, { login: "octocat", name: "The Octocat" }], + author: "bilal", + }), + ), + ); + expect(list.candidates).toEqual([ + { + id: "octocat", + kind: "user", + login: "octocat", + name: "The Octocat", + avatarUrl: null, + isRequested: false, + }, + ]); + expect(list.truncated).toBe(false); + }); + + it("marks whoever has already been asked, and leaves the rest to be asked", () => { + const list = expectSuccess( + decodeReviewerCandidatesJson( + candidatesJson({ + assignable: [{ login: "octocat" }, { login: "hubot" }], + requested: [{ login: "octocat" }], + }), + ), + ); + expect(list.candidates.map((candidate) => [candidate.login, candidate.isRequested])).toEqual([ + ["octocat", true], + ["hubot", false], + ]); + }); + + it("keeps a requested team apart from the people, so the request can be taken back", () => { + // A team is never among the assignable users, and a request that cannot be seen cannot be + // undone — so the ones GitHub reports are carried, marked as the teams they are. + const list = expectSuccess( + decodeReviewerCandidatesJson( + candidatesJson({ + assignable: [{ login: "octocat" }], + requested: [{ slug: "reviewers", name: "Reviewers" }], + }), + ), + ); + expect(list.candidates).toEqual([ + { + id: "reviewers", + kind: "team", + login: "reviewers", + name: "Reviewers", + avatarUrl: null, + isRequested: true, + }, + { + id: "octocat", + kind: "user", + login: "octocat", + name: null, + avatarUrl: null, + isRequested: false, + }, + ]); + }); + + it("says so when the repository has more people than the read asked for", () => { + expect( + expectSuccess( + decodeReviewerCandidatesJson( + candidatesJson({ assignable: [{ login: "octocat" }], hasNextPage: true }), + ), + ).truncated, + ).toBe(true); + }); +}); + +describe("reviewer request payload", () => { + it("sends people and teams in the two lists GitHub keeps them in", () => { + expect( + JSON.parse( + buildReviewerRequestJson([ + { id: "octocat", kind: "user" }, + { id: "reviewers", kind: "team" }, + { id: "hubot", kind: "user" }, + ]), + ), + ).toEqual({ reviewers: ["octocat", "hubot"], team_reviewers: ["reviewers"] }); + }); + + it("sends both lists even where one of them is empty, which is what GitHub reads", () => { + expect(JSON.parse(buildReviewerRequestJson([{ id: "octocat", kind: "user" }]))).toEqual({ + reviewers: ["octocat"], + team_reviewers: [], + }); + }); +}); + +describe("review submission payload", () => { + it("sends the verdict, the summary and every line comment in one body", () => { + const payload = JSON.parse( + buildReviewSubmissionJson({ + verdict: "request-changes", + body: "Two things.", + comments: [ + { path: "src/a.ts", line: 12, side: "right", body: "rename this" }, + { path: "src/b.ts", line: 3, side: "left", body: "why remove?" }, + ], + }), + ) as Record; + expect(payload).toEqual({ + event: "REQUEST_CHANGES", + body: "Two things.", + comments: [ + { path: "src/a.ts", line: 12, side: "RIGHT", body: "rename this" }, + { path: "src/b.ts", line: 3, side: "LEFT", body: "why remove?" }, + ], + }); + }); + + it("sends an approval with no words and no comments", () => { + expect( + JSON.parse(buildReviewSubmissionJson({ verdict: "approve", body: "", comments: [] })), + ).toEqual({ event: "APPROVE", body: "", comments: [] }); + }); +}); + +describe("decodePullRequestFilesJson", () => { + it("assembles a unified patch the files API does not return", () => { + const result = expectSuccess( + decodePullRequestFilesJson( + JSON.stringify([ + { filename: "src/app.ts", status: "modified", patch: "@@ -1 +1 @@\n-old\n+new" }, + ]), + ), + ); + + expect(result.patch).toBe( + [ + "diff --git a/src/app.ts b/src/app.ts", + "--- a/src/app.ts", + "+++ b/src/app.ts", + "@@ -1 +1 @@", + "-old", + "+new", + "", + ].join("\n"), + ); + expect(result.truncated).toBe(false); + expect(result.rawCount).toBe(1); + }); + + it("points an added file at /dev/null on the left and a removed one on the right", () => { + const result = expectSuccess( + decodePullRequestFilesJson( + JSON.stringify([ + { filename: "src/new.ts", status: "added", patch: "@@ -0,0 +1 @@\n+hello" }, + { filename: "src/gone.ts", status: "removed", patch: "@@ -1 +0,0 @@\n-bye" }, + ]), + ), + ); + + expect(result.patch).toBe( + [ + "diff --git a/src/new.ts b/src/new.ts", + "new file mode 100644", + "--- /dev/null", + "+++ b/src/new.ts", + "@@ -0,0 +1 @@", + "+hello", + "diff --git a/src/gone.ts b/src/gone.ts", + "deleted file mode 100644", + "--- a/src/gone.ts", + "+++ /dev/null", + "@@ -1 +0,0 @@", + "-bye", + "", + ].join("\n"), + ); + }); + + it("names both paths of a rename, counting its hunks against the old one", () => { + const result = expectSuccess( + decodePullRequestFilesJson( + JSON.stringify([ + { + filename: "src/new.ts", + status: "renamed", + previous_filename: "src/old.ts", + patch: "@@ -1 +1 @@\n-old\n+new", + }, + ]), + ), + ); + + expect(result.patch).toBe( + [ + "diff --git a/src/old.ts b/src/new.ts", + "rename from src/old.ts", + "rename to src/new.ts", + "--- a/src/old.ts", + "+++ b/src/new.ts", + "@@ -1 +1 @@", + "-old", + "+new", + "", + ].join("\n"), + ); + }); + + it("still lists a file GitHub sent no hunks for, and says what was withheld", () => { + const result = expectSuccess( + decodePullRequestFilesJson( + JSON.stringify([ + // Binary: it changed, and none of it can be shown. + { filename: "logo.png", status: "modified", additions: 4, deletions: 2 }, + { + filename: "src/app.ts", + status: "modified", + additions: 1, + deletions: 1, + patch: "@@ -1 +1 @@\n-old\n+new", + }, + ]), + ), + ); + + // Dropping it would take the file out of the change altogether, not just its contents. + expect(result.patch).toContain("diff --git a/logo.png b/logo.png"); + expect(result.patch).toContain("diff --git a/src/app.ts b/src/app.ts"); + expect(result.truncated).toBe(true); + expect(result.rawCount).toBe(2); + }); + + it("does not call a pure rename incomplete, since it has no hunks to withhold", () => { + const result = expectSuccess( + decodePullRequestFilesJson( + JSON.stringify([ + { + filename: "src/new.ts", + previous_filename: "src/old.ts", + status: "renamed", + additions: 0, + deletions: 0, + }, + ]), + ), + ); + + expect(result.patch).toContain("rename from src/old.ts"); + expect(result.truncated).toBe(false); + }); +}); + +describe("how far a branch trails its base", () => { + const comparison = (pullRequest: unknown) => + JSON.stringify({ data: { repository: { pullRequest } } }); + + it("reads the commit count and whether this viewer may move the branch", () => { + const decoded = expectSuccess( + decodeBaseComparisonJson( + comparison({ viewerCanUpdateBranch: true, baseRef: { compare: { behindBy: 12 } } }), + ), + ); + expect(decoded).toEqual({ behindBy: 12, viewerCanUpdate: true }); + }); + + it("reads a current branch as nothing to do", () => { + expect( + expectSuccess( + decodeBaseComparisonJson( + comparison({ viewerCanUpdateBranch: false, baseRef: { compare: { behindBy: 0 } } }), + ), + ), + ).toEqual({ behindBy: 0, viewerCanUpdate: false }); + }); + + it("answers unknown where the head could not be compared", () => { + // A pull request from a fork whose repository is gone, which GitHub answers with a null + // comparison beside a perfectly good pull request. + expect( + expectSuccess( + decodeBaseComparisonJson(comparison({ viewerCanUpdateBranch: true, baseRef: null })), + ).behindBy, + ).toBeNull(); + expect(expectSuccess(decodeBaseComparisonJson(comparison(null)))).toEqual({ + behindBy: null, + viewerCanUpdate: false, + }); + }); + + it("refuses a body that is not the answer to this question", () => { + expect(Result.isSuccess(decodeBaseComparisonJson("{"))).toBe(false); + }); +}); diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.ts new file mode 100644 index 000000000000..e113b87d81da --- /dev/null +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.ts @@ -0,0 +1,2206 @@ +import * as Cause from "effect/Cause"; +import * as Exit from "effect/Exit"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import type { + PullRequestActor, + PullRequestCheck, + PullRequestCheckStatus, + PullRequestChecksState, + PullRequestComment, + PullRequestCommit, + PullRequestLabel, + PullRequestMergeCapabilities, + PullRequestOmittedFileStat, + PullRequestMergeability, + PullRequestReaction, + PullRequestReactionContent, + PullRequestReviewCommentDraft, + PullRequestReviewDecision, + PullRequestReviewThread, + PullRequestReviewVerdict, + PullRequestReviewerCandidate, + PullRequestReviewerCandidateList, + PullRequestReviewerKind, + PullRequestState, + PullRequestThreadComment, +} from "@t3tools/contracts"; +import { decodeJsonResult } from "@t3tools/shared/schemaJson"; + +import { dedupeChecks } from "./pullRequestChecks.ts"; + +/** + * Enum-ish GitHub CLI fields are decoded as plain strings and normalized here: a `gh` + * release that adds a conclusion or a review state must not fail the whole payload. + */ +const RawActorSchema = Schema.Struct({ + /** + * Optional because a review can be requested from a team or a mannequin, which the query has + * no fragment for and GraphQL answers with an empty object. A reviewer with no login names + * nobody to show, and must not fail the response the conversation travels in. + */ + login: Schema.optional(Schema.String), + /** The node id, which is how a listing's authors are resolved to avatars in one request. */ + id: Schema.optional(Schema.NullOr(Schema.String)), + name: Schema.optional(Schema.NullOr(Schema.String)), + /** Only the GraphQL API reports one; `gh pr view --json` has no avatar to give. */ + avatarUrl: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawLabelSchema = Schema.Struct({ + name: Schema.String, + color: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawReviewRequestSchema = Schema.Struct({ + login: Schema.optional(Schema.NullOr(Schema.String)), + slug: Schema.optional(Schema.NullOr(Schema.String)), + name: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawCheckSchema = Schema.Struct({ + __typename: Schema.optional(Schema.String), + name: Schema.optional(Schema.NullOr(Schema.String)), + context: Schema.optional(Schema.NullOr(Schema.String)), + status: Schema.optional(Schema.NullOr(Schema.String)), + conclusion: Schema.optional(Schema.NullOr(Schema.String)), + state: Schema.optional(Schema.NullOr(Schema.String)), + description: Schema.optional(Schema.NullOr(Schema.String)), + detailsUrl: Schema.optional(Schema.NullOr(Schema.String)), + targetUrl: Schema.optional(Schema.NullOr(Schema.String)), + /** + * What tells two same-named checks apart, and which run of one is the newest. All three ride + * along with `statusCheckRollup` already — it is asked for as a whole field — so reading them + * costs no request. Empty for an app-provided check run, which belongs to no workflow, and + * absent entirely on a commit status, which is not a run at all. + */ + workflowName: Schema.optional(Schema.NullOr(Schema.String)), + startedAt: Schema.optional(Schema.NullOr(Schema.String)), + completedAt: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawListItemSchema = Schema.Struct({ + number: Schema.Int, + title: Schema.String, + url: Schema.String, + author: Schema.optional(Schema.NullOr(RawActorSchema)), + headRefName: Schema.String, + baseRefName: Schema.String, + state: Schema.optional(Schema.NullOr(Schema.String)), + isDraft: Schema.optional(Schema.Boolean), + mergeable: Schema.optional(Schema.NullOr(Schema.String)), + reviewDecision: Schema.optional(Schema.NullOr(Schema.String)), + additions: Schema.optional(Schema.Int), + deletions: Schema.optional(Schema.Int), + createdAt: Schema.String, + updatedAt: Schema.String, + mergedAt: Schema.optional(Schema.NullOr(Schema.String)), + reviewRequests: Schema.optional(Schema.Array(RawReviewRequestSchema)), + labels: Schema.optional(Schema.Array(RawLabelSchema)), + /** + * Every check of the head commit, which is the only rollup `gh pr list --json` can give: there + * is no field for the one-word verdict. Measured against `pingdotgg/t3code`, asking for it costs + * 0.6s -> 7.9s at a hundred rows and 0.9s -> 2.1s at thirty, for 425 KB of checks a listing + * reduces to one word. The listing pays it because the alternative is a request per row; the + * cross-repository search below asks GitHub for the verdict itself instead. + */ + statusCheckRollup: Schema.optional(Schema.NullOr(Schema.Array(RawCheckSchema))), +}); + +/** + * A search's own answer, which is the listing's row one connection deeper: `gh pr list --json` + * flattens reviewers and labels, and GraphQL does not. Everything below the row is optional + * because a node that is not a pull request decodes as an empty object, which is skipped. + */ +const RawSearchItemSchema = Schema.Struct({ + number: Schema.Int, + title: Schema.String, + url: Schema.String, + author: Schema.optional(Schema.NullOr(RawActorSchema)), + headRefName: Schema.String, + baseRefName: Schema.String, + state: Schema.optional(Schema.NullOr(Schema.String)), + isDraft: Schema.optional(Schema.Boolean), + mergeable: Schema.optional(Schema.NullOr(Schema.String)), + reviewDecision: Schema.optional(Schema.NullOr(Schema.String)), + createdAt: Schema.String, + updatedAt: Schema.String, + mergedAt: Schema.optional(Schema.NullOr(Schema.String)), + repository: Schema.optional(Schema.NullOr(Schema.Struct({ nameWithOwner: Schema.String }))), + reviewRequests: Schema.optional( + Schema.NullOr( + Schema.Struct({ + nodes: Schema.optional( + Schema.NullOr( + Schema.Array( + Schema.NullOr( + Schema.Struct({ + requestedReviewer: Schema.optional(Schema.NullOr(RawActorSchema)), + }), + ), + ), + ), + ), + }), + ), + ), + labels: Schema.optional( + Schema.NullOr( + Schema.Struct({ + nodes: Schema.optional(Schema.NullOr(Schema.Array(Schema.NullOr(RawLabelSchema)))), + }), + ), + ), + /** + * GraphQL answers the rollup a listing actually wants — one enum for the head commit, rather + * than the whole check array `gh pr list --json` insists on. Measured at a hundred rows across + * this repository: 0.8s -> 3.0s and 15 KB, against 425 KB for the same verdict over `gh`. + */ + commits: Schema.optional( + Schema.NullOr( + Schema.Struct({ + nodes: Schema.optional( + Schema.NullOr( + Schema.Array( + Schema.NullOr( + Schema.Struct({ + commit: Schema.optional( + Schema.NullOr( + Schema.Struct({ + statusCheckRollup: Schema.optional( + Schema.NullOr(Schema.Struct({ state: Schema.String })), + ), + }), + ), + ), + }), + ), + ), + ), + ), + }), + ), + ), +}); + +const RawSearchSchema = Schema.Struct({ + data: Schema.Struct({ + search: Schema.Struct({ + pageInfo: Schema.optional(Schema.NullOr(Schema.Struct({ hasNextPage: Schema.Boolean }))), + // Row by row, like the listing's own: a node that is not a pull request — or one field + // GitHub changes — is skipped rather than blanking every repository at once. + nodes: Schema.optional(Schema.NullOr(Schema.Array(Schema.Unknown))), + }), + }), +}); + +/** One aliased lookup per row, so the response is keyed by the position it was asked in. */ +const RawStatsSchema = Schema.Struct({ + data: Schema.optional( + Schema.NullOr( + Schema.Record( + Schema.String, + Schema.NullOr( + Schema.Struct({ + pullRequest: Schema.optional( + Schema.NullOr( + Schema.Struct({ + additions: Schema.optional(Schema.NullOr(Schema.Int)), + deletions: Schema.optional(Schema.NullOr(Schema.Int)), + }), + ), + ), + }), + ), + ), + ), + ), +}); + +/** How many of a reaction's people the hover names before it counts the rest. */ +const REACTORS_PER_GROUP = 10; + +/** + * A reaction group as every reactable node reports it. `reactors` is bounded rather than paged: + * a hover says who reacted, and a hundred and forty names is a count, not a sentence. + */ +const REACTION_GROUPS_FIELDS = `reactionGroups { + content + viewerHasReacted + reactors(first: ${REACTORS_PER_GROUP}) { + totalCount + nodes { + ... on User { login } + ... on Bot { login } + ... on Organization { login } + ... on Mannequin { login } + } + } +}`; + +/** GitHub's reaction names, which are the same eight the contract carries under other spellings. */ +const REACTION_CONTENT_BY_GITHUB: Readonly> = { + THUMBS_UP: "thumbs-up", + THUMBS_DOWN: "thumbs-down", + LAUGH: "laugh", + HOORAY: "hooray", + CONFUSED: "confused", + HEART: "heart", + ROCKET: "rocket", + EYES: "eyes", +}; + +const GITHUB_REACTION_BY_CONTENT: Readonly> = { + "thumbs-up": "THUMBS_UP", + "thumbs-down": "THUMBS_DOWN", + laugh: "LAUGH", + hooray: "HOORAY", + confused: "CONFUSED", + heart: "HEART", + rocket: "ROCKET", + eyes: "EYES", +}; + +export function gitHubReactionContent(content: PullRequestReactionContent): string { + return GITHUB_REACTION_BY_CONTENT[content]; +} + +const RawReactionGroupsSchema = Schema.optional( + Schema.NullOr( + Schema.Array( + Schema.Struct({ + content: Schema.optional(Schema.NullOr(Schema.String)), + viewerHasReacted: Schema.optional(Schema.Boolean), + reactors: Schema.optional( + Schema.NullOr( + Schema.Struct({ + totalCount: Schema.optional(Schema.Int), + nodes: Schema.optional( + Schema.NullOr( + Schema.Array( + Schema.NullOr( + Schema.Struct({ login: Schema.optional(Schema.NullOr(Schema.String)) }), + ), + ), + ), + ), + }), + ), + ), + }), + ), + ), +); + +type RawReactionGroups = typeof RawReactionGroupsSchema.Type; + +/** + * The groups GitHub answered with, as the contract carries them. A group with nobody behind it is + * dropped: GitHub answers with a group per content it knows, including the ones nobody chose. The + * viewer's own login is left out of `actors` — the page names them "You" instead, and leaving it + * in would name them twice — but `count` still counts them along with everyone else. + */ +function toReactions( + groups: RawReactionGroups, + viewer: string | null, +): ReadonlyArray { + const normalizedViewer = viewer?.toLowerCase() ?? null; + const reactions: PullRequestReaction[] = []; + for (const group of groups ?? []) { + const content = REACTION_CONTENT_BY_GITHUB[trimmed(group.content)?.toUpperCase() ?? ""]; + if (content === undefined) continue; + const logins = (group.reactors?.nodes ?? []).flatMap((node) => trimmed(node?.login) ?? []); + const count = Math.max(group.reactors?.totalCount ?? logins.length, logins.length); + if (count <= 0) continue; + const actors = + normalizedViewer === null + ? logins + : logins.filter((login) => login.toLowerCase() !== normalizedViewer); + reactions.push({ content, count, actors, viewerHasReacted: group.viewerHasReacted === true }); + } + return reactions; +} + +const RawCommentSchema = Schema.Struct({ + id: Schema.String, + author: Schema.optional(Schema.NullOr(RawActorSchema)), + body: Schema.optional(Schema.String), + createdAt: Schema.String, + url: Schema.optional(Schema.NullOr(Schema.String)), + /** Only ever present on a GraphQL read; `gh pr view --json` reports no reaction at all. */ + reactionGroups: RawReactionGroupsSchema, +}); + +const RawReviewSchema = Schema.Struct({ + id: Schema.String, + author: Schema.optional(Schema.NullOr(RawActorSchema)), + body: Schema.optional(Schema.String), + state: Schema.optional(Schema.NullOr(Schema.String)), + submittedAt: Schema.optional(Schema.NullOr(Schema.String)), + url: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawCommitSchema = Schema.Struct({ + oid: Schema.String, + messageHeadline: Schema.optional(Schema.String), + committedDate: Schema.String, + authors: Schema.optional( + Schema.Array( + Schema.Struct({ + email: Schema.optional(Schema.NullOr(Schema.String)), + id: Schema.optional(Schema.NullOr(Schema.String)), + login: Schema.optional(Schema.NullOr(Schema.String)), + name: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + ), +}); + +const RawDetailSchema = Schema.Struct({ + ...RawListItemSchema.fields, + /** Names the fork a pull request came from, which is what qualifies its head ref. */ + headRepositoryOwner: Schema.optional(Schema.NullOr(Schema.Struct({ login: Schema.String }))), + body: Schema.optional(Schema.String), + changedFiles: Schema.optional(Schema.Int), + closedAt: Schema.optional(Schema.NullOr(Schema.String)), + /** + * The standing instruction to merge once GitHub's own requirements are met, which is an object + * describing who armed it and how, and a JSON null where nobody has. Nothing inside it is read: + * the question the page asks is whether one exists. + */ + autoMergeRequest: Schema.optional(Schema.NullOr(Schema.Unknown)), +}); + +const RawActivitySchema = Schema.Struct({ + author: Schema.optional(Schema.NullOr(RawActorSchema)), + comments: Schema.optional(Schema.Array(RawCommentSchema)), + reviews: Schema.optional(Schema.Array(RawReviewSchema)), + commits: Schema.optional(Schema.Array(RawCommitSchema)), +}); + +/** Where a connection carries on from, which is what every paged read below follows. */ +const RawPageInfoSchema = Schema.Struct({ + hasNextPage: Schema.optional(Schema.Boolean), + endCursor: Schema.optional(Schema.NullOr(Schema.String)), +}); + +/** + * What GitHub says the viewer may do with a pull request. Both are optional so that an install + * that answers without them still delivers the conversation they travel with; an absent field + * reads as granted, which is what an unknown permission is. + */ +const RawViewerFieldsSchema = Schema.Struct({ + viewerCanUpdate: Schema.optional(Schema.Boolean), + viewerDidAuthor: Schema.optional(Schema.Boolean), +}); + +const RawThreadCommentsSchema = Schema.Struct({ + totalCount: Schema.optional(Schema.Int), + pageInfo: Schema.optional(RawPageInfoSchema), + nodes: Schema.Array(RawCommentSchema), +}); + +/** `gh pr view --json` cannot reach review threads, so they come from the GraphQL API. */ +const RawReviewThreadsSchema = Schema.Struct({ + data: Schema.Struct({ + // Rides along in the same request: GitHub names who reacted but never says whether that is + // the reader, so the comparison is made here rather than paid for with a request of its own. + viewer: Schema.optional( + Schema.NullOr(Schema.Struct({ login: Schema.optional(Schema.NullOr(Schema.String)) })), + ), + repository: Schema.Struct({ + pullRequest: Schema.Struct({ + reviewThreads: Schema.Struct({ + totalCount: Schema.optional(Schema.Int), + pageInfo: Schema.optional(RawPageInfoSchema), + nodes: Schema.Array( + Schema.Struct({ + id: Schema.optional(Schema.NullOr(Schema.String)), + isResolved: Schema.optional(Schema.Boolean), + isOutdated: Schema.optional(Schema.Boolean), + path: Schema.optional(Schema.NullOr(Schema.String)), + /** Null once the thread's line has left the diff, which `isOutdated` reports. */ + line: Schema.optional(Schema.NullOr(Schema.Int)), + diffSide: Schema.optional(Schema.NullOr(Schema.String)), + comments: RawThreadCommentsSchema, + }), + ), + }), + ...RawViewerFieldsSchema.fields, + author: Schema.optional(Schema.NullOr(RawActorSchema)), + reactionGroups: RawReactionGroupsSchema, + comments: Schema.optional( + Schema.NullOr( + Schema.Struct({ + nodes: Schema.Array( + Schema.Struct({ + id: Schema.optional(Schema.NullOr(Schema.String)), + author: Schema.optional(Schema.NullOr(RawActorSchema)), + reactionGroups: RawReactionGroupsSchema, + }), + ), + }), + ), + ), + /** + * Reviews for their reactions alone: the words and the verdict arrive with + * `gh pr view --json reviews`, which reports no reaction of any kind. + */ + reviews: Schema.optional( + Schema.NullOr( + Schema.Struct({ + nodes: Schema.Array( + Schema.Struct({ + id: Schema.optional(Schema.NullOr(Schema.String)), + reactionGroups: RawReactionGroupsSchema, + }), + ), + }), + ), + ), + reviewRequests: Schema.optional( + Schema.NullOr( + Schema.Struct({ + nodes: Schema.Array( + Schema.Struct({ + // Null for a team, which is a request nobody in particular owns. + requestedReviewer: Schema.optional(Schema.NullOr(RawActorSchema)), + }), + ), + }), + ), + ), + latestReviews: Schema.optional( + Schema.NullOr( + Schema.Struct({ + nodes: Schema.Array( + Schema.Struct({ author: Schema.optional(Schema.NullOr(RawActorSchema)) }), + ), + }), + ), + ), + reviewDismissals: Schema.optional( + Schema.NullOr( + Schema.Struct({ + pageInfo: Schema.optional(RawPageInfoSchema), + nodes: Schema.Array( + Schema.Struct({ + dismissalMessage: Schema.optional(Schema.NullOr(Schema.String)), + review: Schema.optional( + Schema.NullOr( + Schema.Struct({ id: Schema.optional(Schema.NullOr(Schema.String)) }), + ), + ), + }), + ), + }), + ), + ), + commits: Schema.optional( + Schema.NullOr( + Schema.Struct({ + nodes: Schema.Array( + Schema.Struct({ + commit: Schema.Struct({ + oid: Schema.String, + messageHeadline: Schema.optional(Schema.NullOr(Schema.String)), + committedDate: Schema.optional(Schema.NullOr(Schema.String)), + additions: Schema.optional(Schema.Int), + deletions: Schema.optional(Schema.Int), + authors: Schema.optional( + Schema.NullOr( + Schema.Struct({ + nodes: Schema.Array( + Schema.Struct({ + name: Schema.optional(Schema.NullOr(Schema.String)), + avatarUrl: Schema.optional(Schema.NullOr(Schema.String)), + user: Schema.optional( + Schema.NullOr( + Schema.Struct({ + login: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + ), + }), + ), + }), + ), + ), + }), + }), + ), + }), + ), + ), + }), + }), + }), +}); + +/** Requested together, so a response missing any of them fails rather than defaulting open: + * guessing `true` would offer a merge method the repository forbids. */ +const RawRepositoryAccessSchema = Schema.Struct({ + mergeCommitAllowed: Schema.Boolean, + squashMergeAllowed: Schema.Boolean, + rebaseMergeAllowed: Schema.Boolean, + /** + * ADMIN, MAINTAIN, WRITE, TRIAGE, READ or NONE. Optional rather than required, unlike the + * three above: an install that does not report it leaves the viewer's standing unknown, which + * is answered by granting rather than by failing the whole detail read. + */ + viewerPermission: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawPullRequestFileSchema = Schema.Struct({ + filename: Schema.String, + status: Schema.optional(Schema.NullOr(Schema.String)), + /** Only on a rename, where it names the file the hunks are counted against. */ + previous_filename: Schema.optional(Schema.NullOr(Schema.String)), + /** Absent for a binary file, and for one whose diff GitHub considers too large. */ + patch: Schema.optional(Schema.NullOr(Schema.String)), + /** Whether anything was withheld is the difference between a binary file and a pure rename. */ + additions: Schema.optional(Schema.NullOr(Schema.Int)), + deletions: Schema.optional(Schema.NullOr(Schema.Int)), +}); + +/** Resolves a listing's authors to avatars, which no `gh` JSON field carries. */ +export const ACTOR_AVATARS_GRAPHQL_QUERY = `query($ids: [ID!]!) { + nodes(ids: $ids) { + ... on User { login avatarUrl } + ... on Bot { login avatarUrl } + } +}`; + +const RawActorAvatarsSchema = Schema.Struct({ + data: Schema.Struct({ + nodes: Schema.Array(Schema.NullOr(RawActorSchema)), + }), +}); + +const decodeActorAvatars = decodeJsonResult(RawActorAvatarsSchema); + +export function decodeActorAvatarsJson( + raw: string, +): Result.Result, DecodeFailure> { + const decoded = decodeActorAvatars(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const avatarsByLogin = new Map(); + for (const node of decoded.success.data.nodes) { + const login = trimmed(node?.login); + const avatarUrl = trimmed(node?.avatarUrl); + if (login !== null && avatarUrl !== null) avatarsByLogin.set(login, avatarUrl); + } + return Result.succeed(avatarsByLogin); +} + +export const PULL_REQUEST_LIST_JSON_FIELDS = + "number,title,url,author,headRefName,baseRefName,state,isDraft,mergeable,reviewDecision,additions,deletions,createdAt,updatedAt,mergedAt,reviewRequests,labels,statusCheckRollup"; + +export const PULL_REQUEST_DETAIL_JSON_FIELDS = `${PULL_REQUEST_LIST_JSON_FIELDS},body,changedFiles,closedAt,headRepositoryOwner,autoMergeRequest`; +export const PULL_REQUEST_ACTIVITY_JSON_FIELDS = "author,comments,reviews,commits"; + +/** GitHub's own ceiling on a connection page, which is what both thread reads ask for. */ +const GRAPHQL_PAGE_SIZE = 100; + +/** + * The ceiling on `search`, which refuses anything larger with EXCESSIVE_PAGINATION (measured: + * `first: 101` is an error, `first: 100` is not). + */ +export const PULL_REQUEST_SEARCH_MAX_ROWS = GRAPHQL_PAGE_SIZE; + +/** + * Every repository of a host in one read, which is what makes a listing one request rather than + * one process per repository. + * + * `additions` and `deletions` are deliberately absent: measured over twelve repositories at a + * hundred rows, this query answers in ~4.0s with them left out and ~7.1s with them in, for two + * numbers at the end of a row. They are read afterwards, by `buildPullRequestStatsGraphQlQuery`. + * + * The row count is written into the document rather than sent as a variable because every + * variable here travels as a string — and it is this module's own number, clamped by the caller, + * never a reader's. + * + * `first` on the two inner connections is a bound rather than a page: a pull request with more + * than twenty labels shows twenty, and one that has asked more than twenty people for a review + * is already past what a row can say. + */ +export function pullRequestSearchGraphQlQuery(rows: number): string { + return `query($q: String!) { + search(query: $q, type: ISSUE, first: ${Math.min(Math.max(Math.trunc(rows), 1), PULL_REQUEST_SEARCH_MAX_ROWS)}) { + pageInfo { hasNextPage } + nodes { + ... on PullRequest { + number + title + url + author { login avatarUrl ... on User { name } } + headRefName + baseRefName + state + isDraft + mergeable + reviewDecision + createdAt + updatedAt + mergedAt + repository { nameWithOwner } + reviewRequests(first: 20) { nodes { requestedReviewer { ... on User { login } } } } + labels(first: 20) { nodes { name color } } + commits(last: 1) { nodes { commit { statusCheckRollup { state } } } } + } + } + } +}`; +} + +/** + * One page of review threads with their comments, and the people on the review. `$cursor` is + * null for the first page and the last page's `endCursor` after that, so a pull request with + * more threads than one page holds is walked rather than cut off at the first fifty. + * + * Reviewers come from here rather than from `gh pr view --json reviewRequests` for two reasons: + * that field holds only requests still outstanding, so anyone who has already reviewed drops off + * it, and neither it nor any other `gh` JSON field carries an avatar. A reviewer can be a person + * or an app, and both are asked for by name because they are different GraphQL types. + * + * `viewerCanUpdate` and `viewerDidAuthor` ride along here for the same reason: they belong to the + * pull request this query is already standing on, so what the reader may do with it arrives with + * the conversation rather than costing a request of its own. + * + * Commits are asked for with `last` rather than `first`: `gh pr view --json commits` pages from + * the start, so a pull request with more than a hundred commits loses the newest ones from its + * view entirely. This query gives back the newest hundred, which is what a reader scoping a diff + * wants, and stands in for the `gh` list wherever it came back non-empty. + */ +export const REVIEW_THREADS_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $cursor: String) { + viewer { login } + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + reviewThreads(first: ${GRAPHQL_PAGE_SIZE}, after: $cursor) { + totalCount + pageInfo { hasNextPage endCursor } + nodes { + id + isResolved + isOutdated + path + line + diffSide + comments(first: ${GRAPHQL_PAGE_SIZE}) { + totalCount + pageInfo { hasNextPage endCursor } + nodes { id author { login avatarUrl } body createdAt url ${REACTION_GROUPS_FIELDS} } + } + } + } + viewerCanUpdate + viewerDidAuthor + author { login avatarUrl } + ${REACTION_GROUPS_FIELDS} + comments(first: ${GRAPHQL_PAGE_SIZE}) { + nodes { id author { login avatarUrl } ${REACTION_GROUPS_FIELDS} } + } + reviews(first: ${GRAPHQL_PAGE_SIZE}) { nodes { id ${REACTION_GROUPS_FIELDS} } } + reviewRequests(first: 50) { + nodes { + requestedReviewer { + ... on User { login name avatarUrl } + ... on Bot { login avatarUrl } + } + } + } + latestReviews(first: 50) { + nodes { author { login avatarUrl } } + } + reviewDismissals: timelineItems(itemTypes: [REVIEW_DISMISSED_EVENT], first: ${GRAPHQL_PAGE_SIZE}) { + pageInfo { hasNextPage endCursor } + nodes { ... on ReviewDismissedEvent { dismissalMessage review { id } } } + } + commits(last: ${GRAPHQL_PAGE_SIZE}) { + nodes { + commit { + oid + messageHeadline + committedDate + additions + deletions + authors(first: 3) { nodes { name avatarUrl user { login } } } + } + } + } + } + } +}`; + +/** + * The rest of one thread's conversation. GraphQL pages a connection nested inside another only + * from the inner node itself, so a thread longer than a page is followed on its own — a request + * GitHub makes necessary, and one no ordinary pull request ever provokes. + */ +export const REVIEW_THREAD_COMMENTS_GRAPHQL_QUERY = `query($threadId: ID!, $cursor: String) { + viewer { login } + node(id: $threadId) { + ... on PullRequestReviewThread { + comments(first: ${GRAPHQL_PAGE_SIZE}, after: $cursor) { + pageInfo { hasNextPage endCursor } + nodes { id author { login avatarUrl } body createdAt url ${REACTION_GROUPS_FIELDS} } + } + } + } +}`; + +const RawReviewThreadCommentsSchema = Schema.Struct({ + data: Schema.Struct({ + viewer: Schema.optional( + Schema.NullOr(Schema.Struct({ login: Schema.optional(Schema.NullOr(Schema.String)) })), + ), + /** Null for an id that names nothing the viewer can read, which is not a thread to page. */ + node: Schema.NullOr(Schema.Struct({ comments: Schema.optional(RawThreadCommentsSchema) })), + }), +}); + +export const REVIEW_THREAD_REPLY_GRAPHQL_MUTATION = `mutation($threadId: ID!, $body: String!) { + addPullRequestReviewThreadReply(input: { pullRequestReviewThreadId: $threadId, body: $body }) { + comment { id } + } +}`; + +/** + * The pull request's own node id, which is what a reaction on its description is addressed by. + * Read only when one is being written: the conversation carries an id for every remark in it, and + * the pull request is the one subject nothing in it names. + */ +export const PULL_REQUEST_NODE_ID_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { pullRequest(number: $number) { id } } +}`; + +const RawPullRequestNodeIdSchema = Schema.Struct({ + data: Schema.Struct({ + repository: Schema.Struct({ + pullRequest: Schema.Struct({ id: Schema.String }), + }), + }), +}); + +const decodePullRequestNodeId = decodeJsonResult(RawPullRequestNodeIdSchema); + +export function decodePullRequestNodeIdJson(raw: string): Result.Result { + const decoded = decodePullRequestNodeId(raw); + return Result.isSuccess(decoded) + ? Result.succeed(decoded.success.data.repository.pullRequest.id) + : Result.fail(decoded.failure); +} + +/** + * Where a client-given reaction subject actually hangs: the pull request itself, or the pull + * request an issue comment, a review comment, or a review belongs to. Read before a mutation + * reaches it, so a subject named for one pull request cannot react on another's behalf. + */ +export const REACTION_SUBJECT_PULL_REQUEST_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $subjectId: ID!) { + repository(owner: $owner, name: $name) { pullRequest(number: $number) { id } } + node(id: $subjectId) { + id + ... on IssueComment { pullRequest { id } } + ... on PullRequestReviewComment { pullRequest { id } } + ... on PullRequestReview { pullRequest { id } } + } +}`; + +const RawReactionSubjectScopeSchema = Schema.Struct({ + data: Schema.Struct({ + repository: Schema.NullOr( + Schema.Struct({ pullRequest: Schema.NullOr(Schema.Struct({ id: Schema.String })) }), + ), + node: Schema.NullOr( + Schema.Struct({ + id: Schema.String, + pullRequest: Schema.optional(Schema.Struct({ id: Schema.String })), + }), + ), + }), +}); + +const decodeReactionSubjectScope = decodeJsonResult(RawReactionSubjectScopeSchema); + +/** + * True when the subject named is the pull request itself, or hangs off it — false for anything + * else, including a subject or a pull request this host could not find. + */ +export function decodeReactionSubjectScopeJson(raw: string): Result.Result { + const decoded = decodeReactionSubjectScope(raw); + if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); + const expected = decoded.success.data.repository?.pullRequest?.id ?? null; + const node = decoded.success.data.node; + const actual = node === null ? null : (node.pullRequest?.id ?? node.id); + return Result.succeed(expected !== null && actual !== null && expected === actual); +} + +export const ADD_REACTION_GRAPHQL_MUTATION = `mutation($subjectId: ID!, $content: ReactionContent!) { + addReaction(input: { subjectId: $subjectId, content: $content }) { reaction { content } } +}`; + +export const REMOVE_REACTION_GRAPHQL_MUTATION = `mutation($subjectId: ID!, $content: ReactionContent!) { + removeReaction(input: { subjectId: $subjectId, content: $content }) { reaction { content } } +}`; + +export const RESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION = `mutation($threadId: ID!) { + resolveReviewThread(input: { threadId: $threadId }) { thread { isResolved } } +}`; + +export const UNRESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION = `mutation($threadId: ID!) { + unresolveReviewThread(input: { threadId: $threadId }) { thread { isResolved } } +}`; + +/** + * Rewrites the pull request's own words. Both are nullable so that one document serves a change + * to the title, to the description, or to the two together: a variable the request does not send + * puts no entry in the input at all, which leaves that field as it was rather than clearing it. + */ +export const UPDATE_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!, $title: String, $body: String) { + updatePullRequest(input: { pullRequestId: $pullRequestId, title: $title, body: $body }) { + pullRequest { id } + } +}`; + +/** + * The two comment mutations name their comment differently. The variable is spelled the same in + * both, so a rewrite sends one set of variables whichever kind of remark it is. + */ +export const UPDATE_ISSUE_COMMENT_GRAPHQL_MUTATION = `mutation($commentId: ID!, $body: String!) { + updateIssueComment(input: { id: $commentId, body: $body }) { issueComment { id } } +}`; + +export const UPDATE_REVIEW_COMMENT_GRAPHQL_MUTATION = `mutation($commentId: ID!, $body: String!) { + updatePullRequestReviewComment(input: { pullRequestReviewCommentId: $commentId, body: $body }) { + pullRequestReviewComment { id } + } +}`; + +/** + * A GraphQL request as `gh api graphql --input -` takes it. Variables travel in the document + * rather than as `-f name=value` flags, so a reader's own words never reach argv. + */ +const GraphQlRequestSchema = Schema.Struct({ + query: Schema.String, + variables: Schema.Record(Schema.String, Schema.String), +}); + +const encodeGraphQlRequest = Schema.encodeSync(Schema.fromJsonString(GraphQlRequestSchema)); + +export function encodeGraphQlRequestJson(input: { + readonly query: string; + readonly variables: Readonly>; +}): string { + return encodeGraphQlRequest({ query: input.query, variables: { ...input.variables } }); +} + +/** The body of `POST /repos/{owner}/{repo}/pulls/{number}/reviews`, which sends a review whole. */ +const ReviewSubmissionSchema = Schema.Struct({ + event: Schema.Literals(["COMMENT", "APPROVE", "REQUEST_CHANGES"]), + body: Schema.String, + comments: Schema.Array( + Schema.Struct({ + path: Schema.String, + line: Schema.Int, + side: Schema.Literals(["LEFT", "RIGHT"]), + body: Schema.String, + }), + ), +}); + +const encodeReviewSubmission = Schema.encodeSync(Schema.fromJsonString(ReviewSubmissionSchema)); + +const REVIEW_EVENTS: Record = { + comment: "COMMENT", + approve: "APPROVE", + "request-changes": "REQUEST_CHANGES", +}; + +/** + * The dismissal events past the page the thread read carries. A pull request rarely has any: + * this is followed only while the embedded page reports more. + */ +export const REVIEW_DISMISSALS_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $cursor: String) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + timelineItems(itemTypes: [REVIEW_DISMISSED_EVENT], first: ${GRAPHQL_PAGE_SIZE}, after: $cursor) { + pageInfo { hasNextPage endCursor } + nodes { ... on ReviewDismissedEvent { dismissalMessage review { id } } } + } + } + } +}`; + +/** The whole review as one request body, which is how GitHub keeps it invisible until sent. */ +export function buildReviewSubmissionJson(input: { + readonly verdict: PullRequestReviewVerdict; + readonly body: string; + readonly comments: ReadonlyArray; +}): string { + return encodeReviewSubmission({ + event: REVIEW_EVENTS[input.verdict], + body: input.body, + comments: input.comments.map((comment) => ({ + path: comment.path, + line: comment.line, + side: comment.side === "left" ? ("LEFT" as const) : ("RIGHT" as const), + body: comment.body, + })), + }); +} + +/** + * `viewerPermission` rides along with the merge settings rather than being asked for on its own: + * `gh repo view --json` serves both out of the same GraphQL repository object, so the viewer's + * standing on the repository costs no request of its own. + */ +export const REPOSITORY_ACCESS_JSON_FIELDS = + "mergeCommitAllowed,squashMergeAllowed,rebaseMergeAllowed,viewerPermission"; + +export interface GitHubPullRequestListItem { + /** The author's node id, kept so a batch can resolve the avatar the listing does not carry. */ + readonly authorId: string | null; + readonly number: number; + readonly title: string; + readonly url: string; + readonly author: PullRequestActor | null; + readonly headBranch: string; + readonly baseBranch: string; + readonly state: PullRequestState; + readonly isDraft: boolean; + readonly mergeability: PullRequestMergeability; + /** Null where GitHub has no verdict to summarise, which includes a draft nobody has reviewed. */ + readonly reviewDecision: PullRequestReviewDecision | null; + readonly additions: number; + readonly deletions: number; + readonly createdAt: string; + readonly updatedAt: string; + readonly reviewRequestLogins: ReadonlyArray; + /** At least one outstanding request targets a team rather than an individual login. */ + readonly hasTeamReviewRequest: boolean; + readonly labels: ReadonlyArray; + /** Null where the head commit reported no checks, which is not the same as passing none. */ + readonly checksState: PullRequestChecksState | null; +} + +export interface GitHubPullRequestDetail extends GitHubPullRequestListItem { + /** The owner of the head branch's repository; null where `gh` did not say. */ + readonly headRepositoryOwner: string | null; + readonly body: string; + readonly changedFiles: number; + readonly mergedAt: string | null; + readonly closedAt: string | null; + readonly checks: ReadonlyArray; + /** Absent where `gh` did not answer for auto-merge at all, which is not the same as off. */ + readonly autoMergeEnabled?: boolean; +} + +export interface GitHubPullRequestActivity { + readonly author: PullRequestActor | null; + readonly comments: ReadonlyArray; + readonly commits: ReadonlyArray; +} + +function trimmed(value: string | null | undefined): string | null { + const text = value?.trim() ?? ""; + return text.length > 0 ? text : null; +} + +/** + * Null once a connection has nothing further, which is what ends every walk below. GitHub sends + * an `endCursor` on a page that is also the last one, so the flag is what decides, not the + * cursor's presence. + */ +function nextCursorOf( + pageInfo: Schema.Schema.Type | undefined, +): string | null { + return pageInfo?.hasNextPage === true ? trimmed(pageInfo.endCursor) : null; +} + +/** + * The viewer's standing on one pull request. The two halves take opposite defaults on purpose. + * + * Updating is a permission, so an install that does not report it grants it and lets the host's + * own refusal explain anything that fails. Authorship is not a permission but a fact about who + * wrote the thing, and it is read to decide what an author may do to their own change — so an + * unknown answer is "not the author", which grants nothing it should not. + */ +function toPullRequestViewerFields( + raw: Schema.Schema.Type | null | undefined, +): { readonly canUpdate: boolean; readonly didAuthor: boolean } { + return { canUpdate: raw?.viewerCanUpdate !== false, didAuthor: raw?.viewerDidAuthor === true }; +} + +function toActor(raw: Schema.Schema.Type | null | undefined) { + const login = trimmed(raw?.login); + return login === null + ? null + : { login, name: trimmed(raw?.name), avatarUrl: trimmed(raw?.avatarUrl) }; +} + +function toCommitActor( + raw: NonNullable["authors"]>[number], +): PullRequestActor | null { + // An email-linked GitHub account has a login; an unlinked signature only has a name or email. + // Keep that signature visible instead of silently turning a co-authored commit into one author. + const login = trimmed(raw.login) ?? trimmed(raw.name) ?? trimmed(raw.email); + return login === null ? null : { login, name: trimmed(raw.name), avatarUrl: null }; +} + +/** An author off the GraphQL commits connection, which names an account by `user.login` where + * `gh pr view --json commits` names it by a flat `login` copied off the signature. */ +function toGraphqlCommitActor(raw: { + readonly name?: string | null | undefined; + readonly avatarUrl?: string | null | undefined; + readonly user?: { readonly login?: string | null | undefined } | null | undefined; +}): PullRequestActor | null { + const login = trimmed(raw.user?.login) ?? trimmed(raw.name); + return login === null + ? null + : { login, name: trimmed(raw.name), avatarUrl: trimmed(raw.avatarUrl) }; +} + +function toState(raw: { + readonly state?: string | null | undefined; + readonly mergedAt?: string | null | undefined; +}): PullRequestState { + if (trimmed(raw.mergedAt) !== null) return "merged"; + const state = raw.state?.trim().toUpperCase(); + if (state === "MERGED") return "merged"; + if (state === "CLOSED") return "closed"; + return "open"; +} + +function toMergeability(value: string | null | undefined): PullRequestMergeability { + switch (value?.trim().toUpperCase()) { + case "MERGEABLE": + return "mergeable"; + case "CONFLICTING": + return "conflicting"; + default: + return "unknown"; + } +} + +function toReviewDecision(value: string | null | undefined): PullRequestReviewDecision | null { + switch (value?.trim().toUpperCase()) { + case "APPROVED": + return "approved"; + case "CHANGES_REQUESTED": + return "changes-requested"; + case "REVIEW_REQUIRED": + return "review-required"; + default: + return null; + } +} + +function toLabels( + raw: ReadonlyArray> | undefined, +): ReadonlyArray { + return (raw ?? []).flatMap((label) => { + const name = trimmed(label.name); + return name === null ? [] : [{ name, color: trimmed(label.color) }]; + }); +} + +/** + * User review requests only. Team requests are tracked separately because a slug cannot be + * compared with the viewer's login. + */ +function toReviewRequestLogins( + raw: ReadonlyArray> | undefined, +): ReadonlyArray { + return (raw ?? []).flatMap((request) => { + const login = trimmed(request.login); + return login === null ? [] : [login]; + }); +} + +function hasTeamReviewRequest( + raw: ReadonlyArray> | undefined, +): boolean { + return (raw ?? []).some( + (request) => + trimmed(request.login) === null && + (trimmed(request.slug) !== null || trimmed(request.name) !== null), + ); +} + +function toCheckStatus(raw: Schema.Schema.Type): PullRequestCheckStatus { + // Commit statuses report a single `state`; check runs report `status` plus a `conclusion` + // that only exists once the run has completed. + const status = raw.status?.trim().toUpperCase(); + if (status !== undefined && status !== "COMPLETED" && status !== "") { + return "pending"; + } + switch ((raw.conclusion ?? raw.state)?.trim().toUpperCase()) { + case "SUCCESS": + return "success"; + case "FAILURE": + case "ERROR": + case "TIMED_OUT": + case "STARTUP_FAILURE": + // A completed check asking for manual intervention is blocking, not neutral. + case "ACTION_REQUIRED": + return "failure"; + case "CANCELLED": + return "cancelled"; + case "SKIPPED": + return "skipped"; + case "PENDING": + case "EXPECTED": + return "pending"; + default: + return "neutral"; + } +} + +/** What GitHub writes where a run has not reached that moment yet, which is not a time. */ +const UNSET_TIMESTAMP = "0001-01-01T00:00:00Z"; + +function realTimestamp(value: string | null | undefined): string | null { + const at = trimmed(value); + return at === null || at === UNSET_TIMESTAMP ? null : at; +} + +/** Only a row the rollup gives no name of any kind, which is not a check anyone can show. */ +function isNamelessCheck(raw: Schema.Schema.Type): boolean { + return trimmed(raw.name) === null && trimmed(raw.context) === null; +} + +/** + * The rollup as the deduper reads it: a check, the workflow that owns it, and when the run last + * had something to say. A queued run reports a completion time it has not reached, so the start + * stands in for it rather than sorting the newest run to the bottom. + */ +function toCheckEntries( + raw: ReadonlyArray> | null | undefined, +): ReadonlyArray<{ + readonly check: PullRequestCheck; + readonly workflowName: string | null; + readonly at: string | null; +}> { + return (raw ?? []).flatMap((check) => { + const name = trimmed(check.name) ?? trimmed(check.context); + if (name === null) return []; + return [ + { + check: { + name, + status: toCheckStatus(check), + description: trimmed(check.description), + url: trimmed(check.detailsUrl) ?? trimmed(check.targetUrl), + }, + workflowName: trimmed(check.workflowName), + at: realTimestamp(check.completedAt) ?? realTimestamp(check.startedAt), + }, + ]; + }); +} + +/** + * The one word a listing row has space for. A failure outranks anything still running, the way + * GitHub's own indicator reads: a run that has already gone red will not go green by finishing. + * + * Null rather than "passing" for a head commit with no checks at all, so a repository that runs + * none shows nothing instead of a green tick it never earned. Checks whose verdict is neither a + * pass, a failure nor a wait — skipped, cancelled, neutral — count towards neither. + * + * Counted off the deduped checks rather than the raw rollup, so the word and the list under it + * cannot disagree: the run a re-run replaced is not a verdict twice. A row with no name at all is + * counted as it comes, since the cross-repository search dresses GitHub's own rollup enum as one + * nameless row, and nothing nameless can collide with anything. + */ +function rollupChecksState( + raw: ReadonlyArray> | null | undefined, +): PullRequestChecksState | null { + const statuses = [ + ...toChecks(raw).map((check) => check.status), + ...(raw ?? []).filter(isNamelessCheck).map((check) => toCheckStatus(check)), + ]; + if (statuses.length === 0) return null; + if (statuses.includes("failure")) return "failing"; + if (statuses.includes("pending")) return "pending"; + return statuses.includes("success") ? "passing" : null; +} + +function toChecks( + raw: ReadonlyArray> | null | undefined, +): ReadonlyArray { + return dedupeChecks(toCheckEntries(raw)); +} + +/** The states that are a verdict in themselves, rather than a wrapper around line comments. */ +function isReviewVerdict(reviewState: string | null): boolean { + switch (reviewState?.toUpperCase()) { + case "APPROVED": + case "CHANGES_REQUESTED": + case "DISMISSED": + return true; + default: + return false; + } +} + +function toComments(raw: { + readonly comments?: ReadonlyArray> | undefined; + readonly reviews?: ReadonlyArray> | undefined; +}): ReadonlyArray { + const issueComments = (raw.comments ?? []).map( + (comment): PullRequestComment => ({ + id: comment.id, + kind: "issue-comment", + author: toActor(comment.author), + body: comment.body ?? "", + createdAt: comment.createdAt, + url: trimmed(comment.url), + path: null, + reviewState: null, + }), + ); + // A review with no body is kept only when its state is the event itself — an approval, a + // request for changes, a dismissal. GitHub also opens a bodiless `COMMENTED` review as the + // container for line comments, and those comments are read from the review threads, so + // keeping the container too would show a row with a name and nothing under it. + const reviews = (raw.reviews ?? []).flatMap((review): ReadonlyArray => { + const submittedAt = trimmed(review.submittedAt); + const reviewState = trimmed(review.state); + if ( + submittedAt === null || + ((review.body ?? "").trim().length === 0 && !isReviewVerdict(reviewState)) + ) { + return []; + } + return [ + { + id: review.id, + kind: "review", + author: toActor(review.author), + body: review.body ?? "", + createdAt: submittedAt, + url: trimmed(review.url), + path: null, + reviewState, + }, + ]; + }); + return [...issueComments, ...reviews].toSorted((left, right) => + left.createdAt.localeCompare(right.createdAt), + ); +} + +function toCommits( + commits: ReadonlyArray> | undefined, +): ReadonlyArray { + return (commits ?? []).map((commit) => ({ + oid: commit.oid, + messageHeadline: commit.messageHeadline ?? "", + committedDate: commit.committedDate, + authors: (commit.authors ?? []).flatMap((author) => { + const actor = toCommitActor(author); + return actor === null ? [] : [actor]; + }), + })); +} + +function toListItem(raw: Schema.Schema.Type): GitHubPullRequestListItem { + return { + authorId: trimmed(raw.author?.id), + number: raw.number, + title: raw.title, + url: raw.url, + author: toActor(raw.author), + headBranch: raw.headRefName, + baseBranch: raw.baseRefName, + state: toState(raw), + isDraft: raw.isDraft ?? false, + mergeability: toMergeability(raw.mergeable), + reviewDecision: toReviewDecision(raw.reviewDecision), + additions: raw.additions ?? 0, + deletions: raw.deletions ?? 0, + createdAt: raw.createdAt, + updatedAt: raw.updatedAt, + reviewRequestLogins: toReviewRequestLogins(raw.reviewRequests), + hasTeamReviewRequest: hasTeamReviewRequest(raw.reviewRequests), + labels: toLabels(raw.labels), + checksState: rollupChecksState(raw.statusCheckRollup), + }; +} + +function toDetail(raw: Schema.Schema.Type): GitHubPullRequestDetail { + return { + ...toListItem(raw), + headRepositoryOwner: trimmed(raw.headRepositoryOwner?.login), + body: raw.body ?? "", + changedFiles: raw.changedFiles ?? 0, + mergedAt: trimmed(raw.mergedAt), + closedAt: trimmed(raw.closedAt), + checks: toChecks(raw.statusCheckRollup), + // A JSON null is GitHub saying "nobody armed this"; a missing key is GitHub not saying, and + // the difference survives here rather than being flattened into false. + ...(raw.autoMergeRequest === undefined + ? {} + : { autoMergeEnabled: raw.autoMergeRequest !== null }), + }; +} + +function toActivity(raw: Schema.Schema.Type): GitHubPullRequestActivity { + return { + author: toActor(raw.author), + comments: toComments(raw), + commits: toCommits(raw.commits), + }; +} + +const decodeUnknownList = decodeJsonResult(Schema.Array(Schema.Unknown)); +const decodeListEntry = Schema.decodeUnknownExit(RawListItemSchema); +const decodeSearch = decodeJsonResult(RawSearchSchema); +const decodeSearchItem = Schema.decodeUnknownExit(RawSearchItemSchema); +const decodeStats = decodeJsonResult(RawStatsSchema); +const decodeDetail = decodeJsonResult(RawDetailSchema); +const decodeActivity = decodeJsonResult(RawActivitySchema); +const decodeFileEntry = Schema.decodeUnknownExit(RawPullRequestFileSchema); +const decodeRepositoryAccess = decodeJsonResult(RawRepositoryAccessSchema); +const decodeReviewThreads = decodeJsonResult(RawReviewThreadsSchema); +const decodeReviewThreadComments = decodeJsonResult(RawReviewThreadCommentsSchema); + +type DecodeFailure = Cause.Cause; + +export interface GitHubPullRequestListBatch { + readonly items: ReadonlyArray; + /** Rows gh returned, counted before decoding, so a skipped row cannot hide a next page. */ + readonly rawCount: number; +} + +/** Malformed entries are skipped rather than failing the batch: one unexpected pull request + * must not blank the whole list. */ +export function decodePullRequestListJson( + raw: string, +): Result.Result { + const decoded = decodeUnknownList(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const items: GitHubPullRequestListItem[] = []; + for (const entry of decoded.success) { + const item = decodeListEntry(entry); + if (Exit.isSuccess(item)) { + items.push(toListItem(item.value)); + } + } + return Result.succeed({ items, rawCount: decoded.success.length }); +} + +export interface GitHubPullRequestSearchItem extends GitHubPullRequestListItem { + /** `owner/name` as GitHub spells it, which is how a row from a search finds its repository. */ + readonly repository: string; +} + +export interface GitHubPullRequestSearchBatch { + readonly items: ReadonlyArray; + /** Rows the search returned, counted before decoding, so a skipped row cannot hide a next page. */ + readonly rawCount: number; + /** More rows than this slice asked for, which is truncation for every repository in it. */ + readonly hasNextPage: boolean; +} + +/** + * A search answers with the same pull request the listing does, one connection deeper: reviewers + * and labels arrive as connections, and the row names the repository it came from. Flattened to + * the shape `gh pr list --json` hands over so both reads decode into one type. + * + * Rows that are not pull requests decode as empty and are skipped, the way a malformed listing + * row is — `is:pr` already excludes them, and one surprise must not blank a whole host. + */ +export function decodePullRequestSearchJson( + raw: string, +): Result.Result { + const decoded = decodeSearch(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const nodes = decoded.success.data.search.nodes ?? []; + const items: GitHubPullRequestSearchItem[] = []; + for (const entry of nodes) { + const decodedNode = decodeSearchItem(entry); + if (!Exit.isSuccess(decodedNode)) continue; + const node = decodedNode.value; + const repository = trimmed(node.repository?.nameWithOwner); + if (repository === null) continue; + items.push({ + ...toListItem({ + ...node, + reviewRequests: (node.reviewRequests?.nodes ?? []).flatMap((request) => { + const login = trimmed(request?.requestedReviewer?.login); + return login === null ? [] : [{ login }]; + }), + labels: (node.labels?.nodes ?? []).flatMap((label) => (label === null ? [] : [label])), + // The search asks for the verdict rather than the checks behind it, so it arrives as one + // enum. Dressed as a single check here so the rollup is read the same way on both paths. + statusCheckRollup: (node.commits?.nodes ?? []).flatMap((commitNode) => { + const state = trimmed(commitNode?.commit?.statusCheckRollup?.state); + return state === null ? [] : [{ state }]; + }), + }), + repository, + }); + } + return Result.succeed({ + items, + rawCount: nodes.length, + hasNextPage: decoded.success.data.search.pageInfo?.hasNextPage ?? false, + }); +} + +/** What a repository selector may hold before it is written into a GraphQL document unquoted. */ +const REPOSITORY_PART = /^[A-Za-z0-9._-]+$/; + +/** + * The line counts for rows a listing already handed over, as one aliased lookup each. + * + * Aliases rather than `nodes(ids:)` because the caller asks in the terms the page holds — a + * repository and a number — and never sees a node id. Owner, name and number are written into + * the document, so each is checked against what GitHub can actually name first: null for anything + * else, which the caller reports rather than sends. + * + * Null too for an empty request, since a GraphQL document with no selection is not a document. + */ +export function buildPullRequestStatsGraphQlQuery( + changeRequests: ReadonlyArray<{ readonly repository: string; readonly number: number }>, +): string | null { + if (changeRequests.length === 0) return null; + const selections: string[] = []; + for (const [index, changeRequest] of changeRequests.entries()) { + const [owner, name, ...rest] = changeRequest.repository.trim().split("/"); + if (rest.length > 0 || owner === undefined || name === undefined) return null; + if (!REPOSITORY_PART.test(owner) || !REPOSITORY_PART.test(name)) return null; + if (!Number.isSafeInteger(changeRequest.number) || changeRequest.number <= 0) return null; + selections.push( + ` s${index}: repository(owner: "${owner}", name: "${name}") { pullRequest(number: ${changeRequest.number}) { additions deletions } }`, + ); + } + return `query {\n${selections.join("\n")}\n}`; +} + +/** + * The counts by the position they were asked in. A repository or a pull request GitHub answered + * nothing for is simply absent, which leaves the row with whatever it already had. + */ +export function decodePullRequestStatsJson( + raw: string, +): Result.Result< + ReadonlyMap, + DecodeFailure +> { + const decoded = decodeStats(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const stats = new Map(); + for (const [alias, value] of Object.entries(decoded.success.data ?? {})) { + const index = /^s(\d+)$/.exec(alias)?.[1]; + const pullRequest = value?.pullRequest; + if (index === undefined || pullRequest == null) continue; + stats.set(Number(index), { + additions: pullRequest.additions ?? 0, + deletions: pullRequest.deletions ?? 0, + }); + } + return Result.succeed(stats); +} + +export function decodePullRequestDetailJson( + raw: string, +): Result.Result { + const decoded = decodeDetail(raw); + return Result.isSuccess(decoded) + ? Result.succeed(toDetail(decoded.success)) + : Result.fail(decoded.failure); +} + +export function decodePullRequestActivityJson( + raw: string, +): Result.Result { + const decoded = decodeActivity(raw); + return Result.isSuccess(decoded) + ? Result.succeed(toActivity(decoded.success)) + : Result.fail(decoded.failure); +} + +export interface GitHubReviewThreadComments { + readonly comments: ReadonlyArray; + /** Dismissal reasons by the dismissed review's node id, read off the timeline. */ + readonly dismissalsByReviewId: ReadonlyMap; + /** Whole conversations, kept anchored so the diff can pin them to their line. */ + readonly reviewThreads: ReadonlyArray; + /** The host's own count of the conversation, which a bounded read can fall short of. */ + readonly commentCount: number; + readonly truncated: boolean; + /** The pull request's own reactions, which sit on its description. */ + readonly reactions: ReadonlyArray; + /** Reactions by node id, for the comments and reviews the `gh` JSON read carries no reaction on. */ + readonly reactionsById: ReadonlyMap>; + /** + * Everyone on the review: those still asked and those who have already answered. Whoever has + * reviewed is no longer an outstanding request, so asking only for requests reports nobody on + * a pull request that has in fact been reviewed. + */ + readonly reviewers: ReadonlyArray; + /** + * Avatars by login, for the actors `gh pr view --json` reports without one — which is all of + * them, since no `gh` JSON field carries an avatar. Collected from everyone this query names, + * so an app's avatar arrives the same way a person's does. + */ + readonly avatarsByLogin: ReadonlyMap; + /** Per-commit line counts carried by the same bounded pull-request query. */ + readonly commitStats: ReadonlyMap< + string, + { readonly additions: number; readonly deletions: number } + >; + /** + * The newest hundred commits, oldest to newest, off the same query's `commits(last: ...)`. + * Empty wherever the read never happened (an install too old for the field, a degraded page), + * which the caller reads as "keep the `gh pr view` list" rather than as "this pull request has + * no commits". + */ + readonly commits: ReadonlyArray; + /** What GitHub says the reader may do with this pull request, read off the same response. */ + readonly viewer: { readonly canUpdate: boolean; readonly didAuthor: boolean }; +} + +/** One thread as this page found it, with what it takes to finish reading it. */ +export interface GitHubReviewThreadEntry { + readonly thread: PullRequestReviewThread; + /** How many comments GitHub says the thread holds, read or not. */ + readonly commentCount: number; + /** Where the rest of this thread's comments carry on from, or null once it is whole. */ + readonly nextCommentCursor: string | null; +} + +export interface GitHubReviewThreadPage { + readonly threads: ReadonlyArray; + /** Where the next page of threads starts, or null once the host has handed them all over. */ + readonly nextCursor: string | null; + /** The pull request's own reactions, which sit on its description. */ + readonly reactions: ReadonlyArray; + /** + * Reactions by node id, for the conversation comments and reviews `gh pr view --json` answers + * for without any. Only ids with a reaction are here; the rest carry none. + */ + readonly reactionsById: ReadonlyMap>; + readonly reviewers: ReadonlyArray; + readonly avatarsByLogin: ReadonlyMap; + readonly commitStats: ReadonlyMap< + string, + { readonly additions: number; readonly deletions: number } + >; + readonly commits: ReadonlyArray; + readonly viewer: { readonly canUpdate: boolean; readonly didAuthor: boolean }; + /** Dismissal reasons by the dismissed review's node id, which the review itself never carries. */ + readonly dismissalsByReviewId: ReadonlyMap; + /** Where the rest of the dismissal events start, or null once this page carried them all. */ + readonly nextDismissalCursor: string | null; +} + +/** + * The threads as one flat conversation, which is what the timeline reads. Every comment of + * every thread, resolved or not: a resolved conversation is still what was said, and a reply is + * as much of it as the remark it answers. + */ +export function reviewThreadConversation( + threads: ReadonlyArray, +): ReadonlyArray { + return threads.flatMap((thread) => + thread.comments.map( + (comment): PullRequestComment => ({ + id: comment.id, + kind: "review-comment", + author: comment.author, + body: comment.body, + createdAt: comment.createdAt, + url: comment.url, + path: thread.path, + reviewState: null, + reactions: comment.reactions ?? [], + }), + ), + ); +} + +/** One page of review threads. Following the cursors it hands back is the caller's job. */ +function toDismissalEntries( + nodes: + | ReadonlyArray<{ + readonly dismissalMessage?: string | null | undefined; + readonly review?: { readonly id?: string | null | undefined } | null | undefined; + }> + | undefined, +): Map { + const entries = new Map(); + for (const node of nodes ?? []) { + const reviewId = trimmed(node.review?.id); + const message = trimmed(node.dismissalMessage); + if (reviewId !== null && message !== null) entries.set(reviewId, message); + } + return entries; +} + +const RawReviewDismissalsSchema = Schema.Struct({ + data: Schema.Struct({ + repository: Schema.Struct({ + pullRequest: Schema.Struct({ + timelineItems: Schema.Struct({ + pageInfo: Schema.optional(RawPageInfoSchema), + nodes: Schema.Array( + Schema.Struct({ + dismissalMessage: Schema.optional(Schema.NullOr(Schema.String)), + review: Schema.optional( + Schema.NullOr(Schema.Struct({ id: Schema.optional(Schema.NullOr(Schema.String)) })), + ), + }), + ), + }), + }), + }), + }), +}); + +const decodeReviewDismissals = decodeJsonResult(RawReviewDismissalsSchema); + +/** One further page of dismissal events, in the shape the thread read's own page carries. */ +export function decodeReviewDismissalsJson(raw: string): Result.Result< + { + readonly dismissalsByReviewId: ReadonlyMap; + readonly nextCursor: string | null; + }, + DecodeFailure +> { + const decoded = decodeReviewDismissals(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const items = decoded.success.data.repository.pullRequest.timelineItems; + return Result.succeed({ + dismissalsByReviewId: toDismissalEntries(items.nodes), + nextCursor: nextCursorOf(items.pageInfo), + }); +} + +export function decodeReviewThreadsJson( + raw: string, +): Result.Result { + const decoded = decodeReviewThreads(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const viewer = trimmed(decoded.success.data.viewer?.login); + const threads = decoded.success.data.repository.pullRequest.reviewThreads; + const entries = threads.nodes.flatMap((thread): ReadonlyArray => { + const path = trimmed(thread.path); + const id = trimmed(thread.id); + if (path === null || id === null || thread.comments.nodes.length === 0) return []; + return [ + { + thread: { + id, + path, + // Null once the thread's line has left the diff, which is exactly when GitHub reports + // it outdated. Such a thread is listed rather than pinned to a line it no longer has. + line: + thread.line !== null && thread.line !== undefined && thread.line > 0 + ? thread.line + : null, + side: thread.diffSide?.toUpperCase() === "LEFT" ? "left" : "right", + isResolved: thread.isResolved === true, + isOutdated: thread.isOutdated === true, + comments: thread.comments.nodes.map((comment) => ({ + id: comment.id, + author: toActor(comment.author), + body: comment.body ?? "", + createdAt: comment.createdAt, + url: trimmed(comment.url), + reactions: toReactions(comment.reactionGroups, viewer), + })), + }, + commentCount: thread.comments.totalCount ?? thread.comments.nodes.length, + nextCommentCursor: nextCursorOf(thread.comments.pageInfo), + }, + ]; + }); + const pullRequest = decoded.success.data.repository.pullRequest; + const avatarsByLogin = new Map(); + for (const raw of [ + pullRequest.author, + ...(pullRequest.comments?.nodes ?? []).map((node) => node.author), + ...(pullRequest.reviewRequests?.nodes ?? []).map((node) => node.requestedReviewer), + ...(pullRequest.latestReviews?.nodes ?? []).map((node) => node.author), + ...threads.nodes.flatMap((thread) => thread.comments.nodes.map((comment) => comment.author)), + ]) { + const login = trimmed(raw?.login); + const avatarUrl = trimmed(raw?.avatarUrl); + if (login !== null && avatarUrl !== null) avatarsByLogin.set(login, avatarUrl); + } + const reviewers = new Map(); + for (const raw of [ + ...(pullRequest.reviewRequests?.nodes ?? []).map((node) => node.requestedReviewer), + ...(pullRequest.latestReviews?.nodes ?? []).map((node) => node.author), + ]) { + const actor = toActor(raw); + // Keyed by login, so someone who was asked and then answered appears once. + if (actor !== null && !reviewers.has(actor.login)) reviewers.set(actor.login, actor); + } + const commitStats = new Map(); + const commits: PullRequestCommit[] = []; + for (const node of pullRequest.commits?.nodes ?? []) { + const commit = node.commit; + const oid = trimmed(commit.oid); + if (oid === null) continue; + if (commit.additions !== undefined && commit.deletions !== undefined) { + commitStats.set(oid, { + additions: Math.max(0, commit.additions), + deletions: Math.max(0, commit.deletions), + }); + } + const committedDate = trimmed(commit.committedDate); + if (committedDate === null) continue; + commits.push({ + oid, + messageHeadline: commit.messageHeadline ?? "", + committedDate, + authors: (commit.authors?.nodes ?? []).flatMap((author) => { + const actor = toGraphqlCommitActor(author); + return actor === null ? [] : [actor]; + }), + }); + } + const reactionsById = new Map>(); + for (const node of [ + ...(pullRequest.comments?.nodes ?? []), + ...(pullRequest.reviews?.nodes ?? []), + ]) { + const id = trimmed(node.id); + if (id === null) continue; + const reactions = toReactions(node.reactionGroups, viewer); + if (reactions.length > 0) reactionsById.set(id, reactions); + } + return Result.succeed({ + threads: entries, + nextCursor: nextCursorOf(threads.pageInfo), + reactions: toReactions(pullRequest.reactionGroups, viewer), + reactionsById, + reviewers: [...reviewers.values()], + avatarsByLogin, + commitStats, + commits, + viewer: toPullRequestViewerFields(pullRequest), + dismissalsByReviewId: toDismissalEntries(pullRequest.reviewDismissals?.nodes), + nextDismissalCursor: nextCursorOf(pullRequest.reviewDismissals?.pageInfo), + }); +} + +/** The rest of one thread's comments, in the shape the first page already delivered them. */ +export function decodeReviewThreadCommentsJson(raw: string): Result.Result< + { + readonly comments: ReadonlyArray; + readonly nextCursor: string | null; + }, + DecodeFailure +> { + const decoded = decodeReviewThreadComments(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const viewer = trimmed(decoded.success.data.viewer?.login); + const comments = decoded.success.data.node?.comments; + return Result.succeed({ + comments: (comments?.nodes ?? []).map((comment) => ({ + id: comment.id, + author: toActor(comment.author), + body: comment.body ?? "", + createdAt: comment.createdAt, + url: trimmed(comment.url), + reactions: toReactions(comment.reactionGroups, viewer), + })), + nextCursor: nextCursorOf(comments?.pageInfo), + }); +} + +/** What one `gh repo view` answers: what the repository allows, and where the viewer stands. */ +export interface GitHubRepositoryAccess { + readonly mergeCapabilities: PullRequestMergeCapabilities; + readonly canWrite: boolean; +} + +/** + * Whether the viewer's role on the repository is one that can push, which is what merging needs. + * TRIAGE and READ are not: a triager moves issues about and neither of them lands a commit. + * + * An install that reports no permission at all does not count as write. This is the exception to + * "an unknown permission is granted": write is what merging and closing somebody else's change + * need, and offering those to a reader who cannot use them wastes the press and reads as the app + * being wrong. Everything softer — commenting, reviewing, resolving — keeps the granting default, + * because being unable to say something is the worse failure there. + */ +function toCanWrite(viewerPermission: string | null | undefined): boolean { + switch (viewerPermission?.trim().toUpperCase()) { + case "ADMIN": + case "MAINTAIN": + case "WRITE": + return true; + default: + return false; + } +} + +export function decodeRepositoryAccessJson( + raw: string, +): Result.Result { + const decoded = decodeRepositoryAccess(raw); + return Result.isSuccess(decoded) + ? Result.succeed({ + mergeCapabilities: { + merge: decoded.success.mergeCommitAllowed, + squash: decoded.success.squashMergeAllowed, + rebase: decoded.success.rebaseMergeAllowed, + }, + canWrite: toCanWrite(decoded.success.viewerPermission), + }) + : Result.fail(decoded.failure); +} + +/** + * Who a review may be asked of, and who it has already been asked of, in one read. + * + * `assignableUsers` is the list GitHub's own reviewer picker is built from — everyone with access + * to the repository — rather than `collaborators`, which the REST API refuses to anyone without + * push access and which would therefore be empty for exactly the reader most likely to be looking. + * + * Teams are asked for only where one has already been requested, so a request to a team can be + * taken back. The teams a repository could newly be sent to live on the owning organization and + * need `read:org`, which a repository-scoped token need not carry — and a query GitHub refuses + * fails whole, taking the people down with the teams. + */ +/** + * Where the branch stands against its base, and whether this viewer may move it. + * + * `mergeStateStatus` is not the answer: GitHub only reports BEHIND where the repository requires + * branches to be up to date before merging, so on every other repository a stale branch reads as + * CLEAN or BLOCKED like any other. The comparison counts the commits instead, which is the same + * number GitHub's own "out-of-date" banner shows. + * + * `headRef` is qualified `owner:branch` because a pull request from a fork has no branch of that + * name in the base repository, and an unqualified name is simply not found there. + */ +export const BASE_COMPARISON_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $headRef: String!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + viewerCanUpdateBranch + baseRef { + compare(headRef: $headRef) { + behindBy + } + } + } + } +}`; + +const RawBaseComparisonSchema = Schema.Struct({ + data: Schema.Struct({ + repository: Schema.NullOr( + Schema.Struct({ + pullRequest: Schema.NullOr( + Schema.Struct({ + viewerCanUpdateBranch: Schema.optional(Schema.NullOr(Schema.Boolean)), + /** Null where the head repository is gone, which is a comparison nobody can make. */ + baseRef: Schema.optional( + Schema.NullOr( + Schema.Struct({ + compare: Schema.optional( + Schema.NullOr(Schema.Struct({ behindBy: Schema.Number })), + ), + }), + ), + ), + }), + ), + }), + ), + }), +}); + +const decodeBaseComparison = decodeJsonResult(RawBaseComparisonSchema); + +export interface GitHubBaseComparison { + /** Null where the host could not compare, which the page reads as "unknown". */ + readonly behindBy: number | null; + readonly viewerCanUpdate: boolean; +} + +export function decodeBaseComparisonJson( + raw: string, +): Result.Result { + const decoded = decodeBaseComparison(raw); + if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); + const pullRequest = decoded.success.data.repository?.pullRequest; + const behindBy = pullRequest?.baseRef?.compare?.behindBy; + return Result.succeed({ + behindBy: typeof behindBy === "number" && behindBy >= 0 ? behindBy : null, + viewerCanUpdate: pullRequest?.viewerCanUpdateBranch === true, + }); +} + +export const REVIEWER_CANDIDATES_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + assignableUsers(first: ${GRAPHQL_PAGE_SIZE}) { + pageInfo { hasNextPage } + nodes { login name avatarUrl } + } + pullRequest(number: $number) { + author { login } + reviewRequests(first: ${GRAPHQL_PAGE_SIZE}) { + nodes { + requestedReviewer { + ... on User { login name avatarUrl } + ... on Team { slug name avatarUrl } + ... on Bot { login avatarUrl } + } + } + } + } + } +}`; + +/** A team answers with a slug where a user answers with a login, and nothing else differs. */ +const RawRequestedReviewerSchema = Schema.Struct({ + ...RawActorSchema.fields, + slug: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawReviewerCandidatesSchema = Schema.Struct({ + data: Schema.Struct({ + repository: Schema.Struct({ + assignableUsers: Schema.Struct({ + pageInfo: Schema.optional(RawPageInfoSchema), + nodes: Schema.Array(Schema.NullOr(RawActorSchema)), + }), + /** Null for a number that names no pull request the viewer can see. */ + pullRequest: Schema.NullOr( + Schema.Struct({ + author: Schema.optional(Schema.NullOr(RawActorSchema)), + reviewRequests: Schema.optional( + Schema.NullOr( + Schema.Struct({ + nodes: Schema.Array( + Schema.Struct({ + requestedReviewer: Schema.optional(Schema.NullOr(RawRequestedReviewerSchema)), + }), + ), + }), + ), + ), + }), + ), + }), + }), +}); + +const decodeReviewerCandidates = decodeJsonResult(RawReviewerCandidatesSchema); + +/** + * The people this pull request may be sent to, with whoever is already on it marked. The author is + * dropped rather than shown as an unusable row: GitHub refuses a review request from the person + * who opened the pull request, so offering them is offering a failure. + * + * Whoever has been asked leads the list even where GitHub does not count them assignable — an + * outside collaborator, an app — because a request that cannot be seen cannot be taken back. + */ +export function decodeReviewerCandidatesJson( + raw: string, +): Result.Result { + const decoded = decodeReviewerCandidates(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const repository = decoded.success.data.repository; + const pullRequest = repository.pullRequest; + const author = trimmed(pullRequest?.author?.login); + const candidates = new Map(); + for (const node of pullRequest?.reviewRequests?.nodes ?? []) { + const raw = node.requestedReviewer; + const slug = trimmed(raw?.slug); + const id = slug ?? trimmed(raw?.login); + if (id === null) continue; + candidates.set(`${slug === null ? "user" : "team"} ${id}`, { + id, + kind: slug === null ? "user" : "team", + login: id, + name: trimmed(raw?.name), + avatarUrl: trimmed(raw?.avatarUrl), + isRequested: true, + }); + } + for (const node of repository.assignableUsers.nodes) { + const login = trimmed(node?.login); + if (login === null || login === author || candidates.has(`user ${login}`)) continue; + candidates.set(`user ${login}`, { + id: login, + kind: "user", + login, + name: trimmed(node?.name), + avatarUrl: trimmed(node?.avatarUrl), + isRequested: false, + }); + } + return Result.succeed({ + candidates: [...candidates.values()], + truncated: repository.assignableUsers.pageInfo?.hasNextPage === true, + }); +} + +/** + * The body of `POST`/`DELETE /repos/{owner}/{repo}/pulls/{number}/requested_reviewers`, which + * takes people and teams in two lists of its own. The same body serves both methods, because + * GitHub takes a request back from exactly whoever it was made of. + */ +const ReviewerRequestSchema = Schema.Struct({ + reviewers: Schema.Array(Schema.String), + team_reviewers: Schema.Array(Schema.String), +}); + +const encodeReviewerRequest = Schema.encodeSync(Schema.fromJsonString(ReviewerRequestSchema)); + +export function buildReviewerRequestJson( + reviewers: ReadonlyArray<{ readonly id: string; readonly kind: PullRequestReviewerKind }>, +): string { + return encodeReviewerRequest({ + reviewers: reviewers.flatMap((reviewer) => (reviewer.kind === "user" ? [reviewer.id] : [])), + team_reviewers: reviewers.flatMap((reviewer) => + reviewer.kind === "team" ? [reviewer.id] : [], + ), + }); +} + +/** + * Everything GitHub says about what the signed-in account may do here. `canWrite` is about the + * repository, the other two about this pull request in particular — which is why an author with + * only read access can still be told apart from a passer-by. + */ +export interface GitHubViewerAccess { + readonly canWrite: boolean; + /** GitHub's own `viewerCanUpdate`, true for the author as well as for anyone with write. */ + readonly canUpdate: boolean; + readonly didAuthor: boolean; + /** + * GitHub's own `viewerCanUpdateBranch`, read with the base comparison rather than here: it is + * false for a branch that is already current, so it answers "may update, and there is + * something to update" at once. Absent where the comparison was not read. + */ + readonly canUpdateBranch?: boolean; +} + +/** + * The viewer's standing, asked on its own. Only the write path needs this: reading a pull request + * already carries the same three fields on calls it was making anyway, and this exists so that a + * merge or a close is decided by what GitHub says now rather than by what the page was told when + * it loaded. + */ +export const VIEWER_PERMISSIONS_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + viewerPermission + pullRequest(number: $number) { viewerCanUpdate viewerDidAuthor } + } +}`; + +const RawViewerPermissionsSchema = Schema.Struct({ + data: Schema.Struct({ + repository: Schema.Struct({ + viewerPermission: Schema.optional(Schema.NullOr(Schema.String)), + /** Null for a number that names no pull request the viewer can see. */ + pullRequest: Schema.NullOr(RawViewerFieldsSchema), + }), + }), +}); + +const decodeViewerPermissions = decodeJsonResult(RawViewerPermissionsSchema); + +export function decodeViewerPermissionsJson( + raw: string, +): Result.Result { + const decoded = decodeViewerPermissions(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const repository = decoded.success.data.repository; + return Result.succeed({ + canWrite: toCanWrite(repository.viewerPermission), + ...toPullRequestViewerFields(repository.pullRequest), + }); +} + +export interface GitHubPullRequestFilesPatch { + readonly patch: string; + /** At least one file's hunks were withheld by GitHub, so they are missing from the patch. */ + readonly truncated: boolean; + /** Files GitHub returned, counted before decoding, so the caller can page. */ + readonly rawCount: number; + /** GitHub's own counts for the files whose hunks it withheld. */ + readonly omittedFileStats: ReadonlyArray; +} + +/** + * The files API returns hunks per file with no `diff --git` header, so the unified patch every + * diff viewer expects is assembled here. This decodes one page; walking pages is the caller's + * job, which is why the raw file count comes back with the patch. + */ +export function decodePullRequestFilesJson( + raw: string, +): Result.Result { + const decoded = decodeUnknownList(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const sections: string[] = []; + const omittedFileStats: PullRequestOmittedFileStat[] = []; + let truncated = false; + for (const entry of decoded.success) { + const file = decodeFileEntry(entry); + if (Exit.isFailure(file)) continue; + const value = file.value; + const hunks = value.patch ?? ""; + const status = value.status?.trim().toLowerCase(); + if (hunks.length === 0) { + // A file with no hunks is still a file that changed: a pure rename has none to give, and + // a binary one has none that can be shown. Both are listed, and only the second is a hole + // in the patch — leaving them out entirely would drop them from the change altogether. + const additions = value.additions ?? 0; + const deletions = value.deletions ?? 0; + if (additions + deletions > 0) { + truncated = true; + omittedFileStats.push({ path: value.filename, additions, deletions }); + } + } + // A rename counts its hunks against the old path, which is the only place it is named. + const oldPath = + status === "renamed" ? (trimmed(value.previous_filename) ?? value.filename) : value.filename; + const header = [ + `diff --git a/${oldPath} b/${value.filename}`, + // The files API reports no file mode, so the ordinary one stands in: the viewer reads + // these lines as "added" and "removed" rather than for the mode they carry. + ...(status === "added" ? ["new file mode 100644"] : []), + ...(status === "removed" ? ["deleted file mode 100644"] : []), + ...(status === "renamed" ? [`rename from ${oldPath}`, `rename to ${value.filename}`] : []), + `--- ${status === "added" ? "/dev/null" : `a/${oldPath}`}`, + `+++ ${status === "removed" ? "/dev/null" : `b/${value.filename}`}`, + ].join("\n"); + sections.push(hunks.length === 0 ? `${header}\n` : `${header}\n${hunks.replace(/\n?$/, "\n")}`); + } + return Result.succeed({ + patch: sections.join(""), + truncated, + rawCount: decoded.success.length, + omittedFileStats, + }); +} diff --git a/apps/server/src/pullRequest/gitLabMergeRequestJson.test.ts b/apps/server/src/pullRequest/gitLabMergeRequestJson.test.ts new file mode 100644 index 000000000000..9221c1ab8e04 --- /dev/null +++ b/apps/server/src/pullRequest/gitLabMergeRequestJson.test.ts @@ -0,0 +1,610 @@ +import * as Result from "effect/Result"; +import { describe, expect, it } from "vite-plus/test"; + +import { + decodeAwardEmojiJson, + decodeCommitsJson, + decodeMergeRequestDetailJson, + decodeMergeRequestDiffsJson, + decodeMergeRequestListJson, + decodeNotesJson, + decodeOwnAwardIdJson, + decodeViewerJson, + gitLabAwardName, +} from "./gitLabMergeRequestJson.ts"; + +function listJson(entries: ReadonlyArray>): string { + return JSON.stringify( + entries.map((entry) => ({ + iid: 1, + title: "Add the merge requests page", + web_url: "https://gitlab.com/acme/web/-/merge_requests/1", + source_branch: "feat/page", + target_branch: "main", + created_at: "2026-07-01T00:00:00Z", + updated_at: "2026-07-02T00:00:00Z", + ...entry, + })), + ); +} + +function detailJson(entry: Record): string { + return JSON.stringify({ + iid: 1, + title: "Add the merge requests page", + web_url: "https://gitlab.com/acme/web/-/merge_requests/1", + source_branch: "feat/page", + target_branch: "main", + created_at: "2026-07-01T00:00:00Z", + updated_at: "2026-07-02T00:00:00Z", + ...entry, + }); +} + +function expectSuccess(result: Result.Result): A { + expect(Result.isSuccess(result)).toBe(true); + if (!Result.isSuccess(result)) throw new Error("expected a successful decode"); + return result.success; +} + +describe("decodeMergeRequestListJson", () => { + it("reads a merge request as a change request", () => { + const batch = expectSuccess( + decodeMergeRequestListJson( + listJson([ + { + iid: 42, + author: { username: "bilal", name: "Bilal" }, + state: "opened", + merge_status: "can_be_merged", + draft: false, + reviewers: [{ username: "julius" }], + labels: ["backend", " "], + }, + ]), + ), + ); + + expect(batch.items).toHaveLength(1); + expect(batch.items[0]).toMatchObject({ + number: 42, + author: { login: "bilal", name: "Bilal" }, + headBranch: "feat/page", + baseBranch: "main", + state: "open", + isDraft: false, + mergeability: "mergeable", + reviewRequestLogins: ["julius"], + labels: [{ name: "backend", color: null }], + }); + }); + + it("reports no line counts, which GitLab does not expose", () => { + const batch = expectSuccess(decodeMergeRequestListJson(listJson([{}]))); + + expect(batch.items[0]).toMatchObject({ additions: 0, deletions: 0 }); + }); + + it("treats a merged timestamp as merged whatever the state says", () => { + const batch = expectSuccess( + decodeMergeRequestListJson( + listJson([{ state: "opened", merged_at: "2026-07-03T00:00:00Z" }]), + ), + ); + + expect(batch.items[0]?.state).toBe("merged"); + }); + + it("keeps a locked merge request open", () => { + const batch = expectSuccess(decodeMergeRequestListJson(listJson([{ state: "locked" }]))); + + expect(batch.items[0]?.state).toBe("open"); + }); + + it("reads the legacy draft flag", () => { + const batch = expectSuccess(decodeMergeRequestListJson(listJson([{ work_in_progress: true }]))); + + expect(batch.items[0]?.isDraft).toBe(true); + }); + + it("calls a conflicted merge request conflicting even while the merge check is pending", () => { + const batch = expectSuccess( + decodeMergeRequestListJson(listJson([{ merge_status: "checking", has_conflicts: true }])), + ); + + expect(batch.items[0]?.mergeability).toBe("conflicting"); + }); + + it("leaves an unfinished merge check unknown", () => { + const batch = expectSuccess( + decodeMergeRequestListJson(listJson([{ merge_status: "checking" }])), + ); + + expect(batch.items[0]?.mergeability).toBe("unknown"); + }); + + it("skips a malformed row but still counts it, so paging does not stop early", () => { + const batch = expectSuccess( + decodeMergeRequestListJson( + JSON.stringify([{ iid: "not a number" }, ...JSON.parse(listJson([{}]))]), + ), + ); + + expect(batch.items).toHaveLength(1); + expect(batch.rawIndexes).toEqual([1]); + expect(batch.rawCount).toBe(2); + }); +}); + +describe("decodeMergeRequestDetailJson", () => { + it("reads the description, file count and pipeline", () => { + const detail = expectSuccess( + decodeMergeRequestDetailJson( + detailJson({ + description: "Ships the page.", + changes_count: "3", + reviewers: [{ username: "julius", name: "Julius" }], + head_pipeline: { + status: "success", + web_url: "https://gitlab.com/acme/web/-/pipelines/9", + source: "merge_request_event", + }, + }), + ), + ); + + expect(detail.body).toBe("Ships the page."); + expect(detail.changedFiles).toBe(3); + expect(detail.reviewers).toEqual([{ login: "julius", name: "Julius", avatarUrl: null }]); + expect(detail.checks).toEqual([ + { + name: "Pipeline", + status: "success", + description: "merge_request_event", + url: "https://gitlab.com/acme/web/-/pipelines/9", + }, + ]); + }); + + it("reads an uncounted change set as its floor", () => { + const detail = expectSuccess( + decodeMergeRequestDetailJson(detailJson({ changes_count: "1000+" })), + ); + + expect(detail.changedFiles).toBe(1000); + }); + + it("falls back to no file count when GitLab omits one", () => { + const detail = expectSuccess(decodeMergeRequestDetailJson(detailJson({}))); + + expect(detail.changedFiles).toBe(0); + }); + + it("reads either auto-merge field, and says nothing where GitLab named neither", () => { + const armed = (entry: Record) => + expectSuccess(decodeMergeRequestDetailJson(detailJson(entry))).autoMergeEnabled; + + expect(armed({ merge_when_pipeline_succeeds: true })).toBe(true); + // The newer name for the same fact, which older GitLab installs do not send. + expect(armed({ auto_merge_enabled: true })).toBe(true); + expect(armed({ merge_when_pipeline_succeeds: false })).toBe(false); + // Absent is GitLab not saying, which the page must not read as "not armed". + expect(armed({})).toBeUndefined(); + }); + + it("keeps a divergence GitLab did not count apart from a divergence of none", () => { + const behind = (entry: Record) => + expectSuccess(decodeMergeRequestDetailJson(detailJson(entry))).divergedCommits; + + expect(behind({ diverged_commits_count: 3 })).toBe(3); + // Counted and found level, which is the one answer that entitles the page to say so. + expect(behind({ diverged_commits_count: 0 })).toBe(0); + // An install that does not answer, and a null where the answer would have gone, are both + // silence: reading either as zero would tell a stale branch it is current. + expect(behind({})).toBeUndefined(); + expect(behind({ diverged_commits_count: null })).toBeUndefined(); + }); + + it("maps a pipeline waiting on a person to neutral, not failure", () => { + const detail = expectSuccess( + decodeMergeRequestDetailJson(detailJson({ head_pipeline: { status: "manual" } })), + ); + + expect(detail.checks[0]?.status).toBe("neutral"); + }); +}); + +describe("decodeViewerJson", () => { + it("reads the signed-in username", () => { + expect(expectSuccess(decodeViewerJson(JSON.stringify({ username: "bilal" })))).toBe("bilal"); + }); + + it("returns nothing when the account has no username", () => { + expect(expectSuccess(decodeViewerJson(JSON.stringify({ username: " " })))).toBeNull(); + }); +}); + +describe("decodeNotesJson", () => { + it("keeps comments and drops GitLab's own activity notes", () => { + const notes = expectSuccess( + decodeNotesJson( + JSON.stringify([ + { + id: 1, + body: "assigned to @bilal", + system: true, + created_at: "2026-07-01T00:00:00Z", + }, + { + id: 2, + body: "Looks good.", + author: { username: "julius" }, + created_at: "2026-07-02T00:00:00Z", + }, + { id: 3, body: " ", created_at: "2026-07-03T00:00:00Z" }, + ]), + ), + ); + + expect(notes.comments).toHaveLength(1); + expect(notes.comments[0]).toMatchObject({ + id: "2", + kind: "issue-comment", + body: "Looks good.", + }); + // The raw count keeps the dropped notes visible to the caller, which needs them to page. + expect(notes.rawCount).toBe(3); + }); + + it("reads a line note as a review comment on its file", () => { + const notes = expectSuccess( + decodeNotesJson( + JSON.stringify([ + { + id: 7, + type: "DiffNote", + body: "Rename this.", + created_at: "2026-07-02T00:00:00Z", + position: { new_path: "src/app.ts", old_path: "src/old.ts" }, + }, + ]), + ), + ); + + expect(notes.comments[0]).toMatchObject({ kind: "review-comment", path: "src/app.ts" }); + }); + + it("falls back to the old path for a note on a deleted line", () => { + const notes = expectSuccess( + decodeNotesJson( + JSON.stringify([ + { + id: 8, + type: "DiffNote", + body: "Gone.", + created_at: "2026-07-02T00:00:00Z", + position: { new_path: null, old_path: "src/old.ts" }, + }, + ]), + ), + ); + + expect(notes.comments[0]?.path).toBe("src/old.ts"); + }); +}); + +describe("decodeCommitsJson", () => { + it("returns commits oldest first", () => { + const commits = expectSuccess( + decodeCommitsJson( + JSON.stringify([ + { id: "bbb", title: "second", committed_date: "2026-07-02T00:00:00Z" }, + { id: "aaa", title: "first", committed_date: "2026-07-01T00:00:00Z" }, + ]), + ), + ); + + expect(commits.map((commit) => commit.oid)).toEqual(["aaa", "bbb"]); + }); + + it("skips commits whose id is empty", () => { + const commits = expectSuccess( + decodeCommitsJson( + JSON.stringify([ + { id: " ", title: "invalid", committed_date: "2026-07-02T00:00:00Z" }, + { id: "aaa", committed_date: "2026-07-01T00:00:00Z" }, + ]), + ), + ); + + expect(commits.map((commit) => commit.oid)).toEqual(["aaa"]); + }); + + it("falls back to the creation timestamp when there is no commit date", () => { + const commits = expectSuccess( + decodeCommitsJson( + JSON.stringify([ + { + id: "aaa", + created_at: "2026-07-01T00:00:00+08:00", + author_name: "Ada Lovelace", + author_email: "ada@example.com", + }, + ]), + ), + ); + + expect(commits[0]).toMatchObject({ + oid: "aaa", + committedDate: "2026-07-01T00:00:00+08:00", + authors: [{ login: "Ada Lovelace", name: "Ada Lovelace", avatarUrl: null }], + }); + }); + + it("carries commit additions and deletions when GitLab returns stats", () => { + const commits = expectSuccess( + decodeCommitsJson( + JSON.stringify([ + { + id: "aaa", + committed_date: "2026-07-01T00:00:00Z", + stats: { additions: 21, deletions: 8, total: 29 }, + }, + ]), + ), + ); + + expect(commits[0]).toMatchObject({ additions: 21, deletions: 8 }); + }); +}); + +describe("decodeMergeRequestDiffsJson", () => { + it("assembles a unified patch GitLab does not return", () => { + const result = expectSuccess( + decodeMergeRequestDiffsJson( + JSON.stringify([ + { + old_path: "src/app.ts", + new_path: "src/app.ts", + diff: "@@ -1 +1 @@\n-old\n+new\n", + }, + ]), + ), + ); + + expect(result.patch).toBe( + [ + "diff --git a/src/app.ts b/src/app.ts", + "--- a/src/app.ts", + "+++ b/src/app.ts", + "@@ -1 +1 @@", + "-old", + "+new", + "", + ].join("\n"), + ); + expect(result.truncated).toBe(false); + }); + + it("points a new file at /dev/null on the left and a deleted file on the right", () => { + const result = expectSuccess( + decodeMergeRequestDiffsJson( + JSON.stringify([ + { + old_path: "src/new.ts", + new_path: "src/new.ts", + new_file: true, + b_mode: "100755", + diff: "@@ -0,0 +1 @@\n+hello\n", + }, + { + old_path: "src/gone.ts", + new_path: "src/gone.ts", + deleted_file: true, + diff: "@@ -1 +0,0 @@\n-bye\n", + }, + ]), + ), + ); + + expect(result.patch).toContain("new file mode 100755"); + expect(result.patch).toContain("--- /dev/null"); + expect(result.patch).toContain("deleted file mode 100644"); + expect(result.patch).toContain("+++ /dev/null"); + }); + + it("records a rename so the patch names both paths", () => { + const result = expectSuccess( + decodeMergeRequestDiffsJson( + JSON.stringify([ + { old_path: "src/old.ts", new_path: "src/new.ts", renamed_file: true, diff: "" }, + ]), + ), + ); + + expect(result.patch).toContain("rename from src/old.ts"); + expect(result.patch).toContain("rename to src/new.ts"); + }); + + it("reports truncation for a file GitLab refused to inline", () => { + const result = expectSuccess( + decodeMergeRequestDiffsJson( + JSON.stringify([{ old_path: "big.bin", new_path: "big.bin", diff: "", too_large: true }]), + ), + ); + + expect(result.truncated).toBe(true); + expect(result.patch).toContain("diff --git a/big.bin b/big.bin"); + }); + + it("reports how many files GitLab returned, so the caller can page", () => { + const result = expectSuccess( + decodeMergeRequestDiffsJson( + JSON.stringify( + Array.from({ length: 3 }, (_, index) => ({ + old_path: `src/${index}.ts`, + new_path: `src/${index}.ts`, + diff: "@@ -1 +1 @@\n-a\n+b\n", + })), + ), + ), + ); + + expect(result.rawCount).toBe(3); + expect(result.truncated).toBe(false); + expect(result.patch).toContain("src/2.ts"); + }); + + it("fails when GitLab did not return a list", () => { + expect(Result.isFailure(decodeMergeRequestDiffsJson('{"message":"404"}'))).toBe(true); + }); +}); + +describe("merge request viewer fields", () => { + it("carries GitLab's own answer for whether this viewer can merge", () => { + expect( + expectSuccess(decodeMergeRequestDetailJson(detailJson({ user: { can_merge: false } }))) + .viewerCanMerge, + ).toBe(false); + expect( + expectSuccess(decodeMergeRequestDetailJson(detailJson({ user: { can_merge: true } }))) + .viewerCanMerge, + ).toBe(true); + }); + + it("leaves merging permitted where GitLab answered without the field", () => { + // Only the single-merge-request endpoint carries `user`, and an install that answers without + // it has said nothing about the viewer rather than said no. + expect(expectSuccess(decodeMergeRequestDetailJson(detailJson({}))).viewerCanMerge).toBe(true); + expect( + expectSuccess(decodeMergeRequestDetailJson(detailJson({ user: null }))).viewerCanMerge, + ).toBe(true); + }); +}); + +describe("decodeAwardEmojiJson", () => { + it("reads MR-level awards, keys per-note awards by the REST id inside their gid, and ignores an award outside the eight", () => { + const result = expectSuccess( + decodeAwardEmojiJson( + JSON.stringify({ + data: { + currentUser: { username: "bilal" }, + project: { + mergeRequest: { + awardEmoji: { + nodes: [ + { name: "thumbsup", user: { username: "bilal" } }, + { name: "thumbsup", user: { username: "julius" } }, + ], + }, + notes: { + pageInfo: { hasNextPage: false, endCursor: null }, + nodes: [ + { + id: "gid://gitlab/DiffNote/42", + awardEmoji: { nodes: [{ name: "heart", user: { username: "julius" } }] }, + }, + { + id: "gid://gitlab/Note/7", + // Not one of the eight the contract carries. + awardEmoji: { + nodes: [{ name: "partyparrot", user: { username: "bilal" } }], + }, + }, + ], + }, + }, + }, + }, + }), + ), + ); + + // `bilal` is `currentUser`, so the group they are in reads back as reacted, but their own + // username is left out of `actors` — the page names them "You" instead — while `count` still + // counts them; `julius` alone does not turn a group's own `viewerHasReacted` on. + expect(result.reactions).toEqual([ + { content: "thumbs-up", count: 2, actors: ["julius"], viewerHasReacted: true }, + ]); + // Note 7's only award named nobody the eight recognise, so it carries no reactions and is + // left out of the map rather than kept empty. + expect([...result.reactionsByNoteId]).toEqual([ + ["42", [{ content: "heart", count: 1, actors: ["julius"], viewerHasReacted: false }]], + ]); + expect(result.nextCursor).toBeNull(); + }); + + it("hands back a cursor when GitLab has more notes to page", () => { + const result = expectSuccess( + decodeAwardEmojiJson( + JSON.stringify({ + data: { + currentUser: null, + project: { + mergeRequest: { + awardEmoji: { nodes: [] }, + notes: { pageInfo: { hasNextPage: true, endCursor: "Y3Vyc29yOjE" }, nodes: [] }, + }, + }, + }, + }), + ), + ); + + expect(result.nextCursor).toBe("Y3Vyc29yOjE"); + }); + + it("matches the viewer's username case-insensitively, since GitLab is not consistent about case", () => { + const result = expectSuccess( + decodeAwardEmojiJson( + JSON.stringify({ + data: { + currentUser: { username: "Bilal" }, + project: { + mergeRequest: { + awardEmoji: { + nodes: [ + { name: "heart", user: { username: "bilal" } }, + { name: "heart", user: { username: "julius" } }, + ], + }, + notes: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, + }, + }, + }, + }), + ), + ); + + expect(result.reactions).toEqual([ + { content: "heart", count: 2, actors: ["julius"], viewerHasReacted: true }, + ]); + }); +}); + +describe("decodeOwnAwardIdJson", () => { + const awards = JSON.stringify([ + { id: 101, name: "thumbsup", user: { username: "julius" } }, + { id: 102, name: "thumbsup", user: { username: "bilal" } }, + ]); + + it("finds the reader's own award of that name, which is the one a removal deletes", () => { + expect( + expectSuccess(decodeOwnAwardIdJson(awards, { content: "thumbs-up", viewer: "bilal" })), + ).toBe(102); + }); + + it("returns nothing where the reader has no award of that name", () => { + expect( + expectSuccess(decodeOwnAwardIdJson(awards, { content: "heart", viewer: "bilal" })), + ).toBeNull(); + }); +}); + +describe("gitLabAwardName", () => { + it("spells the contents whose GitLab award name is not their own kebab-case", () => { + expect(gitLabAwardName("thumbs-up")).toBe("thumbsup"); + expect(gitLabAwardName("laugh")).toBe("laughing"); + expect(gitLabAwardName("hooray")).toBe("tada"); + }); +}); diff --git a/apps/server/src/pullRequest/gitLabMergeRequestJson.ts b/apps/server/src/pullRequest/gitLabMergeRequestJson.ts new file mode 100644 index 000000000000..9f4bd96bae08 --- /dev/null +++ b/apps/server/src/pullRequest/gitLabMergeRequestJson.ts @@ -0,0 +1,940 @@ +import * as Cause from "effect/Cause"; +import * as Exit from "effect/Exit"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import type { + PullRequestActor, + PullRequestCheck, + PullRequestCheckStatus, + PullRequestComment, + PullRequestCommit, + PullRequestLabel, + PullRequestMergeability, + PullRequestMergeCapabilities, + PullRequestReaction, + PullRequestReactionContent, + PullRequestReviewThread, + PullRequestReviewerCandidate, + PullRequestState, +} from "@t3tools/contracts"; +import { TrimmedNonEmptyString } from "@t3tools/contracts"; +import { decodeJsonResult } from "@t3tools/shared/schemaJson"; + +/** + * GitLab's REST enums are decoded as plain strings and normalized here: a GitLab release that + * adds a pipeline status or a merge status must not fail the whole payload. + */ +const RawUserSchema = Schema.Struct({ + /** + * GitLab writes a merge request's reviewers as numeric ids and takes no usernames there, so the + * id is carried alongside the handle rather than looked up again when a review is asked for. + */ + id: Schema.optional(Schema.Int), + username: Schema.String, + name: Schema.optional(Schema.NullOr(Schema.String)), + avatar_url: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawPipelineSchema = Schema.Struct({ + status: Schema.optional(Schema.NullOr(Schema.String)), + web_url: Schema.optional(Schema.NullOr(Schema.String)), + source: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawMergeRequestSchema = Schema.Struct({ + iid: Schema.Int, + title: Schema.String, + web_url: Schema.String, + description: Schema.optional(Schema.NullOr(Schema.String)), + author: Schema.optional(Schema.NullOr(RawUserSchema)), + source_branch: Schema.String, + target_branch: Schema.String, + state: Schema.optional(Schema.NullOr(Schema.String)), + draft: Schema.optional(Schema.Boolean), + work_in_progress: Schema.optional(Schema.Boolean), + merge_status: Schema.optional(Schema.NullOr(Schema.String)), + has_conflicts: Schema.optional(Schema.NullOr(Schema.Boolean)), + created_at: Schema.String, + updated_at: Schema.String, + merged_at: Schema.optional(Schema.NullOr(Schema.String)), + closed_at: Schema.optional(Schema.NullOr(Schema.String)), + reviewers: Schema.optional(Schema.NullOr(Schema.Array(RawUserSchema))), + labels: Schema.optional(Schema.NullOr(Schema.Array(Schema.String))), + // A string, and "1000+" past GitLab's counting limit, so it is parsed rather than decoded. + changes_count: Schema.optional(Schema.NullOr(Schema.String)), + head_pipeline: Schema.optional(Schema.NullOr(RawPipelineSchema)), + /** + * What the requesting account may do, which only the single-merge-request endpoint carries. + * GitLab answers `can_merge` for this viewer against this merge request, so it already accounts + * for the role, the approval rules and a protected target branch — none of which a project's + * access level on its own would tell apart. + */ + user: Schema.optional( + Schema.NullOr(Schema.Struct({ can_merge: Schema.optional(Schema.Boolean) })), + ), + /** + * Whether GitLab is holding this merge request to merge it once its pipeline goes green. + * `merge_when_pipeline_succeeds` is the field every version answers with; newer ones also + * carry `auto_merge_enabled`, which is the same fact under the name GitLab settled on, so + * either one saying yes is a yes. + */ + merge_when_pipeline_succeeds: Schema.optional(Schema.NullOr(Schema.Boolean)), + auto_merge_enabled: Schema.optional(Schema.NullOr(Schema.Boolean)), + /** + * How far the target branch has moved on since this one left it, which is the same number + * GitLab's own "out of date" wording counts. It costs a walk of the two branches, so GitLab + * withholds it unless `include_diverged_commits_count` asks for it, and answers it only for a + * single merge request — a list never carries it, however it is asked for. + */ + diverged_commits_count: Schema.optional(Schema.NullOr(Schema.Int)), +}); + +const RawNoteSchema = Schema.Struct({ + id: Schema.Int, + body: Schema.optional(Schema.NullOr(Schema.String)), + author: Schema.optional(Schema.NullOr(RawUserSchema)), + created_at: Schema.String, + /** True for notes GitLab writes itself ("assigned to…"), which are events, not comments. */ + system: Schema.optional(Schema.Boolean), + type: Schema.optional(Schema.NullOr(Schema.String)), + position: Schema.optional( + Schema.NullOr( + Schema.Struct({ + new_path: Schema.optional(Schema.NullOr(Schema.String)), + old_path: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + ), +}); + +/** + * A discussion note carrying its place in the diff, which is the shape the whole thread view + * is built from. `resolved` lives on the note rather than on the discussion: GitLab calls a + * discussion resolved once every resolvable note in it is. + */ +const RawDiscussionNoteSchema = Schema.Struct({ + id: Schema.Int, + body: Schema.optional(Schema.NullOr(Schema.String)), + author: Schema.optional(Schema.NullOr(RawUserSchema)), + created_at: Schema.String, + system: Schema.optional(Schema.Boolean), + resolvable: Schema.optional(Schema.Boolean), + resolved: Schema.optional(Schema.NullOr(Schema.Boolean)), + position: Schema.optional( + Schema.NullOr( + Schema.Struct({ + position_type: Schema.optional(Schema.NullOr(Schema.String)), + new_path: Schema.optional(Schema.NullOr(Schema.String)), + old_path: Schema.optional(Schema.NullOr(Schema.String)), + new_line: Schema.optional(Schema.NullOr(Schema.Int)), + old_line: Schema.optional(Schema.NullOr(Schema.Int)), + }), + ), + ), +}); + +const RawDiscussionSchema = Schema.Struct({ + id: Schema.String, + notes: Schema.optional(Schema.NullOr(Schema.Array(RawDiscussionNoteSchema))), +}); + +const RawDiffRefsSchema = Schema.Struct({ + diff_refs: Schema.optional( + Schema.NullOr( + Schema.Struct({ + base_sha: Schema.String, + head_sha: Schema.String, + start_sha: Schema.String, + }), + ), + ), +}); + +const RawCommitSchema = Schema.Struct({ + id: TrimmedNonEmptyString, + title: Schema.optional(Schema.NullOr(Schema.String)), + committed_date: Schema.optional(Schema.NullOr(Schema.String)), + created_at: Schema.optional(Schema.NullOr(Schema.String)), + parent_ids: Schema.optional(Schema.Array(Schema.String)), + author_name: Schema.optional(Schema.NullOr(Schema.String)), + author_email: Schema.optional(Schema.NullOr(Schema.String)), + stats: Schema.optional( + Schema.NullOr( + Schema.Struct({ + additions: Schema.optional(Schema.Int), + deletions: Schema.optional(Schema.Int), + }), + ), + ), +}); + +const RawDiffSchema = Schema.Struct({ + old_path: Schema.String, + new_path: Schema.String, + a_mode: Schema.optional(Schema.NullOr(Schema.String)), + b_mode: Schema.optional(Schema.NullOr(Schema.String)), + new_file: Schema.optional(Schema.Boolean), + renamed_file: Schema.optional(Schema.Boolean), + deleted_file: Schema.optional(Schema.Boolean), + diff: Schema.optional(Schema.NullOr(Schema.String)), + /** GitLab omits the hunks for a file it considers too large to inline. */ + too_large: Schema.optional(Schema.NullOr(Schema.Boolean)), + /** And for one it collapsed, which withholds them the same way. */ + collapsed: Schema.optional(Schema.NullOr(Schema.Boolean)), +}); + +const RawViewerSchema = Schema.Struct({ + username: Schema.optional(Schema.NullOr(Schema.String)), +}); + +/** A GitLab project settles on one merge strategy plus an optional squash. */ +const RawProjectMergeSettingsSchema = Schema.Struct({ + merge_method: Schema.optional(Schema.NullOr(Schema.String)), + squash_option: Schema.optional(Schema.NullOr(Schema.String)), +}); + +export interface GitLabMergeRequestListItem { + readonly number: number; + readonly title: string; + readonly url: string; + readonly author: PullRequestActor | null; + readonly headBranch: string; + readonly baseBranch: string; + readonly state: PullRequestState; + readonly isDraft: boolean; + readonly mergeability: PullRequestMergeability; + /** + * GitLab reports neither added nor removed lines on a merge request, so both stay zero and + * the surface omits the stat. The Code tab counts them from the patch it already fetched. + */ + readonly additions: number; + readonly deletions: number; + readonly createdAt: string; + readonly updatedAt: string; + readonly reviewRequestLogins: ReadonlyArray; + readonly labels: ReadonlyArray; +} + +export interface GitLabMergeRequestDetail extends GitLabMergeRequestListItem { + readonly body: string; + readonly changedFiles: number; + readonly mergedAt: string | null; + readonly closedAt: string | null; + readonly reviewers: ReadonlyArray; + readonly checks: ReadonlyArray; + /** False only where GitLab said so; an answer without the field leaves merging permitted. */ + readonly viewerCanMerge: boolean; + /** The reviewers as GitLab addresses them, which is what writing the set back takes. */ + readonly reviewerIds: ReadonlyArray; + /** Absent where GitLab named neither auto-merge field, which is not the same as off. */ + readonly autoMergeEnabled?: boolean; + /** + * Absent where GitLab did not count, which is not the same as a branch that has nothing behind + * it: an install too old to answer must not be read as saying the branch is current. + */ + readonly divergedCommits?: number; +} + +function trimmed(value: string | null | undefined): string | null { + const text = value?.trim() ?? ""; + return text.length > 0 ? text : null; +} + +function toActor(raw: Schema.Schema.Type | null | undefined) { + const login = trimmed(raw?.username); + return login === null + ? null + : { login, name: trimmed(raw?.name), avatarUrl: trimmed(raw?.avatar_url) }; +} + +function toState(raw: Schema.Schema.Type): PullRequestState { + if (trimmed(raw.merged_at) !== null) return "merged"; + switch (raw.state?.trim().toLowerCase()) { + case "merged": + return "merged"; + case "closed": + return "closed"; + default: + // `locked` is an open merge request whose discussion is locked. + return "open"; + } +} + +function toMergeability( + raw: Schema.Schema.Type, +): PullRequestMergeability { + if (raw.has_conflicts === true) return "conflicting"; + switch (raw.merge_status?.trim().toLowerCase()) { + case "can_be_merged": + return "mergeable"; + case "cannot_be_merged": + return "conflicting"; + default: + // `unchecked` and `checking` mean GitLab has not finished the merge check yet. + return "unknown"; + } +} + +function toLabels(raw: ReadonlyArray | null | undefined): ReadonlyArray { + // GitLab returns label names only, so there is no colour to carry. + return (raw ?? []).flatMap((label) => { + const name = trimmed(label); + return name === null ? [] : [{ name, color: null }]; + }); +} + +/** + * "3" for a counted change set, "1000+" once GitLab gives up counting. The leading number is + * the floor either way, which reads better than dropping an uncounted change set to nothing. + */ +function toChangedFiles(value: string | null | undefined): number { + const parsed = Number.parseInt(value?.trim() ?? "", 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 0; +} + +function toPipelineStatus(value: string | null | undefined): PullRequestCheckStatus { + switch (value?.trim().toLowerCase()) { + case "success": + return "success"; + case "failed": + return "failure"; + case "canceled": + case "cancelling": + return "cancelled"; + case "skipped": + return "skipped"; + // A pipeline waiting on a person is not progress, and it is not a failure either. + case "manual": + case "scheduled": + return "neutral"; + default: + return "pending"; + } +} + +/** + * GitLab has no per-job check list on a merge request, so its pipeline is reported as the one + * check. The jobs behind it stay one click away through the pipeline URL. + */ +function toChecks( + raw: Schema.Schema.Type, +): ReadonlyArray { + const pipeline = raw.head_pipeline; + if (!pipeline) return []; + return [ + { + name: "Pipeline", + status: toPipelineStatus(pipeline.status), + description: trimmed(pipeline.source), + url: trimmed(pipeline.web_url), + }, + ]; +} + +function toListItem( + raw: Schema.Schema.Type, +): GitLabMergeRequestListItem { + return { + number: raw.iid, + title: raw.title, + url: raw.web_url, + author: toActor(raw.author), + headBranch: raw.source_branch, + baseBranch: raw.target_branch, + state: toState(raw), + isDraft: raw.draft ?? raw.work_in_progress ?? false, + mergeability: toMergeability(raw), + additions: 0, + deletions: 0, + createdAt: raw.created_at, + updatedAt: raw.updated_at, + reviewRequestLogins: (raw.reviewers ?? []).flatMap((reviewer) => { + const login = trimmed(reviewer.username); + return login === null ? [] : [login]; + }), + labels: toLabels(raw.labels), + }; +} + +function toDetail(raw: Schema.Schema.Type): GitLabMergeRequestDetail { + const listItem = toListItem(raw); + const autoMerge = + raw.merge_when_pipeline_succeeds == null && raw.auto_merge_enabled == null + ? undefined + : raw.merge_when_pipeline_succeeds === true || raw.auto_merge_enabled === true; + return { + ...listItem, + body: raw.description ?? "", + changedFiles: toChangedFiles(raw.changes_count), + mergedAt: trimmed(raw.merged_at), + closedAt: trimmed(raw.closed_at), + // Built from the reviewers themselves rather than from their logins, so the avatars survive. + reviewers: (raw.reviewers ?? []).flatMap((reviewer) => { + const actor = toActor(reviewer); + return actor === null ? [] : [actor]; + }), + checks: toChecks(raw), + viewerCanMerge: raw.user?.can_merge !== false, + reviewerIds: (raw.reviewers ?? []).flatMap((reviewer) => + reviewer.id === undefined ? [] : [reviewer.id], + ), + ...(autoMerge === undefined ? {} : { autoMergeEnabled: autoMerge }), + ...(raw.diverged_commits_count == null ? {} : { divergedCommits: raw.diverged_commits_count }), + }; +} + +const decodeUnknownList = decodeJsonResult(Schema.Array(Schema.Unknown)); +const decodeMergeRequestEntry = Schema.decodeUnknownExit(RawMergeRequestSchema); +const decodeMergeRequest = decodeJsonResult(RawMergeRequestSchema); +const decodeNoteEntry = Schema.decodeUnknownExit(RawNoteSchema); +const decodeUserEntry = Schema.decodeUnknownExit(RawUserSchema); +const decodeCommitEntry = Schema.decodeUnknownExit(RawCommitSchema); +const decodeCommit = decodeJsonResult(RawCommitSchema); +const decodeDiffEntry = Schema.decodeUnknownExit(RawDiffSchema); +const decodeDiscussionEntry = Schema.decodeUnknownExit(RawDiscussionSchema); +const decodeDiffRefs = decodeJsonResult(RawDiffRefsSchema); +const decodeViewer = decodeJsonResult(RawViewerSchema); +const decodeProjectMergeSettings = decodeJsonResult(RawProjectMergeSettingsSchema); + +type DecodeFailure = Cause.Cause; + +export interface GitLabProjectUsers { + readonly candidates: ReadonlyArray; + /** Rows GitLab returned, counted before decoding, so a skipped row cannot hide a next page. */ + readonly rawCount: number; +} + +export interface GitLabMergeRequestListBatch { + readonly items: ReadonlyArray; + /** Zero-based positions of the decoded items in GitLab's raw page. */ + readonly rawIndexes: ReadonlyArray; + /** Rows GitLab returned, counted before decoding, so a skipped row cannot hide a next page. */ + readonly rawCount: number; +} + +/** Malformed entries are skipped rather than failing the batch: one unexpected merge request + * must not blank the whole list. */ +export function decodeMergeRequestListJson( + raw: string, +): Result.Result { + const decoded = decodeUnknownList(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const items: GitLabMergeRequestListItem[] = []; + const rawIndexes: number[] = []; + for (const [rawIndex, entry] of decoded.success.entries()) { + const item = decodeMergeRequestEntry(entry); + if (Exit.isSuccess(item)) { + items.push(toListItem(item.value)); + rawIndexes.push(rawIndex); + } + } + return Result.succeed({ items, rawIndexes, rawCount: decoded.success.length }); +} + +export function decodeMergeRequestDetailJson( + raw: string, +): Result.Result { + const decoded = decodeMergeRequest(raw); + return Result.isSuccess(decoded) + ? Result.succeed(toDetail(decoded.success)) + : Result.fail(decoded.failure); +} + +export function decodeViewerJson(raw: string): Result.Result { + const decoded = decodeViewer(raw); + return Result.isSuccess(decoded) + ? Result.succeed(trimmed(decoded.success.username)) + : Result.fail(decoded.failure); +} + +/** + * The people with access to the project, which `GET /projects/:id/users` answers with — the same + * list GitLab's own reviewer field is filled from, including the members a group above the project + * lends it. A malformed row is skipped rather than failing the menu it belongs to. + * + * Nobody is marked requested here: who has been asked lives on the merge request, and only the + * caller holds both. + */ +export function decodeProjectUsersJson( + raw: string, +): Result.Result { + const decoded = decodeUnknownList(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const candidates: PullRequestReviewerCandidate[] = []; + for (const entry of decoded.success) { + const user = decodeUserEntry(entry); + if (Exit.isFailure(user) || user.value.id === undefined) continue; + const actor = toActor(user.value); + if (actor === null) continue; + candidates.push({ + ...actor, + id: String(user.value.id), + kind: "user", + isRequested: false, + }); + } + return Result.succeed({ candidates, rawCount: decoded.success.length }); +} + +/** + * GitLab settles the strategy per project rather than offering all three per merge request: + * `merge_method` picks one of merge commit, semi-linear or fast-forward, and squashing is a + * separate switch. An unrecognized setting offers nothing rather than offering a strategy the + * project forbids. + */ +export function decodeProjectMergeCapabilitiesJson( + raw: string, +): Result.Result { + const decoded = decodeProjectMergeSettings(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const mergeMethod = decoded.success.merge_method?.trim().toLowerCase(); + const squashOption = decoded.success.squash_option?.trim().toLowerCase(); + return Result.succeed({ + merge: mergeMethod === "merge", + // Both semi-linear and fast-forward histories are reached by rebasing onto the target. + rebase: mergeMethod === "rebase_merge" || mergeMethod === "ff", + // Only GitLab's own enabling values. An absent or unrecognized setting offers nothing, + // rather than offering a squash the project may forbid. + squash: + squashOption === "always" || squashOption === "default_on" || squashOption === "default_off", + }); +} + +/** + * Comments only. System notes are GitLab's own activity feed entries, and a `DiffNote` is the + * root of a line-level discussion, which is what the review-comment kind means. + * + * The raw note count comes back alongside, because dropping notes hides whether the page was + * full: a caller cannot tell "no more notes" from "a page of activity entries" without it. + */ +/** The three revisions a positioned comment is written against. */ +export interface GitLabDiffRefs { + readonly baseSha: string; + readonly headSha: string; + readonly startSha: string; +} + +export interface GitLabDiscussions { + readonly threads: ReadonlyArray; + /** Discussions GitLab returned, counted before decoding, so a skipped one still counts. */ + readonly rawCount: number; +} + +/** + * Positioned discussions only. GitLab returns the merge request's whole conversation here, + * including the plain notes the timeline already shows, and only a positioned one belongs + * against a line of the diff. + */ +export function decodeDiscussionsJson( + raw: string, +): Result.Result { + const decoded = decodeUnknownList(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const threads: PullRequestReviewThread[] = []; + for (const entry of decoded.success) { + const discussion = decodeDiscussionEntry(entry); + if (!Exit.isSuccess(discussion)) continue; + const notes = (discussion.value.notes ?? []).filter((note) => note.system !== true); + const root = notes[0]; + const position = root?.position; + if (root === undefined || !position || position.position_type !== "text") continue; + // A comment on an added or context line carries `new_line`; one on a removed line carries + // only `old_line`, and belongs against the file as it was. + const side = position.new_line === null || position.new_line === undefined ? "left" : "right"; + const path = trimmed(side === "left" ? position.old_path : position.new_path); + const line = side === "left" ? position.old_line : position.new_line; + if (path === null) continue; + threads.push({ + id: discussion.value.id, + path, + line: typeof line === "number" && line > 0 ? line : null, + side, + isResolved: root.resolved === true, + // GitLab reports no equivalent of "written against a line that has since moved", so a + // thread the diff cannot place is worked out from the diff itself rather than claimed + // here. + isOutdated: false, + comments: notes.map((note) => ({ + id: String(note.id), + author: toActor(note.author), + body: note.body ?? "", + createdAt: note.created_at, + url: null, + })), + }); + } + return Result.succeed({ threads, rawCount: decoded.success.length }); +} + +export function decodeDiffRefsJson( + raw: string, +): Result.Result { + const decoded = decodeDiffRefs(raw); + if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); + const refs = decoded.success.diff_refs; + return Result.succeed( + refs ? { baseSha: refs.base_sha, headSha: refs.head_sha, startSha: refs.start_sha } : null, + ); +} + +export function decodeNotesJson( + raw: string, +): Result.Result< + { readonly comments: ReadonlyArray; readonly rawCount: number }, + DecodeFailure +> { + const decoded = decodeUnknownList(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const comments: PullRequestComment[] = []; + for (const entry of decoded.success) { + const note = decodeNoteEntry(entry); + if (Exit.isFailure(note)) continue; + const value = note.value; + if (value.system === true) continue; + const body = value.body ?? ""; + if (body.trim().length === 0) continue; + const isDiffNote = value.type?.trim() === "DiffNote"; + comments.push({ + id: String(value.id), + kind: isDiffNote ? "review-comment" : "issue-comment", + author: toActor(value.author), + body, + createdAt: value.created_at, + url: null, + path: trimmed(value.position?.new_path) ?? trimmed(value.position?.old_path), + reviewState: null, + }); + } + return Result.succeed({ comments, rawCount: decoded.success.length }); +} + +export function decodeCommitsJson( + raw: string, +): Result.Result, DecodeFailure> { + const decoded = decodeUnknownList(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const commits: PullRequestCommit[] = []; + for (const entry of decoded.success) { + const commit = decodeCommitEntry(entry); + if (Exit.isFailure(commit)) continue; + const committedDate = trimmed(commit.value.committed_date) ?? trimmed(commit.value.created_at); + if (committedDate === null) continue; + commits.push({ + oid: commit.value.id, + messageHeadline: commit.value.title ?? "", + committedDate, + ...(commit.value.stats === null || commit.value.stats === undefined + ? {} + : { + additions: Math.max(0, commit.value.stats.additions ?? 0), + deletions: Math.max(0, commit.value.stats.deletions ?? 0), + }), + authors: (() => { + const login = trimmed(commit.value.author_name) ?? trimmed(commit.value.author_email); + return login === null + ? [] + : [{ login, name: trimmed(commit.value.author_name), avatarUrl: null }]; + })(), + }); + } + // GitLab lists a merge request's commits newest first; the timeline reads oldest first. + return Result.succeed(commits.toReversed()); +} + +/** The exact comparison GitLab uses for a commit-scoped diff. */ +export function decodeCommitDiffRefsJson( + raw: string, +): Result.Result { + const decoded = decodeCommit(raw); + if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); + const baseSha = trimmed(decoded.success.parent_ids?.[0]); + const headSha = trimmed(decoded.success.id); + return Result.succeed( + baseSha === null || headSha === null ? null : { baseSha, headSha, startSha: baseSha }, + ); +} + +function diffHeaderPaths(raw: Schema.Schema.Type): { + readonly from: string; + readonly to: string; +} { + return { + from: raw.new_file === true ? "/dev/null" : `a/${raw.old_path}`, + to: raw.deleted_file === true ? "/dev/null" : `b/${raw.new_path}`, + }; +} + +export interface GitLabMergeRequestPatch { + readonly patch: string; + /** At least one file's hunks were withheld by GitLab as too large to inline. */ + readonly truncated: boolean; + /** Files GitLab returned, counted before decoding, so the caller can page. */ + readonly rawCount: number; +} + +/** + * GitLab returns hunks per file with no `diff --git` header, so the unified patch every diff + * viewer expects is assembled here. This decodes one page; walking pages is the caller's job, + * which is why the raw file count comes back with the patch. + */ +export function decodeMergeRequestDiffsJson( + raw: string, +): Result.Result { + const decoded = decodeUnknownList(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const sections: string[] = []; + let truncated = false; + for (const entry of decoded.success) { + const file = decodeDiffEntry(entry); + if (Exit.isFailure(file)) continue; + const value = file.value; + const hunks = value.diff ?? ""; + if (hunks.length === 0) { + // A file GitLab declined to inline still belongs in the file list, header only. + truncated = truncated || value.too_large === true || value.collapsed === true; + } + const { from, to } = diffHeaderPaths(value); + const header = [ + `diff --git a/${value.old_path} b/${value.new_path}`, + ...(value.new_file === true ? [`new file mode ${value.b_mode ?? "100644"}`] : []), + ...(value.deleted_file === true ? [`deleted file mode ${value.a_mode ?? "100644"}`] : []), + ...(value.renamed_file === true + ? [`rename from ${value.old_path}`, `rename to ${value.new_path}`] + : []), + `--- ${from}`, + `+++ ${to}`, + ].join("\n"); + sections.push(hunks.length === 0 ? header : `${header}\n${hunks.replace(/\n?$/, "\n")}`); + } + return Result.succeed({ + patch: sections.join("\n"), + truncated, + rawCount: decoded.success.length, + }); +} + +/** GitLab's award names for the eight reactions the contract carries. */ +const GITLAB_AWARD_BY_CONTENT: Readonly> = { + "thumbs-up": "thumbsup", + "thumbs-down": "thumbsdown", + laugh: "laughing", + hooray: "tada", + confused: "confused", + heart: "heart", + rocket: "rocket", + eyes: "eyes", +}; + +const CONTENT_BY_GITLAB_AWARD: Readonly> = + Object.fromEntries( + Object.entries(GITLAB_AWARD_BY_CONTENT).map(([content, name]) => [name, content]), + ) as Readonly>; + +export function gitLabAwardName(content: PullRequestReactionContent): string { + return GITLAB_AWARD_BY_CONTENT[content]; +} + +/** + * Awards on the merge request and on every note of it, in one read. The REST notes endpoint the + * conversation comes from carries no award at all, and asking per note would be a request each. + * + * `currentUser` rides along because GitLab names who awarded but never says whether that is the + * reader — so the comparison is made here rather than paid for with a request of its own. + */ +export const AWARD_EMOJI_GRAPHQL_QUERY = `query($fullPath: ID!, $iid: String!, $cursor: String) { + currentUser { username } + project(fullPath: $fullPath) { + mergeRequest(iid: $iid) { + awardEmoji { nodes { name user { username } } } + notes(first: 100, after: $cursor) { + pageInfo { hasNextPage endCursor } + nodes { id awardEmoji { nodes { name user { username } } } } + } + } + } +}`; + +const RawAwardEmojiNodesSchema = Schema.optional( + Schema.NullOr( + Schema.Struct({ + nodes: Schema.optional( + Schema.NullOr( + Schema.Array( + Schema.NullOr( + Schema.Struct({ + name: Schema.optional(Schema.NullOr(Schema.String)), + user: Schema.optional( + Schema.NullOr( + Schema.Struct({ username: Schema.optional(Schema.NullOr(Schema.String)) }), + ), + ), + }), + ), + ), + ), + ), + }), + ), +); + +const RawAwardEmojiPageSchema = Schema.Struct({ + data: Schema.Struct({ + currentUser: Schema.optional( + Schema.NullOr(Schema.Struct({ username: Schema.optional(Schema.NullOr(Schema.String)) })), + ), + project: Schema.NullOr( + Schema.Struct({ + mergeRequest: Schema.NullOr( + Schema.Struct({ + awardEmoji: RawAwardEmojiNodesSchema, + notes: Schema.optional( + Schema.NullOr( + Schema.Struct({ + pageInfo: Schema.optional( + Schema.Struct({ + hasNextPage: Schema.optional(Schema.Boolean), + endCursor: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + nodes: Schema.Array( + Schema.NullOr( + Schema.Struct({ + id: Schema.optional(Schema.NullOr(Schema.String)), + awardEmoji: RawAwardEmojiNodesSchema, + }), + ), + ), + }), + ), + ), + }), + ), + }), + ), + }), +}); + +const decodeAwardEmojiPage = decodeJsonResult(RawAwardEmojiPageSchema); + +/** + * The awards on one subject, grouped the way a reaction pill is drawn. The viewer's own username + * is left out of `actors` — the page names them "You" instead, and leaving it in would name them + * twice — but `count` still counts them along with everyone else. + */ +function toReactions( + nodes: Schema.Schema.Type, + viewer: string | null, +): ReadonlyArray { + const normalizedViewer = viewer?.toLowerCase() ?? null; + const groups = new Map< + PullRequestReactionContent, + { count: number; actors: string[]; viewer: boolean } + >(); + for (const node of nodes?.nodes ?? []) { + // An award outside the eight is left out rather than shown under a name the picker has no + // way to take back: GitLab accepts any emoji, and the other hosts accept none of them. + const content = CONTENT_BY_GITLAB_AWARD[trimmed(node?.name)?.toLowerCase() ?? ""]; + if (content === undefined) continue; + const username = trimmed(node?.user?.username); + if (username === null) continue; + const group = groups.get(content) ?? { count: 0, actors: [], viewer: false }; + group.count++; + if (normalizedViewer !== null && username.toLowerCase() === normalizedViewer) { + group.viewer = true; + } else { + group.actors.push(username); + } + groups.set(content, group); + } + return [...groups].flatMap(([content, group]) => + group.count === 0 + ? [] + : [{ content, count: group.count, actors: group.actors, viewerHasReacted: group.viewer }], + ); +} + +/** `gid://gitlab/DiffNote/42` is note 42, which is the id the REST conversation carries. */ +function noteIdOf(gid: string | null | undefined): string | null { + const id = trimmed(gid)?.split("/").at(-1); + return id !== undefined && /^\d+$/.test(id) ? id : null; +} + +export interface GitLabAwardEmojiPage { + /** The merge request's own awards, which are the ones on its description. */ + readonly reactions: ReadonlyArray; + readonly reactionsByNoteId: ReadonlyMap>; + readonly nextCursor: string | null; +} + +export function decodeAwardEmojiJson( + raw: string, +): Result.Result { + const decoded = decodeAwardEmojiPage(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const data = decoded.success.data; + const viewer = trimmed(data.currentUser?.username); + const mergeRequest = data.project?.mergeRequest; + const reactionsByNoteId = new Map>(); + for (const node of mergeRequest?.notes?.nodes ?? []) { + const id = noteIdOf(node?.id); + if (id === null) continue; + const reactions = toReactions(node?.awardEmoji, viewer); + if (reactions.length > 0) reactionsByNoteId.set(id, reactions); + } + const pageInfo = mergeRequest?.notes?.pageInfo; + return Result.succeed({ + reactions: toReactions(mergeRequest?.awardEmoji, viewer), + reactionsByNoteId, + nextCursor: pageInfo?.hasNextPage === true ? (trimmed(pageInfo.endCursor) ?? null) : null, + }); +} + +const RawAwardSchema = Schema.Struct({ + id: Schema.Int, + name: Schema.optional(Schema.NullOr(Schema.String)), + user: Schema.optional( + Schema.NullOr(Schema.Struct({ username: Schema.optional(Schema.NullOr(Schema.String)) })), + ), +}); + +const decodeAward = Schema.decodeUnknownExit(RawAwardSchema); + +/** + * The reader's own award of one name on a subject, which is what taking a reaction back is + * addressed by: GitLab deletes an award by its id and has no way to name one by its emoji. + */ +export function decodeOwnAwardIdJson( + raw: string, + input: { readonly content: PullRequestReactionContent; readonly viewer: string }, +): Result.Result { + const decoded = decodeUnknownList(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const name = gitLabAwardName(input.content); + for (const entry of decoded.success) { + const award = decodeAward(entry); + if (Exit.isFailure(award)) continue; + const value = award.value; + if (trimmed(value.name)?.toLowerCase() !== name) continue; + if (trimmed(value.user?.username) !== input.viewer) continue; + return Result.succeed(value.id); + } + return Result.succeed(null); +} diff --git a/apps/server/src/pullRequest/http.ts b/apps/server/src/pullRequest/http.ts new file mode 100644 index 000000000000..88756e64accb --- /dev/null +++ b/apps/server/src/pullRequest/http.ts @@ -0,0 +1,23 @@ +import { AuthOrchestrationReadScope, EnvironmentHttpApi } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; + +import { annotateEnvironmentRequest, requireEnvironmentScope } from "../auth/http.ts"; +import * as PullRequestService from "./PullRequestService.ts"; + +/** The patch is often the largest PR payload and benefits from HTTP compression and flow control. */ +export const pullRequestHttpApiLayer = HttpApiBuilder.group( + EnvironmentHttpApi, + "pullRequests", + Effect.fnUntraced(function* (handlers) { + const pullRequests = yield* PullRequestService.PullRequestService; + return handlers.handle( + "diff", + Effect.fn("environment.pullRequests.diff")(function* (args) { + yield* annotateEnvironmentRequest(args.endpoint.name); + yield* requireEnvironmentScope(AuthOrchestrationReadScope); + return yield* pullRequests.diff(args.payload); + }), + ); + }), +); diff --git a/apps/server/src/pullRequest/pullRequestChecks.test.ts b/apps/server/src/pullRequest/pullRequestChecks.test.ts new file mode 100644 index 000000000000..ba6850832924 --- /dev/null +++ b/apps/server/src/pullRequest/pullRequestChecks.test.ts @@ -0,0 +1,93 @@ +import type { PullRequestCheck, PullRequestCheckStatus } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { dedupeChecks } from "./pullRequestChecks.ts"; + +function entry( + name: string, + status: PullRequestCheckStatus, + extra: { readonly workflowName?: string | null; readonly at?: string | null } = {}, +): { + readonly check: PullRequestCheck; + readonly workflowName: string | null; + readonly at: string | null; +} { + return { + check: { name, status, description: null, url: null }, + workflowName: extra.workflowName ?? null, + at: extra.at ?? null, + }; +} + +describe("dedupeChecks", () => { + it("keeps the newest run of a check the host listed twice", () => { + const checks = dedupeChecks([ + entry("Prepare PR size config", "success", { + workflowName: "PR Size", + at: "2026-08-11T16:06:25Z", + }), + entry("Prepare PR size config", "pending", { + workflowName: "PR Size", + at: "2026-08-11T17:01:04Z", + }), + ]); + + expect(checks.map((check) => [check.name, check.status])).toEqual([ + ["Prepare PR size config", "pending"], + ]); + }); + + it("holds a check at the place it first appeared, so a re-run does not reshuffle the list", () => { + const checks = dedupeChecks([ + entry("lint", "success", { workflowName: "CI", at: "2026-08-11T16:00:00Z" }), + entry("test", "success", { workflowName: "CI", at: "2026-08-11T16:00:00Z" }), + entry("lint", "failure", { workflowName: "CI", at: "2026-08-11T18:00:00Z" }), + ]); + + expect(checks.map((check) => [check.name, check.status])).toEqual([ + ["lint", "failure"], + ["test", "success"], + ]); + }); + + it("loses a run that never said when it happened to one that did, whichever came first", () => { + const undated = dedupeChecks([ + entry("build", "success", { at: "2026-08-11T16:00:00Z" }), + entry("build", "pending"), + ]); + const dated = dedupeChecks([ + entry("build", "pending"), + entry("build", "success", { at: "2026-08-11T16:00:00Z" }), + ]); + + expect([undated[0]?.status, dated[0]?.status]).toEqual(["success", "success"]); + }); + + it("takes the last copy when neither run is dated, which is how an update is listed", () => { + const checks = dedupeChecks([entry("build", "pending"), entry("build", "failure")]); + + expect(checks.map((check) => check.status)).toEqual(["failure"]); + }); + + it("keeps two workflows that name a job the same thing, and says which is which", () => { + const checks = dedupeChecks([ + entry("build", "success", { workflowName: "CI", at: "2026-08-11T16:00:00Z" }), + entry("build", "failure", { workflowName: "Release", at: "2026-08-11T16:00:00Z" }), + ]); + + expect(checks.map((check) => [check.name, check.status])).toEqual([ + ["CI / build", "success"], + ["Release / build", "failure"], + ]); + }); + + it("leaves a colliding check with no workflow of its own unqualified", () => { + // An app-provided check run belongs to no workflow, which GitHub reports as an empty name. + const checks = dedupeChecks([ + entry("build", "success", { workflowName: "", at: "2026-08-11T16:00:00Z" }), + entry("build", "failure", { workflowName: "CI", at: "2026-08-11T16:00:00Z" }), + ]); + + expect(checks.map((check) => check.name)).toEqual(["build", "CI / build"]); + }); +}); diff --git a/apps/server/src/pullRequest/pullRequestChecks.ts b/apps/server/src/pullRequest/pullRequestChecks.ts new file mode 100644 index 000000000000..819cf47b01af --- /dev/null +++ b/apps/server/src/pullRequest/pullRequestChecks.ts @@ -0,0 +1,55 @@ +import type { PullRequestCheck } from "@t3tools/contracts"; + +/** ISO-8601 timestamps in UTC compare correctly as plain text, which is all the ordering needs. */ +function isAtLeastAsNew(candidate: string | null, kept: string | null): boolean { + if (candidate === null) return kept === null; + return kept === null || candidate >= kept; +} + +/** + * One row per check rather than one per run of it. + * + * A host's rollup is a list of runs, not a list of checks: while a workflow is being re-run — or + * while a second run of it is already live — the same check arrives twice, and both copies reach + * the reader as what looks like a duplicate. Nothing in a check carries an id, so the name is what + * identifies it, qualified by the workflow it belongs to since two workflows are free to name a + * job the same thing. + * + * Within a group the newest run is the one worth showing: a re-run is the answer that replaces the + * one before it, and a run that never said when it happened loses to one that did. A tie goes to + * whichever came last, because a host lists a re-run after the run it repeats. + * + * The order is the host's own, held at the place each check first appeared, so a re-run landing + * mid-read replaces a row where it stands instead of reshuffling the list under the reader. + * + * Two checks that survive under the same name are then genuinely different ones, since they came + * from different workflows — each is shown as `workflow / name`, the way GitHub writes it itself. + * A survivor whose workflow the host did not name keeps its bare name rather than being qualified + * with nothing. + */ +export function dedupeChecks( + entries: ReadonlyArray<{ + readonly check: PullRequestCheck; + readonly workflowName: string | null; + readonly at: string | null; + }>, +): ReadonlyArray { + const newestByCheck = new Map(); + for (const entry of entries) { + const key = `${entry.workflowName ?? ""} ${entry.check.name}`; + const kept = newestByCheck.get(key); + // Re-setting a key a Map already holds keeps its first position, which is the order wanted. + if (kept === undefined || isAtLeastAsNew(entry.at, kept.at)) newestByCheck.set(key, entry); + } + const survivors = [...newestByCheck.values()]; + const countsByName = new Map(); + for (const entry of survivors) { + countsByName.set(entry.check.name, (countsByName.get(entry.check.name) ?? 0) + 1); + } + return survivors.map((entry) => { + const workflowName = entry.workflowName ?? ""; + return workflowName.length > 0 && (countsByName.get(entry.check.name) ?? 0) > 1 + ? { ...entry.check, name: `${workflowName} / ${entry.check.name}` } + : entry.check; + }); +} diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index 58de98f1ca16..2a4de7eda911 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -44,6 +44,7 @@ import { getOrCreateEnvironmentKeyPairFromSecretStore } from "../cloud/environme import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEngine.ts"; import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { forkParked } from "../serverActivation.ts"; export class AgentAwarenessRelay extends Context.Service< AgentAwarenessRelay, @@ -599,12 +600,12 @@ export const make = Effect.gen(function* () { }); break; } - yield* Effect.forkScoped( + yield* forkParked( Effect.sleep("1 second").pipe( Effect.andThen(publishActiveThreadsOnceWhenConfigured(startupState !== "enabled")), ), ); - yield* Effect.forkScoped( + yield* forkParked( Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => { const threadId = eventThreadId(event); if (threadId === null) { diff --git a/apps/server/src/resourceTelemetry/ResourceMonitorBinary.test.ts b/apps/server/src/resourceTelemetry/ResourceMonitorBinary.test.ts index 4c3afa97abfa..243556b6e3b0 100644 --- a/apps/server/src/resourceTelemetry/ResourceMonitorBinary.test.ts +++ b/apps/server/src/resourceTelemetry/ResourceMonitorBinary.test.ts @@ -4,7 +4,7 @@ import { HostProcessEnvironment, HostProcessPlatform, } from "@t3tools/shared/hostProcess"; -import { assert, describe, it } from "@effect/vitest"; +import { afterEach, assert, describe, expect, it, vi } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -12,6 +12,36 @@ import { ServerConfig } from "../config.ts"; import * as ResourceMonitorBinary from "./ResourceMonitorBinary.ts"; describe("ResourceMonitorBinary", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it.effect("skips Linux libc detection on Windows", () => + Effect.gen(function* () { + const getReport = vi.spyOn(process.report, "getReport").mockImplementation(() => { + throw new Error("Linux libc detection must not run on Windows"); + }); + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-resource-monitor-binary-", + }); + const binaryPath = `${baseDir}/t3-resource-monitor.exe`; + yield* fileSystem.writeFileString(binaryPath, "binary"); + + const service = yield* ResourceMonitorBinary.make().pipe( + Effect.provide(ServerConfig.layerTest(process.cwd(), baseDir)), + Effect.provideService(HostProcessPlatform, "win32"), + Effect.provideService(HostProcessArchitecture, "arm64"), + Effect.provideService(HostProcessEnvironment, { + T3CODE_RESOURCE_MONITOR_PATH: binaryPath, + }), + ); + + assert.equal(yield* service.resolve, binaryPath); + expect(getReport).not.toHaveBeenCalled(); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.effect("resolves an executable override", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/resourceTelemetry/ResourceMonitorBinary.ts b/apps/server/src/resourceTelemetry/ResourceMonitorBinary.ts index 1f14df518660..c93bc54a1fba 100644 --- a/apps/server/src/resourceTelemetry/ResourceMonitorBinary.ts +++ b/apps/server/src/resourceTelemetry/ResourceMonitorBinary.ts @@ -106,7 +106,7 @@ export function resourceMonitorPlatformKey( export function resourceMonitorRustTarget( platform: NodeJS.Platform, architecture: NodeJS.Architecture, - linuxLibc: ResourceMonitorLinuxLibc, + linuxLibc?: ResourceMonitorLinuxLibc, ): string | undefined { if (platform === "darwin") { return architecture === "arm64" @@ -142,7 +142,7 @@ export const make = Effect.fn("resourceTelemetry.resourceMonitorBinary.make")(fu const platform = yield* HostProcessPlatform; const architecture = yield* HostProcessArchitecture; const environment = yield* HostProcessEnvironment; - const linuxLibc = yield* ResourceMonitorHostLinuxLibc; + const linuxLibc = platform === "linux" ? yield* ResourceMonitorHostLinuxLibc : undefined; const executableName = binaryName(platform); const platformKey = resourceMonitorPlatformKey(platform, architecture); const rustTarget = resourceMonitorRustTarget(platform, architecture, linuxLibc); diff --git a/apps/server/src/review/ReviewService.test.ts b/apps/server/src/review/ReviewService.test.ts index 839eb73b2bb7..01a4692264e0 100644 --- a/apps/server/src/review/ReviewService.test.ts +++ b/apps/server/src/review/ReviewService.test.ts @@ -57,6 +57,39 @@ describe("ReviewService", () => { }).pipe(Effect.provide(NodeServices.layer)), ); + it.effect("attributes file-content workspace violations to the file-content operation", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const workspaceRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-workspace-" }); + const outsideRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-outside-" }); + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-base-" }); + const detectCalls: Array<{ readonly cwd: string }> = []; + + const error = yield* Effect.gen(function* () { + const review = yield* ReviewService.ReviewService; + return yield* review + .getDiffFileContents({ + cwd: outsideRoot, + sourceKind: "working-tree", + changeType: "change", + baseRef: "HEAD", + headRef: null, + oldPath: "file.ts", + newPath: "file.ts", + }) + .pipe(Effect.flip); + }).pipe(Effect.provide(makeLayer({ workspaceRoot, baseDir, detectCalls }))); + + assert.strictEqual(error._tag, "VcsRepositoryDetectionError"); + assert.strictEqual(error.operation, "ReviewService.getDiffFileContents"); + assert.match( + "detail" in error ? error.detail : "", + /must stay within the configured workspace root/, + ); + assert.deepStrictEqual(detectCalls, []); + }).pipe(Effect.provide(NodeServices.layer)), + ); + it.effect("allows diff preview cwd inside the configured workspace root", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/review/ReviewService.ts b/apps/server/src/review/ReviewService.ts index db1dc5bc8d2f..275dcb416610 100644 --- a/apps/server/src/review/ReviewService.ts +++ b/apps/server/src/review/ReviewService.ts @@ -8,6 +8,8 @@ import * as Path from "effect/Path"; import { VcsRepositoryDetectionError, VcsUnsupportedOperationError, + type ReviewDiffFileContentsInput, + type ReviewDiffFileContentsResult, type ReviewDiffPreviewError, type ReviewDiffPreviewInput, type ReviewDiffPreviewResult, @@ -23,6 +25,9 @@ export class ReviewService extends Context.Service< readonly getDiffPreview: ( input: ReviewDiffPreviewInput, ) => Effect.Effect; + readonly getDiffFileContents: ( + input: ReviewDiffFileContentsInput, + ) => Effect.Effect; } >()("t3/review/ReviewService") {} @@ -58,6 +63,7 @@ export const make = Effect.gen(function* () { }; const assertWorkspaceBoundCwd = Effect.fn("ReviewService.assertWorkspaceBoundCwd")(function* ( + operation: "ReviewService.getDiffPreview" | "ReviewService.getDiffFileContents", cwd: string, ) { const [candidate, workspaceRoot, worktreesRoot] = yield* Effect.all([ @@ -71,16 +77,19 @@ export const make = Effect.gen(function* () { } return yield* new VcsRepositoryDetectionError({ - operation: "ReviewService.getDiffPreview", + operation, cwd, - detail: "Review diff preview cwd must stay within the configured workspace root.", + detail: + operation === "ReviewService.getDiffPreview" + ? "Review diff preview cwd must stay within the configured workspace root." + : "Review diff file contents cwd must stay within the configured workspace root.", }); }); const getDiffPreview: ReviewService["Service"]["getDiffPreview"] = Effect.fn( "ReviewService.getDiffPreview", )(function* (input) { - yield* assertWorkspaceBoundCwd(input.cwd); + yield* assertWorkspaceBoundCwd("ReviewService.getDiffPreview", input.cwd); const handle = yield* vcsRegistry.detect({ cwd: input.cwd, requestedKind: "auto" }); if (!handle) { @@ -106,8 +115,26 @@ export const make = Effect.gen(function* () { return yield* getDriverDiffPreview(input); }); + const getDiffFileContents: ReviewService["Service"]["getDiffFileContents"] = Effect.fn( + "ReviewService.getDiffFileContents", + )(function* (input) { + yield* assertWorkspaceBoundCwd("ReviewService.getDiffFileContents", input.cwd); + + const handle = yield* vcsRegistry.detect({ cwd: input.cwd, requestedKind: "auto" }); + if (handle?.kind !== "git") { + return yield* new VcsUnsupportedOperationError({ + operation: "ReviewService.getDiffFileContents", + kind: handle?.kind ?? "unknown", + detail: "Unchanged diff expansion currently requires a Git repository.", + }); + } + + return yield* git.getReviewDiffFileContents(input); + }); + return ReviewService.of({ getDiffPreview, + getDiffFileContents, }); }); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index fff71dbb4e7b..3f63eb4dbef7 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -2,7 +2,7 @@ import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as NodeSocket from "@effect/platform-node/NodeSocket"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodeCrypto from "node:crypto"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { AuthAccessTokenType, @@ -16,6 +16,8 @@ import { KeybindingRule, MessageId, ExternalLauncherCommandNotFoundError, + OrchestrationThreadDetailSnapshot, + type OrchestrationThreadStreamItem, type OrchestrationThreadShell, TerminalNotRunningError, type OrchestrationCommand, @@ -41,6 +43,7 @@ import * as RelayClient from "@t3tools/shared/relayClient"; import { assert, it } from "@effect/vitest"; import { assertFailure, assertInclude, assertTrue } from "@effect/vitest/utils"; import * as Clock from "effect/Clock"; +import * as Config from "effect/Config"; import * as Deferred from "effect/Deferred"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; @@ -52,6 +55,8 @@ import * as ManagedRuntime from "effect/ManagedRuntime"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as PubSub from "effect/PubSub"; +import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import { ChildProcessSpawner } from "effect/unstable/process"; @@ -70,12 +75,34 @@ import * as Socket from "effect/unstable/socket/Socket"; import { vi } from "vite-plus/test"; const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); +const decodeTransferThreadSnapshot = Schema.decodeUnknownEffect( + Schema.fromJsonString(OrchestrationThreadDetailSnapshot), +); + +const collectQueueUntil = Effect.fn("TransferBudget.collectQueueUntil")(function* ( + queue: Queue.Queue, + predicate: (value: A) => boolean, + waitDescription: string, +) { + return yield* Effect.gen(function* () { + const values: A[] = []; + while (true) { + const value = yield* Queue.take(queue); + values.push(value); + if (predicate(value)) return values; + } + }).pipe( + Effect.timeoutOrElse({ + duration: "10 seconds", + orElse: () => Effect.die(new Error(`Timed out waiting for ${waitDescription}`)), + }), + ); +}); import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; import * as ServerConfig from "./config.ts"; -import * as HttpResponseCompression from "./httpCompression/HttpResponseCompression.ts"; import { makeRoutesLayer } from "./server.ts"; -import { resolveAvailableEditorsForConfig } from "./ws.ts"; +import { isThreadDetailEvent, resolveAvailableEditorsForConfig } from "./ws.ts"; import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as GitManager from "./git/GitManager.ts"; import * as Keybindings from "./keybindings.ts"; @@ -89,6 +116,7 @@ import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; import { makeManualOnlyProviderMaintenanceCapabilities } from "./provider/providerMaintenance.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; +import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts"; import * as ServerSettings from "./serverSettings.ts"; import * as TerminalManager from "./terminal/Manager.ts"; import * as PreviewManager from "./preview/Manager.ts"; @@ -121,8 +149,35 @@ import * as DesktopTelemetryReceiver from "./resourceTelemetry/DesktopTelemetryR import * as NativeTelemetryClient from "./resourceTelemetry/NativeTelemetryClient.ts"; import * as ResourceAttribution from "./resourceTelemetry/ResourceAttribution.ts"; import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; +import * as UsageService from "./usage/UsageService.ts"; import * as Data from "effect/Data"; +import { makeOrchestrationIntegrationHarness } from "../integration/OrchestrationEngineHarness.integration.ts"; +import { + countingWsRpcProtocolLayer, + makeCountingWsRpcClient, + makeWebSocketTransferRecorder, + measureHttpGet, + transferDelta, +} from "../integration/NetworkTransferMeasurement.integration.ts"; +import { + expectedMeasuredAssistantText, + queueMeasuredTransferTurn, + seedTransferBudgetHistory, + TRANSFER_HISTORY_TURN_COUNT, + TRANSFER_MEASURED_TURN_CREATED_AT, + TRANSFER_MEASURED_TURN_INDEX, + TRANSFER_THREAD_ID, + transferModelSelection, + waitForTurnQuiesced, +} from "../integration/TransferBudgetScenario.integration.ts"; +import { + formatTransferBudgetReport, + formatTransferBudgetResult, + type TransferBudgetRun, + transferBudgetViolations, +} from "../integration/TransferBudgetReport.integration.ts"; + const defaultProjectId = ProjectId.make("project-default"); const defaultThreadId = ThreadId.make("thread-default"); const defaultDesktopBootstrapToken = "test-desktop-bootstrap-token"; @@ -549,11 +604,17 @@ const buildAppUnderTest = (options?: { ), ), ); + const serviceLauncherClientLayer = ServiceLauncherClient.layer.pipe( + Layer.provide(Layer.succeed(HostProcessEnvironment, {})), + ); - const servedRoutesLayer = HttpRouter.serve(makeRoutesLayer, { - disableListenLog: true, - disableLogger: true, - }).pipe( + const servedRoutesLayer = HttpRouter.serve( + makeRoutesLayer.pipe(Layer.provide(serviceLauncherClientLayer)), + { + disableListenLog: true, + disableLogger: true, + }, + ).pipe( Layer.provide( Layer.mock(Keybindings.Keybindings)({ loadConfigState: Effect.succeed({ @@ -765,6 +826,7 @@ const buildAppUnderTest = (options?: { const appLayer = servedRoutesLayer.pipe( Layer.provide(resourceTelemetryLayer), + Layer.provide(UsageService.layerTest), Layer.provide( Layer.mock(BrowserTraceCollector.BrowserTraceCollector)({ record: () => Effect.void, @@ -888,7 +950,6 @@ const buildAppUnderTest = (options?: { Layer.provideMerge(ServerSecretStore.layer), Layer.provide(workspaceAndProjectServicesLayer), Layer.provideMerge(FetchHttpClient.layer), - Layer.provide(HttpResponseCompression.layerNode), Layer.provide(layerConfig), ); @@ -1317,7 +1378,62 @@ const getWsServerUrl = ( ); }); +// Mirrors NodeHttpServer.layerTest, which does not expose server options, +// with the production `websocket: { perMessageDeflate: true }` setting. +const NodeHttpServerTestWithWsDeflate = HttpServer.layerTestClient.pipe( + Layer.provide( + Layer.fresh(FetchHttpClient.layer).pipe( + Layer.provide(Layer.succeed(FetchHttpClient.RequestInit)({ keepalive: false })), + ), + ), + Layer.provideMerge( + Layer.unwrap( + Effect.map( + Effect.promise(() => import("node:http")), + (NodeHttp) => + NodeHttpServer.layer(NodeHttp.createServer, { + port: 0, + websocket: { perMessageDeflate: true }, + }), + ), + ), + ), +); + it.layer(NodeServices.layer)("server router seam", (it) => { + it.effect("parks HTTP ingress until command readiness", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const staticDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-router-gate-" }); + yield* fileSystem.writeFileString(path.join(staticDir, "index.html"), "ready"); + const entered = yield* Deferred.make(); + const ready = yield* Deferred.make(); + const completed = yield* Deferred.make(); + + yield* buildAppUnderTest({ + config: { staticDir }, + layers: { + serverRuntimeStartup: { + awaitCommandReady: Deferred.succeed(entered, undefined).pipe( + Effect.andThen(Deferred.await(ready)), + ), + }, + }, + }); + const request = yield* HttpClient.get("/").pipe( + Effect.tap(() => Deferred.succeed(completed, undefined)), + Effect.forkChild, + ); + yield* Deferred.await(entered); + assert.isFalse(yield* Deferred.isDone(completed)); + + yield* Deferred.succeed(ready, undefined); + assert.equal((yield* Fiber.join(request)).status, 200); + assert.isTrue(yield* Deferred.isDone(completed)); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("serves static index content for GET / when staticDir is configured", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -3209,7 +3325,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const plain = yield* openSocket(false); assert.notInclude(plain.extensions, "permessage-deflate"); - }).pipe(Effect.scoped, Effect.provide(NodeHttpServer.layerTest)), + }).pipe(Effect.scoped, Effect.provide(NodeHttpServerTestWithWsDeflate)), ); it.effect("issues short-lived websocket tickets for authenticated bearer sessions", () => @@ -5117,6 +5233,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }, branch: "feature/demo", worktreePath: null, + isOnPullRequestHead: true, }), }, gitVcsDriver: { @@ -5192,6 +5309,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }, ], }), + getDiffFileContents: () => + Effect.succeed({ + oldContents: "before\n", + newContents: "after\n", + }), }, }, }); @@ -5306,6 +5428,22 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ), ); assert.equal(diffPreview.sources[0]?.diff, "dirty-diff"); + + const diffFileContents = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.reviewGetDiffFileContents]({ + cwd: "/tmp/repo", + sourceKind: "working-tree", + changeType: "change", + baseRef: "HEAD", + headRef: null, + oldPath: "README.md", + newPath: "README.md", + }), + ), + ); + assert.equal(diffFileContents.oldContents, "before\n"); + assert.equal(diffFileContents.newContents, "after\n"); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); @@ -6005,6 +6143,149 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), ); + it.effect("subscribeThread sends a fresh snapshot instead of replaying a large gap", () => + Effect.gen(function* () { + let readEventsCalls = 0; + const thread = makeDefaultOrchestrationReadModel().threads[0]!; + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + // Head is far ahead of the client's afterSequence (gap > 1000). + latestSequence: Effect.succeed(100_000), + readEvents: () => + Stream.sync(() => { + readEventsCalls += 1; + return {} as OrchestrationEvent; + }), + }, + projectionSnapshotQuery: { + getThreadDetailSnapshot: () => + Effect.succeed(Option.some({ snapshotSequence: 100_000, thread })), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const items = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeThread]({ + threadId: defaultThreadId, + afterSequence: 5, + requestCompletionMarker: true, + }).pipe(Stream.take(2), Stream.runCollect), + ), + ); + + const [first, second] = Array.from(items); + // Large gap => fresh thread snapshot, and the global replay never starts. + assert.equal(first?.kind, "snapshot"); + if (first?.kind === "snapshot") { + assert.equal(first.snapshot.thread.id, defaultThreadId); + assert.equal(first.snapshot.snapshotSequence, 100_000); + } + assert.equal(second?.kind, "synchronized"); + assert.equal(readEventsCalls, 0); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("subscribeThread replaces a cursor ahead of the authoritative head", () => + Effect.gen(function* () { + let readEventsCalls = 0; + const thread = makeDefaultOrchestrationReadModel().threads[0]!; + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + latestSequence: Effect.succeed(5), + readEvents: () => + Stream.sync(() => { + readEventsCalls += 1; + return {} as OrchestrationEvent; + }), + }, + projectionSnapshotQuery: { + getThreadDetailSnapshot: () => + Effect.succeed(Option.some({ snapshotSequence: 5, thread })), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const first = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeThread]({ + threadId: defaultThreadId, + afterSequence: 10, + }).pipe(Stream.runHead), + ), + ); + + assert.equal(Option.getOrThrow(first).kind, "snapshot"); + assert.equal(readEventsCalls, 0); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("subscribeThread bounds catch-up replay to the captured head", () => + Effect.gen(function* () { + let replayLimit: number | undefined; + const now = "2026-01-01T00:00:00.000Z"; + const messageEvent = { + sequence: 3, + eventId: EventId.make("event-replay-message"), + aggregateKind: "thread", + aggregateId: defaultThreadId, + occurredAt: now, + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.message-sent", + payload: { + threadId: defaultThreadId, + messageId: MessageId.make("message-replay"), + role: "user", + text: "Replayed message", + turnId: null, + streaming: false, + createdAt: now, + updatedAt: now, + }, + } satisfies Extract; + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + latestSequence: Effect.succeed(50), + readEvents: (_afterSequence, limit) => { + replayLimit = limit; + return Stream.make(messageEvent); + }, + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const items = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeThread]({ + threadId: defaultThreadId, + afterSequence: 0, + requestCompletionMarker: true, + }).pipe(Stream.take(2), Stream.runCollect), + ), + ); + + const [first, second] = Array.from(items); + assert.equal(first?.kind, "event"); + assert.equal(first?.kind === "event" ? first.event.sequence : null, 3); + assert.equal(second?.kind, "synchronized"); + // The replay is bounded to the head captured before the read, not + // Number.MAX_SAFE_INTEGER. + assert.equal(replayLimit, 50); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("subscribeShell sends a fresh snapshot instead of replaying a large gap", () => Effect.gen(function* () { let readEventsCalls = 0; @@ -6740,6 +7021,126 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("stops the provider session after settle without closing terminals", () => + Effect.gen(function* () { + const threadId = ThreadId.make("thread-settle"); + const effects: string[] = []; + const dispatchedCommands: Array = []; + const now = "2026-01-01T00:00:00.000Z"; + + yield* buildAppUnderTest({ + layers: { + terminalManager: { + close: (input) => + Effect.sync(() => { + effects.push(`terminal.close:${input.threadId}`); + }), + }, + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + dispatchedCommands.push(command); + effects.push(`dispatch:${command.type}`); + return { sequence: dispatchedCommands.length }; + }), + }, + projectionSnapshotQuery: { + getThreadShellById: () => + Effect.succeed( + Option.some( + makeDefaultOrchestrationThreadShell({ + id: threadId, + updatedAt: now, + session: { + threadId, + status: "ready", + providerName: "claudeAgent", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + }), + ), + ), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const dispatchResult = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.settle", + commandId: CommandId.make("cmd-thread-settle"), + threadId, + }), + ), + ); + + assert.equal(dispatchResult.sequence, 1); + assert.deepEqual(effects, ["dispatch:thread.settle", "dispatch:thread.session.stop"]); + const sessionStopCommand = dispatchedCommands[1]; + assert.equal(sessionStopCommand?.type, "thread.session.stop"); + if (sessionStopCommand?.type === "thread.session.stop") { + assert.equal(sessionStopCommand.threadId, threadId); + assert.equal(sessionStopCommand.commandId, "session-stop-for-settle:cmd-thread-settle"); + assert.equal(sessionStopCommand.onlyIfSettled, true); + } + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("settles without dispatching session stop when the thread has no session", () => + Effect.gen(function* () { + const threadId = ThreadId.make("thread-settle-no-session"); + const effects: string[] = []; + const dispatchedCommands: Array = []; + + yield* buildAppUnderTest({ + layers: { + terminalManager: { + close: (input) => + Effect.sync(() => { + effects.push(`terminal.close:${input.threadId}`); + }), + }, + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + dispatchedCommands.push(command); + effects.push(`dispatch:${command.type}`); + return { sequence: dispatchedCommands.length }; + }), + }, + projectionSnapshotQuery: { + getThreadShellById: () => + Effect.succeed( + Option.some(makeDefaultOrchestrationThreadShell({ id: threadId, session: null })), + ), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const dispatchResult = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.settle", + commandId: CommandId.make("cmd-thread-settle-no-session"), + threadId, + }), + ), + ); + + assert.equal(dispatchResult.sequence, 1); + assert.deepEqual(effects, ["dispatch:thread.settle"]); + assert.deepEqual( + dispatchedCommands.map((command) => command.type), + ["thread.settle"], + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("archives and still closes terminals when session stop fails", () => Effect.gen(function* () { const threadId = ThreadId.make("thread-archive-stop-failure"); @@ -6913,6 +7314,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => { pr: null, }), ); + const remoteExists = vi.fn( + (_: Parameters[0]) => + Effect.sync(() => { + bootstrapGitOperations.push("remote-exists"); + return true; + }), + ); const fetchRemote = vi.fn( (_: Parameters[0]) => Effect.sync(() => { @@ -6960,6 +7368,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ layers: { gitVcsDriver: { + remoteExists, fetchRemote, resolveRemoteTrackingCommit, createWorktree, @@ -7050,6 +7459,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { fallbackRemoteName: "origin", }); assert.deepEqual(bootstrapGitOperations, [ + "remote-exists", "fetch", "resolve-remote-commit", "create-worktree", @@ -7078,6 +7488,110 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect( + "falls back to the local base branch when startFromOrigin is set but no origin remote exists", + () => + Effect.gen(function* () { + const dispatchedCommands: Array = []; + const remoteExists = vi.fn( + (_: Parameters[0]) => + Effect.succeed(false), + ); + const fetchRemote = vi.fn( + (_: Parameters[0]) => Effect.void, + ); + const resolveRemoteTrackingCommit = vi.fn( + (_: Parameters[0]) => + Effect.succeed({ + commitSha: "0123456789abcdef0123456789abcdef01234567", + remoteRefName: "origin/main", + }), + ); + const createWorktree = vi.fn( + (_: Parameters[0]) => + Effect.succeed({ + worktree: { + refName: "t3code/bootstrap-refName", + path: "/tmp/bootstrap-worktree", + }, + }), + ); + + yield* buildAppUnderTest({ + layers: { + gitVcsDriver: { + remoteExists, + fetchRemote, + resolveRemoteTrackingCommit, + createWorktree, + }, + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + dispatchedCommands.push(command); + return { sequence: dispatchedCommands.length }; + }), + readEvents: () => Stream.empty, + }, + }, + }); + + const createdAt = "2026-01-01T00:00:00.000Z"; + const wsUrl = yield* getWsServerUrl("/ws"); + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-bootstrap-turn-start-no-origin"), + threadId: ThreadId.make("thread-bootstrap-no-origin"), + message: { + messageId: MessageId.make("msg-bootstrap-no-origin"), + role: "user", + text: "hello", + attachments: [], + }, + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + bootstrap: { + createThread: { + projectId: defaultProjectId, + title: "Bootstrap Thread", + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + createdAt, + }, + prepareWorktree: { + projectCwd: "/tmp/project", + baseBranch: "main", + branch: "t3code/bootstrap-refName", + startFromOrigin: true, + }, + }, + createdAt, + }), + ), + ); + + assert.deepEqual(remoteExists.mock.calls[0]?.[0], { + cwd: "/tmp/project", + remoteName: "origin", + }); + assert.equal(fetchRemote.mock.calls.length, 0); + assert.equal(resolveRemoteTrackingCommit.mock.calls.length, 0); + assert.deepEqual(createWorktree.mock.calls[0]?.[0], { + cwd: "/tmp/project", + refName: "main", + newRefName: "t3code/bootstrap-refName", + baseRefName: "main", + path: null, + }); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("records setup-script failures without aborting bootstrap turn start", () => Effect.gen(function* () { const dispatchedCommands: Array = []; @@ -7505,3 +8019,167 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); }); + +it.live( + "reports thread HTTP and WebSocket transfer budgets", + () => + Effect.gen(function* () { + const providers = [ + ProviderDriverKind.make("codex"), + ProviderDriverKind.make("claudeAgent"), + ] as const; + + const runs = yield* Effect.forEach( + providers, + (provider) => + Effect.acquireUseRelease( + makeOrchestrationIntegrationHarness({ provider }), + (harness) => + Effect.gen(function* () { + yield* seedTransferBudgetHistory(harness, provider); + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: harness.engine, + projectionSnapshotQuery: harness.snapshotQuery, + }, + }); + + const baseUrl = yield* getHttpServerUrl(); + const cookie = yield* getAuthenticatedSessionCookieHeader(); + + const recorder = makeWebSocketTransferRecorder(); + const wsUrl = baseUrl.replace(/^http:/, "ws:") + "/ws"; + const protocolLayer = countingWsRpcProtocolLayer({ + url: wsUrl, + cookie, + recorder, + }); + + return yield* Effect.scoped( + Effect.gen(function* () { + const client = yield* makeCountingWsRpcClient; + + const threadSnapshot = yield* measureHttpGet({ + url: `${baseUrl}/api/orchestration/threads/${TRANSFER_THREAD_ID}`, + headers: { cookie }, + }); + assert.equal(threadSnapshot.status, 200); + assert.equal(threadSnapshot.contentEncoding, "gzip"); + const decodedThread = yield* decodeTransferThreadSnapshot( + Buffer.from(threadSnapshot.decodedBody).toString("utf8"), + ); + assert.equal( + decodedThread.thread.messages.length, + TRANSFER_HISTORY_TURN_COUNT * 2, + ); + + const threadItems = yield* Queue.unbounded(); + yield* client[ORCHESTRATION_WS_METHODS.subscribeThread]({ + threadId: TRANSFER_THREAD_ID, + afterSequence: decodedThread.snapshotSequence, + requestCompletionMarker: true, + }).pipe( + Stream.runForEach((item) => + Queue.offer(threadItems, item).pipe(Effect.asVoid), + ), + Effect.forkScoped, + ); + const initialThreadItems = yield* collectQueueUntil( + threadItems, + (item) => item.kind === "synchronized", + `${provider} thread subscription to synchronize`, + ); + assert.isFalse(initialThreadItems.some((item) => item.kind === "snapshot")); + assert.include(recorder.negotiatedExtensions(), "permessage-deflate"); + + yield* queueMeasuredTransferTurn(harness, provider); + const turnStartTotals = recorder.totals(); + yield* client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.start", + commandId: CommandId.make(`transfer:${provider}:measured-turn`), + threadId: TRANSFER_THREAD_ID, + message: { + messageId: MessageId.make("transfer-user-measured"), + role: "user", + text: "Measure the client-bound transfer for this turn.", + attachments: [], + }, + modelSelection: transferModelSelection(provider), + runtimeMode: "approval-required", + interactionMode: "default", + createdAt: TRANSFER_MEASURED_TURN_CREATED_AT, + }); + yield* waitForTurnQuiesced(harness, TRANSFER_MEASURED_TURN_INDEX + 1); + const finalThreadSequence = yield* harness.engine + .readEvents(decodedThread.snapshotSequence, 10_000) + .pipe( + Stream.runFold( + () => decodedThread.snapshotSequence, + (sequence, event) => + event.aggregateId === TRANSFER_THREAD_ID && isThreadDetailEvent(event) + ? Math.max(sequence, event.sequence) + : sequence, + ), + ); + assert.isAbove(finalThreadSequence, decodedThread.snapshotSequence); + + yield* collectQueueUntil( + threadItems, + (item) => + item.kind === "event" && item.event.sequence === finalThreadSequence, + `${provider} thread stream to reach sequence ${finalThreadSequence}`, + ); + const measuredTurnWebSocket = transferDelta(turnStartTotals, recorder.totals()); + + const finalThreadSnapshot = yield* harness.snapshotQuery + .getThreadDetailSnapshot(TRANSFER_THREAD_ID) + .pipe(Effect.map(Option.getOrThrow)); + const expectedAssistantText = expectedMeasuredAssistantText(provider); + const measuredAssistant = finalThreadSnapshot.thread.messages.find( + (message) => + message.role === "assistant" && message.text === expectedAssistantText, + ); + assert.isDefined(measuredAssistant); + assert.isTrue( + finalThreadSnapshot.thread.messages.length >= TRANSFER_HISTORY_TURN_COUNT * 2, + ); + assert.equal(measuredAssistant?.streaming, false); + assert.equal(finalThreadSnapshot.thread.session?.status, "ready"); + assert.equal( + finalThreadSnapshot.thread.checkpoints.length, + TRANSFER_HISTORY_TURN_COUNT + 1, + ); + + return { + provider, + threadSnapshot, + measuredTurnWebSocket, + } satisfies TransferBudgetRun; + }).pipe(Effect.provide(protocolLayer)), + ); + }), + (harness) => harness.dispose, + ).pipe(Effect.provide(NodeHttpServerTestWithWsDeflate)), + { concurrency: 1 }, + ); + + const report = formatTransferBudgetReport(runs); + yield* Effect.logInfo(`\n${report}`); + const reportPath = yield* Config.string("T3CODE_TRANSFER_BUDGET_REPORT_PATH").pipe( + Config.option, + ); + if (Option.isSome(reportPath)) { + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.writeFileString(reportPath.value, report); + } + const resultPath = yield* Config.string("T3CODE_TRANSFER_BUDGET_RESULT_PATH").pipe( + Config.option, + ); + if (Option.isSome(resultPath)) { + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.writeFileString(resultPath.value, formatTransferBudgetResult(runs)); + } + assert.deepEqual(transferBudgetViolations(runs), []); + }).pipe(Effect.provide(NodeServices.layer)), + 120_000, +); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 853bb0b11013..32bcaaa8b96b 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -1,5 +1,6 @@ import { EnvironmentHttpApi } from "@t3tools/contracts"; import * as Duration from "effect/Duration"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schedule from "effect/Schedule"; @@ -9,7 +10,6 @@ import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; import * as HostPowerMonitor from "./background/HostPowerMonitor.ts"; import * as ServerConfig from "./config.ts"; -import * as HttpResponseCompression from "./httpCompression/HttpResponseCompression.ts"; import { otlpTracesProxyRouteLayer, assetRouteLayer, @@ -21,6 +21,9 @@ import { import { fixPath } from "./os-jank.ts"; import { websocketRpcRouteLayer } from "./ws.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; +import { pullRequestHttpApiLayer } from "./pullRequest/http.ts"; +import * as PullRequestProviderRegistry from "./pullRequest/PullRequestProviderRegistry.ts"; +import * as PullRequestService from "./pullRequest/PullRequestService.ts"; import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; @@ -83,6 +86,7 @@ import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { connectHttpApiLayer, + pendingServiceUpdateExists, reconcileDesiredCloudLink, releaseManagedTunnelOnShutdown, } from "./cloud/http.ts"; @@ -91,6 +95,7 @@ import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts" import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts"; import * as CloudCliState from "./cloud/CliState.ts"; import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; +import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; @@ -99,6 +104,7 @@ import * as NativeTelemetryClient from "./resourceTelemetry/NativeTelemetryClien import * as ResourceAttribution from "./resourceTelemetry/ResourceAttribution.ts"; import * as ResourceMonitorBinary from "./resourceTelemetry/ResourceMonitorBinary.ts"; import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; +import * as UsageService from "./usage/UsageService.ts"; import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; import { clearPersistedServerRuntimeState, @@ -109,6 +115,7 @@ import { orchestrationHttpApiLayer } from "./orchestration/http.ts"; import * as NetService from "@t3tools/shared/Net"; import * as RelayClient from "@t3tools/shared/relayClient"; import { disableTailscaleServe, ensureTailscaleServe } from "@t3tools/tailscale"; +import { forkParked, ServerActivation } from "./serverActivation.ts"; // Effect's default preemptive shutdown waits 20s before finalizing request scopes. // T3's primary transport is long-lived WebSocket RPC, whose Effect scope finalizer @@ -155,6 +162,8 @@ const BackgroundLayerLive = BackgroundPolicy.layer.pipe( Layer.provideMerge(ServerSettingsLayerLive), ); +const UsageLayerLive = UsageService.layer.pipe(Layer.provide(ServerSettingsLayerLive)); + const ResourceDiagnosticsLayerLive = Layer.mergeAll( ResourceTelemetryLayerLive, ProcessDiagnostics.layer.pipe(Layer.provide(ResourceTelemetryLayerLive)), @@ -179,6 +188,20 @@ const HttpServerLive = Layer.unwrap( port: config.port, hostname: config.host ?? "127.0.0.1", gracefulShutdownTimeout: HTTP_PREEMPTIVE_SHUTDOWN_GRACE_MS, + websocket: { + // Negotiate permessage-deflate with clients that offer it; clients + // that don't still get uncompressed frames on their connection. A + // dedicated compressor keeps a per-connection sliding window + // (context takeover) so the compression dictionary is shared across + // server-to-client frames. Decompression uses the shared + // decompressor: uWebSockets' dedicated decompressor path can abort + // connections (close 1006) on valid DEFLATE input — see + // https://github.com/uNetworking/uWebSockets.js/issues/633. + perMessageDeflate: { + compress: "dedicated", + decompress: "shared", + }, + }, }); } else { const [NodeHttpServer, NodeHttp] = yield* Effect.all([ @@ -189,14 +212,18 @@ const HttpServerLive = Layer.unwrap( host: config.host ?? "127.0.0.1", port: config.port, gracefulShutdownTimeout: HTTP_PREEMPTIVE_SHUTDOWN_GRACE_MS, + // Negotiate permessage-deflate with clients that offer it; clients + // that don't still get uncompressed frames on their connection. + // Context takeover stays enabled (ws default) so the compression + // window is shared across frames — that also makes small frames cheap + // to compress, so no size threshold is set (ws only honors + // `threshold` when context takeover is disabled). + websocket: { perMessageDeflate: true }, }); } }), ); -const HttpResponseCompressionLive = - typeof Bun !== "undefined" ? HttpResponseCompression.layerBun : HttpResponseCompression.layerNode; - const PlatformServicesLive = Layer.unwrap( Effect.gen(function* () { if (typeof Bun !== "undefined") { @@ -389,6 +416,7 @@ const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe( // Misc. Layer.provideMerge(BackgroundLayerLive), Layer.provideMerge(ResourceDiagnosticsLayerLive), + Layer.provideMerge(UsageLayerLive), Layer.provideMerge(TraceDiagnostics.layer), Layer.provideMerge(AnalyticsService.layer), Layer.provideMerge(ExternalLauncher.layer), @@ -396,8 +424,19 @@ const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe( Layer.provide(NetService.layer), ); -const RuntimeServicesLive = ServerRuntimeStartup.layer.pipe( - Layer.provideMerge(RuntimeDependenciesLive), +const commandReadinessLayer = HttpRouter.middleware( + (httpEffect) => + Effect.flatMap(ServerRuntimeStartup.ServerRuntimeStartup, (startup) => + startup.awaitCommandReady.pipe(Effect.orDie, Effect.andThen(httpEffect)), + ), + { global: true }, +); + +const PullRequestServiceLive = PullRequestService.layer.pipe( + // One registry entry per supported host; the service only knows the registry. + Layer.provide(PullRequestProviderRegistry.layer), + Layer.provide(SourceControlProviderRegistryLayerLive), + Layer.provide(VcsProcess.layer), ); export const makeRoutesLayer = Layer.mergeAll( @@ -406,6 +445,7 @@ export const makeRoutesLayer = Layer.mergeAll( Layer.provide(authHttpApiLayer), Layer.provide(connectHttpApiLayer), Layer.provide(orchestrationHttpApiLayer), + Layer.provide(pullRequestHttpApiLayer), Layer.provide(serverEnvironmentHttpApiLayer), Layer.provide(environmentAuthenticatedAuthLayer), ), @@ -416,8 +456,12 @@ export const makeRoutesLayer = Layer.mergeAll( ), McpHttpServer.layer.pipe(Layer.provide(McpSessionRegistry.layer)), ).pipe( + // Both transports consume the same service instance, so caches single-flight across clients + // and mutations observed on WebSocket invalidate patches subsequently read over HTTP. + Layer.provide(PullRequestServiceLive), Layer.provide(PreviewAutomationBroker.layer), Layer.provide(ServerSelfUpdate.layer), + Layer.provide(commandReadinessLayer), Layer.provide(browserApiCorsLayer), Layer.provide(httpCompressionLayer), ); @@ -425,6 +469,14 @@ export const makeRoutesLayer = Layer.mergeAll( export const makeServerLayer = Layer.unwrap( Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; + const activation = yield* Deferred.make(); + const awaitActivation = Deferred.await(activation); + const activationLayer = Layer.succeed(ServerActivation, awaitActivation); + const runtimeStateParked = yield* Deferred.make(); + const tailscaleParked = yield* Deferred.make(); + const cloudLinkParked = yield* Deferred.make(); + const routesReady = yield* Deferred.make(); + const launcherLayer = ServiceLauncherClient.layer; yield* fixPath(); @@ -438,6 +490,8 @@ export const makeServerLayer = Layer.unwrap( const runtimeStateLayer = Layer.effectDiscard( Effect.acquireRelease( Effect.gen(function* () { + yield* Deferred.succeed(runtimeStateParked, undefined).pipe(Effect.orDie); + yield* awaitActivation; const server = yield* HttpServer.HttpServer; const address = server.address; if (typeof address === "string" || !("port" in address)) { @@ -451,15 +505,26 @@ export const makeServerLayer = Layer.unwrap( yield* persistServerRuntimeState({ path: config.serverRuntimeStatePath, state, - }); + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to persist server runtime state", { cause }), + ), + ); }), - () => clearPersistedServerRuntimeState(config.serverRuntimeStatePath), + () => + clearPersistedServerRuntimeState(config.serverRuntimeStatePath).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to clear server runtime state", { cause }), + ), + ), ), ); const tailscaleServeLayer = config.tailscaleServeEnabled ? Layer.effectDiscard( Effect.acquireRelease( Effect.gen(function* () { + yield* Deferred.succeed(tailscaleParked, undefined).pipe(Effect.orDie); + yield* awaitActivation; const server = yield* HttpServer.HttpServer; const address = server.address; if (typeof address === "string" || !("port" in address)) { @@ -509,68 +574,93 @@ export const makeServerLayer = Layer.unwrap( : Layer.empty; const cloudDesiredLinkReconcileLayer = Layer.effectDiscard( Effect.gen(function* () { - if (!hasCloudPublicConfig) return; - // Idle Cloudflare tunnels are billed, so a stopping server releases its - // tunnel; the persisted desired link brings one back — same hostname, - // fresh tunnel — when the environment starts again. Registered even - // when no link is desired yet: a client can link a running server, and - // that tunnel needs the same disposal on shutdown. - yield* Effect.addFinalizer(() => - releaseManagedTunnelOnShutdown().pipe( - Effect.timeout("10 seconds"), - Effect.tap((released) => - released ? Effect.logInfo("Released the managed tunnel on shutdown") : Effect.void, - ), - Effect.catchCause((cause) => - Effect.logWarning( - "Failed to release the managed tunnel on shutdown; the next link reuses it", - { cause }, - ), + if (!hasCloudPublicConfig) { + yield* Deferred.succeed(cloudLinkParked, undefined).pipe(Effect.orDie); + return; + } + const releaseManagedTunnel = releaseManagedTunnelOnShutdown().pipe( + Effect.timeout("10 seconds"), + Effect.tap((released) => + released ? Effect.logInfo("Released the managed tunnel on shutdown") : Effect.void, + ), + Effect.catchCause((cause) => + Effect.logWarning( + "Failed to release the managed tunnel on shutdown; the next link reuses it", + { cause }, ), - Effect.asVoid, ), + Effect.asVoid, ); - if (!(yield* CloudCliState.readCliDesiredCloudLink)) return; - const server = yield* HttpServer.HttpServer; - const address = server.address; - if (typeof address === "string" || !("port" in address)) return; - yield* Effect.forkScoped( - Effect.sleep("250 millis").pipe( - Effect.andThen(reconcileDesiredCloudLink(`http://127.0.0.1:${address.port}`)), - // On reboot this races NIC/DNS bring-up, so back off exponentially - // (capped at 30s) instead of burning all retries in a second. - // Bounded overall so a permanently broken setup still surfaces the - // warning below. Bad-request/unauthorized/conflict are - // deterministic failures (malformed origin, not linked yet, linked - // to a different cloud account) that no amount of retrying - // converges. - Effect.retry({ - while: (error) => - error._tag !== "EnvironmentHttpBadRequestError" && - error._tag !== "EnvironmentHttpUnauthorizedError" && - error._tag !== "EnvironmentHttpConflictError", - schedule: Schedule.exponential("1 second").pipe( - Schedule.modifyDelay(({ duration }) => - Effect.succeed(Duration.min(duration, Duration.seconds(30))), + // A launcher trial can be stopped before activation. The previous + // server is already gone, so the trial owns cleanup immediately; the + // pending-state check keeps the tunnel for normal commit or rollback, + // while the launcher's explicit-stop marker allows it to be released. + // Other runtimes wait for activation so a failed standby cannot tear + // down the active runtime's tunnel. + const cleanupBeforeActivation = yield* pendingServiceUpdateExists; + if (cleanupBeforeActivation) { + yield* Effect.addFinalizer(() => releaseManagedTunnel); + } + yield* forkParked( + Effect.gen(function* () { + if (!cleanupBeforeActivation) { + yield* Effect.addFinalizer(() => releaseManagedTunnel); + } + if (!(yield* CloudCliState.readCliDesiredCloudLink)) return; + const server = yield* HttpServer.HttpServer; + const address = server.address; + if (typeof address === "string" || !("port" in address)) return; + // No settling delay before the first attempt: routes are already + // serving by the time activation opens this gate (the startup + // sequence awaits routesReady), and the retry schedule below + // covers anything this sleep used to hedge against. Every + // millisecond here is dead time on the path to remote + // reachability after a restart. + yield* reconcileDesiredCloudLink(`http://127.0.0.1:${address.port}`).pipe( + Effect.retry({ + while: (error) => + error._tag !== "EnvironmentHttpBadRequestError" && + error._tag !== "EnvironmentHttpUnauthorizedError" && + error._tag !== "EnvironmentHttpConflictError", + schedule: Schedule.exponential("1 second").pipe( + Schedule.modifyDelay(({ duration }) => + Effect.succeed(Duration.min(duration, Duration.seconds(30))), + ), + Schedule.upTo({ duration: "10 minutes" }), ), - Schedule.upTo({ duration: "10 minutes" }), - ), - }), - Effect.tap(() => Effect.logInfo("T3 Connect desired link reconciled on startup")), - Effect.catch((cause) => - Effect.logWarning("Failed to reconcile T3 Connect desired link on startup", { - cause, }), - ), - ), + Effect.tap(() => Effect.logInfo("T3 Connect desired link reconciled on startup")), + Effect.catch((cause) => + Effect.logWarning("Failed to reconcile T3 Connect desired link on startup", { + cause, + }), + ), + ); + }), ); + yield* Deferred.succeed(cloudLinkParked, undefined).pipe(Effect.orDie); }), ); + const runtimeServicesLive = ServerRuntimeStartup.layerWithOptions({ + activate: Deferred.succeed(activation, undefined).pipe(Effect.asVoid), + abort: (error) => Deferred.die(activation, error).pipe(Effect.asVoid), + awaitAuxiliaryParked: Effect.all( + [ + Deferred.await(runtimeStateParked), + Deferred.await(cloudLinkParked), + Deferred.await(routesReady), + ...(config.tailscaleServeEnabled ? [Deferred.await(tailscaleParked)] : []), + ], + { concurrency: "unbounded" }, + ).pipe(Effect.asVoid), + }).pipe(Layer.provideMerge(RuntimeDependenciesLive), Layer.provide(launcherLayer)); + + const routesLayer = HttpRouter.serve(makeRoutesLayer.pipe(Layer.provide(launcherLayer)), { + disableLogger: !config.logWebSocketEvents, + }).pipe(Layer.tap(() => Deferred.succeed(routesReady, undefined).pipe(Effect.orDie))); const serverApplicationLayer = Layer.mergeAll( - HttpRouter.serve(makeRoutesLayer, { - disableLogger: !config.logWebSocketEvents, - }), + routesLayer, httpListeningLayer, runtimeStateLayer, tailscaleServeLayer, @@ -578,9 +668,9 @@ export const makeServerLayer = Layer.unwrap( ); return serverApplicationLayer.pipe( - Layer.provideMerge(RuntimeServicesLive), + Layer.provideMerge(runtimeServicesLive), + Layer.provide(activationLayer), Layer.provideMerge(serverRelayBrokerTracingLayer), - Layer.provideMerge(HttpResponseCompressionLive), Layer.provideMerge(HttpServerLive), Layer.provide(ApplicationObservabilityLive), Layer.provideMerge(FetchHttpClient.layer), @@ -590,5 +680,5 @@ export const makeServerLayer = Layer.unwrap( }), ); -// Important: Only `ServerConfig` should be provided by the CLI layer!!! Don't let other requirements leak into the launch layer. +// The CLI supplies configuration. export const runServer = Layer.launch(makeServerLayer); diff --git a/apps/server/src/serverActivation.test.ts b/apps/server/src/serverActivation.test.ts new file mode 100644 index 000000000000..a4f942a95b5d --- /dev/null +++ b/apps/server/src/serverActivation.test.ts @@ -0,0 +1,23 @@ +import { expect, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; + +import { forkParked, ServerActivation } from "./serverActivation.ts"; + +it.effect("proves a root is parked before returning and releases it with one gate", () => + Effect.scoped( + Effect.gen(function* () { + const activation = yield* Deferred.make(); + const ran = yield* Deferred.make(); + + yield* forkParked(Deferred.succeed(ran, undefined)).pipe( + Effect.provideService(ServerActivation, Deferred.await(activation)), + ); + expect(yield* Deferred.isDone(ran)).toBe(false); + + yield* Deferred.succeed(activation, undefined); + yield* Deferred.await(ran); + expect(yield* Deferred.isDone(ran)).toBe(true); + }), + ), +); diff --git a/apps/server/src/serverActivation.ts b/apps/server/src/serverActivation.ts new file mode 100644 index 000000000000..c068d55e7e76 --- /dev/null +++ b/apps/server/src/serverActivation.ts @@ -0,0 +1,26 @@ +import * as Context from "effect/Context"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import type * as Scope from "effect/Scope"; + +export class ServerActivation extends Context.Reference | undefined>( + "t3/serverActivation", + { defaultValue: () => undefined }, +) {} + +/** Forks a long-running root before commit and proves it is parked at the activation boundary. */ +export const forkParked = ( + effect: Effect.Effect, +): Effect.Effect => + Effect.gen(function* () { + const activation = yield* ServerActivation; + if (activation === undefined) { + yield* Effect.forkScoped(effect); + return; + } + const parked = yield* Deferred.make(); + yield* Effect.forkScoped( + Deferred.succeed(parked, undefined).pipe(Effect.andThen(activation), Effect.andThen(effect)), + ); + yield* Deferred.await(parked); + }); diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index b52b577c5b5a..5db2b75556ee 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -34,6 +34,8 @@ import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import * as ProviderSessionReaper from "./provider/Services/ProviderSessionReaper.ts"; +import { forkParked } from "./serverActivation.ts"; +import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts"; import { formatHeadlessServeOutput, formatHostForUrl, @@ -288,151 +290,161 @@ const runStartupPhase = (phase: string, effect: Effect.Effect) Effect.withSpan(`server.startup.${phase}`), ); -export const make = Effect.gen(function* () { - const serverConfig = yield* ServerConfig.ServerConfig; - const keybindings = yield* Keybindings.Keybindings; - const orchestrationReactor = yield* OrchestrationReactor.OrchestrationReactor; - const providerSessionReaper = yield* ProviderSessionReaper.ProviderSessionReaper; - const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents; - const serverSettings = yield* ServerSettings.ServerSettingsService; - const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; - const crypto = yield* Crypto.Crypto; +interface StartupOptions { + readonly activate?: Effect.Effect; + readonly awaitAuxiliaryParked?: Effect.Effect; + readonly abort?: (error: ServerRuntimeStartupError) => Effect.Effect; +} - const commandGate = yield* makeCommandGate; - const httpListening = yield* Deferred.make(); - const reactorScope = yield* Scope.make("sequential"); - - yield* Effect.addFinalizer(() => Scope.close(reactorScope, Exit.void)); - - const startup = Effect.gen(function* () { - yield* Effect.logDebug("startup phase: starting keybindings runtime"); - yield* runStartupPhase( - "keybindings.start", - keybindings.start.pipe( - Effect.catch((error) => - Effect.logWarning("failed to start keybindings runtime", { - path: error.configPath, - detail: error.detail, - cause: error.cause, - }), +export const make = (options?: StartupOptions) => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const keybindings = yield* Keybindings.Keybindings; + const orchestrationReactor = yield* OrchestrationReactor.OrchestrationReactor; + const providerSessionReaper = yield* ProviderSessionReaper.ProviderSessionReaper; + const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; + const crypto = yield* Crypto.Crypto; + const launcher = yield* ServiceLauncherClient.ServiceLauncherClient; + + const commandGate = yield* makeCommandGate; + const httpListening = yield* Deferred.make(); + const reactorScope = yield* Scope.make("sequential"); + + yield* Effect.addFinalizer(() => Scope.close(reactorScope, Exit.void)); + + const startup = Effect.gen(function* () { + yield* Effect.logDebug("startup phase: starting keybindings runtime"); + yield* runStartupPhase( + "keybindings.start", + keybindings.start.pipe( + Effect.catch((error) => + Effect.logWarning("failed to start keybindings runtime", { + path: error.configPath, + detail: error.detail, + cause: error.cause, + }), + ), ), - Effect.forkScoped, - ), - ); + ); - yield* Effect.logDebug("startup phase: starting server settings runtime"); - yield* runStartupPhase( - "settings.start", - serverSettings.start.pipe( - Effect.catch((error) => - Effect.logWarning("failed to start server settings runtime", { - path: error.settingsPath, - operation: error.operation, - providerInstanceId: error.providerInstanceId, - environmentVariable: error.environmentVariable, - cause: error.cause, - }), + yield* Effect.logDebug("startup phase: starting server settings runtime"); + yield* runStartupPhase( + "settings.start", + serverSettings.start.pipe( + Effect.catch((error) => + Effect.logWarning("failed to start server settings runtime", { + path: error.settingsPath, + operation: error.operation, + providerInstanceId: error.providerInstanceId, + environmentVariable: error.environmentVariable, + cause: error.cause, + }), + ), ), - Effect.forkScoped, - ), - ); + ); - yield* Effect.logDebug("startup phase: starting orchestration reactors"); - yield* runStartupPhase( - "reactors.start", - Effect.gen(function* () { - yield* orchestrationReactor.start().pipe(Scope.provide(reactorScope)); - yield* providerSessionReaper.start().pipe(Scope.provide(reactorScope)); - }), - ); + yield* Effect.logDebug("startup phase: parking orchestration roots at activation"); + yield* runStartupPhase( + "reactors.start", + Effect.gen(function* () { + yield* orchestrationReactor.start().pipe(Scope.provide(reactorScope)); + yield* providerSessionReaper.start().pipe(Scope.provide(reactorScope)); + }), + ); - const welcomeBase = yield* resolveWelcomeBase; - const environment = yield* serverEnvironment.getDescriptor; - yield* Effect.logDebug("startup phase: preparing welcome payload"); - yield* Effect.logDebug("startup phase: publishing welcome event", { - environmentId: environment.environmentId, - cwd: welcomeBase.cwd, - projectName: welcomeBase.projectName, - }); - yield* runStartupPhase( - "welcome.publish", - lifecycleEvents.publish({ - version: 1, - type: "welcome", - payload: { - environment, - ...welcomeBase, - }, - }), - ); + const welcomeBase = yield* resolveWelcomeBase; + const environment = yield* serverEnvironment.getDescriptor; + yield* Effect.logDebug("startup phase: preparing welcome payload"); + + if (serverConfig.autoBootstrapProjectFromCwd) { + yield* forkParked( + runStartupPhase( + "welcome.autobootstrap", + Effect.gen(function* () { + const bootstrapTargets = yield* resolveAutoBootstrapWelcomeTargets.pipe( + Effect.provideService(Crypto.Crypto, crypto), + ); + if (!bootstrapTargets.bootstrapProjectId && !bootstrapTargets.bootstrapThreadId) { + return; + } + + yield* Effect.logDebug("startup phase: publishing bootstrapped welcome event", { + environmentId: environment.environmentId, + cwd: welcomeBase.cwd, + projectName: welcomeBase.projectName, + bootstrapProjectId: bootstrapTargets.bootstrapProjectId, + bootstrapThreadId: bootstrapTargets.bootstrapThreadId, + }); + yield* lifecycleEvents.publish({ + version: 1, + type: "welcome", + payload: { + environment, + ...welcomeBase, + ...bootstrapTargets, + }, + }); + }).pipe( + Effect.catch((cause) => + Effect.logWarning("startup auto-bootstrap welcome failed", { + cause, + }), + ), + ), + ), + ); + } - if (serverConfig.autoBootstrapProjectFromCwd) { - yield* Effect.forkScoped( - runStartupPhase( - "welcome.autobootstrap", - Effect.gen(function* () { - const bootstrapTargets = yield* resolveAutoBootstrapWelcomeTargets.pipe( - Effect.provideService(Crypto.Crypto, crypto), + yield* forkParked( + Effect.gen(function* () { + yield* Effect.logDebug("startup phase: recording startup heartbeat"); + yield* recordStartupHeartbeat.pipe( + Effect.annotateSpans({ "startup.phase": "heartbeat.record" }), + Effect.withSpan("server.startup.heartbeat.record"), + Effect.ignoreCause({ log: true }), + ); + if (serverConfig.startupPresentation === "headless") { + const accessInfo = yield* issueHeadlessServeAccessInfo(); + yield* runStartupPhase( + "headless.output", + Console.log(formatHeadlessServeOutput(accessInfo)), ); - if (!bootstrapTargets.bootstrapProjectId && !bootstrapTargets.bootstrapThreadId) { - return; + } else { + const startupBrowserTarget = yield* resolveStartupBrowserTarget; + if (serverConfig.mode !== "desktop") { + yield* Effect.logInfo( + "Authentication required. Open T3 Code using the pairing URL.", + ).pipe(Effect.annotateLogs({ pairingUrl: startupBrowserTarget })); } + yield* runStartupPhase("browser.open", maybeOpenBrowser(startupBrowserTarget)); + } + }), + ); - yield* Effect.logDebug("startup phase: publishing bootstrapped welcome event", { - environmentId: environment.environmentId, - cwd: welcomeBase.cwd, - projectName: welcomeBase.projectName, - bootstrapProjectId: bootstrapTargets.bootstrapProjectId, - bootstrapThreadId: bootstrapTargets.bootstrapThreadId, - }); - yield* lifecycleEvents.publish({ - version: 1, - type: "welcome", - payload: { - environment, - ...welcomeBase, - ...bootstrapTargets, - }, - }); - }).pipe( - Effect.catch((cause) => - Effect.logWarning("startup auto-bootstrap welcome failed", { - cause, - }), - ), - ), - ), + yield* Effect.logDebug("startup phase: waiting for http listener"); + yield* runStartupPhase("http.wait", Deferred.await(httpListening)); + yield* runStartupPhase( + "auxiliary-roots.parked", + options?.awaitAuxiliaryParked ?? Effect.void, ); - } - }).pipe( - Effect.annotateSpans({ - "server.mode": serverConfig.mode, - "server.port": serverConfig.port, - "server.host": serverConfig.host ?? "default", - }), - Effect.withSpan("server.startup", { kind: "server", root: true }), - ); - yield* Effect.forkScoped( - Effect.gen(function* () { - const startupExit = yield* Effect.exit(startup); - if (Exit.isFailure(startupExit)) { - const error = new ServerRuntimeStartupError({ - mode: serverConfig.mode, - host: serverConfig.host ?? null, - port: serverConfig.port, - cause: startupExit.cause, - }); - yield* Effect.logError("server runtime startup failed", { cause: startupExit.cause }); - yield* commandGate.failCommandReady(error); - return; - } + // This is the prepared boundary. Every dependency has been acquired and + // every runtime root has confirmed that it is parked before this request. + const updateOutcome = yield* launcher.prepareTrial; + yield* runStartupPhase( + "welcome.publish", + lifecycleEvents.publish({ + version: 1, + type: "welcome", + payload: { environment, ...welcomeBase }, + }), + ); + yield* options?.activate ?? Effect.void; yield* Effect.logDebug("Accepting commands"); yield* commandGate.signalCommandReady; - yield* Effect.logDebug("startup phase: waiting for http listener"); - yield* runStartupPhase("http.wait", Deferred.await(httpListening)); - yield* Effect.logDebug("startup phase: publishing ready event"); yield* runStartupPhase( "ready.publish", lifecycleEvents.publish({ @@ -440,39 +452,49 @@ export const make = Effect.gen(function* () { type: "ready", payload: { at: DateTime.formatIso(yield* DateTime.now), - environment: yield* serverEnvironment.getDescriptor, + environment, + ...(updateOutcome === undefined ? {} : { updateOutcome }), }, }), ); - - yield* Effect.logDebug("startup phase: recording startup heartbeat"); - yield* launchStartupHeartbeat; - if (serverConfig.startupPresentation === "headless") { - yield* Effect.logDebug("startup phase: headless access info"); - const accessInfo = yield* issueHeadlessServeAccessInfo(); - yield* runStartupPhase( - "headless.output", - Console.log(formatHeadlessServeOutput(accessInfo)), - ); - } else { - yield* Effect.logDebug("startup phase: browser open check"); - const startupBrowserTarget = yield* resolveStartupBrowserTarget; - if (serverConfig.mode !== "desktop") { - yield* Effect.logInfo( - "Authentication required. Open T3 Code using the pairing URL.", - ).pipe(Effect.annotateLogs({ pairingUrl: startupBrowserTarget })); - } - yield* runStartupPhase("browser.open", maybeOpenBrowser(startupBrowserTarget)); - } yield* Effect.logDebug("startup phase: complete"); - }), - ); + }).pipe( + Effect.annotateSpans({ + "server.mode": serverConfig.mode, + "server.port": serverConfig.port, + "server.host": serverConfig.host ?? "default", + }), + Effect.withSpan("server.startup", { kind: "server", root: true }), + ); - return { - awaitCommandReady: commandGate.awaitCommandReady, - markHttpListening: Deferred.succeed(httpListening, undefined), - enqueueCommand: commandGate.enqueueCommand, - } satisfies ServerRuntimeStartup["Service"]; -}); + yield* Effect.forkScoped( + Effect.exit(startup).pipe( + Effect.flatMap((startupExit) => { + if (Exit.isSuccess(startupExit)) return Effect.void; + const error = new ServerRuntimeStartupError({ + mode: serverConfig.mode, + host: serverConfig.host ?? null, + port: serverConfig.port, + cause: startupExit.cause, + }); + return Effect.logError("server runtime startup failed", { + cause: startupExit.cause, + }).pipe( + Effect.andThen(commandGate.failCommandReady(error)), + Effect.andThen(options?.abort?.(error) ?? Effect.void), + ); + }), + ), + ); + + return { + awaitCommandReady: commandGate.awaitCommandReady, + markHttpListening: Deferred.succeed(httpListening, undefined), + enqueueCommand: commandGate.enqueueCommand, + } satisfies ServerRuntimeStartup["Service"]; + }); + +export const layerWithOptions = (options?: StartupOptions) => + Layer.effect(ServerRuntimeStartup, make(options)); -export const layer = Layer.effect(ServerRuntimeStartup, make); +export const layer = layerWithOptions(); diff --git a/apps/server/src/serverRuntimeState.test.ts b/apps/server/src/serverRuntimeState.test.ts index 749fd3062e91..4c2375b29a74 100644 --- a/apps/server/src/serverRuntimeState.test.ts +++ b/apps/server/src/serverRuntimeState.test.ts @@ -33,6 +33,7 @@ describe("serverRuntimeState", () => { host: "127.0.0.1", port: 4_971, origin: "http://127.0.0.1:4971", + devUrl: "http://localhost:5733/", startedAt: "2026-06-20T00:00:00.000Z", }; @@ -43,6 +44,24 @@ describe("serverRuntimeState", () => { }).pipe(Effect.provide(NodeServices.layer)), ); + it.effect("records the dev web URL when the server fronts a dev server", () => + Effect.gen(function* () { + const state = yield* ServerRuntimeState.makePersistedServerRuntimeState({ + config: { host: undefined, devUrl: new URL("http://localhost:5733") }, + port: 13_773, + }); + + assert.equal(state.devUrl, "http://localhost:5733/"); + assert.equal(state.origin, "http://127.0.0.1:13773"); + + const withoutDev = yield* ServerRuntimeState.makePersistedServerRuntimeState({ + config: { host: undefined, devUrl: undefined }, + port: 13_773, + }); + assert.isFalse("devUrl" in withoutDev); + }), + ); + it.effect("treats a missing runtime state file as absent", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/serverRuntimeState.ts b/apps/server/src/serverRuntimeState.ts index 329b000369a0..b32f3814547c 100644 --- a/apps/server/src/serverRuntimeState.ts +++ b/apps/server/src/serverRuntimeState.ts @@ -14,6 +14,9 @@ export const PersistedServerRuntimeState = Schema.Struct({ host: Schema.optional(Schema.String), port: Schema.Int, origin: Schema.String, + // Present when the server fronts a dev web server (VITE_DEV_SERVER_URL). + // Dev is single-origin: browsers must pair through this URL, not `origin`. + devUrl: Schema.optional(Schema.String), startedAt: Schema.String, }); export type PersistedServerRuntimeState = typeof PersistedServerRuntimeState.Type; @@ -45,7 +48,7 @@ const runtimeOriginForConfig = ( }; export const makePersistedServerRuntimeState = (input: { - readonly config: Pick; + readonly config: Pick; readonly port: number; }): Effect.Effect => Effect.map(DateTime.now, (now) => ({ @@ -54,6 +57,7 @@ export const makePersistedServerRuntimeState = (input: { ...(input.config.host ? { host: input.config.host } : {}), port: input.port, origin: runtimeOriginForConfig(input.config, input.port), + ...(input.config.devUrl ? { devUrl: input.config.devUrl.toString() } : {}), startedAt: DateTime.formatIso(now), })); diff --git a/apps/server/src/service-launcher.ts b/apps/server/src/service-launcher.ts new file mode 100644 index 000000000000..105212451629 --- /dev/null +++ b/apps/server/src/service-launcher.ts @@ -0,0 +1 @@ +import "./serviceLauncher.ts"; diff --git a/apps/server/src/serviceLauncher.test.ts b/apps/server/src/serviceLauncher.test.ts new file mode 100644 index 000000000000..45c472af1fc4 --- /dev/null +++ b/apps/server/src/serviceLauncher.test.ts @@ -0,0 +1,292 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { Launcher, readServiceState, writeServiceState } from "./serviceLauncher.ts"; +import { + compareExactServiceVersions, + decodeServiceState, + isExactServiceVersion, + SERVICE_LAUNCHER_PROTOCOL, + SERVICE_STOP_MARKER_FILE, +} from "./cloud/serviceProtocol.ts"; + +it("accepts only exact semantic versions", () => { + for (const version of ["0.0.0", "1.2.3", "1.2.3-alpha.1", "1.2.3-0", "1.2.3+001"]) { + assert.isTrue(isExactServiceVersion(version), version); + } + for (const version of ["latest", "01.2.3", "1.2.3-01", "1.2.3-alpha..1", "1.2.3+."]) { + assert.isFalse(isExactServiceVersion(version), version); + } +}); + +it("orders exact semantic versions without treating build metadata as precedence", () => { + assert.equal(compareExactServiceVersions("1.2.3", "1.2.3"), 0); + assert.equal(compareExactServiceVersions("1.2.4", "1.2.3"), 1); + assert.equal(compareExactServiceVersions("2.0.0-alpha.1", "2.0.0-alpha.2"), -1); + assert.equal(compareExactServiceVersions("2.0.0-alpha.2", "2.0.0-alpha.beta"), -1); + assert.equal(compareExactServiceVersions("2.0.0-alpha-beta", "2.0.0-alpha-alpha"), 1); + assert.equal(compareExactServiceVersions("2.0.0", "2.0.0-rc.1"), 1); + assert.equal(compareExactServiceVersions("2.0.0+one", "2.0.0+two"), 0); +}); + +it("rejects contradictory service state", () => { + assert.isUndefined( + decodeServiceState({ + protocol: SERVICE_LAUNCHER_PROTOCOL, + activeVersion: "0.0.31", + update: { + id: "update-1", + fromVersion: "0.0.30", + targetVersion: "0.0.32", + dbPath: "/tmp/state.sqlite", + status: "pending", + }, + }), + ); + + assert.isUndefined( + decodeServiceState({ + protocol: SERVICE_LAUNCHER_PROTOCOL, + activeVersion: "1.0.0", + update: { + id: "update-3", + fromVersion: "1.0.0", + targetVersion: "1.1.0", + status: "pending", + }, + }), + ); + + assert.isUndefined( + decodeServiceState({ + protocol: SERVICE_LAUNCHER_PROTOCOL, + activeVersion: "1.0.0", + update: { + id: "update-2", + fromVersion: "1.0.0", + targetVersion: "0.9.0", + dbPath: "/tmp/state.sqlite", + status: "pending", + }, + }), + ); +}); + +it.layer(NodeServices.layer)("service state persistence", (it) => { + it.effect("durably replaces and strictly reads one state document", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-test-" }); + const statePath = path.join(root, "runtime", "service-state.json"); + const state = { + protocol: SERVICE_LAUNCHER_PROTOCOL, + activeVersion: "0.0.31", + } as const; + + yield* Effect.promise(() => writeServiceState(statePath, state)); + assert.deepEqual(yield* Effect.promise(() => readServiceState(statePath)), state); + }), + ); + + it.effect("serializes shutdown with launcher recovery", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-stop-" }); + const statePath = path.join(root, "runtime", "service-state.json"); + const versionDir = path.join(root, "runtime", "versions", "1.0.0"); + const entryPath = path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"); + yield* fs.makeDirectory(path.dirname(entryPath), { recursive: true }); + yield* fs.writeFileString(entryPath, "setInterval(() => {}, 1_000);\n"); + yield* fs.writeFileString(path.join(versionDir, ".install-complete"), "1.0.0\n"); + yield* Effect.promise(() => + writeServiceState(statePath, { + protocol: SERVICE_LAUNCHER_PROTOCOL, + activeVersion: "1.0.0", + }), + ); + + const launcher = new Launcher(root, yield* Effect.promise(() => readServiceState(statePath))); + const running = launcher.run(); + const stopping = launcher.stop("SIGTERM"); + // An explicit stop leaves the marker that tells a child shutting down + // mid-update that no replacement server is coming. It is present as + // soon as stop() returns its promise, before queued transitions run. + assert.isTrue(yield* fs.exists(path.join(root, "runtime", SERVICE_STOP_MARKER_FILE))); + yield* Effect.promise(() => stopping); + yield* Effect.promise(() => running); + }), + ); + + it.effect("commits only after the trial reports prepared", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-flow-" }); + const statePath = path.join(root, "runtime", "service-state.json"); + const databasePath = path.join(root, "userdata", "state.sqlite"); + yield* fs.makeDirectory(path.dirname(databasePath), { recursive: true }); + yield* fs.writeFileString(databasePath, "before trial"); + // @effect-diagnostics-next-line preferSchemaOverJson:off - embeds a path in fake child source. + const encodedDatabasePath = JSON.stringify(databasePath); + const childSource = ` +const context = JSON.parse(process.env.T3_SERVICE_LAUNCHER_CONTEXT); +if (context.update?.status === "pending") { + process.send({ type: "prepared", updateId: context.update.id }); + process.on("message", (message) => { + if (message.type === "committed") process.exit(0); + }); +} else if (context.update === undefined) { + process.send({ type: "request-update", targetVersion: "1.1.0", dbPath: ${encodedDatabasePath} }); + setInterval(() => {}, 1_000); +} else { + process.exit(0); +} +`; + for (const version of ["1.0.0", "1.1.0"]) { + const versionDir = path.join(root, "runtime", "versions", version); + const entryPath = path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"); + yield* fs.makeDirectory(path.dirname(entryPath), { recursive: true }); + yield* fs.writeFileString(entryPath, childSource); + yield* fs.writeFileString(path.join(versionDir, ".install-complete"), `${version}\n`); + } + yield* Effect.promise(() => + writeServiceState(statePath, { + protocol: SERVICE_LAUNCHER_PROTOCOL, + activeVersion: "1.0.0", + }), + ); + + const launcher = new Launcher(root, yield* Effect.promise(() => readServiceState(statePath))); + yield* Effect.promise(() => + launcher.run().then( + () => Promise.reject(new Error("launcher unexpectedly completed")), + () => Promise.resolve(), + ), + ); + + const state = yield* Effect.promise(() => readServiceState(statePath)); + assert.equal(state.activeVersion, "1.1.0"); + assert.equal(state.update?.status, "committed"); + }), + ); + + it.effect("rolls back a trial that reports the wrong update ID", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-rollback-" }); + const statePath = path.join(root, "runtime", "service-state.json"); + const databasePath = path.join(root, "userdata", "state.sqlite"); + yield* fs.makeDirectory(path.dirname(databasePath), { recursive: true }); + yield* fs.writeFileString(databasePath, "before trial"); + // @effect-diagnostics-next-line preferSchemaOverJson:off - embeds a path in fake child source. + const encodedDatabasePath = JSON.stringify(databasePath); + const childSource = ` +const context = JSON.parse(process.env.T3_SERVICE_LAUNCHER_CONTEXT); +if (context.update?.status === "pending") { + process.send({ type: "prepared", updateId: "wrong-update" }); +} else if (context.update === undefined) { + process.send({ type: "request-update", targetVersion: "1.1.0", dbPath: ${encodedDatabasePath} }); + setInterval(() => {}, 1_000); +} else { + process.exit(0); +} +`; + for (const version of ["1.0.0", "1.1.0"]) { + const versionDir = path.join(root, "runtime", "versions", version); + const entryPath = path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"); + yield* fs.makeDirectory(path.dirname(entryPath), { recursive: true }); + yield* fs.writeFileString(entryPath, childSource); + yield* fs.writeFileString(path.join(versionDir, ".install-complete"), `${version}\n`); + } + yield* Effect.promise(() => + writeServiceState(statePath, { + protocol: SERVICE_LAUNCHER_PROTOCOL, + activeVersion: "1.0.0", + }), + ); + + const launcher = new Launcher(root, yield* Effect.promise(() => readServiceState(statePath))); + yield* Effect.promise(() => + launcher.run().then( + () => Promise.reject(new Error("launcher unexpectedly completed")), + () => Promise.resolve(), + ), + ); + + const state = yield* Effect.promise(() => readServiceState(statePath)); + assert.equal(state.activeVersion, "1.0.0"); + assert.equal(state.update?.status, "rolled-back"); + assert.equal( + state.update?.status === "rolled-back" ? state.update.reason : undefined, + "invalid-prepared", + ); + }), + ); + + it.effect("restores the database when a migrating trial exits", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-db-" }); + const statePath = path.join(root, "runtime", "service-state.json"); + const databasePath = path.join(root, "userdata", "state.sqlite"); + const original = "database before migration"; + yield* fs.makeDirectory(path.dirname(databasePath), { recursive: true }); + yield* fs.writeFileString(databasePath, original); + // @effect-diagnostics-next-line preferSchemaOverJson:off - embeds a path in fake child source. + const encodedDatabasePath = JSON.stringify(databasePath); + const childSource = ` +import { writeFileSync } from "node:fs"; +const context = JSON.parse(process.env.T3_SERVICE_LAUNCHER_CONTEXT); +if (context.update?.status === "pending") { + writeFileSync(context.update.dbPath, "database after migration"); + writeFileSync(context.update.dbPath + "-wal", "trial wal"); + writeFileSync(context.update.dbPath + "-shm", "trial shm"); + process.exit(1); +} else if (context.update === undefined) { + process.send({ type: "request-update", targetVersion: "1.1.0", dbPath: ${encodedDatabasePath} }); + setInterval(() => {}, 1_000); +} else { + process.exit(0); +} +`; + for (const version of ["1.0.0", "1.1.0"]) { + const versionDir = path.join(root, "runtime", "versions", version); + const entryPath = path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"); + yield* fs.makeDirectory(path.dirname(entryPath), { recursive: true }); + yield* fs.writeFileString(entryPath, childSource); + yield* fs.writeFileString(path.join(versionDir, ".install-complete"), `${version}\n`); + } + yield* Effect.promise(() => + writeServiceState(statePath, { + protocol: SERVICE_LAUNCHER_PROTOCOL, + activeVersion: "1.0.0", + }), + ); + + const launcher = new Launcher(root, yield* Effect.promise(() => readServiceState(statePath))); + yield* Effect.promise(() => + launcher.run().then( + () => Promise.reject(new Error("launcher unexpectedly completed")), + () => Promise.resolve(), + ), + ); + + const state = yield* Effect.promise(() => readServiceState(statePath)); + assert.equal(state.activeVersion, "1.0.0"); + assert.equal(state.update?.status, "rolled-back"); + assert.equal(yield* fs.readFileString(databasePath), original); + assert.isFalse(yield* fs.exists(`${databasePath}-wal`)); + assert.isFalse(yield* fs.exists(`${databasePath}-shm`)); + const updateId = state.update?.id; + assert.isDefined(updateId); + assert.isFalse(yield* fs.exists(path.join(root, "runtime", "db-backup", updateId))); + }), + ); +}); diff --git a/apps/server/src/serviceLauncher.ts b/apps/server/src/serviceLauncher.ts new file mode 100644 index 000000000000..3641593ecf07 --- /dev/null +++ b/apps/server/src/serviceLauncher.ts @@ -0,0 +1,618 @@ +// @effect-diagnostics nodeBuiltinImport:off +// @effect-diagnostics globalDate:off +// @effect-diagnostics globalTimers:off +// This file is shipped as a standalone bundle and copied to a stable path by +// `t3 service update`. Keep runtime imports limited to Node built-ins. +import * as NodeChildProcess from "node:child_process"; +import * as NodeCrypto from "node:crypto"; +import * as NodeFS from "node:fs"; +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; + +import type { + PendingServiceUpdate, + ServiceLauncherChildMessage, + ServiceLauncherContext, + ServiceLauncherParentMessage, + ServiceState, + ServiceUpdateRecord, +} from "./cloud/serviceProtocol.ts"; +import { + compareExactServiceVersions, + decodeServiceLauncherChildMessage, + isExactServiceVersion, + parseServiceState, + SERVICE_LAUNCHER_CONTEXT_ENV, + SERVICE_LAUNCHER_PROTOCOL, + SERVICE_STATE_FILE, + SERVICE_STOP_MARKER_FILE, +} from "./cloud/serviceProtocol.ts"; + +const HANDOFF_DELAY_MS = 2_000; +const PREPARED_TIMEOUT_MS = 120_000; +const TERMINATE_GRACE_MS = 5_000; + +type TerminalStatus = "committed" | "rolled-back" | "failed"; +type ChildRole = "active" | "trial"; + +interface ManagedChild { + readonly version: string; + role: ChildRole; + readonly process: NodeChildProcess.ChildProcess; +} + +const runtimePaths = (baseDir: string, version: string) => { + const versionDir = NodePath.join(baseDir, "runtime", "versions", version); + return { + versionDir, + entryPath: NodePath.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"), + sentinelPath: NodePath.join(versionDir, ".install-complete"), + }; +}; + +/** SQLite persists across the main file plus its WAL and shared-memory sidecars. */ +const DB_FILE_SUFFIXES = ["", "-wal", "-shm"] as const; +const RESTORE_MARKER = ".restore-pending"; + +const databaseBackupDir = (baseDir: string, updateId: string) => + NodePath.join(baseDir, "runtime", "db-backup", updateId); + +const databaseBackupFile = (backupDir: string, suffix: (typeof DB_FILE_SUFFIXES)[number]) => + NodePath.join(backupDir, suffix === "" ? "database" : `database${suffix}`); + +async function pathExists(target: string): Promise { + try { + await NodeFSP.access(target); + return true; + } catch (cause) { + if (cause instanceof Error && "code" in cause && cause.code === "ENOENT") return false; + throw cause; + } +} + +async function syncFile(filePath: string): Promise { + const handle = await NodeFSP.open(filePath, "r"); + try { + await handle.sync(); + } finally { + await handle.close(); + } +} + +async function syncDirectory(directory: string): Promise { + const handle = await NodeFSP.open(directory, "r"); + try { + await handle.sync(); + } finally { + await handle.close(); + } +} + +/** + * Snapshots the database once per update before the first trial. A completed + * backup is never overwritten because a restarted launcher may be looking at + * database writes from an earlier attempt by the same trial. + */ +async function backupDatabaseOnce(baseDir: string, pending: PendingServiceUpdate): Promise { + const backupDir = databaseBackupDir(baseDir, pending.id); + if (await pathExists(backupDir)) return; + + const stagingDir = `${backupDir}.staging`; + await NodeFSP.rm(stagingDir, { recursive: true, force: true }); + await NodeFSP.mkdir(stagingDir, { recursive: true, mode: 0o700 }); + try { + for (const suffix of DB_FILE_SUFFIXES) { + const source = `${pending.dbPath}${suffix}`; + if (suffix !== "" && !(await pathExists(source))) continue; + const destination = databaseBackupFile(stagingDir, suffix); + await NodeFSP.copyFile(source, destination); + await syncFile(destination); + } + await NodeFSP.rename(stagingDir, backupDir); + await syncDirectory(NodePath.dirname(backupDir)); + } catch (cause) { + await NodeFSP.rm(stagingDir, { recursive: true, force: true }).catch(() => undefined); + throw cause; + } +} + +const restoreMarkerPath = (baseDir: string, updateId: string) => + NodePath.join(databaseBackupDir(baseDir, updateId), RESTORE_MARKER); + +const databaseRestorePending = (baseDir: string, pending: PendingServiceUpdate) => + pathExists(restoreMarkerPath(baseDir, pending.id)); + +/** Mark rollback before changing live files so launcher recovery cannot boot a partial restore. */ +async function markDatabaseRestorePending(backupDir: string): Promise { + const markerPath = NodePath.join(backupDir, RESTORE_MARKER); + if (!(await pathExists(markerPath))) { + const handle = await NodeFSP.open(markerPath, "wx", 0o600); + try { + await handle.sync(); + } finally { + await handle.close(); + } + await syncDirectory(backupDir); + } +} + +/** Restore is retryable after any process crash while the backup directory remains. */ +async function restoreDatabaseBackup( + baseDir: string, + pending: PendingServiceUpdate, +): Promise { + const backupDir = databaseBackupDir(baseDir, pending.id); + if (!(await pathExists(backupDir))) return; + + await markDatabaseRestorePending(backupDir); + for (const suffix of DB_FILE_SUFFIXES) { + const target = `${pending.dbPath}${suffix}`; + const source = databaseBackupFile(backupDir, suffix); + if (await pathExists(source)) { + await NodeFSP.copyFile(source, target); + await syncFile(target); + } else { + await NodeFSP.rm(target, { force: true }); + } + } + await syncDirectory(NodePath.dirname(pending.dbPath)); +} + +async function discardDatabaseBackup(baseDir: string, updateId: string): Promise { + const backupDir = databaseBackupDir(baseDir, updateId); + if (!(await pathExists(backupDir))) return; + await NodeFSP.rm(backupDir, { recursive: true, force: true }); + await syncDirectory(NodePath.dirname(backupDir)); +} + +export async function readServiceState(filePath: string): Promise { + const contents = await NodeFSP.readFile(filePath, "utf8"); + const state = parseServiceState(contents); + if (state === undefined) throw new Error("Service state is invalid or unsupported."); + return state; +} + +/** Durable same-directory replacement used for every runtime state transition. */ +export async function writeServiceState(filePath: string, state: ServiceState): Promise { + const directory = NodePath.dirname(filePath); + await NodeFSP.mkdir(directory, { recursive: true, mode: 0o700 }); + const tempPath = NodePath.join( + directory, + `.${NodePath.basename(filePath)}.${process.pid}.${NodeCrypto.randomUUID()}`, + ); + let handle: NodeFSP.FileHandle | undefined; + try { + handle = await NodeFSP.open(tempPath, "wx", 0o600); + await handle.writeFile(`${JSON.stringify(state, null, 2)}\n`, "utf8"); + await handle.sync(); + await handle.close(); + handle = undefined; + await NodeFSP.rename(tempPath, filePath); + const directoryHandle = await NodeFSP.open(directory, "r"); + try { + await directoryHandle.sync(); + } finally { + await directoryHandle.close(); + } + } finally { + await handle?.close().catch(() => undefined); + await NodeFSP.rm(tempPath, { force: true }).catch(() => undefined); + } +} + +async function runtimeExists(baseDir: string, version: string): Promise { + const paths = runtimePaths(baseDir, version); + try { + const [entry, sentinel] = await Promise.all([ + NodeFSP.stat(paths.entryPath), + NodeFSP.readFile(paths.sentinelPath, "utf8"), + ]); + return entry.isFile() && sentinel.trim() === version; + } catch { + return false; + } +} + +function terminalUpdate(input: { + readonly pending: PendingServiceUpdate; + readonly status: S; + readonly reason?: string; +}): Exclude & { readonly status: S } { + return { + id: input.pending.id, + fromVersion: input.pending.fromVersion, + targetVersion: input.pending.targetVersion, + status: input.status, + ...(input.reason === undefined ? {} : { reason: input.reason }), + }; +} + +function sendMessage( + child: NodeChildProcess.ChildProcess, + message: ServiceLauncherParentMessage, +): Promise { + return new Promise((resolve, reject) => { + if (!child.connected || child.send === undefined) { + reject(new Error("service child IPC is disconnected.")); + return; + } + child.send(message, (error) => (error === null ? resolve() : reject(error))); + }); +} + +function waitForExit(child: NodeChildProcess.ChildProcess): Promise { + if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(); + return new Promise((resolve) => child.once("exit", () => resolve())); +} + +async function terminateChild( + child: NodeChildProcess.ChildProcess, + signal: NodeJS.Signals = "SIGTERM", +): Promise { + if (child.exitCode !== null || child.signalCode !== null) return; + child.kill(signal); + const force = setTimeout(() => child.kill("SIGKILL"), TERMINATE_GRACE_MS); + try { + await waitForExit(child); + } finally { + clearTimeout(force); + } +} + +const stopMarkerPath = (baseDir: string) => + NodePath.join(baseDir, "runtime", SERVICE_STOP_MARKER_FILE); + +export class Launcher { + readonly #baseDir: string; + readonly #statePath: string; + #state: ServiceState; + #child: ManagedChild | null = null; + #timer: NodeJS.Timeout | undefined; + #transitions: Promise = Promise.resolve(); + #stopRequested = false; + #stopping = false; + #done = false; + readonly #completion = Promise.withResolvers(); + + constructor(baseDir: string, state: ServiceState) { + this.#baseDir = baseDir; + this.#statePath = NodePath.join(baseDir, "runtime", SERVICE_STATE_FILE); + this.#state = state; + } + + async run(): Promise { + const onSigterm = () => void this.stop("SIGTERM"); + const onSigint = () => void this.stop("SIGINT"); + process.once("SIGTERM", onSigterm); + process.once("SIGINT", onSigint); + try { + this.#enqueue(() => this.#recover()); + await this.#completion.promise; + } finally { + process.off("SIGTERM", onSigterm); + process.off("SIGINT", onSigint); + } + } + + #enqueue(transition: () => Promise): void { + this.#transitions = this.#transitions + .then(transition, transition) + .catch((cause: unknown) => + this.#fatal(cause instanceof Error ? cause : new Error(String(cause))), + ); + } + + async #fatal(error: Error): Promise { + if (this.#done) return; + this.#done = true; + this.#stopping = true; + this.#clearTimer(); + const child = this.#child?.process; + this.#child = null; + if (child !== undefined) await terminateChild(child); + this.#completion.reject(error); + } + + async stop(signal: NodeJS.Signals): Promise { + // This must happen synchronously at signal receipt. A queued update + // transition may already be terminating the active child, and that child + // needs to see the marker in its shutdown finalizer. KillMode=mixed also + // ensures systemd signals the launcher before the rest of the cgroup. + try { + NodeFS.writeFileSync(stopMarkerPath(this.#baseDir), "", { mode: 0o600 }); + } catch { + // Err toward keeping the tunnel; the next link or unlink reconciles it. + } + if (this.#stopRequested || this.#stopping) { + await this.#completion.promise.catch(() => undefined); + return; + } + this.#stopRequested = true; + this.#clearTimer(); + this.#enqueue(async () => { + // Let an update transition already in progress start its replacement + // before this queued stop tears it down. That replacement owns the + // pre-activation tunnel cleanup path and observes the marker above. + this.#stopping = true; + const child = this.#child?.process; + this.#child = null; + if (child !== undefined) await terminateChild(child, signal); + this.#done = true; + this.#completion.resolve(); + }); + await this.#completion.promise.catch(() => undefined); + } + + #clearTimer(): void { + clearTimeout(this.#timer); + this.#timer = undefined; + } + + async #recover(): Promise { + // A fresh launcher means servers are running again: any stop marker from + // a previous explicit stop is stale and must not make a future update + // handoff release its tunnel. + await NodeFSP.rm(stopMarkerPath(this.#baseDir), { force: true }).catch(() => undefined); + const update = this.#state.update; + if (update?.status !== "pending") { + if (update !== undefined) { + await discardDatabaseBackup(this.#baseDir, update.id).catch(() => undefined); + } + await this.#startChild(this.#state.activeVersion, "active", update); + return; + } + if (await databaseRestorePending(this.#baseDir, update)) { + await this.#returnToPrevious(update, "failed", "rollback-interrupted"); + return; + } + if (!(await runtimeExists(this.#baseDir, update.targetVersion))) { + await this.#returnToPrevious(update, "failed", "target-runtime-missing"); + return; + } + await this.#startTrial(update); + } + + async #startTrial(pending: PendingServiceUpdate): Promise { + // The previous child is dead here, so all three SQLite files are quiescent. + try { + await backupDatabaseOnce(this.#baseDir, pending); + } catch { + await this.#returnToPrevious(pending, "failed", "db-backup-failed"); + return; + } + try { + await this.#startChild(pending.targetVersion, "trial", pending); + } catch { + await this.#returnToPrevious(pending, "failed", "candidate-start-failed"); + } + } + + async #startChild(version: string, role: ChildRole, update?: ServiceUpdateRecord): Promise { + if (this.#stopping) return; + if (!(await runtimeExists(this.#baseDir, version))) { + throw new Error(`Selected t3@${version} runtime is missing or incomplete.`); + } + if (this.#stopping) return; + const paths = runtimePaths(this.#baseDir, version); + const context: ServiceLauncherContext = { + protocol: SERVICE_LAUNCHER_PROTOCOL, + childVersion: version, + ...(update === undefined ? {} : { update }), + }; + const child = NodeChildProcess.spawn(process.execPath, [paths.entryPath, "serve"], { + env: { ...process.env, [SERVICE_LAUNCHER_CONTEXT_ENV]: JSON.stringify(context) }, + stdio: ["inherit", "inherit", "inherit", "ipc"], + }); + await new Promise((resolve, reject) => { + const onError = (error: Error) => reject(error); + child.once("error", onError); + child.once("spawn", () => { + child.removeListener("error", onError); + child.on("error", (error) => this.#enqueue(() => Promise.reject(error))); + resolve(); + }); + }); + if (this.#stopping) { + await terminateChild(child); + return; + } + + const managed: ManagedChild = { + version, + role, + process: child, + }; + this.#child = managed; + child.on("message", (value) => { + const message = decodeServiceLauncherChildMessage(value); + if (message !== undefined) this.#enqueue(() => this.#handleMessage(managed, message)); + }); + child.once("exit", (code, signal) => + this.#enqueue(() => this.#handleExit(managed, code, signal)), + ); + + if (role === "trial") { + this.#timer = setTimeout( + () => this.#enqueue(() => this.#handlePreparedTimeout(managed)), + PREPARED_TIMEOUT_MS, + ); + } + } + + async #handleMessage(child: ManagedChild, message: ServiceLauncherChildMessage): Promise { + if (this.#child !== child || this.#stopping) return; + if (message.type === "request-update") { + await this.#handleUpdateRequest(child, message); + return; + } + await this.#handlePrepared(child, message.updateId); + } + + async #handleUpdateRequest( + child: ManagedChild, + message: Extract, + ): Promise { + const reject = (reason: string) => + sendMessage(child.process, { type: "update-rejected", reason }); + if (child.role !== "active") { + await reject("Only the active server can request an update."); + return; + } + if (child.version !== this.#state.activeVersion) { + await reject("The requesting server is not the selected active version."); + return; + } + if (this.#state.update?.status === "pending") { + await reject("Another server update is already pending."); + return; + } + if (!isExactServiceVersion(message.targetVersion)) { + await reject("The requested target is not an exact version."); + return; + } + if (compareExactServiceVersions(message.targetVersion, child.version) <= 0) { + await reject("Remote updates must select a newer server version."); + return; + } + if (!NodePath.isAbsolute(message.dbPath)) { + await reject("The requested database path is not absolute."); + return; + } + if (!(await runtimeExists(this.#baseDir, message.targetVersion))) { + await reject("The requested target runtime is missing or incomplete."); + return; + } + + const pending: PendingServiceUpdate = { + id: NodeCrypto.randomUUID(), + fromVersion: child.version, + targetVersion: message.targetVersion, + dbPath: message.dbPath, + status: "pending", + }; + const next: ServiceState = { ...this.#state, update: pending }; + await writeServiceState(this.#statePath, next); + this.#state = next; + await sendMessage(child.process, { type: "update-accepted", updateId: pending.id }); + this.#timer = setTimeout(() => this.#enqueue(() => this.#beginTrial(child)), HANDOFF_DELAY_MS); + } + + async #beginTrial(child: ManagedChild): Promise { + const pending = this.#state.update; + if (this.#child !== child || child.role !== "active" || pending?.status !== "pending") { + return; + } + this.#timer = undefined; + this.#child = null; + await terminateChild(child.process); + await this.#startTrial(pending); + } + + async #handlePrepared(child: ManagedChild, updateId: string): Promise { + const pending = this.#state.update; + if ( + child.role !== "trial" || + pending?.status !== "pending" || + pending.id !== updateId || + pending.targetVersion !== child.version + ) { + if (child.role === "trial" && pending?.status === "pending") { + await this.#returnToPrevious(pending, "rolled-back", "invalid-prepared", child); + return; + } + throw new Error("Trial child reported prepared for an unexpected update."); + } + this.#clearTimer(); + const committed = terminalUpdate({ pending, status: "committed" }); + const next: ServiceState = { + ...this.#state, + activeVersion: pending.targetVersion, + update: committed, + }; + await writeServiceState(this.#statePath, next); + this.#state = next; + child.role = "active"; + await discardDatabaseBackup(this.#baseDir, committed.id).catch(() => undefined); + await sendMessage(child.process, { type: "committed", updateId: committed.id }); + } + + async #handlePreparedTimeout(child: ManagedChild): Promise { + const pending = this.#state.update; + if (this.#child !== child || child.role !== "trial" || pending?.status !== "pending") { + return; + } + this.#timer = undefined; + await this.#returnToPrevious(pending, "rolled-back", "prepared-timeout", child); + } + + async #handleExit( + child: ManagedChild, + code: number | null, + signal: NodeJS.Signals | null, + ): Promise { + if (this.#child !== child || this.#stopping) return; + this.#child = null; + if (child.role === "trial") { + this.#clearTimer(); + const pending = this.#state.update; + if (pending?.status !== "pending") { + throw new Error("Trial child exited without matching pending state."); + } + await this.#returnToPrevious( + pending, + "rolled-back", + `candidate-exited:${String(code ?? signal ?? "unknown")}`, + ); + return; + } + + this.#clearTimer(); + const pending = this.#state.update; + if (pending?.status === "pending") { + await this.#startTrial(pending); + return; + } + throw new Error(`Active child exited unexpectedly (${String(code ?? signal ?? "unknown")}).`); + } + + async #returnToPrevious( + pending: PendingServiceUpdate, + status: "rolled-back" | "failed", + reason: string, + child?: ManagedChild, + ): Promise { + if (child !== undefined) { + this.#child = null; + await terminateChild(child.process); + } + await restoreDatabaseBackup(this.#baseDir, pending); + const outcome = terminalUpdate({ pending, status, reason }); + const next: ServiceState = { + ...this.#state, + activeVersion: pending.fromVersion, + update: outcome, + }; + await writeServiceState(this.#statePath, next); + this.#state = next; + await discardDatabaseBackup(this.#baseDir, pending.id).catch(() => undefined); + await this.#startChild(next.activeVersion, "active", outcome); + } +} + +async function main(): Promise { + const baseDir = process.env.T3CODE_HOME?.trim(); + if (baseDir === undefined || baseDir === "") { + throw new Error("T3CODE_HOME is required by the T3 Code service launcher."); + } + const statePath = NodePath.join(baseDir, "runtime", SERVICE_STATE_FILE); + const state = await readServiceState(statePath); + await new Launcher(baseDir, state).run(); +} + +if (import.meta.main) { + main().catch((cause: unknown) => { + const error = cause instanceof Error ? cause : new Error(String(cause)); + process.stderr.write(`[service-launcher] ${error.message}\n`); + process.exitCode = 1; + }); +} diff --git a/apps/server/src/sourceControl/BitbucketApi.test.ts b/apps/server/src/sourceControl/BitbucketApi.test.ts index 5a9759ace0b7..4f3433693e5f 100644 --- a/apps/server/src/sourceControl/BitbucketApi.test.ts +++ b/apps/server/src/sourceControl/BitbucketApi.test.ts @@ -756,3 +756,113 @@ it.effect("checks out fork pull requests through an ensured fork remote", () => }); }).pipe(Effect.provide(layer)); }); + +it.effect("refuses a url that points away from the configured Bitbucket", () => { + // A whole url reaches `request` from inside a response — a pagination cursor, say — so + // following one off-host would hand the account's credentials to whoever wrote it. + const { layer, execute } = makeLayer({ response: () => new Response("{}", { status: 200 }) }); + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + + const error = yield* Effect.flip( + bitbucket.request({ method: "GET", url: "https://attacker.example/2.0/repositories" }), + ); + + assert.strictEqual(error._tag, "BitbucketUntrustedUrlError"); + // Nothing was sent at all, so no header travelled anywhere. + assert.strictEqual(execute.mock.calls.length, 0); + }).pipe(Effect.provide(layer)); +}); + +it.effect("keeps only the host of a url it refuses, never its query", () => + Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + + const error = yield* Effect.flip( + bitbucket.request({ + method: "GET", + // A signed link, whose query is the credential. + url: "https://attacker.example/asset?signature=secret-token", + }), + ); + + assert.strictEqual(error._tag, "BitbucketUntrustedUrlError"); + assert.strictEqual( + error._tag === "BitbucketUntrustedUrlError" ? error.host : "", + "https://attacker.example", + ); + assert.notInclude(error.message, "secret-token"); + }).pipe(Effect.provide(makeLayer({ response: () => new Response("{}", { status: 200 }) }).layer)), +); + +it.effect("does not follow a redirect off the configured Bitbucket", () => + Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + + const error = yield* Effect.flip( + bitbucket.request({ method: "GET", url: "/repositories/acme/web/pullrequests/1/diff" }), + ); + + // The client would carry every header to the new host, so the hop is checked here instead. + assert.strictEqual(error._tag, "BitbucketUntrustedUrlError"); + }).pipe( + Effect.provide( + makeLayer({ + response: () => + new Response(null, { + status: 302, + headers: { location: "https://attacker.example/stolen" }, + }), + }).layer, + ), + ), +); + +it.effect("follows a redirect that stays on the configured Bitbucket", () => + Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + + const result = yield* bitbucket.request({ + method: "GET", + url: "/repositories/acme/web/pullrequests/1/diff", + }); + + // Bitbucket serves a diff as a redirect to a commit range, so the hop has to be followed. + assert.strictEqual(result.body, "diff --git a/a.ts b/a.ts"); + assert.isFalse(result.truncated); + }).pipe( + Effect.provide( + makeLayer({ + response: (request) => + request.url.endsWith("/pullrequests/1/diff") + ? new Response(null, { + status: 302, + // The same host the harness configures, which is not bitbucket.org: a + // self-hosted base url has to be trusted on its own terms. + headers: { location: "https://api.test.local/2.0/repositories/acme/web/diff/abc" }, + }) + : new Response("diff --git a/a.ts b/a.ts", { status: 200 }), + }).layer, + ), + ), +); + +it.effect("cuts a response short rather than reading an unbounded diff into memory", () => + Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + + const result = yield* bitbucket.request({ + method: "GET", + url: "/repositories/acme/web/pullrequests/1/diff", + maxBytes: 8, + }); + + assert.strictEqual(result.body, "12345678"); + assert.isTrue(result.truncated); + // Bounded as the body arrives, so an oversized diff is never held whole. + }).pipe( + Effect.provide( + makeLayer({ response: () => new Response("1234567890", { status: 200 }) }).layer, + ), + ), +); diff --git a/apps/server/src/sourceControl/BitbucketApi.ts b/apps/server/src/sourceControl/BitbucketApi.ts index f7d7f6671a46..aad28ee8c1ab 100644 --- a/apps/server/src/sourceControl/BitbucketApi.ts +++ b/apps/server/src/sourceControl/BitbucketApi.ts @@ -22,11 +22,16 @@ import { normalizeBitbucketPullRequestRecord, type NormalizedBitbucketPullRequestRecord, } from "./bitbucketPullRequests.ts"; +import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; import * as SourceControlProvider from "./SourceControlProvider.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; const DEFAULT_API_BASE_URL = "https://api.bitbucket.org/2.0"; +/** A response body past this is cut short, so one huge diff cannot exhaust the server. */ +const DEFAULT_MAX_RESPONSE_BYTES = 8 * 1024 * 1024; +/** Bitbucket redirects a diff once; this leaves room without following a chain forever. */ +const MAX_REDIRECTS = 3; const BitbucketApiEnvConfig = Config.all({ baseUrl: Config.string("T3CODE_BITBUCKET_API_BASE_URL").pipe( @@ -47,6 +52,9 @@ const BitbucketApiOperation = Schema.Literals([ "createPullRequest", "probeAuth", "checkoutPullRequest", + // The raw escape hatch. Callers name their own operation in their own error, the way the + // pull request wrappers do on top of `gh` and `glab`. + "request", ]); type BitbucketApiOperation = typeof BitbucketApiOperation.Type; @@ -56,8 +64,12 @@ export class BitbucketRepositoryLocatorError extends Schema.TaggedErrorClass()( + "BitbucketUntrustedUrlError", + { + /** The host only. A rejected hop is often a signed url, whose query carries a credential. */ + host: Schema.String, + }, +) { + get detail(): string { + return `The response pointed at ${this.host}, outside the configured Bitbucket.`; + } + + override get message(): string { + return `Bitbucket API failed in request: ${this.detail}`; } } export const BitbucketApiError = Schema.Union([ + BitbucketUntrustedUrlError, BitbucketRepositoryLocatorError, BitbucketRequestError, BitbucketResponseError, @@ -246,6 +316,24 @@ export class BitbucketApi extends Context.Service< BitbucketApi, { readonly probeAuth: Effect.Effect; + + /** + * One authenticated request, returning the body verbatim. Bitbucket answers most endpoints + * with JSON and a few — a pull request diff, for one — with plain text, so the body is + * handed back undecoded for the caller to read as it sees fit. + */ + readonly request: (input: { + readonly method: "GET" | "POST" | "PUT" | "DELETE"; + /** + * A path below the API base, or a whole URL as a paged response reports its next page. + * A whole URL is refused unless it belongs to the configured Bitbucket. + */ + readonly url: string; + /** A JSON document, for the endpoints that take one. */ + readonly body?: string; + /** Response bytes to keep; past this the body comes back cut short and marked. */ + readonly maxBytes?: number; + }) => Effect.Effect<{ readonly body: string; readonly truncated: boolean }, BitbucketApiError>; readonly listPullRequests: (input: { readonly cwd: string; readonly context?: SourceControlProvider.SourceControlProviderContext; @@ -473,11 +561,25 @@ function authFromConfig( }; } +/** Null for anything that is not a url at all, which is never the configured Bitbucket. */ +function originOf(value: string): string | null { + try { + return new URL(value).origin; + } catch { + return null; + } +} + function responseError( operation: BitbucketApiOperation, response: HttpClientResponse.HttpClientResponse, ): Effect.Effect { - return response.text.pipe( + // Bounded like any other body: an error response is no smaller than a successful one, and + // only its length is reported anyway. + return collectUint8StreamText({ + stream: response.stream, + maxBytes: DEFAULT_MAX_RESPONSE_BYTES, + }).pipe( Effect.mapError( (cause) => new BitbucketResponseBodyReadError({ @@ -486,12 +588,12 @@ function responseError( cause, }), ), - Effect.flatMap((body) => + Effect.flatMap((collected) => Effect.fail( new BitbucketResponseError({ operation, status: response.status, - responseBodyLength: body.length, + responseBodyLength: collected.text.length, }), ), ), @@ -689,7 +791,107 @@ export const make = Effect.gen(function* () { }); }); + // A pull request's diff, diffstat and conflicts are served as redirects to a commit-range + // URL, and the client does not follow redirects unless asked. The hop stays on the same host, + // so the credentials travel with it. + /** + * The one host these credentials may be sent to. A url that came back inside a response — a + * pagination cursor, or the target of a redirect — is data, not instruction, so it is checked + * against this before the account's token travels with it. + */ + const apiOrigin = originOf(config.baseUrl); + + const trustedUrl = (value: string): string | null => { + if (!/^https?:\/\//u.test(value)) return apiUrl(value); + const origin = originOf(value); + return origin !== null && origin === apiOrigin ? value : null; + }; + + /** + * Redirects are followed here rather than by the client, which forwards every header to + * whatever host it is sent to. A pull request diff, diffstat and conflicts are all served as + * redirects, so they have to be followed — but only back to the same Bitbucket. + */ + const send = (input: { + readonly method: "GET" | "POST" | "PUT" | "DELETE"; + readonly url: string; + readonly body?: string; + readonly redirects: number; + }): Effect.Effect => { + const url = trustedUrl(input.url); + if (url === null) { + return Effect.fail( + new BitbucketUntrustedUrlError({ host: originOf(input.url) ?? "an unreadable url" }), + ); + } + const base = + input.method === "GET" + ? HttpClientRequest.get(url) + : input.method === "POST" + ? HttpClientRequest.post(url) + : input.method === "DELETE" + ? HttpClientRequest.make("DELETE")(url) + : HttpClientRequest.put(url); + // No `Accept: application/json`: the diff endpoints answer with a patch, not JSON. + const withBody = + input.body === undefined + ? base + : base.pipe(HttpClientRequest.bodyText(input.body, "application/json")); + return httpClient.execute(withAuth(withBody)).pipe( + Effect.mapError( + (cause): BitbucketApiError => new BitbucketRequestError({ operation: "request", cause }), + ), + Effect.flatMap((response) => { + const location = response.headers.location; + if ( + response.status >= 300 && + response.status < 400 && + location !== undefined && + input.redirects < MAX_REDIRECTS + ) { + return send({ + ...input, + url: new URL(location, url).toString(), + redirects: input.redirects + 1, + }); + } + return Effect.succeed(response); + }), + ); + }; + + const request: BitbucketApi["Service"]["request"] = (input) => + send({ ...input, redirects: 0 }).pipe( + Effect.flatMap((response) => + HttpClientResponse.matchStatus({ + // Read through the body stream rather than `text`, so an oversized diff is stopped + // as it arrives instead of being materialized whole and then cut. The same collector + // the process runner bounds command output with. + "2xx": (success) => + collectUint8StreamText({ + stream: success.stream, + maxBytes: input.maxBytes ?? DEFAULT_MAX_RESPONSE_BYTES, + }).pipe( + Effect.mapError( + (cause) => + new BitbucketResponseBodyReadError({ + operation: "request", + status: success.status, + cause, + }), + ), + Effect.map((collected) => ({ + body: collected.text, + truncated: collected.truncated, + })), + ), + orElse: (failed) => responseError("request", failed), + })(response), + ), + ); + return BitbucketApi.of({ + request, probeAuth: executeJson( "probeAuth", HttpClientRequest.get(apiUrl("/user")), diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index 5daf7676d60c..964ed3d021c1 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -373,4 +373,34 @@ describe("GitHubCli.layer", () => { assert.equal(error.message.includes(cause.detail), false); }).pipe(Effect.provide(layer)), ); + + it.effect("surfaces an actionable rate-limit error without exposing provider stderr", () => + Effect.gen(function* () { + const cause = new VcsProcessExitError({ + operation: "GitHubCli.execute", + command: "gh", + cwd: "/repo", + exitCode: 1, + failureKind: "rate-limited", + detail: "API rate limit exceeded.", + stderrLength: 82, + stderrTruncated: false, + }); + mockRun.mockReturnValueOnce(Effect.fail(cause)); + + const gh = yield* GitHubCli.GitHubCli; + const error = yield* gh + .listOpenPullRequests({ + cwd: "/repo", + headSelector: "feature/rate-limited", + }) + .pipe(Effect.flip); + + assert.strictEqual(error._tag, "GitHubCliRateLimitError"); + assert.include(error.detail, "GitHub API rate limit exceeded"); + assert.include(error.detail, "gh api rate_limit"); + assert.strictEqual(error.cause, cause); + assert.notInclude(error.message, "user ID"); + }).pipe(Effect.provide(layer)), + ); }); diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index bf3f27378b5e..974574cbd20e 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -51,6 +51,19 @@ export class GitHubCliAuthenticationError extends Schema.TaggedErrorClass()( + "GitHubCliRateLimitError", + gitHubCliFailureFields, +) { + get detail(): string { + return "GitHub API rate limit exceeded. Run `gh api rate_limit` to inspect the quota and reset time."; + } + + override get message(): string { + return `GitHub CLI failed in execute: ${this.detail}`; + } +} + export class GitHubPullRequestNotFoundError extends Schema.TaggedErrorClass()( "GitHubPullRequestNotFoundError", gitHubCliFailureFields, @@ -138,6 +151,7 @@ export class GitHubRepositoryDecodeError extends Schema.TaggedErrorClass; readonly timeoutMs?: number; + /** Piped to the child's stdin, for payloads that must never appear in argv. */ + readonly stdin?: string; + readonly maxOutputBytes?: number; }) => Effect.Effect; readonly listOpenPullRequests: (input: { @@ -314,6 +334,8 @@ export const make = Effect.gen(function* () { args: input.args, cwd: input.cwd, timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, + ...(input.stdin !== undefined ? { stdin: input.stdin } : {}), + ...(input.maxOutputBytes !== undefined ? { maxOutputBytes: input.maxOutputBytes } : {}), }) .pipe(Effect.mapError((error) => fromVcsError({ command: "gh", cwd: input.cwd }, error))); diff --git a/apps/server/src/sourceControl/GitLabCli.ts b/apps/server/src/sourceControl/GitLabCli.ts index a2926afd0efb..ce17b2f5b5c3 100644 --- a/apps/server/src/sourceControl/GitLabCli.ts +++ b/apps/server/src/sourceControl/GitLabCli.ts @@ -127,6 +127,7 @@ export class GitLabCliCommandError extends Schema.TaggedErrorClass; readonly timeoutMs?: number; + /** Piped to the child's stdin, for payloads that must never appear in argv. */ + readonly stdin?: string; + readonly maxOutputBytes?: number; }) => Effect.Effect; readonly listMergeRequests: (input: { @@ -401,6 +405,8 @@ export const make = Effect.gen(function* () { args: input.args, cwd: input.cwd, timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, + ...(input.stdin === undefined ? {} : { stdin: input.stdin }), + ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }), }) .pipe(Effect.mapError(mapError)); diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index 5c4d27e46f94..54038502bfde 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -203,6 +203,38 @@ self-hosted.example.test }), ); +it.effect("refines the caller-selected remote instead of choosing another configured remote", () => + Effect.gen(function* () { + const registry = yield* makeRegistry({ + remotes: [{ name: "origin", url: "git@github.com:fork/project.git" }], + process: { + run: () => + Effect.succeed( + processOutput(`self-hosted.example.test + ✓ Logged in to self-hosted.example.test as gitlab-user +`), + ), + }, + }); + + const handle = yield* registry.resolveHandle({ + cwd: "/repo", + context: { + provider: { + kind: "unknown", + name: "self-hosted.example.test", + baseUrl: "https://self-hosted.example.test", + }, + remoteName: "upstream", + remoteUrl: "https://self-hosted.example.test/group/project.git", + }, + }); + + assert.strictEqual(handle.context?.provider.kind, "gitlab"); + assert.strictEqual(handle.context?.remoteName, "upstream"); + }), +); + it.effect("routes authenticated self-hosted GitLab remotes on non-standard ports", () => Effect.gen(function* () { const registry = yield* makeRegistry({ diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts index fb70d677e435..9fe089a4184c 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -50,6 +50,7 @@ export class SourceControlProviderRegistry extends Context.Service< >; readonly resolveHandle: (input: { readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; }) => Effect.Effect; readonly resolve: (input: { readonly cwd: string; @@ -254,7 +255,15 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit }); const resolveHandle: SourceControlProviderRegistry["Service"]["resolveHandle"] = (input) => - Cache.get(providerContextCache, input.cwd).pipe( + (input.context === undefined + ? Cache.get(providerContextCache, input.cwd) + : refineUnknownRemoteProvider({ + specs: discoverySpecs, + process, + cwd: input.cwd, + context: input.context, + }) + ).pipe( Effect.map((context) => { const kind = context?.provider.kind ?? "unknown"; const provider = providers.get(kind) ?? unsupportedProvider(kind); diff --git a/apps/server/src/sourceControl/azureDevOpsPullRequests.ts b/apps/server/src/sourceControl/azureDevOpsPullRequests.ts index c059f6f0f9e0..8c3c5c4de56b 100644 --- a/apps/server/src/sourceControl/azureDevOpsPullRequests.ts +++ b/apps/server/src/sourceControl/azureDevOpsPullRequests.ts @@ -72,7 +72,12 @@ function encodeAzureDevOpsPathSegment(segment: string): string { return encodeURIComponent(segment); } -function azureDevOpsOrganizationBaseFromRestApiUrl( +/** + * The organization root a REST url belongs to, which is where a browser url and any further + * REST call have to be hung. Exported because the pull requests page derives its own urls from + * whatever Azure returned rather than from the local remote, whose shape varies. + */ +export function azureDevOpsOrganizationBaseFromRestApiUrl( value: string | null | undefined, ): string | null { const rawUrl = trimOptionalString(value); @@ -104,29 +109,53 @@ function azureDevOpsOrganizationBaseFromRestApiUrl( } } -function normalizeAzureDevOpsPullRequestUrl( - raw: Schema.Schema.Type, -): string { - const webLink = trimOptionalString(raw._links?.web?.href); +/** + * Where a pull request lives in a browser. Azure answers with a web link when asked for one and + * otherwise leaves it to be assembled, so all three routes are tried in the order they can be + * trusted. Takes plain fields so both the source control provider and the pull requests page + * can share it. + */ +export function azureDevOpsPullRequestWebUrl(input: { + readonly pullRequestId: number; + readonly webLink?: string | null | undefined; + readonly repositoryWebUrl?: string | null | undefined; + readonly restApiUrl?: string | null | undefined; + readonly projectName?: string | null | undefined; + readonly repositoryName?: string | null | undefined; +}): string { + const webLink = trimOptionalString(input.webLink); if (webLink) { return webLink; } - const repositoryWebUrl = trimOptionalString(raw.repository?.webUrl); + const repositoryWebUrl = trimOptionalString(input.repositoryWebUrl); if (repositoryWebUrl) { - return `${repositoryWebUrl.replace(/\/+$/, "")}/pullrequest/${raw.pullRequestId}`; + return `${repositoryWebUrl.replace(/\/+$/, "")}/pullrequest/${input.pullRequestId}`; } - const organizationBase = azureDevOpsOrganizationBaseFromRestApiUrl(raw.url); - const projectName = trimOptionalString(raw.repository?.project?.name); - const repositoryName = trimOptionalString(raw.repository?.name); + const organizationBase = azureDevOpsOrganizationBaseFromRestApiUrl(input.restApiUrl); + const projectName = trimOptionalString(input.projectName); + const repositoryName = trimOptionalString(input.repositoryName); if (organizationBase && projectName && repositoryName) { const encodedProjectName = encodeAzureDevOpsPathSegment(projectName); const encodedRepositoryName = encodeAzureDevOpsPathSegment(repositoryName); - return `${organizationBase}/${encodedProjectName}/_git/${encodedRepositoryName}/pullrequest/${raw.pullRequestId}`; + return `${organizationBase}/${encodedProjectName}/_git/${encodedRepositoryName}/pullrequest/${input.pullRequestId}`; } - return trimOptionalString(raw.url) ?? ""; + return trimOptionalString(input.restApiUrl) ?? ""; +} + +function normalizeAzureDevOpsPullRequestUrl( + raw: Schema.Schema.Type, +): string { + return azureDevOpsPullRequestWebUrl({ + pullRequestId: raw.pullRequestId, + webLink: raw._links?.web?.href, + repositoryWebUrl: raw.repository?.webUrl, + restApiUrl: raw.url, + projectName: raw.repository?.project?.name, + repositoryName: raw.repository?.name, + }); } function normalizeAzureDevOpsPullRequestRecord( diff --git a/apps/server/src/stream/collectUint8StreamText.test.ts b/apps/server/src/stream/collectUint8StreamText.test.ts index d6715294cce3..4a41cf11ec68 100644 --- a/apps/server/src/stream/collectUint8StreamText.test.ts +++ b/apps/server/src/stream/collectUint8StreamText.test.ts @@ -17,6 +17,7 @@ describe("collectUint8StreamText", () => { text: "hello world", bytes: 11, truncated: false, + invalidUtf8: false, }); }), ); @@ -33,7 +34,24 @@ describe("collectUint8StreamText", () => { text: "abcde[truncated]", bytes: 5, truncated: true, + invalidUtf8: false, }); }), ); + + it.effect("reports invalid UTF-8 separately from a literal replacement character", () => + Effect.gen(function* () { + const invalid = yield* collectUint8StreamText({ + stream: Stream.make(new Uint8Array([0x66, 0x80, 0x6f])), + }); + const literal = yield* collectUint8StreamText({ + stream: Stream.make(encoder.encode("before\uFFFDafter")), + }); + + assert.strictEqual(invalid.invalidUtf8, true); + assert.strictEqual(invalid.text, "f\uFFFDo"); + assert.strictEqual(literal.invalidUtf8, false); + assert.strictEqual(literal.text, "before\uFFFDafter"); + }), + ); }); diff --git a/apps/server/src/stream/collectUint8StreamText.ts b/apps/server/src/stream/collectUint8StreamText.ts index 7ac5530474e6..71114e1de1b5 100644 --- a/apps/server/src/stream/collectUint8StreamText.ts +++ b/apps/server/src/stream/collectUint8StreamText.ts @@ -1,12 +1,21 @@ import * as Effect from "effect/Effect"; import * as Stream from "effect/Stream"; +import * as NodeBuffer from "node:buffer"; export interface CollectedUint8StreamText { readonly text: string; readonly truncated: boolean; readonly bytes: number; + readonly invalidUtf8: boolean; } +export const decodeUtf8 = ( + bytes: Uint8Array, +): Pick => ({ + text: Buffer.from(bytes).toString("utf8"), + invalidUtf8: !NodeBuffer.isUtf8(bytes), +}); + interface CollectState { chunks: Uint8Array[]; readonly bytes: number; @@ -59,11 +68,15 @@ export const collectUint8StreamText = (input: { }, ), Effect.map((state): CollectedUint8StreamText => { - const text = Buffer.concat(state.chunks, state.bytes).toString("utf8"); + const decoded = decodeUtf8(Buffer.concat(state.chunks, state.bytes)); return { - text: state.truncated && truncatedMarker.length > 0 ? `${text}${truncatedMarker}` : text, + text: + state.truncated && truncatedMarker.length > 0 + ? `${decoded.text}${truncatedMarker}` + : decoded.text, bytes: state.bytes, truncated: state.truncated, + invalidUtf8: decoded.invalidUtf8, }; }), ); diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 1cf7e8dffeca..ed25a0880b47 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -991,6 +991,61 @@ it.layer( }), ); + it.effect("strips replayable CSI and DCS traffic while preserving setters", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(); + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + process.emitData("prompt "); + // DECRQM/DECRPM, XTVERSION, and kitty-keyboard CSI query/reply traffic. + process.emitData("\u001b[?2026$p\u001b[?2026;2$y\u001b[>q\u001b[?u\u001b[?31u"); + // DECRQSS and XTGETTCAP query/reply traffic in 7-bit DCS form. + process.emitData("\u001bP$q m\u001b\\\u001bP1$r0m\u001b\\"); + process.emitData("\u001bP+q544e\u001b\\\u001bP1+r544e=1b\u001b\\"); + // The same DCS traffic in 8-bit form. + process.emitData("\u0090$q m\u009c\u00901$r0m\u009c"); + process.emitData("\u0090+q544e\u009c\u00901+r544e=1b\u009c"); + // Setters and cursor movement share final bytes with query families but + // have visible terminal-state value and must survive replay. + process.emitData('\u001b[!p\u001b["p\u001b[4 q\u001b[u'); + process.emitData("done\n"); + + yield* manager.close({ threadId: "thread-1" }); + + const reopened = yield* manager.open(openInput()); + assert.equal(reopened.history, 'prompt \u001b[!p\u001b["p\u001b[4 q\u001b[udone\n'); + }), + ); + + it.effect("handles CSI and DCS query sequences split across output chunks", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(); + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + process.emitData("before "); + process.emitData("\u001b[?2026$"); + process.emitData("pafter "); + process.emitData("\u001bP$q "); + process.emitData("m\u001b"); + process.emitData("\\after "); + process.emitData("\u009b?3"); + process.emitData("1uafter "); + process.emitData("\u0090+q544e"); + process.emitData("\u009cafter\n"); + + yield* manager.close({ threadId: "thread-1" }); + + const reopened = yield* manager.open(openInput()); + assert.equal(reopened.history, "before after after after after\n"); + }), + ); + it.effect( "preserves clear and style control sequences while dropping chunk-split query traffic", () => @@ -1345,6 +1400,8 @@ it.layer( OWD: "/home/user/project", PATH: `${appDir}/usr/bin:${appDir}:/usr/local/bin:/usr/bin:/bin`, LD_LIBRARY_PATH: `${appDir}/usr/lib:/home/user/.local/lib`, + XDG_DATA_DIRS: `${appDir}/usr/share:/usr/local/share:/usr/share`, + GSETTINGS_SCHEMA_DIR: `${appDir}/usr/share/glib-2.0/schemas`, TEST_TERMINAL_KEEP: "keep-me", }, }); @@ -1364,6 +1421,11 @@ it.layer( // mount segments that the runtime prepended. expect(spawnInput.env.PATH).toBe("/usr/local/bin:/usr/bin:/bin"); expect(spawnInput.env.LD_LIBRARY_PATH).toBe("/home/user/.local/lib"); + // XDG_DATA_DIRS keeps the host entries but drops the AppImage share dir. + expect(spawnInput.env.XDG_DATA_DIRS).toBe("/usr/local/share:/usr/share"); + // GSETTINGS_SCHEMA_DIR pointed only at the mount, so it is removed and + // gsettings falls back to the host schema location. + expect(spawnInput.env.GSETTINGS_SCHEMA_DIR).toBeUndefined(); // Unrelated host vars still pass through untouched. expect(spawnInput.env.TEST_TERMINAL_KEEP).toBe("keep-me"); }), diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index caa5106bb9fd..6dc9e1892b63 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -878,9 +878,31 @@ function shouldStripCsiSequence(body: string, finalByte: string): boolean { if (finalByte === "c" && /^[>0-9;?]*$/.test(body)) { return true; } + // DECRQM mode queries (…$p) and DECRPM replies (…$y): replaying a stored + // query makes the terminal answer again, and the shell echoes the answer as + // junk at the prompt. The `$` guard keeps setters like DECSTR (!p) and + // DECSCL ("p) intact. + if ((finalByte === "p" || finalByte === "y") && /^[0-9;?]*\$$/.test(body)) { + return true; + } + // XTVERSION query (>q). DECSCUSR (space-intermediate q) stays. + if (finalByte === "q" && /^>[0-9;]*$/.test(body)) { + return true; + } + // Kitty keyboard protocol query/reply (?u). Restore-cursor (bare u) stays. + if (finalByte === "u" && body.startsWith("?")) { + return true; + } return false; } +// DECRQSS ($q) and XTGETTCAP (+q) queries plus their replies ([01]$r / [01]+r): +// pure request/response traffic with no visual value, and replaying a stored +// query triggers a fresh reply. +function shouldStripDcsSequence(content: string): boolean { + return /^[01]?[$+][qr]/.test(content); +} + function shouldStripOscSequence(content: string): boolean { return /^(10|11|12);(?:\?|rgb:)/.test(content); } @@ -981,7 +1003,10 @@ function sanitizeTerminalHistoryChunk( } const sequence = input.slice(index, terminatorIndex); const content = stripStringTerminator(input.slice(index + 2, terminatorIndex)); - if (nextCodePoint !== 0x5d || !shouldStripOscSequence(content)) { + const strip = + (nextCodePoint === 0x5d && shouldStripOscSequence(content)) || + (nextCodePoint === 0x50 && shouldStripDcsSequence(content)); + if (!strip) { append(sequence); } index = terminatorIndex; @@ -1024,7 +1049,10 @@ function sanitizeTerminalHistoryChunk( } const sequence = input.slice(index, terminatorIndex); const content = stripStringTerminator(input.slice(index + 1, terminatorIndex)); - if (codePoint !== 0x9d || !shouldStripOscSequence(content)) { + const strip = + (codePoint === 0x9d && shouldStripOscSequence(content)) || + (codePoint === 0x90 && shouldStripDcsSequence(content)); + if (!strip) { append(sequence); } index = terminatorIndex; @@ -1069,10 +1097,19 @@ function shouldExcludeTerminalEnvKey(key: string): boolean { // They describe the AppImage itself, not the user's session, so terminals must // not inherit them. const APPIMAGE_RUNTIME_ENV_KEYS = ["APPIMAGE", "APPDIR", "ARGV0", "OWD"] as const; -// PATH-style variables the AppImage runtime prepends with its temporary mount -// (e.g. /tmp/.mount_T3-XXXX/usr/bin). Only the mount segments are dropped; the -// user's real entries are preserved. -const APPIMAGE_PATH_LIKE_ENV_KEYS = ["PATH", "LD_LIBRARY_PATH"] as const; +// Colon-separated search-path variables the AppImage runtime points at its +// temporary mount (e.g. /tmp/.mount_T3-XXXX/usr/bin, the bundled glib schemas, +// and an $APPDIR/usr/share XDG data entry). Only the mount segments are +// dropped; the user's real entries are preserved. When nothing but mount +// segments remain the variable is removed entirely so consumers fall back to +// their platform default (e.g. gsettings finds the host schemas instead of +// reporting "No schemas installed"). See issues #1699 and #5059. +const APPIMAGE_PATH_LIKE_ENV_KEYS = [ + "PATH", + "LD_LIBRARY_PATH", + "XDG_DATA_DIRS", + "GSETTINGS_SCHEMA_DIR", +] as const; function isPathSegmentUnderAppDir(segment: string, appDir: string): boolean { return segment === appDir || segment.startsWith(`${appDir}/`); diff --git a/apps/server/src/terminal/NodePtyAdapter.test.ts b/apps/server/src/terminal/NodePtyAdapter.test.ts index ed87440d4996..7cf6a167ecfa 100644 --- a/apps/server/src/terminal/NodePtyAdapter.test.ts +++ b/apps/server/src/terminal/NodePtyAdapter.test.ts @@ -33,6 +33,7 @@ const testLayer = NodePtyAdapter.layer.pipe( it.effect("spawns through the public adapter with the provided host references", () => Effect.gen(function* () { + spawn.mockClear(); const adapter = yield* PtyAdapter.PtyAdapter; const process = yield* adapter.spawn({ shell: "powershell.exe", @@ -52,8 +53,35 @@ it.effect("spawns through the public adapter with the provided host references", cwd: "C:\\workspace", cols: 120, rows: 40, - env: {}, - name: "xterm-color", + env: { TERM: "xterm-256color" }, + name: "xterm-256color", + }, + ]); + }).pipe(Effect.provide(testLayer)), +); + +it.effect("preserves a caller-provided TERM in the spawn env on win32", () => + Effect.gen(function* () { + spawn.mockClear(); + const adapter = yield* PtyAdapter.PtyAdapter; + yield* adapter.spawn({ + shell: "powershell.exe", + cwd: "C:\\workspace", + cols: 80, + rows: 24, + env: { TERM: "xterm-direct" }, + }); + + assert.equal(spawn.mock.calls.length, 1); + assert.deepEqual(spawn.mock.calls[0], [ + "powershell.exe", + [], + { + cwd: "C:\\workspace", + cols: 80, + rows: 24, + env: { TERM: "xterm-direct" }, + name: "xterm-256color", }, ]); }).pipe(Effect.provide(testLayer)), diff --git a/apps/server/src/terminal/NodePtyAdapter.ts b/apps/server/src/terminal/NodePtyAdapter.ts index ac06e1edfab8..e9c462ab2c20 100644 --- a/apps/server/src/terminal/NodePtyAdapter.ts +++ b/apps/server/src/terminal/NodePtyAdapter.ts @@ -141,14 +141,21 @@ export const make = Effect.fn("NodePtyAdapter.make")(function* ( return PtyAdapter.PtyAdapter.of({ spawn: Effect.fn("NodePtyAdapter.spawn")(function* (input) { yield* ensureNodePtySpawnHelperExecutableCached; + // node-pty only writes `name` into the child's TERM on the Unix path; + // the ConPTY path leaves the environment untouched, so Windows children + // inherit a missing or 16-color TERM unless it is set here. + const env = + platform === "win32" && input.env["TERM"] === undefined + ? { ...input.env, TERM: "xterm-256color" } + : input.env; const ptyProcess = yield* Effect.try({ try: () => nodePty.spawn(input.shell, input.args ?? [], { cwd: input.cwd, cols: input.cols, rows: input.rows, - env: input.env, - name: platform === "win32" ? "xterm-color" : "xterm-256color", + env, + name: "xterm-256color", }), catch: (cause) => new PtyAdapter.PtySpawnError({ diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts index 0f3905a0cb11..e4552eab3e97 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts @@ -264,7 +264,7 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => { ' "Reconnect failures after restart because the session state does not recover" ', }, }), - stdinMustContain: "You write concise thread titles for coding conversations.", + stdinMustContain: "Please investigate reconnect failures after restarting the session.", }, (textGeneration) => Effect.gen(function* () { diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts index 37304795a22e..aa3e59e2bf21 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts @@ -55,7 +55,7 @@ const ClaudeOutputEnvelope = Schema.Struct({ structured_output: Schema.Unknown, }); -const encodeJsonString = Schema.encodeEffect(Schema.UnknownFromJsonString); +const encodeJsonString = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); const decodeClaudeOutputEnvelope = Schema.decodeEffect(Schema.fromJsonString(ClaudeOutputEnvelope)); export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(function* ( diff --git a/apps/server/src/textGeneration/CodexTextGeneration.ts b/apps/server/src/textGeneration/CodexTextGeneration.ts index 62d917012ae1..0b870ac1d679 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -7,7 +7,12 @@ import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { type CodexSettings, type ModelSelection } from "@t3tools/contracts"; +import { + type CodexSettings, + DEFAULT_TEXT_GENERATION_REASONING_EFFORT, + type ModelSelection, + TextGenerationError, +} from "@t3tools/contracts"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; @@ -15,7 +20,6 @@ import { resolveAttachmentPath } from "../attachmentStore.ts"; import * as ServerConfig from "../config.ts"; import { expandHomePath } from "../pathExpansion.ts"; import { codexExecLaunchArgs, resolveCodexLaunchArgs } from "../provider/Layers/codexLaunchArgs.ts"; -import { TextGenerationError } from "@t3tools/contracts"; import * as TextGeneration from "./TextGeneration.ts"; import { buildBranchNamePrompt, @@ -33,9 +37,8 @@ import { import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { getCodexServiceTierOptionValue } from "../codexModelOptions.ts"; -const CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT = "low"; const CODEX_TIMEOUT_MS = 180_000; -const encodeJsonString = Schema.encodeEffect(Schema.UnknownFromJsonString); +const encodeJsonString = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); /** * Build a Codex text-generation closure bound to a specific `CodexSettings` * payload. See `makeCodexAdapter` for the overall per-instance rationale. @@ -178,7 +181,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func const launchArgs = resolveCodexLaunchArgs(codexConfig.launchArgs, resolvedEnvironment); const reasoningEffort = getModelSelectionStringOptionValue(modelSelection, "reasoningEffort") ?? - CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT; + DEFAULT_TEXT_GENERATION_REASONING_EFFORT; const serviceTier = getCodexServiceTierOptionValue(modelSelection); const spawnCommand = yield* resolveSpawnCommand( codexConfig.binaryPath || "codex", diff --git a/apps/server/src/textGeneration/TextGenerationPrompts.test.ts b/apps/server/src/textGeneration/TextGenerationPrompts.test.ts index ede186640517..7614cc9e00f3 100644 --- a/apps/server/src/textGeneration/TextGenerationPrompts.test.ts +++ b/apps/server/src/textGeneration/TextGenerationPrompts.test.ts @@ -154,6 +154,18 @@ describe("buildThreadTitlePrompt", () => { expect(result.prompt).toContain("User message:"); expect(result.prompt).toContain("Investigate reconnect regressions after session restore"); expect(result.prompt).not.toContain("Attachment metadata:"); + expect(result.prompt).toContain( + "Generate a title that will help the user recognize this T3 Code thread weeks later.", + ); + expect(result.prompt).toContain( + "Title the subject and outcome. Discard incidental instructions.", + ); + expect(result.prompt).toContain( + "Name the product change, not the mock, plan, report, branch, or PR used to produce it.", + ); + expect(result.prompt).not.toContain( + "Title should summarize the user's request, not restate it verbatim.", + ); }); it("includes attachment metadata when attachments are provided", () => { @@ -183,12 +195,17 @@ describe("buildThreadTitlePrompt", () => { }); expect(result.prompt).toContain( - "The user requested a new title based on the contents of this thread.", + "Regenerate the title for an existing T3 Code thread so the user can recognize it weeks later.", ); expect(result.prompt).toContain('The previous title was "Investigate reconnect regressions".'); - expect(result.prompt).toContain("better represents the current state of the thread"); expect(result.prompt).toContain( - "Capture the thread's intent, not a PR number or other superficial detail.", + "Read the USER messages first. Identify the latest explicit durable goal.", + ); + expect(result.prompt).toContain( + "Do not promote one assistant finding into the thread subject unless the user adopts it as a new goal.", + ); + expect(result.prompt).toContain( + 'A subagent-monitoring review that finds a Codex roster bug remains "Review Subagent Monitoring Risks,"', ); expect(result.prompt).toContain("Thread contents:"); expect(result.prompt).toContain("The remaining issue is stale session state"); diff --git a/apps/server/src/textGeneration/TextGenerationPrompts.ts b/apps/server/src/textGeneration/TextGenerationPrompts.ts index 8aed88b1f16b..5eaef8c36ce4 100644 --- a/apps/server/src/textGeneration/TextGenerationPrompts.ts +++ b/apps/server/src/textGeneration/TextGenerationPrompts.ts @@ -152,23 +152,10 @@ interface PromptFromMessageInput { responseShape: string; rules: ReadonlyArray; message: string; - messageLabel?: string | undefined; - preserveMessageEnd?: boolean | undefined; attachments?: ReadonlyArray | undefined; additionalInstructions?: string | undefined; } -function preserveMessageEnd(message: string): string { - const alreadyTruncated = message.startsWith(EARLIER_CONTENT_TRUNCATION_MARKER); - const contents = alreadyTruncated - ? message.slice(EARLIER_CONTENT_TRUNCATION_MARKER.length) - : message; - if (!alreadyTruncated && contents.length <= 8_000) { - return contents; - } - return `${EARLIER_CONTENT_TRUNCATION_MARKER}${contents.slice(-8_000)}`; -} - function buildPromptFromMessage(input: PromptFromMessageInput): string { const attachmentLines = (input.attachments ?? []).map( (attachment) => `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`, @@ -180,10 +167,8 @@ function buildPromptFromMessage(input: PromptFromMessageInput): string { "Rules:", ...input.rules.map((rule) => `- ${rule}`), "", - `${input.messageLabel ?? "User message"}:`, - input.preserveMessageEnd - ? preserveMessageEnd(input.message) - : limitSection(input.message, 8_000), + "User message:", + limitSection(input.message, 8_000), ...policyInstruction(input.additionalInstructions), ]; if (attachmentLines.length > 0) { @@ -229,42 +214,102 @@ export interface ThreadTitlePromptInput { policy?: TextGenerationPolicy | undefined; } +// Keep shared editorial rules in these two prompts in sync. Regeneration +// intentionally adds guidance for thread history and the previous title. +const INITIAL_THREAD_TITLE_PROMPT = `Generate a title that will help the user recognize this T3 Code thread weeks later. +Return JSON with exactly one key: title. + +Before answering, silently reduce the request to: +- Subject: What system, feature, or problem is this really about? +- Outcome: What does the user ultimately want to understand or change? +- Incidental instructions: What only describes how the agent should do the work? + +Title the subject and outcome. Discard incidental instructions. + +Editorial rules: +- 3-8 words, fewer than 40 characters. +- Use a compact noun phrase or clear action phrase. +- Capture the umbrella goal when the request lists several symptoms or steps. +- Name the product change, not the mock, plan, report, branch, or PR used to produce it. +- Models, subagents, tools, output formats, and monitoring instructions do not belong in the title unless they are themselves the topic. +- For reviews, name what is being reviewed and the relevant concern. Avoid generic titles such as "Review PR 123" when linked or attached context reveals the subject. +- For research, name the question domain rather than the requested research process. +- Do not claim the work is complete. +- Do not copy and truncate the user's message. +- Avoid project names already visible in the UI, quotes, labels, filler, and trailing punctuation. +- Use attached images as primary context for UI issues. +- When a URL or attachment is the only source of the subject, use available tools to inspect it. If it cannot be resolved, remain accurate rather than guessing.`; + +function regenerateThreadTitlePrompt(previousTitle: string): string { + return `Regenerate the title for an existing T3 Code thread so the user can recognize it weeks later. +The previous title was ${JSON.stringify(previousTitle)}. +Return JSON with exactly one key: title. + +Determine the title in this order: +1. Read the USER messages first. Identify the latest explicit durable goal. The original subject remains the subject until the user clearly changes what the thread is about. +2. Use ASSISTANT messages to resolve vague links, unnamed code, and discovered product nouns. Do not promote one assistant finding into the thread subject unless the user adopts it as a new goal. +3. Compare that subject with the previous title. Preserve accurate scope words, especially when earlier content is truncated. Replace the previous title when it is generic, artifact-based, a completion update, or contradicted by the thread. +4. Title the durable subject and desired outcome, not the current workflow state. + +Editorial rules: +- 3-8 words, fewer than 40 characters. +- Use a compact noun phrase or clear action phrase. +- Preserve the umbrella subject when later messages focus on one finding, provider, platform, or implementation detail. +- A thread progressing through research, planning, implementation, review, CI, merge, and monitoring has usually not changed subjects. +- Ignore deliverables and operations such as mocks, plans, HTML, branches, PRs, tests, CI, commits, merging, and monitoring unless they are the actual topic. +- Models, subagents, tools, output formats, and monitoring instructions do not belong in the title unless they are themselves the topic. +- Treat final operational follow-ups and assistant completion summaries as weak evidence of subject. +- For reviews, name the reviewed feature or system and its durable concern, not one finding from the review. +- For research, name the question domain rather than the research process. +- Do not claim the work is complete. +- Do not copy and truncate a thread message. +- Avoid project names already visible in the UI, PR numbers, quotes, labels, filler, and trailing punctuation. +- Use attached images as primary context for UI issues. +- When a URL or attachment is the only source of the subject, use available tools to inspect it. If it cannot be resolved, remain accurate rather than guessing. +- Return a meaningfully improved title, not a cosmetic paraphrase of the previous title. + +Examples of the distinction: +- A subagent-monitoring review that finds a Codex roster bug remains "Review Subagent Monitoring Risks," not "Codex Roster Bug Review." +- A vague failing-test request later identified as a lazy thread-feed mismatch becomes "Fix Lazy Thread Feed Test," not "Prevent Mobile Feed Regressions." +- A QR-sharing overhaul that ends with CI and merge work remains about QR sharing, not the PR lifecycle.`; +} + +function preserveMessageEnd(message: string): string { + const alreadyTruncated = message.startsWith(EARLIER_CONTENT_TRUNCATION_MARKER); + const contents = alreadyTruncated + ? message.slice(EARLIER_CONTENT_TRUNCATION_MARKER.length) + : message; + if (!alreadyTruncated && contents.length <= 8_000) { + return contents; + } + return `${EARLIER_CONTENT_TRUNCATION_MARKER}${contents.slice(-8_000)}`; +} + +function threadTitlePromptSuffix(input: ThreadTitlePromptInput): string { + const additionalInstructions = policyInstruction(input.policy?.threadTitleInstructions); + const attachmentLines = (input.attachments ?? []).map( + (attachment) => `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`, + ); + + let suffix = ""; + if (additionalInstructions.length > 0) { + suffix = `\n${additionalInstructions.join("\n")}`; + } + if (attachmentLines.length > 0) { + suffix += `\n\nAttachment metadata:\n${limitSection(attachmentLines.join("\n"), 4_000)}`; + } + return suffix; +} + export function buildThreadTitlePrompt(input: ThreadTitlePromptInput) { - const isRegeneration = input.previousTitle !== undefined; - const prompt = buildPromptFromMessage({ - instruction: isRegeneration - ? [ - "You write concise thread titles for coding conversations.", - "The user requested a new title based on the contents of this thread.", - `The previous title was ${JSON.stringify(input.previousTitle)}.`, - "Come up with a new title that better represents the current state of the thread.", - ].join("\n") - : "You write concise thread titles for coding conversations.", - responseShape: "Return a JSON object with key: title.", - rules: [ - isRegeneration - ? "Title should summarize the thread's current state, not just its initial request." - : "Title should summarize the user's request, not restate it verbatim.", - ...(isRegeneration - ? [ - "Capture the thread's intent, not a PR number or other superficial detail.", - "Return a different title from the previous title.", - ] - : []), - "Keep it short and specific (3-8 words).", - "Avoid quotes, filler, prefixes, and trailing punctuation.", - "If images are attached, use them as primary context for visual/UI issues.", - ], - message: input.message, - ...(isRegeneration - ? { - messageLabel: "Thread contents", - preserveMessageEnd: true, - } - : {}), - attachments: input.attachments, - additionalInstructions: input.policy?.threadTitleInstructions, - }); + let prompt: string; + if (input.previousTitle === undefined) { + const message = limitSection(input.message, 8_000); + prompt = `${INITIAL_THREAD_TITLE_PROMPT}\n\nUser message:\n${message}${threadTitlePromptSuffix(input)}`; + } else { + const message = preserveMessageEnd(input.message); + prompt = `${regenerateThreadTitlePrompt(input.previousTitle)}\n\nThread contents:\n${message}${threadTitlePromptSuffix(input)}`; + } const outputSchema = Schema.Struct({ title: Schema.String, }); diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts new file mode 100644 index 000000000000..0bf131ac973b --- /dev/null +++ b/apps/server/src/usage/UsageService.ts @@ -0,0 +1,448 @@ +/** + * UsageService - scans provider transcripts and returns priced usage buckets. + * + * The scan reads the provider CLIs' own session files rather than T3 Code's + * orchestration projections, so usage covers turns driven outside T3 Code too. + * This is the approach `ccusage` takes. + * + * Transcripts are append-only, so parsed records are memoised per file by + * `(size, mtime)`. A cold 30-day scan of ~1.4 GB lands around 2-3 seconds; warm + * scans only reparse files that changed. + * + * @module UsageService + */ +import * as NodeOS from "node:os"; + +import { + USAGE_CONTRACT_VERSION, + type UsageProviderKind, + type UsageSource, + type UsageSummary, + type UsageSummaryInput, + UsageReadError, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; + +import { ServerConfig } from "../config.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import { resolveClaudeHomePath } from "../provider/Drivers/ClaudeHome.ts"; +import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; +import { UsageAggregator } from "./usageAggregation.ts"; +import { parseRateTable, type RateTable } from "./usagePricing.ts"; +import { + listTranscriptFiles, + readDirectoryVolumeId, + readTranscriptRecords, +} from "./usageTranscriptReader.ts"; +import { + decodeScanCache, + dedupeWithinFile, + encodeScanCache, + pruneScanCache, + type ScanCache, +} from "./usageScanCache.ts"; +import type { UsageRecord } from "./usageTranscripts.ts"; + +const LITELLM_RATES_URL = + "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json"; + +/** Rates move rarely; a day-old table keeps the page working offline. */ +const RATES_TTL_MS = 24 * 60 * 60 * 1000; + +/** + * Files are filtered by mtime before opening. The slack covers a session whose + * last write lands just before local midnight on the window's first day. + */ +const MTIME_SLACK_MS = 36 * 60 * 60 * 1000; +const MAX_HOURLY_WINDOW_MS = 24 * 60 * 60 * 1000; + +/** Longest window the UI offers, plus slack. Older entries are pruned. */ +const CACHE_RETENTION_DAYS = 90; + +/** On-disk shape of the rate snapshot. */ +const RatesCacheFile = Schema.Struct({ + fetchedAtMs: Schema.Number, + document: Schema.Unknown, +}); +const decodeRatesCache = Schema.decodeUnknownEffect( + Schema.fromJsonString(RatesCacheFile as unknown as Schema.Codec), +); +const encodeRatesCache = Schema.encodeEffect( + Schema.fromJsonString(RatesCacheFile as unknown as Schema.Codec), +); + +/** The scan cache is narrowed by hand in `usageScanCache`, so JSON is enough here. */ +const ScanCacheJson = Schema.fromJsonString(Schema.Unknown as unknown as Schema.Codec); +const decodeScanCacheFile = Schema.decodeUnknownEffect(ScanCacheJson); +const encodeScanCacheFile = Schema.encodeEffect(ScanCacheJson); + +export class UsageService extends Context.Service< + UsageService, + { + readonly readSummary: (input: UsageSummaryInput) => Effect.Effect; + } +>()("t3/usage/UsageService") {} + +/** Empty summary, for suites that only need the RPC surface to resolve. */ +export const layerTest = Layer.succeed( + UsageService, + UsageService.of({ + readSummary: (input) => + Effect.succeed({ + contractVersion: USAGE_CONTRACT_VERSION, + readAt: "1970-01-01T00:00:00.000Z", + timeZone: input.timeZone, + sinceDay: input.sinceDay, + untilDay: input.untilDay, + buckets: [], + sources: [], + pricing: { + status: "unavailable", + source: LITELLM_RATES_URL, + fetchedAt: null, + knownModels: 0, + }, + scanDurationMs: 0, + }), + }), +); + +export const make = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const config = yield* ServerConfig; + const settingsService = yield* ServerSettings.ServerSettingsService; + const httpClient = yield* HttpClient.HttpClient; + + const fileCache: ScanCache = new Map(); + let cacheDirty = false; + + const ratesCachePath = path.join(config.stateDir, "usage-model-rates.json"); + const scanCachePath = path.join(config.stateDir, "usage-scan-cache.json"); + let rates: RateTable = new Map(); + let ratesFetchedAtMs: number | null = null; + let ratesStatus: UsageSummary["pricing"]["status"] = "unavailable"; + + /** + * Loads the LiteLLM rate table, preferring a fresh copy and falling back to + * the on-disk snapshot. With neither, every model reports as unpriced rather + * than the page failing. + */ + const ensureRates = Effect.fn("UsageService.ensureRates")(function* () { + const now = yield* Clock.currentTimeMillis; + if (ratesFetchedAtMs !== null && now - ratesFetchedAtMs < RATES_TTL_MS) return; + + if (ratesFetchedAtMs === null) { + const fromDisk = yield* fileSystem.readFileString(ratesCachePath).pipe( + Effect.flatMap((raw) => decodeRatesCache(raw)), + Effect.catchCause(() => Effect.succeed(null)), + ); + if (fromDisk !== null) { + const parsed = parseRateTable(fromDisk.document); + if (parsed.size > 0) { + rates = parsed; + ratesFetchedAtMs = fromDisk.fetchedAtMs; + ratesStatus = "cached"; + if (now - fromDisk.fetchedAtMs < RATES_TTL_MS) return; + } + } + } + + const fetched = yield* httpClient.get(LITELLM_RATES_URL).pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap((response) => response.json), + Effect.timeout(10_000), + Effect.catchCause(() => Effect.succeed(null)), + ); + if (fetched === null) { + // The refresh failed; whatever we are serving is now past its TTL and + // must not keep claiming to be fresh. + if (rates.size > 0) ratesStatus = "cached"; + return; + } + + const parsed = parseRateTable(fetched); + if (parsed.size === 0) return; + + rates = parsed; + ratesFetchedAtMs = now; + ratesStatus = "fresh"; + + yield* encodeRatesCache({ fetchedAtMs: now, document: fetched }).pipe( + Effect.flatMap((serialized) => fileSystem.writeFileString(ratesCachePath, serialized)), + Effect.catchCause(() => Effect.void), + ); + }); + + /** + * Claude's config dir is the home itself when overridden, but a default + * install nests transcripts under `~/.claude/projects`. Probe both. + */ + const resolveClaudeTranscriptDir = (homePath: string) => + Effect.gen(function* () { + const nested = path.join(homePath, ".claude", "projects"); + const nestedExists = yield* fileSystem + .exists(nested) + .pipe(Effect.catchCause(() => Effect.succeed(false))); + return nestedExists ? nested : path.join(homePath, "projects"); + }); + + /** Resolves the transcript directory for each provider. */ + const resolveTranscriptDirs = Effect.fn("UsageService.resolveTranscriptDirs")(function* () { + // A settings failure must surface as an error: swallowing it here would + // present "zero usage from every provider" as a valid answer. + const settings = yield* settingsService.getSettings.pipe( + Effect.catchCause( + (cause) => + new UsageReadError({ + reason: "scanFailed", + // Bounded description; the squashed failure travels as the cause. + // Squashed, not the Cause tree: a full tree in a Defect field is + // the unbounded wire payload the bounded detail exists to avoid. + detail: "Server settings could not be read.", + cause: Cause.squash(cause), + }), + ), + ); + + const claudeHome = yield* resolveClaudeHomePath(settings.providers.claudeAgent); + const claudeDir = yield* resolveClaudeTranscriptDir(claudeHome); + const codexLayout = yield* resolveCodexHomeLayout(settings.providers.codex); + + return [ + { provider: "claude" as const, dir: claudeDir }, + { provider: "codex" as const, dir: path.join(codexLayout.sharedHomePath, "sessions") }, + ]; + }); + + /** + * Loads the persisted scan cache exactly once per process. + * + * `Effect.cached` makes concurrent first readers await the same load rather + * than each seeing a "loaded" flag set before the read finished and cold + * scanning against an empty cache. + */ + const ensureScanCacheLoaded = yield* Effect.cached( + Effect.gen(function* () { + const document = yield* fileSystem.readFileString(scanCachePath).pipe( + Effect.flatMap((raw) => decodeScanCacheFile(raw)), + Effect.catchCause(() => Effect.succeed(null)), + ); + if (document === null) return; + for (const [path, entry] of decodeScanCache(document)) fileCache.set(path, entry); + }), + ); + + const persistScanCache = Effect.fn("UsageService.persistScanCache")(function* () { + if (!cacheDirty) return; + // Cleared only after the write lands, so a failed persist is retried on + // the next scan instead of leaving disk permanently stale. + yield* encodeScanCacheFile(encodeScanCache(fileCache)).pipe( + Effect.flatMap((serialized) => fileSystem.writeFileString(scanCachePath, serialized)), + Effect.map(() => { + cacheDirty = false; + }), + // A cache we cannot write is a slower next start, not a failed read. + Effect.catchCause(() => Effect.void), + ); + }); + + /** Parses one transcript, reusing the cached result when it is unchanged. */ + const readFileRecords = ( + filePath: string, + size: number, + mtimeMs: number, + provider: UsageProviderKind, + ): Effect.Effect => + Effect.gen(function* () { + const cached = fileCache.get(filePath); + // Provider is part of the identity: if both providers were ever pointed + // at one directory, a hit parsed by the other parser must not be reused. + if ( + cached && + cached.size === size && + cached.mtimeMs === mtimeMs && + cached.provider === provider + ) { + return cached.records; + } + + const parsed = yield* Effect.promise(() => readTranscriptRecords(filePath, provider)); + // A read failure is not an empty transcript: caching it under this + // (size, mtime) would silently drop the file's usage until it changes. + if (parsed === null) return []; + // Stored already de-duplicated within the file, which is 99% of all + // duplicates. The aggregator still runs the cross-file dedupe pass. + const records = dedupeWithinFile(parsed); + + fileCache.set(filePath, { size, mtimeMs, provider, records }); + cacheDirty = true; + return records; + }); + + const readSummary = Effect.fn("UsageService.readSummary")(function* (input: UsageSummaryInput) { + if (input.sinceDay > input.untilDay) { + return yield* new UsageReadError({ + reason: "invalidWindow", + detail: `sinceDay '${input.sinceDay}' is after untilDay '${input.untilDay}'`, + }); + } + + let hourlyWindow: { readonly sinceTimeMs: number; readonly untilTimeMs: number } | null = null; + if (input.resolution === "hour") { + const sinceTime = + input.sinceTime === undefined ? Option.none() : DateTime.make(input.sinceTime); + const untilTime = + input.untilTime === undefined ? Option.none() : DateTime.make(input.untilTime); + if (Option.isNone(sinceTime) || Option.isNone(untilTime)) { + return yield* new UsageReadError({ + reason: "invalidWindow", + detail: "Hourly usage requires valid sinceTime and untilTime instants", + }); + } + const sinceTimeMs = DateTime.toEpochMillis(sinceTime.value); + const untilTimeMs = DateTime.toEpochMillis(untilTime.value); + const durationMs = untilTimeMs - sinceTimeMs; + if (durationMs <= 0 || durationMs > MAX_HOURLY_WINDOW_MS) { + return yield* new UsageReadError({ + reason: "invalidWindow", + detail: "Hourly usage window must be greater than zero and at most 24 hours", + }); + } + hourlyWindow = { sinceTimeMs, untilTimeMs }; + } + + const startedAtMs = yield* Clock.currentTimeMillis; + yield* ensureRates(); + yield* ensureScanCacheLoaded; + + const hostId = NodeOS.hostname(); + // The home resolvers ask for `Path` themselves; satisfy them from the + // instance we already hold so `readSummary` stays context-free. + const dirs = yield* resolveTranscriptDirs().pipe(Effect.provideService(Path.Path, path)); + const windowStart = DateTime.make(`${input.sinceDay}T00:00:00Z`); + if (Option.isNone(windowStart)) { + return yield* new UsageReadError({ + reason: "invalidWindow", + detail: `sinceDay '${input.sinceDay}' is not a valid date`, + }); + } + const windowStartMs = + (hourlyWindow?.sinceTimeMs ?? DateTime.toEpochMillis(windowStart.value)) - MTIME_SLACK_MS; + + const aggregator = new UsageAggregator({ + timeZone: input.timeZone, + sinceDay: input.sinceDay, + untilDay: input.untilDay, + resolution: input.resolution ?? "day", + ...hourlyWindow, + rates, + }); + + const sources: UsageSource[] = []; + const livePaths = new Set(); + const walkedRoots: string[] = []; + + for (const { provider, dir } of dirs) { + const volumeId = yield* Effect.promise(() => readDirectoryVolumeId(dir)); + const exists = yield* fileSystem + .exists(dir) + .pipe(Effect.catchCause(() => Effect.succeed(false))); + + if (!exists) { + sources.push({ + fingerprint: { hostId, provider, resolvedHomePath: dir, volumeId }, + status: "missing", + scannedFiles: 0, + skippedFiles: 0, + malformedRecords: 0, + distinctSessions: 0, + message: "No transcript directory on this environment.", + }); + continue; + } + + walkedRoots.push(dir); + const files = yield* Effect.promise(() => listTranscriptFiles(dir, windowStartMs)); + let scannedFiles = 0; + let skippedFiles = 0; + // Distinct per directory. Buckets carry per-cell session counts, but a + // session spans days and models, so clients total this figure instead. + const sessionIds = new Set(); + + for (const file of files) { + livePaths.add(file.path); + const records = yield* readFileRecords(file.path, file.size, file.mtimeMs, provider); + if (records.length === 0) { + skippedFiles += 1; + continue; + } + scannedFiles += 1; + for (const record of records) { + // Only sessions that contributed in-window count: the mtime slack + // admits boundary files whose records fall outside the range. + if (aggregator.add(record) && record.sessionId.length > 0) { + sessionIds.add(record.sessionId); + } + } + } + + sources.push({ + fingerprint: { hostId, provider, resolvedHomePath: dir, volumeId }, + status: "ok", + scannedFiles, + skippedFiles, + malformedRecords: 0, + distinctSessions: sessionIds.size, + message: null, + }); + } + + const pruned = pruneScanCache(fileCache, { + livePaths, + walkedRoots, + windowStartMs, + retentionCutoffMs: startedAtMs - CACHE_RETENTION_DAYS * 24 * 60 * 60 * 1000, + }); + if (pruned > 0) cacheDirty = true; + yield* persistScanCache(); + + const aggregated = aggregator.finish(); + const readAt = yield* DateTime.now; + const finishedAtMs = yield* Clock.currentTimeMillis; + + return { + contractVersion: USAGE_CONTRACT_VERSION, + readAt: DateTime.formatIso(readAt), + timeZone: input.timeZone, + sinceDay: input.sinceDay, + untilDay: input.untilDay, + buckets: aggregated.buckets, + sources, + pricing: { + status: ratesStatus, + source: LITELLM_RATES_URL, + fetchedAt: + ratesFetchedAtMs === null + ? null + : DateTime.formatIso(DateTime.makeUnsafe(ratesFetchedAtMs)), + knownModels: rates.size, + }, + scanDurationMs: Math.max(0, finishedAtMs - startedAtMs), + } satisfies UsageSummary; + }); + + return { readSummary } as const; +}); + +export const layer = Layer.effect(UsageService, make); diff --git a/apps/server/src/usage/usageAggregation.test.ts b/apps/server/src/usage/usageAggregation.test.ts new file mode 100644 index 000000000000..8da4e920ac06 --- /dev/null +++ b/apps/server/src/usage/usageAggregation.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { UsageAggregator } from "./usageAggregation.ts"; +import type { RateTable } from "./usagePricing.ts"; +import type { UsageRecord } from "./usageTranscripts.ts"; + +const rates: RateTable = new Map([ + [ + "claude-fable-5", + { + inputCostPerToken: 1e-5, + outputCostPerToken: 5e-5, + cacheReadCostPerToken: 1e-6, + cacheCreationCostPerToken: 1.25e-5, + }, + ], +]); + +function record(overrides: Partial = {}): UsageRecord { + return { + provider: "claude", + // 2026-08-07T04:05Z is still Aug 6 in Los Angeles. + timestampMs: Date.parse("2026-08-07T04:05:13.944Z"), + model: "claude-fable-5", + sessionId: "session-a", + totals: { + uncachedInputTokens: 100, + cachedInputTokens: 1000, + cacheCreationTokens: 10, + outputTokens: 50, + reasoningTokens: 0, + }, + reportedCostUsd: null, + dedupeKey: null, + ...overrides, + }; +} + +function aggregate( + records: readonly UsageRecord[], + timeZone = "UTC", + resolution: "day" | "hour" = "day", +) { + const hourlyBounds = + resolution === "hour" + ? { + sinceTimeMs: Date.parse("2026-08-06T04:37:00.000Z"), + untilTimeMs: Date.parse("2026-08-07T04:37:00.000Z"), + } + : {}; + const aggregator = new UsageAggregator({ + timeZone, + sinceDay: "2026-08-01", + untilDay: "2026-08-31", + resolution, + ...hourlyBounds, + rates, + }); + for (const item of records) aggregator.add(item); + return aggregator.finish(); +} + +describe("UsageAggregator", () => { + it("requires exact bounds for hourly aggregation", () => { + expect( + () => + new UsageAggregator({ + timeZone: "UTC", + sinceDay: "2026-08-01", + untilDay: "2026-08-31", + resolution: "hour", + rates, + }), + ).toThrow("requires exact time bounds"); + }); + + it("keeps only the first record for a repeated dedupe key", () => { + const result = aggregate([ + record({ dedupeKey: "msg_1:" }), + record({ dedupeKey: "msg_1:" }), + record({ dedupeKey: "msg_1:" }), + ]); + + expect(result.duplicatesDropped).toBe(2); + expect(result.buckets).toHaveLength(1); + expect(result.buckets[0]?.records).toBe(1); + expect(result.buckets[0]?.totals.outputTokens).toBe(50); + }); + + it("still sums records that carry no dedupe key", () => { + const result = aggregate([record(), record()]); + + expect(result.duplicatesDropped).toBe(0); + expect(result.buckets[0]?.totals.outputTokens).toBe(100); + }); + + it("buckets by the day in the requested time zone", () => { + const utc = aggregate([record()], "UTC"); + const losAngeles = aggregate([record()], "America/Los_Angeles"); + + expect(utc.buckets[0]?.day).toBe("2026-08-07"); + expect(losAngeles.buckets[0]?.day).toBe("2026-08-06"); + }); + + it("splits an hourly request into fixed buckets anchored to its exact start", () => { + const result = aggregate( + [ + record({ timestampMs: Date.parse("2026-08-07T02:40:13.944Z") }), + record({ timestampMs: Date.parse("2026-08-07T03:40:13.944Z") }), + ], + "America/Los_Angeles", + "hour", + ); + + expect(result.buckets.map((bucket) => [bucket.day, bucket.hourStart])).toEqual([ + ["2026-08-06", "2026-08-07T02:37:00.000Z"], + ["2026-08-06", "2026-08-07T03:37:00.000Z"], + ]); + }); + + it("uses an inclusive start and exclusive end for rolling windows", () => { + const result = aggregate( + [ + record({ timestampMs: Date.parse("2026-08-06T04:36:59.999Z") }), + record({ timestampMs: Date.parse("2026-08-06T04:37:00.000Z") }), + record({ timestampMs: Date.parse("2026-08-07T04:36:59.999Z") }), + record({ timestampMs: Date.parse("2026-08-07T04:37:00.000Z") }), + ], + "UTC", + "hour", + ); + + expect(result.outOfWindow).toBe(2); + expect(result.buckets.map((bucket) => bucket.hourStart)).toEqual([ + "2026-08-06T04:37:00.000Z", + "2026-08-07T03:37:00.000Z", + ]); + }); + + it("keeps daily payloads collapsed when hourly resolution is not requested", () => { + const result = aggregate([ + record({ timestampMs: Date.parse("2026-08-07T04:05:13.944Z") }), + record({ timestampMs: Date.parse("2026-08-07T05:05:13.944Z") }), + ]); + + expect(result.buckets).toHaveLength(1); + expect(result.buckets[0]?.hourStart).toBeUndefined(); + expect(result.buckets[0]?.records).toBe(2); + }); + + it("prices against the rate table", () => { + const result = aggregate([record()]); + + // 100*1e-5 + 1000*1e-6 + 10*1.25e-5 + 50*5e-5 + expect(result.buckets[0]?.costUsd).toBeCloseTo(0.004625, 9); + expect(result.buckets[0]?.costSource).toBe("modelPriced"); + }); + + it("counts tokens but not cost for a model with no rate", () => { + const result = aggregate([record({ model: "kimi-k3" })]); + + expect(result.buckets[0]?.costUsd).toBe(0); + expect(result.buckets[0]?.costSource).toBe("unpriced"); + expect(result.buckets[0]?.unpricedRecords).toBe(1); + expect(result.buckets[0]?.totals.outputTokens).toBe(50); + }); + + it("prefers a reported cost over the rate table", () => { + const result = aggregate([record({ reportedCostUsd: 1.25 })]); + + expect(result.buckets[0]?.costUsd).toBe(1.25); + expect(result.buckets[0]?.costSource).toBe("providerReported"); + }); + + it("drops records outside the window", () => { + const result = aggregate([record({ timestampMs: Date.parse("2026-07-01T12:00:00Z") })]); + + expect(result.outOfWindow).toBe(1); + expect(result.buckets).toHaveLength(0); + }); + + it("reports whether a record contributed", () => { + const aggregator = new UsageAggregator({ + timeZone: "UTC", + sinceDay: "2026-08-01", + untilDay: "2026-08-31", + rates, + }); + + expect(aggregator.add(record({ dedupeKey: "msg_1:" }))).toBe(true); + expect(aggregator.add(record({ dedupeKey: "msg_1:" }))).toBe(false); + expect(aggregator.add(record({ timestampMs: Date.parse("2026-07-01T12:00:00Z") }))).toBe(false); + }); + + it("separates providers and models into their own buckets", () => { + const result = aggregate([ + record(), + record({ provider: "codex", model: "gpt-5.6-sol" }), + record({ model: "claude-opus-5" }), + ]); + + expect(result.buckets).toHaveLength(3); + }); +}); diff --git a/apps/server/src/usage/usageAggregation.ts b/apps/server/src/usage/usageAggregation.ts new file mode 100644 index 000000000000..e100be76e979 --- /dev/null +++ b/apps/server/src/usage/usageAggregation.ts @@ -0,0 +1,224 @@ +// @effect-diagnostics globalDate:off +/** + * Folds parsed transcript records into `(day, hourStart?, provider, model)` + * buckets. + * + * `Intl.DateTimeFormat` is the only reliable way to resolve a wall-clock day in + * an arbitrary IANA zone, and it takes a `Date`. That is why the raw `Date` + * construction is allowed here; nothing in this module reads the clock. + * + * Pure, so the bucketing and de-duplication rules are testable without touching + * the filesystem or the network. + * + * @module usageAggregation + */ +import type { UsageBucket, UsageDay, UsageResolution, UsageTokenTotals } from "@t3tools/contracts"; + +import { addTotals, EMPTY_TOTALS, type UsageRecord } from "./usageTranscripts.ts"; +import { cacheSavingsUsd, priceUsage, type RateTable } from "./usagePricing.ts"; + +/** + * Formats an instant as a `YYYY-MM-DD` day in `timeZone`. + * + * `en-CA` yields ISO-ordered parts, which is why it is used here rather than + * assembling the day from `Date` getters (those are host-local only). + */ +export function makeDayFormatter(timeZone: string): (timestampMs: number) => string { + let format: Intl.DateTimeFormat; + try { + format = new Intl.DateTimeFormat("en-CA", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + } catch { + // An unknown zone should degrade to UTC rather than fail the whole scan. + format = new Intl.DateTimeFormat("en-CA", { + timeZone: "UTC", + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + } + return (timestampMs) => format.format(new Date(timestampMs)); +} + +const HOUR_MS = 60 * 60 * 1000; + +interface MutableBucket { + totals: UsageTokenTotals; + costUsd: number; + cacheSavingsUsd: number; + records: number; + unpricedRecords: number; + providerReportedRecords: number; + sessions: Set; +} + +export interface AggregateOptions { + readonly timeZone: string; + readonly sinceDay: string; + readonly untilDay: string; + readonly rates: RateTable; + readonly resolution?: UsageResolution; + readonly sinceTimeMs?: number; + readonly untilTimeMs?: number; +} + +export interface AggregateResult { + readonly buckets: readonly UsageBucket[]; + /** Records dropped because an earlier record carried the same dedupe key. */ + readonly duplicatesDropped: number; + /** Records whose day fell outside the requested window. */ + readonly outOfWindow: number; +} + +/** + * Accumulates records across many files. + * + * De-duplication is global across the whole scan, not per file: Claude Code + * copies a message's records forward when a session is resumed or forked, so + * the same `dedupeKey` legitimately appears in several transcripts. + */ +export class UsageAggregator { + readonly #buckets = new Map(); + readonly #seen = new Set(); + readonly #toDay: (timestampMs: number) => string; + readonly #hourlyWindow: { readonly sinceTimeMs: number; readonly untilTimeMs: number } | null; + readonly #options: AggregateOptions; + #duplicatesDropped = 0; + #outOfWindow = 0; + + constructor(options: AggregateOptions) { + this.#options = options; + this.#toDay = makeDayFormatter(options.timeZone); + if (options.resolution === "hour") { + if (options.sinceTimeMs === undefined || options.untilTimeMs === undefined) { + throw new Error("Hourly usage aggregation requires exact time bounds"); + } + this.#hourlyWindow = { + sinceTimeMs: options.sinceTimeMs, + untilTimeMs: options.untilTimeMs, + }; + } else { + this.#hourlyWindow = null; + } + } + + /** + * Folds one record in. Returns whether it actually contributed, so callers + * can derive per-window facts (distinct sessions, for one) from the records + * that landed rather than everything the mtime prefilter happened to admit. + */ + add(record: UsageRecord): boolean { + if (record.dedupeKey !== null) { + if (this.#seen.has(record.dedupeKey)) { + this.#duplicatesDropped += 1; + return false; + } + this.#seen.add(record.dedupeKey); + } + + if ( + this.#hourlyWindow !== null && + (record.timestampMs < this.#hourlyWindow.sinceTimeMs || + record.timestampMs >= this.#hourlyWindow.untilTimeMs) + ) { + this.#outOfWindow += 1; + return false; + } + + const day = this.#toDay(record.timestampMs); + if ( + this.#hourlyWindow === null && + (day < this.#options.sinceDay || day > this.#options.untilDay) + ) { + this.#outOfWindow += 1; + return false; + } + + const hourStart = + this.#hourlyWindow === null + ? "" + : new Date( + this.#hourlyWindow.sinceTimeMs + + Math.floor((record.timestampMs - this.#hourlyWindow.sinceTimeMs) / HOUR_MS) * HOUR_MS, + ).toISOString(); + const key = `${day}\u0000${hourStart}\u0000${record.provider}\u0000${record.model}`; + let bucket = this.#buckets.get(key); + if (bucket === undefined) { + bucket = { + totals: EMPTY_TOTALS, + costUsd: 0, + cacheSavingsUsd: 0, + records: 0, + unpricedRecords: 0, + providerReportedRecords: 0, + sessions: new Set(), + }; + this.#buckets.set(key, bucket); + } + + const priced = priceUsage( + this.#options.rates, + record.model, + record.totals, + record.reportedCostUsd, + ); + + bucket.totals = addTotals(bucket.totals, record.totals); + bucket.costUsd += priced.costUsd; + bucket.cacheSavingsUsd += cacheSavingsUsd(this.#options.rates, record.model, record.totals); + bucket.records += 1; + if (priced.costSource === "unpriced") bucket.unpricedRecords += 1; + if (priced.costSource === "providerReported") bucket.providerReportedRecords += 1; + if (record.sessionId.length > 0) bucket.sessions.add(record.sessionId); + return true; + } + + finish(): AggregateResult { + const buckets: UsageBucket[] = []; + for (const [key, bucket] of this.#buckets) { + const [day = "", hourStart = "", provider = "", model = ""] = key.split("\u0000"); + buckets.push({ + day: day as UsageDay, + ...(hourStart === "" ? {} : { hourStart }), + provider: provider as UsageBucket["provider"], + model, + totals: bucket.totals, + costUsd: bucket.costUsd, + cacheSavingsUsd: bucket.cacheSavingsUsd, + costSource: resolveCostSource(bucket), + records: bucket.records, + unpricedRecords: bucket.unpricedRecords, + sessions: bucket.sessions.size, + }); + } + // Stable ordering keeps payloads diffable and snapshot tests meaningful. + buckets.sort( + (a, b) => + a.day.localeCompare(b.day) || + (a.hourStart ?? "").localeCompare(b.hourStart ?? "") || + a.provider.localeCompare(b.provider) || + a.model.localeCompare(b.model), + ); + + return { + buckets, + duplicatesDropped: this.#duplicatesDropped, + outOfWindow: this.#outOfWindow, + }; + } +} + +/** + * A bucket mixes records from one model, but their cost provenance can differ + * when only some records carried a reported cost. The weakest provenance in the + * bucket wins so the UI never overstates confidence. + */ +function resolveCostSource(bucket: MutableBucket): UsageBucket["costSource"] { + if (bucket.unpricedRecords === bucket.records) return "unpriced"; + if (bucket.providerReportedRecords === bucket.records) return "providerReported"; + return "modelPriced"; +} diff --git a/apps/server/src/usage/usagePricing.ts b/apps/server/src/usage/usagePricing.ts new file mode 100644 index 000000000000..f0e59a874399 --- /dev/null +++ b/apps/server/src/usage/usagePricing.ts @@ -0,0 +1,148 @@ +/** + * Model rate lookup and cost arithmetic. + * + * Rates come from LiteLLM's `model_prices_and_context_window.json`, the same + * table `ccusage` prices against. Everything here is pure: fetching and caching + * the table lives in `UsageService`. + * + * @module usagePricing + */ +import type { UsageCostSource, UsageTokenTotals } from "@t3tools/contracts"; + +/** + * The subset of a LiteLLM entry we price against. All values are USD per token. + * + * LiteLLM also publishes tiered variants (`*_above_272k_tokens`, `*_flex`, + * `*_priority`, `*_batches`). We deliberately price at the base tier: the + * transcripts don't record which tier served a request, so anything else would + * be a guess dressed up as precision. + */ +export interface ModelRate { + readonly inputCostPerToken: number; + readonly outputCostPerToken: number; + readonly cacheReadCostPerToken: number; + readonly cacheCreationCostPerToken: number; +} + +export type RateTable = ReadonlyMap; + +/** Raw shape of one LiteLLM entry, narrowed to the fields we read. */ +interface LiteLlmEntry { + readonly input_cost_per_token?: unknown; + readonly output_cost_per_token?: unknown; + readonly cache_read_input_token_cost?: unknown; + readonly cache_creation_input_token_cost?: unknown; +} + +function finiteNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +/** + * Projects the LiteLLM document into a rate table. + * + * Entries without both an input and an output rate are dropped: a half-priced + * model would silently under-report cost, which is worse than reporting the + * model as unpriced. + */ +export function parseRateTable(document: unknown): RateTable { + const table = new Map(); + if (typeof document !== "object" || document === null) return table; + + for (const [name, raw] of Object.entries(document as Record)) { + if (typeof raw !== "object" || raw === null) continue; + const entry = raw as LiteLlmEntry; + const input = finiteNumber(entry.input_cost_per_token); + const output = finiteNumber(entry.output_cost_per_token); + if (input === null || output === null) continue; + + table.set(normalizeModelName(name), { + inputCostPerToken: input, + outputCostPerToken: output, + // Anthropic bills cache reads at a discount and cache writes at a + // premium. When a model omits them, cached input is priced as plain + // input rather than as free. + cacheReadCostPerToken: finiteNumber(entry.cache_read_input_token_cost) ?? input, + cacheCreationCostPerToken: finiteNumber(entry.cache_creation_input_token_cost) ?? input, + }); + } + return table; +} + +/** + * Canonicalises a model name for lookup. + * + * Strips a `provider/` prefix (LiteLLM publishes both `claude-opus-5` and + * `anthropic/claude-opus-5`) and lowercases, since transcripts are inconsistent + * about casing. + */ +export function normalizeModelName(model: string): string { + const trimmed = model.trim().toLowerCase(); + const slash = trimmed.lastIndexOf("/"); + return slash === -1 ? trimmed : trimmed.slice(slash + 1); +} + +/** + * Models we never price, regardless of the table. + * + * `` marks locally generated messages that were never billed. Bare + * family names ("opus", "sonnet") are genuinely ambiguous across generations, + * so we report them as unpriced instead of guessing a generation. + */ +const UNPRICEABLE_MODELS = new Set([ + "", + "synthetic", + "opus", + "sonnet", + "haiku", + "fable", +]); + +export function lookupRate(table: RateTable, model: string): ModelRate | null { + const normalized = normalizeModelName(model); + if (normalized.length === 0 || UNPRICEABLE_MODELS.has(normalized)) return null; + return table.get(normalized) ?? null; +} + +export interface PricedUsage { + readonly costUsd: number; + readonly costSource: UsageCostSource; +} + +/** + * Prices a bucket's tokens. + * + * `reasoningTokens` is intentionally not charged separately: it is already + * counted inside `outputTokens`. + */ +export function priceUsage( + table: RateTable, + model: string, + totals: UsageTokenTotals, + reportedCostUsd: number | null, +): PricedUsage { + if (reportedCostUsd !== null && Number.isFinite(reportedCostUsd)) { + return { costUsd: reportedCostUsd, costSource: "providerReported" }; + } + + const rate = lookupRate(table, model); + if (rate === null) return { costUsd: 0, costSource: "unpriced" }; + + const costUsd = + totals.uncachedInputTokens * rate.inputCostPerToken + + totals.cachedInputTokens * rate.cacheReadCostPerToken + + totals.cacheCreationTokens * rate.cacheCreationCostPerToken + + totals.outputTokens * rate.outputCostPerToken; + + return { costUsd, costSource: "modelPriced" }; +} + +/** + * What the cached input would have cost at full input rates, minus what it + * actually cost. Drives the "cache savings" figure. + */ +export function cacheSavingsUsd(table: RateTable, model: string, totals: UsageTokenTotals): number { + const rate = lookupRate(table, model); + if (rate === null) return 0; + return totals.cachedInputTokens * (rate.inputCostPerToken - rate.cacheReadCostPerToken); +} diff --git a/apps/server/src/usage/usageScanCache.test.ts b/apps/server/src/usage/usageScanCache.test.ts new file mode 100644 index 000000000000..64673e96c090 --- /dev/null +++ b/apps/server/src/usage/usageScanCache.test.ts @@ -0,0 +1,206 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { + decodeScanCache, + dedupeWithinFile, + encodeScanCache, + pruneScanCache, + type ScanCache, +} from "./usageScanCache.ts"; +import type { UsageRecord } from "./usageTranscripts.ts"; + +function record(overrides: Partial = {}): UsageRecord { + return { + provider: "claude", + timestampMs: 1_786_000_000_000, + model: "claude-fable-5", + sessionId: "session-a", + totals: { + uncachedInputTokens: 2, + cachedInputTokens: 1000, + cacheCreationTokens: 10, + outputTokens: 50, + reasoningTokens: 0, + }, + reportedCostUsd: null, + dedupeKey: "msg_1:", + ...overrides, + }; +} + +function cacheWith(entries: readonly [string, number, readonly UsageRecord[]][]): ScanCache { + const cache: ScanCache = new Map(); + for (const [path, mtimeMs, records] of entries) { + cache.set(path, { size: records.length * 10, mtimeMs, provider: "claude", records }); + } + return cache; +} + +describe("scan cache round trip", () => { + it("restores records unchanged", () => { + const original = cacheWith([ + ["/a.jsonl", 100, [record(), record({ dedupeKey: "msg_2:", model: "claude-opus-5" })]], + ["/b.jsonl", 200, [record({ sessionId: "session-b", reportedCostUsd: 1.5 })]], + ]); + + const restored = decodeScanCache(JSON.parse(JSON.stringify(encodeScanCache(original)))); + + expect(restored.size).toBe(2); + expect(restored.get("/a.jsonl")).toEqual(original.get("/a.jsonl")); + expect(restored.get("/b.jsonl")).toEqual(original.get("/b.jsonl")); + }); + + it("interns repeated model and session strings", () => { + const encoded = encodeScanCache( + cacheWith([["/a.jsonl", 100, [record(), record({ dedupeKey: "msg_2:" }), record()]]]), + ); + + expect(encoded.models).toEqual(["claude-fable-5"]); + expect(encoded.sessions).toEqual(["session-a"]); + }); + + it("treats a corrupt or foreign document as an empty cache", () => { + // A bad cache should cost one cold scan, never a broken page. + expect(decodeScanCache(null).size).toBe(0); + expect(decodeScanCache("nonsense").size).toBe(0); + expect(decodeScanCache({ version: 999, models: [], sessions: [], files: {} }).size).toBe(0); + }); + + it("skips malformed file entries but keeps good ones", () => { + const encoded = encodeScanCache(cacheWith([["/good.jsonl", 100, [record()]]])); + const withJunk = { + ...encoded, + files: { ...encoded.files, "/bad.jsonl": { s: "nope", m: 1, p: "claude", r: [] } }, + }; + + const restored = decodeScanCache(JSON.parse(JSON.stringify(withJunk))); + expect([...restored.keys()]).toEqual(["/good.jsonl"]); + }); + + it("rejects the whole cache when an intern table holds a non-string", () => { + // models: [1] would pass the undefined guard, put a number in a record's + // model, and crash normalizeModelName at aggregate time. + const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record()]]])); + const poisoned = { ...encoded, models: [1] }; + + expect(decodeScanCache(JSON.parse(JSON.stringify(poisoned))).size).toBe(0); + }); + + it("drops the whole entry when any row is corrupt, forcing a cold re-parse", () => { + // Keeping the surviving rows under the original (size, mtime) would read + // as a valid warm hit and the file would never be re-parsed. + const encoded = encodeScanCache( + cacheWith([["/a.jsonl", 100, [record(), record({ dedupeKey: "msg_2:" })]]]), + ); + const rows = encoded.files["/a.jsonl"]!.r; + const poisoned = { + ...encoded, + files: { + "/a.jsonl": { + ...encoded.files["/a.jsonl"]!, + r: [rows[0]!, [...rows[1]!.slice(0, 3), "not-a-number", ...rows[1]!.slice(4)]], + }, + }, + }; + + const restored = decodeScanCache(JSON.parse(JSON.stringify(poisoned))); + expect(restored.has("/a.jsonl")).toBe(false); + }); +}); + +describe("pruneScanCache", () => { + const retentionCutoffMs = 1000; + + it("drops entries older than retention", () => { + const cache = cacheWith([["/old.jsonl", 500, [record()]]]); + + const removed = pruneScanCache(cache, { + livePaths: new Set(), + walkedRoots: ["/"], + windowStartMs: 400, + retentionCutoffMs, + }); + + expect(removed).toBe(1); + expect(cache.size).toBe(0); + }); + + it("drops in-window entries whose file has disappeared", () => { + const cache = cacheWith([["/gone.jsonl", 5000, [record()]]]); + + pruneScanCache(cache, { + livePaths: new Set(), + walkedRoots: ["/"], + windowStartMs: 4000, + retentionCutoffMs, + }); + + expect(cache.size).toBe(0); + }); + + it("keeps entries outside the walked window that are still within retention", () => { + // Viewing 7 days must not evict the 30-day entries, which that walk never + // looked for and so cannot prove are gone. + const cache = cacheWith([["/older-but-valid.jsonl", 2000, [record()]]]); + + const removed = pruneScanCache(cache, { + livePaths: new Set(), + walkedRoots: ["/"], + windowStartMs: 4000, + retentionCutoffMs, + }); + + expect(removed).toBe(0); + expect(cache.size).toBe(1); + }); + + it("keeps entries the walk saw", () => { + const cache = cacheWith([["/live.jsonl", 5000, [record()]]]); + + pruneScanCache(cache, { + livePaths: new Set(["/live.jsonl"]), + walkedRoots: ["/"], + windowStartMs: 4000, + retentionCutoffMs, + }); + + expect(cache.size).toBe(1); + }); +}); + +describe("pruneScanCache with an unwalked root", () => { + it("keeps in-window entries for a provider whose directory was not walked", () => { + // A missing provider root or failed settings read leaves livePaths without + // that provider's files. Its warm entries must survive the pass. + const cache = cacheWith([["/codex/sessions/a.jsonl", 5000, [record()]]]); + + const removed = pruneScanCache(cache, { + livePaths: new Set(), + walkedRoots: ["/claude/projects"], + windowStartMs: 4000, + retentionCutoffMs: 1000, + }); + + expect(removed).toBe(0); + expect(cache.size).toBe(1); + }); +}); + +describe("dedupeWithinFile", () => { + it("keeps the first record per dedupe key", () => { + const kept = dedupeWithinFile([ + record({ totals: { ...record().totals, outputTokens: 1 } }), + record({ totals: { ...record().totals, outputTokens: 999 } }), + record({ dedupeKey: "msg_2:" }), + ]); + + expect(kept).toHaveLength(2); + expect(kept[0]?.totals.outputTokens).toBe(1); + }); + + it("keeps every record that has no dedupe key", () => { + expect( + dedupeWithinFile([record({ dedupeKey: null }), record({ dedupeKey: null })]), + ).toHaveLength(2); + }); +}); diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts new file mode 100644 index 000000000000..cc15ee9cee62 --- /dev/null +++ b/apps/server/src/usage/usageScanCache.ts @@ -0,0 +1,255 @@ +/** + * Durable per-file scan cache. + * + * Transcripts are append-only and a file that has not changed can never yield + * different usage, so parsed records are keyed by `(size, mtime)` and reused. + * Without this every server restart re-parses the whole window: roughly 3.5s + * for a 30-day scan here, against ~11ms to reload this cache. + * + * Caching *per file* rather than per day is deliberate. It is timezone + * independent, so changing the reporting zone does not invalidate anything, and + * it keeps cross-file de-duplication exact: cached entries are de-duplicated + * within their own file only, and the aggregator still applies the global + * dedupe pass over the small surviving key set. + * + * @module usageScanCache + */ +import type { UsageProviderKind } from "@t3tools/contracts"; + +import type { UsageRecord } from "./usageTranscripts.ts"; + +// v2: Codex fork-copy suppression changed what a file parses to, so v1 +// entries would keep serving double-counted records forever. +export const USAGE_SCAN_CACHE_VERSION = 2 as const; + +export interface CachedFile { + readonly size: number; + readonly mtimeMs: number; + readonly provider: UsageProviderKind; + readonly records: readonly UsageRecord[]; +} + +export type ScanCache = Map; + +/** + * Row layout for the serialised form. Positional and interned rather than + * object-per-record: on a 30-day window that is the difference between a file + * measured in tens of megabytes and one under six. + */ +type SerializedRecord = readonly [ + timestampMs: number, + modelIndex: number, + sessionIndex: number, + uncachedInputTokens: number, + cachedInputTokens: number, + cacheCreationTokens: number, + outputTokens: number, + reasoningTokens: number, + dedupeKey: string | null, + reportedCostUsd: number | null, +]; + +interface SerializedFile { + readonly s: number; + readonly m: number; + readonly p: UsageProviderKind; + readonly r: readonly SerializedRecord[]; +} + +interface SerializedCache { + readonly version: number; + readonly models: readonly string[]; + readonly sessions: readonly string[]; + readonly files: Readonly>; +} + +/** Serialises the cache, interning the repeated model and session strings. */ +export function encodeScanCache(cache: ScanCache): SerializedCache { + const models: string[] = []; + const sessions: string[] = []; + const modelIndex = new Map(); + const sessionIndex = new Map(); + + const intern = (table: string[], index: Map, value: string): number => { + const existing = index.get(value); + if (existing !== undefined) return existing; + const next = table.length; + table.push(value); + index.set(value, next); + return next; + }; + + const files: Record = {}; + for (const [path, entry] of cache) { + files[path] = { + s: entry.size, + m: entry.mtimeMs, + p: entry.provider, + r: entry.records.map((record) => [ + record.timestampMs, + intern(models, modelIndex, record.model), + intern(sessions, sessionIndex, record.sessionId), + record.totals.uncachedInputTokens, + record.totals.cachedInputTokens, + record.totals.cacheCreationTokens, + record.totals.outputTokens, + record.totals.reasoningTokens, + record.dedupeKey, + record.reportedCostUsd, + ]), + }; + } + + return { version: USAGE_SCAN_CACHE_VERSION, models, sessions, files }; +} + +function isRecordArray(value: unknown): value is readonly unknown[] { + return Array.isArray(value); +} + +/** + * Rebuilds the cache from a parsed document. + * + * Anything malformed yields an empty cache rather than an error: a corrupt + * cache should cost one cold scan, never a broken page. + */ +export function decodeScanCache(document: unknown): ScanCache { + const cache: ScanCache = new Map(); + if (typeof document !== "object" || document === null) return cache; + + const root = document as Partial; + if (root.version !== USAGE_SCAN_CACHE_VERSION) return cache; + if (!isRecordArray(root.models) || !isRecordArray(root.sessions)) return cache; + if (typeof root.files !== "object" || root.files === null) return cache; + + // The intern tables must be all strings: a numeric entry would pass the + // undefined guard below, land in a record's model, and crash the aggregate + // at normalizeModelName. A corrupt table rejects the whole cache. + if (!root.models.every((value) => typeof value === "string")) return cache; + if (!root.sessions.every((value) => typeof value === "string")) return cache; + const models = root.models as readonly string[]; + const sessions = root.sessions as readonly string[]; + + for (const [path, raw] of Object.entries(root.files)) { + if (typeof raw !== "object" || raw === null) continue; + const entry = raw as Partial; + if (typeof entry.s !== "number" || typeof entry.m !== "number") continue; + if (entry.p !== "claude" && entry.p !== "codex") continue; + if (!isRecordArray(entry.r)) continue; + + const provider: UsageProviderKind = entry.p; + const records: UsageRecord[] = []; + // Any corrupt row disqualifies the whole entry. Keeping the survivors + // under the original (size, mtime) would read as a valid warm hit and the + // file would never be re-parsed, silently losing the dropped rows' usage. + let corrupt = false; + for (const row of entry.r) { + if (!isRecordArray(row) || row.length < 10) { + corrupt = true; + break; + } + const [ + timestampMs, + modelIndex, + sessionIndex, + uncached, + cached, + cacheCreation, + output, + reasoning, + dedupeKey, + reportedCostUsd, + ] = row as SerializedRecord; + + const model = typeof modelIndex === "number" ? models[modelIndex] : undefined; + if ( + typeof timestampMs !== "number" || + !Number.isFinite(timestampMs) || + model === undefined || + !Number.isFinite(uncached) || + !Number.isFinite(cached) || + !Number.isFinite(cacheCreation) || + !Number.isFinite(output) || + !Number.isFinite(reasoning) + ) { + corrupt = true; + break; + } + + records.push({ + provider, + timestampMs, + model, + sessionId: (typeof sessionIndex === "number" ? sessions[sessionIndex] : undefined) ?? "", + totals: { + uncachedInputTokens: uncached, + cachedInputTokens: cached, + cacheCreationTokens: cacheCreation, + outputTokens: output, + reasoningTokens: reasoning, + }, + reportedCostUsd: typeof reportedCostUsd === "number" ? reportedCostUsd : null, + dedupeKey: typeof dedupeKey === "string" ? dedupeKey : null, + }); + } + + if (corrupt) continue; + cache.set(path, { size: entry.s, mtimeMs: entry.m, provider, records }); + } + + return cache; +} + +export interface PruneOptions { + /** Files the walk just saw. Only meaningful inside the walked window. */ + readonly livePaths: ReadonlySet; + /** + * Roots the walk actually completed. Absence from `livePaths` only proves a + * file is gone when its root was walked: a provider whose directory failed to + * resolve this pass must not have its warm entries purged. + */ + readonly walkedRoots: readonly string[]; + /** Start of the walked window; entries older than this were not looked for. */ + readonly windowStartMs: number; + /** Entries older than this are dropped regardless. */ + readonly retentionCutoffMs: number; +} + +/** + * Drops aged-out entries, and entries for files that have disappeared. + * + * The walk only covers the requested window, so absence from `livePaths` only + * proves deletion for entries *inside* that window. Pruning everything the walk + * missed would evict the 30-day entries every time someone looked at 7 days. + * + * Replaces an earlier record cap that cleared the whole cache once exceeded, + * which meant a large enough window never warmed up at all. + */ +export function pruneScanCache(cache: ScanCache, options: PruneOptions): number { + let removed = 0; + for (const [path, entry] of cache) { + const agedOut = entry.mtimeMs < options.retentionCutoffMs; + const underWalkedRoot = options.walkedRoots.some((root) => path.startsWith(root)); + const deleted = + underWalkedRoot && entry.mtimeMs >= options.windowStartMs && !options.livePaths.has(path); + if (agedOut || deleted) { + cache.delete(path); + removed += 1; + } + } + return removed; +} + +/** Within-file de-duplication, applied before an entry is cached. */ +export function dedupeWithinFile(records: readonly UsageRecord[]): readonly UsageRecord[] { + const seen = new Set(); + const kept: UsageRecord[] = []; + for (const record of records) { + if (record.dedupeKey !== null) { + if (seen.has(record.dedupeKey)) continue; + seen.add(record.dedupeKey); + } + kept.push(record); + } + return kept; +} diff --git a/apps/server/src/usage/usageTranscriptReader.ts b/apps/server/src/usage/usageTranscriptReader.ts new file mode 100644 index 000000000000..c72f0c24db65 --- /dev/null +++ b/apps/server/src/usage/usageTranscriptReader.ts @@ -0,0 +1,141 @@ +// @effect-diagnostics nodeBuiltinImport:off +/** + * Raw filesystem access for transcript scanning. + * + * Isolated here so the rest of the usage code stays on Effect's `FileSystem`. + * The direct `node:fs` streaming is deliberate: a cold 30-day window is ~1.4 GB + * across ~1,500 files, and `readline` over a read stream is roughly an order of + * magnitude cheaper than materialising each file. The equivalent Effect stream + * pipeline is idiomatic but not fast enough to sit behind a page load. + * + * @module usageTranscriptReader + */ +import * as NodeFS from "node:fs"; +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; +import * as NodeReadline from "node:readline"; + +import type { UsageProviderKind } from "@t3tools/contracts"; + +import { + initialCodexScanState, + mightCarryUsage, + parseClaudeLine, + parseCodexLine, + type UsageRecord, +} from "./usageTranscripts.ts"; + +export interface TranscriptFile { + readonly path: string; + readonly size: number; + readonly mtimeMs: number; +} + +/** + * Lists `.jsonl` transcripts under `root` last modified at or after `sinceMs`. + * + * Errors on individual entries are swallowed: session files rotate and get + * removed while the walk is in flight, and a partial listing is far better than + * failing the page. + */ +export async function listTranscriptFiles( + root: string, + sinceMs: number, +): Promise { + const found: TranscriptFile[] = []; + + const walk = async (dir: string): Promise => { + let entries; + try { + entries = await NodeFSP.readdir(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const child = NodePath.join(dir, entry.name); + if (entry.isDirectory()) { + await walk(child); + continue; + } + if (!entry.name.endsWith(".jsonl")) continue; + try { + const stats = await NodeFSP.stat(child); + if (stats.mtimeMs >= sinceMs) { + found.push({ path: child, size: stats.size, mtimeMs: stats.mtimeMs }); + } + } catch { + // Vanished between readdir and stat. + } + } + }; + + await walk(root); + return found; +} + +/** + * Filesystem identity of a directory, as `device:inode`. + * + * Used to tell "two servers reading the same transcript directory" apart from + * "two machines whose hostname and home path happen to match". Returns an empty + * string when the directory cannot be stat'd. + */ +export async function readDirectoryVolumeId(path: string): Promise { + try { + const stats = await NodeFSP.stat(path); + return `${stats.dev}:${stats.ino}`; + } catch { + return ""; + } +} + +/** + * Streams one transcript and returns the usage records it contains, or `null` + * when the file could not be read. + * + * The distinction matters to the caller's cache: a genuinely empty transcript + * is a stable fact worth memoising, while a transient read failure memoised + * under the same `(size, mtime)` key would silently drop that file's usage + * until the file next changes. + * + * Codex carries the active model on `turn_context` lines that hold no usage of + * their own, so those still have to pass through the reducer to keep model + * attribution correct. + */ +export async function readTranscriptRecords( + filePath: string, + provider: UsageProviderKind, +): Promise { + const records: UsageRecord[] = []; + const codexState = initialCodexScanState(); + + try { + const lines = NodeReadline.createInterface({ + input: NodeFS.createReadStream(filePath, { encoding: "utf8" }), + crlfDelay: Infinity, + }); + + for await (const line of lines) { + if (provider === "codex") { + if ( + !mightCarryUsage(line, provider) && + !line.includes('"turn_context"') && + !line.includes('"session_meta"') + ) { + continue; + } + const record = parseCodexLine(line, codexState); + if (record !== null) records.push(record); + continue; + } + + if (!mightCarryUsage(line, provider)) continue; + const record = parseClaudeLine(line); + if (record !== null) records.push(record); + } + } catch { + return null; + } + + return records; +} diff --git a/apps/server/src/usage/usageTranscripts.test.ts b/apps/server/src/usage/usageTranscripts.test.ts new file mode 100644 index 000000000000..8f86a3d836bd --- /dev/null +++ b/apps/server/src/usage/usageTranscripts.test.ts @@ -0,0 +1,251 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { + initialCodexScanState, + parseClaudeLine, + parseCodexLine, + totalTokens, +} from "./usageTranscripts.ts"; + +/** Shaped after a real Claude Code assistant record. */ +function claudeLine(overrides: { + messageId: string; + contentType: string; + model?: string; + outputTokens?: number; +}): string { + return JSON.stringify({ + type: "assistant", + timestamp: "2026-08-07T04:05:13.944Z", + sessionId: "5a128faa-8253-489e-b935-6c08e8e670c0", + cwd: "/home/theo/project", + message: { + id: overrides.messageId, + role: "assistant", + model: overrides.model ?? "claude-fable-5", + content: [{ type: overrides.contentType }], + usage: { + input_tokens: 2, + cache_creation_input_tokens: 66818, + cache_read_input_tokens: 1000, + output_tokens: overrides.outputTokens ?? 286, + }, + }, + }); +} + +describe("parseClaudeLine", () => { + it("extracts token totals and a dedupe key", () => { + const record = parseClaudeLine(claudeLine({ messageId: "msg_1", contentType: "text" })); + + expect(record).not.toBeNull(); + expect(record?.provider).toBe("claude"); + expect(record?.model).toBe("claude-fable-5"); + expect(record?.totals).toEqual({ + uncachedInputTokens: 2, + cachedInputTokens: 1000, + cacheCreationTokens: 66818, + outputTokens: 286, + reasoningTokens: 0, + }); + expect(record?.dedupeKey).toBe("msg_1:"); + }); + + it("gives every content block of one message the same dedupe key", () => { + // T3 Code writes one record per content block, each repeating the parent + // message's full usage. Summing them would overcount ~2.4x on real data. + const text = parseClaudeLine(claudeLine({ messageId: "msg_2", contentType: "text" })); + const toolUse = parseClaudeLine(claudeLine({ messageId: "msg_2", contentType: "tool_use" })); + + expect(text?.dedupeKey).toBe(toolUse?.dedupeKey); + expect(text?.totals).toEqual(toolUse?.totals); + }); + + it("ignores records that are not assistant messages", () => { + expect(parseClaudeLine(JSON.stringify({ type: "user", message: {} }))).toBeNull(); + expect(parseClaudeLine("not json")).toBeNull(); + }); +}); + +describe("parseCodexLine", () => { + const sessionMeta = JSON.stringify({ + type: "session_meta", + timestamp: "2026-08-01T05:17:41.289Z", + payload: { type: "session_meta", id: "019fbbc1-b12c-7360-a685-28c181f0025f" }, + }); + const turnContext = JSON.stringify({ + type: "turn_context", + timestamp: "2026-08-01T05:17:42.694Z", + payload: { type: "turn_context", model: "gpt-5.6-sol" }, + }); + const tokenCount = (inputTokens: number, cached: number, output: number, reasoning: number) => + JSON.stringify({ + type: "event_msg", + timestamp: "2026-08-01T05:17:49.919Z", + payload: { + type: "token_count", + info: { + last_token_usage: { + input_tokens: inputTokens, + cached_input_tokens: cached, + cache_write_input_tokens: 0, + output_tokens: output, + reasoning_output_tokens: reasoning, + }, + }, + }, + }); + + it("attributes usage to the model from the preceding turn context", () => { + const state = initialCodexScanState(); + parseCodexLine(sessionMeta, state); + parseCodexLine(turnContext, state); + const record = parseCodexLine(tokenCount(19239, 11008, 299, 116), state); + + expect(record?.provider).toBe("codex"); + expect(record?.model).toBe("gpt-5.6-sol"); + expect(record?.sessionId).toBe("019fbbc1-b12c-7360-a685-28c181f0025f"); + // Codex reports input_tokens inclusive of the cached portion. + expect(record?.totals.uncachedInputTokens).toBe(19239 - 11008); + expect(record?.totals.cachedInputTokens).toBe(11008); + expect(record?.totals.reasoningTokens).toBe(116); + }); + + it("skips a repeated token_count so deltas are not double counted", () => { + const state = initialCodexScanState(); + parseCodexLine(turnContext, state); + const first = parseCodexLine(tokenCount(100, 0, 10, 0), state); + const repeat = parseCodexLine(tokenCount(100, 0, 10, 0), state); + + expect(first).not.toBeNull(); + expect(repeat).toBeNull(); + }); + + it("drops usage that arrives before any model is known", () => { + const state = initialCodexScanState(); + expect(parseCodexLine(tokenCount(100, 0, 10, 0), state)).toBeNull(); + }); + + it("does not let a pre-model event poison the duplicate signature", () => { + // A token_count before its turn_context is dropped; the identical event + // re-emitted once the model is known must still be counted. + const state = initialCodexScanState(); + expect(parseCodexLine(tokenCount(100, 0, 10, 0), state)).toBeNull(); + parseCodexLine(turnContext, state); + expect(parseCodexLine(tokenCount(100, 0, 10, 0), state)).not.toBeNull(); + }); + + // A forked/subagent rollout opens with the parent's history copied in and + // every line re-stamped to the fork instant, then the ancestors' session + // metas. Counting those again multiplied usage ~1.85x on real data (#5758). + describe("forked rollouts", () => { + const meta = (overrides: { + id: string; + timestamp: string; + forkedFromId?: string; + spawnParentId?: string; + }) => + JSON.stringify({ + type: "session_meta", + timestamp: overrides.timestamp, + payload: { + type: "session_meta", + id: overrides.id, + ...(overrides.forkedFromId === undefined + ? {} + : { forked_from_id: overrides.forkedFromId }), + ...(overrides.spawnParentId === undefined + ? {} + : { + source: { + subagent: { thread_spawn: { parent_thread_id: overrides.spawnParentId } }, + }, + }), + }, + }); + const stamped = (timestamp: string, line: string) => { + const parsed = JSON.parse(line) as { timestamp: string }; + parsed.timestamp = timestamp; + return JSON.stringify(parsed); + }; + + it("keeps the child session id over copied ancestor metas", () => { + const state = initialCodexScanState(); + parseCodexLine(meta({ id: "child", timestamp: "2026-08-01T05:00:00.000Z" }), state); + parseCodexLine(meta({ id: "parent", timestamp: "2026-08-01T05:00:00.000Z" }), state); + parseCodexLine(turnContext, state); + const record = parseCodexLine(tokenCount(100, 0, 10, 0), state); + + expect(record?.sessionId).toBe("child"); + }); + + it("drops the re-stamped copied burst and keeps the first real event", () => { + const state = initialCodexScanState(); + const forkInstant = "2026-08-01T05:00:00.000Z"; + parseCodexLine(meta({ id: "child", timestamp: forkInstant, forkedFromId: "parent" }), state); + parseCodexLine(meta({ id: "parent", timestamp: forkInstant }), state); + parseCodexLine(stamped(forkInstant, turnContext), state); + + // Copied history: written in one burst at the fork instant. + expect( + parseCodexLine(stamped("2026-08-01T05:00:00.001Z", tokenCount(100, 0, 10, 0)), state), + ).toBeNull(); + expect( + parseCodexLine(stamped("2026-08-01T05:00:00.002Z", tokenCount(200, 0, 20, 0)), state), + ).toBeNull(); + + // The child's first genuine turn lands seconds later and must count. + const real = parseCodexLine( + stamped("2026-08-01T05:00:06.000Z", tokenCount(300, 0, 30, 0)), + state, + ); + expect(real).not.toBeNull(); + expect(real?.totals.outputTokens).toBe(30); + + // Suppression never restarts, even for closely spaced later events. + const next = parseCodexLine( + stamped("2026-08-01T05:00:06.100Z", tokenCount(400, 0, 40, 0)), + state, + ); + expect(next).not.toBeNull(); + }); + + it("recognizes subagent spawns without forked_from_id", () => { + const state = initialCodexScanState(); + const spawnInstant = "2026-08-01T05:00:00.000Z"; + parseCodexLine( + meta({ id: "child", timestamp: spawnInstant, spawnParentId: "parent" }), + state, + ); + parseCodexLine(stamped(spawnInstant, turnContext), state); + expect( + parseCodexLine(stamped("2026-08-01T05:00:00.001Z", tokenCount(100, 0, 10, 0)), state), + ).toBeNull(); + }); + + it("does not suppress anything in a rollout that is not a fork", () => { + const state = initialCodexScanState(); + parseCodexLine(meta({ id: "root", timestamp: "2026-08-01T05:00:00.000Z" }), state); + parseCodexLine(stamped("2026-08-01T05:00:00.100Z", turnContext), state); + const record = parseCodexLine( + stamped("2026-08-01T05:00:00.200Z", tokenCount(100, 0, 10, 0)), + state, + ); + expect(record).not.toBeNull(); + }); + }); +}); + +describe("totalTokens", () => { + it("does not add reasoning on top of output", () => { + expect( + totalTokens({ + uncachedInputTokens: 10, + cachedInputTokens: 20, + cacheCreationTokens: 30, + outputTokens: 40, + reasoningTokens: 25, + }), + ).toBe(100); + }); +}); diff --git a/apps/server/src/usage/usageTranscripts.ts b/apps/server/src/usage/usageTranscripts.ts new file mode 100644 index 000000000000..49f9a1935ccc --- /dev/null +++ b/apps/server/src/usage/usageTranscripts.ts @@ -0,0 +1,300 @@ +/** + * Pure parsers for the provider CLIs' on-disk session transcripts. + * + * Both parsers are line-at-a-time reducers so callers can stream large files + * without materialising them. Neither touches the filesystem. + * + * @module usageTranscripts + */ +import type { UsageProviderKind, UsageTokenTotals } from "@t3tools/contracts"; + +export interface UsageRecord { + readonly provider: UsageProviderKind; + readonly timestampMs: number; + readonly model: string; + readonly sessionId: string; + readonly totals: UsageTokenTotals; + readonly reportedCostUsd: number | null; + /** + * Key for cross-file de-duplication, or `null` when the record is inherently + * unique and needs no dedup. + */ + readonly dedupeKey: string | null; +} + +const EMPTY_TOTALS: UsageTokenTotals = { + uncachedInputTokens: 0, + cachedInputTokens: 0, + cacheCreationTokens: 0, + outputTokens: 0, + reasoningTokens: 0, +}; + +function int(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0; +} + +function parseTimestampMs(value: unknown): number | null { + if (typeof value !== "string") return null; + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? null : parsed; +} + +export function addTotals(a: UsageTokenTotals, b: UsageTokenTotals): UsageTokenTotals { + return { + uncachedInputTokens: a.uncachedInputTokens + b.uncachedInputTokens, + cachedInputTokens: a.cachedInputTokens + b.cachedInputTokens, + cacheCreationTokens: a.cacheCreationTokens + b.cacheCreationTokens, + outputTokens: a.outputTokens + b.outputTokens, + reasoningTokens: a.reasoningTokens + b.reasoningTokens, + }; +} + +export function totalTokens(totals: UsageTokenTotals): number { + // reasoningTokens is a subset of outputTokens and must not be added again. + return ( + totals.uncachedInputTokens + + totals.cachedInputTokens + + totals.cacheCreationTokens + + totals.outputTokens + ); +} + +/** + * Cheap substring gate applied before `JSON.parse`. + * + * Transcripts are mostly tool output; only a minority of lines carry usage. On + * a 30-day window this skips roughly half the lines outright and is worth about + * an order of magnitude. + */ +export function mightCarryUsage(line: string, provider: UsageProviderKind): boolean { + return provider === "claude" ? line.includes('"usage"') : line.includes('"token_count"'); +} + +/* -------------------------------------------------------------------------- */ +/* Claude Code */ +/* -------------------------------------------------------------------------- */ + +/** + * Parses one line of a Claude Code transcript. + * + * T3 Code writes one record per assistant *content block*, and every one of + * those records repeats the same complete `usage` object for the parent + * message. Summing them overcounts by roughly 2.4x on a real workload, so the + * caller must drop repeats by `dedupeKey` and keep the first. + */ +export function parseClaudeLine(line: string): UsageRecord | null { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null) return null; + + const record = parsed as Record; + if (record["type"] !== "assistant") return null; + + const message = record["message"]; + if (typeof message !== "object" || message === null) return null; + const messageRecord = message as Record; + + const usage = messageRecord["usage"]; + if (typeof usage !== "object" || usage === null) return null; + const usageRecord = usage as Record; + + const timestampMs = parseTimestampMs(record["timestamp"]); + if (timestampMs === null) return null; + + const model = typeof messageRecord["model"] === "string" ? messageRecord["model"] : ""; + if (model.length === 0) return null; + + const messageId = typeof messageRecord["id"] === "string" ? messageRecord["id"] : null; + const requestId = typeof record["requestId"] === "string" ? record["requestId"] : null; + // Matches ccusage: prefer the message/request pair, fall back to whichever + // half exists. Records with neither cannot be de-duplicated. + const dedupeKey = + messageId === null && requestId === null ? null : `${messageId ?? ""}:${requestId ?? ""}`; + + const cost = record["costUSD"]; + + return { + provider: "claude", + timestampMs, + model, + sessionId: typeof record["sessionId"] === "string" ? record["sessionId"] : "", + totals: { + uncachedInputTokens: int(usageRecord["input_tokens"]), + cachedInputTokens: int(usageRecord["cache_read_input_tokens"]), + cacheCreationTokens: int(usageRecord["cache_creation_input_tokens"]), + outputTokens: int(usageRecord["output_tokens"]), + // Anthropic folds thinking tokens into output and does not break them out. + reasoningTokens: 0, + }, + reportedCostUsd: typeof cost === "number" && Number.isFinite(cost) ? cost : null, + dedupeKey, + }; +} + +/* -------------------------------------------------------------------------- */ +/* Codex */ +/* -------------------------------------------------------------------------- */ + +/** + * Rolling state for a single Codex rollout file. + * + * Codex `token_count` events carry no model, so the model is carried forward + * from the most recent `turn_context`. Sessions that switch models mid-run + * attribute correctly from the switch onward. + */ +export interface CodexScanState { + model: string; + sessionId: string; + lastUsageSignature: string | null; + sawSessionMeta: boolean; + /** While true, leading usage events are re-stamped copies of parent history. */ + suppressingForkCopies: boolean; + forkCopyAnchorMs: number; +} + +export function initialCodexScanState(): CodexScanState { + return { + model: "", + sessionId: "", + lastUsageSignature: null, + sawSessionMeta: false, + suppressingForkCopies: false, + forkCopyAnchorMs: 0, + }; +} + +/** + * A forked or subagent rollout opens with the parent's full history copied in, + * every line re-stamped to the fork instant. Those copies are written in one + * synchronous burst (observed gaps 0-40ms), while the child's first genuine + * usage event only lands after a real model turn (observed 5s+). One second of + * separation splits the two cleanly; `ccusage` uses the same threshold. + */ +const FORK_COPY_MAX_GAP_MS = 1000; + +/** Whether a `session_meta` payload marks the rollout as a fork or subagent. */ +function isForkedSessionMeta(payload: Record): boolean { + if (typeof payload["forked_from_id"] === "string") return true; + const source = payload["source"]; + if (typeof source !== "object" || source === null) return false; + const subagent = (source as Record)["subagent"]; + if (typeof subagent !== "object" || subagent === null) return false; + const spawn = (subagent as Record)["thread_spawn"]; + if (typeof spawn !== "object" || spawn === null) return false; + return typeof (spawn as Record)["parent_thread_id"] === "string"; +} + +/** + * Feeds one line of a Codex rollout into `state`, returning a record when the + * line was a usage event. + * + * Deltas come from `last_token_usage`. Summing those across a session + * reconciles with the session's final `total_token_usage`, provided + * consecutive duplicate events are dropped, which this does. + */ +export function parseCodexLine(line: string, state: CodexScanState): UsageRecord | null { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null) return null; + + const record = parsed as Record; + const payload = record["payload"]; + if (typeof payload !== "object" || payload === null) return null; + const payloadRecord = payload as Record; + const payloadType = payloadRecord["type"]; + + if (record["type"] === "session_meta") { + // Only the first meta describes this file's own session. A forked rollout + // repeats the ancestors' metas right after it; letting those through would + // reassign every subsequent record to an ancestor session. + if (state.sawSessionMeta) return null; + state.sawSessionMeta = true; + const id = payloadRecord["id"] ?? payloadRecord["session_id"]; + if (typeof id === "string") state.sessionId = id; + const metaTimestampMs = parseTimestampMs(record["timestamp"]); + if (metaTimestampMs !== null && isForkedSessionMeta(payloadRecord)) { + state.suppressingForkCopies = true; + state.forkCopyAnchorMs = metaTimestampMs; + } + return null; + } + + if (record["type"] === "turn_context") { + if (typeof payloadRecord["model"] === "string") state.model = payloadRecord["model"]; + return null; + } + + if (payloadType !== "token_count") return null; + + const info = payloadRecord["info"]; + if (typeof info !== "object" || info === null) return null; + const last = (info as Record)["last_token_usage"]; + if (typeof last !== "object" || last === null) return null; + const lastRecord = last as Record; + + // Only an event that is otherwise eligible may consume the duplicate + // signature. A token_count arriving before its turn_context (no model yet) + // must not poison it, or the re-emitted copy after the model is known would + // be skipped as a duplicate and those tokens never counted. + const timestampMs = parseTimestampMs(record["timestamp"]); + if (timestampMs === null) return null; + if (state.model.length === 0) return null; + + // Codex re-emits an unchanged token_count on some stream boundaries. Summing + // those would double count, so identical consecutive payloads are skipped. + const signature = JSON.stringify(lastRecord); + if (signature === state.lastUsageSignature) return null; + state.lastUsageSignature = signature; + + // In a forked rollout the copied parent history was already counted from the + // parent's own file. Drop the leading burst; the first usage event separated + // from its predecessor by a real turn's worth of time ends it for good. + if (state.suppressingForkCopies) { + if (timestampMs - state.forkCopyAnchorMs < FORK_COPY_MAX_GAP_MS) { + state.forkCopyAnchorMs = timestampMs; + return null; + } + state.suppressingForkCopies = false; + } + + const inputTokens = int(lastRecord["input_tokens"]); + const cachedInputTokens = int(lastRecord["cached_input_tokens"]); + const cacheCreationTokens = int(lastRecord["cache_write_input_tokens"]); + const outputTokens = int(lastRecord["output_tokens"]); + + const totals: UsageTokenTotals = { + // Codex reports `input_tokens` inclusive of the cached portion. + uncachedInputTokens: Math.max(0, inputTokens - cachedInputTokens - cacheCreationTokens), + cachedInputTokens, + cacheCreationTokens, + outputTokens, + // Reported inside output_tokens, surfaced separately for the token mix. + reasoningTokens: Math.min(outputTokens, int(lastRecord["reasoning_output_tokens"])), + }; + + if (totalTokens(totals) === 0) return null; + + return { + provider: "codex", + timestampMs, + model: state.model, + sessionId: state.sessionId, + totals, + // Codex does not report cost in the rollout. + reportedCostUsd: null, + // Events surviving the fork-copy suppression above are unique to this + // rollout, so they need no global dedup. + dedupeKey: null, + }; +} + +export { EMPTY_TOTALS }; diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 55aa8f38835e..8f7f7bd19544 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -20,6 +20,8 @@ import { type VcsCreateWorktreeResult, type ReviewDiffPreviewInput, type ReviewDiffPreviewResult, + type ReviewDiffFileContentsInput, + type ReviewDiffFileContentsResult, type VcsInitInput, type VcsListRefsInput, type VcsListRefsResult, @@ -142,6 +144,36 @@ export interface GitFetchPullRequestBranchInput { branch: string; } +export interface GitFetchPullRequestHeadCommitInput { + cwd: string; + prNumber: number; +} + +export interface GitResolveCommitInput { + cwd: string; + revision: string; +} + +export interface GitResolveCommitResult { + commitSha: string; +} + +export interface GitRefreshCheckedOutBranchInput { + cwd: string; + targetCommit: string; + /** + * Commit the checkout is allowed to be hard-reset away from: the upstream commit read before + * the fetch. HEAD sitting there means the checkout holds no work of its own. + */ + resetWhenHeadCommit?: string | null | undefined; +} + +export interface GitRefreshCheckedOutBranchResult { + headCommit: string; + moved: boolean; + onTarget: boolean; +} + export interface GitEnsureRemoteInput { cwd: string; preferredName: string; @@ -166,6 +198,11 @@ export interface GitFetchRemoteInput { remoteName: string; } +export interface GitRemoteExistsInput { + cwd: string; + remoteName: string; +} + export interface GitResolveRemoteTrackingCommitInput { cwd: string; refName: string; @@ -221,6 +258,9 @@ export class GitVcsDriver extends Context.Service< readonly getReviewDiffPreview: ( input: ReviewDiffPreviewInput, ) => Effect.Effect; + readonly getReviewDiffFileContents: ( + input: ReviewDiffFileContentsInput, + ) => Effect.Effect; readonly readConfigValue: ( cwd: string, key: string, @@ -235,9 +275,21 @@ export class GitVcsDriver extends Context.Service< readonly fetchPullRequestBranch: ( input: GitFetchPullRequestBranchInput, ) => Effect.Effect; + /** Fetches `refs/pull//head` without writing a branch, for heads that exist nowhere else. */ + readonly fetchPullRequestHeadCommit: ( + input: GitFetchPullRequestHeadCommitInput, + ) => Effect.Effect; + readonly resolveCommit: ( + input: GitResolveCommitInput, + ) => Effect.Effect; + /** Moves the branch checked out in `cwd` onto `targetCommit`, from inside that worktree. */ + readonly refreshCheckedOutBranch: ( + input: GitRefreshCheckedOutBranchInput, + ) => Effect.Effect; readonly ensureRemote: (input: GitEnsureRemoteInput) => Effect.Effect; readonly resolvePrimaryRemoteName: (cwd: string) => Effect.Effect; readonly fetchRemote: (input: GitFetchRemoteInput) => Effect.Effect; + readonly remoteExists: (input: GitRemoteExistsInput) => Effect.Effect; readonly resolveRemoteTrackingCommit: ( input: GitResolveRemoteTrackingCommitInput, ) => Effect.Effect; diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 24d53cd4846f..18e594512ee8 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -14,7 +14,7 @@ import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { GitCommandError } from "@t3tools/contracts"; +import { GitCommandError, type ReviewDiffFileContentsInput } from "@t3tools/contracts"; import { ServerConfig } from "../config.ts"; import { makeGitVcsDriverCore, splitNullSeparatedGitStdoutPaths } from "./GitVcsDriverCore.ts"; import * as GitVcsDriver from "./GitVcsDriver.ts"; @@ -78,6 +78,20 @@ const writeTextFile = ( yield* fileSystem.writeFileString(filePath, contents); }); +const makeReviewDiffFileContentsInput = ( + cwd: string, + overrides: Partial> = {}, +): ReviewDiffFileContentsInput => ({ + cwd, + sourceKind: "working-tree", + changeType: "change", + baseRef: "HEAD", + headRef: null, + oldPath: "README.md", + newPath: "README.md", + ...overrides, +}); + const git = ( cwd: string, args: ReadonlyArray, @@ -803,6 +817,107 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { ); }), ); + + it.effect("loads full file contents for working-tree diff expansion", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + const pathService = yield* Path.Path; + yield* writeTextFile(cwd, "nested/.keep", ""); + yield* writeTextFile(cwd, "README.md", "# changed\nunchanged context\n"); + + const contents = yield* driver.getReviewDiffFileContents( + makeReviewDiffFileContentsInput(pathService.join(cwd, "nested")), + ); + + assert.strictEqual(contents.oldContents, "# test\n"); + assert.strictEqual(contents.newContents, "# changed\nunchanged context\n"); + }), + ); + + it.effect("attributes working-tree filesystem failures to the failing operation", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const error = yield* driver + .getReviewDiffFileContents( + makeReviewDiffFileContentsInput(cwd, { + changeType: "new", + oldPath: "missing.ts", + newPath: "missing.ts", + }), + ) + .pipe(Effect.flip); + + assert.deepInclude(error, { + _tag: "GitCommandError", + operation: "GitVcsDriver.getReviewDiffFileContents.workingTree.fs.realPath", + command: "fs.realPath", + cwd, + detail: "Could not resolve diff file 'missing.ts'.", + }); + }), + ); + + it.effect("loads new and deleted files without reading their missing side", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + yield* writeTextFile(cwd, "added.ts", "export const added = true;\n"); + yield* fileSystem.remove(pathService.join(cwd, "README.md")); + + const [added, deleted] = yield* Effect.all([ + driver.getReviewDiffFileContents( + makeReviewDiffFileContentsInput(cwd, { + changeType: "new", + oldPath: "added.ts", + newPath: "added.ts", + }), + ), + driver.getReviewDiffFileContents( + makeReviewDiffFileContentsInput(cwd, { changeType: "deleted" }), + ), + ]); + + assert.deepStrictEqual(added, { + oldContents: "", + newContents: "export const added = true;\n", + }); + assert.deepStrictEqual(deleted, { + oldContents: "# test\n", + newContents: "", + }); + }), + ); + + it.effect("loads merge-base and head contents for branch diff expansion", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["checkout", "-b", "feature/context"]); + yield* writeTextFile(cwd, "README.md", "# branch change\nunchanged context\n"); + yield* git(cwd, ["add", "README.md"]); + yield* git(cwd, ["commit", "-m", "change readme"]); + + const contents = yield* driver.getReviewDiffFileContents( + makeReviewDiffFileContentsInput(cwd, { + sourceKind: "branch-range", + baseRef: initialBranch, + headRef: "feature/context", + }), + ); + + assert.strictEqual(contents.oldContents, "# test\n"); + assert.strictEqual(contents.newContents, "# branch change\nunchanged context\n"); + }), + ); }); describe("repository status", () => { @@ -886,6 +1001,23 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("reports remote status on unborn HEAD without failing", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.initRepo({ cwd }); + const initialBranch = yield* git(cwd, ["symbolic-ref", "--short", "HEAD"]); + + const status = yield* driver.statusDetailsRemote(cwd, { refreshUpstream: false }); + + assert.equal(status.isRepo, true); + assert.equal(status.branch, initialBranch); + assert.equal(status.hasUpstream, false); + assert.equal(status.aheadCount, 0); + assert.equal(status.behindCount, 0); + }), + ); + it.effect("can read cached remote divergence without fetching upstream", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index e44dc0486346..1489db9b3ff4 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -21,6 +21,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { GitCommandError, + type ReviewDiffFileContentsInput, type ReviewDiffPreviewInput, type ReviewDiffPreviewSource, type VcsRef, @@ -46,6 +47,7 @@ const RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES = 19_000; const RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES = 59_000; const REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES = 120_000; const REVIEW_UNTRACKED_DIFF_MAX_OUTPUT_BYTES = 80_000; +const REVIEW_DIFF_FILE_MAX_OUTPUT_BYTES = 1024 * 1024; const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 120_000; const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15); const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); @@ -1282,11 +1284,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }, ).pipe(Effect.map((result) => result.exitCode === 0)); - const originRemoteExists = (cwd: string): Effect.Effect => - executeGit("GitVcsDriver.originRemoteExists", cwd, ["remote", "get-url", "origin"], { + const remoteExists: GitVcsDriver.GitVcsDriver["Service"]["remoteExists"] = (input) => + executeGit("GitVcsDriver.remoteExists", input.cwd, ["remote", "get-url", input.remoteName], { allowNonZeroExit: true, }).pipe(Effect.map((result) => result.exitCode === 0)); + const originRemoteExists = (cwd: string): Effect.Effect => + remoteExists({ cwd, remoteName: "origin" }); + const listRemoteNames = (cwd: string): Effect.Effect, GitCommandError> => runGitStdout("GitVcsDriver.listRemoteNames", cwd, ["remote"]).pipe( Effect.map(parseRemoteNamesInGitOrder), @@ -1472,25 +1477,35 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* if (branchResult === null) { return NON_REPOSITORY_REMOTE_STATUS_DETAILS; } + let branch: string | null; if (branchResult.exitCode !== 0) { if (isNonRepositoryGitStderr(branchResult.stderr)) { return NON_REPOSITORY_REMOTE_STATUS_DETAILS; } - return yield* new GitCommandError({ - ...gitCommandContext({ - operation: "GitVcsDriver.statusDetailsRemote.branch", - cwd, - args: ["rev-parse", "--abbrev-ref", "HEAD"], - }), - detail: "Git branch lookup failed.", - exitCode: branchResult.exitCode, - stdoutLength: branchResult.stdout.length, - stderrLength: branchResult.stderr.length, - }); - } + if (!isUnbornHeadStderr(branchResult.stderr)) { + return yield* new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.statusDetailsRemote.branch", + cwd, + args: ["rev-parse", "--abbrev-ref", "HEAD"], + }), + detail: "Git branch lookup failed.", + exitCode: branchResult.exitCode, + stdoutLength: branchResult.stdout.length, + stderrLength: branchResult.stderr.length, + }); + } - const branchValue = branchResult.stdout.trim(); - const branch = branchValue.length > 0 && branchValue !== "HEAD" ? branchValue : null; + const branchValue = yield* runGitStdout( + "GitVcsDriver.statusDetailsRemote.unbornBranch", + cwd, + ["symbolic-ref", "--quiet", "--short", "HEAD"], + ); + branch = branchValue.trim() || null; + } else { + const branchValue = branchResult.stdout.trim(); + branch = branchValue.length > 0 && branchValue !== "HEAD" ? branchValue : null; + } const upstream = yield* resolveCurrentUpstream(cwd); const upstreamRef = upstream?.upstreamRef ?? null; let aheadCount = 0; @@ -2270,6 +2285,163 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }; }); + const reviewDiffFileError = ( + input: ReviewDiffFileContentsInput, + detail: string, + cause?: unknown, + ) => + new GitCommandError({ + operation: "GitVcsDriver.getReviewDiffFileContents", + command: "git", + cwd: input.cwd, + detail, + ...(cause === undefined ? {} : { cause }), + }); + + const isPathWithinRoot = (root: string, candidate: string) => { + const relative = path.relative(root, candidate); + return ( + relative === "" || + (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) + ); + }; + + const readReviewFileAtRevision = Effect.fn("readReviewFileAtRevision")(function* ( + input: ReviewDiffFileContentsInput, + revision: string, + relativePath: string, + ) { + const result = yield* executeGit( + "GitVcsDriver.getReviewDiffFileContents.revision", + input.cwd, + ["show", `${revision}:${relativePath}`], + { maxOutputBytes: REVIEW_DIFF_FILE_MAX_OUTPUT_BYTES }, + ); + if (result.stdout.includes("\0")) { + return yield* reviewDiffFileError(input, `Cannot expand binary file '${relativePath}'.`); + } + return result.stdout; + }); + + const readWorkingTreeReviewFile = Effect.fn("readWorkingTreeReviewFile")(function* ( + input: ReviewDiffFileContentsInput, + repositoryRoot: string, + ) { + const fileError = (stage: string, detail: string, cause?: unknown) => + new GitCommandError({ + operation: `GitVcsDriver.getReviewDiffFileContents.workingTree.${stage}`, + command: stage, + cwd: input.cwd, + detail, + ...(cause === undefined ? {} : { cause }), + }); + const requestedPath = path.resolve(repositoryRoot, input.newPath); + if (!isPathWithinRoot(repositoryRoot, requestedPath)) { + return yield* fileError( + "path.resolve", + `Diff file '${input.newPath}' resolves outside the review workspace.`, + ); + } + + const [realRepositoryRoot, realTarget] = yield* Effect.all([ + fileSystem.realPath(repositoryRoot), + fileSystem.realPath(requestedPath), + ]).pipe( + Effect.mapError((cause) => + fileError("fs.realPath", `Could not resolve diff file '${input.newPath}'.`, cause), + ), + ); + if (!isPathWithinRoot(realRepositoryRoot, realTarget)) { + return yield* fileError( + "fs.realPath", + `Diff file '${input.newPath}' resolves outside the review workspace.`, + ); + } + + const info = yield* fileSystem + .stat(realTarget) + .pipe( + Effect.mapError((cause) => + fileError("fs.stat", `Could not inspect diff file '${input.newPath}'.`, cause), + ), + ); + if (info.type !== "File") { + return yield* fileError("fs.stat", `Diff path '${input.newPath}' is not a file.`); + } + if (info.size > BigInt(REVIEW_DIFF_FILE_MAX_OUTPUT_BYTES)) { + return yield* fileError( + "fs.stat", + `Diff file '${input.newPath}' exceeds the 1 MB expansion limit.`, + ); + } + + const bytes = yield* fileSystem + .readFile(realTarget) + .pipe( + Effect.mapError((cause) => + fileError("fs.readFile", `Could not read diff file '${input.newPath}'.`, cause), + ), + ); + if (bytes.includes(0)) { + return yield* fileError("fs.readFile", `Cannot expand binary file '${input.newPath}'.`); + } + return new TextDecoder("utf-8").decode(bytes); + }); + + const getReviewDiffFileContents = Effect.fn("getReviewDiffFileContents")(function* ( + input: ReviewDiffFileContentsInput, + ) { + if (input.sourceKind === "working-tree") { + const repositoryRoot = yield* runGitStdout( + "GitVcsDriver.getReviewDiffFileContents.repositoryRoot", + input.cwd, + ["rev-parse", "--show-toplevel"], + ).pipe(Effect.map((value) => value.trim())); + if (repositoryRoot.length === 0) { + return yield* reviewDiffFileError(input, "Could not resolve the Git repository root."); + } + const [oldContents, newContents] = yield* Effect.all( + [ + input.changeType === "new" + ? Effect.succeed("") + : readReviewFileAtRevision(input, input.baseRef ?? "HEAD", input.oldPath), + input.changeType === "deleted" + ? Effect.succeed("") + : readWorkingTreeReviewFile(input, repositoryRoot), + ], + { concurrency: 2 }, + ); + return { oldContents, newContents }; + } + + if (!input.baseRef || !input.headRef) { + return yield* reviewDiffFileError( + input, + "Branch diff file expansion requires both base and head refs.", + ); + } + const mergeBase = yield* runGitStdout( + "GitVcsDriver.getReviewDiffFileContents.mergeBase", + input.cwd, + ["merge-base", input.baseRef, input.headRef], + ).pipe(Effect.map((value) => value.trim())); + if (mergeBase.length === 0) { + return yield* reviewDiffFileError(input, "Could not resolve the branch comparison base."); + } + const [oldContents, newContents] = yield* Effect.all( + [ + input.changeType === "new" + ? Effect.succeed("") + : readReviewFileAtRevision(input, mergeBase, input.oldPath), + input.changeType === "deleted" + ? Effect.succeed("") + : readReviewFileAtRevision(input, input.headRef, input.newPath), + ], + { concurrency: 2 }, + ); + return { oldContents, newContents }; + }); + const readConfigValue: GitVcsDriver.GitVcsDriver["Service"]["readConfigValue"] = (cwd, key) => runGitStdout("GitVcsDriver.readConfigValue", cwd, ["config", "--get", key], true).pipe( Effect.map((stdout) => stdout.trim()), @@ -2632,6 +2804,95 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ); }); + const resolveCommit: GitVcsDriver.GitVcsDriver["Service"]["resolveCommit"] = Effect.fn( + "resolveCommit", + )(function* (input) { + const commitSha = yield* runGitStdout("GitVcsDriver.resolveCommit", input.cwd, [ + "rev-parse", + "--verify", + `${input.revision}^{commit}`, + ]).pipe(Effect.map((stdout) => stdout.trim())); + + return { commitSha }; + }); + + const fetchPullRequestHeadCommit: GitVcsDriver.GitVcsDriver["Service"]["fetchPullRequestHeadCommit"] = + Effect.fn("fetchPullRequestHeadCommit")(function* (input) { + const remoteName = yield* resolvePrimaryRemoteName(input.cwd); + // No refspec destination: the pull head lands in FETCH_HEAD (per worktree) instead of a + // branch, which is the only way to read it while that branch is checked out somewhere. + yield* executeGit( + "GitVcsDriver.fetchPullRequestHeadCommit", + input.cwd, + ["fetch", "--quiet", "--no-tags", remoteName, `refs/pull/${input.prNumber}/head`], + { + fallbackErrorDetail: "git fetch pull request head failed", + }, + ); + + return yield* resolveCommit({ cwd: input.cwd, revision: "FETCH_HEAD" }); + }); + + const refreshCheckedOutBranch: GitVcsDriver.GitVcsDriver["Service"]["refreshCheckedOutBranch"] = + Effect.fn("refreshCheckedOutBranch")(function* (input) { + const { commitSha: headCommit } = yield* resolveCommit({ cwd: input.cwd, revision: "HEAD" }); + if (headCommit === input.targetCommit) { + return { headCommit, moved: false, onTarget: true }; + } + + const worktreeChanges = yield* runGitStdout( + "GitVcsDriver.refreshCheckedOutBranch.status", + input.cwd, + ["status", "--porcelain"], + ); + if (worktreeChanges.trim().length > 0) { + return { headCommit, moved: false, onTarget: false }; + } + + const isAncestor = yield* executeGit( + "GitVcsDriver.refreshCheckedOutBranch.isAncestor", + input.cwd, + ["merge-base", "--is-ancestor", headCommit, input.targetCommit], + { allowNonZeroExit: true }, + ).pipe(Effect.map((result) => result.exitCode === 0)); + // A rewritten head (rebase, squash, amend) does not descend from the checkout, so it can + // only be taken by resetting. That is lossless exactly when the tree is clean and HEAD + // never left the commit the upstream held before the fetch. + if (!isAncestor && headCommit !== input.resetWhenHeadCommit) { + return { headCommit, moved: false, onTarget: false }; + } + + if (!isAncestor) { + // The commit being reset away is about to be reachable from nothing. It is only ever a + // commit the remote already held, but "the remote held it" stops being a way back once + // the head it belonged to has been rewritten, so a ref keeps it findable. + yield* executeGit( + "GitVcsDriver.refreshCheckedOutBranch.keepPrevious", + input.cwd, + ["update-ref", "refs/t3code/pre-refresh", headCommit], + { fallbackErrorDetail: "git failed to record the previous checkout commit" }, + ); + } + + yield* executeGit( + "GitVcsDriver.refreshCheckedOutBranch.move", + input.cwd, + // `--merge` rather than `--hard`: the cleanliness check above is a snapshot, and another + // thread may edit a tracked file between it and this move. Git itself refuses a `--merge` + // reset that would overwrite such an edit — the same guarantee `--ff-only` gives the + // other branch — so a race loses nothing; the refresh fails and is reported instead. + isAncestor + ? ["merge", "--ff-only", input.targetCommit] + : ["reset", "--merge", input.targetCommit], + { + timeoutMs: 30_000, + fallbackErrorDetail: "git failed to move the checkout onto the pull request head", + }, + ); + + return { headCommit: input.targetCommit, moved: true, onTarget: true }; + }); + const fetchRemote: GitVcsDriver.GitVcsDriver["Service"]["fetchRemote"] = Effect.fn("fetchRemote")( function* (input) { yield* executeGit( @@ -2903,14 +3164,20 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* pullCurrentBranch: (cwd) => withListRefsInvalidation(cwd, pullCurrentBranch(cwd)), readRangeContext, getReviewDiffPreview, + getReviewDiffFileContents, readConfigValue, listRefs, createWorktree: (input) => withListRefsInvalidation(input.cwd, createWorktree(input)), fetchPullRequestBranch: (input) => withListRefsInvalidation(input.cwd, fetchPullRequestBranch(input)), + fetchPullRequestHeadCommit, + resolveCommit, + refreshCheckedOutBranch: (input) => + withListRefsInvalidation(input.cwd, refreshCheckedOutBranch(input)), ensureRemote: (input) => withListRefsInvalidation(input.cwd, ensureRemote(input)), resolvePrimaryRemoteName, fetchRemote: (input) => withListRefsInvalidation(input.cwd, fetchRemote(input)), + remoteExists, resolveRemoteTrackingCommit, fetchRemoteBranch: (input) => withListRefsInvalidation(input.cwd, fetchRemoteBranch(input)), fetchRemoteTrackingBranch: (input) => diff --git a/apps/server/src/vcs/VcsProcess.test.ts b/apps/server/src/vcs/VcsProcess.test.ts index 675d20cb82c9..58596d194f7e 100644 --- a/apps/server/src/vcs/VcsProcess.test.ts +++ b/apps/server/src/vcs/VcsProcess.test.ts @@ -140,6 +140,31 @@ describe("VcsProcess.run", () => { }).pipe(provideLive), ); + it.effect("classifies API rate limits without retaining provider stderr", () => + Effect.gen(function* () { + const providerStderr = + "GraphQL: API rate limit already exceeded for user ID 51714798 and token secret-value."; + const error = yield* run({ + operation: "test.rate-limit", + command: "node", + args: ["-e", "process.stderr.write(process.argv[1]); process.exit(1)", providerStderr], + cwd: process.cwd(), + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(VcsProcessExitError); + expect(error).toMatchObject({ + command: "node", + exitCode: 1, + detail: "API rate limit exceeded.", + failureKind: "rate-limited", + stderrLength: providerStderr.length, + stderrTruncated: false, + }); + expect(error.message).not.toContain(providerStderr); + expect(error.message).not.toContain("secret-value"); + }).pipe(provideLive), + ); + it.effect("retains spawn causes without exposing process arguments in the error message", () => Effect.gen(function* () { const secretArgument = "--token=super-secret-token"; @@ -192,6 +217,8 @@ describe("VcsProcess.run", () => { timedOut: false, stdoutTruncated: false, stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, }), ); diff --git a/apps/server/src/vcs/VcsProcess.ts b/apps/server/src/vcs/VcsProcess.ts index 52db6f9b1fb2..ee4ed9712412 100644 --- a/apps/server/src/vcs/VcsProcess.ts +++ b/apps/server/src/vcs/VcsProcess.ts @@ -37,6 +37,9 @@ export interface VcsProcessOutput { readonly stderr: string; readonly stdoutTruncated: boolean; readonly stderrTruncated: boolean; + /** Present on real process output; optional so narrow test doubles remain lightweight. */ + readonly stdoutInvalidUtf8?: boolean; + readonly stderrInvalidUtf8?: boolean; } export class VcsProcess extends Context.Service< @@ -66,6 +69,14 @@ const classifyNonZeroExit = (command: string, stderr: string): VcsProcessExitFai return "authentication"; } + if ( + normalized.includes("api rate limit") || + normalized.includes("rate limit exceeded") || + normalized.includes("secondary rate limit") + ) { + return "rate-limited"; + } + if ( (command === "gh" && (normalized.includes("could not resolve to a pullrequest") || @@ -163,6 +174,8 @@ export const make = Effect.gen(function* () { stderr: result.stderr, stdoutTruncated: result.stdoutTruncated, stderrTruncated: result.stderrTruncated, + stdoutInvalidUtf8: result.stdoutInvalidUtf8 ?? false, + stderrInvalidUtf8: result.stderrInvalidUtf8 ?? false, } satisfies VcsProcessOutput; }); diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts index bb2113dac37d..28a30481b1b6 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -245,7 +245,7 @@ export const make = Effect.gen(function* () { }); return yield* Effect.gen(function* () { const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; - return yield* searchIndex.search(normalizedQuery, input.limit, input.kind); + return yield* searchIndex.search(normalizedQuery, input.limit, input.kind, input.imageOnly); }).pipe( Effect.provide( workspaceSearchIndexes.get( diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts index 155728370307..1fdf956447d4 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts @@ -1,4 +1,10 @@ -import { FileFinder, type GrepCursor, type GrepOptions, type GrepResult } from "@ff-labs/fff-node"; +import { + FileFinder, + type FileItem, + type GrepCursor, + type GrepOptions, + type GrepResult, +} from "@ff-labs/fff-node"; import { afterEach, expect, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; @@ -11,6 +17,54 @@ afterEach(() => { vi.restoreAllMocks(); }); +function fileItem(relativePath: string): FileItem { + return { + relativePath, + fileName: relativePath.slice(relativePath.lastIndexOf("/") + 1), + size: 1, + modified: 0, + accessFrecencyScore: 0, + modificationFrecencyScore: 0, + totalFrecencyScore: 0, + gitStatus: "clean", + }; +} + +it.effect("filters image searches before applying the result limit", () => + Effect.scoped( + Effect.gen(function* () { + const items = [ + ...Array.from({ length: 200 }, (_, index) => fileItem(`src/file-${index}.ts`)), + fileItem("public/icon.svg"), + ]; + const fileSearch = vi.fn(() => ({ + ok: true as const, + value: { + items, + scores: [], + totalMatched: items.length, + totalFiles: items.length, + }, + })); + const finder = { + destroy: vi.fn(), + waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), + fileSearch, + } as unknown as FileFinder; + vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); + + const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project"); + const resultWithoutKind = yield* searchIndex.search("", 200, undefined, true); + const resultWithDirectoryKind = yield* searchIndex.search("", 200, "directory", true); + + expect(resultWithoutKind.entries).toEqual([{ kind: "file", path: "public/icon.svg" }]); + expect(resultWithDirectoryKind.entries).toEqual([{ kind: "file", path: "public/icon.svg" }]); + expect(fileSearch).toHaveBeenCalledTimes(2); + expect(fileSearch).toHaveBeenCalledWith("", { pageSize: 25_002 }); + }), + ), +); + it.effect("preserves unexpected FileFinder creation failures", () => Effect.gen(function* () { const cause = new Error("native initialization failed"); diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.ts b/apps/server/src/workspace/WorkspaceSearchIndex.ts index 8bf36b7a80ac..eeb2df342c2c 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.ts @@ -23,6 +23,7 @@ import type { ProjectSearchContentsResult, ProjectSearchEntriesResult, } from "@t3tools/contracts"; +import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; const WORKSPACE_INDEX_MAX_ENTRIES = 25_000; const WORKSPACE_INDEX_PAGE_SIZE = WORKSPACE_INDEX_MAX_ENTRIES + 2; @@ -111,6 +112,7 @@ export class WorkspaceSearchIndex extends Context.Service< query: string, limit: number, kind?: ProjectEntryKind, + imageOnly?: boolean, ) => Effect.Effect; readonly searchContents: ( input: Omit, @@ -157,15 +159,18 @@ function toDirectoryEntry(item: DirItem): ProjectEntry | null { return normalizedPath ? { path: normalizedPath, kind: "directory" } : null; } -function mapFileSearchResult(result: SearchResult, limit: number): ProjectSearchEntriesResult { +function mapFileSearchResult( + result: SearchResult, + limit: number, + imageOnly = false, +): ProjectSearchEntriesResult { + const entries = result.items.flatMap((item) => { + const entry = toFileEntry(item); + return entry && (!imageOnly || isWorkspaceImagePreviewPath(entry.path)) ? [entry] : []; + }); return { - entries: result.items - .flatMap((item) => { - const entry = toFileEntry(item); - return entry ? [entry] : []; - }) - .slice(0, limit), - truncated: result.totalMatched > limit, + entries: entries.slice(0, limit), + truncated: entries.length > limit || result.totalMatched > result.items.length, }; } @@ -445,13 +450,13 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( const search: WorkspaceSearchIndex["Service"]["search"] = Effect.fn( "WorkspaceSearchIndex.search", - )(function* (query, limit, kind) { - const pageSize = Math.max(1, limit + 1); - if (kind === "file") { + )(function* (query, limit, kind, imageOnly) { + const pageSize = imageOnly ? WORKSPACE_INDEX_PAGE_SIZE : Math.max(1, limit + 1); + if (kind === "file" || imageOnly) { const result = yield* runSearch(query, pageSize, "fileSearch", () => finder.fileSearch(query, { pageSize }), ); - return mapFileSearchResult(result, limit); + return mapFileSearchResult(result, limit, imageOnly); } if (kind === "directory") { const result = yield* runSearch(query, pageSize, "directorySearch", () => diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 06888ef3f701..173c89ecabff 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -91,6 +91,7 @@ import { issueAssetUrl } from "./assets/AssetAccess.ts"; import * as PortScanner from "./preview/PortScanner.ts"; import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; +import { readWorkflowScript } from "./orchestration/workflowScriptQuery.ts"; import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; @@ -104,7 +105,9 @@ import { requiredScopeForRpcMethod } from "./auth/RpcAuthorization.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; +import * as UsageService from "./usage/UsageService.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; +import * as PullRequestService from "./pullRequest/PullRequestService.ts"; import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; @@ -267,7 +270,7 @@ function projectSetupScriptCompatibilityDetail( } } -function isThreadDetailEvent(event: OrchestrationEvent): event is Extract< +export function isThreadDetailEvent(event: OrchestrationEvent): event is Extract< OrchestrationEvent, { type: @@ -298,6 +301,13 @@ const PROVIDER_STATUS_DEBOUNCE_MS = 200; // Matches the event store's default page size (DEFAULT_READ_FROM_SEQUENCE_LIMIT). const SHELL_RESUME_MAX_GAP = 1_000; +// Same bound for thread resume. The replay reads the *global* event range and +// filters per-thread afterwards, so a stale cursor far behind the head would +// otherwise decode every intervening event's payload — reconnects with cursors +// hundreds of thousands of events behind have OOM-killed servers on large +// databases. Past this gap the client is reset with a fresh thread snapshot. +const THREAD_RESUME_MAX_GAP = 1_000; + function toAuthAccessStreamEvent( change: PairingGrantStore.BootstrapCredentialChange | SessionStore.SessionCredentialChange, revision: number, @@ -399,11 +409,13 @@ const makeWsRpcLayer = ( ); const sourceControlRepositories = yield* SourceControlRepositoryService.SourceControlRepositoryService; + const pullRequests = yield* PullRequestService.PullRequestService; const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; const sessions = yield* SessionStore.SessionStore; const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor; const resourceTelemetry = yield* ResourceTelemetry.ResourceTelemetry; + const usage = yield* UsageService.UsageService; const relayClient = yield* RelayClient.RelayClient; const authorizationError = (requiredScope: AuthEnvironmentScope) => new EnvironmentAuthorizationError({ @@ -900,7 +912,16 @@ const makeWsRpcLayer = ( if (bootstrap?.prepareWorktree) { let worktreeBaseRef = bootstrap.prepareWorktree.baseBranch; - if (bootstrap.prepareWorktree.startFromOrigin) { + // "Start from origin" is a stored default; repos without an + // origin remote fall back to the local base branch instead of + // failing the whole bootstrap on `git fetch origin`. + const startFromOrigin = + bootstrap.prepareWorktree.startFromOrigin === true && + (yield* gitWorkflow.remoteExists({ + cwd: bootstrap.prepareWorktree.projectCwd, + remoteName: "origin", + })); + if (startFromOrigin) { yield* gitWorkflow.fetchRemote({ cwd: bootstrap.prepareWorktree.projectCwd, remoteName: "origin", @@ -1002,6 +1023,7 @@ const makeWsRpcLayer = ( settings, shellResumeCompletionMarker: true, threadResumeCompletionMarker: true, + threadSnapshotPagination: true, }; }); @@ -1016,53 +1038,83 @@ const makeWsRpcLayer = ( ORCHESTRATION_WS_METHODS.dispatchCommand, Effect.gen(function* () { const normalizedCommand = yield* normalizeDispatchCommand(command); - const shouldStopSessionAfterArchive = - normalizedCommand.type === "thread.archive" - ? yield* projectionSnapshotQuery - .getThreadShellById(normalizedCommand.threadId) - .pipe( - Effect.map( - Option.match({ - onNone: () => false, - onSome: (thread) => - thread.session !== null && thread.session.status !== "stopped", - }), - ), - Effect.orElseSucceed(() => false), - ) - : false; + // Archive and settle both mean "done with this thread", so a + // live provider session must not keep running background work + // (PR monitors, dev servers, subagent fleets) after either + // lands. The decider rejects settling a starting/running + // session, so for settle this only ever stops an idle one; a + // stopped session-set does not count as activity, so the stop + // cannot un-settle the thread it follows. + const parkingCommand = + normalizedCommand.type === "thread.archive" || + normalizedCommand.type === "thread.settle" + ? normalizedCommand + : undefined; + // Best-effort on purpose: the user's archive/settle must not + // fail because this cleanup read blipped, so a failed read + // logs and skips the stop instead of propagating. + const shouldStopSessionAfterCommand = parkingCommand + ? yield* projectionSnapshotQuery.getThreadShellById(parkingCommand.threadId).pipe( + Effect.map( + Option.match({ + onNone: () => false, + onSome: (thread) => + thread.session !== null && thread.session.status !== "stopped", + }), + ), + Effect.catchCause((cause) => + Effect.logWarning( + "failed to read thread session state before session-stop check", + { threadId: parkingCommand.threadId, cause }, + ).pipe(Effect.as(false)), + ), + ) + : false; const result = yield* dispatchNormalizedCommand(normalizedCommand); - if (normalizedCommand.type === "thread.archive") { - if (shouldStopSessionAfterArchive) { + if (parkingCommand) { + const parkingKind = parkingCommand.type === "thread.archive" ? "archive" : "settle"; + if (shouldStopSessionAfterCommand) { yield* Effect.gen(function* () { const stopCommand = yield* normalizeDispatchCommand({ type: "thread.session.stop", commandId: CommandId.make( - `session-stop-for-archive:${normalizedCommand.commandId}`, + `session-stop-for-${parkingKind}:${parkingCommand.commandId}`, ), - threadId: normalizedCommand.threadId, + threadId: parkingCommand.threadId, createdAt: yield* nowIso, + // A settled thread can be re-engaged before this stop is + // decided; the decider then drops the stop instead of + // killing the new session. Archive stops stay + // unconditional: turn starts on archived threads are + // rejected, so there is no new session to protect. + ...(parkingKind === "settle" ? { onlyIfSettled: true } : {}), }); yield* dispatchNormalizedCommand(stopCommand); }).pipe( Effect.catchCause((cause) => - Effect.logWarning("failed to stop provider session during archive", { - threadId: normalizedCommand.threadId, + Effect.logWarning(`failed to stop provider session during ${parkingKind}`, { + threadId: parkingCommand.threadId, cause, }), ), ); } - yield* terminalManager.close({ threadId: normalizedCommand.threadId }).pipe( - Effect.catch((error) => - Effect.logWarning("failed to close thread terminals after archive", { - threadId: normalizedCommand.threadId, - error: error.message, - }), - ), - ); + // Terminals are user-opened panes, not thread background + // work: archive removes the thread from view so they close + // with it, but a settled thread stays reachable and may be + // un-settled, so its terminals stay up. + if (parkingCommand.type === "thread.archive") { + yield* terminalManager.close({ threadId: parkingCommand.threadId }).pipe( + Effect.catch((error) => + Effect.logWarning("failed to close thread terminals after archive", { + threadId: parkingCommand.threadId, + error: error.message, + }), + ), + ); + } } return result; }).pipe( @@ -1077,6 +1129,12 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "orchestration" }, ), + [ORCHESTRATION_WS_METHODS.getWorkflowScript]: (input) => + observeRpcEffect( + ORCHESTRATION_WS_METHODS.getWorkflowScript, + readWorkflowScript({ scriptPath: input.scriptPath }), + { "rpc.aggregate": "orchestration" }, + ), [ORCHESTRATION_WS_METHODS.getTurnDiff]: (input) => observeRpcEffect( ORCHESTRATION_WS_METHODS.getTurnDiff, @@ -1281,42 +1339,61 @@ const makeWsRpcLayer = ( // catch-up followed by the buffered/ongoing live events. Overlapping // events are deduped by sequence on the client. // - // Read the full range after the cursor (not the store's default - // page-bounded limit): the range is normally tiny (a fresh HTTP - // snapshot sequence) and the per-thread filter runs after reading, - // so a global cap could otherwise omit this thread's events. + // The replay is bounded to the projection head captured below. The + // catch-up range is normally tiny (a fresh HTTP snapshot sequence), + // but a stale cached cursor can sit hundreds of thousands of global + // events behind — replaying that decodes every intervening event + // (including every other thread's tool payloads) only to discard + // almost all of them, which has OOM-killed servers on large + // databases. A truncated replay would silently drop this thread's + // events, so past the gap cap we reset the client with a fresh + // thread snapshot instead, exactly like subscribeShell above. if (input.afterSequence !== undefined) { const afterSequence = input.afterSequence; - const catchUpStream = orchestrationEngine - .readEvents(afterSequence, Number.MAX_SAFE_INTEGER) - .pipe( - Stream.filter(isThisThreadDetailEvent), - Stream.map((event) => ({ - kind: "event" as const, - event: projectActivityEvent(event), - })), - Stream.mapError( - (cause) => - new OrchestrationGetSnapshotError({ - message: `Failed to replay thread ${input.threadId} events`, - cause, - }), - ), - ); - const afterCatchUp = - input.requestCompletionMarker === true - ? Stream.concat( - Stream.fromEffect( - Queue.offer(liveBuffer, { kind: "synchronized" as const }), - ).pipe(Stream.drain), - bufferedLiveStream, - ) - : bufferedLiveStream; - return Stream.concat(catchUpStream, afterCatchUp); + const headSequence = yield* orchestrationEngine.latestSequence; + const replayGap = headSequence - afterSequence; + if (replayGap >= 0 && replayGap <= THREAD_RESUME_MAX_GAP) { + const catchUpStream = orchestrationEngine + .readEvents(afterSequence, replayGap) + .pipe( + Stream.filter(isThisThreadDetailEvent), + Stream.map((event) => ({ + kind: "event" as const, + event: projectActivityEvent(event), + })), + Stream.mapError( + (cause) => + new OrchestrationGetSnapshotError({ + message: `Failed to replay thread ${input.threadId} events`, + cause, + }), + ), + ); + const afterCatchUp = + input.requestCompletionMarker === true + ? Stream.concat( + Stream.fromEffect( + Queue.offer(liveBuffer, { kind: "synchronized" as const }), + ).pipe(Stream.drain), + bufferedLiveStream, + ) + : bufferedLiveStream; + return Stream.concat(catchUpStream, afterCatchUp); + } + // Gap too large (or cursor ahead of authoritative state): fall + // through to the snapshot path so the client converges from a + // fresh thread detail instead of an unbounded replay. } const snapshot = yield* projectionSnapshotQuery - .getThreadDetailSnapshot(input.threadId) + .getThreadDetailSnapshot( + input.threadId, + // Windowing the fallback snapshot is opt-in per subscription: + // clients that don't send turnLimit (including all + // pre-pagination clients) get the full thread, since they + // have no way to load older pages. + input.turnLimit === undefined ? undefined : { turnLimit: input.turnLimit }, + ) .pipe( Effect.mapError( (cause) => @@ -1486,6 +1563,10 @@ const makeWsRpcLayer = ( "rpc.aggregate": "server", }, ), + [WS_METHODS.serverGetUsageSummary]: (input) => + observeRpcEffect(WS_METHODS.serverGetUsageSummary, usage.readSummary(input), { + "rpc.aggregate": "server", + }), [WS_METHODS.serverRetryResourceTelemetry]: (_input) => observeRpcEffect(WS_METHODS.serverRetryResourceTelemetry, resourceTelemetry.retry, { "rpc.aggregate": "server", @@ -1555,6 +1636,84 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "cloud" }, ), + [WS_METHODS.pullRequestsList]: (input) => + observeRpcEffect(WS_METHODS.pullRequestsList, pullRequests.list(input), { + "rpc.aggregate": "pull-requests", + }), + [WS_METHODS.pullRequestsListStats]: (input) => + observeRpcEffect(WS_METHODS.pullRequestsListStats, pullRequests.listStats(input), { + "rpc.aggregate": "pull-requests", + }), + [WS_METHODS.pullRequestsDetail]: (input) => + observeRpcEffect(WS_METHODS.pullRequestsDetail, pullRequests.detail(input), { + "rpc.aggregate": "pull-requests", + }), + [WS_METHODS.pullRequestsActivity]: (input) => + observeRpcEffect(WS_METHODS.pullRequestsActivity, pullRequests.activity(input), { + "rpc.aggregate": "pull-requests", + }), + [WS_METHODS.pullRequestsDiffFileContents]: (input) => + observeRpcEffect( + WS_METHODS.pullRequestsDiffFileContents, + pullRequests.diffFileContents(input), + { "rpc.aggregate": "pull-requests" }, + ), + [WS_METHODS.pullRequestsRunAction]: (input) => + observeRpcEffect(WS_METHODS.pullRequestsRunAction, pullRequests.runAction(input), { + "rpc.aggregate": "pull-requests", + }), + [WS_METHODS.pullRequestsUpdate]: (input) => + observeRpcEffect(WS_METHODS.pullRequestsUpdate, pullRequests.update(input), { + "rpc.aggregate": "pull-requests", + }), + [WS_METHODS.pullRequestsComment]: (input) => + observeRpcEffect(WS_METHODS.pullRequestsComment, pullRequests.comment(input), { + "rpc.aggregate": "pull-requests", + }), + [WS_METHODS.pullRequestsUpdateComment]: (input) => + observeRpcEffect( + WS_METHODS.pullRequestsUpdateComment, + pullRequests.updateComment(input), + { + "rpc.aggregate": "pull-requests", + }, + ), + [WS_METHODS.pullRequestsSubmitReview]: (input) => + observeRpcEffect(WS_METHODS.pullRequestsSubmitReview, pullRequests.submitReview(input), { + "rpc.aggregate": "pull-requests", + }), + [WS_METHODS.pullRequestsReplyToThread]: (input) => + observeRpcEffect( + WS_METHODS.pullRequestsReplyToThread, + pullRequests.replyToThread(input), + { "rpc.aggregate": "pull-requests" }, + ), + [WS_METHODS.pullRequestsSetThreadResolution]: (input) => + observeRpcEffect( + WS_METHODS.pullRequestsSetThreadResolution, + pullRequests.setThreadResolution(input), + { "rpc.aggregate": "pull-requests" }, + ), + [WS_METHODS.pullRequestsSetReaction]: (input) => + observeRpcEffect(WS_METHODS.pullRequestsSetReaction, pullRequests.setReaction(input), { + "rpc.aggregate": "pull-requests", + }), + [WS_METHODS.pullRequestsInvalidate]: (input) => + observeRpcEffect(WS_METHODS.pullRequestsInvalidate, pullRequests.invalidate(input), { + "rpc.aggregate": "pull-requests", + }), + [WS_METHODS.pullRequestsReviewerCandidates]: (input) => + observeRpcEffect( + WS_METHODS.pullRequestsReviewerCandidates, + pullRequests.reviewerCandidates(input), + { "rpc.aggregate": "pull-requests" }, + ), + [WS_METHODS.pullRequestsRequestReviewers]: (input) => + observeRpcEffect( + WS_METHODS.pullRequestsRequestReviewers, + pullRequests.requestReviewers(input), + { "rpc.aggregate": "pull-requests" }, + ), [WS_METHODS.sourceControlLookupRepository]: (input) => observeRpcEffect( WS_METHODS.sourceControlLookupRepository, @@ -1684,9 +1843,33 @@ const makeWsRpcLayer = ( observeRpcEffect( WS_METHODS.assetsCreateUrl, Effect.gen(function* () { - if (input.resource._tag !== "workspace-file") { + if (input.resource._tag === "attachment") { return yield* issueAssetUrl({ resource: input.resource }); } + if (input.resource._tag === "project-favicon") { + const project = yield* projectionSnapshotQuery + .getActiveProjectByWorkspaceRoot(input.resource.cwd) + .pipe( + Effect.mapError( + (cause) => + new AssetWorkspaceContextResolutionError({ + resource: input.resource, + cause, + }), + ), + ); + if (Option.isNone(project)) { + return yield* new AssetWorkspaceContextNotFoundError({ + resource: input.resource, + }); + } + return yield* issueAssetUrl({ + resource: input.resource, + ...(project.value.faviconPath + ? { projectFaviconPath: project.value.faviconPath } + : {}), + }); + } const thread = yield* projectionSnapshotQuery .getThreadShellById(input.resource.threadId) .pipe( @@ -1835,6 +2018,12 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.reviewGetDiffPreview, review.getDiffPreview(input), { "rpc.aggregate": "review", }), + [WS_METHODS.reviewGetDiffFileContents]: (input) => + observeRpcEffect( + WS_METHODS.reviewGetDiffFileContents, + review.getDiffFileContents(input), + { "rpc.aggregate": "review" }, + ), [WS_METHODS.terminalOpen]: (input) => observeRpcEffect(WS_METHODS.terminalOpen, terminalManager.open(input), { "rpc.aggregate": "terminal", @@ -2090,6 +2279,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( Effect.gen(function* () { const previewAutomationBroker = yield* PreviewAutomationBroker.PreviewAutomationBroker; const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; + const pullRequests = yield* PullRequestService.PullRequestService; return HttpRouter.add( "GET", "/ws", @@ -2113,6 +2303,9 @@ export const websocketRpcRouteLayer = Layer.unwrap( Layer.provideMerge(RpcSerialization.layerJson), Layer.provide(ProviderMaintenanceRunner.layer), Layer.provide(Layer.succeed(ServerSelfUpdate.ServerSelfUpdate, serverSelfUpdate)), + // One server-lifetime service means clients share the same PR caches, and a WS + // mutation invalidates the HTTP diff cache that every client reads from. + Layer.provide(Layer.succeed(PullRequestService.PullRequestService, pullRequests)), Layer.provide( SourceControlDiscovery.layer.pipe( Layer.provide( diff --git a/apps/server/test/ActivityPayloadProjection.test.ts b/apps/server/test/ActivityPayloadProjection.test.ts index d6098937e7fb..49f1b532a53a 100644 --- a/apps/server/test/ActivityPayloadProjection.test.ts +++ b/apps/server/test/ActivityPayloadProjection.test.ts @@ -117,9 +117,9 @@ const fixtures = [ server: "repository", tool: "search", arguments: { query: "activity projection" }, - aggregatedOutput: "mcp payload remains available", + aggregatedOutput: "mcp bulk is dropped", }, - ignored: "MCP data is rendered verbatim", + ignored: "top-level bulk", }), makeActivity("search", "web_search", { rawOutput: { @@ -184,13 +184,37 @@ describe("projectActivityPayload", () => { }); }); - it("passes MCP tool data through unchanged", () => { - expect(projectActivityPayload(fixtures[4]!)).toBe(fixtures[4]); + it("slims MCP tool data to the fields the expanded row renders", () => { + expect(projectActivityPayload(fixtures[4]!).payload).toEqual({ + itemType: "mcp_tool_call", + title: "mcp_tool_call", + detail: "mcp_tool_call detail", + status: "completed", + requestKind: "command", + data: { + item: { + server: "repository", + tool: "search", + arguments: { query: "activity projection" }, + }, + }, + }); }); it("keeps current web and mobile derived output identical for every tool item type", () => { for (const activity of fixtures) { const projected = projectActivityPayload(activity); + if (activity === fixtures[4]) { + // MCP is the one deliberate difference: the expanded row's toolData + // loses result bulk but keeps the rendered identity fields. + const [entry] = deriveWorkLogEntries([projected]); + expect(entry?.toolData).toEqual({ + server: "repository", + tool: "search", + arguments: { query: "activity projection" }, + }); + continue; + } expect(deriveWorkLogEntries([projected])).toEqual(deriveWorkLogEntries([activity])); expect(comparableThreadFeed([projected])).toEqual(comparableThreadFeed([activity])); } @@ -233,6 +257,179 @@ describe("projectActivityPayload", () => { }); }); +describe("superseded tool.updated snapshot dedup", () => { + function makeToolLifecycleActivity( + id: string, + kind: "tool.updated" | "tool.completed", + options: { + readonly turn?: string; + readonly title?: string; + readonly detail?: string; + readonly toolCallId?: string; + } = {}, + ): OrchestrationThreadActivity { + const { turn = "turn-a", title = "File change", detail, toolCallId } = options; + return { + id: EventId.make(id), + tone: "tool", + kind, + summary: title, + payload: { + itemType: "file_change", + title, + ...(detail ? { detail } : {}), + data: { + ...(toolCallId ? { toolCallId } : {}), + toolName: "Edit", + input: { file_path: "src/app.ts" }, + }, + }, + turnId: TurnId.make(turn), + createdAt: "2026-07-27T00:00:00.000Z", + }; + } + + function projectedIds(activities: ReadonlyArray) { + return projectThreadDetailSnapshot({ + snapshotSequence: 7, + thread: makeThread(activities), + }).thread.activities.map((activity) => activity.id); + } + + it("drops updates a later completion supersedes in the same turn", () => { + const update1 = makeToolLifecycleActivity("upd-1", "tool.updated"); + const update2 = makeToolLifecycleActivity("upd-2", "tool.updated"); + const completed = makeToolLifecycleActivity("done-1", "tool.completed"); + + expect(projectedIds([update1, update2, completed])).toEqual([completed.id]); + }); + + it("matches on toolCallId when the adapter emits one", () => { + const otherCall = makeToolLifecycleActivity("upd-other", "tool.updated", { + toolCallId: "call-b", + }); + const update = makeToolLifecycleActivity("upd-a", "tool.updated", { toolCallId: "call-a" }); + const completed = makeToolLifecycleActivity("done-a", "tool.completed", { + toolCallId: "call-a", + }); + + // Same itemType/title, different call: only call-a's update is superseded. + expect(projectedIds([otherCall, update, completed])).toEqual([otherCall.id, completed.id]); + }); + + it("keeps updates with no matching completion", () => { + const inFlight = makeToolLifecycleActivity("upd-live", "tool.updated", { title: "Running" }); + const other = makeToolLifecycleActivity("upd-other", "tool.updated", { title: "Reading" }); + const completed = makeToolLifecycleActivity("done-other", "tool.completed", { + title: "Reading", + }); + + expect(projectedIds([inFlight, other, completed])).toEqual([inFlight.id, completed.id]); + }); + + it("drops interleaved superseded updates even when a parallel call separates them", () => { + // Deliberate divergence from the clients' adjacency-based collapse: a + // superseded update separated from its completion by an interleaved + // parallel call renders as its own in-flight row on full history, and the + // snapshot omits it. Its final state still shows via the retained + // completion (1.5% of dropped rows on real data; see the projection's doc + // comment). + const updateA = makeToolLifecycleActivity("upd-a", "tool.updated", { toolCallId: "call-a" }); + const updateB = makeToolLifecycleActivity("upd-b", "tool.updated", { toolCallId: "call-b" }); + const completedA = makeToolLifecycleActivity("done-a", "tool.completed", { + toolCallId: "call-a", + }); + const completedB = makeToolLifecycleActivity("done-b", "tool.completed", { + toolCallId: "call-b", + }); + + expect(projectedIds([updateA, updateB, completedA, completedB])).toEqual([ + completedA.id, + completedB.id, + ]); + }); + + it("keeps an update whose completion lives in another turn", () => { + // A live thread.reverted can discard the completing turn while keeping + // the updating one, which would leave the call unrepresented. + const update = makeToolLifecycleActivity("upd-kept", "tool.updated", { turn: "turn-kept" }); + const completed = makeToolLifecycleActivity("done-later", "tool.completed", { + turn: "turn-reverted", + }); + + expect(projectedIds([update, completed])).toEqual([update.id, completed.id]); + }); + + it("keeps an update that follows its completion", () => { + // A later update under the same identity is the next call, still in flight. + const completed = makeToolLifecycleActivity("done-first", "tool.completed"); + const nextCall = makeToolLifecycleActivity("upd-next", "tool.updated"); + + expect(projectedIds([completed, nextCall])).toEqual([completed.id, nextCall.id]); + }); + + it("keeps identity-less rows the clients never collapse", () => { + const anonymous: OrchestrationThreadActivity = { + id: EventId.make("upd-anon"), + tone: "tool", + kind: "tool.updated", + summary: " ", + payload: { data: { toolName: "Edit" } }, + turnId: TurnId.make("turn-a"), + createdAt: "2026-07-27T00:00:00.000Z", + }; + const completed: OrchestrationThreadActivity = { + ...anonymous, + id: EventId.make("done-anon"), + kind: "tool.completed", + }; + + expect(projectedIds([anonymous, completed])).toEqual([anonymous.id, completed.id]); + }); + + it("does not filter live activity-appended events", () => { + const update = makeToolLifecycleActivity("upd-live-event", "tool.updated"); + const event = { + sequence: 11, + eventId: EventId.make("event-tool-updated"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-projection"), + occurredAt: "2026-07-27T00:00:03.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.activity-appended", + payload: { + threadId: ThreadId.make("thread-projection"), + activity: update, + }, + } satisfies Extract; + + const projected = projectActivityEvent(event); + expect( + projected.type === "thread.activity-appended" ? projected.payload.activity.id : undefined, + ).toEqual(update.id); + }); + + it("leaves the collapsed work log identical to the full history", () => { + const activities = [ + makeToolLifecycleActivity("upd-1", "tool.updated", { detail: "writing" }), + makeToolLifecycleActivity("upd-2", "tool.updated", { detail: "writing" }), + makeToolLifecycleActivity("done-1", "tool.completed", { detail: "writing" }), + ]; + const projected = projectThreadDetailSnapshot({ + snapshotSequence: 7, + thread: makeThread(activities), + }); + + const before = deriveWorkLogEntries(activities); + const after = deriveWorkLogEntries(projected.thread.activities); + expect(after).toHaveLength(before.length); + expect(after.map((entry) => entry.label)).toEqual(before.map((entry) => entry.label)); + }); +}); + describe("context-window snapshot dedup", () => { function makeContextWindowActivity( id: string, @@ -328,12 +525,12 @@ describe("context-window snapshot dedup", () => { ); }); - it("leaves snapshots without context-window activities untouched", () => { + it("applies only payload slimming when there are no context-window activities", () => { const projected = projectThreadDetailSnapshot({ snapshotSequence: 7, thread: makeThread([fixtures[4]!]), }); - expect(projected.thread.activities).toEqual([fixtures[4]]); + expect(projected.thread.activities).toEqual([projectActivityPayload(fixtures[4]!)]); }); it("does not filter live activity-appended events", () => { diff --git a/apps/web/index.html b/apps/web/index.html index dadef17d3bc6..8f49fd32c829 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -6,32 +6,397 @@ name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" /> - - - + @@ -47,30 +412,33 @@ body { background: #ffffff; color: #262626; - font-family: - "DM Sans Variable", - "DM Sans", - -apple-system, - BlinkMacSystemFont, - "Segoe UI", - system-ui, - sans-serif; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; } html.dark body { - background: #161616; + background: #0a0a0a; color: #f5f5f5; } #boot-shell { + position: relative; + overflow: hidden; display: flex; min-height: 100%; align-items: center; justify-content: center; background: inherit; + color: inherit; + } + + html[data-theme-selected="true"] #boot-shell { + background: var(--boot-background); + color: var(--boot-foreground); } #boot-shell-card { + position: relative; + z-index: 1; display: flex; align-items: center; justify-content: center; diff --git a/apps/web/package.json b/apps/web/package.json index a25e42cc84be..598feaec0ce9 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,9 +1,10 @@ { "name": "@t3tools/web", - "version": "0.0.31", + "version": "0.0.33", "private": true, "type": "module", "scripts": { + "build:ghostty-wasm": "bash scripts/build-libghostty-wasm.sh", "dev": "vp dev", "build": "vp build", "preview": "vp preview", @@ -19,11 +20,10 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@effect/atom-react": "catalog:", - "@fontsource-variable/dm-sans": "^5.2.8", - "@fontsource/jetbrains-mono": "^5.2.8", "@formkit/auto-animate": "^0.9.0", - "@legendapp/list": "3.2.0", + "@legendapp/list": "catalog:", "@lexical/react": "^0.41.0", + "@noble/hashes": "catalog:", "@pierre/diffs": "catalog:", "@pierre/trees": "1.0.0-beta.4", "@t3tools/client-runtime": "workspace:*", @@ -31,11 +31,12 @@ "@t3tools/shared": "workspace:*", "@tanstack/react-pacer": "^0.19.4", "@tanstack/react-router": "^1.160.2", - "@xterm/addon-fit": "^0.11.0", - "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", + "culori": "^4.0.2", "effect": "catalog:", "jose": "catalog:", + "jsonc-parser": "3.3.1", + "jszip": "3.10.1", "lexical": "^0.41.0", "lucide-react": "^0.564.0", "react": "19.2.6", @@ -55,11 +56,14 @@ "@tailwindcss/vite": "^4.0.0", "@tanstack/router-plugin": "^1.161.0", "@types/babel__core": "^7.20.5", + "@types/compression": "^1.8.1", + "@types/culori": "^4.0.1", "@types/react": "~19.2.14", "@types/react-dom": "~19.2.3", "@vercel/config": "^0.3.0", "@vitejs/plugin-react": "^6.0.0", "babel-plugin-react-compiler": "1.0.0", + "compression": "^1.8.1", "msw": "2.12.11", "tailwindcss": "^4.0.0", "vite": "catalog:", diff --git a/apps/web/scripts/build-libghostty-wasm.sh b/apps/web/scripts/build-libghostty-wasm.sh new file mode 100755 index 000000000000..d4b1238bbfc0 --- /dev/null +++ b/apps/web/scripts/build-libghostty-wasm.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +WEB_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +REPO_DIR="$(cd "${WEB_DIR}/../.." && pwd)" +CANONICAL_VENDOR_DIR="${REPO_DIR}/native/libghostty-vt" +VENDOR_DIR="${WEB_DIR}/src/terminal/ghostty/vendor" + +GHOSTTY_REVISION="$(tr -d '[:space:]' < "${CANONICAL_VENDOR_DIR}/VERSION")" +GHOSTTY_SOURCE_DIR="${GHOSTTY_SOURCE_DIR:-${HOME}/.cache/t3code/ghostty-${GHOSTTY_REVISION:0:8}}" +GHOSTTY_ZIG_VERSION="${GHOSTTY_ZIG_VERSION:-0.15.2}" +GHOSTTY_ZIG="${GHOSTTY_ZIG:-}" + +log() { + printf '[libghostty-vt-wasm] %s\n' "$*" +} + +die() { + printf '[libghostty-vt-wasm] error: %s\n' "$*" >&2 + exit 1 +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || die "missing required command: $1" +} + +ensure_zig() { + if [[ -n "${GHOSTTY_ZIG}" ]]; then + [[ -x "${GHOSTTY_ZIG}" ]] || die "GHOSTTY_ZIG is not executable: ${GHOSTTY_ZIG}" + return + fi + if command -v zig >/dev/null 2>&1 && [[ "$(zig version)" == "${GHOSTTY_ZIG_VERSION}" ]]; then + GHOSTTY_ZIG="$(command -v zig)" + return + fi + + local host_os host_arch cache_dir + host_os="$(uname -s | tr '[:upper:]' '[:lower:]')" + host_arch="$(uname -m)" + case "${host_os}" in + darwin) host_os="macos" ;; + linux) ;; + *) die "unsupported host OS for Zig download: ${host_os}" ;; + esac + case "${host_arch}" in + arm64) host_arch="aarch64" ;; + aarch64 | x86_64) ;; + *) die "unsupported host architecture: ${host_arch}" ;; + esac + + cache_dir="${HOME}/.cache/t3code/zig-${GHOSTTY_ZIG_VERSION}" + GHOSTTY_ZIG="${cache_dir}/zig" + if [[ -x "${GHOSTTY_ZIG}" ]]; then + return + fi + + require_cmd curl + require_cmd tar + mkdir -p "${cache_dir}" + log "downloading Zig ${GHOSTTY_ZIG_VERSION}" + curl -fsSL \ + "https://ziglang.org/download/${GHOSTTY_ZIG_VERSION}/zig-${host_arch}-${host_os}-${GHOSTTY_ZIG_VERSION}.tar.xz" \ + | tar -xJ --strip-components=1 -C "${cache_dir}" +} + +ensure_ghostty_source() { + require_cmd git + if [[ ! -d "${GHOSTTY_SOURCE_DIR}/.git" ]]; then + mkdir -p "$(dirname "${GHOSTTY_SOURCE_DIR}")" + log "cloning Ghostty ${GHOSTTY_REVISION}" + git clone --filter=blob:none --no-checkout https://github.com/ghostty-org/ghostty.git \ + "${GHOSTTY_SOURCE_DIR}" + fi + + # A cached checkout may still be on a previously pinned revision; converge on + # the pinned one instead of failing the rebuild. + local actual_revision + actual_revision="$(git -C "${GHOSTTY_SOURCE_DIR}" rev-parse HEAD 2>/dev/null || echo none)" + if [[ "${actual_revision}" != "${GHOSTTY_REVISION}" ]]; then + log "checking out Ghostty ${GHOSTTY_REVISION}" + git -C "${GHOSTTY_SOURCE_DIR}" fetch --depth=1 origin "${GHOSTTY_REVISION}" + git -C "${GHOSTTY_SOURCE_DIR}" checkout --detach "${GHOSTTY_REVISION}" + fi + + actual_revision="$(git -C "${GHOSTTY_SOURCE_DIR}" rev-parse HEAD)" + [[ "${actual_revision}" == "${GHOSTTY_REVISION}" ]] || \ + die "expected Ghostty ${GHOSTTY_REVISION}, found ${actual_revision}" +} + +ensure_zig +ensure_ghostty_source + +build_root="$(mktemp -d)" +trap 'rm -rf "${build_root}"' EXIT + +log "building ${GHOSTTY_REVISION} for wasm32-freestanding" +( + cd "${GHOSTTY_SOURCE_DIR}" + # The pinned revision rides along as semver build metadata so the artifact + # identifies its own provenance through ghostty_build_info(); mobile's + # VERSION file stays the single source of truth for the pin. + "${GHOSTTY_ZIG}" build \ + -Demit-lib-vt \ + -Dtarget=wasm32-freestanding \ + -Doptimize=ReleaseSmall \ + -Dstrip=true \ + -Dlib-version-string="0.1.0-dev+${GHOSTTY_REVISION}" \ + -p "${build_root}" +) + +mkdir -p "${VENDOR_DIR}" +cp "${build_root}/bin/ghostty-vt.wasm" "${VENDOR_DIR}/ghostty-vt.wasm" +"${GHOSTTY_ZIG}" build-exe \ + "${SCRIPT_DIR}/ghostty-write-pty.zig" \ + -target wasm32-freestanding \ + -O ReleaseSmall \ + -fno-entry \ + -rdynamic \ + -femit-bin="${VENDOR_DIR}/ghostty-write-pty.wasm" +chmod 0644 "${VENDOR_DIR}/ghostty-write-pty.wasm" +log "wrote ${VENDOR_DIR}/ghostty-vt.wasm" diff --git a/apps/web/scripts/ghostty-write-pty.zig b/apps/web/scripts/ghostty-write-pty.zig new file mode 100644 index 000000000000..dbe6688f5e82 --- /dev/null +++ b/apps/web/scripts/ghostty-write-pty.zig @@ -0,0 +1,5 @@ +extern "env" fn t3_write_pty(terminal: u32, userdata: u32, data: u32, len: u32) void; + +export fn ghostty_write_pty(terminal: u32, userdata: u32, data: u32, len: u32) void { + t3_write_pty(terminal, userdata, data, len); +} diff --git a/apps/web/scripts/warm-dep-cache.ts b/apps/web/scripts/warm-dep-cache.ts new file mode 100644 index 000000000000..4fe50a3bf9c8 --- /dev/null +++ b/apps/web/scripts/warm-dep-cache.ts @@ -0,0 +1,22 @@ +// @effect-diagnostics nodeBuiltinImport:off - setup-script bootstrap, runs before any Effect runtime exists. +/** + * Pre-warms Vite's dependency-optimizer cache (`node_modules/.vite/deps`) so + * the first `vp run dev` in a fresh worktree doesn't stall the initial page + * load on a full optimize pass. Run by the t3.json worktree setup script; + * safe to re-run — a valid cache makes this a fast no-op. + * + * The cache cannot be shared between worktrees: Vite's config hash includes + * the absolute project root, so each worktree must warm its own. + */ +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +import { optimizeDeps, resolveConfig } from "vite"; + +const webRoot = NodePath.dirname(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url))); + +// logLevel "error" silences the "manually calling optimizeDeps is deprecated" +// warning — deliberate here: warming ahead of the server is the whole point. +const config = await resolveConfig({ root: webRoot, logLevel: "error" }, "serve"); +await optimizeDeps(config); +console.log("[warm-dep-cache] web dependency cache is warm"); diff --git a/apps/web/src/appearanceFonts.test.ts b/apps/web/src/appearanceFonts.test.ts new file mode 100644 index 000000000000..31a2f1d779c5 --- /dev/null +++ b/apps/web/src/appearanceFonts.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + areFontAdvancesMonospace, + clampCodeFontSize, + clampInterfaceFontSize, + clampPromptFontSize, + DEFAULT_CODE_FONT_STACK, + DEFAULT_SANS_FONT_STACK, + appearanceFontStack, + cssFontFamilies, + resolveDefaultFamilyLabel, + resolveTerminalFontPreference, + resolveTerminalFontSizePreference, +} from "./appearanceFonts"; + +describe("areFontAdvancesMonospace", () => { + it("accepts a fixed advance and rejects any proportional glyph", () => { + expect(areFontAdvancesMonospace([10, 10, 10, 10])).toBe(true); + expect(areFontAdvancesMonospace([10, 10, 7, 10])).toBe(false); + expect(areFontAdvancesMonospace([10, 10.02])).toBe(false); + }); + + it("fails open when canvas metrics are unavailable", () => { + expect(areFontAdvancesMonospace([])).toBe(true); + expect(areFontAdvancesMonospace([Number.NaN, Number.NaN])).toBe(true); + }); +}); + +describe("cssFontFamilies", () => { + it("returns null for effectively empty input", () => { + expect(cssFontFamilies("")).toBeNull(); + expect(cssFontFamilies(" ")).toBeNull(); + expect(cssFontFamilies(" , , ")).toBeNull(); + }); + + it("quotes names with spaces and keeps single idents bare", () => { + expect(cssFontFamilies("Fira Code")).toBe('"Fira Code"'); + expect(cssFontFamilies("monospace")).toBe("monospace"); + expect(cssFontFamilies('"Comic Mono"')).toBe('"Comic Mono"'); + }); + + it("normalizes comma-separated lists and strips embedded quotes", () => { + expect(cssFontFamilies(" Fira Code , Menlo ")).toBe('"Fira Code", Menlo'); + expect(cssFontFamilies('Bad"Name')).toBe('"BadName"'); + }); + + it("quotes names that are not single CSS idents", () => { + expect(cssFontFamilies("3270 Nerd Font")).toBe('"3270 Nerd Font"'); + expect(cssFontFamilies("M+ 1m")).toBe('"M+ 1m"'); + }); +}); + +describe("resolveDefaultFamilyLabel", () => { + it("skips generic keywords and returns null for a stack of only generics", () => { + expect(resolveDefaultFamilyLabel("system-ui, sans-serif")).toBeNull(); + expect(resolveDefaultFamilyLabel("ui-monospace, monospace")).toBeNull(); + }); +}); + +describe("appearanceFontStack", () => { + it("prepends the custom family to the default stack", () => { + expect(appearanceFontStack("Fira Code", DEFAULT_CODE_FONT_STACK)).toBe( + `"Fira Code", ${DEFAULT_CODE_FONT_STACK}`, + ); + }); + + it("falls back to the default stack when unset", () => { + expect(appearanceFontStack("", DEFAULT_SANS_FONT_STACK)).toBe(DEFAULT_SANS_FONT_STACK); + }); +}); + +describe("resolveTerminalFontPreference", () => { + it("inherits the code font in simple mode", () => { + expect( + resolveTerminalFontPreference({ advanced: false, code: "Fira Code", terminal: "" }), + ).toBe("Fira Code"); + expect( + resolveTerminalFontPreference({ + advanced: false, + code: "Fira Code", + terminal: "Berkeley Mono", + }), + ).toBe("Fira Code"); + }); + + it("keeps code and terminal fonts independent in advanced mode", () => { + expect(resolveTerminalFontPreference({ advanced: true, code: "Fira Code", terminal: "" })).toBe( + "", + ); + expect( + resolveTerminalFontPreference({ + advanced: true, + code: "Fira Code", + terminal: "Berkeley Mono", + }), + ).toBe("Berkeley Mono"); + }); +}); + +describe("resolveTerminalFontSizePreference", () => { + it("inherits the code font size in simple mode", () => { + expect(resolveTerminalFontSizePreference({ advanced: false, code: 15, terminal: 12 })).toBe(15); + }); + + it("keeps code and terminal font sizes independent in advanced mode", () => { + expect(resolveTerminalFontSizePreference({ advanced: true, code: 15, terminal: 12 })).toBe(12); + }); +}); + +describe("font size clamping", () => { + it("keeps sizes inside the ranges the UI can absorb", () => { + expect(clampInterfaceFontSize(16)).toBe(16); + expect(clampInterfaceFontSize(2)).toBe(12); + expect(clampInterfaceFontSize(96)).toBe(20); + expect(clampPromptFontSize(40)).toBe(20); + expect(clampCodeFontSize(1)).toBe(10); + }); + + it("rounds fractional values and falls back for unusable input", () => { + expect(clampCodeFontSize(13.4)).toBe(13); + expect(clampInterfaceFontSize(Number.NaN)).toBe(16); + expect(clampPromptFontSize(Number.POSITIVE_INFINITY)).toBe(14); + }); +}); diff --git a/apps/web/src/appearanceFonts.ts b/apps/web/src/appearanceFonts.ts new file mode 100644 index 000000000000..6053e5fb0dd4 --- /dev/null +++ b/apps/web/src/appearanceFonts.ts @@ -0,0 +1,395 @@ +/** + * Font preferences from Settings → Appearance, applied as CSS custom + * properties. The default stacks mirror the `--font-sans` / `--font-mono` + * definitions in `index.css`; a custom family is always prepended to the + * matching default stack so glyph coverage never regresses. + */ + +import { + DEFAULT_CODE_FONT_SIZE, + DEFAULT_INTERFACE_FONT_SIZE, + DEFAULT_PROMPT_FONT_SIZE, + MAX_CODE_FONT_SIZE, + MAX_INTERFACE_FONT_SIZE, + MAX_PROMPT_FONT_SIZE, + MIN_CODE_FONT_SIZE, + MIN_INTERFACE_FONT_SIZE, + MIN_PROMPT_FONT_SIZE, +} from "@t3tools/contracts"; + +export const DEFAULT_SANS_FONT_STACK = + '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif'; + +// Concrete names first: some engines alias `ui-monospace` to the +// proportional system UI font, which would break every code surface. +export const DEFAULT_CODE_FONT_STACK = + '"SF Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace'; + +export const TYPOGRAPHY_ADVANCED_STORAGE_KEY = "t3code:typography-advanced"; + +/** + * Simple typography treats the terminal as another monospace surface. In + * Advanced mode an empty terminal preference means the terminal default, + * keeping later code-font changes isolated to code surfaces. + */ +export function resolveTerminalFontPreference(input: { + readonly advanced: boolean; + readonly code: string; + readonly terminal: string; +}): string { + if (input.advanced) return input.terminal; + return input.code; +} + +export function resolveTerminalFontSizePreference(input: { + readonly advanced: boolean; + readonly code: number; + readonly terminal: number; +}): number { + if (input.advanced) return input.terminal; + return input.code; +} + +function quoteFontFamilyName(name: string): string { + const bare = name.trim(); + if (bare.length === 0) return ""; + // Already quoted, or a single ident that needs no quoting. + if (/^(['"]).*\1$/.test(bare)) return bare; + if (/^[a-zA-Z][a-zA-Z0-9-]*$/.test(bare)) return bare; + return `"${bare.replaceAll('"', "")}"`; +} + +/** + * Normalize a user-entered family (single name or comma-separated list) into a + * safe CSS font-family list, or null when the input is effectively empty. + */ +export function cssFontFamilies(input: string): string | null { + const families = input + .split(",") + .map(quoteFontFamilyName) + .filter((name) => name.length > 0); + return families.length > 0 ? families.join(", ") : null; +} + +/** The full stack a preference resolves to: custom families before the default. */ +export function appearanceFontStack(custom: string, defaultStack: string): string { + const families = cssFontFamilies(custom); + return families === null ? defaultStack : `${families}, ${defaultStack}`; +} + +export interface AppearanceFontPreferences { + readonly sans: string; + readonly code: string; + readonly composer: string; + readonly sizeInterface: number; + readonly sizePrompt: number; + readonly sizeCode: number; + /** Grayscale `antialiased` rendering; false keeps the heavier platform default. */ + readonly smoothing: boolean; +} + +/** + * Apply the preferences to the root element. Unset families remove the + * override so the stylesheet defaults (and theme changes) stay in charge. + * + * Sizes are always written: the interface size drives the root font size (and + * with it every rem-based dimension), while the prompt and code sizes stay in + * absolute pixels so they do not scale twice. + */ +export function applyAppearanceFontVariables( + root: HTMLElement, + preferences: AppearanceFontPreferences, +): void { + const families: ReadonlyArray = [ + ["--font-sans", preferences.sans, DEFAULT_SANS_FONT_STACK], + ["--font-mono", preferences.code, DEFAULT_CODE_FONT_STACK], + // The composer falls back to whatever the sans preference resolves to. + ["--font-composer", preferences.composer, "var(--font-sans)"], + ]; + for (const [variable, custom, fallback] of families) { + const list = cssFontFamilies(custom); + if (list === null) { + root.style.removeProperty(variable); + } else { + root.style.setProperty(variable, `${list}, ${fallback}`); + } + } + + root.style.fontSize = `${clampInterfaceFontSize(preferences.sizeInterface)}px`; + root.style.setProperty("--font-size-prompt", `${clampPromptFontSize(preferences.sizePrompt)}px`); + const code = clampCodeFontSize(preferences.sizeCode); + root.style.setProperty("--font-size-code", `${code}px`); + // The @pierre/diffs surfaces read their own hook for code text. + root.style.setProperty("--diffs-font-size", `${code}px`); + + // Inherited from the root; only macOS engines honor the property, so no + // platform gate is needed here. Smoothing on means grayscale `antialiased` + // (thinner strokes); off restores the platform default, which macOS renders + // with heavier stem darkening. + if (preferences.smoothing) { + root.style.setProperty("-webkit-font-smoothing", "antialiased"); + } else { + root.style.removeProperty("-webkit-font-smoothing"); + } +} + +function clampFontSize(value: number, minimum: number, maximum: number, fallback: number): number { + if (!Number.isFinite(value)) return fallback; + return Math.min(maximum, Math.max(minimum, Math.round(value))); +} + +export function clampInterfaceFontSize(value: number): number { + return clampFontSize( + value, + MIN_INTERFACE_FONT_SIZE, + MAX_INTERFACE_FONT_SIZE, + DEFAULT_INTERFACE_FONT_SIZE, + ); +} + +export function clampPromptFontSize(value: number): number { + return clampFontSize(value, MIN_PROMPT_FONT_SIZE, MAX_PROMPT_FONT_SIZE, DEFAULT_PROMPT_FONT_SIZE); +} + +export function clampCodeFontSize(value: number): number { + return clampFontSize(value, MIN_CODE_FONT_SIZE, MAX_CODE_FONT_SIZE, DEFAULT_CODE_FONT_SIZE); +} + +const FONT_PROBE_TEXT = "mmmmmmmmMMWli1O0@# fjord"; +let fontProbeContext: CanvasRenderingContext2D | null | undefined; + +function probeWidth(fontList: string): number | null { + if (fontProbeContext === undefined) { + fontProbeContext = document.createElement("canvas").getContext("2d"); + } + if (fontProbeContext === null) return null; + fontProbeContext.font = `16px ${fontList}`; + return fontProbeContext.measureText(FONT_PROBE_TEXT).width; +} + +/** + * Canvas metric probing instead of document.fonts.check(): check() reports + * true for families that are not installed at all (nothing needs loading), so + * it cannot filter the dropdown. A family exists when falling back to at + * least one generic changes the measured advance. + */ +export function isFontFamilyAvailable(family: string): boolean { + const families = cssFontFamilies(family); + if (families === null) return false; + if (/^(system-ui|sans-serif|serif|monospace|ui-monospace)$/i.test(families)) return true; + try { + for (const generic of ["monospace", "serif", "sans-serif"]) { + const baseline = probeWidth(generic); + const candidate = probeWidth(`${families}, ${generic}`); + if (baseline === null || candidate === null) return false; + if (candidate !== baseline) return true; + } + return false; + } catch { + return false; + } +} + +const MONOSPACE_PROBE_VARIANTS = ["normal 400", "normal 700", "italic 400", "italic 700"] as const; +const MONOSPACE_PROBE_GLYPHS = ["i", "M", "W", "0", "@", "#", ".", " "] as const; +const MONOSPACE_ADVANCE_TOLERANCE = 0.01; + +export function areFontAdvancesMonospace(advances: readonly number[]): boolean { + const reference = advances[0]; + if ( + reference === undefined || + reference <= 0 || + advances.some((advance) => !Number.isFinite(advance) || advance <= 0) + ) { + return true; + } + return advances.every((advance) => Math.abs(advance - reference) < MONOSPACE_ADVANCE_TOLERANCE); +} + +/** + * Whether a family renders every character on the same advance. Cell-grid + * surfaces (the terminal) require this: a proportional face draws its text + * narrower than the lattice the cursor and selection are placed on, which + * reads as ragged gaps and a cursor stranded to the right of the text. + * + * Unmeasurable environments answer true, so a missing canvas never blocks a + * legitimate font. + */ +export function isMonospaceFamily(family: string): boolean { + const families = cssFontFamilies(family); + if (families === null) return true; + try { + if (fontProbeContext === undefined) { + fontProbeContext = document.createElement("canvas").getContext("2d"); + } + if (fontProbeContext === null) return true; + const context = fontProbeContext; + // Fall back to a generic mono so an absent face measures as monospace and + // is left for the normal fallback chain to resolve. + for (const variant of MONOSPACE_PROBE_VARIANTS) { + context.font = `${variant} 32px ${families}, monospace`; + const advances = MONOSPACE_PROBE_GLYPHS.map((glyph) => context.measureText(glyph).width); + if (!areFontAdvancesMonospace(advances)) return false; + } + return true; + } catch { + return true; + } +} + +// Nameable faces the platform generics commonly map to, likeliest first. +// Pixel-comparing a generic against these names the actual face; Apple's own +// UI fonts are deliberately not CSS-nameable, so a miss on an Apple platform +// identifies San Francisco itself. +const SANS_GENERIC_CANDIDATES = [ + "Segoe UI", + "Roboto", + "Noto Sans", + "Ubuntu", + "Cantarell", + "DejaVu Sans", + "Liberation Sans", + "Helvetica Neue", + "Arial", +] as const; +const MONO_GENERIC_CANDIDATES = [ + "Menlo", + "Consolas", + "Cascadia Mono", + "DejaVu Sans Mono", + "Ubuntu Mono", + "Liberation Mono", + "Noto Sans Mono", + "Roboto Mono", + "Monaco", + "Courier New", +] as const; + +const GENERIC_PROBE_TEXT = "RagIl10O@ fjord quiz"; + +/** + * Advance width of the probe text laid out by the DOM - not canvas, whose + * generic-family mapping diverges from real rendering (this engine draws + * `ui-monospace` as the proportional UI font on canvas but not in CSS). + * Identical widths at this size mean the same face for practical purposes. + */ +function measureDomProbeWidth(fontFamily: string): number | null { + try { + const body = document.body; + if (!body) return null; + const span = document.createElement("span"); + span.style.cssText = + "position:absolute;left:-9999px;top:0;visibility:hidden;white-space:pre;font-size:100px;"; + span.style.fontFamily = fontFamily; + span.textContent = GENERIC_PROBE_TEXT; + body.appendChild(span); + const width = span.getBoundingClientRect().width; + span.remove(); + return width > 0 ? width : null; + } catch { + return null; + } +} + +function widthsMatch(left: number, right: number): boolean { + return Math.abs(left - right) < 0.01; +} + +/** + * Name the concrete face a generic keyword renders as, by measuring the + * generic against nameable candidates. Null when the face cannot be + * identified (and the platform gives no definitional answer). + */ +function resolveGenericFamilyLabel(generic: string): string | null { + const lower = generic.toLowerCase(); + if (lower === "serif") return null; + const monoLike = lower === "ui-monospace" || lower === "monospace"; + const genericWidth = measureDomProbeWidth(generic); + if (genericWidth === null) return null; + for (const candidate of monoLike ? MONO_GENERIC_CANDIDATES : SANS_GENERIC_CANDIDATES) { + if (!isFontFamilyAvailable(candidate)) continue; + const candidateWidth = measureDomProbeWidth(`"${candidate}"`); + if (candidateWidth !== null && widthsMatch(genericWidth, candidateWidth)) { + return candidate; + } + } + // No nameable face matched; on Apple platforms that means one of the San + // Francisco faces, which CSS cannot name. Comparing against -apple-system + // tells the UI face apart from SF Mono. + if (/mac|iphone|ipad|ipod/i.test(navigator.platform)) { + const systemWidth = measureDomProbeWidth("-apple-system"); + if (systemWidth !== null && widthsMatch(genericWidth, systemWidth)) return "SF Pro"; + return monoLike ? "SF Mono" : "SF Pro"; + } + return null; +} + +/** + * The first family of a default stack that will actually render - what the + * "Default" choice means on this machine. Concrete names are probed for + * availability; generic keywords are resolved to the face they draw with + * where identifiable. Null when nothing can be named. + */ +export function resolveDefaultFamilyLabel(stack: string): string | null { + for (const raw of stack.split(",")) { + const family = raw.trim().replace(/^(['"])(.*)\1$/, "$2"); + if (family.length === 0) continue; + if ( + /^(system-ui|sans-serif|serif|monospace|ui-monospace|-apple-system|BlinkMacSystemFont)$/i.test( + family, + ) + ) { + const resolved = resolveGenericFamilyLabel(family); + if (resolved !== null) return resolved; + continue; + } + if (isFontFamilyAvailable(family)) return family; + } + return null; +} + +export interface InstalledFontFamiliesResult { + readonly families: readonly string[]; + /** + * "unsupported" - the engine has no Local Font Access API (Safari, + * Firefox); "denied" - the API exists but the user declined the permission + * prompt. Both fall back to the curated catalog. + */ + readonly status: "granted" | "denied" | "unsupported"; +} + +let installedFamiliesCache: InstalledFontFamiliesResult | null = null; + +/** + * Every installed family via the Local Font Access API (Chromium and + * Electron). Call from a user gesture: the first call raises the browser's + * local-fonts permission prompt. A denial is not cached, so reopening the + * picker can ask again after the user changes the site setting. + */ +export async function queryInstalledFontFamilies(): Promise { + if (installedFamiliesCache !== null) return installedFamiliesCache; + const query = ( + window as Window & { + queryLocalFonts?: () => Promise>; + } + ).queryLocalFonts; + if (typeof query !== "function") { + installedFamiliesCache = { families: [], status: "unsupported" }; + return installedFamiliesCache; + } + try { + const fonts = await query.call(window); + const families = [...new Set(fonts.map((font) => font.family))] + // Dot-prefixed families are macOS-internal UI faces; selecting one is + // never intended and most refuse to render for web content anyway. + .filter((family) => !family.startsWith(".")) + .sort((left, right) => left.localeCompare(right)); + // A denied permission check resolves with an empty list instead of + // throwing; no machine has zero fonts, so treat empty as denied. + if (families.length === 0) return { families: [], status: "denied" }; + installedFamiliesCache = { families, status: "granted" }; + return installedFamiliesCache; + } catch { + return { families: [], status: "denied" }; + } +} diff --git a/apps/web/src/assets/assetUrls.ts b/apps/web/src/assets/assetUrls.ts index 701af3a79fc9..f8c0b5ae75f7 100644 --- a/apps/web/src/assets/assetUrls.ts +++ b/apps/web/src/assets/assetUrls.ts @@ -12,7 +12,7 @@ export { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; export type AssetUrlState = | { readonly _tag: "Loading" } | { readonly _tag: "Failure" } - | { readonly _tag: "Success"; readonly url: string }; + | { readonly _tag: "Success"; readonly url: string; readonly sourcePath?: string }; export function useAssetUrlState( environmentId: EnvironmentId, @@ -32,7 +32,13 @@ export function useAssetUrlState( return { _tag: "Loading" }; } const url = resolveAssetUrl(preparedConnection.value.httpBaseUrl, result.value.relativeUrl); - return url === null ? { _tag: "Failure" } : { _tag: "Success", url }; + return url === null + ? { _tag: "Failure" } + : { + _tag: "Success", + url, + ...(result.value.sourcePath !== undefined ? { sourcePath: result.value.sourcePath } : {}), + }; } export function useAssetUrl(environmentId: EnvironmentId, resource: AssetResource): string | null { diff --git a/apps/web/src/branding.logic.ts b/apps/web/src/branding.logic.ts index 06d663ca0b4a..056fbb76e6ab 100644 --- a/apps/web/src/branding.logic.ts +++ b/apps/web/src/branding.logic.ts @@ -11,51 +11,6 @@ export function formatAppDisplayName(input: { return `${input.baseName} (${input.stageLabel})`; } -/** - * Whether the sidebar v2 beta is on by default for a build stage. - * - * Nightly and local dev opt in; Alpha and Latest stay on v1. This is resolved - * from the client's own stage label rather than the connected server's version: - * v2 only exists in the client, so a stable client on a nightly server has - * nothing to turn on. - */ -export function resolveSidebarV2Default(stageLabel: string): boolean { - const stage = stageLabel.trim().toLowerCase(); - return stage === "nightly" || stage === "dev"; -} - -/** - * Resolved sidebar v2 state: an explicit choice if the user has made one, - * otherwise the default for this build stage. - * - * A stored `enabled: true` counts as an explicit choice even without the - * companion flag. `true` was never the schema default, so it can only have come - * from the Settings → Beta toggle — settings written before that flag existed - * would otherwise lose the opt-in and drop such users back to v1 on production. - * Mirrors how `normalizeDesktopSettingsDocument` treats a legacy stored - * `updateChannel: "nightly"` as user-configured. - * - * `settingsHydrated` guards the startup window: client settings load - * asynchronously and the pre-hydration snapshot is just the schema defaults, so - * resolving against it would mount one sidebar and swap it out a tick later, - * remounting the tree. While hydrating, hold v1 — where both paths already - * start. - */ -export function resolveSidebarV2Enabled(input: { - readonly enabled: boolean; - readonly configuredByUser: boolean; - readonly settingsHydrated: boolean; - readonly stageLabel: string; -}): boolean { - if (!input.settingsHydrated) { - return false; - } - - return input.configuredByUser || input.enabled - ? input.enabled - : resolveSidebarV2Default(input.stageLabel); -} - export function resolveServerBackedAppStageLabel(input: { readonly primaryServerVersion: string | null | undefined; readonly fallbackStageLabel: string; diff --git a/apps/web/src/branding.test.ts b/apps/web/src/branding.test.ts index e517d40b04f3..e1c87bcf0595 100644 --- a/apps/web/src/branding.test.ts +++ b/apps/web/src/branding.test.ts @@ -2,8 +2,6 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { resolveServerBackedAppDisplayName, resolveServerBackedAppStageLabel, - resolveSidebarV2Default, - resolveSidebarV2Enabled, } from "./branding.logic"; const originalWindow = globalThis.window; @@ -116,74 +114,3 @@ describe("branding logic", () => { ).toBe("T3 Code (Alpha)"); }); }); - -describe("resolveSidebarV2Default", () => { - it.each(["Nightly", "Dev", "nightly", " dev "])("enables the beta for %s builds", (stage) => { - expect(resolveSidebarV2Default(stage)).toBe(true); - }); - - it.each(["Alpha", "Latest", ""])("leaves the beta off for %s builds", (stage) => { - expect(resolveSidebarV2Default(stage)).toBe(false); - }); -}); - -describe("resolveSidebarV2Enabled", () => { - const hydrated = { settingsHydrated: true } as const; - - it.each(["Alpha", "Latest"])( - "keeps a legacy opt-in on %s builds even without the companion flag", - (stageLabel) => { - // `true` was never the schema default, so it can only be an explicit - // opt-in from settings written before `sidebarV2ConfiguredByUser` existed. - expect( - resolveSidebarV2Enabled({ - ...hydrated, - enabled: true, - configuredByUser: false, - stageLabel, - }), - ).toBe(true); - }, - ); - - it("applies the stage default when the beta was never enabled or configured", () => { - expect( - resolveSidebarV2Enabled({ - ...hydrated, - enabled: false, - configuredByUser: false, - stageLabel: "Nightly", - }), - ).toBe(true); - expect( - resolveSidebarV2Enabled({ - ...hydrated, - enabled: false, - configuredByUser: false, - stageLabel: "Latest", - }), - ).toBe(false); - }); - - it("honors an explicit opt-out over the stage default", () => { - expect( - resolveSidebarV2Enabled({ - ...hydrated, - enabled: false, - configuredByUser: true, - stageLabel: "Nightly", - }), - ).toBe(false); - }); - - it("holds v1 until settings hydrate so the sidebar does not remount", () => { - expect( - resolveSidebarV2Enabled({ - enabled: true, - configuredByUser: true, - settingsHydrated: false, - stageLabel: "Nightly", - }), - ).toBe(false); - }); -}); diff --git a/apps/web/src/browser/browserTargetResolver.ts b/apps/web/src/browser/browserTargetResolver.ts index 9b201dbdbaec..3c3be59b4578 100644 --- a/apps/web/src/browser/browserTargetResolver.ts +++ b/apps/web/src/browser/browserTargetResolver.ts @@ -7,7 +7,8 @@ import { isLoopbackHost, normalizePreviewUrl } from "@t3tools/shared/preview"; import { readPreparedConnection } from "~/state/session"; -const normalizeHostname = (host: string): string => host.toLowerCase().replace(/^\[|\]$/g, ""); +export const normalizeHostname = (host: string): string => + host.toLowerCase().replace(/^\[|\]$/g, ""); const parseIpv4Address = (host: string): readonly number[] | null => { const parts = normalizeHostname(host).split(".").map(Number); @@ -17,7 +18,7 @@ const parseIpv4Address = (host: string): readonly number[] | null => { : null; }; -const isLocalLoopbackHost = (host: string): boolean => { +export const isLocalLoopbackHost = (host: string): boolean => { const normalized = normalizeHostname(host); if (normalized === "localhost" || normalized === "::1") return true; return parseIpv4Address(normalized)?.[0] === 127; diff --git a/apps/web/src/browserHistoryStore.test.ts b/apps/web/src/browserHistoryStore.test.ts new file mode 100644 index 000000000000..29d27eb55353 --- /dev/null +++ b/apps/web/src/browserHistoryStore.test.ts @@ -0,0 +1,444 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; + +const { readPreparedConnection } = vi.hoisted(() => ({ + readPreparedConnection: vi.fn<() => { httpBaseUrl: string } | null>(() => null), +})); + +vi.mock("~/state/session", () => ({ readPreparedConnection })); + +import { + BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT, + BROWSER_HISTORY_MAX_PROJECTS, + BROWSER_HISTORY_MAX_TITLE_LENGTH, + type BrowserHistoryEntry, + evictExcessProjects, + mergeBrowserHistoryState, + migratePersistedBrowserHistoryState, + normalizeHistoryUrl, + recordVisitForThread, + removeUrlForThread, + resetBrowserHistoryForTests, + setTitleForThreadUrl, + upsertHistoryEntry, + useBrowserHistoryStore, +} from "./browserHistoryStore"; + +function entry(overrides: Partial = {}): BrowserHistoryEntry { + return { url: "http://localhost:3000/", lastVisitedAt: 1000, ...overrides }; +} + +beforeEach(() => readPreparedConnection.mockReturnValue(null)); +afterEach(() => vi.restoreAllMocks()); + +function spyOnPersistWrites() { + const storage = useBrowserHistoryStore.persist.getOptions().storage; + if (!storage) throw new Error("Browser history persistence storage is unavailable."); + return vi.spyOn(storage, "setItem"); +} + +describe("normalizeHistoryUrl", () => { + it("normalizes bare loopback hosts to http and keeps path/query", () => { + expect(normalizeHistoryUrl("localhost:3000/admin?tab=1")).toBe( + "http://localhost:3000/admin?tab=1", + ); + }); + + it("normalizes bare public hosts to https", () => { + expect(normalizeHistoryUrl("myapp.test")).toBe("https://myapp.test/"); + }); + + it("preserves hash routes and strips credentials", () => { + expect(normalizeHistoryUrl("http://localhost:3000/app#/route")).toBe( + "http://localhost:3000/app#/route", + ); + expect(normalizeHistoryUrl("https://user:secret@example.com/")).toBe("https://example.com/"); + }); + + it("rejects non-http(s), unparseable, and oversized urls", () => { + expect(normalizeHistoryUrl("ftp://example.com")).toBeNull(); + expect(normalizeHistoryUrl("")).toBeNull(); + expect(normalizeHistoryUrl(`http://localhost/${"a".repeat(2048)}`)).toBeNull(); + }); +}); + +describe("upsertHistoryEntry", () => { + it("prepends new urls", () => { + const next = upsertHistoryEntry([entry()], "http://localhost:5173/", 2000); + expect(next.map((e) => e.url)).toEqual(["http://localhost:5173/", "http://localhost:3000/"]); + expect(next[0]).toEqual({ url: "http://localhost:5173/", lastVisitedAt: 2000 }); + }); + + it("moves revisits to front, updates the timestamp, and keeps the title", () => { + const existing = [ + entry({ url: "http://a.test/", lastVisitedAt: 500, title: "A" }), + entry({ url: "http://b.test/", lastVisitedAt: 400 }), + ]; + const next = upsertHistoryEntry(existing, "http://b.test/", 3000); + expect(next.map((e) => e.url)).toEqual(["http://b.test/", "http://a.test/"]); + expect(next[0]?.lastVisitedAt).toBe(3000); + expect(next[1]?.title).toBe("A"); + }); + + it("caps the list at the per-project limit", () => { + const full = Array.from({ length: BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT }, (_, i) => + entry({ url: `http://localhost:${3000 + i}/`, lastVisitedAt: i }), + ); + const next = upsertHistoryEntry(full, "http://new.test/", 9999); + expect(next).toHaveLength(BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT); + expect(next[0]?.url).toBe("http://new.test/"); + const lastPort = 3000 + BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT - 1; + expect(next.some((e) => e.url === `http://localhost:${lastPort}/`)).toBe(false); + expect(next.some((e) => e.url === "http://localhost:3000/")).toBe(true); + }); + + it("with insertOrdered, slots an older entry below a newer one instead of prepending", () => { + const existing = [entry({ url: "http://newer.test/", lastVisitedAt: 2000 })]; + const next = upsertHistoryEntry(existing, "http://older.test/", 1000, { + insertOrdered: true, + }); + expect(next.map((e) => e.url)).toEqual(["http://newer.test/", "http://older.test/"]); + }); + + it("with insertOrdered, replaying an older visit for an existing entry keeps its newer timestamp", () => { + const existing = [entry({ url: "http://a.test/", lastVisitedAt: 2000 })]; + const next = upsertHistoryEntry(existing, "http://a.test/", 1000, { insertOrdered: true }); + expect(next).toEqual([{ url: "http://a.test/", lastVisitedAt: 2000 }]); + }); +}); + +describe("evictExcessProjects", () => { + it("keeps the most recently visited projects when over the cap", () => { + const byProjectKey = Object.fromEntries( + Array.from({ length: BROWSER_HISTORY_MAX_PROJECTS + 2 }, (_, i) => [ + `project-${i}`, + [entry({ lastVisitedAt: i })], + ]), + ); + const next = evictExcessProjects(byProjectKey); + expect(Object.keys(next)).toHaveLength(BROWSER_HISTORY_MAX_PROJECTS); + expect(next["project-0"]).toBeUndefined(); + expect(next["project-1"]).toBeUndefined(); + expect(next[`project-${BROWSER_HISTORY_MAX_PROJECTS + 1}`]).toBeDefined(); + }); +}); + +describe("migratePersistedBrowserHistoryState", () => { + it("drops malformed state and invalid entries", () => { + expect(migratePersistedBrowserHistoryState(null)).toEqual({ byProjectKey: {} }); + expect(migratePersistedBrowserHistoryState({ byProjectKey: 42 })).toEqual({ byProjectKey: {} }); + const migrated = migratePersistedBrowserHistoryState({ + byProjectKey: { + good: [ + { url: "http://a.test/", lastVisitedAt: 100, title: "A" }, + { url: "", lastVisitedAt: 100 }, + { url: "ftp://ghost.test/", lastVisitedAt: 100 }, + { url: "http://b.test/", lastVisitedAt: Number.NaN }, + "junk", + ], + bad: "junk", + }, + }); + expect(migrated.byProjectKey["good"]).toEqual([ + { url: "http://a.test/", lastVisitedAt: 100, title: "A" }, + ]); + expect(migrated.byProjectKey["bad"]).toBeUndefined(); + }); + + it("normalizes persisted urls with the same rules as live writes", () => { + const migrated = migratePersistedBrowserHistoryState({ + byProjectKey: { + good: [{ url: "a.test/path#section", lastVisitedAt: 100 }], + }, + }); + expect(migrated.byProjectKey["good"]).toEqual([ + { url: "https://a.test/path#section", lastVisitedAt: 100 }, + ]); + }); + + it("restores MRU ordering, deduplicates normalized urls, and enforces project bounds", () => { + const byProjectKey = Object.fromEntries( + Array.from({ length: BROWSER_HISTORY_MAX_PROJECTS + 1 }, (_, index) => [ + `project-${index}`, + [{ url: `http://project-${index}.test/`, lastVisitedAt: index }], + ]), + ); + byProjectKey["project-1"] = [ + { url: "a.test/", lastVisitedAt: 1 }, + { url: "http://newer.test/", lastVisitedAt: 3 }, + { url: "https://a.test/", lastVisitedAt: 2 }, + ]; + + const migrated = migratePersistedBrowserHistoryState({ byProjectKey }); + + expect(Object.keys(migrated.byProjectKey)).toHaveLength(BROWSER_HISTORY_MAX_PROJECTS); + expect(migrated.byProjectKey["project-0"]).toBeUndefined(); + expect(migrated.byProjectKey["project-1"]).toEqual([ + { url: "http://newer.test/", lastVisitedAt: 3 }, + { url: "https://a.test/", lastVisitedAt: 2 }, + ]); + }); + + it("rejects a lastVisitedAt outside Date's valid range", () => { + const migrated = migratePersistedBrowserHistoryState({ + byProjectKey: { + good: [ + { url: "http://a.test/", lastVisitedAt: 100 }, + { url: "http://b.test/", lastVisitedAt: 1e20 }, + ], + }, + }); + expect(migrated.byProjectKey["good"]).toEqual([{ url: "http://a.test/", lastVisitedAt: 100 }]); + }); + + it("truncates oversized persisted titles to the contract bound", () => { + const oversized = "x".repeat(BROWSER_HISTORY_MAX_TITLE_LENGTH + 100); + const migrated = migratePersistedBrowserHistoryState({ + byProjectKey: { + good: [{ url: "http://a.test/", lastVisitedAt: 100, title: oversized }], + }, + }); + expect(migrated.byProjectKey["good"]?.[0]?.title).toHaveLength( + BROWSER_HISTORY_MAX_TITLE_LENGTH, + ); + expect(migrated.byProjectKey["good"]?.[0]?.title).toBe( + oversized.slice(0, BROWSER_HISTORY_MAX_TITLE_LENGTH), + ); + }); +}); + +const threadRef = { + environmentId: EnvironmentId.make("env-1"), + threadId: ThreadId.make("thread-1"), +}; + +describe("useBrowserHistoryStore", () => { + beforeEach(() => { + resetBrowserHistoryForTests(); + }); + + it("records visits for registered threads under the project key", () => { + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + recordVisitForThread(threadRef, "myapp.test/admin#section", 1234); + expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]).toEqual([ + { url: "https://myapp.test/admin#section", lastVisitedAt: 1234 }, + ]); + }); + + it("does not persist when a thread is already registered to the same project", () => { + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + const persist = spyOnPersistWrites(); + + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + + expect(persist).not.toHaveBeenCalled(); + }); + + it("ignores invalid urls whether queued pending or recorded post-registration", () => { + recordVisitForThread(threadRef, "ftp://a.test/", 1); + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + recordVisitForThread(threadRef, "ftp://a.test/", 2); + expect(useBrowserHistoryStore.getState().byProjectKey).toEqual({}); + }); + + it("sets titles update-only via the thread helper", () => { + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + setTitleForThreadUrl(threadRef, "http://a.test/", "Should not create"); + expect(useBrowserHistoryStore.getState().byProjectKey).toEqual({}); + recordVisitForThread(threadRef, "http://a.test/#/settings", 1); + setTitleForThreadUrl(threadRef, "http://a.test/#/settings", "My App"); + expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]?.title).toBe("My App"); + }); + + it("does not persist when the title is already set", () => { + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + recordVisitForThread(threadRef, "http://a.test/", 1); + setTitleForThreadUrl(threadRef, "http://a.test/", "My App"); + const persist = spyOnPersistWrites(); + const byProjectKey = useBrowserHistoryStore.getState().byProjectKey; + + setTitleForThreadUrl(threadRef, "http://a.test/", "My App"); + + expect(useBrowserHistoryStore.getState().byProjectKey).toBe(byProjectKey); + expect(persist).not.toHaveBeenCalled(); + }); + + it("sets a title against a settled url that differs from the stored one only by a trailing slash", () => { + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + recordVisitForThread(threadRef, "http://a.test/community", 1); + setTitleForThreadUrl(threadRef, "http://a.test/community/", "Community"); + expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]).toMatchObject({ + url: "http://a.test/community", + title: "Community", + }); + + useBrowserHistoryStore.setState({ byProjectKey: {} }); + recordVisitForThread(threadRef, "http://a.test/community/", 1); + setTitleForThreadUrl(threadRef, "http://a.test/community", "Community"); + expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]).toMatchObject({ + url: "http://a.test/community/", + title: "Community", + }); + }); + + it("matches a requested localhost URL to the resolved environment host", () => { + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + recordVisitForThread(threadRef, "http://localhost:5173/app", 1); + setTitleForThreadUrl(threadRef, "http://192.168.64.2:5173/app", "Local App", "192.168.64.2"); + expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]?.title).toBe("Local App"); + }); + + it("deduplicates loopback aliases and the resolved environment host", () => { + readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://192.168.64.2:3773" }); + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + recordVisitForThread(threadRef, "http://localhost:5173/app", 1); + recordVisitForThread(threadRef, "http://127.0.0.1:5173/app", 2); + recordVisitForThread(threadRef, "http://192.168.64.2:5173/app", 3); + expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]).toEqual([ + { url: "http://localhost:5173/app", lastVisitedAt: 3 }, + ]); + + useBrowserHistoryStore.setState({ byProjectKey: {} }); + recordVisitForThread(threadRef, "http://192.168.64.2:5173/app", 4); + recordVisitForThread(threadRef, "http://localhost:5173/app", 5); + expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]).toEqual([ + { url: "http://localhost:5173/app", lastVisitedAt: 5 }, + ]); + }); + + it("does not match a genuinely different path via the trailing-slash comparison", () => { + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + recordVisitForThread(threadRef, "http://a.test/community", 1); + setTitleForThreadUrl(threadRef, "http://a.test/community/foo", "Foo"); + expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]?.title).toBeUndefined(); + }); + + it("updates only the most recent entry when several share a trailing-slash comparison key", () => { + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + recordVisitForThread(threadRef, "http://a.test/community/", 1); + recordVisitForThread(threadRef, "http://a.test/community", 2); + setTitleForThreadUrl(threadRef, "http://a.test/community/", "Community"); + const entries = useBrowserHistoryStore.getState().byProjectKey["proj-a"]; + expect(entries?.[0]).toMatchObject({ url: "http://a.test/community", title: "Community" }); + expect(entries?.[1]).toMatchObject({ url: "http://a.test/community/" }); + expect(entries?.[1]?.title).toBeUndefined(); + }); + + it("truncates oversized titles to the contract bound", () => { + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + recordVisitForThread(threadRef, "http://a.test/", 1); + const oversized = "y".repeat(BROWSER_HISTORY_MAX_TITLE_LENGTH + 50); + setTitleForThreadUrl(threadRef, "http://a.test/", oversized); + const title = useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]?.title; + expect(title).toHaveLength(BROWSER_HISTORY_MAX_TITLE_LENGTH); + expect(title).toBe(oversized.slice(0, BROWSER_HISTORY_MAX_TITLE_LENGTH)); + }); + + it("removes entries", () => { + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + recordVisitForThread(threadRef, "http://a.test/", 1); + recordVisitForThread(threadRef, "http://b.test/", 2); + removeUrlForThread(threadRef, "http://a.test/"); + expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.map((e) => e.url)).toEqual([ + "http://b.test/", + ]); + }); +}); + +describe("pendingVisitsByThreadKey", () => { + beforeEach(() => { + resetBrowserHistoryForTests(); + }); + + it("queues a visit recorded before registration and drains it in order on registration", () => { + recordVisitForThread(threadRef, "http://a.test/", 1); + recordVisitForThread(threadRef, "http://b.test/", 2); + expect(useBrowserHistoryStore.getState().byProjectKey).toEqual({}); + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.map((e) => e.url)).toEqual([ + "http://b.test/", + "http://a.test/", + ]); + expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]?.lastVisitedAt).toBe(2); + expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[1]?.lastVisitedAt).toBe(1); + expect(useBrowserHistoryStore.getState().pendingVisitsByThreadKey).toEqual({}); + }); + + it("caps the per-thread pending list at 10, dropping the oldest", () => { + for (let i = 0; i < 12; i++) { + recordVisitForThread(threadRef, `http://a.test/${i}`, i); + } + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + const urls = useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.map((e) => e.url); + expect(urls).toHaveLength(10); + expect(urls).not.toContain("http://a.test/0"); + expect(urls).not.toContain("http://a.test/1"); + expect(urls?.[0]).toBe("http://a.test/11"); + }); + + it("slots a replayed visit by timestamp instead of hoisting it above a newer live visit", () => { + const otherThreadRef = { + environmentId: EnvironmentId.make("env-1"), + threadId: ThreadId.make("thread-2"), + }; + useBrowserHistoryStore.getState().registerThreadProject(otherThreadRef, "proj-a"); + recordVisitForThread(otherThreadRef, "http://newer.test/", 2000); + recordVisitForThread(threadRef, "http://older.test/", 1000); + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + + const entries = useBrowserHistoryStore.getState().byProjectKey["proj-a"]; + expect(entries?.map((e) => e.url)).toEqual(["http://newer.test/", "http://older.test/"]); + // `entries[0]` being the most recent is the invariant `evictExcessProjects` relies on. + expect(entries?.[0]?.lastVisitedAt).toBe(2000); + }); +}); + +describe("pendingTitlesByThreadKey", () => { + beforeEach(() => { + resetBrowserHistoryForTests(); + }); + + it("buffers a title set before registration and applies it once the matching visit drains", () => { + recordVisitForThread(threadRef, "http://a.test/", 1); + setTitleForThreadUrl(threadRef, "http://a.test/", "My App"); + expect(useBrowserHistoryStore.getState().byProjectKey).toEqual({}); + + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + + const entries = useBrowserHistoryStore.getState().byProjectKey["proj-a"]; + expect(entries?.[0]).toMatchObject({ url: "http://a.test/", title: "My App" }); + expect(useBrowserHistoryStore.getState().pendingTitlesByThreadKey).toEqual({}); + }); + + it("preserves environment host matching while a title is pending", () => { + recordVisitForThread(threadRef, "http://localhost:5173/app", 1); + setTitleForThreadUrl(threadRef, "http://192.168.64.2:5173/app", "Local App", "192.168.64.2"); + useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); + expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]?.title).toBe("Local App"); + }); +}); + +describe("mergeBrowserHistoryState", () => { + it("sanitizes same-version corrupt persisted data and preserves actions", () => { + // `migrate` only runs when versions differ; `merge` runs on every rehydrate. + const current = useBrowserHistoryStore.getState(); + const merged = mergeBrowserHistoryState( + { + byProjectKey: { + a: [{ url: "ftp://bad.test/", lastVisitedAt: 1 }], + b: [{ url: "http://ok.test/", lastVisitedAt: 5 }], + }, + projectKeyByThreadKey: { good: "b", stale: "a", malformed: 42 }, + }, + current, + ); + expect(merged.byProjectKey).toEqual({ + b: [{ url: "http://ok.test/", lastVisitedAt: 5 }], + }); + expect(typeof merged.recordVisit).toBe("function"); + expect(merged.projectKeyByThreadKey).toEqual({ good: "b" }); + expect(merged.pendingVisitsByThreadKey).toEqual({}); + expect(merged.pendingTitlesByThreadKey).toEqual({}); + }); +}); diff --git a/apps/web/src/browserHistoryStore.ts b/apps/web/src/browserHistoryStore.ts new file mode 100644 index 000000000000..4c0a560817bb --- /dev/null +++ b/apps/web/src/browserHistoryStore.ts @@ -0,0 +1,398 @@ +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; +import { useShallow } from "zustand/react/shallow"; + +import { normalizePreviewUrl } from "@t3tools/shared/preview"; +import { readPreparedConnection } from "~/state/session"; + +import { isLocalLoopbackHost, normalizeHostname } from "./browser/browserTargetResolver"; +import { resolveStorage } from "./lib/storage"; + +export type BrowserHistoryEntry = { url: string; lastVisitedAt: number; title?: string }; + +export const BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT = 50; +export const BROWSER_HISTORY_MAX_PROJECTS = 20; +export const BROWSER_HISTORY_MAX_URL_LENGTH = 2048; +export const BROWSER_HISTORY_MAX_TITLE_LENGTH = 512; +const MAX_VALID_DATE_MS = 8_640_000_000_000_000; + +export function isValidHistoryTimestamp(value: unknown): value is number { + return ( + typeof value === "number" && Number.isFinite(value) && Math.abs(value) <= MAX_VALID_DATE_MS + ); +} + +export function normalizeHistoryUrl(raw: string): string | null { + let parsed: URL; + try { + parsed = new URL(normalizePreviewUrl(raw)); + } catch { + return null; + } + parsed.username = parsed.password = ""; + return parsed.href.length > BROWSER_HISTORY_MAX_URL_LENGTH ? null : parsed.href; +} + +export function titleLookupKey(normalized: string, environmentHostname?: string | null): string { + const parsed = new URL(visitLookupKey(normalized, environmentHostname)); + if (parsed.pathname !== "/" && parsed.pathname.endsWith("/")) + parsed.pathname = parsed.pathname.slice(0, -1); + return parsed.href; +} + +function visitLookupKey(normalized: string, environmentHostname?: string | null): string { + const parsed = new URL(normalized); + const host = normalizeHostname(parsed.hostname); + const environmentHost = environmentHostname && normalizeHostname(environmentHostname); + if (isLocalLoopbackHost(host) || host === "0.0.0.0" || host === environmentHost) + parsed.hostname = "local"; + return parsed.href; +} + +function isStableLocalUrl(normalized: string): boolean { + const host = normalizeHostname(new URL(normalized).hostname); + return isLocalLoopbackHost(host) || host === "0.0.0.0"; +} + +export function upsertHistoryEntry( + entries: ReadonlyArray, + url: string, + at: number, + options?: { insertOrdered?: boolean; environmentHostname?: string | null }, +): BrowserHistoryEntry[] { + const key = visitLookupKey(url, options?.environmentHostname); + const existing = entries.find( + (candidate) => visitLookupKey(candidate.url, options?.environmentHostname) === key, + ); + const rest = entries.filter( + (candidate) => visitLookupKey(candidate.url, options?.environmentHostname) !== key, + ); + const visitedAt = + options?.insertOrdered && existing && existing.lastVisitedAt > at ? existing.lastVisitedAt : at; + const storedUrl = + existing && (isStableLocalUrl(existing.url) || !isStableLocalUrl(url)) ? existing.url : url; + const entry: BrowserHistoryEntry = existing + ? { ...existing, url: storedUrl, lastVisitedAt: visitedAt } + : { url, lastVisitedAt: visitedAt }; + if (!options?.insertOrdered) + return [entry, ...rest].slice(0, BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT); + const index = rest.findIndex((candidate) => candidate.lastVisitedAt < entry.lastVisitedAt); + const next = index === -1 ? [...rest, entry] : rest.toSpliced(index, 0, entry); + return next.slice(0, BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT); +} + +export function evictExcessProjects( + byProjectKey: Record, +): Record { + const keys = Object.keys(byProjectKey); + if (keys.length <= BROWSER_HISTORY_MAX_PROJECTS) return byProjectKey; + const kept = keys + .toSorted( + (a, b) => + (byProjectKey[b]?.[0]?.lastVisitedAt ?? 0) - (byProjectKey[a]?.[0]?.lastVisitedAt ?? 0), + ) + .slice(0, BROWSER_HISTORY_MAX_PROJECTS); + return Object.fromEntries(kept.map((key) => [key, byProjectKey[key] ?? []])); +} + +export function migratePersistedBrowserHistoryState(persistedState: unknown): { + byProjectKey: Record; +} { + if (!persistedState || typeof persistedState !== "object") return { byProjectKey: {} }; + const raw = (persistedState as { byProjectKey?: unknown }).byProjectKey; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { byProjectKey: {} }; + const byProjectKey: Record = {}; + for (const [projectKey, value] of Object.entries(raw as Record)) { + if (!Array.isArray(value)) continue; + const seenUrls = new Set(); + const entries = value + .flatMap((candidate) => { + if (!candidate || typeof candidate !== "object") return []; + const { url, lastVisitedAt, title } = candidate as Record; + if (typeof url !== "string") return []; + const normalizedUrl = normalizeHistoryUrl(url); + if (!normalizedUrl) return []; + if (!isValidHistoryTimestamp(lastVisitedAt)) return []; + return [ + { + url: normalizedUrl, + lastVisitedAt, + ...(typeof title === "string" && title.length > 0 + ? { title: title.slice(0, BROWSER_HISTORY_MAX_TITLE_LENGTH) } + : {}), + }, + ]; + }) + .toSorted((a, b) => b.lastVisitedAt - a.lastVisitedAt) + .filter((entry) => { + const key = visitLookupKey(entry.url); + if (seenUrls.has(key)) return false; + seenUrls.add(key); + return true; + }) + .slice(0, BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT); + if (entries.length > 0) byProjectKey[projectKey] = entries; + } + return { byProjectKey: evictExcessProjects(byProjectKey) }; +} + +const BROWSER_HISTORY_STORAGE_KEY = "t3code:browser-history:v1"; + +const PENDING_MAX_PER_THREAD = 10; +const PENDING_MAX_THREADS = 20; + +type PendingVisit = { url: string; at: number; environmentHostname: string | null }; +type PendingTitle = { url: string; title: string; environmentHostname: string | null | undefined }; + +interface BrowserHistoryStoreState { + byProjectKey: Record; + projectKeyByThreadKey: Record; + pendingVisitsByThreadKey: Record; + pendingTitlesByThreadKey: Record; + recordVisit: ( + projectKey: string, + url: string, + at: number, + options?: { insertOrdered?: boolean; environmentHostname?: string | null }, + ) => void; + setTitleForUrl: ( + projectKey: string, + url: string, + title: string, + environmentHostname?: string | null, + ) => void; + removeUrl: (projectKey: string, url: string) => void; + registerThreadProject: (ref: ScopedThreadRef, projectKey: string) => void; +} + +function addPendingByThread( + pendingByThreadKey: Record, + threadKey: string, + item: T, +): Record { + const existing = pendingByThreadKey[threadKey] ?? []; + const next = { ...pendingByThreadKey }; + next[threadKey] = [...existing, item].slice(-PENDING_MAX_PER_THREAD); + const keys = Object.keys(next); + if (keys.length > PENDING_MAX_THREADS) { + const oldestKey = keys[0]; + if (oldestKey !== undefined && oldestKey !== threadKey) delete next[oldestKey]; + } + return next; +} + +export const useBrowserHistoryStore = create()( + persist( + (set, get) => ({ + byProjectKey: {}, + projectKeyByThreadKey: {}, + pendingVisitsByThreadKey: {}, + pendingTitlesByThreadKey: {}, + recordVisit: (projectKey, url, at, options) => { + const normalized = normalizeHistoryUrl(url); + if (!normalized) return; + set((state) => { + return { + byProjectKey: evictExcessProjects({ + ...state.byProjectKey, + [projectKey]: upsertHistoryEntry( + state.byProjectKey[projectKey] ?? [], + normalized, + at, + options, + ), + }), + }; + }); + }, + setTitleForUrl: (projectKey, url, title, environmentHostname) => { + const normalized = normalizeHistoryUrl(url); + const state = get(); + const entries = state.byProjectKey[projectKey]; + const trimmed = title.trim().slice(0, BROWSER_HISTORY_MAX_TITLE_LENGTH); + if (!normalized || !entries || trimmed.length === 0) return; + const key = titleLookupKey(normalized, environmentHostname); + const index = entries.findIndex( + (candidate) => titleLookupKey(candidate.url, environmentHostname) === key, + ); + if (index === -1 || entries[index]?.title === trimmed) return; + set({ + byProjectKey: { + ...state.byProjectKey, + [projectKey]: entries.map((candidate, candidateIndex) => + candidateIndex === index ? { ...candidate, title: trimmed } : candidate, + ), + }, + }); + }, + removeUrl: (projectKey, url) => { + const normalized = normalizeHistoryUrl(url); + const state = get(); + const entries = state.byProjectKey[projectKey]; + if (!normalized || !entries) return; + const next = entries.filter((candidate) => candidate.url !== normalized); + if (next.length === entries.length) return; + if (next.length === 0) { + const { [projectKey]: _removed, ...rest } = state.byProjectKey; + set({ byProjectKey: rest }); + return; + } + set({ byProjectKey: { ...state.byProjectKey, [projectKey]: next } }); + }, + registerThreadProject: (ref, projectKey) => { + const threadKey = scopedThreadKey(ref); + const state = get(); + const pendingVisits = state.pendingVisitsByThreadKey[threadKey]; + const pendingTitles = state.pendingTitlesByThreadKey[threadKey]; + if ( + state.projectKeyByThreadKey[threadKey] === projectKey && + !pendingVisits && + !pendingTitles + ) { + return; + } + const nextPendingVisits = { ...state.pendingVisitsByThreadKey }; + const nextPendingTitles = { ...state.pendingTitlesByThreadKey }; + delete nextPendingVisits[threadKey]; + delete nextPendingTitles[threadKey]; + set({ + projectKeyByThreadKey: { ...state.projectKeyByThreadKey, [threadKey]: projectKey }, + pendingVisitsByThreadKey: nextPendingVisits, + pendingTitlesByThreadKey: nextPendingTitles, + }); + for (const visit of pendingVisits ?? []) + get().recordVisit(projectKey, visit.url, visit.at, { + insertOrdered: true, + environmentHostname: visit.environmentHostname, + }); + for (const pendingTitle of pendingTitles ?? []) + get().setTitleForUrl( + projectKey, + pendingTitle.url, + pendingTitle.title, + pendingTitle.environmentHostname, + ); + }, + }), + { + name: BROWSER_HISTORY_STORAGE_KEY, + version: 1, + storage: createJSONStorage(() => + resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined), + ), + partialize: (state) => ({ + byProjectKey: state.byProjectKey, + projectKeyByThreadKey: state.projectKeyByThreadKey, + }), + migrate: migratePersistedBrowserHistoryState, + merge: mergeBrowserHistoryState, + }, + ), +); + +export function mergeBrowserHistoryState( + persistedState: unknown, + currentState: BrowserHistoryStoreState, +): BrowserHistoryStoreState { + const migrated = migratePersistedBrowserHistoryState(persistedState); + return { + ...currentState, + ...migrated, + projectKeyByThreadKey: migratePersistedThreadProjectKeys(persistedState, migrated.byProjectKey), + pendingVisitsByThreadKey: {}, + pendingTitlesByThreadKey: {}, + }; +} + +function migratePersistedThreadProjectKeys( + persistedState: unknown, + byProjectKey: Record, +): Record { + if (!persistedState || typeof persistedState !== "object") return {}; + const raw = (persistedState as { projectKeyByThreadKey?: unknown }).projectKeyByThreadKey; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {}; + return Object.fromEntries( + Object.entries(raw as Record) + .filter( + (entry): entry is [string, string] => + typeof entry[1] === "string" && entry[1] in byProjectKey, + ) + .slice(-100), + ); +} + +export function recordVisitForThread(ref: ScopedThreadRef, url: string, at?: number): void { + const threadKey = scopedThreadKey(ref); + const state = useBrowserHistoryStore.getState(); + const projectKey = state.projectKeyByThreadKey[threadKey]; + const visitAt = at ?? Date.now(); + const connection = readPreparedConnection(ref.environmentId); + const environmentHostname = connection ? new URL(connection.httpBaseUrl).hostname : null; + if (!projectKey) { + useBrowserHistoryStore.setState({ + pendingVisitsByThreadKey: addPendingByThread(state.pendingVisitsByThreadKey, threadKey, { + url, + at: visitAt, + environmentHostname, + }), + }); + return; + } + state.recordVisit(projectKey, url, visitAt, { environmentHostname }); +} + +export function setTitleForThreadUrl( + ref: ScopedThreadRef, + url: string, + title: string, + environmentHostname?: string | null, +): void { + const threadKey = scopedThreadKey(ref); + const state = useBrowserHistoryStore.getState(); + const projectKey = state.projectKeyByThreadKey[threadKey]; + if (!projectKey) { + useBrowserHistoryStore.setState({ + pendingTitlesByThreadKey: addPendingByThread(state.pendingTitlesByThreadKey, threadKey, { + url, + title, + environmentHostname, + }), + }); + return; + } + state.setTitleForUrl(projectKey, url, title, environmentHostname); +} + +export function removeUrlForThread(ref: ScopedThreadRef, url: string): void { + const state = useBrowserHistoryStore.getState(); + const projectKey = state.projectKeyByThreadKey[scopedThreadKey(ref)]; + if (!projectKey) return; + state.removeUrl(projectKey, url); +} + +const EMPTY_HISTORY: ReadonlyArray = []; + +export function useThreadRecentHistory( + ref: ScopedThreadRef, + limit: number, +): ReadonlyArray { + return useBrowserHistoryStore( + useShallow((state) => { + const projectKey = state.projectKeyByThreadKey[scopedThreadKey(ref)]; + const entries = projectKey ? state.byProjectKey[projectKey] : undefined; + return entries && entries.length > 0 ? entries.slice(0, limit) : EMPTY_HISTORY; + }), + ); +} + +export function resetBrowserHistoryForTests(): void { + useBrowserHistoryStore.setState({ + byProjectKey: {}, + projectKeyByThreadKey: {}, + pendingVisitsByThreadKey: {}, + pendingTitlesByThreadKey: {}, + }); + useBrowserHistoryStore.persist.clearStorage(); +} diff --git a/apps/web/src/cloud/connectCliAuth.test.ts b/apps/web/src/cloud/connectCliAuth.test.ts index 61d854eb6ab3..59b443a49d93 100644 --- a/apps/web/src/cloud/connectCliAuth.test.ts +++ b/apps/web/src/cloud/connectCliAuth.test.ts @@ -46,6 +46,22 @@ describe("connectCliAuth", () => { expect(url.searchParams.get("code_challenge_method")).toBe("S256"); }); + it("redirects straight to the CLI's loopback listener when the request carries a port", () => { + vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", TEST_PUBLISHABLE_KEY); + vi.stubEnv("VITE_CLERK_CLI_OAUTH_CLIENT_ID", "oauthapp_123"); + + const authorizeUrl = buildConnectCliClerkAuthorizeUrl({ + state: "state-1", + challenge: "challenge-1", + loopbackPort: 34338, + }); + expect(authorizeUrl).not.toBeNull(); + + const url = new URL(authorizeUrl!); + expect(url.searchParams.get("redirect_uri")).toBe("http://127.0.0.1:34338/callback"); + expect(url.searchParams.get("state")).toBe("state-1"); + }); + it("returns null when the CLI OAuth client id is not configured", () => { vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", TEST_PUBLISHABLE_KEY); expect( diff --git a/apps/web/src/cloud/connectCliAuth.ts b/apps/web/src/cloud/connectCliAuth.ts index 849319dcebef..969215d97ad3 100644 --- a/apps/web/src/cloud/connectCliAuth.ts +++ b/apps/web/src/cloud/connectCliAuth.ts @@ -1,6 +1,7 @@ import { buildConnectClerkAuthorizeUrl, connectCallbackUrl, + connectLoopbackRedirectUri, CONNECT_OAUTH_SCOPES, type ConnectAuthorizeRequest, } from "@t3tools/shared/connectAuth"; @@ -34,6 +35,11 @@ export function connectCliAuthRoutesEnabled(): boolean { * Builds the Clerk authorize URL for a CLI-initiated connect request. The * state is mirrored into sessionStorage so the callback page can verify the * response matches a request this browser actually started. + * + * A request carrying a loopback port came from a CLI with a local callback + * listener: the authorization code must return to `127.0.0.1` directly, so + * the hosted callback page never sees it. Clerk enforces its registered + * redirect URI allowlist either way. */ export function buildConnectCliClerkAuthorizeUrl(request: ConnectAuthorizeRequest): string | null { const { clerkPublishableKey } = resolveCloudPublicConfig(); @@ -44,7 +50,10 @@ export function buildConnectCliClerkAuthorizeUrl(request: ConnectAuthorizeReques return buildConnectClerkAuthorizeUrl({ authorizationEndpoint: `${clerkFrontendApiUrlFromPublishableKey(clerkPublishableKey)}/oauth/authorize`, clientId, - redirectUri: connectCallbackUrl(configuredHostedAppUrl()), + redirectUri: + request.loopbackPort === undefined + ? connectCallbackUrl(configuredHostedAppUrl()) + : connectLoopbackRedirectUri(request.loopbackPort), scopes: CONNECT_OAUTH_SCOPES, state: request.state, challenge: request.challenge, diff --git a/apps/web/src/cloud/managedRelayState.ts b/apps/web/src/cloud/managedRelayState.ts index 5f29c121dbcd..9a56bde88514 100644 --- a/apps/web/src/cloud/managedRelayState.ts +++ b/apps/web/src/cloud/managedRelayState.ts @@ -1,14 +1,20 @@ import { useAtomValue } from "@effect/atom-react"; import { createManagedRelayQueryManager, + deregisterManagedRelayEnvironment, ManagedRelay, managedRelaySessionAtom, readManagedRelaySnapshotState, } from "@t3tools/client-runtime/relay"; +import { + createAtomCommandScheduler, + createRuntimeCommand, +} from "@t3tools/client-runtime/state/runtime"; import type { RelayClientDeviceRecord, RelayClientEnvironmentRecord, } from "@t3tools/contracts/relay"; +import type { EnvironmentId } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -29,6 +35,22 @@ const managedRelayAtomRuntime = Atom.runtime( export const managedRelayQueryManager = createManagedRelayQueryManager(managedRelayAtomRuntime); +const managedRelayMutationScheduler = createAtomCommandScheduler(); + +export const deregisterManagedRelayEnvironmentCommand = createRuntimeCommand( + managedRelayAtomRuntime, + { + label: "web:managed-relay:deregister-environment", + scheduler: managedRelayMutationScheduler, + concurrency: { + mode: "serial", + key: (input: { readonly accountId: string; readonly environmentId: EnvironmentId }) => + input.accountId, + }, + execute: (input, registry) => deregisterManagedRelayEnvironment(registry, input), + }, +); + const EMPTY_ENVIRONMENTS_ATOM = Atom.make( AsyncResult.success>([]), ).pipe(Atom.keepAlive, Atom.withLabel("managed-relay:web:environments:null")); diff --git a/apps/web/src/components/AgentsPanel.tsx b/apps/web/src/components/AgentsPanel.tsx new file mode 100644 index 000000000000..4eeff67ce5f7 --- /dev/null +++ b/apps/web/src/components/AgentsPanel.tsx @@ -0,0 +1,581 @@ +/** + * Agents right-panel surface: the fleet view over the native subagent fold, + * and the ONLY place the roster renders (the chat carries one CTA row per + * spawn batch). + * + * Visualization rules (from live-test feedback): + * - Spawn order is stable. Activity and completion update rows in place. + * - Agent rows reserve three fixed lines for identity, activity, and metrics; + * changing data must never change their height. + * - Workflow expansion is presentation state. A live run stays expanded when + * it settles; older collapsed runs can still be opened at run granularity. + * - Static status dots, DOM-write elapsed timers, plain token counters. + */ +import { useAtomValue } from "@effect/atom-react"; +import type { + AgentPanelModel, + AgentPanelWorkflowGroup, + RuntimeSubagent, +} from "@t3tools/client-runtime/state/subagentRuntime"; +import { + formatSubagentModelLabel, + formatSubagentTokenCount, +} from "@t3tools/client-runtime/state/subagentRuntime"; +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { Bot, Braces, Check, ChevronDown, ChevronRight, X } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; + +import { cn } from "~/lib/utils"; +import { orchestrationEnvironment } from "~/state/orchestration"; +import { ScrollArea } from "~/components/ui/scroll-area"; + +/** + * In-flight states all present as Working (one steady state, per the + * monitoring-pill design: detail belongs in the activity sub-line, and a + * stalled/waiting/queued subagent is still the fleet doing its job, not a + * user problem). Only settled states differentiate. + */ +const STATUS_VISUALS: Record = { + pending: { dotClass: "bg-info", label: "Working" }, + running: { dotClass: "bg-info", label: "Working" }, + waiting: { dotClass: "bg-info", label: "Working" }, + // Idle reads as settled (muted, not sky): a resting Codex child looks done + // unless resumed — live-test: sky idle dots read as stuck in-progress. + idle: { dotClass: "bg-muted-foreground/50", label: "Idle · resumable" }, + completed: { dotClass: "bg-success", label: "Completed" }, + failed: { dotClass: "bg-destructive", label: "Failed" }, + cancelled: { dotClass: "bg-muted-foreground/60", label: "Stopped" }, + interrupted: { dotClass: "bg-muted-foreground/60", label: "Stopped" }, +}; + +function StatusDot({ status }: { status: RuntimeSubagent["status"] }) { + return ( + + ); +} + +function formatElapsedSeconds(totalSeconds: number): string { + const seconds = Math.max(0, Math.floor(totalSeconds)); + const minutes = Math.floor(seconds / 60); + if (minutes === 0) { + return `${seconds}s`; + } + const hours = Math.floor(minutes / 60); + if (hours === 0) { + return `${minutes}m ${String(seconds % 60).padStart(2, "0")}s`; + } + return `${hours}h ${String(minutes % 60).padStart(2, "0")}m`; +} + +function elapsedBetween(startedAt: string, endIso: string | null): string { + const start = Date.parse(startedAt); + const end = endIso ? Date.parse(endIso) : Date.now(); + if (Number.isNaN(start) || Number.isNaN(end)) { + return ""; + } + return formatElapsedSeconds((end - start) / 1000); +} + +/** + * Elapsed time for the current activation. Live agents self-tick via DOM + * writes (zero React commits per tick); settled agents freeze at completedAt. + */ +function AgentElapsed({ agent }: { agent: RuntimeSubagent }) { + const textRef = useRef(null); + const live = agent.status === "running" || agent.status === "waiting"; + const startedAt = agent.startedAt; + + useEffect(() => { + if (!live || !startedAt) { + return; + } + const update = () => { + if (textRef.current) { + textRef.current.textContent = elapsedBetween(startedAt, null); + } + }; + update(); + const id = setInterval(update, 1000); + return () => clearInterval(id); + }, [live, startedAt]); + + if (!startedAt) { + return null; + } + return ( + + {elapsedBetween(startedAt, live ? null : agent.completedAt)} + + ); +} + +/** + * Status-dependent activity line. Live rows lead with what is happening now; + * settled rows lead with the outcome. Errors are the only inline previews on + * failed rows because they explain a red row at a glance. + */ +function agentActivityText(agent: RuntimeSubagent): string | null { + const live = + agent.status === "running" || agent.status === "pending" || agent.status === "waiting"; + if (live) { + return ( + agent.progress ?? + (agent.lastToolName ? `▸ ${agent.lastToolName}` : null) ?? + agent.result ?? + agent.error + ); + } + return ( + agent.error ?? + agent.result ?? + agent.progress ?? + (agent.lastToolName ? `▸ ${agent.lastToolName}` : null) + ); +} + +/** Flat, non-interactive agent status line. No unfold. */ +function AgentRow({ agent }: { agent: RuntimeSubagent }) { + const visuals = STATUS_VISUALS[agent.status]; + const activity = agentActivityText(agent); + const modelLabel = formatSubagentModelLabel(agent.model, agent.effort); + const role = + agent.role?.trim().toLocaleLowerCase() === agent.title.trim().toLocaleLowerCase() + ? null + : agent.role; + const metadata = [ + modelLabel, + agent.usage ? `${formatSubagentTokenCount(agent.usage.totalTokens)} tok` : "— tok", + agent.usage?.toolUses !== undefined ? `${agent.usage.toolUses} tools` : null, + agent.activationCount > 1 ? `run ${agent.activationCount}` : null, + ].filter((value): value is string => value !== null); + + return ( +
+ + + + + {agent.title} + {role ? ( + + {role} + + ) : null} + + + + + {agent.status === "completed" ? ( + + ) : null} + + + + {activity ?? visuals.label} + + + {metadata.join(" · ")} + + {visuals.label} +
+ ); +} + +function workflowIsLive(group: AgentPanelWorkflowGroup): boolean { + const status = group.workflow.status; + return ( + status !== "completed" && + status !== "failed" && + status !== "cancelled" && + status !== "interrupted" + ); +} + +function workflowMembers(group: AgentPanelWorkflowGroup): ReadonlyArray { + return [...group.phases.flatMap((phase) => phase.members), ...group.unphasedMembers]; +} + +/** + * Phase rail: the run's shape at a glance. One segment per phase in order, + * separated by chevrons; each segment shows title + one dot per member. + * The whole arc (done → live → pending) is visible without scrolling the + * member list. + */ +function PhaseRail({ group }: { group: AgentPanelWorkflowGroup }) { + if (group.phases.length === 0) { + return null; + } + return ( +
+ {group.phases.map((phase, index) => ( +
+ {index > 0 ? ( + + ) : null} +
+ + {phase.state === "done" ? "✓ " : ""} + {phase.title} + + + {phase.members.length === 0 ? ( + + ) : ( + phase.members.map((member) => ) + )} + +
+
+ ))} +
+ ); +} + +/** + * Read-only workflow script viewer, fetched through the contained + * getWorkflowScript RPC (never a raw filesystem read from the client). + */ +function WorkflowScriptView({ + environmentId, + threadId, + scriptPath, + onClose, +}: { + environmentId: EnvironmentId; + threadId: ThreadId; + scriptPath: string; + onClose: () => void; +}) { + const result = useAtomValue( + orchestrationEnvironment.workflowScript({ environmentId, input: { threadId, scriptPath } }), + ); + return ( +
+
+ + + {scriptPath.split("/").at(-1)} + + +
+
+ {result._tag === "Success" ? ( +
+            {result.value.contents}
+            {result.value.truncated ? "\n… (truncated)" : ""}
+          
+ ) : result._tag === "Failure" ? ( +

Could not load the script.

+ ) : ( +

Loading…

+ )} +
+
+ ); +} + +/** + * Collapsible phase section. A phase opens when it becomes active, then keeps + * that shape as it settles so completion never yanks rows out from under the + * user. Manual toggles stick until a later activation begins. + */ +function PhaseSection({ + phase, + defaultOpen = false, +}: { + phase: AgentPanelWorkflowGroup["phases"][number]; + defaultOpen?: boolean; +}) { + const [open, setOpen] = useState(defaultOpen || phase.state === "running"); + const previousState = useRef(phase.state); + + useEffect(() => { + if (previousState.current !== "running" && phase.state === "running") { + setOpen(true); + } + previousState.current = phase.state; + }, [phase.state]); + + return ( +
+ + {open ? phase.members.map((member) => ) : null} +
+ ); +} + +/** Expanded workflow: phase rail + full phase tree. */ +function ExpandedWorkflowSection({ + group, + environmentId, + threadId, + onCollapse, +}: { + group: AgentPanelWorkflowGroup; + environmentId: EnvironmentId | null; + threadId: ThreadId | null; + onCollapse: () => void; +}) { + const [scriptOpen, setScriptOpen] = useState(false); + const members = workflowMembers(group); + const settled = members.filter( + (member) => + member.status === "completed" || + member.status === "failed" || + member.status === "cancelled" || + member.status === "interrupted", + ).length; + const scriptPath = group.workflow.runHandles?.scriptPath; + const canShowScript = scriptPath !== undefined && environmentId !== null && threadId !== null; + return ( +
+
+ + + {group.workflow.workflowName ?? group.workflow.title} + + {canShowScript ? ( + + ) : null} + + {settled}/{members.length} settled + + +
+ + {scriptOpen && canShowScript ? ( + setScriptOpen(false)} + /> + ) : null} + {group.phases.map((phase) => ( + + ))} + {group.unphasedMembers.map((member) => ( + + ))} + {group.phases.length === 0 && group.unphasedMembers.length === 0 ? ( + + ) : null} +
+ ); +} + +/** + * Collapsed workflow: one summary line. The parent owns expansion so a live + * workflow keeps its shape when it settles. + */ +function CollapsedWorkflowSection({ + group, + onExpand, +}: { + group: AgentPanelWorkflowGroup; + onExpand: () => void; +}) { + const members = workflowMembers(group); + const failed = members.filter((member) => member.status === "failed").length; + // Coordinator usage may already aggregate members (panel-footer rule): + // count it only when there are no member rows to sum. + const totalTokens = members.reduce( + (sum, member) => sum + (member.usage?.totalTokens ?? 0), + members.length === 0 ? (group.workflow.usage?.totalTokens ?? 0) : 0, + ); + const elapsed = + group.workflow.startedAt && group.workflow.completedAt + ? elapsedBetween(group.workflow.startedAt, group.workflow.completedAt) + : null; + return ( +
+ +
+ ); +} + +/** A workflow's open state is presentation state, not a status derivative. */ +function WorkflowSection({ + group, + environmentId, + threadId, +}: { + group: AgentPanelWorkflowGroup; + environmentId: EnvironmentId | null; + threadId: ThreadId | null; +}) { + const [open, setOpen] = useState(() => workflowIsLive(group)); + return open ? ( + setOpen(false)} + /> + ) : ( + setOpen(true)} /> + ); +} + +export function AgentsPanel({ + model, + environmentId = null, + threadId = null, +}: { + model: AgentPanelModel; + environmentId?: EnvironmentId | null; + threadId?: ThreadId | null; +}) { + if (!model.hasAgents) { + return ( +
+ +

No agents yet

+

+ When this thread spawns subagents or runs a workflow, they show up here with live status, + activity, and token usage. +

+
+ ); + } + + return ( +
+ +
+ {model.workflows.map((group) => ( + + ))} + {model.directAgents.length > 0 ? ( +
+
+ Direct spawns +
+ {model.directAgents.map((agent) => ( + + ))} +
+ ) : null} +
+
+
+ + {model.runningCount + model.waitingCount > 0 ? ( + + ● {model.runningCount + model.waitingCount} working + + ) : null} + {model.idleCount > 0 ? {model.idleCount} idle : null} + {model.settledCount > 0 ? {model.settledCount} settled : null} + + Σ {formatSubagentTokenCount(model.totalTokens)} tok +
+
+ ); +} diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 5c6acd62aea8..5138e84d2f15 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -10,14 +10,20 @@ import { import { useLocation, useNavigate } from "@tanstack/react-router"; import { isElectron } from "../env"; -import { getLocalStorageItem } from "../hooks/useLocalStorage"; +import { getLocalStorageItem, removeLocalStorageItem } from "../hooks/useLocalStorage"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import { cn, isMacPlatform } from "../lib/utils"; import { primaryServerKeybindingsAtom } from "../state/server"; -import { useEnvironmentIdentificationMode, useSidebarV2Enabled } from "../hooks/useSettings"; +import { useEnvironmentIdentificationMode, useLegacySidebarEnabled } from "../hooks/useSettings"; +import LegacyThreadSidebar from "./LegacySidebar"; import ThreadSidebar from "./Sidebar"; -import ThreadSidebarV2 from "./SidebarV2"; -import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop"; +import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; +import { SidebarChromeHeader } from "./sidebar/SidebarChrome"; +import { + resolveSidebarStageFocusRingOffsetClass, + useSidebarStageBackdropVariant, +} from "./SidebarStageBackdrop"; +import { useProjects } from "../state/entities"; import { resolveInitialThreadSidebarWidth, resolveThreadSidebarMaximumWidth, @@ -102,7 +108,10 @@ function SidebarControl() { "pointer-events-auto", isSidebarVisible && stageBackdropVariant && - "[:hover,[data-pressed]]:bg-white/15 focus-visible:ring-white/90 focus-visible:ring-offset-blue-700 [&_svg]:stroke-white/90! [&_svg]:opacity-100! [&_svg]:hover:stroke-white!", + "focus-visible:ring-white/90 [&_svg]:stroke-white/90! [&_svg]:opacity-100! [&_svg]:hover:stroke-white! [:hover,[data-pressed]]:bg-white/15", + isSidebarVisible && + stageBackdropVariant && + resolveSidebarStageFocusRingOffsetClass(stageBackdropVariant), )} aria-label="Toggle main sidebar" /> @@ -116,15 +125,21 @@ function SidebarControl() { ); } +// Settings swaps the thread sidebar out of the tree. Keep the lightweight +// project projection subscribed so returning to a draft never renders the +// zero-project state while the environment snapshot reconnects. +function ProjectProjectionRetention() { + useProjects(); + return null; +} + export function AppSidebarLayout({ children }: { children: ReactNode }) { const navigate = useNavigate(); - const sidebarV2Enabled = useSidebarV2Enabled(); - // Settings routes render the settings nav, which lives in the v1 component - // and is identical for both sidebars — so v1 stays mounted there. + const legacySidebarEnabled = useLegacySidebarEnabled(); + // Settings routes show the settings nav in place of whichever thread + // sidebar is active. const pathname = useLocation({ select: (location) => location.pathname }); const isOnSettings = pathname === "/settings" || pathname.startsWith("/settings/"); - const useSidebarV2 = sidebarV2Enabled && !isOnSettings; - const useSidebarV2Theme = useSidebarV2 || isOnSettings; const isMacosDesktop = isElectron && isMacPlatform(navigator.platform); const [sidebarWidth, setSidebarWidth] = useState(readInitialThreadSidebarWidth); // Subscribed rather than read once: the clamp must track live window size, @@ -132,6 +147,14 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { // that would otherwise refresh a render-time snapshot. const viewportWidth = useSyncExternalStore(subscribeToViewportWidth, readViewportWidth); const sidebarMaximumWidth = resolveThreadSidebarMaximumWidth(viewportWidth); + const resetSidebarWidth = () => { + try { + removeLocalStorageItem(THREAD_SIDEBAR_WIDTH_STORAGE_KEY); + } catch (error) { + console.error("Could not clear persisted thread sidebar width.", error); + } + setSidebarWidth(resolveInitialThreadSidebarWidth(null, viewportWidth)); + }; const [isWindowFullscreen, setIsWindowFullscreen] = useState(() => { const getWindowFullscreenState = window.desktopBridge?.getWindowFullscreenState; return isMacosDesktop && typeof getWindowFullscreenState === "function" @@ -184,11 +207,11 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { return ( + - {useSidebarV2 ? : } - + {isOnSettings ? ( + <> + + + + ) : legacySidebarEnabled ? ( + + ) : ( + + )} + {children} diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index 76336f1ef1f2..36d42a60fa81 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -17,6 +17,7 @@ import { resolvePreviousWorktreeLabel, resolvePreviousWorktreeSeed, shouldIncludeBranchPickerItem, + shouldShowComposerContextStrip, shouldShowEnvironmentIndicator, } from "./BranchToolbar.logic"; @@ -421,6 +422,38 @@ describe("shouldShowEnvironmentIndicator", () => { }); }); +describe("shouldShowComposerContextStrip", () => { + it("keeps the environment indicator visible for a non-Git project", () => { + expect( + shouldShowComposerContextStrip({ + hasActiveProject: true, + isGitRepo: false, + showEnvironmentIndicator: true, + }), + ).toBe(true); + }); + + it("hides the strip when a non-Git project has no environment indicator", () => { + expect( + shouldShowComposerContextStrip({ + hasActiveProject: true, + isGitRepo: false, + showEnvironmentIndicator: false, + }), + ).toBe(false); + }); + + it("shows Git controls without requiring an environment indicator", () => { + expect( + shouldShowComposerContextStrip({ + hasActiveProject: true, + isGitRepo: true, + showEnvironmentIndicator: false, + }), + ).toBe(true); + }); +}); + describe("resolveEffectiveEnvMode", () => { it("treats draft threads already attached to a worktree as current-checkout mode", () => { expect( diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index d9737f17a323..485ffbf8d37f 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -54,6 +54,14 @@ export function shouldShowEnvironmentIndicator(input: { return input.activeEnvironment !== null && !input.activeEnvironment.isPrimary; } +export function shouldShowComposerContextStrip(input: { + hasActiveProject: boolean; + isGitRepo: boolean; + showEnvironmentIndicator: boolean; +}): boolean { + return input.hasActiveProject && (input.isGitRepo || input.showEnvironmentIndicator); +} + export function resolveEnvModeLabel(mode: EnvMode): string { return mode === "worktree" ? "New worktree" : "Current checkout"; } diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 3a83f5c9a0ff..5d11cce11fbe 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -9,7 +9,7 @@ import { HistoryIcon, MonitorIcon, } from "lucide-react"; -import { memo, useCallback, useMemo } from "react"; +import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; import { useProject, useThread, useThreadShellsForProjectRefs } from "../state/entities"; @@ -44,6 +44,7 @@ import { Separator } from "./ui/separator"; interface BranchToolbarProps { environmentId: EnvironmentId; threadId: ThreadId; + showGitControls: boolean; draftId?: DraftId; onEnvModeChange: (mode: EnvMode) => void; effectiveEnvModeOverride?: EnvMode; @@ -125,7 +126,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ if (isLocked) { return ( - + {triggerContent} ); @@ -214,9 +215,168 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ ); }); +/** + * Collapse the strip's labels to icons only when the text no longer fits. + * + * Hidden labels stay measurable because their inner text keeps its natural + * width while the outer layout box collapses. This lets every pass recompute + * the expanded width without remembered values that could go stale or latch + * the strip compact. A small hysteresis keeps the boundary from flapping. + */ +const COMPACT_EXPAND_HYSTERESIS_PX = 16; +const COMPOSER_CONTEXT_MOTION_DURATION_MS = 180; +const COMPOSER_CONTEXT_MOTION_EASING = "cubic-bezier(0.32, 0.72, 0, 1)"; +const COMPOSER_CONTEXT_CONTROL_SELECTOR = "[data-composer-context-control]"; + +function useLabelsOverflow(element: HTMLDivElement | null): boolean { + const [overflows, setOverflows] = useState(false); + const pendingControlRectsRef = useRef | null>(null); + const controlAnimationsRef = useRef(new Map()); + // A render-synced mirror instead of useEffectEvent: the compiler memoizes + // the event callback, which left observers reading the first render's null + // element forever. + const stateRef = useRef({ element, overflows }); + stateRef.current = { element, overflows }; + + const measure = useCallback(() => { + const { element: current, overflows: compact } = stateRef.current; + if (!current) return; + const available = current.clientWidth; + if (available === 0) return; + // flex-1 stretches the groups to fill the strip, so their own boxes always + // measure "full". Sum the laid-out content instead, skipping hidden form + // artifacts and other out-of-flow nodes. + const contentWidth = (parent: Element): number => { + const gap = Number.parseFloat(getComputedStyle(parent).columnGap) || 0; + let width = 0; + let counted = 0; + for (const child of parent.children) { + if (!(child instanceof HTMLElement)) continue; + if (child.offsetWidth <= 1) continue; + const position = getComputedStyle(child).position; + if (position === "absolute" || position === "fixed") continue; + width += child.offsetWidth; + counted += 1; + } + return width + gap * Math.max(0, counted - 1); + }; + const stripGap = Number.parseFloat(getComputedStyle(current).columnGap) || 0; + let needed = 0; + let groups = 0; + for (const child of current.children) { + if (!(child instanceof HTMLElement) || child.offsetWidth <= 1) continue; + needed += contentWidth(child); + groups += 1; + } + needed += stripGap * Math.max(0, groups - 1); + for (const label of current.querySelectorAll("[data-composer-label]")) { + // The clipping can happen below the marker (SelectValue truncates + // internally), where the outer span's scrollWidth matches its clipped + // box. The text's real width is the largest scrollWidth in the subtree. + let textWidth = label.scrollWidth; + for (const inner of label.querySelectorAll("*")) { + textWidth = Math.max(textWidth, inner.scrollWidth); + } + if (compact) { + // Compact: the label is squeezed to zero width but keeps reporting + // the full width it would need when expanded. + needed += textWidth; + } else { + // Expanded: the label is in flow; only the clipped remainder is + // missing from the content sum. + needed += Math.max(0, textWidth - label.clientWidth); + } + } + const nextOverflows = compact + ? needed > available - COMPACT_EXPAND_HYSTERESIS_PX + : needed > available; + if (nextOverflows !== compact) { + pendingControlRectsRef.current = new Map( + Array.from(current.querySelectorAll(COMPOSER_CONTEXT_CONTROL_SELECTOR)).map( + (control) => [control, control.getBoundingClientRect()], + ), + ); + } + setOverflows(nextOverflows); + }, []); + + useLayoutEffect(() => { + const previousRects = pendingControlRectsRef.current; + if (!previousRects) return; + pendingControlRectsRef.current = null; + + for (const animation of controlAnimationsRef.current.values()) { + animation.cancel(); + } + controlAnimationsRef.current.clear(); + + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; + + for (const [control, previousRect] of previousRects) { + if (!control.isConnected) continue; + const nextRect = control.getBoundingClientRect(); + const deltaX = previousRect.left - nextRect.left; + const deltaY = previousRect.top - nextRect.top; + if (Math.abs(deltaX) < 0.5 && Math.abs(deltaY) < 0.5) continue; + + const animation = control.animate( + [ + { transform: `translate3d(${deltaX}px, ${deltaY}px, 0)` }, + { transform: "translate3d(0, 0, 0)" }, + ], + { + duration: COMPOSER_CONTEXT_MOTION_DURATION_MS, + easing: COMPOSER_CONTEXT_MOTION_EASING, + fill: "backwards", + }, + ); + controlAnimationsRef.current.set(control, animation); + animation.addEventListener( + "finish", + () => { + if (controlAnimationsRef.current.get(control) === animation) { + controlAnimationsRef.current.delete(control); + } + }, + { once: true }, + ); + } + }, [overflows]); + + useEffect( + () => () => { + for (const animation of controlAnimationsRef.current.values()) { + animation.cancel(); + } + }, + [], + ); + + // Label widths can change without the strip box moving (font family or + // size preferences), so re-measure on every render as well as on resize + // and font loads. + useEffect(() => { + measure(); + }); + + useEffect(() => { + if (!element) return; + const observer = new ResizeObserver(measure); + observer.observe(element); + document.fonts.addEventListener("loadingdone", measure); + return () => { + observer.disconnect(); + document.fonts.removeEventListener("loadingdone", measure); + }; + }, [element, measure]); + + return overflows; +} + export const BranchToolbar = memo(function BranchToolbar({ environmentId, threadId, + showGitControls, draftId, onEnvModeChange, effectiveEnvModeOverride, @@ -300,12 +460,18 @@ export const BranchToolbar = memo(function BranchToolbar({ canPickEnvironment: showEnvironmentPicker, }); const isMobile = useIsMobile(); + const [stripElement, setStripElement] = useState(null); + const labelsOverflow = useLabelsOverflow(stripElement); if (!hasActiveThread || !activeProject) return null; return ( -
- {isMobile ? ( +
+ {isMobile && showGitControls ? ( - + {showGitControls ? ( + + ) : null} )} - + {showGitControls ? ( + + ) : null}
)} - + {showGitControls ? ( + + ) : null}
); }); diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 9b4cbf2b4a41..05ed533acbc2 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -613,9 +613,9 @@ export function BranchToolbarBranchSelector({ // Action-oriented tooltip (the pill opens the PR), distinct from the sidebar's // state-description tooltip. const branchPrTooltip = branchPr - ? `Open ${sourceControlPresentation.terminology.singular} #${branchPr.number} (${branchPr.state}) in browser` + ? `Open ${sourceControlPresentation.terminology.singular} #${branchPr.number} (${branchPr.state})` : ""; - const openPrLink = useOpenPrLink(); + const openPrLink = useOpenPrLink(threadRef); function renderPickerItem(itemValue: string, index: number) { if (checkoutPullRequestItemValue && itemValue === checkoutPullRequestItemValue) { @@ -714,7 +714,10 @@ export function BranchToolbarBranchSelector({ open={isBranchMenuOpen} value={resolvedActiveBranch} > -
+
{branchPr && branchPrStatus ? ( - {triggerLabel} + + + {triggerLabel} + + diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx index d300139d3cf5..9fc2d4892e27 100644 --- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -50,7 +50,10 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe if (envLocked) { return ( - + {activeWorktreePath ? ( <> @@ -82,8 +85,9 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe {effectiveEnvMode === "worktree" ? ( @@ -92,7 +96,17 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe ) : ( )} - + + + + + diff --git a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx index e4ed54758ff4..b5d5751a280b 100644 --- a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx @@ -41,15 +41,33 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir [availableEnvironments], ); + // The static label carries the xs control's height (h-7 sm:h-6) as well as + // its padding: the composer context strip has no min-height of its own, and + // the glass seam joining it to the composer assumes a fixed strip height, so + // a shorter label would drag the seam out of line whenever this label is the + // only thing in the strip. if (envLocked || onEnvironmentChange === undefined) { return ( - + {activeEnvironment?.isPrimary ? ( ) : ( )} - {activeEnvironment?.label ?? "Run on"} + + + {activeEnvironment?.label ?? "Run on"} + + ); } @@ -66,13 +84,24 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir size="xs" className="min-w-0 max-w-full font-medium" aria-label="Run on" + data-composer-context-control > {activeEnvironment?.isPrimary ? ( ) : ( )} - + + + + + diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 985e943cb39c..e9390ed0a8aa 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -4,8 +4,13 @@ import { ChevronRightIcon, CopyIcon, GlobeIcon, + InfoIcon, + LightbulbIcon, Maximize2Icon, + MessageSquareWarningIcon, Minimize2Icon, + OctagonAlertIcon, + TriangleAlertIcon, WrapTextIcon, } from "lucide-react"; import type { ScopedThreadRef, ServerProviderSkill } from "@t3tools/contracts"; @@ -38,6 +43,7 @@ import rehypeRaw from "rehype-raw"; import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; import remarkBreaks from "remark-breaks"; import remarkGfm from "remark-gfm"; +import { remarkGithubAlerts } from "../markdown-github-alerts"; import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText"; import { CHAT_FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip"; import { PierreEntryIcon } from "./chat/PierreEntryIcon"; @@ -52,6 +58,7 @@ import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "./ui/collapsi import { ScrollArea } from "./ui/scroll-area"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; import { stackedThreadToast, toastManager } from "./ui/toast"; +import { recordVisitForThread } from "../browserHistoryStore"; import { useOpenInPreferredEditor } from "../editorPreferences"; import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; import { fnv1a32 } from "../lib/diffRendering"; @@ -83,6 +90,7 @@ import { usePreparedConnection } from "../state/session"; import { previewEnvironment } from "../state/preview"; import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; +import { useOpenChangeRequestLink } from "~/lib/openPullRequestLink"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { isPreviewSupportedInRuntime } from "../previewStateStore"; import { @@ -144,6 +152,7 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = { ...defaultSchema.attributes, "*": (defaultSchema.attributes?.["*"] ?? []).filter((attribute) => attribute !== "title"), code: [...(defaultSchema.attributes?.code ?? []), "dataCodeMeta", "dataInlineCode"], + blockquote: [...(defaultSchema.attributes?.blockquote ?? []), "dataAlert"], }, protocols: { ...defaultSchema.protocols, @@ -153,6 +162,7 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = { const CHAT_MARKDOWN_REMARK_PLUGINS = [ remarkGfm, + remarkGithubAlerts, remarkNormalizeListItemIndentation, remarkPreserveCodeMeta, remarkTagInlineCode, @@ -160,6 +170,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS = [ const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ remarkGfm, + remarkGithubAlerts, remarkNormalizeListItemIndentation, remarkBreaks, remarkPreserveCodeMeta, @@ -171,6 +182,43 @@ const CHAT_MARKDOWN_REHYPE_PLUGINS = [ [rehypeSanitize, CHAT_MARKDOWN_SANITIZE_SCHEMA], ] satisfies NonNullable; +/** GitHub's own five alert kinds, in its colors: the glyph names the urgency, the title says it. */ +const GITHUB_ALERT_PRESENTATIONS: Record< + string, + { label: string; Icon: typeof InfoIcon; borderClassName: string; titleClassName: string } +> = { + note: { + label: "Note", + Icon: InfoIcon, + borderClassName: "border-blue-500/70", + titleClassName: "text-blue-600 dark:text-blue-400", + }, + tip: { + label: "Tip", + Icon: LightbulbIcon, + borderClassName: "border-emerald-500/70", + titleClassName: "text-emerald-600 dark:text-emerald-400", + }, + important: { + label: "Important", + Icon: MessageSquareWarningIcon, + borderClassName: "border-purple-500/70", + titleClassName: "text-purple-600 dark:text-purple-400", + }, + warning: { + label: "Warning", + Icon: TriangleAlertIcon, + borderClassName: "border-amber-500/70", + titleClassName: "text-amber-600 dark:text-amber-500", + }, + caution: { + label: "Caution", + Icon: OctagonAlertIcon, + borderClassName: "border-red-500/70", + titleClassName: "text-red-600 dark:text-red-400", + }, +}; + function extractFenceLanguage(className: string | undefined): string { const match = className?.match(CODE_FENCE_LANGUAGE_REGEX); const raw = match?.[1] ?? "text"; @@ -594,7 +642,7 @@ function MarkdownCodeBlock({ return (
@@ -606,7 +654,7 @@ function MarkdownCodeBlock({ theme={theme} /> - + part !== null) ? parts.join("") : null; } +/** + * Whether the link carries any words of its own. An anchor that is only an image — a badge, a + * "Fix in Cursor" button — already shows its identity, and a favicon bolted on in front of it + * is a stray logo rather than a hint. + */ +function hastHasText(node: unknown): boolean { + if (!node || typeof node !== "object") return false; + if ( + "type" in node && + node.type === "text" && + "value" in node && + typeof node.value === "string" && + node.value.trim().length > 0 + ) { + return true; + } + return "children" in node && Array.isArray(node.children) && node.children.some(hastHasText); +} + const SANITIZED_FRAGMENT_PREFIX = "user-content-"; function decodeMarkdownFragmentId(href: string): string { @@ -1323,6 +1390,7 @@ function ChatMarkdown({ event.clipboardData.setData("text/plain", payload.text); event.clipboardData.setData("text/html", payload.html); }, []); + const openChangeRequestLink = useOpenChangeRequestLink(threadRef); const openExternalLinkInPreview = useCallback( (url: string) => { if (!threadRef) { @@ -1336,7 +1404,10 @@ function ChatMarkdown({ ), ); } - return openUrlInPreview({ threadRef, url, openPreview }); + return openUrlInPreview({ threadRef, url, openPreview }).then((result) => { + if (result._tag === "Success") recordVisitForThread(threadRef, url); + return result; + }); }, [openPreview, threadRef], ); @@ -1363,6 +1434,9 @@ function ChatMarkdown({ }, [createAssetUrl, openPreview, preparedConnection, threadRef], ); + /* eslint-disable react/no-unstable-nested-components -- ReactMarkdown requires component + * renderers that close over this message's metadata. useMemo keeps them stable until that + * metadata changes. */ const markdownComponents = useMemo(() => { const fileLinkChip = ( fileLinkMeta: MarkdownFileLinkMeta, @@ -1409,6 +1483,26 @@ function ChatMarkdown({ p({ node: _node, children, ...props }) { return

{renderSkillInlineMarkdownChildren(children, skills)}

; }, + blockquote({ node: _node, children, ...props }) { + const alert = + GITHUB_ALERT_PRESENTATIONS[ + String((props as Record)["data-alert"] ?? "") + ]; + if (!alert) { + return
{children}
; + } + // Not a
: the stylesheet mutes those, and an alert's body is ordinary + // text under a colored title — which is how the host renders it. + return ( +
+

+ + {alert.label} +

+ {children} +
+ ); + }, li({ node, children, ...props }) { const listItemStart = node?.position?.start.offset; const markerOffset = @@ -1466,16 +1560,23 @@ function ChatMarkdown({ onClick?.(event); if (isSameDocumentLink && href) { handleMarkdownFragmentClick(event, href); + return; } + // A link to a change request in a workspace project opens beside the + // conversation instead of in a browser: it is the thing being talked about, and + // the panel it opens offers the browser as one of its actions. Anything else is + // an ordinary link and keeps the `_blank` the shell already handles. + if (href) openChangeRequestLink(event, href); }} onContextMenu={(event) => { - if (!canOpenInPreview || !href || !faviconHost) return; + if (!href || !faviconHost) return; event.preventDefault(); event.stopPropagation(); const api = readLocalApi(); if (!api) return; void showExternalLinkContextMenu({ href, + canOpenInPreview, position: { x: event.clientX, y: event.clientY }, showContextMenu: (items, position) => api.contextMenu.show(items, position), openInPreview: async (target) => { @@ -1495,7 +1596,7 @@ function ChatMarkdown({ }); }} > - {faviconHost ? ( + {faviconHost && hastHasText(node) ? ( {children} @@ -1593,6 +1694,7 @@ function ChatMarkdown({ text, threadRef, ]); + /* eslint-enable react/no-unstable-nested-components */ return (
{ + afterEach(() => vi.useRealTimers()); + + it("shows a persistent reconnect after the grace period", () => { + vi.useFakeTimers(); + const showWarning = vi.fn(); + + scheduleEnvironmentReconnectWarning(showWarning); + vi.advanceTimersByTime(ENVIRONMENT_RECONNECT_WARNING_GRACE_MS - 1); + expect(showWarning).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1); + expect(showWarning).toHaveBeenCalledOnce(); + }); + + it("cancels the warning when the connection recovers during the grace period", () => { + vi.useFakeTimers(); + const showWarning = vi.fn(); + + const cancel = scheduleEnvironmentReconnectWarning(showWarning); + cancel(); + vi.advanceTimersByTime(ENVIRONMENT_RECONNECT_WARNING_GRACE_MS); + + expect(showWarning).not.toHaveBeenCalled(); + }); + + it("does not reuse elapsed grace from another environment", () => { + const anotherEnvironmentId = EnvironmentId.make("environment-remote"); + + expect(hasEnvironmentReconnectWarningGraceElapsed(environmentId, environmentId)).toBe(true); + expect(hasEnvironmentReconnectWarningGraceElapsed(anotherEnvironmentId, environmentId)).toBe( + false, + ); + }); +}); + function makeThread(overrides: Partial = {}): Thread { return { id: threadId, diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 04b35fd45516..04561b507c3e 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -25,12 +25,25 @@ import type { DraftThreadEnvMode } from "../composerDraftStore"; export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10; export const MAX_HIDDEN_MOUNTED_PREVIEW_THREADS = 3; +export const ENVIRONMENT_RECONNECT_WARNING_GRACE_MS = 2_000; export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); +export function scheduleEnvironmentReconnectWarning(showWarning: () => void): () => void { + const timeoutId = globalThis.setTimeout(showWarning, ENVIRONMENT_RECONNECT_WARNING_GRACE_MS); + return () => globalThis.clearTimeout(timeoutId); +} + +export function hasEnvironmentReconnectWarningGraceElapsed( + activeEnvironmentId: EnvironmentId | null, + elapsedEnvironmentId: EnvironmentId | null, +): boolean { + return activeEnvironmentId !== null && activeEnvironmentId === elapsedEnvironmentId; +} + export function startNewThreadForProject( projectRef: ScopedProjectRef | null, - handleNewThread: (projectRef: ScopedProjectRef) => Promise, + handleNewThread: (projectRef: ScopedProjectRef) => Promise, ): boolean { if (projectRef === null) return false; void handleNewThread(projectRef); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f0a918e136fc..a3c9fcd6fc30 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -8,6 +8,7 @@ import { type ProjectScript, type ProjectId, type ProviderApprovalDecision, + type PreviewAnnotationPayload, ProviderInstanceId, type ServerProvider, type ResolvedKeybindingsConfig, @@ -25,7 +26,11 @@ import { connectionStatusTitle, type EnvironmentConnectionPresentation, } from "@t3tools/client-runtime/connection"; -import { effectiveSettled, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; +import { + effectiveSettled, + effectiveSnoozed, + threadWokeAt, +} from "@t3tools/client-runtime/state/thread-settled"; import { parseScopedThreadKey, scopedThreadKey, @@ -80,7 +85,7 @@ import { deriveTimelineEntries, deriveActiveWorkStartedAt, deriveActivePlanState, - findSidebarProposedPlan, + deriveTurnPlans, findLatestProposedPlan, deriveWorkLogEntries, hasActionableProposedPlan, @@ -122,6 +127,7 @@ import { selectActiveRightPanelSurface, selectThreadRightPanelState, type RightPanelSurface, + updatePullRequestTabStatus, useRightPanelStore, } from "../rightPanelStore"; import { @@ -138,18 +144,25 @@ import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore, } from "../previewMiniPlayerStore"; -import { RightPanelTabs } from "./RightPanelTabs"; +import { isThreadOwnPullRequest } from "./pullRequest/pullRequestDetail.logic"; +import { PullRequestDetailPanel } from "./pullRequest/PullRequestDetailPanel"; +import { PullRequestDetailGhost } from "./pullRequest/PullRequestGhosts"; +import { PullRequestsUnavailableState } from "./pullRequest/PullRequestsUnavailableState"; +import { RightPanelTabs, type PullRequestTabStatus } from "./RightPanelTabs"; +import { AgentsPanel } from "./AgentsPanel"; +import { + deriveAgentPanelModel, + foldSubagentActivities, +} from "@t3tools/client-runtime/state/subagentRuntime"; import { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; import { BranchToolbar } from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; -import PlanSidebar from "./PlanSidebar"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { AlarmClockIcon, CheckCircle2Icon, ChevronDownIcon, GitBranchIcon, - TriangleAlertIcon, WifiOffIcon, } from "lucide-react"; import { cn, randomHex } from "~/lib/utils"; @@ -164,18 +177,26 @@ import { projectScriptIdFromCommand, } from "~/projectScripts"; import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; +import { useBrowserHistoryStore } from "~/browserHistoryStore"; import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances"; -import { useClientSettings, useEnvironmentSettings } from "../hooks/useSettings"; +import { + useClientSettings, + useClientSettingsHydrated, + useEnvironmentSettings, +} from "../hooks/useSettings"; import { useNowMinute } from "../hooks/useNowMinute"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { resolveAppModelSelectionForInstance } from "../modelSelection"; import { getTerminalFocusOwner } from "../lib/terminalFocus"; +import { preventRepeatedTerminalCloseShortcut } from "../lib/terminalCloseShortcut"; import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions"; import { + derivePhysicalProjectKey, deriveLogicalProjectKeyFromSettings, selectProjectGroupingSettings, } from "../logicalProject"; +import { buildPhysicalToLogicalProjectKeyMap } from "../sidebarProjectGrouping"; import { buildDraftThreadRouteParams } from "../threadRoutes"; import { type ComposerImageAttachment, @@ -208,14 +229,17 @@ import { serverEnvironment, } from "../state/server"; import { terminalEnvironment } from "../state/terminal"; -import { threadEnvironment } from "../state/threads"; +import { threadEnvironment, useEnvironmentThread } from "../state/threads"; +import { + requestOlderThreadTurns, + threadHasOlderTurns, +} from "@t3tools/client-runtime/state/threads"; import { vcsEnvironment } from "../state/vcs"; import { useEnvironments, usePrimaryEnvironment } from "../state/environments"; import { useProject, useProjects, useThread, - useThreadProposedPlans, useThreadRefs, useThreadShell, } from "../state/entities"; @@ -225,17 +249,29 @@ import { DraftHeroHeadline } from "./chat/DraftHeroHeadline"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; import { MessagesTimeline } from "./chat/MessagesTimeline"; +import { resolveTimelineIsAtEnd } from "./chat/MessagesTimeline.logic"; import { ChatHeader } from "./chat/ChatHeader"; import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls"; import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; import { NoActiveThreadState } from "./NoActiveThreadState"; -import { resolveEffectiveEnvMode, resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; +import { + resolveEffectiveEnvMode, + resolveLocalCheckoutBranchMismatch, + shouldShowComposerContextStrip, + shouldShowEnvironmentIndicator, +} from "./BranchToolbar.logic"; import { getProviderStatusBannerKey, ProviderStatusBanner, shouldShowProviderStatusBanner, } from "./chat/ProviderStatusBanner"; -import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; +import { + dismissThreadErrorBannerForSession, + getThreadErrorBannerKey, + isThreadErrorBannerDismissedForSession, + shouldShowThreadErrorBanner, + ThreadErrorBanner, +} from "./chat/ThreadErrorBanner"; import { resolveThreadPr } from "./ThreadStatusIndicators"; import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; import { ThreadSyncStatusPill } from "./chat/ThreadSyncStatusPill"; @@ -258,6 +294,8 @@ import { createLocalDispatchSnapshot, deriveComposerSendState, dismissBranchMismatchForSession, + hasEnvironmentReconnectWarningGraceElapsed, + scheduleEnvironmentReconnectWarning, hasServerAcknowledgedLocalDispatch, isBranchMismatchDismissedForSession, shouldShowBranchMismatchBanner, @@ -426,8 +464,6 @@ type EnvironmentUnavailableState = { readonly connection: EnvironmentConnectionPresentation; }; -type ThreadPlanCatalogEntry = Pick; - function eventPathContainsSelector(event: Event, selector: string): boolean { const path = event.composedPath(); if (path.length === 0 && event.target) { @@ -445,6 +481,14 @@ function shouldTypeToFocusComposer(event: KeyboardEvent): boolean { if (eventPathContainsSelector(event, TYPE_TO_FOCUS_INTERACTIVE_SELECTOR)) return false; if (document.querySelector(TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR)) return false; + // The right-panel surface launcher claims its shortcut letters while it is + // visible (data attribute set in RightPanelTabs); those keys open surfaces + // instead of typing into the composer. + const launcherKeys = document + .querySelector("[data-surface-launcher-keys]") + ?.getAttribute("data-surface-launcher-keys"); + if (launcherKeys && launcherKeys.toLowerCase().includes(event.key.toLowerCase())) return false; + return true; } @@ -704,6 +748,19 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra () => drawerTerminalSessions.map((session) => session.target.terminalId), [drawerTerminalSessions], ); + // Every client-side id source participates in allocation: the server list + // lags fresh opens, and panel terminals are filtered out of the drawer's + // sessions — an id collision attaches two viewports to one PTY session. + const allocatableTerminalIds = useMemo( + () => [ + ...new Set([ + ...serverOrderedTerminalIds, + ...terminalUiState.terminalIds, + ...panelTerminalIds, + ]), + ], + [panelTerminalIds, serverOrderedTerminalIds, terminalUiState.terminalIds], + ); const storeSetTerminalHeight = useTerminalUiStateStore((state) => state.setTerminalHeight); const storeSplitTerminal = useTerminalUiStateStore((state) => state.splitTerminal); const storeSplitTerminalVertical = useTerminalUiStateStore( @@ -773,7 +830,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra if (!cwd) { return; } - const terminalId = nextTerminalId(serverOrderedTerminalIds); + const terminalId = nextTerminalId(allocatableTerminalIds); storeSplitTerminal(threadRef, terminalId); bumpFocusRequestId(); void openTerminal({ @@ -787,11 +844,11 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra }, }); }, [ + allocatableTerminalIds, bumpFocusRequestId, cwd, effectiveWorktreePath, runtimeEnv, - serverOrderedTerminalIds, storeSplitTerminal, threadId, threadRef, @@ -801,7 +858,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra if (!cwd) { return; } - const terminalId = nextTerminalId(serverOrderedTerminalIds); + const terminalId = nextTerminalId(allocatableTerminalIds); storeSplitTerminalVertical(threadRef, terminalId); bumpFocusRequestId(); void openTerminal({ @@ -815,12 +872,12 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra }, }); }, [ + allocatableTerminalIds, bumpFocusRequestId, cwd, effectiveWorktreePath, openTerminal, runtimeEnv, - serverOrderedTerminalIds, storeSplitTerminalVertical, threadId, threadRef, @@ -830,7 +887,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra if (!cwd) { return; } - const terminalId = nextTerminalId(serverOrderedTerminalIds); + const terminalId = nextTerminalId(allocatableTerminalIds); storeNewTerminal(threadRef, terminalId); bumpFocusRequestId(); void openTerminal({ @@ -847,8 +904,8 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra bumpFocusRequestId, cwd, effectiveWorktreePath, + allocatableTerminalIds, runtimeEnv, - serverOrderedTerminalIds, storeNewTerminal, threadId, threadRef, @@ -1206,10 +1263,24 @@ function ChatViewContent(props: ChatViewProps) { [routeServerThreadShell, threadDetailLoading], ); const activeServerThread = serverThread ?? loadingServerThread; - const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); - const activeThreadLastVisitedAt = useUiStateStore( - (store) => store.threadLastVisitedAtById[routeThreadKey], + // Pagination window state for the routed server thread: drives the + // "load earlier turns" header when the loaded window has older history. + const routeThreadState = useEnvironmentThread( + routeKind === "server" ? routeThreadRef.environmentId : null, + routeKind === "server" ? routeThreadRef.threadId : null, ); + const loadEarlierTurns = useMemo(() => { + if (routeKind !== "server" || !threadHasOlderTurns(routeThreadState)) { + return null; + } + return { + loading: routeThreadState.page._tag === "Some" && routeThreadState.page.value.loadingOlder, + onLoadEarlier: () => { + requestOlderThreadTurns(routeThreadRef.environmentId, routeThreadRef.threadId); + }, + }; + }, [routeKind, routeThreadRef, routeThreadState]); + const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); const settings = useEnvironmentSettings(environmentId); // New-thread defaults live in the primary environment's settings.json (the // settings UI never writes to remote environments), so read them from the @@ -1219,7 +1290,6 @@ function ChatViewContent(props: ChatViewProps) { (store) => store.setStickyModelSelection, ); const timestampFormat = settings.timestampFormat; - const autoOpenPlanSidebar = settings.autoOpenPlanSidebar; const navigate = useNavigate(); const { resolvedTheme } = useTheme(); // Granular store selectors — avoid subscribing to prompt changes. @@ -1289,12 +1359,7 @@ function ChatViewContent(props: ChatViewProps) { >({}); const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] = useState>({}); - const shouldUsePlanSidebarSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); - // Tracks whether the user explicitly dismissed the sidebar for the active turn. - const planSidebarDismissedForTurnRef = useRef(null); - // When set, the thread-change reset effect will open the sidebar instead of closing it. - // Used by "Implement in a new thread" to carry the sidebar-open intent across navigation. - const planSidebarOpenOnNextThreadRef = useRef(false); + const shouldUseRightPanelSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); const [pullRequestDialogState, setPullRequestDialogState] = useState(null); @@ -1443,12 +1508,36 @@ function ChatViewContent(props: ChatViewProps) { const threadError = isServerThread ? (localServerError ?? activeServerThread?.session?.lastError ?? null) : localDraftError; + // Dismissals can only mask the shown error, never clear it: a server thread + // keeps its error in session.lastError, so clearing the local shadow would + // just fall through to the persisted one. Mask the current error until a + // different error arrives, mirroring the provider status banner. + const threadErrorBannerKey = getThreadErrorBannerKey(routeThreadKey, threadError); + const visibleThreadError = shouldShowThreadErrorBanner( + routeThreadKey, + threadError, + isThreadErrorBannerDismissedForSession(threadErrorBannerKey), + ) + ? threadError + : null; + // Dismissing only mutates the session-scoped mask set, which does not + // trigger a render on its own; setThreadError(null) can also bail when the + // local shadow is already empty and the banner is driven purely by + // session.lastError. Bump a tick so the banner hides immediately. Mirrors + // the branch mismatch banner. + const [, setThreadErrorBannerDismissTick] = useState(0); const runtimeMode = composerRuntimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE; - const interactionMode = - composerInteractionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE; + // Plan mode is legacy (Settings → Beta). With the flag off the effective + // mode is forced to "default" — even for threads with a stored plan mode — + // so nobody is trapped in plan mode while its toggle is hidden. The next + // send persists "default" back to the thread. + const interactionMode = settings.planModeEnabled + ? (composerInteractionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE) + : DEFAULT_INTERACTION_MODE; const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; const canCheckoutPullRequestIntoThread = isLocalDraftThread; const activeThreadId = activeThread?.id ?? null; + const activeThreadEnvironmentId = activeThread?.environmentId ?? null; const runningTerminalIds = useThreadRunningTerminalIds({ environmentId: activeThread?.environmentId ?? null, threadId: activeThreadId, @@ -1484,8 +1573,11 @@ function ChatViewContent(props: ChatViewProps) { return labels; }, [activeThreadKnownSessions]); const activeThreadRef = useMemo( - () => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null), - [activeThread], + () => + activeThreadEnvironmentId && activeThreadId + ? scopeThreadRef(activeThreadEnvironmentId, activeThreadId) + : null, + [activeThreadEnvironmentId, activeThreadId], ); const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; const [timelineAnchor, setTimelineAnchor] = useState<{ @@ -1506,6 +1598,21 @@ function ChatViewContent(props: ChatViewProps) { const activeRightPanelSurface = useRightPanelStore((state) => selectActiveRightPanelSurface(state.byThreadKey, activeThreadRef), ); + const [pullRequestTabStatuses, setPullRequestTabStatuses] = useState< + Record + >({}); + // Keyed by the surface the panel is showing rather than by a key rebuilt from the status, so + // the tab is found again whether or not that surface was opened with an environment on it. + const activePullRequestSurfaceId = + activeRightPanelSurface?.kind === "pull-request" ? activeRightPanelSurface.id : undefined; + const handlePullRequestTabStatusChange = useCallback( + (status: PullRequestTabStatus) => { + const id = activePullRequestSurfaceId; + if (id === undefined) return; + setPullRequestTabStatuses((current) => updatePullRequestTabStatus(current, id, status)); + }, + [activePullRequestSurfaceId], + ); const activeFileSurface = activeRightPanelSurface?.kind === "file" ? activeRightPanelSurface : null; const activePreviewState = useThreadPreviewState(activeThreadRef); @@ -1521,12 +1628,16 @@ function ChatViewContent(props: ChatViewProps) { ), [rightPanelState.surfaces], ); + const allocatableActiveTerminalIds = useMemo( + () => [...new Set([...activeKnownTerminalIds, ...panelTerminalIds])], + [activeKnownTerminalIds, panelTerminalIds], + ); const previewPanelOpen = activeRightPanelKind === "preview" && isPreviewSupportedInRuntime(); const rightPanelOpen = rightPanelState.isOpen; - const canMaximizeRightPanel = rightPanelOpen && !shouldUsePlanSidebarSheet; + const canMaximizeRightPanel = rightPanelOpen && !shouldUseRightPanelSheet; const rightPanelMaximized = canMaximizeRightPanel && maximizedRightPanelThreadKey === routeThreadKey; - const inlineRightPanelOwnsTitleBar = rightPanelOpen && !shouldUsePlanSidebarSheet; + const inlineRightPanelOwnsTitleBar = rightPanelOpen && !shouldUseRightPanelSheet; useEffect(() => { if (!activeThreadRef) return; @@ -1553,36 +1664,29 @@ function ChatViewContent(props: ChatViewProps) { previewPanelOpen, ]); - const planSidebarOpen = activeRightPanelKind === "plan"; - const existingOpenTerminalThreadKeys = useMemo(() => { const existingThreadKeys = new Set([...serverThreadKeys, ...draftThreadKeys]); return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]); const activeLatestTurn = activeThread?.latestTurn ?? null; - const sourcePlanThreadRef = useMemo(() => { - const sourceThreadId = activeLatestTurn?.sourceProposedPlan?.threadId; - if (!activeThread || !sourceThreadId || sourceThreadId === activeThread.id) { - return null; - } - return scopeThreadRef(activeThread.environmentId, sourceThreadId); - }, [activeLatestTurn?.sourceProposedPlan?.threadId, activeThread]); - const sourceThreadProposedPlans = useThreadProposedPlans(sourcePlanThreadRef); - const threadPlanCatalog = useMemo(() => { - if (!activeThread) { - return []; - } - const entries: ThreadPlanCatalogEntry[] = [ - { id: activeThread.id, proposedPlans: activeThread.proposedPlans }, - ]; - if (sourcePlanThreadRef) { - entries.push({ - id: sourcePlanThreadRef.threadId, - proposedPlans: sourceThreadProposedPlans, - }); - } - return entries; - }, [activeThread, sourcePlanThreadRef, sourceThreadProposedPlans]); + // Reading a finished thread clears the sidebar's Done badge. The visit is + // stamped at the turn's completion time — not now/updatedAt — so it clears + // exactly the completion the user is looking at: a wake or completion that + // lands later still gets its signal (markThreadVisited never moves the + // timestamp backwards). + useEffect(() => { + const completedAt = serverThread?.latestTurn?.completedAt; + if (!serverThread?.id || !completedAt) return; + markThreadVisited( + scopedThreadKey(scopeThreadRef(serverThread.environmentId, serverThread.id)), + completedAt, + ); + }, [ + markThreadVisited, + serverThread?.environmentId, + serverThread?.id, + serverThread?.latestTurn?.completedAt, + ]); useEffect(() => { setMountedTerminalThreadKeys((currentThreadIds) => { const nextThreadIds = reconcileMountedTerminalThreadIds({ @@ -1613,6 +1717,8 @@ function ChatViewContent(props: ChatViewProps) { const activeProjectKey = activeProject ? `${activeProject.environmentId}:${activeProject.workspaceRoot}` : null; + const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); + const clientSettingsHydrated = useClientSettingsHydrated(); const [pendingFileSurfaceIdsByProject, setPendingFileSurfaceIdsByProject] = useState< ReadonlyMap> >(() => new Map()); @@ -1651,11 +1757,54 @@ function ChatViewContent(props: ChatViewProps) { // drive the environment picker in BranchToolbar. const allProjects = useProjects(); const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null; + useEffect(() => { + if (!clientSettingsHydrated || !activeThreadRef || !activeProject) return; + // Reuse the sidebar's grouping so history follows the project rows the user + // sees. Deriving the key from the active project alone would miss the + // identity a duplicate row borrows from its siblings. + const logicalKeyByPhysicalKey = buildPhysicalToLogicalProjectKeyMap({ + projects: allProjects, + settings: projectGroupingSettings, + primaryEnvironmentId, + }); + useBrowserHistoryStore + .getState() + .registerThreadProject( + activeThreadRef, + logicalKeyByPhysicalKey.get(derivePhysicalProjectKey(activeProject)) ?? + deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings), + ); + }, [ + activeProject, + activeThreadRef, + allProjects, + clientSettingsHydrated, + primaryEnvironmentId, + projectGroupingSettings, + ]); const activeEnvironment = activeThread == null ? null : (environmentById.get(activeThread.environmentId) ?? null); const activeEnvironmentConnectionPhase = activeEnvironment?.connection.phase ?? "available"; const activeEnvironmentUnavailable = activeEnvironment !== null && activeEnvironmentConnectionPhase !== "connected"; + const activeReconnectingEnvironmentId = + activeEnvironmentConnectionPhase === "connecting" || + activeEnvironmentConnectionPhase === "reconnecting" + ? (activeEnvironment?.environmentId ?? null) + : null; + const [reconnectWarningGraceElapsedEnvironmentId, setReconnectWarningGraceElapsedEnvironmentId] = + useState(null); + const reconnectWarningGraceElapsed = hasEnvironmentReconnectWarningGraceElapsed( + activeReconnectingEnvironmentId, + reconnectWarningGraceElapsedEnvironmentId, + ); + useEffect(() => { + setReconnectWarningGraceElapsedEnvironmentId(null); + if (activeReconnectingEnvironmentId === null) return; + return scheduleEnvironmentReconnectWarning(() => + setReconnectWarningGraceElapsedEnvironmentId(activeReconnectingEnvironmentId), + ); + }, [activeReconnectingEnvironmentId]); const activeEnvironmentUnavailableLabel = activeEnvironment?.label ?? null; const activeEnvironmentUnavailableState = useMemo(() => { if (!activeEnvironmentUnavailable || !activeEnvironmentUnavailableLabel || !activeEnvironment) { @@ -1684,7 +1833,6 @@ function ChatViewContent(props: ChatViewProps) { }, [retryEnvironment], ); - const projectGroupingSettings = selectProjectGroupingSettings(settings); const logicalProjectEnvironments = useMemo(() => { if (!activeProject) return []; const logicalKey = deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings); @@ -1718,6 +1866,14 @@ function ChatViewContent(props: ChatViewProps) { return envs; }, [activeProject, allProjects, projectGroupingSettings, primaryEnvironmentId, environmentById]); const hasMultipleEnvironments = logicalProjectEnvironments.length > 1; + const activeEnvironmentOption = + logicalProjectEnvironments.find( + (environment) => environment.environmentId === activeThread?.environmentId, + ) ?? null; + const showComposerEnvironmentIndicator = shouldShowEnvironmentIndicator({ + activeEnvironment: activeEnvironmentOption, + canPickEnvironment: hasMultipleEnvironments, + }); const openPullRequestDialog = useCallback( (reference?: string) => { @@ -1824,25 +1980,6 @@ function ChatViewContent(props: ChatViewProps) { [openOrReuseProjectDraftThread], ); - useEffect(() => { - if (!serverThread?.id) return; - const threadUpdatedAt = Date.parse(serverThread.updatedAt); - if (Number.isNaN(threadUpdatedAt)) return; - const lastVisitedAt = activeThreadLastVisitedAt ? Date.parse(activeThreadLastVisitedAt) : NaN; - if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= threadUpdatedAt) return; - - markThreadVisited( - scopedThreadKey(scopeThreadRef(serverThread.environmentId, serverThread.id)), - serverThread.updatedAt, - ); - }, [ - activeThreadLastVisitedAt, - markThreadVisited, - serverThread?.environmentId, - serverThread?.id, - serverThread?.updatedAt, - ]); - const selectedProviderByThreadId = composerActiveProvider ?? null; const threadProvider = activeThread?.modelSelection.instanceId ?? @@ -1858,6 +1995,8 @@ function ChatViewContent(props: ChatViewProps) { const serverConfig = activeThread ? (activeEnvironment?.serverConfig ?? null) : (primaryEnvironment?.serverConfig ?? null); + const pullRequestsCapabilityKnown = serverConfig !== null; + const supportsPullRequests = serverConfig?.environment.capabilities.pullRequests === true; const versionMismatch = resolveServerConfigVersionMismatch(serverConfig); const versionMismatchDismissKey = versionMismatch && activeThread @@ -1900,12 +2039,16 @@ function ChatViewContent(props: ChatViewProps) { // While an update runs, transient connect blips are expected (the server // restarts) and the update banner already shows progress. Hard failure // phases still surface so the Reconnect action stays reachable. - const suppressUnavailableBanner = updateRunning && environmentReconnecting; + const suppressUnavailableBanner = + environmentReconnecting && + (updateRunning || (!reconnectingThroughVersionSkew && !reconnectWarningGraceElapsed)); if (activeEnvironmentUnavailableState && unavailableConnection && !suppressUnavailableBanner) { if (reconnectingThroughVersionSkew) { items.push({ id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`, variant: "default", + // Live connection status: calm styling, but it must front the stack. + urgent: true, icon: (
- {rightPanelOpen && !shouldUsePlanSidebarSheet ? ( +
+ {rightPanelOpen && !shouldUseRightPanelSheet ? ( { + void onSend(undefined, { annotation, image }); + }} /> ) : activeRightPanelSurface?.kind === "terminal" ? ( @@ -5676,17 +6066,52 @@ function ChatViewContent(props: ChatViewProps) { initialGitScope={initialDiffPanelGitScope} /> - ) : activeRightPanelSurface?.kind === "plan" ? ( - + ) : activeRightPanelSurface?.kind === "pull-request" && !supportsPullRequests ? ( + + ) : activeRightPanelSurface?.kind === "pull-request" ? ( + // No onClose: the surface tab's own X owns closing here, and a second X in the header + // would be the same action twice. The thread context also drops the checkout button, so it + // is only right for the thread's own pull request, whose branch is already under the + // reader's feet. A link the agent wrote can open any other one here, and that one has to be + // checkable out like it is anywhere else. + + ) : activeRightPanelSurface?.kind === "agents" ? ( + ) : (activeRightPanelSurface?.kind === "files" || activeRightPanelSurface?.kind === "file") && activeProject && @@ -5715,7 +6140,7 @@ function ChatViewContent(props: ChatViewProps) { return (
- {rightPanelOpen && !shouldUsePlanSidebarSheet ? panelLayoutControls : null} + {rightPanelOpen && !shouldUseRightPanelSheet ? panelLayoutControls : null}
{!rightPanelOpen ? panelLayoutControls : null} setThreadError(activeThread.id, null)} + error={visibleThreadError} + onDismiss={() => { + setThreadError(activeThread.id, null); + dismissThreadErrorBannerForSession(threadErrorBannerKey); + setThreadErrorBannerDismissTick((tick) => tick + 1); + }} /> {/* Main content area with optional plan sidebar */}
@@ -5783,8 +6218,11 @@ function ChatViewContent(props: ChatViewProps) {
{/* Messages — LegendList handles virtualization and scrolling internally */} {/* scroll to end pill — shown when user has scrolled away from the live edge */} @@ -5925,10 +6364,6 @@ function ChatViewContent(props: ChatViewProps) { respondingRequestIds={respondingRequestIds} showPlanFollowUpPrompt={showPlanFollowUpPrompt} activeProposedPlan={activeProposedPlan} - activePlan={activePlan as { turnId?: TurnId } | null} - sidebarProposedPlan={sidebarProposedPlan as { turnId?: TurnId } | null} - planSidebarLabel={planSidebarLabel} - planSidebarOpen={planSidebarOpen} runtimeMode={runtimeMode} interactionMode={interactionMode} lockedProvider={lockedProvider} @@ -5966,7 +6401,6 @@ function ChatViewContent(props: ChatViewProps) { toggleInteractionMode={toggleInteractionMode} handleRuntimeModeChange={handleRuntimeModeChange} handleInteractionModeChange={handleInteractionModeChange} - togglePlanSidebar={togglePlanSidebar} focusComposer={focusComposer} scheduleComposerFocus={scheduleComposerFocus} setThreadError={setThreadError} @@ -5984,6 +6418,7 @@ function ChatViewContent(props: ChatViewProps) { - {!shouldUsePlanSidebarSheet && rightPanelOpen && activeThreadRef ? ( + {!shouldUseRightPanelSheet && rightPanelOpen && activeThreadRef ? ( {rightPanelContent} ) : null} - {shouldUsePlanSidebarSheet && rightPanelOpen && activeThreadRef ? ( - + {shouldUseRightPanelSheet && rightPanelOpen && activeThreadRef ? ( + {rightPanelContent} diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 4d591500f5c6..06dabc5e8490 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -264,6 +264,20 @@ describe("buildThreadActionItems", () => { expect(item?.description).toBe("T3 Code · #feat/search"); }); + it("prefers renderDescription when provided", () => { + const [item] = buildThreadActionItems({ + threads: [makeThread({ branch: "feat/search", worktreePath: "/tmp/wt" })], + projectTitleById: new Map([[PROJECT_ID, "T3 Code"]]), + sortOrder: "updated_at", + icon: null, + renderDescription: (thread, { projectTitle }) => + `${projectTitle}:${thread.branch}:${thread.worktreePath ? "wt" : "local"}`, + runThread: async (_thread) => undefined, + }); + + expect(item?.description).toBe("T3 Code:feat/search:wt"); + }); + it("filters archived threads out of thread search items", () => { const items = buildThreadActionItems({ threads: [ diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index eee6ba5886e6..07e0e520d84e 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -12,7 +12,7 @@ import { formatRelativeTimeLabel } from "../timestampFormat"; import { type Project, type SidebarThreadSummary, type Thread } from "../types"; export const RECENT_THREAD_LIMIT = 12; -export const ITEM_ICON_CLASS = "size-4 text-muted-foreground/80"; +export const ITEM_ICON_CLASS = "size-4 text-icon-muted"; export const ADDON_ICON_CLASS = "size-4"; /** @@ -154,7 +154,16 @@ export function buildProjectActionItems(input: { export type BuildThreadActionItemsThread = Pick< SidebarThreadSummary, - "archivedAt" | "branch" | "createdAt" | "environmentId" | "id" | "projectId" | "title" + | "archivedAt" + | "branch" + | "createdAt" + | "environmentId" + | "id" + | "modelSelection" + | "projectId" + | "session" + | "title" + | "worktreePath" > & { updatedAt: string; latestUserMessageAt?: string | null; @@ -170,6 +179,8 @@ export function buildThreadActionItems ReactNode; /** Optional content rendered inline after the title text per-thread. */ renderTrailingContent?: (thread: TThread) => ReactNode; + /** Optional rich description (e.g. favicon + workspace icons). Falls back to text. */ + renderDescription?: (thread: TThread, meta: { projectTitle: string | undefined }) => ReactNode; getContentMatch?: (thread: TThread) => CommandPaletteThreadContentMatch | undefined; runThread: (thread: Pick) => Promise; limit?: number; @@ -198,6 +209,9 @@ export function buildThreadActionItems + ); +} + function getLocalFileManagerName(platform: string): string { if (isMacPlatform(platform)) { return "Finder"; @@ -375,6 +395,7 @@ export function CommandPalette({ children }: { children: ReactNode }) { const openNewThreadIn = useCallback(() => dispatch({ _tag: "OpenNewThreadIn" }), []); const clearOpenIntent = useCallback(() => dispatch({ _tag: "ClearOpenIntent" }), []); const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const { theme, themeHalves, resolvedTheme } = useTheme(); const composerHandleRef = useRef(null); const routeTarget = useParams({ strict: false, @@ -417,6 +438,16 @@ export function CommandPalette({ children }: { children: ReactNode }) { previewOpen, }, }); + if (command === "themeEditor.toggle") { + event.preventDefault(); + event.stopPropagation(); + toggleThemeEditorForTheme({ + theme, + themeHalves, + initialAppearance: resolvedTheme, + }); + return; + } const mode = overlayModeForCommand(command); if (mode === null) { return; @@ -427,7 +458,7 @@ export function CommandPalette({ children }: { children: ReactNode }) { }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); - }, [keybindings, previewOpen, terminalOpen, toggleMode]); + }, [keybindings, previewOpen, resolvedTheme, terminalOpen, theme, themeHalves, toggleMode]); useEffect( () => @@ -556,7 +587,20 @@ function OpenCommandPaletteDialog(props: { const projectOrder = useUiStateStore((store) => store.projectOrder); const threads = useThreadShells(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const { theme, themeHalves, resolvedTheme } = useTheme(); const providers = useAtomValue(primaryServerProvidersAtom); + const providerEntryByEnvironmentAndInstanceId = useMemo(() => { + const map = new Map(); + for (const environment of environments) { + const environmentProviders = + environment.serverConfig?.providers ?? + (environment.environmentId === primaryEnvironmentId ? providers : []); + for (const entry of deriveProviderInstanceEntries(environmentProviders)) { + map.set(`${environment.environmentId}:${entry.instanceId}`, entry); + } + } + return map; + }, [environments, primaryEnvironmentId, providers]); const [viewStack, setViewStack] = useState([]); const currentView = viewStack.at(-1) ?? null; const environmentIds = useMemo( @@ -789,6 +833,10 @@ function OpenCommandPaletteDialog(props: { new Map(projects.map((project) => [project.id, project.workspaceRoot])), [projects], ); + const projectFaviconPathById = useMemo( + () => new Map(projects.map((project) => [project.id, project.faviconPath ?? null] as const)), + [projects], + ); const projectTitleById = useMemo( () => new Map(projects.map((project) => [project.id, project.title])), [projects], @@ -924,13 +972,7 @@ function OpenCommandPaletteDialog(props: { group?.memberProjects.flatMap((member) => [member.title, member.workspaceRoot]) ?? [] ); }, - icon: (project) => ( - - ), + icon: projectFavicon, runProject: openProjectFromSearch, }), [openProjectFromSearch, pickerProjects, projectGroupByTargetKey], @@ -948,13 +990,7 @@ function OpenCommandPaletteDialog(props: { group?.memberProjects.flatMap((member) => [member.title, member.workspaceRoot]) ?? [] ); }, - icon: (project) => ( - - ), + icon: projectFavicon, runProject: async (project) => { const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`); const contextualRefBelongsToGroup = @@ -985,6 +1021,29 @@ function OpenCommandPaletteDialog(props: { icon: , renderLeadingContent: (thread) => , renderTrailingContent: (thread) => , + renderDescription: (thread, { projectTitle }) => { + const modelInstanceId = + thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; + const providerEntry = + providerEntryByEnvironmentAndInstanceId.get( + `${thread.environmentId}:${modelInstanceId}`, + ) ?? null; + return ( + + ); + }, getContentMatch: (thread) => { const match = threadContentMatchByKey.get( threadSearchMatchKey({ @@ -1011,7 +1070,10 @@ function OpenCommandPaletteDialog(props: { activeThreadId, clientSettings.sidebarThreadSortOrder, navigate, + projectCwdById, + projectFaviconPathById, projectTitleById, + providerEntryByEnvironmentAndInstanceId, threadContentMatchByKey, threadSearchQuery, threads, @@ -1464,6 +1526,22 @@ function OpenCommandPaletteDialog(props: { }); } + actionItems.push({ + kind: "action", + value: "action:theme-editor", + searchTerms: ["theme", "appearance", "colors", "palette", "customize"], + title: "Toggle theme editor", + icon: , + shortcutCommand: "themeEditor.toggle", + run: async () => { + toggleThemeEditorForTheme({ + theme, + themeHalves, + initialAppearance: resolvedTheme, + }); + }, + }); + actionItems.push({ kind: "action", value: "action:settings", @@ -1475,6 +1553,33 @@ function OpenCommandPaletteDialog(props: { }, }); + // There is no projects listing page; the action targets the contextual + // project (active thread/draft, falling back to the first sidebar group). + const contextualProjectGroup = + (contextualProjectRef + ? projectGroupByTargetKey.get( + `${contextualProjectRef.environmentId}:${contextualProjectRef.projectId}`, + ) + : null) ?? + projectGroups[0] ?? + null; + if (contextualProjectGroup) { + actionItems.push({ + kind: "action", + value: "action:project-settings", + searchTerms: ["project", "settings", "scripts", "model", "grouping", "checkout"], + title: "Project settings", + description: contextualProjectGroup.displayName, + icon: , + run: async () => { + await navigate({ + to: "/projects/$projectKey", + params: { projectKey: contextualProjectGroup.projectKey }, + }); + }, + }); + } + const rootGroups = buildRootGroups({ actionItems, recentThreadItems }); const sourceSelectionViewValue = addProjectEnvironmentId === null ? null : `sources:${addProjectEnvironmentId}`; diff --git a/apps/web/src/components/CommandPaletteResults.tsx b/apps/web/src/components/CommandPaletteResults.tsx index 2ab4ef8f3f81..bbdbc28b0609 100644 --- a/apps/web/src/components/CommandPaletteResults.tsx +++ b/apps/web/src/components/CommandPaletteResults.tsx @@ -142,7 +142,7 @@ function DisabledCommandPaletteResultRow(props: { ) : null} {props.item.description ? ( - + {props.item.description} ) : null} @@ -193,7 +193,7 @@ function CommandPaletteResultRow(props: { ) : null} {props.item.description ? ( - + {props.item.description} ) : null} diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 169126788ae8..0489e8c79cdf 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -72,8 +72,8 @@ import { cn, isMacPlatform } from "~/lib/utils"; import { basenameOfPath } from "~/pierre-icons"; import { COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, - COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME, COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME, + COMPOSER_INLINE_SKILL_CHIP_LABEL_CLASS_NAME, SKILL_CHIP_ICON_SVG, } from "./composerInlineChip"; import { FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip"; @@ -188,7 +188,7 @@ class ComposerMentionNode extends DecoratorNode { override createDOM(): HTMLElement { const dom = document.createElement("span"); - dom.className = "composer-inline-chip relative inline-flex align-middle leading-none"; + dom.className = "composer-inline-chip relative inline-flex align-[-0.125em] leading-none"; return dom; } @@ -256,7 +256,7 @@ function ComposerSkillDecorator(props: { skillLabel: string; skillDescription: s className={COMPOSER_INLINE_CHIP_ICON_CLASS_NAME} dangerouslySetInnerHTML={{ __html: SKILL_CHIP_ICON_SVG }} /> - {props.skillLabel} + {props.skillLabel} ); @@ -326,7 +326,7 @@ class ComposerSkillNode extends DecoratorNode { override createDOM(): HTMLElement { const dom = document.createElement("span"); - dom.className = "composer-inline-chip relative inline-flex align-middle leading-none"; + dom.className = "composer-inline-chip relative inline-flex align-[-0.125em] leading-none"; return dom; } @@ -397,7 +397,7 @@ class ComposerTerminalContextNode extends DecoratorNode { override createDOM(): HTMLElement { const dom = document.createElement("span"); - dom.className = "composer-inline-chip relative inline-flex align-middle leading-none"; + dom.className = "composer-inline-chip relative inline-flex align-[-0.125em] leading-none"; return dom; } @@ -1747,12 +1747,14 @@ function ComposerPromptEditorInner({ return ( -
+
Appearance + // can drive it; keep everything else here. + "block max-h-50 min-h-17.5 w-full overflow-y-auto whitespace-pre-wrap wrap-break-word bg-transparent leading-relaxed text-foreground focus:outline-none", className, )} data-testid="composer-editor" @@ -1763,7 +1765,7 @@ function ComposerPromptEditorInner({ } placeholder={ terminalContexts.length > 0 ? null : ( -
+
{placeholder}
) diff --git a/apps/web/src/components/ConfirmDialogHost.tsx b/apps/web/src/components/ConfirmDialogHost.tsx new file mode 100644 index 000000000000..c169a1eff7fc --- /dev/null +++ b/apps/web/src/components/ConfirmDialogHost.tsx @@ -0,0 +1,96 @@ +import { useEffect, useSyncExternalStore } from "react"; + +import { + completeConfirmDialogClose, + readConfirmDialogState, + registerConfirmDialogHost, + respondToConfirmDialog, + subscribeConfirmDialog, +} from "../confirmDialog"; +import { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "./ui/alert-dialog"; +import { Button } from "./ui/button"; + +type ConfirmationCopy = { + readonly title: string; + readonly description: string | null; +}; + +export function resolveConfirmDialogCopy(message: string): ConfirmationCopy { + const normalizedMessage = message.trim(); + const lines = normalizedMessage.split("\n"); + const questionLineIndex = lines.findIndex((line) => line.trim().endsWith("?")); + + if (questionLineIndex >= 0) { + const title = lines[questionLineIndex]!.trim(); + const description = lines + .filter((_, index) => index !== questionLineIndex) + .join("\n") + .trim(); + return { title, description: description || null }; + } + + const questionMarkIndex = normalizedMessage.indexOf("?"); + if (questionMarkIndex >= 0) { + return { + title: normalizedMessage.slice(0, questionMarkIndex + 1).trim(), + description: normalizedMessage.slice(questionMarkIndex + 1).trim() || null, + }; + } + + return { + title: "Confirm action", + description: normalizedMessage || "This action requires your confirmation.", + }; +} + +export function ConfirmDialogHost() { + const state = useSyncExternalStore( + subscribeConfirmDialog, + readConfirmDialogState, + readConfirmDialogState, + ); + + useEffect(() => registerConfirmDialogHost(), []); + + const copy = resolveConfirmDialogCopy(state.status === "idle" ? "" : state.message); + const confirmVariant = state.status === "idle" ? "default" : state.variant; + const onCancel = () => respondToConfirmDialog(false); + const onConfirm = () => respondToConfirmDialog(true); + + return ( + { + if (!open) onCancel(); + }} + onOpenChangeComplete={(open) => { + if (!open) completeConfirmDialogClose(); + }} + > + + + {copy.title} + {copy.description ? ( + + {copy.description} + + ) : null} + + + }>Cancel + + + + + ); +} diff --git a/apps/web/src/components/ConnectionStatusDot.tsx b/apps/web/src/components/ConnectionStatusDot.tsx index 0c22f1702e5e..2efddcfa736d 100644 --- a/apps/web/src/components/ConnectionStatusDot.tsx +++ b/apps/web/src/components/ConnectionStatusDot.tsx @@ -1,6 +1,28 @@ +import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; + import { cn } from "~/lib/utils"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; +/** Canonical connection-phase → dot color mapping shared by every status dot. */ +export function connectionPhaseDotClassName(phase: EnvironmentConnectionPhase): string { + switch (phase) { + case "connected": + return "bg-success"; + case "connecting": + case "reconnecting": + return "bg-warning"; + case "error": + return "bg-destructive"; + default: + return "bg-muted-foreground/40"; + } +} + +/** Ping halo for transitional phases; null renders no ping. */ +export function connectionPhasePingClassName(phase: EnvironmentConnectionPhase): string | null { + return phase === "connecting" || phase === "reconnecting" ? "bg-warning/60 duration-2000" : null; +} + type ConnectionStatusDotProps = { tooltipText?: string | null; dotClassName: string; diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index d10cb39f0e36..385d67b6b701 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -1,4 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; +import type { FileDiffContentsLoader } from "@pierre/diffs"; import { useParams } from "@tanstack/react-router"; import { isAtomCommandInterrupted, @@ -15,6 +16,7 @@ import { ChevronsUpDownIcon, Columns2Icon, PilcrowIcon, + RefreshCwIcon, Rows3Icon, SearchIcon, TextWrapIcon, @@ -67,12 +69,13 @@ import { } from "./ui/menu"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { useEnvironmentQuery } from "../state/query"; +import { useAtomCommand } from "../state/use-atom-command"; import { serverEnvironment } from "../state/server"; import { reviewEnvironment } from "../state/review"; import { vcsEnvironment } from "../state/vcs"; import { buildBaseRefChoices, filterBaseRefChoices } from "../lib/baseRefChoices"; +import { createGitDiffFileContentsLoader } from "../lib/diffFileContents"; -type DiffRenderMode = "stacked" | "split"; type DiffThemeType = "light" | "dark"; const AUTOMATIC_BASE_REF = "__automatic_base_ref__"; @@ -83,104 +86,6 @@ interface CollapsedDiffFilesState { const EMPTY_COLLAPSED_DIFF_FILE_KEYS: ReadonlySet = new Set(); -const DIFF_PANEL_UNSAFE_CSS = ` -[data-diffs-header], -[data-diff], -[data-file], -[data-error-wrapper], -[data-virtualizer-buffer] { - --diffs-header-font-family: var(--font-sans) !important; - --diffs-font-family: var(--font-mono) !important; - --diffs-bg: color-mix(in srgb, var(--card) 90%, var(--background)) !important; - --diffs-light-bg: color-mix(in srgb, var(--card) 90%, var(--background)) !important; - --diffs-dark-bg: color-mix(in srgb, var(--card) 90%, var(--background)) !important; - --diffs-token-light-bg: transparent; - --diffs-token-dark-bg: transparent; - - --diffs-bg-context-override: color-mix(in srgb, var(--background) 97%, var(--foreground)); - --diffs-bg-hover-override: color-mix(in srgb, var(--background) 94%, var(--foreground)); - --diffs-bg-separator-override: color-mix(in srgb, var(--background) 95%, var(--foreground)); - --diffs-bg-buffer-override: color-mix(in srgb, var(--background) 90%, var(--foreground)); - - --diffs-bg-addition-override: color-mix(in srgb, var(--background) 92%, var(--success)); - --diffs-bg-addition-number-override: color-mix(in srgb, var(--background) 88%, var(--success)); - --diffs-bg-addition-hover-override: color-mix(in srgb, var(--background) 85%, var(--success)); - --diffs-bg-addition-emphasis-override: color-mix(in srgb, var(--background) 80%, var(--success)); - - --diffs-bg-deletion-override: color-mix(in srgb, var(--background) 92%, var(--destructive)); - --diffs-bg-deletion-number-override: color-mix(in srgb, var(--background) 88%, var(--destructive)); - --diffs-bg-deletion-hover-override: color-mix(in srgb, var(--background) 85%, var(--destructive)); - --diffs-bg-deletion-emphasis-override: color-mix( - in srgb, - var(--background) 80%, - var(--destructive) - ); - - background-color: var(--diffs-bg) !important; -} - -[data-file-info] { - background-color: color-mix(in srgb, var(--card) 94%, var(--foreground)) !important; - border-block-color: var(--border) !important; - color: var(--foreground) !important; -} - -[data-diffs-header] { - position: sticky !important; - top: 0; - z-index: 4; - background-color: color-mix(in srgb, var(--card) 94%, var(--foreground)) !important; - border-bottom: 1px solid var(--border) !important; - align-items: center !important; - font-family: var(--font-sans) !important; - font-size: 12px !important; - line-height: 1 !important; - min-height: 32px !important; - padding-block: 6px !important; -} - -[data-diffs-header] [data-header-content] { - align-items: center !important; - line-height: 1 !important; -} - -[data-diffs-header] [data-metadata] { - align-items: center !important; - line-height: 1 !important; - font-variant-numeric: tabular-nums; -} - -[data-diffs-header] [data-additions-count], -[data-diffs-header] [data-deletions-count] { - font-family: var(--font-mono) !important; - font-size: 11px !important; - font-variant-numeric: tabular-nums; - line-height: 1 !important; -} - -[data-diffs-header] [data-change-icon], -[data-diffs-header] [data-rename-icon] { - display: block; - flex-shrink: 0; -} - -[data-title] { - cursor: pointer; - transition: - color 120ms ease, - text-decoration-color 120ms ease; - text-decoration: underline; - text-decoration-color: transparent; - text-underline-offset: 2px; - font-family: var(--font-sans) !important; -} - -[data-title]:hover { - color: color-mix(in srgb, var(--foreground) 84%, var(--primary)) !important; - text-decoration-color: currentColor; -} -`; - interface DiffPanelProps { mode?: DiffPanelMode; composerDraftTarget: ScopedThreadRef | DraftId; @@ -197,7 +102,8 @@ export default function DiffPanel({ const { resolvedTheme } = useTheme(); const settings = useClientSettings(); const [initialGitScope] = useState(initialGitScopeProp); - const [diffRenderMode, setDiffRenderMode] = useState("stacked"); + const diffRenderMode = useDiffPanelStore((state) => state.diffRenderMode); + const setDiffRenderMode = useDiffPanelStore((state) => state.setDiffRenderMode); const [wordWrap, setWordWrap] = useState(settings.wordWrap); const [diffIgnoreWhitespace, setDiffIgnoreWhitespace] = useState(settings.diffIgnoreWhitespace); const [baseRefQuery, setBaseRefQuery] = useState(""); @@ -205,7 +111,12 @@ export default function DiffPanel({ scopeKey: null, fileKeys: EMPTY_COLLAPSED_DIFF_FILE_KEYS, })); + const [codeViewRevision, setCodeViewRevision] = useState(0); const codeViewRef = useRef(null); + const lastCompletedTurnRefreshRef = useRef<{ + readonly threadKey: string | null; + readonly turnId: TurnId | null; + } | null>(null); const routeThreadRef = useParams({ strict: false, @@ -230,6 +141,7 @@ export default function DiffPanel({ activeThread?.environmentId ?? null, serverConfig?.availableEditors ?? [], ); + const getDiffFileContents = useAtomCommand(reviewEnvironment.diffFileContents); const gitStatusQuery = useEnvironmentQuery( activeThread !== null && activeThread !== undefined && activeCwd != null ? vcsEnvironment.status({ @@ -298,6 +210,7 @@ export default function DiffPanel({ const collapseScopeKey = routeThreadRef ? `${routeThreadRef.environmentId}:${routeThreadRef.threadId}:${reviewSectionId}` : null; + const codeViewMountKey = `${collapseScopeKey ?? reviewSectionId}:${codeViewRevision}`; const collapsedDiffFileKeys = collapsedDiffFiles.scopeKey === collapseScopeKey ? collapsedDiffFiles.fileKeys @@ -360,9 +273,62 @@ export default function DiffPanel({ const branchDiffPreview = shouldRetryBranchDiffAtEnvironmentCwd ? fallbackBranchDiffPreview : primaryBranchDiffPreview; + const refreshBranchDiffPreview = branchDiffPreview.refresh; + const canRefreshGitDiff = + isGitRepo && selectedTurnId === null && activeThread != null && activeCwd != null; + const activeThreadRefreshKey = routeThreadRef + ? `${routeThreadRef.environmentId}:${routeThreadRef.threadId}` + : null; + + useEffect(() => { + if (!canRefreshGitDiff) return; + const refreshOnFocus = () => refreshBranchDiffPreview(); + window.addEventListener("focus", refreshOnFocus); + return () => window.removeEventListener("focus", refreshOnFocus); + }, [canRefreshGitDiff, refreshBranchDiffPreview]); + + useEffect(() => { + const current = { + threadKey: activeThreadRefreshKey, + turnId: latestTurn?.turnId ?? null, + }; + const previous = lastCompletedTurnRefreshRef.current; + if (!canRefreshGitDiff) { + return; + } + if (previous === null || previous.threadKey !== current.threadKey) { + lastCompletedTurnRefreshRef.current = current; + return; + } + if (previous.turnId === current.turnId) return; + refreshBranchDiffPreview(); + lastCompletedTurnRefreshRef.current = current; + }, [activeThreadRefreshKey, canRefreshGitDiff, latestTurn?.turnId, refreshBranchDiffPreview]); + const selectedGitSource = branchDiffPreview.data?.sources.find( (source) => source.kind === (selectedGitScope === "unstaged" ? "working-tree" : "branch-range"), ); + const loadDiffFiles = useMemo(() => { + const preview = branchDiffPreview.data; + if (selectedTurnId !== null || !activeThread || !preview || !selectedGitSource) { + return undefined; + } + + return createGitDiffFileContentsLoader(getDiffFileContents, { + environmentId: activeThread.environmentId, + cwd: preview.cwd, + sourceKind: selectedGitSource.kind, + baseRef: selectedGitSource.baseRef, + headRef: selectedGitSource.headRef, + cacheKey: selectedGitSource.diffHash, + }); + }, [ + activeThread, + branchDiffPreview.data, + getDiffFileContents, + selectedGitSource, + selectedTurnId, + ]); const localBranchRefs = useEnvironmentQuery( selectedTurnId === null && selectedGitScope === "branch" && @@ -439,10 +405,17 @@ export default function DiffPanel({ }), ); }, [renderablePatch]); + const renderableFileEntries = useMemo( + () => + renderableFiles.map((fileDiff) => ({ + fileDiff, + fileKey: buildFileDiffRenderKey(fileDiff), + })), + [renderableFiles], + ); const codeViewFiles = useMemo( () => - renderableFiles.map((fileDiff) => { - const fileKey = buildFileDiffRenderKey(fileDiff); + renderableFileEntries.map(({ fileDiff, fileKey }) => { return { fileDiff, filePath: resolveFileDiffPath(fileDiff), @@ -450,18 +423,19 @@ export default function DiffPanel({ collapsed: collapsedDiffFileKeys.has(fileKey), }; }), - [collapsedDiffFileKeys, renderableFiles], + [collapsedDiffFileKeys, renderableFileEntries], ); const diffFileKeys = useMemo(() => codeViewFiles.map((file) => file.fileKey), [codeViewFiles]); const allDiffFilesCollapsed = areAllDiffFilesCollapsed(diffFileKeys, collapsedDiffFileKeys); const diffLineStat = useMemo(() => getDiffLineStat(renderableFiles), [renderableFiles]); + const selectedDiffFileKey = selectedFilePath + ? (codeViewFiles.find((candidate) => candidate.filePath === selectedFilePath)?.fileKey ?? null) + : null; useEffect(() => { - if (!selectedFilePath) return; - const file = codeViewFiles.find((candidate) => candidate.filePath === selectedFilePath); - if (!file) return; - codeViewRef.current?.scrollTo({ type: "item", id: file.fileKey, align: "start" }); - }, [codeViewFiles, selectedFilePath, selectedFileRevealRequestId]); + if (!selectedDiffFileKey) return; + codeViewRef.current?.scrollTo({ type: "item", id: selectedDiffFileKey, align: "start" }); + }, [codeViewMountKey, selectedDiffFileKey, selectedFileRevealRequestId]); const openDiffFile = useCallback( (filePath: string) => { @@ -506,6 +480,7 @@ export default function DiffPanel({ ); const toggleDiffFileCollapse = useCallback(() => { + setCodeViewRevision((current) => current + 1); setCollapsedDiffFiles((current) => { const currentKeys = current.scopeKey === collapseScopeKey ? current.fileKeys : EMPTY_COLLAPSED_DIFF_FILE_KEYS; @@ -535,11 +510,11 @@ export default function DiffPanel({
{selectedScopeLabel} - + )} + {canRefreshGitDiff && ( + + + } + > + + + + {branchDiffPreview.isPending ? "Refreshing diff…" : "Refresh diff"} + + + )} {codeViewFiles.length > 0 && ( } > {allDiffFilesCollapsed ? ( - + ) : ( - + )} @@ -749,9 +746,8 @@ export default function DiffPanel({ )} { const next = value[0]; @@ -760,11 +756,11 @@ export default function DiffPanel({ } }} > - - + + - - + + @@ -772,8 +768,8 @@ export default function DiffPanel({ render={ { setWordWrap(Boolean(pressed)); @@ -781,7 +777,7 @@ export default function DiffPanel({ /> } > - + {wordWrap ? "Disable line wrapping" : "Enable line wrapping"} @@ -794,8 +790,8 @@ export default function DiffPanel({ aria-label={ diffIgnoreWhitespace ? "Show whitespace changes" : "Hide whitespace changes" } - variant="outline" - size="xs" + variant="ghost" + size="sm" pressed={diffIgnoreWhitespace} onPressedChange={(pressed) => { setDiffIgnoreWhitespace(Boolean(pressed)); @@ -803,7 +799,7 @@ export default function DiffPanel({ /> } > - + {diffIgnoreWhitespace ? "Show whitespace changes" : "Hide whitespace changes"} @@ -838,7 +834,7 @@ export default function DiffPanel({ )} {selectedPatchError && !renderablePatch && (
-

{selectedPatchError}

+

{selectedPatchError}

)} {!renderablePatch ? ( @@ -866,18 +862,41 @@ export default function DiffPanel({ className="min-h-0 flex-1" onClickCapture={(event) => { const composedPath = event.nativeEvent.composedPath?.() ?? []; + for (const node of composedPath) { + if (!(node instanceof HTMLElement)) continue; + // Header controls keep their own actions. In particular, the chevron must + // not also trigger the row handler or the two toggles cancel each other. + if (node instanceof HTMLButtonElement || node instanceof HTMLAnchorElement) { + return; + } + } const title = composedPath.find( (node): node is HTMLElement => node instanceof HTMLElement && node.hasAttribute("data-title"), ); const filePath = title?.textContent?.trim(); - if (filePath) openDiffFile(filePath); + // The filename remains the explicit "open in editor" affordance. + if (filePath) { + openDiffFile(filePath); + return; + } + const header = composedPath.find( + (node): node is HTMLElement => + node instanceof HTMLElement && node.hasAttribute("data-diffs-header"), + ); + const headerFilePath = header?.querySelector("[data-title]")?.textContent?.trim(); + if (!headerFilePath) return; + const file = codeViewFiles.find( + (candidate) => candidate.filePath === headerFilePath, + ); + if (file) toggleDiffFileCollapsed(file.fileKey); }} >
diff --git a/apps/web/src/components/DiffPanelShell.tsx b/apps/web/src/components/DiffPanelShell.tsx index e727a80055d3..c13af4d9560c 100644 --- a/apps/web/src/components/DiffPanelShell.tsx +++ b/apps/web/src/components/DiffPanelShell.tsx @@ -10,7 +10,8 @@ export type DiffPanelMode = "inline" | "sheet" | "sidebar" | "embedded"; function getDiffPanelHeaderRowClassName(mode: DiffPanelMode) { const shouldUseDragRegion = isElectron && mode !== "sheet" && mode !== "embedded"; return cn( - "flex items-center justify-between gap-2 px-4", + "flex items-center justify-between gap-2", + mode === "embedded" ? "px-2" : "px-4", shouldUseDragRegion ? "drag-region h-[52px] border-b border-border wco:h-[env(titlebar-area-height)] wco:pr-[calc(100vw-env(titlebar-area-width)-env(titlebar-area-x)+1em)]" : "surface-subheader", @@ -59,30 +60,53 @@ export function DiffPanelHeaderSkeleton() { ); } +function DiffFileHeaderSkeleton({ titleClassName }: { titleClassName: string }) { + return ( +
+
+ +
+ + +
+ + +
+
+ ); +} + +function DiffCodeLineSkeleton({ contentClassName }: { contentClassName: string }) { + return ( +
+ + +
+ ); +} + export function DiffPanelLoadingState(props: { label: string }) { return ( -
-
-
- - -
-
-
- - - - - -
- {props.label} -
+
+ +
+
+ +
+
+
+ + +
+ + + {props.label}
); } diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 4f2bd19952d2..7b824370b39d 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -22,6 +22,7 @@ import { flushSync } from "react-dom"; import { CheckIcon, ChevronDownIcon, + CloudDownloadIcon, CloudUploadIcon, ExternalLinkIcon, GitBranchPlusIcon, @@ -95,6 +96,11 @@ interface GitActionsControlProps { gitCwd: string | null; activeThreadRef: ScopedThreadRef | null; draftId?: DraftId; + /** + * Opens the thread's own change request beside it. Absent when the thread has no project to + * place it against, in which case it still opens in the browser. + */ + onOpenPullRequest?: ((number: number) => void) | undefined; } interface PendingDefaultBranchAction { @@ -352,7 +358,7 @@ function GitQuickActionIcon({ const iconClassName = "size-3.5"; if (quickAction.kind === "open_pr") return ; if (quickAction.kind === "open_publish") return ; - if (quickAction.kind === "run_pull") return ; + if (quickAction.kind === "run_pull") return ; if (quickAction.kind === "run_action") { if (quickAction.action === "commit") return ; if (quickAction.action === "push" || quickAction.action === "commit_push") { @@ -361,6 +367,7 @@ function GitQuickActionIcon({ return ; } if (quickAction.label === "Commit") return ; + if (quickAction.label === "Push") return ; return ; } @@ -971,6 +978,7 @@ export default function GitActionsControl({ gitCwd, activeThreadRef, draftId, + onOpenPullRequest, }: GitActionsControlProps) { const updateThreadMetadata = useAtomCommand( threadEnvironment.updateMetadata, @@ -1213,6 +1221,13 @@ export default function GitActionsControl({ }, [activeEnvironmentId, gitCwd, refreshVcsStatus]); const openExistingPr = useCallback(async () => { + const openPr = gitStatusForActions?.pr?.state === "open" ? gitStatusForActions.pr : null; + // Beside the thread where it was made, the way the browser opens beside it. Checked before + // the shell, which opening in the app does not need. + if (openPr && onOpenPullRequest) { + onOpenPullRequest(openPr.number); + return; + } const api = readLocalApi(); if (!api) { toastManager.add({ @@ -1222,7 +1237,7 @@ export default function GitActionsControl({ }); return; } - const prUrl = gitStatusForActions?.pr?.state === "open" ? gitStatusForActions.pr.url : null; + const prUrl = openPr?.url ?? null; if (!prUrl) { toastManager.add({ type: "error", @@ -1242,7 +1257,7 @@ export default function GitActionsControl({ }), ); }); - }, [gitStatusForActions, threadToastData]); + }, [gitStatusForActions, onOpenPullRequest, threadToastData]); runGitActionWithToast = useEffectEvent( async ({ @@ -1693,7 +1708,7 @@ export default function GitActionsControl({ render={ + ) : !isThreadRunning ? ( + appSettingsConfirmThreadArchive ? ( +
+ +
+ ) : ( + + + +
+ } + /> + Archive + + ) + ) : null} + + + {isRemoteThread && !isDesktopLocalThread && ( + + + } + > + + + {threadEnvironmentLabel} + + )} + {jumpLabel ? ( + + + } + > + {jumpLabel} + + {jumpLabel} + + ) : ( + + {formatRelativeTimeLabel( + thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt, + )} + + )} + + +
+
+ + + ); +}); + +interface SidebarProjectThreadListProps { + projectKey: string; + projectExpanded: boolean; + hasOverflowingThreads: boolean; + hiddenThreadStatus: ThreadStatusPill | null; + orderedProjectThreadKeys: readonly string[]; + renderedThreads: readonly SidebarThreadSummary[]; + showEmptyThreadState: boolean; + shouldShowThreadPanel: boolean; + isThreadListExpanded: boolean; + projectCwd: string; + activeRouteThreadKey: string | null; + openPullRequestsInRightPanel: boolean; + threadJumpLabelByKey: ReadonlyMap; + appSettingsConfirmThreadArchive: boolean; + renamingThreadKey: string | null; + renamingTitle: string; + setRenamingTitle: (title: string) => void; + startThreadRename: (threadKey: string, title: string) => void; + renamingInputRef: React.RefObject; + renamingCommittedRef: React.RefObject; + confirmingArchiveThreadKey: string | null; + setConfirmingArchiveThreadKey: React.Dispatch>; + confirmArchiveButtonRefs: React.RefObject>; + attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void; + handleThreadClick: ( + event: React.MouseEvent, + threadRef: ScopedThreadRef, + orderedProjectThreadKeys: readonly string[], + ) => void; + navigateToThread: (threadRef: ScopedThreadRef) => void; + handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise; + handleThreadContextMenu: ( + threadRef: ScopedThreadRef, + position: { x: number; y: number }, + ) => Promise; + clearSelection: () => void; + commitRename: ( + threadRef: ScopedThreadRef, + newTitle: string, + originalTitle: string, + ) => Promise; + cancelRename: () => void; + attemptArchiveThread: (threadRef: ScopedThreadRef) => Promise; + openPrLink: ( + event: React.MouseEvent, + prUrl: string, + threadRef?: ScopedThreadRef, + ) => boolean; + expandThreadListForProject: (projectKey: string) => void; + collapseThreadListForProject: (projectKey: string) => void; +} + +const SidebarProjectThreadList = memo(function SidebarProjectThreadList( + props: SidebarProjectThreadListProps, +) { + const { + projectKey, + projectExpanded, + hasOverflowingThreads, + hiddenThreadStatus, + orderedProjectThreadKeys, + renderedThreads, + showEmptyThreadState, + shouldShowThreadPanel, + isThreadListExpanded, + projectCwd, + activeRouteThreadKey, + openPullRequestsInRightPanel, + threadJumpLabelByKey, + appSettingsConfirmThreadArchive, + renamingThreadKey, + renamingTitle, + setRenamingTitle, + startThreadRename, + renamingInputRef, + renamingCommittedRef, + confirmingArchiveThreadKey, + setConfirmingArchiveThreadKey, + confirmArchiveButtonRefs, + attachThreadListAutoAnimateRef, + handleThreadClick, + navigateToThread, + handleMultiSelectContextMenu, + handleThreadContextMenu, + clearSelection, + commitRename, + cancelRename, + attemptArchiveThread, + openPrLink, + expandThreadListForProject, + collapseThreadListForProject, + } = props; + const showMoreButtonRender = useMemo(() => +
+ } + /> + + {newThreadShortcutLabel ? `New thread (${newThreadShortcutLabel})` : "New thread"} + + +
+ + + + { + if (!open) { + closeProjectRenameDialog(); + } + }} + > + + + Rename project + + {projectRenameTarget + ? `Update the title for ${projectRenameTarget.workspaceRoot}.` + : "Update the project title."} + + + +
+ Project title + setProjectRenameTitle(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + void submitProjectRename(); + } + }} + /> +
+ {projectRenameTarget?.environmentLabel ? ( +

+ Environment: {projectRenameTarget.environmentLabel} +

+ ) : null} +
+ + + + +
+
+ + { + if (!open) { + closeProjectGroupingDialog(); + } + }} + > + + + Project grouping + + {projectGroupingTarget + ? `Choose how ${projectGroupingTarget.workspaceRoot} should be grouped in the sidebar.` + : "Choose how this project should be grouped in the sidebar."} + + + +
+ Grouping rule + +
+

+ {projectGroupingSelection === "inherit" + ? projectGroupingModeDescription(projectGroupingSettings.sidebarProjectGroupingMode) + : projectGroupingModeDescription(projectGroupingSelection)} +

+
+ + + + +
+
+ + ); +}); + +const SidebarProjectListRow = memo(function SidebarProjectListRow(props: SidebarProjectItemProps) { + return ( + + + + ); +}); + +function LocalSecondaryStatus() { + const { environments } = useEnvironments(); + // The desktop reports which local secondary backends (e.g. the WSL backend) + // exist; the hook polls because the bridge has no change event. A backend that + // is still cold-booting has no httpBaseUrl yet and isn't in the catalog, so we + // surface "Connecting" straight from the bootstrap list and clear it once the + // matching environment reports a connected phase. + const secondaries = useDesktopLocalBootstraps(); + + // Connected desktop-local environments keyed by their backend URL so we can + // match a bootstrap (which only knows the URL) to its connection phase. + const localEnvByUrl = useMemo(() => { + const map = new Map(); + for (const environment of environments) { + if ( + isDesktopLocalConnectionTarget(environment.entry.target) && + environment.displayUrl !== null + ) { + map.set(environment.displayUrl, { + phase: environment.connection.phase, + error: environment.connection.error, + }); + } + } + return map; + }, [environments]); + + const connecting: string[] = []; + const failed: Array<{ label: string; error: string | null }> = []; + for (const bootstrap of secondaries) { + const env = + bootstrap.httpBaseUrl !== null ? localEnvByUrl.get(bootstrap.httpBaseUrl) : undefined; + if (env?.phase === "connected") { + continue; + } + if (env?.phase === "error") { + failed.push({ label: bootstrap.label, error: env.error }); + continue; + } + connecting.push(bootstrap.label); + } + + if (connecting.length === 0 && failed.length === 0) { + return null; + } + + return ( + + {connecting.length > 0 ? ( + + + + Connecting {connecting.join(", ")} + + + ) : null} + {failed.length > 0 ? ( + + + Couldn't connect {failed.map((entry) => entry.label).join(", ")} + + {failed + .map((entry) => entry.error) + .filter(Boolean) + .join("; ") || "The backend didn't respond."} + + + ) : null} + + ); +} + +type SortableProjectHandleProps = Pick< + ReturnType, + "attributes" | "listeners" | "setActivatorNodeRef" +>; + +function ProjectSortMenu({ + projectSortOrder, + threadSortOrder, + threadPreviewCount, + onProjectSortOrderChange, + onThreadSortOrderChange, + onThreadPreviewCountChange, +}: { + projectSortOrder: SidebarProjectSortOrder; + threadSortOrder: SidebarThreadSortOrder; + threadPreviewCount: SidebarThreadPreviewCount; + onProjectSortOrderChange: (sortOrder: SidebarProjectSortOrder) => void; + onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; + onThreadPreviewCountChange: (count: SidebarThreadPreviewCount) => void; +}) { + const handleThreadPreviewCountChange = useCallback( + (nextValue: number | null) => { + if (nextValue === null) { + return; + } + + const clampedValue = clampSidebarThreadPreviewCount(nextValue); + if (clampedValue !== threadPreviewCount) { + onThreadPreviewCountChange(clampedValue); + } + }, + [onThreadPreviewCountChange, threadPreviewCount], + ); + + return ( + + + + } + > + + + Sidebar options + + + +
+ Sort projects +
+ { + onProjectSortOrderChange(value as SidebarProjectSortOrder); + }} + > + {(Object.entries(SIDEBAR_SORT_LABELS) as Array<[SidebarProjectSortOrder, string]>).map( + ([value, label]) => ( + + {label} + + ), + )} + +
+ +
+ Sort threads +
+ { + onThreadSortOrderChange(value as SidebarThreadSortOrder); + }} + > + {( + Object.entries(SIDEBAR_THREAD_SORT_LABELS) as Array<[SidebarThreadSortOrder, string]> + ).map(([value, label]) => ( + + {label} + + ))} + +
+ +
+ Visible threads +
+
+ + + + { + event.stopPropagation(); + }} + /> + + + +
+
+
+
+ ); +} + +function SortableProjectItem({ + projectId, + disabled = false, + children, +}: { + projectId: string; + disabled?: boolean; + children: (handleProps: SortableProjectHandleProps) => React.ReactNode; +}) { + const { + attributes, + listeners, + setActivatorNodeRef, + setNodeRef, + transform, + transition, + isDragging, + isOver, + } = useSortable({ id: projectId, disabled }); + return ( +
  • + {children({ attributes, listeners, setActivatorNodeRef })} +
  • + ); +} + +interface SidebarProjectsContentProps { + showArm64IntelBuildWarning: boolean; + arm64IntelBuildWarningDescription: string | null; + desktopUpdateButtonAction: "download" | "install" | "none"; + desktopUpdateButtonDisabled: boolean; + desktopUpdateActionPending: boolean; + handleDesktopUpdateButtonClick: () => void; + projectSortOrder: SidebarProjectSortOrder; + threadSortOrder: SidebarThreadSortOrder; + threadPreviewCount: SidebarThreadPreviewCount; + updateSettings: ReturnType; + openAddProject: () => void; + isManualProjectSorting: boolean; + projectDnDSensors: ReturnType; + projectCollisionDetection: CollisionDetection; + handleProjectDragStart: (event: DragStartEvent) => void; + handleProjectDragEnd: (event: DragEndEvent) => void; + handleProjectDragCancel: (event: DragCancelEvent) => void; + handleNewThread: ReturnType; + archiveThread: ReturnType["archiveThread"]; + deleteThread: ReturnType["deleteThread"]; + sortedProjects: readonly SidebarProjectSnapshot[]; + expandedThreadListsByProject: ReadonlySet; + activeRouteProjectKey: string | null; + routeThreadKey: string | null; + openPullRequestsInRightPanel: boolean; + newThreadShortcutLabel: string | null; + commandPaletteShortcutLabel: string | null; + threadJumpLabelByKey: ReadonlyMap; + attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void; + expandThreadListForProject: (projectKey: string) => void; + collapseThreadListForProject: (projectKey: string) => void; + dragInProgressRef: React.RefObject; + suppressProjectClickAfterDragRef: React.RefObject; + suppressProjectClickForContextMenuRef: React.RefObject; + attachProjectListAutoAnimateRef: (node: HTMLElement | null) => void; + projectsLength: number; +} + +const SidebarProjectsContent = memo(function SidebarProjectsContent( + props: SidebarProjectsContentProps, +) { + const { + showArm64IntelBuildWarning, + arm64IntelBuildWarningDescription, + desktopUpdateButtonAction, + desktopUpdateButtonDisabled, + desktopUpdateActionPending, + handleDesktopUpdateButtonClick, + projectSortOrder, + threadSortOrder, + threadPreviewCount, + updateSettings, + openAddProject, + isManualProjectSorting, + projectDnDSensors, + projectCollisionDetection, + handleProjectDragStart, + handleProjectDragEnd, + handleProjectDragCancel, + handleNewThread, + archiveThread, + deleteThread, + sortedProjects, + expandedThreadListsByProject, + activeRouteProjectKey, + routeThreadKey, + openPullRequestsInRightPanel, + newThreadShortcutLabel, + commandPaletteShortcutLabel, + threadJumpLabelByKey, + attachThreadListAutoAnimateRef, + expandThreadListForProject, + collapseThreadListForProject, + dragInProgressRef, + suppressProjectClickAfterDragRef, + suppressProjectClickForContextMenuRef, + attachProjectListAutoAnimateRef, + projectsLength, + } = props; + + const handleProjectSortOrderChange = useCallback( + (sortOrder: SidebarProjectSortOrder) => { + updateSettings({ sidebarProjectSortOrder: sortOrder }); + }, + [updateSettings], + ); + const handleThreadSortOrderChange = useCallback( + (sortOrder: SidebarThreadSortOrder) => { + updateSettings({ sidebarThreadSortOrder: sortOrder }); + }, + [updateSettings], + ); + const handleThreadPreviewCountChange = useCallback( + (count: SidebarThreadPreviewCount) => { + updateSettings({ sidebarThreadPreviewCount: count }); + }, + [updateSettings], + ); + + return ( + + + + + } + > + + Search + {commandPaletteShortcutLabel ? ( + + {commandPaletteShortcutLabel} + + ) : null} + + + + + } + > + {showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? ( + + + + Intel build on Apple Silicon + {arm64IntelBuildWarningDescription} + {desktopUpdateButtonAction !== "none" ? ( + + + + ) : null} + + + ) : null} + + +
    + Projects +
    + + + + } + > + + + Add project + +
    +
    + + {isManualProjectSorting ? ( + + + project.projectKey)} + strategy={verticalListSortingStrategy} + > + {sortedProjects.map((project) => ( + + {(dragHandleProps) => ( + + )} + + ))} + + + + ) : ( + + {sortedProjects.map((project) => ( + + ))} + + )} + + {projectsLength === 0 && ( +
    No projects yet
    + )} +
    +
    + ); +}); + +export default function LegacySidebar() { + const projects = useProjects(); + const sidebarThreads = useThreadShells(); + const projectExpandedById = useUiStateStore((store) => store.projectExpandedById); + const projectOrder = useUiStateStore((store) => store.projectOrder); + const reorderProjects = useUiStateStore((store) => store.reorderProjects); + const navigate = useNavigate(); + const sidebarThreadSortOrder = useClientSettings((s) => s.sidebarThreadSortOrder); + const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); + const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); + const sidebarThreadPreviewCount = useClientSettings((s) => s.sidebarThreadPreviewCount); + const updateSettings = useUpdateClientSettings(); + const handleNewThread = useNewThreadHandler(); + const { archiveThread, deleteThread } = useThreadActions(); + const { isMobile, setOpenMobile } = useSidebar(); + const routeTarget = useParams({ + strict: false, + select: (params) => resolveThreadRouteTarget(params), + }); + const routeDraftThread = useComposerDraftStore((store) => + routeTarget?.kind === "draft" ? store.getDraftSession(routeTarget.draftId) : null, + ); + const routeThreadRef = useMemo( + () => resolveActiveThreadRouteRef(routeTarget, routeDraftThread), + [routeDraftThread, routeTarget], + ); + const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; + const routeTerminalOpen = useTerminalUiStateStore((state) => + routeThreadRef + ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen + : false, + ); + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const openAddProjectCommandPalette = useCallback( + () => openCommandPalette({ open: "add-project" }), + [], + ); + const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< + ReadonlySet + >(() => new Set()); + const { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility(); + const dragInProgressRef = useRef(false); + const suppressProjectClickAfterDragRef = useRef(false); + const suppressProjectClickForContextMenuRef = useRef(false); + const desktopUpdateState = useDesktopUpdateState(); + const [desktopUpdateActionPending, setDesktopUpdateActionPending] = useState(false); + const clearSelection = useThreadSelectionStore((s) => s.clearSelection); + const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); + const platform = navigator.platform; + const shortcutModifiers = useShortcutModifierState(); + const { environments } = useEnvironments(); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const environmentLabelById = useMemo( + () => + new Map( + environments.map((environment) => [environment.environmentId, environment.label] as const), + ), + [environments], + ); + const desktopLocalEnvironmentIds = useMemo( + () => + new Set( + environments + .filter((environment) => isDesktopLocalConnectionTarget(environment.entry.target)) + .map((environment) => environment.environmentId), + ), + [environments], + ); + const orderedProjects = useMemo(() => { + return orderItemsByPreferredIds({ + items: projects, + preferredIds: projectOrder, + getId: getProjectOrderKey, + getPreferenceIds: (project) => [ + getProjectOrderKey(project), + legacyProjectCwdPreferenceKey(project.workspaceRoot), + ], + }); + }, [projectOrder, projects]); + + // Build a mapping from physical project key → logical project key for + // cross-environment grouping. Projects that share a repositoryIdentity + // canonicalKey are treated as one logical project in the sidebar. + const physicalToLogicalKey = useMemo(() => { + return buildPhysicalToLogicalProjectKeyMap({ + projects: orderedProjects, + settings: projectGroupingSettings, + primaryEnvironmentId, + }); + }, [orderedProjects, projectGroupingSettings, primaryEnvironmentId]); + const projectPhysicalKeyByScopedRef = useMemo( + () => + new Map( + orderedProjects.map((project) => [ + scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), + derivePhysicalProjectKey(project), + ]), + ), + [orderedProjects], + ); + + const sidebarProjects = useMemo(() => { + return buildSidebarProjectSnapshots({ + projects: orderedProjects, + settings: projectGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, + isDesktopLocalEnvironment: (environmentId) => desktopLocalEnvironmentIds.has(environmentId), + }); + }, [ + environmentLabelById, + desktopLocalEnvironmentIds, + orderedProjects, + projectGroupingSettings, + primaryEnvironmentId, + ]); + + const sidebarProjectByKey = useMemo( + () => new Map(sidebarProjects.map((project) => [project.projectKey, project] as const)), + [sidebarProjects], + ); + const sidebarThreadByKey = useMemo( + () => + new Map( + sidebarThreads.map( + (thread) => + [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const, + ), + ), + [sidebarThreads], + ); + // Resolve the active route's project key to a logical key so it matches the + // sidebar's grouped project entries. + const activeRouteProjectKey = useMemo(() => { + if (!routeThreadKey) { + return null; + } + const activeThread = sidebarThreadByKey.get(routeThreadKey); + if (!activeThread) return null; + const physicalKey = + projectPhysicalKeyByScopedRef.get( + scopedProjectKey(scopeProjectRef(activeThread.environmentId, activeThread.projectId)), + ) ?? scopedProjectKey(scopeProjectRef(activeThread.environmentId, activeThread.projectId)); + return physicalToLogicalKey.get(physicalKey) ?? physicalKey; + }, [routeThreadKey, sidebarThreadByKey, physicalToLogicalKey, projectPhysicalKeyByScopedRef]); + + // Group threads by logical project key so all threads from grouped projects + // are displayed together. + const threadsByProjectKey = useMemo(() => { + const next = new Map(); + for (const thread of sidebarThreads) { + const physicalKey = + projectPhysicalKeyByScopedRef.get( + scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), + ) ?? scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); + const logicalKey = physicalToLogicalKey.get(physicalKey) ?? physicalKey; + const existing = next.get(logicalKey); + if (existing) { + existing.push(thread); + } else { + next.set(logicalKey, [thread]); + } + } + return next; + }, [sidebarThreads, physicalToLogicalKey, projectPhysicalKeyByScopedRef]); + const getCurrentSidebarShortcutContext = useCallback( + () => ({ + terminalFocus: isTerminalFocused(), + terminalOpen: routeTerminalOpen, + modelPickerOpen: isModelPickerOpen(), + }), + [routeTerminalOpen], + ); + const newThreadShortcutLabelOptions = useMemo( + () => ({ + platform, + context: { + terminalFocus: false, + terminalOpen: false, + }, + }), + [platform], + ); + const newThreadShortcutLabel = + shortcutLabelForCommand(keybindings, "chat.newLocal", newThreadShortcutLabelOptions) ?? + shortcutLabelForCommand(keybindings, "chat.new", newThreadShortcutLabelOptions); + + const navigateToThread = useCallback( + (threadRef: ScopedThreadRef) => { + if (useThreadSelectionStore.getState().selectedThreadKeys.size > 0) { + clearSelection(); + } + setSelectionAnchor(scopedThreadKey(threadRef)); + if (isMobile) { + setOpenMobile(false); + } + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(threadRef), + }); + }, + [clearSelection, isMobile, navigate, setOpenMobile, setSelectionAnchor], + ); + + const projectDnDSensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 6 }, + }), + ); + const projectCollisionDetection = useCallback((args) => { + const pointerCollisions = pointerWithin(args); + if (pointerCollisions.length > 0) { + return pointerCollisions; + } + + return closestCorners(args); + }, []); + + const handleProjectDragEnd = useCallback( + (event: DragEndEvent) => { + if (sidebarProjectSortOrder !== "manual") { + dragInProgressRef.current = false; + return; + } + dragInProgressRef.current = false; + const { active, over } = event; + if (!over || active.id === over.id) return; + const activeProject = sidebarProjects.find((project) => project.projectKey === active.id); + const overProject = sidebarProjects.find((project) => project.projectKey === over.id); + if (!activeProject || !overProject) return; + const activeMemberKeys = activeProject.memberProjects.map( + (member) => member.physicalProjectKey, + ); + const overMemberKeys = overProject.memberProjects.map((member) => member.physicalProjectKey); + reorderProjects(orderedProjects.map(getProjectOrderKey), activeMemberKeys, overMemberKeys); + }, + [orderedProjects, sidebarProjectSortOrder, reorderProjects, sidebarProjects], + ); + + const handleProjectDragStart = useCallback( + (_event: DragStartEvent) => { + if (sidebarProjectSortOrder !== "manual") { + return; + } + dragInProgressRef.current = true; + suppressProjectClickAfterDragRef.current = true; + }, + [sidebarProjectSortOrder], + ); + + const handleProjectDragCancel = useCallback((_event: DragCancelEvent) => { + dragInProgressRef.current = false; + }, []); + + const animatedProjectListsRef = useRef(new WeakSet()); + const attachProjectListAutoAnimateRef = useCallback((node: HTMLElement | null) => { + if (!node || animatedProjectListsRef.current.has(node)) { + return; + } + autoAnimate(node, SIDEBAR_LIST_ANIMATION_OPTIONS); + animatedProjectListsRef.current.add(node); + }, []); + + const animatedThreadListsRef = useRef(new WeakSet()); + const attachThreadListAutoAnimateRef = useCallback((node: HTMLElement | null) => { + if (!node || animatedThreadListsRef.current.has(node)) { + return; + } + autoAnimate(node, SIDEBAR_LIST_ANIMATION_OPTIONS); + animatedThreadListsRef.current.add(node); + }, []); + + const visibleThreads = useMemo( + () => sidebarThreads.filter((thread) => thread.archivedAt === null), + [sidebarThreads], + ); + const sortedProjects = useMemo(() => { + const sortableProjects = sidebarProjects.map((project) => ({ + ...project, + id: project.projectKey, + })); + const sortableThreads = visibleThreads.map((thread) => { + const physicalKey = + projectPhysicalKeyByScopedRef.get( + scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), + ) ?? scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); + return { + ...thread, + projectId: (physicalToLogicalKey.get(physicalKey) ?? physicalKey) as ProjectId, + }; + }); + return sortProjectsForSidebar( + sortableProjects, + sortableThreads, + sidebarProjectSortOrder, + ).flatMap((project) => { + const resolvedProject = sidebarProjectByKey.get(project.id); + return resolvedProject ? [resolvedProject] : []; + }); + }, [ + sidebarProjectSortOrder, + physicalToLogicalKey, + projectPhysicalKeyByScopedRef, + sidebarProjectByKey, + sidebarProjects, + visibleThreads, + ]); + const isManualProjectSorting = sidebarProjectSortOrder === "manual"; + const visibleSidebarThreadKeys = useMemo( + () => + sortedProjects.flatMap((project) => { + const projectThreads = sortThreads( + (threadsByProjectKey.get(project.projectKey) ?? []).filter( + (thread) => thread.archivedAt === null, + ), + sidebarThreadSortOrder, + ); + const projectExpanded = resolveProjectExpanded( + projectExpandedById, + projectExpansionPreferenceKeys(project), + ); + const activeThreadKey = routeThreadKey ?? undefined; + const pinnedCollapsedThread = + !projectExpanded && activeThreadKey + ? (projectThreads.find( + (thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === + activeThreadKey, + ) ?? null) + : null; + const shouldShowThreadPanel = projectExpanded || pinnedCollapsedThread !== null; + if (!shouldShowThreadPanel) { + return []; + } + const isThreadListExpanded = expandedThreadListsByProject.has(project.projectKey); + const hasOverflowingThreads = projectThreads.length > sidebarThreadPreviewCount; + const previewThreads = + isThreadListExpanded || !hasOverflowingThreads + ? projectThreads + : projectThreads.slice(0, sidebarThreadPreviewCount); + const renderedThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : previewThreads; + return renderedThreads.map((thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ); + }), + [ + sidebarThreadSortOrder, + sidebarThreadPreviewCount, + expandedThreadListsByProject, + projectExpandedById, + routeThreadKey, + sortedProjects, + threadsByProjectKey, + ], + ); + const threadJumpCommandByKey = useMemo(() => { + const mapping = new Map>>(); + for (const [visibleThreadIndex, threadKey] of visibleSidebarThreadKeys.entries()) { + const jumpCommand = threadJumpCommandForIndex(visibleThreadIndex); + if (!jumpCommand) { + return mapping; + } + mapping.set(threadKey, jumpCommand); + } + + return mapping; + }, [visibleSidebarThreadKeys]); + const threadJumpThreadKeys = useMemo( + () => [...threadJumpCommandByKey.keys()], + [threadJumpCommandByKey], + ); + const sidebarShortcutContext = { + terminalFocus: false, + terminalOpen: routeTerminalOpen, + modelPickerOpen: isModelPickerOpen(), + }; + const threadJumpLabelByKey = useMemo( + () => + buildThreadJumpLabelMap({ + keybindings, + platform, + terminalOpen: sidebarShortcutContext.terminalOpen, + threadJumpCommandByKey, + }), + [keybindings, platform, sidebarShortcutContext.terminalOpen, threadJumpCommandByKey], + ); + const shouldShowThreadJumpHintsNow = shouldShowThreadJumpHintsForModifiers( + shortcutModifiers, + keybindings, + { + platform, + context: sidebarShortcutContext, + }, + ); + const visibleThreadJumpLabelByKey = showThreadJumpHints + ? threadJumpLabelByKey + : EMPTY_THREAD_JUMP_LABELS; + const orderedSidebarThreadKeys = visibleSidebarThreadKeys; + const prewarmedSidebarThreadKeys = useMemo( + () => getSidebarThreadIdsToPrewarm(visibleSidebarThreadKeys), + [visibleSidebarThreadKeys], + ); + const prewarmedSidebarThreadRefs = useMemo( + () => + prewarmedSidebarThreadKeys.flatMap((threadKey) => { + const ref = parseScopedThreadKey(threadKey); + return ref ? [ref] : []; + }), + [prewarmedSidebarThreadKeys], + ); + + useEffect(() => { + updateThreadJumpHintsVisibility(shouldShowThreadJumpHintsNow); + }, [shouldShowThreadJumpHintsNow, updateThreadJumpHintsVisibility]); + + useEffect(() => { + const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { + const shortcutContext = getCurrentSidebarShortcutContext(); + + if (event.defaultPrevented || event.repeat) { + return; + } + + const command = resolveShortcutCommand(event, keybindings, { + platform, + context: shortcutContext, + }); + const traversalDirection = threadTraversalDirectionFromCommand(command); + if (traversalDirection !== null) { + const targetThreadKey = resolveAdjacentThreadId({ + threadIds: orderedSidebarThreadKeys, + currentThreadId: routeThreadKey, + direction: traversalDirection, + }); + if (!targetThreadKey) { + return; + } + const targetThread = sidebarThreadByKey.get(targetThreadKey); + if (!targetThread) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); + return; + } + + const jumpIndex = threadJumpIndexFromCommand(command ?? ""); + if (jumpIndex === null) { + return; + } + + const targetThreadKey = threadJumpThreadKeys[jumpIndex]; + if (!targetThreadKey) { + return; + } + const targetThread = sidebarThreadByKey.get(targetThreadKey); + if (!targetThread) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); + }; + + window.addEventListener("keydown", onWindowKeyDown); + + return () => { + window.removeEventListener("keydown", onWindowKeyDown); + }; + }, [ + getCurrentSidebarShortcutContext, + keybindings, + navigateToThread, + orderedSidebarThreadKeys, + platform, + routeThreadKey, + sidebarThreadByKey, + threadJumpThreadKeys, + ]); + + useEffect(() => { + const onMouseDown = (event: globalThis.MouseEvent) => { + if (!useThreadSelectionStore.getState().hasSelection()) return; + const target = event.target instanceof HTMLElement ? event.target : null; + if (!shouldClearThreadSelectionOnMouseDown(target)) return; + clearSelection(); + }; + + window.addEventListener("mousedown", onMouseDown); + return () => { + window.removeEventListener("mousedown", onMouseDown); + }; + }, [clearSelection]); + + const desktopUpdateButtonDisabled = isDesktopUpdateButtonDisabled(desktopUpdateState); + const desktopUpdateButtonAction = desktopUpdateState + ? resolveDesktopUpdateButtonAction(desktopUpdateState) + : "none"; + const showArm64IntelBuildWarning = + isElectron && shouldShowArm64IntelBuildWarning(desktopUpdateState); + const arm64IntelBuildWarningDescription = + desktopUpdateState && showArm64IntelBuildWarning + ? getArm64IntelBuildWarningDescription(desktopUpdateState) + : null; + const commandPaletteShortcutLabel = shortcutLabelForCommand( + keybindings, + "commandPalette.toggle", + newThreadShortcutLabelOptions, + ); + const handleDesktopUpdateButtonClick = useCallback(async () => { + const bridge = window.desktopBridge; + if (!bridge || !desktopUpdateState) return; + if ( + desktopUpdateButtonDisabled || + desktopUpdateButtonAction === "none" || + desktopUpdateActionPending + ) { + return; + } + + setDesktopUpdateActionPending(true); + + if (desktopUpdateButtonAction === "download") { + void bridge + .downloadUpdate() + .then((result) => { + if (result.completed) { + showDesktopUpdateDownloadedToast(bridge, result.state); + } + if (!shouldToastDesktopUpdateActionResult(result)) return; + const actionError = getDesktopUpdateActionError(result); + if (!actionError) return; + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not download update", + description: actionError, + }), + ); + }) + .catch((error) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not start update download", + description: error instanceof Error ? error.message : "An unexpected error occurred.", + }), + ); + }) + .finally(() => setDesktopUpdateActionPending(false)); + return; + } + + if (desktopUpdateButtonAction === "install") { + let confirmed = false; + try { + confirmed = await ensureLocalApi().dialogs.confirm( + getDesktopUpdateInstallConfirmationMessage(desktopUpdateState, navigator.platform), + ); + } catch (error) { + setDesktopUpdateActionPending(false); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not confirm update", + description: error instanceof Error ? error.message : "Update confirmation failed.", + }), + ); + return; + } + if (!confirmed) { + setDesktopUpdateActionPending(false); + return; + } + void bridge + .installUpdate() + .then((result) => { + if (!shouldToastDesktopUpdateActionResult(result)) return; + const actionError = getDesktopUpdateActionError(result); + if (!actionError) return; + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not install update", + description: actionError, + }), + ); + }) + .catch((error) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not install update", + description: error instanceof Error ? error.message : "An unexpected error occurred.", + }), + ); + }) + .finally(() => setDesktopUpdateActionPending(false)); + } + }, [ + desktopUpdateActionPending, + desktopUpdateButtonAction, + desktopUpdateButtonDisabled, + desktopUpdateState, + ]); + + const expandThreadListForProject = useCallback((projectKey: string) => { + setExpandedThreadListsByProject((current) => { + if (current.has(projectKey)) return current; + const next = new Set(current); + next.add(projectKey); + return next; + }); + }, []); + + const collapseThreadListForProject = useCallback((projectKey: string) => { + setExpandedThreadListsByProject((current) => { + if (!current.has(projectKey)) return current; + const next = new Set(current); + next.delete(projectKey); + return next; + }); + }, []); + + return ( + <> + {prewarmedSidebarThreadRefs.map((threadRef) => ( + + ))} + + + + + + ); +} diff --git a/apps/web/src/components/PlanSidebar.tsx b/apps/web/src/components/PlanSidebar.tsx deleted file mode 100644 index abc0db79b6cb..000000000000 --- a/apps/web/src/components/PlanSidebar.tsx +++ /dev/null @@ -1,284 +0,0 @@ -import { memo, useState, useCallback } from "react"; -import { - isAtomCommandInterrupted, - squashAtomCommandFailure, -} from "@t3tools/client-runtime/state/runtime"; -import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; -import { type TimestampFormat } from "@t3tools/contracts/settings"; -import { Badge } from "./ui/badge"; -import { Button } from "./ui/button"; -import { ScrollArea } from "./ui/scroll-area"; -import ChatMarkdown from "./ChatMarkdown"; -import { - CheckIcon, - ChevronDownIcon, - ChevronRightIcon, - EllipsisIcon, - LoaderIcon, -} from "lucide-react"; -import { cn } from "~/lib/utils"; -import type { ActivePlanState } from "../session-logic"; -import type { LatestProposedPlanState } from "../session-logic"; -import { formatTimestamp } from "../timestampFormat"; -import { - proposedPlanTitle, - buildProposedPlanMarkdownFilename, - normalizePlanMarkdownForExport, - downloadPlanAsTextFile, - stripDisplayedPlanMarkdown, -} from "../proposedPlan"; -import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; -import { projectEnvironment } from "~/state/projects"; -import { stackedThreadToast, toastManager } from "./ui/toast"; -import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; -import { useAtomCommand } from "~/state/use-atom-command"; - -function stepStatusIcon(status: string): React.ReactNode { - if (status === "completed") { - return ( - - - - ); - } - if (status === "inProgress") { - return ( - - - - ); - } - return ( - - - - ); -} - -interface PlanSidebarProps { - activePlan: ActivePlanState | null; - activeProposedPlan: LatestProposedPlanState | null; - label?: string; - environmentId: EnvironmentId; - threadRef?: ScopedThreadRef | undefined; - markdownCwd: string | undefined; - workspaceRoot: string | undefined; - timestampFormat: TimestampFormat; - mode?: "sheet" | "sidebar" | "embedded"; -} - -const PlanSidebar = memo(function PlanSidebar({ - activePlan, - activeProposedPlan, - label = "Plan", - environmentId, - threadRef, - markdownCwd, - workspaceRoot, - timestampFormat, - mode = "sidebar", -}: PlanSidebarProps) { - const [proposedPlanExpanded, setProposedPlanExpanded] = useState(false); - const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false); - const writeProjectFile = useAtomCommand(projectEnvironment.writeFile, { - reportFailure: false, - }); - const { copyToClipboard, isCopied } = useCopyToClipboard({ target: "plan" }); - - const planMarkdown = activeProposedPlan?.planMarkdown ?? null; - const displayedPlanMarkdown = planMarkdown ? stripDisplayedPlanMarkdown(planMarkdown) : null; - const planTitle = planMarkdown ? proposedPlanTitle(planMarkdown) : null; - - const handleCopyPlan = useCallback(() => { - if (!planMarkdown) return; - copyToClipboard(planMarkdown); - }, [planMarkdown, copyToClipboard]); - - const handleDownload = useCallback(() => { - if (!planMarkdown) return; - const filename = buildProposedPlanMarkdownFilename(planMarkdown); - downloadPlanAsTextFile(filename, normalizePlanMarkdownForExport(planMarkdown)); - }, [planMarkdown]); - - const handleSaveToWorkspace = useCallback(() => { - if (!workspaceRoot || !planMarkdown) return; - const filename = buildProposedPlanMarkdownFilename(planMarkdown); - setIsSavingToWorkspace(true); - void (async () => { - const result = await writeProjectFile({ - environmentId, - input: { - cwd: workspaceRoot, - relativePath: filename, - contents: normalizePlanMarkdownForExport(planMarkdown), - }, - }); - setIsSavingToWorkspace(false); - if (result._tag === "Success") { - toastManager.add({ - type: "success", - title: "Plan saved", - description: result.value.relativePath, - }); - return; - } - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not save plan", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - })(); - }, [environmentId, planMarkdown, workspaceRoot, writeProjectFile]); - - return ( -
    - {/* Header */} -
    -
    - - {label} - - {activePlan ? ( - - {formatTimestamp(activePlan.createdAt, timestampFormat)} - - ) : null} -
    -
    - {planMarkdown ? ( - - - } - > - - - - - {isCopied ? "Copied!" : "Copy to clipboard"} - - Download as markdown - - Save to workspace - - - - ) : null} -
    -
    - - {/* Content */} - -
    - {/* Explanation */} - {activePlan?.explanation ? ( -

    - {activePlan.explanation} -

    - ) : null} - - {/* Plan Steps */} - {activePlan && activePlan.steps.length > 0 ? ( -
    -

    - Steps -

    - {activePlan.steps.map((step) => ( -
    - {stepStatusIcon(step.status)} -

    - {step.step} -

    -
    - ))} -
    - ) : null} - - {/* Proposed Plan Markdown */} - {planMarkdown ? ( -
    - - {proposedPlanExpanded ? ( -
    - -
    - ) : null} -
    - ) : null} - - {/* Empty state */} - {!activePlan && !planMarkdown ? ( -
    -

    No active plan yet.

    -

    - Plans will appear here when generated. -

    -
    - ) : null} -
    -
    -
    - ); -}); - -export default PlanSidebar; -export type { PlanSidebarProps }; diff --git a/apps/web/src/components/ProjectFavicon.test.tsx b/apps/web/src/components/ProjectFavicon.test.tsx index c2fac8beb7ec..bbeeda4bc7fb 100644 --- a/apps/web/src/components/ProjectFavicon.test.tsx +++ b/apps/web/src/components/ProjectFavicon.test.tsx @@ -4,6 +4,7 @@ import type { EnvironmentId } from "@t3tools/contracts"; const testState = vi.hoisted(() => ({ faviconUrl: "https://environment.test/api/assets/token-a/v1-20-favicon.svg", + lastResource: null as unknown, })); const hooks = vi.hoisted(() => { @@ -52,7 +53,10 @@ vi.mock("react", async (importOriginal) => { vi.mock("react/compiler-runtime", () => ({ c: hooks.useMemoCache })); vi.mock("../assets/assetUrls", () => ({ - useAssetUrl: () => testState.faviconUrl, + useAssetUrlState: (_environmentId: unknown, resource: unknown) => { + testState.lastResource = resource; + return { _tag: "Success", url: testState.faviconUrl }; + }, })); import { ProjectFavicon } from "./ProjectFavicon"; @@ -125,4 +129,18 @@ describe("ProjectFavicon", () => { expect(afterDisplayedError[0]).not.toBeNull(); expect(afterDisplayedError[1]).toBeNull(); }); + + it("requests a saved favicon path when one is set", () => { + ProjectFavicon({ + environmentId: "environment-test" as EnvironmentId, + cwd: "/workspace-test", + faviconPath: "brand/icon.svg", + }); + + expect(testState.lastResource).toEqual({ + _tag: "project-favicon", + cwd: "/workspace-test", + path: "brand/icon.svg", + }); + }); }); diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index 1df19a640756..619bbf370018 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -6,7 +6,7 @@ import { import { FolderIcon } from "lucide-react"; import type { ComponentType } from "react"; import { useState } from "react"; -import { useAssetUrl } from "../assets/assetUrls"; +import { useAssetUrlState } from "../assets/assetUrls"; import { cn } from "~/lib/utils"; const loadedProjectFaviconSrcs = new Map(); @@ -14,13 +14,12 @@ const loadedProjectFaviconSrcs = new Map(); export function ProjectFavicon(input: { environmentId: EnvironmentId; cwd: string; + faviconPath?: string | null | undefined; className?: string | undefined; fallbackIcon?: ComponentType<{ className?: string }>; }) { - const src = useAssetUrl(input.environmentId, { - _tag: "project-favicon", - cwd: input.cwd, - }); + const state = useProjectFaviconAsset(input); + const src = state._tag === "Success" ? state.url : null; const FallbackIcon = input.fallbackIcon ?? FolderIcon; if (!src || isProjectFaviconFallbackUrl(src)) { @@ -40,6 +39,18 @@ export function ProjectFavicon(input: { ); } +export function useProjectFaviconAsset(input: { + readonly environmentId: EnvironmentId; + readonly cwd: string; + readonly faviconPath?: string | null | undefined; +}) { + return useAssetUrlState(input.environmentId, { + _tag: "project-favicon", + cwd: input.cwd, + ...(input.faviconPath ? { path: input.faviconPath } : {}), + }); +} + function ProjectFaviconFallback({ className, icon: Icon, @@ -47,7 +58,7 @@ function ProjectFaviconFallback({ readonly className?: string | undefined; readonly icon: ComponentType<{ className?: string }>; }) { - return ; + return ; } function ProjectFaviconImage({ diff --git a/apps/web/src/components/ProjectScriptsControl.test.tsx b/apps/web/src/components/ProjectScriptsControl.test.tsx new file mode 100644 index 000000000000..d9f3e7e69f08 --- /dev/null +++ b/apps/web/src/components/ProjectScriptsControl.test.tsx @@ -0,0 +1,65 @@ +import type { ProjectScript, ResolvedKeybindingsConfig } from "@t3tools/contracts"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import ProjectScriptsControl from "./ProjectScriptsControl"; + +const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; +const PRIMARY_SCRIPT: ProjectScript = { + id: "dev", + name: "Dev", + command: "vp dev", + icon: "play", + runOnWorktreeCreate: false, +}; + +function renderControl(scripts: ReadonlyArray) { + return renderToStaticMarkup( + {}} + onAddScript={async () => undefined as never} + onUpdateScript={async () => undefined as never} + onDeleteScript={async () => undefined as never} + />, + ); +} + +function buttonTag(html: string, ariaLabel: string) { + return html.match(new RegExp(`]*aria-label="${ariaLabel}"[^>]*>`))?.[0]; +} + +function expectResponsiveXsControl(markup: string | undefined) { + expect(markup).toBeDefined(); + expect(markup).toContain("h-7"); + expect(markup).toContain("gap-1"); + expect(markup).toContain("text-sm"); + expect(markup).toContain("sm:h-6"); + expect(markup).toContain("sm:text-xs"); + expect(markup).toContain("w-7"); + expect(markup).toContain("px-0"); + expect(markup).toContain("sm:w-6"); + expect(markup).toContain("@3xl/header-actions:w-auto!"); + expect(markup).toContain("@3xl/header-actions:px-[calc(--spacing(2)-1px)]"); +} + +describe("ProjectScriptsControl compact controls", () => { + it("keeps the primary Run control compact and expands it with its label", () => { + const html = renderControl([PRIMARY_SCRIPT]); + + expectResponsiveXsControl(buttonTag(html, "Run Dev")); + expect(html).toContain( + 'class="sr-only @3xl/header-actions:not-sr-only @3xl/header-actions:ml-0.5"', + ); + }); + + it("keeps the standalone Add control compact and expands it with its label", () => { + const html = renderControl([]); + + expectResponsiveXsControl(buttonTag(html, "Add action")); + expect(html).toContain( + 'class="sr-only @3xl/header-actions:not-sr-only @3xl/header-actions:ml-0.5"', + ); + }); +}); diff --git a/apps/web/src/components/ProjectScriptsControl.tsx b/apps/web/src/components/ProjectScriptsControl.tsx index 3a4e59edbe80..304922909b0a 100644 --- a/apps/web/src/components/ProjectScriptsControl.tsx +++ b/apps/web/src/components/ProjectScriptsControl.tsx @@ -1,61 +1,28 @@ import type { ProjectScript, - ProjectScriptIcon, ResolvedKeybindingsConfig, T3ProjectFileScript, } from "@t3tools/contracts"; import { isAtomCommandInterrupted, squashAtomCommandFailure, - type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; -import { - BugIcon, - ChevronDownIcon, - DownloadIcon, - FlaskConicalIcon, - HammerIcon, - ListChecksIcon, - PlayIcon, - PlusIcon, - SettingsIcon, - WrenchIcon, -} from "lucide-react"; -import React, { type FormEvent, type KeyboardEvent, useCallback, useMemo, useState } from "react"; +import { ChevronDownIcon, DownloadIcon, PlusIcon, SettingsIcon } from "lucide-react"; +import { useCallback, useMemo, useState } from "react"; -import { - keybindingValueForCommand, - decodeProjectScriptKeybindingRule, -} from "~/lib/projectScriptKeybindings"; -import { keybindingFromKeyboardEvent } from "~/components/settings/KeybindingsSettings.logic"; -import { - commandForProjectScript, - nextProjectScriptId, - primaryProjectScript, -} from "~/projectScripts"; +import { commandForProjectScript, primaryProjectScript } from "~/projectScripts"; import { shortcutLabelForCommand } from "~/keybindings"; import { - AlertDialog, - AlertDialogClose, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogPopup, - AlertDialogTitle, -} from "./ui/alert-dialog"; + EMPTY_PROJECT_SCRIPT_INPUT, + editorRequestForScript, + ProjectScriptEditorDialog, + ScriptIcon, + type NewProjectScriptInput, + type ProjectScriptActionResult, + type ProjectScriptEditorRequest, +} from "./projectScriptEditor"; import { Button } from "./ui/button"; -import { - Dialog, - DialogDescription, - DialogFooter, - DialogHeader, - DialogPanel, - DialogPopup, - DialogTitle, -} from "./ui/dialog"; import { Group, GroupSeparator } from "./ui/group"; -import { Input } from "./ui/input"; -import { Label } from "./ui/label"; import { Menu, MenuGroup, @@ -66,48 +33,9 @@ import { MenuShortcut, MenuTrigger, } from "./ui/menu"; -import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; -import { Switch } from "./ui/switch"; -import { Textarea } from "./ui/textarea"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; -const SCRIPT_ICONS: Array<{ id: ProjectScriptIcon; label: string }> = [ - { id: "play", label: "Play" }, - { id: "test", label: "Test" }, - { id: "lint", label: "Lint" }, - { id: "configure", label: "Configure" }, - { id: "build", label: "Build" }, - { id: "debug", label: "Debug" }, -]; - -function ScriptIcon({ - icon, - className = "size-3.5", -}: { - icon: ProjectScriptIcon; - className?: string; -}) { - if (icon === "test") return ; - if (icon === "lint") return ; - if (icon === "configure") return ; - if (icon === "build") return ; - if (icon === "debug") return ; - return ; -} - -export interface NewProjectScriptInput { - name: string; - command: string; - icon: ProjectScriptIcon; - runOnWorktreeCreate: boolean; - keybinding: string | null; - /** Optional URL to open in the in-app preview when this script runs. */ - previewUrl: string | null; - /** When true, automatically open the preview panel pointed at `previewUrl`. */ - autoOpenPreview: boolean; -} - -export type ProjectScriptActionResult = AtomCommandResult; +export type { NewProjectScriptInput, ProjectScriptActionResult }; const NO_FILE_SCRIPTS: ReadonlyArray = []; @@ -136,23 +64,11 @@ export default function ProjectScriptsControl({ onUpdateScript, onDeleteScript, }: ProjectScriptsControlProps) { - const addScriptFormId = React.useId(); - const [editingScriptId, setEditingScriptId] = useState(null); const [actionsMenuOpen, setActionsMenuOpen] = useState({ scripts: false, imports: false, }); - const [dialogOpen, setDialogOpen] = useState(false); - const [name, setName] = useState(""); - const [command, setCommand] = useState(""); - const [icon, setIcon] = useState("play"); - const [iconPickerOpen, setIconPickerOpen] = useState(false); - const [runOnWorktreeCreate, setRunOnWorktreeCreate] = useState(false); - const [keybinding, setKeybinding] = useState(""); - const [previewUrl, setPreviewUrl] = useState(""); - const [autoOpenPreview, setAutoOpenPreview] = useState(false); - const [validationError, setValidationError] = useState(null); - const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); + const [editorRequest, setEditorRequest] = useState(null); const primaryScript = useMemo(() => { if (preferredScriptId) { @@ -173,112 +89,23 @@ export default function ProjectScriptsControl({ ), [fileScripts, scripts], ); - const isEditing = editingScriptId !== null; const dropdownItemClassName = "data-highlighted:bg-transparent data-highlighted:text-foreground hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground data-highlighted:hover:bg-accent data-highlighted:hover:text-accent-foreground data-highlighted:focus-visible:bg-accent data-highlighted:focus-visible:text-accent-foreground"; - const captureKeybinding = (event: KeyboardEvent) => { - if (event.key === "Tab") return; - event.preventDefault(); - if (event.key === "Backspace" || event.key === "Delete") { - setKeybinding(""); - return; - } - const next = keybindingFromKeyboardEvent(event, navigator.platform); - if (!next) return; - setKeybinding(next); - }; - - const submitAddScript = async (event: FormEvent) => { - event.preventDefault(); - const trimmedName = name.trim(); - const trimmedCommand = command.trim(); - if (trimmedName.length === 0) { - setValidationError("Name is required."); - return; - } - if (trimmedCommand.length === 0) { - setValidationError("Command is required."); - return; - } - - setValidationError(null); - let payload: NewProjectScriptInput; - try { - const scriptIdForValidation = - editingScriptId ?? - nextProjectScriptId( - trimmedName, - scripts.map((script) => script.id), - ); - const keybindingRule = decodeProjectScriptKeybindingRule({ - keybinding, - command: commandForProjectScript(scriptIdForValidation), - }); - const trimmedPreviewUrl = previewUrl.trim(); - payload = { - name: trimmedName, - command: trimmedCommand, - icon, - runOnWorktreeCreate, - keybinding: keybindingRule?.key ?? null, - previewUrl: trimmedPreviewUrl.length > 0 ? trimmedPreviewUrl : null, - autoOpenPreview: trimmedPreviewUrl.length > 0 ? autoOpenPreview : false, - } satisfies NewProjectScriptInput; - } catch (error) { - setValidationError(error instanceof Error ? error.message : "Failed to save action."); - return; - } - - const result = editingScriptId - ? await onUpdateScript(editingScriptId, payload) - : await onAddScript(payload); - if (result._tag === "Failure") { - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - setValidationError(error instanceof Error ? error.message : "Failed to save action."); - } - return; - } - setDialogOpen(false); - setIconPickerOpen(false); - }; - const openAddDialog = () => { - setEditingScriptId(null); - setName(""); - setCommand(""); - setIcon("play"); - setIconPickerOpen(false); - setRunOnWorktreeCreate(false); - setKeybinding(""); - setPreviewUrl(""); - setAutoOpenPreview(false); - setValidationError(null); - setDialogOpen(true); + setEditorRequest({ scriptId: null, initial: EMPTY_PROJECT_SCRIPT_INPUT }); }; const openEditDialog = (script: ProjectScript) => { setActionsMenuOpen({ scripts: false, imports: false }); - setEditingScriptId(script.id); - setName(script.name); - setCommand(script.command); - setIcon(script.icon); - setIconPickerOpen(false); - setRunOnWorktreeCreate(script.runOnWorktreeCreate); - setKeybinding(keybindingValueForCommand(keybindings, commandForProjectScript(script.id)) ?? ""); - setPreviewUrl(script.previewUrl ?? ""); - setAutoOpenPreview(script.autoOpenPreview ?? false); - setValidationError(null); - setDialogOpen(true); + setEditorRequest(editorRequestForScript(script, keybindings)); }; - const confirmDeleteScript = useCallback(() => { - if (!editingScriptId) return; - setDeleteConfirmOpen(false); - setDialogOpen(false); - void onDeleteScript(editingScriptId); - }, [editingScriptId, onDeleteScript]); + const submitScript = useCallback( + (scriptId: string | null, input: NewProjectScriptInput) => + scriptId === null ? onAddScript(input) : onUpdateScript(scriptId, input), + [onAddScript, onUpdateScript], + ); const importFileScript = async (fileScript: T3ProjectFileScript) => { const payload: NewProjectScriptInput = { @@ -295,17 +122,11 @@ export default function ProjectScriptsControl({ // Surface the failure through the regular add dialog, prefilled so the // user can adjust and retry. const error = squashAtomCommandFailure(result); - setEditingScriptId(null); - setName(payload.name); - setCommand(payload.command); - setIcon(payload.icon); - setIconPickerOpen(false); - setRunOnWorktreeCreate(payload.runOnWorktreeCreate); - setKeybinding(""); - setPreviewUrl(payload.previewUrl ?? ""); - setAutoOpenPreview(payload.autoOpenPreview); - setValidationError(error instanceof Error ? error.message : "Failed to import action."); - setDialogOpen(true); + setEditorRequest({ + scriptId: null, + initial: payload, + error: error instanceof Error ? error.message : "Failed to import action.", + }); } }; @@ -341,7 +162,11 @@ export default function ProjectScriptsControl({ - ); - })} -
    - - - setName(event.target.value)} - /> -
    -
    -
    - - -

    - Press a shortcut. Use Backspace to clear. -

    -
    -
    - -