diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 73376110d9a..c3d617665fe 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 diff --git a/.github/workflows/mobile-eas-production.yml b/.github/workflows/mobile-eas-production.yml index 6a9c3f0ce67..8e677540e6d 100644 --- a/.github/workflows/mobile-eas-production.yml +++ b/.github/workflows/mobile-eas-production.yml @@ -5,6 +5,23 @@ 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 with --auto-submit (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. 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: @@ -29,10 +46,30 @@ on: 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: ubuntu-24.04 permissions: contents: read @@ -98,15 +135,15 @@ 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: 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 +154,64 @@ 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. Builds started here stay attached to this serialized run so + # the queued run for a later merge cannot overtake them and lose its OTA. + # 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; then + echo ":building_construction: $platform: cut production build for $version (auto-submitted)" >> "$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 00000000000..bc5419ceb8a --- /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: ${{ github.repository == 'pingdotgg/t3code' && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-24.04' }} + 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/release.yml b/.github/workflows/release.yml index f6bd49583d0..5cd34725df7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -691,10 +691,12 @@ jobs: release: name: Publish GitHub Release + # Fork policy: the upstream CLI-package publishing job is omitted + # entirely; the fork never publishes that package. needs: [preflight, build] if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.build.result == 'success' }} runs-on: ubuntu-24.04 - timeout-minutes: 10 + timeout-minutes: 30 permissions: contents: write steps: diff --git a/.github/workflows/web-preview.yml b/.github/workflows/web-preview.yml new file mode 100644 index 00000000000..87df2031d8b --- /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: ${{ github.repository == 'pingdotgg/t3code' && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-24.04' }} + 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/README.md b/README.md index 5dc40335383..48bdf6c7625 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,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/SEAM.md b/SEAM.md index 872c7ab2bae..e4a428247c7 100644 --- a/SEAM.md +++ b/SEAM.md @@ -43,7 +43,7 @@ PR only insta-settles a thread whose activity is not newer than the PR's `update rule. - **Additive** `packages/contracts/src/git.ts` — optional `updatedAt` on `VcsStatusChangeRequest`. - **Additive** `apps/server/src/git/GitManager.ts` — `toStatusPr` forwards the PR's `updatedAt`. -- **Additive** web (`SidebarV2.tsx`, `ChatView.tsx`, `chat/ChatHeader.tsx`, +- **Additive** web (`Sidebar.tsx` — formerly `SidebarV2.tsx`, `ChatView.tsx`, `chat/ChatHeader.tsx`, `hooks/useThreadActionMenu.ts`) and mobile (`threadListV2.ts`, `thread-list-v2-items.tsx`, `HomeScreen.tsx`, `ThreadNavigationSidebar.tsx`, `state/thread-pr-presentation.ts`) — thread the PR `updatedAt` into the settled classification. @@ -52,6 +52,24 @@ On a nightly-sync conflict here, prefer upstream's version wholesale if upstream equivalent (a sticky un-settle or a completed-PR settle gate/toggle); otherwise reapply only the behavior above. +## Reaper wedge cap (upstream adopted the base fix) + +Upstream #5677 landed the fork's background-liveness reaper skip (the +`ThreadBackgroundLiveness` service and the thread-shell `backgroundLiveness` field are now +upstream-owned). The fork's remaining delta is only the wedge cap: background work may defer +reaping, but never forever. + +- **Behavioral** `apps/server/src/provider/Layers/ProviderSessionReaper.ts` — + `backgroundWorkMaxIdleMs` option (default 4 h, floored at the inactivity threshold); the + background-liveness skip applies only while `idleDurationMs` is under the cap. +- **Behavioral** `apps/server/src/provider/Layers/ProviderSessionReaper.test.ts` — upstream's + "skips stale sessions while background work is still live" pins its idle time inside the cap, + and the additive "reaps sessions with live background work once past the wedge cap" exercises + the cap through the thread-shell field. + +On a nightly-sync conflict here, take upstream's reaper wholesale and reapply only the cap +condition; drop the cap if upstream grows an equivalent bound. + ## Nightly sync conflicts Resolve against the new upstream file first, then reapply only the behavior above; never take the diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 058d90c1fe3..5154ab42485 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/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts index eda5af94bf6..49fea493f2a 100644 --- a/apps/desktop/src/app/DesktopLifecycle.test.ts +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -78,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/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index 523e8764697..98bd4065fbe 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -91,6 +91,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/ElectronDialog.test.ts b/apps/desktop/src/electron/ElectronDialog.test.ts index 388b3fd2c15..c41bb34bb43 100644 --- a/apps/desktop/src/electron/ElectronDialog.test.ts +++ b/apps/desktop/src/electron/ElectronDialog.test.ts @@ -28,70 +28,6 @@ describe("ElectronDialog", () => { showErrorBoxMock.mockReset(); }); - it.effect("returns false without opening a confirm dialog for empty messages", () => - Effect.gen(function* () { - const dialog = yield* ElectronDialog.ElectronDialog; - - const result = yield* dialog.confirm({ - message: " ", - owner: Option.none(), - }); - - 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 }); - const dialog = yield* ElectronDialog.ElectronDialog; - - const result = yield* dialog.confirm({ - message: "Delete worktree?", - owner: Option.some(owner), - }); - - assert.isTrue(result); - assert.deepEqual(showMessageBoxMock.mock.calls[0], [ - owner, - { - type: "question", - buttons: ["No", "Yes"], - defaultId: 0, - cancelId: 0, - noLink: true, - message: "Delete worktree?", - }, - ]); - }).pipe(Effect.provide(ElectronDialog.layer)), - ); - - it.effect("opens an app-level confirm dialog when there is no owner window", () => - Effect.gen(function* () { - showMessageBoxMock.mockResolvedValue({ response: 0 }); - const dialog = yield* ElectronDialog.ElectronDialog; - - const result = yield* dialog.confirm({ - message: "Delete worktree?", - owner: Option.none(), - }); - - 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)), - ); - it.effect("preserves folder picker request context and cause", () => Effect.gen(function* () { const cause = new Error("folder picker failed"); @@ -117,31 +53,6 @@ describe("ElectronDialog", () => { }).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)), - ); - it.effect("preserves message box request context and cause", () => Effect.gen(function* () { const cause = new Error("message box failed"); diff --git a/apps/desktop/src/electron/ElectronDialog.ts b/apps/desktop/src/electron/ElectronDialog.ts index f1add4c7cc7..c33a24befcf 100644 --- a/apps/desktop/src/electron/ElectronDialog.ts +++ b/apps/desktop/src/electron/ElectronDialog.ts @@ -6,8 +6,6 @@ import * as Schema from "effect/Schema"; import * as Electron from "electron"; -const CONFIRM_BUTTON_INDEX = 1; - export class ElectronDialogPickFolderError extends Schema.TaggedErrorClass()( "ElectronDialogPickFolderError", { @@ -38,20 +36,6 @@ export class ElectronDialogPickFilesError extends Schema.TaggedErrorClass()( - "ElectronDialogConfirmError", - { - ownerWindowId: Schema.NullOr(Schema.Number), - promptLength: Schema.Number, - 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.`; - } -} - export class ElectronDialogShowMessageBoxError extends Schema.TaggedErrorClass()( "ElectronDialogShowMessageBoxError", { @@ -85,7 +69,6 @@ export class ElectronDialogShowErrorBoxError extends Schema.TaggedErrorClass; - readonly message: string; -} - export class ElectronDialog extends Context.Service< ElectronDialog, { @@ -117,9 +95,6 @@ export class ElectronDialog extends Context.Service< readonly pickFiles: ( input: ElectronDialogPickFilesInput, ) => Effect.Effect; - readonly confirm: ( - input: ElectronDialogConfirmInput, - ) => Effect.Effect; readonly showMessageBox: ( options: Electron.MessageBoxOptions, ) => Effect.Effect; @@ -188,39 +163,6 @@ export const make = ElectronDialog.of({ }); return result.canceled ? [] : result.filePaths; }), - 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({ - ownerWindowId, - promptLength: normalizedMessage.length, - cause, - }), - }); - return result.response === CONFIRM_BUTTON_INDEX; - }), showMessageBox: (options) => Effect.tryPromise({ try: () => Electron.dialog.showMessageBox(options), diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 503a586d9c5..cb35ad19ac7 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -31,7 +31,6 @@ import { setUpdateChannel, } from "./methods/updates.ts"; import { - confirm, getAppBranding, getLocalEnvironmentBootstraps, getLocalEnvironmentBearerToken, @@ -81,7 +80,6 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(pickFolder); yield* ipc.handle(pickThemeFiles); - yield* ipc.handle(confirm); yield* ipc.handle(setTheme); yield* ipc.handle(showContextMenu); yield* ipc.handle(openExternal); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 4d8e783d122..4a1213e4ec6 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -1,6 +1,5 @@ export const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files"; -export const CONFIRM_CHANNEL = "desktop:confirm"; export const SET_THEME_CHANNEL = "desktop:set-theme"; export const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index cfa854e7a16..7a39eb42927 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -220,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, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 7e8859359b3..2aa345ee584 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -98,7 +98,6 @@ contextBridge.exposeInMainWorld("desktopBridge", { setWslOnly: (enabled) => ipcRenderer.invoke(IpcChannels.SET_WSL_ONLY_CHANNEL, enabled), pickFolder: (options) => ipcRenderer.invoke(IpcChannels.PICK_FOLDER_CHANNEL, options), pickThemeFiles: () => ipcRenderer.invoke(IpcChannels.PICK_THEME_FILES_CHANNEL, undefined), - confirm: (message) => ipcRenderer.invoke(IpcChannels.CONFIRM_CHANNEL, message), setTheme: (theme) => ipcRenderer.invoke(IpcChannels.SET_THEME_CHANNEL, theme), showContextMenu: (items, position) => ipcRenderer.invoke(IpcChannels.CONTEXT_MENU_CHANNEL, { diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index c1cb8588b5e..861f72178a6 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -39,8 +39,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/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts index b8c66e9b745..831f06f02d3 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts @@ -231,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", diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index 5627eec54de..bd8aa6654f7 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -207,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`], }), ), ]; @@ -379,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(";")), diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index 6d6dfda9bb5..99400dbce90 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -53,7 +53,6 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { const electronDialogLayer = Layer.succeed(ElectronDialog.ElectronDialog, { pickFolder: () => Effect.succeed(Option.none()), pickFiles: () => Effect.succeed([]), - confirm: () => Effect.succeed(false), showMessageBox: () => Effect.succeed({ response: 0, checkboxChecked: false }), showErrorBox: () => Effect.void, } satisfies ElectronDialog.ElectronDialog["Service"]); @@ -81,6 +80,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"]); @@ -94,6 +95,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* () { @@ -101,25 +126,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"); @@ -138,4 +145,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 cd2c74c73d1..d899f951898 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.ts b/apps/desktop/src/window/DesktopWindow.ts index 3bf746a8e9b..bf8c681448f 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -61,6 +61,8 @@ export type DesktopWindowError = | ElectronWindow.ElectronWindowCreateError | PreviewManager.PreviewManagerError; +export type MainWindowZoomDirection = "in" | "out" | "reset"; + export class DesktopWindow extends Context.Service< DesktopWindow, { @@ -87,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") {} @@ -836,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/mobile/app.config.ts b/apps/mobile/app.config.ts index c684689fb26..486ede13abc 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -338,6 +338,7 @@ const config: ExpoConfig = { "./plugins/withAndroidModernPopupMenu.cjs", "./plugins/withAndroidModernAlertDialog.cjs", "./plugins/withAndroidPredictiveBackCompat.cjs", + "./plugins/withAndroidTabletOrientation.cjs", ...(isIosPersonalTeamBuild ? ["./plugins/withoutIosPersonalTeamCapabilities.cjs"] : []), ], extra: { diff --git a/apps/mobile/plugins/withAndroidTabletOrientation.cjs b/apps/mobile/plugins/withAndroidTabletOrientation.cjs new file mode 100644 index 00000000000..2254cdb1921 --- /dev/null +++ b/apps/mobile/plugins/withAndroidTabletOrientation.cjs @@ -0,0 +1,80 @@ +const { withMainActivity } = require("expo/config-plugins"); + +// The top-level `orientation: "portrait"` writes android:screenOrientation="portrait" +// into the manifest, which locks every Android device — including tablets — to +// portrait. iOS doesn't have this problem: iPads must support all orientations +// because the app is multitasking-capable, so only iPhones end up portrait-only. +// Mirror that split on Android: keep the manifest lock for phones and lift it at +// runtime on tablets (smallest width >= 600dp, the standard tablet breakpoint), +// since requestedOrientation set at runtime overrides the manifest value. +// FULL_USER allows all four orientations while still respecting the user's +// auto-rotate lock, matching iPad behavior. Foldables change +// smallestScreenWidthDp on fold/unfold without recreating the activity +// (smallestScreenSize is in the manifest's configChanges), so the policy is +// re-evaluated in onConfigurationChanged: unfolding past the tablet breakpoint +// unlocks rotation, and folding back restores the portrait lock. + +const ORIENTATION_METHODS = ` + // Applied in onCreate and re-applied on fold/unfold; added by + // withAndroidTabletOrientation. + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + applyTabletOrientation() + } + + private fun applyTabletOrientation() { + requestedOrientation = if (resources.configuration.smallestScreenWidthDp >= 600) { + ActivityInfo.SCREEN_ORIENTATION_FULL_USER + } else { + ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + } + } +`; + +const ORIENTATION_ON_CREATE_CALL = ` + applyTabletOrientation()`; + +function insertAfter(contents, anchor, insertion, description) { + const index = contents.indexOf(anchor); + if (index === -1) { + throw new Error( + `withAndroidTabletOrientation: could not find ${description} in MainActivity — the Expo template changed; update the plugin anchors.`, + ); + } + const end = index + anchor.length; + return contents.slice(0, end) + insertion + contents.slice(end); +} + +module.exports = function withAndroidTabletOrientation(config) { + return withMainActivity(config, (nextConfig) => { + let contents = nextConfig.modResults.contents; + if (nextConfig.modResults.language !== "kt") { + throw new Error("withAndroidTabletOrientation: MainActivity must be Kotlin."); + } + if (contents.includes("SCREEN_ORIENTATION_FULL_USER")) { + return nextConfig; + } + + contents = insertAfter( + contents, + "import android.os.Bundle", + "\nimport android.content.pm.ActivityInfo\nimport android.content.res.Configuration", + "the android.os.Bundle import", + ); + contents = insertAfter( + contents, + "class MainActivity : ReactActivity() {", + ORIENTATION_METHODS, + "the MainActivity class declaration", + ); + contents = insertAfter( + contents, + "super.onCreate(null)", + ORIENTATION_ON_CREATE_CALL, + "the super.onCreate call", + ); + + nextConfig.modResults.contents = contents; + return nextConfig; + }); +}; diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 1516b7cbc73..da1be88a8bd 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -48,6 +48,7 @@ import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteSc import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen"; import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen"; import { SettingsProjectGroupingRouteScreen } from "./features/settings/SettingsProjectGroupingRouteScreen"; +import { UsageRouteScreen } from "./features/usage/UsageRouteScreen"; import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen"; import { ShowcaseCaptureCoordinator } from "./features/showcase/ShowcaseCaptureCoordinator"; import { @@ -190,6 +191,13 @@ const SettingsSheetStack = createNativeStackNavigator({ title: "Client Storage", }, }), + SettingsUsage: createNativeStackScreen({ + screen: UsageRouteScreen, + linking: "usage", + options: { + title: "Usage", + }, + }), SettingsAuth: createNativeStackScreen({ screen: SettingsAuthRouteScreen, linking: "auth", diff --git a/apps/mobile/src/components/AppSymbol.tsx b/apps/mobile/src/components/AppSymbol.tsx index b1e83607d8e..89dc0cc045b 100644 --- a/apps/mobile/src/components/AppSymbol.tsx +++ b/apps/mobile/src/components/AppSymbol.tsx @@ -14,6 +14,7 @@ import { IconBellRinging, IconBolt, IconCamera, + IconChartBar, IconCheck, IconChevronDown, IconCode, @@ -97,6 +98,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial> = { "bolt.circle": IconBolt, "bolt.horizontal.circle": IconBolt, camera: IconCamera, + "chart.bar.xaxis": IconChartBar, checkmark: IconCheck, "checkmark.circle": IconCircleCheck, clock: IconClock, diff --git a/apps/mobile/src/components/CompactBrandTitle.tsx b/apps/mobile/src/components/CompactBrandTitle.tsx index f0710e85d36..28f7cfe57a7 100644 --- a/apps/mobile/src/components/CompactBrandTitle.tsx +++ b/apps/mobile/src/components/CompactBrandTitle.tsx @@ -16,6 +16,18 @@ import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../native/native-glass"; const IOS_NATIVE_LEADING_TITLE_OFFSET = -6; const IPAD_NATIVE_LEADING_TITLE_OFFSET = 7; +/** + * Horizontal correction applied to content rendered in the brand title slot, + * shared with the connection-status swap so both align identically. + */ +export function brandTitleOffset(nativeLeadingItem: boolean): number { + if (Platform.OS !== "ios") return 0; + if (nativeLeadingItem) { + return Platform.isPad ? IPAD_NATIVE_LEADING_TITLE_OFFSET : IOS_NATIVE_LEADING_TITLE_OFFSET; + } + return Platform.isPad ? IPAD_HOME_TITLE_OFFSET : 0; +} + /** * Compact brand lockup sized for native navigation bars. */ @@ -28,16 +40,7 @@ export function CompactBrandTitle( const mutedColor = useThemeColor("--color-foreground-muted"); const subtleColor = useThemeColor("--color-subtle"); const stageLabel = resolveMobileStageLabel(Constants.expoConfig?.extra?.appVariant); - const titleOffset = - Platform.OS !== "ios" - ? 0 - : props.nativeLeadingItem - ? Platform.isPad - ? IPAD_NATIVE_LEADING_TITLE_OFFSET - : IOS_NATIVE_LEADING_TITLE_OFFSET - : Platform.isPad - ? IPAD_HOME_TITLE_OFFSET - : 0; + const titleOffset = brandTitleOffset(props.nativeLeadingItem === true); return ( void; delayLongPress?: number }>; + children = cloneElement(child, { + onLongPress: child.props.onLongPress ?? (() => undefined), + delayLongPress: child.props.delayLongPress ?? 350, + }); + } return ( - {menuProps.children} + {children} ); } diff --git a/apps/mobile/src/components/ProjectFavicon.tsx b/apps/mobile/src/components/ProjectFavicon.tsx index d52aa05b446..c4297f24b09 100644 --- a/apps/mobile/src/components/ProjectFavicon.tsx +++ b/apps/mobile/src/components/ProjectFavicon.tsx @@ -24,13 +24,18 @@ export function ProjectFavicon(props: { readonly size?: number; readonly projectTitle: string; readonly workspaceRoot?: string | null; + readonly faviconPath?: string | null; }) { const size = props.size ?? 42; const faviconUrl = useAssetUrl( props.environmentId, props.workspaceRoot === null || props.workspaceRoot === undefined ? null - : { _tag: "project-favicon", cwd: props.workspaceRoot }, + : { + _tag: "project-favicon", + cwd: props.workspaceRoot, + ...(props.faviconPath ? { path: props.faviconPath } : {}), + }, ); const renderableFaviconUrl = isProjectFaviconFallbackUrl(faviconUrl) ? null : faviconUrl; const cacheKey = diff --git a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx index 01440007bc6..801862086b9 100644 --- a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx +++ b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx @@ -368,6 +368,7 @@ function ProjectGroupLabel(props: { void; readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; + readonly onOpenEnvironments: () => void; readonly onOpenSettings: () => void; readonly onStartNewTask: () => void; }) { @@ -207,18 +209,27 @@ function AndroidHomeHeader(props: HomeHeaderProps) { > - - {/* Mirrors the desktop SidebarBrand: T3 mark + muted "Code". */} - - - Code - - - - {stageLabel} - - - + {/* Brand slot doubles as the connection status surface: while an + environment reconnects, the lockup fades to a status label in + place (no layout shift in the list below). */} + + {/* Mirrors the desktop SidebarBrand: T3 mark + muted "Code". */} + + + Code + + + + {stageLabel} + + + + } + /> navigation.navigate("NewTaskSheet", { screen: "NewTask" })} > <> - {/* Restore the compact title after the split branch blanks the detail header. */} - + {/* Restore the compact title after the split branch blanks the detail + header. The brand slot doubles as the connection status surface: + while an environment reconnects, the lockup fades to a status label + in place (no layout shift in the list below). */} + + navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" }), + })} + /> + navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" }) + } onOpenSettings={() => navigation.navigate("SettingsSheet", { screen: "Settings" })} onProjectSortOrderChange={setProjectSortOrder} onSearchQueryChange={setSearchQuery} @@ -159,11 +171,9 @@ export function HomeRouteScreen() { onUnsettleThread={unsettleThread} onPinThread={pinThread} onUnpinThread={unpinThread} + onMovePinnedThread={movePinnedThread} onEnvironmentChange={setSelectedEnvironmentId} onProjectChange={setSelectedProjectKey} - onOpenEnvironments={() => - navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" }) - } onOpenSettings={() => navigation.navigate("SettingsSheet", { screen: "Settings" })} onProjectSortOrderChange={setProjectSortOrder} onSearchQueryChange={setSearchQuery} diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 60c09a09732..ba95fa83665 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -11,6 +11,7 @@ import { threadSearchMatchKey, type EnvironmentThreadSearchMatch, } from "@t3tools/client-runtime/state/thread-search"; +import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort"; import type { EnvironmentId, SidebarProjectGroupingMode, @@ -71,8 +72,6 @@ import { type HomeProjectSortOrder, } from "./homeThreadList"; import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "./thread-swipe-actions"; -import { WorkspaceConnectionStatus } from "./WorkspaceConnectionStatus"; -import { shouldShowWorkspaceConnectionStatus } from "./workspace-connection-status"; /* ─── Types ──────────────────────────────────────────────────────────── */ @@ -97,7 +96,6 @@ interface HomeScreenProps { readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; readonly onAddConnection: () => void; - readonly onOpenEnvironments: () => void; readonly onOpenSettings: () => void; readonly onStartNewTask: () => void; readonly onSelectThread: (thread: EnvironmentThreadShell) => void; @@ -113,6 +111,10 @@ interface HomeScreenProps { readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void; readonly onPinThread: (thread: EnvironmentThreadShell) => Promise; readonly onUnpinThread: (thread: EnvironmentThreadShell) => Promise; + readonly onMovePinnedThread: ( + thread: EnvironmentThreadShell, + direction: "up" | "down", + ) => Promise; readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void; readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void; readonly onNewThreadInProject: (project: EnvironmentProject) => void; @@ -530,6 +532,12 @@ export function HomeScreen(props: HomeScreenProps) { }, [props.onPinThread], ); + const handleMovePinnedThread = useCallback( + (thread: EnvironmentThreadShell, direction: "up" | "down") => { + void props.onMovePinnedThread(thread, direction); + }, + [props.onMovePinnedThread], + ); const handleUnpinThread = useCallback( (thread: EnvironmentThreadShell) => { void props.onUnpinThread(thread); @@ -604,6 +612,29 @@ export function HomeScreen(props: HomeScreenProps) { } return supported; }, [serverConfigs]); + const pinReorderEnvironmentIds = useMemo(() => { + const supported = new Set(); + for (const [environmentId, config] of serverConfigs) { + if (config.environment.capabilities.threadPinReorder === true) { + supported.add(environmentId); + } + } + return supported; + }, [serverConfigs]); + // Canonical arranged pinned order (reorder-capable threads only) for the + // Move up/down position flags. Computed from all shells, not the rendered + // list, so search/scope filtering never disables or misdirects a move. + const arrangedPinnedKeys = useMemo(() => { + const pinned = sortPinnedThreadsByOrderKey( + props.threads.filter( + (thread) => + thread.pinnedAt != null && + thread.archivedAt === null && + pinReorderEnvironmentIds.has(thread.environmentId), + ), + ); + return pinned.map((thread) => `${thread.environmentId}:${thread.id}`); + }, [pinReorderEnvironmentIds, props.threads]); const threadListV2Layout = useMemo(() => { if (!threadListV2Enabled) return { @@ -790,11 +821,18 @@ export function HomeScreen(props: HomeScreenProps) { onSettleThread={handleSettleThread} snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)} pinningSupported={pinningEnvironmentIds.has(thread.environmentId)} + pinReorderSupported={pinReorderEnvironmentIds.has(thread.environmentId)} + canMovePinnedUp={arrangedPinnedKeys.indexOf(`${thread.environmentId}:${thread.id}`) > 0} + canMovePinnedDown={(() => { + const index = arrangedPinnedKeys.indexOf(`${thread.environmentId}:${thread.id}`); + return index !== -1 && index < arrangedPinnedKeys.length - 1; + })()} onSnoozeThread={handleSnoozeThread} onUnsnoozeThread={handleUnsnoozeThread} onUnsettleThread={handleUnsettleThread} onPinThread={handlePinThread} onUnpinThread={handleUnpinThread} + onMovePinnedThread={handleMovePinnedThread} onChangeRequestState={handleChangeRequestState} projectCwd={ projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null @@ -807,6 +845,8 @@ export function HomeScreen(props: HomeScreenProps) { [ handleChangeRequestState, handleDeleteThread, + arrangedPinnedKeys, + handleMovePinnedThread, handlePinThread, handleSettleThread, handleSnoozeThread, @@ -816,6 +856,7 @@ export function HomeScreen(props: HomeScreenProps) { handleSwipeableWillOpen, handleUnsettleThread, pinningEnvironmentIds, + pinReorderEnvironmentIds, projectByKey, projectCwdByKey, props.onArchiveThread, @@ -982,20 +1023,13 @@ export function HomeScreen(props: HomeScreenProps) { ? null : (props.savedConnectionsById[props.selectedEnvironmentId]?.environmentLabel ?? "this environment"); - const shouldShowConnectionStatus = shouldShowWorkspaceConnectionStatus(props.catalogState); + // Connection state surfaces in the header title slot + // (WorkspaceConnectionTitle) — nothing renders inside the list, so + // reconnects never shift the rows. const emptyState = deriveEmptyState({ catalogState: props.catalogState, projectCount: props.projects.length, }); - const connectionStatus = - shouldShowConnectionStatus && Platform.OS !== "ios" ? ( - - - - ) : null; if (!hasAnyThreads) { return ( @@ -1014,41 +1048,17 @@ export function HomeScreen(props: HomeScreenProps) { onAction={!props.catalogState.hasReadyEnvironment ? props.onAddConnection : undefined} variant="plain" /> - {emptyState.loading && !shouldShowConnectionStatus ? ( + {emptyState.loading ? ( ) : null} - {shouldShowConnectionStatus && Platform.OS === "ios" ? ( - - - - ) : null} - {connectionStatus} ); } - const listHeader = ( - <> - {Platform.OS === "ios" ? null : } - - {shouldShowConnectionStatus && Platform.OS === "ios" ? ( - - - - ) : null} - - ); + const listHeader = Platform.OS === "ios" ? null : ; // Project scoping lives in the header filter menu (no inline chip row on // mobile — the menu is the one filter surface). @@ -1129,7 +1139,6 @@ export function HomeScreen(props: HomeScreenProps) { }} /> - {connectionStatus} ); } @@ -1184,7 +1193,6 @@ export function HomeScreen(props: HomeScreenProps) { } /> - {connectionStatus} ); } diff --git a/apps/mobile/src/features/home/WorkspaceConnectionStatus.tsx b/apps/mobile/src/features/home/WorkspaceConnectionStatus.tsx deleted file mode 100644 index 1e986ad1a50..00000000000 --- a/apps/mobile/src/features/home/WorkspaceConnectionStatus.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { SymbolView } from "../../components/AppSymbol"; -import { ActivityIndicator, Pressable } from "react-native"; - -import { AppText as Text } from "../../components/AppText"; -import { useThemeColor } from "../../lib/useThemeColor"; -import type { WorkspaceState } from "../../state/workspaceModel"; -import { workspaceConnectionStatusLabel } from "./workspace-connection-status"; - -export function WorkspaceConnectionStatus(props: { - readonly state: WorkspaceState; - readonly onPress: () => void; - readonly variant?: "floating" | "sidebar"; -}) { - const iconColor = useThemeColor("--color-icon-muted"); - const isSynchronizing = - props.state.networkStatus !== "offline" && - props.state.connectionError === null && - (props.state.connectingEnvironments.length > 0 || props.state.hasPendingShellSnapshot); - const variant = props.variant ?? "floating"; - - return ( - - {isSynchronizing ? ( - - ) : ( - - )} - - {workspaceConnectionStatusLabel(props.state)} - - {variant === "sidebar" ? ( - - ) : null} - - ); -} diff --git a/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx b/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx new file mode 100644 index 00000000000..1867042988b --- /dev/null +++ b/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx @@ -0,0 +1,194 @@ +import type { + NativeStackHeaderItem, + NativeStackNavigationOptions, +} from "@react-navigation/native-stack"; +import { useEffect, useRef, useState, type ReactNode } from "react"; +import { ActivityIndicator, Animated, Platform, Pressable, View } from "react-native"; + +import { SymbolView } from "../../components/AppSymbol"; +import { AppText as Text } from "../../components/AppText"; +import { brandTitleOffset, CompactBrandTitle } from "../../components/CompactBrandTitle"; +import { useThemeColor } from "../../lib/useThemeColor"; +import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; +import { useWorkspaceState } from "../../state/workspace"; +import { + workspaceConnectionStatusPresentation, + type WorkspaceConnectionStatusPresentation, +} from "./workspace-connection-status"; + +/** + * Delay before a connection interruption surfaces in the title slot. Sub-second + * blips (the common reconnect case) resolve without any UI at all. + */ +const STATUS_SHOW_DELAY_MS = 800; +const FADE_IN_MS = 250; + +/** + * Connection status presentation, debounced for display: null until the + * workspace has been in a non-connected state for STATUS_SHOW_DELAY_MS, + * then live-updating until the workspace reconnects (null again immediately). + */ +function useDelayedConnectionStatus(): WorkspaceConnectionStatusPresentation | null { + const { state } = useWorkspaceState(); + const presentation = workspaceConnectionStatusPresentation(state); + const hasStatus = presentation !== null; + const [visible, setVisible] = useState(false); + + useEffect(() => { + if (!hasStatus) { + setVisible(false); + return; + } + const timer = setTimeout(() => setVisible(true), STATUS_SHOW_DELAY_MS); + return () => clearTimeout(timer); + }, [hasStatus]); + + return visible ? presentation : null; +} + +/** + * One-shot entrance fade for the status label. Deliberately JS-driven: this can + * mount inside a native header item (RNSScreenStackHeaderSubview), where + * native-driver animated nodes blank the re-hosted view entirely. The JS driver + * updates opacity through the ordinary style path, which those subviews handle. + */ +function StatusFadeIn(props: { readonly children: ReactNode; readonly grow?: boolean }) { + const opacity = useRef(new Animated.Value(0)).current; + + useEffect(() => { + const animation = Animated.timing(opacity, { + duration: FADE_IN_MS, + toValue: 1, + useNativeDriver: false, + }); + animation.start(); + return () => animation.stop(); + }, [opacity]); + + return ( + + {props.children} + + ); +} + +/** + * Renders the brand/title slot of a thread-list surface, swapping the brand + * for the workspace connection status while an environment is unavailable. + * + * Both states occupy the same slot, so connection changes never shift the + * layout below. While connected the brand renders untouched — no wrapper — + * keeping the native header item on the exact element tree that predates the + * status swap. Replaces the old WorkspaceConnectionStatus pill, which inserted + * a row above the thread list. + */ +export function WorkspaceConnectionTitle(props: { + /** Content shown while connected (brand lockup or a screen title). */ + readonly brand: ReactNode; + /** Opens environment settings. Status is not pressable when omitted. */ + readonly onPress?: () => void; + /** Fill the available row width (in-flow headers) instead of hugging content (native title slots). */ + readonly grow?: boolean; + readonly size?: "navbar" | "pageTitle"; + /** Horizontal correction so the status aligns with the brand in native title slots. */ + readonly statusOffset?: number; +}) { + const iconColor = String(useThemeColor("--color-icon-muted")); + const status = useDelayedConnectionStatus(); + const size = props.size ?? "navbar"; + + if (status === null) { + return props.grow ? ( + + {props.brand} + + ) : ( + <>{props.brand} + ); + } + + return ( + + + {status.showsProgress ? ( + + ) : ( + + )} + + {status.label} + + + + ); +} + +/** + * getCompactBrandHeaderOptions with the brand slot upgraded to the + * connection-status swap. Screens with an environment-settings callback apply + * this over the static brand options at mount. + */ +export function getConnectionAwareBrandHeaderOptions(opts: { + readonly onOpenEnvironments: () => void; + readonly fallbackTitleStyle?: NativeStackNavigationOptions["headerTitleStyle"]; +}): NativeStackNavigationOptions { + if (Platform.OS === "ios" && NATIVE_LIQUID_GLASS_SUPPORTED) { + return { + headerTitle: "Threads", + headerTitleStyle: { color: "transparent", fontSize: 18, fontWeight: "800" }, + title: "Threads", + unstable_headerLeftItems: (): NativeStackHeaderItem[] => [ + { + element: ( + } + onPress={opts.onOpenEnvironments} + statusOffset={brandTitleOffset(true)} + /> + ), + hidesSharedBackground: true, + type: "custom", + }, + ], + }; + } + + return { + headerTitle: () => ( + } + onPress={opts.onOpenEnvironments} + statusOffset={brandTitleOffset(false)} + /> + ), + headerTitleStyle: opts.fallbackTitleStyle, + title: "Threads", + }; +} diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index dcea2b6791b..3103c5379be 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -8,9 +8,14 @@ import { Alert } from "react-native"; import { showConfirmDialog } from "../../components/ConfirmDialogHost"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThreadSnapshots"; +import { + pinOrderKeyBetween, + planPinnedMove, + sortPinnedThreadsByOrderKey, +} from "@t3tools/client-runtime/state/thread-sort"; import { appAtomRegistry } from "../../state/atom-registry"; import { environmentServerConfigsAtom } from "../../state/server"; -import { threadEnvironment } from "../../state/threads"; +import { environmentThreadShells, threadEnvironment } from "../../state/threads"; import { useAtomCommand } from "../../state/use-atom-command"; /** Version skew: never send settle/unsettle to a server that predates them @@ -36,6 +41,13 @@ function environmentSupportsPinning(environmentId: EnvironmentThreadShell["envir ); } +function environmentSupportsPinReorder(environmentId: EnvironmentThreadShell["environmentId"]) { + return ( + appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities + .threadPinReorder === true + ); +} + type ThreadListAction = "archive" | "unarchive" | "delete" | "settle" | "unsettle"; const ACTION_VERBS: Record = { @@ -211,6 +223,10 @@ export function useThreadListActions(): { readonly unsettleThread: (thread: EnvironmentThreadShell) => Promise; readonly pinThread: (thread: EnvironmentThreadShell) => Promise; readonly unpinThread: (thread: EnvironmentThreadShell) => Promise; + readonly movePinnedThread: ( + thread: EnvironmentThreadShell, + direction: "up" | "down", + ) => Promise; } { const executeAction = useThreadActionExecutor(); const snoozeMutation = useAtomCommand(threadEnvironment.snooze, { reportFailure: false }); @@ -331,9 +347,21 @@ export function useThreadListActions(): { return false; } selectionHaptic(); + // Same placement as web: a fresh pin takes the top of the arranged + // run. Servers that predate reordering get the bare pin (keyless). + let orderKey: string | undefined; + if (environmentSupportsPinReorder(thread.environmentId)) { + const shells = appAtomRegistry.get(environmentThreadShells.threadShellsAtom); + let firstKey: string | null = null; + for (const shell of shells) { + if (shell.pinnedAt == null || shell.pinOrderKey == null) continue; + if (firstKey === null || shell.pinOrderKey < firstKey) firstKey = shell.pinOrderKey; + } + orderKey = pinOrderKeyBetween(null, firstKey) ?? undefined; + } const result = await pinMutation({ environmentId: thread.environmentId, - input: { threadId: thread.id }, + input: { threadId: thread.id, ...(orderKey !== undefined ? { orderKey } : {}) }, }); if (result._tag === "Failure") { const error = Cause.squash(result.cause); @@ -378,6 +406,85 @@ export function useThreadListActions(): { [unpinMutation], ); + // Move up / Move down for the pinned block. Computed against the CANONICAL + // keyed pinned order (not the rendered list), so the move is valid even + // while search or a project scope filters rows: the same fractional-key + // scheme web dragging uses, one write to one thread per move (plus a + // one-time section materialization when legacy keyless pins are involved). + const reorderPinnedMutation = useAtomCommand(threadEnvironment.reorderPin, { + reportFailure: false, + }); + // One move at a time: a second tap before the first write's event lands + // would plan from the same stale snapshot and silently collapse two moves + // into one — same double-dispatch guard as snoozeThread. + const movePinnedInFlightRef = useRef(false); + const movePinnedThread = useCallback( + async (thread: EnvironmentThreadShell, direction: "up" | "down") => { + if (movePinnedInFlightRef.current) return false; + if (!environmentSupportsPinReorder(thread.environmentId)) { + Alert.alert( + "Could not move thread", + "This environment's server does not support pinned reordering yet. Update the server to reorder pins.", + ); + return false; + } + const shells = appAtomRegistry.get(environmentThreadShells.threadShellsAtom); + const pinned = sortPinnedThreadsByOrderKey( + shells.filter( + (shell) => + shell.pinnedAt != null && + shell.archivedAt === null && + environmentSupportsPinReorder(shell.environmentId), + ), + ); + const orderedIds = pinned.map((shell) => scopedThreadKey(shell.environmentId, shell.id)); + const assignments = planPinnedMove({ + orderedIds, + keysById: new Map( + pinned.map((shell) => [ + scopedThreadKey(shell.environmentId, shell.id), + shell.pinOrderKey ?? null, + ]), + ), + movedId: scopedThreadKey(thread.environmentId, thread.id), + direction, + }); + if (assignments === null || assignments.length === 0) return false; + const shellByKey = new Map( + pinned.map((shell) => [scopedThreadKey(shell.environmentId, shell.id), shell]), + ); + selectionHaptic(); + movePinnedInFlightRef.current = true; + try { + for (const assignment of assignments) { + const target = shellByKey.get(assignment.id); + if (target === undefined) continue; + const result = await reorderPinnedMutation({ + environmentId: target.environmentId, + input: { threadId: target.id, orderKey: assignment.orderKey }, + }); + if (result._tag === "Failure") { + const error = Cause.squash(result.cause); + Alert.alert( + "Could not move thread", + error instanceof Error && error.message.trim().length > 0 + ? error.message + : "The pinned thread could not be moved.", + ); + // No rollback: keys already written are valid orderings on their + // own (each write is a complete, consistent placement), so a + // partial materialization leaves the list sensible, not corrupt. + return false; + } + } + return true; + } finally { + movePinnedInFlightRef.current = false; + } + }, + [reorderPinnedMutation], + ); + const confirmDeleteThread = useConfirmDeleteThread(executeAction); return { @@ -389,6 +496,7 @@ export function useThreadListActions(): { unsettleThread, pinThread, unpinThread, + movePinnedThread, }; } diff --git a/apps/mobile/src/features/home/WorkspaceConnectionStatus.test.ts b/apps/mobile/src/features/home/workspace-connection-status.test.ts similarity index 72% rename from apps/mobile/src/features/home/WorkspaceConnectionStatus.test.ts rename to apps/mobile/src/features/home/workspace-connection-status.test.ts index 8c3c873cc9e..15a990bb1cb 100644 --- a/apps/mobile/src/features/home/WorkspaceConnectionStatus.test.ts +++ b/apps/mobile/src/features/home/workspace-connection-status.test.ts @@ -4,6 +4,7 @@ import type { WorkspaceState } from "../../state/workspaceModel"; import { shouldShowWorkspaceConnectionStatus, workspaceConnectionStatusLabel, + workspaceConnectionStatusPresentation, } from "./workspace-connection-status"; function workspaceState(overrides: Partial = {}): WorkspaceState { @@ -84,4 +85,36 @@ describe("workspace connection status", () => { expect(shouldShowWorkspaceConnectionStatus(state)).toBe(true); expect(workspaceConnectionStatusLabel(state)).toBe("Loading threads..."); }); + + it("presents nothing while connected", () => { + expect(workspaceConnectionStatusPresentation(workspaceState())).toBeNull(); + }); + + it("presents progress while reconnecting but not while offline", () => { + const reconnecting = workspaceState({ + hasConnectingEnvironment: true, + hasReadyEnvironment: false, + connectingEnvironments: [ + { + environmentId: "environment-1" as never, + environmentLabel: "Julius’s Mac mini", + displayUrl: "", + isRelayManaged: false, + connectionState: "reconnecting", + connectionError: null, + connectionErrorTraceId: null, + }, + ], + }); + expect(workspaceConnectionStatusPresentation(reconnecting)).toEqual({ + label: "Reconnecting to Julius’s Mac mini", + showsProgress: true, + }); + + const offline = workspaceState({ networkStatus: "offline", hasReadyEnvironment: false }); + expect(workspaceConnectionStatusPresentation(offline)).toEqual({ + label: "You are offline", + showsProgress: false, + }); + }); }); diff --git a/apps/mobile/src/features/home/workspace-connection-status.ts b/apps/mobile/src/features/home/workspace-connection-status.ts index d8eed4383b1..6f9898b1bb0 100644 --- a/apps/mobile/src/features/home/workspace-connection-status.ts +++ b/apps/mobile/src/features/home/workspace-connection-status.ts @@ -1,5 +1,11 @@ import type { WorkspaceState } from "../../state/workspaceModel"; +export interface WorkspaceConnectionStatusPresentation { + readonly label: string; + /** True while actively working (connecting/syncing) — render a spinner. False for offline/error/idle states — render a wifi-slash icon. */ + readonly showsProgress: boolean; +} + export function shouldShowWorkspaceConnectionStatus(state: WorkspaceState): boolean { return ( state.networkStatus === "offline" || @@ -24,3 +30,17 @@ export function workspaceConnectionStatusLabel(state: WorkspaceState): string { } return "Not connected"; } + +/** Header-title presentation of the connection state, or null while connected. */ +export function workspaceConnectionStatusPresentation( + state: WorkspaceState, +): WorkspaceConnectionStatusPresentation | null { + if (!shouldShowWorkspaceConnectionStatus(state)) return null; + return { + label: workspaceConnectionStatusLabel(state), + showsProgress: + state.networkStatus !== "offline" && + state.connectionError === null && + (state.connectingEnvironments.length > 0 || state.hasPendingShellSnapshot), + }; +} diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 8547859adde..90e5af199de 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -131,7 +131,7 @@ function LocalSettingsRouteScreen() { - + @@ -519,7 +519,7 @@ function ConfiguredSettingsRouteScreen() { - + @@ -533,31 +533,33 @@ function GeneralSettingsSection() { return ( + ); } /** - * Device-local beta toggles. Mobile has no client-settings sync, so this is - * the counterpart of web's Settings → Beta backed by mobile preferences. + * Device-local legacy toggles. Mobile has no client-settings sync, so this is + * the counterpart of web's Settings → General → Legacy features backed by + * mobile preferences. */ -function BetaSettingsSection() { +function LegacySettingsSection() { const savePreferences = useAtomSet(updateMobilePreferencesAtom); const threadListV2Enabled = useThreadListV2Enabled(); return ( - + savePreferences({ threadListV2Enabled: value })} + label="Legacy Thread List" + value={!threadListV2Enabled} + onValueChange={(value) => savePreferences({ legacyThreadListEnabled: value })} /> - One flat thread list in creation order. Active work renders as cards; settled threads - collapse to compact rows. Switch back any time. + Brings back the original grouped thread list. The default list is flat, in creation order: + active work renders as cards; settled threads collapse to compact rows. ); diff --git a/apps/mobile/src/features/settings/components/settings-sheet-targets.ts b/apps/mobile/src/features/settings/components/settings-sheet-targets.ts index df012c90325..7189fdc2ebe 100644 --- a/apps/mobile/src/features/settings/components/settings-sheet-targets.ts +++ b/apps/mobile/src/features/settings/components/settings-sheet-targets.ts @@ -3,6 +3,7 @@ export type SettingsSheetTarget = | "SettingsArchive" | "SettingsAppearance" | "SettingsProjectGrouping" - | "SettingsClientStorage"; + | "SettingsClientStorage" + | "SettingsUsage"; export type SettingsLegalDocumentTarget = "SettingsLegal"; diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 6b121d85108..bf2dfa8f4d4 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -25,15 +25,12 @@ import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStri import { ControlPill, ControlPillMenu } from "../../components/ControlPill"; import { ProviderIcon } from "../../components/ProviderIcon"; import { ComposerSurface } from "./ThreadComposer"; +import { ThreadSettingsSheet, threadSettingsSummaryLabel } from "./ThreadSettingsSheet"; +import { useThreadSettingsSheetPresentation } from "./use-thread-settings-sheet-presentation"; import { makeTurnCommandMetadata } from "../../lib/commandMetadata"; import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages"; -import { - applyProviderOptionMenuEvent, - buildProviderOptionMenuActions, - providerOptionsConfigurationLabel, - resolveProviderOptionDescriptors, -} from "../../lib/providerOptions"; +import { resolveProviderOptionDescriptors } from "../../lib/providerOptions"; import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; import { clearComposerDraftContent, @@ -43,7 +40,7 @@ import { type ComposerDraft, } from "../../state/use-composer-drafts"; import { useEnvironmentServerConfig, useProjects } from "../../state/entities"; -import { buildModelMenuActions, resolveSelectableModelSelection } from "../../lib/modelOptions"; +import { resolveSelectableModelSelection } from "../../lib/modelOptions"; import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn"; import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration"; import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../state/thread-outbox"; @@ -103,6 +100,10 @@ export function NewTaskDraftScreen(props: { const promptInputRef = useRef(null); const loadedBranchesProjectKeyRef = useRef(null); const [isComposerFocused, setIsComposerFocused] = useState(false); + const settingsSheetPresentation = useThreadSettingsSheetPresentation({ + editorRef: promptInputRef, + isEditorFocused: isComposerFocused, + }); const [importingShareKey, setImportingShareKey] = useState(null); const [isCancellingShareImport, setIsCancellingShareImport] = useState(false); const [cancelledIncomingShareId, setCancelledIncomingShareId] = useState(null); @@ -521,7 +522,15 @@ export function NewTaskDraftScreen(props: { let focusFrame: ReturnType | null = null; const interaction = InteractionManager.runAfterInteractions(() => { - focusFrame = requestAnimationFrame(() => promptInputRef.current?.focus()); + focusFrame = requestAnimationFrame(() => { + // The delayed focus can land after the settings sheet opened, which + // would pop the keyboard underneath its modal. + if (!settingsSheetPresentation.isActiveRef.current) { + promptInputRef.current?.focus(); + } else { + settingsSheetPresentation.restoreFocusAfterSave(); + } + }); }); return () => { @@ -530,7 +539,11 @@ export function NewTaskDraftScreen(props: { cancelAnimationFrame(focusFrame); } }; - }, [selectedProject]); + }, [ + selectedProject, + settingsSheetPresentation.isActiveRef, + settingsSheetPresentation.restoreFocusAfterSave, + ]); const environmentMenuActions = useMemo( () => @@ -544,10 +557,6 @@ export function NewTaskDraftScreen(props: { [flow.environments, flow.selectedEnvironmentId, isIncomingShareTransferPending], ); - const modelMenuActions = useMemo( - () => buildModelMenuActions(flow.providerGroups, flow.selectedModel), - [flow.providerGroups, flow.selectedModel], - ); const providerOptionDescriptors = useMemo( () => resolveProviderOptionDescriptors({ @@ -557,54 +566,6 @@ export function NewTaskDraftScreen(props: { [flow.selectedModel?.options, flow.selectedModelOption?.capabilities], ); - const optionsMenuActions = useMemo( - () => [ - ...buildProviderOptionMenuActions(providerOptionDescriptors), - { - id: "options-runtime", - title: "Runtime", - subtitle: - flow.runtimeMode === "approval-required" - ? "Approve actions" - : flow.runtimeMode === "auto-accept-edits" - ? "Auto-accept edits" - : flow.runtimeMode === "auto" - ? "Auto" - : "Full access", - subactions: [ - { id: "options:runtime:approval-required", title: "Approve actions" }, - { id: "options:runtime:auto-accept-edits", title: "Auto-accept edits" }, - { id: "options:runtime:auto", title: "Auto" }, - { id: "options:runtime:full-access", title: "Full access" }, - ].map((option) => { - const value = option.id.replace("options:runtime:", ""); - return { - id: option.id, - title: option.title, - state: flow.runtimeMode === value ? ("on" as const) : undefined, - }; - }), - }, - { - id: "options-interaction", - title: "Interaction", - subtitle: flow.interactionMode === "plan" ? "Plan" : "Default", - subactions: [ - { id: "options:interaction:default", title: "Default" }, - { id: "options:interaction:plan", title: "Plan" }, - ].map((option) => { - const value = option.id.replace("options:interaction:", ""); - return { - id: option.id, - title: option.title, - state: flow.interactionMode === value ? ("on" as const) : undefined, - }; - }), - }, - ], - [flow.interactionMode, flow.runtimeMode, providerOptionDescriptors], - ); - const workspaceMenuActions = useMemo(() => { const branchActions = flow.availableBranches.length === 0 @@ -675,10 +636,12 @@ export function NewTaskDraftScreen(props: { flow.availableBranches.find((branch) => branch.current)?.name ?? flow.availableBranches.find((branch) => branch.isDefault)?.name ?? null; - const configurationLabel = useMemo( - () => providerOptionsConfigurationLabel(providerOptionDescriptors), - [providerOptionDescriptors], - ); + const settingsSummaryLabel = threadSettingsSummaryLabel({ + modelLabel: flow.selectedModelOption?.label ?? "Model", + optionDescriptors: providerOptionDescriptors, + runtimeMode: flow.runtimeMode, + interactionMode: flow.interactionMode, + }); const workspaceLabel = useMemo( () => formatWorkspaceLabel({ @@ -688,13 +651,6 @@ export function NewTaskDraftScreen(props: { }), [currentBranchName, flow.selectedBranchName, flow.workspaceMode], ); - function handleModelMenuAction(event: string) { - if (isIncomingShareTransferPending || !event.startsWith("model:")) { - return; - } - flow.setSelectedModelKey(event.slice("model:".length)); - } - function handleEnvironmentMenuAction(event: string) { if (isIncomingShareTransferPending || !event.startsWith("environment:")) { return; @@ -702,28 +658,6 @@ export function NewTaskDraftScreen(props: { flow.selectEnvironment(EnvironmentId.make(event.slice("environment:".length))); } - function handleOptionsMenuAction(event: string) { - if (isIncomingShareTransferPending) { - return; - } - const providerOptions = applyProviderOptionMenuEvent(providerOptionDescriptors, event); - if (providerOptions) { - flow.setSelectedModelOptions(providerOptions); - return; - } - if (event.startsWith("options:runtime:")) { - flow.setRuntimeMode( - event.slice("options:runtime:".length) as Parameters[0], - ); - return; - } - if (event.startsWith("options:interaction:")) { - flow.setInteractionMode( - event.slice("options:interaction:".length) as Parameters[0], - ); - } - } - function handleWorkspaceMenuAction(event: string) { if (isIncomingShareTransferPending) { return; @@ -930,7 +864,9 @@ export function NewTaskDraftScreen(props: { const isDarkMode = colorScheme === "dark"; // Android expansion follows native editor focus so relayout cannot race // the touch gesture that opens the keyboard. - const isExpanded = !isAndroid || isComposerFocused; + // The settings sheet dismisses the keyboard, so its flag keeps the Android + // draft composer expanded through the blur (mirrors ThreadComposer). + const isExpanded = !isAndroid || isComposerFocused || settingsSheetPresentation.isActive; const canStart = Boolean(flow.selectedProject) && Boolean(flow.selectedModel) && @@ -984,28 +920,14 @@ export function NewTaskDraftScreen(props: { showChevron={false} disabled={isIncomingShareTransferPending} /> - handleModelMenuAction(nativeEvent.event)} - > - } - label={flow.selectedModelOption?.label ?? "Model"} - /> - - handleOptionsMenuAction(nativeEvent.event)} - > - - + } + label={settingsSummaryLabel} + maxWidth={320} + onPress={settingsSheetPresentation.open} + /> handleEnvironmentMenuAction(nativeEvent.event)} @@ -1031,6 +953,21 @@ export function NewTaskDraftScreen(props: { ); + const settingsSheet = ( + flow.setSelectedModelKey(option.key, option.selection.options)} + optionDescriptors={providerOptionDescriptors} + onUpdateOptionSelections={flow.setSelectedModelOptions} + runtimeMode={flow.runtimeMode} + onUpdateRuntimeMode={flow.setRuntimeMode} + /> + ); + const startButton = ( + {settingsSheet} ); } @@ -1153,6 +1091,7 @@ export function NewTaskDraftScreen(props: { + {settingsSheet} ); } diff --git a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx index bc044d905b4..7f4a68c08c7 100644 --- a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx @@ -296,6 +296,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps (null); const inputRef = props.editorRef ?? fallbackInputRef; const [isFocused, setIsFocused] = useState(false); + const settingsSheetPresentation = useThreadSettingsSheetPresentation({ + editorRef: inputRef, + isEditorFocused: isFocused, + }); const wasExpandedBeforePreviewRef = useRef(false); const inFlightThreadIdsRef = useRef(new Set()); const { onExpandedChange } = props; const [previewImageUri, setPreviewImageUri] = useState(null); const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0; - const isExpanded = isFocused; + // Opening and closing count as active so the composer stays expanded while + // focus moves between its native editor and the settings modal. + const isExpanded = isFocused || settingsSheetPresentation.isActive; const canSend = hasContent; + // Notify the parent from the derived value, not focus events: the parent + // sizes the feed inset from this, and blur-during-sheet would otherwise + // report collapsed while the composer still renders expanded. + useEffect(() => { + onExpandedChange?.(isExpanded); + }, [isExpanded, onExpandedChange]); + const onPressImage = useCallback( (uri: string) => { wasExpandedBeforePreviewRef.current = isFocused; @@ -299,13 +309,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const handleFocus = useCallback(() => { setIsFocused(true); - onExpandedChange?.(true); - }, [onExpandedChange]); + }, []); const handleBlur = useCallback(() => { setIsFocused(false); - onExpandedChange?.(false); - }, [onExpandedChange]); + }, []); const showStopAction = props.selectedThread.session?.status === "running" || props.selectedThread.session?.status === "starting"; @@ -588,6 +596,12 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer [props.serverConfig, currentModelSelection], ); const providerGroups = useMemo(() => groupByProvider(modelOptions), [modelOptions]); + // An existing thread is bound to its harness: sessions can't move between + // provider instances, so the picker only offers the thread's own group. + const threadProviderGroups = useMemo( + () => providerGroups.filter((group) => group.providerKey === currentModelSelection.instanceId), + [providerGroups, currentModelSelection.instanceId], + ); const currentModelOption = modelOptions.find( (option) => @@ -602,95 +616,12 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer }), [currentModelOption?.capabilities, currentModelSelection.options], ); - const configurationLabel = useMemo( - () => providerOptionsConfigurationLabel(providerOptionDescriptors), - [providerOptionDescriptors], - ); - const modelMenuActions = useMemo( - () => buildModelMenuActions(providerGroups, currentModelSelection), - [providerGroups, currentModelSelection], - ); - - // ── Options menu ───────────────────────────────────────── - const optionsMenuActions = useMemo( - () => [ - ...buildProviderOptionMenuActions(providerOptionDescriptors), - { - id: "options-runtime", - title: "Runtime", - subtitle: - currentRuntimeMode === "approval-required" - ? "Approve actions" - : currentRuntimeMode === "auto-accept-edits" - ? "Auto-accept edits" - : currentRuntimeMode === "auto" - ? "Auto" - : "Full access", - subactions: [ - { id: "options:runtime:approval-required", title: "Approve actions" }, - { id: "options:runtime:auto-accept-edits", title: "Auto-accept edits" }, - { id: "options:runtime:auto", title: "Auto" }, - { id: "options:runtime:full-access", title: "Full access" }, - ].map((option) => { - const value = option.id.replace("options:runtime:", ""); - return { - id: option.id, - title: option.title, - state: currentRuntimeMode === value ? ("on" as const) : undefined, - }; - }), - }, - { - id: "options-interaction", - title: "Interaction", - subtitle: currentInteractionMode === "plan" ? "Plan" : "Default", - subactions: [ - { id: "options:interaction:default", title: "Default" }, - { id: "options:interaction:plan", title: "Plan" }, - ].map((option) => { - const value = option.id.replace("options:interaction:", ""); - return { - id: option.id, - title: option.title, - state: currentInteractionMode === value ? ("on" as const) : undefined, - }; - }), - }, - ], - [currentInteractionMode, currentRuntimeMode, providerOptionDescriptors], - ); - - // ── Menu handlers ──────────────────────────────────────── - function handleModelMenuAction(event: string) { - if (!event.startsWith("model:")) { - return; - } - const modelKey = event.slice("model:".length); - const option = modelOptions.find((o) => o.key === modelKey); - if (option) { - props.onUpdateModelSelection(option.selection); - } - } - - function handleOptionsMenuAction(event: string) { - const providerOptions = applyProviderOptionMenuEvent(providerOptionDescriptors, event); - if (providerOptions) { - props.onUpdateModelSelection({ - ...currentModelSelection, - options: providerOptions, - }); - return; - } - if (event.startsWith("options:runtime:")) { - const runtimeMode = event.slice("options:runtime:".length) as RuntimeMode; - props.onUpdateRuntimeMode(runtimeMode); - return; - } - if (event.startsWith("options:interaction:")) { - const interactionMode = event.slice("options:interaction:".length) as ProviderInteractionMode; - props.onUpdateInteractionMode(interactionMode); - } - } + const settingsSummaryLabel = threadSettingsSummaryLabel({ + modelLabel: currentModelOption?.label ?? currentModelSelection.model, + optionDescriptors: providerOptionDescriptors, + runtimeMode: currentRuntimeMode, + interactionMode: currentInteractionMode, + }); return ( void props.onPickDraftImages()} showChevron={false} /> - handleModelMenuAction(nativeEvent.event)} - > - - } - label={currentModelOption?.label ?? currentModelSelection.model} - /> - - handleOptionsMenuAction(nativeEvent.event)} - > - - + + } + label={settingsSummaryLabel} + maxWidth={320} + onPress={settingsSheetPresentation.open} + /> {showStopAction ? ( + props.onUpdateModelSelection(option.selection)} + optionDescriptors={providerOptionDescriptors} + onUpdateOptionSelections={(options) => + props.onUpdateModelSelection({ ...currentModelSelection, options }) + } + runtimeMode={currentRuntimeMode} + onUpdateRuntimeMode={props.onUpdateRuntimeMode} + /> + (null); const [composerExpanded, setComposerExpanded] = useState(false); const [anchorMessageId, setAnchorMessageId] = useState(null); - const composerBottomInset = composerExpanded ? 0 : Math.max(insets.bottom, 12); + // Key the safe-area padding on keyboard visibility, not focus: on Android + // the back gesture closes the keyboard while the editor stays focused, and + // a focus-keyed inset would leave the toolbar under the gesture bar. + const isKeyboardVisible = useKeyboardState((state) => state.isVisible); + const composerBottomInset = isKeyboardVisible ? 0 : Math.max(insets.bottom, 12); const contentPresentationKind = props.contentPresentation.kind; // The raw sync status enters "synchronizing" on every full fetch, cached or // not. Whether messages are already on screen decides the pill label: no diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index db7fecf64ff..7933e4ca601 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -49,6 +49,7 @@ import Animated, { FadeIn, FadeInUp, type SharedValue } from "react-native-reani import { useThemeColor } from "../../lib/useThemeColor"; import { useFontFamily } from "../../lib/useFontFamily"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; +import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks"; import { hasNativeSelectableMarkdownText, SelectableMarkdownText, @@ -876,6 +877,12 @@ function renderFeedEntry( const timestampLabel = formatMessageTime(isUser ? message.createdAt : message.updatedAt); const attachments = message.attachments ?? []; const hasReviewCommentContext = message.text.includes(" {message.text.trim().length > 0 ? ( diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index e281d9a21c1..5caea851d22 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -11,6 +11,7 @@ import { LegendList } from "@legendapp/list/react-native"; import type { MenuAction } from "@react-native-menu/menu"; import { useAtomValue } from "@effect/atom-react"; import type { EnvironmentId } from "@t3tools/contracts"; +import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent } from "react-native"; import { Platform, Pressable, StyleSheet, TextInput, View, useColorScheme } from "react-native"; @@ -55,8 +56,10 @@ import { buildHomeProjectScopes, buildHomeThreadGroups } from "../home/homeThrea import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "../home/thread-swipe-actions"; import { usePendingTaskListActions } from "../home/usePendingTaskListActions"; import { useThreadListActions } from "../home/useThreadListActions"; -import { WorkspaceConnectionStatus } from "../home/WorkspaceConnectionStatus"; -import { shouldShowWorkspaceConnectionStatus } from "../home/workspace-connection-status"; +import { + getConnectionAwareBrandHeaderOptions, + WorkspaceConnectionTitle, +} from "../home/WorkspaceConnectionTitle"; import { SidebarHeaderActions } from "./sidebar-header-actions"; import { SidebarFilterButton } from "./sidebar-filter-button"; import { createSidebarHeaderItems } from "./sidebar-native-header-items"; @@ -207,6 +210,7 @@ function ThreadNavigationSidebarPane( unsettleThread, pinThread, unpinThread, + movePinnedThread, } = useThreadListActions(); const threadListV2Enabled = useThreadListV2Enabled(); const pendingTasks = usePendingNewTasks(); @@ -498,6 +502,28 @@ function ThreadNavigationSidebarPane( } return supported; }, [serverConfigs]); + const pinReorderEnvironmentIds = useMemo(() => { + const supported = new Set(); + for (const [environmentId, config] of serverConfigs) { + if (config.environment.capabilities.threadPinReorder === true) { + supported.add(environmentId); + } + } + return supported; + }, [serverConfigs]); + // Canonical arranged pinned order for Move up/down flags — computed from + // all shells so search/scope filtering never disables a valid move. + const arrangedPinnedKeys = useMemo(() => { + const pinned = sortPinnedThreadsByOrderKey( + threads.filter( + (thread) => + thread.pinnedAt != null && + thread.archivedAt === null && + pinReorderEnvironmentIds.has(thread.environmentId), + ), + ); + return pinned.map((thread) => `${thread.environmentId}:${thread.id}`); + }, [pinReorderEnvironmentIds, threads]); const threadListV2Layout = useMemo(() => { if (!threadListV2Enabled) return { @@ -606,7 +632,6 @@ function ThreadNavigationSidebarPane( threadListV2Enabled, threadListV2Layout, ]); - const showsConnectionStatus = shouldShowWorkspaceConnectionStatus(catalogState); const listMenuActions = useMemo( () => [ { @@ -935,11 +960,20 @@ function ThreadNavigationSidebarPane( onSettleThread={settleThread} snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)} pinningSupported={pinningEnvironmentIds.has(thread.environmentId)} + pinReorderSupported={pinReorderEnvironmentIds.has(thread.environmentId)} + canMovePinnedUp={ + arrangedPinnedKeys.indexOf(`${thread.environmentId}:${thread.id}`) > 0 + } + canMovePinnedDown={(() => { + const index = arrangedPinnedKeys.indexOf(`${thread.environmentId}:${thread.id}`); + return index !== -1 && index < arrangedPinnedKeys.length - 1; + })()} onSnoozeThread={snoozeThread} onUnsnoozeThread={unsnoozeThread} onUnsettleThread={unsettleThread} onPinThread={pinThread} onUnpinThread={unpinThread} + onMovePinnedThread={movePinnedThread} onChangeRequestState={handleChangeRequestState} projectCwd={projectCwdByKey.get(scopeKey) ?? null} onSwipeableClose={handleSwipeableClose} @@ -1060,13 +1094,16 @@ function ThreadNavigationSidebarPane( }, [ archiveThread, + arrangedPinnedKeys, confirmDeletePendingTask, confirmDeleteThread, handleChangeRequestState, handleSelectThread, handleSwipeableClose, handleSwipeableWillOpen, + movePinnedThread, openPendingTask, + pinReorderEnvironmentIds, pinThread, pinningEnvironmentIds, projectByKey, @@ -1159,6 +1196,13 @@ function ThreadNavigationSidebarPane( - - - ) : null - } ListEmptyComponent={listEmpty} /> @@ -1310,9 +1343,19 @@ function ThreadNavigationSidebarPane( - - Threads - + {/* Title slot doubles as the connection status surface: while an + environment reconnects, "Threads" fades to a status label in + place (no layout shift in the list below). */} + + Threads + + } + /> - - {showsConnectionStatus ? ( - - - - ) : null} ); diff --git a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx new file mode 100644 index 00000000000..9c27e6f01c5 --- /dev/null +++ b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx @@ -0,0 +1,678 @@ +import type { + ModelSelection, + ProviderInteractionMode, + ProviderOptionDescriptor, + ProviderOptionSelection, + RuntimeMode, +} from "@t3tools/contracts"; +import { + getProviderOptionCurrentLabel, + getProviderOptionCurrentValue, + getProviderOptionDescriptors, +} from "@t3tools/shared/model"; +import * as Haptics from "expo-haptics"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { + Modal, + Platform, + Pressable, + ScrollView, + Switch, + useWindowDimensions, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { SymbolView } from "../../components/AppSymbol"; +import { AppText as Text } from "../../components/AppText"; +import { ProviderIcon } from "../../components/ProviderIcon"; +import { cn } from "../../lib/cn"; +import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; +import { applyProviderOptionSelection, providerOptionValueLabels } from "../../lib/providerOptions"; +import { useThemeColor } from "../../lib/useThemeColor"; +import { pendingModelAfterPress } from "./thread-settings-sheet-state"; +import type { ThreadSettingsSheetCloseReason } from "./use-thread-settings-sheet-presentation"; + +/** + * The everyday harnesses stay expanded; every other provider (OpenRouter + * catalogs and friends) folds behind its header so a 300-model catalog can't + * bury the list. + */ +const PRIMARY_PROVIDER_DRIVERS: ReadonlySet = new Set(["claudeAgent", "codex"]); + +/** + * Desktop-oriented effort keywords that don't belong in the phone picker. + * Prompt-injected values (ultrathink and friends) are filtered from the + * descriptor metadata; ultracode is a real option but a workflow trigger, not + * a reasoning level. A value set elsewhere still displays, it just isn't + * offered. + */ +const HIDDEN_EFFORT_OPTION_IDS: ReadonlySet = new Set(["ultracode"]); + +const RUNTIME_MODE_CHOICES: ReadonlyArray<{ + readonly mode: RuntimeMode; + readonly label: string; + readonly shortLabel: string; +}> = [ + { mode: "approval-required", label: "Approve actions", shortLabel: "Approve" }, + { mode: "auto-accept-edits", label: "Auto-accept edits", shortLabel: "Edits" }, + { mode: "auto", label: "Auto", shortLabel: "Auto" }, + { mode: "full-access", label: "Full access", shortLabel: "Full" }, +]; + +/** + * Compact "Fable 5 · Max · Auto" style summary for the composer trigger pill, + * covering model, provider options, runtime mode, and plan mode in one label. + */ +export function threadSettingsSummaryLabel(input: { + readonly modelLabel: string; + readonly optionDescriptors: ReadonlyArray; + readonly runtimeMode: RuntimeMode; + readonly interactionMode: ProviderInteractionMode; +}): string { + const runtime = RUNTIME_MODE_CHOICES.find((choice) => choice.mode === input.runtimeMode); + return [ + input.modelLabel, + ...providerOptionValueLabels(input.optionDescriptors), + ...(runtime ? [runtime.shortLabel] : []), + ...(input.interactionMode === "plan" ? ["Plan"] : []), + ].join(" · "); +} + +function selectableChoices(descriptor: Extract) { + const injected = new Set(descriptor.promptInjectedValues ?? []); + return descriptor.options.filter( + (option) => !injected.has(option.id) && !HIDDEN_EFFORT_OPTION_IDS.has(option.id), + ); +} + +function ModelRow(props: { + readonly option: ModelOption; + readonly selected: boolean; + readonly onPress: () => void; +}) { + const primaryFg = useThemeColor("--color-primary-foreground"); + return ( + + + {props.option.label} + + {props.option.isDefault ? ( + + Default + + ) : null} + {props.option.isLegacy ? ( + + Legacy + + ) : null} + + {props.selected ? ( + + ) : null} + + ); +} + +/** + * Provider section header with the harness logo. Secondary providers render + * as a tappable fold (count + chevron while collapsed); primary providers + * and the group holding the current selection are static headers. + */ +function ProviderHeader(props: { + readonly driver: string | undefined; + readonly label: string; + readonly collapsible: boolean; + readonly collapsed: boolean; + readonly modelCount: number; + readonly onToggle: () => void; +}) { + const iconSubtle = useThemeColor("--color-icon-subtle"); + return ( + + + + {props.label} + + {props.collapsible ? ( + <> + + {props.collapsed ? ( + + {props.modelCount} + + ) : null} + + + ) : null} + + ); +} + +/** Compact row that opens a single-choice submenu panel. */ +function DisclosureRow(props: { + readonly label: string; + readonly value: string | undefined; + readonly disabled?: boolean; + readonly onPress: () => void; +}) { + const iconSubtle = useThemeColor("--color-icon-subtle"); + return ( + + {props.label} + + {props.value ? ( + + {props.value} + + ) : null} + + + ); +} + +/** Single option inside a submenu panel. */ +function ChoiceRow(props: { + readonly label: string; + readonly selected: boolean; + readonly onPress: () => void; +}) { + const primaryFg = useThemeColor("--color-primary-foreground"); + return ( + + + {props.label} + + + {props.selected ? ( + + ) : null} + + ); +} + +function SwitchRow(props: { + readonly label: string; + readonly value: boolean; + readonly disabled?: boolean; + readonly onValueChange: (value: boolean) => void; +}) { + const activeTrack = String(useThemeColor("--color-switch-active")); + const track = String(useThemeColor("--color-secondary-border")); + return ( + + {props.label} + + + ); +} + +type SubmenuPage = + | { readonly kind: "descriptor"; readonly id: string } + | { readonly kind: "runtime" }; + +/** + * Unified thread settings: the sheet is the provider-grouped model list + * (primary harnesses expanded, other providers folded, legacy behind the + * top-right pill) with a Save button, plus compact disclosure rows whose + * single-choice submenus stack in a small panel over the sheet so it never + * changes size. Model changes stage until Save — while staged, the settings + * rows edit the staged model's options and Save applies everything together. + * + * Callers control which harnesses are offered via providerGroups: an + * existing thread must pass only its own provider's group, since a session + * can't switch harness mid-thread. + * + * Rendered through an RN Modal (not the root OverlayPortal) so it also + * presents above natively-presented form sheets like the new-task draft. + * Callers must dismiss the keyboard when opening — the iOS keyboard window + * would otherwise cover the lower half of the sheet. + */ +export function ThreadSettingsSheet(props: { + readonly visible: boolean; + /** + * "save" = the Save/Done button (the user is finished configuring); + * "dismiss" = backdrop, grabber, or system back. Hosts only restore the + * keyboard for "save" so a stray tap outside a control never pops it. + */ + readonly onClose: (reason: ThreadSettingsSheetCloseReason) => void; + readonly onDismissed: () => void; + readonly providerGroups: ReadonlyArray; + readonly selectedModel: ModelSelection | null; + readonly onSelectModel: (option: ModelOption) => void; + readonly optionDescriptors: ReadonlyArray; + readonly onUpdateOptionSelections: (selections: ReadonlyArray) => void; + readonly runtimeMode: RuntimeMode; + readonly onUpdateRuntimeMode: (mode: RuntimeMode) => void; +}) { + const insets = useSafeAreaInsets(); + const { height: windowHeight } = useWindowDimensions(); + const [showLegacyToggle, setShowLegacyToggle] = useState(false); + const [expandedProviders, setExpandedProviders] = useState>(() => new Set()); + const [pendingModel, setPendingModel] = useState(null); + const [submenu, setSubmenu] = useState(null); + const wasPresentedRef = useRef(false); + const notifyDismissed = useCallback(() => { + if (!wasPresentedRef.current) { + return; + } + wasPresentedRef.current = false; + props.onDismissed(); + }, [props.onDismissed]); + + // Every open starts fresh: no staged model, no submenu, legacy hidden, + // secondary providers folded. The sheet stays mounted between opens, so + // state would otherwise stick around. + useEffect(() => { + if (props.visible) { + wasPresentedRef.current = true; + setShowLegacyToggle(false); + setExpandedProviders(new Set()); + setPendingModel(null); + setSubmenu(null); + } else if (Platform.OS === "android" && wasPresentedRef.current) { + // React Native only emits Modal.onDismiss on iOS. Android uses no exit + // animation below, so the post-commit effect is its dismissal boundary. + notifyDismissed(); + } + }, [notifyDismissed, props.visible]); + + const isApplied = (option: ModelOption) => + option.selection.instanceId === props.selectedModel?.instanceId && + option.selection.model === props.selectedModel.model; + // The list highlights the staged pick; Save turns it into the applied one. + const isDisplayed = (option: ModelOption) => + pendingModel ? option.key === pendingModel.key : isApplied(option); + + // While a model is staged, the settings rows describe and edit the staged + // model's options (kept on its pending selection); Save applies model and + // options together. Otherwise they edit the applied selection directly. + const displayedDescriptors = pendingModel + ? pendingModel.capabilities + ? getProviderOptionDescriptors({ + caps: pendingModel.capabilities, + selections: pendingModel.selection.options, + }) + : [] + : props.optionDescriptors; + + const hasLegacyModels = props.providerGroups.some((group) => + group.models.some((model) => model.isLegacy), + ); + // Legacy stays hidden unless the pill is toggled this open; a highlighted + // legacy model is exempted from the filter instead of forcing the whole + // legacy list visible. + const showLegacy = showLegacyToggle; + + // Stable settings rows: the union of descriptors across the primary + // harnesses' current models (plus whatever the displayed model advertises) + // always renders, with unsupported rows disabled instead of vanishing when + // the selection changes. Keyed by label, not id — Claude and Codex use + // different ids for the same "Reasoning" concept. + const descriptorTemplate = (() => { + const seen = new Map(); + for (const group of props.providerGroups) { + const driver = group.models[0]?.providerDriver; + if (driver === undefined || !PRIMARY_PROVIDER_DRIVERS.has(driver)) { + continue; + } + for (const model of group.models) { + if (model.isLegacy) { + continue; + } + for (const descriptor of model.capabilities?.optionDescriptors ?? []) { + if (!seen.has(descriptor.label)) { + seen.set(descriptor.label, { type: descriptor.type }); + } + } + } + } + for (const descriptor of displayedDescriptors) { + if (!seen.has(descriptor.label)) { + seen.set(descriptor.label, { type: descriptor.type }); + } + } + return [...seen.entries()].map(([label, entry]) => ({ label, ...entry })); + })(); + + const handleSave = () => { + if (pendingModel) { + void Haptics.selectionAsync(); + props.onSelectModel(pendingModel); + } + props.onClose("save"); + }; + + const handleOptionChange = (id: string, value: string | boolean) => { + const next = applyProviderOptionSelection(displayedDescriptors, { id, value }); + if (!next) { + return; + } + if (pendingModel) { + setPendingModel({ + ...pendingModel, + selection: { ...pendingModel.selection, options: next }, + }); + } else { + props.onUpdateOptionSelections(next); + } + }; + + const toggleProvider = (providerKey: string) => { + setExpandedProviders((current) => { + const next = new Set(current); + if (!next.delete(providerKey)) { + next.add(providerKey); + } + return next; + }); + }; + + const activeDescriptor = + submenu?.kind === "descriptor" + ? displayedDescriptors.find( + (descriptor) => descriptor.type === "select" && descriptor.id === submenu.id, + ) + : undefined; + + const submenuContent = + submenu?.kind === "runtime" + ? { + title: "Runtime", + rows: RUNTIME_MODE_CHOICES.map((choice) => ({ + id: choice.mode, + label: choice.label, + selected: choice.mode === props.runtimeMode, + onPress: () => { + void Haptics.selectionAsync(); + props.onUpdateRuntimeMode(choice.mode); + setSubmenu(null); + }, + })), + } + : activeDescriptor?.type === "select" + ? { + title: activeDescriptor.label, + rows: selectableChoices(activeDescriptor).map((choice) => ({ + id: choice.id, + label: choice.label, + selected: choice.id === getProviderOptionCurrentValue(activeDescriptor), + onPress: () => { + void Haptics.selectionAsync(); + handleOptionChange(activeDescriptor.id, choice.id); + setSubmenu(null); + }, + })), + } + : null; + + return ( + setSubmenu(null) : () => props.onClose("dismiss")} + > + + props.onClose("dismiss")} + /> + + {/* The grabber doubles as the accessible close control: the dim + backdrop above a tall sheet is a sliver, and VoiceOver can't + reach it at all. */} + props.onClose("dismiss")} + className="items-center pb-1 pt-2.5" + > + + + {hasLegacyModels ? ( + + { + void Haptics.selectionAsync(); + setShowLegacyToggle(!showLegacy); + }} + className="rounded-full border border-border bg-subtle px-3 py-1.5 active:opacity-70" + > + + {showLegacy ? "Hide legacy models" : "Show legacy models"} + + + + ) : null} + {/* Only the model list scrolls. Provider catalogs can run to + hundreds of models (OpenRouter), so the rows below stay pinned + and reachable instead of living at the end of that scroll. */} + + {props.providerGroups.map((group) => { + const driver = group.models[0]?.providerDriver; + const isPrimary = driver !== undefined && PRIMARY_PROVIDER_DRIVERS.has(driver); + const visibleModels = showLegacy + ? group.models + : group.models.filter((model) => !model.isLegacy || isDisplayed(model)); + if (visibleModels.length === 0) { + return null; + } + const containsSelection = group.models.some(isDisplayed); + const collapsible = !isPrimary && !containsSelection; + const collapsed = collapsible && !expandedProviders.has(group.providerKey); + return ( + + toggleProvider(group.providerKey)} + /> + {collapsed + ? null + : visibleModels.map((option) => ( + { + void Haptics.selectionAsync(); + // Re-tapping the applied model cancels staging. + setPendingModel((current) => + pendingModelAfterPress({ + current, + pressed: option, + pressedIsApplied: isApplied(option), + }), + ); + }} + /> + ))} + + ); + })} + + + + + + {descriptorTemplate.map((entry) => { + const live = displayedDescriptors.find( + (descriptor) => descriptor.label === entry.label, + ); + if ((live?.type ?? entry.type) === "select") { + return ( + { + if (live) { + setSubmenu({ kind: "descriptor", id: live.id }); + } + }} + /> + ); + } + return ( + { + if (live) { + handleOptionChange(live.id, value); + } + }} + /> + ); + })} + choice.mode === props.runtimeMode)?.label + } + onPress={() => setSubmenu({ kind: "runtime" })} + /> + + + {pendingModel ? "Save" : "Done"} + + + + + + {/* Submenus stack over the sheet instead of replacing its content, + so the main sheet keeps its size while drilling in and out. */} + {submenuContent ? ( + + setSubmenu(null)} + /> + + setSubmenu(null)} + className="items-center pb-1 pt-2.5" + > + + + + {submenuContent.title} + + + {submenuContent.rows.map((row) => ( + + ))} + + + + ) : null} + + + ); +} diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 18bacd12577..7d79e9ecead 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -3,6 +3,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react" import type { EnvironmentId, ModelSelection, + ProjectReadFileResult, ProviderInteractionMode, ProviderOptionSelection, RuntimeMode, @@ -13,8 +14,14 @@ import { DEFAULT_PROVIDER_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, MessageId, + T3_PROJECT_FILE_NAME, ThreadId, } from "@t3tools/contracts"; +import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile"; +import { + isDefaultThreadEnvModeSettled, + resolveDefaultThreadEnvMode, +} from "@t3tools/shared/threadEnvMode"; import * as Arr from "effect/Array"; import { pipe } from "effect/Function"; @@ -25,10 +32,13 @@ import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; import { buildModelOptions, groupByProvider, + resolveDefaultableModelSelection, resolveSelectableModelSelection, } from "../../lib/modelOptions"; import { scopedProjectKey } from "../../lib/scopedEntities"; import { appAtomRegistry } from "../../state/atom-registry"; +import { projectEnvironment } from "../../state/projects"; +import { useEnvironmentQuery } from "../../state/query"; import { appendComposerDraftAttachments, clearComposerDraft, @@ -147,7 +157,10 @@ type NewTaskFlowContextValue = { readonly reset: () => void; readonly setProject: (project: EnvironmentProject) => void; readonly selectEnvironment: (environmentId: EnvironmentId) => void; - readonly setSelectedModelKey: (key: string | null) => void; + readonly setSelectedModelKey: ( + key: string | null, + options?: ReadonlyArray, + ) => void; readonly setWorkspaceMode: (mode: WorkspaceMode) => void; readonly selectBranch: (branch: VcsRef) => void; readonly setStartFromOrigin: (value: boolean) => void; @@ -341,10 +354,35 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const selectedProjectDraft = useComposerDraft(selectedProjectDraftKey); const prompt = selectedProjectDraft.text; const attachments = selectedProjectDraft.attachments; - // The server's configured default decides the mode until the user picks one - // explicitly — same resolution web uses for new draft threads. - const defaultWorkspaceMode: WorkspaceMode = - selectedEnvironmentServerConfig?.settings.defaultThreadEnvMode ?? "local"; + // Default mode until the user picks one explicitly — same resolution web + // uses for new draft threads: per-project setting, then the repo's + // checked-in t3.json, then the server's configured default. + const t3ProjectFileQuery = useEnvironmentQuery( + selectedProject !== null && selectedProject.workspaceRoot !== "" + ? projectEnvironment.readFile({ + environmentId: selectedProject.environmentId, + input: { cwd: selectedProject.workspaceRoot, relativePath: T3_PROJECT_FILE_NAME }, + }) + : null, + ); + const t3ProjectFileData = t3ProjectFileQuery.data as ProjectReadFileResult | null; + const t3ProjectFileDefaultMode = useMemo(() => { + if (t3ProjectFileData === null || t3ProjectFileData.truncated) return null; + return parseT3ProjectFile(t3ProjectFileData.contents)?.defaultThreadEnvMode ?? null; + }, [t3ProjectFileData]); + const defaultWorkspaceMode: WorkspaceMode = resolveDefaultThreadEnvMode({ + projectSetting: selectedProject?.defaultThreadEnvMode, + projectFile: t3ProjectFileDefaultMode, + globalDefault: selectedEnvironmentServerConfig?.settings.defaultThreadEnvMode ?? "local", + }); + // While unsettled the resolved default is provisional. Nothing may write + // it into the draft during that window (the auto-branch effect does), or + // the frozen interim value beats the t3.json default once it loads. + const defaultWorkspaceModeSettled = isDefaultThreadEnvModeSettled({ + explicitMode: selectedProjectDraft.workspaceSelection?.mode, + projectSetting: selectedProject?.defaultThreadEnvMode, + projectFilePending: t3ProjectFileQuery.isPending, + }); const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? defaultWorkspaceMode; const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null; const selectedWorktreePath = selectedProjectDraft.workspaceSelection?.worktreePath ?? null; @@ -359,14 +397,16 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE; const interactionMode = selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE; - // Stored selections (draft and project default) only count while their - // provider is usable on the server; otherwise the server's default model - // wins instead of silently targeting a disabled provider. + // Stored selections only count while their provider is usable on the + // server; otherwise the server's default model wins instead of silently + // targeting a disabled provider. The draft selection is an explicit pick + // and passes through as-is; the project default (last used, possibly from + // desktop) is implicit and additionally never resolves to a legacy model. const draftModelSelection = resolveSelectableModelSelection( selectedEnvironmentServerConfig, selectedProjectDraft.modelSelection ?? null, ); - const projectDefaultModelSelection = resolveSelectableModelSelection( + const projectDefaultModelSelection = resolveDefaultableModelSelection( selectedEnvironmentServerConfig, selectedProject?.defaultModelSelection ?? null, ); @@ -404,7 +444,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { [selectedEnvironmentServerConfig, selectedModel?.instanceId], ); const setSelectedModelKey = useCallback( - (key: string | null) => { + // Options ride along in the same write: a follow-up setSelectedModelOptions + // call would rebuild the selection from the stale pre-switch model. + (key: string | null, options?: ReadonlyArray) => { if (!key || !selectedProjectDraftKey) { return; } @@ -413,7 +455,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { return; } updateComposerDraftSettings(selectedProjectDraftKey, { - modelSelection: option.selection, + modelSelection: options ? { ...option.selection, options } : option.selection, }); }, [modelOptions, selectedProjectDraftKey], @@ -610,7 +652,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { }, [refreshBranches, selectedProject]); useEffect(() => { - if (workspaceMode !== "worktree" || selectedBranchName !== null) { + if ( + !defaultWorkspaceModeSettled || + workspaceMode !== "worktree" || + selectedBranchName !== null + ) { return; } // The default may only exist as origin/ (isRemote), which @@ -622,7 +668,14 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { if (preferredBranch) { selectBranch(preferredBranch); } - }, [allBranchRefs, availableBranches, selectBranch, selectedBranchName, workspaceMode]); + }, [ + allBranchRefs, + availableBranches, + defaultWorkspaceModeSettled, + selectBranch, + selectedBranchName, + workspaceMode, + ]); const setRuntimeMode = useCallback( (value: RuntimeMode) => { diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx index 855713946ff..8f32df5c726 100644 --- a/apps/mobile/src/features/threads/thread-list-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-items.tsx @@ -134,6 +134,7 @@ export const ThreadListGroupHeader = memo(function ThreadListGroupHeader(props: > void; + /** Position flags for the pinned block so the menu disables the move that + would fall off the end of the list. */ + readonly canMovePinnedUp?: boolean; + readonly canMovePinnedDown?: boolean; readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; readonly onSwipeableClose: (methods: SwipeableMethods) => void; /** Reports this row's live PR state up so the partition can auto-settle @@ -384,6 +393,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { onArchiveThread, onPinThread, onUnpinThread, + onMovePinnedThread, onChangeRequestState, } = props; const snoozedRow = props.snoozed === true; @@ -419,6 +429,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const handleUnsettle = useCallback(() => onUnsettleThread(thread), [onUnsettleThread, thread]); const handlePin = useCallback(() => onPinThread(thread), [onPinThread, thread]); const handleUnpin = useCallback(() => onUnpinThread(thread), [onUnpinThread, thread]); + const handleMovePinnedUp = useCallback( + () => onMovePinnedThread?.(thread, "up"), + [onMovePinnedThread, thread], + ); + const handleMovePinnedDown = useCallback( + () => onMovePinnedThread?.(thread, "down"), + [onMovePinnedThread, thread], + ); const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]); // Swipe: the v2 primary action is the lifecycle transition. Every settled @@ -462,12 +480,34 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { () => props.pinningSupported ? [ + ...(pinnedRow && props.pinReorderSupported === true + ? [ + { + id: "move-pin-up", + title: "Move up", + image: "arrow.up", + attributes: { disabled: props.canMovePinnedUp !== true }, + } satisfies MenuAction, + { + id: "move-pin-down", + title: "Move down", + image: "arrow.down", + attributes: { disabled: props.canMovePinnedDown !== true }, + } satisfies MenuAction, + ] + : []), pinnedRow ? { id: "unpin", title: "Unpin", image: "pin.slash" } : { id: "pin", title: "Pin", image: "pin" }, ] : [], - [pinnedRow, props.pinningSupported], + [ + pinnedRow, + props.canMovePinnedDown, + props.canMovePinnedUp, + props.pinReorderSupported, + props.pinningSupported, + ], ); const snoozableCardMenuActions = useMemo( () => [ @@ -494,6 +534,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { if (nativeEvent.event === "unsnooze") handleUnsnooze(); if (nativeEvent.event === "pin") handlePin(); if (nativeEvent.event === "unpin") handleUnpin(); + if (nativeEvent.event === "move-pin-up") handleMovePinnedUp(); + if (nativeEvent.event === "move-pin-down") handleMovePinnedDown(); if (nativeEvent.event === "archive") handleArchive(); if (nativeEvent.event === "delete") handleDelete(); const snoozeSelection = resolveThreadListV2SnoozeMenuSelection({ @@ -510,6 +552,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { [ handleArchive, handleDelete, + handleMovePinnedDown, + handleMovePinnedUp, handlePin, handleSettle, handleSnooze, @@ -590,6 +634,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { {props.project ? ( = [], +): ModelOption { + return { + key: `codex:${model}`, + label: model, + subtitle: "Codex", + providerKey: "codex", + providerLabel: "Codex", + providerDriver: "codex", + isDefault: false, + isLegacy: false, + capabilities: null, + selection: { + instanceId: ProviderInstanceId.make("codex"), + model, + options, + }, + }; +} + +describe("thread settings sheet state", () => { + it("clears staging when the applied model is pressed", () => { + expect( + pendingModelAfterPress({ + current: modelOption("gpt-next"), + pressed: modelOption("gpt-current"), + pressedIsApplied: true, + }), + ).toBeNull(); + }); + + it("preserves staged options when the highlighted model is pressed again", () => { + const pending = modelOption("gpt-next", [{ id: "effort", value: "high" }]); + + expect( + pendingModelAfterPress({ + current: pending, + pressed: modelOption("gpt-next"), + pressedIsApplied: false, + }), + ).toBe(pending); + }); + + it("stages a different model", () => { + const pressed = modelOption("gpt-other"); + + expect( + pendingModelAfterPress({ + current: modelOption("gpt-next"), + pressed, + pressedIsApplied: false, + }), + ).toBe(pressed); + }); +}); diff --git a/apps/mobile/src/features/threads/thread-settings-sheet-state.ts b/apps/mobile/src/features/threads/thread-settings-sheet-state.ts new file mode 100644 index 00000000000..f0540dc5a97 --- /dev/null +++ b/apps/mobile/src/features/threads/thread-settings-sheet-state.ts @@ -0,0 +1,13 @@ +import type { ModelOption } from "../../lib/modelOptions"; + +/** Preserve staged provider options when the highlighted model is tapped again. */ +export function pendingModelAfterPress(input: { + readonly current: ModelOption | null; + readonly pressed: ModelOption; + readonly pressedIsApplied: boolean; +}): ModelOption | null { + if (input.pressedIsApplied) { + return null; + } + return input.current?.key === input.pressed.key ? input.current : input.pressed; +} diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index 1316b3480c0..a9ea0138b84 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -104,20 +104,24 @@ describe("resolveThreadListV2SnoozeMenuSelection", () => { describe("resolveThreadListV2Enabled", () => { it("defaults on when the device has never chosen", () => { - expect(resolveThreadListV2Enabled({ preference: undefined, preferencesLoaded: true })).toBe( - true, - ); + expect( + resolveThreadListV2Enabled({ legacyPreference: undefined, preferencesLoaded: true }), + ).toBe(true); }); - it("honors an explicit device opt-out", () => { - expect(resolveThreadListV2Enabled({ preference: false, preferencesLoaded: true })).toBe(false); - expect(resolveThreadListV2Enabled({ preference: true, preferencesLoaded: true })).toBe(true); + it("honors an explicit legacy opt-in", () => { + expect(resolveThreadListV2Enabled({ legacyPreference: true, preferencesLoaded: true })).toBe( + false, + ); + expect(resolveThreadListV2Enabled({ legacyPreference: false, preferencesLoaded: true })).toBe( + true, + ); }); it("holds the default while preferences are still loading so the list does not remount", () => { - expect(resolveThreadListV2Enabled({ preference: undefined, preferencesLoaded: false })).toBe( - true, - ); + expect( + resolveThreadListV2Enabled({ legacyPreference: undefined, preferencesLoaded: false }), + ).toBe(true); }); }); diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index eaf4ed520cc..734efe8b736 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -9,6 +9,7 @@ import { import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; +import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort"; import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; @@ -102,23 +103,23 @@ export const THREAD_LIST_V2_SETTLED_INITIAL_COUNT = 10; export const THREAD_LIST_V2_SETTLED_PAGE_COUNT = 25; /** - * Thread List v2 is on by default on every app variant; the Settings → Beta - * toggle is an opt-out. Preferences persist as sparse patches, so `undefined` - * genuinely means "never chosen". + * The flat Thread List v2 is the default on every app variant; the Settings → + * Legacy toggle opts a device back into the grouped legacy list. Preferences + * persist as sparse patches, so `undefined` genuinely means "never chosen". * * `preferencesLoaded` guards the startup window: preferences load * asynchronously, and rendering one list before the stored choice arrives would * remount the whole thing a tick later. While loading, hold the default — that - * is where every device without an explicit opt-out lands anyway. + * is where every device without an explicit legacy opt-in lands anyway. */ export function resolveThreadListV2Enabled(input: { - readonly preference: boolean | undefined; + readonly legacyPreference: boolean | undefined; readonly preferencesLoaded: boolean; }): boolean { if (!input.preferencesLoaded) { return true; } - return input.preference ?? true; + return input.legacyPreference !== true; } export function resolveThreadListV2Status( @@ -388,8 +389,8 @@ export function buildThreadListV2Items(input: { input.changeRequestStateByKey?.get(`${thread.environmentId}:${thread.id}`) ?? null; // Visibility parity with web: snooze outranks everything, including a // pin — a snoozed thread leaves the list until it wakes (or raises its - // hand). The pin survives underneath, so a woken thread reappears at - // its original spot in the creation-ordered pinned block. + // hand). The pin (and its pinOrderKey) survives underneath, so a woken + // thread reappears at its exact spot in the pinned block. if (supportsSnooze && effectiveSnoozed(thread, { now: snoozeNow })) { snoozed.push(thread); if ( @@ -454,7 +455,7 @@ export function buildThreadListV2Items(input: { ); const items: ThreadListV2Item[] = []; - for (const thread of sortThreadsForListV2(pinned)) { + for (const thread of sortPinnedThreadsByOrderKey(pinned)) { items.push({ thread, variant: "card", diff --git a/apps/mobile/src/features/threads/use-thread-list-v2-enabled.ts b/apps/mobile/src/features/threads/use-thread-list-v2-enabled.ts index 266bda944ae..2672942c2d3 100644 --- a/apps/mobile/src/features/threads/use-thread-list-v2-enabled.ts +++ b/apps/mobile/src/features/threads/use-thread-list-v2-enabled.ts @@ -5,15 +5,15 @@ import { mobilePreferencesAtom } from "../../state/preferences"; import { resolveThreadListV2Enabled } from "./threadListV2"; /** - * Resolved Thread List v2 state: the device-local preference if the user has - * set one, otherwise the default (on). Every consumer must read through this + * Resolved Thread List v2 state: on unless the device opted into the legacy + * grouped list (Settings → Legacy). Every consumer must read through this * rather than the raw preference, which is undefined until explicitly chosen. */ export function useThreadListV2Enabled(): boolean { const preferencesResult = useAtomValue(mobilePreferencesAtom); const loaded = AsyncResult.isSuccess(preferencesResult); return resolveThreadListV2Enabled({ - preference: loaded ? preferencesResult.value.threadListV2Enabled : undefined, + legacyPreference: loaded ? preferencesResult.value.legacyThreadListEnabled : undefined, preferencesLoaded: loaded, }); } diff --git a/apps/mobile/src/features/threads/use-thread-settings-sheet-presentation.ts b/apps/mobile/src/features/threads/use-thread-settings-sheet-presentation.ts new file mode 100644 index 00000000000..3cc2ed18468 --- /dev/null +++ b/apps/mobile/src/features/threads/use-thread-settings-sheet-presentation.ts @@ -0,0 +1,98 @@ +import { useCallback, useEffect, useRef, useState, type RefObject } from "react"; +import { KeyboardController } from "react-native-keyboard-controller"; + +import type { ComposerEditorHandle } from "../../components/ComposerEditor"; + +export type ThreadSettingsSheetCloseReason = "save" | "dismiss"; + +type PresentationPhase = "closed" | "opening" | "visible" | "closing"; + +/** + * Keeps the custom native composer and the settings modal from owning focus at + * the same time. Opening waits for the keyboard dismissal to finish, while + * focus restoration waits for the modal's dismissal callback. + */ +export function useThreadSettingsSheetPresentation(input: { + readonly editorRef: RefObject; + readonly isEditorFocused: boolean; +}) { + const [phase, setPhase] = useState("closed"); + const isActiveRef = useRef(false); + const isMountedRef = useRef(true); + const openingIdRef = useRef(0); + const restoreFocusOnSaveRef = useRef(false); + const shouldRestoreAfterDismissRef = useRef(false); + + useEffect( + () => () => { + isMountedRef.current = false; + isActiveRef.current = false; + openingIdRef.current += 1; + }, + [], + ); + + const open = useCallback(() => { + if (isActiveRef.current) { + return; + } + + isActiveRef.current = true; + restoreFocusOnSaveRef.current = input.isEditorFocused || KeyboardController.isVisible(); + shouldRestoreAfterDismissRef.current = false; + setPhase("opening"); + + const openingId = openingIdRef.current + 1; + openingIdRef.current = openingId; + + // Keyboard.dismiss() only tracks React Native TextInputs. The composer is + // a custom native text view, so explicitly resign its first responder too. + input.editorRef.current?.blur(); + void KeyboardController.dismiss().then(() => { + if (!isMountedRef.current || !isActiveRef.current || openingIdRef.current !== openingId) { + return; + } + setPhase("visible"); + }); + }, [input.editorRef, input.isEditorFocused]); + + const close = useCallback((reason: ThreadSettingsSheetCloseReason) => { + if (!isActiveRef.current) { + return; + } + + openingIdRef.current += 1; + shouldRestoreAfterDismissRef.current = reason === "save" && restoreFocusOnSaveRef.current; + setPhase("closing"); + }, []); + + const onDismissed = useCallback(() => { + const shouldRestoreFocus = shouldRestoreAfterDismissRef.current; + shouldRestoreAfterDismissRef.current = false; + restoreFocusOnSaveRef.current = false; + isActiveRef.current = false; + setPhase("closed"); + + if (shouldRestoreFocus) { + input.editorRef.current?.focus(); + } + }, [input.editorRef]); + + // The new-task screen can have an autofocus queued before the sheet opens. + // Preserve that intent for Save without allowing it to focus under the modal. + const restoreFocusAfterSave = useCallback(() => { + if (isActiveRef.current) { + restoreFocusOnSaveRef.current = true; + } + }, []); + + return { + isActive: phase !== "closed", + isActiveRef, + isVisible: phase === "visible", + open, + close, + onDismissed, + restoreFocusAfterSave, + } as const; +} diff --git a/apps/mobile/src/features/usage/UsageDailyChart.ios.tsx b/apps/mobile/src/features/usage/UsageDailyChart.ios.tsx new file mode 100644 index 00000000000..f5924bd8366 --- /dev/null +++ b/apps/mobile/src/features/usage/UsageDailyChart.ios.tsx @@ -0,0 +1,50 @@ +import { Chart, Host, type ChartDataPoint } from "@expo/ui/swift-ui"; +import { frame } from "@expo/ui/swift-ui/modifiers"; +import { useMemo } from "react"; + +import type { DailyTotals } from "@t3tools/shared/usageMerge"; + +import { buildChartDays, type UsageChartMetric } from "./usageChartData"; +import { useProviderColors } from "./usageProviders"; + +export interface UsageDailyChartProps { + readonly days: readonly string[]; + readonly daily: readonly DailyTotals[]; + readonly metric: UsageChartMetric; + readonly height: number; +} + +/** + * Native Swift Charts daily bars. Points sharing an x value stack, so emitting + * one point per provider per day yields per-provider bands whose stack height + * is the day's total; changes animate natively. + * + * Axes are hidden: 30-90 categorical day labels cannot fit on a phone, so the + * screen renders its own edge labels under the chart instead. + */ +export function UsageDailyChart({ days, daily, metric, height }: UsageDailyChartProps) { + const colors = useProviderColors(); + + const data = useMemo((): ChartDataPoint[] => { + return buildChartDays(days, daily, metric).flatMap((day) => + day.values.map((entry) => ({ + x: day.day, + y: entry.value, + color: colors[entry.provider], + })), + ); + }, [days, daily, metric, colors]); + + return ( + + + + ); +} diff --git a/apps/mobile/src/features/usage/UsageDailyChart.tsx b/apps/mobile/src/features/usage/UsageDailyChart.tsx new file mode 100644 index 00000000000..55e2de31572 --- /dev/null +++ b/apps/mobile/src/features/usage/UsageDailyChart.tsx @@ -0,0 +1,44 @@ +import { useMemo } from "react"; +import { View } from "react-native"; + +import type { DailyTotals } from "@t3tools/shared/usageMerge"; + +import { buildChartDays, type UsageChartMetric } from "./usageChartData"; +import { useProviderColors } from "./usageProviders"; + +export interface UsageDailyChartProps { + readonly days: readonly string[]; + readonly daily: readonly DailyTotals[]; + readonly metric: UsageChartMetric; + readonly height: number; +} + +/** + * Stacked daily bars drawn with plain views. Android and any platform without + * Swift Charts land here; iOS resolves `UsageDailyChart.ios.tsx` instead. + */ +export function UsageDailyChart({ days, daily, metric, height }: UsageDailyChartProps) { + const colors = useProviderColors(); + const chartDays = useMemo(() => buildChartDays(days, daily, metric), [days, daily, metric]); + const max = chartDays.reduce((peak, day) => Math.max(peak, day.total), 0); + + return ( + + {/* column-reverse stacks the bottom-first provider values upward + without reversing the array (Hermes lacks Array#toReversed). */} + {chartDays.map((day) => ( + + {day.values.map((entry) => ( + + ))} + + ))} + + ); +} diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx new file mode 100644 index 00000000000..54a9ac7bc21 --- /dev/null +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -0,0 +1,442 @@ +import { useNavigation } from "@react-navigation/native"; +import type { MergedUsage } from "@t3tools/shared/usageMerge"; +import { + enumerateDays, + formatCount, + formatDayShort, + formatPercent, + formatTokens, + formatUsd, + makeWindow, +} from "@t3tools/shared/usageFormat"; +import { useMemo, useState } from "react"; +import { Platform, Pressable, RefreshControl, ScrollView, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; +import { AppText as Text } from "../../components/AppText"; +import { NativeStackScreenOptions } from "../../native/StackHeader"; +import { useUsage, type EnvironmentUsageStatus } from "../../state/usage"; +import { SettingsSection } from "../settings/components/SettingsSection"; +import { UsageDailyChart } from "./UsageDailyChart"; +import type { UsageChartMetric } from "./usageChartData"; +import { PROVIDER_LABEL, useProviderColors } from "./usageProviders"; + +const WINDOW_OPTIONS = [ + { days: 7, label: "7 days" }, + { days: 30, label: "30 days" }, + { days: 90, label: "90 days" }, +] as const; + +const CHART_HEIGHT = 180; + +export function UsageRouteScreen() { + const navigation = useNavigation(); + const insets = useSafeAreaInsets(); + const [windowDays, setWindowDays] = useState(30); + const [metric, setMetric] = useState("cost"); + + // Recomputed only when the window length changes, so a re-render does not + // shift the range and refetch every environment. + const window = useMemo(() => makeWindow(windowDays), [windowDays]); + const { merged, environments, isPending, isPartial, refresh } = useUsage(window); + + const days = useMemo( + () => enumerateDays(window.sinceDay, window.untilDay), + [window.sinceDay, window.untilDay], + ); + + // The pull spinner tracks re-scans of environments that have answered + // before. The initial scan renders its own placeholder, and an unreachable + // environment stays pending forever — neither may pin the spinner on. + const refreshing = environments.some((entry) => entry.isPending && entry.summary !== null); + + return ( + + {Platform.OS === "android" ? ( + <> + + navigation.goBack()} /> + + ) : null} + } + > + ({ value: option.days, label: option.label }))} + selected={windowDays} + onSelect={setWindowDays} + /> + + + + {isPending ? ( + + Scanning provider transcripts… + + ) : environments.length === 0 ? ( + + Connect an environment to see usage. + + ) : ( + <> + + + + + + )} + + + ); +} + +function SegmentedControl(props: { + readonly options: readonly { readonly value: Value; readonly label: string }[]; + readonly selected: Value; + readonly onSelect: (value: Value) => void; +}) { + return ( + + {props.options.map((option) => { + const active = option.value === props.selected; + return ( + props.onSelect(option.value)} + className={ + active + ? "flex-1 items-center rounded-full bg-subtle-strong py-2" + : "flex-1 items-center py-2" + } + > + + {option.label} + + + ); + })} + + ); +} + +/** Headline figure, the animated daily chart, and its legend, in one card. */ +function ChartCard(props: { + readonly merged: MergedUsage; + readonly days: readonly string[]; + readonly metric: UsageChartMetric; + readonly onMetricChange: (metric: UsageChartMetric) => void; + readonly sinceDay: string; + readonly untilDay: string; +}) { + const { merged, metric } = props; + const colors = useProviderColors(); + const hasActivity = merged.daily.some((day) => day.totalTokens > 0); + + return ( + + + + + {metric === "cost" ? "Raw token cost" : "Processed tokens"} + + + {metric === "cost" ? `${formatUsd(merged.costUsd)}*` : formatTokens(merged.totalTokens)} + + + {metric === "cost" + ? "* if billed at full API rate" + : `Across ${formatCount(merged.sessions)} sessions`} + + + + + + {hasActivity ? ( + + ) : ( + + No activity in this window. + + )} + + + {formatDayShort(props.sinceDay)} + + {merged.providers.map((provider) => ( + + + + {PROVIDER_LABEL[provider.provider]} + + + ))} + + {formatDayShort(props.untilDay)} + + + ); +} + +function MetricToggle(props: { + readonly metric: UsageChartMetric; + readonly onChange: (metric: UsageChartMetric) => void; +}) { + return ( + + {(["cost", "tokens"] as const).map((option) => { + const active = option === props.metric; + return ( + props.onChange(option)} + className={active ? "rounded-full bg-subtle-strong px-3 py-1.5" : "px-3 py-1.5"} + > + + {option} + + + ); + })} + + ); +} + +function ProviderSection(props: { + readonly merged: MergedUsage; + readonly metric: UsageChartMetric; +}) { + const { merged, metric } = props; + const colors = useProviderColors(); + if (merged.providers.length === 0) return null; + + // Ranked by whatever the toggle is showing, so the rows always descend. + // .sort() on a copy, not .toSorted(): Hermes doesn't ship the ES2023 method. + const ordered = [...merged.providers].sort((a, b) => + metric === "cost" ? b.costUsd - a.costUsd : b.totalTokens - a.totalTokens, + ); + + return ( + + {ordered.map((provider, index) => { + const share = metric === "cost" ? provider.costShare : provider.tokenShare; + return ( + + + + + {PROVIDER_LABEL[provider.provider]} + + + {metric === "cost" + ? formatUsd(provider.costUsd) + : formatTokens(provider.totalTokens)} + + + + + + + + {metric === "cost" + ? `${formatPercent(share)} of cost · ${formatTokens(provider.totalTokens)} tokens` + : `${formatPercent(share)} of tokens · ${formatUsd(provider.costUsd)}`} + + + ); + })} + + ); +} + +function TotalsSection(props: { readonly merged: MergedUsage }) { + const { merged } = props; + const activeDays = merged.daily.filter((day) => day.totalTokens > 0).length; + const dailyAverage = activeDays === 0 ? 0 : merged.totalTokens / activeDays; + const observedInput = merged.uncachedInputTokens + merged.cachedInputTokens; + const cachedShare = observedInput === 0 ? 0 : merged.cachedInputTokens / observedInput; + + return ( + + + + 0 + ? `${(merged.costQuality.cacheSavingsUsd / merged.costUsd).toFixed(1)}x the raw cost` + : "vs full input rates" + } + /> + + + + + + + ); +} + +function MetricCell(props: { + readonly label: string; + readonly value: string; + readonly detail: string; +}) { + return ( + + {props.label} + {props.value} + {props.detail} + + ); +} + +function ModelsSection(props: { readonly merged: MergedUsage }) { + const { merged } = props; + const colors = useProviderColors(); + if (merged.models.length === 0) return null; + + return ( + + {merged.models.map((model, index) => ( + + + + + {model.model} + + + {formatPercent(model.costShare)} of cost · {formatTokens(model.totalTokens)} tokens + + + {formatUsd(model.costUsd)} + + ))} + + ); +} + +/** + * Says plainly when the totals are incomplete: an environment still answering, + * one that failed, or one whose transcripts another environment already + * reported. + */ +function UsageCoverageNotice(props: { + readonly environments: readonly EnvironmentUsageStatus[]; + readonly merged: MergedUsage; + readonly isPartial: boolean; +}) { + const failed = props.environments.filter((environment) => environment.error !== null); + const stale = props.environments.filter((environment) => + props.merged.staleEnvironments.includes(environment.environmentId), + ); + const duplicateSources = props.merged.duplicateSources; + if ( + failed.length === 0 && + stale.length === 0 && + duplicateSources.length === 0 && + !props.isPartial + ) { + return null; + } + + return ( + + {props.isPartial ? ( + + Some environments are still reporting. Totals are partial. + + ) : null} + {failed.map((environment) => ( + + {environment.label} could not report usage. + + ))} + {stale.map((environment) => ( + + {environment.label} runs an older server version and is excluded from totals. + + ))} + {duplicateSources.length > 0 ? ( + + Counted once across environments sharing a transcript directory:{" "} + {duplicateSources.join(", ")} + + ) : null} + + ); +} diff --git a/apps/mobile/src/features/usage/usageChartData.ts b/apps/mobile/src/features/usage/usageChartData.ts new file mode 100644 index 00000000000..b7b996440cc --- /dev/null +++ b/apps/mobile/src/features/usage/usageChartData.ts @@ -0,0 +1,41 @@ +/** + * Shapes merged daily totals into the per-day provider stacks both chart + * implementations (Swift Charts on iOS, plain views elsewhere) render. + * + * @module usageChartData + */ +import type { UsageProviderKind } from "@t3tools/contracts"; +import type { DailyTotals } from "@t3tools/shared/usageMerge"; + +import { PROVIDER_ORDER } from "./usageProviders"; + +export type UsageChartMetric = "cost" | "tokens"; + +export interface UsageChartDay { + readonly day: string; + /** In {@link PROVIDER_ORDER}, i.e. bottom of the stack first. */ + readonly values: readonly { readonly provider: UsageProviderKind; readonly value: number }[]; + readonly total: number; +} + +/** One entry per day in the window, zero-filled where nothing happened. */ +export function buildChartDays( + days: readonly string[], + daily: readonly DailyTotals[], + metric: UsageChartMetric, +): readonly UsageChartDay[] { + const byDay = new Map(daily.map((totals) => [totals.day, totals])); + return days.map((day) => { + const totals = byDay.get(day); + const values = PROVIDER_ORDER.map((provider) => { + const entry = totals?.byProvider.get(provider); + const value = entry === undefined ? 0 : metric === "cost" ? entry.costUsd : entry.totalTokens; + return { provider, value }; + }); + return { + day, + values, + total: values.reduce((sum, entry) => sum + entry.value, 0), + }; + }); +} diff --git a/apps/mobile/src/features/usage/usageProviders.ts b/apps/mobile/src/features/usage/usageProviders.ts new file mode 100644 index 00000000000..3e2d027a9e3 --- /dev/null +++ b/apps/mobile/src/features/usage/usageProviders.ts @@ -0,0 +1,25 @@ +import type { UsageProviderKind } from "@t3tools/contracts"; +import { useColorScheme } from "react-native"; + +/** + * Series and table order. The chart stacks providers from the bottom in this + * order, so it also fixes which band sits on top of the bars. + */ +export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude"]; + +export const PROVIDER_LABEL: Record = { + claude: "Claude Code", + codex: "Codex", +}; + +/** + * Claude's brand orange holds in both themes; Codex is neutral and must flip + * with the theme or its bars vanish against the matching background. + */ +export function useProviderColors(): Record { + const scheme = useColorScheme(); + return { + claude: "#d97757", + codex: scheme === "dark" ? "#e6e6e6" : "#3c3c43", + }; +} diff --git a/apps/mobile/src/lib/modelOptions.test.ts b/apps/mobile/src/lib/modelOptions.test.ts index 2ec8566b4e4..8a9dabbe034 100644 --- a/apps/mobile/src/lib/modelOptions.test.ts +++ b/apps/mobile/src/lib/modelOptions.test.ts @@ -3,14 +3,14 @@ import { describe, expect, it } from "vite-plus/test"; import { ProviderInstanceId, type ServerConfig } from "@t3tools/contracts"; import { - buildModelMenuActions, buildModelOptions, groupByProvider, + resolveDefaultableModelSelection, resolveSelectableModelSelection, } from "./modelOptions"; describe("mobile model options", () => { - it("folds legacy models into a provider-scoped menu", () => { + it("groups models by provider and flags legacy entries", () => { const config = { providers: [ { @@ -39,51 +39,14 @@ describe("mobile model options", () => { ], } as unknown as ServerConfig; - const actions = buildModelMenuActions(groupByProvider(buildModelOptions(config, null)), null); - - expect(actions).toMatchObject([ - { - title: "Codex", - subactions: [{ id: "model:codex:gpt-5.6-sol", title: "GPT-5.6 Sol" }], - }, + expect(groupByProvider(buildModelOptions(config, null))).toMatchObject([ { - id: "legacy-models:codex", - title: "Codex legacy models", - subactions: [{ id: "model:codex:gpt-5.4", title: "GPT-5.4" }], - }, - ]); - }); - - it("omits an empty provider menu when every model is legacy", () => { - const config = { - providers: [ - { - instanceId: "codex", - driver: "codex", - displayName: "Codex", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - models: [ - { - slug: "gpt-5.4", - name: "GPT-5.4", - isCustom: false, - isLegacy: true, - capabilities: null, - }, - ], - }, - ], - } as unknown as ServerConfig; - - expect( - buildModelMenuActions(groupByProvider(buildModelOptions(config, null)), null), - ).toMatchObject([ - { - id: "legacy-models:codex", - title: "Codex legacy models", - subactions: [{ id: "model:codex:gpt-5.4" }], + providerKey: "codex", + providerLabel: "Codex", + models: [ + { key: "codex:gpt-5.6-sol", label: "GPT-5.6 Sol", isLegacy: false }, + { key: "codex:gpt-5.4", label: "GPT-5.4", isLegacy: true }, + ], }, ]); }); @@ -174,4 +137,38 @@ describe("mobile model options", () => { // No config (environment offline) — nothing to validate against. expect(resolveSelectableModelSelection(null, disabled)).toBe(disabled); }); + + it("keeps legacy models out of implicit defaults", () => { + const config = { + providers: [ + { + instanceId: "codex", + driver: "codex", + displayName: "Codex", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + models: [ + { slug: "gpt-5.6-sol", name: "GPT-5.6 Sol", isCustom: false, capabilities: null }, + { + slug: "gpt-5.4", + name: "GPT-5.4", + isCustom: false, + isLegacy: true, + capabilities: null, + }, + ], + }, + ], + } as unknown as ServerConfig; + + const current = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" }; + const legacy = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }; + + expect(resolveDefaultableModelSelection(config, current)).toBe(current); + // A legacy last-used selection falls through to the provider default. + expect(resolveDefaultableModelSelection(config, legacy)).toBeNull(); + // Offline: nothing to validate against, selection passes through. + expect(resolveDefaultableModelSelection(null, legacy)).toBe(legacy); + }); }); diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index 951b74f7d51..cb7a8c4198e 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -3,7 +3,6 @@ import type { ModelSelection, ServerConfig as T3ServerConfig, } from "@t3tools/contracts"; -import type { MenuAction } from "@react-native-menu/menu"; import { buildProviderOptionSelectionsFromDescriptors, getProviderOptionDescriptors, @@ -85,6 +84,26 @@ export function resolveSelectableModelSelection( : null; } +/** + * Like resolveSelectableModelSelection, but additionally rejects legacy + * models. Used for implicit defaults (stored draft, project last-used): a + * new thread should never quietly start on a legacy model, so those fall + * through to the provider's default instead. Explicit picks in the settings + * sheet are unaffected. + */ +export function resolveDefaultableModelSelection( + config: T3ServerConfig | null | undefined, + selection: ModelSelection | null, +): ModelSelection | null { + const usable = resolveSelectableModelSelection(config, selection); + if (!usable || !config) { + return usable; + } + const provider = config.providers.find((candidate) => candidate.instanceId === usable.instanceId); + const model = provider?.models.find((candidate) => candidate.slug === usable.model); + return model?.isLegacy === true ? null : usable; +} + export function buildModelOptions( config: T3ServerConfig | null | undefined, fallbackModelSelection: ModelSelection | null, @@ -168,53 +187,3 @@ export function groupByProvider(options: ReadonlyArray): ReadonlyAr models: group.models, })); } - -function modelMenuAction(option: ModelOption, selectedModel: ModelSelection | null): MenuAction { - return { - id: `model:${option.key}`, - title: option.label, - state: - option.selection.instanceId === selectedModel?.instanceId && - option.selection.model === selectedModel.model - ? "on" - : undefined, - }; -} - -export function buildModelMenuActions( - groups: ReadonlyArray, - selectedModel: ModelSelection | null, -): MenuAction[] { - return groups.flatMap((group) => { - const currentModels = group.models.filter((model) => !model.isLegacy); - const legacyModels = group.models.filter((model) => model.isLegacy); - const selected = group.models.find( - (model) => - model.selection.instanceId === selectedModel?.instanceId && - model.selection.model === selectedModel.model, - ); - - return [ - ...(currentModels.length > 0 - ? [ - { - id: `provider:${group.providerKey}`, - title: group.providerLabel, - subtitle: selected && !selected.isLegacy ? selected.label : undefined, - subactions: currentModels.map((option) => modelMenuAction(option, selectedModel)), - }, - ] - : []), - ...(legacyModels.length > 0 - ? [ - { - id: `legacy-models:${group.providerKey}`, - title: `${group.providerLabel} legacy models`, - subtitle: selected?.isLegacy ? selected.label : undefined, - subactions: legacyModels.map((option) => modelMenuAction(option, selectedModel)), - }, - ] - : []), - ]; - }); -} diff --git a/apps/mobile/src/lib/providerOptions.test.ts b/apps/mobile/src/lib/providerOptions.test.ts index d7f99a3dab7..d87df6baaf1 100644 --- a/apps/mobile/src/lib/providerOptions.test.ts +++ b/apps/mobile/src/lib/providerOptions.test.ts @@ -3,9 +3,8 @@ import { describe, expect, it } from "vite-plus/test"; import type { ModelCapabilities } from "@t3tools/contracts"; import { - applyProviderOptionMenuEvent, - buildProviderOptionMenuActions, - providerOptionsConfigurationLabel, + applyProviderOptionSelection, + providerOptionValueLabels, resolveProviderOptionDescriptors, } from "./providerOptions"; @@ -35,31 +34,13 @@ const CODEX_CAPABILITIES: ModelCapabilities = { }; describe("mobile provider options", () => { - it("renders the option descriptors advertised by the selected model", () => { + it("summarizes the option values currently in effect", () => { const descriptors = resolveProviderOptionDescriptors({ capabilities: CODEX_CAPABILITIES, selections: undefined, }); - expect(buildProviderOptionMenuActions(descriptors)).toMatchObject([ - { - title: "Reasoning", - subtitle: "Medium", - subactions: [ - { title: "Medium (default)", state: "on" }, - { title: "High", state: undefined }, - ], - }, - { - title: "Service Tier", - subtitle: "Standard", - subactions: [ - { title: "Standard (default)", state: "on" }, - { title: "Fast", state: undefined }, - ], - }, - ]); - expect(providerOptionsConfigurationLabel(descriptors)).toBe("Medium · Standard"); + expect(providerOptionValueLabels(descriptors)).toEqual(["Medium", "Standard"]); }); it("updates generic select options without knowing provider-specific ids", () => { @@ -67,14 +48,18 @@ describe("mobile provider options", () => { capabilities: CODEX_CAPABILITIES, selections: undefined, }); - const actions = buildProviderOptionMenuActions(descriptors); - const fastEvent = actions[1]?.subactions?.[1]?.id; - expect(fastEvent).toBeDefined(); - expect(applyProviderOptionMenuEvent(descriptors, fastEvent!)).toEqual([ + expect( + applyProviderOptionSelection(descriptors, { id: "serviceTier", value: "priority" }), + ).toEqual([ { id: "reasoningEffort", value: "medium" }, { id: "serviceTier", value: "priority" }, ]); + // Choices the model doesn't advertise are rejected, not stored. + expect( + applyProviderOptionSelection(descriptors, { id: "serviceTier", value: "turbo" }), + ).toBeNull(); + expect(applyProviderOptionSelection(descriptors, { id: "unknown", value: "high" })).toBeNull(); }); it("treats an unspecified boolean capability as off", () => { @@ -85,16 +70,9 @@ describe("mobile provider options", () => { selections: undefined, }); - expect(buildProviderOptionMenuActions(descriptors)).toMatchObject([ - { - title: "Fast Mode", - subtitle: "Off", - subactions: [ - { title: "Off", state: "on" }, - { title: "On", state: undefined }, - ], - }, + expect(providerOptionValueLabels(descriptors)).toEqual([]); + expect(applyProviderOptionSelection(descriptors, { id: "fastMode", value: true })).toEqual([ + { id: "fastMode", value: true }, ]); - expect(providerOptionsConfigurationLabel(descriptors)).toBe("Configuration"); }); }); diff --git a/apps/mobile/src/lib/providerOptions.ts b/apps/mobile/src/lib/providerOptions.ts index ae195498962..593f5a37442 100644 --- a/apps/mobile/src/lib/providerOptions.ts +++ b/apps/mobile/src/lib/providerOptions.ts @@ -3,48 +3,12 @@ import type { ProviderOptionDescriptor, ProviderOptionSelection, } from "@t3tools/contracts"; -import type { MenuAction } from "@react-native-menu/menu"; import { buildProviderOptionSelectionsFromDescriptors, getProviderOptionCurrentLabel, - getProviderOptionCurrentValue, getProviderOptionDescriptors, } from "@t3tools/shared/model"; -const PROVIDER_OPTION_EVENT_PREFIX = "provider-option:"; - -function providerOptionEvent(id: string, value: string | boolean): string { - return `${PROVIDER_OPTION_EVENT_PREFIX}${encodeURIComponent(JSON.stringify({ id, value }))}`; -} - -function parseProviderOptionEvent( - event: string, -): { readonly id: string; readonly value: string | boolean } | null { - if (!event.startsWith(PROVIDER_OPTION_EVENT_PREFIX)) { - return null; - } - - try { - const parsed: unknown = JSON.parse( - decodeURIComponent(event.slice(PROVIDER_OPTION_EVENT_PREFIX.length)), - ); - if ( - typeof parsed === "object" && - parsed !== null && - "id" in parsed && - typeof parsed.id === "string" && - "value" in parsed && - (typeof parsed.value === "string" || typeof parsed.value === "boolean") - ) { - return { id: parsed.id, value: parsed.value }; - } - } catch { - return null; - } - - return null; -} - export function resolveProviderOptionDescriptors(input: { readonly capabilities: ModelCapabilities | null | undefined; readonly selections: ReadonlyArray | null | undefined; @@ -58,72 +22,41 @@ export function resolveProviderOptionDescriptors(input: { }); } -export function buildProviderOptionMenuActions( - descriptors: ReadonlyArray, -): ReadonlyArray { - return descriptors.map((descriptor) => { - const currentValue = - descriptor.type === "boolean" - ? (descriptor.currentValue ?? false) - : getProviderOptionCurrentValue(descriptor); - const choices = - descriptor.type === "select" - ? descriptor.options.map((option) => ({ - id: providerOptionEvent(descriptor.id, option.id), - title: `${option.label}${option.isDefault ? " (default)" : ""}`, - state: currentValue === option.id ? ("on" as const) : undefined, - })) - : ([false, true] as const).map((value) => ({ - id: providerOptionEvent(descriptor.id, value), - title: value ? "On" : "Off", - state: currentValue === value ? ("on" as const) : undefined, - })); - - return { - id: `provider-option-menu:${descriptor.id}`, - title: descriptor.label, - subtitle: - descriptor.type === "boolean" - ? currentValue - ? "On" - : "Off" - : getProviderOptionCurrentLabel(descriptor), - subactions: choices, - }; - }); -} - -export function providerOptionsConfigurationLabel( +/** + * Labels for the option values currently in effect (select values plus + * enabled booleans), used to summarize the thread configuration in the + * composer trigger pill. + */ +export function providerOptionValueLabels( descriptors: ReadonlyArray, -): string { - const labels = descriptors.flatMap((descriptor) => { +): ReadonlyArray { + return descriptors.flatMap((descriptor) => { if (descriptor.type === "boolean") { return descriptor.currentValue ? [descriptor.label] : []; } const label = getProviderOptionCurrentLabel(descriptor); return label ? [label] : []; }); - return labels.length > 0 ? labels.join(" · ") : "Configuration"; } -export function applyProviderOptionMenuEvent( +/** + * Applies one option change (by descriptor id) and returns the full selection + * list to store on the model selection, or null when the change doesn't match + * an advertised descriptor / choice. + */ +export function applyProviderOptionSelection( descriptors: ReadonlyArray, - event: string, + change: ProviderOptionSelection, ): ReadonlyArray | null { - const selection = parseProviderOptionEvent(event); - if (!selection) { - return null; - } - - const descriptor = descriptors.find((candidate) => candidate.id === selection.id); + const descriptor = descriptors.find((candidate) => candidate.id === change.id); if (!descriptor) { return null; } if ( - (descriptor.type === "boolean" && typeof selection.value !== "boolean") || + (descriptor.type === "boolean" && typeof change.value !== "boolean") || (descriptor.type === "select" && - (typeof selection.value !== "string" || - !descriptor.options.some((option) => option.id === selection.value))) + (typeof change.value !== "string" || + !descriptor.options.some((option) => option.id === change.value))) ) { return null; } @@ -132,7 +65,7 @@ export function applyProviderOptionMenuEvent( candidate.id === descriptor.id ? { ...candidate, - currentValue: selection.value, + currentValue: change.value, } : candidate, ) as ReadonlyArray; diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 886644bf83e..cd8e8cad212 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -554,6 +554,8 @@ function toolDetailTextLooksLikeFailure(text: string): boolean { normalized.includes("command not found") || (normalized.includes("cannot find path") && normalized.includes("because it does not exist")) || (normalized.includes("is not recognized") && normalized.includes("the term '")) || + normalized.includes("is not recognized as the name of a cmdlet") || + normalized.includes("a parameter cannot be found that matches parameter name") || //i.test(text) || /exit(?:ed)? with exit code\s+[1-9]\d*/i.test(text) || /exit code\s*[:\s]\s*[1-9]\d*\b/i.test(text) diff --git a/apps/mobile/src/lib/wideMarkdownBlocks.test.ts b/apps/mobile/src/lib/wideMarkdownBlocks.test.ts new file mode 100644 index 00000000000..9f0bcaee325 --- /dev/null +++ b/apps/mobile/src/lib/wideMarkdownBlocks.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { hasWideMarkdownBlock } from "./wideMarkdownBlocks"; + +describe("hasWideMarkdownBlock", () => { + it("ignores prose, inline code, and emphasis", () => { + expect(hasWideMarkdownBlock("just a message")).toBe(false); + expect(hasWideMarkdownBlock("I found it in `secteurs_intervention` earlier")).toBe(false); + expect(hasWideMarkdownBlock("a | b in a sentence")).toBe(false); + expect(hasWideMarkdownBlock("an em dash — and a rule\n\n---\n")).toBe(false); + }); + + it("detects fenced code blocks", () => { + expect(hasWideMarkdownBlock("before\n```\ncode\n```\nafter")).toBe(true); + expect(hasWideMarkdownBlock("before\n```ts\ncode\n```")).toBe(true); + expect(hasWideMarkdownBlock("before\n~~~\ncode\n~~~")).toBe(true); + expect(hasWideMarkdownBlock(" ```\ncode\n```")).toBe(true); + }); + + it("detects GFM tables", () => { + expect(hasWideMarkdownBlock("| a | b |\n| --- | --- |\n| 1 | 2 |")).toBe(true); + expect(hasWideMarkdownBlock("a | b\n:-- | --:\n1 | 2")).toBe(true); + }); +}); diff --git a/apps/mobile/src/lib/wideMarkdownBlocks.ts b/apps/mobile/src/lib/wideMarkdownBlocks.ts new file mode 100644 index 00000000000..801d826df54 --- /dev/null +++ b/apps/mobile/src/lib/wideMarkdownBlocks.ts @@ -0,0 +1,38 @@ +/** + * Detects markdown that the JS renderer draws as a standalone block View + * wrapping a horizontal ScrollView — fenced code blocks and GFM tables. + * + * Those blocks report an intrinsic width equal to their widest line, which is + * effectively unbounded. A user bubble sizes itself from its content + * (`maxWidth` with no `width`), so Android lays the bubble's children out + * during the unclamped intrinsic pass — where the surrounding paragraphs + * collapse to a single line — and never repositions them once the width is + * clamped back to `maxWidth`. The result is siblings drawn on top of each + * other inside an over-tall bubble. Pinning the bubble's width removes the + * intrinsic pass entirely, which is the same reason review-comment bubbles + * already carry an explicit width. + * + * Indented (four-space) code blocks are deliberately not detected: they are + * vanishingly rare in chat input and the check would fire on ordinary nested + * list continuations. + */ + +const FENCED_CODE_BLOCK = /^ {0,3}(?:```|~~~)/m; + +function isTableDelimiterRow(line: string): boolean { + const trimmed = line.trim(); + if (!trimmed.includes("|") || !trimmed.includes("-")) { + return false; + } + return /^[|\-: \t]+$/.test(trimmed); +} + +export function hasWideMarkdownBlock(text: string): boolean { + if (FENCED_CODE_BLOCK.test(text)) { + return true; + } + if (!text.includes("|")) { + return false; + } + return text.split("\n").some(isTableDelimiterRow); +} diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index 9a5ed82b3b8..bf40acb053b 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -27,12 +27,13 @@ export interface Preferences { readonly projectGroupingEnabled?: boolean; readonly projectGroupingMode?: SidebarProjectGroupingMode; /** - * Device-local mirror of the web beta's `sidebarV2Enabled`. Mobile has no - * client-settings sync, so the flat v2 thread list is opted out of per - * device. Undefined means the user has never chosen, which resolves to on — - * see `resolveThreadListV2Enabled`. + * Device-local mirror of the web `legacySidebarEnabled` setting. Mobile has + * no client-settings sync, so the legacy grouped thread list is opted into + * per device. Deliberately a fresh key (was `threadListV2Enabled`, an + * opt-out): sanitizing drops the old key, so every device resets to the + * default flat list — see `resolveThreadListV2Enabled`. */ - readonly threadListV2Enabled?: boolean; + readonly legacyThreadListEnabled?: boolean; } export class MobilePreferencesLoadError extends Schema.TaggedErrorClass()( @@ -84,7 +85,7 @@ function sanitizePreferences(parsed: Preferences): Preferences { collapsedProjectGroups?: readonly string[]; projectGroupingEnabled?: boolean; projectGroupingMode?: SidebarProjectGroupingMode; - threadListV2Enabled?: boolean; + legacyThreadListEnabled?: boolean; } = {}; if (typeof parsed.liveActivitiesEnabled === "boolean") { @@ -121,8 +122,8 @@ function sanitizePreferences(parsed: Preferences): Preferences { ) { preferences.projectGroupingMode = parsed.projectGroupingMode; } - if (typeof parsed.threadListV2Enabled === "boolean") { - preferences.threadListV2Enabled = parsed.threadListV2Enabled; + if (typeof parsed.legacyThreadListEnabled === "boolean") { + preferences.legacyThreadListEnabled = parsed.legacyThreadListEnabled; } return preferences; } diff --git a/apps/mobile/src/state/usage.ts b/apps/mobile/src/state/usage.ts new file mode 100644 index 00000000000..91cefda07f5 --- /dev/null +++ b/apps/mobile/src/state/usage.ts @@ -0,0 +1,129 @@ +/** + * Multi-environment usage state. + * + * Every connected environment answers the same typed query; the client merges + * the results. Raw transcripts never leave the machine that produced them. + * + * Mirror of `apps/web/src/state/usage.ts` over mobile's atom wiring; the merge + * rules themselves live in `@t3tools/shared/usageMerge`. + * + * @module state/usage + */ +import { useAtomValue } from "@effect/atom-react"; +import { + USAGE_CONTRACT_VERSION, + type EnvironmentId, + type UsageSummary, + type UsageSummaryInput, +} from "@t3tools/contracts"; +import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "@t3tools/shared/usageMerge"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { useCallback, useMemo } from "react"; + +import { appAtomRegistry } from "./atom-registry"; +import { environmentPresentations } from "./presentation"; +import { serverEnvironment } from "./server"; + +export interface EnvironmentUsageStatus { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly isPending: boolean; + readonly error: string | null; + readonly summary: UsageSummary | null; +} + +/** + * Reads every environment's summary for one window. + * + * Keyed by the serialised window so switching ranges does not thrash the atom + * cache, and so each environment's query is shared with any other reader of the + * same window. + */ +const usageByWindowAtom = Atom.family((windowKey: string) => + Atom.make((get): readonly EnvironmentUsageStatus[] => { + const input = JSON.parse(windowKey) as UsageSummaryInput; + const presentations = get(environmentPresentations.presentationsAtom); + + const statuses: EnvironmentUsageStatus[] = []; + for (const [environmentId, presentation] of presentations) { + const result = get(serverEnvironment.usageSummary({ environmentId, input })); + statuses.push({ + environmentId, + label: presentation.entry.target.label, + isPending: result.waiting, + error: result._tag === "Failure" ? "This environment could not report usage." : null, + summary: Option.getOrNull(AsyncResult.value(result)), + }); + } + return statuses; + }).pipe(Atom.withLabel(`mobile-usage:window:${windowKey}`)), +); + +export interface UsageView { + readonly merged: MergedUsage; + readonly environments: readonly EnvironmentUsageStatus[]; + /** True until at least one environment has answered. */ + readonly isPending: boolean; + /** + * True while environments that have not failed are still answering. Failed + * environments are reported through their own error rows: totals will not + * improve by waiting on them, so they must not read as "still reporting". + */ + readonly isPartial: boolean; + readonly refresh: () => void; +} + +export function useUsage(input: UsageSummaryInput): UsageView { + const windowKey = useMemo( + () => + JSON.stringify({ + sinceDay: input.sinceDay, + untilDay: input.untilDay, + timeZone: input.timeZone, + }), + [input.sinceDay, input.untilDay, input.timeZone], + ); + const atom = usageByWindowAtom(windowKey); + const environments = useAtomValue(atom); + + // Refreshing only the derived atom would re-read the per-environment SWR + // queries within their stale window and change nothing. Refresh each + // environment's query so pull-to-refresh always rescans. + const refresh = useCallback(() => { + const input = JSON.parse(windowKey) as UsageSummaryInput; + for (const environment of environments) { + appAtomRegistry.refresh( + serverEnvironment.usageSummary({ environmentId: environment.environmentId, input }), + ); + } + }, [environments, windowKey]); + + const merged = useMemo(() => { + const answered: EnvironmentUsage[] = environments.flatMap((environment) => + environment.summary === null + ? [] + : [ + { + environmentId: environment.environmentId, + label: environment.label, + summary: environment.summary, + }, + ], + ); + return mergeUsage(answered, USAGE_CONTRACT_VERSION); + }, [environments]); + + const answeredCount = environments.filter((environment) => environment.summary !== null).length; + const stillReporting = environments.filter( + (environment) => environment.summary === null && environment.error === null, + ).length; + + return { + merged, + environments, + isPending: answeredCount === 0 && stillReporting > 0, + isPartial: answeredCount > 0 && stillReporting > 0, + refresh, + }; +} diff --git a/apps/server/integration/providerService.integration.test.ts b/apps/server/integration/providerService.integration.test.ts index c57d289f992..6089d22d9aa 100644 --- a/apps/server/integration/providerService.integration.test.ts +++ b/apps/server/integration/providerService.integration.test.ts @@ -23,6 +23,7 @@ import { ProviderService, type ProviderServiceShape, } from "../src/provider/Services/ProviderService.ts"; +import * as ServerConfig from "../src/config.ts"; import { ServerSettingsService } from "../src/serverSettings.ts"; import { AnalyticsService } from "../src/telemetry/Services/AnalyticsService.ts"; import { SqlitePersistenceMemory } from "../src/persistence/Layers/Sqlite.ts"; @@ -93,6 +94,7 @@ const makeIntegrationFixture = (options?: { readonly analytics?: Layer.Layer( + databasePath: string, + effect: Effect.Effect, +) => effect.pipe(Effect.provide(NodeSqliteClient.layer({ filename: databasePath }))); + +/** A migrated source db with one thread per lifecycle state. Only + * `stopped-thread` qualifies for the clone. */ +const createFixtureSource = Effect.fn("createMigrateDevDbFixtureSource")(function* ( + baseDir: string, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const stateDir = path.join(baseDir, "userdata"); + const databasePath = path.join(stateDir, "state.sqlite"); + yield* fs.makeDirectory(stateDir, { recursive: true }); + yield* withDatabase( + databasePath, + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations(); + // The real shared db carries this column from a branch build without a + // matching migration; reproduce that drift so the filter is exercised. + yield* sql`ALTER TABLE projection_threads ADD COLUMN monitor_json TEXT`; + + yield* sql`INSERT INTO projection_projects + (project_id, title, workspace_root, scripts_json, created_at, updated_at, deleted_at) + VALUES + ('project-kept', 'Kept', '/tmp/kept', '[]', '2026-08-01', '2026-08-01', NULL), + ('project-deleted', 'Deleted', '/tmp/deleted', '[]', '2026-08-01', '2026-08-02', '2026-08-02')`; + + const threads = [ + ["stopped-thread", "project-kept", "stopped", null, null], + ["running-thread", "project-kept", "running", null, null], + ["settled-thread", "project-kept", "stopped", "2026-08-01", null], + ["monitored-thread", "project-kept", "stopped", null, '{"kind":"pr"}'], + ["deleted-project-thread", "project-deleted", "stopped", null, null], + ] as const; + for (const [threadId, projectId, status, settledAt, monitorJson] of threads) { + yield* sql`INSERT INTO projection_threads + (thread_id, project_id, title, created_at, updated_at, settled_at, monitor_json) + VALUES (${threadId}, ${projectId}, ${threadId}, '2026-08-01', '2026-08-01', ${settledAt}, ${monitorJson})`; + yield* sql`INSERT INTO projection_thread_sessions (thread_id, status, updated_at) + VALUES (${threadId}, ${status}, '2026-08-01')`; + yield* sql`INSERT INTO orchestration_events + (event_id, aggregate_kind, stream_id, stream_version, event_type, occurred_at, actor_kind, payload_json, metadata_json) + VALUES (${`event-${threadId}`}, 'thread', ${threadId}, 0, 'thread.created', '2026-08-01', 'user', '{}', '{}')`; + } + yield* sql`INSERT INTO auth_sessions (session_id, subject, scopes, method, issued_at, expires_at) + VALUES ('session-1', 'user', '[]', 'pairing', '2026-08-01', '2027-08-01')`; + }), + ); + return databasePath; +}); + +it.layer(NodeServices.layer)("migrate-dev-db", (it) => { + it.effect("keeps only stopped threads from live projects and clears auth state", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sourceDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-src-" }); + const destDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-dest-" }); + const source = yield* createFixtureSource(sourceDir); + + const result = yield* runMigrateDevDb( + { baseDir: destDir, source, projects: 5, threadsPerProject: 10 }, + { sharedHome: sourceDir }, + ); + + assert.equal(result.databasePath, path.join(destDir, "userdata", "state.sqlite")); + const kept = yield* withDatabase( + result.databasePath, + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const threads = yield* sql<{ thread_id: string }>` + SELECT thread_id FROM projection_threads ORDER BY thread_id`; + const events = yield* sql<{ stream_id: string }>` + SELECT stream_id FROM orchestration_events`; + const [auth] = yield* sql<{ count: number }>` + SELECT COUNT(*) AS count FROM auth_sessions`; + return { threads, events, authCount: auth?.count ?? 0 }; + }), + ); + assert.deepStrictEqual( + kept.threads.map((row) => row.thread_id), + ["stopped-thread"], + ); + assert.deepStrictEqual( + kept.events.map((row) => row.stream_id), + ["stopped-thread"], + ); + assert.equal(kept.authCount, 0); + }), + ); + + it.effect("fails loudly on a migration slot collision", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const sourceDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-slot-" }); + const destDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-slot-dest-" }); + const source = yield* createFixtureSource(sourceDir); + // Simulate another branch having claimed slot 1 first: the id is + // recorded, so this checkout's migration 1 silently never runs. + yield* withDatabase( + source, + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`UPDATE effect_sql_migrations + SET name = 'SomebodyElsesMigration' WHERE migration_id = 1`; + }), + ); + + const error = yield* runMigrateDevDb( + { baseDir: destDir, source, projects: 5, threadsPerProject: 10 }, + { sharedHome: sourceDir }, + ).pipe(Effect.flip); + assert.equal(error._tag, "MigrateDevDbSlotCollisionError"); + if (error._tag === "MigrateDevDbSlotCollisionError") { + assert.equal(error.slot, 1); + assert.equal(error.appliedName, "SomebodyElsesMigration"); + } + }), + ); + + it.effect("refuses while a dev server holds the destination", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sourceDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-busy-" }); + const destDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-busy-dest-" }); + const source = yield* createFixtureSource(sourceDir); + // This test process stands in for a live dev server. + const stateDir = path.join(destDir, "userdata"); + yield* fs.makeDirectory(stateDir, { recursive: true }); + yield* fs.writeFileString( + path.join(stateDir, "server-runtime.json"), + `{"version":1,"pid":${process.pid}}`, + ); + + const error = yield* runMigrateDevDb( + { baseDir: destDir, source, projects: 5, threadsPerProject: 10 }, + { sharedHome: sourceDir }, + ).pipe(Effect.flip); + assert.equal(error._tag, "MigrateDevDbServerRunningError"); + if (error._tag === "MigrateDevDbServerRunningError") { + assert.equal(error.pid, process.pid); + } + }), + ); + + it.effect("refuses a source that resolves to a destination path", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sharedDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-overlap-" }); + const destDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-overlap-dest-" }); + // A leftover snapshot from a prior failed run, passed as --source: it + // must not be deleted before it is read. + const leftoverSnapshot = path.join(destDir, "userdata", "state.sqlite.migrate-dev-db-tmp"); + yield* fs.makeDirectory(path.dirname(leftoverSnapshot), { recursive: true }); + yield* fs.writeFileString(leftoverSnapshot, "not a real db"); + + const error = yield* runMigrateDevDb( + { baseDir: destDir, source: leftoverSnapshot, projects: 5, threadsPerProject: 10 }, + { sharedHome: sharedDir }, + ).pipe(Effect.flip); + assert.equal(error._tag, "MigrateDevDbSourceIsDestinationError"); + assert.equal(yield* fs.exists(leftoverSnapshot), true); + }), + ); + + it.effect("refuses to rebuild the shared home", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const sourceDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-shared-" }); + const source = yield* createFixtureSource(sourceDir); + + const error = yield* runMigrateDevDb( + { baseDir: sourceDir, source, projects: 5, threadsPerProject: 10 }, + { sharedHome: sourceDir }, + ).pipe(Effect.flip); + assert.equal(error._tag, "MigrateDevDbSharedHomeError"); + }), + ); +}); diff --git a/apps/server/scripts/migrate-dev-db.ts b/apps/server/scripts/migrate-dev-db.ts new file mode 100644 index 00000000000..0958f2149f4 --- /dev/null +++ b/apps/server/scripts/migrate-dev-db.ts @@ -0,0 +1,560 @@ +#!/usr/bin/env node + +/** + * Rebuild an isolated dev database from a pruned snapshot of the real + * ~/.t3 database, then run this checkout's migrations against it. + * + * `vp run migrate-dev-db` from a worktree: + * 1. Nukes `/.t3/userdata/state.sqlite`. + * 2. Snapshots the real db (read-only VACUUM INTO) and prunes it to the + * most recently updated projects and, per project, the most recent + * threads that have fully stopped. Working, settled, and monitored + * threads are skipped so the dev server never adopts live work. + * Auth sessions, pairing links, command receipts, and provider + * runtime rows are dropped — pair a fresh browser against dev. + * 3. Runs migrations on the result. Because the clone carries the real + * `effect_sql_migrations` table, this proves a new migration applies + * on top of the real applied set, and the slot check below catches + * the silent failure where two branches claim the same + * `Migrations/NNN_` id (the second one's CREATE TABLE is skipped). + * + * The event log (`orchestration_events`) is pruned per stream while + * `sqlite_sequence` and `projection_state` carry over untouched, so new + * events keep appending after the old high-water mark and projection + * cursors never rewind. + */ + +// @effect-diagnostics nodeBuiltinImport:off - node:os resolves the shared T3 home guard. +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NodeOS from "node:os"; +import { resolveWorktreeT3Home } from "@t3tools/shared/devHome"; +import * as Console from "effect/Console"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import { Command, Flag } from "effect/unstable/cli"; + +import { migrationManifest, runMigrations } from "../src/persistence/Migrations.ts"; +import * as NodeSqliteClient from "../src/persistence/NodeSqliteClient.ts"; + +export class MigrateDevDbNotInWorktreeError extends Schema.TaggedErrorClass()( + "MigrateDevDbNotInWorktreeError", + {}, +) { + override get message(): string { + return "Not inside a linked git worktree. Pass --base-dir to target an isolated .t3 directory."; + } +} + +export class MigrateDevDbSharedHomeError extends Schema.TaggedErrorClass()( + "MigrateDevDbSharedHomeError", + {}, +) { + override get message(): string { + return "Refusing to rebuild the shared ~/.t3 database. Use an isolated --base-dir."; + } +} + +export class MigrateDevDbSourceMissingError extends Schema.TaggedErrorClass()( + "MigrateDevDbSourceMissingError", + { + sourcePath: Schema.String, + }, +) { + override get message(): string { + return `Source database does not exist at '${this.sourcePath}'.`; + } +} + +export class MigrateDevDbSourceIsDestinationError extends Schema.TaggedErrorClass()( + "MigrateDevDbSourceIsDestinationError", + { + sourcePath: Schema.String, + }, +) { + override get message(): string { + return `Source database '${this.sourcePath}' resolves to a path this command rewrites. Pick a different --source or --base-dir.`; + } +} + +export class MigrateDevDbServerRunningError extends Schema.TaggedErrorClass()( + "MigrateDevDbServerRunningError", + { + databasePath: Schema.String, + pid: Schema.Number, + }, +) { + override get message(): string { + return `Dev database at '${this.databasePath}' is open by a running server (pid ${this.pid} per server-runtime.json). Stop that server first; if that pid is not actually a T3 server (stale descriptor, reused pid), delete the server-runtime.json next to the database and retry.`; + } +} + +export class MigrateDevDbDestinationBusyError extends Schema.TaggedErrorClass()( + "MigrateDevDbDestinationBusyError", + { + databasePath: Schema.String, + reason: Schema.Literals(["write-locked", "wal-held"]), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + const detail = + this.reason === "write-locked" + ? "the database is write-locked" + : "another connection is holding its WAL"; + return `Dev database at '${this.databasePath}' looks in use (${detail}). Stop the dev server first; if none is running, delete the -wal/-shm files next to it.`; + } +} + +/** + * Two branches claimed the same Migrations/NNN_ slot: the id was already + * recorded under a different name, so this checkout's migration was + * silently skipped and its schema changes never applied. + */ +export class MigrateDevDbSlotCollisionError extends Schema.TaggedErrorClass()( + "MigrateDevDbSlotCollisionError", + { + slot: Schema.Number, + codeName: Schema.String, + appliedName: Schema.String, + }, +) { + override get message(): string { + return `Migration slot collision at ${this.slot}: this checkout registers '${this.codeName}' but the database already applied '${this.appliedName}' in that slot. Renumber the new migration to a free slot.`; + } +} + +export class MigrateDevDbPhaseError extends Schema.TaggedErrorClass()( + "MigrateDevDbPhaseError", + { + phase: Schema.Literals(["snapshot", "prune", "compact", "migrate", "verify"]), + databasePath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `migrate-dev-db failed during ${this.phase} on '${this.databasePath}'.`; + } +} + +export interface RunMigrateDevDbInput { + /** Isolated .t3 directory. Defaults to `/.t3` of the cwd. */ + readonly baseDir?: string | undefined; + /** Source database. Defaults to `~/.t3/userdata/state.sqlite`. */ + readonly source?: string | undefined; + readonly projects: number; + readonly threadsPerProject: number; +} + +export interface RunMigrateDevDbOptions { + /** Overridable for tests; the directory writes must never target. */ + readonly sharedHome?: string | undefined; +} + +interface KeptProject { + readonly title: string; + readonly threads: number; +} + +const removeDatabaseFiles = Effect.fn("removeDatabaseFiles")(function* (databasePath: string) { + const fs = yield* FileSystem.FileSystem; + for (const suffix of ["", "-wal", "-shm"]) { + yield* fs.remove(`${databasePath}${suffix}`).pipe(Effect.orElseSucceed(() => undefined)); + } +}); + +/** The slice of server-runtime.json this script cares about. */ +const ServerRuntimeState = Schema.fromJsonString(Schema.Struct({ pid: Schema.Number })); +const decodeServerRuntimeState = Schema.decodeEffect(ServerRuntimeState); + +const isProcessAlive = (pid: number): boolean => { + try { + process.kill(pid, 0); + return true; + } catch (error) { + // EPERM means the process exists but belongs to someone else. + return (error as NodeJS.ErrnoException).code === "EPERM"; + } +}; + +/** Liveness probe for a running dev server. The server writes its pid to + * server-runtime.json next to the database, which also catches an idle + * server holding an open-but-inactive connection. The SQL probes below back + * that up: BEGIN IMMEDIATE fails while a writer is active, and + * wal_checkpoint(TRUNCATE) reports busy while another connection holds the + * WAL. A leftover -shm alone is not a signal — read-only connections cannot + * clean it up on close. */ +const ensureNotInUse = Effect.fn("ensureDevDbNotInUse")(function* (databasePath: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const runtimeStatePath = path.join(path.dirname(databasePath), "server-runtime.json"); + const runtimeState = yield* fs.readFileString(runtimeStatePath).pipe( + Effect.flatMap(decodeServerRuntimeState), + // A missing or malformed descriptor is not a liveness signal. + Effect.option, + ); + if (Option.isSome(runtimeState) && isProcessAlive(runtimeState.value.pid)) { + return yield* new MigrateDevDbServerRunningError({ + databasePath, + pid: runtimeState.value.pid, + }); + } + + if (!(yield* fs.exists(databasePath))) { + return; + } + const checkpoint = yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql.unsafe("PRAGMA busy_timeout = 0").unprepared; + yield* sql.unsafe("BEGIN IMMEDIATE").unprepared; + yield* sql.unsafe("ROLLBACK").unprepared; + return yield* sql.unsafe<{ busy: number }>("PRAGMA wal_checkpoint(TRUNCATE)").unprepared; + }).pipe( + Effect.provide(NodeSqliteClient.layer({ filename: databasePath })), + Effect.mapError( + (cause) => + new MigrateDevDbDestinationBusyError({ + databasePath, + reason: "write-locked", + cause, + }), + ), + ); + if (checkpoint[0] !== undefined && Number(checkpoint[0].busy) !== 0) { + return yield* new MigrateDevDbDestinationBusyError({ + databasePath, + reason: "wal-held", + }); + } +}); + +const pruneSnapshot = Effect.fn("pruneDevDbSnapshot")(function* (input: RunMigrateDevDbInput) { + const sql = yield* SqlClient.SqlClient; + + // The shared db can carry monitor_json from a branch build even though no + // migration in this checkout creates it, so filter it only when present. + const threadColumns = yield* sql<{ name: string }>` + SELECT name FROM pragma_table_info('projection_threads')`; + const monitorFilter = threadColumns.some((column) => column.name === "monitor_json") + ? "AND t.monitor_json IS NULL" + : ""; + + // "Stopped" is the persisted subset of the UI's thread status: the session + // reached status 'stopped' and nothing marks the thread settled or + // monitored. The in-memory working/monitoring liveness never persists, so + // filtering the session status is sufficient. + yield* sql.unsafe(`CREATE TEMP TABLE stopped_threads AS + SELECT t.thread_id, t.project_id, t.updated_at + FROM projection_threads t + JOIN projection_thread_sessions s ON s.thread_id = t.thread_id + WHERE t.deleted_at IS NULL + AND t.archived_at IS NULL + AND t.settled_at IS NULL + AND (t.settled_override IS NULL OR t.settled_override <> 'settled') + ${monitorFilter} + AND s.status = 'stopped'`).unprepared; + + // Projects with clonable threads outrank empty-but-recent ones: the point + // of the exercise is thread data, not the project list. + yield* sql`CREATE TEMP TABLE kept_projects AS + SELECT p.project_id + FROM projection_projects p + LEFT JOIN ( + SELECT project_id, MAX(updated_at) AS last_stopped_at + FROM stopped_threads + GROUP BY project_id + ) q ON q.project_id = p.project_id + WHERE p.deleted_at IS NULL + ORDER BY (q.last_stopped_at IS NULL) ASC, + COALESCE(q.last_stopped_at, p.updated_at) DESC + LIMIT ${input.projects}`; + + yield* sql`CREATE TEMP TABLE kept_threads AS + SELECT thread_id FROM ( + SELECT + st.thread_id, + ROW_NUMBER() OVER ( + PARTITION BY st.project_id + ORDER BY st.updated_at DESC + ) AS recency_rank + FROM stopped_threads st + JOIN kept_projects kp ON kp.project_id = st.project_id + ) + WHERE recency_rank <= ${input.threadsPerProject}`; + + yield* sql.withTransaction( + Effect.gen(function* () { + yield* sql`DELETE FROM projection_projects + WHERE project_id NOT IN (SELECT project_id FROM kept_projects)`; + yield* sql`DELETE FROM projection_threads + WHERE thread_id NOT IN (SELECT thread_id FROM kept_threads)`; + for (const table of [ + "projection_thread_messages", + "projection_thread_activities", + "projection_thread_sessions", + "projection_turns", + "projection_pending_approvals", + "projection_thread_proposed_plans", + "checkpoint_diff_blobs", + ]) { + yield* sql.unsafe( + `DELETE FROM ${table} WHERE thread_id NOT IN (SELECT thread_id FROM kept_threads)`, + ).unprepared; + } + yield* sql`DELETE FROM orchestration_events + WHERE (aggregate_kind = 'thread' + AND stream_id NOT IN (SELECT thread_id FROM kept_threads)) + OR (aggregate_kind = 'project' + AND stream_id NOT IN (SELECT project_id FROM kept_projects))`; + yield* sql`DELETE FROM orchestration_command_receipts`; + yield* sql`DELETE FROM provider_session_runtime`; + yield* sql`DELETE FROM auth_sessions`; + yield* sql`DELETE FROM auth_pairing_links`; + }), + ); + + const keptProjects = yield* sql<{ title: string; threads: number }>` + SELECT + p.title, + (SELECT COUNT(*) FROM projection_threads t WHERE t.project_id = p.project_id) AS threads + FROM projection_projects p + ORDER BY p.updated_at DESC`; + const [events] = yield* sql<{ count: number }>` + SELECT COUNT(*) AS count FROM orchestration_events`; + + return { + projects: keptProjects as ReadonlyArray, + eventCount: events?.count ?? 0, + }; +}); + +/** Compare this checkout's migration registry against what the cloned + * database recorded: same slot under a different name means the migration + * was skipped, not applied. */ +const verifyMigrationSlots = Effect.fn("verifyMigrationSlots")(function* () { + const sql = yield* SqlClient.SqlClient; + const applied = yield* sql<{ migration_id: number; name: string }>` + SELECT migration_id, name FROM effect_sql_migrations`; + const appliedById = new Map(applied.map((row) => [Number(row.migration_id), row.name])); + for (const [slot, codeName] of migrationManifest) { + const appliedName = appliedById.get(slot); + if (appliedName !== undefined && appliedName !== codeName) { + return yield* new MigrateDevDbSlotCollisionError({ slot, codeName, appliedName }); + } + } +}); + +export const runMigrateDevDb = Effect.fn("runMigrateDevDb")(function* ( + input: RunMigrateDevDbInput, + options: RunMigrateDevDbOptions = {}, +) { + // SQLite treats a negative LIMIT as "no limit", which would clone + // everything. The CLI flags validate this too; this covers direct callers. + if (input.projects < 1 || input.threadsPerProject < 0) { + return yield* Effect.die("projects must be >= 1 and threadsPerProject >= 0"); + } + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const sharedHome = path.resolve(options.sharedHome ?? path.join(NodeOS.homedir(), ".t3")); + const sourcePath = path.resolve( + input.source ?? path.join(sharedHome, "userdata", "state.sqlite"), + ); + + const baseDir = + input.baseDir !== undefined + ? path.resolve(input.baseDir) + : yield* resolveWorktreeT3Home(process.cwd()); + if (baseDir === undefined) { + return yield* new MigrateDevDbNotInWorktreeError(); + } + const stateDir = path.join(baseDir, "userdata"); + const databasePath = path.join(stateDir, "state.sqlite"); + const snapshotPath = `${databasePath}.migrate-dev-db-tmp`; + + if (!(yield* fs.exists(sourcePath))) { + return yield* new MigrateDevDbSourceMissingError({ sourcePath }); + } + const [canonicalBaseDir, canonicalSharedHome] = yield* Effect.all([ + fs.realPath(baseDir).pipe(Effect.orElseSucceed(() => baseDir)), + fs.realPath(sharedHome).pipe(Effect.orElseSucceed(() => sharedHome)), + ]); + if (canonicalBaseDir === canonicalSharedHome) { + return yield* new MigrateDevDbSharedHomeError(); + } + // The destination db and snapshot both get deleted below; a --source that + // resolves to either (e.g. a leftover snapshot file) would be destroyed + // before it is ever read. + const canonicalSourcePath = yield* fs + .realPath(sourcePath) + .pipe(Effect.orElseSucceed(() => sourcePath)); + for (const destination of [databasePath, snapshotPath]) { + const canonicalDestination = yield* fs + .realPath(destination) + .pipe(Effect.orElseSucceed(() => destination)); + if (canonicalSourcePath === canonicalDestination) { + return yield* new MigrateDevDbSourceIsDestinationError({ sourcePath }); + } + } + + yield* fs.makeDirectory(stateDir, { recursive: true }); + yield* ensureNotInUse(databasePath); + + const wrapPhase = + (phase: MigrateDevDbPhaseError["phase"], phaseDatabasePath: string) => + (effect: Effect.Effect) => + effect.pipe( + Effect.mapError( + (cause) => new MigrateDevDbPhaseError({ phase, databasePath: phaseDatabasePath, cause }), + ), + ); + + yield* removeDatabaseFiles(snapshotPath); + // The snapshot is a full-size copy of the source; make sure it is removed + // even when a phase fails partway through. + const { executedMigrations, pruned } = yield* Effect.gen(function* () { + yield* Console.log(`Snapshotting ${sourcePath} (read-only)...`); + yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`VACUUM INTO ${snapshotPath}`; + }).pipe( + Effect.provide(NodeSqliteClient.layer({ filename: sourcePath, readonly: true })), + wrapPhase("snapshot", sourcePath), + ); + + // Migrate before pruning: a source older than this checkout would + // otherwise crash the prune queries on columns that don't exist yet. + // Running against the full snapshot also exercises new migrations on the + // same data volume the real database would face. + yield* Console.log("Running migrations on the snapshot..."); + const executed = yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + // Mirror server boot (persistence/Layers/Sqlite.ts). + yield* sql.unsafe("PRAGMA foreign_keys = ON").unprepared; + return yield* runMigrations(); + }).pipe( + Effect.provide(NodeSqliteClient.layer({ filename: snapshotPath })), + wrapPhase("migrate", snapshotPath), + ); + + // Verify while the snapshot is still the only thing touched: a slot + // collision must abort before the old worktree db gets replaced with a + // schema whose colliding migration was silently skipped. + yield* verifyMigrationSlots().pipe( + Effect.provide(NodeSqliteClient.layer({ filename: snapshotPath })), + Effect.catchTags({ + SqlError: (cause) => + Effect.fail( + new MigrateDevDbPhaseError({ phase: "verify", databasePath: snapshotPath, cause }), + ), + }), + ); + + yield* Console.log( + `Pruning to ${input.projects} projects, ${input.threadsPerProject} stopped threads each...`, + ); + const result = yield* pruneSnapshot(input).pipe( + Effect.provide(NodeSqliteClient.layer({ filename: snapshotPath })), + wrapPhase("prune", snapshotPath), + ); + + yield* Console.log(`Compacting into ${databasePath}...`); + // Re-check right before the swap: a dev server started while the + // snapshot was migrating and pruning must not lose its database. + yield* ensureNotInUse(databasePath); + yield* removeDatabaseFiles(databasePath); + yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`VACUUM INTO ${databasePath}`; + }).pipe( + Effect.provide(NodeSqliteClient.layer({ filename: snapshotPath })), + wrapPhase("compact", databasePath), + ); + return { executedMigrations: executed, pruned: result }; + }).pipe(Effect.ensuring(removeDatabaseFiles(snapshotPath))); + yield* fs.chmod(databasePath, 0o600); + + yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + // WAL does not survive VACUUM INTO; set it so first `vp run dev` finds + // the database exactly as server boot would have left it. + yield* sql.unsafe("PRAGMA journal_mode = WAL").unprepared; + }).pipe( + Effect.provide(NodeSqliteClient.layer({ filename: databasePath })), + wrapPhase("compact", databasePath), + ); + + const size = (yield* fs.stat(databasePath)).size; + return { + databasePath, + sizeBytes: Number(size), + projects: pruned.projects, + eventCount: pruned.eventCount, + executedMigrations: executedMigrations.map(([id, name]) => `${id}_${name}`), + }; +}); + +const formatSize = (bytes: number): string => + bytes >= 1024 * 1024 + ? `${(bytes / (1024 * 1024)).toFixed(1)} MB` + : `${(bytes / 1024).toFixed(0)} KB`; + +export const migrateDevDbCommand = Command.make( + "migrate-dev-db", + { + projects: Flag.integer("projects").pipe( + Flag.withDefault(5), + Flag.withDescription("How many recently updated projects to keep."), + ), + threadsPerProject: Flag.integer("threads-per-project").pipe( + Flag.withDefault(10), + Flag.withDescription("How many recent stopped threads to keep per project."), + ), + baseDir: Flag.string("base-dir").pipe( + Flag.optional, + Flag.withDescription("Isolated .t3 directory. Defaults to the current worktree's .t3."), + ), + source: Flag.string("source").pipe( + Flag.optional, + Flag.withDescription("Source database. Defaults to ~/.t3/userdata/state.sqlite."), + ), + }, + ({ projects, threadsPerProject, baseDir, source }) => + Effect.gen(function* () { + const result = yield* runMigrateDevDb({ + projects, + threadsPerProject, + baseDir: Option.getOrUndefined(baseDir), + source: Option.getOrUndefined(source), + }); + yield* Console.log(""); + yield* Console.log( + `Dev database ready: ${result.databasePath} (${formatSize(result.sizeBytes)})`, + ); + for (const project of result.projects) { + yield* Console.log(` ${project.title}: ${project.threads} threads`); + } + yield* Console.log(` ${result.eventCount} orchestration events kept`); + yield* Console.log( + result.executedMigrations.length === 0 + ? " Migrations: already current (no new migrations in this checkout)" + : ` Migrations applied: ${result.executedMigrations.join(", ")}`, + ); + }), +).pipe( + Command.withDescription( + "Rebuild the worktree dev database from a pruned snapshot of the real ~/.t3 data, then run migrations.", + ), +); + +if (import.meta.main) { + Command.run(migrateDevDbCommand, { version: "0.0.0" }).pipe( + Effect.provide(NodeServices.layer), + NodeRuntime.runMain, + ); +} diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 568dc3739c0..0a1972c2827 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -1,5 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { ThreadId } from "@t3tools/contracts"; +import { AssetPreviewTypeValidationError, ThreadId } from "@t3tools/contracts"; import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; import { describe, expect, it } from "@effect/vitest"; import * as Crypto from "effect/Crypto"; @@ -225,6 +225,7 @@ describe("AssetAccess", () => { const faviconResult = yield* issueAssetUrl({ resource: { _tag: "project-favicon", cwd: root }, }); + expect(faviconResult.sourcePath).toBe("favicon.svg"); expect(faviconResult.relativeUrl).toMatch(/\/v[0-9a-f]{64}-favicon\.svg$/); expect( yield* issueAssetUrl({ @@ -253,6 +254,7 @@ describe("AssetAccess", () => { resource: { _tag: "project-favicon", cwd: root }, }); expect(fallbackResult.relativeUrl.endsWith(`/${PROJECT_FAVICON_FALLBACK_MARKER}`)).toBe(true); + expect(fallbackResult.sourcePath).toBeUndefined(); const fallbackSuffix = fallbackResult.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); const fallbackSeparatorIndex = fallbackSuffix.indexOf("/"); expect( @@ -264,6 +266,86 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(testLayer)), ); + it.effect("issues project favicon capabilities for a saved override", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-favicon-override-", + }); + yield* fileSystem.makeDirectory(path.join(root, "brand")); + yield* fileSystem.writeFileString(path.join(root, "brand", "custom.svg"), ""); + yield* fileSystem.writeFileString(path.join(root, "favicon.svg"), "auto"); + + const result = yield* issueAssetUrl({ + resource: { _tag: "project-favicon", cwd: root }, + projectFaviconPath: "brand/custom.svg", + }); + + expect(result.sourcePath).toBe("brand/custom.svg"); + expect(result.relativeUrl).toMatch(/\/v[0-9a-f]{64}-custom\.svg$/); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("ignores a client favicon path hint", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-favicon-hint-", + }); + yield* fileSystem.makeDirectory(path.join(root, "brand")); + yield* fileSystem.writeFileString(path.join(root, "brand", "hint.svg"), "hint"); + yield* fileSystem.writeFileString(path.join(root, "brand", "saved.svg"), "saved"); + + const result = yield* issueAssetUrl({ + resource: { _tag: "project-favicon", cwd: root, path: "brand/hint.svg" }, + projectFaviconPath: "brand/saved.svg", + }); + + expect(result.sourcePath).toBe("brand/saved.svg"); + expect(result.relativeUrl).toMatch(/\/v[0-9a-f]{64}-saved\.svg$/); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("keeps automatic favicon resolution separate from a saved override", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-favicon-automatic-", + }); + yield* fileSystem.makeDirectory(path.join(root, "brand")); + yield* fileSystem.writeFileString(path.join(root, "brand", "saved.svg"), "saved"); + yield* fileSystem.writeFileString(path.join(root, "favicon.svg"), "automatic"); + + const result = yield* issueAssetUrl({ + resource: { _tag: "project-favicon", cwd: root }, + }); + + expect(result.sourcePath).toBe("favicon.svg"); + expect(result.relativeUrl).toMatch(/\/v[0-9a-f]{64}-favicon\.svg$/); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("rejects a resolved project favicon with a non-image extension", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-favicon-type-", + }); + yield* fileSystem.writeFileString(path.join(root, "secret.txt"), "not an image"); + + const error = yield* issueAssetUrl({ + resource: { _tag: "project-favicon", cwd: root }, + projectFaviconPath: "secret.txt", + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(AssetPreviewTypeValidationError); + }).pipe(Effect.provide(testLayer)), + ); + it.effect("buckets project favicon expiry after content hashing", () => Effect.gen(function* () { const crypto = yield* Crypto.Crypto; diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index c00f7f1a5e3..7157513b14d 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -169,6 +169,7 @@ const resolveCanonicalWorkspaceFileForRequest = (input: { export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (input: { readonly resource: AssetResource; readonly workspaceRoot?: string; + readonly projectFaviconPath?: string; }) { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -176,6 +177,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i let expiresAt = (yield* Clock.currentTimeMillis) + ASSET_TOKEN_TTL_MS; let claims: AssetClaims; let fileName: string; + let sourcePath: string | undefined; switch (input.resource._tag) { case "workspace-file": { @@ -287,16 +289,22 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i ), ); const faviconResolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; - const faviconPath = yield* faviconResolver.resolvePath(workspaceRoot).pipe( - Effect.mapError( - (cause) => - new AssetProjectFaviconResolutionError({ - resource: input.resource, - cause, - }), - ), - ); + const faviconPath = yield* faviconResolver + .resolvePath(workspaceRoot, input.projectFaviconPath ?? undefined) + .pipe( + Effect.mapError( + (cause) => + new AssetProjectFaviconResolutionError({ + resource: input.resource, + cause, + }), + ), + ); const relativePath = faviconPath ? path.relative(workspaceRoot, faviconPath) : null; + if (relativePath && !isWorkspaceImagePreviewPath(relativePath)) { + return yield* new AssetPreviewTypeValidationError({ resource: input.resource }); + } + sourcePath = relativePath ?? undefined; const canonicalFaviconPath = relativePath ? yield* resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath }).pipe( Effect.mapError( @@ -379,6 +387,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i return { relativeUrl: `${ASSET_ROUTE_PREFIX}/${token}/${encodeURIComponent(fileName)}`, expiresAt, + ...(sourcePath !== undefined ? { sourcePath } : {}), }; }); diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index 5aba9783075..790be9386e6 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -37,6 +37,17 @@ describe("RPC authorization scopes", () => { expect(requiredScopeForRpcMethod(WS_METHODS.cloudInstallRelayClient)).toBe(AuthRelayWriteScope); }); + it("reads the reviewer menu under the same scope as the pull request it belongs to", () => { + // The candidate list is a read like the detail beside it, and asking somebody for a review is + // a write like every other pull request operation. + expect(requiredScopeForRpcMethod(WS_METHODS.pullRequestsReviewerCandidates)).toBe( + requiredScopeForRpcMethod(WS_METHODS.pullRequestsDetail), + ); + expect(requiredScopeForRpcMethod(WS_METHODS.pullRequestsRequestReviewers)).toBe( + requiredScopeForRpcMethod(WS_METHODS.pullRequestsComment), + ); + }); + it("rejects unknown RPC method names", () => { for (const method of ["server.notRegistered", "toString", "constructor"]) { expect(() => requiredScopeForRpcMethod(method)).toThrow( diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 2aa057ee0ca..34853209dbd 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -45,12 +45,30 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.serverGetProcessResourceHistory]: AuthOrchestrationReadScope, [WS_METHODS.serverGetResourceTelemetryHistory]: AuthOrchestrationReadScope, [WS_METHODS.serverRetryResourceTelemetry]: AuthOrchestrationOperateScope, + [WS_METHODS.serverGetUsageSummary]: AuthOrchestrationReadScope, [WS_METHODS.serverSignalProcess]: AuthOrchestrationOperateScope, [WS_METHODS.serverReportClientActivity]: AuthOrchestrationReadScope, [WS_METHODS.serverReportHostPowerState]: AuthOrchestrationOperateScope, [WS_METHODS.serverGetBackgroundPolicy]: AuthOrchestrationReadScope, [WS_METHODS.cloudGetRelayClientStatus]: AuthRelayReadScope, [WS_METHODS.cloudInstallRelayClient]: AuthRelayWriteScope, + [WS_METHODS.pullRequestsList]: AuthOrchestrationReadScope, + [WS_METHODS.pullRequestsListStats]: AuthOrchestrationReadScope, + [WS_METHODS.pullRequestsDetail]: AuthOrchestrationReadScope, + [WS_METHODS.pullRequestsActivity]: AuthOrchestrationReadScope, + [WS_METHODS.pullRequestsDiffFileContents]: AuthOrchestrationReadScope, + [WS_METHODS.pullRequestsRunAction]: AuthOrchestrationOperateScope, + [WS_METHODS.pullRequestsComment]: AuthOrchestrationOperateScope, + [WS_METHODS.pullRequestsSubmitReview]: AuthOrchestrationOperateScope, + [WS_METHODS.pullRequestsReplyToThread]: AuthOrchestrationOperateScope, + [WS_METHODS.pullRequestsSetThreadResolution]: AuthOrchestrationOperateScope, + // Read scope like the reads it un-caches: refreshing is part of reading, and a read-only + // client pressing refresh must not be told it may not look again. + [WS_METHODS.pullRequestsInvalidate]: AuthOrchestrationReadScope, + // The candidate list is a read like the detail beside it; asking somebody for a review is a + // write like every other one. + [WS_METHODS.pullRequestsReviewerCandidates]: AuthOrchestrationReadScope, + [WS_METHODS.pullRequestsRequestReviewers]: AuthOrchestrationOperateScope, [WS_METHODS.sourceControlLookupRepository]: AuthOrchestrationReadScope, [WS_METHODS.sourceControlCloneRepository]: AuthOrchestrationOperateScope, [WS_METHODS.sourceControlPublishRepository]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index 12eebbbd223..55d72bc6619 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -35,6 +35,18 @@ it("keeps systemd pinned to the stable launcher rather than a versioned server", expect(unit).not.toContain("versions/1.2.3"); }); +it("survives the kernel OOM-killing a greedy agent child", () => { + const unit = BootService.renderBootServiceUnit({ + nodePath: "/usr/bin/node", + launcherPath: "/home/theo/.t3/runtime/service-launcher.mjs", + baseDir: "/home/theo/.t3", + logPath: "/home/theo/.t3/userdata/logs/boot-service.log", + unitPath: "/home/theo/.config/systemd/user/t3code.service", + }); + + expect(unit).toContain("OOMPolicy=continue"); +}); + const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( platform: NodeJS.Platform = "linux", usePinnedLauncher = false, @@ -69,6 +81,8 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( timedOut: false, stdoutTruncated: false, stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, }; }), }); diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index b348d2b80ac..e359be42188 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -67,6 +67,11 @@ export function renderBootServiceUnit(plan: BootServicePlan): string { // Let the launcher mark an explicit stop before it signals the server. // systemd still SIGKILLs the whole cgroup if graceful shutdown times out. "KillMode=mixed", + // Agent tool calls run as children of the server, so they share this cgroup. + // With the systemd default of OOMPolicy=stop, the kernel killing one greedy + // child stops the whole unit: the server, every live agent, and the user's + // connection. Keep running and let Restart=always cover the main process. + "OOMPolicy=continue", "Restart=always", "RestartSec=5", `StandardOutput=append:${escapeSystemdSpecifiers(plan.logPath)}`, diff --git a/apps/server/src/cloud/pinnedRuntime.test.ts b/apps/server/src/cloud/pinnedRuntime.test.ts index e6ec99e7e8c..f34f0f5cf4d 100644 --- a/apps/server/src/cloud/pinnedRuntime.test.ts +++ b/apps/server/src/cloud/pinnedRuntime.test.ts @@ -31,6 +31,8 @@ const successfulRunner = (fs: FileSystem.FileSystem, path: Path.Path) => timedOut: false, stdoutTruncated: false, stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, }; }), }); diff --git a/apps/server/src/cloud/selfUpdate.test.ts b/apps/server/src/cloud/selfUpdate.test.ts index 276ee037773..71880ec4ecf 100644 --- a/apps/server/src/cloud/selfUpdate.test.ts +++ b/apps/server/src/cloud/selfUpdate.test.ts @@ -45,6 +45,8 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* ( timedOut: false, stdoutTruncated: false, stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, }; } order.push("preflight"); @@ -64,6 +66,8 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* ( timedOut: false, stdoutTruncated: false, stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, }; }), }); diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index a7aea90f826..84269c381ce 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -69,6 +69,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { expect(first.environmentId).toBe(second.environmentId); expect(second.capabilities.repositoryIdentity).toBe(true); expect(second.capabilities.connectionProbe).toBe(true); + expect(second.capabilities.pullRequests).toBe(true); expect(second.capabilities.threadTitleRegeneration).toBe(true); }), ); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index b6eedb87e66..e1e9020eb27 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -143,9 +143,11 @@ export const make = Effect.gen(function* () { capabilities: { repositoryIdentity: true, connectionProbe: true, + pullRequests: true, threadSettlement: true, threadSnooze: true, threadPinning: true, + threadPinReorder: true, threadTitleRegeneration: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), ...(serverSelfUpdate === "boot-service" ? { serverSelfUpdateProgress: true } : {}), diff --git a/apps/server/src/environment/ServerEnvironmentLabel.test.ts b/apps/server/src/environment/ServerEnvironmentLabel.test.ts index b5bb8a8ff1c..6fc06b889db 100644 --- a/apps/server/src/environment/ServerEnvironmentLabel.test.ts +++ b/apps/server/src/environment/ServerEnvironmentLabel.test.ts @@ -81,6 +81,8 @@ describe("resolveServerEnvironmentLabel", () => { timedOut: false, stdoutTruncated: false, stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, }), ); @@ -120,6 +122,8 @@ describe("resolveServerEnvironmentLabel", () => { timedOut: false, stdoutTruncated: false, stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, }), ); @@ -223,6 +227,8 @@ describe("resolveServerEnvironmentLabel", () => { timedOut: false, stdoutTruncated: false, stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, }), ); @@ -264,6 +270,8 @@ describe("resolveServerEnvironmentLabel", () => { timedOut: false, stdoutTruncated: false, stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, }), ); diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index cdcbcac84af..b001e0ecfbc 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -5,6 +5,7 @@ import * as NodeChildProcess from "node:child_process"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -987,6 +988,73 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("status skips the provider lookup for a branch that was never pushed", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/never-pushed"]); + + const { manager, ghCalls } = yield* makeManager(); + + const status = yield* manager.status({ cwd: repoDir }); + + expect(status.refName).toBe("feature/never-pushed"); + expect(status.pr).toBeNull(); + expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(0); + }), + ); + + it.effect("status still looks up PRs for a branch pushed without --set-upstream", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/pushed-no-upstream"]); + // No `-u`, so the remote-tracking ref exists but branch..merge does + // not. Most terminal and agent pushes land this way, and they can still + // have a PR, so the skip must not trigger here. + yield* runGit(repoDir, ["push", "origin", "feature/pushed-no-upstream"]); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListSequence: [ + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 214, + title: "Pushed without upstream", + url: "https://github.com/pingdotgg/t3code/pull/214", + baseRefName: "main", + headRefName: "feature/pushed-no-upstream", + state: "OPEN", + updatedAt: "2026-04-01T15:00:00Z", + }, + ]), + ], + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + + expect(status.pr?.number).toBe(214); + expect(ghCalls.filter((call) => call.startsWith("pr list ")).length).toBeGreaterThan(0); + }), + ); + + it("backs off repeated PR lookup failures past the healthy refresh cadence", () => { + expect(Duration.toMillis(GitManager.prLookupFailureTtl(1))).toBe(20_000); + expect(Duration.toMillis(GitManager.prLookupFailureTtl(2))).toBe(40_000); + // The point of the backoff: by the third retry a failing branch must not be + // asking more often than a healthy one, which refreshes every 2 minutes. + expect(Duration.toMillis(GitManager.prLookupFailureTtl(4))).toBeGreaterThan(120_000); + expect(Duration.toMillis(GitManager.prLookupFailureTtl(20))).toBe(900_000); + }); + it.effect( "status ignores unrelated fork PRs when the current branch tracks the same repository", () => @@ -3316,7 +3384,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); - it.effect("launches setup only when creating a new PR worktree", () => + it.effect("launches setup when creating a new PR worktree", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); @@ -3589,6 +3657,547 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { NodeFS.realpathSync.native(worktreePath), ); expect(result.branch).toBe("feature/pr-existing-worktree"); + // Nothing to fetch from, so the checkout keeps the commit it had and setup stays out of a + // worktree another thread may be sitting in. + expect(setupCalls).toHaveLength(0); + expect(result.isOnPullRequestHead).toBe(false); + }), + ); + + it.effect("refreshes a reused PR worktree onto the updated pull request head", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-reused-stale"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "stale.txt"), "stale\n"); + yield* runGit(repoDir, ["add", "stale.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Reused stale PR branch"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-reused-stale"]); + yield* runGit(repoDir, ["checkout", "main"]); + const worktreePath = NodePath.join( + repoDir, + "..", + `pr-reused-stale-${NodePath.basename(repoDir)}`, + ); + yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-reused-stale"]); + + yield* runGit(repoDir, ["checkout", "-b", "author-push", "origin/feature/pr-reused-stale"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "authored.txt"), "authored\n"); + yield* runGit(repoDir, ["add", "authored.txt"]); + yield* runGit(repoDir, ["commit", "-m", "New PR head commit"]); + yield* runGit(repoDir, ["push", "origin", "author-push:feature/pr-reused-stale"]); + const updatedHead = (yield* runGit(repoDir, ["rev-parse", "author-push"])).stdout.trim(); + yield* runGit(repoDir, ["checkout", "main"]); + + const { manager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 84, + title: "Reused stale PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/84", + baseRefName: "main", + headRefName: "feature/pr-reused-stale", + state: "open", + }, + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "84", + mode: "worktree", + }); + + expect(result.worktreePath && NodeFS.realpathSync.native(result.worktreePath)).toBe( + NodeFS.realpathSync.native(worktreePath), + ); + expect(result.branch).toBe("feature/pr-reused-stale"); + expect((yield* runGit(worktreePath, ["rev-parse", "HEAD"])).stdout.trim()).toBe(updatedHead); + }), + ); + + it.effect("runs the setup script when a reused PR worktree moves onto the new head", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-reused-setup"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "reused-setup.txt"), "reused setup\n"); + yield* runGit(repoDir, ["add", "reused-setup.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Reused setup PR branch"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-reused-setup"]); + yield* runGit(repoDir, ["checkout", "main"]); + const worktreePath = NodePath.join( + repoDir, + "..", + `pr-reused-setup-${NodePath.basename(repoDir)}`, + ); + yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-reused-setup"]); + + yield* runGit(repoDir, ["checkout", "-b", "setup-author-push", "feature/pr-reused-setup"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "reused-setup.txt"), "reused setup again\n"); + yield* runGit(repoDir, ["add", "reused-setup.txt"]); + yield* runGit(repoDir, ["commit", "-m", "New reused setup head"]); + yield* runGit(repoDir, ["push", "origin", "setup-author-push:feature/pr-reused-setup"]); + yield* runGit(repoDir, ["checkout", "main"]); + + const setupCalls: ProjectSetupScriptRunner.ProjectSetupScriptRunnerInput[] = []; + const { manager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 85, + title: "Reused setup PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/85", + baseRefName: "main", + headRefName: "feature/pr-reused-setup", + state: "open", + }, + }, + setupScriptRunner: { + runForThread: (setupInput) => + Effect.sync(() => { + setupCalls.push(setupInput); + return { status: "no-script" as const }; + }), + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "85", + mode: "worktree", + threadId: asThreadId("thread-pr-reused-setup"), + }); + + expect(setupCalls).toHaveLength(1); + expect(setupCalls[0]).toEqual({ + threadId: "thread-pr-reused-setup", + projectCwd: repoDir, + worktreePath: result.worktreePath as string, + }); + expect(result.isOnPullRequestHead).toBe(true); + }), + ); + + it.effect("leaves the setup script alone when a reused PR worktree is already on the head", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-reused-current"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "reused-current.txt"), "reused current\n"); + yield* runGit(repoDir, ["add", "reused-current.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Reused current PR branch"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-reused-current"]); + yield* runGit(repoDir, ["checkout", "main"]); + const worktreePath = NodePath.join( + repoDir, + "..", + `pr-reused-current-${NodePath.basename(repoDir)}`, + ); + yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-reused-current"]); + const currentHead = (yield* runGit(worktreePath, ["rev-parse", "HEAD"])).stdout.trim(); + + const setupCalls: ProjectSetupScriptRunner.ProjectSetupScriptRunnerInput[] = []; + const { manager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 95, + title: "Reused current PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/95", + baseRefName: "main", + headRefName: "feature/pr-reused-current", + state: "open", + }, + }, + setupScriptRunner: { + runForThread: (setupInput) => + Effect.sync(() => { + setupCalls.push(setupInput); + return { status: "no-script" as const }; + }), + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "95", + mode: "worktree", + threadId: asThreadId("thread-pr-reused-current"), + }); + + expect(result.isOnPullRequestHead).toBe(true); + expect((yield* runGit(worktreePath, ["rev-parse", "HEAD"])).stdout.trim()).toBe(currentHead); + expect(setupCalls).toHaveLength(0); + }), + ); + + it.effect("resets a clean reused PR worktree onto a force-pushed pull request head", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-force-pushed"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "force-pushed.txt"), "first\n"); + yield* runGit(repoDir, ["add", "force-pushed.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Force-pushed PR branch"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-force-pushed"]); + yield* runGit(repoDir, ["checkout", "main"]); + const worktreePath = NodePath.join( + repoDir, + "..", + `pr-force-pushed-${NodePath.basename(repoDir)}`, + ); + yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-force-pushed"]); + const staleHead = (yield* runGit(worktreePath, ["rev-parse", "HEAD"])).stdout.trim(); + + yield* runGit(repoDir, ["checkout", "-b", "author-rewrite", "feature/pr-force-pushed"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "force-pushed.txt"), "rewritten\n"); + yield* runGit(repoDir, ["add", "force-pushed.txt"]); + yield* runGit(repoDir, ["commit", "--amend", "-m", "Rewritten PR head"]); + yield* runGit(repoDir, [ + "push", + "--force", + "origin", + "author-rewrite:feature/pr-force-pushed", + ]); + const rewrittenHead = (yield* runGit(repoDir, ["rev-parse", "author-rewrite"])).stdout.trim(); + // Pushing from this clone also advanced its remote-tracking ref. A head rewritten by the + // author leaves that ref behind, which is the state a reused worktree is really opened in. + yield* runGit(repoDir, [ + "update-ref", + "refs/remotes/origin/feature/pr-force-pushed", + staleHead, + ]); + yield* runGit(repoDir, ["checkout", "main"]); + + const { manager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 86, + title: "Force-pushed PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/86", + baseRefName: "main", + headRefName: "feature/pr-force-pushed", + state: "open", + }, + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "86", + mode: "worktree", + }); + + expect(result.worktreePath && NodeFS.realpathSync.native(result.worktreePath)).toBe( + NodeFS.realpathSync.native(worktreePath), + ); + expect(result.isOnPullRequestHead).toBe(true); + expect((yield* runGit(worktreePath, ["rev-parse", "HEAD"])).stdout.trim()).toBe( + rewrittenHead, + ); + expect(NodeFS.readFileSync(NodePath.join(worktreePath, "force-pushed.txt"), "utf8")).toBe( + "rewritten\n", + ); + }), + ); + + it.effect("keeps a reused PR worktree that carries its own commit off the rewritten head", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-local-commit"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "local-commit.txt"), "first\n"); + yield* runGit(repoDir, ["add", "local-commit.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Local commit PR branch"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-local-commit"]); + yield* runGit(repoDir, ["checkout", "main"]); + const worktreePath = NodePath.join( + repoDir, + "..", + `pr-local-commit-${NodePath.basename(repoDir)}`, + ); + yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-local-commit"]); + const upstreamHead = (yield* runGit(worktreePath, ["rev-parse", "HEAD"])).stdout.trim(); + + yield* runGit(repoDir, ["checkout", "-b", "local-commit-rewrite", "feature/pr-local-commit"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "local-commit.txt"), "rewritten\n"); + yield* runGit(repoDir, ["add", "local-commit.txt"]); + yield* runGit(repoDir, ["commit", "--amend", "-m", "Rewritten local commit head"]); + yield* runGit(repoDir, [ + "push", + "--force", + "origin", + "local-commit-rewrite:feature/pr-local-commit", + ]); + yield* runGit(repoDir, [ + "update-ref", + "refs/remotes/origin/feature/pr-local-commit", + upstreamHead, + ]); + yield* runGit(repoDir, ["checkout", "main"]); + + // The work that must survive: a commit made in the worktree, on top of the stale head. + NodeFS.writeFileSync(NodePath.join(worktreePath, "thread-work.txt"), "thread work\n"); + yield* runGit(worktreePath, ["add", "thread-work.txt"]); + yield* runGit(worktreePath, ["commit", "-m", "Work done in the reused worktree"]); + const worktreeHead = (yield* runGit(worktreePath, ["rev-parse", "HEAD"])).stdout.trim(); + + const setupCalls: ProjectSetupScriptRunner.ProjectSetupScriptRunnerInput[] = []; + const { manager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 87, + title: "Local commit PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/87", + baseRefName: "main", + headRefName: "feature/pr-local-commit", + state: "open", + }, + }, + setupScriptRunner: { + runForThread: (setupInput) => + Effect.sync(() => { + setupCalls.push(setupInput); + return { status: "no-script" as const }; + }), + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "87", + mode: "worktree", + threadId: asThreadId("thread-pr-local-commit"), + }); + + expect(result.isOnPullRequestHead).toBe(false); + expect((yield* runGit(worktreePath, ["rev-parse", "HEAD"])).stdout.trim()).toBe(worktreeHead); + expect(NodeFS.existsSync(NodePath.join(worktreePath, "thread-work.txt"))).toBe(true); + expect(setupCalls).toHaveLength(0); + }), + ); + + it.effect("keeps a dirty reused PR worktree off the rewritten pull request head", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-dirty-worktree"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "dirty.txt"), "first\n"); + yield* runGit(repoDir, ["add", "dirty.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Dirty worktree PR branch"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-dirty-worktree"]); + yield* runGit(repoDir, ["checkout", "main"]); + const worktreePath = NodePath.join( + repoDir, + "..", + `pr-dirty-worktree-${NodePath.basename(repoDir)}`, + ); + yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-dirty-worktree"]); + const staleHead = (yield* runGit(worktreePath, ["rev-parse", "HEAD"])).stdout.trim(); + + yield* runGit(repoDir, ["checkout", "-b", "dirty-rewrite", "feature/pr-dirty-worktree"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "dirty.txt"), "rewritten\n"); + yield* runGit(repoDir, ["add", "dirty.txt"]); + yield* runGit(repoDir, ["commit", "--amend", "-m", "Rewritten dirty head"]); + yield* runGit(repoDir, [ + "push", + "--force", + "origin", + "dirty-rewrite:feature/pr-dirty-worktree", + ]); + yield* runGit(repoDir, [ + "update-ref", + "refs/remotes/origin/feature/pr-dirty-worktree", + staleHead, + ]); + yield* runGit(repoDir, ["checkout", "main"]); + + NodeFS.writeFileSync(NodePath.join(worktreePath, "dirty.txt"), "uncommitted edit\n"); + + const setupCalls: ProjectSetupScriptRunner.ProjectSetupScriptRunnerInput[] = []; + const { manager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 89, + title: "Dirty worktree PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/89", + baseRefName: "main", + headRefName: "feature/pr-dirty-worktree", + state: "open", + }, + }, + setupScriptRunner: { + runForThread: (setupInput) => + Effect.sync(() => { + setupCalls.push(setupInput); + return { status: "no-script" as const }; + }), + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "89", + mode: "worktree", + threadId: asThreadId("thread-pr-dirty-worktree"), + }); + + expect(result.isOnPullRequestHead).toBe(false); + expect((yield* runGit(worktreePath, ["rev-parse", "HEAD"])).stdout.trim()).toBe(staleHead); + expect(NodeFS.readFileSync(NodePath.join(worktreePath, "dirty.txt"), "utf8")).toBe( + "uncommitted edit\n", + ); + expect(setupCalls).toHaveLength(0); + }), + ); + + it.effect("refreshes a reused PR worktree that has no upstream from the pull request ref", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-ref-only"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "ref-only.txt"), "ref only\n"); + yield* runGit(repoDir, ["add", "ref-only.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Pull ref only PR branch"]); + // The head lives at refs/pull/90/head and nowhere else, so nothing can be tracked. + yield* runGit(repoDir, ["push", "origin", "HEAD:refs/pull/90/head"]); + yield* runGit(repoDir, ["checkout", "main"]); + yield* runGit(repoDir, ["branch", "-D", "feature/pr-ref-only"]); + + const { manager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 90, + title: "Pull ref only PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/90", + baseRefName: "main", + headRefName: "feature/pr-ref-only", + state: "open", + }, + }, + }); + + const created = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "90", + mode: "worktree", + }); + const worktreePath = created.worktreePath as string; + expect( + (yield* runGit(worktreePath, ["rev-parse", "--abbrev-ref", "@{upstream}"], true)).exitCode, + ).not.toBe(0); + + yield* runGit(repoDir, ["fetch", "origin", "refs/pull/90/head"]); + yield* runGit(repoDir, ["checkout", "-b", "ref-only-author", "FETCH_HEAD"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "ref-only.txt"), "ref only again\n"); + yield* runGit(repoDir, ["add", "ref-only.txt"]); + yield* runGit(repoDir, ["commit", "-m", "New pull ref head"]); + yield* runGit(repoDir, ["push", "origin", "ref-only-author:refs/pull/90/head"]); + const updatedHead = (yield* runGit(repoDir, ["rev-parse", "ref-only-author"])).stdout.trim(); + yield* runGit(repoDir, ["checkout", "main"]); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "90", + mode: "worktree", + }); + + expect(result.worktreePath && NodeFS.realpathSync.native(result.worktreePath)).toBe( + NodeFS.realpathSync.native(worktreePath), + ); + expect(result.isOnPullRequestHead).toBe(true); + expect((yield* runGit(worktreePath, ["rev-parse", "HEAD"])).stdout.trim()).toBe(updatedHead); + }), + ); + + it.effect("never moves an unrelated local branch that shares the fork head branch name", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); + yield* runGit(repoDir, ["checkout", "-b", "fork-main-collision"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "contributor.txt"), "contributor\n"); + yield* runGit(repoDir, ["add", "contributor.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Contributor commit on the fork main"]); + yield* runGit(repoDir, ["push", "-u", "fork-seed", "fork-main-collision:main"]); + // The user's own main, checked out in its own worktree and behind the fork's main: a + // fast-forward would land the contributor's commits in it. + yield* runGit(repoDir, ["checkout", "-b", "feature/root-work", "main"]); + const mainWorktreePath = NodePath.join( + repoDir, + "..", + `local-main-${NodePath.basename(repoDir)}`, + ); + yield* runGit(repoDir, ["worktree", "add", mainWorktreePath, "main"]); + const localMainBefore = (yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim(); + + const setupCalls: ProjectSetupScriptRunner.ProjectSetupScriptRunnerInput[] = []; + const { manager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 94, + title: "Fork main collision PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/94", + baseRefName: "main", + headRefName: "main", + state: "open", + isCrossRepository: true, + headRepositoryNameWithOwner: "octocat/codething-mvp", + headRepositoryOwnerLogin: "octocat", + }, + repositoryCloneUrls: { + "octocat/codething-mvp": { + url: forkDir, + sshUrl: forkDir, + }, + }, + }, + setupScriptRunner: { + runForThread: (setupInput) => + Effect.sync(() => { + setupCalls.push(setupInput); + return { status: "no-script" as const }; + }), + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "94", + mode: "worktree", + threadId: asThreadId("thread-pr-fork-main-collision"), + }); + + expect((yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim()).toBe(localMainBefore); + expect((yield* runGit(mainWorktreePath, ["rev-parse", "HEAD"])).stdout.trim()).toBe( + localMainBefore, + ); + expect(NodeFS.existsSync(NodePath.join(mainWorktreePath, "contributor.txt"))).toBe(false); + expect(result.isOnPullRequestHead).toBe(false); expect(setupCalls).toHaveLength(0); }), ); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 7f6e77833b5..4079f69ef78 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -110,8 +110,25 @@ const TOAST_DESCRIPTION_MAX = 72; const STATUS_RESULT_CACHE_TTL = Duration.seconds(1); const STATUS_RESULT_CACHE_CAPACITY = 2_048; const PR_LOOKUP_CACHE_TTL = Duration.minutes(2); -const PR_LOOKUP_FAILURE_TTL = Duration.seconds(20); +const PR_LOOKUP_FAILURE_BASE_TTL = Duration.seconds(20); +const PR_LOOKUP_FAILURE_MAX_TTL = Duration.minutes(15); const PR_LOOKUP_CACHE_CAPACITY = 2_048; + +/** + * How long a failed PR lookup is cached, given the number of consecutive + * failures for that branch. + * + * A hosting provider rejects a throttled request immediately, so caching every + * failure for a flat 20s made a rate-limited poller re-ask *faster* than a + * healthy one does (which waits PR_LOOKUP_CACHE_TTL), turning a transient 429 + * into sustained pressure. Backing off per branch keeps the retry rate below + * the healthy rate once a branch has failed more than a couple of times. + */ +export function prLookupFailureTtl(consecutiveFailures: number): Duration.Duration { + const exponent = Math.max(0, consecutiveFailures - 1); + const backoffMs = Duration.toMillis(PR_LOOKUP_FAILURE_BASE_TTL) * Math.pow(2, exponent); + return Duration.min(Duration.millis(backoffMs), PR_LOOKUP_FAILURE_MAX_TTL); +} type StripProgressContext = T extends any ? Omit : never; type GitActionProgressPayload = StripProgressContext; type GitActionProgressEmitter = (event: GitActionProgressPayload) => Effect.Effect; @@ -892,6 +909,23 @@ export const make = Effect.gen(function* () { // back to a null upstreamRef. const prLookupCacheKey = (cwd: string, details: { branch: string; upstreamRef: string | null }) => [cwd, details.branch, details.upstreamRef ?? "", String(prLookupEpoch(cwd))].join("\u0000"); + // Consecutive failures per cache key, so a branch that keeps failing waits + // longer before the next attempt. Cleared as soon as a lookup succeeds. + const prLookupFailureStreakByKey = new Map(); + const nextPrLookupFailureTtl = (key: string) => { + if ( + !prLookupFailureStreakByKey.has(key) && + prLookupFailureStreakByKey.size >= PR_LOOKUP_CACHE_CAPACITY + ) { + const oldestKey = prLookupFailureStreakByKey.keys().next().value; + if (oldestKey !== undefined) { + prLookupFailureStreakByKey.delete(oldestKey); + } + } + const streak = (prLookupFailureStreakByKey.get(key) ?? 0) + 1; + prLookupFailureStreakByKey.set(key, streak); + return prLookupFailureTtl(streak); + }; const prLookupCache = yield* Cache.makeWith( (key: string) => { const [cwd = "", branch = "", upstreamRef = ""] = key.split("\u0000"); @@ -899,17 +933,26 @@ export const make = Effect.gen(function* () { branch, upstreamRef: upstreamRef.length > 0 ? upstreamRef : null, }; - return resolveBranchHeadContext(cwd, details).pipe( - Effect.flatMap((headContext) => - findLatestPrForHeadContext(cwd, headContext).pipe( - Effect.map((latest) => ({ latest, headContext })), - ), - ), - ); + return Effect.gen(function* () { + const headContext = yield* resolveBranchHeadContext(cwd, details); + // Only skip when the branch is untracked as well: anything carrying an + // upstream keeps the old behaviour. + if (details.upstreamRef === null && (yield* isUnpublishedBranch(cwd, headContext))) { + return { latest: null, headContext }; + } + const latest = yield* findLatestPrForHeadContext(cwd, headContext); + return { latest, headContext }; + }); }, { capacity: PR_LOOKUP_CACHE_CAPACITY, - timeToLive: (exit) => (Exit.isSuccess(exit) ? PR_LOOKUP_CACHE_TTL : PR_LOOKUP_FAILURE_TTL), + timeToLive: (exit, key) => { + if (Exit.isSuccess(exit)) { + prLookupFailureStreakByKey.delete(key); + return PR_LOOKUP_CACHE_TTL; + } + return nextPrLookupFailureTtl(key); + }, }, ); // A transient lookup failure (rate limit, network blip) must not clear an @@ -1172,6 +1215,43 @@ export const make = Effect.gen(function* () { } satisfies BranchHeadContext; }); + /** + * Whether git has no record of this branch on any remote, so a change request + * cannot exist for it and asking the provider is a guaranteed-empty API call. + * + * `git push` writes the remote-tracking ref even without `-u` (how most + * terminal and agent pushes land), which makes this a safer "did it ever + * reach the host" test than looking for upstream config, and the glob spans + * every remote so a fork branch still counts. A repository that tracks no + * remotes at all cannot answer the question, because then every branch looks + * unpublished; it, and any failed probe, keeps the lookup. + */ + const isUnpublishedBranch = Effect.fn("isUnpublishedBranch")(function* ( + cwd: string, + headContext: Pick, + ) { + if (headContext.headBranch.length === 0) { + return false; + } + const matchesRef = (pattern: string) => + gitCore + .execute({ + operation: "GitManager.isUnpublishedBranch", + cwd, + args: ["for-each-ref", "--count=1", "--format=%(refname)", pattern], + timeoutMs: 5_000, + }) + .pipe(Effect.map((result) => result.stdout.trim().length > 0)); + + return yield* Effect.all( + [matchesRef("refs/remotes"), matchesRef(`refs/remotes/*/${headContext.headBranch}`)], + { concurrency: "unbounded" }, + ).pipe( + Effect.map(([tracksAnyRemote, tracksThisBranch]) => tracksAnyRemote && !tracksThisBranch), + Effect.orElseSucceed(() => false), + ); + }); + const findOpenPr = Effect.fn("findOpenPr")(function* ( cwd: string, headContext: Pick< @@ -1772,6 +1852,7 @@ export const make = Effect.gen(function* () { pullRequest, branch: details.branch ?? pullRequest.headBranch, worktreePath: null, + isOnPullRequestHead: true, }; } @@ -1796,6 +1877,102 @@ export const make = Effect.gen(function* () { const localPullRequestBranch = resolvePullRequestWorktreeLocalBranchName(pullRequestWithRemoteInfo); + // Git refuses to move a branch that is checked out in a worktree, so the + // reuse paths cannot go through materializePullRequestHeadBranch and instead + // advance the checkout from inside the worktree. A worktree that cannot be + // moved (no reachable head, local commits, dirty tree) is still handed + // back, because stranding the thread is worse than reporting the staleness. + const reuseExistingWorktree = Effect.fn("reuseExistingWorktree")(function* ( + worktreePath: string, + checkedOutBranch: string, + ) { + if (checkedOutBranch !== localPullRequestBranch) { + // findLocalHeadBranch also accepts a branch that merely shares the head's bare name — + // a fork PR opened from "main" matches the user's own local main. That checkout is + // somebody else's work, so it keeps its tracking config and nothing else. + yield* ensureExistingWorktreeUpstream(worktreePath); + return { + pullRequest, + branch: localPullRequestBranch, + worktreePath, + isOnPullRequestHead: false, + }; + } + + // Read before ensureExistingWorktreeUpstream: it force-updates the remote-tracking ref, + // and once that has jumped to a rewritten head there is no way left to tell a checkout + // that holds nothing of its own from one carrying local commits. + const upstreamCommitBeforeFetch = yield* gitCore + .resolveCommit({ cwd: worktreePath, revision: "@{upstream}" }) + .pipe( + Effect.map((resolved) => resolved.commitSha), + Effect.orElseSucceed(() => null), + ); + + yield* ensureExistingWorktreeUpstream(worktreePath); + + const refreshed = yield* gitCore + // The pull request's own ref, because it is the only thing that certainly names its + // head. The branch's upstream does not: configuring it is best-effort, so a branch cut + // from `origin/main` whose head branch has since been deleted still resolves — and + // following it would move the checkout onto main and call that the pull request. + .fetchPullRequestHeadCommit({ cwd: worktreePath, prNumber: pullRequest.number }) + .pipe( + // A host that publishes no `refs/pull//head` leaves the remote-tracking branch, + // taken only where it is the head branch's own rather than whatever the checkout + // happened to be cut from. + Effect.catch(() => + Effect.gen(function* () { + const details = yield* gitCore.statusDetails(worktreePath); + if ( + details.upstreamRef === null || + !details.upstreamRef.endsWith(`/${pullRequest.headBranch}`) + ) { + return yield* new GitManagerError({ + operation: "preparePullRequestThread", + cwd: worktreePath, + detail: "The pull request head could not be resolved for this checkout.", + }); + } + return yield* gitCore.resolveCommit({ + cwd: worktreePath, + revision: details.upstreamRef, + }); + }), + ), + Effect.flatMap((target) => + gitCore.refreshCheckedOutBranch({ + cwd: worktreePath, + targetCommit: target.commitSha, + resetWhenHeadCommit: upstreamCommitBeforeFetch, + }), + ), + Effect.catch((error) => + Effect.logWarning( + "GitManager.preparePullRequestThread reused worktree refresh failed", + { + worktreePath, + localBranch: localPullRequestBranch, + cause: error, + }, + ).pipe(Effect.as({ moved: false, onTarget: false })), + ), + ); + + // Only when the checkout actually moved: another thread may be running in this worktree, + // and re-running the setup script under it buys nothing when the code did not change. + if (refreshed.moved) { + yield* maybeRunSetupScript(worktreePath); + } + + return { + pullRequest, + branch: localPullRequestBranch, + worktreePath, + isOnPullRequestHead: refreshed.onTarget, + }; + }); + const findLocalHeadBranch = Effect.fn("findLocalHeadBranch")(function* (cwd: string) { const result = yield* gitCore.listRefs({ cwd, refresh: true }); const localBranch = result.refs.find( @@ -1830,12 +2007,10 @@ export const make = Effect.gen(function* () { existingBranchBeforeFetch?.worktreePath && existingBranchBeforeFetchPath !== rootWorktreePath ) { - yield* ensureExistingWorktreeUpstream(existingBranchBeforeFetch.worktreePath); - return { - pullRequest, - branch: localPullRequestBranch, - worktreePath: existingBranchBeforeFetch.worktreePath, - }; + return yield* reuseExistingWorktree( + existingBranchBeforeFetch.worktreePath, + existingBranchBeforeFetch.name, + ); } if (existingBranchBeforeFetchPath === rootWorktreePath) { return yield* new GitManagerError({ @@ -1860,12 +2035,10 @@ export const make = Effect.gen(function* () { existingBranchAfterFetch?.worktreePath && existingBranchAfterFetchPath !== rootWorktreePath ) { - yield* ensureExistingWorktreeUpstream(existingBranchAfterFetch.worktreePath); - return { - pullRequest, - branch: localPullRequestBranch, - worktreePath: existingBranchAfterFetch.worktreePath, - }; + return yield* reuseExistingWorktree( + existingBranchAfterFetch.worktreePath, + existingBranchAfterFetch.name, + ); } if (existingBranchAfterFetchPath === rootWorktreePath) { return yield* new GitManagerError({ @@ -1888,6 +2061,7 @@ export const make = Effect.gen(function* () { pullRequest, branch: worktree.worktree.refName, worktreePath: worktree.worktree.path, + isOnPullRequestHead: true, }; }).pipe(Effect.ensuring(invalidateStatus(input.cwd))); }); diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts index 4af2ecb6457..ec4d2aae16e 100644 --- a/apps/server/src/http.test.ts +++ b/apps/server/src/http.test.ts @@ -1,7 +1,7 @@ import { expect, it } from "@effect/vitest"; import { describe } from "vite-plus/test"; -import { isLoopbackHostname, resolveDevRedirectUrl } from "./http.ts"; +import { assetResponseHeaders, isLoopbackHostname, resolveDevRedirectUrl } from "./http.ts"; describe("http dev routing", () => { it("treats localhost and loopback addresses as local", () => { @@ -26,3 +26,22 @@ describe("http dev routing", () => { ); }); }); + +describe("assetResponseHeaders", () => { + it("sandboxes SVG assets", () => { + expect(assetResponseHeaders("/attachments/user-image.svg")).toMatchObject({ + "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; sandbox", + "X-Content-Type-Options": "nosniff", + }); + expect(assetResponseHeaders("/attachments/user-image.SVG")).toHaveProperty( + "Content-Security-Policy", + ); + }); + + it("does not apply document policy to raster images", () => { + expect(assetResponseHeaders("/attachments/user-image.png")).toEqual({ + "Cache-Control": "private, max-age=3600", + "X-Content-Type-Options": "nosniff", + }); + }); +}); diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 3c406731a4b..0da55686b92 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -43,6 +43,18 @@ import { browserApiCorsAllowedHeaders, browserApiCorsAllowedMethods } from "./ht const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces"; const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); const DESKTOP_RENDERER_ORIGINS = ["t3code://app", "t3code-dev://app"]; +const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inline'; sandbox"; + +export function assetResponseHeaders(filePath: string): Record { + return { + "Cache-Control": "private, max-age=3600", + "X-Content-Type-Options": "nosniff", + ...(filePath.toLowerCase().endsWith(".svg") + ? { "Content-Security-Policy": SVG_CONTENT_SECURITY_POLICY } + : {}), + }; +} + export const httpCompressionLayer = HttpRouter.middleware(HttpMiddleware.compression(), { global: true, }); @@ -207,10 +219,7 @@ export const assetRouteLayer = HttpRouter.add( } return yield* HttpServerResponse.file(asset.path, { status: 200, - headers: { - "Cache-Control": "private, max-age=3600", - "X-Content-Type-Options": "nosniff", - }, + headers: assetResponseHeaders(asset.path), }).pipe( Effect.orElseSucceed(() => HttpServerResponse.text("Internal Server Error", { status: 500 })), ); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 8e65295b1ba..e3b18d74a9a 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -2764,15 +2764,18 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5", }, + faviconPath: "brand/icon.svg", }); const projectRows = yield* sql<{ readonly scriptsJson: string; readonly defaultModelSelection: string; + readonly faviconPath: string | null; }>` SELECT scripts_json AS "scriptsJson", - default_model_selection_json AS "defaultModelSelection" + default_model_selection_json AS "defaultModelSelection", + favicon_path AS "faviconPath" FROM projection_projects WHERE project_id = 'project-scripts' `; @@ -2781,6 +2784,7 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { scriptsJson: '[{"id":"script-1","name":"Build","command":"bun run build","icon":"build","runOnWorktreeCreate":false}]', defaultModelSelection: '{"instanceId":"codex","model":"gpt-5"}', + faviconPath: "brand/icon.svg", }, ]); }), diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 7776e374ee2..e9a625dd91c 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -495,6 +495,8 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti title: event.payload.title, workspaceRoot: event.payload.workspaceRoot, defaultModelSelection: event.payload.defaultModelSelection, + defaultThreadEnvMode: null, + faviconPath: event.payload.faviconPath ?? null, scripts: event.payload.scripts, createdAt: event.payload.createdAt, updatedAt: event.payload.updatedAt, @@ -518,6 +520,12 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ...(event.payload.defaultModelSelection !== undefined ? { defaultModelSelection: event.payload.defaultModelSelection } : {}), + ...(event.payload.defaultThreadEnvMode !== undefined + ? { defaultThreadEnvMode: event.payload.defaultThreadEnvMode } + : {}), + ...(event.payload.faviconPath !== undefined + ? { faviconPath: event.payload.faviconPath } + : {}), ...(event.payload.scripts !== undefined ? { scripts: event.payload.scripts } : {}), updatedAt: event.payload.updatedAt, }); @@ -612,6 +620,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti snoozedUntil: null, snoozedAt: null, pinnedAt: null, + pinOrderKey: null, titleRegenerationRequestId: null, titleRegenerationStartedAt: null, latestUserMessageAt: null, @@ -728,6 +737,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti yield* projectionThreadRepository.upsert({ ...existingRow.value, pinnedAt: event.payload.pinnedAt, + ...(event.payload.pinOrderKey !== undefined + ? { pinOrderKey: event.payload.pinOrderKey } + : {}), updatedAt: event.payload.updatedAt, }); return; @@ -743,6 +755,22 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti yield* projectionThreadRepository.upsert({ ...existingRow.value, pinnedAt: null, + pinOrderKey: null, + updatedAt: event.payload.updatedAt, + }); + return; + } + + case "thread.pin-reordered": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + pinOrderKey: event.payload.orderKey, updatedAt: event.payload.updatedAt, }); return; diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index d5dda7aa86b..be596b36b85 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -87,6 +87,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { pending_approval_count, pending_user_input_count, has_actionable_proposed_plan, + pinned_at, + pin_order_key, created_at, updated_at, deleted_at @@ -105,6 +107,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { 1, 0, 0, + '2026-02-24T00:00:01.000Z', + 'gm', '2026-02-24T00:00:02.000Z', '2026-02-24T00:00:03.000Z', NULL @@ -271,6 +275,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, + faviconPath: null, scripts: [ { id: "script-1", @@ -280,6 +285,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { runOnWorktreeCreate: false, }, ], + defaultThreadEnvMode: null, createdAt: "2026-02-24T00:00:00.000Z", updatedAt: "2026-02-24T00:00:01.000Z", deletedAt: null, @@ -317,7 +323,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { settledAt: null, snoozedUntil: null, snoozedAt: null, - pinnedAt: null, + pinnedAt: "2026-02-24T00:00:01.000Z", + pinOrderKey: "gm", titleRegeneration: null, deletedAt: null, messages: [ @@ -388,6 +395,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, + faviconPath: null, scripts: [ { id: "script-1", @@ -397,6 +405,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { runOnWorktreeCreate: false, }, ], + defaultThreadEnvMode: null, createdAt: "2026-02-24T00:00:00.000Z", updatedAt: "2026-02-24T00:00:01.000Z", }, @@ -433,7 +442,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { settledAt: null, snoozedUntil: null, snoozedAt: null, - pinnedAt: null, + pinnedAt: "2026-02-24T00:00:01.000Z", + pinOrderKey: "gm", titleRegeneration: null, session: { threadId: ThreadId.make("thread-1"), diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 9633f162d2b..3e77f9cf875 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -316,6 +316,8 @@ function mapProjectShellRow( workspaceRoot: row.workspaceRoot, repositoryIdentity, defaultModelSelection: row.defaultModelSelection, + defaultThreadEnvMode: row.defaultThreadEnvMode, + faviconPath: row.faviconPath ?? null, scripts: row.scripts, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -391,6 +393,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { title, workspace_root AS "workspaceRoot", default_model_selection_json AS "defaultModelSelection", + default_thread_env_mode AS "defaultThreadEnvMode", + favicon_path AS "faviconPath", scripts_json AS "scripts", created_at AS "createdAt", updated_at AS "updatedAt", @@ -423,6 +427,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", + pin_order_key AS "pinOrderKey", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", @@ -458,6 +463,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", + pin_order_key AS "pinOrderKey", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", @@ -495,6 +501,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", + pin_order_key AS "pinOrderKey", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", @@ -841,6 +848,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { title, workspace_root AS "workspaceRoot", default_model_selection_json AS "defaultModelSelection", + default_thread_env_mode AS "defaultThreadEnvMode", + favicon_path AS "faviconPath", scripts_json AS "scripts", created_at AS "createdAt", updated_at AS "updatedAt", @@ -863,6 +872,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { title, workspace_root AS "workspaceRoot", default_model_selection_json AS "defaultModelSelection", + default_thread_env_mode AS "defaultThreadEnvMode", + favicon_path AS "faviconPath", scripts_json AS "scripts", created_at AS "createdAt", updated_at AS "updatedAt", @@ -932,6 +943,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", + pin_order_key AS "pinOrderKey", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", @@ -1538,6 +1550,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { workspaceRoot: row.workspaceRoot, repositoryIdentity: repositoryIdentities.get(row.projectId) ?? null, defaultModelSelection: row.defaultModelSelection, + defaultThreadEnvMode: row.defaultThreadEnvMode, + faviconPath: row.faviconPath ?? null, scripts: row.scripts, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -1562,6 +1576,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozedUntil: row.snoozedUntil, snoozedAt: row.snoozedAt, pinnedAt: row.pinnedAt, + pinOrderKey: row.pinOrderKey ?? null, titleRegeneration: mapTitleRegeneration(row), deletedAt: row.deletedAt, messages: messagesByThread.get(row.threadId) ?? [], @@ -1666,6 +1681,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { title: row.title, workspaceRoot: row.workspaceRoot, defaultModelSelection: row.defaultModelSelection, + defaultThreadEnvMode: row.defaultThreadEnvMode, + faviconPath: row.faviconPath ?? null, scripts: row.scripts, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -1766,6 +1783,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozedUntil: row.snoozedUntil, snoozedAt: row.snoozedAt, pinnedAt: row.pinnedAt, + pinOrderKey: row.pinOrderKey ?? null, titleRegeneration: mapTitleRegeneration(row), deletedAt: row.deletedAt, messages: [], @@ -1901,6 +1919,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozedUntil: row.snoozedUntil, snoozedAt: row.snoozedAt, pinnedAt: row.pinnedAt, + pinOrderKey: row.pinOrderKey ?? null, titleRegeneration: mapTitleRegeneration(row), session: sessionByThread.get(row.threadId) ?? null, latestUserMessageAt: row.latestUserMessageAt, @@ -2045,6 +2064,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozedUntil: row.snoozedUntil, snoozedAt: row.snoozedAt, pinnedAt: row.pinnedAt, + pinOrderKey: row.pinOrderKey ?? null, titleRegeneration: mapTitleRegeneration(row), session: sessionByThread.get(row.threadId) ?? null, latestUserMessageAt: row.latestUserMessageAt, @@ -2154,6 +2174,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { workspaceRoot: option.value.workspaceRoot, repositoryIdentity, defaultModelSelection: option.value.defaultModelSelection, + defaultThreadEnvMode: option.value.defaultThreadEnvMode, + faviconPath: option.value.faviconPath ?? null, scripts: option.value.scripts, createdAt: option.value.createdAt, updatedAt: option.value.updatedAt, @@ -2321,6 +2343,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozedUntil: threadRow.value.snoozedUntil, snoozedAt: threadRow.value.snoozedAt, pinnedAt: threadRow.value.pinnedAt, + pinOrderKey: threadRow.value.pinOrderKey ?? null, titleRegeneration: mapTitleRegeneration(threadRow.value), session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null, latestUserMessageAt: threadRow.value.latestUserMessageAt, @@ -2441,6 +2464,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozedUntil: threadRow.value.snoozedUntil, snoozedAt: threadRow.value.snoozedAt, pinnedAt: threadRow.value.pinnedAt, + pinOrderKey: threadRow.value.pinOrderKey ?? null, titleRegeneration: mapTitleRegeneration(threadRow.value), deletedAt: null, messages: messageRows.map((row) => { diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index dfc47320768..258aa010e3e 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -16,6 +16,7 @@ import { DEFAULT_PROVIDER_INTERACTION_MODE, EventId, MessageId, + type OrchestrationCommand, ProjectId, ProviderItemId, type ServerSettings, @@ -256,57 +257,52 @@ describe("ProviderRuntimeIngestion", () => { scope = await Effect.runPromise(Scope.make("sequential")); await Effect.runPromise(ingestion.start().pipe(Scope.provide(scope))); const drain = () => Effect.runPromise(ingestion.drain); + const dispatch = (command: OrchestrationCommand) => Effect.runPromise(engine.dispatch(command)); const createdAt = "2026-01-01T00:00:00.000Z"; - await Effect.runPromise( - engine.dispatch({ - type: "project.create", - commandId: CommandId.make("cmd-provider-project-create"), - projectId: asProjectId("project-1"), - title: "Provider Project", - workspaceRoot, - defaultModelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5-codex", - }, - createdAt, - }), - ); - await Effect.runPromise( - engine.dispatch({ - type: "thread.create", - commandId: CommandId.make("cmd-thread-create"), + await dispatch({ + type: "project.create", + commandId: CommandId.make("cmd-provider-project-create"), + projectId: asProjectId("project-1"), + title: "Provider Project", + workspaceRoot, + defaultModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + createdAt, + }); + await dispatch({ + type: "thread.create", + commandId: CommandId.make("cmd-thread-create"), + threadId: ThreadId.make("thread-1"), + projectId: asProjectId("project-1"), + title: "Thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt, + }); + await dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-seed"), + threadId: ThreadId.make("thread-1"), + session: { threadId: ThreadId.make("thread-1"), - projectId: asProjectId("project-1"), - title: "Thread", - modelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5-codex", - }, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + status: "ready", + providerName: "codex", runtimeMode: "approval-required", - branch: null, - worktreePath: null, - createdAt, - }), - ); - await Effect.runPromise( - engine.dispatch({ - type: "thread.session.set", - commandId: CommandId.make("cmd-session-seed"), - threadId: ThreadId.make("thread-1"), - session: { - threadId: ThreadId.make("thread-1"), - status: "ready", - providerName: "codex", - runtimeMode: "approval-required", - activeTurnId: null, - updatedAt: createdAt, - lastError: null, - }, - createdAt, - }), - ); + activeTurnId: null, + updatedAt: createdAt, + lastError: null, + }, + createdAt, + }); provider.setSession({ provider: ProviderDriverKind.make("codex"), status: "ready", @@ -318,6 +314,7 @@ describe("ProviderRuntimeIngestion", () => { return { engine, + dispatch, readModel: () => Effect.runPromise(snapshotQuery.getSnapshot()), emit: provider.emit, setProviderSession: provider.setSession, @@ -843,6 +840,82 @@ describe("ProviderRuntimeIngestion", () => { ); }); + it("rejects an untargeted turn.completed when no turn is active", async () => { + const harness = await createHarness(); + const seededAt = "2026-01-01T00:00:00.000Z"; + + // A turn start is pending: the session reads "starting" with no active + // turn tracked yet. This is the window the Claude resume handshake's + // phantom (turn.completed with no turnId) used to slip through, stomping + // "starting" back to "ready" for a turn that never existed. + await harness.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-seed-untargeted-completion"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "starting", + providerName: "claudeAgent", + runtimeMode: "approval-required", + activeTurnId: null, + updatedAt: seededAt, + lastError: null, + }, + createdAt: seededAt, + }); + + harness.emit({ + type: "turn.completed", + eventId: asEventId("evt-turn-completed-untargeted"), + provider: ProviderDriverKind.make("claudeAgent"), + createdAt: seededAt, + threadId: asThreadId("thread-1"), + status: "completed", + }); + + await harness.drain(); + const readModel = await harness.readModel(); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect(thread?.session?.status).toBe("starting"); + expect(thread?.session?.activeTurnId).toBeNull(); + }); + + it("accepts a targeted turn.completed when no turn is active", async () => { + const harness = await createHarness(); + const seededAt = "2026-01-01T00:00:00.000Z"; + + // A completion that names its turn still lands even when no active turn + // is tracked (e.g. its turn.started was lost). Only untargeted + // completions are rejected. + await harness.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-seed-targeted-completion"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "starting", + providerName: "claudeAgent", + runtimeMode: "approval-required", + activeTurnId: null, + updatedAt: seededAt, + lastError: null, + }, + createdAt: seededAt, + }); + + harness.emit({ + type: "turn.completed", + eventId: asEventId("evt-turn-completed-targeted-late"), + provider: ProviderDriverKind.make("claudeAgent"), + createdAt: seededAt, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-late"), + status: "completed", + }); + + await waitForThread(harness.readModel, (thread) => thread.session?.status === "ready"); + }); + it("ignores non-active turn completion when runtime omits thread id", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; @@ -2199,7 +2272,7 @@ describe("ProviderRuntimeIngestion", () => { }); it("starts a new streaming assistant message segment after approval", async () => { - const harness = await createHarness({ serverSettings: { enableAssistantStreaming: true } }); + const harness = await createHarness({ serverSettings: { enableLegacyTokenStreaming: true } }); const startedAt = "2026-03-28T07:00:00.000Z"; const pausedAt = "2026-03-28T07:00:01.000Z"; const resumedAt = "2026-03-28T07:00:02.000Z"; @@ -2306,7 +2379,7 @@ describe("ProviderRuntimeIngestion", () => { }); it("streams assistant deltas when thread.turn.start requests streaming mode", async () => { - const harness = await createHarness({ serverSettings: { enableAssistantStreaming: true } }); + const harness = await createHarness({ serverSettings: { enableLegacyTokenStreaming: true } }); const now = "2026-01-01T00:00:00.000Z"; await Effect.runPromise( diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index a86adea5232..03253797242 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1532,8 +1532,14 @@ const make = Effect.gen(function* () { if (activeTurnId !== null && eventTurnId !== undefined) { return sameId(activeTurnId, eventTurnId); } - // If no active turn is tracked, accept completion scoped to this thread. - return true; + // No active turn tracked: accept only completions that name their + // turn (covers a real completion whose turn.started was lost). An + // untargeted completion cannot prove it belongs to any turn this + // thread ran — the known emitter was the Claude resume handshake + // (system/init + result(num_turns: 0)), which is not a turn at + // all — and applying it here stomps the "starting" lifecycle + // state while a turn start is pending. + return eventTurnId !== undefined; default: return true; } @@ -1655,7 +1661,7 @@ const make = Effect.gen(function* () { const assistantDeliveryMode: AssistantDeliveryMode = yield* Effect.map( serverSettingsService.getSettings, - (settings) => (settings.enableAssistantStreaming ? "streaming" : "buffered"), + (settings) => (settings.enableLegacyTokenStreaming ? "streaming" : "buffered"), ); if (assistantDeliveryMode === "buffered") { const spillChunk = yield* appendBufferedAssistantText(assistantMessageId, assistantDelta); @@ -1691,7 +1697,7 @@ const make = Effect.gen(function* () { const detailedThread = yield* getLoadedThreadDetail(); const assistantDeliveryMode: AssistantDeliveryMode = yield* Effect.map( serverSettingsService.getSettings, - (settings) => (settings.enableAssistantStreaming ? "streaming" : "buffered"), + (settings) => (settings.enableLegacyTokenStreaming ? "streaming" : "buffered"), ); const flushedMessageIds = assistantDeliveryMode === "buffered" diff --git a/apps/server/src/orchestration/Schemas.ts b/apps/server/src/orchestration/Schemas.ts index ee96e422945..7e866cf8959 100644 --- a/apps/server/src/orchestration/Schemas.ts +++ b/apps/server/src/orchestration/Schemas.ts @@ -15,6 +15,7 @@ import { ThreadUnsnoozedPayload as ContractsThreadUnsnoozedPayloadSchema, ThreadPinnedPayload as ContractsThreadPinnedPayloadSchema, ThreadUnpinnedPayload as ContractsThreadUnpinnedPayloadSchema, + ThreadPinReorderedPayload as ContractsThreadPinReorderedPayloadSchema, ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema, ThreadProposedPlanUpsertedPayload as ContractsThreadProposedPlanUpsertedPayloadSchema, ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema, @@ -46,6 +47,7 @@ export const ThreadSnoozedPayload = ContractsThreadSnoozedPayloadSchema; export const ThreadUnsnoozedPayload = ContractsThreadUnsnoozedPayloadSchema; export const ThreadPinnedPayload = ContractsThreadPinnedPayloadSchema; export const ThreadUnpinnedPayload = ContractsThreadUnpinnedPayloadSchema; +export const ThreadPinReorderedPayload = ContractsThreadPinReorderedPayloadSchema; export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema; export const ThreadProposedPlanUpsertedPayload = ContractsThreadProposedPlanUpsertedPayloadSchema; diff --git a/apps/server/src/orchestration/decider.pinned.test.ts b/apps/server/src/orchestration/decider.pinned.test.ts index bed41e13a17..4ad00ba994b 100644 --- a/apps/server/src/orchestration/decider.pinned.test.ts +++ b/apps/server/src/orchestration/decider.pinned.test.ts @@ -16,6 +16,7 @@ const PINNED_AT = "1969-12-30T00:00:00.000Z"; function makeReadModel(input: { readonly pinnedAt?: string | null; + readonly pinOrderKey?: string | null; readonly archivedAt?: string | null; readonly settledOverride?: "settled" | "active" | null; readonly settledAt?: string | null; @@ -44,6 +45,7 @@ function makeReadModel(input: { snoozedUntil: input.snoozedUntil ?? null, snoozedAt: input.snoozedAt ?? (input.snoozedUntil != null ? PINNED_AT : null), pinnedAt: input.pinnedAt ?? null, + pinOrderKey: input.pinOrderKey ?? null, deletedAt: null, messages: [], proposedPlans: [], @@ -223,4 +225,100 @@ it.layer(NodeServices.layer)("pinned thread decider", (it) => { expect(error._tag).toBe("OrchestrationCommandInvariantError"); }), ); + + it.effect("a fresh pin carries the client's order key", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.pin", + commandId: CommandId.make("cmd-pin-keyed"), + threadId: ThreadId.make("thread-1"), + orderKey: "g", + }, + readModel: makeReadModel({}), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events[0]?.type).toBe("thread.pinned"); + if (events[0]?.type === "thread.pinned") { + expect(events[0].payload.pinOrderKey).toBe("g"); + } + }), + ); + + it.effect( + "re-pinning ignores the incoming order key so raced pins cannot move a placed thread", + () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.pin", + commandId: CommandId.make("cmd-pin-keyed-again"), + threadId: ThreadId.make("thread-1"), + orderKey: "t", + }, + readModel: makeReadModel({ pinnedAt: PINNED_AT, pinOrderKey: "g" }), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events[0]?.type).toBe("thread.pinned"); + if (events[0]?.type === "thread.pinned") { + expect(events[0].payload.pinOrderKey).toBeUndefined(); + } + }), + ); + + it.effect("reorders a pinned thread, stamping the new key", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.pin.reorder", + commandId: CommandId.make("cmd-reorder"), + threadId: ThreadId.make("thread-1"), + orderKey: "m", + }, + readModel: makeReadModel({ pinnedAt: PINNED_AT, pinOrderKey: "g" }), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events[0]?.type).toBe("thread.pin-reordered"); + if (events[0]?.type === "thread.pin-reordered") { + expect(events[0].payload.orderKey).toBe("m"); + // A real move stamps the command time (the test clock), not the + // thread's previous updatedAt. + expect(events[0].payload.updatedAt).not.toBe(NOW); + } + }), + ); + + it.effect("reordering onto the same key preserves updatedAt", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.pin.reorder", + commandId: CommandId.make("cmd-reorder-noop"), + threadId: ThreadId.make("thread-1"), + orderKey: "g", + }, + readModel: makeReadModel({ pinnedAt: PINNED_AT, pinOrderKey: "g" }), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events[0]?.type).toBe("thread.pin-reordered"); + if (events[0]?.type === "thread.pin-reordered") { + expect(events[0].payload.updatedAt).toBe(NOW); + } + }), + ); + + it.effect("rejects reordering an unpinned thread", () => + Effect.gen(function* () { + const error = yield* decideOrchestrationCommand({ + command: { + type: "thread.pin.reorder", + commandId: CommandId.make("cmd-reorder-unpinned"), + threadId: ThreadId.make("thread-1"), + orderKey: "m", + }, + readModel: makeReadModel({}), + }).pipe(Effect.flip); + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + }), + ); }); diff --git a/apps/server/src/orchestration/decider.projectScripts.test.ts b/apps/server/src/orchestration/decider.projectScripts.test.ts index a0c06840733..bf5c509fa16 100644 --- a/apps/server/src/orchestration/decider.projectScripts.test.ts +++ b/apps/server/src/orchestration/decider.projectScripts.test.ts @@ -94,6 +94,47 @@ it.layer(NodeServices.layer)("decider project scripts", (it) => { }), ); + it.effect("propagates a project favicon path in project.meta.update", () => + Effect.gen(function* () { + const now = "2026-01-01T00:00:00.000Z"; + const readModel = yield* projectEvent(createEmptyReadModel(now), { + sequence: 1, + eventId: asEventId("evt-project-create-favicon"), + aggregateKind: "project", + aggregateId: asProjectId("project-favicon"), + type: "project.created", + occurredAt: now, + commandId: CommandId.make("cmd-project-create-favicon"), + causationEventId: null, + correlationId: CommandId.make("cmd-project-create-favicon"), + metadata: {}, + payload: { + projectId: asProjectId("project-favicon"), + title: "Favicon", + workspaceRoot: "/tmp/favicon", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); + + const result = yield* decideOrchestrationCommand({ + command: { + type: "project.meta.update", + commandId: CommandId.make("cmd-project-update-favicon"), + projectId: asProjectId("project-favicon"), + faviconPath: "brand/icon.svg", + }, + readModel, + }); + + const event = Array.isArray(result) ? result[0] : result; + expect(event.type).toBe("project.meta-updated"); + expect((event.payload as { faviconPath?: string }).faviconPath).toBe("brand/icon.svg"); + }), + ); + it.effect("rejects project.create for an active workspace root that already exists", () => Effect.gen(function* () { const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/decider.projectThreadEnvMode.test.ts b/apps/server/src/orchestration/decider.projectThreadEnvMode.test.ts new file mode 100644 index 00000000000..afee13343ad --- /dev/null +++ b/apps/server/src/orchestration/decider.projectThreadEnvMode.test.ts @@ -0,0 +1,103 @@ +import { CommandId, EventId, ProjectId, type OrchestrationEvent } from "@t3tools/contracts"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as NodeServices from "@effect/platform-node/NodeServices"; + +import { decideOrchestrationCommand } from "./decider.ts"; +import { createEmptyReadModel, projectEvent } from "./projector.ts"; + +const now = "2026-01-01T00:00:00.000Z"; +const projectId = ProjectId.make("project-env-mode"); + +const seedProjectCreated = (sequence: number): OrchestrationEvent => ({ + sequence, + eventId: EventId.make(`evt-project-env-mode-${sequence}`), + aggregateKind: "project", + aggregateId: projectId, + type: "project.created", + occurredAt: now, + commandId: CommandId.make(`cmd-project-env-mode-${sequence}`), + causationEventId: null, + correlationId: CommandId.make(`cmd-project-env-mode-${sequence}`), + metadata: {}, + payload: { + projectId, + title: "Env mode", + workspaceRoot: "/tmp/env-mode", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, +}); + +it.layer(NodeServices.layer)("decider project defaultThreadEnvMode", (it) => { + it.effect("propagates defaultThreadEnvMode through meta.update into the read model", () => + Effect.gen(function* () { + const readModel = yield* projectEvent(createEmptyReadModel(now), seedProjectCreated(1)); + expect(readModel.projects[0]?.defaultThreadEnvMode).toBeNull(); + + const result = yield* decideOrchestrationCommand({ + command: { + type: "project.meta.update", + commandId: CommandId.make("cmd-project-env-mode-set"), + projectId, + defaultThreadEnvMode: "worktree", + }, + readModel, + }); + + const event = Array.isArray(result) ? result[0] : result; + expect(event.type).toBe("project.meta-updated"); + expect((event.payload as { defaultThreadEnvMode?: unknown }).defaultThreadEnvMode).toBe( + "worktree", + ); + + const updated = yield* projectEvent(readModel, { ...event, sequence: 2 }); + expect(updated.projects[0]?.defaultThreadEnvMode).toBe("worktree"); + }), + ); + + it.effect("omits the field when unset and clears it on explicit null", () => + Effect.gen(function* () { + const readModel = yield* projectEvent(createEmptyReadModel(now), seedProjectCreated(1)); + + const unrelated = yield* decideOrchestrationCommand({ + command: { + type: "project.meta.update", + commandId: CommandId.make("cmd-project-env-mode-title"), + projectId, + title: "Renamed", + }, + readModel, + }); + const unrelatedEvent = Array.isArray(unrelated) ? unrelated[0] : unrelated; + expect("defaultThreadEnvMode" in (unrelatedEvent.payload as object)).toBe(false); + + const set = yield* decideOrchestrationCommand({ + command: { + type: "project.meta.update", + commandId: CommandId.make("cmd-project-env-mode-set"), + projectId, + defaultThreadEnvMode: "worktree", + }, + readModel, + }); + const setEvent = Array.isArray(set) ? set[0] : set; + const afterSet = yield* projectEvent(readModel, { ...setEvent, sequence: 2 }); + + const clear = yield* decideOrchestrationCommand({ + command: { + type: "project.meta.update", + commandId: CommandId.make("cmd-project-env-mode-clear"), + projectId, + defaultThreadEnvMode: null, + }, + readModel: afterSet, + }); + const clearEvent = Array.isArray(clear) ? clear[0] : clear; + const afterClear = yield* projectEvent(afterSet, { ...clearEvent, sequence: 3 }); + expect(afterClear.projects[0]?.defaultThreadEnvMode).toBeNull(); + }), + ); +}); diff --git a/apps/server/src/orchestration/decider.settled.test.ts b/apps/server/src/orchestration/decider.settled.test.ts index 2144c0ada93..95623519c3f 100644 --- a/apps/server/src/orchestration/decider.settled.test.ts +++ b/apps/server/src/orchestration/decider.settled.test.ts @@ -529,4 +529,55 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => { expect(routineEvents.map((event) => event.type)).toEqual(["thread.activity-appended"]); }), ); + + it.effect("drops an onlyIfSettled session stop when the thread was re-engaged", () => + Effect.gen(function* () { + const stopCommand = (commandId: string) => + ({ + type: "thread.session.stop", + commandId: CommandId.make(commandId), + threadId: ThreadId.make("thread-1"), + createdAt: NOW, + onlyIfSettled: true, + }) as const; + + // Still settled with an idle session: the cleanup stop goes through. + const stopped = yield* decideOrchestrationCommand({ + command: stopCommand("cmd-stop-settled-idle"), + readModel: makeReadModel("settled", null, makeSession("ready")), + }); + const stoppedEvents = Array.isArray(stopped) ? stopped : [stopped]; + expect(stoppedEvents.map((event) => event.type)).toEqual(["thread.session-stop-requested"]); + + // Re-engaged before the stop was decided (a turn start unsettles the + // thread): the stale cleanup stop must not kill the new session. + const unsettledError = yield* decideOrchestrationCommand({ + command: stopCommand("cmd-stop-unsettled"), + readModel: makeReadModel(null, null, makeSession("starting")), + }).pipe(Effect.flip); + expect(unsettledError._tag).toBe("OrchestrationCommandInvariantError"); + + // Still settled but the session is already coming alive: same drop. + const aliveError = yield* decideOrchestrationCommand({ + command: stopCommand("cmd-stop-session-alive"), + readModel: makeReadModel("settled", null, makeSession("starting")), + }).pipe(Effect.flip); + expect(aliveError._tag).toBe("OrchestrationCommandInvariantError"); + + // Without the flag the stop stays unconditional (archive, stop button). + const unconditional = yield* decideOrchestrationCommand({ + command: { + type: "thread.session.stop", + commandId: CommandId.make("cmd-stop-unconditional"), + threadId: ThreadId.make("thread-1"), + createdAt: NOW, + }, + readModel: makeReadModel(null, null, makeSession("starting")), + }); + const unconditionalEvents = Array.isArray(unconditional) ? unconditional : [unconditional]; + expect(unconditionalEvents.map((event) => event.type)).toEqual([ + "thread.session-stop-requested", + ]); + }), + ); }); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index d7fc22708f6..5517306b380 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -250,6 +250,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" title: command.title, workspaceRoot: command.workspaceRoot, defaultModelSelection: command.defaultModelSelection ?? null, + faviconPath: null, scripts: [], createdAt: command.createdAt, updatedAt: command.createdAt, @@ -287,6 +288,10 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ...(command.defaultModelSelection !== undefined ? { defaultModelSelection: command.defaultModelSelection } : {}), + ...(command.defaultThreadEnvMode !== undefined + ? { defaultThreadEnvMode: command.defaultThreadEnvMode } + : {}), + ...(command.faviconPath !== undefined ? { faviconPath: command.faviconPath } : {}), ...(command.scripts !== undefined ? { scripts: command.scripts } : {}), updatedAt: occurredAt, }, @@ -676,6 +681,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" payload: { threadId: command.threadId, pinnedAt: existingPinnedAt ?? occurredAt, + // A fresh pin takes the client's slot in the arranged order; on a + // re-pin the existing key wins so raced duplicates cannot move a + // thread the user already placed. + ...(existingPinnedAt === null && command.orderKey !== undefined + ? { pinOrderKey: command.orderKey } + : {}), updatedAt: existingPinnedAt !== null ? thread.updatedAt : occurredAt, }, }; @@ -745,6 +756,43 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.pin.reorder": { + const thread = yield* requireThreadNotArchived({ + readModel, + command, + threadId: command.threadId, + }); + // Only pinned threads have a slot in the arranged order. Rejecting + // (rather than silently pinning) keeps a raced reorder-after-unpin + // from resurrecting a pin the user just cleared. + if (thread.pinnedAt == null) { + return yield* Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} is not pinned and cannot be reordered`, + }), + ); + } + // Idempotent by re-emission (see thread.settle): a duplicate drop on + // the same slot keeps the existing updatedAt so it projects as a no-op. + const keyUnchanged = thread.pinOrderKey === command.orderKey; + const occurredAt = yield* nowIso; + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.pin-reordered", + payload: { + threadId: command.threadId, + orderKey: command.orderKey, + updatedAt: keyUnchanged ? thread.updatedAt : occurredAt, + }, + }; + } + case "thread.meta.update": { const thread = yield* requireThread({ readModel, @@ -1077,11 +1125,32 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" } case "thread.session.stop": { - yield* requireThread({ + const thread = yield* requireThread({ readModel, command, threadId: command.threadId, }); + // Settle-cleanup stops are conditional: between the settle landing and + // this command, another client may have re-engaged the thread (a turn + // start unsettles it and brings the session alive). Commands are + // decided serially against this read model, so checking here — not in + // the dispatcher's pre-settle snapshot — closes that race. + if (command.onlyIfSettled === true) { + const sessionComingAlive = + thread.session?.status === "starting" || thread.session?.status === "running"; + if ( + thread.settledOverride !== "settled" || + sessionComingAlive || + threadHasQueuedTurnStart(thread, command.createdAt) + ) { + return yield* Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} was re-engaged after settle; skipping session stop`, + }), + ); + } + } return { ...(yield* withEventBase({ aggregateKind: "thread", diff --git a/apps/server/src/orchestration/projector.pinned.test.ts b/apps/server/src/orchestration/projector.pinned.test.ts index 35bd063667a..791bd4b75e6 100644 --- a/apps/server/src/orchestration/projector.pinned.test.ts +++ b/apps/server/src/orchestration/projector.pinned.test.ts @@ -75,3 +75,80 @@ it.effect("projects pin lifecycle events", () => expect(unpinned.threads[0]?.pinnedAt).toBeNull(); }), ); + +it.effect("projects pin order key lifecycle", () => + Effect.gen(function* () { + const now = "2026-01-01T00:00:00.000Z"; + const created = yield* projectEvent( + createEmptyReadModel(now), + makeEvent({ + sequence: 1, + type: "thread.created", + payload: { + threadId: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { provider: "codex", model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }), + ); + expect(created.threads[0]?.pinOrderKey ?? null).toBeNull(); + + // Fresh pin carries the client's slot in the arranged order. + const pinned = yield* projectEvent( + created, + makeEvent({ + sequence: 2, + type: "thread.pinned", + payload: { + threadId: ThreadId.make("thread-1"), + pinnedAt: now, + pinOrderKey: "g", + updatedAt: now, + }, + }), + ); + expect(pinned.threads[0]?.pinOrderKey).toBe("g"); + + // Re-pins and events from pre-reorder servers omit the field entirely; + // the existing key must survive rather than being nulled out. + const repinned = yield* projectEvent( + pinned, + makeEvent({ + sequence: 3, + type: "thread.pinned", + payload: { threadId: ThreadId.make("thread-1"), pinnedAt: now, updatedAt: now }, + }), + ); + expect(repinned.threads[0]?.pinOrderKey).toBe("g"); + + // A drag persists the new slot. + const reordered = yield* projectEvent( + repinned, + makeEvent({ + sequence: 4, + type: "thread.pin-reordered", + payload: { threadId: ThreadId.make("thread-1"), orderKey: "m", updatedAt: now }, + }), + ); + expect(reordered.threads[0]?.pinOrderKey).toBe("m"); + + // Unpin clears the slot: re-pinning is "pin again", not "restore an + // ancient position". + const unpinned = yield* projectEvent( + reordered, + makeEvent({ + sequence: 5, + type: "thread.unpinned", + payload: { threadId: ThreadId.make("thread-1"), updatedAt: now }, + }), + ); + expect(unpinned.threads[0]?.pinOrderKey).toBeNull(); + }), +); diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index ed4b084e4f9..f486dcb2bcb 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -24,6 +24,7 @@ import { ThreadRuntimeModeSetPayload, ThreadSettledPayload, ThreadPinnedPayload, + ThreadPinReorderedPayload, ThreadSnoozedPayload, ThreadUnpinnedPayload, ThreadUnarchivedPayload, @@ -213,6 +214,8 @@ export function projectEvent( title: payload.title, workspaceRoot: payload.workspaceRoot, defaultModelSelection: payload.defaultModelSelection, + defaultThreadEnvMode: null, + faviconPath: payload.faviconPath ?? null, scripts: payload.scripts, createdAt: payload.createdAt, updatedAt: payload.updatedAt, @@ -245,6 +248,12 @@ export function projectEvent( ...(payload.defaultModelSelection !== undefined ? { defaultModelSelection: payload.defaultModelSelection } : {}), + ...(payload.defaultThreadEnvMode !== undefined + ? { defaultThreadEnvMode: payload.defaultThreadEnvMode } + : {}), + ...(payload.faviconPath !== undefined + ? { faviconPath: payload.faviconPath } + : {}), ...(payload.scripts !== undefined ? { scripts: payload.scripts } : {}), updatedAt: payload.updatedAt, } @@ -402,6 +411,7 @@ export function projectEvent( ...nextBase, threads: updateThread(nextBase.threads, payload.threadId, { pinnedAt: payload.pinnedAt, + ...(payload.pinOrderKey !== undefined ? { pinOrderKey: payload.pinOrderKey } : {}), updatedAt: payload.updatedAt, }), })), @@ -413,6 +423,20 @@ export function projectEvent( ...nextBase, threads: updateThread(nextBase.threads, payload.threadId, { pinnedAt: null, + // Unpin clears the slot: re-pinning is "pin again", not "restore + // an ancient position". + pinOrderKey: null, + updatedAt: payload.updatedAt, + }), + })), + ); + + case "thread.pin-reordered": + return decodeForEvent(ThreadPinReorderedPayload, event.payload, event.type, "payload").pipe( + Effect.map((payload) => ({ + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + pinOrderKey: payload.orderKey, updatedAt: payload.updatedAt, }), })), diff --git a/apps/server/src/persistence/Layers/ProjectionProjects.ts b/apps/server/src/persistence/Layers/ProjectionProjects.ts index c1ca6d3104e..ba133bb24a4 100644 --- a/apps/server/src/persistence/Layers/ProjectionProjects.ts +++ b/apps/server/src/persistence/Layers/ProjectionProjects.ts @@ -35,6 +35,8 @@ const makeProjectionProjectRepository = Effect.gen(function* () { title, workspace_root, default_model_selection_json, + default_thread_env_mode, + favicon_path, scripts_json, created_at, updated_at, @@ -45,6 +47,8 @@ const makeProjectionProjectRepository = Effect.gen(function* () { ${row.title}, ${row.workspaceRoot}, ${row.defaultModelSelection !== null ? JSON.stringify(row.defaultModelSelection) : null}, + ${row.defaultThreadEnvMode}, + ${row.faviconPath ?? null}, ${JSON.stringify(row.scripts)}, ${row.createdAt}, ${row.updatedAt}, @@ -55,6 +59,8 @@ const makeProjectionProjectRepository = Effect.gen(function* () { title = excluded.title, workspace_root = excluded.workspace_root, default_model_selection_json = excluded.default_model_selection_json, + default_thread_env_mode = excluded.default_thread_env_mode, + favicon_path = excluded.favicon_path, scripts_json = excluded.scripts_json, created_at = excluded.created_at, updated_at = excluded.updated_at, @@ -72,6 +78,8 @@ const makeProjectionProjectRepository = Effect.gen(function* () { title, workspace_root AS "workspaceRoot", default_model_selection_json AS "defaultModelSelection", + default_thread_env_mode AS "defaultThreadEnvMode", + favicon_path AS "faviconPath", scripts_json AS "scripts", created_at AS "createdAt", updated_at AS "updatedAt", @@ -91,6 +99,8 @@ const makeProjectionProjectRepository = Effect.gen(function* () { title, workspace_root AS "workspaceRoot", default_model_selection_json AS "defaultModelSelection", + default_thread_env_mode AS "defaultThreadEnvMode", + favicon_path AS "faviconPath", scripts_json AS "scripts", created_at AS "createdAt", updated_at AS "updatedAt", diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index 71d7df566fd..bebd8fbb4a7 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -33,6 +33,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4", }, + defaultThreadEnvMode: null, scripts: [], createdAt: "2026-03-24T00:00:00.000Z", updatedAt: "2026-03-24T00:00:00.000Z", diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index 0e2adeecf3b..b7d8ae13747 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -48,6 +48,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { snoozed_until, snoozed_at, pinned_at, + pin_order_key, title_regeneration_request_id, title_regeneration_started_at, latest_user_message_at, @@ -74,6 +75,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.snoozedUntil}, ${row.snoozedAt}, ${row.pinnedAt}, + ${row.pinOrderKey ?? null}, ${row.titleRegenerationRequestId ?? null}, ${row.titleRegenerationStartedAt ?? null}, ${row.latestUserMessageAt}, @@ -100,6 +102,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { snoozed_until = excluded.snoozed_until, snoozed_at = excluded.snoozed_at, pinned_at = excluded.pinned_at, + pin_order_key = excluded.pin_order_key, title_regeneration_request_id = excluded.title_regeneration_request_id, title_regeneration_started_at = excluded.title_regeneration_started_at, latest_user_message_at = excluded.latest_user_message_at, @@ -133,6 +136,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", + pin_order_key AS "pinOrderKey", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", @@ -168,6 +172,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", + pin_order_key AS "pinOrderKey", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 1f335bdfda7..b137cedfbed 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -50,6 +50,9 @@ import Migration0034 from "./Migrations/034_ProjectionThreadsSnoozed.ts"; import Migration0035 from "./Migrations/035_ProjectionThreadTitleRegeneration.ts"; import Migration0036 from "./Migrations/036_ProjectionThreadsPinned.ts"; import Migration0037 from "./Migrations/037_ProjectionTurnsKeysetIndex.ts"; +import Migration0038 from "./Migrations/038_ProjectionThreadsPinOrderKey.ts"; +import Migration0039 from "./Migrations/039_ProjectionProjectsDefaultThreadEnvMode.ts"; +import Migration0040 from "./Migrations/040_ProjectionProjectFaviconPath.ts"; /** * Migration loader with all migrations defined inline. @@ -99,6 +102,9 @@ export const migrationEntries = [ [35, "ProjectionThreadTitleRegeneration", Migration0035], [36, "ProjectionThreadsPinned", Migration0036], [37, "ProjectionTurnsKeysetIndex", Migration0037], + [38, "ProjectionThreadsPinOrderKey", Migration0038], + [39, "ProjectionProjectsDefaultThreadEnvMode", Migration0039], + [40, "ProjectionProjectFaviconPath", Migration0040], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/038_ProjectionThreadsPinOrderKey.ts b/apps/server/src/persistence/Migrations/038_ProjectionThreadsPinOrderKey.ts new file mode 100644 index 00000000000..d6735ebdbfb --- /dev/null +++ b/apps/server/src/persistence/Migrations/038_ProjectionThreadsPinOrderKey.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + + if (!columns.some((column) => column.name === "pin_order_key")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN pin_order_key TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Migrations/039_ProjectionProjectsDefaultThreadEnvMode.ts b/apps/server/src/persistence/Migrations/039_ProjectionProjectsDefaultThreadEnvMode.ts new file mode 100644 index 00000000000..2ac6f78e6de --- /dev/null +++ b/apps/server/src/persistence/Migrations/039_ProjectionProjectsDefaultThreadEnvMode.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_projects) + `; + + if (!columns.some((column) => column.name === "default_thread_env_mode")) { + yield* sql` + ALTER TABLE projection_projects + ADD COLUMN default_thread_env_mode TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Migrations/040_ProjectionProjectFaviconPath.test.ts b/apps/server/src/persistence/Migrations/040_ProjectionProjectFaviconPath.test.ts new file mode 100644 index 00000000000..7fd43d9b2ec --- /dev/null +++ b/apps/server/src/persistence/Migrations/040_ProjectionProjectFaviconPath.test.ts @@ -0,0 +1,28 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("040_ProjectionProjectFaviconPath", (it) => { + it.effect("adds the nullable favicon path to project projections", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 39 }); + yield* runMigrations({ toMigrationInclusive: 40 }); + + const columns = yield* sql<{ readonly name: string; readonly notnull: number }>` + PRAGMA table_info(projection_projects) + `; + const faviconPath = columns.find((column) => column.name === "favicon_path"); + + assert.equal(faviconPath?.name, "favicon_path"); + assert.equal(faviconPath?.notnull, 0); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/040_ProjectionProjectFaviconPath.ts b/apps/server/src/persistence/Migrations/040_ProjectionProjectFaviconPath.ts new file mode 100644 index 00000000000..8424e8d5e72 --- /dev/null +++ b/apps/server/src/persistence/Migrations/040_ProjectionProjectFaviconPath.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_projects) + `; + + if (!columns.some((column) => column.name === "favicon_path")) { + yield* sql` + ALTER TABLE projection_projects + ADD COLUMN favicon_path TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Services/ProjectionProjects.ts b/apps/server/src/persistence/Services/ProjectionProjects.ts index 5632205a269..339439fdfcb 100644 --- a/apps/server/src/persistence/Services/ProjectionProjects.ts +++ b/apps/server/src/persistence/Services/ProjectionProjects.ts @@ -6,7 +6,13 @@ * * @module ProjectionProjectRepository */ -import { IsoDateTime, ModelSelection, ProjectId, ProjectScript } from "@t3tools/contracts"; +import { + IsoDateTime, + ModelSelection, + ProjectId, + ProjectScript, + ThreadEnvMode, +} from "@t3tools/contracts"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Context from "effect/Context"; @@ -19,6 +25,8 @@ export const ProjectionProject = Schema.Struct({ title: Schema.String, workspaceRoot: Schema.String, defaultModelSelection: Schema.NullOr(ModelSelection), + defaultThreadEnvMode: Schema.NullOr(ThreadEnvMode), + faviconPath: Schema.optional(Schema.NullOr(Schema.String)), scripts: Schema.Array(ProjectScript), createdAt: IsoDateTime, updatedAt: IsoDateTime, diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index a0cee8e3298..c572e1d11cc 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -42,6 +42,7 @@ export const ProjectionThread = Schema.Struct({ snoozedUntil: Schema.NullOr(IsoDateTime), snoozedAt: Schema.NullOr(IsoDateTime), pinnedAt: Schema.NullOr(IsoDateTime), + pinOrderKey: Schema.optional(Schema.NullOr(Schema.String)), titleRegenerationRequestId: Schema.optional(Schema.NullOr(CommandId)), titleRegenerationStartedAt: Schema.optional(Schema.NullOr(IsoDateTime)), latestUserMessageAt: Schema.NullOr(IsoDateTime), diff --git a/apps/server/src/processRunner.ts b/apps/server/src/processRunner.ts index c1ee2b2cb0c..16b5625d469 100644 --- a/apps/server/src/processRunner.ts +++ b/apps/server/src/processRunner.ts @@ -13,6 +13,7 @@ import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { collectUint8StreamText, + decodeUtf8, type CollectedUint8StreamText, } from "./stream/collectUint8StreamText.ts"; @@ -41,6 +42,8 @@ export interface ProcessRunOutput { readonly timedOut: boolean; readonly stdoutTruncated: boolean; readonly stderrTruncated: boolean; + readonly stdoutInvalidUtf8: boolean; + readonly stderrInvalidUtf8: boolean; } const ProcessInvocationFields = { @@ -238,7 +241,7 @@ const collectText = Effect.fn("processRunner.collectText")(function* (input: { ), Effect.map( (state): CollectedUint8StreamText => ({ - text: Buffer.concat(state.chunks, state.bytes).toString("utf8"), + ...decodeUtf8(Buffer.concat(state.chunks, state.bytes)), bytes: state.bytes, truncated: false, }), @@ -268,6 +271,8 @@ function finalizeRunProcess( timedOut: true, stdoutTruncated: false, stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, } satisfies ProcessRunOutput); } return Effect.fail( @@ -394,6 +399,8 @@ const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* ( timedOut: false, stdoutTruncated: stdout.truncated, stderrTruncated: stderr.truncated, + stdoutInvalidUtf8: stdout.invalidUtf8, + stderrInvalidUtf8: stderr.invalidUtf8, } satisfies ProcessRunOutput; }); diff --git a/apps/server/src/project/ProjectFaviconResolver.test.ts b/apps/server/src/project/ProjectFaviconResolver.test.ts index 75db78844a5..7448ced247b 100644 --- a/apps/server/src/project/ProjectFaviconResolver.test.ts +++ b/apps/server/src/project/ProjectFaviconResolver.test.ts @@ -77,6 +77,33 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => { }), ); + it.effect("uses a saved project favicon override", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "brand/custom.svg", "custom"); + yield* writeTextFile(cwd, "favicon.svg", "automatic"); + + const resolved = yield* resolver.resolvePath(cwd, "brand/custom.svg"); + + expect(resolved).not.toBeNull(); + expect(resolved).toContain("brand/custom.svg"); + }), + ); + + it.effect("falls back when a saved override is missing from a checkout", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "favicon.svg", "automatic"); + + const resolved = yield* resolver.resolvePath(cwd, "brand/missing.svg"); + + expect(resolved).not.toBeNull(); + expect(resolved).toContain("favicon.svg"); + }), + ); + it.effect("falls back to well-known files when the t3.json iconPath does not exist", () => Effect.gen(function* () { const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; @@ -133,6 +160,109 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => { }), ); + it.effect("resolves icon hrefs from object-literal route metadata", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile( + cwd, + "src/routes/__root.tsx", + `export const Route = createRootRoute({ + head: () => ({ + links: [ + { rel: "stylesheet", href: "/app.css" }, + { rel: "icon", href: "/brand/logo.svg" }, + ], + }), +});`, + ); + yield* writeTextFile(cwd, "public/brand/logo.svg", "brand"); + + const resolved = yield* resolver.resolvePath(cwd); + + expect(resolved).not.toBeNull(); + expect(resolved).toContain("public/brand/logo.svg"); + }), + ); + + it.effect("resolves object-literal icon metadata when href precedes rel", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile( + cwd, + "src/root.tsx", + `const links = [{ href: "/brand/logo.svg", rel: "shortcut icon" }];`, + ); + yield* writeTextFile(cwd, "public/brand/logo.svg", "brand"); + + const resolved = yield* resolver.resolvePath(cwd); + + expect(resolved).not.toBeNull(); + expect(resolved).toContain("public/brand/logo.svg"); + }), + ); + + it.effect("resolves object-literal icon metadata alongside nested objects", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile( + cwd, + "src/root.tsx", + `const links = [{ attributes: {}, rel: "icon", href: "/brand/logo.svg" }];`, + ); + yield* writeTextFile(cwd, "public/brand/logo.svg", "brand"); + + const resolved = yield* resolver.resolvePath(cwd); + + expect(resolved).not.toBeNull(); + expect(resolved).toContain("public/brand/logo.svg"); + }), + ); + + it.effect("skips icon metadata without an href and keeps scanning", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile( + cwd, + "src/root.tsx", + `const links = [{ rel: "icon" }, { rel: "icon", href: "/brand/logo.svg" }];`, + ); + yield* writeTextFile(cwd, "public/brand/logo.svg", "brand"); + + const resolved = yield* resolver.resolvePath(cwd); + + expect(resolved).not.toBeNull(); + expect(resolved).toContain("public/brand/logo.svg"); + }), + ); + + // A large icon source with no icon metadata used to pin the server's event loop for + // minutes: the object pattern was unanchored, so it restarted at every offset and + // rescanned forward from each one. Anchoring keeps this proportional to file size. + it.effect("scans large icon sources without an icon in reasonable time", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + // Mirrors a generated single-file build: large, brace-sparse, and no icon metadata. + const filler = `

${"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 2c7195de630..458954daed4 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 5b148366e93..21397160869 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -978,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* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 9a183c51bb8..93eec11817c 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -2248,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; } @@ -4150,6 +4150,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 } : {}), @@ -4173,7 +4181,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 ? { @@ -4208,7 +4216,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) ?? "", diff --git a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts index 3a02c45b23f..38e0e0a7b2c 100644 --- a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts +++ b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts @@ -245,4 +245,53 @@ describe("CodexSessionRuntime collab integration", () => { 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/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 57a1162dd08..58c012bd63e 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -814,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, })); }); @@ -1782,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, diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 177040428ea..7a387e0c84d 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( @@ -656,6 +666,7 @@ it.effect("marks the persisted binding stopped when session.exited flows through Layer.provide(Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, registry)), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -736,6 +747,7 @@ it.effect( Layer.provide(Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, registry)), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -823,6 +835,7 @@ it.effect( Layer.provide(Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, registry)), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -903,6 +916,7 @@ it.effect("refreshes the binding lastSeenAt when turn activity flows through the Layer.provide(Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, registry)), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -998,6 +1012,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( @@ -1064,6 +1079,7 @@ it.effect( ), Layer.provide(firstDirectoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -1123,6 +1139,7 @@ it.effect( ), Layer.provide(secondDirectoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -1254,6 +1271,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; @@ -1634,6 +1699,7 @@ routing.layer("ProviderServiceLive routing", (it) => { ), Layer.provide(firstDirectoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -1672,6 +1738,7 @@ routing.layer("ProviderServiceLive routing", (it) => { ), Layer.provide(secondDirectoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -1740,6 +1807,7 @@ routing.layer("ProviderServiceLive routing", (it) => { ), Layer.provide(firstDirectoryLayer), Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( Layer.succeed( @@ -1773,6 +1841,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 f174dcf43e6..253bbe8ee16 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -36,6 +36,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, @@ -204,6 +206,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 @@ -772,16 +775,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, diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index c1f992ade11..fab8ae1cfae 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -18,8 +18,6 @@ import * as Stream from "effect/Stream"; import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; -import * as ThreadBackgroundLiveness from "../../orchestration/ThreadBackgroundLiveness.ts"; -import { ThreadBackgroundLivenessService } from "../../orchestration/ThreadBackgroundLiveness.ts"; import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; import * as ProviderSessionRuntime from "../../persistence/ProviderSessionRuntime.ts"; import { ProviderValidationError } from "../Errors.ts"; @@ -70,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"; @@ -111,6 +110,7 @@ function makeReadModel( latestTurn: null, messages: [], session: thread.session, + backgroundLiveness: thread.backgroundLiveness ?? null, activities: [], proposedPlans: [], checkpoints: [], @@ -137,12 +137,19 @@ 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: { readonly threadId: ThreadId; }) => ReturnType; - readonly backgroundLiveness?: ThreadBackgroundLivenessService["Service"]; readonly backgroundWorkMaxIdleMs?: number; }) { const stoppedThreadIds = new Set(); @@ -197,11 +204,6 @@ describe("ProviderSessionReaper", () => { Layer.provideMerge(providerSessionDirectoryLayer), Layer.provideMerge(runtimeRepositoryLayer), Layer.provideMerge(Layer.succeed(ProviderService, providerService)), - Layer.provideMerge( - input.backgroundLiveness !== undefined - ? Layer.succeed(ThreadBackgroundLivenessService, input.backgroundLiveness) - : ThreadBackgroundLiveness.layer, - ), Layer.provideMerge( Layer.succeed(ProjectionSnapshotQuery, { getCommandReadModel: () => Effect.die("unused"), @@ -273,9 +275,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); @@ -323,9 +323,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(); @@ -333,7 +331,7 @@ describe("ProviderSessionReaper", () => { expect(Option.isSome(remaining)).toBe(true); }); - it("skips idle sessions while background work is live", async () => { + 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({ @@ -349,19 +347,17 @@ describe("ProviderSessionReaper", () => { lastError: null, updatedAt: now, }, + backgroundLiveness: "working", }, ]), - backgroundLiveness: { - recordTaskLiveness: () => {}, - clearThreadLiveness: () => {}, - getThreadBackgroundLiveness: (id) => (id === threadId ? "working" : null), - }, }); const repository = await runtime!.runPromise( Effect.service(ProviderSessionRuntime.ProviderSessionRuntimeRepository), ); - // Past the inactivity threshold (1s) but inside the default wedge cap. + // Past the inactivity threshold (1s) but inside the default wedge cap — + // beyond the cap the fork reaps even live background work (see the + // wedge-cap test below). const nowMs = await runtime!.runPromise(Clock.currentTimeMillis); await runtime!.runPromise( repository.upsert({ @@ -379,12 +375,12 @@ describe("ProviderSessionReaper", () => { }), ); - const reaper = await runtime!.runPromise(Effect.service(ProviderSessionReaper)); - scope = await runtime!.runPromise(Scope.make("sequential")); - await runtime!.runPromise(reaper.start().pipe(Scope.provide(scope))); - await runtime!.runPromise(drainFibers); + 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("reaps sessions with live background work once past the wedge cap", async () => { @@ -403,13 +399,9 @@ describe("ProviderSessionReaper", () => { lastError: null, updatedAt: now, }, + backgroundLiveness: "working", }, ]), - backgroundLiveness: { - recordTaskLiveness: () => {}, - clearThreadLiveness: () => {}, - getThreadBackgroundLiveness: () => "working", - }, backgroundWorkMaxIdleMs: 5_000, }); const repository = await runtime!.runPromise( @@ -482,9 +474,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(); @@ -531,9 +521,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(); @@ -617,9 +605,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); @@ -700,9 +686,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 4c4a35fad62..a88943235bf 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.ts @@ -6,7 +6,6 @@ import * as Option from "effect/Option"; import * as Schedule from "effect/Schedule"; import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; -import { ThreadBackgroundLivenessService } from "../../orchestration/ThreadBackgroundLiveness.ts"; import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; import { ProviderSessionReaper, @@ -35,7 +34,6 @@ const makeProviderSessionReaper = (options?: ProviderSessionReaperLiveOptions) = const providerService = yield* ProviderService; const directory = yield* ProviderSessionDirectory; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; - const threadBackgroundLiveness = yield* ThreadBackgroundLivenessService; const inactivityThresholdMs = Math.max( 1, @@ -84,19 +82,17 @@ const makeProviderSessionReaper = (options?: ProviderSessionReaperLiveOptions) = continue; } - // Background agents and monitors run on after the turn settles, with - // no activeTurnId and no lastSeenAt refresh. The liveness registry is - // the decaying signal for that work (entries clear on terminal task - // status, session exit, and server restart), so defer reaping while - // it reports work — up to the wedge cap. Info-level on purpose: rare - // and decision-bearing. - const backgroundLiveness = threadBackgroundLiveness.getThreadBackgroundLiveness( - binding.threadId, - ); - if (backgroundLiveness !== null && idleDurationMs < backgroundWorkMaxIdleMs) { - yield* Effect.logInfo("provider.session.reaper.skipped-live-background-work", { + // 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. Defer only up to the + // wedge cap: a task that has been "live" this long without reaching + // a terminal state is wedged, and holding its session (and child + // process) forever is worse than reaping it. + if (thread?.backgroundLiveness != null && idleDurationMs < backgroundWorkMaxIdleMs) { + yield* Effect.logDebug("provider.session.reaper.skipped-background-work", { threadId: binding.threadId, - backgroundLiveness, + backgroundLiveness: thread.backgroundLiveness, idleDurationMs, }); continue; diff --git a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs index 59580d2c7e6..f06e984c9aa 100644 --- a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs +++ b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs @@ -16,6 +16,7 @@ const fixture = JSON.parse( 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) => { @@ -43,14 +44,20 @@ rl.on("line", (line) => { return; } if (method === "turn/start") { - write({ id, result: fixture.responses.turnStart }); + 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; - const turn = fixture.responses.turnStart.turn; - write({ - jsonrpc: "2.0", - method: "turn/started", - params: { threadId: rootThreadId, turn }, - }); + 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 }); } @@ -75,6 +82,20 @@ rl.on("line", (line) => { `${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; diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts new file mode 100644 index 00000000000..b52b6d497d6 --- /dev/null +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts @@ -0,0 +1,507 @@ +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.each([ + { 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("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 00000000000..43f929163db --- /dev/null +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts @@ -0,0 +1,487 @@ +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; + + /** + * 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"]; + case "ready": + return ["--draft", "false"]; + case "draft": + return ["--draft", "true"]; + case "close": + return ["--status", "abandoned"]; + 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), + }); +}); + +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 00000000000..cce581ce6c9 --- /dev/null +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts @@ -0,0 +1,21 @@ +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"], + // 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 00000000000..5d8f7a093e3 --- /dev/null +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts @@ -0,0 +1,246 @@ +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"], + // 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, + // 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 }, +}; + +/** + * 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, + }), + ), + ), + + 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"))), + + // 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"), + }; + + 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 00000000000..1248b396956 --- /dev/null +++ b/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts @@ -0,0 +1,874 @@ +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("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 00000000000..7c0a7d11744 --- /dev/null +++ b/apps/server/src/pullRequest/BitbucketPullRequestApi.ts @@ -0,0 +1,785 @@ +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 comment: (input: { + readonly repository: string; + readonly number: number; + 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); + }), + + 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), + ), + + 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 00000000000..7e57d6c771e --- /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 00000000000..8a0ea980692 --- /dev/null +++ b/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts @@ -0,0 +1,290 @@ +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, + review: { + inlineComment: true, + reply: true, + resolve: true, + verdicts: ["comment", "approve", "request-changes"], + }, + reviewers: { request: true, listCandidates: 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"))), + + comment: (input) => + api + .comment({ repository: input.repository, number: input.number, body: input.body }) + .pipe(Effect.mapError(fail("comment"))), + + 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"))), + + 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 00000000000..bf7e8951afe --- /dev/null +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts @@ -0,0 +1,1772 @@ +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"; + +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("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("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("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("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,additions,deletions,createdAt,updatedAt,mergedAt,reviewRequests,labels,body,changedFiles,closedAt,statusCheckRollup", + ); + 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("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 00000000000..392fac8564f --- /dev/null +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.ts @@ -0,0 +1,1473 @@ +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, + PullRequestActor, + PullRequestInvolvement, + PullRequestListState, + PullRequestMergeMethod, + PullRequestReviewCommentDraft, + PullRequestReviewVerdict, + PullRequestReviewerCandidateList, + PullRequestReviewerKind, + PullRequestThreadComment, +} from "@t3tools/contracts"; + +import * as GitHubCli from "../sourceControl/GitHubCli.ts"; +import { + ACTOR_AVATARS_GRAPHQL_QUERY, + buildReviewSubmissionJson, + buildReviewerRequestJson, + decodeActorAvatarsJson, + decodePullRequestActivityJson, + decodePullRequestDetailJson, + decodePullRequestFilesJson, + decodePullRequestListJson, + decodePullRequestSearchJson, + decodePullRequestStatsJson, + decodeRepositoryAccessJson, + decodeReviewerCandidatesJson, + decodeReviewThreadCommentsJson, + decodeReviewThreadsJson, + buildPullRequestStatsGraphQlQuery, + encodeGraphQlRequestJson, + pullRequestSearchGraphQlQuery, + PULL_REQUEST_SEARCH_MAX_ROWS, + PULL_REQUEST_ACTIVITY_JSON_FIELDS, + PULL_REQUEST_DETAIL_JSON_FIELDS, + PULL_REQUEST_LIST_JSON_FIELDS, + REPOSITORY_ACCESS_JSON_FIELDS, + RESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION, + REVIEWER_CANDIDATES_GRAPHQL_QUERY, + REVIEW_THREAD_COMMENTS_GRAPHQL_QUERY, + REVIEW_THREAD_REPLY_GRAPHQL_MUTATION, + REVIEW_THREADS_GRAPHQL_QUERY, + reviewThreadConversation, + UNRESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION, + VIEWER_PERMISSIONS_GRAPHQL_QUERY, + decodeViewerPermissionsJson, + 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}`; + } +} + +export type GitHubPullRequestCliError = + | GitHubCli.GitHubCliError + | GitHubPullRequestReadError + | GitHubDiffCursorError + | GitHubDiffCommitError + | GitHubDiffRevisionsUnavailableError + | GitHubDiffFileContentsUnavailableError + | GitHubRepositorySelectorError + | 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; +} + +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; + }) => 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; + }) => 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; + + 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; + }) => 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; + } +>()("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('"', '\\"')}"`; +} + +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; +}): 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}`]), + // `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; + }, +): 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; +} + +/** 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; +}): 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}`]), + // 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, +): ReadonlyArray { + switch (action) { + case "merge": + return ["merge", `--${mergeMethod ?? "merge"}`]; + 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; + + // `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, + }); + }), + ); + }; + + 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. + const searched = (input.query?.trim().length ?? 0) > 0; + return read(true).pipe( + Effect.flatMap((batch) => + batch.items.length === 0 && input.cursor === undefined && !searched + ? 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, + }), + ); + }), + ), + + 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 commits: GitHubReviewThreadPage["commits"] = []; + let viewer: GitHubReviewThreadPage["viewer"] = { canUpdate: true, didAuthor: false }; + 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; + commits = read.commits; + viewer = read.viewer; + for (const [oid, stat] of read.commitStats) commitStats.set(oid, stat); + } + cursor = read.nextCursor; + page += 1; + } while (cursor !== null && page < REVIEW_THREAD_PAGES); + + // 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), + 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), + 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); + 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 }, + }), + }); +}); + +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 00000000000..453ac31dfdb --- /dev/null +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +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({ + actions: ["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; 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, + headBranch: "feat/page", + baseBranch: "main", + state: "open", + isDraft: false, + mergeability: "mergeable", + additions: 1, + deletions: 1, + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-02T00:00:00Z", + reviewRequestLogins: [], + hasTeamReviewRequest: false, + 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("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, + labels: [], + body: "", + changedFiles: 1, + mergedAt: null, + closedAt: null, + checks: [], + comments: [], + }; + + const baseThreadComments = { + comments: [], + reviewThreads: [], + commentCount: 0, + truncated: false, + 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("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 00000000000..b77c20a541c --- /dev/null +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts @@ -0,0 +1,352 @@ +import * as Effect from "effect/Effect"; +import type { + PullRequestActor, + PullRequestCapabilities, + 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"], + mergeMethods: ["merge", "squash", "rebase"], + search: true, + review: { + inlineComment: true, + reply: true, + resolve: true, + verdicts: ["comment", "approve", "request-changes"], + }, + reviewers: { request: true, listCandidates: 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: [ + ...(access.canWrite ? (["merge"] as const) : []), + ...(access.canUpdate ? (["ready", "draft", "close", "reopen"] 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, + }; +} + +/** 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; +} + +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, + }) + .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, + }) + .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), + 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( + ([pullRequest, repository, viewerAccess]): ProviderChangeRequestDetail => ({ + ...pullRequest, + reviewers: pullRequest.reviewRequestLogins.map((login) => ({ + login, + name: null, + avatarUrl: null, + })), + mergeCapabilities: repository.mergeCapabilities, + viewerPermissions: gitHubViewerPermissions(viewerAccess), + }), + ), + ), + + 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: [], + 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, + 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, + author: withAvatar(comment.author, reviewThreads.avatarsByLogin, input.host), + })) + .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) => + cli + .getViewerAccess(input) + .pipe(Effect.mapError(fail("getViewerPermissions")), Effect.map(gitHubViewerPermissions)), + + 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 }), + }) + .pipe(Effect.mapError(fail("runAction"))), + + comment: (input) => cli.commentOnPullRequest(input).pipe(Effect.mapError(fail("comment"))), + + 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"))), + + 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 00000000000..9bfad2648c1 --- /dev/null +++ b/apps/server/src/pullRequest/GitLabPullRequestCli.test.ts @@ -0,0 +1,1144 @@ +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("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("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("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] }); + }), + ); +}); diff --git a/apps/server/src/pullRequest/GitLabPullRequestCli.ts b/apps/server/src/pullRequest/GitLabPullRequestCli.ts new file mode 100644 index 00000000000..4cbb74d32a6 --- /dev/null +++ b/apps/server/src/pullRequest/GitLabPullRequestCli.ts @@ -0,0 +1,1153 @@ +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, + PullRequestReviewCommentDraft, + PullRequestReviewThread, + PullRequestReviewVerdict, + PullRequestReviewerCandidateList, +} from "@t3tools/contracts"; + +import * as GitLabCli from "../sourceControl/GitLabCli.ts"; +import { + decodeCommitDiffRefsJson, + decodeCommitsJson, + decodeDiffRefsJson, + decodeDiscussionsJson, + decodeMergeRequestDetailJson, + decodeMergeRequestDiffsJson, + decodeMergeRequestListJson, + decodeNotesJson, + decodeProjectMergeCapabilitiesJson, + decodeProjectUsersJson, + decodeViewerJson, + 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; + + readonly commentOnMergeRequest: (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + 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; + } +>()("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"] : []), + ]; + case "ready": + return ["update", "--ready"]; + case "draft": + return ["update", "--draft"]; + case "close": + return ["close"]; + 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, + path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}`, + }).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, + }), + ); + }), + ); + + return GitLabPullRequestCli.of({ + getViewerUsername: (input) => + 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); + }), + ), + + 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: [] }), + + 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) => { + 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); + }, + + 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), + + 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 00000000000..55bbbe38d65 --- /dev/null +++ b/apps/server/src/pullRequest/GitLabPullRequestProvider.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { gitLabViewerPermissions } from "./GitLabPullRequestProvider.ts"; + +describe("gitLabViewerPermissions", () => { + it("offers everything to a viewer GitLab says can merge", () => { + expect(gitLabViewerPermissions({ viewerCanMerge: true })).toEqual({ + actions: ["merge", "ready", "draft", "close", "reopen"], + comment: true, + resolve: true, + verdicts: ["comment", "approve"], + // GitLab says nothing about who may set a reviewer, and an unreported permission is granted. + requestReviewers: true, + }); + }); + + it("keeps merge 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("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", + ]); + }); +}); diff --git a/apps/server/src/pullRequest/GitLabPullRequestProvider.ts b/apps/server/src/pullRequest/GitLabPullRequestProvider.ts new file mode 100644 index 00000000000..50396e27ea5 --- /dev/null +++ b/apps/server/src/pullRequest/GitLabPullRequestProvider.ts @@ -0,0 +1,219 @@ +import * as Effect from "effect/Effect"; +import type { PullRequestCapabilities, 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"], + // GitLab offers all three, though a project settles on one; `mergeCapabilities` narrows it. + mergeMethods: ["merge", "squash", "rebase"], + search: 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 }, +}; + +/** + * 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 { + actions: CAPABILITIES.actions.filter((action) => action !== "merge" || input.viewerCanMerge), + comment: true, + resolve: true, + verdicts: CAPABILITIES.review.verdicts, + requestReviewers: true, + }; +} + +/** 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), + }), + ), + ), + + 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 }))), + ], + { concurrency: 3 }, + ).pipe( + Effect.mapError(fail("getChangeRequestActivity")), + Effect.map( + ([notes, commits, discussions]): ProviderChangeRequestActivity => ({ + comments: notes.comments, + // 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, + 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"))), + + comment: (input) => cli.commentOnMergeRequest(input).pipe(Effect.mapError(fail("comment"))), + + 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"))), + + 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 00000000000..34ec28b4106 --- /dev/null +++ b/apps/server/src/pullRequest/PullRequestProvider.ts @@ -0,0 +1,387 @@ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import type { + PullRequestAction, + PullRequestActor, + PullRequestCapabilities, + PullRequestCheck, + PullRequestComment, + PullRequestCommit, + PullRequestInvolvement, + PullRequestLabel, + PullRequestListState, + PullRequestMergeCapabilities, + PullRequestMergeMethod, + PullRequestMergeability, + PullRequestReviewCommentDraft, + PullRequestReviewThread, + PullRequestReviewVerdict, + PullRequestReviewerCandidateList, + PullRequestReviewerKind, + PullRequestState, + 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; +} + +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; +} + +/** 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; +} + +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; +} + +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; + }, + ) => 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; + }) => 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; + readonly mergeMethod?: PullRequestMergeMethod; + }, + ) => Effect.Effect; + + readonly comment: ( + input: ProviderRepositoryRef & { readonly number: number; 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; + + /** 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 00000000000..84a4ebef057 --- /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 00000000000..c46808aa2d8 --- /dev/null +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -0,0 +1,2262 @@ +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 { + 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, + review: FULL_REVIEW, + reviewers: FULL_REVIEWERS, + }, + 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, + comment: () => Effect.void, + submitReview: () => Effect.void, + replyToThread: () => Effect.void, + setThreadResolution: () => Effect.void, + listReviewerCandidates: () => Effect.succeed({ candidates: [], truncated: false }), + setReviewerRequest: () => Effect.void, + ...overrides, + }; +} + +function makeService(input: { + readonly projects: ReadonlyArray; + readonly providers: ReadonlyArray; +}) { + return PullRequestService.make.pipe( + Effect.provide( + Layer.mergeAll( + Layer.succeed(PullRequestProviderRegistry, fromProviders(input.providers)), + Layer.mock(ProjectionSnapshotQuery.ProjectionSnapshotQuery)({ + getShellSnapshot: () => + Effect.succeed({ + snapshotSequence: 1, + projects: input.projects, + threads: [], + updatedAt: "2026-07-01T00:00:00Z", + }), + }), + ), + ), + ); +} + +/** 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" }); + + assert.deepStrictEqual(result.nextCursors, { + "dev.azure.com acme/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, + 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("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, + 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, + 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, + 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, + // 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, + 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, + 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 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, + 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, + 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, + 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("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); + }), +); diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts new file mode 100644 index 00000000000..8652e4b9c9f --- /dev/null +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -0,0 +1,1670 @@ +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, + type OrchestrationProjectShell, + type PullRequestAction, + type PullRequestActionInput, + type PullRequestActivity, + type PullRequestCommentInput, + type PullRequestDetail, + type PullRequestDiffFileContentsInput, + type PullRequestDiffFileContentsResult, + type PullRequestDiffStat, + type PullRequestDiffInput, + type PullRequestDiffResult, + type PullRequestInvalidateInput, + type PullRequestListEntry, + type PullRequestListInput, + type PullRequestListProjectError, + type PullRequestListResult, + type PullRequestListStatsInput, + type PullRequestListStatsResult, + type PullRequestProviderSummary, + type PullRequestRef, + type PullRequestReviewVerdict, + type PullRequestReviewerCandidateList, + type PullRequestReviewerRequestInput, + type PullRequestSubmitReviewInput, + type PullRequestThreadReplyInput, + type PullRequestThreadResolutionInput, + type SourceControlProviderKind, +} from "@t3tools/contracts"; + +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.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 comment: (input: PullRequestCommentInput) => Effect.Effect; + readonly submitReview: ( + input: PullRequestSubmitReviewInput, + ) => Effect.Effect; + readonly replyToThread: ( + input: PullRequestThreadReplyInput, + ) => Effect.Effect; + readonly setThreadResolution: ( + input: PullRequestThreadResolutionInput, + ) => 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.", + reopen: + "You need write access on this repository, or to have opened this change request, to reopen it.", +}; + +/** + * 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 identity. `displayName` is the full path below the host, which + * is what nested GitLab groups and Azure project paths need; owner/name is the two-segment + * fallback for identities recorded before that field existed. + */ +function repositoryIdentityOf(project: OrchestrationProjectShell): string | null { + const identity = project.repositoryIdentity; + if (!identity) return 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 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.map((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; + const kind = project.repositoryIdentity?.provider as + | SourceControlProviderKind + | undefined; + const repository = repositoryIdentityOf(project); + if (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. + const host = pullRequestHostOf(project.repositoryIdentity, 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 }, + ); + + 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, + viewerReviewRequested: + input.item.author?.login.toLowerCase() !== viewer && + input.item.reviewRequestLogins.some((login) => login.toLowerCase() === viewer), + labels: input.item.labels, + }; + }; + + 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, + // 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.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, + ...(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.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, + }; + }); + + const detailUncached: PullRequestService["Service"]["detail"] = (input) => + requireProject(input).pipe( + Effect.flatMap((project) => + project.api + .getChangeRequest({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + }) + .pipe( + Effect.mapError(toPullRequestError("detail")), + Effect.map( + (changeRequest): 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, + }), + ), + ), + ), + ); + + 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, + }), + ), + ), + ), + ); + + 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.`, + }), + ); + } + // 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], + }), + ); + } + 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 }), + }) + .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"))); + }), + ); + }), + ); + + 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"))); + }), + ); + }), + ); + + /** + * 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); + }; + + // 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, projectId, host, limit, query, cursorEntries] = JSON.parse( + key, + ) as [ + number, + string, + string | null, + string | null, + string | null, + number | null, + string | null, + ReadonlyArray<[string, string]> | null, + ]; + return listUncached({ + state, + ...(involvement === null ? {} : { involvement }), + ...(projectId === null ? {} : { projectId }), + ...(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, + input.projectId ?? null, + 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), + comment: invalidatedByMutation(comment), + submitReview: invalidatedByMutation(submitReview), + replyToThread: invalidatedByMutation(replyToThread), + setThreadResolution: invalidatedByMutation(setThreadResolution), + // 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 00000000000..3ac55cde1e8 --- /dev/null +++ b/apps/server/src/pullRequest/azureDevOpsPullRequestJson.test.ts @@ -0,0 +1,300 @@ +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("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 00000000000..a51eef4f0ce --- /dev/null +++ b/apps/server/src/pullRequest/azureDevOpsPullRequestJson.ts @@ -0,0 +1,333 @@ +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)), + 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; +} + +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), + }; +} + +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 00000000000..81212949fdb --- /dev/null +++ b/apps/server/src/pullRequest/bitbucketPullRequestJson.test.ts @@ -0,0 +1,360 @@ +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); + }); +}); + +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 00000000000..697ab2bbb97 --- /dev/null +++ b/apps/server/src/pullRequest/bitbucketPullRequestJson.ts @@ -0,0 +1,614 @@ +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"; + +/** + * 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: PullRequestCheck[] = []; + 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; + checks.push({ + name, + status: toBuildStatus(status.state), + description: trimmed(status.description), + url: trimmed(status.url), + }); + } + return Result.succeed({ items: 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 00000000000..d3d9945da3a --- /dev/null +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts @@ -0,0 +1,959 @@ +import * as Result from "effect/Result"; +import { describe, expect, it } from "vite-plus/test"; + +import { + buildReviewSubmissionJson, + buildReviewerRequestJson, + decodePullRequestActivityJson, + decodePullRequestDetailJson, + decodePullRequestFilesJson, + decodePullRequestListJson, + decodeRepositoryAccessJson, + decodeReviewerCandidatesJson, + decodeReviewThreadCommentsJson, + decodeReviewThreadsJson, + decodeViewerPermissionsJson, + reviewThreadConversation, +} 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("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 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("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("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", + }, + { + 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", + }, + ], + }, + ]); + }); + + 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); + }); +}); + +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); + }); +}); diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.ts new file mode 100644 index 00000000000..8668d840ce1 --- /dev/null +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.ts @@ -0,0 +1,1590 @@ +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, + PullRequestMergeCapabilities, + PullRequestMergeability, + PullRequestReviewCommentDraft, + PullRequestReviewThread, + PullRequestReviewVerdict, + PullRequestReviewerCandidate, + PullRequestReviewerCandidateList, + PullRequestReviewerKind, + PullRequestState, + PullRequestThreadComment, +} from "@t3tools/contracts"; +import { decodeJsonResult } from "@t3tools/shared/schemaJson"; + +/** + * 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 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)), + 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)), +}); + +/** + * 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)), + 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)))), + }), + ), + ), +}); + +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)), + }), + ), + ), + }), + ), + ), + ), + ), +}); + +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)), +}); + +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)), +}); + +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, + body: Schema.optional(Schema.String), + changedFiles: Schema.optional(Schema.Int), + closedAt: Schema.optional(Schema.NullOr(Schema.String)), + statusCheckRollup: Schema.optional(Schema.NullOr(Schema.Array(RawCheckSchema))), +}); + +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({ + 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)), + comments: Schema.optional( + Schema.NullOr( + Schema.Struct({ + nodes: Schema.Array( + Schema.Struct({ author: Schema.optional(Schema.NullOr(RawActorSchema)) }), + ), + }), + ), + ), + 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)) }), + ), + }), + ), + ), + 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,additions,deletions,createdAt,updatedAt,mergedAt,reviewRequests,labels"; + +export const PULL_REQUEST_DETAIL_JSON_FIELDS = `${PULL_REQUEST_LIST_JSON_FIELDS},body,changedFiles,closedAt,statusCheckRollup`; +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 + createdAt + updatedAt + mergedAt + repository { nameWithOwner } + reviewRequests(first: 20) { nodes { requestedReviewer { ... on User { login } } } } + labels(first: 20) { nodes { name color } } + } + } + } +}`; +} + +/** + * 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) { + 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 } + } + } + } + viewerCanUpdate + viewerDidAuthor + author { login avatarUrl } + comments(first: ${GRAPHQL_PAGE_SIZE}) { nodes { author { login avatarUrl } } } + reviewRequests(first: 50) { + nodes { + requestedReviewer { + ... on User { login name avatarUrl } + ... on Bot { login avatarUrl } + } + } + } + latestReviews(first: 50) { + nodes { author { login avatarUrl } } + } + 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) { + node(id: $threadId) { + ... on PullRequestReviewThread { + comments(first: ${GRAPHQL_PAGE_SIZE}, after: $cursor) { + pageInfo { hasNextPage endCursor } + nodes { id author { login avatarUrl } body createdAt url } + } + } + } +}`; + +const RawReviewThreadCommentsSchema = Schema.Struct({ + data: Schema.Struct({ + /** 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 } + } +}`; + +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 } } +}`; + +/** + * 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 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; + 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; +} + +export interface GitHubPullRequestDetail extends GitHubPullRequestListItem { + readonly body: string; + readonly changedFiles: number; + readonly mergedAt: string | null; + readonly closedAt: string | null; + readonly checks: ReadonlyArray; +} + +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 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"; + } +} + +function toChecks( + raw: ReadonlyArray> | null | undefined, +): ReadonlyArray { + return (raw ?? []).flatMap((check) => { + const name = trimmed(check.name) ?? trimmed(check.context); + if (name === null) return []; + return [ + { + name, + status: toCheckStatus(check), + description: trimmed(check.description), + url: trimmed(check.detailsUrl) ?? trimmed(check.targetUrl), + }, + ]; + }); +} + +/** 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), + 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), + }; +} + +function toDetail(raw: Schema.Schema.Type): GitHubPullRequestDetail { + return { + ...toListItem(raw), + body: raw.body ?? "", + changedFiles: raw.changedFiles ?? 0, + mergedAt: trimmed(raw.mergedAt), + closedAt: trimmed(raw.closedAt), + checks: toChecks(raw.statusCheckRollup), + }; +} + +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])), + }), + 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; + /** 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; + /** + * 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; + 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 }; +} + +/** + * 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, + }), + ), + ); +} + +/** One page of review threads. Following the cursors it hands back is the caller's job. */ +export function decodeReviewThreadsJson( + raw: string, +): Result.Result { + const decoded = decodeReviewThreads(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + 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), + })), + }, + 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]; + }), + }); + } + return Result.succeed({ + threads: entries, + nextCursor: nextCursorOf(threads.pageInfo), + reviewers: [...reviewers.values()], + avatarsByLogin, + commitStats, + commits, + viewer: toPullRequestViewerFields(pullRequest), + }); +} + +/** 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 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), + })), + 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. + */ +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; +} + +/** + * 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; +} + +/** + * 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[] = []; + 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. + if ((value.additions ?? 0) + (value.deletions ?? 0) > 0) truncated = true; + } + // 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, + }); +} diff --git a/apps/server/src/pullRequest/gitLabMergeRequestJson.test.ts b/apps/server/src/pullRequest/gitLabMergeRequestJson.test.ts new file mode 100644 index 00000000000..553bd7fb6ef --- /dev/null +++ b/apps/server/src/pullRequest/gitLabMergeRequestJson.test.ts @@ -0,0 +1,455 @@ +import * as Result from "effect/Result"; +import { describe, expect, it } from "vite-plus/test"; + +import { + decodeCommitsJson, + decodeMergeRequestDetailJson, + decodeMergeRequestDiffsJson, + decodeMergeRequestListJson, + decodeNotesJson, + decodeViewerJson, +} 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("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); + }); +}); diff --git a/apps/server/src/pullRequest/gitLabMergeRequestJson.ts b/apps/server/src/pullRequest/gitLabMergeRequestJson.ts new file mode 100644 index 00000000000..5c0fd0ac753 --- /dev/null +++ b/apps/server/src/pullRequest/gitLabMergeRequestJson.ts @@ -0,0 +1,698 @@ +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, + 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) })), + ), +}); + +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; +} + +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); + 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], + ), + }; +} + +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, + }); +} diff --git a/apps/server/src/pullRequest/http.ts b/apps/server/src/pullRequest/http.ts new file mode 100644 index 00000000000..88756e64acc --- /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/server.test.ts b/apps/server/src/server.test.ts index cc8667fc7a1..dd02eda55c9 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -149,6 +149,7 @@ 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"; @@ -825,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, @@ -5231,6 +5233,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }, branch: "feature/demo", worktreePath: null, + isOnPullRequestHead: true, }), }, gitVcsDriver: { @@ -7018,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"); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 1d824afbd1b..8d4f8bb61d7 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -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"; @@ -101,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, @@ -158,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)), @@ -410,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), @@ -425,12 +432,19 @@ const commandReadinessLayer = HttpRouter.middleware( { 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(VcsProcess.layer), +); + export const makeRoutesLayer = Layer.mergeAll( Layer.mergeAll( HttpApiBuilder.layer(EnvironmentHttpApi).pipe( Layer.provide(authHttpApiLayer), Layer.provide(connectHttpApiLayer), Layer.provide(orchestrationHttpApiLayer), + Layer.provide(pullRequestHttpApiLayer), Layer.provide(serverEnvironmentHttpApiLayer), Layer.provide(environmentAuthenticatedAuthLayer), ), @@ -441,6 +455,9 @@ 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), diff --git a/apps/server/src/sourceControl/BitbucketApi.test.ts b/apps/server/src/sourceControl/BitbucketApi.test.ts index 5a9759ace0b..4f3433693e5 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 f7d7f6671a4..aad28ee8c1a 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.ts b/apps/server/src/sourceControl/GitHubCli.ts index bf3f27378b5..a705b0fb0b3 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -203,6 +203,9 @@ export class GitHubCli extends Context.Service< readonly cwd: string; readonly args: ReadonlyArray; 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 +317,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 a2926afd0ef..05475400d42 100644 --- a/apps/server/src/sourceControl/GitLabCli.ts +++ b/apps/server/src/sourceControl/GitLabCli.ts @@ -249,6 +249,9 @@ export class GitLabCli extends Context.Service< readonly cwd: string; readonly args: ReadonlyArray; 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 +404,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/azureDevOpsPullRequests.ts b/apps/server/src/sourceControl/azureDevOpsPullRequests.ts index c059f6f0f9e..8c3c5c4de56 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 d6715294cce..4a41cf11ec6 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 7ac5530474e..71114e1de1b 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/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts new file mode 100644 index 00000000000..2ad2a729ecb --- /dev/null +++ b/apps/server/src/usage/UsageService.ts @@ -0,0 +1,420 @@ +/** + * UsageService - scans provider transcripts and returns priced daily usage. + * + * 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; + +/** 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}'`, + }); + } + + 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 = DateTime.toEpochMillis(windowStart.value) - MTIME_SLACK_MS; + + const aggregator = new UsageAggregator({ + timeZone: input.timeZone, + sinceDay: input.sinceDay, + untilDay: input.untilDay, + 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 00000000000..9117e216f12 --- /dev/null +++ b/apps/server/src/usage/usageAggregation.test.ts @@ -0,0 +1,132 @@ +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") { + const aggregator = new UsageAggregator({ + timeZone, + sinceDay: "2026-08-01", + untilDay: "2026-08-31", + rates, + }); + for (const item of records) aggregator.add(item); + return aggregator.finish(); +} + +describe("UsageAggregator", () => { + 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("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 00000000000..4f04a318c52 Binary files /dev/null and b/apps/server/src/usage/usageAggregation.ts differ diff --git a/apps/server/src/usage/usagePricing.ts b/apps/server/src/usage/usagePricing.ts new file mode 100644 index 00000000000..f0e59a87439 --- /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 00000000000..64673e96c09 --- /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 00000000000..cc15ee9cee6 --- /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 00000000000..c72f0c24db6 --- /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 00000000000..8f86a3d836b --- /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 00000000000..49f9a1935cc --- /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 bfcbf8e8a4b..42e0aa56cb2 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -144,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; @@ -245,6 +275,17 @@ 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; diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index d39817c0ee1..5b9359adaa8 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2794,6 +2794,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( @@ -3071,6 +3160,10 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* 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)), diff --git a/apps/server/src/vcs/VcsProcess.test.ts b/apps/server/src/vcs/VcsProcess.test.ts index 675d20cb82c..d202fa48ca4 100644 --- a/apps/server/src/vcs/VcsProcess.test.ts +++ b/apps/server/src/vcs/VcsProcess.test.ts @@ -192,6 +192,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 52db6f9b1fb..299990e56ea 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< @@ -163,6 +166,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 bb2113dac37..28a30481b1b 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 15572837030..1fdf956447d 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 8bf36b7a80a..eeb2df342c2 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 a6b155c296f..126222d214a 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -105,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"; @@ -407,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({ @@ -1034,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( @@ -1529,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", @@ -1598,6 +1636,68 @@ 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.pullRequestsComment]: (input) => + observeRpcEffect(WS_METHODS.pullRequestsComment, pullRequests.comment(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.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, @@ -1727,9 +1827,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( @@ -2139,6 +2263,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", @@ -2162,6 +2287,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/web/index.html b/apps/web/index.html index 49b49d1f253..9d233805e45 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -37,7 +37,7 @@ light: { background: "#fdf7fd", foreground: "#501854", - accent: "#e33f86", + accent: "#db2777", chrome: "#fdf7fd", }, dark: { @@ -54,7 +54,7 @@ light: { background: "#fdf7fd", foreground: "#501854", - accent: "#e33f86", + accent: "#db2777", chrome: "#fdf7fd", }, dark: { diff --git a/apps/web/package.json b/apps/web/package.json index 0a2f0d8e86b..dfec330a107 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/web", - "version": "0.0.31", + "version": "0.0.33", "private": true, "type": "module", "scripts": { diff --git a/apps/web/src/appearanceFonts.test.ts b/apps/web/src/appearanceFonts.test.ts index 5c642e33f0f..31a2f1d779c 100644 --- a/apps/web/src/appearanceFonts.test.ts +++ b/apps/web/src/appearanceFonts.test.ts @@ -11,6 +11,7 @@ import { cssFontFamilies, resolveDefaultFamilyLabel, resolveTerminalFontPreference, + resolveTerminalFontSizePreference, } from "./appearanceFonts"; describe("areFontAdvancesMonospace", () => { @@ -97,6 +98,16 @@ describe("resolveTerminalFontPreference", () => { }); }); +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); diff --git a/apps/web/src/appearanceFonts.ts b/apps/web/src/appearanceFonts.ts index 60801ef0118..6053e5fb0dd 100644 --- a/apps/web/src/appearanceFonts.ts +++ b/apps/web/src/appearanceFonts.ts @@ -41,6 +41,15 @@ export function resolveTerminalFontPreference(input: { 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 ""; diff --git a/apps/web/src/assets/assetUrls.ts b/apps/web/src/assets/assetUrls.ts index 701af3a79fc..f8c0b5ae75f 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 06d663ca0b4..056fbb76e6a 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 863f7839bd6..4cb5de121c9 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 Turbo (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 9b201dbdbae..3c3be59b457 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 00000000000..29d27eb5535 --- /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 00000000000..4c0a560817b --- /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/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 5c6acd62aea..d27cc0379d7 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -14,10 +14,13 @@ import { getLocalStorageItem } 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 { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; +import { SidebarChromeHeader } from "./sidebar/SidebarChrome"; import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop"; +import { useProjects } from "../state/entities"; import { resolveInitialThreadSidebarWidth, resolveThreadSidebarMaximumWidth, @@ -116,15 +119,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, @@ -184,11 +193,11 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { return ( + - {useSidebarV2 ? : } + {isOnSettings ? ( + <> + + + + ) : legacySidebarEnabled ? ( + + ) : ( + + )} {children} diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 440f48d7c90..5ceec813187 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -126,7 +126,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ if (isLocked) { return ( - + {triggerContent} ); diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index bbd27f65ab0..251e59a40a4 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) { diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx index ca778daad31..64bcd8c57cb 100644 --- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -50,7 +50,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe if (envLocked) { return ( - + {activeWorktreePath ? ( <> diff --git a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx index 2cf99547752..56fb91fb4b8 100644 --- a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx @@ -41,9 +41,14 @@ 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 ? ( ) : ( diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 02084488349..4d25ac031f3 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"; @@ -904,6 +952,25 @@ function plainHastText(node: unknown): string | null { return parts.every((part) => 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], ); @@ -1412,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 = @@ -1476,7 +1567,13 @@ 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; @@ -1505,7 +1602,7 @@ function ChatMarkdown({ }); }} > - {faviconHost ? ( + {faviconHost && hastHasText(node) ? ( {children} diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 39285438d1a..5c026c94a13 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -6,7 +6,7 @@ import { ThreadId, TurnId, } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import type { Thread, ThreadShell } from "../types"; import { @@ -19,13 +19,16 @@ import { createLocalDispatchSnapshot, deriveComposerSendState, dismissBranchMismatchForSession, + ENVIRONMENT_RECONNECT_WARNING_GRACE_MS, getStartedThreadModelChangeBlockReason, + hasEnvironmentReconnectWarningGraceElapsed, hasServerAcknowledgedLocalDispatch, isBranchMismatchDismissedForSession, reconcileMountedTerminalThreadIds, reconcileRetainedMountedThreadIds, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, + scheduleEnvironmentReconnectWarning, startNewThreadForProject, shouldShowBranchMismatchBanner, shouldWriteThreadErrorToCurrentServerThread, @@ -36,6 +39,42 @@ const projectId = ProjectId.make("project-1"); const threadId = ThreadId.make("thread-1"); const now = "2026-03-29T00:00:00.000Z"; +describe("environment reconnect warning grace", () => { + 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 04b35fd4551..04561b507c3 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 1d94dda876c..5029e872919 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -123,6 +123,7 @@ import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; import { useMediaQuery } from "../hooks/useMediaQuery"; import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; import { + pullRequestSurfaceId, selectActiveRightPanel, selectActiveRightPanelSurface, selectThreadRightPanelState, @@ -143,7 +144,10 @@ import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore, } from "../previewMiniPlayerStore"; -import { RightPanelTabs } from "./RightPanelTabs"; +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, @@ -172,9 +176,14 @@ 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"; @@ -182,9 +191,11 @@ 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, @@ -276,6 +287,8 @@ import { createLocalDispatchSnapshot, deriveComposerSendState, dismissBranchMismatchForSession, + hasEnvironmentReconnectWarningGraceElapsed, + scheduleEnvironmentReconnectWarning, hasServerAcknowledgedLocalDispatch, isBranchMismatchDismissedForSession, shouldShowBranchMismatchBanner, @@ -1491,6 +1504,7 @@ function ChatViewContent(props: ChatViewProps) { 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, @@ -1526,8 +1540,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<{ @@ -1548,6 +1565,17 @@ function ChatViewContent(props: ChatViewProps) { const activeRightPanelSurface = useRightPanelStore((state) => selectActiveRightPanelSurface(state.byThreadKey, activeThreadRef), ); + const [pullRequestTabStatuses, setPullRequestTabStatuses] = useState< + Record + >({}); + const handlePullRequestTabStatusChange = useCallback((status: PullRequestTabStatus) => { + const id = pullRequestSurfaceId(status); + setPullRequestTabStatuses((current) => + current[id]?.state === status.state && current[id]?.isDraft === status.isDraft + ? current + : { ...current, [id]: status }, + ); + }, []); const activeFileSurface = activeRightPanelSurface?.kind === "file" ? activeRightPanelSurface : null; const activePreviewState = useThreadPreviewState(activeThreadRef); @@ -1652,6 +1680,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()); @@ -1690,11 +1720,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) { @@ -1723,7 +1796,6 @@ function ChatViewContent(props: ChatViewProps) { }, [retryEnvironment], ); - const projectGroupingSettings = selectProjectGroupingSettings(settings); const logicalProjectEnvironments = useMemo(() => { if (!activeProject) return []; const logicalKey = deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings); @@ -1886,6 +1958,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 @@ -1928,7 +2002,9 @@ 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({ @@ -2056,6 +2132,7 @@ function ChatViewContent(props: ChatViewProps) { return items; }, [ activeEnvironmentUnavailableState, + reconnectWarningGraceElapsed, handleReconnectActiveEnvironment, navigate, setDismissedVersionMismatchKey, @@ -3166,6 +3243,27 @@ function ChatViewContent(props: ChatViewProps) { }, [activeProject, activeThreadRef], ); + // The thread's own change request, placed against the project it belongs to. Without a + // project there is nothing to resolve it against, so the caller falls back to the browser. + const threadRepository = activeProject?.repositoryIdentity?.displayName ?? null; + const openThreadPullRequest = useCallback( + (number: number) => { + if ( + !supportsPullRequests || + !activeThreadRef || + !activeProject || + threadRepository === null + ) { + return; + } + useRightPanelStore.getState().openPullRequest(activeThreadRef, { + projectId: activeProject.id, + repository: threadRepository, + number, + }); + }, + [activeProject, activeThreadRef, supportsPullRequests, threadRepository], + ); const togglePreviewPanel = useCallback(() => { if (!activeThreadRef || !isPreviewSupportedInRuntime()) return; if (previewPanelOpen) { @@ -3970,6 +4068,14 @@ function ChatViewContent(props: ChatViewProps) { threadBranch: activeThread?.branch ?? null, gitStatus: gitStatusQuery.data ?? null, }); + // The right panel offers the thread's own change request, so it can only offer it once the + // branch has one; until then the picker says so rather than opening an empty panel. + const addPullRequestSurface = useCallback(() => { + if (activeThreadPr === null) return; + openThreadPullRequest(activeThreadPr.number); + }, [activeThreadPr, openThreadPullRequest]); + const pullRequestSurfaceAvailable = + supportsPullRequests && activeThreadPr !== null && threadRepository !== null; const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true; const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true; const nowMinute = useNowMinute(); @@ -4687,6 +4793,7 @@ function ChatViewContent(props: ChatViewProps) { "This will discard newer messages and turn diffs in this thread.", "This action cannot be undone.", ].join("\n"), + { variant: "destructive" }, ); if (!confirmed) { return; @@ -5855,6 +5962,11 @@ function ChatViewContent(props: ChatViewProps) { rightPanelAvailable={activeProject !== null} rightPanelOpen={rightPanelOpen} rightPanelShortcutLabel={shortcutLabelForCommand(keybindings, "rightPanel.toggle")} + // Suppressed while the Agents surface is visible: the roster itself is + // on screen, so the toggle badge would be pointing at nothing. + liveAgentCount={ + rightPanelOpen && activeRightPanelSurface?.kind === "agents" ? 0 : agentPanelModel.liveCount + } onToggleTerminal={toggleTerminalVisibility} onToggleRightPanel={toggleRightPanel} /> @@ -5918,6 +6030,35 @@ function ChatViewContent(props: ChatViewProps) { initialGitScope={initialDiffPanelGitScope} /> + ) : activeRightPanelSurface?.kind === "pull-request" && !pullRequestsCapabilityKnown ? ( + + ) : 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" ? ( {!rightPanelOpen ? panelLayoutControls : null} {rightPanelContent} @@ -6386,10 +6537,16 @@ function ChatViewContent(props: ChatViewProps) { onAddTerminal={addTerminalSurface} onAddDiff={addDiffSurface} onAddFiles={addFilesSurface} + onAddPullRequest={addPullRequestSurface} onAddAgents={addAgentsSurface} browserAvailable={isPreviewSupportedInRuntime()} + terminalAvailable={activeProject !== null} diffAvailable={isServerThread && isGitRepo} filesAvailable={activeProject !== null} + pullRequestAvailable={pullRequestSurfaceAvailable} + agentsAvailable + pullRequestStatuses={pullRequestTabStatuses} + liveAgentCount={agentPanelModel.liveCount} > {rightPanelContent} diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 605127f9737..ad909962968 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -150,6 +150,7 @@ function projectFavicon(project: Project) { ); @@ -1505,6 +1506,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/ConfirmDialogHost.tsx b/apps/web/src/components/ConfirmDialogHost.tsx new file mode 100644 index 00000000000..c169a1eff7f --- /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/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 76191e6d4d7..385d67b6b70 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -36,7 +36,6 @@ import { getRenderablePatch, resolveDiffThemeName, resolveFileDiffPath, - DIFF_SURFACE_THEME_UNSAFE_CSS, } from "../lib/diffRendering"; import { areAllDiffFilesCollapsed, toggleAllDiffFiles } from "../lib/diffCollapse"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; @@ -75,8 +74,8 @@ 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__"; @@ -87,215 +86,6 @@ interface CollapsedDiffFilesState { const EMPTY_COLLAPSED_DIFF_FILE_KEYS: ReadonlySet = new Set(); -const DIFF_PANEL_UNSAFE_CSS = `${DIFF_SURFACE_THEME_UNSAFE_CSS} -:is( - [data-line], - [data-line-annotation], - [data-merge-conflict], - [data-merge-conflict-actions], - [data-no-newline] -)[data-selected-line] { - --diffs-line-bg: light-dark( - color-mix( - in lab, - var(--code-background) 88%, - color-mix(in srgb, var(--code-background) 50%, var(--diffs-modified-base)) - ), - color-mix( - in lab, - var(--code-background) 80%, - color-mix(in srgb, var(--code-background) 70%, var(--diffs-modified-base)) - ) - ) !important; -} - -:is([data-gutter-buffer], [data-column-number])[data-selected-line] { - --diffs-line-bg: light-dark( - color-mix( - in lab, - var(--code-background) 91%, - color-mix(in srgb, var(--code-background) 35%, var(--diffs-modified-base)) - ), - color-mix( - in lab, - var(--code-background) 85%, - color-mix(in srgb, var(--code-background) 60%, var(--diffs-modified-base)) - ) - ) !important; -} - -[data-indicators="bars"] - :is([data-column-number], [data-gutter-buffer="annotation"])[data-selected-line] { - position: relative; -} - -[data-indicators="bars"] - :is([data-column-number], [data-gutter-buffer="annotation"])[data-selected-line]::before { - position: absolute !important; - inset-block: 0 !important; - inset-inline-start: 0 !important; - display: block !important; - width: 4px !important; - min-width: 4px !important; - max-width: 4px !important; - height: auto !important; - padding: 0 !important; - content: "" !important; - background-color: var(--diffs-modified-base) !important; - background-image: none !important; -} - -[data-file-info] { - background-color: var(--code-background) !important; - border-block-color: transparent !important; - color: var(--code-foreground) !important; -} - -[data-diffs-header] { - position: sticky !important; - top: 0; - z-index: 4; - background-color: var(--code-background) !important; - border-bottom-color: transparent !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; - padding-inline: 8px 12px !important; -} - -[data-diffs-header]:hover { - background-color: color-mix(in srgb, var(--code-background) 97%, var(--code-foreground)) !important; -} - -:is([data-separator="line-info"], [data-separator="line-info-basic"]) { - height: 24px !important; - margin-block: 0 !important; - background-color: var(--code-background) !important; -} - -:is([data-separator="line-info"], [data-separator="line-info-basic"]) - [data-separator-wrapper] { - padding-inline: 8px 12px !important; - background-color: transparent !important; -} - -:is([data-separator="line-info"], [data-separator="line-info-basic"]) - [data-separator-content] { - gap: 8px; - padding-inline: 0 !important; - background-color: transparent !important; - color: color-mix(in srgb, var(--code-foreground) 52%, var(--code-background)) !important; - font-family: var(--font-sans) !important; - font-size: 11px !important; - text-decoration: none !important; -} - -:is([data-separator="line-info"], [data-separator="line-info-basic"]) - [data-unmodified-lines] { - display: flex !important; - min-width: 0; - flex: 1 1 auto; - align-items: center; - gap: 8px; - cursor: pointer; -} - -:is([data-separator="line-info"], [data-separator="line-info-basic"]) - [data-unmodified-lines]::before, -:is([data-separator="line-info"], [data-separator="line-info-basic"]) - [data-unmodified-lines]::after { - width: auto; - height: 1px; - flex: 1 1 auto; - content: ""; - background-color: color-mix(in srgb, var(--code-background) 92%, var(--code-foreground)); -} - -:is([data-separator="line-info"], [data-separator="line-info-basic"])[data-expand-index] - [data-separator-wrapper] { - grid-template-columns: 0 minmax(0, 1fr) !important; -} - -:is([data-separator="line-info"], [data-separator="line-info-basic"])[data-expand-index] - [data-separator-content] { - grid-column: 2 !important; -} - -:is([data-separator="line-info"], [data-separator="line-info-basic"]) - [data-expand-button] { - display: none !important; -} - -:is([data-separator="line-info"], [data-separator="line-info-basic"]):has( - [data-expand-button] - ) - [data-separator-content] { - cursor: pointer; -} - -:is([data-separator="line-info"], [data-separator="line-info-basic"]):has( - [data-expand-button] - ):hover - [data-separator-content] { - color: color-mix(in srgb, var(--code-foreground) 76%, var(--code-background)) !important; -} - -:is([data-separator="line-info"], [data-separator="line-info-basic"]):has( - [data-expand-button] - ):hover - [data-unmodified-lines]::before, -:is([data-separator="line-info"], [data-separator="line-info-basic"]):has( - [data-expand-button] - ):hover - [data-unmodified-lines]::after { - background-color: color-mix(in srgb, var(--code-background) 84%, var(--code-foreground)); -} - -[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(--code-foreground) 84%, var(--primary)) !important; - text-decoration-color: currentColor; -} -`; - interface DiffPanelProps { mode?: DiffPanelMode; composerDraftTarget: ScopedThreadRef | DraftId; @@ -312,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(""); @@ -523,45 +314,14 @@ export default function DiffPanel({ return undefined; } - const source = selectedGitSource; - return async (fileDiff) => { - const newPath = resolveFileDiffPath(fileDiff); - const oldPath = fileDiff.prevName - ? resolveFileDiffPath({ ...fileDiff, name: fileDiff.prevName }) - : newPath; - const result = await getDiffFileContents({ - environmentId: activeThread.environmentId, - input: { - cwd: preview.cwd, - sourceKind: source.kind, - changeType: fileDiff.type, - baseRef: source.baseRef, - headRef: source.headRef, - oldPath, - newPath, - }, - }); - if (result._tag !== "Success") { - throw squashAtomCommandFailure(result); - } - - const newFile = { - name: newPath, - contents: result.value.newContents, - cacheKey: `${source.diffHash}:new:${newPath}`, - }; - if (fileDiff.type === "rename-pure") { - return { oldFile: null, newFile }; - } - return { - oldFile: { - name: oldPath, - contents: result.value.oldContents, - cacheKey: `${source.diffHash}:old:${oldPath}`, - }, - newFile, - }; - }; + return createGitDiffFileContentsLoader(getDiffFileContents, { + environmentId: activeThread.environmentId, + cwd: preview.cwd, + sourceKind: selectedGitSource.kind, + baseRef: selectedGitSource.baseRef, + headRef: selectedGitSource.headRef, + cacheKey: selectedGitSource.diffHash, + }); }, [ activeThread, branchDiffPreview.data, @@ -1102,19 +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/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 99f9f450e27..21db21eaa67 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -95,6 +95,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 { @@ -971,6 +976,7 @@ export default function GitActionsControl({ gitCwd, activeThreadRef, draftId, + onOpenPullRequest, }: GitActionsControlProps) { const updateThreadMetadata = useAtomCommand( threadEnvironment.updateMetadata, @@ -1213,6 +1219,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 +1235,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 +1255,7 @@ export default function GitActionsControl({ }), ); }); - }, [gitStatusForActions, threadToastData]); + }, [gitStatusForActions, onOpenPullRequest, threadToastData]); runGitActionWithToast = useEffectEvent( async ({ diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx new file mode 100644 index 00000000000..916e3214ac1 --- /dev/null +++ b/apps/web/src/components/LegacySidebar.tsx @@ -0,0 +1,3697 @@ +import { + ArchiveIcon, + ArrowUpDownIcon, + ChevronRightIcon, + CloudIcon, + ContainerIcon, + FolderPlusIcon, + Globe2Icon, + LoaderIcon, + SearchIcon, + SquarePenIcon, + TerminalIcon, + TriangleAlertIcon, +} from "lucide-react"; +import { + ChangeRequestStatusIcon, + prStatusIndicator, + PrStatusTooltipContent, + resolveThreadPr, + terminalStatusFromRunningIds, + ThreadStatusLabel, + ThreadWorktreeIndicator, +} from "./ThreadStatusIndicators"; +import { ProjectFavicon } from "./ProjectFavicon"; +import { useAtomValue } from "@effect/atom-react"; +import { autoAnimate } from "@formkit/auto-animate"; +import React, { useCallback, useEffect, memo, useMemo, useRef, useState } from "react"; +import { useShallow } from "zustand/react/shallow"; +import { + DndContext, + type DragCancelEvent, + type CollisionDetection, + PointerSensor, + type DragStartEvent, + closestCorners, + pointerWithin, + useSensor, + useSensors, + type DragEndEvent, +} from "@dnd-kit/core"; +import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; +import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers"; +import { CSS } from "@dnd-kit/utilities"; +import { + type ContextMenuItem, + ProjectId, + type ScopedThreadRef, + type ResolvedKeybindingsConfig, + type SidebarProjectGroupingMode, + ThreadId, +} from "@t3tools/contracts"; +import { + parseScopedThreadKey, + scopedProjectKey, + scopedThreadKey, + scopeProjectRef, + scopeThreadRef, +} from "@t3tools/client-runtime/environment"; +import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; +import { + isAtomCommandInterrupted, + settlePromise, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { useNavigate, useParams, useRouter } from "@tanstack/react-router"; +import { + MAX_SIDEBAR_THREAD_PREVIEW_COUNT, + MIN_SIDEBAR_THREAD_PREVIEW_COUNT, + type SidebarProjectSortOrder, + type SidebarThreadPreviewCount, + type SidebarThreadSortOrder, +} from "@t3tools/contracts/settings"; +import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; +import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps"; +import { isElectron } from "../env"; +import { useOpenPrLink } from "../lib/openPullRequestLink"; +import { isTerminalFocused } from "../lib/terminalFocus"; +import { isMacPlatform } from "../lib/utils"; +import { + readThreadShell, + useProject, + useProjects, + useThreadShells, + useThreadShellsForProjectRefs, +} from "../state/entities"; +import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; +import { useThreadRunningTerminalIds } from "../state/terminalSessions"; +import { useThreadDiscoveredPorts } from "../portDiscoveryState"; +import { openDiscoveredPort } from "./preview/openDiscoveredPort"; +import { useAtomCommand } from "../state/use-atom-command"; +import { previewEnvironment } from "../state/preview"; +import { + legacyProjectCwdPreferenceKey, + resolveProjectExpanded, + useUiStateStore, +} from "../uiStateStore"; +import { + resolveShortcutCommand, + shortcutLabelForCommand, + shouldShowThreadJumpHintsForModifiers, + threadJumpCommandForIndex, + threadJumpIndexFromCommand, + threadTraversalDirectionFromCommand, +} from "../keybindings"; +import { isModelPickerOpen } from "../modelPickerVisibility"; +import { useShortcutModifierState } from "../shortcutModifierState"; +import { ensureLocalApi, readLocalApi } from "../localApi"; +import { useComposerDraftStore } from "../composerDraftStore"; +import { useNewThreadHandler } from "../hooks/useHandleNewThread"; +import { useDesktopUpdateState } from "../state/desktopUpdate"; + +import { useThreadActions } from "../hooks/useThreadActions"; +import { projectEnvironment } from "../state/projects"; +import { useEnvironmentQuery } from "../state/query"; +import { threadEnvironment, useEnvironmentThread } from "../state/threads"; +import { vcsEnvironment } from "../state/vcs"; +import { useEnvironment, useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; +import { + buildThreadRouteParams, + resolveActiveThreadRouteRef, + resolveThreadRouteTarget, +} from "../threadRoutes"; +import { stackedThreadToast, toastManager } from "./ui/toast"; +import { formatRelativeTimeLabel } from "../timestampFormat"; +import { Kbd } from "./ui/kbd"; +import { + getArm64IntelBuildWarningDescription, + getDesktopUpdateActionError, + getDesktopUpdateInstallConfirmationMessage, + isDesktopUpdateButtonDisabled, + resolveDesktopUpdateButtonAction, + shouldShowArm64IntelBuildWarning, + shouldToastDesktopUpdateActionResult, +} from "./desktopUpdate.logic"; +import { showDesktopUpdateDownloadedToast } from "./desktopUpdate.toast"; +import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; +import { Button } from "./ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "./ui/dialog"; +import { Input } from "./ui/input"; +import { Menu, MenuGroup, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu"; +import { + NumberField, + NumberFieldDecrement, + NumberFieldGroup, + NumberFieldIncrement, + NumberFieldInput, +} from "./ui/number-field"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; +import { + SidebarContent, + SidebarGroup, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuSub, + SidebarMenuSubButton, + SidebarMenuSubItem, + useSidebar, +} from "./ui/sidebar"; +import { useThreadSelectionStore } from "../threadSelectionStore"; +import { openCommandPalette } from "../commandPaletteBus"; +import { + archiveSelectedThreadEntries, + buildMultiSelectThreadContextMenuItems, + getSidebarThreadIdsToPrewarm, + resolveAdjacentThreadId, + isContextMenuPointerDown, + isTrailingDoubleClick, + resolveProjectStatusIndicator, + resolveThreadRowClassName, + resolveThreadStatusPill, + orderItemsByPreferredIds, + shouldClearThreadSelectionOnMouseDown, + sortProjectsForSidebar, + useThreadJumpHintVisibility, + ThreadStatusPill, +} from "./Sidebar.logic"; +import { sortThreads } from "../lib/threadSort"; +import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; +import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; +import { useIsMobile } from "~/hooks/useMediaQuery"; +import { CommandDialogTrigger } from "./ui/command"; +import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"; +import { primaryServerKeybindingsAtom } from "../state/server"; +import { + derivePhysicalProjectKey, + deriveProjectGroupingOverrideKey, + getProjectOrderKey, + selectProjectGroupingSettings, +} from "../logicalProject"; +import type { SidebarThreadSummary } from "../types"; +import { + buildPhysicalToLogicalProjectKeyMap, + buildSidebarProjectSnapshots, + type SidebarProjectGroupMember, + type SidebarProjectSnapshot, +} from "../sidebarProjectGrouping"; +const SIDEBAR_SORT_LABELS: Record = { + updated_at: "Last user message", + created_at: "Created at", + manual: "Manual", +}; +const SIDEBAR_THREAD_SORT_LABELS: Record = { + updated_at: "Last user message", + created_at: "Created at", +}; +const SIDEBAR_LIST_ANIMATION_OPTIONS = { + duration: 180, + easing: "ease-out", +} as const; +const EMPTY_THREAD_JUMP_LABELS = new Map(); +const PROJECT_GROUPING_MODE_LABELS: Record = { + repository: "Group by repository", + repository_path: "Group by repository path", + separate: "Keep separate", +}; +const SIDEBAR_ICON_ACTION_BUTTON_CLASS = + "inline-flex h-6 min-w-6 cursor-pointer items-center justify-center rounded-md px-[calc(--spacing(1)-1px)] text-icon-muted hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"; + +function SidebarThreadDetailPrewarmer({ threadRef }: { readonly threadRef: ScopedThreadRef }) { + useEnvironmentThread(threadRef.environmentId, threadRef.threadId); + return null; +} + +function clampSidebarThreadPreviewCount(value: number): SidebarThreadPreviewCount { + return Math.min( + MAX_SIDEBAR_THREAD_PREVIEW_COUNT, + Math.max(MIN_SIDEBAR_THREAD_PREVIEW_COUNT, value), + ) as SidebarThreadPreviewCount; +} + +function formatProjectMemberActionLabel( + member: SidebarProjectGroupMember, + groupedProjectCount: number, +): string { + if (groupedProjectCount <= 1) { + return member.title; + } + + return member.environmentLabel + ? `${member.environmentLabel} — ${member.workspaceRoot}` + : member.workspaceRoot; +} + +function projectExpansionPreferenceKeys(project: SidebarProjectSnapshot): string[] { + return [ + project.projectKey, + ...project.memberProjects.map((member) => member.physicalProjectKey), + ...project.memberProjects.map((member) => legacyProjectCwdPreferenceKey(member.workspaceRoot)), + ]; +} + +function projectGroupingModeDescription(mode: SidebarProjectGroupingMode): string { + switch (mode) { + case "repository": + return "Projects from the same repository share one sidebar row."; + case "repository_path": + return "Projects group only when both the repository and repo-relative path match."; + case "separate": + return "Every project path gets its own sidebar row."; + } +} + +function buildThreadJumpLabelMap(input: { + keybindings: ResolvedKeybindingsConfig; + platform: string; + terminalOpen: boolean; + threadJumpCommandByKey: ReadonlyMap< + string, + NonNullable> + >; +}): ReadonlyMap { + if (input.threadJumpCommandByKey.size === 0) { + return EMPTY_THREAD_JUMP_LABELS; + } + + const shortcutLabelOptions = { + platform: input.platform, + context: { + terminalFocus: false, + terminalOpen: input.terminalOpen, + }, + } as const; + const mapping = new Map(); + for (const [threadKey, command] of input.threadJumpCommandByKey) { + const label = shortcutLabelForCommand(input.keybindings, command, shortcutLabelOptions); + if (label) { + mapping.set(threadKey, label); + } + } + return mapping.size > 0 ? mapping : EMPTY_THREAD_JUMP_LABELS; +} + +interface SidebarThreadRowProps { + thread: SidebarThreadSummary; + projectCwd: string | null; + orderedProjectThreadKeys: readonly string[]; + isActive: boolean; + openPullRequestsInRightPanel: boolean; + jumpLabel: string | null; + 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>; + 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; +} + +export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { + const { + orderedProjectThreadKeys, + isActive, + openPullRequestsInRightPanel, + jumpLabel, + appSettingsConfirmThreadArchive, + renamingThreadKey, + renamingTitle, + setRenamingTitle, + startThreadRename, + renamingInputRef, + renamingCommittedRef, + confirmingArchiveThreadKey, + setConfirmingArchiveThreadKey, + confirmArchiveButtonRefs, + handleThreadClick, + navigateToThread, + handleMultiSelectContextMenu, + handleThreadContextMenu, + clearSelection, + commitRename, + cancelRename, + attemptArchiveThread, + openPrLink, + thread, + } = props; + const threadRef = scopeThreadRef(thread.environmentId, thread.id); + const threadKey = scopedThreadKey(threadRef); + const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); + const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); + const runningTerminalIds = useThreadRunningTerminalIds({ + environmentId: thread.environmentId, + threadId: thread.id, + }); + const isMobile = useIsMobile(); + const discoveredPorts = useThreadDiscoveredPorts({ + environmentId: thread.environmentId, + threadId: thread.id, + }); + const openPreview = useAtomCommand(previewEnvironment.open, { + reportFailure: false, + }); + const environment = useEnvironment(thread.environmentId); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const isRemoteThread = + primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; + const remoteEnvLabel = environment?.label ?? null; + // A desktop-local secondary backend (e.g. the WSL backend) shows up as a + // bearer environment whose connection id is prefixed "local:". It runs on the + // user's own machine, so the cloud icon is misleading — label it "Local" and + // suppress the cloud icon (the project header already shows a container icon + // for desktop-local projects, see sidebarProjectGrouping). + const isDesktopLocalThread = + environment !== null && isDesktopLocalConnectionTarget(environment.entry.target); + const threadEnvironmentLabel = isRemoteThread + ? (remoteEnvLabel ?? (isDesktopLocalThread ? "Local" : "Remote")) + : null; + // For grouped projects, the thread may belong to a different environment + // than the representative project. Look up the thread's own project cwd + // so git status (and thus PR detection) queries the correct path. + const threadProject = useProject( + useMemo( + () => scopeProjectRef(thread.environmentId, thread.projectId), + [thread.environmentId, thread.projectId], + ), + ); + const threadProjectCwd = threadProject?.workspaceRoot ?? null; + const gitCwd = thread.worktreePath ?? threadProjectCwd ?? props.projectCwd; + const gitStatus = useEnvironmentQuery( + thread.branch != null && gitCwd !== null + ? vcsEnvironment.status({ + environmentId: thread.environmentId, + input: { cwd: gitCwd }, + }) + : null, + ); + const isHighlighted = isActive || isSelected; + const handleOpenDiscoveredPort = useCallback( + (event: React.MouseEvent) => { + const port = discoveredPorts[0]; + if (!port) return; + event.preventDefault(); + event.stopPropagation(); + navigateToThread(threadRef); + void (async () => { + const result = await openDiscoveredPort({ threadRef, port, openPreview }); + if (result._tag === "Success" || isAtomCommandInterrupted(result)) { + return; + } + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open preview", + description: + error instanceof Error ? error.message : "The preview could not be opened.", + }), + ); + })(); + }, + [discoveredPorts, navigateToThread, openPreview, threadRef], + ); + const isThreadRunning = + thread.session?.status === "running" && thread.session.activeTurnId != null; + const threadStatus = resolveThreadStatusPill({ + thread: { + ...thread, + lastVisitedAt, + }, + }); + const pr = resolveThreadPr({ + threadBranch: thread.branch, + gitStatus: gitStatus.data, + }); + const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); + const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); + const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning; + const threadMetaClassName = isConfirmingArchive + ? "pointer-events-none opacity-0" + : !isThreadRunning + ? "pointer-events-none transition-opacity duration-150 max-sm:pr-6 group-hover/menu-sub-item:opacity-0 group-focus-within/menu-sub-item:opacity-0" + : "pointer-events-none"; + const clearConfirmingArchive = useCallback(() => { + setConfirmingArchiveThreadKey((current) => (current === threadKey ? null : current)); + }, [setConfirmingArchiveThreadKey, threadKey]); + const handleMouseLeave = useCallback(() => { + clearConfirmingArchive(); + }, [clearConfirmingArchive]); + const handleBlurCapture = useCallback( + (event: React.FocusEvent) => { + const currentTarget = event.currentTarget; + requestAnimationFrame(() => { + if (currentTarget.contains(document.activeElement)) { + return; + } + clearConfirmingArchive(); + }); + }, + [clearConfirmingArchive], + ); + const handleRowClick = useCallback( + (event: React.MouseEvent) => { + handleThreadClick(event, threadRef, orderedProjectThreadKeys); + }, + [handleThreadClick, orderedProjectThreadKeys, threadRef], + ); + const handleRowDoubleClick = useCallback( + (event: React.MouseEvent) => { + // Already renaming this row: a double-click on the row chrome (outside the + // input) must not restart and discard the in-progress edit. + if (renamingThreadKey === threadKey) return; + // On mobile the first tap navigates and closes the sidebar sheet, so the + // inline rename can't be shown. Renaming there stays on the context menu. + if (isMobile) return; + // cmd/ctrl/shift double-clicks are multi-select intent, not rename. + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; + // Ignore double-clicks bubbling from nested controls (PR status, port, + // archive buttons) — only the row body should enter inline rename. + if ((event.target as HTMLElement).closest("button, a")) return; + event.preventDefault(); + startThreadRename(threadKey, thread.title); + }, + [isMobile, renamingThreadKey, startThreadRename, threadKey, thread.title], + ); + const handleRowKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + navigateToThread(threadRef); + }, + [navigateToThread, threadRef], + ); + const handleRowContextMenu = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + const hasSelection = useThreadSelectionStore.getState().hasSelection(); + if (hasSelection && isSelected) { + void (async () => { + const result = await settlePromise(() => + handleMultiSelectContextMenu({ + x: event.clientX, + y: event.clientY, + }), + ); + if (result._tag === "Failure") { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Thread action failed", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + })(); + return; + } + + if (hasSelection) { + clearSelection(); + } + void (async () => { + const result = await settlePromise(() => + handleThreadContextMenu(threadRef, { + x: event.clientX, + y: event.clientY, + }), + ); + if (result._tag === "Failure") { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Thread action failed", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + })(); + }, + [clearSelection, handleMultiSelectContextMenu, handleThreadContextMenu, isSelected, threadRef], + ); + const handlePrClick = useCallback( + (event: React.MouseEvent) => { + if (!prStatus) return; + const openedInRightPanel = openPrLink( + event, + prStatus.url, + openPullRequestsInRightPanel ? threadRef : undefined, + ); + if (openedInRightPanel && openPullRequestsInRightPanel && !isActive) { + navigateToThread(threadRef); + } + }, + [isActive, navigateToThread, openPrLink, openPullRequestsInRightPanel, prStatus, threadRef], + ); + const handleRenameInputRef = useCallback( + (element: HTMLInputElement | null) => { + if (element && renamingInputRef.current !== element) { + renamingInputRef.current = element; + element.focus(); + element.select(); + } + }, + [renamingInputRef], + ); + const handleRenameInputChange = useCallback( + (event: React.ChangeEvent) => { + setRenamingTitle(event.target.value); + }, + [setRenamingTitle], + ); + const handleRenameInputKeyDown = useCallback( + (event: React.KeyboardEvent) => { + event.stopPropagation(); + if (event.key === "Enter") { + event.preventDefault(); + renamingCommittedRef.current = true; + void commitRename(threadRef, renamingTitle, thread.title); + } else if (event.key === "Escape") { + event.preventDefault(); + renamingCommittedRef.current = true; + cancelRename(); + } + }, + [cancelRename, commitRename, renamingCommittedRef, renamingTitle, thread.title, threadRef], + ); + const handleRenameInputBlur = useCallback(() => { + if (!renamingCommittedRef.current) { + void commitRename(threadRef, renamingTitle, thread.title); + } + }, [commitRename, renamingCommittedRef, renamingTitle, thread.title, threadRef]); + // Keep clicks/double-clicks inside the rename input from bubbling to the row. + // Without stopping `dblclick`, double-clicking to select a word would re-fire + // the row's rename handler and reset the in-progress edit back to the title. + const handleRenameInputClick = useCallback((event: React.MouseEvent) => { + event.stopPropagation(); + }, []); + const handleConfirmArchiveRef = useCallback( + (element: HTMLButtonElement | null) => { + if (element) { + confirmArchiveButtonRefs.current.set(threadKey, element); + } else { + confirmArchiveButtonRefs.current.delete(threadKey); + } + }, + [confirmArchiveButtonRefs, threadKey], + ); + const stopPropagationOnPointerDown = useCallback( + (event: React.PointerEvent) => { + event.stopPropagation(); + }, + [], + ); + const handleConfirmArchiveClick = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + clearConfirmingArchive(); + void attemptArchiveThread(threadRef); + }, + [attemptArchiveThread, clearConfirmingArchive, threadRef], + ); + const handleStartArchiveConfirmation = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + setConfirmingArchiveThreadKey(threadKey); + requestAnimationFrame(() => { + confirmArchiveButtonRefs.current.get(threadKey)?.focus(); + }); + }, + [confirmArchiveButtonRefs, setConfirmingArchiveThreadKey, threadKey], + ); + const handleArchiveImmediateClick = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + void attemptArchiveThread(threadRef); + }, + [attemptArchiveThread, threadRef], + ); + const rowButtonRender = useMemo(() =>
, []); + + return ( + + +
+ {prStatus && ( + + + + + } + /> + + + + + )} + {threadStatus && } + {renamingThreadKey === threadKey ? ( + + ) : ( + + + {thread.title} + + } + /> + + {thread.title} + + + )} +
+
+ {discoveredPorts.length > 0 && ( + + + } + > + + + + Open localhost:{discoveredPorts[0]?.port} + {discoveredPorts.length > 1 ? ` (+${discoveredPorts.length - 1})` : ""} + + + )} + + {terminalStatus && ( + + + } + > + + + {terminalStatus.label} + + )} +
+ {isConfirmingArchive ? ( + + ) : !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/ProjectFavicon.test.tsx b/apps/web/src/components/ProjectFavicon.test.tsx index c2fac8beb7e..bbeeda4bc7f 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 66216e10cb5..619bbf37001 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, diff --git a/apps/web/src/components/ProjectScriptsControl.tsx b/apps/web/src/components/ProjectScriptsControl.tsx index 7f21177e7b1..304922909b0 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.", + }); } }; @@ -466,184 +287,13 @@ export default function ProjectScriptsControl({ )} - { - setDialogOpen(open); - if (!open) { - setIconPickerOpen(false); - } - }} - onOpenChangeComplete={(open) => { - if (open) return; - setEditingScriptId(null); - setName(""); - setCommand(""); - setIcon("play"); - setRunOnWorktreeCreate(false); - setKeybinding(""); - setPreviewUrl(""); - setAutoOpenPreview(false); - setValidationError(null); - }} - open={dialogOpen} - > - - - {isEditing ? "Edit Action" : "Add Action"} - - Actions are project-scoped commands you can run from the top bar or keybindings. - - - -
    -
    - -
    - - - } - > - - - -
    - {SCRIPT_ICONS.map((entry) => { - const isSelected = entry.id === icon; - return ( - - ); - })} -
    -
    -
    - setName(event.target.value)} - /> -
    -
    -
    - - -

    - Press a shortcut. Use Backspace to clear. -

    -
    -
    - -