From 9a85aac901ff22045d8a70885b91bb4322e35431 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 24 Oct 2026 04:24:33 -0700 Subject: [PATCH 01/27] refactor(source-control): forges parse their own change request URLs (#23992) Co-authored-by: Claude Opus 5.5 --- .../HardwareKeyboardCommandProvider.tsx | 2 +- .../pullRequestNavigation.test.ts | 51 ++- .../pull-requests/usePullRequestStack.ts | 6 +- .../pull-requests/usePullRequestThreads.ts | 6 +- .../threads/LinkedPullRequestList.tsx | 2 +- .../threads/new-task-context-presentation.ts | 4 +- apps/mobile/src/lib/pullRequestNavigation.ts | 23 +- apps/mobile/src/native/useOpenPullRequest.ts | 7 +- .../src/state/thread-pr-presentation.ts | 2 +- apps/server/src/git/GitManager.ts | 8 +- apps/server/src/git/linkCreatedPullRequest.ts | 8 +- apps/server/src/mcp/OrchestratorMcpService.ts | 2 +- .../toolkits/pullRequests/handlers.test.ts | 56 +-- .../src/mcp/toolkits/pullRequests/handlers.ts | 64 +-- .../src/mcp/toolkits/pullRequests/tools.ts | 10 + .../src/orchestration-v2/Orchestrator.ts | 4 +- .../src/orchestration-v2/ProjectionStore.ts | 4 +- .../PullRequestSyncReactor.ts | 20 +- .../PullRequestWatchReactor.ts | 2 +- .../legacy/LegacyV1ThreadImporter.ts | 2 +- .../050_ProjectionThreadPullRequests.ts | 2 +- .../064_CanonicalizeThreadPullRequestLinks.ts | 2 +- .../Migrations/legacyThreadPullRequestKey.ts | 109 +++++ apps/server/src/pullRequest/linkedThreads.ts | 2 +- .../src/pullRequest/pullRequestSyncKey.ts | 2 +- .../SourceControlDiscovery.test.ts | 16 +- .../src/sourceControl/builtInDrivers.ts | 16 +- .../src/sourceControl/sourceControlClients.ts | 27 ++ .../sourceControl/sourceControlHostSecrets.ts | 18 +- .../src/sourceControl/threadPullRequests.ts | 15 + .../BranchToolbarBranchSelector.tsx | 2 +- .../ChatMarkdown.permissions.browser.test.tsx | 12 +- apps/web/src/components/ChatMarkdown.tsx | 21 +- apps/web/src/components/ChatView.tsx | 2 +- apps/web/src/components/CommandPalette.tsx | 2 +- apps/web/src/components/LegacySidebar.tsx | 2 +- apps/web/src/components/RightPanelTabs.tsx | 2 +- .../src/components/ThreadStatusIndicators.tsx | 7 +- .../components/chat/ThreadDetailsPrRows.tsx | 9 +- .../desktop/DesktopWebLinkCoordinator.tsx | 4 +- .../pullRequest/PullRequestDetailPanel.tsx | 28 +- .../pullRequest/PullRequestThreadLinks.tsx | 6 +- .../PullRequestsUnavailableState.tsx | 12 +- .../pullRequest/ThreadPullRequestsPanel.tsx | 5 +- .../pullRequest/pullRequestDetail.logic.ts | 16 +- .../components/sidebar/SidebarThreadRow.tsx | 2 +- .../src/hooks/useOpenPanelPullRequestUrl.ts | 8 +- .../src/hooks/usePullRequestLinking.test.ts | 6 +- apps/web/src/hooks/usePullRequestLinking.ts | 27 +- apps/web/src/lib/openPullRequestLink.test.ts | 316 +-------------- apps/web/src/lib/openPullRequestLink.ts | 70 +--- apps/web/src/state/pullRequests.ts | 2 +- packages/client-runtime/package.json | 12 + .../src/changeRequestLinks.test.ts | 104 +++++ .../client-runtime/src/changeRequestLinks.ts | 77 ++++ packages/client-runtime/src/promptLinks.ts | 2 +- .../src/pullRequestLinkInput.ts | 19 +- .../src/sourceControlClients.test.ts | 240 +++++++++++ .../src/sourceControlClients.ts | 8 +- packages/client-runtime/src/state/models.ts | 2 +- .../src/state/orchestrationV2Projection.ts | 2 +- .../src/threadPullRequestCompatibility.ts | 2 +- .../src/threadPullRequests.test.ts | 0 .../client-runtime/src/threadPullRequests.ts | 28 ++ .../src/threadReference.test.ts | 0 .../src/threadReference.ts | 0 .../src/work-log/presentation.ts | 7 +- packages/shared/package.json | 8 - packages/shared/src/changeRequestUrl.test.ts | 197 ---------- packages/shared/src/changeRequestUrl.ts | 216 ---------- .../orchestrationV2PendingBackgroundWork.ts | 5 +- packages/shared/src/threadPullRequests.ts | 343 +--------------- .../src/client/definition.test.ts | 104 +++++ .../src/client/definition.ts | 23 +- .../src/server/driver.ts | 6 +- .../src/client/definition.test.ts | 38 ++ .../src/client/definition.ts | 25 +- .../src/server/driver.ts | 6 +- packages/source-control-core/package.json | 4 + .../src/client/definition.ts | 139 ++++++- .../src/client/threadPullRequests.ts | 372 ++++++++++++++++++ .../source-control-core/src/server/driver.ts | 3 + .../src/client/definition.test.ts | 104 +++++ .../src/client/definition.ts | 31 +- .../src/server/driver.ts | 6 +- .../src/client/definition.test.ts | 49 +++ .../src/client/definition.ts | 31 +- .../src/server/driver.ts | 6 +- .../src/client/definition.test.ts | 96 +++++ .../src/client/definition.ts | 49 ++- .../src/server/driver.ts | 6 +- .../src/client/definition.test.ts | 50 +++ .../src/client/definition.ts | 16 +- .../GitLabSourceControlProvider.test.ts | 22 +- .../src/server/driver.ts | 6 +- 95 files changed, 2036 insertions(+), 1481 deletions(-) create mode 100644 apps/server/src/persistence/Migrations/legacyThreadPullRequestKey.ts create mode 100644 apps/server/src/sourceControl/sourceControlClients.ts create mode 100644 apps/server/src/sourceControl/threadPullRequests.ts create mode 100644 packages/client-runtime/src/changeRequestLinks.test.ts create mode 100644 packages/client-runtime/src/changeRequestLinks.ts create mode 100644 packages/client-runtime/src/sourceControlClients.test.ts rename packages/{shared => client-runtime}/src/threadPullRequests.test.ts (100%) create mode 100644 packages/client-runtime/src/threadPullRequests.ts rename packages/{shared => client-runtime}/src/threadReference.test.ts (100%) rename packages/{shared => client-runtime}/src/threadReference.ts (100%) delete mode 100644 packages/shared/src/changeRequestUrl.test.ts delete mode 100644 packages/shared/src/changeRequestUrl.ts create mode 100644 packages/source-control-azure-devops/src/client/definition.test.ts create mode 100644 packages/source-control-bitbucket/src/client/definition.test.ts create mode 100644 packages/source-control-core/src/client/threadPullRequests.ts create mode 100644 packages/source-control-forgejo/src/client/definition.test.ts create mode 100644 packages/source-control-gitcafe/src/client/definition.test.ts create mode 100644 packages/source-control-github/src/client/definition.test.ts create mode 100644 packages/source-control-gitlab/src/client/definition.test.ts diff --git a/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx b/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx index ada99e156960..cdbfc7d446de 100644 --- a/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx +++ b/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx @@ -1,5 +1,5 @@ import { StackActions, useNavigation } from "@react-navigation/native"; -import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference"; +import { resolveThreadReferenceCopyTarget } from "@t3tools/client-runtime/thread-reference"; import { createContext, use, diff --git a/apps/mobile/src/features/pull-requests/pullRequestNavigation.test.ts b/apps/mobile/src/features/pull-requests/pullRequestNavigation.test.ts index 23a2d4c8ed94..4d1a4ab5addb 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestNavigation.test.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestNavigation.test.ts @@ -1,26 +1,61 @@ +import { EnvironmentId, ProjectId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { nativePullRequestDestination } from "../../lib/pullRequestNavigation"; + const context = { environmentId: "remote", projectId: "project" }; +const project = (id: string, canonicalKey: string, provider: string, remoteUrl: string) => ({ + id: ProjectId.make(id), + environmentId: EnvironmentId.make("remote"), + repositoryIdentity: { + canonicalKey, + locator: { source: "git-remote" as const, remoteName: "origin", remoteUrl }, + displayName: canonicalKey.split("/").slice(1).join("/"), + provider, + }, +}); +const projects = [ + project("project", "github.com/org/repo", "github", "git@github.com:org/repo.git"), + project("gitlab", "git.example/group/sub/repo", "gitlab", "git@git.example:group/sub/repo.git"), + project("forgejo", "forgejo.test/org/repo", "forgejo", "http://forgejo.test:3000/org/repo.git"), +]; + describe("native PR entry points", () => { it("keeps a thread link in its own environment and handles renamed host path shapes", () => { expect( - nativePullRequestDestination("https://github.com/org/repo/pull/12/files#diff", context), + nativePullRequestDestination( + "https://github.com/org/repo/pull/12/files#diff", + context, + projects, + ), ).toEqual({ ...context, host: "github.com", repository: "org/repo", number: 12 }); expect( - nativePullRequestDestination("https://git.example/group/sub/repo/-/merge_requests/9", context) - ?.repository, - ).toBe("group/sub/repo"); + nativePullRequestDestination( + "https://git.example/group/sub/repo/-/merge_requests/9", + context, + projects, + ), + ).toMatchObject({ projectId: "gitlab", repository: "group/sub/repo" }); expect( - nativePullRequestDestination("http://forgejo.test:3000/org/repo/pulls/3", context)?.host, + nativePullRequestDestination("http://forgejo.test:3000/org/repo/pulls/3", context, projects) + ?.host, ).toBe("forgejo.test:3000"); }); + + it("leaves pull requests from repositories without a project to the external opener", () => { + expect( + nativePullRequestDestination("https://github.com/someone/else/pull/12", context, projects), + ).toBeNull(); + }); + it("leaves issues, unknown sites, and unscoped links to the external opener", () => { expect( - nativePullRequestDestination("https://github.com/org/repo/issues/12", context), + nativePullRequestDestination("https://github.com/org/repo/issues/12", context, projects), + ).toBeNull(); + expect( + nativePullRequestDestination("https://example.com/org/repo/pull/12", context, projects), ).toBeNull(); expect( - nativePullRequestDestination("https://example.com/org/repo/pull/12", context), + nativePullRequestDestination("https://github.com/org/repo/pull/12", null, projects), ).toBeNull(); - expect(nativePullRequestDestination("https://github.com/org/repo/pull/12", null)).toBeNull(); }); }); diff --git a/apps/mobile/src/features/pull-requests/usePullRequestStack.ts b/apps/mobile/src/features/pull-requests/usePullRequestStack.ts index 85e38795d8cd..50a3d11f0d3e 100644 --- a/apps/mobile/src/features/pull-requests/usePullRequestStack.ts +++ b/apps/mobile/src/features/pull-requests/usePullRequestStack.ts @@ -1,6 +1,6 @@ import { threadPullRequestLinkMode } from "@t3tools/client-runtime/thread-pull-request-compatibility"; import type { PullRequestDetail } from "@t3tools/contracts"; -import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { useMemo } from "react"; import { useEnvironmentServerConfig, useNavigationThreadShells } from "../../state/entities"; import { pullRequestStackAtom } from "../../state/pull-requests"; @@ -18,7 +18,9 @@ export function usePullRequestStack(params: PullRequestParams, pr: PullRequestDe const reference = useMemo( () => ({ ...pullRequestTarget(params).input, - host: params.host ?? (pr ? parseChangeRequestUrl(pr.url)?.host : undefined), + host: + params.host ?? + (pr ? sourceControlClients.parseChangeRequestUrl(pr.url)?.link.host : undefined), }), [params, pr], ); diff --git a/apps/mobile/src/features/pull-requests/usePullRequestThreads.ts b/apps/mobile/src/features/pull-requests/usePullRequestThreads.ts index e845c960e58a..ba8abcbff8ed 100644 --- a/apps/mobile/src/features/pull-requests/usePullRequestThreads.ts +++ b/apps/mobile/src/features/pull-requests/usePullRequestThreads.ts @@ -11,8 +11,8 @@ import { type PullRequestLinkedThreadsResult, } from "@t3tools/contracts"; import { formatComposerContextReference } from "@t3tools/shared/composerContextReferences"; -import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; -import { normalizeThreadPullRequestKey } from "@t3tools/shared/threadPullRequests"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; +import { normalizeThreadPullRequestKey } from "@t3tools/client-runtime/thread-pull-requests"; import { useRef, useState } from "react"; import { Platform } from "react-native"; import { useAndroidWorkspaceSelection } from "../workspace/useAndroidWorkspaceSelection"; @@ -49,7 +49,7 @@ export function usePullRequestThreads( const target = pullRequestTarget(params); const config = useEnvironmentServerConfig(target.environmentId); const supported = threadPullRequestLinkMode(config?.environment.capabilities) === "multiple"; - const parsed = pr ? parseChangeRequestUrl(pr.url) : null; + const parsed = pr ? sourceControlClients.parseChangeRequestUrl(pr.url)?.link : undefined; const reference = { ...target, input: { ...target.input, ...(parsed ? normalizeThreadPullRequestKey(parsed) : {}) }, diff --git a/apps/mobile/src/features/threads/LinkedPullRequestList.tsx b/apps/mobile/src/features/threads/LinkedPullRequestList.tsx index 903e7fd574bb..2082b8753683 100644 --- a/apps/mobile/src/features/threads/LinkedPullRequestList.tsx +++ b/apps/mobile/src/features/threads/LinkedPullRequestList.tsx @@ -4,7 +4,7 @@ import { resolveThreadPullRequestChains, threadPullRequestKeyOf, type PullRequestListLine, -} from "@t3tools/shared/threadPullRequests"; +} from "@t3tools/client-runtime/thread-pull-requests"; import { memo, useMemo } from "react"; import { Pressable, useWindowDimensions, View } from "react-native"; diff --git a/apps/mobile/src/features/threads/new-task-context-presentation.ts b/apps/mobile/src/features/threads/new-task-context-presentation.ts index bd2add22eb64..b1dbd5206ada 100644 --- a/apps/mobile/src/features/threads/new-task-context-presentation.ts +++ b/apps/mobile/src/features/threads/new-task-context-presentation.ts @@ -1,5 +1,5 @@ import { sanitizeNewRefName } from "@t3tools/shared/git"; -import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { resolveSendEnvMode } from "@t3tools/shared/projectSettings"; import type { ComposerDraftWorkspaceSelection } from "../../state/use-composer-drafts"; @@ -79,7 +79,7 @@ export function resolveNewTaskBranchLabel(input: { readonly pullRequestReference?: string; }): string { if (input.workspaceMode === "worktree" && input.pullRequestReference) { - const pr = parseChangeRequestUrl(input.pullRequestReference); + const pr = sourceControlClients.parseChangeRequestUrl(input.pullRequestReference)?.link; if (pr) return `From PR #${pr.number}`; } if (!input.branchName) { diff --git a/apps/mobile/src/lib/pullRequestNavigation.ts b/apps/mobile/src/lib/pullRequestNavigation.ts index 98cc6d7273d0..1c84cfb6876d 100644 --- a/apps/mobile/src/lib/pullRequestNavigation.ts +++ b/apps/mobile/src/lib/pullRequestNavigation.ts @@ -1,13 +1,30 @@ -import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; +import { findProjectForChangeRequest } from "@t3tools/client-runtime/change-request-links"; +import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; +/** + * Opens a change request in the app only under a project of the link's environment whose own + * repository holds it, preferring the one the link came from; anything else is the browser's. + */ export function nativePullRequestDestination( url: string, context: { environmentId: string; projectId: string } | null, + projects: ReadonlyArray>, ) { - const parsed = parseChangeRequestUrl(url); + const parsed = sourceControlClients.parseChangeRequestUrl(url)?.link; if (!parsed || !context) return null; + const candidates = projects.filter((project) => project.environmentId === context.environmentId); + const project = findProjectForChangeRequest( + [ + ...candidates.filter((candidate) => candidate.id === context.projectId), + ...candidates.filter((candidate) => candidate.id !== context.projectId), + ], + parsed, + ); + if (project === undefined) return null; return { - ...context, + environmentId: context.environmentId, + projectId: String(project.id), repository: parsed.repository, number: parsed.number, host: parsed.authority ?? parsed.host, diff --git a/apps/mobile/src/native/useOpenPullRequest.ts b/apps/mobile/src/native/useOpenPullRequest.ts index 48d895de7a9f..096906a1716b 100644 --- a/apps/mobile/src/native/useOpenPullRequest.ts +++ b/apps/mobile/src/native/useOpenPullRequest.ts @@ -4,7 +4,7 @@ import { useCallback } from "react"; import { Platform } from "react-native"; import { resetAndroidWorkspace } from "../lib/android-workspace-navigation"; import { EnvironmentId, ThreadId } from "@t3tools/contracts"; -import { useThreadShell } from "../state/entities"; +import { useProjects, useThreadShell } from "../state/entities"; import { tryOpenExternalUrl } from "../lib/openExternalUrl"; type PullRequestContext = { readonly environmentId: string; readonly projectId: string }; @@ -13,10 +13,11 @@ type PullRequestContext = { readonly environmentId: string; readonly projectId: // route context for useRoute, can open pull requests for any thread. export function useNavigateToPullRequest(options?: { readonly replace?: boolean }) { const navigation = useNavigation(); + const projects = useProjects(); const replace = options?.replace; return useCallback( async (url: string, context: PullRequestContext | null) => { - const params = nativePullRequestDestination(url, context); + const params = nativePullRequestDestination(url, context, projects); if (!params) return tryOpenExternalUrl(url, "pull-request"); if (Platform.OS === "android") resetAndroidWorkspace(navigation, { name: "PullRequest", params }); @@ -24,7 +25,7 @@ export function useNavigateToPullRequest(options?: { readonly replace?: boolean else navigation.navigate("PullRequest", params); return true; }, - [navigation, replace], + [navigation, projects, replace], ); } diff --git a/apps/mobile/src/state/thread-pr-presentation.ts b/apps/mobile/src/state/thread-pr-presentation.ts index bd6ed87e3bb1..7e4bcc505a9a 100644 --- a/apps/mobile/src/state/thread-pr-presentation.ts +++ b/apps/mobile/src/state/thread-pr-presentation.ts @@ -9,7 +9,7 @@ import { sourceControlClients } from "@t3tools/client-runtime/source-control-cli import { resolveThreadCurrentPullRequestLink, resolveThreadPullRequestBadge, -} from "@t3tools/shared/threadPullRequests"; +} from "@t3tools/client-runtime/thread-pull-requests"; export type ThreadPr = NonNullable; diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index ae1b22157a99..4691f81fda89 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -62,7 +62,6 @@ import { isSshRemoteUrl, type ChangeRequestTerminology, } from "@t3tools/shared/sourceControl"; -import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { parseGitHubRepositorySelector, @@ -71,7 +70,8 @@ import { import { normalizeThreadPullRequestKey, threadPullRequestKeyOf, -} from "@t3tools/shared/threadPullRequests"; +} from "../sourceControl/threadPullRequests.ts"; +import { sourceControlClients } from "../sourceControl/sourceControlClients.ts"; import { GitManagerError, GitPullRequestMaterializationError } from "@t3tools/contracts"; import { @@ -1202,8 +1202,8 @@ export const make = Effect.gen(function* () { >(); const noteLookupState = (pr: PullRequestInfo) => Effect.suspend(() => { - const parsed = parseChangeRequestUrl(pr.url); - if (parsed === null || parsed.number !== pr.number) return Effect.void; + const parsed = sourceControlClients.parseChangeRequestUrl(pr.url)?.link; + if (parsed === undefined || parsed.number !== pr.number) return Effect.void; const key = normalizeThreadPullRequestKey(parsed); const id = threadPullRequestKeyOf(key); const previous = lookupStates.get(id); diff --git a/apps/server/src/git/linkCreatedPullRequest.ts b/apps/server/src/git/linkCreatedPullRequest.ts index 2b687d0832b3..29e80f9c13c4 100644 --- a/apps/server/src/git/linkCreatedPullRequest.ts +++ b/apps/server/src/git/linkCreatedPullRequest.ts @@ -7,13 +7,13 @@ import { type SourceControlProviderKind, type ThreadId, } from "@t3tools/contracts"; -import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; import * as ProjectService from "../project/ProjectService.ts"; +import { sourceControlClients } from "../sourceControl/sourceControlClients.ts"; export interface CreatedPullRequestKey { readonly host: string; @@ -30,8 +30,10 @@ export function createdPullRequestKey( if ((status !== "created" && status !== "opened_existing") || number === undefined || !url) { return null; } - const parsed = parseChangeRequestUrl(url); - if (parsed !== null) return { ...parsed, url }; + const parsed = sourceControlClients.parseChangeRequestUrl(url)?.link; + if (parsed !== undefined) { + return { host: parsed.host, repository: parsed.repository, number: parsed.number, url }; + } const identity = project?.repositoryIdentity; const kind = identity?.provider as SourceControlProviderKind | undefined; const repository = sourceControlRepositorySelector(identity); diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 8f39c2e452a6..53d57406c7cb 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -70,7 +70,7 @@ import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError"; import { normalizeThreadPullRequestKey, visibleThreadPullRequests, -} from "@t3tools/shared/threadPullRequests"; +} from "../sourceControl/threadPullRequests.ts"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts index c49715eb7eb7..11bce07bf5ba 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts @@ -390,44 +390,22 @@ describe("pull request toolkit handlers", () => { }); expect(result.url).toBe("https://gitlab.com/group/sub/project/-/merge_requests/42"); expect(result.host).toBe("gitlab.com"); - }), - ); - - it.effect("links a numeric Forgejo reference with its remote's web origin and mount path", () => - Effect.gen(function* () { - const harness = yield* makeHarness({ - project: makeProject({ - canonicalKey: "forge.example/git/owner/repo", - locator: { - source: "git-remote", - remoteName: "origin", - remoteUrl: "http://forge.example:3000/git/owner/repo.git", - }, - provider: "forgejo", - displayName: "git/owner/repo", - }), - }); - const result = yield* harness.call("link_pull_request", { - repository: "git/owner/repo", - number: 42, - }); - expect(result).toEqual({ - host: "forge.example:3000", - repository: "git/owner/repo", - number: 42, - url: "http://forge.example:3000/git/owner/repo/pulls/42", - alreadyLinked: false, - }); const other = yield* harness.call("link_pull_request", { - host: "other.example", - repository: "owner/repo", - number: 42, + host: "gitlab.example.test", + repository: "team/repo", + number: 7, + }); + expect(other.url).toBe("https://gitlab.example.test/team/repo/-/merge_requests/7"); + const onGitHub = yield* harness.call("link_pull_request", { + host: "github.com", + repository: "acme/web", + number: 9, }); - expect(other.url).toBe("https://other.example/owner/repo/pull/42"); + expect(onGitHub.url).toBe("https://github.com/acme/web/pull/9"); }), ); - it.effect("links Azure DevOps Server URLs and numeric references without a known provider", () => + it.effect("links a URL but not a bare number when the project's host is unknown", () => Effect.gen(function* () { const harness = yield* makeHarness({ project: makeProject({ @@ -441,13 +419,10 @@ describe("pull request toolkit handlers", () => { displayName: "tfs/collection/project/_git/repo", }), }); - const generated = - "http://scm.example.test:8080/tfs/collection/project/_git/repo/pullrequest/124"; - const fromNumber = yield* harness.call("link_pull_request", { - repository: "tfs/Collection/Project/_git/Repo", - number: 124, - }); - expect.soft(fromNumber.url).toBe(generated); + const error = yield* harness + .call("link_pull_request", { repository: "tfs/Collection/Project/_git/Repo", number: 124 }) + .pipe(Effect.flip); + expect(error).toMatchObject({ _tag: "PullRequestUrlUnavailableError" }); const supplied = "https://scm.example.test/tfs/Collection/Project/_git/Repo/pullrequest/123"; const fromUrl = yield* harness.call("link_pull_request", { url: supplied }); @@ -459,7 +434,6 @@ describe("pull request toolkit handlers", () => { alreadyLinked: false, }); expect(yield* Ref.get(harness.commands)).toMatchObject([ - { type: "thread.pull-request.link", number: 124, url: generated }, { type: "thread.pull-request.link", number: 123, url: supplied }, ]); }), diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts index c777214aad5c..ff323fcdd948 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts @@ -1,7 +1,3 @@ -import { - threadPullRequestsOf, - threadPullRequestKeysEqual, -} from "@t3tools/shared/threadPullRequests"; import { CommandId, pullRequestHostOf, @@ -11,14 +7,7 @@ import { type ThreadId, type ThreadPullRequestLink, } from "@t3tools/contracts"; -import { changeRequestUrlFor, parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; -import { - normalizeThreadPullRequestKey, - resolveThreadPullRequestChains, - threadPullRequestKeyOf, - visibleThreadPullRequests, -} from "@t3tools/shared/threadPullRequests"; import * as Cause from "effect/Cause"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; @@ -28,6 +17,15 @@ import * as Orchestrator from "../../../orchestration-v2/Orchestrator.ts"; import * as ProjectService from "../../../project/ProjectService.ts"; import * as PullRequestReviewGuideService from "../../../pullRequest/PullRequestReviewGuideService.ts"; import { pullRequestSyncKey } from "../../../pullRequest/pullRequestSyncKey.ts"; +import { sourceControlClients } from "../../../sourceControl/sourceControlClients.ts"; +import { + normalizeThreadPullRequestKey, + resolveThreadPullRequestChains, + threadPullRequestKeyOf, + threadPullRequestKeysEqual, + threadPullRequestsOf, + visibleThreadPullRequests, +} from "../../../sourceControl/threadPullRequests.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as McpToolAccess from "../../McpToolAccess.ts"; import { @@ -36,6 +34,7 @@ import { PullRequestUrlInvalidError, PullRequestTargetIncompleteError, PullRequestHostRequiredError, + PullRequestUrlUnavailableError, PullRequestUnlinkFailedError, PullRequestListFailedError, PullRequestNotOpenError, @@ -75,8 +74,8 @@ const resolveTarget = Effect.fn("PullRequestsToolkit.resolveTarget")(function* ( project: OrchestrationProjectShell | undefined, ) { if (input.url !== undefined) { - const parsed = parseChangeRequestUrl(input.url); - if (parsed === null) { + const parsed = sourceControlClients.parseChangeRequestUrl(input.url)?.link; + if (parsed === undefined) { return yield* new PullRequestUrlInvalidError({}); } return { ...normalizeThreadPullRequestKey(parsed), url: input.url } satisfies ResolvedTarget; @@ -90,24 +89,21 @@ const resolveTarget = Effect.fn("PullRequestsToolkit.resolveTarget")(function* ( return yield* new PullRequestHostRequiredError({}); } const repository = input.repository.toLowerCase(); + // Without a URL, a public forge host says what shape its change request URLs take; + // any other host is taken to run the project's own forge. + const kind = sourceControlClients.findByPublicHost(host)?.kind ?? projectHost.kind; const url = - changeRequestUrlFor( - host === projectHost.host ? projectHost.kind : null, - host, - repository, - input.number, - project?.repositoryIdentity?.locator.remoteUrl, - ) ?? - (/(?:^|\/)_git\/[^/]+$/u.test(repository) - ? changeRequestUrlFor( - "azure-devops", + kind === null + ? null + : sourceControlClients.get(kind).changeRequestUrl({ host, repository, - input.number, - project?.repositoryIdentity?.locator.remoteUrl, - ) - : null) ?? - `https://${host}/${repository}/pull/${input.number}`; + number: input.number, + remoteUrl: project?.repositoryIdentity?.locator.remoteUrl, + }); + if (url === null) { + return yield* new PullRequestUrlUnavailableError({}); + } return { ...normalizeThreadPullRequestKey({ host, repository, number: input.number, url }), url, @@ -314,8 +310,18 @@ const make = Effect.gen(function* () { .listShells() .pipe(Effect.mapError((cause) => new PullRequestListFailedError({ cause }))); const callerProject = known.find((project) => project.id === callerThread?.projectId); + // A pull request named by host and repository builds its URL through the project that holds + // it, which need not be the caller's. + const urlProject = (input: PullRequestTargetInput) => + input.url === undefined && input.host !== undefined && input.repository !== undefined + ? (projectForPullRequest( + known, + { host: input.host, repository: input.repository }, + callerProject, + ) ?? callerProject) + : callerProject; return yield* Effect.forEach(targets, (input) => - resolveTarget(input, callerProject).pipe( + resolveTarget(input, urlProject(input)).pipe( Effect.map((target) => ({ input, target, diff --git a/apps/server/src/mcp/toolkits/pullRequests/tools.ts b/apps/server/src/mcp/toolkits/pullRequests/tools.ts index a3a75eabaa38..510f6831d7eb 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/tools.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/tools.ts @@ -93,6 +93,15 @@ export class PullRequestHostRequiredError extends Schema.TaggedError()( + "PullRequestUrlUnavailableError", + {}, +) { + override get message(): string { + return "This thread's project is on a host with no known pull request URL shape. Pass url."; + } +} + export class PullRequestThreadRequiredError extends Schema.TaggedError()( "PullRequestThreadRequiredError", {}, @@ -189,6 +198,7 @@ export const PullRequestToolError = Schema.Union([ PullRequestUrlInvalidError, PullRequestTargetIncompleteError, PullRequestHostRequiredError, + PullRequestUrlUnavailableError, PullRequestThreadRequiredError, PullRequestThreadNotFoundError, PullRequestLinkFailedError, diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 1a650c81f747..77533f593e9a 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -9,13 +9,13 @@ import { runRanAfter, usageLimitBlockedRun, } from "@t3tools/shared/orchestrationV2ThreadError"; -import { threadPullRequestsOf } from "@t3tools/shared/threadPullRequests"; +import { threadPullRequestsOf } from "../sourceControl/threadPullRequests.ts"; import { normalizeThreadPullRequestKey, visibleThreadPullRequests, threadPullRequestKeysEqual, legacyThreadPullRequestKey, -} from "@t3tools/shared/threadPullRequests"; +} from "../sourceControl/threadPullRequests.ts"; import { ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE, type ChatAttachment, diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 8deb5bf7e93f..a50ce6ff3524 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -1,4 +1,4 @@ -import { applyThreadPullRequestLinkUpdate } from "@t3tools/shared/threadPullRequests"; +import { applyThreadPullRequestLinkUpdate } from "../sourceControl/threadPullRequests.ts"; import { projectTurnItemForWire } from "./WireProjection.ts"; import * as Stream from "effect/Stream"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; @@ -14,7 +14,7 @@ import { threadErrorSummary, usageLimitRunPresentedAsLatest, } from "@t3tools/shared/orchestrationV2ThreadError"; -import { threadPullRequestsOf } from "@t3tools/shared/threadPullRequests"; +import { threadPullRequestsOf } from "../sourceControl/threadPullRequests.ts"; import type { OrchestrationV2AppThread, OrchestrationV2Checkpoint, diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts index af7ecdadb12c..5e3dbbb6d937 100644 --- a/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts @@ -1,4 +1,3 @@ -import { siblingPullRequestUrl } from "@t3tools/shared/changeRequestUrl"; import { CommandId, type PullRequestSummary, @@ -14,7 +13,8 @@ import { normalizeThreadPullRequestKey, threadPullRequestKeysEqual, visibleThreadPullRequests, -} from "@t3tools/shared/threadPullRequests"; +} from "../sourceControl/threadPullRequests.ts"; +import { sourceControlClients } from "../sourceControl/sourceControlClients.ts"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; @@ -53,6 +53,20 @@ interface LinkEntry { readonly link: ThreadPullRequestLink; } +/** Another layer of the stack `url` belongs to, in its host's canonical shape. */ +function siblingChangeRequestUrl(url: string, number: number): string | null { + if (!Number.isSafeInteger(number) || number < 1) return null; + const parsed = sourceControlClients.parseChangeRequestUrl(url); + if (parsed === null) return null; + const { definition, link } = parsed; + return definition.changeRequestUrl({ + host: link.host, + repository: link.repository, + number, + webUrl: link.repositoryUrl, + }); +} + function snapshotFieldsOf(summary: PullRequestSummary): SnapshotFields { return { state: summary.state, @@ -287,7 +301,7 @@ export const make = Effect.gen(function* () { ) { continue; } - const url = siblingPullRequestUrl(link.url, layer.number); + const url = siblingChangeRequestUrl(link.url, layer.number); if (url === null) continue; const uuid = yield* crypto.randomUUIDv4(); yield* engine.dispatch({ diff --git a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts index 30152d305382..37631ce41a07 100644 --- a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts @@ -13,7 +13,7 @@ import { normalizeThreadPullRequestKey, threadPullRequestKeyOf, visibleThreadPullRequests, -} from "@t3tools/shared/threadPullRequests"; +} from "../sourceControl/threadPullRequests.ts"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; diff --git a/apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.ts b/apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.ts index 610be4c1ba3a..30403ea527f0 100644 --- a/apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.ts +++ b/apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.ts @@ -1,7 +1,7 @@ import { threadPullRequestKeysEqual, threadPullRequestsOf, -} from "@t3tools/shared/threadPullRequests"; +} from "../../sourceControl/threadPullRequests.ts"; import { ChatAttachment, OrchestrationMessageContext, diff --git a/apps/server/src/persistence/Migrations/050_ProjectionThreadPullRequests.ts b/apps/server/src/persistence/Migrations/050_ProjectionThreadPullRequests.ts index 0ee0f00c0b33..f130aae7e10a 100644 --- a/apps/server/src/persistence/Migrations/050_ProjectionThreadPullRequests.ts +++ b/apps/server/src/persistence/Migrations/050_ProjectionThreadPullRequests.ts @@ -1,4 +1,4 @@ -import { legacyThreadPullRequestKey } from "@t3tools/shared/threadPullRequests"; +import { legacyThreadPullRequestKey } from "./legacyThreadPullRequestKey.ts"; import * as Effect from "effect/Effect"; import * as SqlClient from "effect/sql/SqlClient"; diff --git a/apps/server/src/persistence/Migrations/064_CanonicalizeThreadPullRequestLinks.ts b/apps/server/src/persistence/Migrations/064_CanonicalizeThreadPullRequestLinks.ts index 7135696246c6..9364a8d817c4 100644 --- a/apps/server/src/persistence/Migrations/064_CanonicalizeThreadPullRequestLinks.ts +++ b/apps/server/src/persistence/Migrations/064_CanonicalizeThreadPullRequestLinks.ts @@ -1,5 +1,5 @@ import { ThreadLinkedPullRequest, ThreadPullRequestLink } from "@t3tools/contracts"; -import { legacyThreadPullRequestKey } from "@t3tools/shared/threadPullRequests"; +import { legacyThreadPullRequestKey } from "./legacyThreadPullRequestKey.ts"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import * as SqlClient from "effect/sql/SqlClient"; diff --git a/apps/server/src/persistence/Migrations/legacyThreadPullRequestKey.ts b/apps/server/src/persistence/Migrations/legacyThreadPullRequestKey.ts new file mode 100644 index 000000000000..c5c0f80b8d9d --- /dev/null +++ b/apps/server/src/persistence/Migrations/legacyThreadPullRequestKey.ts @@ -0,0 +1,109 @@ +import type { ThreadLinkedPullRequest, ThreadPullRequestKey } from "@t3tools/contracts"; + +/** + * Frozen for migrations 050 and 064: the change request URL parsing and key normalization they + * shipped with. Migrations are history, so they must not follow the live source control hosts. + */ + +interface FrozenLink { + readonly host: string; + readonly repository: string; + readonly number: number; + readonly authority?: string; +} + +function canonicalRepositoryKey(key: string): string { + return key + .replace( + /^(?:ssh\.dev\.azure\.com|vs-ssh\.visualstudio\.com)\/v3\/([^/]+)\/([^/]+)\/([^/]+)$/u, + "dev.azure.com/$1/$2/_git/$3", + ) + .replace( + /^([^.]+)\.visualstudio\.com\/(?:defaultcollection\/)?([^/]+)\/_git\/([^/]+)$/u, + "dev.azure.com/$1/$2/_git/$3", + ); +} + +function isHostOf(hostname: string, apex: string, label: string): boolean { + return hostname === apex || hostname.endsWith(`.${apex}`) || hostname.split(".").includes(label); +} + +function claim(host: string, match: RegExpExecArray | null): FrozenLink | null { + const repository = match?.[1]; + const number = Number(match?.[2]); + return repository && Number.isSafeInteger(number) && number > 0 + ? { host, repository: repository.toLowerCase(), number } + : null; +} + +function parseChangeRequestUrl(targetUrl: string): FrozenLink | null { + let url: URL; + try { + url = new URL(targetUrl); + } catch { + return null; + } + if (url.protocol !== "https:" && url.protocol !== "http:") return null; + const host = url.hostname.toLowerCase(); + + if (host === "git.cafe" || host === "staging.git.cafe") { + const match = /^\/([^/]+\/[^/]+)\/pulls?\/(\d+)(?:\/|$)/u.exec(url.pathname); + if (match) return claim(host, match); + } else { + if (isHostOf(host, "github.com", "github")) { + const match = /^\/([^/]+\/[^/]+)\/pull\/(\d+)(?:\/|$)/u.exec(url.pathname); + if (match) return claim(host, match); + } + const forgejo = /^\/([^/]+(?:\/[^/]+)+)\/pulls\/(\d+)(?:\/|$)/u.exec(url.pathname); + if (forgejo) { + const link = claim(host, forgejo); + return link === null ? null : { ...link, authority: url.host.toLowerCase() }; + } + const gitlab = /^\/([^/]+(?:\/[^/]+)+)\/-\/merge_requests\/(\d+)(?:\/|$)/u.exec(url.pathname); + if (gitlab) return claim(host, gitlab); + if (isHostOf(host, "bitbucket.org", "bitbucket")) { + const match = /^\/([^/]+\/[^/]+)\/pull-requests\/(\d+)(?:\/|$)/u.exec(url.pathname); + if (match) return claim(host, match); + } + } + const azure = /^\/((?:[^/]+\/)*_git\/[^/]+)\/pullrequest\/(\d+)(?:\/|$)/u.exec(url.pathname); + const link = claim(host, azure); + return link === null || url.port === "" ? link : { ...link, authority: url.host.toLowerCase() }; +} + +function normalizeThreadPullRequestKey( + key: ThreadPullRequestKey & { readonly authority?: string }, +): ThreadPullRequestKey { + const canonical = canonicalRepositoryKey( + `${(key.authority ?? key.host).trim().toLowerCase()}/${key.repository.trim().toLowerCase()}`, + ); + const separator = canonical.indexOf("/"); + return { + host: canonical.slice(0, separator), + repository: canonical.slice(separator + 1), + number: key.number, + }; +} + +export function legacyThreadPullRequestKey( + linked: Pick, +): ThreadPullRequestKey { + const parsed = parseChangeRequestUrl(linked.url); + if (parsed !== null && parsed.number === linked.number) { + const canonical = canonicalRepositoryKey(`${parsed.host}/${parsed.repository}`); + if (parsed.authority !== undefined || canonical.startsWith("dev.azure.com/")) { + return normalizeThreadPullRequestKey(parsed); + } + } + let host: string; + try { + host = new URL(linked.url).hostname; + } catch { + host = "unknown"; + } + return { + host: host.trim().toLowerCase() || "unknown", + repository: linked.repository.trim().toLowerCase(), + number: linked.number, + }; +} diff --git a/apps/server/src/pullRequest/linkedThreads.ts b/apps/server/src/pullRequest/linkedThreads.ts index fa51017ebcd5..c7a2b01946a2 100644 --- a/apps/server/src/pullRequest/linkedThreads.ts +++ b/apps/server/src/pullRequest/linkedThreads.ts @@ -1,7 +1,7 @@ import { normalizeThreadPullRequestKey, threadPullRequestKeysEqual, -} from "@t3tools/shared/threadPullRequests"; +} from "../sourceControl/threadPullRequests.ts"; import { PullRequestLinkedThreadsResult, PullRequestOperationError, diff --git a/apps/server/src/pullRequest/pullRequestSyncKey.ts b/apps/server/src/pullRequest/pullRequestSyncKey.ts index 3b5ecc380d03..90416e066ca0 100644 --- a/apps/server/src/pullRequest/pullRequestSyncKey.ts +++ b/apps/server/src/pullRequest/pullRequestSyncKey.ts @@ -6,7 +6,7 @@ import { type ThreadPullRequestKey, } from "@t3tools/contracts"; import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; -import { normalizeThreadPullRequestKey } from "@t3tools/shared/threadPullRequests"; +import { normalizeThreadPullRequestKey } from "../sourceControl/threadPullRequests.ts"; export function pullRequestSyncKey( reference: PullRequestRef, diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index aad475ab54af..43f3a9ff8680 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -217,27 +217,27 @@ Logged in to gitlab.com as gitlab-user detail: Option.none(), }, { - kind: SourceControlProviderKind.make("azure-devops"), + kind: SourceControlProviderKind.make("gitcafe"), auth: "authenticated", - account: Option.some("azure-user@example.com"), + account: Option.some("cafe-user"), detail: Option.none(), }, { - kind: SourceControlProviderKind.make("bitbucket"), + kind: SourceControlProviderKind.make("forgejo"), auth: "authenticated", - account: Option.some("bitbucket-user"), + account: Option.some("forgejo-user"), detail: Option.none(), }, { - kind: SourceControlProviderKind.make("forgejo"), + kind: SourceControlProviderKind.make("bitbucket"), auth: "authenticated", - account: Option.some("forgejo-user"), + account: Option.some("bitbucket-user"), detail: Option.none(), }, { - kind: SourceControlProviderKind.make("gitcafe"), + kind: SourceControlProviderKind.make("azure-devops"), auth: "authenticated", - account: Option.some("cafe-user"), + account: Option.some("azure-user@example.com"), detail: Option.none(), }, ], diff --git a/apps/server/src/sourceControl/builtInDrivers.ts b/apps/server/src/sourceControl/builtInDrivers.ts index 9af54853a049..7941c7f3eadc 100644 --- a/apps/server/src/sourceControl/builtInDrivers.ts +++ b/apps/server/src/sourceControl/builtInDrivers.ts @@ -1,34 +1,20 @@ import * as AzureDevOpsCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsCli"; import * as AzureDevOpsPullRequestCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestCli"; -import * as AzureDevOpsDriver from "@t3tools/source-control-azure-devops/server/driver"; import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi"; import * as BitbucketPullRequestApi from "@t3tools/source-control-bitbucket/server/BitbucketPullRequestApi"; -import * as BitbucketDriver from "@t3tools/source-control-bitbucket/server/driver"; import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; -import * as ForgejoDriver from "@t3tools/source-control-forgejo/server/driver"; import * as GitCafeApi from "@t3tools/source-control-gitcafe/server/GitCafeApi"; import * as GitCafeCredentials from "@t3tools/source-control-gitcafe/server/GitCafeCredentials"; -import * as GitCafeDriver from "@t3tools/source-control-gitcafe/server/driver"; import * as GitHubRest from "@t3tools/source-control-github/server/GitHubRest"; import * as GitHubPullRequestApi from "@t3tools/source-control-github/server/GitHubPullRequestApi"; -import * as GitHubDriver from "@t3tools/source-control-github/server/driver"; import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli"; import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli"; -import * as GitLabDriver from "@t3tools/source-control-gitlab/server/driver"; import type { SourceControlDriver } from "@t3tools/source-control-core/server/driver"; import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; import * as Layer from "effect/Layer"; import * as ServerSourceControlHost from "./ServerSourceControlHost.ts"; - -const drivers = [ - GitHubDriver.driver, - GitLabDriver.driver, - AzureDevOpsDriver.driver, - BitbucketDriver.driver, - ForgejoDriver.driver, - GitCafeDriver.driver, -]; +import { BUILT_IN_SOURCE_CONTROL_DRIVER_LIST as drivers } from "./sourceControlClients.ts"; export type BuiltInSourceControlDriversEnv = (typeof drivers)[number] extends SourceControlDriver ? R : never; diff --git a/apps/server/src/sourceControl/sourceControlClients.ts b/apps/server/src/sourceControl/sourceControlClients.ts new file mode 100644 index 000000000000..bfe6cc8fecaa --- /dev/null +++ b/apps/server/src/sourceControl/sourceControlClients.ts @@ -0,0 +1,27 @@ +import * as AzureDevOpsDriver from "@t3tools/source-control-azure-devops/server/driver"; +import * as BitbucketDriver from "@t3tools/source-control-bitbucket/server/driver"; +import { makeSourceControlClientRegistry } from "@t3tools/source-control-core/client/definition"; +import * as ForgejoDriver from "@t3tools/source-control-forgejo/server/driver"; +import * as GitCafeDriver from "@t3tools/source-control-gitcafe/server/driver"; +import * as GitHubDriver from "@t3tools/source-control-github/server/driver"; +import * as GitLabDriver from "@t3tools/source-control-gitlab/server/driver"; + +/** + * The same order as the clients' registry in `@t3tools/client-runtime/source-control-clients`. + * A change request URL goes to the first host whose shape it has, so GitCafe, which claims + * `/pull/` and `/pulls/` only on its own hostnames, claims before GitHub and Forgejo, which + * claim them everywhere. + */ +export const BUILT_IN_SOURCE_CONTROL_DRIVER_LIST = [ + GitHubDriver.driver, + GitLabDriver.driver, + GitCafeDriver.driver, + ForgejoDriver.driver, + BitbucketDriver.driver, + AzureDevOpsDriver.driver, +] as const; + +export const sourceControlClients = makeSourceControlClientRegistry( + BUILT_IN_SOURCE_CONTROL_DRIVER_LIST.map((driver) => driver.definition), + { claimChangeRequestUrlsFirst: [GitCafeDriver.driver.kind] }, +); diff --git a/apps/server/src/sourceControl/sourceControlHostSecrets.ts b/apps/server/src/sourceControl/sourceControlHostSecrets.ts index 8ac8046985ce..ab366398be3d 100644 --- a/apps/server/src/sourceControl/sourceControlHostSecrets.ts +++ b/apps/server/src/sourceControl/sourceControlHostSecrets.ts @@ -1,26 +1,12 @@ import { secretSettingsFields } from "@t3tools/contracts"; -import * as AzureDevOps from "@t3tools/source-control-azure-devops/client/definition"; -import * as Bitbucket from "@t3tools/source-control-bitbucket/client/definition"; -import type { SourceControlClientDefinition } from "@t3tools/source-control-core/client/definition"; -import * as Forgejo from "@t3tools/source-control-forgejo/client/definition"; -import * as GitCafe from "@t3tools/source-control-gitcafe/client/definition"; -import * as GitHub from "@t3tools/source-control-github/client/definition"; -import * as GitLab from "@t3tools/source-control-gitlab/client/definition"; -const definitions: ReadonlyArray = [ - GitHub.definition, - GitLab.definition, - AzureDevOps.definition, - Bitbucket.definition, - Forgejo.definition, - GitCafe.definition, -]; +import { sourceControlClients } from "./sourceControlClients.ts"; export const SOURCE_CONTROL_HOST_SECRET_FIELDS: ReadonlyMap< string, ReadonlyArray > = new Map( - definitions.flatMap((definition) => { + sourceControlClients.definitions.flatMap((definition) => { const fields = definition.settings ? secretSettingsFields(definition.settings) : []; return fields.length > 0 ? [[definition.kind, fields] as const] : []; }), diff --git a/apps/server/src/sourceControl/threadPullRequests.ts b/apps/server/src/sourceControl/threadPullRequests.ts new file mode 100644 index 000000000000..148fc92ac9e2 --- /dev/null +++ b/apps/server/src/sourceControl/threadPullRequests.ts @@ -0,0 +1,15 @@ +import { makeThreadPullRequests } from "@t3tools/source-control-core/client/threadPullRequests"; + +import { sourceControlClients } from "./sourceControlClients.ts"; + +export { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; + +export const { + normalizeThreadPullRequestKey, + legacyThreadPullRequestKey, + threadPullRequestKeysEqual, + threadPullRequestKeyOf, + resolveThreadPullRequestChains, + threadPullRequestsOf, + applyThreadPullRequestLinkUpdate, +} = makeThreadPullRequests(sourceControlClients); diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 84cb807f45fb..cb3df202217f 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -1,7 +1,7 @@ import { ThreadDetailsControl } from "./chat/ThreadDetailsControl"; import { ComposerContextLabel } from "./ComposerContextLabel"; import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; -import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests"; +import { resolveThreadCurrentPullRequestLink } from "@t3tools/client-runtime/thread-pull-requests"; import { useRightPanelStore } from "../rightPanelStore"; import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { diff --git a/apps/web/src/components/ChatMarkdown.permissions.browser.test.tsx b/apps/web/src/components/ChatMarkdown.permissions.browser.test.tsx index de274fdd433b..2ea19680499b 100644 --- a/apps/web/src/components/ChatMarkdown.permissions.browser.test.tsx +++ b/apps/web/src/components/ChatMarkdown.permissions.browser.test.tsx @@ -95,20 +95,10 @@ vi.mock(import("../localApi"), async (original) => ({ shell: { openExternal: state.openExternal }, }) as never, })); -vi.mock(import("~/lib/openPullRequestLink"), async (original) => ({ +vi.mock(import("@t3tools/client-runtime/change-request-links"), async (original) => ({ ...(await original()), resolvePullRequestPreviewTarget: () => null, findProjectForChangeRequest: ((projects: readonly { id: ProjectId }[]) => projects[0]) as never, - matchesLinkedPullRequestUrl: (candidate: ThreadLinkedPullRequest, href: string) => - candidate.url === href, - parseChangeRequestUrl: ((href: string) => - href === linkedPullRequest.url - ? { - host: "github.com", - repository: linkedPullRequest.repository, - number: linkedPullRequest.number, - } - : null) as never, })); import ChatMarkdown from "./ChatMarkdown"; diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 91acb81d1730..e46e3092d6b0 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -167,12 +167,9 @@ import { stripRepeatedWorkspacePrefix, WORKSPACE_BASENAME_LOOKUP_LIMIT, } from "@t3tools/client-runtime/workspace-basename-lookup"; -import { - parseChangeRequestUrl, - pullRequestCandidateUrlFromReferenceAutolink, - resolvePullRequestPreviewTarget, - useOpenChangeRequestLink, -} from "~/lib/openPullRequestLink"; +import { resolvePullRequestPreviewTarget } from "@t3tools/client-runtime/change-request-links"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; +import { useOpenChangeRequestLink } from "~/lib/openPullRequestLink"; import { useOpenLink } from "../browser/useOpenLink"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { isPreviewAvailableFor } from "../browser/previewRuntime"; @@ -679,14 +676,14 @@ function useChatMarkdownState({ !pullRequestLinking.canLink(href) ) return null; - const parsed = parseChangeRequestUrl(href); - return parsed === null ? null : { ...parsed, url: href }; + const parsed = sourceControlClients.parseChangeRequestUrl(href)?.link; + return parsed === undefined ? null : { ...parsed, url: href }; }; const linkedThreadPullRequestFor = (href: string) => { if (threadRef === undefined || !pullRequestLinking.isLinked(readThreadShell(threadRef), href)) return null; - const parsed = parseChangeRequestUrl(href); - return parsed === null ? null : { ...parsed, url: href }; + const parsed = sourceControlClients.parseChangeRequestUrl(href)?.link; + return parsed === undefined ? null : { ...parsed, url: href }; }; const updateThreadPullRequestLink = async (href: string, linked: boolean) => { if (threadRef === undefined || (!linked && linkedThreadPullRequestFor(href) === null)) return; @@ -1109,7 +1106,9 @@ const CHAT_MARKDOWN_COMPONENTS = { const isPullRequestAutolink = pullRequestCopy !== undefined; const confirmBeforeOpen = pullRequestAutolink === "reference"; const pullRequestCandidateUrl = - confirmBeforeOpen && href ? pullRequestCandidateUrlFromReferenceAutolink(href) : href; + confirmBeforeOpen && href + ? sourceControlClients.changeRequestUrlFromReferenceAutolink(href) + : href; const pullRequestPreviewTarget = pullRequestCandidateUrl ? resolvePullRequestPreviewTarget({ environmentId, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 62223ba82096..ec4fe2a5511d 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -102,7 +102,7 @@ import { formatModelSlugName, resolveSelectableModel } from "@t3tools/shared/mod import { projectScriptCwd, resolveProjectScripts } from "@t3tools/shared/projectScripts"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; -import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference"; +import { resolveThreadReferenceCopyTarget } from "@t3tools/client-runtime/thread-reference"; import { resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; import { useAtomValue } from "@effect/atom-react"; import { type SetStateAction, useEffect, useLayoutEffect, useRef, useState } from "react"; diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 64b2b4edf7c9..53a39007574e 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -22,7 +22,7 @@ import { } from "@t3tools/client-runtime/operations/projects"; import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { connectionStatusText } from "@t3tools/client-runtime/connection"; -import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference"; +import { resolveThreadReferenceCopyTarget } from "@t3tools/client-runtime/thread-reference"; import { canPreloadBrowsePath, createBrowseNavigationCoordinator, diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 4072f3aad541..d9bf14748258 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -1,5 +1,5 @@ import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; -import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests"; +import { resolveThreadCurrentPullRequestLink } from "@t3tools/client-runtime/thread-pull-requests"; import { Spinner } from "~/components/ui/spinner"; import { ArchiveIcon, diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 4c1059eaeeb4..c4577f64fe51 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -4,7 +4,7 @@ import { useProjects, useServerConfigs, useThreadShells } from "~/state/entities import { threadPullRequestKeysEqual, visibleThreadPullRequests, -} from "@t3tools/shared/threadPullRequests"; +} from "@t3tools/client-runtime/thread-pull-requests"; import type { ContextMenuItem, EnvironmentId, diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 00710286a6bd..7a66512ccadc 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -27,13 +27,12 @@ import { resolveThreadPullRequestChains, type ThreadPullRequestBadge, visibleThreadPullRequests, -} from "@t3tools/shared/threadPullRequests"; +} from "@t3tools/client-runtime/thread-pull-requests"; import { useRender } from "@base-ui/react/use-render"; import { type ReactNode, type AnimationEvent, type MouseEvent, type ReactElement } from "react"; import { cn } from "../lib/utils"; import { revealThreadTerminal } from "../lib/revealThreadTerminal"; -import { parseChangeRequestUrl } from "../lib/openPullRequestLink"; import { useEnvironmentQuery } from "../state/query"; import { linkedPullRequestDetailAtom, useSharedPullRequestSummary } from "../state/pullRequests"; import { useThreadRunningTerminalIds } from "../state/terminalSessions"; @@ -120,7 +119,7 @@ export function useLinkedThreadPullRequest( } function linkedPullRequestReference(fallback: ThreadLinkedPullRequest) { - const host = parseChangeRequestUrl(fallback.url)?.host; + const host = sourceControlClients.parseChangeRequestUrl(fallback.url)?.link.host; return { ...fallback, ...(host === undefined ? {} : { host }) }; } @@ -150,7 +149,7 @@ export function linkedPullRequestSnapshotStatus( export { resolveThreadPullRequestBadge, type ThreadPullRequestBadge, -} from "@t3tools/shared/threadPullRequests"; +} from "@t3tools/client-runtime/thread-pull-requests"; export interface ThreadPullRequestBadgePresentation { readonly Icon: PullRequestGlyphIcon; diff --git a/apps/web/src/components/chat/ThreadDetailsPrRows.tsx b/apps/web/src/components/chat/ThreadDetailsPrRows.tsx index 8dfb4623801f..f08251b18425 100644 --- a/apps/web/src/components/chat/ThreadDetailsPrRows.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPrRows.tsx @@ -5,11 +5,12 @@ import { resolveThreadPullRequestChains, threadPullRequestKeyOf, visibleThreadPullRequests, -} from "@t3tools/shared/threadPullRequests"; +} from "@t3tools/client-runtime/thread-pull-requests"; import { Minus, Plus } from "lucide"; import { useState, type ComponentProps, type MouseEvent as ReactMouseEvent } from "react"; -import { findProjectOnChangeRequestHost, parseChangeRequestUrl } from "~/lib/openPullRequestLink"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; +import { findProjectOnChangeRequestHost } from "~/lib/openPullRequestLink"; import { useProjects } from "~/state/entities"; import { threadEnvironment } from "~/state/threads"; @@ -34,9 +35,9 @@ function ThreadDetailsPrLinkRow({ onStopWatching?: (() => void) | undefined; }) { const projects = useProjects(); - const parsed = parseChangeRequestUrl(link.url); + const parsed = sourceControlClients.parseChangeRequestUrl(link.url)?.link; const project = - parsed === null + parsed === undefined ? null : (findProjectOnChangeRequestHost( projects.filter((candidate) => candidate.environmentId === environmentId), diff --git a/apps/web/src/components/desktop/DesktopWebLinkCoordinator.tsx b/apps/web/src/components/desktop/DesktopWebLinkCoordinator.tsx index 6e42b7257c3f..8706f374522f 100644 --- a/apps/web/src/components/desktop/DesktopWebLinkCoordinator.tsx +++ b/apps/web/src/components/desktop/DesktopWebLinkCoordinator.tsx @@ -1,7 +1,7 @@ import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; -import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { promptLinkAppPath } from "@t3tools/shared/promptLinks"; import { useRouter } from "@tanstack/react-router"; import * as Effect from "effect/Effect"; @@ -155,7 +155,7 @@ export function DesktopWebLinkCoordinator() { // delivered at launch would miss its project and fall through to the browser. Hold PR // links, and only those, until discovery settles; an ordinary link never waits for it. const hold = - openPullRequestsInApp && parseChangeRequestUrl(url) !== null + openPullRequestsInApp && sourceControlClients.parseChangeRequestUrl(url) !== null ? projectsSettled() : Promise.resolve(); void hold.then(() => { diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 0e3372b81a1a..f715ecef5153 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -1,4 +1,4 @@ -import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; +import { projectChangeRequestUrl } from "@t3tools/client-runtime/change-request-links"; import { useAtomValue } from "@effect/atom-react"; import { usePullRequestStack } from "~/state/usePullRequestStack"; import { @@ -53,7 +53,6 @@ import { resolveShortcutCommand, type ShortcutMatchContext } from "~/keybindings import { primaryServerKeybindingsAtom } from "~/state/server"; import { usePullRequestDefaultMergeMethodResolver } from "./usePullRequestActions"; import { OFF_HEAD_PULL_REQUEST_CHECKOUT_TOAST } from "./pullRequestCheckoutToast"; -import { changeRequestRepositoryUrl, gitHubPullRequestBrowserUrl } from "~/lib/openPullRequestLink"; import { usePreparePullRequestThreadAction } from "~/lib/sourceControlActions"; import { cn } from "~/lib/utils"; import { readLocalApi } from "~/localApi"; @@ -252,7 +251,10 @@ function pullRequestStackReference( ) { return detail === null || detail.capabilities.stacks !== true || !supportsThreadPullRequests ? null - : { ...reference, host: reference.host ?? parseChangeRequestUrl(detail.url)?.host }; + : { + ...reference, + host: reference.host ?? sourceControlClients.parseChangeRequestUrl(detail.url)?.link.host, + }; } export function PullRequestDetailPanel({ @@ -305,7 +307,7 @@ export function PullRequestDetailPanel({ supportsThreadPullRequests, ); const loadingCheckoutCommand = loadingPullRequestCheckoutCommand(reference, repositoryIdentity); - const unavailableGitHubUrl = gitHubPullRequestBrowserUrl( + const unavailableHostUrl = projectChangeRequestUrl( repositoryIdentity, reference.repository, reference.number, @@ -469,7 +471,10 @@ export function PullRequestDetailPanel({ setSeenAutoMergeKey(autoMergeKey); if (detail?.autoMergeMethod !== undefined) setMergeMethod(detail.autoMergeMethod); } - const repositoryUrl = detail === null ? null : changeRequestRepositoryUrl(detail.url); + const repositoryUrl = + detail === null + ? null + : (sourceControlClients.parseChangeRequestUrl(detail.url)?.link.repositoryUrl ?? null); const hostClient = sourceControlClients.get(detail?.provider); const openOnHostText = openOnHostLabel(hostClient); const markdownContext = pullRequestMarkdownContext(hostClient, repositoryUrl, threadRef); @@ -483,7 +488,7 @@ export function PullRequestDetailPanel({ handoffSummary.number, handoffSummary.headBranch, detail?.headRepositoryNameWithOwner, - changeRequestRepositoryUrl(handoffSummary.url), + sourceControlClients.parseChangeRequestUrl(handoffSummary.url)?.link.repositoryUrl ?? null, ) : loadingCheckoutCommand; const branchRefsQuery = useEnvironmentQuery( @@ -1695,7 +1700,16 @@ export function PullRequestDetailPanel({ : { error: detailQuery.error })} refreshing={detailQuery.isPending} onRetry={refreshDetail} - {...(unavailableGitHubUrl ? { gitHubUrl: unavailableGitHubUrl } : {})} + {...(unavailableHostUrl + ? { + host: { + url: unavailableHostUrl, + openLabel: openOnHostLabel( + sourceControlClients.get(repositoryIdentity?.provider), + ), + }, + } + : {})} /> ) : detail ? ( diff --git a/apps/web/src/components/pullRequest/PullRequestThreadLinks.tsx b/apps/web/src/components/pullRequest/PullRequestThreadLinks.tsx index f9dff552a95c..6b70218b91da 100644 --- a/apps/web/src/components/pullRequest/PullRequestThreadLinks.tsx +++ b/apps/web/src/components/pullRequest/PullRequestThreadLinks.tsx @@ -7,8 +7,8 @@ import { useState } from "react"; import { threadPullRequestLinkMode } from "@t3tools/client-runtime/thread-pull-request-compatibility"; import { usePullRequestLinking } from "~/hooks/usePullRequestLinking"; -import { parseChangeRequestUrl } from "~/lib/openPullRequestLink"; -import { normalizeThreadPullRequestKey } from "@t3tools/shared/threadPullRequests"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; +import { normalizeThreadPullRequestKey } from "@t3tools/client-runtime/thread-pull-requests"; import { useProjects, useServerConfigs, useThreadShell, useThreadShells } from "~/state/entities"; import { pullRequestEnvironment } from "~/state/pullRequests"; import { useEnvironmentQuery } from "~/state/query"; @@ -49,7 +49,7 @@ function EnabledPullRequestThreadLinks({ display, onPickerOpenChange, }: PullRequestThreadLinksProps) { - const parsed = parseChangeRequestUrl(url); + const parsed = sourceControlClients.parseChangeRequestUrl(url)?.link ?? null; const thread = useThreadShell(threadRef?.environmentId === environmentId ? threadRef : null); const currentThreadRef = thread === null ? null : threadRef; const linking = usePullRequestLinking(environmentId); diff --git a/apps/web/src/components/pullRequest/PullRequestsUnavailableState.tsx b/apps/web/src/components/pullRequest/PullRequestsUnavailableState.tsx index 6c84b59fecf9..b207e5a048c9 100644 --- a/apps/web/src/components/pullRequest/PullRequestsUnavailableState.tsx +++ b/apps/web/src/components/pullRequest/PullRequestsUnavailableState.tsx @@ -10,13 +10,13 @@ export function PullRequestsUnavailableState({ error, onRetry, refreshing = false, - gitHubUrl, + host, }: { title?: string; error: string; onRetry?: () => void; refreshing?: boolean; - gitHubUrl?: string; + host?: { readonly url: string; readonly openLabel: string }; }) { return ( @@ -29,7 +29,7 @@ export function PullRequestsUnavailableState({ shows its message rather than trying to infer one from the failure text. */} {error} - {onRetry || gitHubUrl ? ( + {onRetry || host ? (
{onRetry ? ( ) : null}
diff --git a/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx b/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx index f52e5b7c4312..41455322e6ca 100644 --- a/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx +++ b/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx @@ -8,7 +8,7 @@ import { pullRequestListLines, resolveThreadPullRequestChains, visibleThreadPullRequests, -} from "@t3tools/shared/threadPullRequests"; +} from "@t3tools/client-runtime/thread-pull-requests"; import { ArrowUpRightIcon, EyeIcon, @@ -25,7 +25,8 @@ import { encodePullRequestDragPayload, } from "~/components/chat/composerMentionDrag"; import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; -import { findProjectForChangeRequest, useOpenPrLink } from "~/lib/openPullRequestLink"; +import { findProjectForChangeRequest } from "@t3tools/client-runtime/change-request-links"; +import { useOpenPrLink } from "~/lib/openPullRequestLink"; import { cn } from "~/lib/utils"; import { useShortcutModifierState } from "~/shortcutModifierState"; import { useProjects, useServerConfigs, useThreadShell } from "~/state/entities"; diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts index ede70552a7c6..dd29162fbde4 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts @@ -34,7 +34,7 @@ import { resolveThreadCurrentPullRequestLink, threadPullRequestKeysEqual, visibleThreadPullRequests, -} from "@t3tools/shared/threadPullRequests"; +} from "@t3tools/client-runtime/thread-pull-requests"; import { inferReviewCommentFenceLanguage, type ReviewCommentContext } from "~/reviewCommentContext"; import { reviewCommentContextId } from "~/lib/composerContextRecords"; @@ -1409,16 +1409,10 @@ export function resolveDisplayedPullRequestDetail(input: { return null; } if (input.reference.host === undefined) return input.cached; - try { - const url = new URL(input.cached.url); - const host = sourceControlClients.get(input.cached.provider).changeRequestUrlHost(url); - return (url.protocol === "https:" || url.protocol === "http:") && - host.toLowerCase() === input.reference.host.toLowerCase() - ? input.cached - : null; - } catch { - return null; - } + const link = sourceControlClients.parseChangeRequestUrl(input.cached.url)?.link; + return link !== undefined && (link.authority ?? link.host) === input.reference.host.toLowerCase() + ? input.cached + : null; } export function resolvePullRequestDetailChecks( diff --git a/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx index cfab5ca8b0c1..51d913f61b99 100644 --- a/apps/web/src/components/sidebar/SidebarThreadRow.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx @@ -3,7 +3,7 @@ import { ThreadTerminalStatusButton } from "../ThreadStatusIndicators"; import { discardComposerDraft } from "../../lib/discardComposerDraft"; import { requestCustomSnooze } from "../CustomSnoozeDialog"; import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; -import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests"; +import { resolveThreadCurrentPullRequestLink } from "@t3tools/client-runtime/thread-pull-requests"; import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { diff --git a/apps/web/src/hooks/useOpenPanelPullRequestUrl.ts b/apps/web/src/hooks/useOpenPanelPullRequestUrl.ts index 7edbd93a0e0c..255452cb2dd5 100644 --- a/apps/web/src/hooks/useOpenPanelPullRequestUrl.ts +++ b/apps/web/src/hooks/useOpenPanelPullRequestUrl.ts @@ -5,7 +5,7 @@ import { readPullRequestDetailSnapshot, resolvePullRequestReferenceHost, } from "../components/pullRequest/pullRequestDetail.logic"; -import { gitHubPullRequestBrowserUrl } from "../lib/openPullRequestLink"; +import { projectChangeRequestUrl } from "@t3tools/client-runtime/change-request-links"; import { selectActiveRightPanelSurface, useRightPanelStore } from "../rightPanelStore"; import { useProject } from "../state/entities"; import { pullRequestEnvironment } from "../state/pullRequests"; @@ -71,11 +71,7 @@ export function useOpenPanelPullRequestUrl(threadRef: ScopedThreadRef | null) { : null; const fallbackUrl = reference ? (requestedReference?.url ?? - gitHubPullRequestBrowserUrl( - project?.repositoryIdentity, - reference.repository, - reference.number, - )) + projectChangeRequestUrl(project?.repositoryIdentity, reference.repository, reference.number)) : undefined; const detail = useEnvironmentQuery( reference && environmentId diff --git a/apps/web/src/hooks/usePullRequestLinking.test.ts b/apps/web/src/hooks/usePullRequestLinking.test.ts index 5cb7138f1d13..107cb0f56afd 100644 --- a/apps/web/src/hooks/usePullRequestLinking.test.ts +++ b/apps/web/src/hooks/usePullRequestLinking.test.ts @@ -1,11 +1,13 @@ import { describe, expect, it } from "vite-plus/test"; import { canLinkChangeRequest } from "./usePullRequestLinking"; -import { parseChangeRequestUrl } from "~/lib/openPullRequestLink"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; describe("canLinkChangeRequest (#9435 / #9440)", () => { const nonGitProject = { id: "scratch-project" } as never; - const link = parseChangeRequestUrl("https://github.com/pingdotgg/t3code/pull/15111")!; + const link = sourceControlClients.parseChangeRequestUrl( + "https://github.com/pingdotgg/t3code/pull/15111", + )!.link; it("links a pasted pull request URL when the server tracks links on the thread, even with no matching project", () => { expect(canLinkChangeRequest("multiple", [nonGitProject], link)).toBe(true); diff --git a/apps/web/src/hooks/usePullRequestLinking.ts b/apps/web/src/hooks/usePullRequestLinking.ts index a9ee2163a8be..18c31cd3714d 100644 --- a/apps/web/src/hooks/usePullRequestLinking.ts +++ b/apps/web/src/hooks/usePullRequestLinking.ts @@ -17,13 +17,12 @@ import { import { threadPullRequestKeysEqual, visibleThreadPullRequests, -} from "@t3tools/shared/threadPullRequests"; +} from "@t3tools/client-runtime/thread-pull-requests"; +import { findProjectForChangeRequest } from "@t3tools/client-runtime/change-request-links"; import { - findProjectForChangeRequest, - matchesLinkedPullRequestUrl, - parseChangeRequestUrl, + sourceControlClients, type ChangeRequestLink, -} from "~/lib/openPullRequestLink"; +} from "@t3tools/client-runtime/source-control-clients"; import { useAtomValue } from "@effect/atom-react"; import { Atom } from "effect/reactivity"; import { appAtomRegistry } from "~/rpc/atomRegistry"; @@ -62,8 +61,8 @@ export function usePullRequestLinking(environmentId: EnvironmentId | null | unde const updateMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false }); const environmentProjects = projects.filter((project) => project.environmentId === environmentId); const canLink = (url: string) => { - const parsed = parseChangeRequestUrl(url); - return parsed !== null && canLinkChangeRequest(mode, environmentProjects, parsed); + const parsed = sourceControlClients.parseChangeRequestUrl(url)?.link; + return parsed !== undefined && canLinkChangeRequest(mode, environmentProjects, parsed); }; const isLinked = ( thread: { @@ -76,19 +75,23 @@ export function usePullRequestLinking(environmentId: EnvironmentId | null | unde if (mode !== "multiple") return ( thread.linkedPullRequest != null && - matchesLinkedPullRequestUrl(thread.linkedPullRequest, url) + sourceControlClients.isSameChangeRequest(thread.linkedPullRequest.url, url) ); - const parsed = parseChangeRequestUrl(url); + const parsed = sourceControlClients.parseChangeRequestUrl(url)?.link; return ( - parsed !== null && + parsed !== undefined && visibleThreadPullRequests(thread.pullRequests ?? []).some((entry) => threadPullRequestKeysEqual(entry, parsed), ) ); }; const changeLink = async (threadRef: ScopedThreadRef, url: string, linked: boolean) => { - const parsed = parseChangeRequestUrl(url); - if (parsed === null || threadRef.environmentId !== environmentId || (linked && !canLink(url))) + const parsed = sourceControlClients.parseChangeRequestUrl(url)?.link; + if ( + parsed === undefined || + threadRef.environmentId !== environmentId || + (linked && !canLink(url)) + ) throw new Error("The pull request is not available in this environment."); const legacyProject = findProjectForChangeRequest(environmentProjects, parsed); const mutation = planThreadPullRequestMutation({ diff --git a/apps/web/src/lib/openPullRequestLink.test.ts b/apps/web/src/lib/openPullRequestLink.test.ts index 7489d60006c3..4b0de9244c54 100644 --- a/apps/web/src/lib/openPullRequestLink.test.ts +++ b/apps/web/src/lib/openPullRequestLink.test.ts @@ -1,190 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { - changeRequestRepositoryUrl, - findProjectForChangeRequest, - findProjectOnChangeRequestHost, - gitHubPullRequestBrowserUrl, - matchesLinkedPullRequestUrl, - parseChangeRequestUrl, - pullRequestCandidateUrlFromReferenceAutolink, -} from "./openPullRequestLink"; -import { ProjectId, type RepositoryIdentity } from "@t3tools/contracts"; - -function repositoryIdentity( - provider: string, - canonicalKey: string, - remoteUrl: string, -): RepositoryIdentity { - return { - canonicalKey, - provider, - locator: { source: "git-remote", remoteName: "origin", remoteUrl }, - }; -} - -describe("gitHubPullRequestBrowserUrl", () => { - it("uses the requested GitHub repository instead of the project's default repository", () => { - const identity = repositoryIdentity( - "github", - "github.com/acme/default", - "https://github.com/acme/default.git", - ); - - expect(gitHubPullRequestBrowserUrl(identity, "acme/other", 42)).toBe( - "https://github.com/acme/other/pull/42", - ); - }); - - it("preserves a custom GitHub HTTP origin without its credentials", () => { - const identity = repositoryIdentity( - "github", - "github.acme.test/team/default", - "http://token@github.acme.test:8443/team/default.git", - ); - - expect(gitHubPullRequestBrowserUrl(identity, "platform/api", 7)).toBe( - "http://github.acme.test:8443/platform/api/pull/7", - ); - }); - - it.each([ - { - name: "SSH", - remoteUrl: "git@github.acme.test:team/default.git", - }, - { - name: "git protocol", - remoteUrl: "git://github.acme.test/team/default.git", - }, - ])("uses the normalized host for a $name remote", ({ remoteUrl }) => { - const identity = repositoryIdentity("github", "github.acme.test/team/default", remoteUrl); - - expect(gitHubPullRequestBrowserUrl(identity, "platform/api", 9)).toBe( - "https://github.acme.test/platform/api/pull/9", - ); - }); - - // regression: #8507 the browser fallback must not open a bogus URL - it("returns null for missing or invalid GitHub data", () => { - expect(gitHubPullRequestBrowserUrl(null, "acme/repository", 1)).toBeNull(); - expect( - gitHubPullRequestBrowserUrl( - repositoryIdentity("github", "github.com/acme/repository", "https://github.com/a/b"), - "acme", - 1, - ), - ).toBeNull(); - expect( - gitHubPullRequestBrowserUrl( - repositoryIdentity("github", "github.com/acme/repository", "https://github.com/a/b"), - "../repository", - 1, - ), - ).toBeNull(); - expect( - gitHubPullRequestBrowserUrl( - repositoryIdentity("github", "github.com/acme/repository", "https://github.com/a/b"), - "acme/repository", - 0, - ), - ).toBeNull(); - expect( - gitHubPullRequestBrowserUrl( - repositoryIdentity("github", "bad host/acme/repository", "not a remote"), - "acme/repository", - 1, - ), - ).toBeNull(); - }); - - // regression: #8507 the browser fallback for failed PR loads must only target GitHub - it.each(["gitlab", "bitbucket", "azure-devops", "unknown"])( - "does not build a fallback for %s", - (provider) => { - expect( - gitHubPullRequestBrowserUrl( - repositoryIdentity(provider, "github.com/acme/repository", "https://github.com/a/b"), - "acme/repository", - 1, - ), - ).toBeNull(); - }, - ); -}); - -describe("changeRequestRepositoryUrl", () => { - it("keeps pull-like segments inside nested GitLab repository paths", () => { - expect( - changeRequestRepositoryUrl( - "https://gitlab.example.test/group/pull/123/repo/-/merge_requests/42", - ), - ).toBe("https://gitlab.example.test/group/pull/123/repo"); - }); -}); - -describe("pullRequestCandidateUrlFromReferenceAutolink", () => { - it("turns GitHub's shared issue route into a pull request candidate", () => { - expect( - pullRequestCandidateUrlFromReferenceAutolink( - "https://github.com/pingdotgg/t3code/issues/8600#issuecomment-1", - ), - ).toBe("https://github.com/pingdotgg/t3code/pull/8600#issuecomment-1"); - }); - - it("does not reinterpret other issue hosts or malformed references", () => { - expect( - pullRequestCandidateUrlFromReferenceAutolink( - "https://gitlab.com/pingdotgg/t3code/-/issues/8600", - ), - ).toBeNull(); - expect( - pullRequestCandidateUrlFromReferenceAutolink( - "https://github.com/pingdotgg/t3code/issues/not-a-number", - ), - ).toBeNull(); - }); -}); - -describe("matchesLinkedPullRequestUrl", () => { - const linkedPullRequest = { - projectId: ProjectId.make("project-1"), - repository: "pingdotgg/t3code", - number: 42, - url: "https://github.com/pingdotgg/t3code/pull/42", - }; - - it("matches the same pull request without looking up its project", () => { - expect( - matchesLinkedPullRequestUrl( - linkedPullRequest, - "https://github.com/PingDotGG/T3Code/pull/42/files", - ), - ).toBe(true); - }); - - it.each([ - ["http://forge.example:3000/git/team/repo/pulls/42/files", true], - ["http://forge.example:4000/git/team/repo/pulls/42", false], - ["http://forge.example/git/team/repo/pulls/42", false], - ])("matches Forgejo links by web authority: %s", (url, expected) => { - expect( - matchesLinkedPullRequestUrl( - { ...linkedPullRequest, url: "http://forge.example:3000/git/team/repo/pulls/42" }, - url, - ), - ).toBe(expected); - }); - - it("keeps Forgejo and GitHub path shapes distinct on a GitHub-named host", () => { - expect( - matchesLinkedPullRequestUrl( - { ...linkedPullRequest, url: "https://github.internal/team/repo/pulls/42" }, - "https://github.internal/team/repo/pull/42", - ), - ).toBe(false); - }); -}); +import { findProjectOnChangeRequestHost } from "./openPullRequestLink"; describe("findProjectOnChangeRequestHost", () => { const project = (id: string, identity: Record) => @@ -202,41 +18,6 @@ describe("findProjectOnChangeRequestHost", () => { name: "backend", }); - it.each([ - ["ssh.dev.azure.com/v3/org-a/project/web", "git@ssh.dev.azure.com:v3/org-a/project/web"], - [ - "vs-ssh.visualstudio.com/v3/org-a/project/web", - "git@vs-ssh.visualstudio.com:v3/org-a/project/web", - ], - [ - "org-a.visualstudio.com/defaultcollection/project/_git/web", - "https://org-a.visualstudio.com/DefaultCollection/Project/_git/Web", - ], - ["dev.azure.com/org-a/project/_git/web", "https://org-a@dev.azure.com/org-a/Project/_git/Web"], - ])("matches Azure browser references against %s", (canonicalKey, remoteUrl) => { - const checkout = project("azure", { - ...repositoryIdentity("azure-devops", canonicalKey, remoteUrl), - displayName: canonicalKey.split("/").slice(1).join("/"), - }); - const reference = parseChangeRequestUrl( - "https://dev.azure.com/org-a/project/_git/web/pullrequest/42", - )!; - expect(findProjectForChangeRequest([checkout], reference)).toBe(checkout); - expect(findProjectOnChangeRequestHost([checkout], reference)).toBe(checkout); - expect( - findProjectOnChangeRequestHost([checkout], { - ...reference, - repository: "org-b/project/_git/web", - }), - ).toBeUndefined(); - expect( - findProjectOnChangeRequestHost([checkout], { - ...reference, - repository: "org-a/other-project/_git/web", - }), - ).toBeUndefined(); - }); - it("prefers the project checked out from the link's own repository", () => { expect( findProjectOnChangeRequestHost([frontend, backend], { @@ -247,59 +28,6 @@ describe("findProjectOnChangeRequestHost", () => { ).toBe(backend); }); - it("selects the Forgejo HTTP port for repository and host-level matches", () => { - const projects = [3000, 4000].map((port) => - project(`forgejo-${port}`, { - canonicalKey: "forge.example/git/team/repo", - provider: "forgejo", - displayName: "git/team/repo", - locator: { remoteUrl: `http://forge.example:${port}/git/team/repo.git` }, - }), - ); - const reference = parseChangeRequestUrl("http://forge.example:4000/git/team/repo/pulls/42")!; - expect(findProjectForChangeRequest(projects, reference)).toBe(projects[1]); - expect(findProjectOnChangeRequestHost(projects, reference)).toBe(projects[1]); - expect( - findProjectOnChangeRequestHost(projects, { ...reference, repository: "git/team/other" }), - ).toBe(projects[1]); - expect(findProjectForChangeRequest([projects[0]!], reference)).toBeUndefined(); - expect(findProjectOnChangeRequestHost([projects[0]!], reference)).toBeUndefined(); - }); - - it("lets tea resolve the web port for Forgejo SSH remotes", () => { - const checkout = project("forgejo-ssh", { - canonicalKey: "forge.example/git/team/repo", - provider: "forgejo", - displayName: "git/team/repo", - locator: { remoteUrl: "git@forge.example:git/team/repo.git" }, - }); - const reference = parseChangeRequestUrl("http://forge.example:4000/git/team/repo/pulls/42")!; - expect(findProjectForChangeRequest([checkout], reference)).toBe(checkout); - const aliased = project("forgejo-alias", { - canonicalKey: "ssh.forge.example/team/repo", - provider: "forgejo", - displayName: "team/repo", - locator: { remoteUrl: "git@ssh.forge.example:team/repo.git" }, - webUrl: "http://forge.example:4000/git/team/repo", - }); - expect(findProjectForChangeRequest([aliased], reference)).toBe(aliased); - expect(findProjectOnChangeRequestHost([aliased], reference)).toBe(aliased); - expect( - findProjectOnChangeRequestHost([aliased], { ...reference, repository: "git/team/other" }), - ).toBe(aliased); - for (const url of [ - "http://other.example:4000/git/team/repo/pulls/42", - "http://forge.example:3000/git/team/repo/pulls/42", - ]) { - const other = parseChangeRequestUrl(url)!; - expect(findProjectForChangeRequest([aliased], other)).toBeUndefined(); - expect(findProjectOnChangeRequestHost([aliased], other)).toBeUndefined(); - } - const otherMount = parseChangeRequestUrl("http://forge.example:4000/other/team/repo/pulls/42")!; - expect(findProjectForChangeRequest([aliased], otherMount)).toBeUndefined(); - expect(findProjectOnChangeRequestHost([aliased], otherMount)).toBeUndefined(); - }); - it("lends any project on the host to a repository nobody has checked out", () => { expect( findProjectOnChangeRequestHost([frontend], { @@ -310,45 +38,3 @@ describe("findProjectOnChangeRequestHost", () => { ).toBe(frontend); }); }); - -describe("findProjectForChangeRequest", () => { - const project = (identity: Record) => - ({ id: "p1", repositoryIdentity: identity }) as never; - - it("matches a nested GitLab group by the whole path below the host", () => { - const projects = [ - project({ - canonicalKey: "gitlab.com/t3tools/platform/t3code", - provider: "gitlab", - displayName: "t3tools/platform/t3code", - owner: "t3tools", - name: "t3code", - }), - ]; - expect( - findProjectForChangeRequest(projects, { - host: "gitlab.com", - repository: "t3tools/platform/t3code", - number: 42, - }), - ).toBe(projects[0]); - }); - - it("claims nothing for a lookalike host, which is what keeps a link a link", () => { - const projects = [ - project({ - canonicalKey: "github.com/pingdotgg/t3code", - provider: "github", - owner: "pingdotgg", - name: "t3code", - }), - ]; - expect( - findProjectForChangeRequest(projects, { - host: "github.com-evil.test", - repository: "pingdotgg/t3code", - number: 1, - }), - ).toBeUndefined(); - }); -}); diff --git a/apps/web/src/lib/openPullRequestLink.ts b/apps/web/src/lib/openPullRequestLink.ts index ce5fb92778b9..0d3361e73de2 100644 --- a/apps/web/src/lib/openPullRequestLink.ts +++ b/apps/web/src/lib/openPullRequestLink.ts @@ -1,10 +1,16 @@ -import type { EnvironmentId, PullRequestRef, ScopedThreadRef } from "@t3tools/contracts"; +import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import { useNavigate } from "@tanstack/react-router"; import { type MouseEvent } from "react"; -import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; -import { parseChangeRequestUrl, type ChangeRequestLink } from "@t3tools/shared/changeRequestUrl"; +import { + findProjectForChangeRequest, + resolvePullRequestPreviewTarget, +} from "@t3tools/client-runtime/change-request-links"; +import { + sourceControlClients, + type ChangeRequestLink, +} from "@t3tools/client-runtime/source-control-clients"; import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; import { useOpenLink } from "../browser/useOpenLink"; @@ -18,58 +24,6 @@ import { readProjects, useProjects } from "../state/entities"; import { environmentServerConfigsAtom, serverEnvironment } from "../state/server"; import { usePrimaryEnvironmentId } from "../state/environments"; -export { - parseChangeRequestUrl, - type ChangeRequestLink, - gitHubPullRequestBrowserUrl, - pullRequestCandidateUrlFromReferenceAutolink, - matchesLinkedPullRequestUrl, - changeRequestRepositoryUrl, -} from "@t3tools/shared/changeRequestUrl"; - -export function findProjectForChangeRequest( - projects: ReadonlyArray, - link: ChangeRequestLink, -): EnvironmentProject | undefined { - return projects.find((project) => { - const identity = project.repositoryIdentity; - return ( - identity?.provider !== undefined && - sourceControlClients.get(identity.provider).isChangeRequestInRepository(identity, link) - ); - }); -} - -export function resolvePullRequestPreviewTarget({ - environmentId, - projects, - pullRequestsEnabled, - url, -}: { - environmentId: EnvironmentId | null; - projects: ReadonlyArray; - pullRequestsEnabled: boolean; - url: string; -}): { environmentId: EnvironmentId; input: PullRequestRef } | null { - if (!pullRequestsEnabled || environmentId === null) return null; - const parsed = parseChangeRequestUrl(url); - if (parsed === null) return null; - const project = findProjectForChangeRequest( - projects.filter((candidate) => candidate.environmentId === environmentId), - parsed, - ); - if (project === undefined) return null; - return { - environmentId, - input: { - projectId: project.id, - host: parsed.authority ?? parsed.host, - repository: sourceControlRepositorySelector(project.repositoryIdentity) ?? parsed.repository, - number: parsed.number, - }, - }; -} - export function usePullRequestPreviewTarget(environmentId: EnvironmentId | null, url: string) { const projects = useProjects(); const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); @@ -96,7 +50,7 @@ export function findProjectOnChangeRequestHost( }); } -export function shouldOpenPullRequestExternally( +function shouldOpenPullRequestExternally( event: Pick, "metaKey" | "ctrlKey"> & Partial, "button">>, ): boolean { @@ -127,8 +81,8 @@ export function useOpenChangeRequestLink( const allProjects = readProjects(); const resolvedThreadRef = targetThreadRef ?? threadRef; const resolvedPanelRef = panelRef ?? resolvedThreadRef; - const parsed = parseChangeRequestUrl(targetUrl); - if (parsed === null) return false; + const parsed = sourceControlClients.parseChangeRequestUrl(targetUrl)?.link; + if (parsed === undefined) return false; const reads = (environmentId: string) => serverConfigs.get(environmentId as EnvironmentId)?.environment.capabilities.pullRequests === true; diff --git a/apps/web/src/state/pullRequests.ts b/apps/web/src/state/pullRequests.ts index 8757c4b6caf0..caf610184557 100644 --- a/apps/web/src/state/pullRequests.ts +++ b/apps/web/src/state/pullRequests.ts @@ -19,7 +19,7 @@ import type { import { threadPullRequestKeyOf, visibleThreadPullRequests, -} from "@t3tools/shared/threadPullRequests"; +} from "@t3tools/client-runtime/thread-pull-requests"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/reactivity"; import { useLayoutEffect } from "react"; diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 7bec2b2c0831..81a91d6057aa 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -367,6 +367,18 @@ "types": "./src/pullRequestLinkInput.ts", "default": "./src/pullRequestLinkInput.ts" }, + "./change-request-links": { + "types": "./src/changeRequestLinks.ts", + "default": "./src/changeRequestLinks.ts" + }, + "./thread-pull-requests": { + "types": "./src/threadPullRequests.ts", + "default": "./src/threadPullRequests.ts" + }, + "./thread-reference": { + "types": "./src/threadReference.ts", + "default": "./src/threadReference.ts" + }, "./thread-pull-request-compatibility": { "types": "./src/threadPullRequestCompatibility.ts", "default": "./src/threadPullRequestCompatibility.ts" diff --git a/packages/client-runtime/src/changeRequestLinks.test.ts b/packages/client-runtime/src/changeRequestLinks.test.ts new file mode 100644 index 000000000000..f5d39305da2e --- /dev/null +++ b/packages/client-runtime/src/changeRequestLinks.test.ts @@ -0,0 +1,104 @@ +import { EnvironmentId, ProjectId, type RepositoryIdentity } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + findProjectForChangeRequest, + projectChangeRequestUrl, + resolvePullRequestPreviewTarget, +} from "./changeRequestLinks.ts"; + +describe("findProjectForChangeRequest", () => { + const project = (identity: Record) => + ({ id: "p1", repositoryIdentity: identity }) as never; + + it("matches a nested GitLab group by the whole path below the host", () => { + const projects = [ + project({ + canonicalKey: "gitlab.com/t3tools/platform/t3code", + provider: "gitlab", + displayName: "t3tools/platform/t3code", + owner: "t3tools", + name: "t3code", + }), + ]; + expect( + findProjectForChangeRequest(projects, { + host: "gitlab.com", + repository: "t3tools/platform/t3code", + number: 42, + }), + ).toBe(projects[0]); + }); + + it("claims nothing for a lookalike host, which is what keeps a link a link", () => { + const projects = [ + project({ + canonicalKey: "github.com/pingdotgg/t3code", + provider: "github", + owner: "pingdotgg", + name: "t3code", + }), + ]; + expect( + findProjectForChangeRequest(projects, { + host: "github.com-evil.test", + repository: "pingdotgg/t3code", + number: 1, + }), + ).toBeUndefined(); + }); +}); + +describe("resolvePullRequestPreviewTarget", () => { + const environmentId = EnvironmentId.make("env-1"); + const project = { + id: ProjectId.make("project-1"), + environmentId, + repositoryIdentity: { + canonicalKey: "github.com/acme/web", + provider: "github", + displayName: "acme/web", + locator: { source: "git-remote", remoteName: "origin", remoteUrl: "git@github.com:acme/web" }, + } satisfies RepositoryIdentity, + }; + + it("previews a pull request only under the project that holds its repository", () => { + const target = (url: string) => + resolvePullRequestPreviewTarget({ + environmentId, + projects: [project], + pullRequestsEnabled: true, + url, + }); + expect(target("https://github.com/acme/web/pull/7")).toEqual({ + environmentId, + input: { projectId: project.id, host: "github.com", repository: "acme/web", number: 7 }, + }); + expect(target("https://github.com/acme/other/pull/7")).toBeNull(); + }); +}); + +describe("projectChangeRequestUrl", () => { + const identity = (provider: string): RepositoryIdentity => ({ + canonicalKey: "github.com/acme/default", + provider, + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "git@github.com:acme/default", + }, + }); + + it("builds the requested repository's URL on the project's host", () => { + expect(projectChangeRequestUrl(identity("github"), "acme/other", 42)).toBe( + "https://github.com/acme/other/pull/42", + ); + }); + + it("builds nothing without a project host that knows its URL shape", () => { + expect(projectChangeRequestUrl(null, "acme/repository", 1)).toBeNull(); + expect(projectChangeRequestUrl(identity("unknown"), "acme/repository", 1)).toBeNull(); + const { provider: _provider, ...withoutProvider } = identity("github"); + expect(projectChangeRequestUrl(withoutProvider, "acme/repository", 1)).toBeNull(); + }); +}); diff --git a/packages/client-runtime/src/changeRequestLinks.ts b/packages/client-runtime/src/changeRequestLinks.ts new file mode 100644 index 000000000000..36c9097c951e --- /dev/null +++ b/packages/client-runtime/src/changeRequestLinks.ts @@ -0,0 +1,77 @@ +import type { + EnvironmentId, + ProjectId, + PullRequestRef, + RepositoryIdentity, +} from "@t3tools/contracts"; +import type { ChangeRequestLink } from "@t3tools/source-control-core/client/definition"; +import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; + +import { sourceControlClients } from "./sourceControlClients.ts"; + +interface ChangeRequestProject { + readonly id: ProjectId; + readonly environmentId: EnvironmentId; + readonly repositoryIdentity?: RepositoryIdentity | null | undefined; +} + +/** The project whose own repository holds the change request, not just one on its host. */ +export function findProjectForChangeRequest( + projects: ReadonlyArray, + link: ChangeRequestLink, +): Project | undefined { + return projects.find((project) => { + const identity = project.repositoryIdentity; + return ( + identity?.provider !== undefined && + sourceControlClients.get(identity.provider).isChangeRequestInRepository(identity, link) + ); + }); +} + +export function resolvePullRequestPreviewTarget({ + environmentId, + projects, + pullRequestsEnabled, + url, +}: { + environmentId: EnvironmentId | null; + projects: ReadonlyArray; + pullRequestsEnabled: boolean; + url: string; +}): { environmentId: EnvironmentId; input: PullRequestRef } | null { + if (!pullRequestsEnabled || environmentId === null) return null; + const parsed = sourceControlClients.parseChangeRequestUrl(url)?.link; + if (parsed === undefined) return null; + const project = findProjectForChangeRequest( + projects.filter((candidate) => candidate.environmentId === environmentId), + parsed, + ); + if (project === undefined) return null; + return { + environmentId, + input: { + projectId: project.id, + host: parsed.authority ?? parsed.host, + repository: sourceControlRepositorySelector(project.repositoryIdentity) ?? parsed.repository, + number: parsed.number, + }, + }; +} + +/** The project host's browser URL for one of its change requests, before its detail loads. */ +export function projectChangeRequestUrl( + identity: RepositoryIdentity | null | undefined, + repository: string, + number: number, +): string | null { + if (identity?.provider === undefined) return null; + const host = identity.canonicalKey.split("/")[0]; + if (!host) return null; + return sourceControlClients.get(identity.provider).changeRequestUrl({ + host, + repository, + number, + remoteUrl: identity.locator.remoteUrl.trim(), + }); +} diff --git a/packages/client-runtime/src/promptLinks.ts b/packages/client-runtime/src/promptLinks.ts index 4e4c1214180e..175b3aa5cb6f 100644 --- a/packages/client-runtime/src/promptLinks.ts +++ b/packages/client-runtime/src/promptLinks.ts @@ -12,7 +12,7 @@ import { threadPullRequestKeyOf, threadPullRequestsOf, visibleThreadPullRequests, -} from "@t3tools/shared/threadPullRequests"; +} from "./threadPullRequests.ts"; const repositoryKey = (repository: PromptLinkRepository) => `github.com/${repository.owner}/${repository.name}`.toLowerCase(); diff --git a/packages/client-runtime/src/pullRequestLinkInput.ts b/packages/client-runtime/src/pullRequestLinkInput.ts index a72701adc903..c897dc458878 100644 --- a/packages/client-runtime/src/pullRequestLinkInput.ts +++ b/packages/client-runtime/src/pullRequestLinkInput.ts @@ -1,5 +1,4 @@ import { pullRequestHostOf, type RepositoryIdentity } from "@t3tools/contracts"; -import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; import { sourceControlClients } from "./sourceControlClients.ts"; @@ -75,12 +74,12 @@ export function resolveLinkPullRequestInput(input: { readonly hasProject: (reference: ResolvedLink) => boolean; }): { link: ResolvedLink } | { error: string } | null { const parsed = - parseChangeRequestUrl(input.reference.trim()) !== null + sourceControlClients.parseChangeRequestUrl(input.reference.trim()) !== null ? input.reference.trim() : parsePullRequestReference(input.reference); if (parsed === null) return null; - const url = parseChangeRequestUrl(parsed); - if (url !== null) { + const url = sourceControlClients.parseChangeRequestUrl(parsed)?.link; + if (url !== undefined) { if (!input.hasProject({ ...url, url: parsed })) { return { error: `No project in this environment can read ${url.host}/${url.repository}.` }; } @@ -97,11 +96,17 @@ export function resolveLinkPullRequestInput(input: { }; } const webUrl = input.project.webUrl(number); - const webReference = webUrl === null ? null : parseChangeRequestUrl(webUrl); - if (webUrl === null || webReference === null) { + const webReference = + webUrl === null ? undefined : sourceControlClients.parseChangeRequestUrl(webUrl)?.link; + if (webUrl === null || webReference === undefined) { return { error: "Paste a full URL; this project's host has no known pull request URL." }; } return { - link: { ...webReference, url: webUrl }, + link: { + host: webReference.host, + repository: webReference.repository, + number: webReference.number, + url: webUrl, + }, }; } diff --git a/packages/client-runtime/src/sourceControlClients.test.ts b/packages/client-runtime/src/sourceControlClients.test.ts new file mode 100644 index 000000000000..4a646bc9caba --- /dev/null +++ b/packages/client-runtime/src/sourceControlClients.test.ts @@ -0,0 +1,240 @@ +import { ProjectId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { sourceControlClients } from "./sourceControlClients.ts"; +import { normalizeThreadPullRequestKey } from "./threadPullRequests.ts"; + +const parse = (url: string) => { + const parsed = sourceControlClients.parseChangeRequestUrl(url); + if (parsed === null) return null; + const { repositoryUrl: _repositoryUrl, ...link } = parsed.link; + return link; +}; +const kindOf = (url: string) => sourceControlClients.parseChangeRequestUrl(url)?.definition.kind; + +describe("sourceControlClients.parseChangeRequestUrl", () => { + it("reads a GitHub pull request, lower-casing the repository", () => { + expect(parse("https://github.com/T3Tools/T3Code/pull/123")).toEqual({ + host: "github.com", + repository: "t3tools/t3code", + number: 123, + }); + }); + + it("reads a GitCafe pull request at its canonical /pull/ path and the older /pulls/ path", () => { + for (const path of ["pull", "pulls"]) { + const url = `https://git.cafe/PingDotGG/T3Code/${path}/23897`; + expect(parse(url), path).toEqual({ + host: "git.cafe", + repository: "pingdotgg/t3code", + number: 23897, + }); + expect(kindOf(url), path).toBe("gitcafe"); + } + expect(parse("https://staging.git.cafe/team/repo/pull/7/files")).toEqual({ + host: "staging.git.cafe", + repository: "team/repo", + number: 7, + }); + }); + + it("reads Forgejo URLs even when the hostname contains github", () => { + expect(parse("https://github.internal/team/repo/pulls/7")).toEqual({ + host: "github.internal", + authority: "github.internal", + repository: "team/repo", + number: 7, + }); + }); + + it("reads a GitHub pull request on any host, so GitHub Enterprise works on its own name", () => { + expect(parse("https://code.github.example.com/acme/web/pull/42")).toEqual({ + host: "code.github.example.com", + repository: "acme/web", + number: 42, + }); + expect(kindOf("https://scm.example.test/acme/web/pull/42")).toBe("github"); + expect( + sourceControlClients.changeRequestUrlFromReferenceAutolink( + "https://code.github.example.com/acme/web/issues/42", + ), + ).toBe("https://code.github.example.com/acme/web/pull/42"); + }); + + it("reads a GitLab merge request on any host, nested groups and all", () => { + expect(parse("https://gitlab.com/t3tools/platform/t3code/-/merge_requests/42")).toEqual({ + host: "gitlab.com", + repository: "t3tools/platform/t3code", + number: 42, + }); + expect(parse("https://code.acme.test/team/project/-/merge_requests/9")).toEqual({ + host: "code.acme.test", + repository: "team/project", + number: 9, + }); + }); + + it("reads Bitbucket and both Azure DevOps URL forms", () => { + expect(parse("https://bitbucket.org/workspace/repo/pull-requests/5")).toEqual({ + host: "bitbucket.org", + repository: "workspace/repo", + number: 5, + }); + expect(parse("https://dev.azure.com/acme/platform/_git/t3code/pullrequest/17")).toEqual({ + host: "dev.azure.com", + repository: "acme/platform/_git/t3code", + number: 17, + }); + expect(parse("https://acme.visualstudio.com/platform/_git/t3code/pullrequest/17")).toEqual({ + host: "acme.visualstudio.com", + repository: "platform/_git/t3code", + number: 17, + }); + }); + + it("reads Azure DevOps Server links and keeps servers on different ports distinct", () => { + for (const host of ["scm.example.test", "git.cafe", "code.bitbucket.example.test"]) { + const repository = "tfs/collection/project/_git/repo"; + const linked = { + projectId: ProjectId.make("project-1"), + host, + repository, + number: 123, + url: `https://${host.toUpperCase()}:8443/tfs/Collection/Project/_git/Repo/pullrequest/123`, + }; + const other = { ...linked, url: linked.url.replace(":8443/", ":9443/") }; + expect.soft(parse(linked.url), host).toEqual({ + host, + authority: `${host}:8443`, + repository, + number: 123, + }); + expect + .soft( + sourceControlClients.isSameChangeRequest(linked.url, `${linked.url}?view=files`), + host, + ) + .toBe(true); + expect + .soft(sourceControlClients.isSameChangeRequest(linked.url, other.url), host) + .toBe(false); + expect.soft([linked, other].map(normalizeThreadPullRequestKey), host).toEqual([ + { host: `${host}:8443`, repository, number: 123 }, + { host: `${host}:9443`, repository, number: 123 }, + ]); + } + }); + + it("survives trailing segments, a trailing slash and a query string", () => { + expect(parse("https://github.com/t3tools/t3code/pull/123/files?w=1")).toEqual({ + host: "github.com", + repository: "t3tools/t3code", + number: 123, + }); + expect(parse("https://github.com/t3tools/t3code/pull/123/")).toEqual({ + host: "github.com", + repository: "t3tools/t3code", + number: 123, + }); + }); + + it("claims nothing it cannot be sure of", () => { + for (const link of [ + "https://github.com/t3tools/t3code/issues/123", + "https://git.cafe/pingdotgg/t3code/issues/23897", + "https://git.cafe/pingdotgg/t3code/pull/abc", + "https://git.cafe/pingdotgg/t3code", + "https://github.com/t3tools/t3code/commit/0a1b2c3", + "https://github.com/t3tools/t3code", + "https://github.com/t3tools/t3code/pull/abc", + "https://gitlab.com/t3tools/t3code/-/issues/12", + "https://blog.example.test/2026/updates/archive/pull/3", + "https://scm.example.test/tfs/Collection/Project/_git/Repo", + "https://scm.example.test/tfs/Collection/Project/pullrequest/123", + "https://scm.example.test/tfs/Collection/Project/_git/Repo/pullrequest/abc", + "https://scm.example.test/tfs/Collection/Project/_git/Repo/pullrequest/0", + "https://scm.example.test/tfs/Collection/Project/_git/Repo/pullrequest/9007199254740992", + "ftp://scm.example.test/tfs/Collection/Project/_git/Repo/pullrequest/123", + "javascript:alert(1)//github.com/t3tools/t3code/pull/1", + "not a url", + ]) { + expect(parse(link), link).toBeNull(); + } + }); +}); + +describe("sourceControlClients.isSameChangeRequest", () => { + const linked = "https://github.com/pingdotgg/t3code/pull/42"; + + it("matches the same pull request whatever its case or trailing path", () => { + expect( + sourceControlClients.isSameChangeRequest( + linked, + "https://github.com/PingDotGG/T3Code/pull/42/files", + ), + ).toBe(true); + }); + + it.each([ + ["http://forge.example:3000/git/team/repo/pulls/42/files", true], + ["http://forge.example:4000/git/team/repo/pulls/42", false], + ["http://forge.example/git/team/repo/pulls/42", false], + ])("matches Forgejo links by web authority: %s", (url, expected) => { + expect( + sourceControlClients.isSameChangeRequest( + "http://forge.example:3000/git/team/repo/pulls/42", + url, + ), + ).toBe(expected); + }); + + it("keeps Forgejo and GitHub path shapes distinct on a GitHub-named host", () => { + expect( + sourceControlClients.isSameChangeRequest( + "https://github.internal/team/repo/pulls/42", + "https://github.internal/team/repo/pull/42", + ), + ).toBe(false); + }); +}); + +describe("change request URLs built from a parsed link", () => { + const sibling = (url: string, number: number) => { + const parsed = sourceControlClients.parseChangeRequestUrl(url)!; + return parsed.definition.changeRequestUrl({ + host: parsed.link.host, + repository: parsed.link.repository, + number, + webUrl: parsed.link.repositoryUrl, + }); + }; + + it.each([ + [ + "http://git.example.test:3000/team/repo/pulls/42/files", + "http://git.example.test:3000/team/repo/pulls/43", + ], + ["https://github.com/pull/1/pull/42/files", "https://github.com/pull/1/pull/43"], + [ + "https://dev.azure.com/acme/project/_git/web/pullrequest/42?view=files", + "https://dev.azure.com/acme/project/_git/web/pullrequest/43", + ], + ])("builds a canonical sibling of %s", (url, expected) => { + expect(sibling(url, 43)).toBe(expected); + }); + + it.each([ + ["ssh.dev.azure.com", "v3/org/project/web"], + ["org.visualstudio.com", "defaultcollection/project/_git/web"], + ["dev.azure.com", "org/project/_git/web"], + ])("reads back the Azure browser URL built from the remote %s/%s", (host, repository) => { + const url = sourceControlClients + .get("azure-devops") + .changeRequestUrl({ host, repository, number: 42 }); + expect(parse(url!)).toEqual({ + host: "dev.azure.com", + repository: "org/project/_git/web", + number: 42, + }); + }); +}); diff --git a/packages/client-runtime/src/sourceControlClients.ts b/packages/client-runtime/src/sourceControlClients.ts index 55c2cde6456e..7faf3abcc903 100644 --- a/packages/client-runtime/src/sourceControlClients.ts +++ b/packages/client-runtime/src/sourceControlClients.ts @@ -8,7 +8,9 @@ import type { ServerSettings } from "@t3tools/contracts"; export { UNKNOWN_SOURCE_CONTROL_CLIENT, + type ChangeRequestLink, type ChangeRequestTerminology, + type ParsedChangeRequestUrl, type SourceControlClientDefinition, } from "@t3tools/source-control-core/client/definition"; import * as Forgejo from "@t3tools/source-control-forgejo/client/definition"; @@ -19,8 +21,9 @@ import * as GitLab from "@t3tools/source-control-gitlab/client/definition"; /** * GitHub comes first because a repository that has not reported its host yet reads as GitHub, * which is what clients have always shown there. A change request URL goes to the first host - * whose shape it has, so GitCafe, which claims `/pulls/` only on its own hostnames, comes before - * Forgejo, which claims it everywhere. + * whose shape it has, so GitCafe, which claims `/pull/` and `/pulls/` only on its own hostnames, + * claims before GitHub and Forgejo, which claim them everywhere. The server's + * `sourceControl/sourceControlClients.ts` keeps the same order. */ const BUILT_IN_SOURCE_CONTROL_CLIENTS = [ GitHub.definition, @@ -33,6 +36,7 @@ const BUILT_IN_SOURCE_CONTROL_CLIENTS = [ export const sourceControlClients = makeSourceControlClientRegistry( BUILT_IN_SOURCE_CONTROL_CLIENTS, + { claimChangeRequestUrlsFirst: [GitCafe.definition.kind] }, ); export type { GitHubSettings } from "@t3tools/source-control-github/client/definition"; diff --git a/packages/client-runtime/src/state/models.ts b/packages/client-runtime/src/state/models.ts index ad011530b5d6..2474f3852efc 100644 --- a/packages/client-runtime/src/state/models.ts +++ b/packages/client-runtime/src/state/models.ts @@ -1,5 +1,5 @@ import { backgroundWorkHoldsCompletion } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; -import { threadPullRequestsOf } from "@t3tools/shared/threadPullRequests"; +import { threadPullRequestsOf } from "../threadPullRequests.ts"; import type { ThreadLinkedPullRequest, EnvironmentId, diff --git a/packages/client-runtime/src/state/orchestrationV2Projection.ts b/packages/client-runtime/src/state/orchestrationV2Projection.ts index 491d158700f2..506afe1394dd 100644 --- a/packages/client-runtime/src/state/orchestrationV2Projection.ts +++ b/packages/client-runtime/src/state/orchestrationV2Projection.ts @@ -1,4 +1,4 @@ -import { applyThreadPullRequestLinkUpdate } from "@t3tools/shared/threadPullRequests"; +import { applyThreadPullRequestLinkUpdate } from "../threadPullRequests.ts"; import type { OrchestrationV2DomainEvent, OrchestrationV2Run, diff --git a/packages/client-runtime/src/threadPullRequestCompatibility.ts b/packages/client-runtime/src/threadPullRequestCompatibility.ts index 922fc119c993..82915bd6deed 100644 --- a/packages/client-runtime/src/threadPullRequestCompatibility.ts +++ b/packages/client-runtime/src/threadPullRequestCompatibility.ts @@ -4,7 +4,7 @@ import type { ThreadId, ThreadPullRequestKey, } from "@t3tools/contracts"; -import { normalizeThreadPullRequestKey } from "@t3tools/shared/threadPullRequests"; +import { normalizeThreadPullRequestKey } from "./threadPullRequests.ts"; type LinkingCapabilities = Pick< ExecutionEnvironmentCapabilities, diff --git a/packages/shared/src/threadPullRequests.test.ts b/packages/client-runtime/src/threadPullRequests.test.ts similarity index 100% rename from packages/shared/src/threadPullRequests.test.ts rename to packages/client-runtime/src/threadPullRequests.test.ts diff --git a/packages/client-runtime/src/threadPullRequests.ts b/packages/client-runtime/src/threadPullRequests.ts new file mode 100644 index 000000000000..c4443728930e --- /dev/null +++ b/packages/client-runtime/src/threadPullRequests.ts @@ -0,0 +1,28 @@ +import { makeThreadPullRequests } from "@t3tools/source-control-core/client/threadPullRequests"; + +import { sourceControlClients } from "./sourceControlClients.ts"; + +export { + threadPullRequestSearchTerms, + visibleThreadPullRequests, +} from "@t3tools/shared/threadPullRequests"; +export { + pullRequestListLines, + type PullRequestListLine, + type ThreadCurrentPullRequest, + type ThreadPullRequestBadge, + type ThreadPullRequestChain, +} from "@t3tools/source-control-core/client/threadPullRequests"; + +export const { + normalizeThreadPullRequestKey, + legacyThreadPullRequestKey, + threadPullRequestKeysEqual, + threadPullRequestKeyOf, + resolveThreadCurrentPullRequest, + resolveThreadCurrentPullRequestLink, + resolveThreadPullRequestChains, + resolveThreadPullRequestBadge, + threadPullRequestsOf, + applyThreadPullRequestLinkUpdate, +} = makeThreadPullRequests(sourceControlClients); diff --git a/packages/shared/src/threadReference.test.ts b/packages/client-runtime/src/threadReference.test.ts similarity index 100% rename from packages/shared/src/threadReference.test.ts rename to packages/client-runtime/src/threadReference.test.ts diff --git a/packages/shared/src/threadReference.ts b/packages/client-runtime/src/threadReference.ts similarity index 100% rename from packages/shared/src/threadReference.ts rename to packages/client-runtime/src/threadReference.ts diff --git a/packages/client-runtime/src/work-log/presentation.ts b/packages/client-runtime/src/work-log/presentation.ts index 57bbc2db2b6e..146fffde2c79 100644 --- a/packages/client-runtime/src/work-log/presentation.ts +++ b/packages/client-runtime/src/work-log/presentation.ts @@ -16,7 +16,7 @@ import { } from "@t3tools/shared/t3McpToolPresentation"; import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images"; import { resolveMediaSource } from "@t3tools/client-runtime/media-source"; -import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; import { formatTokens } from "@t3tools/shared/usageFormat"; import { classifyToolActivity } from "@t3tools/shared/toolActivity"; @@ -184,7 +184,10 @@ function resolveT3McpToolPresentation( const payload = asRecord(data); const input = asRecord(payload?.arguments) ?? asRecord(payload?.input) ?? asRecord(payload?.rawInput); - const urlTarget = typeof input?.url === "string" ? parseChangeRequestUrl(input.url) : null; + const urlTarget = + typeof input?.url === "string" + ? sourceControlClients.parseChangeRequestUrl(input.url)?.link + : null; const number = urlTarget?.number ?? input?.number; const target = actionKind !== undefined && diff --git a/packages/shared/package.json b/packages/shared/package.json index 0254443e1b46..3fe44db4f345 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -239,10 +239,6 @@ "types": "./src/threadTranscript.ts", "import": "./src/threadTranscript.ts" }, - "./threadReference": { - "types": "./src/threadReference.ts", - "import": "./src/threadReference.ts" - }, "./paletteSearch": { "types": "./src/paletteSearch.ts", "import": "./src/paletteSearch.ts" @@ -251,10 +247,6 @@ "types": "./src/threadPullRequests.ts", "import": "./src/threadPullRequests.ts" }, - "./changeRequestUrl": { - "types": "./src/changeRequestUrl.ts", - "import": "./src/changeRequestUrl.ts" - }, "./composerTrigger": { "types": "./src/composerTrigger.ts", "import": "./src/composerTrigger.ts" diff --git a/packages/shared/src/changeRequestUrl.test.ts b/packages/shared/src/changeRequestUrl.test.ts deleted file mode 100644 index 0c7a469753ee..000000000000 --- a/packages/shared/src/changeRequestUrl.test.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { ProjectId } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; - -import { - changeRequestUrlFor, - matchesLinkedPullRequestUrl, - parseChangeRequestUrl, - pullRequestCandidateUrlFromReferenceAutolink, - siblingPullRequestUrl, -} from "./changeRequestUrl.ts"; -import { normalizeThreadPullRequestKey } from "./threadPullRequests.ts"; - -describe("parseChangeRequestUrl", () => { - it("reads a GitHub pull request, lower-casing the repository", () => { - expect(parseChangeRequestUrl("https://github.com/T3Tools/T3Code/pull/123")).toEqual({ - host: "github.com", - repository: "t3tools/t3code", - number: 123, - }); - }); - - it("reads a GitCafe pull request at its canonical /pull/ path and the older /pulls/ path", () => { - for (const path of ["pull", "pulls"]) { - expect( - parseChangeRequestUrl(`https://git.cafe/PingDotGG/T3Code/${path}/23897`), - path, - ).toEqual({ host: "git.cafe", repository: "pingdotgg/t3code", number: 23897 }); - } - expect(parseChangeRequestUrl("https://staging.git.cafe/team/repo/pull/7/files")).toEqual({ - host: "staging.git.cafe", - repository: "team/repo", - number: 7, - }); - }); - - it("reads Forgejo URLs even when the hostname contains github", () => { - expect(parseChangeRequestUrl("https://github.internal/team/repo/pulls/7")).toEqual({ - host: "github.internal", - authority: "github.internal", - repository: "team/repo", - number: 7, - }); - }); - - it("reads a supported GitHub host with a middle DNS label", () => { - expect(parseChangeRequestUrl("https://code.github.example.com/acme/web/pull/42")).toEqual({ - host: "code.github.example.com", - repository: "acme/web", - number: 42, - }); - expect( - pullRequestCandidateUrlFromReferenceAutolink( - "https://code.github.example.com/acme/web/issues/42", - ), - ).toBe("https://code.github.example.com/acme/web/pull/42"); - }); - - it("reads a GitLab merge request on any host, nested groups and all", () => { - expect( - parseChangeRequestUrl("https://gitlab.com/t3tools/platform/t3code/-/merge_requests/42"), - ).toEqual({ host: "gitlab.com", repository: "t3tools/platform/t3code", number: 42 }); - expect(parseChangeRequestUrl("https://code.acme.test/team/project/-/merge_requests/9")).toEqual( - { host: "code.acme.test", repository: "team/project", number: 9 }, - ); - }); - - it("reads Bitbucket and both Azure DevOps URL forms", () => { - expect(parseChangeRequestUrl("https://bitbucket.org/workspace/repo/pull-requests/5")).toEqual({ - host: "bitbucket.org", - repository: "workspace/repo", - number: 5, - }); - expect( - parseChangeRequestUrl("https://dev.azure.com/acme/platform/_git/t3code/pullrequest/17"), - ).toEqual({ host: "dev.azure.com", repository: "acme/platform/_git/t3code", number: 17 }); - expect( - parseChangeRequestUrl("https://acme.visualstudio.com/platform/_git/t3code/pullrequest/17"), - ).toEqual({ host: "acme.visualstudio.com", repository: "platform/_git/t3code", number: 17 }); - }); - - it("reads Azure DevOps Server links and keeps servers on different ports distinct", () => { - for (const host of ["scm.example.test", "git.cafe", "code.bitbucket.example.test"]) { - const repository = "tfs/collection/project/_git/repo"; - const linked = { - projectId: ProjectId.make("project-1"), - host, - repository, - number: 123, - url: `https://${host.toUpperCase()}:8443/tfs/Collection/Project/_git/Repo/pullrequest/123`, - }; - const other = { ...linked, url: linked.url.replace(":8443/", ":9443/") }; - expect.soft(parseChangeRequestUrl(linked.url), host).toEqual({ - host, - authority: `${host}:8443`, - repository, - number: 123, - }); - expect.soft(matchesLinkedPullRequestUrl(linked, `${linked.url}?view=files`), host).toBe(true); - expect.soft(matchesLinkedPullRequestUrl(linked, other.url), host).toBe(false); - expect.soft([linked, other].map(normalizeThreadPullRequestKey), host).toEqual([ - { host: `${host}:8443`, repository, number: 123 }, - { host: `${host}:9443`, repository, number: 123 }, - ]); - } - }); - - it("survives trailing segments, a trailing slash and a query string", () => { - expect(parseChangeRequestUrl("https://github.com/t3tools/t3code/pull/123/files?w=1")).toEqual({ - host: "github.com", - repository: "t3tools/t3code", - number: 123, - }); - expect(parseChangeRequestUrl("https://github.com/t3tools/t3code/pull/123/")).toEqual({ - host: "github.com", - repository: "t3tools/t3code", - number: 123, - }); - }); - - it("claims nothing it cannot be sure of", () => { - for (const link of [ - "https://github.com/t3tools/t3code/issues/123", - "https://git.cafe/pingdotgg/t3code/issues/23897", - "https://git.cafe/pingdotgg/t3code/pull/abc", - "https://git.cafe/pingdotgg/t3code", - "https://github.com/t3tools/t3code/commit/0a1b2c3", - "https://github.com/t3tools/t3code", - "https://github.com/t3tools/t3code/pull/abc", - "https://gitlab.com/t3tools/t3code/-/issues/12", - "https://blog.example.test/2026/updates/pull/3", - "https://scm.example.test/tfs/Collection/Project/_git/Repo", - "https://scm.example.test/tfs/Collection/Project/pullrequest/123", - "https://scm.example.test/tfs/Collection/Project/_git/Repo/pullrequest/abc", - "https://scm.example.test/tfs/Collection/Project/_git/Repo/pullrequest/0", - "https://scm.example.test/tfs/Collection/Project/_git/Repo/pullrequest/9007199254740992", - "ftp://scm.example.test/tfs/Collection/Project/_git/Repo/pullrequest/123", - "javascript:alert(1)//github.com/t3tools/t3code/pull/1", - "not a url", - ]) { - expect(parseChangeRequestUrl(link), link).toBeNull(); - } - }); -}); - -describe("siblingPullRequestUrl", () => { - it.each([ - [ - "http://git.example.test:3000/team/repo/pulls/42/files", - "http://git.example.test:3000/team/repo/pulls/43", - ], - ["https://github.com/pull/1/pull/42/files", "https://github.com/pull/1/pull/43"], - [ - "https://dev.azure.com/acme/project/_git/web/pullrequest/42?view=files", - "https://dev.azure.com/acme/project/_git/web/pullrequest/43", - ], - ])("builds a canonical sibling of %s", (url, expected) => { - expect(siblingPullRequestUrl(url, 43)).toBe(expected); - }); -}); - -describe("changeRequestUrlFor", () => { - it("preserves the origin when the Forgejo host already contains its port", () => { - expect( - changeRequestUrlFor( - "forgejo", - "forge.example:3000", - "team/repo", - 42, - "http://forge.example:3000/team/repo.git", - ), - ).toBe("http://forge.example:3000/team/repo/pulls/42"); - }); - - it.each([ - ["http://forge.example:3000/git/owner/repo.git", "http://forge.example:3000"], - ["git@forge.example:git/owner/repo.git", "https://forge.example"], - ["http://other.example:3000/git/owner/repo.git", "https://forge.example"], - ])("preserves the matching Forgejo web origin from %s", (remoteUrl, origin) => { - expect(changeRequestUrlFor("forgejo", "forge.example", "git/owner/repo", 42, remoteUrl)).toBe( - `${origin}/git/owner/repo/pulls/42`, - ); - }); - - it.each([ - ["ssh.dev.azure.com", "v3/org/project/web"], - ["org.visualstudio.com", "defaultcollection/project/_git/web"], - ["dev.azure.com", "org/project/_git/web"], - ])("builds a browser URL from the Azure remote %s/%s", (host, repository) => { - const url = changeRequestUrlFor("azure-devops", host, repository, 42); - expect(url).toBe("https://dev.azure.com/org/project/_git/web/pullrequest/42"); - expect(parseChangeRequestUrl(url!)).toEqual({ - host: "dev.azure.com", - repository: "org/project/_git/web", - number: 42, - }); - }); -}); diff --git a/packages/shared/src/changeRequestUrl.ts b/packages/shared/src/changeRequestUrl.ts deleted file mode 100644 index 129e4d090ee0..000000000000 --- a/packages/shared/src/changeRequestUrl.ts +++ /dev/null @@ -1,216 +0,0 @@ -import type { RepositoryIdentity, ThreadLinkedPullRequest } from "@t3tools/contracts"; -import { canonicalRepositoryKey } from "./sourceControl.ts"; - -/** - * The two strings are what `pullRequestHostOf` and the project's `repositoryIdentity` produce - * from a git remote — lower case, no port, the full path below the host — because links are - * matched against those. Anything else matches nothing. - */ -export interface ChangeRequestLink { - readonly host: string; - readonly repository: string; - readonly number: number; - readonly authority?: string; -} - -function isHostOf(hostname: string, apex: string, label?: string): boolean { - if (hostname === apex || hostname.endsWith(`.${apex}`)) return true; - return label !== undefined && hostname.split(".").includes(label); -} - -export function parseChangeRequestUrl(targetUrl: string): ChangeRequestLink | null { - let url: URL; - try { - url = new URL(targetUrl); - } catch { - return null; - } - if (url.protocol !== "https:" && url.protocol !== "http:") return null; - const host = url.hostname.toLowerCase(); - - if (host === "git.cafe" || host === "staging.git.cafe") { - // GitCafe serves pull requests at `/pull/` and redirects the older `/pulls/` there. - const match = /^\/([^/]+\/[^/]+)\/pulls?\/(\d+)(?:\/|$)/u.exec(url.pathname); - if (match) return claim(host, match); - } else { - if (isHostOf(host, "github.com", "github")) { - const match = /^\/([^/]+\/[^/]+)\/pull\/(\d+)(?:\/|$)/u.exec(url.pathname); - if (match) return claim(host, match); - } - const forgejo = /^\/([^/]+(?:\/[^/]+)+)\/pulls\/(\d+)(?:\/|$)/u.exec(url.pathname); - if (forgejo) { - const link = claim(host, forgejo); - return link === null ? null : { ...link, authority: url.host.toLowerCase() }; - } - const gitlab = /^\/([^/]+(?:\/[^/]+)+)\/-\/merge_requests\/(\d+)(?:\/|$)/u.exec(url.pathname); - if (gitlab) return claim(host, gitlab); - if (isHostOf(host, "bitbucket.org", "bitbucket")) { - const match = /^\/([^/]+\/[^/]+)\/pull-requests\/(\d+)(?:\/|$)/u.exec(url.pathname); - if (match) return claim(host, match); - } - } - // Azure DevOps Server can use any hostname; its `_git` pull request route identifies it. - const azure = /^\/((?:[^/]+\/)*_git\/[^/]+)\/pullrequest\/(\d+)(?:\/|$)/u.exec(url.pathname); - const link = claim(host, azure); - return link === null || url.port === "" ? link : { ...link, authority: url.host.toLowerCase() }; -} - -function claim(host: string, match: RegExpExecArray | null): ChangeRequestLink | null { - const repository = match?.[1]; - const number = Number(match?.[2]); - return repository && Number.isSafeInteger(number) && number > 0 - ? { host, repository: repository.toLowerCase(), number } - : null; -} - -function remoteWebOrigin(host: string, remoteUrl: string | undefined): string { - try { - const remote = new URL(remoteUrl ?? ""); - if ( - (remote.protocol === "http:" || remote.protocol === "https:") && - (remote.hostname.toLowerCase() === host.toLowerCase() || - remote.host.toLowerCase() === host.toLowerCase()) - ) { - return remote.origin; - } - } catch { - // SSH remotes do not specify the server's web origin. - } - return `https://${host}`; -} - -export function changeRequestUrlFor( - kind: string | null | undefined, - host: string, - repository: string, - number: number, - remoteUrl?: string, -): string | null { - switch (kind) { - case "github": - return `https://${host}/${repository}/pull/${number}`; - case "gitcafe": - return `https://${host}/${repository}/pulls/${number}`; - case "forgejo": - return `${remoteWebOrigin(host, remoteUrl)}/${repository}/pulls/${number}`; - case "gitlab": - return `https://${host}/${repository}/-/merge_requests/${number}`; - case "bitbucket": - return `https://${host}/${repository}/pull-requests/${number}`; - case "azure-devops": { - const [canonicalHost = host, ...path] = canonicalRepositoryKey( - `${host}/${repository}`.toLowerCase(), - ).split("/"); - return `${remoteWebOrigin(canonicalHost, remoteUrl)}/${path.join("/")}/pullrequest/${number}`; - } - default: - return null; - } -} - -export function gitHubPullRequestBrowserUrl( - identity: RepositoryIdentity | null | undefined, - repository: string, - number: number, -): string | null { - if (identity?.provider !== "github" || !Number.isSafeInteger(number) || number < 1) return null; - const repositoryPath = repository.split("/"); - if ( - repositoryPath.length !== 2 || - repositoryPath.some((segment) => segment.length === 0 || segment === "." || segment === "..") - ) { - return null; - } - - let origin: string | null = null; - try { - const remoteUrl = new URL(identity.locator.remoteUrl.trim()); - if (remoteUrl.protocol === "http:" || remoteUrl.protocol === "https:") { - origin = remoteUrl.origin; - } - } catch {} - const hostname = identity.canonicalKey.split("/")[0]; - if (origin === null && !hostname) return null; - - try { - const url = new URL(origin ?? `https://${hostname}`); - url.pathname = `/${repositoryPath.join("/")}/pull/${number}`; - return url.toString(); - } catch { - return null; - } -} - -/** - * The pull-request URL a GitHub-style `#123` autolink might name. GitHub writes every bare - * reference through `/issues/`, including pull requests, so this only builds a candidate: the - * caller must successfully read it as a pull request before treating it as one. - */ -export function pullRequestCandidateUrlFromReferenceAutolink(targetUrl: string): string | null { - let url: URL; - try { - url = new URL(targetUrl); - } catch { - return null; - } - if ( - (url.protocol !== "https:" && url.protocol !== "http:") || - !( - url.hostname.toLowerCase() === "github.com" || - url.hostname.toLowerCase().endsWith(".github.com") || - url.hostname.toLowerCase().split(".").includes("github") - ) - ) { - return null; - } - const match = /^\/([^/]+\/[^/]+)\/issues\/(\d+)(?:\/|$)/u.exec(url.pathname); - if (match?.[1] === undefined || match[2] === undefined) return null; - url.pathname = `/${match[1]}/pull/${match[2]}`; - return url.toString(); -} - -export function matchesLinkedPullRequestUrl( - linkedPullRequest: ThreadLinkedPullRequest, - targetUrl: string, -): boolean { - const linked = parseChangeRequestUrl(linkedPullRequest.url); - const target = parseChangeRequestUrl(targetUrl); - return ( - linked !== null && - target !== null && - linked.host === target.host && - linked.repository === target.repository && - linked.number === target.number && - linked.authority === target.authority - ); -} - -export function changeRequestRepositoryUrl(targetUrl: string): string | null { - const changeRequest = parseChangeRequestUrl(targetUrl); - if (changeRequest === null) return null; - const url = new URL(targetUrl); - const repositoryPath = - /^(.*?)\/-\/merge_requests\/\d+(?:\/|$)/iu.exec(url.pathname)?.[1] ?? - /^(.*?)(?:\/pulls?\/\d+|\/-\/merge_requests\/\d+|\/pull-requests\/\d+|\/pullrequest\/\d+)(?:\/|$)/iu.exec( - url.pathname, - )?.[1]; - if (!repositoryPath) return null; - url.pathname = repositoryPath; - url.search = ""; - url.hash = ""; - return url.toString(); -} - -export function siblingPullRequestUrl(url: string, number: number): string | null { - const reference = parseChangeRequestUrl(url); - if (reference === null || !Number.isSafeInteger(number) || number < 1) return null; - const sibling = new URL(url); - const route = /^\/(-\/merge_requests|pulls?|pull-requests|pullrequest)\/\d+(?:\/|$)/u.exec( - sibling.pathname.slice(reference.repository.length + 1), - )?.[1]; - if (route === undefined) return null; - sibling.pathname = `/${reference.repository}/${route}/${number}`; - sibling.search = ""; - sibling.hash = ""; - return sibling.toString(); -} diff --git a/packages/shared/src/orchestrationV2PendingBackgroundWork.ts b/packages/shared/src/orchestrationV2PendingBackgroundWork.ts index 8073bcccd0bb..f96662e57b24 100644 --- a/packages/shared/src/orchestrationV2PendingBackgroundWork.ts +++ b/packages/shared/src/orchestrationV2PendingBackgroundWork.ts @@ -10,7 +10,6 @@ import { isOrchestrationV2WorkActive } from "@t3tools/contracts"; import * as Predicate from "effect/Predicate"; import { resolveT3McpToolId } from "./t3McpToolPresentation.ts"; -import { threadPullRequestKeyOf } from "./threadPullRequests.ts"; const BACKGROUND_TURN_ITEM_TYPES = new Set([ "command_execution", @@ -303,7 +302,9 @@ function pullRequestWatchTasks( ? [] : [ { - taskId: `pull-request-watch:${threadPullRequestKeyOf(link)}`, + // Links store their normalized identity, and this id only has to be unique within the + // thread, so it reads those fields instead of parsing the URL. + taskId: `pull-request-watch:${link.host.toLowerCase()}/${link.repository.toLowerCase()}#${link.number}`, description: `Watching pull request #${link.number}`, kind: "monitor" as const, }, diff --git a/packages/shared/src/threadPullRequests.ts b/packages/shared/src/threadPullRequests.ts index 83c5a60c0cee..c86fcc1158bf 100644 --- a/packages/shared/src/threadPullRequests.ts +++ b/packages/shared/src/threadPullRequests.ts @@ -1,88 +1,4 @@ -import type { - OrchestrationV2AppThread, - ThreadLinkedPullRequest, - ThreadPullRequestKey, - ThreadPullRequestLink, -} from "@t3tools/contracts"; - -import { parseChangeRequestUrl } from "./changeRequestUrl.ts"; -import { canonicalRepositoryKey } from "./sourceControl.ts"; - -type ThreadPullRequestKeySource = ThreadPullRequestKey & { - readonly authority?: string; - readonly url?: string; -}; - -export function normalizeThreadPullRequestKey( - key: ThreadPullRequestKeySource, -): ThreadPullRequestKey { - const parsed = key.url === undefined ? null : parseChangeRequestUrl(key.url); - const authority = - key.authority ?? - (parsed?.repository === key.repository.trim().toLowerCase() && parsed.number === key.number - ? parsed.authority - : undefined); - const canonical = canonicalRepositoryKey( - `${(authority ?? key.host).trim().toLowerCase()}/${key.repository.trim().toLowerCase()}`, - ); - const separator = canonical.indexOf("/"); - return { - host: canonical.slice(0, separator), - repository: canonical.slice(separator + 1), - number: key.number, - }; -} - -export function legacyThreadPullRequestKey( - linked: Pick, - fallbackHost?: string, -): ThreadPullRequestKey { - const parsed = parseChangeRequestUrl(linked.url); - if (parsed !== null && parsed.number === linked.number) { - const canonical = canonicalRepositoryKey(`${parsed.host}/${parsed.repository}`); - if (parsed.authority !== undefined || canonical.startsWith("dev.azure.com/")) { - return normalizeThreadPullRequestKey(parsed); - } - } - let host = fallbackHost; - if (host === undefined) { - try { - host = new URL(linked.url).hostname; - } catch { - host = "unknown"; - } - } - return { - host: host.trim().toLowerCase() || "unknown", - repository: linked.repository.trim().toLowerCase(), - number: linked.number, - }; -} - -export function threadPullRequestKeysEqual( - left: ThreadPullRequestKeySource, - right: ThreadPullRequestKeySource, -): boolean { - return threadPullRequestKeyOf(left) === threadPullRequestKeyOf(right); -} - -// Clients re-derive chains for every link on each render, and normalizing parses the -// link's URL. Links are immutable and keep their identity until they change. -const normalizedKeys = new WeakMap(); - -function normalizedKeyOf(key: ThreadPullRequestKeySource): ThreadPullRequestKey { - let normalized = normalizedKeys.get(key); - if (normalized === undefined) { - normalized = normalizeThreadPullRequestKey(key); - normalizedKeys.set(key, normalized); - } - return normalized; -} - -export function threadPullRequestKeyOf(key: ThreadPullRequestKeySource): string { - const normalized = normalizedKeyOf(key); - return `${normalized.host}/${normalized.repository}#${normalized.number}`; -} +import type { ThreadLinkedPullRequest, ThreadPullRequestLink } from "@t3tools/contracts"; /** Tombstoned stack members stay in the array only so the * sync reactor does not re-add them. */ @@ -92,202 +8,12 @@ export function visibleThreadPullRequests( return links.filter((link) => link.source !== "stack-dismissed"); } -function isOpen(link: ThreadPullRequestLink): boolean { - return link.snapshot === null || link.snapshot.state === "open"; -} - function latestUpdatedAt(link: ThreadPullRequestLink): number { const value = link.snapshot?.updatedAt ?? link.linkedAt; const ms = Date.parse(value); return Number.isNaN(ms) ? 0 : ms; } -export type ThreadCurrentPullRequest = - | { readonly kind: "single"; readonly link: ThreadPullRequestLink } - | { - readonly kind: "stack"; - readonly open: ReadonlyArray; - readonly top: ThreadPullRequestLink; - }; - -function layersFromTop(chain: ThreadPullRequestChain): Array { - // `.sort()` on a fresh array, not `.toSorted()`: this runs on Hermes, which has no ES2023 - // array methods, and a TypeError here is fatal on every mobile launch that renders a stack. - return chain.layers - .map((link, index) => ({ link, index, depth: chain.depths[index]! })) - .sort((left, right) => right.depth - left.depth || right.index - left.index) - .map((entry) => entry.link); -} - -export function resolveThreadCurrentPullRequest( - links: ReadonlyArray, -): ThreadCurrentPullRequest | null { - const visible = visibleThreadPullRequests(links); - if (visible.length === 0) return null; - const open = visible.filter(isOpen); - if (open.length === 1) return { kind: "single", link: open[0]! }; - const chains = resolveThreadPullRequestChains(visible); - if (open.length > 1) { - const openChains = chains - .map((chain) => layersFromTop(chain).filter(isOpen)) - .filter((layers) => layers.length > 0) - .sort( - (left, right) => - Math.max(...right.map((link) => Date.parse(link.linkedAt))) - - Math.max(...left.map((link) => Date.parse(link.linkedAt))), - ); - const ordered = openChains.flat(); - return { kind: "stack", open: ordered, top: ordered[0]! }; - } - if (chains.length === 1) { - return { kind: "single", link: layersFromTop(chains[0]!)[0]! }; - } - const terminal = [...visible].sort( - (left, right) => latestUpdatedAt(right) - latestUpdatedAt(left), - ); - return { kind: "single", link: terminal[0]! }; -} - -export function resolveThreadCurrentPullRequestLink( - links: ReadonlyArray, -): ThreadPullRequestLink | null { - const current = resolveThreadCurrentPullRequest(links); - if (current === null) return null; - return current.kind === "single" ? current.link : current.top; -} - -export interface ThreadPullRequestChain { - readonly kind: "native" | "derived"; - readonly layers: ReadonlyArray; - /** One per layer: 0 for the base, parent's depth + 1 above it. Siblings share a depth. */ - readonly depths: ReadonlyArray; -} - -export function resolveThreadPullRequestChains( - links: ReadonlyArray, -): ReadonlyArray { - const visible = visibleThreadPullRequests(links); - const chains: Array = []; - const placed = new Set(); - - const nativeStacks = new Map>(); - for (const link of visible) { - if (link.stack === null) continue; - const key = normalizedKeyOf(link); - const stackKey = `${key.host}/${key.repository}#stack:${link.stack.id}`; - const members = nativeStacks.get(stackKey) ?? []; - members.push(link); - nativeStacks.set(stackKey, members); - } - for (const members of nativeStacks.values()) { - // Saved snapshots can lag behind newer members of the same stack. - const memberNumbers = new Set(members.map((member) => member.number)); - let layers = members[0]!.stack!.layers; - let coverage = layers.filter((layer) => memberNumbers.has(layer.number)).length; - for (const member of members.slice(1)) { - const candidate = member.stack!.layers; - const candidateCoverage = candidate.filter((layer) => memberNumbers.has(layer.number)).length; - if ( - candidateCoverage > coverage || - (candidateCoverage === coverage && candidate.length > layers.length) - ) { - layers = candidate; - coverage = candidateCoverage; - } - } - const order = new Map(layers.map((layer, index) => [layer.number, index])); - members.sort( - (left, right) => - (order.get(left.number) ?? layers.length) - (order.get(right.number) ?? layers.length), - ); - for (const member of members) placed.add(threadPullRequestKeyOf(member)); - chains.push({ kind: "native", layers: members, depths: members.map((_, index) => index) }); - } - - const remaining = visible.filter((link) => !placed.has(threadPullRequestKeyOf(link))); - const branchKey = (link: ThreadPullRequestLink, branch: string) => { - const key = normalizedKeyOf(link); - return `${key.host}/${key.repository}:${branch}`; - }; - const byHead = new Map(); - for (const link of remaining) { - if (link.snapshot === null) continue; - const key = branchKey(link, link.snapshot.headBranch); - byHead.set(key, byHead.has(key) ? null : link); - } - const parentOf = (link: ThreadPullRequestLink) => { - if (link.snapshot === null) return undefined; - const parent = byHead.get(branchKey(link, link.snapshot.baseBranch)); - return parent == null || parent === link ? undefined : parent; - }; - const children = new Map>(); - for (const link of remaining) { - const parent = parentOf(link); - if (parent === undefined) continue; - const key = threadPullRequestKeyOf(parent); - const siblings = children.get(key) ?? []; - siblings.push(link); - children.set(key, siblings); - } - for (const base of remaining) { - if (parentOf(base) !== undefined) continue; - const layers: Array = []; - const depths: Array = []; - const pending: Array<{ readonly link: ThreadPullRequestLink; readonly depth: number }> = [ - { link: base, depth: 0 }, - ]; - for (let next = pending.pop(); next !== undefined; next = pending.pop()) { - const key = threadPullRequestKeyOf(next.link); - // Two links with one identity would otherwise share a child list and expand forever. - if (placed.has(key)) continue; - placed.add(key); - layers.push(next.link); - depths.push(next.depth); - const below = children.get(key) ?? []; - for (let index = below.length - 1; index >= 0; index -= 1) { - pending.push({ link: below[index]!, depth: next.depth + 1 }); - } - } - if (layers.length > 0) chains.push({ kind: "derived", layers, depths }); - } - for (const link of remaining) { - if (!placed.has(threadPullRequestKeyOf(link))) { - chains.push({ kind: "derived", layers: [link], depths: [0] }); - } - } - return chains; -} - -export type ThreadPullRequestBadge = { - readonly state: "open" | "closed" | "merged" | "draft"; -} & ( - | { - readonly kind: "stack"; - readonly layers: number; - } - | { readonly kind: "pull-request"; readonly others: number } -); - -export function resolveThreadPullRequestBadge( - pullRequests: ReadonlyArray | undefined, -): ThreadPullRequestBadge | null { - const visible = visibleThreadPullRequests(pullRequests ?? []); - if (visible.length === 0) return null; - const states = visible.map((link) => link.snapshot?.state ?? "open"); - const state = visible.every((link) => link.snapshot?.state === "open" && link.snapshot.isDraft) - ? "draft" - : states.includes("open") - ? "open" - : states.every((entry) => entry === "merged") - ? "merged" - : "closed"; - const chains = resolveThreadPullRequestChains(visible); - if (visible.length > 1 && chains.length === 1) { - return { kind: "stack", layers: visible.length, state }; - } - return { kind: "pull-request", others: visible.length - 1, state }; -} - export interface ThreadSearchPullRequest { readonly number: number; readonly title: string | null; @@ -338,70 +64,3 @@ export function threadPullRequestSearchTerms(thread: { ...(pullRequest.title ? [pullRequest.title] : []), ]); } - -/** Older V2 event payloads stored one link; an explicit empty array means it was unlinked. */ -export function threadPullRequestsOf(thread: { - readonly pullRequests?: ReadonlyArray | undefined; - readonly linkedPullRequest?: ThreadLinkedPullRequest | null | undefined; -}): ReadonlyArray { - if (thread.pullRequests !== undefined) return thread.pullRequests; - const linked = thread.linkedPullRequest; - return linked == null - ? [] - : [ - { - ...legacyThreadPullRequestKey(linked), - url: linked.url, - source: "manual", - linkedAt: "1970-01-01T00:00:00.000Z", - snapshot: null, - stack: null, - }, - ]; -} - -export function applyThreadPullRequestLinkUpdate( - thread: OrchestrationV2AppThread, - updated: ThreadPullRequestLink, -): OrchestrationV2AppThread { - return { - ...thread, - pullRequests: threadPullRequestsOf(thread).map((link) => - threadPullRequestKeysEqual(link, updated) ? updated : link, - ), - }; -} - -export interface PullRequestListLine { - readonly link: ThreadPullRequestLink; - readonly depth: number; - readonly chainKey: string; - readonly stack: { readonly kind: ThreadPullRequestChain["kind"]; readonly size: number } | null; -} - -function chainKeyOf(chain: ThreadPullRequestChain): string { - const bottom = chain.layers[0]!; - return `${bottom.host}/${bottom.repository}#${bottom.number}`; -} - -export function pullRequestListLines( - chains: ReadonlyArray, -): ReadonlyArray { - const ordered = [...chains].sort( - (left, right) => - Math.max(...right.layers.map(latestUpdatedAt)) - - Math.max(...left.layers.map(latestUpdatedAt)), - ); - return ordered.flatMap((chain) => { - const chainKey = chainKeyOf(chain); - return chain.layers.map((link, index) => ({ - link, - depth: chain.depths[index]!, - chainKey, - stack: - index === 0 && chain.layers.length > 1 - ? { kind: chain.kind, size: chain.layers.length } - : null, - })); - }); -} diff --git a/packages/source-control-azure-devops/src/client/definition.test.ts b/packages/source-control-azure-devops/src/client/definition.test.ts new file mode 100644 index 000000000000..c4a0e4fd526b --- /dev/null +++ b/packages/source-control-azure-devops/src/client/definition.test.ts @@ -0,0 +1,104 @@ +import type { ChangeRequestProjectIdentity } from "@t3tools/source-control-core/client/definition"; +import { describe, expect, it } from "vite-plus/test"; + +import { definition } from "./definition.ts"; + +const parse = (url: string) => definition.parseChangeRequestUrl(new URL(url)); + +describe("Azure DevOps change request URLs", () => { + it("reads both hosted URL forms", () => { + expect(parse("https://dev.azure.com/acme/platform/_git/t3code/pullrequest/17")).toEqual({ + host: "dev.azure.com", + repository: "acme/platform/_git/t3code", + number: 17, + repositoryUrl: "https://dev.azure.com/acme/platform/_git/t3code", + }); + expect( + parse("https://acme.visualstudio.com/platform/_git/t3code/pullrequest/17"), + ).toMatchObject({ + host: "acme.visualstudio.com", + repository: "platform/_git/t3code", + number: 17, + }); + }); + + it("keeps Azure DevOps Server ports in the authority, on any hostname", () => { + expect( + parse("https://GIT.CAFE:8443/tfs/Collection/Project/_git/Repo/pullrequest/123?view=files"), + ).toEqual({ + host: "git.cafe", + authority: "git.cafe:8443", + repository: "tfs/collection/project/_git/repo", + number: 123, + repositoryUrl: "https://git.cafe:8443/tfs/Collection/Project/_git/Repo", + }); + expect(parse("https://scm.example.test/tfs/c/p/_git/r/pullrequest/1")).not.toHaveProperty( + "authority", + ); + }); + + it("claims nothing without the _git route or a valid number", () => { + for (const url of [ + "https://scm.example.test/tfs/Collection/Project/_git/Repo", + "https://scm.example.test/tfs/Collection/Project/pullrequest/123", + "https://scm.example.test/tfs/Collection/Project/_git/Repo/pullrequest/abc", + "https://scm.example.test/tfs/Collection/Project/_git/Repo/pullrequest/0", + "https://scm.example.test/tfs/Collection/Project/_git/Repo/pullrequest/9007199254740992", + ]) { + expect(parse(url), url).toBeNull(); + } + }); + + it.each([ + ["ssh.dev.azure.com", "v3/org/project/web"], + ["org.visualstudio.com", "defaultcollection/project/_git/web"], + ["dev.azure.com", "org/project/_git/web"], + ])("builds a canonical browser URL from the remote %s/%s", (host, repository) => { + expect(definition.changeRequestUrl({ host, repository, number: 42 })).toBe( + "https://dev.azure.com/org/project/_git/web/pullrequest/42", + ); + }); + + it("keeps an Azure DevOps Server's HTTP origin and builds below a web URL", () => { + expect( + definition.changeRequestUrl({ + host: "scm.example.test", + repository: "tfs/collection/project/_git/repo", + number: 124, + remoteUrl: "http://dummy:secret@scm.example.test:8080/tfs/Collection/Project/_git/Repo", + }), + ).toBe("http://scm.example.test:8080/tfs/collection/project/_git/repo/pullrequest/124"); + expect( + definition.changeRequestUrl({ + host: "dev.azure.com", + repository: "acme/project/_git/web", + number: 43, + webUrl: "https://dev.azure.com/acme/project/_git/web", + }), + ).toBe("https://dev.azure.com/acme/project/_git/web/pullrequest/43"); + }); + + it.each([ + ["ssh.dev.azure.com/v3/org-a/project/web", "git@ssh.dev.azure.com:v3/org-a/project/web"], + [ + "vs-ssh.visualstudio.com/v3/org-a/project/web", + "git@vs-ssh.visualstudio.com:v3/org-a/project/web", + ], + [ + "org-a.visualstudio.com/defaultcollection/project/_git/web", + "https://org-a.visualstudio.com/DefaultCollection/Project/_git/Web", + ], + ["dev.azure.com/org-a/project/_git/web", "https://org-a@dev.azure.com/org-a/Project/_git/Web"], + ])("matches browser references against the checkout %s", (canonicalKey, remoteUrl) => { + const identity: ChangeRequestProjectIdentity = { + canonicalKey, + displayName: canonicalKey.split("/").slice(1).join("/"), + locator: { source: "git-remote", remoteName: "origin", remoteUrl }, + }; + const link = parse("https://dev.azure.com/org-a/project/_git/web/pullrequest/42")!; + expect(definition.isChangeRequestInRepository(identity, link)).toBe(true); + for (const repository of ["org-b/project/_git/web", "org-a/other-project/_git/web"]) { + expect(definition.isChangeRequestInRepository(identity, { ...link, repository })).toBe(false); + } + }); +}); diff --git a/packages/source-control-azure-devops/src/client/definition.ts b/packages/source-control-azure-devops/src/client/definition.ts index 0149e9fa928a..ac83fb8fc3af 100644 --- a/packages/source-control-azure-devops/src/client/definition.ts +++ b/packages/source-control-azure-devops/src/client/definition.ts @@ -1,11 +1,15 @@ import { SourceControlProviderKind } from "@t3tools/contracts"; import { canonicalRepositoryKey } from "@t3tools/shared/sourceControl"; import { + changeRequestUrlBelow, defineSourceControlClient, isChangeRequestAuthorityOfProject, - isChangeRequestPath, + parseChangeRequestPath, + remoteWebOrigin, } from "@t3tools/source-control-core/client/definition"; +// Azure DevOps Server runs on any hostname; its `_git` route identifies it. +const CHANGE_REQUEST_PATH = /^\/((?:[^/]+\/)*_git\/[^/]+)\/pullrequest\/(\d+)(?:\/|$)/u; const CHECKOUT_COMMAND = /^az\s+repos\s+pr\s+checkout\s+(.+)$/i; const CHANGE_REQUEST_REFERENCE = /^https:\/\/(?:dev\.azure\.com\/[^/\s]+\/[^/\s]+|[^/\s]+\.visualstudio\.com\/[^/\s]+)\/_git\/[^/\s]+\/pullrequest\/(\d+)(?:[/?#].*)?$/i; @@ -33,8 +37,19 @@ export const definition = defineSourceControlClient({ publishHost: () => "dev.azure.com", newRepositoryOwner: () => null, defaultCloneTransport: "ssh", - changeRequestUrl: ({ host, repository, number }) => - `https://${canonicalRepositoryKey(`${host}/${repository}`.toLowerCase())}/pullrequest/${number}`, + changeRequestUrl: ({ host, repository, number, remoteUrl, webUrl }) => { + if (webUrl) return changeRequestUrlBelow(webUrl, `pullrequest/${number}`); + const [canonicalHost = host, ...path] = canonicalRepositoryKey( + `${host}/${repository}`.toLowerCase(), + ).split("/"); + return `${remoteWebOrigin(canonicalHost, remoteUrl)}/${path.join("/")}/pullrequest/${number}`; + }, + // Servers on different ports are different servers, so a port stays in the link. + parseChangeRequestUrl: (url) => { + const link = parseChangeRequestPath(url, CHANGE_REQUEST_PATH); + return link === null || url.port === "" ? link : { ...link, authority: url.host.toLowerCase() }; + }, + changeRequestUrlFromReferenceAutolink: () => null, changeRequestActions: new Set([ "merge", "ready", @@ -53,12 +68,10 @@ export const definition = defineSourceControlClient({ return args ? checkoutCommandId(args) : null; }, isChangeRequestReference: (url) => CHANGE_REQUEST_REFERENCE.test(url), - changeRequestUrlHost: (url) => url.hostname, checkoutChangeRequestHost: () => null, isChangeRequestInRepository: (identity, link) => isChangeRequestAuthorityOfProject(identity, link) && canonicalRepositoryKey(identity.canonicalKey.toLowerCase()) === canonicalRepositoryKey(`${link.host}/${link.repository}`.toLowerCase()), canReadChangeRequestOnHost: () => false, - isChangeRequestUrl: (url) => isChangeRequestPath(url, "/pullrequest/"), }); diff --git a/packages/source-control-azure-devops/src/server/driver.ts b/packages/source-control-azure-devops/src/server/driver.ts index 3932e8425437..1d218c303854 100644 --- a/packages/source-control-azure-devops/src/server/driver.ts +++ b/packages/source-control-azure-devops/src/server/driver.ts @@ -1,12 +1,14 @@ -import { SourceControlProviderKind } from "@t3tools/contracts"; import { defineSourceControlDriver } from "@t3tools/source-control-core/server/driver"; import * as Effect from "effect/Effect"; +import { definition } from "../client/definition.ts"; + import * as AzureDevOpsPullRequestProvider from "./AzureDevOpsPullRequestProvider.ts"; import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlProvider.ts"; export const driver = defineSourceControlDriver({ - kind: SourceControlProviderKind.make("azure-devops"), + kind: definition.kind, + definition, make: Effect.all({ sourceControl: AzureDevOpsSourceControlProvider.make, discovery: Effect.succeed(AzureDevOpsSourceControlProvider.discovery), diff --git a/packages/source-control-bitbucket/src/client/definition.test.ts b/packages/source-control-bitbucket/src/client/definition.test.ts new file mode 100644 index 000000000000..5c53884c3376 --- /dev/null +++ b/packages/source-control-bitbucket/src/client/definition.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { definition } from "./definition.ts"; + +const parse = (url: string) => definition.parseChangeRequestUrl(new URL(url)); + +describe("Bitbucket change request URLs", () => { + it("reads a pull request on Bitbucket hosts", () => { + expect(parse("https://bitbucket.org/workspace/repo/pull-requests/5/diff")).toEqual({ + host: "bitbucket.org", + repository: "workspace/repo", + number: 5, + repositoryUrl: "https://bitbucket.org/workspace/repo", + }); + expect(parse("https://code.bitbucket.example.test/team/repo/pull-requests/6")).toMatchObject({ + host: "code.bitbucket.example.test", + number: 6, + }); + }); + + it("leaves other hosts alone", () => { + expect(parse("https://scm.example.test/workspace/repo/pull-requests/5")).toBeNull(); + }); + + it("builds pull request URLs below the repository", () => { + expect( + definition.changeRequestUrl({ host: "bitbucket.org", repository: "w/repo", number: 5 }), + ).toBe("https://bitbucket.org/w/repo/pull-requests/5"); + expect( + definition.changeRequestUrl({ + host: "bitbucket.org", + repository: "w/repo", + number: 5, + webUrl: "https://bitbucket.org/w/repo", + }), + ).toBe("https://bitbucket.org/w/repo/pull-requests/5"); + }); +}); diff --git a/packages/source-control-bitbucket/src/client/definition.ts b/packages/source-control-bitbucket/src/client/definition.ts index 65d11a707596..2d423fbb2279 100644 --- a/packages/source-control-bitbucket/src/client/definition.ts +++ b/packages/source-control-bitbucket/src/client/definition.ts @@ -6,17 +6,29 @@ import { import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { + changeRequestUrlBelow, defineSourceControlClient, isChangeRequestInProjectRepository, isChangeRequestOnProjectHost, - isChangeRequestPath, + parseChangeRequestPath, } from "@t3tools/source-control-core/client/definition"; const safeShellArgument = /^[A-Za-z0-9._/@+=,-]+$/; const repositoryName = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/; +const CHANGE_REQUEST_PATH = /^\/([^/]+\/[^/]+)\/pull-requests\/(\d+)(?:\/|$)/u; + const KIND = SourceControlProviderKind.make("bitbucket"); +function isBitbucketHost(hostname: string): boolean { + const host = hostname.toLowerCase(); + return ( + host === "bitbucket.org" || + host.endsWith(".bitbucket.org") || + host.split(".").includes("bitbucket") + ); +} + const text = (annotations: Schema.Annotations.Key) => TrimmedString.pipe( Schema.withDecodingDefault(Effect.succeed("")), @@ -62,8 +74,13 @@ export const definition = defineSourceControlClient({ // A new repository belongs to a workspace, which the signed-in account does not name. newRepositoryOwner: () => null, defaultCloneTransport: "ssh", - changeRequestUrl: ({ host, repository, number }) => - `https://${host}/${repository}/pull-requests/${number}`, + changeRequestUrl: ({ host, repository, number, webUrl }) => + webUrl + ? changeRequestUrlBelow(webUrl, `pull-requests/${number}`) + : `https://${host}/${repository}/pull-requests/${number}`, + parseChangeRequestUrl: (url) => + isBitbucketHost(url.hostname) ? parseChangeRequestPath(url, CHANGE_REQUEST_PATH) : null, + changeRequestUrlFromReferenceAutolink: () => null, // No endpoint reopens a declined pull request, and nothing documented moves one in or out of // draft, so neither is offered rather than failing when pressed. changeRequestActions: new Set(["merge", "close"] as const), @@ -79,12 +96,10 @@ export const definition = defineSourceControlClient({ reviewSummaryRequired: () => false, checkoutCommandArgument: () => null, isChangeRequestReference: () => false, - changeRequestUrlHost: (url) => url.hostname, checkoutChangeRequestHost: () => null, isChangeRequestInRepository: (identity, link) => isChangeRequestInProjectRepository(KIND, identity, link), canReadChangeRequestOnHost: (identity, link) => isChangeRequestOnProjectHost(KIND, identity, link), - isChangeRequestUrl: (url) => isChangeRequestPath(url, "/pull-requests/"), settings, }); diff --git a/packages/source-control-bitbucket/src/server/driver.ts b/packages/source-control-bitbucket/src/server/driver.ts index 381ce7bf206f..19d35d82015c 100644 --- a/packages/source-control-bitbucket/src/server/driver.ts +++ b/packages/source-control-bitbucket/src/server/driver.ts @@ -1,12 +1,14 @@ -import { SourceControlProviderKind } from "@t3tools/contracts"; import { defineSourceControlDriver } from "@t3tools/source-control-core/server/driver"; import * as Effect from "effect/Effect"; +import { definition } from "../client/definition.ts"; + import * as BitbucketPullRequestProvider from "./BitbucketPullRequestProvider.ts"; import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts"; export const driver = defineSourceControlDriver({ - kind: SourceControlProviderKind.make("bitbucket"), + kind: definition.kind, + definition, make: Effect.all({ sourceControl: BitbucketSourceControlProvider.make, discovery: BitbucketSourceControlProvider.makeDiscovery, diff --git a/packages/source-control-core/package.json b/packages/source-control-core/package.json index d463190918b2..4ecd07cc8678 100644 --- a/packages/source-control-core/package.json +++ b/packages/source-control-core/package.json @@ -7,6 +7,10 @@ "types": "./src/client/definition.ts", "import": "./src/client/definition.ts" }, + "./client/threadPullRequests": { + "types": "./src/client/threadPullRequests.ts", + "import": "./src/client/threadPullRequests.ts" + }, "./server/diffFileRevisions": { "types": "./src/server/diffFileRevisions.ts", "import": "./src/server/diffFileRevisions.ts" diff --git a/packages/source-control-core/src/client/definition.ts b/packages/source-control-core/src/client/definition.ts index fde7ab9fba00..69a4fa402627 100644 --- a/packages/source-control-core/src/client/definition.ts +++ b/packages/source-control-core/src/client/definition.ts @@ -5,9 +5,25 @@ import { type RepositoryIdentity, SourceControlProviderKind, } from "@t3tools/contracts"; -import type { ChangeRequestLink } from "@t3tools/shared/changeRequestUrl"; import * as Schema from "effect/Schema"; +/** + * The two strings are what `pullRequestHostOf` and the project's `repositoryIdentity` produce + * from a git remote — lower case, no port, the full path below the host — because links are + * matched against those. `authority` keeps the port for hosts whose identity includes it. + */ +export interface ChangeRequestLink { + readonly host: string; + readonly repository: string; + readonly number: number; + readonly authority?: string; +} + +export interface ParsedChangeRequestUrl extends ChangeRequestLink { + /** The repository's browser URL, without query or hash. */ + readonly repositoryUrl: string; +} + export interface ChangeRequestTerminology { readonly shortLabel: string; readonly singular: string; @@ -18,6 +34,7 @@ export interface ChangeRequestUrlInput { readonly repository: string; readonly number: number; readonly remoteUrl?: string | undefined; + /** The repository's browser URL; when given, the result is this plus the host's route. */ readonly webUrl?: string | undefined; } @@ -46,6 +63,14 @@ export interface SourceControlClientDefinition { readonly newRepositoryOwner: (account: string | null) => { readonly owner: string | null } | null; readonly defaultCloneTransport: "https" | "ssh"; readonly changeRequestUrl: (input: ChangeRequestUrlInput) => string | null; + /** Null when the URL is not one of this host's change requests. */ + readonly parseChangeRequestUrl: (url: URL) => ParsedChangeRequestUrl | null; + /** + * The change request URL a bare `#123` autolink might name, for hosts that write every + * reference through their issue route. Only a candidate: the caller must read it as a change + * request before treating it as one. + */ + readonly changeRequestUrlFromReferenceAutolink: (url: URL) => string | null; /** * The actions this host can take on a change request, the same list its server provider * declares in `capabilities.actions`. @@ -65,9 +90,7 @@ export interface SourceControlClientDefinition { identity: ChangeRequestProjectIdentity, link: ChangeRequestLink, ) => boolean; - readonly changeRequestUrlHost: (url: URL) => string; readonly checkoutChangeRequestHost: (identity: ChangeRequestProjectIdentity) => string | null; - readonly isChangeRequestUrl: (url: string) => boolean; readonly settings?: SourceControlHostSettingsSchema; } @@ -75,14 +98,44 @@ export type SourceControlHostSettingsSchema = { readonly fields: Readonly>; } & Schema.Decoder; -export function isChangeRequestPath(url: string, route: string): boolean { +/** + * Reads a change request from `url` with `route`, whose first group is the repository path and + * second the number. + */ +export function parseChangeRequestPath(url: URL, route: RegExp): ParsedChangeRequestUrl | null { + if (url.protocol !== "https:" && url.protocol !== "http:") return null; + const match = route.exec(url.pathname); + const repository = match?.[1]; + const number = Number(match?.[2]); + return repository && Number.isSafeInteger(number) && number > 0 + ? { + host: url.hostname.toLowerCase(), + repository: repository.toLowerCase(), + number, + repositoryUrl: `${url.origin}/${repository}`, + } + : null; +} + +export function changeRequestUrlBelow(webUrl: string, route: string): string { + return `${webUrl.replace(/\/+$/u, "")}/${route}`; +} + +/** An HTTP remote on `host` names the server's web origin, port included; SSH remotes do not. */ +export function remoteWebOrigin(host: string, remoteUrl: string | undefined): string { try { - const path = new URL(url).pathname; - const at = path.indexOf(route); - return at > 0 && /^\d+(?:\/|$)/u.test(path.slice(at + route.length)); + const remote = new URL(remoteUrl ?? ""); + if ( + (remote.protocol === "http:" || remote.protocol === "https:") && + (remote.hostname.toLowerCase() === host.toLowerCase() || + remote.host.toLowerCase() === host.toLowerCase()) + ) { + return remote.origin; + } } catch { - return false; + // SSH remotes do not specify the server's web origin. } + return `https://${host}`; } export function defineSourceControlClient( @@ -150,6 +203,8 @@ export const UNKNOWN_SOURCE_CONTROL_CLIENT: SourceControlClientDefinition = { newRepositoryOwner: () => null, defaultCloneTransport: "ssh", changeRequestUrl: () => null, + parseChangeRequestUrl: () => null, + changeRequestUrlFromReferenceAutolink: () => null, changeRequestActions: new Set(), checkoutCommand: () => null, authorProfileUrl: () => null, @@ -157,26 +212,49 @@ export const UNKNOWN_SOURCE_CONTROL_CLIENT: SourceControlClientDefinition = { reviewSummaryRequired: () => false, checkoutCommandArgument: () => null, isChangeRequestReference: () => false, - changeRequestUrlHost: (url) => url.hostname, checkoutChangeRequestHost: () => null, isChangeRequestInRepository: (identity, link) => isChangeRequestInProjectRepository(SourceControlProviderKind.make("unknown"), identity, link), canReadChangeRequestOnHost: (identity, link) => isChangeRequestOnProjectHost(SourceControlProviderKind.make("unknown"), identity, link), - isChangeRequestUrl: () => false, }; +export interface ParsedChangeRequest { + readonly definition: SourceControlClientDefinition; + readonly link: ParsedChangeRequestUrl; +} + export interface SourceControlClientRegistry { readonly definitions: ReadonlyArray; readonly get: (kind: string | null | undefined) => SourceControlClientDefinition; readonly find: (kind: string) => SourceControlClientDefinition | undefined; readonly findByPublicHost: (hostname: string) => SourceControlClientDefinition | undefined; + /** The first host, in registry order, that reads `url` as one of its change requests. */ + readonly parseChangeRequestUrl: (url: string) => ParsedChangeRequest | null; readonly findByChangeRequestUrl: (url: string) => SourceControlClientDefinition | undefined; readonly hostLabelForChangeRequestUrl: (url: string) => string; + readonly isSameChangeRequest: (left: string, right: string) => boolean; + readonly changeRequestUrlFromReferenceAutolink: (url: string) => string | null; +} + +function httpUrl(value: string): URL | null { + try { + const url = new URL(value); + return url.protocol === "https:" || url.protocol === "http:" ? url : null; + } catch { + return null; + } } +/** + * `definitions` is display order, and its first entry is what `get` returns for a repository + * that has not reported its host yet. A change request URL goes to the first definition that + * reads it, trying `claimChangeRequestUrlsFirst` before the rest, so a host that claims a route + * only on its own hostnames can precede one that claims the route everywhere. + */ export function makeSourceControlClientRegistry( definitions: ReadonlyArray, + options?: { readonly claimChangeRequestUrlsFirst?: ReadonlyArray }, ): SourceControlClientRegistry { const byKind = new Map(); for (const definition of definitions) { @@ -185,6 +263,20 @@ export function makeSourceControlClientRegistry( } byKind.set(definition.kind, definition); } + const first = new Set(options?.claimChangeRequestUrlsFirst); + const claimOrder = [ + ...definitions.filter((definition) => first.has(definition.kind)), + ...definitions.filter((definition) => !first.has(definition.kind)), + ]; + const parseChangeRequestUrl = (value: string): ParsedChangeRequest | null => { + const url = httpUrl(value); + if (url === null) return null; + for (const definition of claimOrder) { + const link = definition.parseChangeRequestUrl(url); + if (link !== null) return { definition, link }; + } + return null; + }; return { definitions, get: (kind) => @@ -194,10 +286,31 @@ export function makeSourceControlClientRegistry( const host = hostname.toLowerCase(); return definitions.find((definition) => definition.publicHost === host); }, - findByChangeRequestUrl: (url) => - definitions.find((definition) => definition.isChangeRequestUrl(url)), + parseChangeRequestUrl, + findByChangeRequestUrl: (url) => parseChangeRequestUrl(url)?.definition, hostLabelForChangeRequestUrl: (url) => - definitions.find((definition) => definition.isChangeRequestUrl(url))?.label ?? "the host", + parseChangeRequestUrl(url)?.definition.label ?? "the host", + isSameChangeRequest: (left, right) => { + const a = parseChangeRequestUrl(left)?.link; + const b = parseChangeRequestUrl(right)?.link; + return ( + a !== undefined && + b !== undefined && + a.host === b.host && + a.repository === b.repository && + a.number === b.number && + a.authority === b.authority + ); + }, + changeRequestUrlFromReferenceAutolink: (value) => { + const url = httpUrl(value); + if (url === null) return null; + for (const definition of claimOrder) { + const candidate = definition.changeRequestUrlFromReferenceAutolink(url); + if (candidate !== null) return candidate; + } + return null; + }, }; } diff --git a/packages/source-control-core/src/client/threadPullRequests.ts b/packages/source-control-core/src/client/threadPullRequests.ts new file mode 100644 index 000000000000..a16fbae8572f --- /dev/null +++ b/packages/source-control-core/src/client/threadPullRequests.ts @@ -0,0 +1,372 @@ +import type { + OrchestrationV2AppThread, + ThreadLinkedPullRequest, + ThreadPullRequestKey, + ThreadPullRequestLink, +} from "@t3tools/contracts"; +import { canonicalRepositoryKey } from "@t3tools/shared/sourceControl"; +import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; + +import type { SourceControlClientRegistry } from "./definition.ts"; + +type ThreadPullRequestKeySource = ThreadPullRequestKey & { + readonly authority?: string; + readonly url?: string; +}; + +function isOpen(link: ThreadPullRequestLink): boolean { + return link.snapshot === null || link.snapshot.state === "open"; +} + +function latestUpdatedAt(link: ThreadPullRequestLink): number { + const value = link.snapshot?.updatedAt ?? link.linkedAt; + const ms = Date.parse(value); + return Number.isNaN(ms) ? 0 : ms; +} + +export type ThreadCurrentPullRequest = + | { readonly kind: "single"; readonly link: ThreadPullRequestLink } + | { + readonly kind: "stack"; + readonly open: ReadonlyArray; + readonly top: ThreadPullRequestLink; + }; + +function layersFromTop(chain: ThreadPullRequestChain): Array { + // `.sort()` on a fresh array, not `.toSorted()`: this runs on Hermes, which has no ES2023 + // array methods, and a TypeError here is fatal on every mobile launch that renders a stack. + return chain.layers + .map((link, index) => ({ link, index, depth: chain.depths[index]! })) + .sort((left, right) => right.depth - left.depth || right.index - left.index) + .map((entry) => entry.link); +} + +export interface ThreadPullRequestChain { + readonly kind: "native" | "derived"; + readonly layers: ReadonlyArray; + /** One per layer: 0 for the base, parent's depth + 1 above it. Siblings share a depth. */ + readonly depths: ReadonlyArray; +} + +export type ThreadPullRequestBadge = { + readonly state: "open" | "closed" | "merged" | "draft"; +} & ( + | { + readonly kind: "stack"; + readonly layers: number; + } + | { readonly kind: "pull-request"; readonly others: number } +); + +/** + * Thread pull request identity reads a link's URL through the hosts in `registry`, so each side + * binds these once to its own registry. + */ +export function makeThreadPullRequests( + registry: Pick, +) { + const parse = (url: string) => registry.parseChangeRequestUrl(url)?.link ?? null; + + function normalizeThreadPullRequestKey(key: ThreadPullRequestKeySource): ThreadPullRequestKey { + const parsed = key.url === undefined ? null : parse(key.url); + const authority = + key.authority ?? + (parsed?.repository === key.repository.trim().toLowerCase() && parsed.number === key.number + ? parsed.authority + : undefined); + const canonical = canonicalRepositoryKey( + `${(authority ?? key.host).trim().toLowerCase()}/${key.repository.trim().toLowerCase()}`, + ); + const separator = canonical.indexOf("/"); + return { + host: canonical.slice(0, separator), + repository: canonical.slice(separator + 1), + number: key.number, + }; + } + + function legacyThreadPullRequestKey( + linked: Pick, + fallbackHost?: string, + ): ThreadPullRequestKey { + const parsed = parse(linked.url); + if (parsed !== null && parsed.number === linked.number) { + const canonical = canonicalRepositoryKey(`${parsed.host}/${parsed.repository}`); + if (parsed.authority !== undefined || canonical.startsWith("dev.azure.com/")) { + return normalizeThreadPullRequestKey(parsed); + } + } + let host = fallbackHost; + if (host === undefined) { + try { + host = new URL(linked.url).hostname; + } catch { + host = "unknown"; + } + } + return { + host: host.trim().toLowerCase() || "unknown", + repository: linked.repository.trim().toLowerCase(), + number: linked.number, + }; + } + + function threadPullRequestKeysEqual( + left: ThreadPullRequestKeySource, + right: ThreadPullRequestKeySource, + ): boolean { + return threadPullRequestKeyOf(left) === threadPullRequestKeyOf(right); + } + + // Clients re-derive chains for every link on each render, and normalizing parses the + // link's URL. Links are immutable and keep their identity until they change. + const normalizedKeys = new WeakMap(); + + function normalizedKeyOf(key: ThreadPullRequestKeySource): ThreadPullRequestKey { + let normalized = normalizedKeys.get(key); + if (normalized === undefined) { + normalized = normalizeThreadPullRequestKey(key); + normalizedKeys.set(key, normalized); + } + return normalized; + } + + function threadPullRequestKeyOf(key: ThreadPullRequestKeySource): string { + const normalized = normalizedKeyOf(key); + return `${normalized.host}/${normalized.repository}#${normalized.number}`; + } + + function resolveThreadCurrentPullRequest( + links: ReadonlyArray, + ): ThreadCurrentPullRequest | null { + const visible = visibleThreadPullRequests(links); + if (visible.length === 0) return null; + const open = visible.filter(isOpen); + if (open.length === 1) return { kind: "single", link: open[0]! }; + const chains = resolveThreadPullRequestChains(visible); + if (open.length > 1) { + const openChains = chains + .map((chain) => layersFromTop(chain).filter(isOpen)) + .filter((layers) => layers.length > 0) + .sort( + (left, right) => + Math.max(...right.map((link) => Date.parse(link.linkedAt))) - + Math.max(...left.map((link) => Date.parse(link.linkedAt))), + ); + const ordered = openChains.flat(); + return { kind: "stack", open: ordered, top: ordered[0]! }; + } + if (chains.length === 1) { + return { kind: "single", link: layersFromTop(chains[0]!)[0]! }; + } + const terminal = [...visible].sort( + (left, right) => latestUpdatedAt(right) - latestUpdatedAt(left), + ); + return { kind: "single", link: terminal[0]! }; + } + + function resolveThreadCurrentPullRequestLink( + links: ReadonlyArray, + ): ThreadPullRequestLink | null { + const current = resolveThreadCurrentPullRequest(links); + if (current === null) return null; + return current.kind === "single" ? current.link : current.top; + } + + function resolveThreadPullRequestChains( + links: ReadonlyArray, + ): ReadonlyArray { + const visible = visibleThreadPullRequests(links); + const chains: Array = []; + const placed = new Set(); + + const nativeStacks = new Map>(); + for (const link of visible) { + if (link.stack === null) continue; + const key = normalizedKeyOf(link); + const stackKey = `${key.host}/${key.repository}#stack:${link.stack.id}`; + const members = nativeStacks.get(stackKey) ?? []; + members.push(link); + nativeStacks.set(stackKey, members); + } + for (const members of nativeStacks.values()) { + // Saved snapshots can lag behind newer members of the same stack. + const memberNumbers = new Set(members.map((member) => member.number)); + let layers = members[0]!.stack!.layers; + let coverage = layers.filter((layer) => memberNumbers.has(layer.number)).length; + for (const member of members.slice(1)) { + const candidate = member.stack!.layers; + const candidateCoverage = candidate.filter((layer) => + memberNumbers.has(layer.number), + ).length; + if ( + candidateCoverage > coverage || + (candidateCoverage === coverage && candidate.length > layers.length) + ) { + layers = candidate; + coverage = candidateCoverage; + } + } + const order = new Map(layers.map((layer, index) => [layer.number, index])); + members.sort( + (left, right) => + (order.get(left.number) ?? layers.length) - (order.get(right.number) ?? layers.length), + ); + for (const member of members) placed.add(threadPullRequestKeyOf(member)); + chains.push({ kind: "native", layers: members, depths: members.map((_, index) => index) }); + } + + const remaining = visible.filter((link) => !placed.has(threadPullRequestKeyOf(link))); + const branchKey = (link: ThreadPullRequestLink, branch: string) => { + const key = normalizedKeyOf(link); + return `${key.host}/${key.repository}:${branch}`; + }; + const byHead = new Map(); + for (const link of remaining) { + if (link.snapshot === null) continue; + const key = branchKey(link, link.snapshot.headBranch); + byHead.set(key, byHead.has(key) ? null : link); + } + const parentOf = (link: ThreadPullRequestLink) => { + if (link.snapshot === null) return undefined; + const parent = byHead.get(branchKey(link, link.snapshot.baseBranch)); + return parent == null || parent === link ? undefined : parent; + }; + const children = new Map>(); + for (const link of remaining) { + const parent = parentOf(link); + if (parent === undefined) continue; + const key = threadPullRequestKeyOf(parent); + const siblings = children.get(key) ?? []; + siblings.push(link); + children.set(key, siblings); + } + for (const base of remaining) { + if (parentOf(base) !== undefined) continue; + const layers: Array = []; + const depths: Array = []; + const pending: Array<{ readonly link: ThreadPullRequestLink; readonly depth: number }> = [ + { link: base, depth: 0 }, + ]; + for (let next = pending.pop(); next !== undefined; next = pending.pop()) { + const key = threadPullRequestKeyOf(next.link); + // Two links with one identity would otherwise share a child list and expand forever. + if (placed.has(key)) continue; + placed.add(key); + layers.push(next.link); + depths.push(next.depth); + const below = children.get(key) ?? []; + for (let index = below.length - 1; index >= 0; index -= 1) { + pending.push({ link: below[index]!, depth: next.depth + 1 }); + } + } + if (layers.length > 0) chains.push({ kind: "derived", layers, depths }); + } + for (const link of remaining) { + if (!placed.has(threadPullRequestKeyOf(link))) { + chains.push({ kind: "derived", layers: [link], depths: [0] }); + } + } + return chains; + } + + function resolveThreadPullRequestBadge( + pullRequests: ReadonlyArray | undefined, + ): ThreadPullRequestBadge | null { + const visible = visibleThreadPullRequests(pullRequests ?? []); + if (visible.length === 0) return null; + const states = visible.map((link) => link.snapshot?.state ?? "open"); + const state = visible.every((link) => link.snapshot?.state === "open" && link.snapshot.isDraft) + ? "draft" + : states.includes("open") + ? "open" + : states.every((entry) => entry === "merged") + ? "merged" + : "closed"; + const chains = resolveThreadPullRequestChains(visible); + if (visible.length > 1 && chains.length === 1) { + return { kind: "stack", layers: visible.length, state }; + } + return { kind: "pull-request", others: visible.length - 1, state }; + } + + /** Older V2 event payloads stored one link; an explicit empty array means it was unlinked. */ + function threadPullRequestsOf(thread: { + readonly pullRequests?: ReadonlyArray | undefined; + readonly linkedPullRequest?: ThreadLinkedPullRequest | null | undefined; + }): ReadonlyArray { + if (thread.pullRequests !== undefined) return thread.pullRequests; + const linked = thread.linkedPullRequest; + return linked == null + ? [] + : [ + { + ...legacyThreadPullRequestKey(linked), + url: linked.url, + source: "manual", + linkedAt: "1970-01-01T00:00:00.000Z", + snapshot: null, + stack: null, + }, + ]; + } + + function applyThreadPullRequestLinkUpdate( + thread: OrchestrationV2AppThread, + updated: ThreadPullRequestLink, + ): OrchestrationV2AppThread { + return { + ...thread, + pullRequests: threadPullRequestsOf(thread).map((link) => + threadPullRequestKeysEqual(link, updated) ? updated : link, + ), + }; + } + + return { + normalizeThreadPullRequestKey, + legacyThreadPullRequestKey, + threadPullRequestKeysEqual, + threadPullRequestKeyOf, + resolveThreadCurrentPullRequest, + resolveThreadCurrentPullRequestLink, + resolveThreadPullRequestChains, + resolveThreadPullRequestBadge, + threadPullRequestsOf, + applyThreadPullRequestLinkUpdate, + }; +} + +export interface PullRequestListLine { + readonly link: ThreadPullRequestLink; + readonly depth: number; + readonly chainKey: string; + readonly stack: { readonly kind: ThreadPullRequestChain["kind"]; readonly size: number } | null; +} + +function chainKeyOf(chain: ThreadPullRequestChain): string { + const bottom = chain.layers[0]!; + return `${bottom.host}/${bottom.repository}#${bottom.number}`; +} + +export function pullRequestListLines( + chains: ReadonlyArray, +): ReadonlyArray { + const ordered = [...chains].sort( + (left, right) => + Math.max(...right.layers.map(latestUpdatedAt)) - + Math.max(...left.layers.map(latestUpdatedAt)), + ); + return ordered.flatMap((chain) => { + const chainKey = chainKeyOf(chain); + return chain.layers.map((link, index) => ({ + link, + depth: chain.depths[index]!, + chainKey, + stack: + index === 0 && chain.layers.length > 1 + ? { kind: chain.kind, size: chain.layers.length } + : null, + })); + }); +} diff --git a/packages/source-control-core/src/server/driver.ts b/packages/source-control-core/src/server/driver.ts index aef99bf5e4f9..360868770abf 100644 --- a/packages/source-control-core/src/server/driver.ts +++ b/packages/source-control-core/src/server/driver.ts @@ -1,6 +1,8 @@ import type { SourceControlProviderKind } from "@t3tools/contracts"; import type * as Effect from "effect/Effect"; +import type { SourceControlClientDefinition } from "../client/definition.ts"; + import type { SourceControlProviderDiscoverySpec } from "./discovery.ts"; import type { PullRequestProviderApi } from "./PullRequestProvider.ts"; import type * as SourceControlProvider from "./SourceControlProvider.ts"; @@ -14,6 +16,7 @@ export interface SourceControlDriverInstance { export interface SourceControlDriver { readonly kind: SourceControlProviderKind; + readonly definition: SourceControlClientDefinition; readonly make: Effect.Effect; } diff --git a/packages/source-control-forgejo/src/client/definition.test.ts b/packages/source-control-forgejo/src/client/definition.test.ts new file mode 100644 index 000000000000..74d4cddbcd4d --- /dev/null +++ b/packages/source-control-forgejo/src/client/definition.test.ts @@ -0,0 +1,104 @@ +import type { ChangeRequestProjectIdentity } from "@t3tools/source-control-core/client/definition"; +import { describe, expect, it } from "vite-plus/test"; + +import { definition } from "./definition.ts"; + +const parse = (url: string) => definition.parseChangeRequestUrl(new URL(url))!; + +const identity = ( + remoteUrl: string, + fields: Partial = {}, +): ChangeRequestProjectIdentity => ({ + canonicalKey: "forge.example/git/team/repo", + displayName: "git/team/repo", + locator: { source: "git-remote", remoteName: "origin", remoteUrl }, + ...fields, +}); + +const onHost = (project: ChangeRequestProjectIdentity, link: ReturnType) => + definition.isChangeRequestInRepository(project, link) || + definition.canReadChangeRequestOnHost(project, link); + +describe("Forgejo change request URLs", () => { + it("reads nested paths and keeps the server's port as the authority", () => { + expect(parse("http://git.example.test:3000/Team/Repo/pulls/42/files")).toEqual({ + host: "git.example.test", + authority: "git.example.test:3000", + repository: "team/repo", + number: 42, + repositoryUrl: "http://git.example.test:3000/Team/Repo", + }); + expect(parse("https://github.internal/team/repo/pulls/7")).toMatchObject({ + host: "github.internal", + authority: "github.internal", + }); + }); + + it("claims only its own route", () => { + for (const url of [ + "https://forge.example/team/repo/pull/7", + "https://forge.example/repo/pulls/7", + "https://forge.example/team/repo/pulls/abc", + ]) { + expect(definition.parseChangeRequestUrl(new URL(url)), url).toBeNull(); + } + }); + + it.each([ + ["http://forge.example:3000/git/owner/repo.git", "http://forge.example:3000"], + ["git@forge.example:git/owner/repo.git", "https://forge.example"], + ["http://other.example:3000/git/owner/repo.git", "https://forge.example"], + ])("keeps the matching web origin from %s", (remoteUrl, origin) => { + expect( + definition.changeRequestUrl({ + host: "forge.example", + repository: "git/owner/repo", + number: 42, + remoteUrl, + }), + ).toBe(`${origin}/git/owner/repo/pulls/42`); + }); + + it("keeps the origin when the host already carries its port", () => { + expect( + definition.changeRequestUrl({ + host: "forge.example:3000", + repository: "team/repo", + number: 42, + remoteUrl: "http://forge.example:3000/team/repo.git", + }), + ).toBe("http://forge.example:3000/team/repo/pulls/42"); + }); + + it("matches projects by HTTP port at repository and host level", () => { + const [atThree, atFour] = [3000, 4000].map((port) => + identity(`http://forge.example:${port}/git/team/repo.git`), + ); + const link = parse("http://forge.example:4000/git/team/repo/pulls/42"); + expect(definition.isChangeRequestInRepository(atFour!, link)).toBe(true); + expect(onHost(atFour!, { ...link, repository: "git/team/other" })).toBe(true); + expect(definition.isChangeRequestInRepository(atThree!, link)).toBe(false); + expect(onHost(atThree!, link)).toBe(false); + }); + + it("lets tea resolve the web port for SSH remotes, and honours a resolved web URL", () => { + const link = parse("http://forge.example:4000/git/team/repo/pulls/42"); + expect( + definition.isChangeRequestInRepository(identity("git@forge.example:git/team/repo.git"), link), + ).toBe(true); + const aliased = identity("git@ssh.forge.example:team/repo.git", { + canonicalKey: "ssh.forge.example/team/repo", + displayName: "team/repo", + webUrl: "http://forge.example:4000/git/team/repo", + }); + expect(definition.isChangeRequestInRepository(aliased, link)).toBe(true); + expect(onHost(aliased, { ...link, repository: "git/team/other" })).toBe(true); + for (const url of [ + "http://other.example:4000/git/team/repo/pulls/42", + "http://forge.example:3000/git/team/repo/pulls/42", + "http://forge.example:4000/other/team/repo/pulls/42", + ]) { + expect(onHost(aliased, parse(url)), url).toBe(false); + } + }); +}); diff --git a/packages/source-control-forgejo/src/client/definition.ts b/packages/source-control-forgejo/src/client/definition.ts index f9aa6df2a1d2..9f8c2aa3573e 100644 --- a/packages/source-control-forgejo/src/client/definition.ts +++ b/packages/source-control-forgejo/src/client/definition.ts @@ -1,17 +1,22 @@ import { SourceControlProviderKind } from "@t3tools/contracts"; import { + changeRequestUrlBelow, type ChangeRequestProjectIdentity, defineSourceControlClient, isChangeRequestAuthorityOfProject, isChangeRequestInProjectRepository, isChangeRequestOnProjectHost, - isChangeRequestPath, + parseChangeRequestPath, + remoteWebOrigin, } from "@t3tools/source-control-core/client/definition"; const CHECKOUT_COMMAND = /^tea\s+(?:pr|pulls)\s+checkout\s+(.+)$/i; const CHANGE_REQUEST_REFERENCE = /^https?:\/\/[^/\s]+\/(?:[^/\s]+\/)+[^/\s]+\/pulls\/(\d+)(?:[/?#].*)?$/i; +// Forgejo and Gitea run on any hostname, possibly mounted below a path. +const CHANGE_REQUEST_PATH = /^\/([^/]+(?:\/[^/]+)+)\/pulls\/(\d+)(?:\/|$)/u; + const KIND = SourceControlProviderKind.make("forgejo"); /** @@ -43,20 +48,16 @@ export const definition = defineSourceControlClient({ defaultCloneTransport: "https", // The server's resolved web URL wins; otherwise an HTTP remote on the same host names the // origin, which may carry a port. SSH remotes say nothing about the web origin. - changeRequestUrl: ({ host, repository, number, remoteUrl, webUrl }) => { - if (webUrl) return `${webUrl.replace(/\/+$/, "")}/pulls/${number}`; - try { - const remote = new URL(remoteUrl ?? ""); - if ( - (remote.protocol === "http:" || remote.protocol === "https:") && - (remote.hostname.toLowerCase() === host.toLowerCase() || - remote.host.toLowerCase() === host.toLowerCase()) - ) { - return `${remote.origin}/${repository}/pulls/${number}`; - } - } catch {} - return `https://${host}/${repository}/pulls/${number}`; + changeRequestUrl: ({ host, repository, number, remoteUrl, webUrl }) => + webUrl + ? changeRequestUrlBelow(webUrl, `pulls/${number}`) + : `${remoteWebOrigin(host, remoteUrl)}/${repository}/pulls/${number}`, + // A server's identity includes its port, so the link carries it. + parseChangeRequestUrl: (url) => { + const link = parseChangeRequestPath(url, CHANGE_REQUEST_PATH); + return link === null ? null : { ...link, authority: url.host.toLowerCase() }; }, + changeRequestUrlFromReferenceAutolink: () => null, changeRequestActions: new Set(["merge", "close", "reopen", "update-branch"] as const), // Neither `fj` nor `tea` checks out by number, so fetch the pull ref from the repository itself. checkoutCommand: ({ number, repositoryUrl }) => @@ -69,7 +70,6 @@ export const definition = defineSourceControlClient({ reviewSummaryRequired: (verdict) => verdict === "request-changes", checkoutCommandArgument: (input) => CHECKOUT_COMMAND.exec(input)?.[1]?.trim() ?? null, isChangeRequestReference: (url) => CHANGE_REQUEST_REFERENCE.test(url), - changeRequestUrlHost: (url) => url.host, checkoutChangeRequestHost: () => null, isChangeRequestInRepository: (identity, link) => { if (!isChangeRequestAuthorityOfProject(identity, link)) return false; @@ -94,5 +94,4 @@ export const definition = defineSourceControlClient({ (!mount || link.repository.toLowerCase().startsWith(`${mount.toLowerCase()}/`)) ); }, - isChangeRequestUrl: (url) => isChangeRequestPath(url, "/pulls/"), }); diff --git a/packages/source-control-forgejo/src/server/driver.ts b/packages/source-control-forgejo/src/server/driver.ts index dc5fa6a69116..7324741656c1 100644 --- a/packages/source-control-forgejo/src/server/driver.ts +++ b/packages/source-control-forgejo/src/server/driver.ts @@ -1,12 +1,14 @@ -import { SourceControlProviderKind } from "@t3tools/contracts"; import { defineSourceControlDriver } from "@t3tools/source-control-core/server/driver"; import * as Effect from "effect/Effect"; +import { definition } from "../client/definition.ts"; + import * as ForgejoPullRequestProvider from "./ForgejoPullRequestProvider.ts"; import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts"; export const driver = defineSourceControlDriver({ - kind: SourceControlProviderKind.make("forgejo"), + kind: definition.kind, + definition, make: Effect.all({ sourceControl: ForgejoSourceControlProvider.make, discovery: ForgejoSourceControlProvider.makeDiscovery, diff --git a/packages/source-control-gitcafe/src/client/definition.test.ts b/packages/source-control-gitcafe/src/client/definition.test.ts new file mode 100644 index 000000000000..4bc0c017aaa1 --- /dev/null +++ b/packages/source-control-gitcafe/src/client/definition.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { definition } from "./definition.ts"; + +const parse = (url: string) => definition.parseChangeRequestUrl(new URL(url)); + +describe("GitCafe change request URLs", () => { + it("reads its canonical /pull/ path and the older /pulls/ path on its own hosts", () => { + for (const path of ["pull", "pulls"]) { + expect(parse(`https://git.cafe/PingDotGG/T3Code/${path}/23897`), path).toEqual({ + host: "git.cafe", + repository: "pingdotgg/t3code", + number: 23897, + repositoryUrl: "https://git.cafe/PingDotGG/T3Code", + }); + } + expect(parse("https://staging.git.cafe/team/repo/pull/7/files")).toMatchObject({ + host: "staging.git.cafe", + repository: "team/repo", + number: 7, + }); + }); + + it("leaves other hosts and routes alone", () => { + for (const url of [ + "https://github.com/team/repo/pull/7", + "https://forge.example/team/repo/pulls/7", + "https://git.cafe/pingdotgg/t3code/issues/23897", + "https://git.cafe/pingdotgg/t3code/pull/abc", + "https://git.cafe/pingdotgg/t3code", + ]) { + expect(parse(url), url).toBeNull(); + } + }); + + it("builds pull request URLs below the repository", () => { + expect( + definition.changeRequestUrl({ host: "git.cafe", repository: "team/repo", number: 8 }), + ).toBe("https://git.cafe/team/repo/pulls/8"); + expect( + definition.changeRequestUrl({ + host: "git.cafe", + repository: "team/repo", + number: 8, + webUrl: "https://git.cafe/Team/Repo/", + }), + ).toBe("https://git.cafe/Team/Repo/pulls/8"); + }); +}); diff --git a/packages/source-control-gitcafe/src/client/definition.ts b/packages/source-control-gitcafe/src/client/definition.ts index c33cb45d5174..ac7736d541b7 100644 --- a/packages/source-control-gitcafe/src/client/definition.ts +++ b/packages/source-control-gitcafe/src/client/definition.ts @@ -7,15 +7,19 @@ import { import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { + changeRequestUrlBelow, defineSourceControlClient, isChangeRequestInProjectRepository, isChangeRequestOnProjectHost, - isChangeRequestPath, + parseChangeRequestPath, } from "@t3tools/source-control-core/client/definition"; /** GitCafe has no self-hosted installs: production and staging are its only hosts. */ const GITCAFE_HOSTS = new Set(["git.cafe", "staging.git.cafe"]); +/** GitCafe serves pull requests at `/pull/` and redirects the older `/pulls/` there. */ +const CHANGE_REQUEST_PATH = /^\/([^/]+\/[^/]+)\/pulls?\/(\d+)(?:\/|$)/u; + const KIND = SourceControlProviderKind.make("gitcafe"); export const settings = makeProviderSettingsSchema({ @@ -44,8 +48,17 @@ export const definition = defineSourceControlClient({ publishHost: () => "git.cafe", newRepositoryOwner: (account) => (account ? { owner: account } : null), defaultCloneTransport: "ssh", - changeRequestUrl: ({ host, repository, number }) => - `https://${host}/${repository}/pulls/${number}`, + changeRequestUrl: ({ host, repository, number, webUrl }) => + webUrl + ? changeRequestUrlBelow(webUrl, `pulls/${number}`) + : `https://${host}/${repository}/pulls/${number}`, + // `/pull/` and `/pulls/` are GitHub's and Forgejo's shapes too, so only GitCafe's own hosts + // claim them. + parseChangeRequestUrl: (url) => + GITCAFE_HOSTS.has(url.hostname.toLowerCase()) + ? parseChangeRequestPath(url, CHANGE_REQUEST_PATH) + : null, + changeRequestUrlFromReferenceAutolink: () => null, changeRequestActions: new Set([ "ready", "draft", @@ -58,24 +71,12 @@ export const definition = defineSourceControlClient({ authorProfileUrl: () => null, referenceAutolinkRepositoryUrl: () => null, reviewSummaryRequired: () => false, - // `/pulls/` is Forgejo's shape too, so only GitCafe's own hosts claim it. checkoutCommandArgument: () => null, isChangeRequestReference: () => false, - changeRequestUrlHost: (url) => url.hostname, checkoutChangeRequestHost: () => null, isChangeRequestInRepository: (identity, link) => isChangeRequestInProjectRepository(KIND, identity, link), canReadChangeRequestOnHost: (identity, link) => isChangeRequestOnProjectHost(KIND, identity, link), - isChangeRequestUrl: (url) => { - try { - return ( - GITCAFE_HOSTS.has(new URL(url).hostname.toLowerCase()) && - (isChangeRequestPath(url, "/pull/") || isChangeRequestPath(url, "/pulls/")) - ); - } catch { - return false; - } - }, settings, }); diff --git a/packages/source-control-gitcafe/src/server/driver.ts b/packages/source-control-gitcafe/src/server/driver.ts index 6fce65b345a4..dca35f45b75a 100644 --- a/packages/source-control-gitcafe/src/server/driver.ts +++ b/packages/source-control-gitcafe/src/server/driver.ts @@ -1,12 +1,14 @@ -import { SourceControlProviderKind } from "@t3tools/contracts"; import { defineSourceControlDriver } from "@t3tools/source-control-core/server/driver"; import * as Effect from "effect/Effect"; +import { definition } from "../client/definition.ts"; + import * as GitCafePullRequestProvider from "./GitCafePullRequestProvider.ts"; import * as GitCafeSourceControlProvider from "./GitCafeSourceControlProvider.ts"; export const driver = defineSourceControlDriver({ - kind: SourceControlProviderKind.make("gitcafe"), + kind: definition.kind, + definition, make: Effect.all({ sourceControl: GitCafeSourceControlProvider.make, discovery: GitCafeSourceControlProvider.makeDiscovery, diff --git a/packages/source-control-github/src/client/definition.test.ts b/packages/source-control-github/src/client/definition.test.ts new file mode 100644 index 000000000000..5e2153bbb056 --- /dev/null +++ b/packages/source-control-github/src/client/definition.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { definition } from "./definition.ts"; + +const parse = (url: string) => definition.parseChangeRequestUrl(new URL(url)); + +describe("GitHub change request URLs", () => { + it("reads a pull request on any host, lower-casing host and repository", () => { + expect(parse("https://github.com/T3Tools/T3Code/pull/123/files?w=1#diff")).toEqual({ + host: "github.com", + repository: "t3tools/t3code", + number: 123, + repositoryUrl: "https://github.com/T3Tools/T3Code", + }); + expect(parse("https://GHE.Example.com:8443/acme/web/pull/42/")).toEqual({ + host: "ghe.example.com", + repository: "acme/web", + number: 42, + repositoryUrl: "https://ghe.example.com:8443/acme/web", + }); + }); + + it("claims only the two-segment pull route", () => { + for (const url of [ + "https://github.com/t3tools/t3code/issues/123", + "https://github.com/t3tools/t3code/pulls/123", + "https://github.com/t3tools/t3code/pull/abc", + "https://github.com/t3tools/t3code/pull/0", + "https://github.com/group/sub/repo/pull/1", + "ftp://github.com/t3tools/t3code/pull/1", + ]) { + expect(parse(url), url).toBeNull(); + } + }); + + it("builds pull request URLs below the repository, keeping an HTTP remote's origin", () => { + expect( + definition.changeRequestUrl({ + host: "github.acme.test", + repository: "platform/api", + number: 7, + remoteUrl: "http://token@github.acme.test:8443/team/default.git", + }), + ).toBe("http://github.acme.test:8443/platform/api/pull/7"); + for (const remoteUrl of [ + "git@github.acme.test:team/default.git", + "git://github.acme.test/team/default.git", + "http://other.test/team/default.git", + ]) { + expect( + definition.changeRequestUrl({ + host: "github.acme.test", + repository: "platform/api", + number: 9, + remoteUrl, + }), + remoteUrl, + ).toBe("https://github.acme.test/platform/api/pull/9"); + } + expect( + definition.changeRequestUrl({ + host: "github.com", + repository: "pull/1", + number: 43, + webUrl: "https://github.com/pull/1", + }), + ).toBe("https://github.com/pull/1/pull/43"); + }); + + // regression: #8507 the browser fallback must not open a bogus URL + it("builds nothing from an invalid repository, number, or host", () => { + for (const [host, repository, number] of [ + ["github.com", "acme", 1], + ["github.com", "../repository", 1], + ["github.com", "acme/%2e%2e", 1], + ["github.com", "acme/repository?tab=1", 1], + ["github.com", "acme/repository", 0], + ["bad host", "acme/repository", 1], + ] as const) { + expect(definition.changeRequestUrl({ host, repository, number })).toBeNull(); + } + }); + + it("turns GitHub's shared issue route into a pull request candidate", () => { + const candidate = (url: string) => + definition.changeRequestUrlFromReferenceAutolink(new URL(url)); + expect(candidate("https://github.com/pingdotgg/t3code/issues/8600#issuecomment-1")).toBe( + "https://github.com/pingdotgg/t3code/pull/8600#issuecomment-1", + ); + expect(candidate("https://code.github.example.com/acme/web/issues/42")).toBe( + "https://code.github.example.com/acme/web/pull/42", + ); + expect(candidate("https://github.com/pingdotgg/t3code/issues/not-a-number")).toBeNull(); + expect(candidate("https://code.example.test/acme/web/issues/42")).toBeNull(); + }); +}); diff --git a/packages/source-control-github/src/client/definition.ts b/packages/source-control-github/src/client/definition.ts index 66c1cd003968..f9db4b87a3a0 100644 --- a/packages/source-control-github/src/client/definition.ts +++ b/packages/source-control-github/src/client/definition.ts @@ -10,18 +10,42 @@ import { import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { + changeRequestUrlBelow, defineSourceControlClient, isChangeRequestInProjectRepository, isChangeRequestOnProjectHost, - isChangeRequestPath, + parseChangeRequestPath, + remoteWebOrigin, } from "@t3tools/source-control-core/client/definition"; const CHECKOUT_COMMAND = /^gh\s+pr\s+checkout\s+(.+)$/i; const CHANGE_REQUEST_REFERENCE = /^https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/(\d+)(?:[/?#].*)?$/i; +// GitHub Enterprise Server runs on any hostname, so the `//pull/` route alone +// identifies a GitHub pull request. +const CHANGE_REQUEST_PATH = /^\/([^/]+\/[^/]+)\/pull\/(\d+)(?:\/|$)/u; +const ISSUE_PATH = /^\/([^/]+\/[^/]+)\/issues\/(\d+)(?:\/|$)/u; + const KIND = SourceControlProviderKind.make("github"); +function isGitHubHost(hostname: string): boolean { + const host = hostname.toLowerCase(); + return ( + host === "github.com" || host.endsWith(".github.com") || host.split(".").includes("github") + ); +} + +function isRepositoryPath(repository: string): boolean { + const segments = repository.split("/"); + return ( + segments.length === 2 && + // GitHub owner and repository names are only these characters, so nothing here can + // be an encoded dot segment, query or fragment that `new URL` would reinterpret. + segments.every((segment) => /^[\w.-]+$/u.test(segment) && segment !== "." && segment !== "..") + ); +} + export const GitHubHostChoice = Schema.Struct({ account: Schema.optionalKey(TrimmedNonEmptyString), enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), @@ -53,8 +77,25 @@ export const definition = defineSourceControlClient({ publishHost: () => "github.com", newRepositoryOwner: (account) => ({ owner: account }), defaultCloneTransport: "https", - changeRequestUrl: ({ host, repository, number }) => - `https://${host}/${repository}/pull/${number}`, + changeRequestUrl: ({ host, repository, number, remoteUrl, webUrl }) => { + if (!Number.isSafeInteger(number) || number < 1) return null; + if (webUrl) return changeRequestUrlBelow(webUrl, `pull/${number}`); + if (!isRepositoryPath(repository)) return null; + try { + return new URL(`/${repository}/pull/${number}`, remoteWebOrigin(host, remoteUrl)).toString(); + } catch { + return null; + } + }, + parseChangeRequestUrl: (url) => parseChangeRequestPath(url, CHANGE_REQUEST_PATH), + // GitHub writes every bare reference through `/issues/`, including pull requests. + changeRequestUrlFromReferenceAutolink: (url) => { + const match = isGitHubHost(url.hostname) ? ISSUE_PATH.exec(url.pathname) : null; + if (match?.[1] === undefined || match[2] === undefined) return null; + const candidate = new URL(url); + candidate.pathname = `/${match[1]}/pull/${match[2]}`; + return candidate.toString(); + }, changeRequestActions: new Set([ "merge", "ready", @@ -77,12 +118,10 @@ export const definition = defineSourceControlClient({ reviewSummaryRequired: () => false, checkoutCommandArgument: (input) => CHECKOUT_COMMAND.exec(input)?.[1]?.trim() ?? null, isChangeRequestReference: (url) => CHANGE_REQUEST_REFERENCE.test(url), - changeRequestUrlHost: (url) => url.hostname, checkoutChangeRequestHost: (identity) => pullRequestHostOf(identity, KIND), isChangeRequestInRepository: (identity, link) => isChangeRequestInProjectRepository(KIND, identity, link), canReadChangeRequestOnHost: (identity, link) => isChangeRequestOnProjectHost(KIND, identity, link), - isChangeRequestUrl: (url) => isChangeRequestPath(url, "/pull/"), settings, }); diff --git a/packages/source-control-github/src/server/driver.ts b/packages/source-control-github/src/server/driver.ts index 8aec1f6c40ac..d5247732936f 100644 --- a/packages/source-control-github/src/server/driver.ts +++ b/packages/source-control-github/src/server/driver.ts @@ -1,12 +1,14 @@ -import { SourceControlProviderKind } from "@t3tools/contracts"; import { defineSourceControlDriver } from "@t3tools/source-control-core/server/driver"; import * as Effect from "effect/Effect"; +import { definition } from "../client/definition.ts"; + import * as GitHubPullRequestProvider from "./GitHubPullRequestProvider.ts"; import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; export const driver = defineSourceControlDriver({ - kind: SourceControlProviderKind.make("github"), + kind: definition.kind, + definition, make: Effect.all({ sourceControl: GitHubSourceControlProvider.make, discovery: GitHubSourceControlProvider.makeDiscovery, diff --git a/packages/source-control-gitlab/src/client/definition.test.ts b/packages/source-control-gitlab/src/client/definition.test.ts new file mode 100644 index 000000000000..4184084e3864 --- /dev/null +++ b/packages/source-control-gitlab/src/client/definition.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { definition } from "./definition.ts"; + +const parse = (url: string) => definition.parseChangeRequestUrl(new URL(url)); + +describe("GitLab change request URLs", () => { + it("reads a merge request on any host, nested groups and all", () => { + expect(parse("https://gitlab.com/t3tools/platform/t3code/-/merge_requests/42")).toEqual({ + host: "gitlab.com", + repository: "t3tools/platform/t3code", + number: 42, + repositoryUrl: "https://gitlab.com/t3tools/platform/t3code", + }); + expect(parse("https://code.acme.test/team/project/-/merge_requests/9")).toMatchObject({ + host: "code.acme.test", + repository: "team/project", + number: 9, + }); + }); + + it("keeps pull-like segments inside nested repository paths", () => { + expect( + parse("https://gitlab.example.test/group/pull/123/repo/-/merge_requests/42")?.repositoryUrl, + ).toBe("https://gitlab.example.test/group/pull/123/repo"); + }); + + it("leaves issues and single-segment paths alone", () => { + expect(parse("https://gitlab.com/t3tools/t3code/-/issues/12")).toBeNull(); + expect(parse("https://gitlab.com/t3code/-/merge_requests/12")).toBeNull(); + }); + + it("builds merge request URLs below the repository", () => { + expect( + definition.changeRequestUrl({ + host: "gitlab.com", + repository: "group/sub/project", + number: 4, + }), + ).toBe("https://gitlab.com/group/sub/project/-/merge_requests/4"); + expect( + definition.changeRequestUrl({ + host: "gitlab.com", + repository: "group/sub/project", + number: 4, + webUrl: "https://gitlab.com/group/sub/project", + }), + ).toBe("https://gitlab.com/group/sub/project/-/merge_requests/4"); + }); +}); diff --git a/packages/source-control-gitlab/src/client/definition.ts b/packages/source-control-gitlab/src/client/definition.ts index 67cbc507cffc..a3f4a153a1a0 100644 --- a/packages/source-control-gitlab/src/client/definition.ts +++ b/packages/source-control-gitlab/src/client/definition.ts @@ -1,15 +1,19 @@ import { SourceControlProviderKind } from "@t3tools/contracts"; import { + changeRequestUrlBelow, defineSourceControlClient, isChangeRequestInProjectRepository, isChangeRequestOnProjectHost, - isChangeRequestPath, + parseChangeRequestPath, } from "@t3tools/source-control-core/client/definition"; const CHECKOUT_COMMAND = /^glab\s+mr\s+checkout\s+(.+)$/i; const CHANGE_REQUEST_REFERENCE = /^https:\/\/[^/\s]*gitlab[^/\s]*\/.+\/-\/merge_requests\/(\d+)(?:[/?#].*)?$/i; +// Self-managed GitLab runs on any hostname and nests projects in groups. +const CHANGE_REQUEST_PATH = /^\/([^/]+(?:\/[^/]+)+)\/-\/merge_requests\/(\d+)(?:\/|$)/u; + const KIND = SourceControlProviderKind.make("gitlab"); export const definition = defineSourceControlClient({ @@ -24,8 +28,12 @@ export const definition = defineSourceControlClient({ publishHost: () => "gitlab.com", newRepositoryOwner: (account) => ({ owner: account }), defaultCloneTransport: "ssh", - changeRequestUrl: ({ host, repository, number }) => - `https://${host}/${repository}/-/merge_requests/${number}`, + changeRequestUrl: ({ host, repository, number, webUrl }) => + webUrl + ? changeRequestUrlBelow(webUrl, `-/merge_requests/${number}`) + : `https://${host}/${repository}/-/merge_requests/${number}`, + parseChangeRequestUrl: (url) => parseChangeRequestPath(url, CHANGE_REQUEST_PATH), + changeRequestUrlFromReferenceAutolink: () => null, changeRequestActions: new Set([ "merge", "ready", @@ -42,11 +50,9 @@ export const definition = defineSourceControlClient({ reviewSummaryRequired: () => false, checkoutCommandArgument: (input) => CHECKOUT_COMMAND.exec(input)?.[1]?.trim() ?? null, isChangeRequestReference: (url) => CHANGE_REQUEST_REFERENCE.test(url), - changeRequestUrlHost: (url) => url.hostname, checkoutChangeRequestHost: () => null, isChangeRequestInRepository: (identity, link) => isChangeRequestInProjectRepository(KIND, identity, link), canReadChangeRequestOnHost: (identity, link) => isChangeRequestOnProjectHost(KIND, identity, link), - isChangeRequestUrl: (url) => isChangeRequestPath(url, "/-/merge_requests/"), }); diff --git a/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.test.ts b/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.test.ts index 23728f63127c..93defc897d79 100644 --- a/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.test.ts +++ b/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.test.ts @@ -1,5 +1,4 @@ import { SourceControlProviderKind } from "@t3tools/contracts"; -import { changeRequestUrlFor } from "@t3tools/shared/changeRequestUrl"; import { detectSourceControlProviderFromGitRemoteUrl, normalizeGitRemoteUrl, @@ -16,6 +15,7 @@ import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSour import * as GitLabCli from "./GitLabCli.ts"; import { parseGitLabAuthStatusHosts } from "./gitLabAuthStatus.ts"; import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts"; +import { definition } from "../client/definition.ts"; function makeProvider(gitlab: Partial) { return GitLabSourceControlProvider.make.pipe( @@ -166,15 +166,17 @@ it.effect.each([ ), }) ?? Effect.succeed(identity) ); - const [host, ...repository] = refined.canonicalKey.split("/"); - assert.strictEqual( - changeRequestUrlFor(refined.provider, host!, repository.join("/"), 42), - expectedProvider === "gitlab" - ? "https://code.example.test/team/repo/-/merge_requests/42" - : expectedProvider === "github" - ? "https://github.com/team/repo/pull/42" - : null, - ); + if (expectedProvider === "gitlab") { + const [host, ...repository] = refined.canonicalKey.split("/"); + assert.strictEqual( + definition.changeRequestUrl({ + host: host!, + repository: repository.join("/"), + number: 42, + }), + "https://code.example.test/team/repo/-/merge_requests/42", + ); + } assert.deepStrictEqual(refined, { ...identity, provider: expectedProvider }); if (expectedProvider === "github") assert.strictEqual(authCalls, 0); }), diff --git a/packages/source-control-gitlab/src/server/driver.ts b/packages/source-control-gitlab/src/server/driver.ts index 2b555786015a..175efc16269e 100644 --- a/packages/source-control-gitlab/src/server/driver.ts +++ b/packages/source-control-gitlab/src/server/driver.ts @@ -1,12 +1,14 @@ -import { SourceControlProviderKind } from "@t3tools/contracts"; import { defineSourceControlDriver } from "@t3tools/source-control-core/server/driver"; import * as Effect from "effect/Effect"; +import { definition } from "../client/definition.ts"; + import * as GitLabPullRequestProvider from "./GitLabPullRequestProvider.ts"; import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts"; export const driver = defineSourceControlDriver({ - kind: SourceControlProviderKind.make("gitlab"), + kind: definition.kind, + definition, make: Effect.all({ sourceControl: GitLabSourceControlProvider.make, discovery: Effect.succeed(GitLabSourceControlProvider.discovery), From 3d4c75370cd7d6ec91bb5c9763744125adc316ca Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 24 Oct 2026 04:24:33 -0700 Subject: [PATCH 02/27] refactor(source-control): forges recognise their own remotes and repository keys (#23993) Co-authored-by: Claude Opus 5.5 --- .../pullRequestNavigation.test.ts | 4 +- apps/server/src/git/GitManager.ts | 23 +- .../src/git/linkCreatedPullRequest.test.ts | 3 +- apps/server/src/git/linkCreatedPullRequest.ts | 9 +- .../toolkits/pullRequests/handlers.test.ts | 11 +- .../src/mcp/toolkits/pullRequests/handlers.ts | 8 +- .../ThreadPullRequestService.test.ts | 4 +- .../ThreadPullRequestService.ts | 11 +- .../ThreadSettlementService.ts | 17 +- .../src/project/AgentSessionScanner.test.ts | 7 +- .../server/src/project/AgentSessionScanner.ts | 14 +- .../src/project/RepositoryIdentityResolver.ts | 11 +- .../PullRequestReviewGuideService.ts | 4 +- .../pullRequest/PullRequestService.test.ts | 2 +- .../src/pullRequest/PullRequestService.ts | 105 +++++---- .../pullRequest/pullRequestSyncKey.test.ts | 4 +- .../src/pullRequest/pullRequestSyncKey.ts | 40 ++-- .../SourceControlProviderRegistry.ts | 6 +- .../src/sourceControl/sourceControlClients.ts | 15 +- apps/server/src/vcs/GitVcsDriverCore.ts | 7 +- apps/web/src/components/ChatView.tsx | 4 +- apps/web/src/components/RightPanelTabs.tsx | 4 +- .../components/chat/ThreadDetailsPrRow.tsx | 4 +- .../pullRequest/PullRequestsPage.tsx | 8 +- .../pullRequestDetail.logic.test.ts | 2 +- .../pullRequest/pullRequestDetail.logic.ts | 3 +- .../pullRequest/pullRequestList.logic.test.ts | 6 +- .../pullRequest/pullRequestList.logic.ts | 12 +- .../pullRequest/usePullRequestListView.ts | 7 +- apps/web/src/hooks/usePullRequestLinking.ts | 3 +- apps/web/src/lib/openPullRequestLink.ts | 4 +- .../src/changeRequestLinks.test.ts | 32 ++- .../client-runtime/src/changeRequestLinks.ts | 12 +- .../src/pullRequestLinkInput.ts | 4 +- .../src/sourceControlClients.test.ts | 114 +++++++++- .../src/sourceControlClients.ts | 15 +- .../src/state/projectGrouping.test.ts | 4 +- packages/contracts/src/environment.ts | 3 +- packages/contracts/src/pullRequest.test.ts | 19 -- packages/contracts/src/pullRequest.ts | 21 -- packages/shared/src/git.test.ts | 21 -- packages/shared/src/git.ts | 73 ++---- packages/shared/src/sourceControl.test.ts | 172 +------------- packages/shared/src/sourceControl.ts | 181 +-------------- .../src/client/definition.test.ts | 91 ++++++++ .../src/client/definition.ts | 84 ++++++- .../src/client/definition.test.ts | 17 ++ .../src/client/definition.ts | 13 +- .../src/server/BitbucketApi.ts | 9 +- .../server/BitbucketPullRequestProvider.ts | 1 + .../src/client/definition.ts | 215 ++++++++++++++++-- .../src/client/threadPullRequests.ts | 14 +- .../src/server/PullRequestProvider.ts | 5 + .../src/client/definition.test.ts | 27 +++ .../src/client/definition.ts | 36 ++- .../src/server/ForgejoPullRequestProvider.ts | 1 + .../ForgejoSourceControlProvider.test.ts | 2 +- .../src/client/definition.test.ts | 15 ++ .../src/client/definition.ts | 9 +- .../src/client/definition.test.ts | 27 +++ .../src/client/definition.ts | 19 +- .../src/server/GitHubPullRequestProvider.ts | 1 + .../src/server/gitHubRepositoryResolution.ts | 15 +- .../src/client/definition.test.ts | 25 ++ .../src/client/definition.ts | 13 +- .../GitLabSourceControlProvider.test.ts | 15 +- .../src/server/GitLabSourceControlProvider.ts | 14 +- 67 files changed, 981 insertions(+), 720 deletions(-) diff --git a/apps/mobile/src/features/pull-requests/pullRequestNavigation.test.ts b/apps/mobile/src/features/pull-requests/pullRequestNavigation.test.ts index 4d1a4ab5addb..faed80ecc134 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestNavigation.test.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestNavigation.test.ts @@ -1,4 +1,4 @@ -import { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import { EnvironmentId, ProjectId, SourceControlProviderKind } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { nativePullRequestDestination } from "../../lib/pullRequestNavigation"; @@ -10,7 +10,7 @@ const project = (id: string, canonicalKey: string, provider: string, remoteUrl: canonicalKey, locator: { source: "git-remote" as const, remoteName: "origin", remoteUrl }, displayName: canonicalKey.split("/").slice(1).join("/"), - provider, + provider: SourceControlProviderKind.make(provider), }, }); const projects = [ diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 4691f81fda89..0e517a53ae9a 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -49,15 +49,12 @@ import { resolveProjectSettings, } from "@t3tools/shared/projectSettings"; import { - detectSourceControlProviderFromGitRemoteUrl, mergeGitStatusParts, - normalizeGitRemoteUrl, resolveAutoFeatureBranchName, sanitizeBranchFragment, sanitizeFeatureBranchName, } from "@t3tools/shared/git"; import { - canonicalRepositoryKey, getChangeRequestTerminologyForKind, isSshRemoteUrl, type ChangeRequestTerminology, @@ -268,7 +265,7 @@ export function pullRequestRepositoryKey(value: string): string | null { url.pathname = match[1]; url.search = ""; url.hash = ""; - return normalizeGitRemoteUrl(url.toString()); + return sourceControlClients.repositoryKeyFromRemote(url.toString()); } catch { return null; } @@ -1027,8 +1024,10 @@ export const make = Effect.gen(function* () { if ( remoteUrl === null || pullRequestKey === null || - canonicalRepositoryKey(pullRequestKey) !== - canonicalRepositoryKey(normalizeGitRemoteUrl(remoteUrl)) + sourceControlClients.canonicalRepositoryKey(pullRequestKey) !== + sourceControlClients.canonicalRepositoryKey( + sourceControlClients.repositoryKeyFromRemote(remoteUrl), + ) ) { return yield* cause; } @@ -1451,7 +1450,7 @@ export const make = Effect.gen(function* () { const baseUrl = remote.remoteUrl === null ? undefined - : detectSourceControlProviderFromGitRemoteUrl(remote.remoteUrl)?.baseUrl; + : sourceControlClients.recognizeRemote(remote.remoteUrl)?.baseUrl; const host = baseUrl === undefined ? null : urlHost(baseUrl); const repository = remote.repositoryNameWithOwner?.toLowerCase(); const fromEnvironment = parseGitHubRepositorySelector(environment.GH_REPO ?? "", host ?? ""); @@ -1485,7 +1484,7 @@ export const make = Effect.gen(function* () { (yield* readConfigValueNullable(cwd, `remote.${preferredRemoteName}.url`)) ?? (yield* readConfigValueNullable(cwd, "remote.origin.url")); - const provider = remoteUrl ? detectSourceControlProviderFromGitRemoteUrl(remoteUrl) : null; + const provider = remoteUrl ? sourceControlClients.recognizeRemote(remoteUrl) : null; if (!remoteUrl || provider?.kind !== "unknown") return provider; const handle = yield* sourceControlProviders .resolveHandle({ @@ -1516,7 +1515,7 @@ export const make = Effect.gen(function* () { /^https?:\/\//iu.test(remoteUrl) && (repositoryNameWithOwner?.split("/").length ?? 0) > 2 ) { - const kind = detectSourceControlProviderFromGitRemoteUrl(remoteUrl)?.kind; + const kind = sourceControlClients.recognizeRemote(remoteUrl)?.kind; const provider = kind === undefined ? undefined @@ -1527,7 +1526,7 @@ export const make = Effect.gen(function* () { } return { remoteUrl, - remoteUrlKey: remoteUrl ? normalizeGitRemoteUrl(remoteUrl) : null, + remoteUrlKey: remoteUrl ? sourceControlClients.repositoryKeyFromRemote(remoteUrl) : null, repositoryNameWithOwner, ownerLogin: parseRepositoryOwnerLogin(repositoryNameWithOwner), }; @@ -2486,7 +2485,9 @@ export const make = Effect.gen(function* () { remoteName === null ? Effect.succeed(null) : readConfig(`remote.${remoteName}.url`).pipe( - Effect.map((remoteUrl) => (remoteUrl ? normalizeGitRemoteUrl(remoteUrl) : null)), + Effect.map((remoteUrl) => + remoteUrl ? sourceControlClients.repositoryKeyFromRemote(remoteUrl) : null, + ), ); const remoteName = remoteNameOverride ?? (yield* readConfig(`branch.${branch}.remote`)); const [headRemoteUrlKey, targetRemoteUrlKey] = diff --git a/apps/server/src/git/linkCreatedPullRequest.test.ts b/apps/server/src/git/linkCreatedPullRequest.test.ts index 23af74c8ba22..d4d48bd98619 100644 --- a/apps/server/src/git/linkCreatedPullRequest.test.ts +++ b/apps/server/src/git/linkCreatedPullRequest.test.ts @@ -6,6 +6,7 @@ import { type GitRunStackedActionResult, type OrchestrationV2ServerCommand as OrchestrationCommand, type OrchestrationProjectShell, + SourceControlProviderKind, } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -40,7 +41,7 @@ const project: OrchestrationProjectShell = { remoteName: "origin", remoteUrl: "git@github.acme.test:Platform/API.git", }, - provider: "github", + provider: SourceControlProviderKind.make("github"), displayName: "Platform/API", owner: "Platform", name: "API", diff --git a/apps/server/src/git/linkCreatedPullRequest.ts b/apps/server/src/git/linkCreatedPullRequest.ts index 29e80f9c13c4..0d44a31bf9b8 100644 --- a/apps/server/src/git/linkCreatedPullRequest.ts +++ b/apps/server/src/git/linkCreatedPullRequest.ts @@ -1,10 +1,7 @@ -import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; import { type CommandId, - pullRequestHostOf, type GitRunStackedActionResult, type OrchestrationProjectShell, - type SourceControlProviderKind, type ThreadId, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; @@ -35,11 +32,11 @@ export function createdPullRequestKey( return { host: parsed.host, repository: parsed.repository, number: parsed.number, url }; } const identity = project?.repositoryIdentity; - const kind = identity?.provider as SourceControlProviderKind | undefined; - const repository = sourceControlRepositorySelector(identity); + const kind = identity?.provider; + const repository = sourceControlClients.repositorySelector(identity); if (!identity || kind === undefined || repository === null) return null; return { - host: pullRequestHostOf(identity, kind), + host: sourceControlClients.changeRequestHost(identity, kind), repository: repository.toLowerCase(), number, url, diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts index 11bce07bf5ba..d1a0170caf5c 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts @@ -9,6 +9,7 @@ import { type PullRequestRef, type PullRequestReviewGuideResult, type ThreadPullRequestLink, + SourceControlProviderKind, } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as Crypto from "effect/Crypto"; @@ -64,7 +65,7 @@ function makeProject( remoteName: "origin", remoteUrl: "git@github.com:T3Tools/T3Code.git", }, - provider: "github", + provider: SourceControlProviderKind.make("github"), displayName: "T3Tools/T3Code", owner: "T3Tools", name: "T3Code", @@ -380,7 +381,7 @@ describe("pull request toolkit handlers", () => { remoteName: "origin", remoteUrl: "git@gitlab.com:group/sub/project.git", }, - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), displayName: "group/sub/project", }), }); @@ -415,7 +416,7 @@ describe("pull request toolkit handlers", () => { remoteName: "origin", remoteUrl: "http://dummy:secret@scm.example.test:8080/tfs/Collection/Project/_git/Repo", }, - provider: "unknown", + provider: SourceControlProviderKind.make("unknown"), displayName: "tfs/collection/project/_git/repo", }), }); @@ -592,7 +593,7 @@ describe("pull request guide tools", () => { remoteName: "origin", remoteUrl: "git@github.com:acme/web.git", }, - provider: "github", + provider: SourceControlProviderKind.make("github"), displayName: "acme/web", owner: "acme", name: "web", @@ -651,7 +652,7 @@ describe("pull request guide tools", () => { remoteName: "origin", remoteUrl: "https://dev.azure.com/org/project/_git/web", }, - provider: "azure-devops", + provider: SourceControlProviderKind.make("azure-devops"), displayName: "org/project/_git/web", owner: "org", name: "web", diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts index ff323fcdd948..191258ca6693 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts @@ -1,13 +1,11 @@ import { CommandId, - pullRequestHostOf, type OrchestrationProjectShell, type OrchestrationV2ThreadShell, type SourceControlProviderKind, type ThreadId, type ThreadPullRequestLink, } from "@t3tools/contracts"; -import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; import * as Cause from "effect/Cause"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; @@ -61,10 +59,10 @@ function projectHostAndProvider(project: OrchestrationProjectShell | undefined): readonly kind: SourceControlProviderKind | null; } { const identity = project?.repositoryIdentity; - const kind = (identity?.provider as SourceControlProviderKind | undefined) ?? null; + const kind = identity?.provider ?? null; if (!identity || kind === null) return { host: null, kind: null }; return { - host: pullRequestHostOf(identity, kind), + host: sourceControlClients.changeRequestHost(identity, kind), kind, }; } @@ -173,7 +171,7 @@ function projectForPullRequest( : pullRequestSyncKey( { projectId: project.id, - repository: sourceControlRepositorySelector(project.repositoryIdentity) ?? "", + repository: sourceControlClients.repositorySelector(project.repositoryIdentity) ?? "", number: 1, }, project.repositoryIdentity, diff --git a/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts b/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts index b4c323412267..80b17c0e2c26 100644 --- a/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts @@ -137,7 +137,7 @@ describe("ThreadPullRequestServiceV2 reads", () => { remoteName: "origin", remoteUrl: "git@github.com:acme/project.git", }, - provider: "github" as const, + provider: SourceControlProviderKind.make("github"), owner: "acme", name: "project", }), @@ -261,7 +261,7 @@ describe("ThreadPullRequestServiceV2 reads", () => { remoteName: "origin", remoteUrl: "git@github.com:acme/project.git", }, - provider: "github" as const, + provider: SourceControlProviderKind.make("github"), owner: "acme", name: "project", }), diff --git a/apps/server/src/orchestration-v2/ThreadPullRequestService.ts b/apps/server/src/orchestration-v2/ThreadPullRequestService.ts index 2c3f03b0d348..f235ef63e8f4 100644 --- a/apps/server/src/orchestration-v2/ThreadPullRequestService.ts +++ b/apps/server/src/orchestration-v2/ThreadPullRequestService.ts @@ -1,7 +1,3 @@ -import { - canonicalRepositoryKey, - sourceControlRepositorySelector, -} from "@t3tools/shared/sourceControl"; import { CommandId, type OrchestrationProjectShell, @@ -29,6 +25,7 @@ import * as ProjectStore from "./ProjectStore.ts"; import * as PullRequestService from "../pullRequest/PullRequestService.ts"; import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; import { forkParked } from "../serverActivation.ts"; +import { sourceControlClients } from "../sourceControl/sourceControlClients.ts"; import * as Orchestrator from "./Orchestrator.ts"; class ThreadPullRequestServiceV2 extends Context.Service< @@ -59,8 +56,8 @@ function pullRequestMatchesProject( return ( pullRequest.repositoryKey !== null && project.repositoryIdentity != null && - canonicalRepositoryKey(pullRequest.repositoryKey) === - canonicalRepositoryKey(project.repositoryIdentity.canonicalKey) + sourceControlClients.canonicalRepositoryKey(pullRequest.repositoryKey) === + sourceControlClients.canonicalRepositoryKey(project.repositoryIdentity.canonicalKey) ); } @@ -78,7 +75,7 @@ const resolveProjectForPullRequestDiscovery = Effect.fn( }); return { project: { ...project, repositoryIdentity }, - repository: sourceControlRepositorySelector(repositoryIdentity), + repository: sourceControlClients.repositorySelector(repositoryIdentity), }; }); diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.ts index 7d95dfd035a3..bf9b46c41a58 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.ts @@ -26,6 +26,7 @@ import * as ServerSettings from "../serverSettings.ts"; import { forkParked } from "../serverActivation.ts"; import * as TerminalManager from "../terminal/Manager.ts"; import * as ProjectStore from "./ProjectStore.ts"; +import { sourceControlClients } from "../sourceControl/sourceControlClients.ts"; import * as Orchestrator from "./Orchestrator.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; @@ -51,18 +52,6 @@ function latestMillis(values: ReadonlyArray): number | null { return latest; } -function canonicalRepositoryKey(key: string): string { - return key - .replace( - /^(?:ssh\.dev\.azure\.com|vs-ssh\.visualstudio\.com)\/v3\/([^/]+)\/([^/]+)\/([^/]+)$/u, - "dev.azure.com/$1/$2/_git/$3", - ) - .replace( - /^([^.]+)\.visualstudio\.com\/(?:defaultcollection\/)?([^/]+)\/_git\/([^/]+)$/u, - "dev.azure.com/$1/$2/_git/$3", - ); -} - function pullRequestMatchesProject( pullRequest: GitManager.GitBranchPullRequest, project: { @@ -72,8 +61,8 @@ function pullRequestMatchesProject( return ( pullRequest.repositoryKey !== null && project.repositoryIdentity != null && - canonicalRepositoryKey(pullRequest.repositoryKey) === - canonicalRepositoryKey(project.repositoryIdentity.canonicalKey) + sourceControlClients.canonicalRepositoryKey(pullRequest.repositoryKey) === + sourceControlClients.canonicalRepositoryKey(project.repositoryIdentity.canonicalKey) ); } diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index 29a55ec22ba1..baf0f27f9edb 100644 --- a/apps/server/src/project/AgentSessionScanner.test.ts +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -745,7 +745,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { yield* fileSystem.makeDirectory(submoduleGitDir, { recursive: true }); yield* fileSystem.writeFileString( path.join(submoduleGitDir, "config"), - '[remote "origin"]\n\turl = ssh://github.com/pingdotgg/vendor.git\n', + '[remote "origin"]\n\turl = ssh://gitlab.com/pingdotgg/libs/vendor.git\n', ); yield* fileSystem.writeFileString( path.join(submodule, ".git"), @@ -767,7 +767,10 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { ).toEqual([ { path: submodule, - git: { remoteKey: "github.com/pingdotgg/vendor", repository: "pingdotgg/vendor" }, + git: { + remoteKey: "gitlab.com/pingdotgg/libs/vendor", + repository: "pingdotgg/libs/vendor", + }, }, { path: noRemote, git: { remoteKey: null, repository: null } }, { path: plain, git: null }, diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts index e516ce0e53b9..ffdee62c01b5 100644 --- a/apps/server/src/project/AgentSessionScanner.ts +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -24,20 +24,17 @@ import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; -import { - normalizeGitRemoteUrl, - isBareGitConfig, - parseGitHubRepositoryNameWithOwnerFromRemoteUrl, - parseOriginUrlFromGitConfig, -} from "@t3tools/shared/git"; +import { isBareGitConfig, parseOriginUrlFromGitConfig } from "@t3tools/shared/git"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { normalizeProjectPathForComparison } from "@t3tools/shared/path"; +import { repositoryPathFromRemoteUrl } from "@t3tools/source-control-core/server/SourceControlProvider"; import * as ServerConfig from "../config.ts"; import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import * as ServerSettings from "../serverSettings.ts"; +import { sourceControlClients } from "../sourceControl/sourceControlClients.ts"; import { createTranscriptJsonReader, createTranscriptJsonSelector, @@ -725,8 +722,9 @@ export const make = Effect.gen(function* () { return { _tag: "Repository", git: { - remoteKey: originUrl === null ? null : normalizeGitRemoteUrl(originUrl), - repository: parseGitHubRepositoryNameWithOwnerFromRemoteUrl(originUrl), + remoteKey: + originUrl === null ? null : sourceControlClients.repositoryKeyFromRemote(originUrl), + repository: repositoryPathFromRemoteUrl(originUrl), }, } as const; }); diff --git a/apps/server/src/project/RepositoryIdentityResolver.ts b/apps/server/src/project/RepositoryIdentityResolver.ts index 0ec948b485fc..86a840fec0f4 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.ts @@ -1,9 +1,5 @@ import type { RepositoryIdentity, SourceControlProviderError } from "@t3tools/contracts"; import { repositoryPathFromRemoteUrl } from "@t3tools/source-control-core/server/SourceControlProvider"; -import { - detectSourceControlProviderFromGitRemoteUrl, - normalizeGitRemoteUrl, -} from "@t3tools/shared/git"; import * as Cache from "effect/Cache"; import * as Context from "effect/Context"; import * as Data from "effect/Data"; @@ -13,6 +9,7 @@ import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as ProcessRunner from "../processRunner.ts"; +import { sourceControlClients } from "../sourceControl/sourceControlClients.ts"; import { explicitBareRepositoryEnv } from "../vcs/VcsProcess.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; @@ -102,7 +99,7 @@ function buildRepositoryOrigin( canonicalKey: string, ): RepositoryIdentity["origin"] { if (!originUrl) return undefined; - const originKey = normalizeGitRemoteUrl(originUrl); + const originKey = sourceControlClients.repositoryKeyFromRemote(originUrl); if (originKey === canonicalKey) return undefined; const displayName = repositoryPathOf(originKey, originUrl); return { canonicalKey: originKey, remoteUrl: originUrl, ...(displayName ? { displayName } : {}) }; @@ -114,8 +111,8 @@ function buildRepositoryIdentity(input: { readonly originUrl: string | undefined; readonly rootPath: string; }): RepositoryIdentity { - const canonicalKey = normalizeGitRemoteUrl(input.remoteUrl); - const sourceControlProvider = detectSourceControlProviderFromGitRemoteUrl(input.remoteUrl); + const canonicalKey = sourceControlClients.repositoryKeyFromRemote(input.remoteUrl); + const sourceControlProvider = sourceControlClients.recognizeRemote(input.remoteUrl); const repositoryPath = repositoryPathOf(canonicalKey, input.remoteUrl); const repositoryPathSegments = repositoryPath.split("/").filter((segment) => segment.length > 0); const [owner] = repositoryPathSegments; diff --git a/apps/server/src/pullRequest/PullRequestReviewGuideService.ts b/apps/server/src/pullRequest/PullRequestReviewGuideService.ts index edd5dd2c7286..e78d850d6834 100644 --- a/apps/server/src/pullRequest/PullRequestReviewGuideService.ts +++ b/apps/server/src/pullRequest/PullRequestReviewGuideService.ts @@ -24,7 +24,7 @@ import { } from "@t3tools/contracts"; import type { ReviewGuideStackLayer } from "@t3tools/provider-core/server/textGeneration"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; -import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; +import { sourceControlClients } from "../sourceControl/sourceControlClients.ts"; import * as ServerConfig from "../config.ts"; import * as PullRequestReviewGuides from "../persistence/PullRequestReviewGuides.ts"; @@ -104,7 +104,7 @@ const make = Effect.gen(function* () { { projectId: ProjectId.make("own"), number: key.number, - repository: sourceControlRepositorySelector(identity) ?? "", + repository: sourceControlClients.repositorySelector(identity) ?? "", }, identity, ); diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 1d2b879e11a9..2a07b5715a3a 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -77,7 +77,7 @@ function project(input: { remoteName: "origin", remoteUrl: input.remoteUrl ?? `https://${host}/${input.repository}.git`, }, - provider: input.provider ?? "github", + provider: SourceControlProviderKind.make(input.provider ?? "github"), displayName: input.repository, }, } diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index c57375c3dd3e..97264c56d133 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -1,9 +1,4 @@ -import { - canonicalRepositoryKey, - isSshRemoteUrl, - sourceControlRepositorySelector, -} from "@t3tools/shared/sourceControl"; -import { normalizeGitRemoteUrl } from "@t3tools/shared/git"; +import { isSshRemoteUrl } from "@t3tools/shared/sourceControl"; import { parseDiffFilePaths } from "@t3tools/source-control-core/server/diffFileRevisions"; import * as Cache from "effect/Cache"; import * as Cause from "effect/Cause"; @@ -23,7 +18,6 @@ import * as SubscriptionRef from "effect/SubscriptionRef"; import { PullRequestOperationError, PullRequestUnavailableError, - pullRequestHostOf, pullRequestProviderRequirement, resolvePullRequestAuthorFilter, type OrchestrationProjectShell, @@ -82,7 +76,7 @@ import { type ThreadPullRequestKey, } from "@t3tools/contracts"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; -import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; +import { sourceControlClients } from "../sourceControl/sourceControlClients.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as ServerSettings from "../serverSettings.ts"; @@ -500,6 +494,9 @@ function withRateLimitBackoff( ? {} : { mergeMessageRewrite: api.mergeMessageRewrite }), ...(api.repositoryKey === undefined ? {} : { repositoryKey: api.repositoryKey }), + ...(api.readsPublicHostWithoutCheckout === undefined + ? {} + : { readsPublicHostWithoutCheckout: api.readsPublicHostWithoutCheckout }), getViewer: options?.viewerAllowsPause === true ? interactive("getViewer", api.getViewer) @@ -648,6 +645,29 @@ function rankFilterOptions( }; } +/** Whether the project's remote is SSH on a host whose SSH remotes do not name its web host. */ +function sshRemoteHidesWebHost( + identity: NonNullable, +) { + return ( + identity.provider !== undefined && + sourceControlClients.find(identity.provider)?.sshRemoteHidesWebHost === true && + isSshRemoteUrl(identity.locator.remoteUrl) + ); +} + +/** An HTTP remote names its server's web host, port included. */ +function httpRemoteHost(remoteUrl: string): string | null { + try { + const remote = new URL(remoteUrl); + return remote.protocol === "http:" || remote.protocol === "https:" + ? remote.host.toLowerCase() + : null; + } catch { + return null; + } +} + // Capture before the provider read so a slow response keeps its original freshness through caches. const observeRead = Effect.fnUntraced(function* (read: Effect.Effect) { const observedAt = yield* Clock.currentTimeMillis; @@ -682,24 +702,22 @@ export const make = Effect.gen(function* () { if (filter.projectId !== undefined && project.id !== filter.projectId) continue; const identity = project.repositoryIdentity; if ( - (identity?.provider !== "unknown" && - !(identity?.provider === "forgejo" && isSshRemoteUrl(identity.locator.remoteUrl))) || - sourceControlRepositorySelector(project.repositoryIdentity) === null + (identity?.provider !== "unknown" && !(identity && sshRemoteHidesWebHost(identity))) || + sourceControlClients.repositorySelector(project.repositoryIdentity) === null ) continue; - const host = pullRequestHostOf(identity, SourceControlProviderKind.make("unknown")); + const host = sourceControlClients.changeRequestHost(identity, "unknown"); if ( filter.host !== undefined && host !== "unknown" && host !== filter.host.toLowerCase() && - pullRequestHostOf(identity, SourceControlProviderKind.make("forgejo")) !== - filter.host.toLowerCase() && + httpRemoteHost(identity.locator.remoteUrl) !== filter.host.toLowerCase() && !isSshRemoteUrl(identity.locator.remoteUrl) ) { continue; } const { remoteName, remoteUrl } = identity.locator; - const provider = detectSourceControlProviderFromRemoteUrl(remoteUrl); + const provider = sourceControlClients.recognizeRemote(remoteUrl); if (provider !== null) { const candidates = refinements.get(provider.baseUrl); const candidate = { project, provider, remoteName, remoteUrl }; @@ -717,10 +735,9 @@ export const make = Effect.gen(function* () { sourceControlProviders.resolveHandle({ cwd: project.workspaceRoot, context: { - provider: - provider.kind === "forgejo" - ? { ...provider, kind: SourceControlProviderKind.make("unknown") } - : provider, + provider: sourceControlClients.find(provider.kind)?.sshRemoteHidesWebHost + ? { ...provider, kind: SourceControlProviderKind.make("unknown") } + : provider, remoteName, remoteUrl, ...(filter.host !== undefined && isSshRemoteUrl(remoteUrl) @@ -791,22 +808,20 @@ export const make = Effect.gen(function* () { if (filter.projectId !== undefined && project.id !== filter.projectId) continue; if (filter.projectIds !== undefined && !filter.projectIds.includes(project.id)) continue; const identity = project.repositoryIdentity; - let kind = identity?.provider as SourceControlProviderKind | undefined; - const repository = sourceControlRepositorySelector(project.repositoryIdentity); + let kind = identity?.provider; + const repository = sourceControlClients.repositorySelector(project.repositoryIdentity); if (!identity || kind === undefined || repository === null) continue; let refinedProvider: SourceControlProviderInfo | null | undefined; - if ( - kind === "unknown" || - (kind === "forgejo" && isSshRemoteUrl(identity.locator.remoteUrl)) - ) { - const provider = detectSourceControlProviderFromRemoteUrl(identity.locator.remoteUrl); + if (kind === "unknown" || sshRemoteHidesWebHost(identity)) { + const provider = sourceControlClients.recognizeRemote(identity.locator.remoteUrl); refinedProvider = provider === null ? null : refinedProviders.get(provider.baseUrl); kind = refinedProvider?.kind ?? kind; } const host = - refinedProvider?.kind === "forgejo" + refinedProvider && + sourceControlClients.find(refinedProvider.kind)?.sshRemoteHidesWebHost ? new URL(refinedProvider.baseUrl).host.toLowerCase() - : pullRequestHostOf(identity, kind); + : sourceControlClients.changeRequestHost(identity, kind); if (filter.host !== undefined && host !== filter.host.toLowerCase()) { continue; } @@ -833,9 +848,13 @@ export const make = Effect.gen(function* () { api: withRateLimitBackoff(api, host, rateLimits), repository, host, - remote: repositoryKey ?? normalizeGitRemoteUrl(`https://${host}/${repository}`), + remote: + repositoryKey ?? + sourceControlClients.repositoryKeyFromRemote(`https://${host}/${repository}`), repositoryKey: - repositoryKey === null ? null : canonicalRepositoryKey(repositoryKey.toLowerCase()), + repositoryKey === null + ? null + : sourceControlClients.canonicalRepositoryKey(repositoryKey.toLowerCase()), }); } return { supported, unimplemented, viewerRoots }; @@ -866,11 +885,13 @@ export const make = Effect.gen(function* () { }), ); } - const repositoryKey = canonicalRepositoryKey(`${host}/${repository}`.toLowerCase()); - // Azure SSH and legacy clone hosts differ from the browser URL's host. Compare - // the complete repository identity before narrowing those checkouts by host. + const referenceKey = `${host}/${repository}`.toLowerCase(); + const repositoryKey = sourceControlClients.canonicalRepositoryKey(referenceKey); + // A host that spells one repository several ways (Azure's SSH and legacy clone hosts) + // can differ from the browser URL's host. Compare the complete repository identity + // before narrowing those checkouts by host. return listWorkspaceProjects( - repositoryKey.startsWith("dev.azure.com/") ? {} : { host }, + sourceControlClients.findByRepositoryKey(referenceKey) ? {} : { host }, ).pipe( Effect.flatMap(({ supported }) => { const onHost = supported.filter((candidate) => candidate.host === host); @@ -887,11 +908,9 @@ export const make = Effect.gen(function* () { onHost.find((candidate) => candidate.repositoryKey === null); if (route === undefined) { return Effect.gen(function* () { - const provider = ["github.com", "bitbucket.org", "codeberg.org"].includes(host) - ? detectSourceControlProviderFromRemoteUrl(`https://${host}/${repository}.git`) - : null; - const api = provider === null ? null : registry.get(provider.kind); - if (api !== null) { + const publicHost = sourceControlClients.findByPublicHost(host); + const api = publicHost === undefined ? null : registry.get(publicHost.kind); + if (api?.readsPublicHostWithoutCheckout) { const selected = yield* projects.getShell(ref.projectId).pipe( Effect.mapError( (cause) => @@ -909,7 +928,9 @@ export const make = Effect.gen(function* () { api: withRateLimitBackoff(api, host, rateLimits), repository, host, - remote: normalizeGitRemoteUrl(`https://${host}/${repository}`), + remote: sourceControlClients.repositoryKeyFromRemote( + `https://${host}/${repository}`, + ), repositoryKey: null, }; } @@ -924,7 +945,9 @@ export const make = Effect.gen(function* () { : { ...route, repository, - remote: normalizeGitRemoteUrl(`https://${host}/${repository}`), + remote: sourceControlClients.repositoryKeyFromRemote( + `https://${host}/${repository}`, + ), }, ); }), diff --git a/apps/server/src/pullRequest/pullRequestSyncKey.test.ts b/apps/server/src/pullRequest/pullRequestSyncKey.test.ts index 2b21e20d15fc..83f61338c198 100644 --- a/apps/server/src/pullRequest/pullRequestSyncKey.test.ts +++ b/apps/server/src/pullRequest/pullRequestSyncKey.test.ts @@ -1,4 +1,4 @@ -import { ProjectId } from "@t3tools/contracts"; +import { ProjectId, SourceControlProviderKind } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { pullRequestSyncKey } from "./pullRequestSyncKey.ts"; @@ -13,7 +13,7 @@ describe("pullRequestSyncKey", () => { ])("resolves hostless and checkout-host Azure references for %s", (canonicalKey) => { const identity = { canonicalKey, - provider: "azure-devops", + provider: SourceControlProviderKind.make("azure-devops"), name: "web", displayName: canonicalKey.split("/").slice(1).join("/"), locator: { diff --git a/apps/server/src/pullRequest/pullRequestSyncKey.ts b/apps/server/src/pullRequest/pullRequestSyncKey.ts index 90416e066ca0..681dfb07b9de 100644 --- a/apps/server/src/pullRequest/pullRequestSyncKey.ts +++ b/apps/server/src/pullRequest/pullRequestSyncKey.ts @@ -1,43 +1,37 @@ -import { - pullRequestHostOf, - type PullRequestRef, - type RepositoryIdentity, - SourceControlProviderKind, - type ThreadPullRequestKey, -} from "@t3tools/contracts"; -import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; +import type { PullRequestRef, RepositoryIdentity, ThreadPullRequestKey } from "@t3tools/contracts"; +import { sourceControlClients } from "../sourceControl/sourceControlClients.ts"; import { normalizeThreadPullRequestKey } from "../sourceControl/threadPullRequests.ts"; export function pullRequestSyncKey( reference: PullRequestRef, identity?: RepositoryIdentity | null, ): ThreadPullRequestKey | null { - if (identity?.provider === "azure-devops" && !reference.repository.includes("/")) { + const host = identity + ? sourceControlClients.changeRequestHost(identity, identity.provider) + : undefined; + // A host whose tools select a repository by something other than its path (Azure DevOps takes + // the bare name) can only key such a reference through the project's own repository. + const definition = identity?.provider ? sourceControlClients.find(identity.provider) : undefined; + if (identity && definition?.repositorySelector && !reference.repository.includes("/")) { if ( reference.repository.toLowerCase() !== - sourceControlRepositorySelector(identity)?.toLowerCase() || - (reference.host !== undefined && - reference.host.toLowerCase() !== - pullRequestHostOf(identity, SourceControlProviderKind.make("azure-devops"))) + definition.repositorySelector(identity)?.toLowerCase() || + (reference.host !== undefined && reference.host.toLowerCase() !== host) ) return null; - const [host, ...repository] = identity.canonicalKey.split("/"); - if (!host || repository.length === 0) return null; + const [projectHost, ...repository] = identity.canonicalKey.split("/"); + if (!projectHost || repository.length === 0) return null; return normalizeThreadPullRequestKey({ - host, + host: projectHost, repository: repository.join("/"), number: reference.number, }); } - const host = - reference.host ?? - (identity - ? pullRequestHostOf(identity, identity.provider as SourceControlProviderKind) - : undefined); - return host === undefined + const resolvedHost = reference.host ?? host; + return resolvedHost === undefined ? null : normalizeThreadPullRequestKey({ - host, + host: resolvedHost, repository: reference.repository, number: reference.number, }); diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts index 6109fe43c19b..5ea6c4037c9c 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -13,9 +13,9 @@ import { type SourceControlProviderDiscoveryItem, } from "@t3tools/contracts"; import type {} from "@t3tools/contracts"; -import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; import * as BuiltInDrivers from "./builtInDrivers.ts"; +import { sourceControlClients } from "./sourceControlClients.ts"; import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; import { probeSourceControlProvider, @@ -144,7 +144,7 @@ function selectProviderContext( ): SourceControlProvider.SourceControlProviderContext | null { const candidates: Array = []; for (const remote of remotes) { - const provider = detectSourceControlProviderFromRemoteUrl(remote.url); + const provider = sourceControlClients.recognizeRemote(remote.url); if (provider) { candidates.push({ provider, @@ -337,7 +337,7 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit if (input.url.protocol !== "https:" || input.url.username || input.url.password) { return undefined; } - const kind = detectSourceControlProviderFromRemoteUrl(input.url.href)?.kind; + const kind = sourceControlClients.recognizeRemote(input.url.href)?.kind; return kind ? providers.get(kind)?.resolveLink?.(input) : undefined; }, get, diff --git a/apps/server/src/sourceControl/sourceControlClients.ts b/apps/server/src/sourceControl/sourceControlClients.ts index bfe6cc8fecaa..856fe7da39a1 100644 --- a/apps/server/src/sourceControl/sourceControlClients.ts +++ b/apps/server/src/sourceControl/sourceControlClients.ts @@ -10,7 +10,8 @@ import * as GitLabDriver from "@t3tools/source-control-gitlab/server/driver"; * The same order as the clients' registry in `@t3tools/client-runtime/source-control-clients`. * A change request URL goes to the first host whose shape it has, so GitCafe, which claims * `/pull/` and `/pulls/` only on its own hostnames, claims before GitHub and Forgejo, which - * claim them everywhere. + * claim them everywhere. A remote goes to the first host that recognises it, and a hostname can + * carry more than one host's label (`gitea.github.example`), so recognition has its own order. */ export const BUILT_IN_SOURCE_CONTROL_DRIVER_LIST = [ GitHubDriver.driver, @@ -23,5 +24,15 @@ export const BUILT_IN_SOURCE_CONTROL_DRIVER_LIST = [ export const sourceControlClients = makeSourceControlClientRegistry( BUILT_IN_SOURCE_CONTROL_DRIVER_LIST.map((driver) => driver.definition), - { claimChangeRequestUrlsFirst: [GitCafeDriver.driver.kind] }, + { + claimChangeRequestUrlsFirst: [GitCafeDriver.driver.kind], + recognizeRemotesIn: [ + GitCafeDriver.driver.kind, + ForgejoDriver.driver.kind, + GitHubDriver.driver.kind, + GitLabDriver.driver.kind, + AzureDevOpsDriver.driver.kind, + BitbucketDriver.driver.kind, + ], + }, ); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index b5a523876a58..2bc11cdb0451 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -31,7 +31,7 @@ import { type ReviewDiffPreviewSource, type VcsRef, } from "@t3tools/contracts"; -import { dedupeRemoteBranchesWithLocalMatches, normalizeGitRemoteUrl } from "@t3tools/shared/git"; +import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { compactTraceAttributes } from "@t3tools/shared/observability"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; @@ -50,6 +50,7 @@ import { parseRemoteRefWithRemoteNames, } from "../git/remoteRefs.ts"; import * as ServerConfig from "../config.ts"; +import { sourceControlClients } from "../sourceControl/sourceControlClients.ts"; const DEFAULT_TIMEOUT_MS = 30_000; const gitProcesses = Semaphore.makeUnsafe(8); @@ -1727,7 +1728,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* "ensureRemote", )(function* (input) { const preferredName = sanitizeRemoteName(input.preferredName); - const normalizedTargetUrl = normalizeGitRemoteUrl(input.url); + const normalizedTargetUrl = sourceControlClients.repositoryKeyFromRemote(input.url); const remoteFetchUrls = yield* runGitStdout( "GitVcsDriver.ensureRemote.listRemoteUrls", input.cwd, @@ -1735,7 +1736,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ).pipe(Effect.map((stdout) => parseRemoteFetchUrls(stdout))); for (const [remoteName, remoteUrl] of remoteFetchUrls.entries()) { - if (normalizeGitRemoteUrl(remoteUrl) === normalizedTargetUrl) { + if (sourceControlClients.repositoryKeyFromRemote(remoteUrl) === normalizedTargetUrl) { return remoteName; } } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ec4fe2a5511d..6252b087abe4 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,3 +1,4 @@ +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { ScrollToEndButton, WorkspaceFileDropOverlay } from "./chat/TimelineOverlays"; import { usePasteToFocusComposer } from "./chat/usePasteToFocusComposer"; import { announcedRun, isDetailBehindShell } from "./chat/threadActivityAnnouncement"; @@ -101,7 +102,6 @@ import { import { formatModelSlugName, resolveSelectableModel } from "@t3tools/shared/model"; import { projectScriptCwd, resolveProjectScripts } from "@t3tools/shared/projectScripts"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; -import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; import { resolveThreadReferenceCopyTarget } from "@t3tools/client-runtime/thread-reference"; import { resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; import { useAtomValue } from "@effect/atom-react"; @@ -1911,7 +1911,7 @@ export default function ChatView(props: ChatViewProps) { }); // 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 activeProjectRepository = sourceControlRepositorySelector( + const activeProjectRepository = sourceControlClients.repositorySelector( activeProject?.repositoryIdentity, ); const { diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index c4577f64fe51..aabd2d5f7ced 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -1,4 +1,4 @@ -import { pullRequestHostOf, type SourceControlProviderKind } from "@t3tools/contracts"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { useProjects, useServerConfigs, useThreadShells } from "~/state/entities"; import { @@ -715,7 +715,7 @@ function PullRequestSurfaceIcon({ const host = surface.host ?? (identity?.provider - ? pullRequestHostOf(identity, identity.provider as SourceControlProviderKind) + ? sourceControlClients.changeRequestHost(identity, identity.provider) : null); const configs = useServerConfigs(); const capabilities = diff --git a/apps/web/src/components/chat/ThreadDetailsPrRow.tsx b/apps/web/src/components/chat/ThreadDetailsPrRow.tsx index 85c65005b9d8..5867b56b3a39 100644 --- a/apps/web/src/components/chat/ThreadDetailsPrRow.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPrRow.tsx @@ -1,10 +1,10 @@ +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { PullRequestChangeBreakdownRows } from "../pullRequest/PullRequestChangeBreakdownStat"; import { ThreadDetailsControl } from "./ThreadDetailsControl"; import { PullRequestGlyph } from "../pullRequest/pullRequestIcons"; import { selectEnvironmentQueryData } from "@t3tools/client-runtime/state/runtime"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import type { EnvironmentId, ProjectId, PullRequestRef } from "@t3tools/contracts"; -import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; import { ArrowUpRightIcon, EyeIcon, @@ -106,7 +106,7 @@ export function ThreadDetailsPrRow({ const serverConfigs = useServerConfigs(); const supportsPullRequests = serverConfigs.get(environmentId)?.environment.capabilities.pullRequests === true; - const repository = sourceControlRepositorySelector(project?.repositoryIdentity); + const repository = sourceControlClients.repositorySelector(project?.repositoryIdentity); const reference: PullRequestRef | null = supportsPullRequests && project !== null ? linkedReference diff --git a/apps/web/src/components/pullRequest/PullRequestsPage.tsx b/apps/web/src/components/pullRequest/PullRequestsPage.tsx index 17a438bd07fe..31362b01f753 100644 --- a/apps/web/src/components/pullRequest/PullRequestsPage.tsx +++ b/apps/web/src/components/pullRequest/PullRequestsPage.tsx @@ -1,3 +1,4 @@ +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { applyPullRequestDraftFilterChange } from "@t3tools/client-runtime/pull-request-query"; import { Spinner } from "~/components/ui/spinner"; import type { PullRequestSpeedActionResult } from "~/components/pullRequest/PullRequestSpeedActions"; @@ -5,7 +6,7 @@ import { usePullRequestCloseBatch } from "~/components/pullRequest/usePullReques import { SidebarPointerSensor } from "~/components/Sidebar.pointer"; import { resolveSidebarSweepKeys } from "~/components/Sidebar.logic"; import { sortProjectsByGroupOrder } from "@t3tools/client-runtime/state/project-grouping"; -import { pullRequestHostOf, resolveEnvironmentMachineKind } from "@t3tools/contracts"; +import { resolveEnvironmentMachineKind } from "@t3tools/contracts"; import type { EnvironmentId, ProjectId, @@ -15,7 +16,6 @@ import type { PullRequestListInput, PullRequestListResult, PullRequestListState, - SourceControlProviderKind, } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import { useNavigate, useSearch } from "@tanstack/react-router"; @@ -1014,9 +1014,9 @@ export function PullRequestsPage() { host: activePullRequestSurface.host ?? (selectedProject?.repositoryIdentity - ? pullRequestHostOf( + ? sourceControlClients.changeRequestHost( selectedProject.repositoryIdentity, - selectedProject.repositoryIdentity.provider as SourceControlProviderKind, + selectedProject.repositoryIdentity.provider, ) : undefined), } diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts index 292d40bfe400..2883897daf53 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts @@ -85,7 +85,7 @@ describe("pull request checkout commands", () => { remoteName: "origin", remoteUrl: "git@github.com:acme/web.git", }, - provider, + provider: SourceControlProviderKind.make(provider), }); it("uses a matching enterprise identity and rejects an explicit host mismatch", () => { diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts index dd29162fbde4..9dabaea0992e 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts @@ -5,7 +5,6 @@ import { GitCommandError, PullRequestDetail, PullRequestOperationError, - pullRequestHostOf, type PullRequestAction, type PullRequestActor, type PullRequestBaseComparison, @@ -135,7 +134,7 @@ export function loadingPullRequestCheckoutCommand( if ( identity?.provider !== undefined && host && - pullRequestHostOf(identity, definition.kind) !== host + sourceControlClients.changeRequestHost(identity, definition.kind) !== host ) { return null; } diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts index 44cf97bda850..58f6964e4b9e 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts @@ -50,7 +50,7 @@ function entry( ) { return { environmentId: "env-1", - provider: "github", + provider: SourceControlProviderKind.make("github"), host: "github.com", projectId: "project-1", projectTitle: "t3code", @@ -752,7 +752,7 @@ describe("findProjectForRepository", () => { repositoryIdentity: { canonicalKey: `dev.azure.com/${path}`, locator: { source: "git-remote" as const, remoteName: "origin", remoteUrl: "" }, - provider: "azure-devops", + provider: SourceControlProviderKind.make("azure-devops"), displayName: path, owner: "contoso", name: path.split("/").at(-1)!, @@ -788,7 +788,7 @@ describe("findProjectForRepository", () => { repositoryIdentity: { canonicalKey: `github.com/${repository}`, locator: { source: "git-remote" as const, remoteName: "origin", remoteUrl: "" }, - provider: "github", + provider: SourceControlProviderKind.make("github"), displayName: repository, }, }); diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.ts index fbd70381f87d..656913257a11 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.ts @@ -1,3 +1,4 @@ +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import * as Schema from "effect/Schema"; import { @@ -5,13 +6,11 @@ import { PullRequestListEntry, PullRequestListProjectError, PullRequestListResult, - pullRequestHostOf, resolvePullRequestAuthorFilter, } from "@t3tools/contracts"; import type { ProjectId, RepositoryIdentity, - SourceControlProviderKind, PullRequestAction, PullRequestActor, PullRequestDiffStat, @@ -23,8 +22,6 @@ import type { PullRequestState, } from "@t3tools/contracts"; -import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; - import { toSortableTimestamp } from "../../lib/threadSort"; import type { PullRequestListSort } from "./pullRequestListPreferences"; @@ -1078,12 +1075,15 @@ export function findProjectForRepository< return false; } const identity = project.repositoryIdentity; - if (!identity || sourceControlRepositorySelector(identity)?.toLowerCase() !== repository) { + if ( + !identity || + sourceControlClients.repositorySelector(identity)?.toLowerCase() !== repository + ) { return false; } return ( host === undefined || - pullRequestHostOf(identity, identity.provider as SourceControlProviderKind) === host + sourceControlClients.changeRequestHost(identity, identity.provider) === host ); }); // Azure repository names can collide across projects on the same host. diff --git a/apps/web/src/components/pullRequest/usePullRequestListView.ts b/apps/web/src/components/pullRequest/usePullRequestListView.ts index 347a00b628b9..2629fb6a34b3 100644 --- a/apps/web/src/components/pullRequest/usePullRequestListView.ts +++ b/apps/web/src/components/pullRequest/usePullRequestListView.ts @@ -1,13 +1,12 @@ +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { parsePullRequestQuery } from "@t3tools/client-runtime/pull-request-query"; import { - pullRequestHostOf, type EnvironmentId, type ProjectId, type PullRequestInvolvement, type PullRequestListFilters, type PullRequestListResult, type PullRequestListState, - type SourceControlProviderKind, } from "@t3tools/contracts"; import { useState } from "react"; @@ -156,9 +155,9 @@ export function usePullRequestScope(search: PullRequestsSearch) { const expectedHosts = (() => { const byHost = new Map(); for (const project of projects) { - const kind = project.repositoryIdentity?.provider as SourceControlProviderKind | undefined; + const kind = project.repositoryIdentity?.provider; if (kind === undefined) continue; - const host = pullRequestHostOf(project.repositoryIdentity, kind); + const host = sourceControlClients.changeRequestHost(project.repositoryIdentity, kind); if (!byHost.has(host)) byHost.set(host, { host, kind }); } return [...byHost.values()]; diff --git a/apps/web/src/hooks/usePullRequestLinking.ts b/apps/web/src/hooks/usePullRequestLinking.ts index 18c31cd3714d..98ed5462cf5a 100644 --- a/apps/web/src/hooks/usePullRequestLinking.ts +++ b/apps/web/src/hooks/usePullRequestLinking.ts @@ -1,4 +1,3 @@ -import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; import type { EnvironmentId, ScopedThreadRef, @@ -101,7 +100,7 @@ export function usePullRequestLinking(environmentId: EnvironmentId | null | unde reference: { ...parsed, url }, legacyProjectId: legacyProject?.id ?? null, legacyRepository: - sourceControlRepositorySelector(legacyProject?.repositoryIdentity) ?? undefined, + sourceControlClients.repositorySelector(legacyProject?.repositoryIdentity) ?? undefined, linked, }); if (mutation === null) diff --git a/apps/web/src/lib/openPullRequestLink.ts b/apps/web/src/lib/openPullRequestLink.ts index 0d3361e73de2..8c9a41998641 100644 --- a/apps/web/src/lib/openPullRequestLink.ts +++ b/apps/web/src/lib/openPullRequestLink.ts @@ -11,7 +11,6 @@ import { sourceControlClients, type ChangeRequestLink, } from "@t3tools/client-runtime/source-control-clients"; -import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; import { useOpenLink } from "../browser/useOpenLink"; import { useClientSettings } from "../hooks/useSettings"; @@ -117,7 +116,8 @@ export function useOpenChangeRequestLink( const repository = serverConfigs.get(project.environmentId)?.environment.capabilities.threadPullRequests === true ? parsed.repository - : (sourceControlRepositorySelector(project.repositoryIdentity) ?? parsed.repository); + : (sourceControlClients.repositorySelector(project.repositoryIdentity) ?? + parsed.repository); event.preventDefault(); event.stopPropagation(); if (resolvedPanelRef) { diff --git a/packages/client-runtime/src/changeRequestLinks.test.ts b/packages/client-runtime/src/changeRequestLinks.test.ts index f5d39305da2e..be25a3e5b10b 100644 --- a/packages/client-runtime/src/changeRequestLinks.test.ts +++ b/packages/client-runtime/src/changeRequestLinks.test.ts @@ -1,4 +1,9 @@ -import { EnvironmentId, ProjectId, type RepositoryIdentity } from "@t3tools/contracts"; +import { + EnvironmentId, + ProjectId, + type RepositoryIdentity, + SourceControlProviderKind, +} from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { @@ -15,7 +20,7 @@ describe("findProjectForChangeRequest", () => { const projects = [ project({ canonicalKey: "gitlab.com/t3tools/platform/t3code", - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), displayName: "t3tools/platform/t3code", owner: "t3tools", name: "t3code", @@ -34,7 +39,7 @@ describe("findProjectForChangeRequest", () => { const projects = [ project({ canonicalKey: "github.com/pingdotgg/t3code", - provider: "github", + provider: SourceControlProviderKind.make("github"), owner: "pingdotgg", name: "t3code", }), @@ -56,7 +61,7 @@ describe("resolvePullRequestPreviewTarget", () => { environmentId, repositoryIdentity: { canonicalKey: "github.com/acme/web", - provider: "github", + provider: SourceControlProviderKind.make("github"), displayName: "acme/web", locator: { source: "git-remote", remoteName: "origin", remoteUrl: "git@github.com:acme/web" }, } satisfies RepositoryIdentity, @@ -81,7 +86,7 @@ describe("resolvePullRequestPreviewTarget", () => { describe("projectChangeRequestUrl", () => { const identity = (provider: string): RepositoryIdentity => ({ canonicalKey: "github.com/acme/default", - provider, + provider: SourceControlProviderKind.make(provider), locator: { source: "git-remote", remoteName: "origin", @@ -95,6 +100,23 @@ describe("projectChangeRequestUrl", () => { ); }); + it("builds the project's own Azure repository from its full path, not its short selector", () => { + const azure: RepositoryIdentity = { + canonicalKey: "dev.azure.com/org/project/_git/web", + provider: SourceControlProviderKind.make("azure-devops"), + owner: "org", + name: "web", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://dev.azure.com/org/project/_git/web", + }, + }; + expect(projectChangeRequestUrl(azure, "web", 42)).toBe( + "https://dev.azure.com/org/project/_git/web/pullrequest/42", + ); + }); + it("builds nothing without a project host that knows its URL shape", () => { expect(projectChangeRequestUrl(null, "acme/repository", 1)).toBeNull(); expect(projectChangeRequestUrl(identity("unknown"), "acme/repository", 1)).toBeNull(); diff --git a/packages/client-runtime/src/changeRequestLinks.ts b/packages/client-runtime/src/changeRequestLinks.ts index 36c9097c951e..3786a9eff06e 100644 --- a/packages/client-runtime/src/changeRequestLinks.ts +++ b/packages/client-runtime/src/changeRequestLinks.ts @@ -5,7 +5,6 @@ import type { RepositoryIdentity, } from "@t3tools/contracts"; import type { ChangeRequestLink } from "@t3tools/source-control-core/client/definition"; -import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; import { sourceControlClients } from "./sourceControlClients.ts"; @@ -53,7 +52,8 @@ export function resolvePullRequestPreviewTarget({ input: { projectId: project.id, host: parsed.authority ?? parsed.host, - repository: sourceControlRepositorySelector(project.repositoryIdentity) ?? parsed.repository, + repository: + sourceControlClients.repositorySelector(project.repositoryIdentity) ?? parsed.repository, number: parsed.number, }, }; @@ -66,12 +66,16 @@ export function projectChangeRequestUrl( number: number, ): string | null { if (identity?.provider === undefined) return null; - const host = identity.canonicalKey.split("/")[0]; + const [host, ...path] = identity.canonicalKey.split("/"); if (!host) return null; + // The RPC selector can be shorter than the browser path (Azure's `web` for + // `org/project/_git/web`), so the project's own repository uses its full key. + const ownRepository = sourceControlClients.repositorySelector(identity) === repository; return sourceControlClients.get(identity.provider).changeRequestUrl({ host, - repository, + repository: ownRepository ? path.join("/") : repository, number, remoteUrl: identity.locator.remoteUrl.trim(), + ...(ownRepository && identity.webUrl !== undefined ? { webUrl: identity.webUrl } : {}), }); } diff --git a/packages/client-runtime/src/pullRequestLinkInput.ts b/packages/client-runtime/src/pullRequestLinkInput.ts index c897dc458878..30c9e3e89caa 100644 --- a/packages/client-runtime/src/pullRequestLinkInput.ts +++ b/packages/client-runtime/src/pullRequestLinkInput.ts @@ -1,4 +1,4 @@ -import { pullRequestHostOf, type RepositoryIdentity } from "@t3tools/contracts"; +import type { RepositoryIdentity } from "@t3tools/contracts"; import { sourceControlClients } from "./sourceControlClients.ts"; @@ -42,7 +42,7 @@ export function pullRequestLinkProject(identity: RepositoryIdentity | null | und (identity.owner && identity.name ? `${identity.owner}/${identity.name}` : null); if (repository === null) return null; const definition = sourceControlClients.get(identity.provider ?? "unknown"); - const host = pullRequestHostOf(identity, definition.kind); + const host = sourceControlClients.changeRequestHost(identity, definition.kind); return { host, repository, diff --git a/packages/client-runtime/src/sourceControlClients.test.ts b/packages/client-runtime/src/sourceControlClients.test.ts index 4a646bc9caba..9a2cb20e4a47 100644 --- a/packages/client-runtime/src/sourceControlClients.test.ts +++ b/packages/client-runtime/src/sourceControlClients.test.ts @@ -1,4 +1,4 @@ -import { ProjectId } from "@t3tools/contracts"; +import { ProjectId, SourceControlProviderKind } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { sourceControlClients } from "./sourceControlClients.ts"; @@ -238,3 +238,115 @@ describe("change request URLs built from a parsed link", () => { }); }); }); + +describe("sourceControlClients.recognizeRemote", () => { + const kindOfRemote = (url: string) => sourceControlClients.recognizeRemote(url)?.kind ?? null; + + it("asks hosts in a fixed order when one hostname carries several hosts' labels", () => { + expect(kindOfRemote("https://gitea.github.example/o/r")).toBe("forgejo"); + expect(kindOfRemote("https://forgejo.gitlab.example/o/r")).toBe("forgejo"); + expect(kindOfRemote("https://gitlab.github.example/o/r")).toBe("github"); + expect(kindOfRemote("https://bitbucket.gitlab.example/o/r")).toBe("gitlab"); + expect(kindOfRemote("https://bitbucket.visualstudio.com/p/_git/r")).toBe("azure-devops"); + expect(kindOfRemote("git@git.cafe:pingdotgg/t3code.git")).toBe("gitcafe"); + }); + + it("reads a remote no host claims as an unknown host, and a local path as no host", () => { + expect( + sourceControlClients.recognizeRemote("https://self-hosted.example.test:8443/group/repo.git"), + ).toEqual({ + kind: "unknown", + name: "self-hosted.example.test:8443", + baseUrl: "https://self-hosted.example.test:8443", + }); + expect(sourceControlClients.recognizeRemote("git@github-work:owner/repo.git")).toEqual({ + kind: "unknown", + name: "github-work", + baseUrl: "https://github-work", + }); + for (const local of ["/srv/git/repo.git", "C:/repos/project", "file:///srv/git/repo.git", ""]) { + expect(sourceControlClients.recognizeRemote(local), local).toBeNull(); + } + }); +}); + +describe("sourceControlClients repository keys", () => { + // Persisted as `repositoryIdentity.canonicalKey` and in thread pull request keys. + it.each([ + ["git@github.com:T3Tools/T3Code.git", "github.com/t3tools/t3code"], + ["ssh://git@gitlab.company.com:2222/team/project.git", "gitlab.company.com/team/project"], + [ + "git@ssh.dev.azure.com:v3/T3Tools/Platform/T3Code", + "dev.azure.com/t3tools/platform/_git/t3code", + ], + [ + "T3Tools@vs-ssh.visualstudio.com:v3/T3Tools/Platform/T3Code", + "t3tools.visualstudio.com/platform/_git/t3code", + ], + [ + "https://T3Tools.visualstudio.com/DefaultCollection/Platform/_git/T3Code", + "t3tools.visualstudio.com/defaultcollection/platform/_git/t3code", + ], + ["git@ssh.dev.azure.com:v3/o/p", "ssh.dev.azure.com/v3/o/p"], + ["ssh.dev.azure.com/v3/o/p/r", "ssh.dev.azure.com/v3/o/p/r"], + ["C:/repos/project", "c:/repos/project"], + ])("keys %s as %s", (remote, key) => { + expect(sourceControlClients.repositoryKeyFromRemote(remote)).toBe(key); + }); + + it("compares Azure DevOps spellings on dev.azure.com and leaves other keys alone", () => { + expect( + sourceControlClients.canonicalRepositoryKey( + "org.visualstudio.com/defaultcollection/project/_git/repo", + ), + ).toBe("dev.azure.com/org/project/_git/repo"); + expect(sourceControlClients.findByRepositoryKey("dev.azure.com/o/r")?.kind).toBe( + "azure-devops", + ); + expect(sourceControlClients.canonicalRepositoryKey("tfs.corp.example/c/p/_git/r")).toBe( + "tfs.corp.example/c/p/_git/r", + ); + expect(sourceControlClients.findByRepositoryKey("github.com/o/r")).toBeUndefined(); + }); +}); + +describe("sourceControlClients project hosts", () => { + const identity = { + canonicalKey: "forge.example/team/repo", + locator: { remoteUrl: "http://forge.example:3000/team/repo.git" }, + }; + + it("keeps an HTTP remote's port for Forgejo only, and falls back to the kind without a key", () => { + expect(sourceControlClients.changeRequestHost(identity, "forgejo")).toBe("forge.example:3000"); + expect(sourceControlClients.changeRequestHost(identity, "gitlab")).toBe("forge.example"); + expect(sourceControlClients.changeRequestHost(identity, undefined)).toBe("forge.example"); + expect( + sourceControlClients.changeRequestHost( + { locator: { remoteUrl: "https://x.example/a/b" } }, + "github", + ), + ).toBe("github"); + }); + + it("selects a repository by its path except where the host's tools take a name", () => { + const repository = { + displayName: "group/subgroup/service", + owner: "group", + name: "service", + }; + expect(sourceControlClients.repositorySelector(repository)).toBe("group/subgroup/service"); + expect( + sourceControlClients.repositorySelector({ + ...repository, + provider: SourceControlProviderKind.make("gitlab"), + }), + ).toBe("group/subgroup/service"); + expect( + sourceControlClients.repositorySelector({ + ...repository, + provider: SourceControlProviderKind.make("azure-devops"), + }), + ).toBe("service"); + expect(sourceControlClients.repositorySelector({ owner: "o", name: "r" })).toBe("o/r"); + }); +}); diff --git a/packages/client-runtime/src/sourceControlClients.ts b/packages/client-runtime/src/sourceControlClients.ts index 7faf3abcc903..ac67c682c520 100644 --- a/packages/client-runtime/src/sourceControlClients.ts +++ b/packages/client-runtime/src/sourceControlClients.ts @@ -22,7 +22,8 @@ import * as GitLab from "@t3tools/source-control-gitlab/client/definition"; * GitHub comes first because a repository that has not reported its host yet reads as GitHub, * which is what clients have always shown there. A change request URL goes to the first host * whose shape it has, so GitCafe, which claims `/pull/` and `/pulls/` only on its own hostnames, - * claims before GitHub and Forgejo, which claim them everywhere. The server's + * claims before GitHub and Forgejo, which claim them everywhere. A hostname can carry more than + * one host's label (`gitea.github.example`), so remote recognition has its own order. The server's * `sourceControl/sourceControlClients.ts` keeps the same order. */ const BUILT_IN_SOURCE_CONTROL_CLIENTS = [ @@ -36,7 +37,17 @@ const BUILT_IN_SOURCE_CONTROL_CLIENTS = [ export const sourceControlClients = makeSourceControlClientRegistry( BUILT_IN_SOURCE_CONTROL_CLIENTS, - { claimChangeRequestUrlsFirst: [GitCafe.definition.kind] }, + { + claimChangeRequestUrlsFirst: [GitCafe.definition.kind], + recognizeRemotesIn: [ + GitCafe.definition.kind, + Forgejo.definition.kind, + GitHub.definition.kind, + GitLab.definition.kind, + AzureDevOps.definition.kind, + Bitbucket.definition.kind, + ], + }, ); export type { GitHubSettings } from "@t3tools/source-control-github/client/definition"; diff --git a/packages/client-runtime/src/state/projectGrouping.test.ts b/packages/client-runtime/src/state/projectGrouping.test.ts index 8ca5ed4dea09..824fb87e3c40 100644 --- a/packages/client-runtime/src/state/projectGrouping.test.ts +++ b/packages/client-runtime/src/state/projectGrouping.test.ts @@ -1,4 +1,4 @@ -import { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import { EnvironmentId, ProjectId, SourceControlProviderKind } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import type { EnvironmentProject } from "./models.ts"; @@ -84,7 +84,7 @@ const repositoryIdentity = { remoteName: "upstream", remoteUrl: "https://github.com/t3tools/t3code.git", }, - provider: "github", + provider: SourceControlProviderKind.make("github"), owner: "t3tools", name: "t3code", displayName: "T3 Code", diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 18d09c304a83..c03b57d2756a 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -8,6 +8,7 @@ import { ThreadId, TrimmedNonEmptyString, } from "./baseSchemas.ts"; +import { SourceControlProviderKind } from "./sourceControl.ts"; export const ORCHESTRATION_PROTOCOL_VERSION = 2; export const ORCHESTRATION_PROTOCOL_VERSION_TEXT = "2"; @@ -186,7 +187,7 @@ export const RepositoryIdentity = Schema.Struct({ /** Work tree root, or the git dir for a bare repository. */ rootPath: Schema.optionalKey(TrimmedNonEmptyString), displayName: Schema.optionalKey(TrimmedNonEmptyString), - provider: Schema.optionalKey(TrimmedNonEmptyString), + provider: Schema.optionalKey(SourceControlProviderKind), owner: Schema.optionalKey(TrimmedNonEmptyString), name: Schema.optionalKey(TrimmedNonEmptyString), origin: Schema.optionalKey(RepositoryOrigin), diff --git a/packages/contracts/src/pullRequest.test.ts b/packages/contracts/src/pullRequest.test.ts index bf9d359b0e3d..7c56ca8b9df3 100644 --- a/packages/contracts/src/pullRequest.test.ts +++ b/packages/contracts/src/pullRequest.test.ts @@ -5,7 +5,6 @@ import { describe, expect, it } from "vite-plus/test"; import { PullRequestDetail, PullRequestListResult, - pullRequestHostOf, resolvePullRequestAuthorFilter, } from "./pullRequest.ts"; @@ -61,24 +60,6 @@ const LIST_RESULT: PullRequestListResult = { }; describe("PullRequestListResult", () => { - it("separates Forgejo HTTP ports while preserving other provider host identities", () => { - const identity = { - canonicalKey: "forge.example/team/repo", - locator: { remoteUrl: "http://forge.example:3000/team/repo.git" }, - }; - expect(pullRequestHostOf(identity, SourceControlProviderKind.make("forgejo"))).toBe( - "forge.example:3000", - ); - expect(pullRequestHostOf(identity, SourceControlProviderKind.make("gitlab"))).toBe( - "forge.example", - ); - expect( - pullRequestHostOf( - { ...identity, locator: { remoteUrl: "ssh://git@forge.example:2222/team/repo.git" } }, - SourceControlProviderKind.make("forgejo"), - ), - ).toBe("forge.example"); - }); /** * The RPC builds this codec at call time, so a shape it cannot lower — an open-keyed record * with an optional value, for one — fails as an interrupted request rather than as a schema diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index bf8f124c3606..7e371e867722 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -938,27 +938,6 @@ const PROVIDER_REQUIREMENT = new Map< ], ]); -export function pullRequestHostOf( - identity: - | { - readonly canonicalKey?: string | undefined; - readonly locator?: { readonly remoteUrl: string } | undefined; - } - | null - | undefined, - kind: SourceControlProviderKind, -): string { - if (kind === "forgejo") { - try { - const remote = new URL(identity?.locator?.remoteUrl ?? ""); - if (remote.protocol === "http:" || remote.protocol === "https:") - return remote.host.toLowerCase(); - } catch {} - } - const host = identity?.canonicalKey?.split("/")[0]?.trim(); - return host === undefined || host.length === 0 ? kind : host.toLowerCase(); -} - /** * `author:me` names whoever is signed in to the host being read, GitHub's `@me` spelled either * way. A host's own search would answer it, but the page and the server both re-check an author diff --git a/packages/shared/src/git.test.ts b/packages/shared/src/git.test.ts index 91d01cd3a18f..adf44d2289e4 100644 --- a/packages/shared/src/git.test.ts +++ b/packages/shared/src/git.test.ts @@ -62,27 +62,6 @@ describe("normalizeGitRemoteUrl", () => { expect(normalizeGitRemoteUrl("C:/repos/project")).toBe("c:/repos/project"); expect(normalizeGitRemoteUrl("git@x:team/project.git")).toBe("x/team/project"); }); - - it("gives an Azure DevOps repository the same key over SSH as over HTTPS", () => { - expect(normalizeGitRemoteUrl("git@ssh.dev.azure.com:v3/T3Tools/Platform/T3Code")).toBe( - "dev.azure.com/t3tools/platform/_git/t3code", - ); - expect(normalizeGitRemoteUrl("ssh://git@ssh.dev.azure.com:22/v3/T3Tools/Platform/T3Code")).toBe( - "dev.azure.com/t3tools/platform/_git/t3code", - ); - expect( - normalizeGitRemoteUrl("https://T3Tools@dev.azure.com/T3Tools/Platform/_git/T3Code"), - ).toBe("dev.azure.com/t3tools/platform/_git/t3code"); - }); - - it("puts the organization back in the host on the name dev.azure.com replaced", () => { - expect( - normalizeGitRemoteUrl("T3Tools@vs-ssh.visualstudio.com:v3/T3Tools/Platform/T3Code"), - ).toBe("t3tools.visualstudio.com/platform/_git/t3code"); - expect(normalizeGitRemoteUrl("https://T3Tools.visualstudio.com/Platform/_git/T3Code")).toBe( - "t3tools.visualstudio.com/platform/_git/t3code", - ); - }); }); describe("parseOriginUrlFromGitConfig", () => { diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index 32b7fbf88585..7bf954cc3d8f 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -1,7 +1,6 @@ import type { BranchNamingOptions, VcsRef, - SourceControlProviderInfo, VcsStatusLocalResult, VcsStatusRemoteResult, VcsStatusResult, @@ -9,7 +8,6 @@ import type { } from "@t3tools/contracts"; import * as Arr from "effect/Array"; import * as Result from "effect/Result"; -import { detectSourceControlProviderFromRemoteUrl } from "./sourceControl.ts"; export const WORKTREE_BRANCH_PREFIX = "t3"; const TEMP_WORKTREE_HEX_TOKEN = "[0-9a-f]{8}"; @@ -125,41 +123,32 @@ export function isTemporaryWorktreeBranch(refName: string): boolean { return TEMP_WORKTREE_BRANCH_PATTERN.test(refName.trim().toLowerCase()); } -/** - * Azure alone addresses one repository under two names that share no part: `ssh.dev.azure.com` and - * `v3/{org}/{project}/{repo}` over SSH, against `dev.azure.com` and `{org}/{project}/_git/{repo}` - * everywhere a person sees it. A project cloned over SSH would otherwise be a different repository - * to every comparison made against a pull request URL, which arrives in the web spelling. - */ -function azureDevOpsRepositoryKey(host: string, segments: ReadonlyArray): string | null { - if (host !== "ssh.dev.azure.com" && host !== "vs-ssh.visualstudio.com") return null; - const [marker, organization, project, repository] = segments; - if (segments.length !== 4 || marker !== "v3") return null; - if (!organization || !project || !repository) return null; - return host === "ssh.dev.azure.com" - ? `dev.azure.com/${organization}/${project}/_git/${repository}` - : `${organization}.visualstudio.com/${project}/_git/${repository}`; -} - -export function normalizeGitRemoteUrl(value: string): string { - const normalized = value +function lowerCaseRemote(value: string): string { + return value .trim() .replace(/\/+$/g, "") .replace(/\.git$/i, "") .toLowerCase(); +} + +/** + * The host and repository path a remote names, lower case and without a port, or null for a + * remote that names no host, such as a local path. + */ +export function parseGitRemoteRepository( + value: string, +): { readonly host: string; readonly path: string } | null { + const normalized = lowerCaseRemote(value); if (/^(?:ssh|https?|git):\/\//i.test(normalized)) { try { const url = new URL(normalized); const repositorySegments = url.pathname.split("/").filter((segment) => segment.length > 0); if (url.hostname && repositorySegments.length > 1) { - return ( - azureDevOpsRepositoryKey(url.hostname, repositorySegments) ?? - `${url.hostname}/${repositorySegments.join("/")}` - ); + return { host: url.hostname, path: repositorySegments.join("/") }; } } catch { - return normalized; + return null; } } @@ -169,11 +158,17 @@ export function normalizeGitRemoteUrl(value: string): string { : /^(?:[a-zA-Z0-9._-]+@)?([^@:/\\\s]+):\/?([^/\s]+(?:\/[^/\s]+)+)$/i.exec(normalized); const scpHost = scpStyleHostAndPath?.[1]; const scpPath = scpStyleHostAndPath?.[2]; - if (scpHost && scpPath) { - return azureDevOpsRepositoryKey(scpHost, scpPath.split("/")) ?? `${scpHost}/${scpPath}`; - } + return scpHost && scpPath ? { host: scpHost, path: scpPath } : null; +} - return normalized; +/** + * `host/path` for a remote that names a host, otherwise the remote itself, lower case. A host that + * spells repositories differently over SSH has its source control registry's + * `repositoryKeyFromRemote` rewrite this, so persisted keys come from that, not from here. + */ +export function normalizeGitRemoteUrl(value: string): string { + const repository = parseGitRemoteRepository(value); + return repository === null ? lowerCaseRemote(value) : `${repository.host}/${repository.path}`; } function parseGitConfigValue(raw: string): string { @@ -251,20 +246,6 @@ export function parseOriginUrlFromGitConfig(configText: string): string | null { return originUrl ?? firstRemoteUrl; } -export function parseGitHubRepositoryNameWithOwnerFromRemoteUrl(url: string | null): string | null { - const trimmed = url?.trim() ?? ""; - if (trimmed.length === 0) { - return null; - } - - const match = - /^(?:git@github\.com:|ssh:\/\/(?:git@)?github\.com\/|https:\/\/github\.com\/|git:\/\/github\.com\/)([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/i.exec( - trimmed, - ); - const repositoryNameWithOwner = match?.[1]?.trim() ?? ""; - return repositoryNameWithOwner.length > 0 ? repositoryNameWithOwner : null; -} - function deriveLocalBranchNameCandidatesFromRemoteRef( branchName: string, remoteName?: string, @@ -318,12 +299,6 @@ export function dedupeRemoteBranchesWithLocalMatches( }); } -export function detectSourceControlProviderFromGitRemoteUrl( - remoteUrl: string, -): SourceControlProviderInfo | null { - return detectSourceControlProviderFromRemoteUrl(remoteUrl); -} - const EMPTY_GIT_STATUS_REMOTE: VcsStatusRemoteResult = { hasUpstream: false, aheadCount: 0, diff --git a/packages/shared/src/sourceControl.test.ts b/packages/shared/src/sourceControl.test.ts index 23d0c8b4ba81..bb3ad5716fdb 100644 --- a/packages/shared/src/sourceControl.test.ts +++ b/packages/shared/src/sourceControl.test.ts @@ -1,171 +1,11 @@ import { SourceControlProviderKind } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; +import { expect, it } from "vite-plus/test"; -import { - sourceControlRepositorySelector, - detectSourceControlProviderFromRemoteUrl, - getChangeRequestTerminologyForKind, -} from "./sourceControl.ts"; +import { getChangeRequestTerminologyForKind } from "./sourceControl.ts"; -describe("detectSourceControlProviderFromRemoteUrl", () => { - it("detects Forgejo and Gitea hosts while preserving HTTP origins", () => { - for (const host of ["codeberg.org", "forgejo.example.test", "gitea.example.test"]) { - expect(detectSourceControlProviderFromRemoteUrl(`http://${host}:3000/team/repo.git`)).toEqual( - { - kind: "forgejo", - name: "Forgejo", - baseUrl: `http://${host}:3000`, - }, - ); - } - expect(getChangeRequestTerminologyForKind(SourceControlProviderKind.make("forgejo"))).toEqual({ - shortLabel: "PR", - singular: "pull request", - }); +it("calls a Forgejo change request a pull request", () => { + expect(getChangeRequestTerminologyForKind(SourceControlProviderKind.make("forgejo"))).toEqual({ + shortLabel: "PR", + singular: "pull request", }); - - it("detects Azure DevOps SSH remotes", () => { - expect( - detectSourceControlProviderFromRemoteUrl("git@ssh.dev.azure.com:v3/org/project/repo")?.kind, - ).toBe("azure-devops"); - expect( - detectSourceControlProviderFromRemoteUrl("ssh://git@ssh.dev.azure.com:22/v3/org/project/repo") - ?.kind, - ).toBe("azure-devops"); - expect( - detectSourceControlProviderFromRemoteUrl("git@vs-ssh.visualstudio.com:v3/org/project/repo") - ?.kind, - ).toBe("azure-devops"); - }); - - it("preserves ports while classifying by hostname", () => { - expect( - detectSourceControlProviderFromRemoteUrl("https://gitlab.com:8443/group/repo.git"), - ).toEqual({ - kind: "gitlab", - name: "GitLab", - baseUrl: "https://gitlab.com:8443", - }); - expect( - detectSourceControlProviderFromRemoteUrl( - "https://self-hosted.example.test:8443/group/repo.git", - ), - ).toEqual({ - kind: "unknown", - name: "self-hosted.example.test:8443", - baseUrl: "https://self-hosted.example.test:8443", - }); - }); - - it("does not reuse SSH ports for HTTPS provider URLs", () => { - expect( - detectSourceControlProviderFromRemoteUrl("ssh://git@gitlab.example.test:24/group/repo.git"), - ).toEqual({ - kind: "gitlab", - name: "GitLab Self-Hosted", - baseUrl: "https://gitlab.example.test", - }); - expect( - detectSourceControlProviderFromRemoteUrl("ssh://git@code.example.test:24/team/project.git"), - ).toEqual({ - kind: "unknown", - name: "code.example.test", - baseUrl: "https://code.example.test", - }); - }); - - it("matches self-hosted providers by complete DNS labels", () => { - expect( - detectSourceControlProviderFromRemoteUrl("https://github.example.com/owner/repo.git")?.kind, - ).toBe("github"); - expect( - detectSourceControlProviderFromRemoteUrl("https://gitlab.example.com/group/repo.git")?.kind, - ).toBe("gitlab"); - expect( - detectSourceControlProviderFromRemoteUrl("https://bitbucket.example.com/workspace/repo.git") - ?.kind, - ).toBe("bitbucket"); - }); - - it("detects GitHub Enterprise Cloud data residency hosts on ghe.com", () => { - expect(detectSourceControlProviderFromRemoteUrl("https://acme.ghe.com/owner/repo.git")).toEqual( - { - kind: "github", - name: "GitHub Self-Hosted", - baseUrl: "https://acme.ghe.com", - }, - ); - expect(detectSourceControlProviderFromRemoteUrl("git@acme.ghe.com:owner/repo.git")?.kind).toBe( - "github", - ); - expect(detectSourceControlProviderFromRemoteUrl("https://ghe.com/owner/repo.git")?.kind).toBe( - "unknown", - ); - }); - - it("does not match provider names embedded in unrelated DNS labels", () => { - expect( - detectSourceControlProviderFromRemoteUrl("https://notgithub.example.com/owner/repo.git") - ?.kind, - ).toBe("unknown"); - expect( - detectSourceControlProviderFromRemoteUrl("https://notgitlab.example.com/group/repo.git") - ?.kind, - ).toBe("unknown"); - expect( - detectSourceControlProviderFromRemoteUrl( - "https://notbitbucket.example.com/workspace/repo.git", - )?.kind, - ).toBe("unknown"); - }); - - it("detects SSH remotes with non-git SSH users (e.g. gitlab@, deploy@)", () => { - expect( - detectSourceControlProviderFromRemoteUrl("gitlab@gitlab.example.com:group/project.git")?.kind, - ).toBe("gitlab"); - expect( - detectSourceControlProviderFromRemoteUrl("gitlab@gitlab.example.com:group/project.git") - ?.baseUrl, - ).toBe("https://gitlab.example.com"); - expect(detectSourceControlProviderFromRemoteUrl("deploy@github.com:owner/repo.git")?.kind).toBe( - "github", - ); - expect( - detectSourceControlProviderFromRemoteUrl("git@bitbucket.org:workspace/repo.git")?.kind, - ).toBe("bitbucket"); - }); -}); - -it("names an Azure DevOps repository by its own name, not its project path", () => { - // `az repos pr list --repository` takes a name and detects the organisation and project from - // the checkout; the recorded `org/project/_git/repo` path is refused, and the repository then - // reads as unavailable on the page. - const selector = sourceControlRepositorySelector({ - provider: "azure-devops", - displayName: "contoso/payments/_git/checkout", - owner: "contoso", - name: "checkout", - }); - expect(selector).toBe("checkout"); -}); - -it("decodes an Azure DevOps repository name that the remote URL percent-encodes", () => { - // A remote spells a space as `%20`, and `az repos pr list --repository` reads that literally. - const selector = sourceControlRepositorySelector({ - provider: "azure-devops", - displayName: "contoso/payments%20(eu)/_git/checkout%20(eu)", - owner: "contoso", - name: "checkout%20(eu)", - }); - expect(selector).toBe("checkout (eu)"); -}); - -it("keeps a GitLab identity's whole path, because a nested group is part of the name", () => { - const selector = sourceControlRepositorySelector({ - provider: "gitlab", - displayName: "group/subgroup/service", - owner: "group", - name: "service", - }); - expect(selector).toBe("group/subgroup/service"); }); diff --git a/packages/shared/src/sourceControl.ts b/packages/shared/src/sourceControl.ts index 5729d2015014..f7039961f975 100644 --- a/packages/shared/src/sourceControl.ts +++ b/packages/shared/src/sourceControl.ts @@ -1,5 +1,4 @@ -import { SourceControlProviderKind } from "@t3tools/contracts"; -import type { RepositoryIdentity, SourceControlProviderInfo } from "@t3tools/contracts"; +import type { SourceControlProviderInfo, SourceControlProviderKind } from "@t3tools/contracts"; export interface ChangeRequestPresentation { readonly icon: @@ -143,181 +142,3 @@ export function isSshRemoteUrl(remoteUrl: string): boolean { const trimmed = remoteUrl.trim(); return SCP_SSH_REMOTE_PATTERN.test(trimmed) || trimmed.toLowerCase().startsWith("ssh://"); } - -function parseRemoteHost(remoteUrl: string): string | null { - const trimmed = remoteUrl.trim(); - if (trimmed.length === 0) { - return null; - } - - const scpMatch = SCP_SSH_REMOTE_PATTERN.exec(trimmed); - if (scpMatch?.[1]) { - return scpMatch[1].toLowerCase(); - } - - try { - const url = new URL(trimmed); - return (url.protocol === "ssh:" ? url.hostname : url.host).toLowerCase(); - } catch { - return null; - } -} - -function parseHostName(host: string): string { - try { - return new URL(`https://${host}`).hostname.toLowerCase(); - } catch { - return host.replace(/:\d+$/u, "").toLowerCase(); - } -} - -function toBaseUrl(host: string): string { - return `https://${host}`; -} - -function hasDnsLabel(host: string, label: string): boolean { - return host.split(".").includes(label); -} - -/** `*.ghe.com` is GitHub Enterprise Cloud with data residency, one subdomain per enterprise. */ -function isGitHubHost(host: string): boolean { - return host === "github.com" || host.endsWith(".ghe.com") || hasDnsLabel(host, "github"); -} - -function isGitLabHost(host: string): boolean { - return host === "gitlab.com" || hasDnsLabel(host, "gitlab"); -} - -function isAzureDevOpsHost(host: string): boolean { - // `ssh.dev.azure.com` is the default Azure DevOps SSH clone host - // (git@ssh.dev.azure.com:v3/org/project/repo), so match any `*.dev.azure.com` - // subdomain, not just the bare `dev.azure.com`. Legacy hosts stay under - // `.visualstudio.com` (including `vs-ssh.visualstudio.com`). - return ( - host === "dev.azure.com" || - host.endsWith(".dev.azure.com") || - host.endsWith(".visualstudio.com") - ); -} - -function isGitCafeHost(host: string): boolean { - return host === "git.cafe" || host === "staging.git.cafe"; -} - -function isBitbucketHost(host: string): boolean { - return host === "bitbucket.org" || hasDnsLabel(host, "bitbucket"); -} - -export function detectSourceControlProviderFromRemoteUrl( - remoteUrl: string, -): SourceControlProviderInfo | null { - const host = parseRemoteHost(remoteUrl); - if (!host) { - return null; - } - const hostname = parseHostName(host); - - if (isGitCafeHost(hostname)) { - return { - kind: SourceControlProviderKind.make("gitcafe"), - name: "GitCafe", - baseUrl: toBaseUrl(hostname), - }; - } - - if ( - hostname === "codeberg.org" || - hasDnsLabel(hostname, "forgejo") || - hasDnsLabel(hostname, "gitea") - ) { - return { - kind: SourceControlProviderKind.make("forgejo"), - name: "Forgejo", - baseUrl: /^https?:/iu.test(remoteUrl.trim()) - ? new URL(remoteUrl.trim()).origin - : toBaseUrl(host), - }; - } - - if (isGitHubHost(hostname)) { - return { - kind: SourceControlProviderKind.make("github"), - name: hostname === "github.com" ? "GitHub" : "GitHub Self-Hosted", - baseUrl: toBaseUrl(host), - }; - } - - if (isGitLabHost(hostname)) { - return { - kind: SourceControlProviderKind.make("gitlab"), - name: hostname === "gitlab.com" ? "GitLab" : "GitLab Self-Hosted", - baseUrl: toBaseUrl(host), - }; - } - - if (isAzureDevOpsHost(hostname)) { - return { - kind: SourceControlProviderKind.make("azure-devops"), - name: "Azure DevOps", - baseUrl: toBaseUrl(host), - }; - } - - if (isBitbucketHost(hostname)) { - return { - kind: SourceControlProviderKind.make("bitbucket"), - name: hostname === "bitbucket.org" ? "Bitbucket" : "Bitbucket Self-Hosted", - baseUrl: toBaseUrl(host), - }; - } - - return { - kind: SourceControlProviderKind.make("unknown"), - name: host, - baseUrl: toBaseUrl(host), - }; -} - -function decodeUrlPathSegment(segment: string): string { - try { - return decodeURIComponent(segment); - } catch { - return segment; - } -} - -/** - * Azure DevOps is the exception: `az repos pr list --repository` takes a repository name, and - * takes the organisation and project from the checkout it detects — so the recorded - * `org/project/_git/repo` path is refused outright and the whole repository reads as - * unavailable. Its name is the last segment, which is what this hands over. The segment comes - * from the remote URL, where a name with a space reads `my%20repo`; `az` looks that up literally - * and answers 404, so it goes out decoded. - */ -export function sourceControlRepositorySelector( - identity: - | Pick - | null - | undefined, -): string | null { - if (!identity) return null; - if (identity.provider === "azure-devops") { - const segments = (identity.displayName ?? "").split("/").filter((part) => part !== "_git"); - const name = identity.name || segments.at(-1); - return name ? decodeUrlPathSegment(name) : null; - } - if (identity.displayName) return identity.displayName; - return identity.owner && identity.name ? `${identity.owner}/${identity.name}` : null; -} - -export function canonicalRepositoryKey(key: string): string { - return key - .replace( - /^(?:ssh\.dev\.azure\.com|vs-ssh\.visualstudio\.com)\/v3\/([^/]+)\/([^/]+)\/([^/]+)$/u, - "dev.azure.com/$1/$2/_git/$3", - ) - .replace( - /^([^.]+)\.visualstudio\.com\/(?:defaultcollection\/)?([^/]+)\/_git\/([^/]+)$/u, - "dev.azure.com/$1/$2/_git/$3", - ); -} diff --git a/packages/source-control-azure-devops/src/client/definition.test.ts b/packages/source-control-azure-devops/src/client/definition.test.ts index c4a0e4fd526b..b739e8de831f 100644 --- a/packages/source-control-azure-devops/src/client/definition.test.ts +++ b/packages/source-control-azure-devops/src/client/definition.test.ts @@ -1,4 +1,9 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import type { ChangeRequestProjectIdentity } from "@t3tools/source-control-core/client/definition"; +import { + makeSourceControlClientRegistry, + recognizeRemoteWith, +} from "@t3tools/source-control-core/client/definition"; import { describe, expect, it } from "vite-plus/test"; import { definition } from "./definition.ts"; @@ -102,3 +107,89 @@ describe("Azure DevOps change request URLs", () => { } }); }); + +describe("Azure DevOps remotes", () => { + it("recognises the SSH clone hosts as well as the web ones", () => { + for (const remote of [ + "git@ssh.dev.azure.com:v3/org/project/repo", + "ssh://git@ssh.dev.azure.com:22/v3/org/project/repo", + "git@vs-ssh.visualstudio.com:v3/org/project/repo", + "https://org@dev.azure.com/org/project/_git/repo", + ]) { + expect(recognizeRemoteWith(definition, remote)?.kind, remote).toBe("azure-devops"); + } + expect(recognizeRemoteWith(definition, "https://azure.example/o/p/_git/r")).toBeNull(); + }); +}); + +describe("Azure DevOps repository keys", () => { + const registry = makeSourceControlClientRegistry([definition]); + + it("gives a repository the same key over SSH as over HTTPS", () => { + for (const remote of [ + "git@ssh.dev.azure.com:v3/T3Tools/Platform/T3Code", + "ssh://git@ssh.dev.azure.com:22/v3/T3Tools/Platform/T3Code", + "https://T3Tools@dev.azure.com/T3Tools/Platform/_git/T3Code", + ]) { + expect(registry.repositoryKeyFromRemote(remote), remote).toBe( + "dev.azure.com/t3tools/platform/_git/t3code", + ); + } + }); + + it("keeps the organization in the host for the legacy visualstudio.com remotes", () => { + for (const remote of [ + "T3Tools@vs-ssh.visualstudio.com:v3/T3Tools/Platform/T3Code", + "https://T3Tools.visualstudio.com/Platform/_git/T3Code", + ]) { + expect(registry.repositoryKeyFromRemote(remote), remote).toBe( + "t3tools.visualstudio.com/platform/_git/t3code", + ); + } + }); + + it("compares every spelling on dev.azure.com", () => { + for (const key of [ + "ssh.dev.azure.com/v3/org/project/repo", + "vs-ssh.visualstudio.com/v3/org/project/repo", + "org.visualstudio.com/project/_git/repo", + "org.visualstudio.com/defaultcollection/project/_git/repo", + "dev.azure.com/org/project/_git/repo", + ]) { + expect(registry.canonicalRepositoryKey(key), key).toBe("dev.azure.com/org/project/_git/repo"); + expect(registry.findByRepositoryKey(key), key).toBe(definition); + } + expect(registry.canonicalRepositoryKey("tfs.example/c/p/_git/r")).toBe( + "tfs.example/c/p/_git/r", + ); + expect(registry.findByRepositoryKey("tfs.example/c/p/_git/r")).toBeUndefined(); + }); +}); + +describe("Azure DevOps repository selector", () => { + const registry = makeSourceControlClientRegistry([definition]); + + it("names a repository by its own name, not its project path", () => { + // `az repos pr list --repository` takes a name and detects the organisation and project from + // the checkout; the recorded `org/project/_git/repo` path is refused. + expect( + registry.repositorySelector({ + provider: SourceControlProviderKind.make("azure-devops"), + displayName: "contoso/payments/_git/checkout", + owner: "contoso", + name: "checkout", + }), + ).toBe("checkout"); + }); + + it("decodes a name the remote URL percent-encodes", () => { + expect( + registry.repositorySelector({ + provider: SourceControlProviderKind.make("azure-devops"), + displayName: "contoso/payments%20(eu)/_git/checkout%20(eu)", + owner: "contoso", + name: "checkout%20(eu)", + }), + ).toBe("checkout (eu)"); + }); +}); diff --git a/packages/source-control-azure-devops/src/client/definition.ts b/packages/source-control-azure-devops/src/client/definition.ts index ac83fb8fc3af..e0753bc82f08 100644 --- a/packages/source-control-azure-devops/src/client/definition.ts +++ b/packages/source-control-azure-devops/src/client/definition.ts @@ -1,5 +1,4 @@ import { SourceControlProviderKind } from "@t3tools/contracts"; -import { canonicalRepositoryKey } from "@t3tools/shared/sourceControl"; import { changeRequestUrlBelow, defineSourceControlClient, @@ -14,6 +13,44 @@ const CHECKOUT_COMMAND = /^az\s+repos\s+pr\s+checkout\s+(.+)$/i; const CHANGE_REQUEST_REFERENCE = /^https:\/\/(?:dev\.azure\.com\/[^/\s]+\/[^/\s]+|[^/\s]+\.visualstudio\.com\/[^/\s]+)\/_git\/[^/\s]+\/pullrequest\/(\d+)(?:[/?#].*)?$/i; +/** + * Azure alone addresses one repository under two names that share no part: `ssh.dev.azure.com` and + * `v3/{org}/{project}/{repo}` over SSH, against `dev.azure.com` and `{org}/{project}/_git/{repo}` + * everywhere a person sees it. A project cloned over SSH would otherwise be a different repository + * to every comparison made against a pull request URL, which arrives in the web spelling. + */ +function repositoryKeyFromRemote(key: string): string | null { + const match = + /^(ssh\.dev\.azure\.com|vs-ssh\.visualstudio\.com)\/v3\/([^/]+)\/([^/]+)\/([^/]+)$/u.exec(key); + if (match === null) return null; + const [, host, organization, project, repository] = match; + return host === "ssh.dev.azure.com" + ? `dev.azure.com/${organization}/${project}/_git/${repository}` + : `${organization}.visualstudio.com/${project}/_git/${repository}`; +} + +/** Every spelling of an Azure DevOps Services repository, on `dev.azure.com`. */ +function canonicalRepositoryKey(key: string): string | null { + const canonical = key + .replace( + /^(?:ssh\.dev\.azure\.com|vs-ssh\.visualstudio\.com)\/v3\/([^/]+)\/([^/]+)\/([^/]+)$/u, + "dev.azure.com/$1/$2/_git/$3", + ) + .replace( + /^([^.]+)\.visualstudio\.com\/(?:defaultcollection\/)?([^/]+)\/_git\/([^/]+)$/u, + "dev.azure.com/$1/$2/_git/$3", + ); + return canonical.startsWith("dev.azure.com/") ? canonical : null; +} + +function decodeUrlPathSegment(segment: string): string { + try { + return decodeURIComponent(segment); + } catch { + return segment; + } +} + function checkoutCommandId(args: string): string | null { const parts = args.trim().split(/\s+/).filter(Boolean); for (const [index, part] of parts.entries()) { @@ -23,8 +60,10 @@ function checkoutCommandId(args: string): string | null { return parts.find((part) => !part.startsWith("-")) ?? null; } +const KIND = SourceControlProviderKind.make("azure-devops"); + export const definition = defineSourceControlClient({ - kind: SourceControlProviderKind.make("azure-devops"), + kind: KIND, label: "Azure DevOps", pickerLabel: "Azure DevOps", icon: "azure-devops", @@ -39,9 +78,8 @@ export const definition = defineSourceControlClient({ defaultCloneTransport: "ssh", changeRequestUrl: ({ host, repository, number, remoteUrl, webUrl }) => { if (webUrl) return changeRequestUrlBelow(webUrl, `pullrequest/${number}`); - const [canonicalHost = host, ...path] = canonicalRepositoryKey( - `${host}/${repository}`.toLowerCase(), - ).split("/"); + const key = `${host}/${repository}`.toLowerCase(); + const [canonicalHost = host, ...path] = (canonicalRepositoryKey(key) ?? key).split("/"); return `${remoteWebOrigin(canonicalHost, remoteUrl)}/${path.join("/")}/pullrequest/${number}`; }, // Servers on different ports are different servers, so a port stays in the link. @@ -69,9 +107,37 @@ export const definition = defineSourceControlClient({ }, isChangeRequestReference: (url) => CHANGE_REQUEST_REFERENCE.test(url), checkoutChangeRequestHost: () => null, - isChangeRequestInRepository: (identity, link) => - isChangeRequestAuthorityOfProject(identity, link) && - canonicalRepositoryKey(identity.canonicalKey.toLowerCase()) === - canonicalRepositoryKey(`${link.host}/${link.repository}`.toLowerCase()), + // `ssh.dev.azure.com` is the default SSH clone host (git@ssh.dev.azure.com:v3/org/project/repo), + // so any `*.dev.azure.com` subdomain is Azure. Legacy hosts stay under `.visualstudio.com`, + // including `vs-ssh.visualstudio.com`. + recognizeRemote: ({ host, hostname }) => + hostname === "dev.azure.com" || + hostname.endsWith(".dev.azure.com") || + hostname.endsWith(".visualstudio.com") + ? { kind: KIND, name: "Azure DevOps", baseUrl: `https://${host}` } + : null, + /** + * `az repos pr list --repository` takes a repository name, and takes the organisation and + * project from the checkout it detects, so the recorded `org/project/_git/repo` path is refused + * outright and the whole repository reads as unavailable. The segment comes from the remote URL, + * where a name with a space reads `my%20repo`; `az` looks that up literally and answers 404, so + * it goes out decoded. + */ + repositorySelector: (identity) => { + const segments = (identity.displayName ?? "").split("/").filter((part) => part !== "_git"); + const name = identity.name || segments.at(-1); + return name ? decodeUrlPathSegment(name) : null; + }, + repositoryKeyFromRemote, + canonicalRepositoryKey, + isChangeRequestInRepository: (identity, link) => { + if (!isChangeRequestAuthorityOfProject(identity, link)) return false; + const projectKey = identity.canonicalKey.toLowerCase(); + const linkKey = `${link.host}/${link.repository}`.toLowerCase(); + return ( + (canonicalRepositoryKey(projectKey) ?? projectKey) === + (canonicalRepositoryKey(linkKey) ?? linkKey) + ); + }, canReadChangeRequestOnHost: () => false, }); diff --git a/packages/source-control-bitbucket/src/client/definition.test.ts b/packages/source-control-bitbucket/src/client/definition.test.ts index 5c53884c3376..c28e0dfbac38 100644 --- a/packages/source-control-bitbucket/src/client/definition.test.ts +++ b/packages/source-control-bitbucket/src/client/definition.test.ts @@ -1,3 +1,4 @@ +import { recognizeRemoteWith } from "@t3tools/source-control-core/client/definition"; import { describe, expect, it } from "vite-plus/test"; import { definition } from "./definition.ts"; @@ -36,3 +37,19 @@ describe("Bitbucket change request URLs", () => { ).toBe("https://bitbucket.org/w/repo/pull-requests/5"); }); }); + +describe("Bitbucket remotes", () => { + const recognize = (url: string) => recognizeRemoteWith(definition, url); + + it("recognises bitbucket.org and hosts with a bitbucket label", () => { + expect(recognize("git@bitbucket.org:workspace/repo.git")).toEqual({ + kind: "bitbucket", + name: "Bitbucket", + baseUrl: "https://bitbucket.org", + }); + expect(recognize("https://bitbucket.example.com/workspace/repo.git")?.name).toBe( + "Bitbucket Self-Hosted", + ); + expect(recognize("https://notbitbucket.example.com/workspace/repo.git")).toBeNull(); + }); +}); diff --git a/packages/source-control-bitbucket/src/client/definition.ts b/packages/source-control-bitbucket/src/client/definition.ts index 2d423fbb2279..3ad2e97aefd5 100644 --- a/packages/source-control-bitbucket/src/client/definition.ts +++ b/packages/source-control-bitbucket/src/client/definition.ts @@ -11,6 +11,7 @@ import { isChangeRequestInProjectRepository, isChangeRequestOnProjectHost, parseChangeRequestPath, + projectChangeRequestHost, } from "@t3tools/source-control-core/client/definition"; const safeShellArgument = /^[A-Za-z0-9._/@+=,-]+$/; @@ -97,9 +98,17 @@ export const definition = defineSourceControlClient({ checkoutCommandArgument: () => null, isChangeRequestReference: () => false, checkoutChangeRequestHost: () => null, + recognizeRemote: ({ host, hostname }) => + hostname === "bitbucket.org" || hostname.split(".").includes("bitbucket") + ? { + kind: KIND, + name: hostname === "bitbucket.org" ? "Bitbucket" : "Bitbucket Self-Hosted", + baseUrl: `https://${host}`, + } + : null, isChangeRequestInRepository: (identity, link) => - isChangeRequestInProjectRepository(KIND, identity, link), + isChangeRequestInProjectRepository(projectChangeRequestHost(identity, KIND), identity, link), canReadChangeRequestOnHost: (identity, link) => - isChangeRequestOnProjectHost(KIND, identity, link), + isChangeRequestOnProjectHost(projectChangeRequestHost(identity, KIND), identity, link), settings, }); diff --git a/packages/source-control-bitbucket/src/server/BitbucketApi.ts b/packages/source-control-bitbucket/src/server/BitbucketApi.ts index 2da9b4630b4b..07c7064b4498 100644 --- a/packages/source-control-bitbucket/src/server/BitbucketApi.ts +++ b/packages/source-control-bitbucket/src/server/BitbucketApi.ts @@ -17,10 +17,9 @@ import { } from "@t3tools/contracts"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; import { sanitizeBranchFragment } from "@t3tools/shared/git"; -import { - detectSourceControlProviderFromRemoteUrl, - isSshRemoteUrl, -} from "@t3tools/shared/sourceControl"; +import { isSshRemoteUrl } from "@t3tools/shared/sourceControl"; +import { recognizeRemoteWith } from "@t3tools/source-control-core/client/definition"; +import { definition } from "../client/definition.ts"; import { BitbucketPullRequestListSchema, @@ -791,7 +790,7 @@ export const make = Effect.gen(function* () { ); for (const remote of remotes.remotes) { - if (detectSourceControlProviderFromRemoteUrl(remote.url)?.kind !== "bitbucket") continue; + if (recognizeRemoteWith(definition, remote.url) === null) continue; const parsed = parseBitbucketRemoteUrl(remote.url); if (parsed) return parsed; } diff --git a/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.ts b/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.ts index 619b138ded51..ac7b947f1714 100644 --- a/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.ts +++ b/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.ts @@ -147,6 +147,7 @@ export const make = Effect.gen(function* () { const provider: PullRequestProviderApi = { kind: SourceControlProviderKind.make("bitbucket"), capabilities: CAPABILITIES, + readsPublicHostWithoutCheckout: true, getViewer: () => api.getViewer().pipe(Effect.mapError(fail("getViewer"))), diff --git a/packages/source-control-core/src/client/definition.ts b/packages/source-control-core/src/client/definition.ts index 69a4fa402627..9a13dd20a4ee 100644 --- a/packages/source-control-core/src/client/definition.ts +++ b/packages/source-control-core/src/client/definition.ts @@ -1,14 +1,15 @@ import { type PullRequestAction, - pullRequestHostOf, type PullRequestReviewVerdict, type RepositoryIdentity, SourceControlProviderKind, + type SourceControlProviderInfo, } from "@t3tools/contracts"; +import { normalizeGitRemoteUrl, parseGitRemoteRepository } from "@t3tools/shared/git"; import * as Schema from "effect/Schema"; /** - * The two strings are what `pullRequestHostOf` and the project's `repositoryIdentity` produce + * The two strings are what `changeRequestHost` and the project's `repositoryIdentity` produce * from a git remote — lower case, no port, the full path below the host — because links are * matched against those. `authority` keeps the port for hosts whose identity includes it. */ @@ -43,6 +44,29 @@ export type ChangeRequestProjectIdentity = Pick< "canonicalKey" | "locator" | "webUrl" | "displayName" | "owner" | "name" >; +export type ChangeRequestHostIdentity = + | { + readonly canonicalKey?: string | undefined; + readonly locator?: { readonly remoteUrl: string } | undefined; + } + | null + | undefined; + +export type RepositorySelectorIdentity = Pick< + RepositoryIdentity, + "provider" | "displayName" | "owner" | "name" +>; + +/** A git remote's host, as `recognizeRemote` reads it. */ +export interface GitRemoteHost { + /** Trimmed. */ + readonly remoteUrl: string; + /** Lower case. Keeps the port of an HTTP remote, never an SSH one. */ + readonly host: string; + /** `host` without its port. */ + readonly hostname: string; +} + export interface ChangeRequestCheckoutInput { readonly number: number; readonly headBranch: string; @@ -91,6 +115,32 @@ export interface SourceControlClientDefinition { link: ChangeRequestLink, ) => boolean; readonly checkoutChangeRequestHost: (identity: ChangeRequestProjectIdentity) => string | null; + /** Null when this host does not claim the remote. Asked in the registry's recognition order. */ + readonly recognizeRemote: (remote: GitRemoteHost) => SourceControlProviderInfo | null; + /** The host change requests in the project are listed and keyed under. */ + readonly changeRequestHost?: (identity: ChangeRequestHostIdentity) => string; + /** + * What the host's tools take to select the project's repository. Absent means the repository + * path, `owner/name`. + */ + readonly repositorySelector?: (identity: RepositorySelectorIdentity) => string | null; + /** + * For a host that spells one repository differently over SSH and on the web: the web spelling + * of the `host/path` key `normalizeGitRemoteUrl` made from a remote that names a host. Null when + * the key is not this host's. Persisted repository keys are built with it, so its output must + * not change. + */ + readonly repositoryKeyFromRemote?: (key: string) => string | null; + /** + * For a host with more than one spelling of a repository key: the one every spelling compares + * equal under. Null when the key is not this host's. + */ + readonly canonicalRepositoryKey?: (key: string) => string | null; + /** + * An SSH remote names the SSH server only, so the web host, and its port, come from resolving + * the provider rather than from the remote. + */ + readonly sshRemoteHidesWebHost?: boolean; readonly settings?: SourceControlHostSettingsSchema; } @@ -160,14 +210,23 @@ export function isChangeRequestAuthorityOfProject( return true; } +/** The host of the project's repository key, or `kind` when the key has none. */ +export function projectChangeRequestHost( + identity: ChangeRequestHostIdentity, + kind: Kind, +): string | Kind { + const host = identity?.canonicalKey?.split("/")[0]?.trim(); + return host === undefined || host.length === 0 ? kind : host.toLowerCase(); +} + +/** `projectHost` is the project's `changeRequestHost`. */ export function isChangeRequestOnProjectHost( - kind: SourceControlProviderKind, + projectHost: string, identity: ChangeRequestProjectIdentity, link: ChangeRequestLink, ): boolean { if (!isChangeRequestAuthorityOfProject(identity, link)) return false; - const host = pullRequestHostOf(identity, kind); - return host === link.host.toLowerCase() || host === link.authority; + return projectHost === link.host.toLowerCase() || projectHost === link.authority; } function projectRepositoryPath(identity: ChangeRequestProjectIdentity): string | null { @@ -178,7 +237,7 @@ function projectRepositoryPath(identity: ChangeRequestProjectIdentity): string | } export function isChangeRequestInProjectRepository( - kind: SourceControlProviderKind, + projectHost: string, identity: ChangeRequestProjectIdentity, link: ChangeRequestLink, ): boolean { @@ -186,10 +245,64 @@ export function isChangeRequestInProjectRepository( return ( repository !== null && repository.toLowerCase() === link.repository.toLowerCase() && - isChangeRequestOnProjectHost(kind, identity, link) + isChangeRequestOnProjectHost(projectHost, identity, link) ); } +const SCP_REMOTE = /^[a-zA-Z0-9._-]+@([^:/]+):/; + +/** Null when the remote names no host, such as a local path. */ +export function parseGitRemoteHost(remoteUrl: string): GitRemoteHost | null { + const trimmed = remoteUrl.trim(); + if (trimmed.length === 0) return null; + let host: string; + const scpHost = SCP_REMOTE.exec(trimmed)?.[1]; + if (scpHost) { + host = scpHost.toLowerCase(); + } else { + try { + const url = new URL(trimmed); + host = (url.protocol === "ssh:" ? url.hostname : url.host).toLowerCase(); + } catch { + return null; + } + } + if (host.length === 0) return null; + let hostname: string; + try { + hostname = new URL(`https://${host}`).hostname.toLowerCase(); + } catch { + hostname = host.replace(/:\d+$/u, "").toLowerCase(); + } + return { remoteUrl: trimmed, host, hostname }; +} + +/** What a remote on a host no definition claims reads as. */ +export function unknownRemoteProvider(remote: GitRemoteHost): SourceControlProviderInfo { + return { + kind: SourceControlProviderKind.make("unknown"), + name: remote.host, + baseUrl: `https://${remote.host}`, + }; +} + +/** What `definition` alone makes of a remote, for a host's own code that asks only about itself. */ +export function recognizeRemoteWith( + definition: Pick, + remoteUrl: string, +): SourceControlProviderInfo | null { + const remote = parseGitRemoteHost(remoteUrl); + return remote === null ? null : definition.recognizeRemote(remote); +} + +function defaultRepositorySelector( + identity: RepositorySelectorIdentity | null | undefined, +): string | null { + if (!identity) return null; + if (identity.displayName) return identity.displayName; + return identity.owner && identity.name ? `${identity.owner}/${identity.name}` : null; +} + export const UNKNOWN_SOURCE_CONTROL_CLIENT: SourceControlClientDefinition = { kind: SourceControlProviderKind.make("unknown"), label: "source control", @@ -213,10 +326,15 @@ export const UNKNOWN_SOURCE_CONTROL_CLIENT: SourceControlClientDefinition = { checkoutCommandArgument: () => null, isChangeRequestReference: () => false, checkoutChangeRequestHost: () => null, + recognizeRemote: () => null, isChangeRequestInRepository: (identity, link) => - isChangeRequestInProjectRepository(SourceControlProviderKind.make("unknown"), identity, link), + isChangeRequestInProjectRepository( + projectChangeRequestHost(identity, "unknown"), + identity, + link, + ), canReadChangeRequestOnHost: (identity, link) => - isChangeRequestOnProjectHost(SourceControlProviderKind.make("unknown"), identity, link), + isChangeRequestOnProjectHost(projectChangeRequestHost(identity, "unknown"), identity, link), }; export interface ParsedChangeRequest { @@ -235,6 +353,23 @@ export interface SourceControlClientRegistry { readonly hostLabelForChangeRequestUrl: (url: string) => string; readonly isSameChangeRequest: (left: string, right: string) => boolean; readonly changeRequestUrlFromReferenceAutolink: (url: string) => string | null; + /** + * The host a remote is on, from the first definition in recognition order that claims it, or an + * unknown host. Null when the remote names no host. + */ + readonly recognizeRemote: (remoteUrl: string) => SourceControlProviderInfo | null; + /** `normalizeGitRemoteUrl`, in the spelling the remote's host uses on the web. */ + readonly repositoryKeyFromRemote: (remoteUrl: string) => string; + readonly canonicalRepositoryKey: (key: string) => string; + /** The host that has more than one spelling of `key`, if any does. */ + readonly findByRepositoryKey: (key: string) => SourceControlClientDefinition | undefined; + readonly repositorySelector: ( + identity: RepositorySelectorIdentity | null | undefined, + ) => string | null; + readonly changeRequestHost: ( + identity: ChangeRequestHostIdentity, + kind: Kind, + ) => string | Kind; } function httpUrl(value: string): URL | null { @@ -246,15 +381,30 @@ function httpUrl(value: string): URL | null { } } +function inOrder( + definitions: ReadonlyArray, + firstKinds: ReadonlyArray | undefined, +): ReadonlyArray { + const first = (firstKinds ?? []).flatMap( + (kind) => definitions.find((definition) => definition.kind === kind) ?? [], + ); + return [...first, ...definitions.filter((definition) => !first.includes(definition))]; +} + /** * `definitions` is display order, and its first entry is what `get` returns for a repository * that has not reported its host yet. A change request URL goes to the first definition that * reads it, trying `claimChangeRequestUrlsFirst` before the rest, so a host that claims a route - * only on its own hostnames can precede one that claims the route everywhere. + * only on its own hostnames can precede one that claims the route everywhere. A remote likewise + * goes to the first that recognises it, in `recognizeRemotesIn` order and then the rest, because + * one hostname can carry several hosts' labels. */ export function makeSourceControlClientRegistry( definitions: ReadonlyArray, - options?: { readonly claimChangeRequestUrlsFirst?: ReadonlyArray }, + options?: { + readonly claimChangeRequestUrlsFirst?: ReadonlyArray; + readonly recognizeRemotesIn?: ReadonlyArray; + }, ): SourceControlClientRegistry { const byKind = new Map(); for (const definition of definitions) { @@ -263,11 +413,15 @@ export function makeSourceControlClientRegistry( } byKind.set(definition.kind, definition); } - const first = new Set(options?.claimChangeRequestUrlsFirst); - const claimOrder = [ - ...definitions.filter((definition) => first.has(definition.kind)), - ...definitions.filter((definition) => !first.has(definition.kind)), - ]; + const claimOrder = inOrder(definitions, options?.claimChangeRequestUrlsFirst); + const recognitionOrder = inOrder(definitions, options?.recognizeRemotesIn); + const repositoryKeyOwner = (key: string) => { + for (const definition of definitions) { + const canonical = definition.canonicalRepositoryKey?.(key); + if (canonical != null) return { definition, canonical }; + } + return undefined; + }; const parseChangeRequestUrl = (value: string): ParsedChangeRequest | null => { const url = httpUrl(value); if (url === null) return null; @@ -311,6 +465,35 @@ export function makeSourceControlClientRegistry( } return null; }, + recognizeRemote: (remoteUrl) => { + const remote = parseGitRemoteHost(remoteUrl); + if (remote === null) return null; + for (const definition of recognitionOrder) { + const provider = definition.recognizeRemote(remote); + if (provider !== null) return provider; + } + return unknownRemoteProvider(remote); + }, + repositoryKeyFromRemote: (remoteUrl) => { + const key = normalizeGitRemoteUrl(remoteUrl); + if (parseGitRemoteRepository(remoteUrl) === null) return key; + for (const definition of definitions) { + const spelled = definition.repositoryKeyFromRemote?.(key); + if (spelled != null) return spelled; + } + return key; + }, + canonicalRepositoryKey: (key) => repositoryKeyOwner(key)?.canonical ?? key, + findByRepositoryKey: (key) => repositoryKeyOwner(key)?.definition, + repositorySelector: (identity) => + identity?.provider === undefined + ? defaultRepositorySelector(identity) + : (byKind.get(identity.provider)?.repositorySelector ?? defaultRepositorySelector)( + identity, + ), + changeRequestHost: (identity, kind) => + (kind === undefined ? undefined : byKind.get(kind)?.changeRequestHost?.(identity)) ?? + projectChangeRequestHost(identity, kind), }; } diff --git a/packages/source-control-core/src/client/threadPullRequests.ts b/packages/source-control-core/src/client/threadPullRequests.ts index a16fbae8572f..3655f74e6088 100644 --- a/packages/source-control-core/src/client/threadPullRequests.ts +++ b/packages/source-control-core/src/client/threadPullRequests.ts @@ -4,7 +4,6 @@ import type { ThreadPullRequestKey, ThreadPullRequestLink, } from "@t3tools/contracts"; -import { canonicalRepositoryKey } from "@t3tools/shared/sourceControl"; import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; import type { SourceControlClientRegistry } from "./definition.ts"; @@ -63,7 +62,10 @@ export type ThreadPullRequestBadge = { * binds these once to its own registry. */ export function makeThreadPullRequests( - registry: Pick, + registry: Pick< + SourceControlClientRegistry, + "parseChangeRequestUrl" | "canonicalRepositoryKey" | "findByRepositoryKey" + >, ) { const parse = (url: string) => registry.parseChangeRequestUrl(url)?.link ?? null; @@ -74,7 +76,7 @@ export function makeThreadPullRequests( (parsed?.repository === key.repository.trim().toLowerCase() && parsed.number === key.number ? parsed.authority : undefined); - const canonical = canonicalRepositoryKey( + const canonical = registry.canonicalRepositoryKey( `${(authority ?? key.host).trim().toLowerCase()}/${key.repository.trim().toLowerCase()}`, ); const separator = canonical.indexOf("/"); @@ -91,8 +93,10 @@ export function makeThreadPullRequests( ): ThreadPullRequestKey { const parsed = parse(linked.url); if (parsed !== null && parsed.number === linked.number) { - const canonical = canonicalRepositoryKey(`${parsed.host}/${parsed.repository}`); - if (parsed.authority !== undefined || canonical.startsWith("dev.azure.com/")) { + if ( + parsed.authority !== undefined || + registry.findByRepositoryKey(`${parsed.host}/${parsed.repository}`) !== undefined + ) { return normalizeThreadPullRequestKey(parsed); } } diff --git a/packages/source-control-core/src/server/PullRequestProvider.ts b/packages/source-control-core/src/server/PullRequestProvider.ts index 32a06d2b314e..18fb90a3b3bf 100644 --- a/packages/source-control-core/src/server/PullRequestProvider.ts +++ b/packages/source-control-core/src/server/PullRequestProvider.ts @@ -296,6 +296,11 @@ export interface PullRequestProviderApi { * organization from the checkout, so only a checkout whose key matches the reference serves it. */ readonly repositoryKey?: (input: { readonly canonicalKey: string }) => string; + /** + * Names the repository in every request and authenticates by host, so a change request on the + * definition's `publicHost` can be read from a project with no checkout of its repository. + */ + readonly readsPublicHostWithoutCheckout?: boolean; readonly getViewer: (input: { readonly cwd: string; diff --git a/packages/source-control-forgejo/src/client/definition.test.ts b/packages/source-control-forgejo/src/client/definition.test.ts index 74d4cddbcd4d..f2d31320212c 100644 --- a/packages/source-control-forgejo/src/client/definition.test.ts +++ b/packages/source-control-forgejo/src/client/definition.test.ts @@ -1,4 +1,5 @@ import type { ChangeRequestProjectIdentity } from "@t3tools/source-control-core/client/definition"; +import { recognizeRemoteWith } from "@t3tools/source-control-core/client/definition"; import { describe, expect, it } from "vite-plus/test"; import { definition } from "./definition.ts"; @@ -102,3 +103,29 @@ describe("Forgejo change request URLs", () => { } }); }); + +describe("Forgejo remotes", () => { + it("recognises Codeberg and Forgejo or Gitea hosts, keeping an HTTP remote's origin", () => { + for (const host of ["codeberg.org", "forgejo.example.test", "gitea.example.test"]) { + expect(recognizeRemoteWith(definition, `http://${host}:3000/team/repo.git`)).toEqual({ + kind: "forgejo", + name: "Forgejo", + baseUrl: `http://${host}:3000`, + }); + } + expect( + recognizeRemoteWith(definition, "ssh://git@forgejo.example.test:2222/team/repo.git")?.baseUrl, + ).toBe("https://forgejo.example.test"); + expect(recognizeRemoteWith(definition, "https://git.example.test/team/repo")).toBeNull(); + }); +}); + +describe("Forgejo change request host", () => { + it("keeps an HTTP remote's port, because a server's identity includes it", () => { + const project = identity("http://forge.example:3000/git/team/repo.git"); + expect(definition.changeRequestHost?.(project)).toBe("forge.example:3000"); + expect( + definition.changeRequestHost?.(identity("ssh://git@forge.example:2222/git/team/repo.git")), + ).toBe("forge.example"); + }); +}); diff --git a/packages/source-control-forgejo/src/client/definition.ts b/packages/source-control-forgejo/src/client/definition.ts index 9f8c2aa3573e..1411a42c1e23 100644 --- a/packages/source-control-forgejo/src/client/definition.ts +++ b/packages/source-control-forgejo/src/client/definition.ts @@ -1,12 +1,14 @@ import { SourceControlProviderKind } from "@t3tools/contracts"; import { changeRequestUrlBelow, + type ChangeRequestHostIdentity, type ChangeRequestProjectIdentity, defineSourceControlClient, isChangeRequestAuthorityOfProject, isChangeRequestInProjectRepository, isChangeRequestOnProjectHost, parseChangeRequestPath, + projectChangeRequestHost, remoteWebOrigin, } from "@t3tools/source-control-core/client/definition"; @@ -33,6 +35,19 @@ function resolvedWebUrl(identity: ChangeRequestProjectIdentity): URL | null { } } +/** A server's identity includes its port, which only an HTTP remote names. */ +function changeRequestHost(identity: ChangeRequestHostIdentity): string { + try { + const remote = new URL(identity?.locator?.remoteUrl ?? ""); + if (remote.protocol === "http:" || remote.protocol === "https:") { + return remote.host.toLowerCase(); + } + } catch { + // SSH remotes do not name the web server. + } + return projectChangeRequestHost(identity, KIND); +} + export const definition = defineSourceControlClient({ kind: KIND, label: "Forgejo", @@ -40,8 +55,8 @@ export const definition = defineSourceControlClient({ icon: "forgejo", changeRequest: { shortLabel: "PR", singular: "pull request" }, repositoryPathHint: "owner/repo", - // Forgejo and Gitea are self-hosted, so no hostname names them. - publicHost: null, + // Codeberg is the public Forgejo; any other server is self-hosted. + publicHost: "codeberg.org", publishDescription: "Your signed-in server", publishHost: (signedInHost) => signedInHost ?? "your server", newRepositoryOwner: (account) => (account ? { owner: account } : null), @@ -71,10 +86,23 @@ export const definition = defineSourceControlClient({ checkoutCommandArgument: (input) => CHECKOUT_COMMAND.exec(input)?.[1]?.trim() ?? null, isChangeRequestReference: (url) => CHANGE_REQUEST_REFERENCE.test(url), checkoutChangeRequestHost: () => null, + recognizeRemote: ({ remoteUrl, host, hostname }) => + hostname === "codeberg.org" || + hostname.split(".").includes("forgejo") || + hostname.split(".").includes("gitea") + ? { + kind: KIND, + name: "Forgejo", + baseUrl: /^https?:/iu.test(remoteUrl) ? new URL(remoteUrl).origin : `https://${host}`, + } + : null, + changeRequestHost, + sshRemoteHidesWebHost: true, isChangeRequestInRepository: (identity, link) => { if (!isChangeRequestAuthorityOfProject(identity, link)) return false; const web = resolvedWebUrl(identity); - if (!web) return isChangeRequestInProjectRepository(KIND, identity, link); + if (!web) + return isChangeRequestInProjectRepository(changeRequestHost(identity), identity, link); return ( web.host.toLowerCase() === (link.authority ?? link.host).toLowerCase() && web.pathname.replace(/^\/+|\/+$/g, "").toLowerCase() === link.repository.toLowerCase() @@ -83,7 +111,7 @@ export const definition = defineSourceControlClient({ // A server mounted below a path serves only repositories under that mount. canReadChangeRequestOnHost: (identity, link) => { const web = resolvedWebUrl(identity); - if (!web) return isChangeRequestOnProjectHost(KIND, identity, link); + if (!web) return isChangeRequestOnProjectHost(changeRequestHost(identity), identity, link); const mount = web.pathname .replace(/^\/+|\/+$/g, "") .split("/") diff --git a/packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.ts b/packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.ts index b7616c276c3c..2ba5b43a4dd5 100644 --- a/packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.ts +++ b/packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.ts @@ -259,6 +259,7 @@ export const make = Effect.gen(function* () { const provider: PullRequestProviderApi = { kind: SourceControlProviderKind.make("forgejo"), capabilities: CAPABILITIES, + readsPublicHostWithoutCheckout: true, getViewer, listChangeRequests: Effect.fn("ForgejoPullRequestProvider.listChangeRequests")( function* (input) { diff --git a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts index c3e8146f5f0f..5b4d92418e0c 100644 --- a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts +++ b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts @@ -597,7 +597,7 @@ it.effect("gives Forgejo identities the browser URL of the login that serves the canonicalKey: "forge.test/team/repo", locator: { source: "git-remote" as const, remoteName: "origin", remoteUrl }, rootPath: "/repo", - ...(provider === undefined ? {} : { provider }), + ...(provider === undefined ? {} : { provider: SourceControlProviderKind.make(provider) }), }); const served = (baseUrl: string) => () => Effect.succeed({ diff --git a/packages/source-control-gitcafe/src/client/definition.test.ts b/packages/source-control-gitcafe/src/client/definition.test.ts index 4bc0c017aaa1..9c4914171500 100644 --- a/packages/source-control-gitcafe/src/client/definition.test.ts +++ b/packages/source-control-gitcafe/src/client/definition.test.ts @@ -1,3 +1,4 @@ +import { recognizeRemoteWith } from "@t3tools/source-control-core/client/definition"; import { describe, expect, it } from "vite-plus/test"; import { definition } from "./definition.ts"; @@ -47,3 +48,17 @@ describe("GitCafe change request URLs", () => { ).toBe("https://git.cafe/Team/Repo/pulls/8"); }); }); + +describe("GitCafe remotes", () => { + it("recognises its own hosts only, without a port", () => { + expect(recognizeRemoteWith(definition, "https://git.cafe:8443/a/b")).toEqual({ + kind: "gitcafe", + name: "GitCafe", + baseUrl: "https://git.cafe", + }); + expect(recognizeRemoteWith(definition, "git@staging.git.cafe:team/repo.git")?.kind).toBe( + "gitcafe", + ); + expect(recognizeRemoteWith(definition, "https://cafe.example/a/b")).toBeNull(); + }); +}); diff --git a/packages/source-control-gitcafe/src/client/definition.ts b/packages/source-control-gitcafe/src/client/definition.ts index ac7736d541b7..f0ea69bdbd2a 100644 --- a/packages/source-control-gitcafe/src/client/definition.ts +++ b/packages/source-control-gitcafe/src/client/definition.ts @@ -12,6 +12,7 @@ import { isChangeRequestInProjectRepository, isChangeRequestOnProjectHost, parseChangeRequestPath, + projectChangeRequestHost, } from "@t3tools/source-control-core/client/definition"; /** GitCafe has no self-hosted installs: production and staging are its only hosts. */ @@ -74,9 +75,13 @@ export const definition = defineSourceControlClient({ checkoutCommandArgument: () => null, isChangeRequestReference: () => false, checkoutChangeRequestHost: () => null, + recognizeRemote: ({ hostname }) => + GITCAFE_HOSTS.has(hostname) + ? { kind: KIND, name: "GitCafe", baseUrl: `https://${hostname}` } + : null, isChangeRequestInRepository: (identity, link) => - isChangeRequestInProjectRepository(KIND, identity, link), + isChangeRequestInProjectRepository(projectChangeRequestHost(identity, KIND), identity, link), canReadChangeRequestOnHost: (identity, link) => - isChangeRequestOnProjectHost(KIND, identity, link), + isChangeRequestOnProjectHost(projectChangeRequestHost(identity, KIND), identity, link), settings, }); diff --git a/packages/source-control-github/src/client/definition.test.ts b/packages/source-control-github/src/client/definition.test.ts index 5e2153bbb056..c929be7167aa 100644 --- a/packages/source-control-github/src/client/definition.test.ts +++ b/packages/source-control-github/src/client/definition.test.ts @@ -1,3 +1,4 @@ +import { recognizeRemoteWith } from "@t3tools/source-control-core/client/definition"; import { describe, expect, it } from "vite-plus/test"; import { definition } from "./definition.ts"; @@ -94,3 +95,29 @@ describe("GitHub change request URLs", () => { expect(candidate("https://code.example.test/acme/web/issues/42")).toBeNull(); }); }); + +describe("GitHub remotes", () => { + const recognize = (url: string) => recognizeRemoteWith(definition, url); + + it("recognises github.com, ghe.com subdomains and hosts with a github label", () => { + expect(recognize("deploy@github.com:owner/repo.git")).toEqual({ + kind: "github", + name: "GitHub", + baseUrl: "https://github.com", + }); + expect(recognize("https://acme.ghe.com/owner/repo.git")).toEqual({ + kind: "github", + name: "GitHub Self-Hosted", + baseUrl: "https://acme.ghe.com", + }); + expect(recognize("git@acme.ghe.com:owner/repo.git")?.kind).toBe("github"); + expect(recognize("https://github.example.com:8443/owner/repo.git")?.baseUrl).toBe( + "https://github.example.com:8443", + ); + }); + + it("does not claim ghe.com itself or a label that only contains github", () => { + expect(recognize("https://ghe.com/owner/repo.git")).toBeNull(); + expect(recognize("https://notgithub.example.com/owner/repo.git")).toBeNull(); + }); +}); diff --git a/packages/source-control-github/src/client/definition.ts b/packages/source-control-github/src/client/definition.ts index f9db4b87a3a0..c022ed824b41 100644 --- a/packages/source-control-github/src/client/definition.ts +++ b/packages/source-control-github/src/client/definition.ts @@ -2,7 +2,6 @@ import { GitHubHost, makeProviderSettingsSchema, secretSetting, - pullRequestHostOf, SourceControlProviderKind, TrimmedNonEmptyString, TrimmedString, @@ -15,6 +14,7 @@ import { isChangeRequestInProjectRepository, isChangeRequestOnProjectHost, parseChangeRequestPath, + projectChangeRequestHost, remoteWebOrigin, } from "@t3tools/source-control-core/client/definition"; @@ -118,10 +118,21 @@ export const definition = defineSourceControlClient({ reviewSummaryRequired: () => false, checkoutCommandArgument: (input) => CHECKOUT_COMMAND.exec(input)?.[1]?.trim() ?? null, isChangeRequestReference: (url) => CHANGE_REQUEST_REFERENCE.test(url), - checkoutChangeRequestHost: (identity) => pullRequestHostOf(identity, KIND), + checkoutChangeRequestHost: (identity) => projectChangeRequestHost(identity, KIND), + // `*.ghe.com` is GitHub Enterprise Cloud with data residency, one subdomain per enterprise. + recognizeRemote: ({ host, hostname }) => + hostname === "github.com" || + hostname.endsWith(".ghe.com") || + hostname.split(".").includes("github") + ? { + kind: KIND, + name: hostname === "github.com" ? "GitHub" : "GitHub Self-Hosted", + baseUrl: `https://${host}`, + } + : null, isChangeRequestInRepository: (identity, link) => - isChangeRequestInProjectRepository(KIND, identity, link), + isChangeRequestInProjectRepository(projectChangeRequestHost(identity, KIND), identity, link), canReadChangeRequestOnHost: (identity, link) => - isChangeRequestOnProjectHost(KIND, identity, link), + isChangeRequestOnProjectHost(projectChangeRequestHost(identity, KIND), identity, link), settings, }); diff --git a/packages/source-control-github/src/server/GitHubPullRequestProvider.ts b/packages/source-control-github/src/server/GitHubPullRequestProvider.ts index ab770dac4e03..f57bf3ee2246 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestProvider.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestProvider.ts @@ -243,6 +243,7 @@ export const make = Effect.gen(function* () { const provider: PullRequestProviderApi = { kind: SourceControlProviderKind.make("github"), capabilities: CAPABILITIES, + readsPublicHostWithoutCheckout: true, mergeMessageRewrite: removeAgentCredits, getRoutingIdentity: (input) => cli.getRoutingIdentity(input).pipe(Effect.mapError(fail("routeIdentity"))), diff --git a/packages/source-control-github/src/server/gitHubRepositoryResolution.ts b/packages/source-control-github/src/server/gitHubRepositoryResolution.ts index d9e2e9743e16..ddf57a42c33c 100644 --- a/packages/source-control-github/src/server/gitHubRepositoryResolution.ts +++ b/packages/source-control-github/src/server/gitHubRepositoryResolution.ts @@ -1,8 +1,7 @@ import { normalizeGitRemoteUrl } from "@t3tools/shared/git"; -import { - detectSourceControlProviderFromRemoteUrl, - isSshRemoteUrl, -} from "@t3tools/shared/sourceControl"; +import { isSshRemoteUrl } from "@t3tools/shared/sourceControl"; +import { parseGitRemoteHost } from "@t3tools/source-control-core/client/definition"; +import { definition } from "../client/definition.ts"; export interface GitHubRepositoryLocator { readonly host: string; @@ -163,13 +162,13 @@ export function parsePullRequestReference( * #6198). */ export function gitHubApiHostForRemote(remoteUrl: string): string | null { - const provider = detectSourceControlProviderFromRemoteUrl(remoteUrl); - if (provider === null) return null; - const host = new URL(provider.baseUrl).host.toLowerCase(); + const remote = parseGitRemoteHost(remoteUrl); + if (remote === null) return null; + const host = new URL(`https://${remote.host}`).host.toLowerCase(); if (isSshRemoteUrl(remoteUrl) && !host.includes(".")) { return host.includes("github") ? "github.com" : null; } - return provider.kind === "github" ? host : null; + return definition.recognizeRemote(remote) === null ? null : host; } function apiHostForHint(host: string): string { diff --git a/packages/source-control-gitlab/src/client/definition.test.ts b/packages/source-control-gitlab/src/client/definition.test.ts index 4184084e3864..02514ee373db 100644 --- a/packages/source-control-gitlab/src/client/definition.test.ts +++ b/packages/source-control-gitlab/src/client/definition.test.ts @@ -1,3 +1,4 @@ +import { recognizeRemoteWith } from "@t3tools/source-control-core/client/definition"; import { describe, expect, it } from "vite-plus/test"; import { definition } from "./definition.ts"; @@ -48,3 +49,27 @@ describe("GitLab change request URLs", () => { ).toBe("https://gitlab.com/group/sub/project/-/merge_requests/4"); }); }); + +describe("GitLab remotes", () => { + const recognize = (url: string) => recognizeRemoteWith(definition, url); + + it("keeps an HTTP port in the base URL and drops an SSH one", () => { + expect(recognize("https://gitlab.com:8443/group/repo.git")).toEqual({ + kind: "gitlab", + name: "GitLab", + baseUrl: "https://gitlab.com:8443", + }); + expect(recognize("ssh://git@gitlab.example.test:24/group/repo.git")).toEqual({ + kind: "gitlab", + name: "GitLab Self-Hosted", + baseUrl: "https://gitlab.example.test", + }); + expect(recognize("gitlab@gitlab.example.com:group/project.git")?.baseUrl).toBe( + "https://gitlab.example.com", + ); + }); + + it("matches complete DNS labels only", () => { + expect(recognize("https://notgitlab.example.com/group/repo.git")).toBeNull(); + }); +}); diff --git a/packages/source-control-gitlab/src/client/definition.ts b/packages/source-control-gitlab/src/client/definition.ts index a3f4a153a1a0..e944270a94e8 100644 --- a/packages/source-control-gitlab/src/client/definition.ts +++ b/packages/source-control-gitlab/src/client/definition.ts @@ -5,6 +5,7 @@ import { isChangeRequestInProjectRepository, isChangeRequestOnProjectHost, parseChangeRequestPath, + projectChangeRequestHost, } from "@t3tools/source-control-core/client/definition"; const CHECKOUT_COMMAND = /^glab\s+mr\s+checkout\s+(.+)$/i; @@ -51,8 +52,16 @@ export const definition = defineSourceControlClient({ checkoutCommandArgument: (input) => CHECKOUT_COMMAND.exec(input)?.[1]?.trim() ?? null, isChangeRequestReference: (url) => CHANGE_REQUEST_REFERENCE.test(url), checkoutChangeRequestHost: () => null, + recognizeRemote: ({ host, hostname }) => + hostname === "gitlab.com" || hostname.split(".").includes("gitlab") + ? { + kind: KIND, + name: hostname === "gitlab.com" ? "GitLab" : "GitLab Self-Hosted", + baseUrl: `https://${host}`, + } + : null, isChangeRequestInRepository: (identity, link) => - isChangeRequestInProjectRepository(KIND, identity, link), + isChangeRequestInProjectRepository(projectChangeRequestHost(identity, KIND), identity, link), canReadChangeRequestOnHost: (identity, link) => - isChangeRequestOnProjectHost(KIND, identity, link), + isChangeRequestOnProjectHost(projectChangeRequestHost(identity, KIND), identity, link), }); diff --git a/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.test.ts b/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.test.ts index 93defc897d79..2337b8b5fd74 100644 --- a/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.test.ts +++ b/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.test.ts @@ -1,8 +1,5 @@ import { SourceControlProviderKind } from "@t3tools/contracts"; -import { - detectSourceControlProviderFromGitRemoteUrl, - normalizeGitRemoteUrl, -} from "@t3tools/shared/git"; +import { normalizeGitRemoteUrl } from "@t3tools/shared/git"; import { refineUnknownRemoteProvider } from "@t3tools/source-control-core/server/discovery"; import { processOutput } from "@t3tools/source-control-testing/TestSourceControlHost"; import { assert, it } from "@effect/vitest"; @@ -128,7 +125,10 @@ it.effect.each([ canonicalKey: normalizeGitRemoteUrl(remoteUrl), locator: { source: "git-remote" as const, remoteName: "upstream", remoteUrl }, rootPath: "/repo", - provider: detectSourceControlProviderFromGitRemoteUrl(remoteUrl)?.kind ?? "unknown", + // Only a remote no host recognised is left for glab to refine. + provider: SourceControlProviderKind.make( + expectedProvider === "github" ? "github" : "unknown", + ), }; let authCalls = 0; const refined = yield* ( @@ -177,7 +177,10 @@ it.effect.each([ "https://code.example.test/team/repo/-/merge_requests/42", ); } - assert.deepStrictEqual(refined, { ...identity, provider: expectedProvider }); + assert.deepStrictEqual(refined, { + ...identity, + provider: SourceControlProviderKind.make(expectedProvider), + }); if (expectedProvider === "github") assert.strictEqual(authCalls, 0); }), ); diff --git a/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.ts b/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.ts index 38c404e6523f..d8404c6dbb7b 100644 --- a/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.ts +++ b/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.ts @@ -3,7 +3,10 @@ import * as Schema from "effect/Schema"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; -import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; +import { + parseGitRemoteHost, + unknownRemoteProvider as unknownHostProvider, +} from "@t3tools/source-control-core/client/definition"; import * as GitLabCli from "./GitLabCli.ts"; import * as GitLabChangeRequestTemplate from "./gitLabChangeRequestTemplate.ts"; @@ -105,13 +108,12 @@ function refineUnknownGitLabRemote(input: SourceControlUnknownRemoteRefinementIn } as const; } +/** The caller refines only an identity no host recognised, so its remote is on an unknown host. */ function unknownRemoteProvider(remoteUrl: string) { - // The shared detector does not recognize userless SCP remotes. + // Remote host parsing does not recognize userless SCP remotes. const scpHost = /^([^@:/\\\s]{2,}):\/?[^/\s]+(?:\/[^/\s]+)+$/u.exec(remoteUrl.trim())?.[1]; - const provider = detectSourceControlProviderFromRemoteUrl( - scpHost ? `ssh://${scpHost}` : remoteUrl, - ); - return provider?.kind === "unknown" ? provider : null; + const remote = parseGitRemoteHost(scpHost ? `ssh://${scpHost}` : remoteUrl); + return remote === null ? null : unknownHostProvider(remote); } // A glab login confirms a self-hosted GitLab whose hostname does not identify its provider. From 7368bd307bbfdebeddfacdc0049339664d8c27dc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 24 Oct 2026 04:24:34 -0700 Subject: [PATCH 03/27] refactor(source-control): drivers build their own services and own host media (#23994) Co-authored-by: Claude Opus 5.5 --- .../pull-requests/PullRequestMedia.tsx | 15 +- .../pull-requests/pullRequestMediaSource.ts | 8 +- apps/mobile/src/lib/videoPreviewSource.ts | 19 ++- apps/server/src/assets/AssetAccess.test.ts | 145 +++++++----------- apps/server/src/assets/AssetAccess.ts | 29 ++-- apps/server/src/assets/GitHubMediaFetch.ts | 138 ----------------- .../src/assets/sourceControlMediaResponse.ts | 80 ++++++++++ apps/server/src/http.ts | 13 +- .../PullRequestProviderRegistry.ts | 8 +- apps/server/src/server.ts | 5 +- .../SourceControlDiscovery.test.ts | 4 + .../SourceControlProviderRegistry.test.ts | 95 ++++++------ .../SourceControlProviderRegistry.ts | 16 +- .../SourceControlRepositoryService.test.ts | 45 ++++++ .../SourceControlRepositoryService.ts | 11 +- .../sourceControl/builtInDrivers.testkit.ts | 40 +++++ .../src/sourceControl/builtInDrivers.ts | 54 +++---- apps/server/src/ws.ts | 3 +- apps/web/src/components/ChatMarkdown.tsx | 34 ++-- .../src/components/chat/ChatMarkdownMedia.tsx | 4 +- .../pullRequest/PullRequestMarkdown.tsx | 27 ++-- knip.jsonc | 3 +- .../client-runtime/src/pullRequestMarkdown.ts | 5 +- packages/contracts/src/assets.ts | 19 ++- packages/shared/package.json | 4 - .../src/server/driver.ts | 23 ++- .../src/server/BitbucketApi.ts | 4 + .../server/BitbucketSourceControlProvider.ts | 11 +- .../src/server/driver.ts | 27 +++- .../src/client/definition.ts | 30 ++++ .../src/server/SourceControlProvider.ts | 13 ++ .../source-control-core/src/server/driver.ts | 63 +++++++- .../src/server/driver.ts | 21 ++- .../src/server/driver.ts | 23 ++- .../scripts/measure-pr-preview.ts | 65 +++++--- .../src/client/definition.test.ts | 38 +++++ .../src/client/definition.ts | 3 + .../src/client/gitHubMedia.ts} | 63 ++++---- .../src/server/GitHubRest.ts | 7 +- .../src/server/driver.ts | 29 +++- .../src/server/gitHubMediaFetch.test.ts | 104 +++++++++++++ .../src/server/gitHubMediaFetch.ts | 68 ++++++++ packages/source-control-github/tsconfig.json | 2 +- .../src/server/driver.ts | 23 ++- 44 files changed, 947 insertions(+), 494 deletions(-) delete mode 100644 apps/server/src/assets/GitHubMediaFetch.ts create mode 100644 apps/server/src/assets/sourceControlMediaResponse.ts create mode 100644 apps/server/src/sourceControl/builtInDrivers.testkit.ts rename {apps/server => packages/source-control-github}/scripts/measure-pr-preview.ts (69%) rename packages/{shared/src/githubMedia.ts => source-control-github/src/client/gitHubMedia.ts} (51%) create mode 100644 packages/source-control-github/src/server/gitHubMediaFetch.test.ts create mode 100644 packages/source-control-github/src/server/gitHubMediaFetch.ts diff --git a/apps/mobile/src/features/pull-requests/PullRequestMedia.tsx b/apps/mobile/src/features/pull-requests/PullRequestMedia.tsx index 8c176059ec3f..8f97b9c22899 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestMedia.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestMedia.tsx @@ -1,8 +1,12 @@ import { createContext, useContext, useMemo, useState } from "react"; import { pullRequestMediaSource, pullRequestImageSource } from "./pullRequestMediaSource"; import { useFilePreviewGallery } from "../../components/FilePreviewGallery"; -import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; -import { githubMediaFetchUrl } from "@t3tools/shared/githubMedia"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; +import { + SOURCE_CONTROL_MEDIA_ASSET, + type AssetResource, + type EnvironmentId, +} from "@t3tools/contracts"; import { videoMimeType } from "@t3tools/shared/video"; import { Pressable, View } from "react-native"; import { Image } from "expo-image"; @@ -25,9 +29,12 @@ export const PullRequestMediaContext = createContext<{ export function usePullRequestMediaUrl(url: string) { const context = useContext(PullRequestMediaContext); - const fetchUrl = githubMediaFetchUrl(url); + const fetchUrl = sourceControlClients.mediaUrl(url)?.fetchUrl; const resource = useMemo( - () => (context && fetchUrl ? { _tag: "github-media", cwd: context.cwd, url: fetchUrl } : null), + () => + context && fetchUrl + ? { _tag: SOURCE_CONTROL_MEDIA_ASSET, cwd: context.cwd, url: fetchUrl } + : null, [context, fetchUrl], ); const asset = useAssetUrlState(context?.environmentId ?? null, resource); diff --git a/apps/mobile/src/features/pull-requests/pullRequestMediaSource.ts b/apps/mobile/src/features/pull-requests/pullRequestMediaSource.ts index e45cd548bcc5..075eccfc38d9 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestMediaSource.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestMediaSource.ts @@ -1,5 +1,5 @@ -import type { EnvironmentId } from "@t3tools/contracts"; -import { githubMediaFetchUrl } from "@t3tools/shared/githubMedia"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; +import { SOURCE_CONTROL_MEDIA_ASSET, type EnvironmentId } from "@t3tools/contracts"; import type { FilePreviewItem } from "../../components/FilePreviewModal.types"; export interface PullRequestMediaLocation { @@ -9,11 +9,11 @@ export interface PullRequestMediaLocation { /** Keep the original resource identity so previewers can resolve and renew its capability. */ export function pullRequestMediaSource(url: string, location: PullRequestMediaLocation | null) { - const fetchUrl = githubMediaFetchUrl(url); + const fetchUrl = sourceControlClients.mediaUrl(url)?.fetchUrl; return location && fetchUrl ? { environmentId: location.environmentId, - resource: { _tag: "github-media" as const, cwd: location.cwd, url: fetchUrl }, + resource: { _tag: SOURCE_CONTROL_MEDIA_ASSET, cwd: location.cwd, url: fetchUrl }, } : { uri: url }; } diff --git a/apps/mobile/src/lib/videoPreviewSource.ts b/apps/mobile/src/lib/videoPreviewSource.ts index dabc896e2ded..837b4fbb0ae4 100644 --- a/apps/mobile/src/lib/videoPreviewSource.ts +++ b/apps/mobile/src/lib/videoPreviewSource.ts @@ -1,4 +1,9 @@ -import type { AssetResource, ChatFileAttachment, EnvironmentId } from "@t3tools/contracts"; +import { + SOURCE_CONTROL_MEDIA_ASSET, + type AssetResource, + type ChatFileAttachment, + type EnvironmentId, +} from "@t3tools/contracts"; import { videoMimeType } from "@t3tools/shared/video"; import type { DraftComposerFileAttachment } from "./composerImages"; @@ -17,7 +22,13 @@ export type MediaVideoPreviewSource = { readonly environmentId: EnvironmentId; readonly resource: Extract< AssetResource, - { readonly _tag: "attachment" | "media-file" | "draft-workspace-file" | "github-media" } + { + readonly _tag: + | "attachment" + | "media-file" + | "draft-workspace-file" + | typeof SOURCE_CONTROL_MEDIA_ASSET; + } >; } ); @@ -45,10 +56,10 @@ export function mediaVideoThumbnailKey(source: MediaVideoPreviewSource): string source.resource.path, source.srcFragment ?? "", ] - : source.resource._tag === "github-media" + : source.resource._tag === SOURCE_CONTROL_MEDIA_ASSET ? [ "media-video", - "github-media", + SOURCE_CONTROL_MEDIA_ASSET, source.environmentId, source.resource.cwd, source.resource.url, diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 5b63d61d80cb..0fb9f4eee5c8 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -4,7 +4,9 @@ import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform"; import * as NodeFSP from "node:fs/promises"; import { AssetAccessError, + AssetCreateUrlInput, AssetPreviewTypeValidationError, + SOURCE_CONTROL_MEDIA_ASSET, ThreadId, TurnItemId, } from "@t3tools/contracts"; @@ -15,12 +17,11 @@ 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 Redacted from "effect/Redacted"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; -import { HttpClient, HttpClientResponse, HttpServerResponse } from "effect/http"; +import { HttpClientRequest, HttpClientResponse, HttpServerResponse } from "effect/http"; import { vi } from "vite-plus/test"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; @@ -33,8 +34,10 @@ import { assetFileResponse } from "../http.ts"; import { ASSET_ROUTE_PREFIX, issueAssetUrl, readMcpAppHtml, resolveAsset } from "./AssetAccess.ts"; import * as NativeAppIconResolver from "./NativeAppIconResolver.ts"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; -import * as GitHubCredentials from "@t3tools/source-control-github/server/GitHubCredentials"; -import { githubMediaResponse } from "./GitHubMediaFetch.ts"; +import type { SourceControlDriverInstance } from "@t3tools/source-control-core/server/driver"; +import * as BuiltInDrivers from "../sourceControl/builtInDrivers.ts"; +import { sourceControlClients } from "../sourceControl/sourceControlClients.ts"; +import { sourceControlMediaResponse } from "./sourceControlMediaResponse.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; vi.mock("node:fs/promises", async (importOriginal) => { @@ -178,53 +181,6 @@ describe("AssetAccess", () => { ); }); - it.effect("loads private media immediately after login with the GitHub credential", () => { - let lookups = 0; - const authorizations: Array = []; - return Effect.gen(function* () { - const asset = { - url: "https://raw.githubusercontent.com/owner/repo/main/shot.png", - cwd: "/repo", - expiresAt: Number.MAX_SAFE_INTEGER, - }; - expect((yield* githubMediaResponse(asset, {})).status).toBe(404); - expect((yield* githubMediaResponse(asset, {})).status).toBe(200); - expect((yield* githubMediaResponse(asset, {})).status).toBe(200); - expect(lookups).toBe(3); - expect(authorizations).toEqual([undefined, "Bearer signed-in", "Bearer signed-in"]); - }).pipe( - Effect.provide( - Layer.mock(GitHubCredentials.GitHubCredentials)({ - get: (host) => - ++lookups === 1 - ? Effect.fail(new GitHubCredentials.GitHubNotSignedInError({ host })) - : Effect.succeed({ - host, - token: Redacted.make("signed-in"), - source: "gh" as const, - fingerprint: "fingerprint", - }), - }), - ), - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make((request) => { - authorizations.push(request.headers.authorization); - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - new Response(null, { - status: request.headers.authorization ? 200 : 404, - headers: { "content-type": "image/png" }, - }), - ), - ); - }), - ), - Effect.scoped, - ); - }); - it.effect("serves an image a tool returned inline from the stored item", () => Effect.gen(function* () { const resource = { @@ -994,51 +950,66 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(layerTest)), ); - it.effect("serves GitHub-hosted pull request media through the repository's credential", () => - Effect.gen(function* () { - const resolve = (relativeUrl: string) => { - const suffix = relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); - const separator = suffix.indexOf("/"); - return resolveAsset(suffix.slice(0, separator), suffix.slice(separator + 1)); - }; - const issue = (url: string) => - issueAssetUrl({ resource: { _tag: "github-media", cwd: "/repo", url } }); - - const attachment = yield* issue( - "https://github.com/user-attachments/assets/1a1842fb-6383-492f-873c-57aa0033fa6c", - ); - expect(attachment.relativeUrl.endsWith("/1a1842fb-6383-492f-873c-57aa0033fa6c")).toBe(true); - expect(yield* resolve(attachment.relativeUrl)).toEqual({ - kind: "github-media", - url: "https://github.com/user-attachments/assets/1a1842fb-6383-492f-873c-57aa0033fa6c", - cwd: "/repo", - expiresAt: attachment.expiresAt, + it.effect("serves a host's credentialed media through that host's driver", () => { + const fetched: Array<{ url: string; headers: Readonly> }> = []; + const url = "https://github.com/owner/repo/blob/main/docs/shot.png"; + const media = sourceControlClients.mediaUrl(url)!; + const layerDrivers = Layer.succeed(BuiltInDrivers.SourceControlDrivers, [ + { + driver: { kind: media.definition.kind, definition: media.definition }, + instance: { + sourceControl: {} as SourceControlDriverInstance["sourceControl"], + discovery: {} as SourceControlDriverInstance["discovery"], + pullRequests: null, + fetchMedia: (request) => + Effect.sync(() => { + fetched.push(request); + return HttpClientResponse.fromWeb( + HttpClientRequest.get(request.url), + new Response("bytes", { status: 206, headers: { "content-length": "5" } }), + ); + }), + }, + }, + ]); + return Effect.gen(function* () { + // Every client and server release spells the resource this way on the wire. + const { resource } = yield* Schema.decodeUnknownEffect(AssetCreateUrlInput)({ + resource: { _tag: "github-media", cwd: "/repo", url }, }); - - const committed = yield* issue("https://github.com/owner/repo/blob/main/docs/shot.png"); - expect(yield* resolve(committed.relativeUrl)).toMatchObject({ - url: "https://raw.githubusercontent.com/owner/repo/main/docs/shot.png", + const issued = yield* issueAssetUrl({ resource }); + const suffix = issued.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separator = suffix.indexOf("/"); + const asset = yield* resolveAsset(suffix.slice(0, separator), suffix.slice(separator + 1)); + expect(asset).toEqual({ + kind: "source-control-media", + url: media.fetchUrl, + cwd: "/repo", + expiresAt: issued.expiresAt, }); + if (asset?.kind !== "source-control-media") return; - const legacy = yield* issue("https://github.com/owner/repo/assets/45952064/1a1842fb"); - expect(yield* resolve(legacy.relativeUrl)).toMatchObject({ - url: "https://github.com/owner/repo/assets/45952064/1a1842fb", - }); - const lfs = yield* issue("https://media.githubusercontent.com/media/owner/repo/main/a.mp4"); - expect(yield* resolve(lfs.relativeUrl)).toMatchObject({ - url: "https://media.githubusercontent.com/media/owner/repo/main/a.mp4", + const response = yield* sourceControlMediaResponse(asset, { + range: "bytes=0-4", + cookie: "session", }); - const awkward = yield* issue("https://raw.githubusercontent.com/o/r/main/100%.png"); - expect(awkward.relativeUrl.endsWith("/100%25.png")).toBe(true); + expect(fetched).toEqual([{ url: media.fetchUrl, headers: { range: "bytes=0-4" } }]); + expect(response.status).toBe(206); + // The upstream sent no type, so it comes from the file name the host gave the media. + expect(response.headers["content-type"]).toBe("image/png"); + expect(response.headers["content-length"]).toBe("5"); + }).pipe(Effect.provide(Layer.merge(layerTest, layerDrivers)), Effect.scoped); + }); + it.effect("refuses media no host keeps behind a credential, without echoing the URL", () => + Effect.gen(function* () { for (const url of [ "https://example.com/shot.png", "https://example.com/shot.png?token=private-media-token", - "http://github.com/user-attachments/assets/1a1842fb", - "https://github.com/owner/repo/pull/1", - "https://github.com/owner/repo/blob/main/", ]) { - const error = yield* issue(url).pipe(Effect.flip); + const error = yield* issueAssetUrl({ + resource: { _tag: SOURCE_CONTROL_MEDIA_ASSET, cwd: "/repo", url }, + }).pipe(Effect.flip); expect(error._tag).toBe("AssetGitHubMediaUrlValidationError"); const encoded = yield* Schema.encodeEffect(Schema.fromJsonString(AssetAccessError))(error); expect(encoded).not.toContain(url); diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index cc9621b962c2..2d53da803922 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -1,7 +1,7 @@ import type { AssetResource } from "@t3tools/contracts"; import { AssetAttachmentNotFoundError, - AssetGitHubMediaUrlValidationError, + AssetSourceControlMediaUrlValidationError, AssetPreviewTypeValidationError, AssetProjectFaviconInspectionError, AssetProjectFaviconNotFoundError, @@ -13,6 +13,7 @@ import { AssetWorkspacePathValidationError, AssetWorkspaceResolutionError, AssetWorkspaceRootNormalizationError, + SOURCE_CONTROL_MEDIA_ASSET, ThreadId, ToolActivityNativeAppReference, TurnItemId, @@ -30,7 +31,6 @@ import { readImageDimensions, type ImageDimensions, } from "@t3tools/shared/imageDimensions"; -import { githubMediaFetchUrl, githubMediaFileName } from "@t3tools/shared/githubMedia"; import { MCP_APP_MAX_HTML_BYTES } from "@t3tools/shared/mcpApp"; import { isProjectFaviconPath, @@ -64,6 +64,7 @@ import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; import { isWithinRoot } from "../workspace/KnownWorkspaces.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import * as NativeAppIconResolver from "./NativeAppIconResolver.ts"; +import { sourceControlClients } from "../sourceControl/sourceControlClients.ts"; import { extractIcnsPng } from "./icns.ts"; import { openMediaFile, readMediaFileHeader, type OpenMediaFile } from "./MediaFile.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; @@ -160,8 +161,8 @@ const AssetClaimsSchema = Schema.Union([ }), Schema.Struct({ version: Schema.Literal(1), - kind: Schema.Literal("github-media"), - /** Already narrowed to a GitHub media host at mint time; the signature is what keeps it there. */ + kind: Schema.Literal(SOURCE_CONTROL_MEDIA_ASSET), + /** A host's `fetchUrl`, narrowed at mint time; the signature is what keeps it there. */ url: Schema.String, cwd: Schema.String, expiresAt: Schema.Number, @@ -189,7 +190,7 @@ export type ResolvedAsset = readonly mimeType: string; } | { - readonly kind: "github-media"; + readonly kind: "source-control-media"; readonly url: string; readonly cwd: string; readonly expiresAt: number; @@ -803,19 +804,19 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i fileName = "native-app-icon.png"; break; } - case "github-media": { - const fetchUrl = githubMediaFetchUrl(input.resource.url); - if (fetchUrl === null) { - return yield* new AssetGitHubMediaUrlValidationError({}); + case SOURCE_CONTROL_MEDIA_ASSET: { + const media = sourceControlClients.mediaUrl(input.resource.url); + if (media === null) { + return yield* new AssetSourceControlMediaUrlValidationError({}); } claims = { version: 1, - kind: "github-media", - url: fetchUrl, + kind: SOURCE_CONTROL_MEDIA_ASSET, + url: media.fetchUrl, cwd: input.resource.cwd, expiresAt, }; - fileName = githubMediaFileName(fetchUrl); + fileName = media.fileName; break; } } @@ -926,9 +927,9 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( return faviconPath === claims.filePath ? yield* projectFaviconAsset(faviconPath) : null; } - if (claims.kind === "github-media") { + if (claims.kind === SOURCE_CONTROL_MEDIA_ASSET) { return { - kind: "github-media", + kind: "source-control-media", url: claims.url, cwd: claims.cwd, expiresAt: claims.expiresAt, diff --git a/apps/server/src/assets/GitHubMediaFetch.ts b/apps/server/src/assets/GitHubMediaFetch.ts deleted file mode 100644 index 668f91b3d78a..000000000000 --- a/apps/server/src/assets/GitHubMediaFetch.ts +++ /dev/null @@ -1,138 +0,0 @@ -import * as Mime from "effect/http/Mime"; -import { githubMediaFileName } from "@t3tools/shared/githubMedia"; -import * as Clock from "effect/Clock"; -import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; -import * as Redacted from "effect/Redacted"; -import { - FetchHttpClient, - HttpClient, - HttpClientRequest, - HttpServerResponse, - type HttpClientResponse, -} from "effect/http"; - -import * as GitHubCredentials from "@t3tools/source-control-github/server/GitHubCredentials"; - -/** - * Everything a redirect leads to — the presigned object stores GitHub hands assets off to — - * authorizes with its own signature, and some of them reject a request that also carries a - * bearer token. - */ -const CREDENTIALED_HOSTS = new Set([ - "github.com", - "www.github.com", - "raw.githubusercontent.com", - "media.githubusercontent.com", -]); -const isCredentialedHost = (url: string) => { - try { - return CREDENTIALED_HOSTS.has(new URL(url).hostname.toLowerCase()); - } catch { - return false; - } -}; - -const MAX_REDIRECTS = 3; -const MANUAL_REDIRECT: RequestInit = { redirect: "manual" }; -const FORWARDED_REQUEST_HEADERS = ["range", "if-range"] as const; -const FORWARDED_RESPONSE_HEADERS = [ - "content-type", - "content-length", - "content-range", - "accept-ranges", - "etag", - "last-modified", -] as const; -const MEDIA_CONTENT_TYPE_PATTERN = /^(?:image|video|audio)\/[\w!#$&^.+-]+$/i; -const SVG_CONTENT_TYPE = "image/svg+xml"; -// An SVG is a document: same policy the asset route gives a workspace SVG, so one embedded in a -// body cannot run script against this origin. -const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inline'; sandbox"; - -const githubToken = GitHubCredentials.GitHubCredentials.pipe( - Effect.flatMap((credentials) => credentials.get("github.com")), - Effect.map((credential) => credential.token), - Effect.orElseSucceed(() => null), -); - -const fetchFollowingRedirects = Effect.fn("GitHubMediaFetch.fetchFollowingRedirects")(function* ( - url: string, - headers: Record, - token: Redacted.Redacted | null, -) { - const httpClient = HttpClient.withScope(yield* HttpClient.HttpClient); - let target = url; - for (let hop = 0; ; hop += 1) { - const authorization = - token !== null && isCredentialedHost(target) ? `Bearer ${Redacted.value(token)}` : null; - const response: HttpClientResponse.HttpClientResponse = yield* httpClient - .execute( - HttpClientRequest.get(target).pipe( - HttpClientRequest.setHeaders({ - ...headers, - // The bytes are streamed straight through, so never let an encoding layer in. - "accept-encoding": "identity", - ...(authorization === null ? {} : { authorization }), - }), - ), - ) - .pipe(Effect.provideService(FetchHttpClient.RequestInit, MANUAL_REDIRECT)); - const location = response.headers.location; - if (response.status < 300 || response.status >= 400) return response; - if (!location || hop >= MAX_REDIRECTS) return null; - const next = new URL(location, target); - if (next.protocol !== "https:") return null; - target = next.toString(); - } -}); - -export const githubMediaResponse = Effect.fn("GitHubMediaFetch.githubMediaResponse")(function* ( - asset: { readonly url: string; readonly cwd: string; readonly expiresAt: number }, - requestHeaders: Record, -) { - // Both media hosts are served by github.com's account, which is the host `gh` stores it under. - const token = yield* githubToken; - const forwarded: Record = {}; - for (const name of FORWARDED_REQUEST_HEADERS) { - const value = requestHeaders[name]; - if (value !== undefined) forwarded[name] = value; - } - const response = yield* fetchFollowingRedirects(asset.url, forwarded, token); - const remainingSeconds = Math.floor((asset.expiresAt - (yield* Clock.currentTimeMillis)) / 1000); - const headers: Record = { - "cache-control": - remainingSeconds > 0 ? `private, max-age=${remainingSeconds}` : "private, no-store", - "x-content-type-options": "nosniff", - }; - if (response === null) return HttpServerResponse.empty({ status: 502, headers }); - // It carries none of the upstream entity headers: a `content-length` with no body behind it - // holds the connection open until the browser gives up on it. - if (response.status >= 400) { - return HttpServerResponse.empty({ - status: response.status >= 500 ? 502 : response.status, - headers, - }); - } - const upstreamType = - response.headers["content-type"]?.split(";", 1)[0]?.trim().toLowerCase() ?? ""; - const contentType = MEDIA_CONTENT_TYPE_PATTERN.test(upstreamType) - ? upstreamType - : Option.getOrElse(Mime.getType(githubMediaFileName(asset.url)), () => "").toLowerCase(); - if (!MEDIA_CONTENT_TYPE_PATTERN.test(contentType)) { - return HttpServerResponse.empty({ status: 415, headers }); - } - for (const name of FORWARDED_RESPONSE_HEADERS) { - const value = response.headers[name]; - if (value !== undefined) headers[name] = value; - } - headers["content-type"] = contentType; - if (contentType === SVG_CONTENT_TYPE) { - headers["content-security-policy"] = SVG_CONTENT_SECURITY_POLICY; - } - return HttpServerResponse.stream(response.stream, { - status: response.status, - headers, - contentType, - }); -}); diff --git a/apps/server/src/assets/sourceControlMediaResponse.ts b/apps/server/src/assets/sourceControlMediaResponse.ts new file mode 100644 index 000000000000..9c49420670fb --- /dev/null +++ b/apps/server/src/assets/sourceControlMediaResponse.ts @@ -0,0 +1,80 @@ +import * as Mime from "effect/http/Mime"; +import * as Clock from "effect/Clock"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import { HttpServerResponse } from "effect/http"; + +import * as BuiltInDrivers from "../sourceControl/builtInDrivers.ts"; +import { sourceControlClients } from "../sourceControl/sourceControlClients.ts"; + +const FORWARDED_REQUEST_HEADERS = ["range", "if-range"] as const; +const FORWARDED_RESPONSE_HEADERS = [ + "content-type", + "content-length", + "content-range", + "accept-ranges", + "etag", + "last-modified", +] as const; +const MEDIA_CONTENT_TYPE_PATTERN = /^(?:image|video|audio)\/[\w!#$&^.+-]+$/i; +const SVG_CONTENT_TYPE = "image/svg+xml"; +// An SVG is a document: same policy the asset route gives a workspace SVG, so one embedded in a +// body cannot run script against this origin. +const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inline'; sandbox"; + +export const sourceControlMediaResponse = Effect.fn( + "SourceControlMediaResponse.sourceControlMediaResponse", +)(function* ( + asset: { readonly url: string; readonly cwd: string; readonly expiresAt: number }, + requestHeaders: Record, +) { + const remainingSeconds = Math.floor((asset.expiresAt - (yield* Clock.currentTimeMillis)) / 1000); + const headers: Record = { + "cache-control": + remainingSeconds > 0 ? `private, max-age=${remainingSeconds}` : "private, no-store", + "x-content-type-options": "nosniff", + }; + const media = sourceControlClients.mediaUrl(asset.url); + const drivers = yield* BuiltInDrivers.SourceControlDrivers; + const fetchMedia = drivers.find(({ driver }) => driver.kind === media?.definition.kind)?.instance + .fetchMedia; + if (media === null || fetchMedia === undefined) { + return HttpServerResponse.empty({ status: 404, headers }); + } + const forwarded: Record = {}; + for (const name of FORWARDED_REQUEST_HEADERS) { + const value = requestHeaders[name]; + if (value !== undefined) forwarded[name] = value; + } + const response = yield* fetchMedia({ url: asset.url, headers: forwarded }); + if (response === null) return HttpServerResponse.empty({ status: 502, headers }); + // It carries none of the upstream entity headers: a `content-length` with no body behind it + // holds the connection open until the browser gives up on it. + if (response.status >= 400) { + return HttpServerResponse.empty({ + status: response.status >= 500 ? 502 : response.status, + headers, + }); + } + const upstreamType = + response.headers["content-type"]?.split(";", 1)[0]?.trim().toLowerCase() ?? ""; + const contentType = MEDIA_CONTENT_TYPE_PATTERN.test(upstreamType) + ? upstreamType + : Option.getOrElse(Mime.getType(media.fileName), () => "").toLowerCase(); + if (!MEDIA_CONTENT_TYPE_PATTERN.test(contentType)) { + return HttpServerResponse.empty({ status: 415, headers }); + } + for (const name of FORWARDED_RESPONSE_HEADERS) { + const value = response.headers[name]; + if (value !== undefined) headers[name] = value; + } + headers["content-type"] = contentType; + if (contentType === SVG_CONTENT_TYPE) { + headers["content-security-policy"] = SVG_CONTENT_SECURITY_POLICY; + } + return HttpServerResponse.stream(response.stream, { + status: response.status, + headers, + contentType, + }); +}); diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 8871a5df3451..b6c6833a87d6 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -31,7 +31,7 @@ import { OtlpTracer, OtlpSerialization } from "effect/observability"; import * as ServerConfig from "./config.ts"; import { ASSET_ROUTE_PREFIX, readMcpAppHtml, resolveAsset } from "./assets/AssetAccess.ts"; -import { githubMediaResponse } from "./assets/GitHubMediaFetch.ts"; +import { sourceControlMediaResponse } from "./assets/sourceControlMediaResponse.ts"; import { statMediaFile, streamMediaFile, type OpenMediaFile } from "./assets/MediaFile.ts"; import { ATTACHMENT_UPLOAD_ROUTE_PREFIX, @@ -432,10 +432,15 @@ export const layerAssetRoute = HttpRouter.add( headers: { "cache-control": "private, max-age=3600", "x-content-type-options": "nosniff" }, }); } - if (asset.kind === "github-media") { - return yield* githubMediaResponse(asset, request.headers).pipe( + if (asset.kind === "source-control-media") { + return yield* sourceControlMediaResponse(asset, request.headers).pipe( Effect.tapError((cause) => - Effect.logWarning("Failed to fetch GitHub media.", { url: asset.url, cause }), + // The URL can carry a token in its query string, and the error keeps the request it + // failed on; the host and the failure kind are enough to diagnose. + Effect.logWarning("Failed to fetch source control media.", { + host: URL.canParse(asset.url) ? new URL(asset.url).host : undefined, + reason: cause.reason._tag, + }), ), Effect.orElseSucceed(() => HttpServerResponse.empty({ diff --git a/apps/server/src/pullRequest/PullRequestProviderRegistry.ts b/apps/server/src/pullRequest/PullRequestProviderRegistry.ts index 22a07e2a2317..72cd314f5e28 100644 --- a/apps/server/src/pullRequest/PullRequestProviderRegistry.ts +++ b/apps/server/src/pullRequest/PullRequestProviderRegistry.ts @@ -28,12 +28,8 @@ export function fromProviders( * @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { - const drivers = yield* Effect.forEach(BuiltInDrivers.BUILT_IN_SOURCE_CONTROL_DRIVERS, (driver) => - driver.make.pipe(Effect.map((instance) => instance.pullRequests)), - ); - return fromProviders( - drivers.filter((provider): provider is PullRequestProviderApi => provider !== null), - ); + const drivers = yield* BuiltInDrivers.SourceControlDrivers; + return fromProviders(drivers.flatMap(({ instance }) => instance.pullRequests ?? [])); }); export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe( diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index ff0aca4126bc..24ad96319c87 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -60,6 +60,7 @@ import * as AcpRegistrySupport from "@t3tools/provider-acp-registry/server/AcpRe import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as CheckpointStore from "./checkpointing/CheckpointStore.ts"; import * as SourceControlBuiltInDrivers from "./sourceControl/builtInDrivers.ts"; +import { BUILT_IN_SOURCE_CONTROL_DRIVER_LIST } from "./sourceControl/sourceControlClients.ts"; import * as TextGeneration from "./textGeneration/TextGeneration.ts"; import * as ProviderInstanceRegistryHydration from "./provider/ProviderInstanceRegistryHydration.ts"; import * as ProjectSettingsOverrideSweep from "./provider/ProjectSettingsOverrideSweep.ts"; @@ -332,9 +333,7 @@ const layerRepositoryIdentityResolver = Layer.effect( const registry = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; return yield* RepositoryIdentityResolver.make({ refine: Effect.fn(function* (identity: RepositoryIdentity) { - for (const kind of SourceControlBuiltInDrivers.BUILT_IN_SOURCE_CONTROL_DRIVERS.map( - (driver) => driver.kind, - )) { + for (const { kind } of BUILT_IN_SOURCE_CONTROL_DRIVER_LIST) { const provider = yield* registry.get(kind); if (provider.refineRepositoryIdentity === undefined) continue; const refined = yield* provider.refineRepositoryIdentity({ diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index 43f3a9ff8680..8337ac730d3f 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -23,6 +23,7 @@ import * as BitbucketPullRequestApi from "@t3tools/source-control-bitbucket/serv import * as GitHubRest from "@t3tools/source-control-github/server/GitHubRest"; import * as GitHubRequestPolicy from "@t3tools/source-control-github/server/GitHubRequestPolicy"; import * as GitHubPullRequestApi from "@t3tools/source-control-github/server/GitHubPullRequestApi"; +import * as GitHubCredentials from "@t3tools/source-control-github/server/GitHubCredentials"; import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli"; import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli"; import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; @@ -30,6 +31,7 @@ import * as GitCafeApi from "@t3tools/source-control-gitcafe/server/GitCafeApi"; import * as GitCafeCredentials from "@t3tools/source-control-gitcafe/server/GitCafeCredentials"; import * as ForgejoSourceControlProvider from "@t3tools/source-control-forgejo/server/ForgejoSourceControlProvider"; import * as SourceControlDiscovery from "./SourceControlDiscovery.ts"; +import * as BuiltInDriversTestkit from "./builtInDrivers.testkit.ts"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; import * as ServerSourceControlHost from "./ServerSourceControlHost.ts"; import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; @@ -39,6 +41,7 @@ const layerSourceControlProviderRegistryTest = (input: { readonly process: Partial; }) => SourceControlProviderRegistry.layer.pipe( + Layer.provide(BuiltInDriversTestkit.layerOverHostServices), Layer.provide( Layer.mergeAll( ServerConfig.layerTest(process.cwd(), { @@ -50,6 +53,7 @@ const layerSourceControlProviderRegistryTest = (input: { Layer.mock(BitbucketPullRequestApi.BitbucketPullRequestApi)({}), ServerSettings.ServerSettingsService.layerTest(), Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({}), + Layer.mock(GitHubCredentials.GitHubCredentials)({}), Layer.mock(GitHubRest.GitHubRest)({}), // The GitHub provider's GraphQL reads go through these; no test here makes one. Layer.mock(GitHubRequestPolicy.GitHubRequestPolicy)({}), diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index 80738fcf38a2..7d7c79990bda 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -39,6 +39,7 @@ import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/Git import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; import * as GitCafeApi from "@t3tools/source-control-gitcafe/server/GitCafeApi"; import * as GitCafeCredentials from "@t3tools/source-control-gitcafe/server/GitCafeCredentials"; +import * as BuiltInDriversTestkit from "./builtInDrivers.testkit.ts"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; import * as ServerSourceControlHost from "./ServerSourceControlHost.ts"; @@ -114,53 +115,59 @@ function makeRegistry(input: { return SourceControlProviderRegistry.make.pipe( Effect.provide( - Layer.mergeAll( - NodeServices.layer, - layerRegistry, - layerProcess, - ServerSourceControlHost.layer.pipe( - Layer.provide( - Layer.mergeAll( - layerProcess, - layerRegistry, - ServerSettings.ServerSettingsService.layerTest(), - Layer.mock(GitVcsDriver.GitVcsDriver)({}), + BuiltInDriversTestkit.layerOverHostServices.pipe( + Layer.provideMerge( + Layer.mergeAll( + NodeServices.layer, + layerRegistry, + layerProcess, + ServerSourceControlHost.layer.pipe( + Layer.provide( + Layer.mergeAll( + layerProcess, + layerRegistry, + ServerSettings.ServerSettingsService.layerTest(), + Layer.mock(GitVcsDriver.GitVcsDriver)({}), + ), + ), ), + Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), + Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({}), + Layer.mock(BitbucketApi.BitbucketApi)({}), + Layer.mock(BitbucketPullRequestApi.BitbucketPullRequestApi)({}), + input.settings + ? Layer.mock(ServerSettings.ServerSettingsService)(input.settings) + : ServerSettings.ServerSettingsService.layerTest(), + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({}), + Layer.mock(GitHubCredentials.GitHubCredentials)({}), + // The GitHub provider's GraphQL reads go through these; no test here makes one. + Layer.mock(GitHubRequestPolicy.GitHubRequestPolicy)({}), + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die(new Error("Unexpected GitHub GraphQL read"))), + ), + Layer.mock(GitHubRest.GitHubRest)({ + // No GitHub credential unless a test supplies one, so custom hosts stay unclaimed. + credential: (host) => + Effect.fail(new GitHubCredentials.GitHubNotSignedInError({ host })), + ...input.githubApi, + }), + Layer.mock(GitVcsDriver.GitVcsDriver)({}), + Layer.mock(GitLabCli.GitLabCli)(input.gitlab ?? {}), + Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({}), + Layer.mock(ForgejoCli.ForgejoCli)({ + listLogins: () => Effect.succeed([]), + ...input.forgejo, + }), + Layer.mock(GitCafeApi.GitCafeApi)({}), + Layer.mock(GitCafeCredentials.GitCafeCredentials)({ + cliEnv: () => Effect.succeed({}), + }), + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-source-control-registry-test-", + }).pipe(Layer.provide(NodeServices.layer)), ), ), - Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), - Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({}), - Layer.mock(BitbucketApi.BitbucketApi)({}), - Layer.mock(BitbucketPullRequestApi.BitbucketPullRequestApi)({}), - input.settings - ? Layer.mock(ServerSettings.ServerSettingsService)(input.settings) - : ServerSettings.ServerSettingsService.layerTest(), - Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({}), - // The GitHub provider's GraphQL reads go through these; no test here makes one. - Layer.mock(GitHubRequestPolicy.GitHubRequestPolicy)({}), - Layer.succeed( - HttpClient.HttpClient, - HttpClient.make(() => Effect.die(new Error("Unexpected GitHub GraphQL read"))), - ), - Layer.mock(GitHubRest.GitHubRest)({ - // No GitHub credential unless a test supplies one, so custom hosts stay unclaimed. - credential: (host) => Effect.fail(new GitHubCredentials.GitHubNotSignedInError({ host })), - ...input.githubApi, - }), - Layer.mock(GitVcsDriver.GitVcsDriver)({}), - Layer.mock(GitLabCli.GitLabCli)(input.gitlab ?? {}), - Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({}), - Layer.mock(ForgejoCli.ForgejoCli)({ - listLogins: () => Effect.succeed([]), - ...input.forgejo, - }), - Layer.mock(GitCafeApi.GitCafeApi)({}), - Layer.mock(GitCafeCredentials.GitCafeCredentials)({ - cliEnv: () => Effect.succeed({}), - }), - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-source-control-registry-test-", - }).pipe(Layer.provide(NodeServices.layer)), ), ), ); diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts index 5ea6c4037c9c..e08b7a7e6fb5 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -358,16 +358,14 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit ); export const make = Effect.gen(function* () { - const drivers = yield* Effect.forEach(BuiltInDrivers.BUILT_IN_SOURCE_CONTROL_DRIVERS, (driver) => - driver.make.pipe( - Effect.map((instance): SourceControlProviderRegistration => ({ - kind: driver.kind, - provider: instance.sourceControl, - discovery: instance.discovery, - })), - ), + const drivers = yield* BuiltInDrivers.SourceControlDrivers; + return yield* makeWithProviders( + drivers.map(({ driver, instance }): SourceControlProviderRegistration => ({ + kind: driver.kind, + provider: instance.sourceControl, + discovery: instance.discovery, + })), ); - return yield* makeWithProviders(drivers); }); export const layer = Layer.effect(SourceControlProviderRegistry, make); diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index 14615a39856d..4a9105e9f5d9 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -101,6 +101,51 @@ function layer(input: { : layerService.pipe(Layer.provideMerge(NodeServices.layer)); } +const lookupFailingWith = (cause: unknown, detail: string) => + Effect.gen(function* () { + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; + return yield* service + .lookupRepository({ provider: SourceControlProviderKind.make("bitbucket"), repository: "x" }) + .pipe(Effect.flip); + }).pipe( + Effect.provide( + layer({ + provider: makeProvider({ + getRepositoryCloneUrls: (input) => + Effect.fail( + new SourceControlProviderError({ + provider: SourceControlProviderKind.make("bitbucket"), + operation: "getRepositoryCloneUrls", + cwd: input.cwd, + detail, + cause, + }), + ), + }), + }), + ), + ); + +it.effect("tells the user a host's fixed explanation of a failed repository lookup", () => + Effect.gen(function* () { + const error = yield* lookupFailingWith( + { userFacingDetail: "Repositories are named workspace/repository." }, + "Repositories are named workspace/repository.", + ); + assert.strictEqual(error.detail, "Repositories are named workspace/repository."); + }), +); + +it.effect("keeps a host's raw response text out of a failed repository lookup", () => + Effect.gen(function* () { + const error = yield* lookupFailingWith( + new Error("upstream"), + "internal token=abc", + ); + assert.strictEqual(error.detail, "The source control operation could not be completed."); + }), +); + it.effect("clones a looked-up repository after creating missing parent directories", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index cba0df9d9056..ceb9ec33288d 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -30,14 +30,13 @@ import { type GitCloneProgressLine, } from "../project/gitCloneProgress.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; -import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi"; +import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { cloneUrlForProtocol, cloneUrlProtocol } from "@t3tools/shared/cloneUrl"; const isSourceControlRepositoryError = Schema.is(SourceControlRepositoryError); const isSourceControlProviderError = Schema.is(SourceControlProviderError); const isGitCommandError = Schema.is(GitCommandError); -const isBitbucketRepositoryLocatorError = Schema.is(BitbucketApi.BitbucketRepositoryLocatorError); export class SourceControlRepositoryService extends Context.Service< SourceControlRepositoryService, @@ -95,11 +94,9 @@ function mapRepositoryError(operation: string, provider: SourceControlProviderKi operation, provider, detail: - isSourceControlProviderError(cause) && - cause.provider === "bitbucket" && - isBitbucketRepositoryLocatorError(cause.cause) - ? BitbucketApi.BitbucketRepositoryLocatorError.detail - : "The source control operation could not be completed.", + (isSourceControlProviderError(cause) + ? SourceControlProvider.userFacingSourceControlDetail(cause.cause) + : undefined) ?? "The source control operation could not be completed.", cause, }), ); diff --git a/apps/server/src/sourceControl/builtInDrivers.testkit.ts b/apps/server/src/sourceControl/builtInDrivers.testkit.ts new file mode 100644 index 000000000000..e3d314336b0e --- /dev/null +++ b/apps/server/src/sourceControl/builtInDrivers.testkit.ts @@ -0,0 +1,40 @@ +import * as AzureDevOpsDriver from "@t3tools/source-control-azure-devops/server/driver"; +import * as BitbucketDriver from "@t3tools/source-control-bitbucket/server/driver"; +import * as ForgejoDriver from "@t3tools/source-control-forgejo/server/driver"; +import * as GitCafeDriver from "@t3tools/source-control-gitcafe/server/driver"; +import * as GitHubDriver from "@t3tools/source-control-github/server/driver"; +import * as GitLabDriver from "@t3tools/source-control-gitlab/server/driver"; +import type { + SourceControlDriver, + SourceControlDriverInstance, +} from "@t3tools/source-control-core/server/driver"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as BuiltInDrivers from "./builtInDrivers.ts"; + +const entry = ( + driver: SourceControlDriver, + instance: SourceControlDriverInstance, +): BuiltInDrivers.SourceControlDriverEntry => ({ driver, instance }); + +/** Every built-in driver over whatever stands in for its host's own services. */ +export const layerOverHostServices = Layer.effect( + BuiltInDrivers.SourceControlDrivers, + Effect.all([ + GitHubDriver.makeInstance.pipe(Effect.map((instance) => entry(GitHubDriver.driver, instance))), + GitLabDriver.makeInstance.pipe(Effect.map((instance) => entry(GitLabDriver.driver, instance))), + GitCafeDriver.makeInstance.pipe( + Effect.map((instance) => entry(GitCafeDriver.driver, instance)), + ), + ForgejoDriver.makeInstance.pipe( + Effect.map((instance) => entry(ForgejoDriver.driver, instance)), + ), + BitbucketDriver.makeInstance.pipe( + Effect.map((instance) => entry(BitbucketDriver.driver, instance)), + ), + AzureDevOpsDriver.makeInstance.pipe( + Effect.map((instance) => entry(AzureDevOpsDriver.driver, instance)), + ), + ]), +); diff --git a/apps/server/src/sourceControl/builtInDrivers.ts b/apps/server/src/sourceControl/builtInDrivers.ts index 7941c7f3eadc..26bc4d4da213 100644 --- a/apps/server/src/sourceControl/builtInDrivers.ts +++ b/apps/server/src/sourceControl/builtInDrivers.ts @@ -1,37 +1,31 @@ -import * as AzureDevOpsCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsCli"; -import * as AzureDevOpsPullRequestCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestCli"; -import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi"; -import * as BitbucketPullRequestApi from "@t3tools/source-control-bitbucket/server/BitbucketPullRequestApi"; -import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; -import * as GitCafeApi from "@t3tools/source-control-gitcafe/server/GitCafeApi"; -import * as GitCafeCredentials from "@t3tools/source-control-gitcafe/server/GitCafeCredentials"; -import * as GitHubRest from "@t3tools/source-control-github/server/GitHubRest"; -import * as GitHubPullRequestApi from "@t3tools/source-control-github/server/GitHubPullRequestApi"; -import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli"; -import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli"; -import type { SourceControlDriver } from "@t3tools/source-control-core/server/driver"; +import type { + SourceControlDriver, + SourceControlDriverInstance, +} from "@t3tools/source-control-core/server/driver"; import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as ServerSourceControlHost from "./ServerSourceControlHost.ts"; -import { BUILT_IN_SOURCE_CONTROL_DRIVER_LIST as drivers } from "./sourceControlClients.ts"; +import { BUILT_IN_SOURCE_CONTROL_DRIVER_LIST } from "./sourceControlClients.ts"; -export type BuiltInSourceControlDriversEnv = - (typeof drivers)[number] extends SourceControlDriver ? R : never; +export interface SourceControlDriverEntry { + readonly driver: Pick; + readonly instance: SourceControlDriverInstance; +} -export const BUILT_IN_SOURCE_CONTROL_DRIVERS: ReadonlyArray< - SourceControlDriver -> = drivers; +/** Every driver's instance, in registry order. Everything that reads a host reads it here. */ +export class SourceControlDrivers extends Context.Service< + SourceControlDrivers, + ReadonlyArray +>()("t3/sourceControl/builtInDrivers/SourceControlDrivers") {} -export const layer = Layer.mergeAll( - // `GitHubRest.layerWithDependencies` carries the quota reserve and rate-limit pause every GitHub - // reader shares, so the server builds it once, here. - GitHubPullRequestApi.layer.pipe(Layer.provideMerge(GitHubRest.layerWithDependencies)), - AzureDevOpsPullRequestCli.layer.pipe(Layer.provideMerge(AzureDevOpsCli.layer)), - BitbucketPullRequestApi.layer.pipe( - Layer.provideMerge(BitbucketApi.layer.pipe(Layer.provide(SourceControlRateLimit.layer))), - ), - ForgejoCli.layer, - GitCafeApi.layer.pipe(Layer.provideMerge(GitCafeCredentials.layer)), - GitLabPullRequestCli.layer.pipe(Layer.provideMerge(GitLabCli.layer)), -).pipe(Layer.provideMerge(ServerSourceControlHost.layer)); +const make = Effect.forEach(BUILT_IN_SOURCE_CONTROL_DRIVER_LIST, (driver) => + driver.make.pipe(Effect.map((instance) => ({ driver, instance }))), +); + +export const layer = Layer.effect(SourceControlDrivers, make).pipe( + Layer.provideMerge(ServerSourceControlHost.layer), + Layer.provideMerge(SourceControlRateLimit.layer), +); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 9a72a501f43f..cc70767a8d38 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -85,6 +85,7 @@ import { FilesystemBrowseError, AssetWorkspaceContextNotFoundError, AssetWorkspaceContextResolutionError, + SOURCE_CONTROL_MEDIA_ASSET, ChatAttachmentId, PersistChatAttachmentsError, RpcClientId, @@ -2772,7 +2773,7 @@ export const layerWsRpc = ( input.resource._tag === "attachment" || input.resource._tag === "native-app-icon" || input.resource._tag === "tool-output-image" || - input.resource._tag === "github-media" || + input.resource._tag === SOURCE_CONTROL_MEDIA_ASSET || // A network path is served only from the thread's project, looked up below. (input.resource._tag === "media-file" && path.isAbsolute(input.resource.path) && diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index e46e3092d6b0..2a3c71583427 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -8,7 +8,11 @@ import { } from "@t3tools/shared/markdownLinks"; import { isHostPath, resolveWorkspaceFilePath } from "@t3tools/shared/path"; import { usePullRequestLinking } from "~/hooks/usePullRequestLinking"; -import { AuthFilesystemReadScope, AuthOrchestrationOperateScope } from "@t3tools/contracts"; +import { + AuthFilesystemReadScope, + AuthOrchestrationOperateScope, + SOURCE_CONTROL_MEDIA_ASSET, +} from "@t3tools/contracts"; import { CHAT_MARKDOWN_REMARK_PLUGINS, CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS, @@ -45,7 +49,6 @@ import { type ServerProviderSkill, type ThreadPullRequestKey, } from "@t3tools/contracts"; -import { githubMediaFetchUrl } from "@t3tools/shared/githubMedia"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -234,9 +237,9 @@ interface ChatMarkdownProps { onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; extraRemarkPlugins?: NonNullable; renderContextReference?: ((reference: ChatMarkdownContextReference) => ReactNode) | undefined; - /** Loads GitHub-hosted media through `cwd`'s GitHub credential, which a private repository's - uploads need; without it those images and videos load unauthenticated and 404. */ - githubMedia?: boolean | undefined; + /** Loads media a source control host keeps behind a credential through the server, which holds + `cwd`'s; without it a private repository's uploads load unauthenticated and 404. */ + sourceControlMedia?: boolean | undefined; headingLevelOffset?: number | undefined; } @@ -448,7 +451,7 @@ function useChatMarkdownState({ onImageExpand, renderContextReference, headingLevelOffset = 0, - githubMedia = false, + sourceControlMedia = false, }: ChatMarkdownProps) { const resolvedTheme = useRenderedAppearance(); const markdownRef = useRef(null); @@ -885,7 +888,7 @@ function useChatMarkdownState({ environmentId, expandMedia, fileLinkChip, - githubMedia, + sourceControlMedia, renderContextReference, headingLevelOffset, imageBaseDir, @@ -1352,7 +1355,7 @@ const CHAT_MARKDOWN_COMPONENTS = { expandMedia, cwd, environmentId, - githubMedia, + sourceControlMedia, imageBaseDir, threadRef, workspaceMutationId, @@ -1384,19 +1387,22 @@ const CHAT_MARKDOWN_COMPONENTS = { const imageSource = classifyMarkdownImageSource(classifiedSrc, imageBaseDir ?? cwd); const kind = mediaKindFromPath(classifiedSrc) ?? "image"; const directUri = imageSource._tag === "Direct" ? imageSource.uri : null; - const githubMediaUrl = - directUri === null ? null : githubMediaFetchUrl(resolveProtocolRelativeMediaUrl(directUri)); + const hostMediaUrl = + directUri === null + ? null + : (sourceControlClients.mediaUrl(resolveProtocolRelativeMediaUrl(directUri))?.fetchUrl ?? + null); if ( - githubMedia && + sourceControlMedia && cwd !== undefined && environmentId !== null && directUri !== null && - githubMediaUrl !== null + hostMediaUrl !== null ) { return ( ); diff --git a/apps/web/src/components/chat/ChatMarkdownMedia.tsx b/apps/web/src/components/chat/ChatMarkdownMedia.tsx index f0601234628e..d74746378d93 100644 --- a/apps/web/src/components/chat/ChatMarkdownMedia.tsx +++ b/apps/web/src/components/chat/ChatMarkdownMedia.tsx @@ -1,6 +1,6 @@ import { MISSING_FILE_ACCESS_MESSAGE } from "@t3tools/client-runtime/state/assets"; import { mediaFileReference, mediaUrlReference } from "@t3tools/client-runtime/media-reference"; -import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; +import type { AssetResource, EnvironmentId, SOURCE_CONTROL_MEDIA_ASSET } from "@t3tools/contracts"; import { TriangleAlertIcon } from "lucide-react"; import { createContext, @@ -312,7 +312,7 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props | "attachment" | "workspace-file" | "media-file" - | "github-media" + | typeof SOURCE_CONTROL_MEDIA_ASSET | "tool-output-image"; } >; diff --git a/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx b/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx index 4d25c8011d13..91dec8acdaca 100644 --- a/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx +++ b/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx @@ -1,7 +1,12 @@ import { ExternalLinkIcon, PaperclipIcon } from "lucide-react"; import { markdownImageSourceFragment } from "@t3tools/client-runtime/markdown-images"; -import { githubMediaFetchUrl } from "@t3tools/shared/githubMedia"; -import type { AssetResource, EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; +import { + SOURCE_CONTROL_MEDIA_ASSET, + type AssetResource, + type EnvironmentId, + type ScopedThreadRef, +} from "@t3tools/contracts"; import { createContext, useContext } from "react"; import type { Options as ReactMarkdownOptions } from "react-markdown"; @@ -18,7 +23,7 @@ export const PullRequestMarkdownContext = createContext<{ threadRef: ScopedThreadRef | null; } | null>(null); -function PullRequestGitHubVideo({ +function PullRequestHostVideo({ environmentId, cwd, url, @@ -29,7 +34,7 @@ function PullRequestGitHubVideo({ url: string; fetchUrl: string; }) { - const resource: AssetResource = { _tag: "github-media", cwd, url: fetchUrl }; + const resource: AssetResource = { _tag: SOURCE_CONTROL_MEDIA_ASSET, cwd, url: fetchUrl }; const assetUrl = useAssetUrlState(environmentId, resource); const refreshAssetUrl = useAssetUrlRefresh(environmentId, resource); const src = @@ -85,19 +90,19 @@ export function PullRequestMarkdown({ pullRequestPanelRef={resolvedThreadRef ?? PULL_REQUESTS_PANEL_REF} environmentId={environmentId} extraRemarkPlugins={extraRemarkPlugins} - githubMedia + sourceControlMedia /> ); } - const githubMediaUrl = segment.media === "video" ? githubMediaFetchUrl(segment.url) : null; - if (githubMediaUrl !== null) { + const hostMedia = sourceControlClients.mediaUrl(segment.url); + if (segment.media === "video" && hostMedia !== null) { return ( - ); } @@ -125,7 +130,9 @@ export function PullRequestMarkdown({ className="flex items-center gap-2 rounded-lg border border-border/60 bg-muted/30 px-3 py-2 text-sm hover:bg-muted/60" > - Open attachment on GitHub + + Open attachment on {hostMedia?.definition.label ?? new URL(segment.url).hostname} + ); diff --git a/knip.jsonc b/knip.jsonc index 13885f968c60..aba0742d2424 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -44,7 +44,6 @@ "scripts/cli.ts", "scripts/evaluate-thread-titles.ts", "scripts/fetch-expo-schema.ts", - "scripts/measure-pr-preview.ts", "scripts/probe-claude-fork-local-rollback-replay.ts", "scripts/update-test-shard-weights.ts", "scripts/verify-background-live.ts", @@ -135,7 +134,7 @@ }, "packages/source-control-github": { // graphqlgen reads its config; the generated operations export every selection's class. - "entry": ["graphql.config.ts"], + "entry": ["graphql.config.ts", "scripts/measure-pr-preview.ts"], "ignoreIssues": { "**/*.graphql.ts": ["exports", "types", "namespaceMembers"] }, }, }, diff --git a/packages/client-runtime/src/pullRequestMarkdown.ts b/packages/client-runtime/src/pullRequestMarkdown.ts index 67ac68e92619..8e342c425dba 100644 --- a/packages/client-runtime/src/pullRequestMarkdown.ts +++ b/packages/client-runtime/src/pullRequestMarkdown.ts @@ -1,3 +1,5 @@ +import { sourceControlClients } from "./sourceControlClients.ts"; + /** `id` is positional on purpose: the same attachment can be embedded twice in one body. */ export type PullRequestBodySegment = | { readonly id: string; readonly kind: "markdown"; readonly text: string } @@ -21,7 +23,6 @@ const IMAGE_EXTENSION_PATTERN = /\.(?:png|jpe?g|gif|webp|avif|heic|heif|svg)(?:$ const MARKDOWN_LINK_PATTERN = /^\[[^\]]*\]\((https?:\/\/[^\s)]+)\)$/u; const VIDEO_EXTENSION_PATTERN = /\.(?:mp4|webm|mov|m4v|ogv)(?:$|[?#])/iu; -const GITHUB_ASSET_PATTERN = /^https:\/\/github\.com\/user-attachments\/assets\/[\w-]+$/iu; const VIDEO_TAG_SRC_PATTERN = /<(?:video|source)\b[^>]*\bsrc\s*=\s*["']([^"']+)["']/iu; const STANDALONE_VIDEO_TAG_PATTERN = /^\s*()( +// The tag predates the rename and stays: clients on either side of an update decode it. +export class AssetSourceControlMediaUrlValidationError extends Schema.TaggedError()( "AssetGitHubMediaUrlValidationError", {}, ) { override get message(): string { - return "Only media hosted by GitHub can be fetched with a GitHub credential."; + return "Only media a source control host keeps behind a credential can be fetched with one."; } } @@ -314,7 +321,7 @@ export const AssetAccessError = Schema.Union([ AssetProjectFaviconResolutionError, AssetProjectFaviconInspectionError, AssetProjectFaviconNotFoundError, - AssetGitHubMediaUrlValidationError, + AssetSourceControlMediaUrlValidationError, AssetSigningKeyLoadError, ]); export type AssetAccessError = typeof AssetAccessError.Type; diff --git a/packages/shared/package.json b/packages/shared/package.json index 3fe44db4f345..0fcf52cc8805 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -55,10 +55,6 @@ "types": "./src/git.ts", "import": "./src/git.ts" }, - "./githubMedia": { - "types": "./src/githubMedia.ts", - "import": "./src/githubMedia.ts" - }, "./sourceControl": { "types": "./src/sourceControl.ts", "import": "./src/sourceControl.ts" diff --git a/packages/source-control-azure-devops/src/server/driver.ts b/packages/source-control-azure-devops/src/server/driver.ts index 1d218c303854..d1758ad8ba8d 100644 --- a/packages/source-control-azure-devops/src/server/driver.ts +++ b/packages/source-control-azure-devops/src/server/driver.ts @@ -1,17 +1,28 @@ -import { defineSourceControlDriver } from "@t3tools/source-control-core/server/driver"; +import { + defineSourceControlDriver, + withOwnServices, +} from "@t3tools/source-control-core/server/driver"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import { definition } from "../client/definition.ts"; +import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; +import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts"; import * as AzureDevOpsPullRequestProvider from "./AzureDevOpsPullRequestProvider.ts"; import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlProvider.ts"; +const services = AzureDevOpsPullRequestCli.layer.pipe(Layer.provideMerge(AzureDevOpsCli.layer)); + +/** Over this host's own services, which `driver` builds; tests stand in for them. */ +export const makeInstance = Effect.all({ + sourceControl: AzureDevOpsSourceControlProvider.make, + discovery: Effect.succeed(AzureDevOpsSourceControlProvider.discovery), + pullRequests: AzureDevOpsPullRequestProvider.make, +}); + export const driver = defineSourceControlDriver({ kind: definition.kind, definition, - make: Effect.all({ - sourceControl: AzureDevOpsSourceControlProvider.make, - discovery: Effect.succeed(AzureDevOpsSourceControlProvider.discovery), - pullRequests: AzureDevOpsPullRequestProvider.make, - }), + make: withOwnServices(makeInstance, services), }); diff --git a/packages/source-control-bitbucket/src/server/BitbucketApi.ts b/packages/source-control-bitbucket/src/server/BitbucketApi.ts index 07c7064b4498..21aa02f34bbb 100644 --- a/packages/source-control-bitbucket/src/server/BitbucketApi.ts +++ b/packages/source-control-bitbucket/src/server/BitbucketApi.ts @@ -77,6 +77,10 @@ export class BitbucketRepositoryLocatorError extends Schema.TaggedError + isRepositoryLocatorError(error) ? error.detail : fallback; + function toChangeRequest(summary: NormalizedBitbucketPullRequestRecord): ChangeRequest { return { provider: SourceControlProviderKind.make("bitbucket"), @@ -122,7 +129,7 @@ export const make = Effect.gen(function* () { repository: SourceControlProvider.transportSafeSourceControlErrorValue( input.repository, ), - detail: "Failed to get repository clone URLs.", + detail: repositoryDetail(error, "Failed to get repository clone URLs."), cause: error, }), ), @@ -138,7 +145,7 @@ export const make = Effect.gen(function* () { repository: SourceControlProvider.transportSafeSourceControlErrorValue( input.repository, ), - detail: "Failed to create repository.", + detail: repositoryDetail(error, "Failed to create repository."), cause: error, }), ), diff --git a/packages/source-control-bitbucket/src/server/driver.ts b/packages/source-control-bitbucket/src/server/driver.ts index 19d35d82015c..5cec134e8e7f 100644 --- a/packages/source-control-bitbucket/src/server/driver.ts +++ b/packages/source-control-bitbucket/src/server/driver.ts @@ -1,17 +1,32 @@ -import { defineSourceControlDriver } from "@t3tools/source-control-core/server/driver"; +import { + defineSourceControlDriver, + withOwnServices, +} from "@t3tools/source-control-core/server/driver"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import { definition } from "../client/definition.ts"; +import * as BitbucketApi from "./BitbucketApi.ts"; +import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts"; import * as BitbucketPullRequestProvider from "./BitbucketPullRequestProvider.ts"; import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts"; +// Every setting the API reads from the environment is optional, so reading them cannot fail. +const services = BitbucketPullRequestApi.layer.pipe( + Layer.provideMerge(BitbucketApi.layer), + Layer.orDie, +); + +/** Over this host's own services, which `driver` builds; tests stand in for them. */ +export const makeInstance = Effect.all({ + sourceControl: BitbucketSourceControlProvider.make, + discovery: BitbucketSourceControlProvider.makeDiscovery, + pullRequests: BitbucketPullRequestProvider.make, +}); + export const driver = defineSourceControlDriver({ kind: definition.kind, definition, - make: Effect.all({ - sourceControl: BitbucketSourceControlProvider.make, - discovery: BitbucketSourceControlProvider.makeDiscovery, - pullRequests: BitbucketPullRequestProvider.make, - }), + make: withOwnServices(makeInstance, services), }); diff --git a/packages/source-control-core/src/client/definition.ts b/packages/source-control-core/src/client/definition.ts index 9a13dd20a4ee..35cc5ddaeba3 100644 --- a/packages/source-control-core/src/client/definition.ts +++ b/packages/source-control-core/src/client/definition.ts @@ -74,6 +74,16 @@ export interface ChangeRequestCheckoutInput { readonly repositoryUrl?: string | null | undefined; } +/** Media a host serves only to a request carrying a credential, as `mediaUrl` reads it. */ +export interface SourceControlMediaUrl { + /** The canonical URL to fetch, the same for every spelling of one file. */ + readonly fetchUrl: string; + /** A file name for the media, safe to put in a path or a download. */ + readonly fileName: string; + /** Set when the URL alone says what the media is, as an extensionless upload can. */ + readonly kind?: "image" | "video"; +} + export interface SourceControlClientDefinition { readonly kind: SourceControlProviderKind; readonly label: string; @@ -141,6 +151,11 @@ export interface SourceControlClientDefinition { * the provider rather than from the remote. */ readonly sshRemoteHidesWebHost?: boolean; + /** + * Null when `url` is not media this host keeps behind a credential. A client cannot load such + * media itself, so it asks the server, whose driver for this host fetches it. + */ + readonly mediaUrl?: (url: URL) => SourceControlMediaUrl | null; readonly settings?: SourceControlHostSettingsSchema; } @@ -342,6 +357,10 @@ export interface ParsedChangeRequest { readonly link: ParsedChangeRequestUrl; } +export interface SourceControlMedia extends SourceControlMediaUrl { + readonly definition: SourceControlClientDefinition; +} + export interface SourceControlClientRegistry { readonly definitions: ReadonlyArray; readonly get: (kind: string | null | undefined) => SourceControlClientDefinition; @@ -370,6 +389,8 @@ export interface SourceControlClientRegistry { identity: ChangeRequestHostIdentity, kind: Kind, ) => string | Kind; + /** The first host, in registry order, that keeps `url` behind a credential. */ + readonly mediaUrl: (url: string) => SourceControlMedia | null; } function httpUrl(value: string): URL | null { @@ -494,6 +515,15 @@ export function makeSourceControlClientRegistry( changeRequestHost: (identity, kind) => (kind === undefined ? undefined : byKind.get(kind)?.changeRequestHost?.(identity)) ?? projectChangeRequestHost(identity, kind), + mediaUrl: (value) => { + const url = httpUrl(value); + if (url === null) return null; + for (const definition of definitions) { + const media = definition.mediaUrl?.(url); + if (media != null) return { ...media, definition }; + } + return null; + }, }; } diff --git a/packages/source-control-core/src/server/SourceControlProvider.ts b/packages/source-control-core/src/server/SourceControlProvider.ts index e149fcd202a2..23e26dcf0d7f 100644 --- a/packages/source-control-core/src/server/SourceControlProvider.ts +++ b/packages/source-control-core/src/server/SourceControlProvider.ts @@ -38,6 +38,19 @@ export interface SourceControlRefSelector { const MAX_ERROR_TRANSPORT_VALUE_LENGTH = 256; +/** + * The fixed product text a provider failure carries for the user, if it has one. Any other + * detail may be upstream response text, so callers show a generic message instead. + */ +export function userFacingSourceControlDetail(cause: unknown): string | undefined { + return typeof cause === "object" && + cause !== null && + "userFacingDetail" in cause && + typeof cause.userFacingDetail === "string" + ? cause.userFacingDetail + : undefined; +} + export function transportSafeSourceControlErrorValue(value: string): string { let printable = ""; for (const character of value) { diff --git a/packages/source-control-core/src/server/driver.ts b/packages/source-control-core/src/server/driver.ts index 360868770abf..9007dad84f0b 100644 --- a/packages/source-control-core/src/server/driver.ts +++ b/packages/source-control-core/src/server/driver.ts @@ -1,25 +1,76 @@ import type { SourceControlProviderKind } from "@t3tools/contracts"; -import type * as Effect from "effect/Effect"; +import type * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import type * as FileSystem from "effect/FileSystem"; +import type { HttpClient, HttpClientError, HttpClientResponse } from "effect/http"; +import * as Layer from "effect/Layer"; +import type * as Scope from "effect/Scope"; import type { SourceControlClientDefinition } from "../client/definition.ts"; import type { SourceControlProviderDiscoverySpec } from "./discovery.ts"; import type { PullRequestProviderApi } from "./PullRequestProvider.ts"; +import type * as SourceControlHost from "./SourceControlHost.ts"; import type * as SourceControlProvider from "./SourceControlProvider.ts"; +import type * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; + +export interface SourceControlMediaRequest { + /** A `fetchUrl` this host's definition returned from `mediaUrl`. */ + readonly url: string; + /** Request headers to forward, such as `range`. */ + readonly headers: Readonly>; +} export interface SourceControlDriverInstance { readonly sourceControl: SourceControlProvider.SourceControlProvider["Service"]; readonly discovery: SourceControlProviderDiscoverySpec; /** Null for a host whose pull requests this build cannot read. */ readonly pullRequests: PullRequestProviderApi | null; + /** + * Fetches media the definition's `mediaUrl` claimed, with the credential the host serves it + * to. Null when the fetch went nowhere a response could come from, such as a redirect loop. + */ + readonly fetchMedia?: ( + request: SourceControlMediaRequest, + ) => Effect.Effect< + HttpClientResponse.HttpClientResponse | null, + HttpClientError.HttpClientError, + Scope.Scope + >; } -export interface SourceControlDriver { +/** What the server gives every driver. A driver builds anything else it needs itself. */ +export type SourceControlDriverServices = + | SourceControlHost.SourceControlHost + | SourceControlRateLimit.SourceControlRateLimit + | HttpClient.HttpClient + | FileSystem.FileSystem + | Crypto.Crypto; + +export interface SourceControlDriver { readonly kind: SourceControlProviderKind; readonly definition: SourceControlClientDefinition; - readonly make: Effect.Effect; + /** + * Run once per server, in a scope that lasts as long as the server. State a host shares across + * everything that talks to it, such as GitHub's quota reserve and rate-limit pause, lives in + * what this builds, so building it twice would split that state. + */ + readonly make: Effect.Effect< + SourceControlDriverInstance, + never, + SourceControlDriverServices | Scope.Scope + >; } -export const defineSourceControlDriver = ( - driver: SourceControlDriver, -): SourceControlDriver => driver; +export const defineSourceControlDriver = (driver: SourceControlDriver): SourceControlDriver => + driver; + +/** + * `make` over a host's own `services`, built into the caller's scope so they live as long as the + * instance that holds them. + */ +export const withOwnServices = ( + make: Effect.Effect, + services: Layer.Layer, +): Effect.Effect | RIn | Scope.Scope> => + Layer.build(services).pipe(Effect.flatMap((context) => make.pipe(Effect.provide(context)))); diff --git a/packages/source-control-forgejo/src/server/driver.ts b/packages/source-control-forgejo/src/server/driver.ts index 7324741656c1..b96b50a4f8a1 100644 --- a/packages/source-control-forgejo/src/server/driver.ts +++ b/packages/source-control-forgejo/src/server/driver.ts @@ -1,17 +1,26 @@ -import { defineSourceControlDriver } from "@t3tools/source-control-core/server/driver"; +import { + defineSourceControlDriver, + withOwnServices, +} from "@t3tools/source-control-core/server/driver"; import * as Effect from "effect/Effect"; import { definition } from "../client/definition.ts"; +import * as ForgejoCli from "./ForgejoCli.ts"; import * as ForgejoPullRequestProvider from "./ForgejoPullRequestProvider.ts"; import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts"; +const services = ForgejoCli.layer; + +/** Over this host's own services, which `driver` builds; tests stand in for them. */ +export const makeInstance = Effect.all({ + sourceControl: ForgejoSourceControlProvider.make, + discovery: ForgejoSourceControlProvider.makeDiscovery, + pullRequests: ForgejoPullRequestProvider.make, +}); + export const driver = defineSourceControlDriver({ kind: definition.kind, definition, - make: Effect.all({ - sourceControl: ForgejoSourceControlProvider.make, - discovery: ForgejoSourceControlProvider.makeDiscovery, - pullRequests: ForgejoPullRequestProvider.make, - }), + make: withOwnServices(makeInstance, services), }); diff --git a/packages/source-control-gitcafe/src/server/driver.ts b/packages/source-control-gitcafe/src/server/driver.ts index dca35f45b75a..3fdc129ac405 100644 --- a/packages/source-control-gitcafe/src/server/driver.ts +++ b/packages/source-control-gitcafe/src/server/driver.ts @@ -1,17 +1,28 @@ -import { defineSourceControlDriver } from "@t3tools/source-control-core/server/driver"; +import { + defineSourceControlDriver, + withOwnServices, +} from "@t3tools/source-control-core/server/driver"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import { definition } from "../client/definition.ts"; +import * as GitCafeApi from "./GitCafeApi.ts"; +import * as GitCafeCredentials from "./GitCafeCredentials.ts"; import * as GitCafePullRequestProvider from "./GitCafePullRequestProvider.ts"; import * as GitCafeSourceControlProvider from "./GitCafeSourceControlProvider.ts"; +const services = GitCafeApi.layer.pipe(Layer.provideMerge(GitCafeCredentials.layer)); + +/** Over this host's own services, which `driver` builds; tests stand in for them. */ +export const makeInstance = Effect.all({ + sourceControl: GitCafeSourceControlProvider.make, + discovery: GitCafeSourceControlProvider.makeDiscovery, + pullRequests: GitCafePullRequestProvider.make, +}); + export const driver = defineSourceControlDriver({ kind: definition.kind, definition, - make: Effect.all({ - sourceControl: GitCafeSourceControlProvider.make, - discovery: GitCafeSourceControlProvider.makeDiscovery, - pullRequests: GitCafePullRequestProvider.make, - }), + make: withOwnServices(makeInstance, services), }); diff --git a/apps/server/scripts/measure-pr-preview.ts b/packages/source-control-github/scripts/measure-pr-preview.ts similarity index 69% rename from apps/server/scripts/measure-pr-preview.ts rename to packages/source-control-github/scripts/measure-pr-preview.ts index 5a37b6858329..448984f5ce8b 100644 --- a/apps/server/scripts/measure-pr-preview.ts +++ b/packages/source-control-github/scripts/measure-pr-preview.ts @@ -1,30 +1,29 @@ -// Run with: node apps/server/scripts/measure-pr-preview.ts owner/repo 123 456 +// Run with: node packages/source-control-github/scripts/measure-pr-preview.ts owner/repo 123 456 // Numbers form a session with shared repository-permission caches. Browser and // service caches are excluded. Uses real GitHub reads, without a server or database. // Requests are counted at the HTTP client. A GraphQL request's cost is how far it moved GitHub's // `x-ratelimit-used` for the `graphql` resource, which is approximate when anything else spends // the same token's quota while this runs. import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; +import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; import * as Effect from "effect/Effect"; import * as Console from "effect/Console"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; import { FetchHttpClient, HttpClient } from "effect/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; -import * as GitHubPullRequestApi from "@t3tools/source-control-github/server/GitHubPullRequestApi"; -import * as GitHubPullRequestProvider from "@t3tools/source-control-github/server/GitHubPullRequestProvider"; -import * as GitHubRest from "@t3tools/source-control-github/server/GitHubRest"; -import * as ServerConfig from "../src/config.ts"; -import * as ServerSettings from "../src/serverSettings.ts"; -import * as ServerSourceControlHost from "../src/sourceControl/ServerSourceControlHost.ts"; -import * as GitVcsDriver from "../src/vcs/GitVcsDriver.ts"; -import * as VcsDriverRegistry from "../src/vcs/VcsDriverRegistry.ts"; -import * as VcsProcess from "../src/vcs/VcsProcess.ts"; -import * as VcsProjectConfig from "../src/vcs/VcsProjectConfig.ts"; +import * as GitHubPullRequestApi from "../src/server/GitHubPullRequestApi.ts"; +import * as GitHubPullRequestProvider from "../src/server/GitHubPullRequestProvider.ts"; +import * as GitHubRest from "../src/server/GitHubRest.ts"; const [repository, ...numbers] = process.argv.slice(2); if (!repository || numbers.length === 0 || numbers.some((number) => !/^\d+$/.test(number))) { - throw new Error("Usage: node apps/server/scripts/measure-pr-preview.ts owner/repo number..."); + throw new Error( + "Usage: node packages/source-control-github/scripts/measure-pr-preview.ts owner/repo number...", + ); } type Read = { graphqlRequests: number; restRequests: number; cost: number }; @@ -73,18 +72,42 @@ const measuredHttp = Layer.effect( }), ).pipe(Layer.provide(FetchHttpClient.layer)); -// The server's host port over default settings: gh's own account choice, no saved token. -const sourceControlHost = ServerSourceControlHost.layer.pipe( - Layer.provide(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProjectConfig.layer))), - Layer.provide(GitVcsDriver.layer), - Layer.provide(ServerSettings.layerTest()), - Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-measure-pr-preview-" })), - Layer.provideMerge(VcsProcess.layer), - Layer.provideMerge(NodeServices.layer), -); +// Default settings, so `gh` picks the account and no saved token applies. Only the credential +// lookup runs a process, and it needs no more than its output. +const sourceControlHost = Layer.unwrap( + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + return TestSourceControlHost.layer({ + process: { + run: (input) => + Effect.scoped( + Effect.gen(function* () { + const handle = yield* spawner.spawn( + ChildProcess.make(input.command, [...input.args], { + cwd: input.spawnCwd ?? input.cwd, + env: input.env, + extendEnv: true, + }), + ); + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + Stream.mkString(Stream.decodeText(handle.stdout)), + Stream.mkString(Stream.decodeText(handle.stderr)), + handle.exitCode, + ], + { concurrency: "unbounded" }, + ); + return { exitCode, stdout, stderr, stdoutTruncated: false, stderrTruncated: false }; + }), + ).pipe(Effect.orDie), + }, + }); + }), +).pipe(Layer.provideMerge(NodeServices.layer)); const services = GitHubPullRequestApi.layer.pipe( Layer.provideMerge(GitHubRest.layerWithDependencies), + Layer.provideMerge(SourceControlRateLimit.layer), Layer.provideMerge(measuredHttp), Layer.provideMerge(sourceControlHost), ); diff --git a/packages/source-control-github/src/client/definition.test.ts b/packages/source-control-github/src/client/definition.test.ts index c929be7167aa..30fcf7ccc177 100644 --- a/packages/source-control-github/src/client/definition.test.ts +++ b/packages/source-control-github/src/client/definition.test.ts @@ -121,3 +121,41 @@ describe("GitHub remotes", () => { expect(recognize("https://notgithub.example.com/owner/repo.git")).toBeNull(); }); }); + +describe("GitHub media URLs", () => { + const media = (url: string) => definition.mediaUrl(new URL(url)); + + it("fetches every spelling of an upload or committed file from one canonical URL", () => { + expect( + media("https://github.com/user-attachments/assets/1a1842fb-6383-492f-873c-57aa0033fa6c"), + ).toEqual({ + fetchUrl: "https://github.com/user-attachments/assets/1a1842fb-6383-492f-873c-57aa0033fa6c", + fileName: "1a1842fb-6383-492f-873c-57aa0033fa6c", + kind: "video", + }); + expect(media("https://github.com/owner/repo/blob/main/docs/shot.png")).toEqual({ + fetchUrl: "https://raw.githubusercontent.com/owner/repo/main/docs/shot.png", + fileName: "shot.png", + }); + expect(media("https://github.com/owner/repo/assets/45952064/1a1842fb")?.fetchUrl).toBe( + "https://github.com/owner/repo/assets/45952064/1a1842fb", + ); + expect(media("https://media.githubusercontent.com/media/owner/repo/main/a.mp4")).toEqual({ + fetchUrl: "https://media.githubusercontent.com/media/owner/repo/main/a.mp4", + fileName: "a.mp4", + }); + expect(media("https://raw.githubusercontent.com/o/r/main/100%.png")?.fileName).toBe("100%.png"); + }); + + it("claims nothing a request without a credential loads just as well", () => { + for (const url of [ + "https://example.com/shot.png", + "http://github.com/user-attachments/assets/1a1842fb", + "https://github.com/owner/repo/pull/1", + "https://github.com/owner/repo/blob/main/", + "https://objects.githubusercontent.com/github-production-user-asset/shot.png", + ]) { + expect(media(url), url).toBeNull(); + } + }); +}); diff --git a/packages/source-control-github/src/client/definition.ts b/packages/source-control-github/src/client/definition.ts index c022ed824b41..86268f29d408 100644 --- a/packages/source-control-github/src/client/definition.ts +++ b/packages/source-control-github/src/client/definition.ts @@ -18,6 +18,8 @@ import { remoteWebOrigin, } from "@t3tools/source-control-core/client/definition"; +import { gitHubMediaUrl } from "./gitHubMedia.ts"; + const CHECKOUT_COMMAND = /^gh\s+pr\s+checkout\s+(.+)$/i; const CHANGE_REQUEST_REFERENCE = /^https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/(\d+)(?:[/?#].*)?$/i; @@ -134,5 +136,6 @@ export const definition = defineSourceControlClient({ isChangeRequestInProjectRepository(projectChangeRequestHost(identity, KIND), identity, link), canReadChangeRequestOnHost: (identity, link) => isChangeRequestOnProjectHost(projectChangeRequestHost(identity, KIND), identity, link), + mediaUrl: gitHubMediaUrl, settings, }); diff --git a/packages/shared/src/githubMedia.ts b/packages/source-control-github/src/client/gitHubMedia.ts similarity index 51% rename from packages/shared/src/githubMedia.ts rename to packages/source-control-github/src/client/gitHubMedia.ts index 2d2797d09bc2..51b9e0598fa2 100644 --- a/packages/shared/src/githubMedia.ts +++ b/packages/source-control-github/src/client/gitHubMedia.ts @@ -1,44 +1,25 @@ +import type { SourceControlMediaUrl } from "@t3tools/source-control-core/client/definition"; + /** * Only hosts where a credential is what decides the answer belong here. `objects.githubusercontent.com` * and the `private-user-images` links GitHub's own HTML carries are already signed and load on their * own, and a token does nothing for them once that signature expires. */ - +const WEB_HOSTS = new Set(["github.com", "www.github.com"]); const RAW_HOST = "raw.githubusercontent.com"; const LFS_HOST = "media.githubusercontent.com"; +export const GITHUB_CREDENTIALED_MEDIA_HOSTS: ReadonlySet = new Set([ + ...WEB_HOSTS, + RAW_HOST, + LFS_HOST, +]); + const ATTACHMENT_PATH_PATTERN = /^\/user-attachments\/assets\/[\w-]+$/u; const LEGACY_ATTACHMENT_PATH_PATTERN = /^\/[^/]+\/[^/]+\/assets\/\d+\/[\w-]+$/u; /** `blob` and `raw` both address file bytes; `raw` is the one the token is honoured on. */ const REPOSITORY_FILE_PATTERN = /^\/([^/]+)\/([^/]+)\/(?:raw|blob)\/(.*[^/])$/u; -function canonicalUrl(host: string, url: URL): string { - return `https://${host}${url.pathname}${url.search}`; -} - -export function githubMediaFetchUrl(source: string): string | null { - let url: URL; - try { - url = new URL(source); - } catch { - return null; - } - if (url.protocol !== "https:") return null; - const host = url.hostname.toLowerCase(); - if (host === RAW_HOST || host === LFS_HOST) return canonicalUrl(host, url); - if (host !== "github.com" && host !== "www.github.com") return null; - if ( - ATTACHMENT_PATH_PATTERN.test(url.pathname) || - LEGACY_ATTACHMENT_PATH_PATTERN.test(url.pathname) - ) { - return `https://github.com${url.pathname}`; - } - const repositoryFile = REPOSITORY_FILE_PATTERN.exec(url.pathname); - return repositoryFile - ? `https://${RAW_HOST}/${repositoryFile[1]}/${repositoryFile[2]}/${repositoryFile[3]}` - : null; -} - -export function githubMediaFileName(fetchUrl: string): string { +function fileName(fetchUrl: string): string { const segment = new URL(fetchUrl).pathname.split("/").pop() ?? ""; let decoded: string; try { @@ -49,3 +30,27 @@ export function githubMediaFileName(fetchUrl: string): string { const name = decoded.replace(/[\p{Cc}\\/]/gu, ""); return name.length > 0 ? name : "github-media"; } + +function media(fetchUrl: string, kind?: "video"): SourceControlMediaUrl { + return { fetchUrl, fileName: fileName(fetchUrl), ...(kind ? { kind } : {}) }; +} + +export function gitHubMediaUrl(url: URL): SourceControlMediaUrl | null { + if (url.protocol !== "https:") return null; + const host = url.hostname.toLowerCase(); + if (host === RAW_HOST || host === LFS_HOST) { + return media(`https://${host}${url.pathname}${url.search}`); + } + if (!WEB_HOSTS.has(host)) return null; + // GitHub links an uploaded video bare, with no extension, and an image as markdown. + if (ATTACHMENT_PATH_PATTERN.test(url.pathname)) { + return media(`https://github.com${url.pathname}`, "video"); + } + if (LEGACY_ATTACHMENT_PATH_PATTERN.test(url.pathname)) { + return media(`https://github.com${url.pathname}`); + } + const repositoryFile = REPOSITORY_FILE_PATTERN.exec(url.pathname); + return repositoryFile + ? media(`https://${RAW_HOST}/${repositoryFile[1]}/${repositoryFile[2]}/${repositoryFile[3]}`) + : null; +} diff --git a/packages/source-control-github/src/server/GitHubRest.ts b/packages/source-control-github/src/server/GitHubRest.ts index 6ed65eebabf7..a29505ecc7af 100644 --- a/packages/source-control-github/src/server/GitHubRest.ts +++ b/packages/source-control-github/src/server/GitHubRest.ts @@ -232,12 +232,11 @@ export const layer = Layer.effect(GitHubRest, make).pipe( ); /** - * REST and the request policy, with the credential, quota reserve and rate-limit pause the policy - * reads through: everything both transports need. An application builds this once so every GitHub - * reader shares one view of each quota per host. + * REST and the request policy, with the credential and quota reserve the policy reads through. + * Built once, so every GitHub reader shares one view of each quota per host. The rate-limit pause + * comes from the server, which every host's change request reads share. */ export const layerWithDependencies = layer.pipe( Layer.provideMerge(GitHubCredentials.layer), Layer.provideMerge(GitHubQuota.layer), - Layer.provideMerge(SourceControlRateLimit.layer), ); diff --git a/packages/source-control-github/src/server/driver.ts b/packages/source-control-github/src/server/driver.ts index d5247732936f..2afcd0898c01 100644 --- a/packages/source-control-github/src/server/driver.ts +++ b/packages/source-control-github/src/server/driver.ts @@ -1,17 +1,34 @@ -import { defineSourceControlDriver } from "@t3tools/source-control-core/server/driver"; +import { + defineSourceControlDriver, + withOwnServices, +} from "@t3tools/source-control-core/server/driver"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import { definition } from "../client/definition.ts"; +import * as GitHubMediaFetch from "./gitHubMediaFetch.ts"; +import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; import * as GitHubPullRequestProvider from "./GitHubPullRequestProvider.ts"; +import * as GitHubRest from "./GitHubRest.ts"; import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; +// `GitHubRest.layerWithDependencies` carries the quota reserve every GitHub reader shares, so it +// must be built once; the driver is. +const services = GitHubPullRequestApi.layer.pipe( + Layer.provideMerge(GitHubRest.layerWithDependencies), +); + +/** Over this host's own services, which `driver` builds; tests stand in for them. */ +export const makeInstance = Effect.all({ + sourceControl: GitHubSourceControlProvider.make, + discovery: GitHubSourceControlProvider.makeDiscovery, + pullRequests: GitHubPullRequestProvider.make, + fetchMedia: GitHubMediaFetch.make, +}); + export const driver = defineSourceControlDriver({ kind: definition.kind, definition, - make: Effect.all({ - sourceControl: GitHubSourceControlProvider.make, - discovery: GitHubSourceControlProvider.makeDiscovery, - pullRequests: GitHubPullRequestProvider.make, - }), + make: withOwnServices(makeInstance, services), }); diff --git a/packages/source-control-github/src/server/gitHubMediaFetch.test.ts b/packages/source-control-github/src/server/gitHubMediaFetch.test.ts new file mode 100644 index 000000000000..07e2bbd8f724 --- /dev/null +++ b/packages/source-control-github/src/server/gitHubMediaFetch.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Redacted from "effect/Redacted"; +import { HttpClient, HttpClientResponse } from "effect/http"; + +import * as GitHubCredentials from "./GitHubCredentials.ts"; +import * as GitHubMediaFetch from "./gitHubMediaFetch.ts"; + +describe("GitHubMediaFetch", () => { + it.effect("loads private media immediately after login with the GitHub credential", () => { + let lookups = 0; + const authorizations: Array = []; + return Effect.gen(function* () { + const fetchMedia = yield* GitHubMediaFetch.make; + const request = { + url: "https://raw.githubusercontent.com/owner/repo/main/shot.png", + headers: {}, + }; + expect((yield* fetchMedia(request))?.status).toBe(404); + expect((yield* fetchMedia(request))?.status).toBe(200); + expect((yield* fetchMedia(request))?.status).toBe(200); + expect(lookups).toBe(3); + expect(authorizations).toEqual([undefined, "Bearer signed-in", "Bearer signed-in"]); + }).pipe( + Effect.provide( + Layer.mock(GitHubCredentials.GitHubCredentials)({ + get: (host) => + ++lookups === 1 + ? Effect.fail(new GitHubCredentials.GitHubNotSignedInError({ host })) + : Effect.succeed({ + host, + token: Redacted.make("signed-in"), + source: "gh" as const, + fingerprint: "fingerprint", + }), + }), + ), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => { + authorizations.push(request.headers.authorization); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(null, { + status: request.headers.authorization ? 200 : 404, + headers: { "content-type": "image/png" }, + }), + ), + ); + }), + ), + Effect.scoped, + ); + }); + + it.effect("keeps the credential off the object store a redirect hands the media to", () => { + const authorizations: Array<[string, string | undefined]> = []; + return Effect.gen(function* () { + const fetchMedia = yield* GitHubMediaFetch.make; + const response = yield* fetchMedia({ + url: "https://github.com/user-attachments/assets/1a1842fb", + headers: { range: "bytes=0-1" }, + }); + expect(response?.status).toBe(200); + expect(authorizations).toEqual([ + ["github.com", "Bearer signed-in"], + ["objects.example.com", undefined], + ]); + }).pipe( + Effect.provide( + Layer.mock(GitHubCredentials.GitHubCredentials)({ + get: (host) => + Effect.succeed({ + host, + token: Redacted.make("signed-in"), + source: "gh" as const, + fingerprint: "fingerprint", + }), + }), + ), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => { + const url = new URL(request.url); + authorizations.push([url.hostname, request.headers.authorization]); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + url.hostname === "github.com" + ? new Response(null, { + status: 302, + headers: { location: "https://objects.example.com/signed" }, + }) + : new Response(null, { status: 200 }), + ), + ); + }), + ), + Effect.scoped, + ); + }); +}); diff --git a/packages/source-control-github/src/server/gitHubMediaFetch.ts b/packages/source-control-github/src/server/gitHubMediaFetch.ts new file mode 100644 index 000000000000..a360f873ae1c --- /dev/null +++ b/packages/source-control-github/src/server/gitHubMediaFetch.ts @@ -0,0 +1,68 @@ +import type { SourceControlMediaRequest } from "@t3tools/source-control-core/server/driver"; +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; +import { + FetchHttpClient, + HttpClient, + HttpClientRequest, + type HttpClientResponse, +} from "effect/http"; + +import { GITHUB_CREDENTIALED_MEDIA_HOSTS } from "../client/gitHubMedia.ts"; + +import * as GitHubCredentials from "./GitHubCredentials.ts"; + +/** + * Everything a redirect leads to — the presigned object stores GitHub hands assets off to — + * authorizes with its own signature, and some of them reject a request that also carries a + * bearer token. + */ +const isCredentialedHost = (url: string) => { + try { + return GITHUB_CREDENTIALED_MEDIA_HOSTS.has(new URL(url).hostname.toLowerCase()); + } catch { + return false; + } +}; + +const MAX_REDIRECTS = 3; +const MANUAL_REDIRECT: RequestInit = { redirect: "manual" }; + +export const make = Effect.gen(function* () { + const credentials = yield* GitHubCredentials.GitHubCredentials; + const httpClient = HttpClient.withScope(yield* HttpClient.HttpClient); + + // Both media hosts are served by github.com's account, which is the host `gh` stores it under. + // Read on every fetch, so media loads as soon as the user signs in. + const token = Effect.suspend(() => credentials.get("github.com")).pipe( + Effect.map((credential) => credential.token), + Effect.orElseSucceed(() => null), + ); + + return Effect.fn("GitHubMediaFetch.fetchMedia")(function* (request: SourceControlMediaRequest) { + const bearer = yield* token; + let target = request.url; + for (let hop = 0; ; hop += 1) { + const authorization = + bearer !== null && isCredentialedHost(target) ? `Bearer ${Redacted.value(bearer)}` : null; + const response: HttpClientResponse.HttpClientResponse = yield* httpClient + .execute( + HttpClientRequest.get(target).pipe( + HttpClientRequest.setHeaders({ + ...request.headers, + // The bytes are streamed straight through, so never let an encoding layer in. + "accept-encoding": "identity", + ...(authorization === null ? {} : { authorization }), + }), + ), + ) + .pipe(Effect.provideService(FetchHttpClient.RequestInit, MANUAL_REDIRECT)); + const location = response.headers.location; + if (response.status < 300 || response.status >= 400) return response; + if (!location || hop >= MAX_REDIRECTS) return null; + const next = new URL(location, target); + if (next.protocol !== "https:") return null; + target = next.toString(); + } + }); +}); diff --git a/packages/source-control-github/tsconfig.json b/packages/source-control-github/tsconfig.json index 374bac55202d..154996e94d31 100644 --- a/packages/source-control-github/tsconfig.json +++ b/packages/source-control-github/tsconfig.json @@ -3,5 +3,5 @@ "compilerOptions": { "types": ["node"] }, - "include": ["src"] + "include": ["src", "scripts"] } diff --git a/packages/source-control-gitlab/src/server/driver.ts b/packages/source-control-gitlab/src/server/driver.ts index 175efc16269e..faf30e3406ae 100644 --- a/packages/source-control-gitlab/src/server/driver.ts +++ b/packages/source-control-gitlab/src/server/driver.ts @@ -1,17 +1,28 @@ -import { defineSourceControlDriver } from "@t3tools/source-control-core/server/driver"; +import { + defineSourceControlDriver, + withOwnServices, +} from "@t3tools/source-control-core/server/driver"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import { definition } from "../client/definition.ts"; +import * as GitLabCli from "./GitLabCli.ts"; +import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts"; import * as GitLabPullRequestProvider from "./GitLabPullRequestProvider.ts"; import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts"; +const services = GitLabPullRequestCli.layer.pipe(Layer.provideMerge(GitLabCli.layer)); + +/** Over this host's own services, which `driver` builds; tests stand in for them. */ +export const makeInstance = Effect.all({ + sourceControl: GitLabSourceControlProvider.make, + discovery: Effect.succeed(GitLabSourceControlProvider.discovery), + pullRequests: GitLabPullRequestProvider.make, +}); + export const driver = defineSourceControlDriver({ kind: definition.kind, definition, - make: Effect.all({ - sourceControl: GitLabSourceControlProvider.make, - discovery: Effect.succeed(GitLabSourceControlProvider.discovery), - pullRequests: GitLabPullRequestProvider.make, - }), + make: withOwnServices(makeInstance, services), }); From 39f6e2b2a22678013ed691512e6703e99c7d0861 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 24 Oct 2026 04:24:34 -0700 Subject: [PATCH 04/27] refactor(source-control): git and process behaviour comes from forge hooks (#23995) Co-authored-by: Claude Opus 5.5 --- apps/server/src/git/GitManager.test.ts | 141 +++--- apps/server/src/git/GitManager.ts | 427 ++++++++---------- .../PullRequestReviewGuideService.test.ts | 11 +- .../PullRequestReviewGuideService.ts | 7 +- apps/server/src/vcs/GitVcsDriver.ts | 28 +- apps/server/src/vcs/GitVcsDriverCore.test.ts | 14 +- apps/server/src/vcs/GitVcsDriverCore.ts | 110 +++-- apps/server/src/vcs/VcsProcess.test.ts | 31 +- apps/server/src/vcs/VcsProcess.ts | 64 +-- apps/server/src/vcs/VcsStatusBroadcaster.ts | 2 +- packages/contracts/src/vcs.ts | 10 +- packages/shared/src/sourceControl.test.ts | 11 - packages/shared/src/sourceControl.ts | 138 ------ .../src/server/AzureDevOpsCli.test.ts | 16 +- .../src/server/AzureDevOpsCli.ts | 14 + .../src/client/definition.ts | 5 + .../src/server/SourceControlHost.ts | 13 + .../src/server/SourceControlProvider.ts | 19 + .../src/client/definition.ts | 5 +- .../src/client/definition.ts | 1 + .../src/server/GitHubCredentials.ts | 7 + .../GitHubSourceControlProvider.test.ts | 28 ++ .../src/server/GitHubSourceControlProvider.ts | 54 ++- .../src/server/gitHubPullRequests.test.ts | 27 ++ .../src/client/definition.ts | 1 + .../src/server/GitLabCli.ts | 11 + 26 files changed, 630 insertions(+), 565 deletions(-) delete mode 100644 packages/shared/src/sourceControl.test.ts create mode 100644 packages/source-control-github/src/server/gitHubPullRequests.test.ts diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 660e2de9cbd8..d3ebc2daa1fe 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -3053,6 +3053,47 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); + it.effect("hands the branch base to the host when a failed first push is retried", () => + 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", "HEAD:main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/retried-push", "--track", "origin/main"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "retried.txt"), "retried\n"); + yield* runGit(repoDir, ["add", "retried.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Retried push"]); + const rejectOnce = NodePath.join(remoteDir, "reject-once"); + NodeFS.writeFileSync(rejectOnce, ""); + NodeFS.writeFileSync( + NodePath.join(remoteDir, "hooks", "pre-receive"), + `#!/bin/sh\nif [ -f "${rejectOnce}" ]; then rm "${rejectOnce}"; exit 1; fi\n`, + { mode: 0o755 }, + ); + + const recorded: Array<{ branch: string; baseBranch: string }> = []; + const { service } = createGitHubProviderWithFakeGh(); + const { manager } = yield* makeManager({ + sourceControlProvider: { + ...service, + recordBranchBase: ({ branch, baseBranch }) => + Effect.sync(() => { + recorded.push({ branch, baseBranch }); + }), + }, + }); + const failed = yield* runStackedAction(manager, { cwd: repoDir, action: "push" }).pipe( + Effect.flip, + ); + expect(failed).toBeDefined(); + const result = yield* runStackedAction(manager, { cwd: repoDir, action: "push" }); + + expect(result.push.status).toBe("pushed"); + expect(recorded).toEqual([{ branch: "feature/retried-push", baseBranch: "main" }]); + }), + ); + it.effect("create_pr targets the remote default branch when it is not main", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -4576,63 +4617,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); - it.effect("derives fork repository identity from PR URL when GitHub omits nameWithOwner", () => - 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", "binbandit-seed", forkDir]); - yield* runGit(repoDir, ["checkout", "-b", "fix/git-action-default-without-origin"]); - NodeFS.writeFileSync(NodePath.join(repoDir, "derived-fork.txt"), "derived fork\n"); - yield* runGit(repoDir, ["add", "derived-fork.txt"]); - yield* runGit(repoDir, ["commit", "-m", "Derived fork PR branch"]); - yield* runGit(repoDir, [ - "push", - "-u", - "binbandit-seed", - "fix/git-action-default-without-origin", - ]); - yield* runGit(repoDir, ["checkout", "main"]); - yield* runGit(repoDir, ["branch", "-D", "fix/git-action-default-without-origin"]); - - const { manager } = yield* makeManager({ - ghScenario: { - pullRequest: { - number: 642, - title: "fix: use commit as the default git action without origin", - url: "https://github.com/pingdotgg/t3code/pull/642", - baseRefName: "main", - headRefName: "fix/git-action-default-without-origin", - state: "open", - isCrossRepository: true, - headRepositoryOwnerLogin: "binbandit", - }, - repositoryCloneUrls: { - "binbandit/t3code": { - url: forkDir, - sshUrl: forkDir, - }, - }, - }, - }); - - const result = yield* preparePullRequestThread(manager, { - cwd: repoDir, - reference: "642", - mode: "local", - }); - - expect(result.branch).toBe("fix/git-action-default-without-origin"); - expect(result.worktreePath).toBeNull(); - expect( - (yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"])).stdout.trim(), - ).toBe("binbandit-seed/fix/git-action-default-without-origin"); - }), - ); - it.effect("refreshes a reused PR worktree onto the updated pull request head", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -5058,6 +5042,49 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); + it.effect("creates a merge request worktree from the host's own head 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/mr-ref-only"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "mr.txt"), "merge request\n"); + yield* runGit(repoDir, ["add", "mr.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Merge request ref only"]); + const head = (yield* runGit(repoDir, ["rev-parse", "HEAD"])).stdout.trim(); + yield* runGit(repoDir, ["push", "origin", "HEAD:refs/merge-requests/93/head"]); + yield* runGit(repoDir, ["checkout", "main"]); + yield* runGit(repoDir, ["branch", "-D", "feature/mr-ref-only"]); + + const { service } = createGitHubProviderWithFakeGh({ + pullRequest: { + number: 93, + title: "Merge request ref only", + url: "https://gitlab.com/pingdotgg/codething-mvp/-/merge_requests/93", + baseRefName: "main", + headRefName: "feature/mr-ref-only", + state: "open", + }, + }); + const { manager } = yield* makeManager({ + sourceControlProvider: { ...service, kind: SourceControlProviderKind.make("gitlab") }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "93", + mode: "worktree", + }); + + expect(result.isOnPullRequestHead).toBe(true); + expect( + (yield* runGit(result.worktreePath as string, ["rev-parse", "HEAD"])).stdout.trim(), + ).toBe(head); + }), + ); + it.effect("preserves an unrelated local branch and upstream that share the fork head name", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 0e517a53ae9a..6902e84777ba 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -1,4 +1,3 @@ -import { SourceControlProviderKind } from "@t3tools/contracts"; import * as Arr from "effect/Array"; import * as Cache from "effect/Cache"; import * as Clock from "effect/Clock"; @@ -54,16 +53,11 @@ import { sanitizeBranchFragment, sanitizeFeatureBranchName, } from "@t3tools/shared/git"; +import { isSshRemoteUrl } from "@t3tools/shared/sourceControl"; import { - getChangeRequestTerminologyForKind, - isSshRemoteUrl, type ChangeRequestTerminology, -} from "@t3tools/shared/sourceControl"; -import * as HostProcess from "@t3tools/shared/HostProcess"; -import { - parseGitHubRepositorySelector, - resolveGitHubRepository, -} from "@t3tools/source-control-github/server/gitHubRepositoryResolution"; + UNKNOWN_SOURCE_CONTROL_CLIENT, +} from "@t3tools/source-control-core/client/definition"; import { normalizeThreadPullRequestKey, threadPullRequestKeyOf, @@ -255,48 +249,15 @@ interface BranchHeadContext { } export function pullRequestRepositoryKey(value: string): string | null { - try { - const url = new URL(value); - const match = - /^(.*)(?:\/pulls?\/|\/-\/merge_requests\/|\/pull-requests\/|\/pullrequest\/)\d+(?:\/.*)?$/iu.exec( - url.pathname, - ); - if (match?.[1] === undefined) return null; - url.pathname = match[1]; - url.search = ""; - url.hash = ""; - return sourceControlClients.repositoryKeyFromRemote(url.toString()); - } catch { - return null; - } + const repositoryUrl = sourceControlClients.parseChangeRequestUrl(value)?.link.repositoryUrl; + return repositoryUrl === undefined + ? null + : sourceControlClients.repositoryKeyFromRemote(repositoryUrl); } -function parseRepositoryNameFromPullRequestUrl(url: string): string | null { - const trimmed = url.trim(); - const match = /^https?:\/\/[^/]+\/[^/]+\/([^/]+)\/pull\/\d+(?:\/.*)?$/i.exec(trimmed); - const repositoryName = match?.[1]?.trim() ?? ""; - return repositoryName.length > 0 ? repositoryName : null; -} - -function resolveHeadRepositoryNameWithOwner( - pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, -): string | null { - const explicitRepository = pullRequest.headRepositoryNameWithOwner?.trim() ?? ""; - if (explicitRepository.length > 0) { - return explicitRepository; - } - - if (!pullRequest.isCrossRepository) { - return null; - } - - const ownerLogin = pullRequest.headRepositoryOwnerLogin?.trim() ?? ""; - const repositoryName = parseRepositoryNameFromPullRequestUrl(pullRequest.url); - if (ownerLogin.length === 0 || !repositoryName) { - return null; - } - - return `${ownerLogin}/${repositoryName}`; +/** Null when the host did not name the repository the head lives in. */ +function resolveHeadRepositoryNameWithOwner(pullRequest: PullRequestHeadRemoteInfo): string | null { + return normalizeOptionalString(pullRequest.headRepositoryNameWithOwner); } function resolvePullRequestWorktreeLocalBranchName( @@ -331,7 +292,8 @@ function parseRepositoryOwnerLogin(nameWithOwner: string | null): string | null if (trimmed.length === 0) { return null; } - // GitLab reports the top-level group as owner. The full path distinguishes subgroups. + // The first segment only: a nested path's owner is its top-level group. The full path + // distinguishes subgroups. const [ownerLogin] = trimmed.split("/"); const normalizedOwnerLogin = ownerLogin?.trim() ?? ""; return normalizedOwnerLogin.length > 0 ? normalizedOwnerLogin : null; @@ -352,27 +314,6 @@ function normalizeOptionalOwnerLogin(value: string | null | undefined): string | return normalized ? normalized.toLowerCase() : null; } -function resolvePullRequestHeadRepositoryNameWithOwner( - pr: PullRequestHeadRemoteInfo & { url: string }, -) { - const explicitRepository = normalizeOptionalString(pr.headRepositoryNameWithOwner); - if (explicitRepository) { - return explicitRepository; - } - - if (!pr.isCrossRepository) { - return null; - } - - const ownerLogin = normalizeOptionalString(pr.headRepositoryOwnerLogin); - const repositoryName = parseRepositoryNameFromPullRequestUrl(pr.url); - if (!ownerLogin || !repositoryName) { - return null; - } - - return `${ownerLogin}/${repositoryName}`; -} - interface PullRequestHeadIdentity { readonly repositoryNameWithOwner: string | null; readonly ownerLogin: string | null; @@ -394,7 +335,7 @@ function resolveExpectedHeadIdentity( function resolvePullRequestHeadIdentity(pr: PullRequestInfo): PullRequestHeadIdentity { const repositoryNameWithOwner = normalizeOptionalRepositoryNameWithOwner( - resolvePullRequestHeadRepositoryNameWithOwner(pr), + resolveHeadRepositoryNameWithOwner(pr), ); return { repositoryNameWithOwner, @@ -634,11 +575,6 @@ function headBranchProbe( ); } -/** A URL's host as `new URL` reads it: lowercased, any non-default port kept, `:443` dropped. */ -function urlHost(url: string): string | null { - return URL.canParse(url) ? new URL(url).host : null; -} - function appendUnique(values: string[], next: string | null | undefined): void { const trimmed = next?.trim() ?? ""; if (trimmed.length === 0 || values.includes(trimmed)) { @@ -726,9 +662,16 @@ export const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const environment = yield* HostProcess.Environment; const sourceControlProvider = (cwd: string) => sourceControlProviders.resolve({ cwd }); + const changeRequestTermsFor = Effect.fn("GitManager.changeRequestTermsFor")(function* ( + cwd: string, + ) { + return yield* sourceControlProvider(cwd).pipe( + Effect.map((provider) => sourceControlClients.get(provider.kind).changeRequest), + Effect.orElseSucceed(() => UNKNOWN_SOURCE_CONTROL_CLIENT.changeRequest), + ); + }); const serverSettingsService = yield* ServerSettings.ServerSettingsService; const threads = yield* ProjectionStore.ProjectionStoreV2; const projects = yield* ProjectStore.ProjectStoreV2; @@ -775,6 +718,31 @@ export const make = Effect.gen(function* () { ), ), ); + // Hands the base T3 recorded for a branch to the host, whose own tools may keep it elsewhere. + // Best-effort: nothing T3 does reads it back from there. + const shareBranchBase = Effect.fn("GitManager.shareBranchBase")(function* ( + cwd: string, + branch: string, + ) { + const baseBranch = yield* gitCore + .readBranchBase(cwd, branch) + .pipe(Effect.orElseSucceed(() => null)); + if (baseBranch === null) return; + const provider = yield* sourceControlProvider(cwd).pipe(Effect.orElseSucceed(() => null)); + yield* provider?.recordBranchBase?.({ cwd, branch, baseBranch }) ?? Effect.void; + }); + // Publishing a branch that tracks its base records that base first, even when the push then + // fails, so the host hears of it after every successful push rather than the first attempt. + const pushCurrentBranch = Effect.fn("GitManager.pushCurrentBranch")(function* ( + cwd: string, + branch: string | null, + ) { + const result = yield* gitCore.pushCurrentBranch(cwd, branch); + if (result.status === "pushed" && result.branch) { + yield* shareBranchBase(cwd, result.branch); + } + return result; + }); const readWorktreesDirectory = serverSettingsService.getSettings.pipe( Effect.map((settings) => settings.worktreesDirectory), Effect.orElseSucceed(() => ""), @@ -790,7 +758,15 @@ export const make = Effect.gen(function* () { Effect.orElseSucceed(() => null), ); const worktreesDirectory = yield* readWorktreesDirectory; - return yield* gitCore.createWorktree(input, { worktreesDirectory, ...options, submodules }); + const result = yield* gitCore.createWorktree(input, { + worktreesDirectory, + ...options, + submodules, + }); + if (input.newRefName && input.baseRefName) { + yield* shareBranchBase(input.cwd, input.newRefName); + } + return result; }); const readRepositoryInstructions = Effect.fn("GitManager.readRepositoryInstructions")( @@ -990,6 +966,31 @@ export const make = Effect.gen(function* () { ), ); + // The kind comes from the provider, not the remote alone: an unrecognised remote can resolve + // to a known host. + const changeRequestHeadRef = Effect.fn("GitManager.changeRequestHeadRef")(function* ( + cwd: string, + number: number, + ) { + const provider = yield* sourceControlProvider(cwd); + const ref = sourceControlClients.get(provider.kind).changeRequestHeadRef?.(number); + if (ref === undefined) { + return yield* new GitManagerError({ + operation: "changeRequestHeadRef", + cwd, + detail: "This host publishes no ref for a change request's head.", + }); + } + return ref; + }); + + const fetchChangeRequestHeadIntoBranch = Effect.fn("GitManager.fetchChangeRequestHeadIntoBranch")( + function* (cwd: string, number: number, branch: string) { + const ref = yield* changeRequestHeadRef(cwd, number); + yield* gitCore.fetchRefIntoBranch({ cwd, ref, branch }); + }, + ); + const materializePullRequestHeadBranchBase = Effect.fn("materializePullRequestHeadBranch")( function* ( cwd: string, @@ -999,47 +1000,38 @@ export const make = Effect.gen(function* () { const repositoryNameWithOwner = resolveHeadRepositoryNameWithOwner(pullRequest) ?? ""; if (repositoryNameWithOwner.length === 0) { - yield* gitCore - .fetchPullRequestBranch({ - cwd, - prNumber: pullRequest.number, - branch: localBranch, - }) - .pipe( - // Azure DevOps, GitLab and Bitbucket publish no `refs/pull//head`. A head in the - // same repository is a branch on the primary remote, so it is fetched by name instead, - // but only when that remote is the pull request's own repository: Azure finds a pull - // request by number anywhere in the organization. Only while it is open, too: the - // branch of a closed one may have moved on past the head it was closed with. - Effect.catch((cause) => - pullRequest.isCrossRepository === true || pullRequest.state !== "open" - ? Effect.fail(cause) - : Effect.gen(function* () { - const remoteName = yield* gitCore.resolvePrimaryRemoteName(cwd); - const remoteUrl = yield* gitCore.readConfigValue( - cwd, - `remote.${remoteName}.url`, - ); - const pullRequestKey = pullRequestRepositoryKey(pullRequest.url); - if ( - remoteUrl === null || - pullRequestKey === null || - sourceControlClients.canonicalRepositoryKey(pullRequestKey) !== - sourceControlClients.canonicalRepositoryKey( - sourceControlClients.repositoryKeyFromRemote(remoteUrl), - ) - ) { - return yield* cause; - } - yield* gitCore.fetchRemoteBranch({ - cwd, - remoteName, - remoteBranch: pullRequest.headBranch, - localBranch, - }); - }), - ), - ); + yield* fetchChangeRequestHeadIntoBranch(cwd, pullRequest.number, localBranch).pipe( + // Without the host's head ref, a head in the same repository is a branch on the + // primary remote, so it is fetched by name instead, but only when that remote is the + // pull request's own repository: a host can find a change request by number outside + // the checkout's repository. Only while it is open, too: the branch of a closed one + // may have moved on past the head it was closed with. + Effect.catch((cause) => + pullRequest.isCrossRepository === true || pullRequest.state !== "open" + ? Effect.fail(cause) + : Effect.gen(function* () { + const remoteName = yield* gitCore.resolvePrimaryRemoteName(cwd); + const remoteUrl = yield* gitCore.readConfigValue(cwd, `remote.${remoteName}.url`); + const pullRequestKey = pullRequestRepositoryKey(pullRequest.url); + if ( + remoteUrl === null || + pullRequestKey === null || + sourceControlClients.canonicalRepositoryKey(pullRequestKey) !== + sourceControlClients.canonicalRepositoryKey( + sourceControlClients.repositoryKeyFromRemote(remoteUrl), + ) + ) { + return yield* cause; + } + yield* gitCore.fetchRemoteBranch({ + cwd, + remoteName, + remoteBranch: pullRequest.headBranch, + localBranch, + }); + }), + ), + ); return; } @@ -1081,29 +1073,23 @@ export const make = Effect.gen(function* () { ) => materializePullRequestHeadBranchBase(cwd, pullRequest, localBranch).pipe( Effect.catch((primaryCause) => - gitCore - .fetchPullRequestBranch({ - cwd, - prNumber: pullRequest.number, - branch: localBranch, - }) - .pipe( - Effect.mapError( - (fallbackCause) => - new GitPullRequestMaterializationError({ - cwd, - pullRequestNumber: pullRequest.number, - headRepository: resolveHeadRepositoryNameWithOwner(pullRequest), - headBranch: pullRequest.headBranch, - localBranch, - cause: new AggregateError( - [primaryCause, fallbackCause], - `Repository-head and pull-request-ref fetches both failed for pull request #${pullRequest.number}.`, - { cause: primaryCause }, - ), - }), - ), + fetchChangeRequestHeadIntoBranch(cwd, pullRequest.number, localBranch).pipe( + Effect.mapError( + (fallbackCause) => + new GitPullRequestMaterializationError({ + cwd, + pullRequestNumber: pullRequest.number, + headRepository: resolveHeadRepositoryNameWithOwner(pullRequest), + headBranch: pullRequest.headBranch, + localBranch, + cause: new AggregateError( + [primaryCause, fallbackCause], + `Repository-head and pull-request-ref fetches both failed for pull request #${pullRequest.number}.`, + { cause: primaryCause }, + ), + }), ), + ), ), ); const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp"; @@ -1437,39 +1423,27 @@ export const make = Effect.gen(function* () { const readConfigValueNullable = (cwd: string, key: string) => gitCore.readConfigValue(cwd, key).pipe(Effect.orElseSucceed(() => null)); - // Whether the GitHub provider creates PRs in the only remote. It picks GH_REPO when that - // parses, else what resolveGitHubRepository makes of the remote and its gh-resolved mark from - // `gh repo set-default`, given the host the provider's context gets from the remote URL. An - // HTTPS remote keeps a non-default port there, and the provider then ignores the mark. With no - // answer at all, the provider falls back to the remote itself. + // Whether the remote's host creates change requests in the only remote. A host's tools can let + // configuration outside the remotes name another repository; the remote itself otherwise. const targetsOnlyRemote = Effect.fn("targetsOnlyRemote")(function* ( cwd: string, remoteName: string, remote: { readonly remoteUrl: string | null; readonly repositoryNameWithOwner: string | null }, ) { - const baseUrl = - remote.remoteUrl === null - ? undefined - : sourceControlClients.recognizeRemote(remote.remoteUrl)?.baseUrl; - const host = baseUrl === undefined ? null : urlHost(baseUrl); - const repository = remote.repositoryNameWithOwner?.toLowerCase(); - const fromEnvironment = parseGitHubRepositorySelector(environment.GH_REPO ?? "", host ?? ""); - if (fromEnvironment !== null) { - const { host: targetHost, owner, name } = fromEnvironment; - return ( - host !== null && - urlHost(`https://${targetHost}`) === host && - `${owner}/${name}`.toLowerCase() === repository - ); - } - const mark = yield* readConfigValueNullable(cwd, `remote.${remoteName}.gh-resolved`); - const { locator } = resolveGitHubRepository({ - remotes: remote.remoteUrl === null ? "" : `${remoteName}\t${remote.remoteUrl} (fetch)`, - resolved: mark === null ? "" : `remote.${remoteName}.gh-resolved ${mark}`, - hostHint: host ?? undefined, - defaultHost: host ?? "github.com", + const provider = + remote.remoteUrl === null ? null : sourceControlClients.recognizeRemote(remote.remoteUrl); + if (remote.remoteUrl === null || provider === null) return true; + const context = { provider, remoteName, remoteUrl: remote.remoteUrl }; + const handle = yield* sourceControlProviders + .resolveHandle({ cwd, context }) + .pipe(Effect.orElseSucceed(() => null)); + const createsChangeRequestsIn = handle?.provider.createsChangeRequestsIn; + if (createsChangeRequestsIn === undefined) return true; + return yield* createsChangeRequestsIn({ + cwd, + context: handle?.context ?? context, + repository: remote.repositoryNameWithOwner, }); - return locator === null || `${locator.owner}/${locator.name}`.toLowerCase() === repository; }); const resolveHostingProvider = Effect.fn("resolveHostingProvider")(function* ( @@ -1564,9 +1538,9 @@ export const make = Effect.gen(function* () { remoteName, ); - // With no origin to compare against, the only remote is the repository gh targets - // unless GH_REPO or `gh repo set-default` (gh-resolved) names another, so a branch on it - // is not a fork. With several remotes, or with that other target, it may still be a fork. + // With no origin to compare against, the only remote is the repository change requests + // are created in unless the host's own configuration names another, so a branch on it is + // not a fork. With several remotes, or with that other target, it may still be a fork. const isOnlyRemote = remoteName !== null && remoteName !== "origin" && @@ -1928,12 +1902,7 @@ export const make = Effect.gen(function* () { cwd: string, result: Pick, ) { - const terms = yield* sourceControlProvider(cwd).pipe( - Effect.map((provider) => getChangeRequestTerminologyForKind(provider.kind)), - Effect.orElseSucceed(() => - getChangeRequestTerminologyForKind(SourceControlProviderKind.make("unknown")), - ), - ); + const terms = yield* changeRequestTermsFor(cwd); const summary = summarizeGitActionResult(result, terms); let latestOpenPr: PullRequestInfo | null = null; let currentBranchIsDefault = false; @@ -2032,7 +2001,7 @@ export const make = Effect.gen(function* () { if (configuredBranch) return { baseBranch: configuredBranch, baseRef: configuredRef }; } - const configured = yield* gitCore.readConfigValue(cwd, `branch.${branch}.gh-merge-base`); + const configured = yield* gitCore.readBranchBase(cwd, branch); if (configured) { const localRef = configured.startsWith("refs/") ? configured : `refs/heads/${configured}`; const localBranch = yield* gitCore.resolveBranchName(cwd, localRef); @@ -2278,7 +2247,7 @@ export const make = Effect.gen(function* () { emit: GitActionProgressEmitter, ) { const provider = yield* sourceControlProvider(cwd); - const terms = getChangeRequestTerminologyForKind(provider.kind); + const terms = sourceControlClients.get(provider.kind).changeRequest; const details = yield* gitCore.statusDetails(cwd); const branch = details.branch ?? fallbackBranch; if (!branch) { @@ -2871,53 +2840,52 @@ export const make = Effect.gen(function* () { 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({ + // 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. + const refreshed = yield* changeRequestHeadRef(worktreePath, pullRequest.number).pipe( + Effect.flatMap((ref) => gitCore.fetchRefCommit({ cwd: worktreePath, ref })), + // A host that publishes no head ref 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, - revision: details.upstreamRef, + detail: "The pull request head could not be resolved for this checkout.", }); - }), - ), - Effect.flatMap((target) => - gitCore.refreshCheckedOutBranch({ + } + return yield* gitCore.resolveCommit({ 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 })), - ), - ); + 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. @@ -3189,14 +3157,7 @@ export const make = Effect.gen(function* () { const currentBranch = branchStep.name ?? initialStatus.branch; const commitAction = isCommitAction(input.action) ? input.action : null; - const changeRequestTerms = wantsPr - ? yield* sourceControlProvider(input.cwd).pipe( - Effect.map((provider) => getChangeRequestTerminologyForKind(provider.kind)), - Effect.orElseSucceed(() => - getChangeRequestTerminologyForKind(SourceControlProviderKind.make("unknown")), - ), - ) - : null; + const changeRequestTerms = wantsPr ? yield* changeRequestTermsFor(input.cwd) : null; const commit = commitAction ? yield* Ref.set(currentPhase, Option.some("commit")).pipe( @@ -3225,7 +3186,7 @@ export const make = Effect.gen(function* () { }) .pipe( Effect.tap(() => Ref.set(currentPhase, Option.some("push"))), - Effect.flatMap(() => gitCore.pushCurrentBranch(input.cwd, currentBranch)), + Effect.flatMap(() => pushCurrentBranch(input.cwd, currentBranch)), ) : { status: "skipped_not_requested" as const }; diff --git a/apps/server/src/pullRequest/PullRequestReviewGuideService.test.ts b/apps/server/src/pullRequest/PullRequestReviewGuideService.test.ts index 76f703123c27..b4dd965570a9 100644 --- a/apps/server/src/pullRequest/PullRequestReviewGuideService.test.ts +++ b/apps/server/src/pullRequest/PullRequestReviewGuideService.test.ts @@ -94,6 +94,7 @@ const makeRepositories = Effect.gen(function* () { const detail = (workspaceRoot: string, headSha: string): PullRequestDetail => ({ + provider: "github", number: 7, title: "Add the feature", body: "Adds the feature.", @@ -385,13 +386,7 @@ it.layer(layerBase, { excludeTestServices: true })("PullRequestReviewGuideServic name: "a head no source has", reportedHead: () => "e".repeat(40), shadow: "none", - expected: [ - "local objects:", - "the commit:", - "branch feature:", - "refs/pull/7/head:", - "refs/merge-requests/7/head:", - ], + expected: ["local objects:", "the commit:", "branch feature:", "refs/pull/7/head:"], }, ])("refuses to write a guide for $name", ({ reportedHead, shadow, expected }) => Effect.scoped( @@ -415,6 +410,8 @@ it.layer(layerBase, { excludeTestServices: true })("PullRequestReviewGuideServic assert.strictEqual(result.run?._tag, "Failed"); const detail = result.run?._tag === "Failed" ? result.run.detail : ""; for (const part of expected) assert.include(detail, part); + // Only the host's own head ref is tried. + assert.notInclude(detail, "refs/merge-requests/"); assert.deepStrictEqual(run.observed, []); assert.isFalse(run.fetchedRefs.some((ref) => ref.startsWith("-"))); const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/pullRequest/PullRequestReviewGuideService.ts b/apps/server/src/pullRequest/PullRequestReviewGuideService.ts index e78d850d6834..5ae93c8827b1 100644 --- a/apps/server/src/pullRequest/PullRequestReviewGuideService.ts +++ b/apps/server/src/pullRequest/PullRequestReviewGuideService.ts @@ -251,6 +251,7 @@ const make = Effect.gen(function* () { ), ); const fetchAndResolve = (ref: string) => fetch([ref]).pipe(Effect.andThen(resolveHead)); + const headRef = sourceControlClients.get(detail.provider).changeRequestHeadRef?.(number); const branchRef = `refs/heads/${branch}`; const checkBranch = git.execute({ operation: "PullRequestReviewGuideService.checkHeadBranch", @@ -266,11 +267,7 @@ const make = Effect.gen(function* () { source: `branch ${branch}`, head: checkBranch.pipe(Effect.andThen(fetchAndResolve(branchRef))), }, - { source: `refs/pull/${number}/head`, head: fetchAndResolve(`refs/pull/${number}/head`) }, - { - source: `refs/merge-requests/${number}/head`, - head: fetchAndResolve(`refs/merge-requests/${number}/head`), - }, + ...(headRef === undefined ? [] : [{ source: headRef, head: fetchAndResolve(headRef) }]), ]; const failures: Array = []; for (const { source, head } of sources) { diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 3f839643086b..0599b0caf24b 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -201,15 +201,10 @@ export interface GitRenameBranchResult { branch: string; } -export interface GitFetchPullRequestBranchInput { +export interface GitFetchRefInput { cwd: string; - prNumber: number; - branch: string; -} - -export interface GitFetchPullRequestHeadCommitInput { - cwd: string; - prNumber: number; + /** A ref on the primary remote, such as a host's change request head ref. */ + ref: string; } export interface GitResolveCommitInput { @@ -342,6 +337,14 @@ export class GitVcsDriver extends Context.Service< cwd: string, key: string, ) => Effect.Effect; + /** + * The branch, named as on the host, that T3 recorded `branch` was cut from. Null when none + * was recorded. + */ + readonly readBranchBase: ( + cwd: string, + branch: string, + ) => Effect.Effect; readonly resolveBranchName: ( cwd: string, refName: string, @@ -354,11 +357,12 @@ export class GitVcsDriver extends Context.Service< input: VcsCreateWorktreeInput, options?: CreateWorktreeOptions, ) => Effect.Effect; - readonly fetchPullRequestBranch: ( - input: GitFetchPullRequestBranchInput, + /** Force-updates local `branch` to the primary remote's `ref`. */ + readonly fetchRefIntoBranch: ( + input: GitFetchRefInput & { readonly branch: string }, ) => Effect.Effect; - readonly fetchPullRequestHeadCommit: ( - input: GitFetchPullRequestHeadCommitInput, + readonly fetchRefCommit: ( + input: GitFetchRefInput, ) => Effect.Effect; readonly resolveCommit: ( input: GitResolveCommitInput, diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 6bd0137154da..fc0ee47223fe 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -3066,7 +3066,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { yield* git(cwd, ["rev-parse", "HEAD"]), ); assert.equal( - yield* git(cwd, ["config", "branch.feature/parallel.gh-merge-base"]), + yield* git(cwd, ["config", "branch.feature/parallel.t3-merge-base"]), initialBranch, ); for (const [configured, expected] of [ @@ -3117,7 +3117,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { assert.equal(yield* git(worktreePath, ["rev-parse", "HEAD"]), commit); assert.equal( - yield* driver.readConfigValue(cwd, "branch.feature/revision.gh-merge-base"), + yield* driver.readConfigValue(cwd, "branch.feature/revision.t3-merge-base"), null, ); }), @@ -3142,7 +3142,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }); assert.equal( - yield* driver.readConfigValue(cwd, "branch.feature/branch.gh-merge-base"), + yield* driver.readConfigValue(cwd, "branch.feature/branch.t3-merge-base"), baseBranch, ); }), @@ -3465,7 +3465,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { "# test\n", ); assert.equal( - yield* driver.readConfigValue(worktreePath, "branch.feature/locked-config.gh-merge-base"), + yield* driver.readConfigValue(worktreePath, "branch.feature/locked-config.t3-merge-base"), null, ); assert.equal(yield* fileSystem.exists(pathService.join(cwd, ".git/config.lock")), true); @@ -3737,7 +3737,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { assert.equal(yield* driver.resolvePrimaryRemoteName(cwd), remoteName); yield* driver.fetchRemoteTrackingBranch({ cwd, remoteName, remoteBranch: initialBranch }); yield* driver.fetchRemote({ cwd, remoteName }); - const head = yield* driver.fetchPullRequestHeadCommit({ cwd, prNumber: 7 }); + const head = yield* driver.fetchRefCommit({ cwd, ref: "refs/pull/7/head" }); assert.isFalse(yield* fileSystem.exists((yield* Path.Path).join(cwd, "t3-marker"))); assert.equal(head.commitSha, yield* git(cwd, ["rev-parse", "HEAD"])); @@ -4133,7 +4133,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { assert.strictEqual(status.branchChanges?.deletions, 0); // gh pr create consumes this setting verbatim as the hosted target branch. assert.equal( - yield* driver.readConfigValue(worktreePath, "branch.feature/from-upstream.gh-merge-base"), + yield* driver.readConfigValue(worktreePath, "branch.feature/from-upstream.t3-merge-base"), "release", ); assert.equal(changes.baseRef, "upstream/release"); @@ -4362,7 +4362,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { yield* git(cwd, ["rev-parse", "--abbrev-ref", "@{upstream}"]), "origin/feature/x", ); - assert.equal(yield* driver.readConfigValue(cwd, "branch.feature/x.gh-merge-base"), "dev"); + assert.equal(yield* driver.readConfigValue(cwd, "branch.feature/x.t3-merge-base"), "dev"); const preview = yield* driver.getReviewDiffPreview({ cwd }); const changes = preview.sources.find((source) => source.kind === "branch-range")!; diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 2bc11cdb0451..78c88d3c790b 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -53,6 +53,13 @@ import * as ServerConfig from "../config.ts"; import { sourceControlClients } from "../sourceControl/sourceControlClients.ts"; const DEFAULT_TIMEOUT_MS = 30_000; +/** T3's record of the branch, named as on the host, that a branch was cut from. */ +const branchBaseConfigKey = (branch: string) => `branch.${branch}.t3-merge-base`; +/** + * Where T3 kept that record before it had a key of its own, for every host alike: the GitHub + * CLI's key, which branches cut before then still carry instead. + */ +const legacyBranchBaseConfigKey = (branch: string) => `branch.${branch}.gh-merge-base`; const gitProcesses = Semaphore.makeUnsafe(8); const WORKTREE_ADD_TIMEOUT_MS = 300_000; const WORKTREE_REMOVE_TIMEOUT_MS = Duration.toMillis(Duration.minutes(5)); @@ -1818,8 +1825,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } } - const configuredBaseBranch = - (yield* readConfigValue(cwd, `branch.${refName}.gh-merge-base`)) ?? ""; + const configuredBaseBranch = (yield* readBranchBase(cwd, refName)) ?? ""; const configuredBase = yield* resolveComparisonBaseRef(cwd, configuredBaseBranch); if ( configuredBase !== null && @@ -2638,16 +2644,10 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const remoteName = publishRemoteName ?? currentUpstream.remoteName; const publishBranch = yield* resolvePublishBranchName(cwd, branch); // Publishing retargets the upstream, so preserve its comparison base first. - const configuredMergeBase = yield* runGitStdout( - "GitVcsDriver.pushCurrentBranch.readMergeBase", - cwd, - ["config", "--get", `branch.${branch}.gh-merge-base`], - true, - ).pipe(Effect.map((stdout) => stdout.trim())); - if (configuredMergeBase.length === 0) { + if ((yield* readBranchBase(cwd, branch)) === null) { yield* runGit("GitVcsDriver.pushCurrentBranch.recordMergeBase", cwd, [ "config", - `branch.${branch}.gh-merge-base`, + branchBaseConfigKey(branch), currentUpstream.branchName, ]); if (!(yield* readConfigValue(cwd, `branch.${branch}.t3-merge-base-ref`))) { @@ -3369,6 +3369,13 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* Effect.map((trimmed) => (trimmed.length > 0 ? trimmed : null)), ); + const readBranchBase: GitVcsDriver.GitVcsDriver["Service"]["readBranchBase"] = Effect.fn( + "GitVcsDriver.readBranchBase", + )(function* (cwd, branch) { + const base = yield* readConfigValue(cwd, branchBaseConfigKey(branch)); + return base ?? (yield* readConfigValue(cwd, legacyBranchBaseConfigKey(branch))); + }); + const resolveBranchBase = Effect.fn("GitVcsDriver.resolveBranchBase")(function* ( cwd: string, refName: string, @@ -3842,17 +3849,18 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ); } - if (input.newRefName && input.baseRefName) { + const newRefName = input.newRefName; + if (newRefName && input.baseRefName) { const base = yield* resolveBranchBase(input.cwd, input.baseRefName); if (base) { yield* Effect.gen(function* () { yield* runGit("GitVcsDriver.createWorktree.configureBaseRef", input.cwd, [ "config", - `branch.${input.newRefName}.gh-merge-base`, + branchBaseConfigKey(newRefName), base.branchName, ]); if (base.baseRef.startsWith("refs/remotes/")) { - // gh consumes its key as a provider branch; T3 also needs the selected remote. + // The base key names a branch on the host; this one keeps the remote it came from. yield* runGit("GitVcsDriver.createWorktree.configureComparisonRef", input.cwd, [ "config", `branch.${input.newRefName}.t3-merge-base-ref`, @@ -3878,25 +3886,26 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }; }); - const fetchPullRequestBranch: GitVcsDriver.GitVcsDriver["Service"]["fetchPullRequestBranch"] = - Effect.fn("fetchPullRequestBranch")(function* (input) { - const remoteName = yield* resolvePrimaryRemoteName(input.cwd); - yield* executeGit( - "GitVcsDriver.fetchPullRequestBranch", - input.cwd, - [ - "fetch", - "--quiet", - "--no-tags", - "--", - remoteName, - `+refs/pull/${input.prNumber}/head:refs/heads/${input.branch}`, - ], - { - fallbackErrorDetail: "git fetch pull request branch failed", - }, - ); - }); + const fetchRefIntoBranch: GitVcsDriver.GitVcsDriver["Service"]["fetchRefIntoBranch"] = Effect.fn( + "GitVcsDriver.fetchRefIntoBranch", + )(function* (input) { + const remoteName = yield* resolvePrimaryRemoteName(input.cwd); + yield* executeGit( + "GitVcsDriver.fetchRefIntoBranch", + input.cwd, + [ + "fetch", + "--quiet", + "--no-tags", + "--", + remoteName, + `+${input.ref}:refs/heads/${input.branch}`, + ], + { + fallbackErrorDetail: "git fetch ref into branch failed", + }, + ); + }); const resolveCommit: GitVcsDriver.GitVcsDriver["Service"]["resolveCommit"] = Effect.fn( "resolveCommit", @@ -3910,22 +3919,23 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* 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", - }, - ); + const fetchRefCommit: GitVcsDriver.GitVcsDriver["Service"]["fetchRefCommit"] = Effect.fn( + "GitVcsDriver.fetchRefCommit", + )(function* (input) { + const remoteName = yield* resolvePrimaryRemoteName(input.cwd); + // No refspec destination: the ref 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.fetchRefCommit", + input.cwd, + ["fetch", "--quiet", "--no-tags", "--", remoteName, input.ref], + { + fallbackErrorDetail: "git fetch ref failed", + }, + ); - return yield* resolveCommit({ cwd: input.cwd, revision: "FETCH_HEAD" }); - }); + return yield* resolveCommit({ cwd: input.cwd, revision: "FETCH_HEAD" }); + }); const refreshCheckedOutBranch: GitVcsDriver.GitVcsDriver["Service"]["refreshCheckedOutBranch"] = Effect.fn("refreshCheckedOutBranch")(function* (input) { @@ -4507,13 +4517,13 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* getReviewDiffPreview, getReviewDiffFileContents, readConfigValue, + readBranchBase, resolveBranchName, listRefs, createWorktree: (input, options) => withListRefsInvalidation(input.cwd, createWorktree(input, options)), - fetchPullRequestBranch: (input) => - withListRefsInvalidation(input.cwd, fetchPullRequestBranch(input)), - fetchPullRequestHeadCommit, + fetchRefIntoBranch: (input) => withListRefsInvalidation(input.cwd, fetchRefIntoBranch(input)), + fetchRefCommit, resolveCommit, refreshCheckedOutBranch: (input) => withListRefsInvalidation(input.cwd, refreshCheckedOutBranch(input)), diff --git a/apps/server/src/vcs/VcsProcess.test.ts b/apps/server/src/vcs/VcsProcess.test.ts index 03d274fdd823..212415171bdc 100644 --- a/apps/server/src/vcs/VcsProcess.test.ts +++ b/apps/server/src/vcs/VcsProcess.test.ts @@ -258,7 +258,7 @@ describe("VcsProcess.run", () => { }), ); - it.effect("bounds a synthetic burst of GitHub API processes", () => + it.effect("bounds a burst of processes that share a concurrency key", () => Effect.gen(function* () { const gate = yield* Deferred.make(); const starts = yield* Queue.unbounded(); @@ -294,10 +294,11 @@ describe("VcsProcess.run", () => { const burst = yield* Effect.all( Array.from({ length: 32 }, (_, index) => service.run({ - operation: `synthetic.github.${index}`, - command: "gh", + operation: `synthetic.cli.${index}`, + command: "host-cli", args: ["api", "user"], cwd: "/workspace", + concurrency: { key: "host-cli", limit: 4 }, }), ), { concurrency: "unbounded" }, @@ -391,6 +392,30 @@ describe("VcsProcess.run", () => { }).pipe(provideLive), ); + it.effect("reads a failure with the command's own classifier and names it in its terms", () => + Effect.gen(function* () { + const fail = (input: Partial) => + run({ + operation: "test.classify", + command: "node", + args: ["-e", "process.stderr.write('HTTP 404: 404 Not Found'); process.exit(1)"], + cwd: process.cwd(), + ...input, + }).pipe(Effect.flip); + + expect(yield* fail({})).toMatchObject({ + failureKind: "command-failed", + detail: "Process exited with a non-zero status.", + }); + expect( + yield* fail({ + classifyFailure: (stderr) => (stderr.includes("404") ? "not-found" : null), + notFoundDetail: "Merge request not found.", + }), + ).toMatchObject({ failureKind: "not-found", detail: "Merge request not found." }); + }).pipe(provideLive), + ); + it.effect("retains spawn causes without exposing process arguments in the error message", () => Effect.gen(function* () { const secretArgument = "--token=super-secret-token"; diff --git a/apps/server/src/vcs/VcsProcess.ts b/apps/server/src/vcs/VcsProcess.ts index 2a62c6fb1ac4..980f4c7e674b 100644 --- a/apps/server/src/vcs/VcsProcess.ts +++ b/apps/server/src/vcs/VcsProcess.ts @@ -20,6 +20,7 @@ import { VcsProcessTimeoutError, } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import type { SourceControlProcessInput } from "@t3tools/source-control-core/server/SourceControlHost"; import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts"; import * as ProcessRunner from "../processRunner.ts"; @@ -38,6 +39,9 @@ export interface VcsProcessInput { readonly maxOutputBytes?: number; readonly outputMode?: ProcessRunner.ProcessRunInput["outputMode"]; readonly appendTruncationMarker?: boolean; + readonly classifyFailure?: SourceControlProcessInput["classifyFailure"]; + readonly notFoundDetail?: string; + readonly concurrency?: SourceControlProcessInput["concurrency"]; } export interface VcsProcessOutput { @@ -61,7 +65,6 @@ const DEFAULT_TIMEOUT_MS = 30_000; const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; const OUTPUT_TRUNCATED_MARKER = "\n\n[truncated]"; const VCS_PROCESS_CONCURRENCY = 8; -const GITHUB_PROCESS_CONCURRENCY = 4; /** Shown to users when a Git command fails because another process holds the index lock. */ export const GIT_INDEX_LOCKED_DETAIL = @@ -132,22 +135,23 @@ export const gitConfigEnv = ( * Git otherwise opens any folder holding `HEAD`, `objects/` and `refs/` as a * bare repository and applies its `config`, including commands such as * `core.fsmonitor`. Files saved in a project or checked out from a clone can - * form one, so git started by T3 (or by gh and friends) only opens a bare + * form one, so git started by T3 (or by a host CLI) only opens a bare * repository named with `--git-dir` or `GIT_DIR`. Git before 2.38 ignores it. */ export const explicitBareRepositoryEnv = (env: NodeJS.ProcessEnv, platform: NodeJS.Platform) => gitConfigEnv(env, "safe.bareRepository", "explicit", platform); -const classifyNonZeroExit = (command: string, stderr: string): VcsProcessExitFailureKind => { +const classifyNonZeroExit = ( + stderr: string, + classifyFailure: VcsProcessInput["classifyFailure"], +): VcsProcessExitFailureKind => { const normalized = stderr.toLowerCase(); + const commandKind = classifyFailure?.(stderr) ?? null; if ( + commandKind === "authentication" || normalized.includes("authentication failed") || normalized.includes("not logged in") || - normalized.includes("gh auth login") || - normalized.includes("glab auth login") || - normalized.includes("az devops login") || - normalized.includes("please run az login") || normalized.includes("no oauth token") || normalized.includes("unauthorized") ) { @@ -155,6 +159,7 @@ const classifyNonZeroExit = (command: string, stderr: string): VcsProcessExitFai } if ( + commandKind === "rate-limited" || normalized.includes("api rate limit") || normalized.includes("rate limit exceeded") || normalized.includes("secondary rate limit") || @@ -164,24 +169,7 @@ const classifyNonZeroExit = (command: string, stderr: string): VcsProcessExitFai return "rate-limited"; } - if ( - (command === "gh" && - (normalized.includes("could not resolve to a pullrequest") || - normalized.includes("repository.pullrequest") || - normalized.includes("no pull requests found for branch") || - normalized.includes("pull request not found"))) || - (command === "glab" && - (normalized.includes("merge request not found") || - normalized.includes("not found") || - normalized.includes("404"))) || - (command === "az" && - normalized.includes("pull request") && - (normalized.includes("not found") || normalized.includes("does not exist"))) - ) { - return "not-found"; - } - - return "command-failed"; + return commandKind ?? "command-failed"; }; // Classify before discarding stderr; keep paths and process output out of errors. @@ -204,7 +192,15 @@ export const make = Effect.gen(function* () { const processRunner = yield* ProcessRunner.ProcessRunner; const hostPlatform = yield* HostProcess.Platform; const vcsProcesses = yield* Semaphore.make(VCS_PROCESS_CONCURRENCY); - const githubProcesses = yield* Semaphore.make(GITHUB_PROCESS_CONCURRENCY); + const keyedProcesses = new Map(); + const keyedPermit = (concurrency: NonNullable) => { + let semaphore = keyedProcesses.get(concurrency.key); + if (semaphore === undefined) { + semaphore = Semaphore.makeUnsafe(concurrency.limit); + keyedProcesses.set(concurrency.key, semaphore); + } + return semaphore.withPermits(1); + }; const spawn = Effect.fn("VcsProcess.spawn")(function* ( input: VcsProcessInput, @@ -315,7 +311,7 @@ export const make = Effect.gen(function* () { } if (!input.allowNonZeroExit && result.code !== 0) { - const failureKind = classifyNonZeroExit(input.command, result.stderr); + const failureKind = classifyNonZeroExit(result.stderr, input.classifyFailure); const error = VcsProcessExitError.fromProcessExit( baseError, { @@ -324,9 +320,13 @@ export const make = Effect.gen(function* () { stderrTruncated: result.stderrTruncated, }, failureKind, - input.command === "git" && - failureKind === "command-failed" && - isTransientGitExit(result.stderr), + { + retryable: + input.command === "git" && + failureKind === "command-failed" && + isTransientGitExit(result.stderr), + ...(input.notFoundDetail === undefined ? {} : { notFoundDetail: input.notFoundDetail }), + }, ); if ( input.command === "git" && @@ -374,7 +374,9 @@ export const make = Effect.gen(function* () { }), ); } - return yield* input.command === "gh" ? githubProcesses.withPermits(1)(bounded) : bounded; + return yield* input.concurrency === undefined + ? bounded + : keyedPermit(input.concurrency)(bounded); }); return VcsProcess.of({ run }); diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index 41859b9dd907..71427713e5b8 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -243,7 +243,7 @@ export const make = Effect.gen(function* () { ); const cacheRef = yield* Ref.make(new Map()); // One permit per cwd for remote reads that write the cache. Without it a - // periodic poll that started before `gh pr create` can finish after the + // periodic poll that started before an agent creates a PR can finish after the // turn-end refresh and overwrite the fresh PR with its stale `pr: null`. const remoteWriteLocks = yield* KeyedLock.make(); const withRemoteWriteLock = (cwd: string, effect: Effect.Effect) => diff --git a/packages/contracts/src/vcs.ts b/packages/contracts/src/vcs.ts index f92e4fc1d5c4..3d59d61c7aa6 100644 --- a/packages/contracts/src/vcs.ts +++ b/packages/contracts/src/vcs.ts @@ -132,7 +132,7 @@ export class VcsProcessExitError extends Schema.TaggedError context: VcsProcessErrorContext, error: VcsProcessExitFailure, failureKind: VcsProcessExitFailureKind, - retryable?: boolean, + options?: { readonly retryable?: boolean; readonly notFoundDetail?: string }, ) { const detail = failureKind === "authentication" @@ -140,11 +140,7 @@ export class VcsProcessExitError extends Schema.TaggedError : failureKind === "rate-limited" ? "API rate limit exceeded." : failureKind === "not-found" - ? context.command === "glab" - ? "Merge request not found." - : context.command === "gh" || context.command === "az" - ? "Pull request not found." - : "VCS resource not found." + ? (options?.notFoundDetail ?? "VCS resource not found.") : "Process exited with a non-zero status."; return new VcsProcessExitError({ @@ -152,7 +148,7 @@ export class VcsProcessExitError extends Schema.TaggedError exitCode: error.exitCode, detail, failureKind, - ...(retryable === true ? { retryable: true } : {}), + ...(options?.retryable === true ? { retryable: true } : {}), stderrLength: error.stderr.length, stderrTruncated: error.stderrTruncated, }); diff --git a/packages/shared/src/sourceControl.test.ts b/packages/shared/src/sourceControl.test.ts deleted file mode 100644 index bb3ad5716fdb..000000000000 --- a/packages/shared/src/sourceControl.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { SourceControlProviderKind } from "@t3tools/contracts"; -import { expect, it } from "vite-plus/test"; - -import { getChangeRequestTerminologyForKind } from "./sourceControl.ts"; - -it("calls a Forgejo change request a pull request", () => { - expect(getChangeRequestTerminologyForKind(SourceControlProviderKind.make("forgejo"))).toEqual({ - shortLabel: "PR", - singular: "pull request", - }); -}); diff --git a/packages/shared/src/sourceControl.ts b/packages/shared/src/sourceControl.ts index f7039961f975..31fba958776e 100644 --- a/packages/shared/src/sourceControl.ts +++ b/packages/shared/src/sourceControl.ts @@ -1,141 +1,3 @@ -import type { SourceControlProviderInfo, SourceControlProviderKind } from "@t3tools/contracts"; - -export interface ChangeRequestPresentation { - readonly icon: - | "github" - | "gitlab" - | "forgejo" - | "azure-devops" - | "bitbucket" - | "gitcafe" - | "change-request"; - readonly providerName: string; - readonly shortName: string; - readonly longName: string; - readonly pluralLongName: string; - readonly providerLongName: string; - readonly checkoutCommandExample?: string; - readonly urlExample: string; -} - -export interface ChangeRequestTerminology { - readonly shortLabel: string; - readonly singular: string; -} - -const GITHUB_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { - icon: "github", - providerName: "GitHub", - shortName: "PR", - longName: "pull request", - pluralLongName: "pull requests", - providerLongName: "GitHub pull request", - checkoutCommandExample: "gh pr checkout 123", - urlExample: "https://github.com/owner/repo/pull/42", -}; - -const GITLAB_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { - icon: "gitlab", - providerName: "GitLab", - shortName: "MR", - longName: "merge request", - pluralLongName: "merge requests", - providerLongName: "GitLab merge request", - checkoutCommandExample: "glab mr checkout 123", - urlExample: "https://gitlab.com/group/project/-/merge_requests/42", -}; - -const FORGEJO_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { - icon: "forgejo", - providerName: "Forgejo", - shortName: "PR", - longName: "pull request", - pluralLongName: "pull requests", - providerLongName: "Forgejo pull request", - checkoutCommandExample: "tea pr checkout 123", - urlExample: "https://codeberg.org/owner/repo/pulls/42", -}; - -const AZURE_DEVOPS_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { - icon: "azure-devops", - providerName: "Azure DevOps", - shortName: "PR", - longName: "pull request", - pluralLongName: "pull requests", - providerLongName: "Azure DevOps pull request", - checkoutCommandExample: "az repos pr checkout --id 123", - urlExample: "https://dev.azure.com/org/project/_git/repo/pullrequest/42", -}; - -const BITBUCKET_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { - icon: "bitbucket", - providerName: "Bitbucket", - shortName: "PR", - longName: "pull request", - pluralLongName: "pull requests", - providerLongName: "Bitbucket pull request", - urlExample: "https://bitbucket.org/workspace/repo/pull-requests/42", -}; - -const GITCAFE_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { - icon: "gitcafe", - providerName: "GitCafe", - shortName: "PR", - longName: "pull request", - pluralLongName: "pull requests", - providerLongName: "GitCafe pull request", - checkoutCommandExample: "cafe pr checkout 123", - urlExample: "https://git.cafe/owner/repo/pulls/42", -}; - -const GENERIC_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { - icon: "change-request", - providerName: "source control", - shortName: "change request", - longName: "change request", - pluralLongName: "change requests", - providerLongName: "change request", - urlExample: "#42", -}; - -function resolveChangeRequestPresentation( - provider: SourceControlProviderInfo | null | undefined, -): ChangeRequestPresentation { - switch (provider?.kind) { - case "github": - case undefined: - return GITHUB_CHANGE_REQUEST_PRESENTATION; - case "gitlab": - return GITLAB_CHANGE_REQUEST_PRESENTATION; - case "forgejo": - return FORGEJO_CHANGE_REQUEST_PRESENTATION; - case "azure-devops": - return AZURE_DEVOPS_CHANGE_REQUEST_PRESENTATION; - case "bitbucket": - return BITBUCKET_CHANGE_REQUEST_PRESENTATION; - case "gitcafe": - return GITCAFE_CHANGE_REQUEST_PRESENTATION; - default: - return GENERIC_CHANGE_REQUEST_PRESENTATION; - } -} - -function resolveChangeRequestPresentationForKind( - kind: SourceControlProviderKind, -): ChangeRequestPresentation { - return resolveChangeRequestPresentation({ kind, name: "", baseUrl: "" }); -} - -export function getChangeRequestTerminologyForKind( - kind: SourceControlProviderKind, -): ChangeRequestTerminology { - const presentation = resolveChangeRequestPresentationForKind(kind); - return { - shortLabel: presentation.shortName, - singular: presentation.longName, - }; -} - const SCP_SSH_REMOTE_PATTERN = /^[a-zA-Z0-9._-]+@([^:/]+):/; export function isSshRemoteUrl(remoteUrl: string): boolean { diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsCli.test.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsCli.test.ts index 9a39f74d290a..ff2ddd033997 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsCli.test.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsCli.test.ts @@ -70,7 +70,20 @@ describe("AzureDevOpsCli.layer", () => { assert.strictEqual(result.headSha, "a".repeat(40)); assert.strictEqual(result.state, "open"); assert.deepStrictEqual(result.updatedAt._tag, Option.some(1)._tag); - assert.deepStrictEqual(mockRun.mock.calls.at(-1)?.[0], { + const { classifyFailure, ...call } = mockRun.mock.calls.at(-1)?.[0] ?? {}; + assert.strictEqual( + classifyFailure?.("Please run az login to setup account."), + "authentication", + ); + assert.strictEqual( + classifyFailure?.("Please run 'az login' to setup account."), + "authentication", + ); + assert.strictEqual( + classifyFailure?.("TF401180: The requested pull request was not found."), + "not-found", + ); + assert.deepStrictEqual(call, { operation: "AzureDevOpsCli.execute", command: "az", args: [ @@ -86,6 +99,7 @@ describe("AzureDevOpsCli.layer", () => { "json", ], cwd: "/repo", + notFoundDetail: "Pull request not found.", timeoutMs: 30_000, }); assert.strictEqual(result.isCrossRepository, false); diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsCli.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsCli.ts index c333a220c6d3..b12901d6aceb 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsCli.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsCli.ts @@ -9,6 +9,7 @@ import { TrimmedNonEmptyString, type SourceControlRepositoryVisibility, type VcsError, + type VcsProcessExitFailureKind, } from "@t3tools/contracts"; import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; @@ -350,6 +351,17 @@ function decodeAzureDevOpsJson( ); } +const classifyAzFailure = (stderr: string): VcsProcessExitFailureKind | null => { + const normalized = stderr.toLowerCase(); + if (normalized.includes("az devops login") || /please run '?az login'?/.test(normalized)) { + return "authentication"; + } + return normalized.includes("pull request") && + (normalized.includes("not found") || normalized.includes("does not exist")) + ? "not-found" + : null; +}; + /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const { process } = yield* SourceControlHost.SourceControlHost; @@ -361,6 +373,8 @@ export const make = Effect.gen(function* () { command: "az", args: input.args, cwd: input.cwd, + classifyFailure: classifyAzFailure, + notFoundDetail: "Pull request not found.", timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }), }) diff --git a/packages/source-control-core/src/client/definition.ts b/packages/source-control-core/src/client/definition.ts index 35cc5ddaeba3..a253780a6a3d 100644 --- a/packages/source-control-core/src/client/definition.ts +++ b/packages/source-control-core/src/client/definition.ts @@ -125,6 +125,11 @@ export interface SourceControlClientDefinition { link: ChangeRequestLink, ) => boolean; readonly checkoutChangeRequestHost: (identity: ChangeRequestProjectIdentity) => string | null; + /** + * The ref the target repository publishes change request `number`'s head under, which names the + * head even when it lives in a fork. Absent for a host that publishes none. + */ + readonly changeRequestHeadRef?: (number: number) => string; /** Null when this host does not claim the remote. Asked in the registry's recognition order. */ readonly recognizeRemote: (remote: GitRemoteHost) => SourceControlProviderInfo | null; /** The host change requests in the project are listed and keyed under. */ diff --git a/packages/source-control-core/src/server/SourceControlHost.ts b/packages/source-control-core/src/server/SourceControlHost.ts index 330fea4bbfaa..711538d0aa7e 100644 --- a/packages/source-control-core/src/server/SourceControlHost.ts +++ b/packages/source-control-core/src/server/SourceControlHost.ts @@ -4,6 +4,7 @@ import type { ServerSettingsError, VcsError, VcsListRemotesResult, + VcsProcessExitFailureKind, VcsSwitchRefInput, VcsSwitchRefResult, } from "@t3tools/contracts"; @@ -25,6 +26,18 @@ export interface SourceControlProcessInput { readonly maxOutputBytes?: number; readonly outputMode?: "error" | "truncate" | undefined; readonly appendTruncationMarker?: boolean; + /** + * Reads a failure the generic rules cannot, such as a host CLI's sign-in hint, from its stderr. + * Null leaves it to them. + */ + readonly classifyFailure?: (stderr: string) => VcsProcessExitFailureKind | null; + /** The error's detail when the command reports something it was asked for as missing. */ + readonly notFoundDetail?: string; + /** + * Runs sharing a key run at most `limit` at a time, for a host that throttles bursts of its CLI. + * The first run of a key fixes its limit. + */ + readonly concurrency?: { readonly key: string; readonly limit: number }; } export interface SourceControlProcessOutput { diff --git a/packages/source-control-core/src/server/SourceControlProvider.ts b/packages/source-control-core/src/server/SourceControlProvider.ts index 23e26dcf0d7f..90e9acfcb257 100644 --- a/packages/source-control-core/src/server/SourceControlProvider.ts +++ b/packages/source-control-core/src/server/SourceControlProvider.ts @@ -142,6 +142,25 @@ export class SourceControlProvider extends Context.Service< }) => Effect.Effect; }) => Effect.Effect; readonly resolveLink?: ResolveSourceControlLink; + /** + * Whether `createChangeRequest` from `cwd` targets `repository`, the `owner/name` of the + * context's remote, for a host whose tools let configuration outside the remotes name another + * repository. Absent means it does. + */ + readonly createsChangeRequestsIn?: (input: { + readonly cwd: string; + readonly context: SourceControlProviderContext; + readonly repository: string | null; + }) => Effect.Effect; + /** + * Told when T3 records `baseBranch`, a branch name on the host, as the base `branch` was cut + * from, for a host whose own tools keep that in git config of their own. + */ + readonly recordBranchBase?: (input: { + readonly cwd: string; + readonly branch: string; + readonly baseBranch: string; + }) => Effect.Effect; readonly listChangeRequests: (input: { readonly cwd: string; readonly context?: SourceControlProviderContext; diff --git a/packages/source-control-forgejo/src/client/definition.ts b/packages/source-control-forgejo/src/client/definition.ts index 1411a42c1e23..f11e7f58aaa2 100644 --- a/packages/source-control-forgejo/src/client/definition.ts +++ b/packages/source-control-forgejo/src/client/definition.ts @@ -21,6 +21,8 @@ const CHANGE_REQUEST_PATH = /^\/([^/]+(?:\/[^/]+)+)\/pulls\/(\d+)(?:\/|$)/u; const KIND = SourceControlProviderKind.make("forgejo"); +const changeRequestHeadRef = (number: number) => `refs/pull/${number}/head`; + /** * The repository's browser URL the server resolved from the signed-in account, which is the * only place a Forgejo mounted below a path says so. @@ -77,7 +79,7 @@ export const definition = defineSourceControlClient({ // Neither `fj` nor `tea` checks out by number, so fetch the pull ref from the repository itself. checkoutCommand: ({ number, repositoryUrl }) => repositoryUrl - ? `git fetch '${repositoryUrl.replaceAll("'", "'\\''")}' refs/pull/${number}/head && git checkout -B pulls/${number} FETCH_HEAD` + ? `git fetch '${repositoryUrl.replaceAll("'", "'\\''")}' ${changeRequestHeadRef(number)} && git checkout -B pulls/${number} FETCH_HEAD` : null, authorProfileUrl: () => null, // Forgejo refuses a change request review without a summary, even with inline comments. @@ -86,6 +88,7 @@ export const definition = defineSourceControlClient({ checkoutCommandArgument: (input) => CHECKOUT_COMMAND.exec(input)?.[1]?.trim() ?? null, isChangeRequestReference: (url) => CHANGE_REQUEST_REFERENCE.test(url), checkoutChangeRequestHost: () => null, + changeRequestHeadRef, recognizeRemote: ({ remoteUrl, host, hostname }) => hostname === "codeberg.org" || hostname.split(".").includes("forgejo") || diff --git a/packages/source-control-github/src/client/definition.ts b/packages/source-control-github/src/client/definition.ts index 86268f29d408..f60f10e72389 100644 --- a/packages/source-control-github/src/client/definition.ts +++ b/packages/source-control-github/src/client/definition.ts @@ -121,6 +121,7 @@ export const definition = defineSourceControlClient({ checkoutCommandArgument: (input) => CHECKOUT_COMMAND.exec(input)?.[1]?.trim() ?? null, isChangeRequestReference: (url) => CHANGE_REQUEST_REFERENCE.test(url), checkoutChangeRequestHost: (identity) => projectChangeRequestHost(identity, KIND), + changeRequestHeadRef: (number) => `refs/pull/${number}/head`, // `*.ghe.com` is GitHub Enterprise Cloud with data residency, one subdomain per enterprise. recognizeRemote: ({ host, hostname }) => hostname === "github.com" || diff --git a/packages/source-control-github/src/server/GitHubCredentials.ts b/packages/source-control-github/src/server/GitHubCredentials.ts index b2d0b7d0ea34..58aed003e3b9 100644 --- a/packages/source-control-github/src/server/GitHubCredentials.ts +++ b/packages/source-control-github/src/server/GitHubCredentials.ts @@ -19,6 +19,12 @@ import type { ServerSettings } from "@t3tools/contracts"; import * as GitHubClient from "../client/definition.ts"; +/** + * Every gh run on the server shares this. A burst of them (a status poll across many checkouts) + * otherwise starts enough at once to trip GitHub's secondary rate limit. + */ +export const GH_PROCESS_CONCURRENCY = { key: "gh", limit: 4 } as const; + const TOKEN_TTL = Duration.minutes(5); const MISSING_TTL = Duration.seconds(10); @@ -146,6 +152,7 @@ export const make = Effect.gen(function* () { // Never let gh print the token into a debug log. env: { GH_DEBUG: "", GH_PROMPT_DISABLED: "1" }, timeoutMs: 10_000, + concurrency: GH_PROCESS_CONCURRENCY, }) .pipe( Effect.mapError((error) => diff --git a/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts b/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts index a8dfbabf7d28..2c2293efb658 100644 --- a/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts +++ b/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts @@ -1051,3 +1051,31 @@ it.effect.each([ ); }, ); + +it.effect("says a GH_REPO naming an unparseable host targets no remote rather than failing", () => { + const { layer } = harness({ + remotes: remotesOutput(["upstream", "git@github.com:acme/web.git"]), + resolved: "", + api: {}, + }); + return Effect.gen(function* () { + const provider = yield* GitHubSourceControlProvider.make; + const targets = yield* provider.createsChangeRequestsIn!({ + cwd: "/repo", + context: { + provider: { + kind: SourceControlProviderKind.make("github"), + name: "GitHub", + baseUrl: "https://github.com", + }, + remoteName: "upstream", + remoteUrl: "git@github.com:acme/web.git", + }, + repository: "acme/web", + }); + assert.isFalse(targets); + }).pipe( + Effect.provide(layer), + Effect.provideService(HostProcess.Environment, { GH_REPO: "bad host/acme/web" }), + ); +}); diff --git a/packages/source-control-github/src/server/GitHubSourceControlProvider.ts b/packages/source-control-github/src/server/GitHubSourceControlProvider.ts index b4c2b5107fe0..22bbf99f2ab9 100644 --- a/packages/source-control-github/src/server/GitHubSourceControlProvider.ts +++ b/packages/source-control-github/src/server/GitHubSourceControlProvider.ts @@ -26,6 +26,7 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; import { isSshRemoteUrl } from "@t3tools/shared/sourceControl"; +import { GH_PROCESS_CONCURRENCY } from "./GitHubCredentials.ts"; import * as GitHubRest from "./GitHubRest.ts"; import * as GitHubRequestPolicy from "./GitHubRequestPolicy.ts"; import * as GitHubChangeRequestTemplate from "./gitHubChangeRequestTemplate.ts"; @@ -235,7 +236,9 @@ export const makeDiscovery = Effect.gen(function* () { ); const cli = yield* probeSourceControlProvider({ cwd, - process, + process: { + run: (input) => process.run({ ...input, concurrency: GH_PROCESS_CONCURRENCY }), + }, spec: { ...discovery, parseAuth: (input) => parseGitHubAuth(input, settings) }, }); const savedToken = (settings.tokens["github.com"] ?? "").trim() !== ""; @@ -893,6 +896,55 @@ export const make = Effect.gen(function* () { }), readChangeRequestTemplate: ({ cwd, treeish }) => GitHubChangeRequestTemplate.detect(cwd, treeish, git.execute), + // `createChangeRequest` targets what `resolveRepository` picks: GH_REPO when it parses, else + // the remote `gh repo set-default` marked (`gh-resolved`). An HTTPS remote keeps a + // non-default port in the context's host, and the mark is then ignored. + createsChangeRequestsIn: Effect.fn("GitHubSourceControlProvider.createsChangeRequestsIn")( + function* ({ cwd, context, repository }) { + const host = URL.canParse(context.provider.baseUrl) + ? new URL(context.provider.baseUrl).host + : null; + const target = repository?.toLowerCase(); + const fromEnvironment = parseGitHubRepositorySelector( + environment.GH_REPO ?? "", + host ?? "", + ); + if (fromEnvironment !== null) { + const { host: targetHost, owner, name } = fromEnvironment; + return ( + host !== null && + URL.canParse(`https://${targetHost}`) && + new URL(`https://${targetHost}`).host === host && + `${owner}/${name}`.toLowerCase() === target + ); + } + const mark = yield* git + .readConfigValue(cwd, `remote.${context.remoteName}.gh-resolved`) + .pipe(Effect.orElseSucceed(() => null)); + const { locator } = resolveGitHubRepository({ + remotes: `${context.remoteName}\t${context.remoteUrl} (fetch)`, + resolved: mark === null ? "" : `remote.${context.remoteName}.gh-resolved ${mark}`, + hostHint: host ?? undefined, + defaultHost: host ?? "github.com", + }); + return locator === null || `${locator.owner}/${locator.name}`.toLowerCase() === target; + }, + ), + // `gh pr create` run in the checkout, as an agent in a T3 worktree often does, takes its base + // from this key. + recordBranchBase: ({ cwd, branch, baseBranch }) => + git + .execute({ + operation: "GitHubSourceControlProvider.recordBranchBase", + cwd, + args: ["config", `branch.${branch}.gh-merge-base`, baseBranch], + }) + .pipe( + Effect.asVoid, + Effect.catch((cause) => + Effect.logWarning("Could not record the branch base for gh", { cause }), + ), + ), resolveLink: (input) => { // Automatic enrichment must not send ambient CLI credentials to a host from message text. if (input.url.host !== "github.com") return undefined; diff --git a/packages/source-control-github/src/server/gitHubPullRequests.test.ts b/packages/source-control-github/src/server/gitHubPullRequests.test.ts new file mode 100644 index 000000000000..59e8fffa418f --- /dev/null +++ b/packages/source-control-github/src/server/gitHubPullRequests.test.ts @@ -0,0 +1,27 @@ +import * as Result from "effect/Result"; +import { expect, it } from "vite-plus/test"; + +import { decodeGitHubPullRequestListJson } from "./gitHubPullRequests.ts"; + +// gh before 2.47 names the head repository without its owner, and a fork's checkout needs both. +it("names a fork's head repository from its owner when gh leaves nameWithOwner out", () => { + const decoded = decodeGitHubPullRequestListJson( + JSON.stringify([ + { + number: 642, + title: "fix: use commit as the default git action without origin", + url: "https://github.com/pingdotgg/t3code/pull/642", + baseRefName: "main", + headRefName: "fix/git-action-default-without-origin", + isCrossRepository: true, + headRepository: { id: "R_1", name: "t3code" }, + headRepositoryOwner: { login: "binbandit" }, + }, + ]), + ); + + expect(Result.getOrThrow(decoded)[0]).toMatchObject({ + headRepositoryNameWithOwner: "binbandit/t3code", + headRepositoryOwnerLogin: "binbandit", + }); +}); diff --git a/packages/source-control-gitlab/src/client/definition.ts b/packages/source-control-gitlab/src/client/definition.ts index e944270a94e8..d9262f5abf73 100644 --- a/packages/source-control-gitlab/src/client/definition.ts +++ b/packages/source-control-gitlab/src/client/definition.ts @@ -52,6 +52,7 @@ export const definition = defineSourceControlClient({ checkoutCommandArgument: (input) => CHECKOUT_COMMAND.exec(input)?.[1]?.trim() ?? null, isChangeRequestReference: (url) => CHANGE_REQUEST_REFERENCE.test(url), checkoutChangeRequestHost: () => null, + changeRequestHeadRef: (number) => `refs/merge-requests/${number}/head`, recognizeRemote: ({ host, hostname }) => hostname === "gitlab.com" || hostname.split(".").includes("gitlab") ? { diff --git a/packages/source-control-gitlab/src/server/GitLabCli.ts b/packages/source-control-gitlab/src/server/GitLabCli.ts index aea251144e8e..405c6a520040 100644 --- a/packages/source-control-gitlab/src/server/GitLabCli.ts +++ b/packages/source-control-gitlab/src/server/GitLabCli.ts @@ -11,6 +11,7 @@ import { TrimmedNonEmptyString, type SourceControlRepositoryVisibility, type VcsError, + type VcsProcessExitFailureKind, } from "@t3tools/contracts"; import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; @@ -409,6 +410,14 @@ function parseRepositoryPath(repository: string): { return { namespacePath, projectPath }; } +// glab prints a missing project or merge request as a bare 404. +const classifyGlabFailure = (stderr: string): VcsProcessExitFailureKind | null => { + const normalized = stderr.toLowerCase(); + if (normalized.includes("glab auth login")) return "authentication"; + if (normalized.includes("not found") || normalized.includes("404")) return "not-found"; + return null; +}; + /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const { process } = yield* SourceControlHost.SourceControlHost; @@ -423,6 +432,8 @@ export const make = Effect.gen(function* () { command: "glab", args: input.args, cwd: input.cwd, + classifyFailure: classifyGlabFailure, + notFoundDetail: "Merge request not found.", timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, ...(input.stdin === undefined ? {} : { stdin: input.stdin }), ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }), From 233f6eac69def8ba59581a487837e15b48cf1163 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 24 Oct 2026 04:24:34 -0700 Subject: [PATCH 05/27] refactor(source-control): pull request behaviour comes from forge capabilities (#23996) Co-authored-by: Claude Opus 5.5 --- .../SettingsServerControlsRouteScreen.tsx | 2 +- .../src/mcp/toolkits/pullRequests/tools.ts | 4 +- .../PullRequestSyncReactor.test.ts | 8 +- .../PullRequestSyncReactor.ts | 12 +- .../pullRequestWatch.forgejoComments.test.ts | 12 +- .../orchestration-v2/pullRequestWatch.test.ts | 23 ++-- .../src/orchestration-v2/pullRequestWatch.ts | 14 +-- .../src/orchestration-v2/runtimeLayer.test.ts | 1 + .../grok_transcript.ndjson | 4 +- .../grok_transcript.ndjson | 4 +- .../grok_transcript.ndjson | 4 +- .../grok_transcript.ndjson | 4 +- .../grok_monitor/grok_transcript.ndjson | 4 +- .../grok_prompt_error/grok_transcript.ndjson | 8 +- .../message_steering/grok_transcript.ndjson | 8 +- .../multi_turn/grok_transcript.ndjson | 8 +- .../multi_turn/muse_transcript.ndjson | 12 +- .../muse_permission/muse_transcript.ndjson | 12 +- .../muse_transcript.ndjson | 6 +- .../muse_transcript.ndjson | 6 +- .../muse_workflow/muse_transcript.ndjson | 6 +- .../queued_turn/grok_transcript.ndjson | 8 +- .../fixtures/simple/grok_transcript.ndjson | 4 +- .../fixtures/simple/muse_transcript.ndjson | 6 +- .../subagent_v2/muse_transcript.ndjson | 6 +- .../fixtures/todo_list/grok_transcript.ndjson | 4 +- .../grok_transcript.ndjson | 4 +- .../turn_interrupt/grok_transcript.ndjson | 4 +- .../turn_interrupt/muse_transcript.ndjson | 6 +- .../pullRequest/PullRequestService.test.ts | 69 +++++++++--- .../src/pullRequest/PullRequestService.ts | 101 ++++++++++------- .../pullRequest/PullRequestCodeTab.tsx | 20 ++-- .../pullRequest/PullRequestDetailPanel.tsx | 1 + .../pullRequest/PullRequestGuideTab.tsx | 6 +- .../pullRequest/guide/GuideExcerpt.tsx | 13 ++- .../settings/ProjectDefaultsSettings.tsx | 2 +- apps/web/src/reviewCommentContext.test.ts | 33 ++++++ apps/web/src/reviewCommentContext.ts | 40 +++++++ docs/internals/overview.md | 3 +- .../src/state/pullRequestRouting.ts | 5 +- packages/contracts/src/pullRequest.ts | 103 +++++------------- .../src/server/runtimeInstructions.ts | 2 +- .../src/client/definition.ts | 3 + .../server/AzureDevOpsPullRequestProvider.ts | 7 ++ .../server/BitbucketPullRequestProvider.ts | 7 ++ .../src/client/definition.ts | 5 + .../src/server/PullRequestProvider.ts | 14 +++ .../src/client/definition.ts | 1 + .../src/server/ForgejoPullRequestProvider.ts | 8 ++ .../src/client/definition.ts | 1 + .../src/client/definition.ts | 1 + .../src/server/GitHubPullRequestProvider.ts | 15 +++ .../src/server/gitHubPullRequestJson.test.ts | 32 ++++++ .../src/server/gitHubPullRequestJson.ts | 20 +++- .../src/client/definition.ts | 1 + .../src/server/GitLabPullRequestProvider.ts | 7 ++ 56 files changed, 480 insertions(+), 244 deletions(-) diff --git a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx index e33f1b8d5cb5..cb9e04f58e26 100644 --- a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx @@ -368,7 +368,7 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { write({ removeAgentCreditsOnMerge: value })} diff --git a/apps/server/src/mcp/toolkits/pullRequests/tools.ts b/apps/server/src/mcp/toolkits/pullRequests/tools.ts index 510f6831d7eb..4e476863700b 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/tools.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/tools.ts @@ -43,7 +43,7 @@ export const PullRequestTargetInput = Schema.Struct({ url: Schema.optional( TrimmedNonEmptyString.annotate({ description: - "The pull request's web URL, for example https://github.com/owner/repo/pull/123. Preferred when you have it; host, repository and number are read from it.", + "The pull request's web URL as its host shows it, for example https://git.example.com/owner/repo/pull/123 or https://git.example.com/group/project/-/merge_requests/123. Preferred when you have it; host, repository and number are read from it.", }), ), repository: Schema.optional( @@ -60,7 +60,7 @@ export const PullRequestTargetInput = Schema.Struct({ host: Schema.optional( TrimmedNonEmptyString.annotate({ description: - "Host the repository lives on, for example github.com. Defaults to the host of this thread's project.", + "Host the repository lives on, for example git.example.com. Defaults to the host of this thread's project.", }), ), }); diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts index 54f3e4b25553..09424a2a91dd 100644 --- a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts +++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts @@ -1337,7 +1337,11 @@ describe("PullRequestSyncReactor", () => { ), ); - it.effect("reads open links fresh only when a run that ran a merge command ends", () => + it.effect.each([ + "gh pr merge 7 --squash 2>&1 | tail -3", + "cafe pr merge 7 --squash", + "az repos pr update --id 7 --status completed", + ])("reads open links fresh only when a run that ran `%s` ends", (mergeCommand) => Effect.scoped( Effect.gen(function* () { yield* TestClock.setTime(Date.parse(NOW)); @@ -1366,7 +1370,7 @@ describe("PullRequestSyncReactor", () => { yield* Queue.offerAll(fixture.domainEvents, [ commandRan(ThreadId.make("other"), "gh pr view 9"), runUpdated(ThreadId.make("other"), "completed"), - commandRan(ThreadId.make("agent"), "gh pr merge 7 --squash 2>&1 | tail -3"), + commandRan(ThreadId.make("agent"), mergeCommand), runUpdated(ThreadId.make("agent"), "completed"), ]); yield* Queue.take(fixture.snapshotReads); diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts index 5e3dbbb6d937..5d0b47eb4c49 100644 --- a/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts @@ -39,10 +39,14 @@ import { isTerminalRunStatus } from "./ThreadManagementService.ts"; const SLOW_SYNC_INTERVAL_MS = 15 * 60 * 1_000; // Every server polls every open linked pull request, and most of them sit untouched for days. // One nobody has updated for QUIET_AFTER_MS is read on the quiet cadence instead of each sweep; -// requested refreshes, watches and `gh pr merge` still read it at once. +// requested refreshes, watches and a host CLI's merge command still read it at once. const QUIET_AFTER_MS = 30 * 60 * 1_000; const QUIET_SYNC_INTERVAL_MS = 5 * 60 * 1_000; -const PULL_REQUEST_CLOSE_COMMAND = /\b(?:gh\s+pr|glab\s+mr)\s+(?:merge|close)\b/u; + +const isChangeRequestCloseCommand = (command: string) => + sourceControlClients.definitions.some( + (definition) => definition.changeRequestCloseCommand?.test(command) === true, + ); const isPullRequestProviderError = Schema.is(PullRequestProviderError); @@ -533,14 +537,14 @@ export const make = Effect.gen(function* () { requestSync, { discard: true }, ); - // An agent can merge or close its pull request from a shell (`gh pr merge`), which + // An agent can merge or close its pull request from a shell with its host's CLI, which // sends no merge notification. When a run that ran such a command ends, read the // thread's open links fresh, so settlement does not wait for the next sweep and the // cached summary. Other runs add no host reads. case "turn-item.updated": if ( event.payload.type === "command_execution" && - PULL_REQUEST_CLOSE_COMMAND.test(event.payload.input) + isChangeRequestCloseCommand(event.payload.input) ) { closeCommandThreads.add(event.threadId); } diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.forgejoComments.test.ts b/apps/server/src/orchestration-v2/pullRequestWatch.forgejoComments.test.ts index 4301e38fe24c..57d0f5888309 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.forgejoComments.test.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.forgejoComments.test.ts @@ -1,4 +1,4 @@ -import { SourceControlProviderKind, type ThreadPullRequestWatch } from "@t3tools/contracts"; +import type { ThreadPullRequestWatch } from "@t3tools/contracts"; import { assert, describe, it } from "@effect/vitest"; import * as Schema from "effect/Schema"; import { @@ -30,7 +30,15 @@ describe("Forgejo comment watch boundary", () => { wakes: 2, } satisfies ThreadPullRequestWatch; const finalDetail = { - provider: SourceControlProviderKind.make("forgejo"), + capabilities: { + diff: true, + comment: true, + actions: [], + mergeMethods: [], + search: false, + review: { inlineComment: true, reply: false, resolve: false, verdicts: [] }, + reviewers: { request: true, listCandidates: true }, + }, headSha: seen.headSha, checks: [], mergeability: "mergeable", diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts index ea27a0fffe58..a3da5180408d 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts @@ -1,5 +1,4 @@ import { - SourceControlProviderKind, type PullRequestCheck, PullRequestComment, ThreadPullRequestWatch, @@ -40,8 +39,19 @@ const check = (name: string, status: PullRequestCheck["status"]): PullRequestChe type Detail = Parameters[1]; +const capabilities = (checksAlwaysReport: boolean): Detail["capabilities"] => ({ + diff: true, + comment: true, + actions: [], + mergeMethods: [], + search: false, + review: { inlineComment: false, reply: false, resolve: false, verdicts: [] }, + reviewers: { request: false, listCandidates: false }, + checksAlwaysReport, +}); + const detail = (overrides: Partial = {}): Detail => ({ - provider: SourceControlProviderKind.make("github"), + capabilities: capabilities(true), headSha: "aaaaaaaaaa", checks: [check("lint", "success"), check("test", "pending")], mergeability: "mergeable", @@ -364,11 +374,8 @@ describe("evaluatePullRequestWatch", () => { assert.deepEqual(pushed.flat(), ["no-checks"]); }); - it("never reports missing checks on a host whose checks it cannot read", () => { - const azure = detail({ - provider: SourceControlProviderKind.make("azure-devops"), - checks: [], - }); - assert.isTrue(anHourLater(watch(), azure).every((kinds) => kinds.length === 0)); + it("never reports missing checks on a host where an empty list says nothing", () => { + const silent = detail({ capabilities: capabilities(false), checks: [] }); + assert.isTrue(anHourLater(watch(), silent).every((kinds) => kinds.length === 0)); }); }); diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.ts b/apps/server/src/orchestration-v2/pullRequestWatch.ts index 371cb46de8d1..0701354ee2c6 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.ts @@ -12,7 +12,7 @@ import * as DateTime from "effect/DateTime"; * this only stops a chatty bot looping an agent that is replying to it. */ export const PULL_REQUEST_WATCH_WAKE_LIMIT = 10; -/** How long a GitHub head may go without any check before the agent is told none reported. */ +/** How long a head may go without any check before the agent is told none reported. */ const PULL_REQUEST_WATCH_NO_CHECKS_MS = 15 * 60_000; const LISTED_ITEMS = 10; const SNIPPET_LENGTH = 200; @@ -38,7 +38,7 @@ export function evaluatePullRequestWatch( watch: ThreadPullRequestWatch, detail: Pick< PullRequestDetail, - "provider" | "headSha" | "checks" | "mergeability" | "viewer" | "author" + "capabilities" | "headSha" | "checks" | "mergeability" | "viewer" | "author" >, remarks: ReadonlyArray | null, now: number, @@ -95,10 +95,10 @@ export function evaluatePullRequestWatch( // Only a head never read with checks waits for them: an empty read after checks were seen is // the failed read above. A watch saved before this field may have seen pending checks on its - // head, so its clock starts only at the next push. Other hosts can lack a checks API (Azure - // DevOps pipelines), where an empty list says nothing, so only GitHub waits. + // head, so its clock starts only at the next push. Where a host's empty list can mean the + // repository runs no checks at all, nothing waits. let checksAwaitedSince = watch.checksAwaitedSince; - if (detail.provider === "github") { + if (detail.capabilities.checksAlwaysReport === true) { const awaitedBefore = headMoved ? DateTime.formatIso(DateTime.makeUnsafe(now)) : (watch.checksAwaitedSince ?? null); @@ -113,7 +113,7 @@ export function evaluatePullRequestWatch( const own = (detail.viewer ?? detail.author?.login)?.toLowerCase(); const through = Date.parse(watch.remarksThrough); const activeAt = (remark: PullRequestComment) => remark.editedAt ?? remark.createdAt; - // GitHub times are per second, so remarks at the boundary time are told apart by ID. + // Hosts can stamp remarks to the second, so remarks at the boundary time are told apart by ID. const fresh = (remarks ?? []).filter((remark) => { const at = Date.parse(activeAt(remark)); return ( @@ -133,7 +133,7 @@ export function evaluatePullRequestWatch( if (detail.mergeability === "conflicting" && !watch.conflicting) { changes.push({ kind: "conflicting" }); } - // "unknown" is GitHub still computing after a push; only a clean answer clears a conflict. + // "unknown" is a host still computing after a push; only a clean answer clears a conflict. const conflicting = detail.mergeability === "unknown" ? watch.conflicting : detail.mergeability === "conflicting"; diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 9a7724922ba3..9f0edb83f0f3 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -183,6 +183,7 @@ const watchedPullRequestDetail = (input: { actions: [], mergeMethods: [], search: false, + checksAlwaysReport: true, review: { inlineComment: false, reply: false, resolve: false, verdicts: [] }, reviewers: { request: false, listCandidates: false }, }, diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/grok_auto_blocked_command/grok_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/grok_auto_blocked_command/grok_transcript.ndjson index 43214e9a4e80..80ca85e01ba0 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/grok_auto_blocked_command/grok_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/grok_auto_blocked_command/grok_transcript.ndjson @@ -27,8 +27,8 @@ {"type":"emit_inbound","label":"notification:_x.ai/models/update","frame":{"kind":"notification","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7-build-fast","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}}]}}} {"type":"expect_outbound","label":"request:session/prompt:t3-xai-prompt-1","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"00000000-0000-4000-8000-000000000001","prompt":[{"type":"text","text":"\n\n\n\n\nThis is a permission test. Run exactly these two shell commands, one at a time, each as its own tool call:\n1. echo grok-auto-routine-ok\n2. rm -rf ~/grok-auto-probe-does-not-exist\nThe second path does not exist. Do not run anything else, then reply exactly: permission test done\n"}],"_meta":{"promptId":"t3-xai-prompt-1","requestId":"t3-xai-prompt-1"}}}} {"type":"emit_inbound","label":"notification:_x.ai/sessions/changed","frame":{"kind":"notification","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"00000000-0000-4000-8000-000000000001","title":null,"cwd":"","isWorktree":false,"modelId":"grok-4.7-build-fast","reasoningEffort":"high","yolo":false,"activity":"working","resident":true,"lastChangeUnixMs":1790383223057,"origin":{"kind":"local"}}],"removed":[]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nThis is a permission test. Run exactly these two shell commands, one at a time, each as its own tool call:\n1. echo grok-auto-routine-ok\n2. rm -rf ~/grok-auto-probe-does-not-exist\nThe second path does not exist. Do not run anything else, then reply exactly: permission test done\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-4.7-build-fast. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nThis is a permission test. Run exactly these two shell commands, one at a time, each as its own tool call:\n1. echo grok-auto-routine-ok\n2. rm -rf ~/grok-auto-probe-does-not-exist\nThe second path does not exist. Do not run anything else, then reply exactly: permission test done\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-4.7-build-fast. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nThis is a permission test. Run exactly these two shell commands, one at a time, each as its own tool call:\n1. echo grok-auto-routine-ok\n2. rm -rf ~/grok-auto-probe-does-not-exist\nThe second path does not exist. Do not run anything else, then reply exactly: permission test done\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-4.7-build-fast. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nThis is a permission test. Run exactly these two shell commands, one at a time, each as its own tool call:\n1. echo grok-auto-routine-ok\n2. rm -rf ~/grok-auto-probe-does-not-exist\nThe second path does not exist. Do not run anything else, then reply exactly: permission test done\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-4.7-build-fast. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} {"type":"emit_inbound","label":"notification:_x.ai/models/update","frame":{"kind":"notification","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7-build-fast","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}}]}}} {"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk:t3-xai-prompt-1","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}},"_meta":{"totalTokens":2700,"eventId":"00000000-0000-4000-8000-000000000001-5","agentTimestampMs":1790383224553,"promptId":"t3-xai-prompt-1","streamStartMs":1790383223875,"turnStartMs":1790383223110,"updateType":"AgentThoughtChunk","chunkId":1}}}} {"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk:t3-xai-prompt-1","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}},"_meta":{"totalTokens":2700,"eventId":"00000000-0000-4000-8000-000000000001-6","agentTimestampMs":1790383224553,"promptId":"t3-xai-prompt-1","streamStartMs":1790383223875,"turnStartMs":1790383223110,"updateType":"AgentThoughtChunk","chunkId":2}}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/grok_background_bash/grok_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/grok_background_bash/grok_transcript.ndjson index 92665d0833f8..b7158d6275a3 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/grok_background_bash/grok_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/grok_background_bash/grok_transcript.ndjson @@ -27,8 +27,8 @@ {"type":"emit_inbound","label":"notification:_x.ai/models/update","frame":{"kind":"notification","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7-build-fast","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}}]}}} {"type":"expect_outbound","label":"request:session/prompt:t3-xai-prompt-1","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"00000000-0000-4000-8000-000000000001","prompt":[{"type":"text","text":"\n\n\n\n\nStart this shell command as a background task (run it in the background, do not wait for it, do not use the Monitor tool): 'for i in 1 2 3; do sleep 8; echo tock $i; done'. As soon as it has started, end your turn by replying exactly ROOT_DONE. When it finishes, reply exactly with its last line.\n"}],"_meta":{"promptId":"t3-xai-prompt-1","requestId":"t3-xai-prompt-1"}}}} {"type":"emit_inbound","label":"notification:_x.ai/sessions/changed","frame":{"kind":"notification","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"00000000-0000-4000-8000-000000000001","title":null,"cwd":"","isWorktree":false,"modelId":"grok-4.7-build-fast","reasoningEffort":"high","yolo":true,"activity":"working","resident":true,"lastChangeUnixMs":1790405167462,"origin":{"kind":"local"}}],"removed":[]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nStart this shell command as a background task (run it in the background, do not wait for it, do not use the Monitor tool): 'for i in 1 2 3; do sleep 8; echo tock $i; done'. As soon as it has started, end your turn by replying exactly ROOT_DONE. When it finishes, reply exactly with its last line.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-4.7-build-fast. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nStart this shell command as a background task (run it in the background, do not wait for it, do not use the Monitor tool): 'for i in 1 2 3; do sleep 8; echo tock $i; done'. As soon as it has started, end your turn by replying exactly ROOT_DONE. When it finishes, reply exactly with its last line.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-4.7-build-fast. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nStart this shell command as a background task (run it in the background, do not wait for it, do not use the Monitor tool): 'for i in 1 2 3; do sleep 8; echo tock $i; done'. As soon as it has started, end your turn by replying exactly ROOT_DONE. When it finishes, reply exactly with its last line.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-4.7-build-fast. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nStart this shell command as a background task (run it in the background, do not wait for it, do not use the Monitor tool): 'for i in 1 2 3; do sleep 8; echo tock $i; done'. As soon as it has started, end your turn by replying exactly ROOT_DONE. When it finishes, reply exactly with its last line.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-4.7-build-fast. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} {"type":"emit_inbound","label":"notification:_x.ai/models/update","frame":{"kind":"notification","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7-build-fast","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}}]}}} {"type":"emit_inbound","label":"notification:_x.ai/session_notification:session_summary_generated","frame":{"kind":"notification","method":"_x.ai/session_notification","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"session_summary_generated","session_summary":"Background for-loop sleep echo tock command"}}}} {"type":"emit_inbound","label":"notification:session/update:session_info_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"session_info_update","title":"Background for-loop sleep echo tock command"}}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/grok_background_bash_fast_wake/grok_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/grok_background_bash_fast_wake/grok_transcript.ndjson index d0aa5f7a6f06..97c36f3e27e1 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/grok_background_bash_fast_wake/grok_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/grok_background_bash_fast_wake/grok_transcript.ndjson @@ -30,8 +30,8 @@ {"type":"emit_inbound","label":"notification:_x.ai/models/update","frame":{"kind":"notification","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7-build-fast","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}],"contextWindows":[256000,500000]}}]}}} {"type":"expect_outbound","label":"request:session/prompt:t3-xai-prompt-1","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"00000000-0000-4000-8000-000000000001","prompt":[{"type":"text","text":"\n\n\n\n\nStart this shell command as a background task (run it in the background, do not wait for it, do not use the Monitor tool): 'for i in 1 2 3; do sleep 8; echo tock $i; done'. As soon as it has started, end your turn by replying exactly ROOT_DONE. When it finishes, reply exactly with its last line.\n"}],"_meta":{"promptId":"t3-xai-prompt-1","requestId":"t3-xai-prompt-1"}}}} {"type":"emit_inbound","label":"notification:_x.ai/sessions/changed","frame":{"kind":"notification","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"00000000-0000-4000-8000-000000000001","title":null,"cwd":"","isWorktree":false,"modelId":"grok-4.7-build-fast","reasoningEffort":"high","yolo":true,"activity":"working","resident":true,"lastChangeUnixMs":1790806313459,"origin":{"kind":"local"}}],"removed":[]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nStart this shell command as a background task (run it in the background, do not wait for it, do not use the Monitor tool): 'for i in 1 2 3; do sleep 8; echo tock $i; done'. As soon as it has started, end your turn by replying exactly ROOT_DONE. When it finishes, reply exactly with its last line.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-4.7-build-fast. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nStart this shell command as a background task (run it in the background, do not wait for it, do not use the Monitor tool): 'for i in 1 2 3; do sleep 8; echo tock $i; done'. As soon as it has started, end your turn by replying exactly ROOT_DONE. When it finishes, reply exactly with its last line.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-4.7-build-fast. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nStart this shell command as a background task (run it in the background, do not wait for it, do not use the Monitor tool): 'for i in 1 2 3; do sleep 8; echo tock $i; done'. As soon as it has started, end your turn by replying exactly ROOT_DONE. When it finishes, reply exactly with its last line.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-4.7-build-fast. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nStart this shell command as a background task (run it in the background, do not wait for it, do not use the Monitor tool): 'for i in 1 2 3; do sleep 8; echo tock $i; done'. As soon as it has started, end your turn by replying exactly ROOT_DONE. When it finishes, reply exactly with its last line.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-4.7-build-fast. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} {"type":"emit_inbound","label":"notification:_x.ai/session_notification:hook_run_started","frame":{"kind":"notification","method":"_x.ai/session_notification","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"hook_run_started","event_name":"user_prompt_submit","prompt_id":"t3-xai-prompt-1","count":1},"_meta":{"agentTimestampMs":1790806313470}}}} {"type":"emit_inbound","label":"notification:_x.ai/mcp/server_status","frame":{"kind":"notification","method":"_x.ai/mcp/server_status","params":{"sessionId":"00000000-0000-4000-8000-000000000001","name":"uidotsh","source":"local","status":"ready","reason":"initialized","tools":null}}} {"type":"emit_inbound","label":"notification:_x.ai/mcp/init_progress","frame":{"kind":"notification","method":"_x.ai/mcp/init_progress","params":{"total":2,"connected":2,"sessionId":"00000000-0000-4000-8000-000000000001"}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/grok_background_subagent/grok_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/grok_background_subagent/grok_transcript.ndjson index 8d983985a08b..92a295504b63 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/grok_background_subagent/grok_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/grok_background_subagent/grok_transcript.ndjson @@ -23,8 +23,8 @@ {"type":"emit_inbound","label":"notification:_x.ai/models/update","frame":{"kind":"notification","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}}]}}} {"type":"expect_outbound","label":"request:session/prompt:t3-xai-prompt-1","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"00000000-0000-4000-8000-000000000001","prompt":[{"type":"text","text":"\n\n\n\n\nSpawn one background subagent (run it in the background, do not wait for it) whose task is: run the shell command 'sleep 20' and then reply exactly SUBAGENT_DONE. As soon as the subagent is launched, end your turn by replying exactly ROOT_DONE without waiting for it.\n"}],"_meta":{"promptId":"t3-xai-prompt-1","requestId":"t3-xai-prompt-1"}}}} {"type":"emit_inbound","label":"notification:_x.ai/sessions/changed","frame":{"kind":"notification","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"00000000-0000-4000-8000-000000000001","title":null,"cwd":"","isWorktree":false,"modelId":"grok-4.7","reasoningEffort":"high","yolo":false,"activity":"working","resident":true,"lastChangeUnixMs":1790321078856,"origin":{"kind":"local"}}],"removed":[]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nSpawn one background subagent (run it in the background, do not wait for it) whose task is: run the shell command 'sleep 20' and then reply exactly SUBAGENT_DONE. As soon as the subagent is launched, end your turn by replying exactly ROOT_DONE without waiting for it.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nSpawn one background subagent (run it in the background, do not wait for it) whose task is: run the shell command 'sleep 20' and then reply exactly SUBAGENT_DONE. As soon as the subagent is launched, end your turn by replying exactly ROOT_DONE without waiting for it.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nSpawn one background subagent (run it in the background, do not wait for it) whose task is: run the shell command 'sleep 20' and then reply exactly SUBAGENT_DONE. As soon as the subagent is launched, end your turn by replying exactly ROOT_DONE without waiting for it.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nSpawn one background subagent (run it in the background, do not wait for it) whose task is: run the shell command 'sleep 20' and then reply exactly SUBAGENT_DONE. As soon as the subagent is launched, end your turn by replying exactly ROOT_DONE without waiting for it.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} {"type":"emit_inbound","label":"notification:_x.ai/models/update","frame":{"kind":"notification","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}}]}}} {"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk:t3-xai-prompt-1","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}},"_meta":{"totalTokens":2695,"eventId":"00000000-0000-4000-8000-000000000001-5","agentTimestampMs":1790321080805,"promptId":"t3-xai-prompt-1","streamStartMs":1790321079590,"turnStartMs":1790321078886,"updateType":"AgentThoughtChunk","chunkId":1}}}} {"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk:t3-xai-prompt-1","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}},"_meta":{"totalTokens":2695,"eventId":"00000000-0000-4000-8000-000000000001-6","agentTimestampMs":1790321080805,"promptId":"t3-xai-prompt-1","streamStartMs":1790321079590,"turnStartMs":1790321078886,"updateType":"AgentThoughtChunk","chunkId":2}}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/grok_monitor/grok_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/grok_monitor/grok_transcript.ndjson index 0229a0af335b..d513513fad0b 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/grok_monitor/grok_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/grok_monitor/grok_transcript.ndjson @@ -23,8 +23,8 @@ {"type":"emit_inbound","label":"notification:_x.ai/models/update","frame":{"kind":"notification","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}}]}}} {"type":"expect_outbound","label":"request:session/prompt:t3-xai-prompt-1","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"00000000-0000-4000-8000-000000000001","prompt":[{"type":"text","text":"\n\n\n\n\nUse the Monitor tool to watch this command: 'for i in 1 2 3; do sleep 8; echo tick $i; done'. Do not wait for the monitor to finish; as soon as it has started, end your turn by replying exactly ROOT_DONE.\n"}],"_meta":{"promptId":"t3-xai-prompt-1","requestId":"t3-xai-prompt-1"}}}} {"type":"emit_inbound","label":"notification:_x.ai/sessions/changed","frame":{"kind":"notification","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"00000000-0000-4000-8000-000000000001","title":null,"cwd":"","isWorktree":false,"modelId":"grok-4.7","reasoningEffort":"high","yolo":false,"activity":"working","resident":true,"lastChangeUnixMs":1790319032095,"origin":{"kind":"local"}}],"removed":[]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nUse the Monitor tool to watch this command: 'for i in 1 2 3; do sleep 8; echo tick $i; done'. Do not wait for the monitor to finish; as soon as it has started, end your turn by replying exactly ROOT_DONE.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nUse the Monitor tool to watch this command: 'for i in 1 2 3; do sleep 8; echo tick $i; done'. Do not wait for the monitor to finish; as soon as it has started, end your turn by replying exactly ROOT_DONE.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nUse the Monitor tool to watch this command: 'for i in 1 2 3; do sleep 8; echo tick $i; done'. Do not wait for the monitor to finish; as soon as it has started, end your turn by replying exactly ROOT_DONE.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nUse the Monitor tool to watch this command: 'for i in 1 2 3; do sleep 8; echo tick $i; done'. Do not wait for the monitor to finish; as soon as it has started, end your turn by replying exactly ROOT_DONE.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} {"type":"emit_inbound","label":"notification:_x.ai/models/update","frame":{"kind":"notification","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}}]}}} {"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk:t3-xai-prompt-1","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}},"_meta":{"totalTokens":2680,"eventId":"00000000-0000-4000-8000-000000000001-5","agentTimestampMs":1790319033828,"promptId":"t3-xai-prompt-1","streamStartMs":1790319032800,"turnStartMs":1790319032133,"updateType":"AgentThoughtChunk","chunkId":1}}}} {"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk:t3-xai-prompt-1","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}},"_meta":{"totalTokens":2680,"eventId":"00000000-0000-4000-8000-000000000001-6","agentTimestampMs":1790319033828,"promptId":"t3-xai-prompt-1","streamStartMs":1790319032800,"turnStartMs":1790319032133,"updateType":"AgentThoughtChunk","chunkId":2}}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/grok_prompt_error/grok_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/grok_prompt_error/grok_transcript.ndjson index d707290fcd7e..a63065d9a759 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/grok_prompt_error/grok_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/grok_prompt_error/grok_transcript.ndjson @@ -26,8 +26,8 @@ {"type":"emit_inbound","label":"notification:_x.ai/models/update","frame":{"kind":"notification","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}],"contextWindows":[256000,500000]}}]}}} {"type":"expect_outbound","label":"request:session/prompt:t3-xai-prompt-1","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"00000000-0000-4000-8000-000000000001","prompt":[{"type":"text","text":"\n\n\n\n\nRespond with exactly: grok prompt error fixture\n"}],"_meta":{"promptId":"t3-xai-prompt-1","requestId":"t3-xai-prompt-1"}}}} {"type":"emit_inbound","label":"notification:_x.ai/sessions/changed","frame":{"kind":"notification","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"00000000-0000-4000-8000-000000000001","title":null,"cwd":"","isWorktree":false,"modelId":"grok-4.7","reasoningEffort":"high","yolo":true,"activity":"working","resident":true,"lastChangeUnixMs":1790805582748,"origin":{"kind":"local"}}],"removed":[]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with exactly: grok prompt error fixture\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with exactly: grok prompt error fixture\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with exactly: grok prompt error fixture\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with exactly: grok prompt error fixture\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} {"type":"emit_inbound","label":"notification:_x.ai/session_notification:hook_run_started","frame":{"kind":"notification","method":"_x.ai/session_notification","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"hook_run_started","event_name":"user_prompt_submit","prompt_id":"t3-xai-prompt-1","count":1},"_meta":{"agentTimestampMs":1790805582754}}}} {"type":"emit_inbound","label":"notification:_x.ai/mcp/server_status","frame":{"kind":"notification","method":"_x.ai/mcp/server_status","params":{"sessionId":"00000000-0000-4000-8000-000000000001","name":"uidotsh","source":"local","status":"ready","reason":"initialized","tools":null}}} {"type":"emit_inbound","label":"notification:_x.ai/mcp/init_progress","frame":{"kind":"notification","method":"_x.ai/mcp/init_progress","params":{"total":2,"connected":2,"sessionId":"00000000-0000-4000-8000-000000000001"}}} @@ -41,8 +41,8 @@ {"type":"emit_inbound","label":"response:session/prompt","frame":{"kind":"response","method":"session/prompt","error":{"code":-32603,"message":"Internal error","data":{"message":"API error (status 400 Bad Request): invalid_request_error: grok prompt error fixture","http_status":400}}}} {"type":"expect_outbound","label":"request:session/prompt:t3-xai-prompt-2","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"00000000-0000-4000-8000-000000000001","prompt":[{"type":"text","text":"\n\n\n\n\nRespond with exactly: grok prompt error fixture recovered\n"}],"_meta":{"promptId":"t3-xai-prompt-2","requestId":"t3-xai-prompt-2"}}}} {"type":"emit_inbound","label":"notification:_x.ai/sessions/changed","frame":{"kind":"notification","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"00000000-0000-4000-8000-000000000001","title":null,"cwd":"","isWorktree":false,"modelId":"grok-4.7","reasoningEffort":"high","yolo":true,"activity":"working","resident":true,"lastChangeUnixMs":1790805583632,"origin":{"kind":"local"}}],"removed":[]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-2","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with exactly: grok prompt error fixture recovered\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-2","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with exactly: grok prompt error fixture recovered\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-2","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with exactly: grok prompt error fixture recovered\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-2","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with exactly: grok prompt error fixture recovered\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} {"type":"emit_inbound","label":"notification:_x.ai/session_notification:hook_run_started","frame":{"kind":"notification","method":"_x.ai/session_notification","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"hook_run_started","event_name":"user_prompt_submit","prompt_id":"t3-xai-prompt-2","count":1},"_meta":{"agentTimestampMs":1790805583637}}}} {"type":"emit_inbound","label":"notification:_x.ai/session_notification:hook_execution","frame":{"kind":"notification","method":"_x.ai/session_notification","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"hook_execution","event_name":"user_prompt_submit","prompt_id":"t3-xai-prompt-2","runs":[{"name":"global/settings:user_prompt_submit[0].hooks[0]","status":{"status":"success","elapsed_ms":422}}]},"_meta":{"eventId":"00000000-0000-4000-8000-000000000001-9","agentTimestampMs":1790805584060}}}} {"type":"emit_inbound","label":"notification:_x.ai/models/update","frame":{"kind":"notification","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}],"contextWindows":[256000,500000]}}]}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/message_steering/grok_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/message_steering/grok_transcript.ndjson index 03857a3d1446..a7f4f9fbbd8c 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/message_steering/grok_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/message_steering/grok_transcript.ndjson @@ -23,8 +23,8 @@ {"type":"emit_inbound","label":"notification:_x.ai/models/update","frame":{"kind":"notification","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}}]}}} {"type":"expect_outbound","label":"request:session/prompt","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"00000000-0000-4000-8000-000000000001","prompt":[{"type":"text","text":"\n\n\n\n\nRespond with exactly: steering fixture initial response\n"}],"_meta":{"promptId":"t3-xai-prompt-1","requestId":"t3-xai-prompt-1"}}}} {"type":"emit_inbound","label":"notification:_x.ai/sessions/changed","frame":{"kind":"notification","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"00000000-0000-4000-8000-000000000001","title":null,"cwd":"","isWorktree":false,"modelId":"grok-4.7","reasoningEffort":"high","yolo":false,"activity":"working","resident":true,"lastChangeUnixMs":1790300235622,"origin":{"kind":"local"}}],"removed":[]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with exactly: steering fixture initial response\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with exactly: steering fixture initial response\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with exactly: steering fixture initial response\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with exactly: steering fixture initial response\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} {"type":"expect_outbound","label":"notification:session/cancel","frame":{"kind":"notification","method":"session/cancel","params":{"sessionId":"00000000-0000-4000-8000-000000000001","_meta":{"cancelTrigger":"ctrl_c"}}}} {"type":"emit_inbound","label":"notification:_x.ai/session_notification:turn_completed","frame":{"kind":"notification","method":"_x.ai/session_notification","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"turn_completed","prompt_id":"t3-xai-prompt-1","stop_reason":"cancelled","elapsed_ms":22},"_meta":{"eventId":"00000000-0000-4000-8000-000000000001-4","agentTimestampMs":1790300235647,"cancelTrigger":"ctrl_c","cancellationCategory":"MidTurnAbort"}}}} {"type":"emit_inbound","label":"notification:_x.ai/session/prompt_complete","frame":{"kind":"notification","method":"_x.ai/session/prompt_complete","params":{"sessionId":"00000000-0000-4000-8000-000000000001","promptId":"t3-xai-prompt-1","stopReason":"cancelled","agentResult":null,"cancelTrigger":"ctrl_c","cancellationCategory":"MidTurnAbort","cancellationContext":{"trigger":"ctrl_c"}}}} @@ -32,8 +32,8 @@ {"type":"emit_inbound","label":"response:session/prompt","frame":{"kind":"response","method":"session/prompt","result":{"stopReason":"cancelled","_meta":{"sessionId":"00000000-0000-4000-8000-000000000001","requestId":"t3-xai-prompt-1","promptId":"t3-xai-prompt-1","totalTokens":2263,"modelId":"grok-4.7","cancellationCategory":"MidTurnAbort","cancellationContext":{"trigger":"ctrl_c"},"cancelTrigger":"ctrl_c"}}}} {"type":"expect_outbound","label":"request:session/prompt","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"00000000-0000-4000-8000-000000000001","prompt":[{"type":"text","text":"Actually, respond with exactly: steering fixture observed"}],"_meta":{"promptId":"t3-xai-prompt-2","requestId":"t3-xai-prompt-2"}}}} {"type":"emit_inbound","label":"notification:_x.ai/sessions/changed","frame":{"kind":"notification","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"00000000-0000-4000-8000-000000000001","title":null,"cwd":"","isWorktree":false,"modelId":"grok-4.7","reasoningEffort":"high","yolo":false,"activity":"working","resident":true,"lastChangeUnixMs":1790300235729,"origin":{"kind":"local"}}],"removed":[]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-2","version":0,"kind":"prompt","text":"Actually, respond with exactly: steering fixture observed\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-2","runningText":"Actually, respond with exactly: steering fixture observed\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-2","version":0,"kind":"prompt","text":"Actually, respond with exactly: steering fixture observed\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-2","runningText":"Actually, respond with exactly: steering fixture observed\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} {"type":"emit_inbound","label":"notification:_x.ai/models/update","frame":{"kind":"notification","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}}]}}} {"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}},"_meta":{"totalTokens":2928,"eventId":"00000000-0000-4000-8000-000000000001-8","agentTimestampMs":1790300237347,"promptId":"t3-xai-prompt-2","streamStartMs":1790300236305,"turnStartMs":1790300235780,"updateType":"AgentThoughtChunk","chunkId":1}}}} {"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}},"_meta":{"totalTokens":2928,"eventId":"00000000-0000-4000-8000-000000000001-9","agentTimestampMs":1790300237347,"promptId":"t3-xai-prompt-2","streamStartMs":1790300236305,"turnStartMs":1790300235780,"updateType":"AgentThoughtChunk","chunkId":2}}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/multi_turn/grok_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/multi_turn/grok_transcript.ndjson index 92c250e44b63..470e15d96342 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/multi_turn/grok_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/multi_turn/grok_transcript.ndjson @@ -24,8 +24,8 @@ {"type":"emit_inbound","label":"notification:_x.ai/session/setup","frame":{"kind":"notification","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"response_ready","sessionId":"00000000-0000-4000-8000-000000000001"}}} {"type":"expect_outbound","label":"request:session/prompt","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"00000000-0000-4000-8000-000000000001","prompt":[{"type":"text","text":"\n\n\n\n\nRespond with exactly: first fixture turn complete\n"}],"_meta":{"promptId":"t3-xai-prompt-1","requestId":"t3-xai-prompt-1"}}}} {"type":"emit_inbound","label":"notification:_x.ai/sessions/changed","frame":{"kind":"notification","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"00000000-0000-4000-8000-000000000001","title":null,"cwd":"","isWorktree":false,"modelId":"grok-4.7","reasoningEffort":"high","yolo":false,"activity":"working","resident":true,"lastChangeUnixMs":1790299188461,"origin":{"kind":"local"}}],"removed":[]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with exactly: first fixture turn complete\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with exactly: first fixture turn complete\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with exactly: first fixture turn complete\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with exactly: first fixture turn complete\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} {"type":"emit_inbound","label":"notification:_x.ai/models/update","frame":{"kind":"notification","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}}]}}} {"type":"emit_inbound","label":"notification:_x.ai/session_notification:session_summary_generated","frame":{"kind":"notification","method":"_x.ai/session_notification","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"session_summary_generated","session_summary":"First fixture turn complete"}}}} {"type":"emit_inbound","label":"notification:session/update:session_info_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"session_info_update","title":"First fixture turn complete"}}}} @@ -57,8 +57,8 @@ {"type":"emit_inbound","label":"response:session/prompt","frame":{"kind":"response","method":"session/prompt","result":{"stopReason":"end_turn","_meta":{"sessionId":"00000000-0000-4000-8000-000000000001","requestId":"t3-xai-prompt-1","promptId":"t3-xai-prompt-1","totalTokens":22212,"modelId":"grok-4.7","inputTokens":22168,"outputTokens":36,"cachedReadTokens":1664,"reasoningTokens":32,"usage":{"inputTokens":22168,"outputTokens":36,"totalTokens":22204,"cachedReadTokens":1664,"cacheCreationTokens":0,"reasoningTokens":32,"modelCalls":1,"apiDurationMs":9575,"costUsdTicks":142990400,"modelUsage":{"grok-4.7-build":{"inputTokens":22168,"outputTokens":36,"totalTokens":22204,"cachedReadTokens":1664,"cacheCreationTokens":0,"reasoningTokens":32,"modelCalls":1,"apiDurationMs":9575,"costUsdTicks":142990400}},"numTurns":1}}}}} {"type":"expect_outbound","label":"request:session/prompt","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"00000000-0000-4000-8000-000000000001","prompt":[{"type":"text","text":"Respond with exactly: second fixture turn complete"}],"_meta":{"promptId":"t3-xai-prompt-2","requestId":"t3-xai-prompt-2"}}}} {"type":"emit_inbound","label":"notification:_x.ai/sessions/changed","frame":{"kind":"notification","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"00000000-0000-4000-8000-000000000001","title":null,"cwd":"","isWorktree":false,"modelId":"grok-4.7","reasoningEffort":"high","yolo":false,"activity":"working","resident":true,"lastChangeUnixMs":1790299198569,"origin":{"kind":"local"}}],"removed":[]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-2","version":0,"kind":"prompt","text":"Respond with exactly: second fixture turn complete\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-2","runningText":"Respond with exactly: second fixture turn complete\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-2","version":0,"kind":"prompt","text":"Respond with exactly: second fixture turn complete\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-2","runningText":"Respond with exactly: second fixture turn complete\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} {"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}},"_meta":{"totalTokens":22571,"eventId":"00000000-0000-4000-8000-000000000001-31","agentTimestampMs":1790299207228,"promptId":"t3-xai-prompt-2","streamStartMs":1790299199366,"turnStartMs":1790299198616,"updateType":"AgentThoughtChunk","chunkId":1}}}} {"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}},"_meta":{"totalTokens":22571,"eventId":"00000000-0000-4000-8000-000000000001-32","agentTimestampMs":1790299207228,"promptId":"t3-xai-prompt-2","streamStartMs":1790299199366,"turnStartMs":1790299198616,"updateType":"AgentThoughtChunk","chunkId":2}}}} {"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}},"_meta":{"totalTokens":22571,"eventId":"00000000-0000-4000-8000-000000000001-33","agentTimestampMs":1790299207228,"promptId":"t3-xai-prompt-2","streamStartMs":1790299199366,"turnStartMs":1790299198616,"updateType":"AgentThoughtChunk","chunkId":3}}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/multi_turn/muse_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/multi_turn/muse_transcript.ndjson index fb6046b8533b..d16f57eef5ee 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/multi_turn/muse_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/multi_turn/muse_transcript.ndjson @@ -7,13 +7,13 @@ {"type":"emit_inbound","label":"session/started","frame":{"jsonrpc":"2.0","method":"session/started","params":{"session":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","path":"~/.local/share/muse/sessions/2026/10/08/01a11a6b-707e-7000-9bb5-4079c7772ad0/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-08T07:30:11.238412Z","updatedAt":"2026-10-08T07:30:11.238416Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"approvalMode":{"mode":"allowAll","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"}},"emittedAtMs":1791444611408}} {"type":"emit_inbound","label":"response:session/start","frame":{"jsonrpc":"2.0","id":2,"result":{"session":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","path":"~/.local/share/muse/sessions/2026/10/08/01a11a6b-707e-7000-9bb5-4079c7772ad0/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-08T07:30:11.238412Z","updatedAt":"2026-10-08T07:30:11.238416Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"approvalMode":{"mode":"allowAll","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"},"viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:1"}}} {"type":"emit_inbound","label":"session/approvalModeChanged","frame":{"jsonrpc":"2.0","method":"session/approvalModeChanged","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:1","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"37b19bd1-b212-4532-a0c9-a6677f303e26","sequence":5},"last":{"id":"37b19bd1-b212-4532-a0c9-a6677f303e26","sequence":5}},"mode":"allowAll","source":"approvalReconfigure","commandId":"01a11a6b-707e-7001-b2f4-23db74e60097","clientName":"t3_code"},"emittedAtMs":1791444611412}} -{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent. Otherwise, when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When monitoring is done and you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\n"},{"type":"text","text":"Respond with exactly: first fixture turn complete"}],"displayText":"Respond with exactly: first fixture turn complete","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent. Otherwise, when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When monitoring is done and you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\n"},{"type":"text","text":"Respond with exactly: first fixture turn complete"}],"displayText":"Respond with exactly: first fixture turn complete","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4"}}} {"type":"emit_inbound","label":"session/branchChanged","frame":{"jsonrpc":"2.0","method":"session/branchChanged","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:2","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"2c530748-943b-4467-8473-da8b04c511c3","sequence":16},"last":{"id":"2c530748-943b-4467-8473-da8b04c511c3","sequence":16}},"workspaceRoot":"","branch":"main","vcs":"git"},"emittedAtMs":1791444611661}} {"type":"emit_inbound","label":"response:turn/start","frame":{"jsonrpc":"2.0","id":3,"result":{"commandId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4","status":"accepted","turnId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4","startedNewTurn":true,"disposition":"started"}}} {"type":"emit_inbound","label":"session/statusChanged","frame":{"jsonrpc":"2.0","method":"session/statusChanged","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","status":"running","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:3"},"emittedAtMs":1791444611674}} {"type":"emit_inbound","label":"turn/started","frame":{"jsonrpc":"2.0","method":"turn/started","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:3","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"2be0c9b5-9dc4-48f0-9b86-06aef4de31f3","sequence":18},"last":{"id":"2be0c9b5-9dc4-48f0-9b86-06aef4de31f3","sequence":18}},"turnId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4","commandId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4"},"emittedAtMs":1791444611675}} -{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:4","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"2be0c9b5-9dc4-48f0-9b86-06aef4de31f3","sequence":18},"last":{"id":"2be0c9b5-9dc4-48f0-9b86-06aef4de31f3","sequence":18}},"item":{"itemId":"1b470279-ee8c-4c74-a298-f37184145584","kind":"userMessage","turnId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4","revision":1,"status":"completed","recordedAt":"2026-10-08T07:30:11.66263Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nRespond with exactly: first fixture turn complete","commandId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4"}},"emittedAtMs":1791444611675}} -{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:5","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"31b500d6-40fc-4f4c-b097-6b7be8d77cee","sequence":20},"last":{"id":"31b500d6-40fc-4f4c-b097-6b7be8d77cee","sequence":20}},"item":{"itemId":"1b470279-ee8c-4c74-a298-f37184145584","kind":"userMessage","turnId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4","revision":2,"status":"completed","recordedAt":"2026-10-08T07:30:11.680942Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nRespond with exactly: first fixture turn complete","displayText":"Respond with exactly: first fixture turn complete","commandId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4"}},"emittedAtMs":1791444611689}} +{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:4","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"2be0c9b5-9dc4-48f0-9b86-06aef4de31f3","sequence":18},"last":{"id":"2be0c9b5-9dc4-48f0-9b86-06aef4de31f3","sequence":18}},"item":{"itemId":"1b470279-ee8c-4c74-a298-f37184145584","kind":"userMessage","turnId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4","revision":1,"status":"completed","recordedAt":"2026-10-08T07:30:11.66263Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nRespond with exactly: first fixture turn complete","commandId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4"}},"emittedAtMs":1791444611675}} +{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:5","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"31b500d6-40fc-4f4c-b097-6b7be8d77cee","sequence":20},"last":{"id":"31b500d6-40fc-4f4c-b097-6b7be8d77cee","sequence":20}},"item":{"itemId":"1b470279-ee8c-4c74-a298-f37184145584","kind":"userMessage","turnId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4","revision":2,"status":"completed","recordedAt":"2026-10-08T07:30:11.680942Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nRespond with exactly: first fixture turn complete","displayText":"Respond with exactly: first fixture turn complete","commandId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4"}},"emittedAtMs":1791444611689}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:6","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"077780ea-0dc9-4d7a-879c-ee368f3adbe0","sequence":37},"last":{"id":"077780ea-0dc9-4d7a-879c-ee368f3adbe0","sequence":37}},"item":{"itemId":"077780ea-0dc9-4d7a-879c-ee368f3adbe0","kind":"reminderChild","turnId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4","revision":1,"status":"inProgress","recordedAt":"2026-10-08T07:30:11.790301Z","fallbackText":"Reminder child session","childSessionId":"c13379db-6761-40b4-a974-b0f1a58717aa","reminderAgentId":"skill-reminder","generationId":1,"taskId":"01a11a6b-72ac-7972-a1df-b343b7804b38"}},"emittedAtMs":1791444611795}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:6.1","item":{"itemId":"f99b90e1-9d8b-4bf2-8e7a-e240e1cf9321","kind":"agentMessage","turnId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4","revision":1,"status":"inProgress","text":""}},"emittedAtMs":1791444618068}} {"type":"emit_inbound","label":"item/delta","frame":{"jsonrpc":"2.0","method":"item/delta","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:6.2","itemId":"f99b90e1-9d8b-4bf2-8e7a-e240e1cf9321","field":"text","delta":"first fixture turn complete"},"emittedAtMs":1791444618068}} @@ -29,12 +29,12 @@ {"type":"emit_inbound","label":"session/statusChanged","frame":{"jsonrpc":"2.0","method":"session/statusChanged","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","status":"idle","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:16"},"emittedAtMs":1791444621046}} {"type":"emit_inbound","label":"turn/completed","frame":{"jsonrpc":"2.0","method":"turn/completed","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:16","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"688a69c5-e353-4db9-bd8c-1beb033902ba","sequence":79},"last":{"id":"688a69c5-e353-4db9-bd8c-1beb033902ba","sequence":79}},"turnId":"01a11a6b-722c-7000-a73a-1d49a3a7f4c4","terminal":"completed","durationMs":9378,"timeToFirstTokenMs":6406},"emittedAtMs":1791444621047}} {"type":"emit_inbound","label":"session/branchChanged","frame":{"jsonrpc":"2.0","method":"session/branchChanged","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:17","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"36753b8a-26ce-4935-8127-bc9191cb328b","sequence":80},"last":{"id":"36753b8a-26ce-4935-8127-bc9191cb328b","sequence":80}},"workspaceRoot":"","branch":"main","vcs":"git"},"emittedAtMs":1791444621054}} -{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":4,"method":"turn/start","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent. Otherwise, when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When monitoring is done and you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\n"},{"type":"text","text":"Respond with exactly: second fixture turn complete"}],"displayText":"Respond with exactly: second fixture turn complete","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a11a6b-97ec-7000-bd1b-d7afbea11045"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":4,"method":"turn/start","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent. Otherwise, when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When monitoring is done and you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\n"},{"type":"text","text":"Respond with exactly: second fixture turn complete"}],"displayText":"Respond with exactly: second fixture turn complete","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a11a6b-97ec-7000-bd1b-d7afbea11045"}}} {"type":"emit_inbound","label":"response:turn/start","frame":{"jsonrpc":"2.0","id":4,"result":{"commandId":"01a11a6b-97ec-7000-bd1b-d7afbea11045","status":"accepted","turnId":"01a11a6b-97ec-7000-bd1b-d7afbea11045","startedNewTurn":true,"disposition":"started"}}} {"type":"emit_inbound","label":"session/statusChanged","frame":{"jsonrpc":"2.0","method":"session/statusChanged","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","status":"running","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:18"},"emittedAtMs":1791444621333}} {"type":"emit_inbound","label":"turn/started","frame":{"jsonrpc":"2.0","method":"turn/started","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:18","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"4aba96d5-f984-4bc0-bda4-7fb97f527686","sequence":87},"last":{"id":"4aba96d5-f984-4bc0-bda4-7fb97f527686","sequence":87}},"turnId":"01a11a6b-97ec-7000-bd1b-d7afbea11045","commandId":"01a11a6b-97ec-7000-bd1b-d7afbea11045"},"emittedAtMs":1791444621334}} -{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:19","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"4aba96d5-f984-4bc0-bda4-7fb97f527686","sequence":87},"last":{"id":"4aba96d5-f984-4bc0-bda4-7fb97f527686","sequence":87}},"item":{"itemId":"90719a5d-d2c1-46dc-ad4d-274adda02344","kind":"userMessage","turnId":"01a11a6b-97ec-7000-bd1b-d7afbea11045","revision":1,"status":"completed","recordedAt":"2026-10-08T07:30:21.321391Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nRespond with exactly: second fixture turn complete","commandId":"01a11a6b-97ec-7000-bd1b-d7afbea11045"}},"emittedAtMs":1791444621334}} -{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:20","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"dc502e2c-d81c-4f0e-ad86-dd11d1368677","sequence":89},"last":{"id":"dc502e2c-d81c-4f0e-ad86-dd11d1368677","sequence":89}},"item":{"itemId":"90719a5d-d2c1-46dc-ad4d-274adda02344","kind":"userMessage","turnId":"01a11a6b-97ec-7000-bd1b-d7afbea11045","revision":2,"status":"completed","recordedAt":"2026-10-08T07:30:21.338944Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nRespond with exactly: second fixture turn complete","displayText":"Respond with exactly: second fixture turn complete","commandId":"01a11a6b-97ec-7000-bd1b-d7afbea11045"}},"emittedAtMs":1791444621347}} +{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:19","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"4aba96d5-f984-4bc0-bda4-7fb97f527686","sequence":87},"last":{"id":"4aba96d5-f984-4bc0-bda4-7fb97f527686","sequence":87}},"item":{"itemId":"90719a5d-d2c1-46dc-ad4d-274adda02344","kind":"userMessage","turnId":"01a11a6b-97ec-7000-bd1b-d7afbea11045","revision":1,"status":"completed","recordedAt":"2026-10-08T07:30:21.321391Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nRespond with exactly: second fixture turn complete","commandId":"01a11a6b-97ec-7000-bd1b-d7afbea11045"}},"emittedAtMs":1791444621334}} +{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:20","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"dc502e2c-d81c-4f0e-ad86-dd11d1368677","sequence":89},"last":{"id":"dc502e2c-d81c-4f0e-ad86-dd11d1368677","sequence":89}},"item":{"itemId":"90719a5d-d2c1-46dc-ad4d-274adda02344","kind":"userMessage","turnId":"01a11a6b-97ec-7000-bd1b-d7afbea11045","revision":2,"status":"completed","recordedAt":"2026-10-08T07:30:21.338944Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nRespond with exactly: second fixture turn complete","displayText":"Respond with exactly: second fixture turn complete","commandId":"01a11a6b-97ec-7000-bd1b-d7afbea11045"}},"emittedAtMs":1791444621347}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:21","sourceRange":{"stream":{"kind":"session","id":"01a11a6b-707e-7000-9bb5-4079c7772ad0"},"first":{"id":"09cb4c2a-03c2-4089-ba12-8d5d31f7ea48","sequence":106},"last":{"id":"09cb4c2a-03c2-4089-ba12-8d5d31f7ea48","sequence":106}},"item":{"itemId":"09cb4c2a-03c2-4089-ba12-8d5d31f7ea48","kind":"reminderChild","turnId":"01a11a6b-97ec-7000-bd1b-d7afbea11045","revision":1,"status":"inProgress","recordedAt":"2026-10-08T07:30:21.446966Z","fallbackText":"Reminder child session","childSessionId":"cd97261a-3ea9-480f-9e54-5e59bb077da2","reminderAgentId":"skill-reminder","generationId":1,"taskId":"01a11a6b-9869-7061-a4f3-42daa3e3a8f1"}},"emittedAtMs":1791444621455}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:21.1","item":{"itemId":"2de26c0d-26b4-4533-8810-918ee44fcd82","kind":"agentMessage","turnId":"01a11a6b-97ec-7000-bd1b-d7afbea11045","revision":1,"status":"inProgress","text":""}},"emittedAtMs":1791444623127}} {"type":"emit_inbound","label":"item/delta","frame":{"jsonrpc":"2.0","method":"item/delta","params":{"sessionId":"01a11a6b-707e-7000-9bb5-4079c7772ad0","viewCursor":"v:01a11a6b-707e-7000-9bb5-4079c7772ad0:21.2","itemId":"2de26c0d-26b4-4533-8810-918ee44fcd82","field":"text","delta":"second fixture turn complete"},"emittedAtMs":1791444623127}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/muse_permission/muse_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/muse_permission/muse_transcript.ndjson index 8b425ec29268..12492cb13bb2 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/muse_permission/muse_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/muse_permission/muse_transcript.ndjson @@ -7,13 +7,13 @@ {"type":"emit_inbound","label":"session/started","frame":{"jsonrpc":"2.0","method":"session/started","params":{"session":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","path":"~/.local/share/muse/sessions/2026/10/08/01a11a6d-39bb-7000-8c93-4594c98e6a11/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-08T07:32:08.30084Z","updatedAt":"2026-10-08T07:32:08.300844Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"approvalMode":{"mode":"promptUnmatched","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"}},"emittedAtMs":1791444728496}} {"type":"emit_inbound","label":"response:session/start","frame":{"jsonrpc":"2.0","id":2,"result":{"session":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","path":"~/.local/share/muse/sessions/2026/10/08/01a11a6d-39bb-7000-8c93-4594c98e6a11/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-08T07:32:08.30084Z","updatedAt":"2026-10-08T07:32:08.300844Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"approvalMode":{"mode":"promptUnmatched","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"},"viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:1"}}} {"type":"emit_inbound","label":"session/approvalModeChanged","frame":{"jsonrpc":"2.0","method":"session/approvalModeChanged","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:1","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"b05fb8ec-7b58-4cf7-a369-379938395230","sequence":5},"last":{"id":"b05fb8ec-7b58-4cf7-a369-379938395230","sequence":5}},"mode":"promptUnmatched","source":"approvalReconfigure","commandId":"01a11a6d-39bb-7001-923e-425cc6129ce7","clientName":"t3_code"},"emittedAtMs":1791444728501}} -{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent. Otherwise, when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When monitoring is done and you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\n"},{"type":"text","text":"Run the shell command `touch muse-approved.txt` with your shell tool, then reply DONE."}],"displayText":"Run the shell command `touch muse-approved.txt` with your shell tool, then reply DONE.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent. Otherwise, when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When monitoring is done and you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\n"},{"type":"text","text":"Run the shell command `touch muse-approved.txt` with your shell tool, then reply DONE."}],"displayText":"Run the shell command `touch muse-approved.txt` with your shell tool, then reply DONE.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6"}}} {"type":"emit_inbound","label":"session/branchChanged","frame":{"jsonrpc":"2.0","method":"session/branchChanged","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:2","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"6159e84a-ae1e-4fae-ae8b-49f41ca34509","sequence":16},"last":{"id":"6159e84a-ae1e-4fae-ae8b-49f41ca34509","sequence":16}},"workspaceRoot":"","branch":"main","vcs":"git"},"emittedAtMs":1791444728769}} {"type":"emit_inbound","label":"response:turn/start","frame":{"jsonrpc":"2.0","id":3,"result":{"commandId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6","status":"accepted","turnId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6","startedNewTurn":true,"disposition":"started"}}} {"type":"emit_inbound","label":"session/statusChanged","frame":{"jsonrpc":"2.0","method":"session/statusChanged","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","status":"running","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:3"},"emittedAtMs":1791444728783}} {"type":"emit_inbound","label":"turn/started","frame":{"jsonrpc":"2.0","method":"turn/started","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:3","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"09218c3a-88fd-4bc8-ab45-d1aa69bea95a","sequence":18},"last":{"id":"09218c3a-88fd-4bc8-ab45-d1aa69bea95a","sequence":18}},"turnId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6","commandId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6"},"emittedAtMs":1791444728783}} -{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:4","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"09218c3a-88fd-4bc8-ab45-d1aa69bea95a","sequence":18},"last":{"id":"09218c3a-88fd-4bc8-ab45-d1aa69bea95a","sequence":18}},"item":{"itemId":"dde9573b-650f-434b-ab99-60cfc820a0a5","kind":"userMessage","turnId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6","revision":1,"status":"completed","recordedAt":"2026-10-08T07:32:08.769894Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nRun the shell command `touch muse-approved.txt` with your shell tool, then reply DONE.","commandId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6"}},"emittedAtMs":1791444728784}} -{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:5","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"76c57706-1378-4167-8bbf-f76d959804e8","sequence":20},"last":{"id":"76c57706-1378-4167-8bbf-f76d959804e8","sequence":20}},"item":{"itemId":"dde9573b-650f-434b-ab99-60cfc820a0a5","kind":"userMessage","turnId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6","revision":2,"status":"completed","recordedAt":"2026-10-08T07:32:08.791728Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nRun the shell command `touch muse-approved.txt` with your shell tool, then reply DONE.","displayText":"Run the shell command `touch muse-approved.txt` with your shell tool, then reply DONE.","commandId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6"}},"emittedAtMs":1791444728800}} +{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:4","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"09218c3a-88fd-4bc8-ab45-d1aa69bea95a","sequence":18},"last":{"id":"09218c3a-88fd-4bc8-ab45-d1aa69bea95a","sequence":18}},"item":{"itemId":"dde9573b-650f-434b-ab99-60cfc820a0a5","kind":"userMessage","turnId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6","revision":1,"status":"completed","recordedAt":"2026-10-08T07:32:08.769894Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nRun the shell command `touch muse-approved.txt` with your shell tool, then reply DONE.","commandId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6"}},"emittedAtMs":1791444728784}} +{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:5","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"76c57706-1378-4167-8bbf-f76d959804e8","sequence":20},"last":{"id":"76c57706-1378-4167-8bbf-f76d959804e8","sequence":20}},"item":{"itemId":"dde9573b-650f-434b-ab99-60cfc820a0a5","kind":"userMessage","turnId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6","revision":2,"status":"completed","recordedAt":"2026-10-08T07:32:08.791728Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nRun the shell command `touch muse-approved.txt` with your shell tool, then reply DONE.","displayText":"Run the shell command `touch muse-approved.txt` with your shell tool, then reply DONE.","commandId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6"}},"emittedAtMs":1791444728800}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:6","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"29180105-84c4-4f9e-8bfd-003392b93b73","sequence":38},"last":{"id":"29180105-84c4-4f9e-8bfd-003392b93b73","sequence":38}},"item":{"itemId":"29180105-84c4-4f9e-8bfd-003392b93b73","kind":"reminderChild","turnId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6","revision":1,"status":"inProgress","recordedAt":"2026-10-08T07:32:08.905989Z","fallbackText":"Reminder child session","childSessionId":"7c9485f9-26aa-4b56-8ca2-f36cb9cfb8c4","reminderAgentId":"skill-reminder","generationId":1,"taskId":"01a11a6d-3c27-73f2-8c27-cd06a3b7dadf"}},"emittedAtMs":1791444728910}} {"type":"emit_inbound","label":"session/tokenUsage","frame":{"jsonrpc":"2.0","method":"session/tokenUsage","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:7","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"b1dab1f5-3c9e-4d92-b955-841a2269c23c","sequence":54},"last":{"id":"b1dab1f5-3c9e-4d92-b955-841a2269c23c","sequence":54}},"turnId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6","modelId":"muse-spark-1.3-contributor","usage":{"inputTokens":32618,"outputTokens":315,"cachedTokens":0,"cacheWriteTokens":0,"cacheReadTokens":0,"reasoningTokens":227},"promptTokens":32618,"totalTokens":32933,"finishReason":"tool_calls","durationMs":2684,"cumulative":{"promptTokens":32618,"outputTokens":315,"totalTokens":32933,"cost":{"usd":0.0033248,"partial":false}}},"emittedAtMs":1791444731684}} {"type":"emit_inbound","label":"session/contextUsage","frame":{"jsonrpc":"2.0","method":"session/contextUsage","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:8","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"b1dab1f5-3c9e-4d92-b955-841a2269c23c","sequence":54},"last":{"id":"b1dab1f5-3c9e-4d92-b955-841a2269c23c","sequence":54}},"windowTokens":1007997,"usedTokens":32933,"pressure":"normal"},"emittedAtMs":1791444731684}} @@ -42,12 +42,12 @@ {"type":"emit_inbound","label":"session/statusChanged","frame":{"jsonrpc":"2.0","method":"session/statusChanged","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","status":"idle","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:23"},"emittedAtMs":1791444747386}} {"type":"emit_inbound","label":"turn/completed","frame":{"jsonrpc":"2.0","method":"turn/completed","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:23","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"7c29ac76-7e09-48c1-a18f-f336126e27d1","sequence":120},"last":{"id":"7c29ac76-7e09-48c1-a18f-f336126e27d1","sequence":120}},"turnId":"01a11a6d-3b9f-7000-b6ad-ab05e6b973b6","terminal":"completed","durationMs":18608,"timeToFirstTokenMs":4791},"emittedAtMs":1791444747387}} {"type":"emit_inbound","label":"session/branchChanged","frame":{"jsonrpc":"2.0","method":"session/branchChanged","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:24","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"1e4afe30-d924-444c-8665-5e763f524e70","sequence":121},"last":{"id":"1e4afe30-d924-444c-8665-5e763f524e70","sequence":121}},"workspaceRoot":"","branch":"main","vcs":"git"},"emittedAtMs":1791444747394}} -{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":5,"method":"turn/start","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent. Otherwise, when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When monitoring is done and you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\n"},{"type":"text","text":"Run the shell command `touch muse-declined.txt` with your shell tool once. If it is rejected, do not retry; reply REJECTED."}],"displayText":"Run the shell command `touch muse-declined.txt` with your shell tool once. If it is rejected, do not retry; reply REJECTED.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a11a6d-8566-7000-8ad1-6269ac187a29"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":5,"method":"turn/start","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent. Otherwise, when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When monitoring is done and you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\n"},{"type":"text","text":"Run the shell command `touch muse-declined.txt` with your shell tool once. If it is rejected, do not retry; reply REJECTED."}],"displayText":"Run the shell command `touch muse-declined.txt` with your shell tool once. If it is rejected, do not retry; reply REJECTED.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a11a6d-8566-7000-8ad1-6269ac187a29"}}} {"type":"emit_inbound","label":"response:turn/start","frame":{"jsonrpc":"2.0","id":5,"result":{"commandId":"01a11a6d-8566-7000-8ad1-6269ac187a29","status":"accepted","turnId":"01a11a6d-8566-7000-8ad1-6269ac187a29","startedNewTurn":true,"disposition":"started"}}} {"type":"emit_inbound","label":"session/statusChanged","frame":{"jsonrpc":"2.0","method":"session/statusChanged","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","status":"running","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:25"},"emittedAtMs":1791444747660}} {"type":"emit_inbound","label":"turn/started","frame":{"jsonrpc":"2.0","method":"turn/started","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:25","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"4a1cf90d-7457-448d-a2b5-d82108636b3b","sequence":128},"last":{"id":"4a1cf90d-7457-448d-a2b5-d82108636b3b","sequence":128}},"turnId":"01a11a6d-8566-7000-8ad1-6269ac187a29","commandId":"01a11a6d-8566-7000-8ad1-6269ac187a29"},"emittedAtMs":1791444747660}} -{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:26","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"4a1cf90d-7457-448d-a2b5-d82108636b3b","sequence":128},"last":{"id":"4a1cf90d-7457-448d-a2b5-d82108636b3b","sequence":128}},"item":{"itemId":"27473708-71e0-4d3c-811c-a8bae6bbaa3b","kind":"userMessage","turnId":"01a11a6d-8566-7000-8ad1-6269ac187a29","revision":1,"status":"completed","recordedAt":"2026-10-08T07:32:27.650913Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nRun the shell command `touch muse-declined.txt` with your shell tool once. If it is rejected, do not retry; reply REJECTED.","commandId":"01a11a6d-8566-7000-8ad1-6269ac187a29"}},"emittedAtMs":1791444747660}} -{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:27","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"4a056a03-469a-4c9f-94a9-78eb815023ad","sequence":130},"last":{"id":"4a056a03-469a-4c9f-94a9-78eb815023ad","sequence":130}},"item":{"itemId":"27473708-71e0-4d3c-811c-a8bae6bbaa3b","kind":"userMessage","turnId":"01a11a6d-8566-7000-8ad1-6269ac187a29","revision":2,"status":"completed","recordedAt":"2026-10-08T07:32:27.66745Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nRun the shell command `touch muse-declined.txt` with your shell tool once. If it is rejected, do not retry; reply REJECTED.","displayText":"Run the shell command `touch muse-declined.txt` with your shell tool once. If it is rejected, do not retry; reply REJECTED.","commandId":"01a11a6d-8566-7000-8ad1-6269ac187a29"}},"emittedAtMs":1791444747676}} +{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:26","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"4a1cf90d-7457-448d-a2b5-d82108636b3b","sequence":128},"last":{"id":"4a1cf90d-7457-448d-a2b5-d82108636b3b","sequence":128}},"item":{"itemId":"27473708-71e0-4d3c-811c-a8bae6bbaa3b","kind":"userMessage","turnId":"01a11a6d-8566-7000-8ad1-6269ac187a29","revision":1,"status":"completed","recordedAt":"2026-10-08T07:32:27.650913Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nRun the shell command `touch muse-declined.txt` with your shell tool once. If it is rejected, do not retry; reply REJECTED.","commandId":"01a11a6d-8566-7000-8ad1-6269ac187a29"}},"emittedAtMs":1791444747660}} +{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:27","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"4a056a03-469a-4c9f-94a9-78eb815023ad","sequence":130},"last":{"id":"4a056a03-469a-4c9f-94a9-78eb815023ad","sequence":130}},"item":{"itemId":"27473708-71e0-4d3c-811c-a8bae6bbaa3b","kind":"userMessage","turnId":"01a11a6d-8566-7000-8ad1-6269ac187a29","revision":2,"status":"completed","recordedAt":"2026-10-08T07:32:27.66745Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nRun the shell command `touch muse-declined.txt` with your shell tool once. If it is rejected, do not retry; reply REJECTED.","displayText":"Run the shell command `touch muse-declined.txt` with your shell tool once. If it is rejected, do not retry; reply REJECTED.","commandId":"01a11a6d-8566-7000-8ad1-6269ac187a29"}},"emittedAtMs":1791444747676}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:28","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"3b28955d-a87a-4acb-97e9-14cad87fd41f","sequence":148},"last":{"id":"3b28955d-a87a-4acb-97e9-14cad87fd41f","sequence":148}},"item":{"itemId":"3b28955d-a87a-4acb-97e9-14cad87fd41f","kind":"reminderChild","turnId":"01a11a6d-8566-7000-8ad1-6269ac187a29","revision":1,"status":"inProgress","recordedAt":"2026-10-08T07:32:27.787099Z","fallbackText":"Reminder child session","childSessionId":"efd21bf1-4fd1-49a2-9c55-91b7fc099765","reminderAgentId":"skill-reminder","generationId":1,"taskId":"01a11a6d-85ed-7b91-8d91-c4ce76b6dfef"}},"emittedAtMs":1791444747794}} {"type":"emit_inbound","label":"session/tokenUsage","frame":{"jsonrpc":"2.0","method":"session/tokenUsage","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:29","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"b032c01d-eae1-437e-910c-8756aedc6084","sequence":164},"last":{"id":"b032c01d-eae1-437e-910c-8756aedc6084","sequence":164}},"turnId":"01a11a6d-8566-7000-8ad1-6269ac187a29","modelId":"muse-spark-1.3-contributor","usage":{"inputTokens":33401,"outputTokens":201,"cachedTokens":33009,"cacheWriteTokens":0,"cacheReadTokens":33009,"reasoningTokens":108},"promptTokens":33401,"totalTokens":33602,"finishReason":"tool_calls","durationMs":2522,"cumulative":{"promptTokens":99045,"outputTokens":538,"totalTokens":99583,"cacheReadTokens":65506,"cost":{"usd":0.003592512,"partial":false}}},"emittedAtMs":1791444750395}} {"type":"emit_inbound","label":"session/contextUsage","frame":{"jsonrpc":"2.0","method":"session/contextUsage","params":{"sessionId":"01a11a6d-39bb-7000-8c93-4594c98e6a11","viewCursor":"v:01a11a6d-39bb-7000-8c93-4594c98e6a11:30","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-39bb-7000-8c93-4594c98e6a11"},"first":{"id":"b032c01d-eae1-437e-910c-8756aedc6084","sequence":164},"last":{"id":"b032c01d-eae1-437e-910c-8756aedc6084","sequence":164}},"windowTokens":1007997,"usedTokens":33602,"pressure":"normal"},"emittedAtMs":1791444750395}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/muse_subagent_approval/muse_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/muse_subagent_approval/muse_transcript.ndjson index c1462a92c2cc..dbbd70a9b596 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/muse_subagent_approval/muse_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/muse_subagent_approval/muse_transcript.ndjson @@ -7,13 +7,13 @@ {"type":"emit_inbound","label":"session/started","frame":{"jsonrpc":"2.0","method":"session/started","params":{"session":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","path":"~/.local/share/muse/sessions/2026/10/11/01a12d1d-a1b7-7000-9697-d430c2e2afd8/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-11T22:37:59.14136Z","updatedAt":"2026-10-11T22:37:59.141364Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"sideFrom":null,"approvalMode":{"mode":"promptUnmatched","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"}},"emittedAtMs":1791758279294}} {"type":"emit_inbound","label":"response:session/start","frame":{"jsonrpc":"2.0","id":2,"result":{"session":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","path":"~/.local/share/muse/sessions/2026/10/11/01a12d1d-a1b7-7000-9697-d430c2e2afd8/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-11T22:37:59.14136Z","updatedAt":"2026-10-11T22:37:59.141364Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"sideFrom":null,"approvalMode":{"mode":"promptUnmatched","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"},"viewCursor":"v:01a12d1d-a1b7-7000-9697-d430c2e2afd8:1"}}} {"type":"emit_inbound","label":"session/approvalModeChanged","frame":{"jsonrpc":"2.0","method":"session/approvalModeChanged","params":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","viewCursor":"v:01a12d1d-a1b7-7000-9697-d430c2e2afd8:1","sourceRange":{"stream":{"kind":"session","id":"01a12d1d-a1b7-7000-9697-d430c2e2afd8"},"first":{"id":"ed95a9db-eada-4751-a7e6-22ccc9bdabd5","sequence":5},"last":{"id":"ed95a9db-eada-4751-a7e6-22ccc9bdabd5","sequence":5}},"mode":"promptUnmatched","source":"approvalReconfigure","commandId":"01a12d1d-a1b8-7000-8d6c-f1324b724cdb","clientName":"t3_code"},"emittedAtMs":1791758279297}} -{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent. Otherwise, when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When monitoring is done and you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\n"},{"type":"text","text":"Do not run any commands yourself. Spawn one subagent whose only task is to run this exact shell command: printf 'subagent approval fixture' > subagent-approval.txt and then reply with exactly: Written. Wait for it to finish, then reply with exactly what it said."}],"displayText":"Do not run any commands yourself. Spawn one subagent whose only task is to run this exact shell command: printf 'subagent approval fixture' > subagent-approval.txt and then reply with exactly: Written. Wait for it to finish, then reply with exactly what it said.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a12d1d-a348-7000-ae97-9d27b5f80bce"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent. Otherwise, when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When monitoring is done and you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\n"},{"type":"text","text":"Do not run any commands yourself. Spawn one subagent whose only task is to run this exact shell command: printf 'subagent approval fixture' > subagent-approval.txt and then reply with exactly: Written. Wait for it to finish, then reply with exactly what it said."}],"displayText":"Do not run any commands yourself. Spawn one subagent whose only task is to run this exact shell command: printf 'subagent approval fixture' > subagent-approval.txt and then reply with exactly: Written. Wait for it to finish, then reply with exactly what it said.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a12d1d-a348-7000-ae97-9d27b5f80bce"}}} {"type":"emit_inbound","label":"session/branchChanged","frame":{"jsonrpc":"2.0","method":"session/branchChanged","params":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","viewCursor":"v:01a12d1d-a1b7-7000-9697-d430c2e2afd8:2","sourceRange":{"stream":{"kind":"session","id":"01a12d1d-a1b7-7000-9697-d430c2e2afd8"},"first":{"id":"4f170229-d428-4a64-9516-7a355ff3bcdb","sequence":15},"last":{"id":"4f170229-d428-4a64-9516-7a355ff3bcdb","sequence":15}},"workspaceRoot":"","branch":"main","vcs":"git"},"emittedAtMs":1791758279531}} {"type":"emit_inbound","label":"response:turn/start","frame":{"jsonrpc":"2.0","id":3,"result":{"commandId":"01a12d1d-a348-7000-ae97-9d27b5f80bce","status":"accepted","turnId":"01a12d1d-a348-7000-ae97-9d27b5f80bce","startedNewTurn":true,"disposition":"started"}}} {"type":"emit_inbound","label":"session/statusChanged","frame":{"jsonrpc":"2.0","method":"session/statusChanged","params":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","status":"running","viewCursor":"v:01a12d1d-a1b7-7000-9697-d430c2e2afd8:3"},"emittedAtMs":1791758279541}} {"type":"emit_inbound","label":"turn/started","frame":{"jsonrpc":"2.0","method":"turn/started","params":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","viewCursor":"v:01a12d1d-a1b7-7000-9697-d430c2e2afd8:3","sourceRange":{"stream":{"kind":"session","id":"01a12d1d-a1b7-7000-9697-d430c2e2afd8"},"first":{"id":"429e8ecf-d532-4b14-a303-b9f832c36a51","sequence":17},"last":{"id":"429e8ecf-d532-4b14-a303-b9f832c36a51","sequence":17}},"turnId":"01a12d1d-a348-7000-ae97-9d27b5f80bce","commandId":"01a12d1d-a348-7000-ae97-9d27b5f80bce"},"emittedAtMs":1791758279542}} -{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","viewCursor":"v:01a12d1d-a1b7-7000-9697-d430c2e2afd8:4","sourceRange":{"stream":{"kind":"session","id":"01a12d1d-a1b7-7000-9697-d430c2e2afd8"},"first":{"id":"429e8ecf-d532-4b14-a303-b9f832c36a51","sequence":17},"last":{"id":"429e8ecf-d532-4b14-a303-b9f832c36a51","sequence":17}},"item":{"itemId":"a4b3de8d-842b-4065-8769-aed18e62e9fc","kind":"userMessage","turnId":"01a12d1d-a348-7000-ae97-9d27b5f80bce","revision":1,"status":"completed","recordedAt":"2026-10-11T22:37:59.531477Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nDo not run any commands yourself. Spawn one subagent whose only task is to run this exact shell command: printf 'subagent approval fixture' > subagent-approval.txt and then reply with exactly: Written. Wait for it to finish, then reply with exactly what it said.","commandId":"01a12d1d-a348-7000-ae97-9d27b5f80bce"}},"emittedAtMs":1791758279542}} -{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","viewCursor":"v:01a12d1d-a1b7-7000-9697-d430c2e2afd8:5","sourceRange":{"stream":{"kind":"session","id":"01a12d1d-a1b7-7000-9697-d430c2e2afd8"},"first":{"id":"5a63e348-5bf6-4262-b37f-d31d307ea6d8","sequence":19},"last":{"id":"5a63e348-5bf6-4262-b37f-d31d307ea6d8","sequence":19}},"item":{"itemId":"a4b3de8d-842b-4065-8769-aed18e62e9fc","kind":"userMessage","turnId":"01a12d1d-a348-7000-ae97-9d27b5f80bce","revision":2,"status":"completed","recordedAt":"2026-10-11T22:37:59.545908Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nDo not run any commands yourself. Spawn one subagent whose only task is to run this exact shell command: printf 'subagent approval fixture' > subagent-approval.txt and then reply with exactly: Written. Wait for it to finish, then reply with exactly what it said.","displayText":"Do not run any commands yourself. Spawn one subagent whose only task is to run this exact shell command: printf 'subagent approval fixture' > subagent-approval.txt and then reply with exactly: Written. Wait for it to finish, then reply with exactly what it said.","commandId":"01a12d1d-a348-7000-ae97-9d27b5f80bce"}},"emittedAtMs":1791758279553}} +{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","viewCursor":"v:01a12d1d-a1b7-7000-9697-d430c2e2afd8:4","sourceRange":{"stream":{"kind":"session","id":"01a12d1d-a1b7-7000-9697-d430c2e2afd8"},"first":{"id":"429e8ecf-d532-4b14-a303-b9f832c36a51","sequence":17},"last":{"id":"429e8ecf-d532-4b14-a303-b9f832c36a51","sequence":17}},"item":{"itemId":"a4b3de8d-842b-4065-8769-aed18e62e9fc","kind":"userMessage","turnId":"01a12d1d-a348-7000-ae97-9d27b5f80bce","revision":1,"status":"completed","recordedAt":"2026-10-11T22:37:59.531477Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nDo not run any commands yourself. Spawn one subagent whose only task is to run this exact shell command: printf 'subagent approval fixture' > subagent-approval.txt and then reply with exactly: Written. Wait for it to finish, then reply with exactly what it said.","commandId":"01a12d1d-a348-7000-ae97-9d27b5f80bce"}},"emittedAtMs":1791758279542}} +{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","viewCursor":"v:01a12d1d-a1b7-7000-9697-d430c2e2afd8:5","sourceRange":{"stream":{"kind":"session","id":"01a12d1d-a1b7-7000-9697-d430c2e2afd8"},"first":{"id":"5a63e348-5bf6-4262-b37f-d31d307ea6d8","sequence":19},"last":{"id":"5a63e348-5bf6-4262-b37f-d31d307ea6d8","sequence":19}},"item":{"itemId":"a4b3de8d-842b-4065-8769-aed18e62e9fc","kind":"userMessage","turnId":"01a12d1d-a348-7000-ae97-9d27b5f80bce","revision":2,"status":"completed","recordedAt":"2026-10-11T22:37:59.545908Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nDo not run any commands yourself. Spawn one subagent whose only task is to run this exact shell command: printf 'subagent approval fixture' > subagent-approval.txt and then reply with exactly: Written. Wait for it to finish, then reply with exactly what it said.","displayText":"Do not run any commands yourself. Spawn one subagent whose only task is to run this exact shell command: printf 'subagent approval fixture' > subagent-approval.txt and then reply with exactly: Written. Wait for it to finish, then reply with exactly what it said.","commandId":"01a12d1d-a348-7000-ae97-9d27b5f80bce"}},"emittedAtMs":1791758279553}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","viewCursor":"v:01a12d1d-a1b7-7000-9697-d430c2e2afd8:6","sourceRange":{"stream":{"kind":"session","id":"01a12d1d-a1b7-7000-9697-d430c2e2afd8"},"first":{"id":"d55286a9-a9e2-4d7b-be97-d44360bcca2b","sequence":37},"last":{"id":"d55286a9-a9e2-4d7b-be97-d44360bcca2b","sequence":37}},"item":{"itemId":"d55286a9-a9e2-4d7b-be97-d44360bcca2b","kind":"reminderChild","turnId":"01a12d1d-a348-7000-ae97-9d27b5f80bce","revision":1,"status":"inProgress","recordedAt":"2026-10-11T22:37:59.660065Z","fallbackText":"Reminder child session","childSessionId":"0779d605-54b8-45dc-90b6-91dc58b9a52c","reminderAgentId":"skill-reminder","generationId":1,"taskId":"01a12d1d-a3d4-7712-8b98-26f3c4f9bf38"}},"emittedAtMs":1791758279666}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","viewCursor":"v:01a12d1d-a1b7-7000-9697-d430c2e2afd8:6.1","item":{"itemId":"21b74b34-fe9f-457c-83f0-cf49257b5948","kind":"reasoning","turnId":"01a12d1d-a348-7000-ae97-9d27b5f80bce","revision":1,"status":"inProgress","summary":[""]}},"emittedAtMs":1791758282481}} {"type":"emit_inbound","label":"item/delta","frame":{"jsonrpc":"2.0","method":"item/delta","params":{"sessionId":"01a12d1d-a1b7-7000-9697-d430c2e2afd8","viewCursor":"v:01a12d1d-a1b7-7000-9697-d430c2e2afd8:6.2","itemId":"21b74b34-fe9f-457c-83f0-cf49257b5948","field":"summary.0","delta":"Spawning a subagent via muse_subagent_spawn to run a shell command and return its exact output, while avoiding direct command execution."},"emittedAtMs":1791758282481}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/muse_subagent_interrupt/muse_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/muse_subagent_interrupt/muse_transcript.ndjson index d73fa8b51bf5..f9c5f262fc58 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/muse_subagent_interrupt/muse_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/muse_subagent_interrupt/muse_transcript.ndjson @@ -7,13 +7,13 @@ {"type":"emit_inbound","label":"session/started","frame":{"jsonrpc":"2.0","method":"session/started","params":{"session":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","path":"~/.local/share/muse/sessions/2026/10/11/01a12d28-7788-7000-a946-c407f380868e/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-11T22:49:49.235205Z","updatedAt":"2026-10-11T22:49:49.235209Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"sideFrom":null,"approvalMode":{"mode":"allowAll","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"}},"emittedAtMs":1791758989388}} {"type":"emit_inbound","label":"response:session/start","frame":{"jsonrpc":"2.0","id":2,"result":{"session":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","path":"~/.local/share/muse/sessions/2026/10/11/01a12d28-7788-7000-a946-c407f380868e/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-11T22:49:49.235205Z","updatedAt":"2026-10-11T22:49:49.235209Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"sideFrom":null,"approvalMode":{"mode":"allowAll","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"},"viewCursor":"v:01a12d28-7788-7000-a946-c407f380868e:1"}}} {"type":"emit_inbound","label":"session/approvalModeChanged","frame":{"jsonrpc":"2.0","method":"session/approvalModeChanged","params":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","viewCursor":"v:01a12d28-7788-7000-a946-c407f380868e:1","sourceRange":{"stream":{"kind":"session","id":"01a12d28-7788-7000-a946-c407f380868e"},"first":{"id":"29d4e769-78f7-48fa-90cd-3ac9f8fa04bf","sequence":5},"last":{"id":"29d4e769-78f7-48fa-90cd-3ac9f8fa04bf","sequence":5}},"mode":"allowAll","source":"approvalReconfigure","commandId":"01a12d28-7788-7001-a223-c10e52187cdf","clientName":"t3_code"},"emittedAtMs":1791758989391}} -{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent. Otherwise, when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When monitoring is done and you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\n"},{"type":"text","text":"Spawn one subagent whose only task is to run the shell command `sleep 30` and then reply with exactly: Done. Wait for it to finish, then reply with exactly what it said."}],"displayText":"Spawn one subagent whose only task is to run the shell command `sleep 30` and then reply with exactly: Done. Wait for it to finish, then reply with exactly what it said.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a12d28-7912-7000-993a-afd019e6ac5c"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent. Otherwise, when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When monitoring is done and you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\n"},{"type":"text","text":"Spawn one subagent whose only task is to run the shell command `sleep 30` and then reply with exactly: Done. Wait for it to finish, then reply with exactly what it said."}],"displayText":"Spawn one subagent whose only task is to run the shell command `sleep 30` and then reply with exactly: Done. Wait for it to finish, then reply with exactly what it said.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a12d28-7912-7000-993a-afd019e6ac5c"}}} {"type":"emit_inbound","label":"session/branchChanged","frame":{"jsonrpc":"2.0","method":"session/branchChanged","params":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","viewCursor":"v:01a12d28-7788-7000-a946-c407f380868e:2","sourceRange":{"stream":{"kind":"session","id":"01a12d28-7788-7000-a946-c407f380868e"},"first":{"id":"660e2cfd-318a-4d2d-87bb-0b1375d79fdf","sequence":15},"last":{"id":"660e2cfd-318a-4d2d-87bb-0b1375d79fdf","sequence":15}},"workspaceRoot":"","branch":"main","vcs":"git"},"emittedAtMs":1791758989619}} {"type":"emit_inbound","label":"response:turn/start","frame":{"jsonrpc":"2.0","id":3,"result":{"commandId":"01a12d28-7912-7000-993a-afd019e6ac5c","status":"accepted","turnId":"01a12d28-7912-7000-993a-afd019e6ac5c","startedNewTurn":true,"disposition":"started"}}} {"type":"emit_inbound","label":"session/statusChanged","frame":{"jsonrpc":"2.0","method":"session/statusChanged","params":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","status":"running","viewCursor":"v:01a12d28-7788-7000-a946-c407f380868e:3"},"emittedAtMs":1791758989632}} {"type":"emit_inbound","label":"turn/started","frame":{"jsonrpc":"2.0","method":"turn/started","params":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","viewCursor":"v:01a12d28-7788-7000-a946-c407f380868e:3","sourceRange":{"stream":{"kind":"session","id":"01a12d28-7788-7000-a946-c407f380868e"},"first":{"id":"5cb199bd-04e5-40d9-a299-8760be80e0a4","sequence":17},"last":{"id":"5cb199bd-04e5-40d9-a299-8760be80e0a4","sequence":17}},"turnId":"01a12d28-7912-7000-993a-afd019e6ac5c","commandId":"01a12d28-7912-7000-993a-afd019e6ac5c"},"emittedAtMs":1791758989632}} -{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","viewCursor":"v:01a12d28-7788-7000-a946-c407f380868e:4","sourceRange":{"stream":{"kind":"session","id":"01a12d28-7788-7000-a946-c407f380868e"},"first":{"id":"5cb199bd-04e5-40d9-a299-8760be80e0a4","sequence":17},"last":{"id":"5cb199bd-04e5-40d9-a299-8760be80e0a4","sequence":17}},"item":{"itemId":"5e234d9d-0744-4757-9629-761ea4e7f062","kind":"userMessage","turnId":"01a12d28-7912-7000-993a-afd019e6ac5c","revision":1,"status":"completed","recordedAt":"2026-10-11T22:49:49.620067Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nSpawn one subagent whose only task is to run the shell command `sleep 30` and then reply with exactly: Done. Wait for it to finish, then reply with exactly what it said.","commandId":"01a12d28-7912-7000-993a-afd019e6ac5c"}},"emittedAtMs":1791758989632}} -{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","viewCursor":"v:01a12d28-7788-7000-a946-c407f380868e:5","sourceRange":{"stream":{"kind":"session","id":"01a12d28-7788-7000-a946-c407f380868e"},"first":{"id":"5724636e-9398-4d23-87ac-1e9a2b83ad9f","sequence":19},"last":{"id":"5724636e-9398-4d23-87ac-1e9a2b83ad9f","sequence":19}},"item":{"itemId":"5e234d9d-0744-4757-9629-761ea4e7f062","kind":"userMessage","turnId":"01a12d28-7912-7000-993a-afd019e6ac5c","revision":2,"status":"completed","recordedAt":"2026-10-11T22:49:49.635395Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nSpawn one subagent whose only task is to run the shell command `sleep 30` and then reply with exactly: Done. Wait for it to finish, then reply with exactly what it said.","displayText":"Spawn one subagent whose only task is to run the shell command `sleep 30` and then reply with exactly: Done. Wait for it to finish, then reply with exactly what it said.","commandId":"01a12d28-7912-7000-993a-afd019e6ac5c"}},"emittedAtMs":1791758989642}} +{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","viewCursor":"v:01a12d28-7788-7000-a946-c407f380868e:4","sourceRange":{"stream":{"kind":"session","id":"01a12d28-7788-7000-a946-c407f380868e"},"first":{"id":"5cb199bd-04e5-40d9-a299-8760be80e0a4","sequence":17},"last":{"id":"5cb199bd-04e5-40d9-a299-8760be80e0a4","sequence":17}},"item":{"itemId":"5e234d9d-0744-4757-9629-761ea4e7f062","kind":"userMessage","turnId":"01a12d28-7912-7000-993a-afd019e6ac5c","revision":1,"status":"completed","recordedAt":"2026-10-11T22:49:49.620067Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nSpawn one subagent whose only task is to run the shell command `sleep 30` and then reply with exactly: Done. Wait for it to finish, then reply with exactly what it said.","commandId":"01a12d28-7912-7000-993a-afd019e6ac5c"}},"emittedAtMs":1791758989632}} +{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","viewCursor":"v:01a12d28-7788-7000-a946-c407f380868e:5","sourceRange":{"stream":{"kind":"session","id":"01a12d28-7788-7000-a946-c407f380868e"},"first":{"id":"5724636e-9398-4d23-87ac-1e9a2b83ad9f","sequence":19},"last":{"id":"5724636e-9398-4d23-87ac-1e9a2b83ad9f","sequence":19}},"item":{"itemId":"5e234d9d-0744-4757-9629-761ea4e7f062","kind":"userMessage","turnId":"01a12d28-7912-7000-993a-afd019e6ac5c","revision":2,"status":"completed","recordedAt":"2026-10-11T22:49:49.635395Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nSpawn one subagent whose only task is to run the shell command `sleep 30` and then reply with exactly: Done. Wait for it to finish, then reply with exactly what it said.","displayText":"Spawn one subagent whose only task is to run the shell command `sleep 30` and then reply with exactly: Done. Wait for it to finish, then reply with exactly what it said.","commandId":"01a12d28-7912-7000-993a-afd019e6ac5c"}},"emittedAtMs":1791758989642}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","viewCursor":"v:01a12d28-7788-7000-a946-c407f380868e:6","sourceRange":{"stream":{"kind":"session","id":"01a12d28-7788-7000-a946-c407f380868e"},"first":{"id":"44afb415-d6e4-4d51-9002-4deb196d463a","sequence":36},"last":{"id":"44afb415-d6e4-4d51-9002-4deb196d463a","sequence":36}},"item":{"itemId":"44afb415-d6e4-4d51-9002-4deb196d463a","kind":"reminderChild","turnId":"01a12d28-7912-7000-993a-afd019e6ac5c","revision":1,"status":"inProgress","recordedAt":"2026-10-11T22:49:49.743418Z","fallbackText":"Reminder child session","childSessionId":"4dd3511e-2115-4fbb-a4c4-383cbb383d63","reminderAgentId":"skill-reminder","generationId":1,"taskId":"01a12d28-7997-79d0-8a69-8ac0acf92a56"}},"emittedAtMs":1791758989750}} {"type":"emit_inbound","label":"session/tokenUsage","frame":{"jsonrpc":"2.0","method":"session/tokenUsage","params":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","viewCursor":"v:01a12d28-7788-7000-a946-c407f380868e:7","sourceRange":{"stream":{"kind":"session","id":"01a12d28-7788-7000-a946-c407f380868e"},"first":{"id":"c94f5484-20b7-4057-a736-303e97bc097c","sequence":52},"last":{"id":"c94f5484-20b7-4057-a736-303e97bc097c","sequence":52}},"turnId":"01a12d28-7912-7000-993a-afd019e6ac5c","modelId":"muse-spark-1.3-contributor","usage":{"inputTokens":32911,"outputTokens":264,"cachedTokens":0,"cacheWriteTokens":0,"cacheReadTokens":0,"reasoningTokens":143},"promptTokens":32911,"totalTokens":33175,"finishReason":"tool_calls","durationMs":3651,"cumulative":{"promptTokens":32911,"outputTokens":264,"totalTokens":33175,"cost":{"usd":0.0033439000000000003,"partial":false}}},"emittedAtMs":1791758993485}} {"type":"emit_inbound","label":"session/contextUsage","frame":{"jsonrpc":"2.0","method":"session/contextUsage","params":{"sessionId":"01a12d28-7788-7000-a946-c407f380868e","viewCursor":"v:01a12d28-7788-7000-a946-c407f380868e:8","sourceRange":{"stream":{"kind":"session","id":"01a12d28-7788-7000-a946-c407f380868e"},"first":{"id":"c94f5484-20b7-4057-a736-303e97bc097c","sequence":52},"last":{"id":"c94f5484-20b7-4057-a736-303e97bc097c","sequence":52}},"windowTokens":1007997,"usedTokens":33175,"pressure":"normal"},"emittedAtMs":1791758993485}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/muse_workflow/muse_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/muse_workflow/muse_transcript.ndjson index 714b315bf228..50f444b01d5f 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/muse_workflow/muse_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/muse_workflow/muse_transcript.ndjson @@ -8,13 +8,13 @@ {"type":"emit_inbound","label":"response:session/start","frame":{"jsonrpc":"2.0","id":2,"result":{"session":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","path":"~/.local/share/muse/sessions/2026/10/11/01a12d5d-405f-7000-89a3-5aa62af6ea23/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-11T23:47:28.587276Z","updatedAt":"2026-10-11T23:47:28.587281Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"sideFrom":null,"approvalMode":{"mode":"allowAll","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"},"viewCursor":"v:01a12d5d-405f-7000-89a3-5aa62af6ea23:2"}}} {"type":"emit_inbound","label":"session/approvalModeChanged","frame":{"jsonrpc":"2.0","method":"session/approvalModeChanged","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","viewCursor":"v:01a12d5d-405f-7000-89a3-5aa62af6ea23:1","sourceRange":{"stream":{"kind":"session","id":"01a12d5d-405f-7000-89a3-5aa62af6ea23"},"first":{"id":"0c13b14b-877c-4c5b-b05f-8ad250dfd381","sequence":5},"last":{"id":"0c13b14b-877c-4c5b-b05f-8ad250dfd381","sequence":5}},"mode":"allowAll","source":"approvalReconfigure","commandId":"01a12d5d-4060-7000-b6cd-aaabe26f80de","clientName":"t3_code"},"emittedAtMs":1791762448807}} {"type":"emit_inbound","label":"session/branchChanged","frame":{"jsonrpc":"2.0","method":"session/branchChanged","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","viewCursor":"v:01a12d5d-405f-7000-89a3-5aa62af6ea23:2","sourceRange":{"stream":{"kind":"session","id":"01a12d5d-405f-7000-89a3-5aa62af6ea23"},"first":{"id":"28b1f3f4-4448-4a21-a1a0-2df72ad1d326","sequence":6},"last":{"id":"28b1f3f4-4448-4a21-a1a0-2df72ad1d326","sequence":6}},"workspaceRoot":"","branch":"main","vcs":"git"},"emittedAtMs":1791762448807}} -{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent. Otherwise, when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When monitoring is done and you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\n"},{"type":"text","text":"Use your workflow tool to launch a workflow with exactly TWO agents: one labeled 'ping' that replies with the word PING, and one labeled 'pong' that replies with the word PONG. Launch it and end your turn; do not wait for it."}],"displayText":"Use your workflow tool to launch a workflow with exactly TWO agents: one labeled 'ping' that replies with the word PING, and one labeled 'pong' that replies with the word PONG. Launch it and end your turn; do not wait for it.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent. Otherwise, when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When monitoring is done and you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\n"},{"type":"text","text":"Use your workflow tool to launch a workflow with exactly TWO agents: one labeled 'ping' that replies with the word PING, and one labeled 'pong' that replies with the word PONG. Launch it and end your turn; do not wait for it."}],"displayText":"Use your workflow tool to launch a workflow with exactly TWO agents: one labeled 'ping' that replies with the word PING, and one labeled 'pong' that replies with the word PONG. Launch it and end your turn; do not wait for it.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5"}}} {"type":"emit_inbound","label":"session/branchChanged","frame":{"jsonrpc":"2.0","method":"session/branchChanged","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","viewCursor":"v:01a12d5d-405f-7000-89a3-5aa62af6ea23:3","sourceRange":{"stream":{"kind":"session","id":"01a12d5d-405f-7000-89a3-5aa62af6ea23"},"first":{"id":"3f1880e1-81a0-4f21-b536-6b4fa5cf9d12","sequence":16},"last":{"id":"3f1880e1-81a0-4f21-b536-6b4fa5cf9d12","sequence":16}},"workspaceRoot":"","branch":"main","vcs":"git"},"emittedAtMs":1791762449097}} {"type":"emit_inbound","label":"response:turn/start","frame":{"jsonrpc":"2.0","id":3,"result":{"commandId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5","status":"accepted","turnId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5","startedNewTurn":true,"disposition":"started"}}} {"type":"emit_inbound","label":"session/statusChanged","frame":{"jsonrpc":"2.0","method":"session/statusChanged","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","status":"running","viewCursor":"v:01a12d5d-405f-7000-89a3-5aa62af6ea23:4"},"emittedAtMs":1791762449102}} {"type":"emit_inbound","label":"turn/started","frame":{"jsonrpc":"2.0","method":"turn/started","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","viewCursor":"v:01a12d5d-405f-7000-89a3-5aa62af6ea23:4","sourceRange":{"stream":{"kind":"session","id":"01a12d5d-405f-7000-89a3-5aa62af6ea23"},"first":{"id":"7a830825-f571-4781-8e4f-a4ef5e085bb7","sequence":18},"last":{"id":"7a830825-f571-4781-8e4f-a4ef5e085bb7","sequence":18}},"turnId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5","commandId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5"},"emittedAtMs":1791762449103}} -{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","viewCursor":"v:01a12d5d-405f-7000-89a3-5aa62af6ea23:5","sourceRange":{"stream":{"kind":"session","id":"01a12d5d-405f-7000-89a3-5aa62af6ea23"},"first":{"id":"7a830825-f571-4781-8e4f-a4ef5e085bb7","sequence":18},"last":{"id":"7a830825-f571-4781-8e4f-a4ef5e085bb7","sequence":18}},"item":{"itemId":"0c1ceda9-41f7-4c3e-990e-4c53873c7ef5","kind":"userMessage","turnId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5","revision":1,"status":"completed","recordedAt":"2026-10-11T23:47:29.098117Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nUse your workflow tool to launch a workflow with exactly TWO agents: one labeled 'ping' that replies with the word PING, and one labeled 'pong' that replies with the word PONG. Launch it and end your turn; do not wait for it.","commandId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5"}},"emittedAtMs":1791762449103}} -{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","viewCursor":"v:01a12d5d-405f-7000-89a3-5aa62af6ea23:6","sourceRange":{"stream":{"kind":"session","id":"01a12d5d-405f-7000-89a3-5aa62af6ea23"},"first":{"id":"0411a5c8-2c8a-4c9e-bb67-25fa3cef21a3","sequence":20},"last":{"id":"0411a5c8-2c8a-4c9e-bb67-25fa3cef21a3","sequence":20}},"item":{"itemId":"0c1ceda9-41f7-4c3e-990e-4c53873c7ef5","kind":"userMessage","turnId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5","revision":2,"status":"completed","recordedAt":"2026-10-11T23:47:29.107019Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nUse your workflow tool to launch a workflow with exactly TWO agents: one labeled 'ping' that replies with the word PING, and one labeled 'pong' that replies with the word PONG. Launch it and end your turn; do not wait for it.","displayText":"Use your workflow tool to launch a workflow with exactly TWO agents: one labeled 'ping' that replies with the word PING, and one labeled 'pong' that replies with the word PONG. Launch it and end your turn; do not wait for it.","commandId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5"}},"emittedAtMs":1791762449131}} +{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","viewCursor":"v:01a12d5d-405f-7000-89a3-5aa62af6ea23:5","sourceRange":{"stream":{"kind":"session","id":"01a12d5d-405f-7000-89a3-5aa62af6ea23"},"first":{"id":"7a830825-f571-4781-8e4f-a4ef5e085bb7","sequence":18},"last":{"id":"7a830825-f571-4781-8e4f-a4ef5e085bb7","sequence":18}},"item":{"itemId":"0c1ceda9-41f7-4c3e-990e-4c53873c7ef5","kind":"userMessage","turnId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5","revision":1,"status":"completed","recordedAt":"2026-10-11T23:47:29.098117Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nUse your workflow tool to launch a workflow with exactly TWO agents: one labeled 'ping' that replies with the word PING, and one labeled 'pong' that replies with the word PONG. Launch it and end your turn; do not wait for it.","commandId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5"}},"emittedAtMs":1791762449103}} +{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","viewCursor":"v:01a12d5d-405f-7000-89a3-5aa62af6ea23:6","sourceRange":{"stream":{"kind":"session","id":"01a12d5d-405f-7000-89a3-5aa62af6ea23"},"first":{"id":"0411a5c8-2c8a-4c9e-bb67-25fa3cef21a3","sequence":20},"last":{"id":"0411a5c8-2c8a-4c9e-bb67-25fa3cef21a3","sequence":20}},"item":{"itemId":"0c1ceda9-41f7-4c3e-990e-4c53873c7ef5","kind":"userMessage","turnId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5","revision":2,"status":"completed","recordedAt":"2026-10-11T23:47:29.107019Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nUse your workflow tool to launch a workflow with exactly TWO agents: one labeled 'ping' that replies with the word PING, and one labeled 'pong' that replies with the word PONG. Launch it and end your turn; do not wait for it.","displayText":"Use your workflow tool to launch a workflow with exactly TWO agents: one labeled 'ping' that replies with the word PING, and one labeled 'pong' that replies with the word PONG. Launch it and end your turn; do not wait for it.","commandId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5"}},"emittedAtMs":1791762449131}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","viewCursor":"v:01a12d5d-405f-7000-89a3-5aa62af6ea23:7","sourceRange":{"stream":{"kind":"session","id":"01a12d5d-405f-7000-89a3-5aa62af6ea23"},"first":{"id":"95574bfe-853f-44cc-81f6-88b05d6c8c16","sequence":38},"last":{"id":"95574bfe-853f-44cc-81f6-88b05d6c8c16","sequence":38}},"item":{"itemId":"95574bfe-853f-44cc-81f6-88b05d6c8c16","kind":"reminderChild","turnId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5","revision":1,"status":"inProgress","recordedAt":"2026-10-11T23:47:29.328939Z","fallbackText":"Reminder child session","childSessionId":"b16dbb52-7f62-4a5a-84c2-f68d7ea22b18","reminderAgentId":"skill-reminder","generationId":1,"taskId":"01a12d5d-4375-7d72-b5b2-4b096d0df507"}},"emittedAtMs":1791762449336}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","viewCursor":"v:01a12d5d-405f-7000-89a3-5aa62af6ea23:7.1","item":{"itemId":"3ce724bc-dacf-400e-af9a-995104717cb1","kind":"reasoning","turnId":"01a12d5d-42c3-7000-ab94-c7f56ea457e5","revision":1,"status":"inProgress","summary":[""]}},"emittedAtMs":1791762451554}} {"type":"emit_inbound","label":"item/delta","frame":{"jsonrpc":"2.0","method":"item/delta","params":{"sessionId":"01a12d5d-405f-7000-89a3-5aa62af6ea23","viewCursor":"v:01a12d5d-405f-7000-89a3-5aa62af6ea23:7.2","itemId":"3ce724bc-dacf-400e-af9a-995104717cb1","field":"summary.0","delta":"Launching a two-agent ping-pong workflow via the workflow tool while skipping skill reading for a trivial task."},"emittedAtMs":1791762451554}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/queued_turn/grok_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/queued_turn/grok_transcript.ndjson index c8937afa2753..e1343593df51 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/queued_turn/grok_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/queued_turn/grok_transcript.ndjson @@ -23,8 +23,8 @@ {"type":"emit_inbound","label":"notification:_x.ai/models/update","frame":{"kind":"notification","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}}]}}} {"type":"expect_outbound","label":"request:session/prompt","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"00000000-0000-4000-8000-000000000001","prompt":[{"type":"text","text":"\n\n\n\n\nRespond with exactly: first fixture turn complete\n"}],"_meta":{"promptId":"t3-xai-prompt-1","requestId":"t3-xai-prompt-1"}}}} {"type":"emit_inbound","label":"notification:_x.ai/sessions/changed","frame":{"kind":"notification","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"00000000-0000-4000-8000-000000000001","title":null,"cwd":"","isWorktree":false,"modelId":"grok-4.7","reasoningEffort":"high","yolo":false,"activity":"working","resident":true,"lastChangeUnixMs":1790300065163,"origin":{"kind":"local"}}],"removed":[]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with exactly: first fixture turn complete\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with exactly: first fixture turn complete\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with exactly: first fixture turn complete\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with exactly: first fixture turn complete\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} {"type":"emit_inbound","label":"notification:_x.ai/models/update","frame":{"kind":"notification","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}}]}}} {"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}},"_meta":{"totalTokens":2641,"eventId":"00000000-0000-4000-8000-000000000001-5","agentTimestampMs":1790300066681,"promptId":"t3-xai-prompt-1","streamStartMs":1790300065806,"turnStartMs":1790300065197,"updateType":"AgentThoughtChunk","chunkId":1}}}} {"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}},"_meta":{"totalTokens":2641,"eventId":"00000000-0000-4000-8000-000000000001-6","agentTimestampMs":1790300066681,"promptId":"t3-xai-prompt-1","streamStartMs":1790300065806,"turnStartMs":1790300065197,"updateType":"AgentThoughtChunk","chunkId":2}}}} @@ -54,8 +54,8 @@ {"type":"emit_inbound","label":"response:session/prompt","frame":{"kind":"response","method":"session/prompt","result":{"stopReason":"end_turn","_meta":{"sessionId":"00000000-0000-4000-8000-000000000001","requestId":"t3-xai-prompt-1","promptId":"t3-xai-prompt-1","totalTokens":22196,"modelId":"grok-4.7","inputTokens":22152,"outputTokens":36,"cachedReadTokens":1664,"reasoningTokens":32,"usage":{"inputTokens":22152,"outputTokens":36,"totalTokens":22188,"cachedReadTokens":1664,"cacheCreationTokens":0,"reasoningTokens":32,"modelCalls":1,"apiDurationMs":1589,"costUsdTicks":142881600,"modelUsage":{"grok-4.7-build":{"inputTokens":22152,"outputTokens":36,"totalTokens":22188,"cachedReadTokens":1664,"cacheCreationTokens":0,"reasoningTokens":32,"modelCalls":1,"apiDurationMs":1589,"costUsdTicks":142881600}},"numTurns":1}}}}} {"type":"expect_outbound","label":"request:session/prompt","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"00000000-0000-4000-8000-000000000001","prompt":[{"type":"text","text":"Respond with exactly: second fixture turn complete"}],"_meta":{"promptId":"t3-xai-prompt-2","requestId":"t3-xai-prompt-2"}}}} {"type":"emit_inbound","label":"notification:_x.ai/sessions/changed","frame":{"kind":"notification","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"00000000-0000-4000-8000-000000000001","title":null,"cwd":"","isWorktree":false,"modelId":"grok-4.7","reasoningEffort":"high","yolo":false,"activity":"working","resident":true,"lastChangeUnixMs":1790300067131,"origin":{"kind":"local"}}],"removed":[]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-2","version":0,"kind":"prompt","text":"Respond with exactly: second fixture turn complete\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-2","runningText":"Respond with exactly: second fixture turn complete\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-2","version":0,"kind":"prompt","text":"Respond with exactly: second fixture turn complete\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-2","runningText":"Respond with exactly: second fixture turn complete\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} {"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}},"_meta":{"totalTokens":22480,"eventId":"00000000-0000-4000-8000-000000000001-31","agentTimestampMs":1790300068257,"promptId":"t3-xai-prompt-2","streamStartMs":1790300067703,"turnStartMs":1790300067162,"updateType":"AgentThoughtChunk","chunkId":1}}}} {"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}},"_meta":{"totalTokens":22480,"eventId":"00000000-0000-4000-8000-000000000001-32","agentTimestampMs":1790300068257,"promptId":"t3-xai-prompt-2","streamStartMs":1790300067703,"turnStartMs":1790300067162,"updateType":"AgentThoughtChunk","chunkId":2}}}} {"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}},"_meta":{"totalTokens":22480,"eventId":"00000000-0000-4000-8000-000000000001-33","agentTimestampMs":1790300068258,"promptId":"t3-xai-prompt-2","streamStartMs":1790300067703,"turnStartMs":1790300067162,"updateType":"AgentThoughtChunk","chunkId":3}}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/simple/grok_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/simple/grok_transcript.ndjson index cdc7f802140d..463c61b43fd1 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/simple/grok_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/simple/grok_transcript.ndjson @@ -24,8 +24,8 @@ {"type":"emit_inbound","label":"notification:_x.ai/session/setup","frame":{"kind":"notification","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"response_ready","sessionId":"00000000-0000-4000-8000-000000000001"}}} {"type":"expect_outbound","label":"request:session/prompt","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"00000000-0000-4000-8000-000000000001","prompt":[{"type":"text","text":"\n\n\n\n\nRespond with the following text: fixture simple ok\n"}],"_meta":{"promptId":"t3-xai-prompt-1","requestId":"t3-xai-prompt-1"}}}} {"type":"emit_inbound","label":"notification:_x.ai/sessions/changed","frame":{"kind":"notification","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"00000000-0000-4000-8000-000000000001","title":null,"cwd":"","isWorktree":false,"modelId":"grok-4.7","reasoningEffort":"high","yolo":false,"activity":"working","resident":true,"lastChangeUnixMs":1790301055553,"origin":{"kind":"local"}}],"removed":[]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with the following text: fixture simple ok\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with the following text: fixture simple ok\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with the following text: fixture simple ok\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with the following text: fixture simple ok\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} {"type":"emit_inbound","label":"notification:_x.ai/models/update","frame":{"kind":"notification","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}}]}}} {"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}},"_meta":{"totalTokens":2716,"eventId":"00000000-0000-4000-8000-000000000001-5","agentTimestampMs":1790301057223,"promptId":"t3-xai-prompt-1","streamStartMs":1790301056936,"turnStartMs":1790301055613,"updateType":"AgentThoughtChunk","chunkId":1}}}} {"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}},"_meta":{"totalTokens":2716,"eventId":"00000000-0000-4000-8000-000000000001-6","agentTimestampMs":1790301057223,"promptId":"t3-xai-prompt-1","streamStartMs":1790301056936,"turnStartMs":1790301055613,"updateType":"AgentThoughtChunk","chunkId":2}}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/simple/muse_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/simple/muse_transcript.ndjson index 8890613a9d77..3d402db32193 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/simple/muse_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/simple/muse_transcript.ndjson @@ -7,13 +7,13 @@ {"type":"emit_inbound","label":"session/started","frame":{"jsonrpc":"2.0","method":"session/started","params":{"session":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","path":"~/.local/share/muse/sessions/2026/10/08/01a11a6a-bbb5-7000-ad0b-0c9e728406e5/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-08T07:29:25.018803Z","updatedAt":"2026-10-08T07:29:25.018807Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"approvalMode":{"mode":"allowAll","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"}},"emittedAtMs":1791444565334}} {"type":"emit_inbound","label":"response:session/start","frame":{"jsonrpc":"2.0","id":2,"result":{"session":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","path":"~/.local/share/muse/sessions/2026/10/08/01a11a6a-bbb5-7000-ad0b-0c9e728406e5/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-08T07:29:25.018803Z","updatedAt":"2026-10-08T07:29:25.018807Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"approvalMode":{"mode":"allowAll","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"},"viewCursor":"v:01a11a6a-bbb5-7000-ad0b-0c9e728406e5:1"}}} {"type":"emit_inbound","label":"session/approvalModeChanged","frame":{"jsonrpc":"2.0","method":"session/approvalModeChanged","params":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","viewCursor":"v:01a11a6a-bbb5-7000-ad0b-0c9e728406e5:1","sourceRange":{"stream":{"kind":"session","id":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5"},"first":{"id":"cfd043e8-8728-493d-ba28-252ef91e8a80","sequence":5},"last":{"id":"cfd043e8-8728-493d-ba28-252ef91e8a80","sequence":5}},"mode":"allowAll","source":"approvalReconfigure","commandId":"01a11a6a-bbb6-7000-b1a4-a01ea59676ef","clientName":"t3_code"},"emittedAtMs":1791444565340}} -{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent. Otherwise, when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When monitoring is done and you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\n"},{"type":"text","text":"Respond with the following text: fixture simple ok"}],"displayText":"Respond with the following text: fixture simple ok","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a11a6a-be41-7000-8db9-1d84c1b8be48"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent. Otherwise, when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When monitoring is done and you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\n"},{"type":"text","text":"Respond with the following text: fixture simple ok"}],"displayText":"Respond with the following text: fixture simple ok","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a11a6a-be41-7000-8db9-1d84c1b8be48"}}} {"type":"emit_inbound","label":"session/branchChanged","frame":{"jsonrpc":"2.0","method":"session/branchChanged","params":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","viewCursor":"v:01a11a6a-bbb5-7000-ad0b-0c9e728406e5:2","sourceRange":{"stream":{"kind":"session","id":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5"},"first":{"id":"0f64f4ea-a08c-43e1-af53-cf85cee1a11c","sequence":16},"last":{"id":"0f64f4ea-a08c-43e1-af53-cf85cee1a11c","sequence":16}},"workspaceRoot":"","branch":"main","vcs":"git"},"emittedAtMs":1791444565612}} {"type":"emit_inbound","label":"response:turn/start","frame":{"jsonrpc":"2.0","id":3,"result":{"commandId":"01a11a6a-be41-7000-8db9-1d84c1b8be48","status":"accepted","turnId":"01a11a6a-be41-7000-8db9-1d84c1b8be48","startedNewTurn":true,"disposition":"started"}}} {"type":"emit_inbound","label":"session/statusChanged","frame":{"jsonrpc":"2.0","method":"session/statusChanged","params":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","status":"running","viewCursor":"v:01a11a6a-bbb5-7000-ad0b-0c9e728406e5:3"},"emittedAtMs":1791444565635}} {"type":"emit_inbound","label":"turn/started","frame":{"jsonrpc":"2.0","method":"turn/started","params":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","viewCursor":"v:01a11a6a-bbb5-7000-ad0b-0c9e728406e5:3","sourceRange":{"stream":{"kind":"session","id":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5"},"first":{"id":"1f33a521-7829-4cc3-97db-a3b5a6e7deaf","sequence":18},"last":{"id":"1f33a521-7829-4cc3-97db-a3b5a6e7deaf","sequence":18}},"turnId":"01a11a6a-be41-7000-8db9-1d84c1b8be48","commandId":"01a11a6a-be41-7000-8db9-1d84c1b8be48"},"emittedAtMs":1791444565635}} -{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","viewCursor":"v:01a11a6a-bbb5-7000-ad0b-0c9e728406e5:4","sourceRange":{"stream":{"kind":"session","id":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5"},"first":{"id":"1f33a521-7829-4cc3-97db-a3b5a6e7deaf","sequence":18},"last":{"id":"1f33a521-7829-4cc3-97db-a3b5a6e7deaf","sequence":18}},"item":{"itemId":"23294b54-8e67-4896-a7f3-681119e1ddbe","kind":"userMessage","turnId":"01a11a6a-be41-7000-8db9-1d84c1b8be48","revision":1,"status":"completed","recordedAt":"2026-10-08T07:29:25.627042Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nRespond with the following text: fixture simple ok","commandId":"01a11a6a-be41-7000-8db9-1d84c1b8be48"}},"emittedAtMs":1791444565635}} -{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","viewCursor":"v:01a11a6a-bbb5-7000-ad0b-0c9e728406e5:5","sourceRange":{"stream":{"kind":"session","id":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5"},"first":{"id":"8c00aa21-7809-4cb2-89f7-b499aaa8746d","sequence":20},"last":{"id":"8c00aa21-7809-4cb2-89f7-b499aaa8746d","sequence":20}},"item":{"itemId":"23294b54-8e67-4896-a7f3-681119e1ddbe","kind":"userMessage","turnId":"01a11a6a-be41-7000-8db9-1d84c1b8be48","revision":2,"status":"completed","recordedAt":"2026-10-08T07:29:25.643029Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nRespond with the following text: fixture simple ok","displayText":"Respond with the following text: fixture simple ok","commandId":"01a11a6a-be41-7000-8db9-1d84c1b8be48"}},"emittedAtMs":1791444565652}} +{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","viewCursor":"v:01a11a6a-bbb5-7000-ad0b-0c9e728406e5:4","sourceRange":{"stream":{"kind":"session","id":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5"},"first":{"id":"1f33a521-7829-4cc3-97db-a3b5a6e7deaf","sequence":18},"last":{"id":"1f33a521-7829-4cc3-97db-a3b5a6e7deaf","sequence":18}},"item":{"itemId":"23294b54-8e67-4896-a7f3-681119e1ddbe","kind":"userMessage","turnId":"01a11a6a-be41-7000-8db9-1d84c1b8be48","revision":1,"status":"completed","recordedAt":"2026-10-08T07:29:25.627042Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nRespond with the following text: fixture simple ok","commandId":"01a11a6a-be41-7000-8db9-1d84c1b8be48"}},"emittedAtMs":1791444565635}} +{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","viewCursor":"v:01a11a6a-bbb5-7000-ad0b-0c9e728406e5:5","sourceRange":{"stream":{"kind":"session","id":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5"},"first":{"id":"8c00aa21-7809-4cb2-89f7-b499aaa8746d","sequence":20},"last":{"id":"8c00aa21-7809-4cb2-89f7-b499aaa8746d","sequence":20}},"item":{"itemId":"23294b54-8e67-4896-a7f3-681119e1ddbe","kind":"userMessage","turnId":"01a11a6a-be41-7000-8db9-1d84c1b8be48","revision":2,"status":"completed","recordedAt":"2026-10-08T07:29:25.643029Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nRespond with the following text: fixture simple ok","displayText":"Respond with the following text: fixture simple ok","commandId":"01a11a6a-be41-7000-8db9-1d84c1b8be48"}},"emittedAtMs":1791444565652}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","viewCursor":"v:01a11a6a-bbb5-7000-ad0b-0c9e728406e5:6","sourceRange":{"stream":{"kind":"session","id":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5"},"first":{"id":"ed1680a5-6f99-4b0f-b9ea-d12193f0de1f","sequence":37},"last":{"id":"ed1680a5-6f99-4b0f-b9ea-d12193f0de1f","sequence":37}},"item":{"itemId":"ed1680a5-6f99-4b0f-b9ea-d12193f0de1f","kind":"reminderChild","turnId":"01a11a6a-be41-7000-8db9-1d84c1b8be48","revision":1,"status":"inProgress","recordedAt":"2026-10-08T07:29:25.781511Z","fallbackText":"Reminder child session","childSessionId":"9785bf9d-382d-4781-ab3b-3817c9e6a604","reminderAgentId":"skill-reminder","generationId":1,"taskId":"01a11a6a-bef1-7032-974a-059b8765c487"}},"emittedAtMs":1791444565789}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","viewCursor":"v:01a11a6a-bbb5-7000-ad0b-0c9e728406e5:6.1","item":{"itemId":"0a68e576-f554-4700-8853-a68ad2b7179b","kind":"agentMessage","turnId":"01a11a6a-be41-7000-8db9-1d84c1b8be48","revision":1,"status":"inProgress","text":""}},"emittedAtMs":1791444567707}} {"type":"emit_inbound","label":"item/delta","frame":{"jsonrpc":"2.0","method":"item/delta","params":{"sessionId":"01a11a6a-bbb5-7000-ad0b-0c9e728406e5","viewCursor":"v:01a11a6a-bbb5-7000-ad0b-0c9e728406e5:6.2","itemId":"0a68e576-f554-4700-8853-a68ad2b7179b","field":"text","delta":"fixture simple ok"},"emittedAtMs":1791444567707}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2/muse_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2/muse_transcript.ndjson index 965d0f17acdf..8e98fccc55e4 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2/muse_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2/muse_transcript.ndjson @@ -7,13 +7,13 @@ {"type":"emit_inbound","label":"session/started","frame":{"jsonrpc":"2.0","method":"session/started","params":{"session":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","path":"~/.local/share/muse/sessions/2026/10/11/01a12d17-75fa-7000-9157-e8365ce94492/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-11T22:31:14.73121Z","updatedAt":"2026-10-11T22:31:14.731214Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"sideFrom":null,"approvalMode":{"mode":"allowAll","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"}},"emittedAtMs":1791757874894}} {"type":"emit_inbound","label":"response:session/start","frame":{"jsonrpc":"2.0","id":2,"result":{"session":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","path":"~/.local/share/muse/sessions/2026/10/11/01a12d17-75fa-7000-9157-e8365ce94492/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-11T22:31:14.73121Z","updatedAt":"2026-10-11T22:31:14.731214Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"sideFrom":null,"approvalMode":{"mode":"allowAll","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"},"viewCursor":"v:01a12d17-75fa-7000-9157-e8365ce94492:1"}}} {"type":"emit_inbound","label":"session/approvalModeChanged","frame":{"jsonrpc":"2.0","method":"session/approvalModeChanged","params":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","viewCursor":"v:01a12d17-75fa-7000-9157-e8365ce94492:1","sourceRange":{"stream":{"kind":"session","id":"01a12d17-75fa-7000-9157-e8365ce94492"},"first":{"id":"94d1e1ed-1d3e-4cd5-91bf-a51394fc8a0a","sequence":5},"last":{"id":"94d1e1ed-1d3e-4cd5-91bf-a51394fc8a0a","sequence":5}},"mode":"allowAll","source":"approvalReconfigure","commandId":"01a12d17-75fa-7001-a940-79b5d7c648a4","clientName":"t3_code"},"emittedAtMs":1791757874898}} -{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent. Otherwise, when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When monitoring is done and you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\n"},{"type":"text","text":"Spawn one subagent whose only task is to reply with exactly: Hello. Wait for it to finish, then reply with exactly what it said."}],"displayText":"Spawn one subagent whose only task is to reply with exactly: Hello. Wait for it to finish, then reply with exactly what it said.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a12d17-7790-7000-967d-3f2246f38a60"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent. Otherwise, when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When monitoring is done and you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\n"},{"type":"text","text":"Spawn one subagent whose only task is to reply with exactly: Hello. Wait for it to finish, then reply with exactly what it said."}],"displayText":"Spawn one subagent whose only task is to reply with exactly: Hello. Wait for it to finish, then reply with exactly what it said.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a12d17-7790-7000-967d-3f2246f38a60"}}} {"type":"emit_inbound","label":"session/branchChanged","frame":{"jsonrpc":"2.0","method":"session/branchChanged","params":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","viewCursor":"v:01a12d17-75fa-7000-9157-e8365ce94492:2","sourceRange":{"stream":{"kind":"session","id":"01a12d17-75fa-7000-9157-e8365ce94492"},"first":{"id":"1dc98410-05c3-4618-a431-3638055e20df","sequence":15},"last":{"id":"1dc98410-05c3-4618-a431-3638055e20df","sequence":15}},"workspaceRoot":"","branch":"main","vcs":"git"},"emittedAtMs":1791757875120}} {"type":"emit_inbound","label":"response:turn/start","frame":{"jsonrpc":"2.0","id":3,"result":{"commandId":"01a12d17-7790-7000-967d-3f2246f38a60","status":"accepted","turnId":"01a12d17-7790-7000-967d-3f2246f38a60","startedNewTurn":true,"disposition":"started"}}} {"type":"emit_inbound","label":"session/statusChanged","frame":{"jsonrpc":"2.0","method":"session/statusChanged","params":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","status":"running","viewCursor":"v:01a12d17-75fa-7000-9157-e8365ce94492:3"},"emittedAtMs":1791757875131}} {"type":"emit_inbound","label":"turn/started","frame":{"jsonrpc":"2.0","method":"turn/started","params":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","viewCursor":"v:01a12d17-75fa-7000-9157-e8365ce94492:3","sourceRange":{"stream":{"kind":"session","id":"01a12d17-75fa-7000-9157-e8365ce94492"},"first":{"id":"766cb840-a6f1-41f2-8c7e-8c372d0470cd","sequence":17},"last":{"id":"766cb840-a6f1-41f2-8c7e-8c372d0470cd","sequence":17}},"turnId":"01a12d17-7790-7000-967d-3f2246f38a60","commandId":"01a12d17-7790-7000-967d-3f2246f38a60"},"emittedAtMs":1791757875132}} -{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","viewCursor":"v:01a12d17-75fa-7000-9157-e8365ce94492:4","sourceRange":{"stream":{"kind":"session","id":"01a12d17-75fa-7000-9157-e8365ce94492"},"first":{"id":"766cb840-a6f1-41f2-8c7e-8c372d0470cd","sequence":17},"last":{"id":"766cb840-a6f1-41f2-8c7e-8c372d0470cd","sequence":17}},"item":{"itemId":"95c179c8-e8f8-4629-80df-e28243a234ea","kind":"userMessage","turnId":"01a12d17-7790-7000-967d-3f2246f38a60","revision":1,"status":"completed","recordedAt":"2026-10-11T22:31:15.120499Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nSpawn one subagent whose only task is to reply with exactly: Hello. Wait for it to finish, then reply with exactly what it said.","commandId":"01a12d17-7790-7000-967d-3f2246f38a60"}},"emittedAtMs":1791757875132}} -{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","viewCursor":"v:01a12d17-75fa-7000-9157-e8365ce94492:5","sourceRange":{"stream":{"kind":"session","id":"01a12d17-75fa-7000-9157-e8365ce94492"},"first":{"id":"dcf03486-5149-4377-b325-dea3e118628a","sequence":19},"last":{"id":"dcf03486-5149-4377-b325-dea3e118628a","sequence":19}},"item":{"itemId":"95c179c8-e8f8-4629-80df-e28243a234ea","kind":"userMessage","turnId":"01a12d17-7790-7000-967d-3f2246f38a60","revision":2,"status":"completed","recordedAt":"2026-10-11T22:31:15.136047Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nSpawn one subagent whose only task is to reply with exactly: Hello. Wait for it to finish, then reply with exactly what it said.","displayText":"Spawn one subagent whose only task is to reply with exactly: Hello. Wait for it to finish, then reply with exactly what it said.","commandId":"01a12d17-7790-7000-967d-3f2246f38a60"}},"emittedAtMs":1791757875144}} +{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","viewCursor":"v:01a12d17-75fa-7000-9157-e8365ce94492:4","sourceRange":{"stream":{"kind":"session","id":"01a12d17-75fa-7000-9157-e8365ce94492"},"first":{"id":"766cb840-a6f1-41f2-8c7e-8c372d0470cd","sequence":17},"last":{"id":"766cb840-a6f1-41f2-8c7e-8c372d0470cd","sequence":17}},"item":{"itemId":"95c179c8-e8f8-4629-80df-e28243a234ea","kind":"userMessage","turnId":"01a12d17-7790-7000-967d-3f2246f38a60","revision":1,"status":"completed","recordedAt":"2026-10-11T22:31:15.120499Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nSpawn one subagent whose only task is to reply with exactly: Hello. Wait for it to finish, then reply with exactly what it said.","commandId":"01a12d17-7790-7000-967d-3f2246f38a60"}},"emittedAtMs":1791757875132}} +{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","viewCursor":"v:01a12d17-75fa-7000-9157-e8365ce94492:5","sourceRange":{"stream":{"kind":"session","id":"01a12d17-75fa-7000-9157-e8365ce94492"},"first":{"id":"dcf03486-5149-4377-b325-dea3e118628a","sequence":19},"last":{"id":"dcf03486-5149-4377-b325-dea3e118628a","sequence":19}},"item":{"itemId":"95c179c8-e8f8-4629-80df-e28243a234ea","kind":"userMessage","turnId":"01a12d17-7790-7000-967d-3f2246f38a60","revision":2,"status":"completed","recordedAt":"2026-10-11T22:31:15.136047Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nSpawn one subagent whose only task is to reply with exactly: Hello. Wait for it to finish, then reply with exactly what it said.","displayText":"Spawn one subagent whose only task is to reply with exactly: Hello. Wait for it to finish, then reply with exactly what it said.","commandId":"01a12d17-7790-7000-967d-3f2246f38a60"}},"emittedAtMs":1791757875144}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","viewCursor":"v:01a12d17-75fa-7000-9157-e8365ce94492:6","sourceRange":{"stream":{"kind":"session","id":"01a12d17-75fa-7000-9157-e8365ce94492"},"first":{"id":"599a220a-d212-4a81-a60d-7284ff290284","sequence":36},"last":{"id":"599a220a-d212-4a81-a60d-7284ff290284","sequence":36}},"item":{"itemId":"599a220a-d212-4a81-a60d-7284ff290284","kind":"reminderChild","turnId":"01a12d17-7790-7000-967d-3f2246f38a60","revision":1,"status":"inProgress","recordedAt":"2026-10-11T22:31:15.249949Z","fallbackText":"Reminder child session","childSessionId":"0b3f4f01-850a-43bd-af36-9b011699354f","reminderAgentId":"skill-reminder","generationId":1,"taskId":"01a12d17-7819-7212-8057-3558c2bdd46d"}},"emittedAtMs":1791757875258}} {"type":"emit_inbound","label":"session/tokenUsage","frame":{"jsonrpc":"2.0","method":"session/tokenUsage","params":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","viewCursor":"v:01a12d17-75fa-7000-9157-e8365ce94492:7","sourceRange":{"stream":{"kind":"session","id":"01a12d17-75fa-7000-9157-e8365ce94492"},"first":{"id":"9198dd8d-eede-487a-9109-a48d2563c371","sequence":51},"last":{"id":"9198dd8d-eede-487a-9109-a48d2563c371","sequence":51}},"turnId":"01a12d17-7790-7000-967d-3f2246f38a60","modelId":"muse-spark-1.3-contributor","usage":{"inputTokens":32900,"outputTokens":129,"cachedTokens":0,"cacheWriteTokens":0,"cacheReadTokens":0,"reasoningTokens":36},"promptTokens":32900,"totalTokens":33029,"finishReason":"tool_calls","durationMs":2354,"cumulative":{"promptTokens":32900,"outputTokens":129,"totalTokens":33029,"cost":{"usd":0.0033158000000000003,"partial":false}}},"emittedAtMs":1791757877764}} {"type":"emit_inbound","label":"session/contextUsage","frame":{"jsonrpc":"2.0","method":"session/contextUsage","params":{"sessionId":"01a12d17-75fa-7000-9157-e8365ce94492","viewCursor":"v:01a12d17-75fa-7000-9157-e8365ce94492:8","sourceRange":{"stream":{"kind":"session","id":"01a12d17-75fa-7000-9157-e8365ce94492"},"first":{"id":"9198dd8d-eede-487a-9109-a48d2563c371","sequence":51},"last":{"id":"9198dd8d-eede-487a-9109-a48d2563c371","sequence":51}},"windowTokens":1007997,"usedTokens":33029,"pressure":"normal"},"emittedAtMs":1791757877764}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/todo_list/grok_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/todo_list/grok_transcript.ndjson index 0ca06d35889b..61770a4ef9c9 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/todo_list/grok_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/todo_list/grok_transcript.ndjson @@ -23,8 +23,8 @@ {"type":"emit_inbound","label":"notification:_x.ai/models/update","frame":{"kind":"notification","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}}]}}} {"type":"expect_outbound","label":"request:session/prompt","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"00000000-0000-4000-8000-000000000001","prompt":[{"type":"text","text":"\n\n\n\n\nUse the update_plan tool to track exactly three steps: inspect package.json, inspect tsconfig.json, report completion. Then read package.json and tsconfig.json, and answer exactly: todo list fixture complete\n"}],"_meta":{"promptId":"t3-xai-prompt-1","requestId":"t3-xai-prompt-1"}}}} {"type":"emit_inbound","label":"notification:_x.ai/sessions/changed","frame":{"kind":"notification","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"00000000-0000-4000-8000-000000000001","title":null,"cwd":"","isWorktree":false,"modelId":"grok-4.7","reasoningEffort":"high","yolo":true,"activity":"working","resident":true,"lastChangeUnixMs":1790329153513,"origin":{"kind":"local"}}],"removed":[]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nUse the update_plan tool to track exactly three steps: inspect package.json, inspect tsconfig.json, report completion. Then read package.json and tsconfig.json, and answer exactly: todo list fixture complete\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nUse the update_plan tool to track exactly three steps: inspect package.json, inspect tsconfig.json, report completion. Then read package.json and tsconfig.json, and answer exactly: todo list fixture complete\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nUse the update_plan tool to track exactly three steps: inspect package.json, inspect tsconfig.json, report completion. Then read package.json and tsconfig.json, and answer exactly: todo list fixture complete\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nUse the update_plan tool to track exactly three steps: inspect package.json, inspect tsconfig.json, report completion. Then read package.json and tsconfig.json, and answer exactly: todo list fixture complete\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} {"type":"emit_inbound","label":"notification:_x.ai/models/update","frame":{"kind":"notification","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}}]}}} {"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}},"_meta":{"totalTokens":2680,"eventId":"00000000-0000-4000-8000-000000000001-5","agentTimestampMs":1790329155080,"promptId":"t3-xai-prompt-1","streamStartMs":1790329154153,"turnStartMs":1790329153545,"updateType":"AgentThoughtChunk","chunkId":1}}}} {"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}},"_meta":{"totalTokens":2680,"eventId":"00000000-0000-4000-8000-000000000001-6","agentTimestampMs":1790329155080,"promptId":"t3-xai-prompt-1","streamStartMs":1790329154153,"turnStartMs":1790329153545,"updateType":"AgentThoughtChunk","chunkId":2}}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/tool_call_read_only_on_request/grok_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/tool_call_read_only_on_request/grok_transcript.ndjson index 81590cc061e7..b715de016e7d 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/tool_call_read_only_on_request/grok_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/tool_call_read_only_on_request/grok_transcript.ndjson @@ -24,8 +24,8 @@ {"type":"emit_inbound","label":"notification:_x.ai/session/setup","frame":{"kind":"notification","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"response_ready","sessionId":"00000000-0000-4000-8000-000000000001"}}} {"type":"expect_outbound","label":"request:session/prompt","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"00000000-0000-4000-8000-000000000001","prompt":[{"type":"text","text":"\n\n\n\n\nCreate or overwrite .codex-probe-write-action.txt with exactly this text: codex app-server approval fixture. Use a local shell command or file edit only, then briefly report what happened. Do not read package metadata, use GitHub, use web, or use MCP.\n"}],"_meta":{"promptId":"t3-xai-prompt-1","requestId":"t3-xai-prompt-1"}}}} {"type":"emit_inbound","label":"notification:_x.ai/sessions/changed","frame":{"kind":"notification","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"00000000-0000-4000-8000-000000000001","title":null,"cwd":"","isWorktree":false,"modelId":"grok-4.7","reasoningEffort":"high","yolo":false,"activity":"working","resident":true,"lastChangeUnixMs":1790329142568,"origin":{"kind":"local"}}],"removed":[]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nCreate or overwrite .codex-probe-write-action.txt with exactly this text: codex app-server approval fixture. Use a local shell command or file edit only, then briefly report what happened. Do not read package metadata, use GitHub, use web, or use MCP.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nCreate or overwrite .codex-probe-write-action.txt with exactly this text: codex app-server approval fixture. Use a local shell command or file edit only, then briefly report what happened. Do not read package metadata, use GitHub, use web, or use MCP.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nCreate or overwrite .codex-probe-write-action.txt with exactly this text: codex app-server approval fixture. Use a local shell command or file edit only, then briefly report what happened. Do not read package metadata, use GitHub, use web, or use MCP.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nCreate or overwrite .codex-probe-write-action.txt with exactly this text: codex app-server approval fixture. Use a local shell command or file edit only, then briefly report what happened. Do not read package metadata, use GitHub, use web, or use MCP.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} {"type":"emit_inbound","label":"notification:_x.ai/models/update","frame":{"kind":"notification","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}}]}}} {"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}},"_meta":{"totalTokens":2691,"eventId":"00000000-0000-4000-8000-000000000001-5","agentTimestampMs":1790329143906,"promptId":"t3-xai-prompt-1","streamStartMs":1790329143232,"turnStartMs":1790329142589,"updateType":"AgentThoughtChunk","chunkId":1}}}} {"type":"emit_inbound","label":"notification:session/update:agent_thought_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}},"_meta":{"totalTokens":2691,"eventId":"00000000-0000-4000-8000-000000000001-6","agentTimestampMs":1790329143906,"promptId":"t3-xai-prompt-1","streamStartMs":1790329143232,"turnStartMs":1790329142589,"updateType":"AgentThoughtChunk","chunkId":2}}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt/grok_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt/grok_transcript.ndjson index 100804ae950c..5cdbc179a518 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt/grok_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt/grok_transcript.ndjson @@ -23,6 +23,6 @@ {"type":"emit_inbound","label":"notification:_x.ai/models/update","frame":{"kind":"notification","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"SpaceXAI's latest frontier model","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}]}}]}}} {"type":"expect_outbound","label":"request:session/prompt","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"00000000-0000-4000-8000-000000000001","prompt":[{"type":"text","text":"\n\n\n\n\nDo not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally.\n"}],"_meta":{"promptId":"t3-xai-prompt-1","requestId":"t3-xai-prompt-1"}}}} {"type":"emit_inbound","label":"notification:_x.ai/sessions/changed","frame":{"kind":"notification","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"00000000-0000-4000-8000-000000000001","title":null,"cwd":"","isWorktree":false,"modelId":"grok-4.7","reasoningEffort":"high","yolo":false,"activity":"working","resident":true,"lastChangeUnixMs":1790300234968,"origin":{"kind":"local"}}],"removed":[]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nDo not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} -{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nDo not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[{"id":"t3-xai-prompt-1","version":0,"kind":"prompt","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nDo not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","position":0}]}}} +{"type":"emit_inbound","label":"notification:_x.ai/queue/changed","frame":{"kind":"notification","method":"_x.ai/queue/changed","params":{"sessionId":"00000000-0000-4000-8000-000000000001","entries":[],"runningPromptId":"t3-xai-prompt-1","runningText":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nDo not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally.\n\n\nIn case you're asked: you are running in T3 Code through the Grok harness, as grok-build. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked.\n","runningKind":"prompt"}}} {"type":"runtime_exit","status":"cancelled"} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt/muse_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt/muse_transcript.ndjson index 0d72ecd0dcb4..b23d87e93614 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt/muse_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt/muse_transcript.ndjson @@ -7,14 +7,14 @@ {"type":"emit_inbound","label":"session/started","frame":{"jsonrpc":"2.0","method":"session/started","params":{"session":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","path":"~/.local/share/muse/sessions/2026/10/08/01a11a6d-2d3d-7000-8178-411fdb90bc34/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-08T07:32:05.094285Z","updatedAt":"2026-10-08T07:32:05.094289Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"approvalMode":{"mode":"allowAll","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"}},"emittedAtMs":1791444725265}} {"type":"emit_inbound","label":"response:session/start","frame":{"jsonrpc":"2.0","id":2,"result":{"session":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","path":"~/.local/share/muse/sessions/2026/10/08/01a11a6d-2d3d-7000-8178-411fdb90bc34/session.jsonl","status":"idle","activeTurnId":null,"createdAt":"2026-10-08T07:32:05.094285Z","updatedAt":"2026-10-08T07:32:05.094289Z","workspaceRoot":"","providerId":"meta","modelId":"muse-spark-1.3-contributor","turnCount":0,"forkedFrom":null,"approvalMode":{"mode":"allowAll","source":"startup","lastCommandId":null},"createdByClient":{"name":"t3_code","version":"1"},"kind":"root"},"viewCursor":"v:01a11a6d-2d3d-7000-8178-411fdb90bc34:1"}}} {"type":"emit_inbound","label":"session/approvalModeChanged","frame":{"jsonrpc":"2.0","method":"session/approvalModeChanged","params":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","viewCursor":"v:01a11a6d-2d3d-7000-8178-411fdb90bc34:1","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-2d3d-7000-8178-411fdb90bc34"},"first":{"id":"9865268e-0a39-43a0-89d0-251a820ec8a3","sequence":5},"last":{"id":"9865268e-0a39-43a0-89d0-251a820ec8a3","sequence":5}},"mode":"allowAll","source":"approvalReconfigure","commandId":"01a11a6d-2d3e-7000-b0e7-f04c53ee070b","clientName":"t3_code"},"emittedAtMs":1791444725268}} -{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent. Otherwise, when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When monitoring is done and you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\n"},{"type":"text","text":"Do not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally."}],"displayText":"Do not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","input":[{"type":"text","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent. Otherwise, when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When monitoring is done and you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\n"},{"type":"text","text":"Do not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally."}],"displayText":"Do not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally.","workspaceRoots":[""],"ifBusy":"queue","reasoningEffort":"high","commandId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5"}}} {"type":"emit_inbound","label":"session/branchChanged","frame":{"jsonrpc":"2.0","method":"session/branchChanged","params":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","viewCursor":"v:01a11a6d-2d3d-7000-8178-411fdb90bc34:2","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-2d3d-7000-8178-411fdb90bc34"},"first":{"id":"531b0e9e-c96d-4fa5-a6dd-5cc7d4569543","sequence":16},"last":{"id":"531b0e9e-c96d-4fa5-a6dd-5cc7d4569543","sequence":16}},"workspaceRoot":"","branch":"main","vcs":"git"},"emittedAtMs":1791444725509}} {"type":"emit_inbound","label":"response:turn/start","frame":{"jsonrpc":"2.0","id":3,"result":{"commandId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5","status":"accepted","turnId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5","startedNewTurn":true,"disposition":"started"}}} {"type":"expect_outbound","label":"turn/interrupt","frame":{"jsonrpc":"2.0","id":4,"method":"turn/interrupt","params":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","turnId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5","commandId":"01a11a6d-2f0b-7000-a1d4-359cf54e471a"}}} {"type":"emit_inbound","label":"session/statusChanged","frame":{"jsonrpc":"2.0","method":"session/statusChanged","params":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","status":"running","viewCursor":"v:01a11a6d-2d3d-7000-8178-411fdb90bc34:3"},"emittedAtMs":1791444725522}} {"type":"emit_inbound","label":"turn/started","frame":{"jsonrpc":"2.0","method":"turn/started","params":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","viewCursor":"v:01a11a6d-2d3d-7000-8178-411fdb90bc34:3","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-2d3d-7000-8178-411fdb90bc34"},"first":{"id":"50d1fd8c-232b-47b9-9bfa-9b543961bbc9","sequence":18},"last":{"id":"50d1fd8c-232b-47b9-9bfa-9b543961bbc9","sequence":18}},"turnId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5","commandId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5"},"emittedAtMs":1791444725523}} -{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","viewCursor":"v:01a11a6d-2d3d-7000-8178-411fdb90bc34:4","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-2d3d-7000-8178-411fdb90bc34"},"first":{"id":"50d1fd8c-232b-47b9-9bfa-9b543961bbc9","sequence":18},"last":{"id":"50d1fd8c-232b-47b9-9bfa-9b543961bbc9","sequence":18}},"item":{"itemId":"cfa989ad-2359-4da2-9fca-562855cc0ae8","kind":"userMessage","turnId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5","revision":1,"status":"completed","recordedAt":"2026-10-08T07:32:05.510508Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nDo not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally.","commandId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5"}},"emittedAtMs":1791444725523}} -{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","viewCursor":"v:01a11a6d-2d3d-7000-8178-411fdb90bc34:5","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-2d3d-7000-8178-411fdb90bc34"},"first":{"id":"5db75d92-0e52-4bce-bb81-40e254b40bc3","sequence":20},"last":{"id":"5db75d92-0e52-4bce-bb81-40e254b40bc3","sequence":20}},"item":{"itemId":"cfa989ad-2359-4da2-9fca-562855cc0ae8","kind":"userMessage","turnId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5","revision":2,"status":"completed","recordedAt":"2026-10-08T07:32:05.530992Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nDo not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally.","displayText":"Do not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally.","commandId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5"}},"emittedAtMs":1791444725539}} +{"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","viewCursor":"v:01a11a6d-2d3d-7000-8178-411fdb90bc34:4","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-2d3d-7000-8178-411fdb90bc34"},"first":{"id":"50d1fd8c-232b-47b9-9bfa-9b543961bbc9","sequence":18},"last":{"id":"50d1fd8c-232b-47b9-9bfa-9b543961bbc9","sequence":18}},"item":{"itemId":"cfa989ad-2359-4da2-9fca-562855cc0ae8","kind":"userMessage","turnId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5","revision":1,"status":"completed","recordedAt":"2026-10-08T07:32:05.510508Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nDo not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally.","commandId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5"}},"emittedAtMs":1791444725523}} +{"type":"emit_inbound","label":"item/updated","frame":{"jsonrpc":"2.0","method":"item/updated","params":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","viewCursor":"v:01a11a6d-2d3d-7000-8178-411fdb90bc34:5","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-2d3d-7000-8178-411fdb90bc34"},"first":{"id":"5db75d92-0e52-4bce-bb81-40e254b40bc3","sequence":20},"last":{"id":"5db75d92-0e52-4bce-bb81-40e254b40bc3","sequence":20}},"item":{"itemId":"cfa989ad-2359-4da2-9fca-562855cc0ae8","kind":"userMessage","turnId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5","revision":2,"status":"completed","recordedAt":"2026-10-08T07:32:05.530992Z","text":"In case you're asked: you are running in T3 Code through the Muse Code harness, as muse-spark-1.3-contributor with high reasoning effort. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\nDo not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally.","displayText":"Do not answer immediately. First run the local shell command `sleep 30`, then respond with exactly: interrupt fixture should not finish naturally.","commandId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5"}},"emittedAtMs":1791444725539}} {"type":"emit_inbound","label":"item/started","frame":{"jsonrpc":"2.0","method":"item/started","params":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","viewCursor":"v:01a11a6d-2d3d-7000-8178-411fdb90bc34:6","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-2d3d-7000-8178-411fdb90bc34"},"first":{"id":"ae604001-22b9-479c-a45b-3dcfed45996c","sequence":37},"last":{"id":"ae604001-22b9-479c-a45b-3dcfed45996c","sequence":37}},"item":{"itemId":"ae604001-22b9-479c-a45b-3dcfed45996c","kind":"reminderChild","turnId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5","revision":1,"status":"inProgress","recordedAt":"2026-10-08T07:32:05.638142Z","fallbackText":"Reminder child session","childSessionId":"1f58196a-6bf6-42da-9499-8f53a439e6e1","reminderAgentId":"skill-reminder","generationId":1,"taskId":"01a11a6d-2f65-70b1-bb8f-69b33bb4fc62"}},"emittedAtMs":1791444725646}} {"type":"emit_inbound","label":"response:turn/interrupt","frame":{"jsonrpc":"2.0","id":4,"result":{"commandId":"01a11a6d-2f0b-7000-a1d4-359cf54e471a","status":"accepted","turnId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5"}}} {"type":"emit_inbound","label":"item/completed","frame":{"jsonrpc":"2.0","method":"item/completed","params":{"sessionId":"01a11a6d-2d3d-7000-8178-411fdb90bc34","viewCursor":"v:01a11a6d-2d3d-7000-8178-411fdb90bc34:7","sourceRange":{"stream":{"kind":"session","id":"01a11a6d-2d3d-7000-8178-411fdb90bc34"},"first":{"id":"ae604001-22b9-479c-a45b-3dcfed45996c","sequence":37},"last":{"id":"3962d8d1-4608-4dda-8fdc-39a1804474aa","sequence":50}},"item":{"itemId":"ae604001-22b9-479c-a45b-3dcfed45996c","kind":"reminderChild","turnId":"01a11a6d-2ee0-7000-be19-f20eda7ce5d5","revision":2,"status":"cancelled","recordedAt":"2026-10-08T07:32:05.727236Z","fallbackText":"Reminder child session","failureReason":"main run cancelled","childSessionId":"1f58196a-6bf6-42da-9499-8f53a439e6e1","reminderAgentId":"skill-reminder","generationId":1,"taskId":"01a11a6d-2f65-70b1-bb8f-69b33bb4fc62"}},"emittedAtMs":1791444725734}} diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 2a07b5715a3a..f14b80bcb505 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -713,6 +713,10 @@ it.effect("reports an unusable host over a merely failing one", () => ), }), fakeProvider(SourceControlProviderKind.make("gitlab"), { + unavailableRequirement: { + missing: "Install glab.", + unauthenticated: "Run glab auth login.", + }, getViewer: () => Effect.fail(unusable(SourceControlProviderKind.make("gitlab"), "missing-tool")), }), @@ -722,7 +726,10 @@ it.effect("reports an unusable host over a merely failing one", () => const error = yield* Effect.flip(service.list({ state: "open" })); assert.strictEqual(error._tag, "PullRequestUnavailableError"); - assert.strictEqual(error.message.includes("glab"), true); + if (error._tag === "PullRequestUnavailableError") { + assert.strictEqual(error.requirement, "Install glab."); + } + assert.strictEqual(error.message, "Install glab."); }), ); @@ -2303,6 +2310,7 @@ it.effect("carries every repository of a slice on from the oldest row in it", () ], providers: [ fakeProvider(SourceControlProviderKind.make("github"), { + listsAcrossMayOmitRepositories: true, listChangeRequestsAcross: () => Effect.succeed({ items: [ @@ -2394,7 +2402,51 @@ it.effect("reads a workspace larger than one search in chunks, and merges them", assert.strictEqual(result.entries.length, 101); }), ); -it.effect("asks on its own for a repository a search answered nothing for", () => +it.effect( + "asks on its own for a repository a search answered nothing for, where searches can omit one", + () => + 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(SourceControlProviderKind.make("github"), { + listsAcrossMayOmitRepositories: true, + 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" }); + + 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("believes a search's silence about a repository where searches cover every one", () => Effect.gen(function* () { const separately: string[] = []; const service = yield* makeService({ @@ -2406,9 +2458,7 @@ it.effect("asks on its own for a repository a search answered nothing for", () = fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequests: ({ repository }) => { separately.push(repository); - return repository === "acme/docs" - ? Effect.fail(requestFailed) - : Effect.succeed({ items: [], truncated: false, continues: true }); + return Effect.succeed({ items: [], truncated: false, continues: true }); }, listChangeRequestsAcross: () => Effect.succeed({ @@ -2421,14 +2471,7 @@ it.effect("asks on its own for a repository a search answered nothing for", () = const result = yield* service.list({ state: "open" }); - assert.deepStrictEqual(separately, ["acme/docs"]); - assert.deepStrictEqual(result.errors, [ - { - projectId: "p2" as ProjectId, - projectTitle: "docs", - message: "acme/docs could not be read.", - }, - ]); + assert.deepStrictEqual(separately, []); assert.deepStrictEqual( result.entries.map((entry) => entry.number), [1], diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 97264c56d133..b124c61d4a8e 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -18,7 +18,6 @@ import * as SubscriptionRef from "effect/SubscriptionRef"; import { PullRequestOperationError, PullRequestUnavailableError, - pullRequestProviderRequirement, resolvePullRequestAuthorFilter, type OrchestrationProjectShell, type ProjectId, @@ -402,36 +401,47 @@ function isProviderUnusable(error: PullRequestProviderError): boolean { return error.reason === "missing-tool" || error.reason === "unauthenticated"; } -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 - ); +type UnavailableRequirementOf = ( + kind: SourceControlProviderKind, +) => PullRequestProviderApi["unavailableRequirement"]; + +function unavailableRequirement( + requirementOf: UnavailableRequirementOf, + error: PullRequestProviderError, +): string | undefined { + const requirement = requirementOf(error.provider); + if (requirement === undefined) return undefined; + return error.reason === "missing-tool" ? requirement.missing : requirement.unauthenticated; } -function toUnavailableError(error: PullRequestProviderError): PullRequestUnavailableError { - return new PullRequestUnavailableError({ - reason: error.reason === "missing-tool" ? "cli-missing" : "cli-unauthenticated", - provider: error.provider, - cause: error, - }); +function providerDetail( + requirementOf: UnavailableRequirementOf, + error: PullRequestProviderError, +): string { + if (!isProviderUnusable(error)) return error.detail; + return unavailableRequirement(requirementOf, error) ?? error.detail; } -function toPullRequestError( - operation: string, -): (error: PullRequestProviderError) => PullRequestError { - return (error) => - isProviderUnusable(error) - ? toUnavailableError(error) - : new PullRequestOperationError({ - operation, - detail: error.detail, - ...(error.reason === "not-found" ? { reason: "not-found" as const } : {}), - cause: error, - }); +function toPullRequestErrorWith( + requirementOf: UnavailableRequirementOf, +): (operation: string) => (error: PullRequestProviderError) => PullRequestError { + return (operation) => (error) => { + if (isProviderUnusable(error)) { + const requirement = unavailableRequirement(requirementOf, error); + return new PullRequestUnavailableError({ + reason: error.reason === "missing-tool" ? "cli-missing" : "cli-unauthenticated", + provider: error.provider, + ...(requirement === undefined ? {} : { requirement }), + cause: error, + }); + } + return new PullRequestOperationError({ + operation, + detail: error.detail, + ...(error.reason === "not-found" ? { reason: "not-found" as const } : {}), + cause: error, + }); + }; } function withRateLimitBackoff( @@ -493,6 +503,12 @@ function withRateLimitBackoff( ...(api.mergeMessageRewrite === undefined ? {} : { mergeMessageRewrite: api.mergeMessageRewrite }), + ...(api.unavailableRequirement === undefined + ? {} + : { unavailableRequirement: api.unavailableRequirement }), + ...(api.listsAcrossMayOmitRepositories === undefined + ? {} + : { listsAcrossMayOmitRepositories: api.listsAcrossMayOmitRepositories }), ...(api.repositoryKey === undefined ? {} : { repositoryKey: api.repositoryKey }), ...(api.readsPublicHostWithoutCheckout === undefined ? {} @@ -679,6 +695,9 @@ export const make = Effect.gen(function* () { const stateChanges = yield* PubSub.sliding(64); const pullRequestRefreshes = yield* SubscriptionRef.make(0); const registry = yield* PullRequestProviderRegistry.PullRequestProviderRegistry; + const requirementOf: UnavailableRequirementOf = (kind) => + registry.get(kind)?.unavailableRequirement; + const toPullRequestError = toPullRequestErrorWith(requirementOf); const serverSettings = yield* ServerSettings.ServerSettingsService; const projects = yield* ProjectService.ProjectService; const repositoryIdentities = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; @@ -1172,9 +1191,9 @@ export const make = Effect.gen(function* () { }; }; - // A repository that has appeared in a host search is known to be indexed there. Empty - // authored/reviewing searches for that same repository are therefore real empty answers, not - // a reason to issue the two-command per-repository fallback again. + // On a host whose read across repositories may omit some, a repository that has appeared in + // one is known to be covered. Empty reads for that same repository are therefore real empty + // answers, not a reason to read it on its own again. const searchVisibleAt = new Map(); const searchVisibilityKey = (host: string, repository: string) => `${host}\n${repository.trim().toLowerCase()}`; @@ -1207,7 +1226,7 @@ export const make = Effect.gen(function* () { actions: capabilities?.actions ?? [], projectCount: projectCounts.get(result.host) ?? 1, configured: result.viewer !== null, - detail: result.error === null ? null : providerDetail(result.error), + detail: result.error === null ? null : providerDetail(requirementOf, result.error), }; }), ...[...unimplemented].map(([host, { kind, projectCount }]) => ({ @@ -1412,18 +1431,18 @@ export const make = Effect.gen(function* () { 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. + // A repository the read said nothing at all about, on a host whose read can omit + // one, 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. const lastVisible = searchVisibleAt.get( searchVisibilityKey(project.host, project.repository), ); const searchIsKnownVisible = - !page.truncated && - lastVisible !== undefined && - now - lastVisible <= Duration.toMillis(SEARCH_VISIBILITY_TTL); + first.api.listsAcrossMayOmitRepositories !== true || + (!page.truncated && + lastVisible !== undefined && + now - lastVisible <= Duration.toMillis(SEARCH_VISIBILITY_TTL)); if ( fetched.length === 0 && cursorOf(project) === undefined && @@ -1529,7 +1548,7 @@ export const make = Effect.gen(function* () { host, }) .pipe(Effect.mapError(toPullRequestError("routeIdentity"))); - return { ...identity, host, provider: "github" as const }; + return { ...identity, host, provider: api.kind }; }); const withRoutingCredential: PullRequestService["Service"]["withRoutingCredential"] = Effect.fn( @@ -1539,7 +1558,7 @@ export const make = Effect.gen(function* () { const rejected = () => new PullRequestOperationError({ operation: "routeIdentity", - detail: "The GitHub account could not be verified before starting the operation.", + detail: "The signed-in account could not be verified before starting the operation.", }); const project = yield* requireProject(input).pipe(Effect.mapError(rejected)); const api = registry.get(project.api.kind); diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index 3902c4fbf097..4cf38890205b 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -59,7 +59,7 @@ import { cn } from "~/lib/utils"; import { createPullRequestDiffFileContentsLoader } from "~/lib/diffFileContents"; import { buildDiffReviewComment, - resolveDiffReviewPosition, + resolveDiffReviewAnchor, type ReviewCommentContext, } from "~/reviewCommentContext"; import { pullRequestEnvironment } from "~/state/pullRequests"; @@ -231,6 +231,7 @@ interface DraftAnchor { readonly path: string; readonly oldPath: string | null; readonly position: PullRequestReviewPosition; + readonly startPosition?: PullRequestReviewPosition; readonly range: SelectedLineRange; } @@ -466,6 +467,7 @@ function PullRequestCodeTab({ resolve: hostReview.resolve && detail.viewerPermissions.resolve, }; const canCommentOnLines = review.inlineComment && commit === null; + const rangeComments = hostReview.rangeComment === true; // Agent context is local, so it does not require host comment permission. const canSelectLines = commit === null && (review.inlineComment || onAddToAgentSelection !== undefined); @@ -719,23 +721,22 @@ function PullRequestCodeTab({ if (item.type !== "diff") return; const file = files.find((candidate) => buildFileDiffRenderKey(candidate) === item.id); if (!file) return; - // A range collapses to its last line: only GitHub carries a multi-line comment, and one - // that silently lost its first line on the other hosts would be worse than one line. + // On a host without ranged comments a range collapses to its last line: one that silently + // lost its first line there would be worse than one line. const path = resolveFileDiffPath(file); const previousPath = resolveFileDiffPreviousPath(file); const sourceFile = parsedSlices .flatMap((slice) => (slice?.kind === "files" ? slice.sourceFiles : [])) .find((candidate) => resolveFileDiffPath(candidate) === path); - const side = range.endSide ?? range.side; - const position = - (sourceFile && resolveDiffReviewPosition(sourceFile, range.end, side)) ?? - resolveDiffReviewPosition(file, range.end, side); - if (position === null) return; + const anchor = + (sourceFile && resolveDiffReviewAnchor(sourceFile, range, rangeComments)) ?? + resolveDiffReviewAnchor(file, range, rangeComments); + if (anchor === null) return; setDraft({ fileKey: item.id, path, oldPath: previousPath === path ? null : previousPath, - position, + ...anchor, range, }); }; @@ -970,6 +971,7 @@ function PullRequestCodeTab({ id: nextPendingReviewCommentId(), path: draft.path, ...(draft.oldPath === null ? {} : { oldPath: draft.oldPath }), + ...(draft.startPosition === undefined ? {} : { startPosition: draft.startPosition }), position: draft.position, body, }); diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index f715ecef5153..d8a1f51a9334 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -1744,6 +1744,7 @@ export function PullRequestDetailPanel({ canComment={ detail.capabilities.review.inlineComment && detail.viewerPermissions.comment } + rangeComments={detail.capabilities.review.rangeComment === true} onAskAboutLines={(comment, request) => addSelectionToAgent({ comment, request }, { keepGuide: true }) } diff --git a/apps/web/src/components/pullRequest/PullRequestGuideTab.tsx b/apps/web/src/components/pullRequest/PullRequestGuideTab.tsx index c9a3e89c7adf..f5fd63f059e9 100644 --- a/apps/web/src/components/pullRequest/PullRequestGuideTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestGuideTab.tsx @@ -26,6 +26,7 @@ export function PullRequestGuideTab({ workspaceRoot, active, canComment, + rangeComments, onAskAboutLines, onAskAboutStop, onFinishReview, @@ -38,6 +39,7 @@ export function PullRequestGuideTab({ workspaceRoot: string; active: boolean; canComment: boolean; + rangeComments: boolean; onAskAboutLines: ((comment: ReviewCommentContext, request: string) => void) | null; onAskAboutStop: | ((stop: PullRequestReviewGuideStop, index: number, question: string) => void) @@ -61,12 +63,14 @@ export function PullRequestGuideTab({ ? null : { canComment, + rangeComments, pending, - onComment: ({ path, oldPath, position, body }) => + onComment: ({ path, oldPath, position, startPosition, body }) => addComment(reviewKey, { id: nextPendingReviewCommentId(), path, ...(oldPath === null ? {} : { oldPath }), + ...(startPosition === undefined ? {} : { startPosition }), position, body, }), diff --git a/apps/web/src/components/pullRequest/guide/GuideExcerpt.tsx b/apps/web/src/components/pullRequest/guide/GuideExcerpt.tsx index 357c201e5bee..803e32ea3194 100644 --- a/apps/web/src/components/pullRequest/guide/GuideExcerpt.tsx +++ b/apps/web/src/components/pullRequest/guide/GuideExcerpt.tsx @@ -18,7 +18,7 @@ import { import { PREFERRED_HIGHLIGHTER } from "~/lib/syntaxHighlighting"; import { buildDiffReviewComment, - resolveDiffReviewPosition, + resolveDiffReviewAnchor, type ReviewCommentContext, } from "~/reviewCommentContext"; @@ -53,11 +53,14 @@ export interface ExcerptNoteMarker { export interface ExcerptCommenting { readonly canComment: boolean; + /** The host anchors a comment to a range of lines rather than to its last one. */ + readonly rangeComments: boolean; readonly pending: ReadonlyArray; readonly onComment: (comment: { readonly path: string; readonly oldPath: string | null; readonly position: PullRequestReviewPosition; + readonly startPosition?: PullRequestReviewPosition | undefined; readonly body: string; }) => void; readonly onRemove: (commentId: string) => void; @@ -73,6 +76,7 @@ interface ExcerptAnnotation { interface ExcerptDraft { readonly range: SelectedLineRange; readonly position: PullRequestReviewPosition; + readonly startPosition?: PullRequestReviewPosition; } type ExcerptSlice = NonNullable>; @@ -253,9 +257,9 @@ function ExcerptDiff({ const beginComment = (range: SelectedLineRange | null) => { if (range === null) return; - const position = resolveDiffReviewPosition(file, range.end, range.endSide ?? range.side); - if (position === null) return; - setDraft({ range, position }); + const anchor = resolveDiffReviewAnchor(file, range, commenting?.rangeComments === true); + if (anchor === null) return; + setDraft({ range, ...anchor }); }; const interactive = commenting !== null && draft === null; const onAskAgent = commenting?.onAskAgent ?? null; @@ -344,6 +348,7 @@ function ExcerptDiff({ path: excerpt.path, oldPath: previousPathOf(file, excerpt.path), position: draft.position, + startPosition: draft.startPosition, body, }); setDraft(null); diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx index 184b66256dce..2828d141630a 100644 --- a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx @@ -413,7 +413,7 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting settingKeys={["removeAgentCreditsOnMerge"]} mixed={mixedAgentCredits} {...searchableSetting("remove-agent-credits-on-merge")} - description="Remove recognized agent credit lines from GitHub merge and squash messages, keeping human co-authors. Includes auto-merge. Excludes merge queues, stack merges, and existing commits." + description="Remove recognized agent credit lines from merge and squash messages on hosts that support it, keeping human co-authors. Includes auto-merge. Excludes merge queues, stack merges, and existing commits." resetAction={ settings.removeAgentCreditsOnMerge ? ( { oldLine: 25, }); }); + + it("keeps a selection's first line only for a ranged host and within one hunk", () => { + const fileDiff = parsePatchFiles( + [ + "diff --git a/src/app.ts b/src/app.ts", + "--- a/src/app.ts", + "+++ b/src/app.ts", + "@@ -1,3 +1,4 @@", + " one", + "+two", + " three", + " four", + "@@ -20,2 +21,3 @@", + " twenty", + "+twenty-one", + " twenty-two", + ].join("\n"), + "review-anchor-range", + )[0]!.files[0]!; + const upward = { start: 3, side: "additions", end: 1, endSide: "additions" } as const; + + expect(resolveDiffReviewAnchor(fileDiff, upward, false)).toEqual({ + position: { kind: "context", oldLine: 1, newLine: 1, side: "right" }, + }); + expect(resolveDiffReviewAnchor(fileDiff, upward, true)).toEqual({ + startPosition: { kind: "context", oldLine: 1, newLine: 1, side: "right" }, + position: { kind: "context", oldLine: 2, newLine: 3, side: "right" }, + }); + expect( + resolveDiffReviewAnchor(fileDiff, { start: 2, side: "additions", end: 22 }, true), + ).toEqual({ position: { kind: "added", newLine: 22 } }); + }); }); diff --git a/apps/web/src/reviewCommentContext.ts b/apps/web/src/reviewCommentContext.ts index e363c3656a73..336a506a5267 100644 --- a/apps/web/src/reviewCommentContext.ts +++ b/apps/web/src/reviewCommentContext.ts @@ -375,6 +375,46 @@ export function resolveDiffReviewPosition( } } +function reviewHunkIndex(fileDiff: FileDiffMetadata, position: PullRequestReviewPosition): number { + const [side, line] = + position.kind === "deleted" + ? (["left", position.oldLine] as const) + : (["right", position.newLine] as const); + return fileDiff.hunks.findIndex((hunk) => + side === "left" + ? line >= hunk.deletionStart && line < hunk.deletionStart + hunk.deletionCount + : line >= hunk.additionStart && line < hunk.additionStart + hunk.additionCount, + ); +} + +/** + * Where a review comment on `range` is anchored. With `ranged`, a selection inside one hunk + * keeps its first line, in diff order, as `startPosition`, because hosts refuse a range that + * crosses hunks. Otherwise the comment sits on the line the selection ended on. + */ +export function resolveDiffReviewAnchor( + fileDiff: FileDiffMetadata, + range: SelectedLineRange, + ranged: boolean, +): { + readonly position: PullRequestReviewPosition; + readonly startPosition?: PullRequestReviewPosition; +} | null { + const endSide = range.endSide ?? range.side; + const last = resolveDiffReviewPosition(fileDiff, range.end, endSide); + if (last === null) return null; + const first = ranged ? resolveDiffReviewPosition(fileDiff, range.start, range.side) : null; + if (first === null) return { position: last }; + const startIndex = findDiffReviewLineIndex(fileDiff, range.start, range.side); + const endIndex = findDiffReviewLineIndex(fileDiff, range.end, endSide); + if (startIndex === endIndex) return { position: last }; + const [start, end] = startIndex < endIndex ? [first, last] : [last, first]; + const hunk = reviewHunkIndex(fileDiff, end); + return hunk >= 0 && reviewHunkIndex(fileDiff, start) === hunk + ? { position: end, startPosition: start } + : { position: last }; +} + function getDiffRange( lines: ReadonlyArray, key: "oldLineNumber" | "newLineNumber", diff --git a/docs/internals/overview.md b/docs/internals/overview.md index 149bcf57dff0..88b2affa061c 100644 --- a/docs/internals/overview.md +++ b/docs/internals/overview.md @@ -89,7 +89,8 @@ Thread settlement is server-owned. The and inactivity settings without a connected client. Merge notifications invalidate cached PR state and trigger a check. A merge outside T3, such as an agent running `gh pr merge`, sends no notification, so the [PR sync reactor](../../apps/server/src/orchestration-v2/PullRequestSyncReactor.ts) -re-reads a thread's open links when a run that ran a merge or close command ends. The guarded +re-reads a thread's open links when a run that ran a merge or close command ends. Each forge's +client definition names its CLI's commands in `changeRequestCloseCommand`. The guarded `thread.auto-settle` command rejects newer activity, explicit settlement overrides, and live or blocked work. It records the activity timestamp for stable sorting and detaches idle provider sessions. Clients render the persisted result; they do not diff --git a/packages/client-runtime/src/state/pullRequestRouting.ts b/packages/client-runtime/src/state/pullRequestRouting.ts index 2eeb6b0f9355..a6cd89ee8643 100644 --- a/packages/client-runtime/src/state/pullRequestRouting.ts +++ b/packages/client-runtime/src/state/pullRequestRouting.ts @@ -354,7 +354,8 @@ export function createPullRequestRouter() { identityProbe, ) : identityProbe; - if (identity === null || identity.provider !== "github") return yield* finish(source); + // The source server answers only for a host whose account it can verify. + if (identity === null) return yield* finish(source); const routedInput = { ...input, @@ -437,7 +438,7 @@ export function createPullRequestRouter() { ); if ( alternate === null || - alternate.provider !== "github" || + alternate.provider !== identity.provider || alternate.host.toLowerCase() !== identity.host.toLowerCase() || alternate.accountId !== identity.accountId ) { diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 7e371e867722..e7bfa2c35e56 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -240,6 +240,11 @@ export type PullRequestCommit = typeof PullRequestCommit.Type; export const PullRequestReviewCapabilities = Schema.Struct({ inlineComment: Schema.Boolean, + /** + * An inline comment can cover a range of lines rather than its last one. Absent, as from a + * server that predates it, means one line. + */ + rangeComment: Schema.optional(Schema.Boolean), reply: Schema.Boolean, resolve: Schema.Boolean, verdicts: ForwardCompatibleArray(PullRequestReviewVerdict), @@ -281,6 +286,11 @@ export const PullRequestCapabilities = Schema.Struct({ stacks: Schema.optional(Schema.Boolean), stackActions: Schema.optional(Schema.Boolean), labels: Schema.optional(Schema.Boolean), + /** + * Every head gets at least one check, so a head that has gone a while without any means none + * reported rather than that the repository runs none. Absent means an empty list says nothing. + */ + checksAlwaysReport: Schema.optional(Schema.Boolean), }); export type PullRequestCapabilities = typeof PullRequestCapabilities.Type; @@ -421,7 +431,7 @@ export type PullRequestRoutingIdentityInput = typeof PullRequestRoutingIdentityI export const PullRequestRoutingIdentityResult = Schema.Struct({ accountId: TrimmedNonEmptyString, host: TrimmedNonEmptyString, - provider: Schema.Literal("github"), + provider: SourceControlProviderKind, viewer: TrimmedNonEmptyString, }); export type PullRequestRoutingIdentityResult = typeof PullRequestRoutingIdentityResult.Type; @@ -815,6 +825,11 @@ export const PullRequestReviewCommentDraft = Schema.Struct({ */ oldPath: Schema.optional(TrimmedNonEmptyString), position: PullRequestReviewPosition, + /** + * The first line of a comment on a range, which ends at `position`. Sent only to a host whose + * capabilities include `review.rangeComment`. + */ + startPosition: Schema.optional(PullRequestReviewPosition), body: CommentBody, }); export type PullRequestReviewCommentDraft = typeof PullRequestReviewCommentDraft.Type; @@ -878,6 +893,10 @@ export const PullRequestLabelChangeInput = Schema.Struct({ }); export type PullRequestLabelChangeInput = typeof PullRequestLabelChangeInput.Type; +/** + * The names keep their `cli-` prefix for wire compatibility; for a host read with a credential + * rather than a tool they mean "no credential" and "a refused credential". + */ export const PullRequestUnavailableReason = Schema.Literals([ "cli-missing", "cli-unauthenticated", @@ -885,59 +904,6 @@ export const PullRequestUnavailableReason = Schema.Literals([ ]); export type PullRequestUnavailableReason = typeof PullRequestUnavailableReason.Type; -/** - * The reason names keep their `cli-` prefix for wire compatibility; for GitHub and - * Bitbucket they mean "no credential" and "a refused credential", not a missing tool. - */ -const PROVIDER_REQUIREMENT = new Map< - string, - { readonly missing: string; readonly unauthenticated: string } ->([ - [ - "github", - { - missing: - "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI (https://cli.github.com/) and run `gh auth login`.", - unauthenticated: - "GitHub has no working credential for this host. Run `gh auth login`, or check the account and hosts in Settings → Source Control.", - }, - ], - [ - "forgejo", - { - missing: - "Install Forgejo CLI (`fj` 0.6 or later) from https://codeberg.org/forgejo-contrib/forgejo-cli or Gitea CLI (`tea` 0.16 or later) from https://gitea.com/gitea/tea to browse Forgejo pull requests.", - unauthenticated: - "Authenticate your Forgejo or Gitea server with `fj --host auth add-token` on the T3 Code server. If fj is missing or unconfigured for that server, use `tea login add`. A configured fj account must be repaired with fj.", - }, - ], - [ - "gitlab", - { - missing: - "GitLab CLI (`glab`) is required to browse change requests on this host. Install it from https://gitlab.com/gitlab-org/cli and reload.", - unauthenticated: "GitLab CLI is not authenticated. Run `glab auth login` and retry.", - }, - ], - [ - "azure-devops", - { - missing: - "Azure CLI (`az`) with the Azure DevOps extension is required. Install `az`, then run `az extension add --name azure-devops`.", - unauthenticated: "Azure CLI is not signed in. Run `az login` and retry.", - }, - ], - [ - "bitbucket", - { - missing: - "Bitbucket needs API credentials on the server. Add them in Settings → Source Control.", - unauthenticated: - "Bitbucket rejected the configured credentials. Check them in Settings → Source Control.", - }, - ], -]); - /** * `author:me` names whoever is signed in to the host being read, GitHub's `@me` spelled either * way. A host's own search would answer it, but the page and the server both re-check an author @@ -953,27 +919,16 @@ export function resolvePullRequestAuthorFilter( return viewer === null || viewer === undefined || viewer.trim().length === 0 ? trimmed : viewer; } -export function pullRequestProviderRequirement( - provider: SourceControlProviderKind, - reason: PullRequestUnavailableReason, -): string | null { - const requirement = PROVIDER_REQUIREMENT.get(provider); - if (requirement === undefined) return null; - switch (reason) { - case "cli-missing": - return requirement.missing; - case "cli-unauthenticated": - return requirement.unauthenticated; - case "provider-unsupported": - return null; - } -} - export class PullRequestUnavailableError extends Schema.TaggedError()( "PullRequestUnavailableError", { reason: PullRequestUnavailableReason, provider: Schema.optional(SourceControlProviderKind), + /** + * What the host needs on the server, in its own words. Absent from older servers, which + * shipped no such field, and for a host that names nothing. + */ + requirement: Schema.optional(TrimmedNonEmptyString), cause: Schema.optional(Schema.Defect()), }, { httpApiStatus: 503 }, @@ -983,15 +938,11 @@ export class PullRequestUnavailableError extends Schema.TaggedError -When the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent. Otherwise, when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When monitoring is done and you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox. +When the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through your host's CLI (including its stack commands) or its API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent. Otherwise, when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When monitoring is done and you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox. `; /** diff --git a/packages/source-control-azure-devops/src/client/definition.ts b/packages/source-control-azure-devops/src/client/definition.ts index e0753bc82f08..1f63cf9fe95a 100644 --- a/packages/source-control-azure-devops/src/client/definition.ts +++ b/packages/source-control-azure-devops/src/client/definition.ts @@ -98,6 +98,9 @@ export const definition = defineSourceControlClient({ "disable-auto-merge", ] as const), checkoutCommand: ({ number }) => `az repos pr checkout --id ${number}`, + // Completing or abandoning is a status change, the same `update` this package runs. + changeRequestCloseCommand: + /\baz\s+repos\s+pr\s+update\b[^;&|]*--status[\s=]+["']?(?:completed|abandoned)\b/u, authorProfileUrl: () => null, referenceAutolinkRepositoryUrl: () => null, reviewSummaryRequired: () => false, diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.ts index ce872c7c1650..5032479d3b42 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.ts @@ -70,6 +70,12 @@ const CAPABILITIES: PullRequestCapabilities = { viewedFiles: "environment", }; +const UNAVAILABLE_REQUIREMENT = { + missing: + "Azure CLI (`az`) with the Azure DevOps extension is required. Install `az`, then run `az extension add --name azure-devops`.", + unauthenticated: "Azure CLI is not signed in. Run `az login` and retry.", +}; + /** * 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 @@ -259,6 +265,7 @@ export const make = Effect.gen(function* () { const provider: PullRequestProviderApi = { kind: SourceControlProviderKind.make("azure-devops"), capabilities: CAPABILITIES, + unavailableRequirement: UNAVAILABLE_REQUIREMENT, // The bare repository name repeats across an organization; the project path does not. repositoryKey: ({ canonicalKey }) => canonicalKey, diff --git a/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.ts b/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.ts index ac7b947f1714..c6ca1d155929 100644 --- a/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.ts +++ b/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.ts @@ -38,6 +38,12 @@ const CAPABILITIES: PullRequestCapabilities = { viewedFiles: "environment", }; +const UNAVAILABLE_REQUIREMENT = { + missing: "Bitbucket needs API credentials on the server. Add them in Settings → Source Control.", + unauthenticated: + "Bitbucket rejected the configured credentials. Check them in Settings → Source Control.", +}; + /** * 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 @@ -147,6 +153,7 @@ export const make = Effect.gen(function* () { const provider: PullRequestProviderApi = { kind: SourceControlProviderKind.make("bitbucket"), capabilities: CAPABILITIES, + unavailableRequirement: UNAVAILABLE_REQUIREMENT, readsPublicHostWithoutCheckout: true, getViewer: () => api.getViewer().pipe(Effect.mapError(fail("getViewer"))), diff --git a/packages/source-control-core/src/client/definition.ts b/packages/source-control-core/src/client/definition.ts index a253780a6a3d..2796f24df062 100644 --- a/packages/source-control-core/src/client/definition.ts +++ b/packages/source-control-core/src/client/definition.ts @@ -111,6 +111,11 @@ export interface SourceControlClientDefinition { */ readonly changeRequestActions: ReadonlySet; readonly checkoutCommand: (changeRequest: ChangeRequestCheckoutInput) => string | null; + /** + * Matches a shell command that merges or closes a change request with this host's CLI, which + * sends T3 no notification of its own. Absent for a host with no such CLI. + */ + readonly changeRequestCloseCommand?: RegExp; readonly authorProfileUrl: (login: string, repositoryUrl: string) => string | null; readonly referenceAutolinkRepositoryUrl: (repositoryUrl: string) => string | null; readonly reviewSummaryRequired: (verdict: PullRequestReviewVerdict) => boolean; diff --git a/packages/source-control-core/src/server/PullRequestProvider.ts b/packages/source-control-core/src/server/PullRequestProvider.ts index 18fb90a3b3bf..bd6b9ea1cef6 100644 --- a/packages/source-control-core/src/server/PullRequestProvider.ts +++ b/packages/source-control-core/src/server/PullRequestProvider.ts @@ -287,6 +287,14 @@ export interface PullRequestProviderApi { >; readonly kind: SourceControlProviderKind; readonly capabilities: PullRequestCapabilities; + /** + * What a reader is told when this host cannot be read on the server: `missing` for no tool or + * credential, `unauthenticated` for one the host refused. Absent means a generic sentence. + */ + readonly unavailableRequirement?: { + readonly missing: string; + readonly unauthenticated: string; + }; readonly mergeMessageRewrite?: (message: string) => string; /** * Absent means `owner/name` on the host identifies it, and any checkout on the host can serve a @@ -335,6 +343,12 @@ export interface PullRequestProviderApi { * 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. */ + /** + * Set when `listChangeRequestsAcross` can answer with silence for a repository it does not + * cover rather than with an error, so a repository the read said nothing about is read on its + * own before it is believed empty. + */ + readonly listsAcrossMayOmitRepositories?: boolean; readonly listChangeRequestsAcross?: (input: { readonly cwd: string; readonly host: string; diff --git a/packages/source-control-forgejo/src/client/definition.ts b/packages/source-control-forgejo/src/client/definition.ts index f11e7f58aaa2..d6c8698f591b 100644 --- a/packages/source-control-forgejo/src/client/definition.ts +++ b/packages/source-control-forgejo/src/client/definition.ts @@ -81,6 +81,7 @@ export const definition = defineSourceControlClient({ repositoryUrl ? `git fetch '${repositoryUrl.replaceAll("'", "'\\''")}' ${changeRequestHeadRef(number)} && git checkout -B pulls/${number} FETCH_HEAD` : null, + changeRequestCloseCommand: /\b(?:tea\s+(?:pr|pulls)|fj\s+pr)\s+(?:merge|close)\b/u, authorProfileUrl: () => null, // Forgejo refuses a change request review without a summary, even with inline comments. referenceAutolinkRepositoryUrl: () => null, diff --git a/packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.ts b/packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.ts index 2ba5b43a4dd5..30ea7faedb25 100644 --- a/packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.ts +++ b/packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.ts @@ -63,6 +63,13 @@ const ForgejoUserSearch = Schema.Struct({ data: Schema.optional(Schema.NullOr(Schema.Array(ForgejoUser))), }); +const UNAVAILABLE_REQUIREMENT = { + missing: + "Install Forgejo CLI (`fj` 0.6 or later) from https://codeberg.org/forgejo-contrib/forgejo-cli or Gitea CLI (`tea` 0.16 or later) from https://gitea.com/gitea/tea to browse Forgejo pull requests.", + unauthenticated: + "Authenticate your Forgejo or Gitea server with `fj --host auth add-token` on the T3 Code server. If fj is missing or unconfigured for that server, use `tea login add`. A configured fj account must be repaired with fj.", +}; + const repoPath = (input: ProviderRepositoryRef) => `repos/${input.repository.split("/").map(encodeURIComponent).join("/")}`; const pullPath = (input: ProviderRepositoryRef & { readonly number: number }) => @@ -259,6 +266,7 @@ export const make = Effect.gen(function* () { const provider: PullRequestProviderApi = { kind: SourceControlProviderKind.make("forgejo"), capabilities: CAPABILITIES, + unavailableRequirement: UNAVAILABLE_REQUIREMENT, readsPublicHostWithoutCheckout: true, getViewer, listChangeRequests: Effect.fn("ForgejoPullRequestProvider.listChangeRequests")( diff --git a/packages/source-control-gitcafe/src/client/definition.ts b/packages/source-control-gitcafe/src/client/definition.ts index f0ea69bdbd2a..50a4b33e51c5 100644 --- a/packages/source-control-gitcafe/src/client/definition.ts +++ b/packages/source-control-gitcafe/src/client/definition.ts @@ -69,6 +69,7 @@ export const definition = defineSourceControlClient({ "update-branch", ] as const), checkoutCommand: ({ number }) => `cafe pr checkout ${number}`, + changeRequestCloseCommand: /\bcafe\s+(?:pr|stack)\s+(?:merge|close)\b/u, authorProfileUrl: () => null, referenceAutolinkRepositoryUrl: () => null, reviewSummaryRequired: () => false, diff --git a/packages/source-control-github/src/client/definition.ts b/packages/source-control-github/src/client/definition.ts index f60f10e72389..ffc58c2dec69 100644 --- a/packages/source-control-github/src/client/definition.ts +++ b/packages/source-control-github/src/client/definition.ts @@ -112,6 +112,7 @@ export const definition = defineSourceControlClient({ "approve-workflows", ] as const), checkoutCommand: ({ number }) => `gh pr checkout ${number}`, + changeRequestCloseCommand: /\bgh\s+(?:pr\s+(?:merge|close)|stack\s+merge)\b/u, authorProfileUrl: (login, repositoryUrl) => login.endsWith("[bot]") ? null diff --git a/packages/source-control-github/src/server/GitHubPullRequestProvider.ts b/packages/source-control-github/src/server/GitHubPullRequestProvider.ts index f57bf3ee2246..5091d58d5909 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestProvider.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestProvider.ts @@ -38,6 +38,7 @@ const CAPABILITIES: PullRequestCapabilities = { viewedFiles: "host", review: { inlineComment: true, + rangeComment: true, reply: true, resolve: true, verdicts: ["comment", "approve", "request-changes"], @@ -47,6 +48,16 @@ const CAPABILITIES: PullRequestCapabilities = { stacks: true, stackActions: true, labels: true, + // Statuses and check runs land on the head commit itself and are read whole, so an empty list + // after a push is CI that never started rather than a read that saw nothing. + checksAlwaysReport: true, +}; + +const UNAVAILABLE_REQUIREMENT = { + missing: + "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI (https://cli.github.com/) and run `gh auth login`.", + unauthenticated: + "GitHub has no working credential for this host. Run `gh auth login`, or check the account and hosts in Settings → Source Control.", }; /** @@ -243,6 +254,7 @@ export const make = Effect.gen(function* () { const provider: PullRequestProviderApi = { kind: SourceControlProviderKind.make("github"), capabilities: CAPABILITIES, + unavailableRequirement: UNAVAILABLE_REQUIREMENT, readsPublicHostWithoutCheckout: true, mergeMessageRewrite: removeAgentCredits, getRoutingIdentity: (input) => @@ -294,6 +306,9 @@ export const make = Effect.gen(function* () { ), ), + // GitHub does not index every repository for search: a renamed one answers for its old name + // with silence rather than with an error. + listsAcrossMayOmitRepositories: true, listChangeRequestsAcross: (input) => cli .searchPullRequests({ diff --git a/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts b/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts index 5a408c98044b..c5edf102cbc0 100644 --- a/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts +++ b/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts @@ -1399,6 +1399,38 @@ describe("review submission payload", () => { ], }); }); + + it("sends a range from its first line, and a one-line range as one line", () => { + const payload = buildReviewSubmission({ + verdict: "comment", + body: "", + comments: [ + { + path: "src/a.ts", + startPosition: { kind: "context", oldLine: 4, newLine: 5, side: "right" }, + position: { kind: "added", newLine: 9 }, + body: "this block", + }, + { + path: "src/b.ts", + startPosition: { kind: "added", newLine: 2 }, + position: { kind: "added", newLine: 2 }, + body: "this line", + }, + ], + }); + expect(payload.comments).toEqual([ + { + path: "src/a.ts", + start_line: 5, + start_side: "RIGHT", + line: 9, + side: "RIGHT", + body: "this block", + }, + { path: "src/b.ts", line: 2, side: "RIGHT", body: "this line" }, + ]); + }); }); describe("decodePullRequestFilesJson", () => { diff --git a/packages/source-control-github/src/server/gitHubPullRequestJson.ts b/packages/source-control-github/src/server/gitHubPullRequestJson.ts index 6d9abb6d604e..4f7e26e1b86d 100644 --- a/packages/source-control-github/src/server/gitHubPullRequestJson.ts +++ b/packages/source-control-github/src/server/gitHubPullRequestJson.ts @@ -309,6 +309,8 @@ const ReviewSubmissionSchema = Schema.Struct({ comments: Schema.Array( Schema.Struct({ path: Schema.String, + start_line: Schema.optional(Schema.Int), + start_side: Schema.optional(Schema.Literals(["LEFT", "RIGHT"])), line: Schema.Int, side: Schema.Literals(["LEFT", "RIGHT"]), body: Schema.String, @@ -346,11 +348,19 @@ export function buildReviewSubmission(input: { return { event: REVIEW_EVENTS[input.verdict], body: input.body, - comments: input.comments.map((comment) => ({ - path: comment.path, - ...gitHubReviewPosition(comment.position), - body: comment.body, - })), + comments: input.comments.map((comment) => { + const end = gitHubReviewPosition(comment.position); + const start = + comment.startPosition === undefined ? null : gitHubReviewPosition(comment.startPosition); + // GitHub refuses a start that is the end itself, so a one-line range is sent as one line. + const ranged = start !== null && (start.line !== end.line || start.side !== end.side); + return { + path: comment.path, + ...(ranged ? { start_line: start.line, start_side: start.side } : {}), + ...end, + body: comment.body, + }; + }), }; } diff --git a/packages/source-control-gitlab/src/client/definition.ts b/packages/source-control-gitlab/src/client/definition.ts index d9262f5abf73..801de8ca5618 100644 --- a/packages/source-control-gitlab/src/client/definition.ts +++ b/packages/source-control-gitlab/src/client/definition.ts @@ -46,6 +46,7 @@ export const definition = defineSourceControlClient({ "disable-auto-merge", ] as const), checkoutCommand: ({ number }) => `glab mr checkout ${number}`, + changeRequestCloseCommand: /\bglab\s+mr\s+(?:merge|close)\b/u, authorProfileUrl: () => null, referenceAutolinkRepositoryUrl: () => null, reviewSummaryRequired: () => false, diff --git a/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts b/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts index 297343fb9bef..987834a5e61e 100644 --- a/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts +++ b/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts @@ -41,6 +41,12 @@ const CAPABILITIES: PullRequestCapabilities = { edit: { changeRequest: true, comment: true }, }; +const UNAVAILABLE_REQUIREMENT = { + missing: + "GitLab CLI (`glab`) is required to browse change requests on this host. Install it from https://gitlab.com/gitlab-org/cli and reload.", + unauthenticated: "GitLab CLI is not authenticated. Run `glab auth login` and retry.", +}; + /** * The actions `user.can_merge` answers for. Rebasing writes to the source branch rather than to * the target, so it is not literally the same permission — but GitLab reports nothing narrower, @@ -115,6 +121,7 @@ export const make = Effect.gen(function* () { const provider: PullRequestProviderApi = { kind: SourceControlProviderKind.make("gitlab"), capabilities: CAPABILITIES, + unavailableRequirement: UNAVAILABLE_REQUIREMENT, getViewer: (input) => cli.getViewerUsername({ cwd: input.cwd }).pipe(Effect.mapError(fail("getViewer"))), From a6009faba02b0c820ddc7fd5c4a22e1804fc3e35 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 24 Oct 2026 04:24:35 -0700 Subject: [PATCH 06/27] refactor(clients): pull request surfaces ask the forge instead of assuming GitHub (#23997) Co-authored-by: Claude Opus 5.5 --- .../features/projects/AddProjectScreen.tsx | 3 +- .../PullRequestDiscussionScreen.tsx | 5 +- .../pull-requests/PullRequestMarkdown.tsx | 30 +++++-- .../pull-requests/PullRequestRouteScreen.tsx | 5 +- .../pull-requests/PullRequestStatusRows.tsx | 5 +- .../guide/PullRequestGuideScreen.tsx | 5 +- .../pullRequestActivityFilter.test.ts | 2 +- .../pullRequestActivityFilter.ts | 2 +- .../pullRequestAutolinks.test.ts | 38 ++++++++ .../pull-requests/pullRequestAutolinks.ts | 73 +++++++++++++++ .../pull-requests/usePullRequestList.ts | 7 +- .../src/features/threads/ThreadFeed.tsx | 2 - apps/mobile/src/lib/markdownLinkIcons.ts | 14 +-- apps/mobile/src/lib/markdownLinks.ts | 8 -- .../src/native/useEnrichedLinkVariants.ts | 6 +- apps/web/src/components/ChatMarkdown.tsx | 4 +- apps/web/src/components/CommandPalette.tsx | 17 +++- .../src/components/ThreadStatusIndicators.tsx | 6 +- .../components/chat/MarkdownExternalLink.tsx | 16 ++-- .../pullRequest/PullRequestDetailActions.tsx | 2 +- .../pullRequest/PullRequestDetailPanel.tsx | 12 +-- .../pullRequest/PullRequestMarkdown.tsx | 13 +-- .../pullRequestDetail.logic.test.ts | 16 ++++ .../pullRequest/pullRequestDetail.logic.ts | 9 +- .../pullRequest/pullRequestList.logic.test.ts | 8 +- .../pullRequest/pullRequestList.logic.ts | 9 +- .../pullRequestMarkdown.logic.test.ts | 46 ++++++++++ .../pullRequest/pullRequestMarkdown.logic.ts | 82 ++++++++--------- .../settings/ThemeSearchSection.tsx | 17 ++-- apps/web/src/vendor/mdast-find-and-replace.ts | 89 ------------------- .../client-runtime/src/operations/projects.ts | 20 +++-- .../src/pullRequestMarkdown.test.ts | 19 +++- .../client-runtime/src/pullRequestMarkdown.ts | 18 +++- .../src/sourceControlClients.test.ts | 15 ++++ .../src/sourceControlClients.ts | 1 + packages/shared/package.json | 4 - .../shared/src/githubMarkdownLinks.test.ts | 27 ------ packages/shared/src/githubMarkdownLinks.ts | 89 ------------------- .../src/client/definition.ts | 1 - .../src/client/definition.ts | 1 - .../src/client/definition.ts | 47 +++++++++- .../src/client/definition.ts | 1 - .../src/client/definition.ts | 1 - .../src/server/gitCafePullRequestJson.test.ts | 19 ++++ .../src/server/gitCafePullRequestJson.ts | 7 +- .../src/client/definition.ts | 8 +- .../src/client/referenceAutolinks.test.ts | 44 +++++++++ .../src/client/referenceAutolinks.ts | 58 ++++++++++++ .../src/client/definition.ts | 1 - 49 files changed, 578 insertions(+), 354 deletions(-) create mode 100644 apps/mobile/src/features/pull-requests/pullRequestAutolinks.test.ts create mode 100644 apps/mobile/src/features/pull-requests/pullRequestAutolinks.ts create mode 100644 apps/web/src/components/pullRequest/pullRequestMarkdown.logic.test.ts delete mode 100644 apps/web/src/vendor/mdast-find-and-replace.ts delete mode 100644 packages/shared/src/githubMarkdownLinks.test.ts delete mode 100644 packages/shared/src/githubMarkdownLinks.ts create mode 100644 packages/source-control-github/src/client/referenceAutolinks.test.ts create mode 100644 packages/source-control-github/src/client/referenceAutolinks.ts diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index 66a68b2ebae2..9ac01547d035 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -21,6 +21,7 @@ import { getNewProjectPublishTarget, getNewProjectPublishTargets, getNewProjectRepository, + defaultHostCloneUrl, normalizePastedCloneUrl, parseAddProjectRemoteSource, resolveAddProjectPath, @@ -821,7 +822,7 @@ export function AddProjectRepositoryScreen(props: { autoCorrect={false} placeholder={ source === "url" - ? "https://github.com/org/repo.git" + ? (defaultHostCloneUrl("org/repo") ?? "https://example.com/org/repo.git") : addProjectRemoteSourcePathHint(source) } returnKeyType="next" diff --git a/apps/mobile/src/features/pull-requests/PullRequestDiscussionScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestDiscussionScreen.tsx index 6ee6c572625f..68ab91b3a3f9 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestDiscussionScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestDiscussionScreen.tsx @@ -124,10 +124,7 @@ function Discussion({ params }: { params: PullRequestParams & { readonly discuss const canReply = permissions.canReply && Boolean(thread); const resolveLabel = thread?.isResolved ? "Unresolve" : "Resolve"; return ( - + (null); +const AutolinksContext = createContext<((text: string) => ReadonlyArray) | null>( + null, +); export function PullRequestMarkdownRepository(props: { - readonly value: string | null; + /** The change request the markdown belongs to, whose host decides what its text links. */ + readonly changeRequest: { readonly provider: string; readonly url: string } | null | undefined; readonly params: PullRequestParams; readonly children: ReactNode; }) { + const provider = props.changeRequest?.provider; + const url = props.changeRequest?.url; + const autolinks = useMemo( + () => + provider === undefined || url === undefined + ? null + : changeRequestReferenceAutolinker({ provider, url }), + [provider, url], + ); const target = pullRequestTarget(props.params); const project = useProject({ environmentId: target.environmentId, @@ -44,9 +58,9 @@ export function PullRequestMarkdownRepository(props: { [target.environmentId, cwd], ); return ( - + {props.children} - + ); } @@ -103,11 +117,11 @@ function PullRequestMarkdownContent(props: { readonly markdown: string; readonly imageGroupMarkdown?: string; }) { - const repositoryUrl = useContext(RepositoryContext); + const autolinks = useContext(AutolinksContext); const mediaContext = useContext(PullRequestMediaContext); const markdown = useMemo( - () => githubMarkdownLinks(props.markdown, repositoryUrl), - [props.markdown, repositoryUrl], + () => linkReferenceAutolinks(props.markdown, autolinks), + [props.markdown, autolinks], ); const openPullRequest = useOpenPullRequest(); const openLink = useCallback( diff --git a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx index f83ef07eb154..c12bd8739644 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx @@ -387,10 +387,7 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps + void Linking.openURL( - props.pr.provider === "github" ? `${props.pr.url}/checks` : props.pr.url, + sourceControlClients + .get(props.pr.provider) + .changeRequestChecksUrl?.(props.pr.url) ?? props.pr.url, ) } /> diff --git a/apps/mobile/src/features/pull-requests/guide/PullRequestGuideScreen.tsx b/apps/mobile/src/features/pull-requests/guide/PullRequestGuideScreen.tsx index cfc18131873b..08fdfb1ab18c 100644 --- a/apps/mobile/src/features/pull-requests/guide/PullRequestGuideScreen.tsx +++ b/apps/mobile/src/features/pull-requests/guide/PullRequestGuideScreen.tsx @@ -161,10 +161,7 @@ export function PullRequestGuideStopScreen({ route }: StaticScreenProps - + ({ id, author, diff --git a/apps/mobile/src/features/pull-requests/pullRequestActivityFilter.ts b/apps/mobile/src/features/pull-requests/pullRequestActivityFilter.ts index 46c4cfda2ac9..85b50b2fcf63 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestActivityFilter.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestActivityFilter.ts @@ -15,7 +15,7 @@ export const DEFAULT_ACTIVITY_FILTER: ActivityFilter = { }; function isBot(actor: PullRequestActor | null) { - return actor?.isBot === true || actor?.login.endsWith("[bot]") === true; + return actor?.isBot === true; } function eventCount(activity: PullRequestActivity) { diff --git a/apps/mobile/src/features/pull-requests/pullRequestAutolinks.test.ts b/apps/mobile/src/features/pull-requests/pullRequestAutolinks.test.ts new file mode 100644 index 000000000000..4aeeb1f388a7 --- /dev/null +++ b/apps/mobile/src/features/pull-requests/pullRequestAutolinks.test.ts @@ -0,0 +1,38 @@ +import { changeRequestReferenceAutolinker } from "@t3tools/client-runtime/pull-request-markdown"; +import { describe, expect, it } from "vite-plus/test"; + +import { linkReferenceAutolinks } from "./pullRequestAutolinks"; + +const github = changeRequestReferenceAutolinker({ + provider: "github", + url: "https://github.com/team/repo/pull/8", +}); + +describe("linkReferenceAutolinks", () => { + it("links the host's references in prose", () => { + expect(linkReferenceAutolinks("Closes #123, see @julius", github)).toBe( + "Closes [#123](https://github.com/team/repo/issues/123), see [@julius](https://github.com/julius)", + ); + }); + + it("leaves code, links, images, escapes and definitions alone", () => { + const text = + "`#123 @person abc1234`\n\n```\n#123\n```\n\n[#123](https://example.com) ![#123](image.png) https://example.com/#123 user@example.com \\#123 1234567\n\n[ref]: https://example.com/#123"; + expect(linkReferenceAutolinks(text, github)).toBe(text); + }); + + it("does not nest links inside HTML anchors or code", () => { + const text = 'See #12 and #13, then #14.'; + expect(linkReferenceAutolinks(text, github)).toBe( + 'See #12 and #13, then [#14](https://github.com/team/repo/issues/14).', + ); + }); + + it("leaves markdown unchanged for a host that links nothing", () => { + const gitlab = changeRequestReferenceAutolinker({ + provider: "gitlab", + url: "https://gitlab.com/group/project/-/merge_requests/7", + }); + expect(linkReferenceAutolinks("Closes #123", gitlab)).toBe("Closes #123"); + }); +}); diff --git a/apps/mobile/src/features/pull-requests/pullRequestAutolinks.ts b/apps/mobile/src/features/pull-requests/pullRequestAutolinks.ts new file mode 100644 index 000000000000..d46b2b11a665 --- /dev/null +++ b/apps/mobile/src/features/pull-requests/pullRequestAutolinks.ts @@ -0,0 +1,73 @@ +import type { ReferenceAutolink } from "@t3tools/client-runtime/source-control-clients"; +import remarkGfm from "remark-gfm"; +import remarkParse from "remark-parse"; +import { unified } from "unified"; + +type MarkdownNode = { + readonly type: string; + readonly position?: + | { + readonly start: { readonly offset?: number | undefined }; + readonly end: { readonly offset?: number | undefined }; + } + | undefined; + readonly children?: readonly MarkdownNode[] | undefined; +}; + +const parser = unified().use(remarkParse).use(remarkGfm); +const UNLINKED_TYPES = new Set([ + "link", + "linkReference", + "image", + "imageReference", + "code", + "inlineCode", + "html", + "definition", +]); + +/** + * Rewrites the references `autolinks` finds in the markdown's prose as markdown links, because the + * native renderer takes markdown text. Text is searched as written, escapes included. + */ +export function linkReferenceAutolinks( + markdown: string, + autolinks: ((text: string) => ReadonlyArray) | null, +): string { + if (autolinks === null) return markdown; + const edits: { start: number; end: number; value: string }[] = []; + const visit = (node: MarkdownNode) => { + if (UNLINKED_TYPES.has(node.type)) return; + if (node.type === "text") { + const start = node.position?.start.offset; + const end = node.position?.end.offset; + if (start === undefined || end === undefined) return; + for (const link of autolinks(markdown.slice(start, end))) { + edits.push({ + start: start + link.index, + end: start + link.index + link.text.length, + value: `[${link.label}](${link.url})`, + }); + } + } else if (node.children !== undefined) { + let protectedHtmlDepth = 0; + for (const child of node.children) { + if (child.type === "html") { + const start = child.position?.start.offset; + const end = child.position?.end.offset; + const tag = start === undefined || end === undefined ? "" : markdown.slice(start, end); + for (const match of tag.matchAll(/<(\/)?(?:a|code|pre|script|style)(?=[\s>])[^>]*>/giu)) { + protectedHtmlDepth = Math.max(0, protectedHtmlDepth + (match[1] ? -1 : 1)); + } + } else if (protectedHtmlDepth === 0) visit(child); + } + } + }; + visit(parser.parse(markdown)); + let linked = markdown; + for (let index = edits.length - 1; index >= 0; index--) { + const edit = edits[index]!; + linked = linked.slice(0, edit.start) + edit.value + linked.slice(edit.end); + } + return linked; +} diff --git a/apps/mobile/src/features/pull-requests/usePullRequestList.ts b/apps/mobile/src/features/pull-requests/usePullRequestList.ts index 512e9c46036e..5656e1d18ab5 100644 --- a/apps/mobile/src/features/pull-requests/usePullRequestList.ts +++ b/apps/mobile/src/features/pull-requests/usePullRequestList.ts @@ -202,12 +202,7 @@ export function usePullRequestList(targets: readonly ListTarget[]) { const statsKey = JSON.stringify( targets.flatMap(({ environmentId }) => { const refs = visibleRows - .filter( - (row) => - row.environmentId === environmentId && - row.provider === "github" && - row.additions + row.deletions === 0, - ) + .filter((row) => row.environmentId === environmentId && row.additions + row.deletions === 0) .map(({ projectId, host, repository, number }) => ({ projectId, host, diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index f2f4e9a39866..5d29e519917f 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -188,11 +188,9 @@ import { } from "../../lib/appearancePreferences"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { PierreEntryIcon } from "../../components/PierreEntryIcon"; -import { markdownLinkIconSource } from "../../lib/markdownLinkIcons"; import { normalizeNativeMarkdownUrl, resolveMarkdownInlineCodePresentation, - resolveMarkdownLinkIcon, resolveMarkdownLinkPresentation, } from "../../lib/markdownLinks"; import { diff --git a/apps/mobile/src/lib/markdownLinkIcons.ts b/apps/mobile/src/lib/markdownLinkIcons.ts index 13eee7bbfac0..f88ac2f52bca 100644 --- a/apps/mobile/src/lib/markdownLinkIcons.ts +++ b/apps/mobile/src/lib/markdownLinkIcons.ts @@ -1,11 +1,13 @@ +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import type { ImageSourcePropType } from "react-native"; -import type { MarkdownLinkIcon } from "./markdownLinks"; - -const MARKDOWN_LINK_ICON_SOURCES = { +// The native renderer draws a link's icon from an image, so a host's mark is a bundled image keyed +// by its definition's `icon`. A host without one keeps a plain link. +const SOURCE_CONTROL_LINK_ICON_SOURCES: Partial> = { github: require("../../assets/link-icons/github.png"), -} as const satisfies Readonly>; +}; -export function markdownLinkIconSource(icon: MarkdownLinkIcon): ImageSourcePropType { - return MARKDOWN_LINK_ICON_SOURCES[icon]; +export function markdownLinkIconSource(host: string): ImageSourcePropType | null { + const icon = sourceControlClients.findByPublicDomain(host)?.icon; + return (icon === undefined ? undefined : SOURCE_CONTROL_LINK_ICON_SOURCES[icon]) ?? null; } diff --git a/apps/mobile/src/lib/markdownLinks.ts b/apps/mobile/src/lib/markdownLinks.ts index f68add2bf7f9..8020c1de5b13 100644 --- a/apps/mobile/src/lib/markdownLinks.ts +++ b/apps/mobile/src/lib/markdownLinks.ts @@ -34,14 +34,6 @@ export type MarkdownLinkPresentation = export type MarkdownFileIcon = keyof typeof MARKDOWN_FILE_ICON_SOURCES; -export type MarkdownLinkIcon = "github"; - -export function resolveMarkdownLinkIcon(host: string): MarkdownLinkIcon | null { - const hostname = host.toLowerCase(); - if (hostname === "github.com" || hostname.endsWith(".github.com")) return "github"; - return null; -} - const FILE_ICON_BY_NAME: Readonly> = { ".babelrc": "babel", ".babelrc.json": "babel", diff --git a/apps/mobile/src/native/useEnrichedLinkVariants.ts b/apps/mobile/src/native/useEnrichedLinkVariants.ts index 2b32956f5e03..dda01bd75451 100644 --- a/apps/mobile/src/native/useEnrichedLinkVariants.ts +++ b/apps/mobile/src/native/useEnrichedLinkVariants.ts @@ -13,7 +13,7 @@ import { contextChipIconSource } from "../lib/contextChipIconSource"; import { markdownFileIconSource } from "../lib/markdownFileIcons"; import { markdownLinkIconSource } from "../lib/markdownLinkIcons"; import { useUniwindTheme } from "../lib/useUniwindTheme"; -import { resolveMarkdownLinkIcon, resolveMarkdownLinkPresentation } from "../lib/markdownLinks"; +import { resolveMarkdownLinkPresentation } from "../lib/markdownLinks"; import type { MarkdownLinkCustomization, SelectableMarkdownTextProps, @@ -83,11 +83,11 @@ function presentation( icon: markdownFileIconSource(link.icon), chip: { accent: contextChipPresentation("mention").accent, tintsIcon: false }, }; - const icon = link.kind === "external" ? resolveMarkdownLinkIcon(link.host) : null; + const icon = link.kind === "external" ? markdownLinkIconSource(link.host) : null; return icon ? { color: props.textStyle.linkColor, - icon: markdownLinkIconSource(icon), + icon, iconTintColor: props.textStyle.linkColor, } : undefined; diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 2a3c71583427..847d5dcd8d53 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1102,8 +1102,8 @@ const CHAT_MARKDOWN_COMPONENTS = { ); const pullRequestCopy = pullRequestAutolink === "commit" - ? /\/commit\/([0-9a-f]{40})$/iu.exec(href ?? "")?.[1] - : pullRequestAutolink === "reference" + ? /\/commit\/([0-9a-f]{7,40})$/iu.exec(href ?? "")?.[1] + : pullRequestAutolink === "reference" || pullRequestAutolink === "mention" ? plainHastText(node) : undefined; const isPullRequestAutolink = pullRequestCopy !== undefined; diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 53a39007574e..0d760535ee70 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -1540,7 +1540,22 @@ function OpenCommandPaletteDialog(props: { actionItems.push({ kind: "action", value: "action:pull-requests", - searchTerms: ["pull requests", "prs", "pr", "github", "review", "merge", "branch"], + searchTerms: [ + ...new Set( + sourceControlClients.definitions.flatMap((definition) => { + const shortLabel = definition.changeRequest.shortLabel.toLowerCase(); + return [ + `${definition.changeRequest.singular}s`, + `${shortLabel}s`, + shortLabel, + ...definition.label.toLowerCase().split(/[^a-z0-9]+/u), + ].filter(Boolean); + }), + ), + "review", + "merge", + "branch", + ], title: "Open pull requests", icon: , run: async () => { diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 7a66512ccadc..d540cc8e06f6 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -114,7 +114,11 @@ export function useLinkedThreadPullRequest( ? null : { pr: pullRequestDetailToVcsStatus(detail), - sourceControlProvider: { kind: detail.provider, name: detail.provider, baseUrl: "" }, + sourceControlProvider: { + kind: detail.provider, + name: sourceControlClients.get(detail.provider).label, + baseUrl: "", + }, }; } diff --git a/apps/web/src/components/chat/MarkdownExternalLink.tsx b/apps/web/src/components/chat/MarkdownExternalLink.tsx index fae6a9fad486..b3efae29406b 100644 --- a/apps/web/src/components/chat/MarkdownExternalLink.tsx +++ b/apps/web/src/components/chat/MarkdownExternalLink.tsx @@ -2,21 +2,25 @@ import { faviconUrlForOrigin } from "@t3tools/shared/favicon"; import { GlobeIcon } from "lucide-react"; import { Children, Fragment, memo, useState, type ReactNode } from "react"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; + import { cn } from "../../lib/utils"; -import { GitHubIcon } from "../Icons"; +import { sourceControlIcon } from "../../sourceControlPresentation"; const MARKDOWN_LINK_FAVICON_CLASS_NAME = "block size-full shrink-0 select-none"; const failedFaviconHosts = new Set(); -function isGitHubHost(host: string): boolean { - const hostname = host.toLowerCase(); - return hostname === "github.com" || hostname.endsWith(".github.com"); +function sourceControlHostIcon(host: string): ReactNode { + const definition = sourceControlClients.findByPublicDomain(host); + if (definition === undefined) return null; + const Icon = sourceControlIcon(definition); + return ; } const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host: string }) { const [failedHost, setFailedHost] = useState(null); - const brandIcon = isGitHubHost(host); + const brandIcon = sourceControlHostIcon(host); const faviconUrl = brandIcon ? null : faviconUrlForOrigin(`https://${host}`); return ( {brandIcon ? ( - + brandIcon ) : faviconUrl === null || failedHost === host || failedFaviconHosts.has(host) ? ( ) : ( diff --git a/apps/web/src/components/pullRequest/PullRequestDetailActions.tsx b/apps/web/src/components/pullRequest/PullRequestDetailActions.tsx index de1af5536fb6..c4956c019962 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailActions.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailActions.tsx @@ -131,7 +131,7 @@ export function PullRequestCheckoutMenu({ In this repository - Switches the branch you are working in, like `gh pr checkout`. + Switches the branch you are working in. diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index d8a1f51a9334..385f9c416d61 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -1,4 +1,5 @@ import { projectChangeRequestUrl } from "@t3tools/client-runtime/change-request-links"; +import { changeRequestReferenceAutolinker } from "@t3tools/client-runtime/pull-request-markdown"; import { useAtomValue } from "@effect/atom-react"; import { usePullRequestStack } from "~/state/usePullRequestStack"; import { @@ -233,15 +234,10 @@ function resolvePullRequestReference( } function pullRequestMarkdownContext( - hostClient: ReturnType, - repositoryUrl: string | null, + detail: Pick | null, threadRef: ScopedThreadRef | null, ) { - return { - repositoryUrl: - repositoryUrl === null ? null : hostClient.referenceAutolinkRepositoryUrl(repositoryUrl), - threadRef, - }; + return { autolinks: changeRequestReferenceAutolinker(detail), threadRef }; } function pullRequestStackReference( @@ -477,7 +473,7 @@ export function PullRequestDetailPanel({ : (sourceControlClients.parseChangeRequestUrl(detail.url)?.link.repositoryUrl ?? null); const hostClient = sourceControlClients.get(detail?.provider); const openOnHostText = openOnHostLabel(hostClient); - const markdownContext = pullRequestMarkdownContext(hostClient, repositoryUrl, threadRef); + const markdownContext = pullRequestMarkdownContext(detail, threadRef); const authorProfileUrl = detail?.author && repositoryUrl !== null ? hostClient.authorProfileUrl(detail.author.login, repositoryUrl) diff --git a/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx b/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx index 91dec8acdaca..a9fdf0f09800 100644 --- a/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx +++ b/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx @@ -1,6 +1,9 @@ import { ExternalLinkIcon, PaperclipIcon } from "lucide-react"; import { markdownImageSourceFragment } from "@t3tools/client-runtime/markdown-images"; -import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; +import { + sourceControlClients, + type ReferenceAutolink, +} from "@t3tools/client-runtime/source-control-clients"; import { SOURCE_CONTROL_MEDIA_ASSET, type AssetResource, @@ -19,7 +22,7 @@ import { MediaVideoPlayer } from "../media/MediaVideoPlayer"; import { remarkPullRequestAutolinks, splitPullRequestBody } from "./pullRequestMarkdown.logic"; export const PullRequestMarkdownContext = createContext<{ - repositoryUrl: string | null; + autolinks: ((text: string) => ReadonlyArray) | null; threadRef: ScopedThreadRef | null; } | null>(null); @@ -66,10 +69,10 @@ export function PullRequestMarkdown({ }) { const segments = splitPullRequestBody(text); const context = useContext(PullRequestMarkdownContext); - const repositoryUrl = context?.repositoryUrl; + const autolinks = context?.autolinks; const resolvedThreadRef = threadRef ?? context?.threadRef ?? undefined; - const extraRemarkPlugins: NonNullable = repositoryUrl - ? [[remarkPullRequestAutolinks, { repositoryUrl }]] + const extraRemarkPlugins: NonNullable = autolinks + ? [[remarkPullRequestAutolinks, { autolinks }]] : []; return (
{ }, ); }); + +describe("readableFailure", () => { + it("replaces a host tool's bare failure with the hint, for every host", () => { + for (const message of [ + "GitHub CLI failed.", + "Azure DevOps API failed", + "Forgejo command failed", + ]) { + expect(readableFailure(new Error(message), "Try again.")).toBe("Try again."); + } + expect(readableFailure(new Error("Branch is protected."), "Try again.")).toBe( + "Branch is protected.", + ); + }); +}); diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts index 9dabaea0992e..8f5446811a0a 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts @@ -646,8 +646,7 @@ export function filterPullRequestConversationItems( // Matching any member keeps the finding beside a human reply to a bot. pullRequestConversationItemComments(item).some( (comment) => - (filter.bots || - !(comment.author?.isBot === true || comment.author?.login.endsWith("[bot]"))) && + (filter.bots || comment.author?.isBot !== true) && (filter.resolved || !isPullRequestCommentFinished(comment, item.kind === "thread" ? item.thread : undefined)), ), @@ -1216,8 +1215,12 @@ export function isPullRequestNotFound(failure: unknown): boolean { const OPERATION_PREFIX = /^Pull request operation \w+ failed:\s*/iu; +const HOST_LABELS = sourceControlClients.definitions + .map((definition) => definition.label.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&")) + .join("|"); + const TOOL_NOISE = [ - /^(github|gitlab|bitbucket|azure devops)?\s*(cli|api)?\s*(command\s*)?failed\.?$/iu, + new RegExp(`^(${HOST_LABELS})?\\s*(cli|api)?\\s*(command\\s*)?failed\\.?$`, "iu"), /^exited? with (code|status) \d+\.?$/iu, /^unknown error\.?$/iu, ]; diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts index 58f6964e4b9e..7f3c6bfd0bd1 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts @@ -701,10 +701,11 @@ describe("pull request quick actions", () => { ...(actions === undefined ? {} : { actions }), }); - it("offers what each host reports, and GitHub's legacy set when a server reports none", () => { + it("offers what each host reports, and the host's own set when a server reports none", () => { const actionsOf = pullRequestHostActions([ summary("bitbucket.org", "bitbucket", ["merge", "close"]), summary("github.com", "github"), + summary("gitlab.com", "gitlab"), ]); const bitbucket = actionsOf({ host: "bitbucket.org", @@ -719,6 +720,11 @@ describe("pull request quick actions", () => { "close", "ready", ]); + const gitlab = actionsOf({ + host: "gitlab.com", + provider: SourceControlProviderKind.make("gitlab"), + }); + expect(pullRequestQuickActions({ state: "open", isDraft: false }, gitlab)).toEqual([]); }); }); diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.ts index 656913257a11..5bf0b6f4c021 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.ts @@ -1015,10 +1015,11 @@ export function pullRequestHostActions( : [[provider.host.toLowerCase(), new Set(provider.actions)] as const], ), ); - const legacy = new Set(["close", "merge", "ready", "reopen"]); const none = new Set(); return (entry) => - byHost.get(entry.host.toLowerCase()) ?? (entry.provider === "github" ? legacy : none); + byHost.get(entry.host.toLowerCase()) ?? + sourceControlClients.find(entry.provider)?.changeRequestActionsWhenUnreported ?? + none; } export function pullRequestQuickActions( @@ -1042,7 +1043,9 @@ export function largestPullRequestReadCount( const counts = new Map(); for (const entry of entries) { const read = `${entry.environmentId} ${entry.host.toLowerCase()} ${ - entry.provider === "github" ? "" : entry.repository.toLowerCase() + sourceControlClients.find(entry.provider)?.changeRequestListScope === "host" + ? "" + : entry.repository.toLowerCase() }`; counts.set(read, (counts.get(read) ?? 0) + 1); } diff --git a/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.test.ts new file mode 100644 index 000000000000..19c2e9038449 --- /dev/null +++ b/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.test.ts @@ -0,0 +1,46 @@ +import { changeRequestReferenceAutolinker } from "@t3tools/client-runtime/pull-request-markdown"; +import remarkParse from "remark-parse"; +import { unified } from "unified"; +import { describe, expect, it } from "vite-plus/test"; + +import { remarkPullRequestAutolinks } from "./pullRequestMarkdown.logic"; + +type Node = { + type: string; + value?: string; + url?: string; + data?: { hProperties?: Record }; + children?: Node[]; +}; + +function links(markdown: string) { + const autolinks = changeRequestReferenceAutolinker({ + provider: "github", + url: "https://github.com/team/repo/pull/8", + })!; + const tree = unified().use(remarkParse).parse(markdown) as Node; + remarkPullRequestAutolinks({ autolinks })(tree as never); + const found: Array<[string | undefined, string | undefined, unknown]> = []; + const visit = (node: Node) => { + if (node.type === "link") { + found.push([ + node.children?.[0]?.value, + node.url, + node.data?.hProperties?.["dataPullRequestAutolink"], + ]); + } + node.children?.forEach(visit); + }; + visit(tree); + return found; +} + +describe("remarkPullRequestAutolinks", () => { + it("links the host's references in prose and leaves links and code alone", () => { + expect(links("Fixes #12 in **abcdef1**. [#13](https://example.com) `#14`")).toEqual([ + ["#12", "https://github.com/team/repo/issues/12", "reference"], + ["abcdef1", "https://github.com/team/repo/commit/abcdef1", "commit"], + ["#13", "https://example.com", undefined], + ]); + }); +}); diff --git a/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.ts b/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.ts index 659236fc2e54..bb38f3d6caf9 100644 --- a/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.ts @@ -1,52 +1,54 @@ -import { - findAndReplaceText, - type MarkdownNode, - type TextMatch, -} from "~/vendor/mdast-find-and-replace"; +import type { ReferenceAutolink } from "@t3tools/client-runtime/source-control-clients"; export { splitPullRequestBody, type PullRequestBodySegment, } from "@t3tools/client-runtime/pull-request-markdown"; -const AUTOLINK_CANDIDATE_PATTERN = /#[1-9]\d*|[0-9a-f]{40}/giu; -const AUTOLINK_WORD_CHARACTER_PATTERN = /[A-Za-z0-9_]/u; -const AUTOLINK_COMMIT_PREFIX_PATTERN = /[\s([{]/u; +type MarkdownNode = { + type: string; + value?: string; + children?: MarkdownNode[]; + url?: string; + data?: unknown; +}; + const AUTOLINK_IGNORED_TYPES = new Set(["link", "linkReference"]); -export function remarkPullRequestAutolinks(options: { readonly repositoryUrl: string }) { - const repositoryUrl = options.repositoryUrl.replace(/\/+$/u, ""); - return (tree: MarkdownNode) => { - findAndReplaceText( - tree, - AUTOLINK_CANDIDATE_PATTERN, - (matched: string, match: TextMatch) => { - const reference = matched.startsWith("#"); - const before = match.input[match.index - 1]; - const after = match.input[match.index + matched.length]; - if ( - (before !== undefined && - (reference - ? AUTOLINK_WORD_CHARACTER_PATTERN.test(before) - : !AUTOLINK_COMMIT_PREFIX_PATTERN.test(before))) || - (after !== undefined && AUTOLINK_WORD_CHARACTER_PATTERN.test(after)) - ) { - return false; +export function remarkPullRequestAutolinks(options: { + readonly autolinks: (text: string) => ReadonlyArray; +}) { + const visit = (node: MarkdownNode): void => { + const children = node.children; + if (children === undefined) return; + for (let index = 0; index < children.length; index += 1) { + const child = children[index]!; + if (AUTOLINK_IGNORED_TYPES.has(child.type)) continue; + if (child.type !== "text" || child.value === undefined) { + visit(child); + continue; + } + const value = child.value; + const links = options.autolinks(value); + if (links.length === 0) continue; + const replacements: MarkdownNode[] = []; + let start = 0; + for (const link of links) { + if (start < link.index) { + replacements.push({ type: "text", value: value.slice(start, link.index) }); } - return { + replacements.push({ type: "link", - url: reference - ? `${repositoryUrl}/issues/${matched.slice(1)}` - : `${repositoryUrl}/commit/${matched}`, - data: { - hProperties: { - dataPullRequestAutolink: reference ? "reference" : "commit", - }, - }, - children: [{ type: "text", value: reference ? matched : matched.slice(0, 7) }], - }; - }, - AUTOLINK_IGNORED_TYPES, - ); + url: link.url, + data: { hProperties: { dataPullRequestAutolink: link.kind } }, + children: [{ type: "text", value: link.label }], + }); + start = link.index + link.text.length; + } + if (start < value.length) replacements.push({ type: "text", value: value.slice(start) }); + children.splice(index, 1, ...replacements); + index += replacements.length - 1; + } }; + return (tree: MarkdownNode) => visit(tree); } diff --git a/apps/web/src/components/settings/ThemeSearchSection.tsx b/apps/web/src/components/settings/ThemeSearchSection.tsx index b754391c459b..0afa8cff9afe 100644 --- a/apps/web/src/components/settings/ThemeSearchSection.tsx +++ b/apps/web/src/components/settings/ThemeSearchSection.tsx @@ -1,6 +1,7 @@ +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { RefreshIcon } from "~/components/ui/refresh-icon"; import { ExternalLinkIcon, PackagePlusIcon, PaletteIcon, SearchIcon } from "lucide-react"; -import { useEffect, useEffectEvent, useRef, useState } from "react"; +import { type ReactNode, useEffect, useEffectEvent, useRef, useState } from "react"; import { importOpenVsxThemeExtension, searchOpenVsxThemes, @@ -14,7 +15,7 @@ import { replaceCustomThemeCollection, type ThemeDefinition, } from "../../themePalette"; -import { GitHubIcon, GitLabIcon } from "../Icons"; +import { sourceControlIcon } from "../../sourceControlPresentation"; import { AlertDialog, AlertDialogClose, @@ -51,13 +52,11 @@ function sourceHost(url: string): string | null { } } -function SourceLinkIcon({ url }: { url: string }) { +function sourceLinkIcon(url: string): ReactNode { const host = sourceHost(url); - if (host === "github.com" || host?.endsWith(".github.com")) - return ; - if (host === "gitlab.com" || host?.endsWith(".gitlab.com")) - return ; - return ; + const definition = host === null ? undefined : sourceControlClients.findByPublicDomain(host); + const Icon = definition ? sourceControlIcon(definition) : ExternalLinkIcon; + return ; } function ThemeExtensionIcon({ extension }: { extension: OpenVsxThemeExtension }) { @@ -415,7 +414,7 @@ export function ThemeSearchSection({ size="icon-micro" variant="ghost-muted" > - + {sourceLinkIcon(extension.sourceUrl)} ) : null}
diff --git a/apps/web/src/vendor/mdast-find-and-replace.ts b/apps/web/src/vendor/mdast-find-and-replace.ts deleted file mode 100644 index f1bd1cbdfee3..000000000000 --- a/apps/web/src/vendor/mdast-find-and-replace.ts +++ /dev/null @@ -1,89 +0,0 @@ -/* - * Adapted from mdast-util-find-and-replace: - * https://github.com/syntax-tree/mdast-util-find-and-replace/blob/main/lib/index.js - * - * The MIT License - * - * Copyright (c) Titus Wormer - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ - -export type MarkdownNode = { - type: string; - value?: string; - children?: MarkdownNode[]; - url?: string; - data?: unknown; -}; - -export type TextMatch = { - index: number; - input: string; -}; - -export function findAndReplaceText( - tree: MarkdownNode, - find: RegExp, - replace: (matched: string, match: TextMatch) => MarkdownNode | false, - ignoredTypes: ReadonlySet, -): void { - visit(tree); - - function visit(node: MarkdownNode): void { - if (node.children === undefined) return; - for (let childIndex = 0; childIndex < node.children.length; childIndex += 1) { - const child = node.children[childIndex]!; - if (ignoredTypes.has(child.type)) continue; - if (child.type !== "text" || child.value === undefined) { - visit(child); - continue; - } - - const replacements: MarkdownNode[] = []; - let start = 0; - let changed = false; - find.lastIndex = 0; - let match = find.exec(child.value); - while (match !== null) { - const position = match.index; - const replacement = replace(match[0], { index: position, input: match.input }); - if (replacement === false) { - find.lastIndex = position + 1; - } else { - if (start < position) { - replacements.push({ type: "text", value: child.value.slice(start, position) }); - } - replacements.push(replacement); - start = position + match[0].length; - changed = true; - } - if (!find.global) break; - match = find.exec(child.value); - } - - if (!changed) continue; - if (start < child.value.length) { - replacements.push({ type: "text", value: child.value.slice(start) }); - } - node.children.splice(childIndex, 1, ...replacements); - childIndex += replacements.length - 1; - } - } -} diff --git a/packages/client-runtime/src/operations/projects.ts b/packages/client-runtime/src/operations/projects.ts index 90729a640465..182c95d6d717 100644 --- a/packages/client-runtime/src/operations/projects.ts +++ b/packages/client-runtime/src/operations/projects.ts @@ -91,14 +91,24 @@ export function parseAddProjectRemoteSource( return (value ? sourceControlClients.find(value)?.kind : undefined) ?? "url"; } -const GITHUB_REPOSITORY_SHORTHAND = - /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/[A-Za-z0-9._-]+(?:\.git)?$/; +const REPOSITORY_SHORTHAND = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/[A-Za-z0-9._-]+(?:\.git)?$/; + +/** + * The clone URL of `owner/name` on the public host of the host a repository reads as before it + * reports one. Null when that host has no public host. + */ +export function defaultHostCloneUrl(repository: string): string | null { + const definition = sourceControlClients.get(undefined); + if (definition.publicHost === null) return null; + const path = repository.endsWith(".git") ? repository : `${repository}.git`; + return definition.defaultCloneTransport === "https" + ? `https://${definition.publicHost}/${path}` + : `git@${definition.publicHost}:${path}`; +} export function normalizePastedCloneUrl(input: string): string { const trimmed = input.trim(); - if (!GITHUB_REPOSITORY_SHORTHAND.test(trimmed)) return trimmed; - const repository = trimmed.endsWith(".git") ? trimmed : `${trimmed}.git`; - return `https://github.com/${repository}`; + return REPOSITORY_SHORTHAND.test(trimmed) ? (defaultHostCloneUrl(trimmed) ?? trimmed) : trimmed; } export function getDefaultCloneUrl( diff --git a/packages/client-runtime/src/pullRequestMarkdown.test.ts b/packages/client-runtime/src/pullRequestMarkdown.test.ts index 5c0b3d84f27b..3395d78313b9 100644 --- a/packages/client-runtime/src/pullRequestMarkdown.test.ts +++ b/packages/client-runtime/src/pullRequestMarkdown.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { splitPullRequestBody } from "./pullRequestMarkdown.ts"; +import { changeRequestReferenceAutolinker, splitPullRequestBody } from "./pullRequestMarkdown.ts"; describe("pull request body segmentation", () => { // GitHub writes a dropped image into the body as an `` tag, so a bare attachment link on @@ -123,3 +123,20 @@ describe("media on custom hosts", () => { ]); }); }); + +describe("change request reference autolinks", () => { + const urls = (provider: string, url: string, text: string) => + changeRequestReferenceAutolinker({ provider, url })?.(text).map((link) => link.url) ?? null; + + it("links references against the change request's own repository", () => { + expect(urls("github", "https://ghe.example.com/acme/web/pull/7", "Fixes #5")).toEqual([ + "https://ghe.example.com/acme/web/issues/5", + ]); + }); + + // regression: the mobile review guide linked every host's `#5` with GitHub's grammar + it("links nothing on a host whose grammar is not applied", () => { + expect(urls("gitlab", "https://gitlab.com/group/project/-/merge_requests/7", "#5")).toBeNull(); + expect(urls("forgejo", "https://codeberg.org/acme/web/pulls/7", "#5")).toBeNull(); + }); +}); diff --git a/packages/client-runtime/src/pullRequestMarkdown.ts b/packages/client-runtime/src/pullRequestMarkdown.ts index 8e342c425dba..1e03c32509e9 100644 --- a/packages/client-runtime/src/pullRequestMarkdown.ts +++ b/packages/client-runtime/src/pullRequestMarkdown.ts @@ -1,4 +1,4 @@ -import { sourceControlClients } from "./sourceControlClients.ts"; +import { sourceControlClients, type ReferenceAutolink } from "./sourceControlClients.ts"; /** `id` is positional on purpose: the same attachment can be embedded twice in one body. */ export type PullRequestBodySegment = @@ -131,3 +131,19 @@ export function splitPullRequestBody(body: string): ReadonlyArray ReadonlyArray) | null { + if (!changeRequest) return null; + const find = sourceControlClients.get(changeRequest.provider).referenceAutolinks; + const repositoryUrl = sourceControlClients.parseChangeRequestUrl(changeRequest.url)?.link + .repositoryUrl; + return find === undefined || repositoryUrl === undefined + ? null + : (text) => find(text, repositoryUrl); +} diff --git a/packages/client-runtime/src/sourceControlClients.test.ts b/packages/client-runtime/src/sourceControlClients.test.ts index 9a2cb20e4a47..a47b6ddfe53f 100644 --- a/packages/client-runtime/src/sourceControlClients.test.ts +++ b/packages/client-runtime/src/sourceControlClients.test.ts @@ -350,3 +350,18 @@ describe("sourceControlClients project hosts", () => { expect(sourceControlClients.repositorySelector({ owner: "o", name: "r" })).toBe("o/r"); }); }); + +describe("sourceControlClients.findByPublicDomain", () => { + it("names the host a link is on, subdomains included", () => { + const kindOf = (hostname: string) => sourceControlClients.findByPublicDomain(hostname)?.kind; + expect(kindOf("gist.github.com")).toBe("github"); + expect(kindOf("GitLab.com")).toBe("gitlab"); + expect(kindOf("codeberg.org")).toBe("forgejo"); + expect(kindOf("notgithub.com")).toBeUndefined(); + expect(kindOf("github.com.example")).toBeUndefined(); + }); + + it("leaves findByPublicHost to the public host alone", () => { + expect(sourceControlClients.findByPublicHost("gist.github.com")).toBeUndefined(); + }); +}); diff --git a/packages/client-runtime/src/sourceControlClients.ts b/packages/client-runtime/src/sourceControlClients.ts index ac67c682c520..87da337cdee8 100644 --- a/packages/client-runtime/src/sourceControlClients.ts +++ b/packages/client-runtime/src/sourceControlClients.ts @@ -11,6 +11,7 @@ export { type ChangeRequestLink, type ChangeRequestTerminology, type ParsedChangeRequestUrl, + type ReferenceAutolink, type SourceControlClientDefinition, } from "@t3tools/source-control-core/client/definition"; import * as Forgejo from "@t3tools/source-control-forgejo/client/definition"; diff --git a/packages/shared/package.json b/packages/shared/package.json index 0fcf52cc8805..81f26b66384b 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -7,10 +7,6 @@ "types": "./src/changedFileKind.ts", "import": "./src/changedFileKind.ts" }, - "./githubMarkdownLinks": { - "types": "./src/githubMarkdownLinks.ts", - "import": "./src/githubMarkdownLinks.ts" - }, "./authScopeOptions": { "types": "./src/authScopeOptions.ts", "import": "./src/authScopeOptions.ts" diff --git a/packages/shared/src/githubMarkdownLinks.test.ts b/packages/shared/src/githubMarkdownLinks.test.ts deleted file mode 100644 index be31e63603cc..000000000000 --- a/packages/shared/src/githubMarkdownLinks.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import { githubMarkdownLinks } from "./githubMarkdownLinks.ts"; - -const url = "https://github.com/team/repo/pull/8"; -describe("githubMarkdownLinks", () => { - it("links closing references, cross-repository references, commits and mentions", () => { - expect( - githubMarkdownLinks( - "Closes #123, fixes other/project#9; other#4 @julius @team/mobile abc1234 other/project@abcdef1", - url, - ), - ).toBe( - "Closes [#123](https://github.com/team/repo/issues/123), fixes [other/project#9](https://github.com/other/project/issues/9); [other#4](https://github.com/other/repo/issues/4) [@julius](https://github.com/julius) [@team/mobile](https://github.com/orgs/team/teams/mobile) [abc1234](https://github.com/team/repo/commit/abc1234) [other/project@abcdef1](https://github.com/other/project/commit/abcdef1)", - ); - }); - it("leaves code, links, images, URLs, escapes, emails, and plain numbers alone", () => { - const text = - "`#123 @person abc1234`\n\n```\n#123\n```\n\n[#123](https://example.com) ![#123](image.png) https://example.com/#123 user@example.com \\#123 1234567\n\n[ref]: https://example.com/#123"; - expect(githubMarkdownLinks(text, url)).toBe(text); - }); - it("does not nest links inside HTML anchors or code", () => { - const text = 'See #12 and #13, then #14.'; - expect(githubMarkdownLinks(text, url)).toBe( - 'See #12 and #13, then [#14](https://github.com/team/repo/issues/14).', - ); - }); -}); diff --git a/packages/shared/src/githubMarkdownLinks.ts b/packages/shared/src/githubMarkdownLinks.ts deleted file mode 100644 index 21777947b678..000000000000 --- a/packages/shared/src/githubMarkdownLinks.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { unified } from "unified"; -import remarkParse from "remark-parse"; -import remarkGfm from "remark-gfm"; - -type MarkdownNode = { - readonly type: string; - readonly position?: - | { - readonly start: { readonly offset?: number | undefined }; - readonly end: { readonly offset?: number | undefined }; - } - | undefined; - readonly children?: readonly MarkdownNode[] | undefined; -}; - -const parser = unified().use(remarkParse).use(remarkGfm); -const reference = - /(? - qualifier?.includes("/") ? qualifier : qualifier ? `${qualifier}/${repo}` : `${owner}/${repo}`; - const visit = (node: MarkdownNode) => { - if ( - [ - "link", - "linkReference", - "image", - "imageReference", - "code", - "inlineCode", - "html", - "definition", - ].includes(node.type) - ) - return; - if (node.type === "text") { - const start = node.position?.start.offset; - const end = node.position?.end.offset; - if (start === undefined || end === undefined) return; - const source = markdown.slice(start, end); - for (const match of source.matchAll(reference)) { - const [label, issueRepo, number, commitRepo, sha, mention] = match; - if (sha && !/[a-f]/i.test(sha)) continue; - const path = number - ? `${repository(issueRepo)}/issues/${number}` - : sha - ? `${repository(commitRepo)}/commit/${sha}` - : mention?.includes("/") - ? `orgs/${mention.split("/")[0]}/teams/${mention.split("/")[1]}` - : mention; - if (!path) continue; - edits.push({ - start: start + match.index, - end: start + match.index + label.length, - value: `[${label}](${url.origin}/${path})`, - }); - } - } else if ("children" in node) { - let protectedHtmlDepth = 0; - for (const child of node.children ?? []) { - if (child.type === "html") { - const start = child.position?.start.offset; - const end = child.position?.end.offset; - const tag = start === undefined || end === undefined ? "" : markdown.slice(start, end); - for (const match of tag.matchAll(/<(\/)?(?:a|code|pre|script|style)(?=[\s>])[^>]*>/gi)) { - protectedHtmlDepth = Math.max(0, protectedHtmlDepth + (match[1] ? -1 : 1)); - } - } else if (protectedHtmlDepth === 0) visit(child); - } - } - }; - visit(parser.parse(markdown)); - for (let index = edits.length - 1; index >= 0; index--) { - const edit = edits[index]!; - markdown = markdown.slice(0, edit.start) + edit.value + markdown.slice(edit.end); - } - return markdown; -} diff --git a/packages/source-control-azure-devops/src/client/definition.ts b/packages/source-control-azure-devops/src/client/definition.ts index 1f63cf9fe95a..bc203ef1dcf4 100644 --- a/packages/source-control-azure-devops/src/client/definition.ts +++ b/packages/source-control-azure-devops/src/client/definition.ts @@ -102,7 +102,6 @@ export const definition = defineSourceControlClient({ changeRequestCloseCommand: /\baz\s+repos\s+pr\s+update\b[^;&|]*--status[\s=]+["']?(?:completed|abandoned)\b/u, authorProfileUrl: () => null, - referenceAutolinkRepositoryUrl: () => null, reviewSummaryRequired: () => false, checkoutCommandArgument: (input) => { const args = CHECKOUT_COMMAND.exec(input)?.[1]; diff --git a/packages/source-control-bitbucket/src/client/definition.ts b/packages/source-control-bitbucket/src/client/definition.ts index 3ad2e97aefd5..3ed6ddeaad85 100644 --- a/packages/source-control-bitbucket/src/client/definition.ts +++ b/packages/source-control-bitbucket/src/client/definition.ts @@ -93,7 +93,6 @@ export const definition = defineSourceControlClient({ ? `git clone --single-branch --branch ${headBranch} https://bitbucket.org/${headRepositoryNameWithOwner}.git t3code-pr-${number}` : null, authorProfileUrl: () => null, - referenceAutolinkRepositoryUrl: () => null, reviewSummaryRequired: () => false, checkoutCommandArgument: () => null, isChangeRequestReference: () => false, diff --git a/packages/source-control-core/src/client/definition.ts b/packages/source-control-core/src/client/definition.ts index 2796f24df062..53101b59955e 100644 --- a/packages/source-control-core/src/client/definition.ts +++ b/packages/source-control-core/src/client/definition.ts @@ -84,6 +84,16 @@ export interface SourceControlMediaUrl { readonly kind?: "image" | "video"; } +export interface ReferenceAutolink { + /** Where `text` starts in the string that was searched. */ + readonly index: number; + readonly text: string; + /** What to show for the link, which can be shorter than `text`, as for a commit SHA. */ + readonly label: string; + readonly url: string; + readonly kind: "reference" | "commit" | "mention"; +} + export interface SourceControlClientDefinition { readonly kind: SourceControlProviderKind; readonly label: string; @@ -117,7 +127,28 @@ export interface SourceControlClientDefinition { */ readonly changeRequestCloseCommand?: RegExp; readonly authorProfileUrl: (login: string, repositoryUrl: string) => string | null; - readonly referenceAutolinkRepositoryUrl: (repositoryUrl: string) => string | null; + /** + * The references this host's own renderer links in plain text, such as `#12` or a commit SHA, + * resolved against `repositoryUrl`, in the order they appear. Absent for a host whose grammar + * T3 does not apply. + */ + readonly referenceAutolinks?: ( + text: string, + repositoryUrl: string, + ) => ReadonlyArray; + /** Where the host lists a change request's checks. Absent means the change request's own page. */ + readonly changeRequestChecksUrl?: (changeRequestUrl: string) => string; + /** + * The actions a server too old to report `capabilities.actions` could still take on this host's + * change requests. Absent means none. + */ + readonly changeRequestActionsWhenUnreported?: ReadonlySet; + /** + * `host` when the server lists change requests in many repositories on this host with one read, + * so a page's size bounds the rows of every repository together. Absent means one read per + * repository. + */ + readonly changeRequestListScope?: "host" | "repository"; readonly reviewSummaryRequired: (verdict: PullRequestReviewVerdict) => boolean; readonly checkoutCommandArgument: (input: string) => string | null; readonly isChangeRequestReference: (url: string) => boolean; @@ -346,7 +377,6 @@ export const UNKNOWN_SOURCE_CONTROL_CLIENT: SourceControlClientDefinition = { changeRequestActions: new Set(), checkoutCommand: () => null, authorProfileUrl: () => null, - referenceAutolinkRepositoryUrl: () => null, reviewSummaryRequired: () => false, checkoutCommandArgument: () => null, isChangeRequestReference: () => false, @@ -376,6 +406,11 @@ export interface SourceControlClientRegistry { readonly get: (kind: string | null | undefined) => SourceControlClientDefinition; readonly find: (kind: string) => SourceControlClientDefinition | undefined; readonly findByPublicHost: (hostname: string) => SourceControlClientDefinition | undefined; + /** + * The host whose `publicHost` is `hostname` or a parent domain of it, such as GitHub for + * `gist.github.com`: which host a link is on, not one T3 can read from. + */ + readonly findByPublicDomain: (hostname: string) => SourceControlClientDefinition | undefined; /** The first host, in registry order, that reads `url` as one of its change requests. */ readonly parseChangeRequestUrl: (url: string) => ParsedChangeRequest | null; readonly findByChangeRequestUrl: (url: string) => SourceControlClientDefinition | undefined; @@ -471,6 +506,14 @@ export function makeSourceControlClientRegistry( const host = hostname.toLowerCase(); return definitions.find((definition) => definition.publicHost === host); }, + findByPublicDomain: (hostname) => { + const host = hostname.toLowerCase(); + return definitions.find( + (definition) => + definition.publicHost !== null && + (host === definition.publicHost || host.endsWith(`.${definition.publicHost}`)), + ); + }, parseChangeRequestUrl, findByChangeRequestUrl: (url) => parseChangeRequestUrl(url)?.definition, hostLabelForChangeRequestUrl: (url) => diff --git a/packages/source-control-forgejo/src/client/definition.ts b/packages/source-control-forgejo/src/client/definition.ts index d6c8698f591b..ea2759a61b73 100644 --- a/packages/source-control-forgejo/src/client/definition.ts +++ b/packages/source-control-forgejo/src/client/definition.ts @@ -84,7 +84,6 @@ export const definition = defineSourceControlClient({ changeRequestCloseCommand: /\b(?:tea\s+(?:pr|pulls)|fj\s+pr)\s+(?:merge|close)\b/u, authorProfileUrl: () => null, // Forgejo refuses a change request review without a summary, even with inline comments. - referenceAutolinkRepositoryUrl: () => null, reviewSummaryRequired: (verdict) => verdict === "request-changes", checkoutCommandArgument: (input) => CHECKOUT_COMMAND.exec(input)?.[1]?.trim() ?? null, isChangeRequestReference: (url) => CHANGE_REQUEST_REFERENCE.test(url), diff --git a/packages/source-control-gitcafe/src/client/definition.ts b/packages/source-control-gitcafe/src/client/definition.ts index 50a4b33e51c5..22c96ce70c82 100644 --- a/packages/source-control-gitcafe/src/client/definition.ts +++ b/packages/source-control-gitcafe/src/client/definition.ts @@ -71,7 +71,6 @@ export const definition = defineSourceControlClient({ checkoutCommand: ({ number }) => `cafe pr checkout ${number}`, changeRequestCloseCommand: /\bcafe\s+(?:pr|stack)\s+(?:merge|close)\b/u, authorProfileUrl: () => null, - referenceAutolinkRepositoryUrl: () => null, reviewSummaryRequired: () => false, checkoutCommandArgument: () => null, isChangeRequestReference: () => false, diff --git a/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.test.ts b/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.test.ts index 7257f8fd133f..f3b08b6c647c 100644 --- a/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.test.ts +++ b/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.test.ts @@ -118,6 +118,25 @@ describe("toChangeRequest", () => { assert.strictEqual(row.author?.login, "remote-user"); assert.deepStrictEqual(row.reviewRequestLogins, ["alice", "upstream", "linked-local"]); }); + + it("marks a mirrored GitHub bot account as a bot", () => { + const row = (accountType: string) => + GitCafePullRequestJson.toChangeRequest( + decodePull({ + ...pull, + author: { + kind: "github", + actorId: "act_bot", + login: "coderabbitai[bot]", + accountType, + avatarUrl: null, + }, + }), + { repository: "owner/repo", host: "git.cafe" }, + ); + assert.isTrue(row("bot").author?.isBot); + assert.isUndefined(row("user").author?.isBot); + }); }); describe("toViewerPermissions", () => { diff --git a/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.ts b/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.ts index f6c700dba197..a3ba916bd3bd 100644 --- a/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.ts +++ b/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.ts @@ -37,6 +37,7 @@ const Actor = Schema.Union([ actorId: Schema.String, login: TrimmedNonEmptyString, avatarUrl: Schema.NullOr(Schema.String), + accountType: Schema.optional(Schema.NullOr(Schema.String)), linkedProfile: Schema.optional( Schema.NullOr( Schema.Struct({ handle: TrimmedNonEmptyString, actorId: Schema.optional(Schema.String) }), @@ -300,7 +301,11 @@ function toActor(actor: Actor, host: string): PullRequestActor | null { return { login: actor.kind === "local" ? actor.handle : actor.login, name: actor.kind === "local" ? actor.displayName : null, - ...(actor.kind === "local" && actor.bot === true ? { isBot: true } : {}), + // A mirrored GitHub account says it is a bot by its account type, not its login. + ...((actor.kind === "local" && actor.bot === true) || + (actor.kind === "github" && actor.accountType === "bot") + ? { isBot: true } + : {}), // GitCafe serves its own avatars by path, so a relative one is read against the host. avatarUrl: actor.avatarUrl === null ? null : new URL(actor.avatarUrl, `https://${host}`).href, }; diff --git a/packages/source-control-github/src/client/definition.ts b/packages/source-control-github/src/client/definition.ts index ffc58c2dec69..1a45ab704cd5 100644 --- a/packages/source-control-github/src/client/definition.ts +++ b/packages/source-control-github/src/client/definition.ts @@ -19,6 +19,7 @@ import { } from "@t3tools/source-control-core/client/definition"; import { gitHubMediaUrl } from "./gitHubMedia.ts"; +import { gitHubReferenceAutolinks } from "./referenceAutolinks.ts"; const CHECKOUT_COMMAND = /^gh\s+pr\s+checkout\s+(.+)$/i; const CHANGE_REQUEST_REFERENCE = @@ -117,7 +118,12 @@ export const definition = defineSourceControlClient({ login.endsWith("[bot]") ? null : new URL(`/${encodeURIComponent(login)}`, repositoryUrl).toString(), - referenceAutolinkRepositoryUrl: (repositoryUrl) => repositoryUrl, + referenceAutolinks: gitHubReferenceAutolinks, + changeRequestChecksUrl: (changeRequestUrl) => `${changeRequestUrl.replace(/\/+$/u, "")}/checks`, + // Before servers reported their actions, GitHub was the only host they acted on from a list. + changeRequestActionsWhenUnreported: new Set(["close", "merge", "ready", "reopen"] as const), + // One search reads every repository on the host. + changeRequestListScope: "host", reviewSummaryRequired: () => false, checkoutCommandArgument: (input) => CHECKOUT_COMMAND.exec(input)?.[1]?.trim() ?? null, isChangeRequestReference: (url) => CHANGE_REQUEST_REFERENCE.test(url), diff --git a/packages/source-control-github/src/client/referenceAutolinks.test.ts b/packages/source-control-github/src/client/referenceAutolinks.test.ts new file mode 100644 index 000000000000..c3ce130b9093 --- /dev/null +++ b/packages/source-control-github/src/client/referenceAutolinks.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { gitHubReferenceAutolinks } from "./referenceAutolinks.ts"; + +const repositoryUrl = "https://github.com/team/repo"; +const linked = (text: string) => + gitHubReferenceAutolinks(text, repositoryUrl).map(({ label, url, kind }) => [label, url, kind]); + +describe("gitHubReferenceAutolinks", () => { + it("links references, cross-repository references, commits and mentions", () => { + expect( + linked( + "Closes #123, fixes other/project#9; other#4 @julius @team/mobile abc1234 other/project@abcdef1", + ), + ).toEqual([ + ["#123", "https://github.com/team/repo/issues/123", "reference"], + ["other/project#9", "https://github.com/other/project/issues/9", "reference"], + ["other#4", "https://github.com/other/repo/issues/4", "reference"], + ["@julius", "https://github.com/julius", "mention"], + ["@team/mobile", "https://github.com/orgs/team/teams/mobile", "mention"], + ["abc1234", "https://github.com/team/repo/commit/abc1234", "commit"], + ["other/project@abcdef1", "https://github.com/other/project/commit/abcdef1", "commit"], + ]); + }); + + it("shows a full SHA by its first seven characters", () => { + const sha = "0123456789abcdef0123456789abcdef01234567"; + expect(linked(`Reverts ${sha}.`)).toEqual([ + ["0123456", `https://github.com/team/repo/commit/${sha}`, "commit"], + ]); + }); + + it("leaves URLs, escapes, emails, words and plain numbers alone", () => { + expect(linked("https://example.com/#123 user@example.com \\#123 1234567 issue#x a#1b")).toEqual( + [], + ); + }); + + it("links against the repository's own host", () => { + expect( + gitHubReferenceAutolinks("See #5", "https://ghe.example.com/acme/web").map(({ url }) => url), + ).toEqual(["https://ghe.example.com/acme/web/issues/5"]); + }); +}); diff --git a/packages/source-control-github/src/client/referenceAutolinks.ts b/packages/source-control-github/src/client/referenceAutolinks.ts new file mode 100644 index 000000000000..93f7a5514e43 --- /dev/null +++ b/packages/source-control-github/src/client/referenceAutolinks.ts @@ -0,0 +1,58 @@ +import type { ReferenceAutolink } from "@t3tools/source-control-core/client/definition"; + +// GitHub's autolinked references: `#12`, `repo#12` and `owner/repo#12`; a SHA of 7 to 40 +// characters, optionally `repo@` or `owner/repo@` qualified; `@user` and `@org/team`. +const REFERENCE = + /(? { + if (!CANDIDATE.test(text)) return []; + let url: URL; + try { + url = new URL(repositoryUrl); + } catch { + return []; + } + const [owner, repo] = url.pathname.split("/").filter(Boolean); + if (!owner || !repo || !/^https?:$/u.test(url.protocol)) return []; + const repository = (qualifier: string | undefined) => + qualifier?.includes("/") ? qualifier : qualifier ? `${qualifier}/${repo}` : `${owner}/${repo}`; + const links: ReferenceAutolink[] = []; + for (const match of text.matchAll(REFERENCE)) { + const [matched, issueRepo, number, commitRepo, sha, mention] = match; + if (number) { + links.push({ + index: match.index, + text: matched, + label: matched, + url: `${url.origin}/${repository(issueRepo)}/issues/${number}`, + kind: "reference", + }); + } else if (sha) { + // A run of digits is a number far more often than a SHA. + if (!/[a-f]/iu.test(sha)) continue; + links.push({ + index: match.index, + text: matched, + label: `${commitRepo ? `${commitRepo}@` : ""}${sha.slice(0, SHORT_SHA_LENGTH)}`, + url: `${url.origin}/${repository(commitRepo)}/commit/${sha}`, + kind: "commit", + }); + } else if (mention) { + const [organization, team] = mention.split("/"); + links.push({ + index: match.index, + text: matched, + label: matched, + url: `${url.origin}/${team ? `orgs/${organization}/teams/${team}` : mention}`, + kind: "mention", + }); + } + } + return links; +} diff --git a/packages/source-control-gitlab/src/client/definition.ts b/packages/source-control-gitlab/src/client/definition.ts index 801de8ca5618..ce1262f42fb8 100644 --- a/packages/source-control-gitlab/src/client/definition.ts +++ b/packages/source-control-gitlab/src/client/definition.ts @@ -48,7 +48,6 @@ export const definition = defineSourceControlClient({ checkoutCommand: ({ number }) => `glab mr checkout ${number}`, changeRequestCloseCommand: /\bglab\s+mr\s+(?:merge|close)\b/u, authorProfileUrl: () => null, - referenceAutolinkRepositoryUrl: () => null, reviewSummaryRequired: () => false, checkoutCommandArgument: (input) => CHECKOUT_COMMAND.exec(input)?.[1]?.trim() ?? null, isChangeRequestReference: (url) => CHANGE_REQUEST_REFERENCE.test(url), From 326e08b08a96c81a286fdff13ddb38ff46dc1902 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 24 Oct 2026 04:24:35 -0700 Subject: [PATCH 07/27] refactor(clients): forge settings, routing and icons come from definitions (#23998) Co-authored-by: Claude Opus 5.5 --- .../mobile/assets/link-icons/azure-devops.png | Bin 0 -> 4148 bytes apps/mobile/assets/link-icons/bitbucket.png | Bin 0 -> 1930 bytes apps/mobile/assets/link-icons/forgejo.png | Bin 0 -> 1419 bytes apps/mobile/assets/link-icons/gitcafe.png | Bin 0 -> 285 bytes apps/mobile/assets/link-icons/github.png | Bin 1727 -> 1346 bytes apps/mobile/assets/link-icons/gitlab.png | Bin 0 -> 1723 bytes apps/mobile/package.json | 1 + apps/mobile/scripts/sync-link-icons.ts | 79 +++++ apps/mobile/src/connection/storage.ts | 8 +- .../connection/ConnectionsRouteScreen.tsx | 4 +- ...ngs.tsx => PullRequestRoutingSettings.tsx} | 34 +- .../features/projects/AddProjectScreen.tsx | 6 +- .../features/projects/SourceControlIcon.tsx | 305 ++++-------------- .../SettingsEnvironmentsRouteScreen.tsx | 4 +- .../src/lib/markdownLinkIcons.generated.ts | 10 + apps/mobile/src/lib/markdownLinkIcons.ts | 27 +- .../src/native/useEnrichedLinkVariants.ts | 8 +- apps/web/src/components/Icons.tsx | 250 -------------- .../components/SourceControlIconArtwork.tsx | 59 ++++ .../settings/ConnectionsSettings.tsx | 4 +- .../settings/IntegrationsSettings.tsx | 2 +- ...ngs.tsx => PullRequestRoutingSettings.tsx} | 43 +-- ...ourceControlAccountSettings.logic.test.ts} | 20 +- ... => SourceControlAccountSettings.logic.ts} | 20 +- ...s.tsx => SourceControlAccountSettings.tsx} | 56 ++-- .../settings/SourceControlSettings.tsx | 62 ++-- ...ngs.tsx => SourceControlTokenSettings.tsx} | 77 +++-- .../settings/settingsSearch.test.ts | 53 ++- .../src/components/settings/settingsSearch.ts | 87 +++-- apps/web/src/connection/storage.test.ts | 6 +- apps/web/src/connection/storage.ts | 64 ++-- ...ation.ts => sourceControlPresentation.tsx} | 30 +- docs/internals/remote.md | 2 +- docs/user/source-control.md | 11 +- .../client-runtime/src/connection/index.ts | 2 +- ...ns.ts => pullRequestRoutingPermissions.ts} | 48 +-- .../src/connection/registry.test.ts | 75 +++-- .../client-runtime/src/connection/registry.ts | 31 +- .../src/connection/resolver.test.ts | 14 +- .../client-runtime/src/connection/resolver.ts | 12 +- .../src/connection/routes.test.ts | 8 +- .../src/platform/storageDocument.test.ts | 20 +- .../src/platform/storageDocument.ts | 6 +- .../src/sourceControlClients.ts | 16 +- .../client-runtime/src/state/connections.ts | 34 +- .../src/state/pullRequestRouting.ts | 13 +- .../src/state/pullRequests.test.ts | 22 +- packages/contracts/src/settings.ts | 4 - .../src/client/definition.ts | 3 +- .../src/client/icon.ts | 162 ++++++++++ .../src/client/definition.ts | 3 +- .../src/client/icon.ts | 29 ++ .../src/client/definition.ts | 105 +++++- .../src/client/definition.ts | 4 +- .../source-control-forgejo/src/client/icon.ts | 13 + .../src/client/definition.ts | 3 +- .../source-control-gitcafe/src/client/icon.ts | 6 + .../src/client/definition.ts | 42 +-- .../source-control-github/src/client/icon.ts | 12 + .../src/client/definition.ts | 3 +- .../source-control-gitlab/src/client/icon.ts | 23 ++ 61 files changed, 1115 insertions(+), 930 deletions(-) create mode 100644 apps/mobile/assets/link-icons/azure-devops.png create mode 100644 apps/mobile/assets/link-icons/bitbucket.png create mode 100644 apps/mobile/assets/link-icons/forgejo.png create mode 100644 apps/mobile/assets/link-icons/gitcafe.png create mode 100644 apps/mobile/assets/link-icons/gitlab.png create mode 100644 apps/mobile/scripts/sync-link-icons.ts rename apps/mobile/src/features/connection/{GitHubRoutingSettings.tsx => PullRequestRoutingSettings.tsx} (76%) create mode 100644 apps/mobile/src/lib/markdownLinkIcons.generated.ts create mode 100644 apps/web/src/components/SourceControlIconArtwork.tsx rename apps/web/src/components/settings/{GitHubRoutingSettings.tsx => PullRequestRoutingSettings.tsx} (71%) rename apps/web/src/components/settings/{GitHubAccountSettings.logic.test.ts => SourceControlAccountSettings.logic.test.ts} (73%) rename apps/web/src/components/settings/{GitHubAccountSettings.logic.ts => SourceControlAccountSettings.logic.ts} (73%) rename apps/web/src/components/settings/{GitHubAccountSettings.tsx => SourceControlAccountSettings.tsx} (81%) rename apps/web/src/components/settings/{GitHubTokenSettings.tsx => SourceControlTokenSettings.tsx} (63%) rename apps/web/src/{sourceControlPresentation.ts => sourceControlPresentation.tsx} (72%) rename packages/client-runtime/src/connection/{githubRoutingPermissions.ts => pullRequestRoutingPermissions.ts} (75%) create mode 100644 packages/source-control-azure-devops/src/client/icon.ts create mode 100644 packages/source-control-bitbucket/src/client/icon.ts create mode 100644 packages/source-control-forgejo/src/client/icon.ts create mode 100644 packages/source-control-gitcafe/src/client/icon.ts create mode 100644 packages/source-control-github/src/client/icon.ts create mode 100644 packages/source-control-gitlab/src/client/icon.ts diff --git a/apps/mobile/assets/link-icons/azure-devops.png b/apps/mobile/assets/link-icons/azure-devops.png new file mode 100644 index 0000000000000000000000000000000000000000..0d0f09b298140b37eb7c4f3f1faf6bb8aeb060bf GIT binary patch literal 4148 zcmV-45X^E=1D|BRCt`_n+H@>=emY>&N=towQg3dNM{&2Gfc05s4>xKjK)NR zCAL_CBGRR5M9;nVF-^84=4&EXwXT{ zx7M?MU@dU|=iU4J|6L@AS5)?CwO_C@%Imq=@_K%@d^-WWM*O>!Rgz|vL#h(Yj4E(4 z72s!=LU5zx90B~9_;)DF#D-(G$!a*H)xwFAPzJ$`QV4GpMibZY7vhgzmI;mLtdg{_ zO;N*5^BJ@UI|{NoD={_ zPC>#ST7v17duM3z&7dbVz$Qrp`&2a;>D6FoRDhpd22oBSB(mEX9GUEO;&)vU_|xb! zppU!Q@y@uY_6>$nZ99#k@5h%!!|_&swt*dfpfi_YKr2VLP&G- ze;*P?AI4aN;}DKJ0r#<|y0(r!gX3c&yRybacHSF%t_$NXbYgr|2MjK@!!WuP#+UEI zB(4Q!*Y1GUD}ic?N;sugft{&bK1=A?v#Mpz|kDG83L zu*#RV6YJ=hBRv5>&!1T&p z^fpE7MBU2ps40>wLm^ybx8a(XCl}`Do4m5g(>}gnZ4pu4YPq`|w4d5xzPAHr`#WHI z@R5WG14)SNFTo(H9R|_uFp7Bqli2&9n&P!4&=cxmlTru!R27&R)w-sbRSNNqLb&GL zgj;TI=_`?7IrkEs7AEho{=N*w+;hWa*mX#N0f%<>i!5$;~x?X_Ke9$5>f~$~$boZH3K_ zR#@+B1AUiHg2ez53=iv?qQS8)svnF!)s4}ob^V~fgti_DE!2@y?B^|8K@5AYzEnxJO@J%ahziS2A51y3JyE61!9*43=xYP!tXtF7`^fbk0&=VVB zlT-)$6tW-mZHh(Yc3ClElV^CBba4OY9you$k7r8gZHiAwc;@mrz#f5;5c5P+?73!e zAXf%*yPR1LUUuny{`KPdeLwefne27vgj9h`O3G~K)YR3k>B-?PDJfsMrX?SAO-nxK zmYSI4mYx(PNlx}BA|n3u)X6)oFYXX*y$jyo?tzrRCMEpiKAgU20pm-Z1pB8+_^BP# z9%29E5+)3Bk6>`2wcFrgtJWyCwZtU0CDZ&$bDVj6(@A=K-5%>i&6jpbwVN5K%GGu` zm1e!4*(D)+@}#uHf;ThbJEvwO;LVH#Ovy;Zl#C>b$!STLl$H!PlGGGTOih6#IU`RP zn>G5e$us7iv}CU>>JWW)7s4%f!QXnXp9E&Fgr25IO0XF~g4y2ocC&r$YSV)q1;&Ru zQw@LVykKy+>(Kb4UEhp9*0puaDR_@Lg(YvC#=CDsLOSX!Y)3_7+;ERp>D4Uh#H8f5 ziOH$Zkq*}+9WIIKaHhzBG$8}h#7s!zGa-r3gg8DEyx1E*_nW*V{0_vM?tqdIaTmNT z&0vSA!Pr;{d!J(1uD{vA_@ZSu<6rkbxBsdo*#4^rYiz%3op1B~gQ=D~+IZHxTWAh@ z@CNacN*I@&F1eNo$+avB@s(@}(Uoin<8%n(a&*MzfFCOZ|FR6c7#TRxGA;E-?A+tb ztmj>Awt7Dr7Sm6{Y|3F6yY9sV@lF`s$d? zXA9tTwg9ZiLO4YhP%tA4!Hg^d<4h5l6vZG+CLKLtJn2^q6{ATvCIL48e_&xr0epAmy0^Gj=Pj_{6R^Lgyq~63PYt*}wcz>Gg6~@kzF#c_0V)VL zDj^P5LrMw=SHtBq4SN0zUgP*^=W~6vKX81t7r4IlXB95O{%k|TC_mIT)Z(tL8 zRvfQtJPFsk8axu8YVdq(An>b!Ft8S)ASEQhN=U<1aM`TE#0Yiw5dI0g=I-AA4Nggf zc9bZzd8H`0*-yB!VYwivVKy(I-c=Y-=g9M}oiNbvj6|{5SE3h=mkzFX6}UcC;Q3aA z?^g{$Ks7``HIRhV!Z}O{=S@m-@^IUx(%lvxZV4mq7>L8~D8!-75QQ{B7}N-UU?X?| z4dD9K>rR&qb#U@hH!;^~${1@^nGPPx7>CuhzdEe0`QCnI)qmTssPMB}UbftJnS8d* z(h^tu#f6Tx?!^;`VTxm|D+dYNvmEU86<~W+fa6^Wu1^K{zEu$TS3$I~2IAlvNJDGj zvPlWoElNzT>cDdekc8if5Qp7?D5M#};3fz*Hi93}0G@w6xW4sZduzcW1+3Rl zODAKk3XY#D;jp?^x1(HH3A<0qVYj>tw#!OkyR;ZKOA4v0P3uLs$jj~~EBBl-`bRf1 zE$3%O(?85OVmUW;2W?LB=d@XI5#}>41)EMi?P=(CWHD>40u<}Y!1h#tvt9wNcLjK) z0KZBI1FIkos)jVA8ZMh^;rf{pZd+BDv`uw;W`uUsa|sZKHSZoYeeXK3yvQX}1IAi4 z96eOJo#g5o*srRBJ$X&BLP1?z*)A=it}<;tE`ZJA+pu1E6V?lIVdYNVfXSxrLRo&0 z4$JqGL7N>9i&=3nn;r#|H&4LGc^{lSnt138{v2cr_+& zsl}viN=O5W%4tgyp1+xkLz`xbLPsopSh>q!_0bK`=VyWb;dNNfO$BXEA}rp!3X69y z!*tp?m`phaL+L&^eOdx1k5aJK$-(iIgS$ZizK;R|{|bl#D9hx`lZ3)=xOTgVw3ZAbV0)GWWK^2gMRKj&rHJn4`uv>8H zAH;86!hi-hZa{tKbEUuRY3cU}n3vV>+&7;|KA4FjpH9Kp?Jy>Ye}t1q5!jw3;CPjQ z?<0rMPY&@$1*D-BaM@G|k>9N@ySZm(5hGPxzxuzE>5~GUKYg75D^dV;=b1eG2S|VB zC74e?57ViqU_4PLfVH+5-1Q~kd6z=qR|Zj_0@9FjxNNEb&m*(ddiFUQF_JYU0)Juq z)Me<>ucOju4mtfE0Sj-zdO_af>3{fS`q8j>CkkfM&QSu4$Rj|s8ywFf@HUh{;9Ck& zU>PLA3b=$h4nMdk&KRir(}~U_%O6T=4YtKX~%ozeFAW zqyTCOoOp;5!1F5BO+KI$lHf8p2g~8`(d8K8WtY?XI?ut=?{oMsDSnjxqWvqT_Z-&LcH6J2d3yRE=Qj%w_FkSWct*(zaVeqwx)Vj?)bCK($e_8>02+lS#R!sYuK;E{%*^5 yN%1J_kBd&&EH1cjweZ#rYxi4julh6ft^WtIlwLdQ#TTFe0000^6JV``BRCt{2nrlo`XBfv1f*=>US`etEw4Bq;MYFjjOSUZY!?LeS z=5$^`5D)|e6j7*9?0dSIn3$L>F-!JoCR=n%%(j@AY%y8($$8rZtEixWSmbIc7rFkI zw_PuAdOydOh)?o-gy(sG{XftDyr*TD*YP@*cjOVMIUYZ-1lL#|*I$VwxW@7L-CME* z=QLkK$*?^B=6GOPCc#yPBbW$#BVkIGhlODYT9+*Vf>sLP{4xe`p5b`tmN9@9M#E!k zxEl#u8nBH~>+x3D8wpct^w_|>`z=SeCe+FDn8`bX80*6j$HTbn01&i0o(4IB zpO>KmE;<0P1p06{6oxc70T{4-2{aViG+?)L1Ps_1_J%^7Y95=MH^MWBp<6NBO@%P$ zHl9NKf@?9|ic6rekR;2aQ}AAZ<#93GjfF5T2>@_B3`?M~P~|BB01d&eCD2$%Qp2Oj z-3YubRRNaA084OEs#Ld3os}JY23b`f*4VSs6m%-ImQMr#a&%b55zI;iVVt>uNJgSa zDeozb`tn~yZucs!I$1$w+G6@aAD?(bJ_ zzYSLiD}sa&y`yc~V`+X3LAvmh};&u8e$9D`#ct62~NJ$ zO&eq+6m<+m@4AENoo%2XqC}`+by4S4&j5JjOEv-oe@F!&uAqG`X15VBE)ZNtB66$$ zYDg~a`p#n^4UhLE1Tf415n>OYR{ca1n|}|h_T0rP2ZTFxmV;75YK5uLW3fyf;^=d- zR1j1rp%4ooy|S-1uHZgm^V<=-#|FZ?8eAYO2C?M^vg-%sUJJ4Wk0jg!pNR#KR%!Yv zzVHF!_TEQaK|6$MXhXCWLaS{-da=uu+i%bMEXeV=B;gpehz3y6dncjjAt;1+7YJ50 z>=vrQ+epObw-5RQ;FM$lc>E$3KuTGUdG)?dBouWZq3|K%_j*^u!jV|0hSkNLH+>e& zBPf(`1iHl?9}&r=UGs^>k7)B)y{`lA)nJEUv@J*vYDg*TKIgL}djjuE2mqW8Yl1y~ zW=;R5>vc%r2<9c+1Hy}kpRu-XSesJT z4I6|mB$YgdRSo+Vs=@n$Y_l!M9k-EHVdnfQ%<<@!OaTP-K?A5A`YpBm2~x^>ki6dx zLZ}AkNO-o(Rrz=4{C+NB2`)VYRnDes{(?0}GDgHxKbyC{1gSJ3t>P)t>=4R(kX+_zBwXyWzzHI)%5=f++8Tlnq;vxM{{lUK zw$`#ay}BRi^i9w~`WA@3&9SS&btGKW;KeRwH3M~i7thflS;8FvRm=3_+~z5xEhml2 zCYlpQg?7TIXdE}LquDTKT-RVR%8yx$@}p*BcK!3djM@QY)bu00+6KW@4GTuXqZ+7P zQV0)_b9CgLfIb11N3RqB^7OM%>86p}JcZn*7f>FbM2>a>ipFs$8pg2hm<95qqmb8| zkzM~BSw}{YRX2>x!$VkmXb5X-2a!=T0ICMZdqE1pts3kL(rK4;Bod1|=NTPV1O$My z{hm(T0wnXuJ24A&%ZwF*Y7vAS3ZZcf>l!Q$5ZM&Ok>|*&8$ssbVPv{Oa8ZMEBo?WG z+GVLz4Rj<@4?GzS=?2`SgMB1C zsKN2YiEu$GwB~?q*cu!`pVSb1s^Rz~loW#2z97A*!46@OUD`(Ce>GH?@Uae2OfbO` zTzMG)+%CwLX%Tig=+Q3IYX-)HIy?=Q$2TtvfWK-G+U00KcA0s2sDlYMI35RHHUM{O za6o9bFGwGDNg)XAl0xW5_7U^tpaEzIHirU$HSG}ebAB&Kr(M#KaBr9LV;1MH!2%7H z4(TBS;O~O8+U2-!yQB~f4MM4%{FD(BERTV3KycdSjQ@7IXhBjK^lN`n05~3hh8lps zYRFk&m%{smyxu%1>hLsJg0tZQ;Qc-^d0I38Mu!!w0XLQ$1iknBgtBoQD*e<3@c^4JxN4CRCt{2n`?-bMHt6_u6Lu0ru>=!W&&->fhK4Qeb|LTLYWl>SJ*STE zzUMvX-95YSIdA^q|7jm)=Kp_Yo_Eg7EK~rbj!~jmCYmjxNr_^cXx2$BlLdN^RLETV ziDtEE{uYe{Qt`diGMc18Gy_EOU0&fAns8JU<46)j^L|m{|6_~PF@y@4)_03$xhOV@ z!ieIaDE`Xfu{SB}=Jl*S2O!Ks_j*S|FNw(VXan@GM^oY`>1N0kJc?U#SnWlwP8E zxdg(qSzQy@k78oG*jlC$C*)x+5zQAR6W_)^+)`@SmtsS^*cxt*M>*hjWuo|?#NxBu zBQ;Er8T1s*FgGg#Vh+S6-a$0aN1WQHMYBT`ABp0fw5T?&7u}(vSw~zD%_PyBFLI#U zMKfKVrhne{6!RmB|I*c-?p2*ZdRn>-P2oYdq=gsx14cAUi!}9HS-_RV1<}kaVvr4P z?M809hI{jx>oz``XJsLMyGB_N5KV7U94a9Gr_N?5{moZAA(|IOvsg4!!j@3CL28*+ zpwOL4X6Z}EkEg``5-zWdD~;*Uu^14$~Rh8qe@|My5T#8v1p18RAsRDJD(zA3mh z1yd}H-%S}Deu(0+Py^~1+u1($z%>@u88~V|vNUkcz*ipJLtGHelc5IGb8TnZH!#&N zJctHXZY73YpePoF8Ze)$I@-1e6AZK#5}(6&sU!ooLN52 z2Dba+&Ko*ccS>r&k%^2j@KOh|?KfaUQ2%cE_+ve|k8&`ebrxc)*xMQ5ISkxRr=U6@ zc6RIroV*`t>|YY$*%|P8Q2z!V4~V}jRs&8X8;8(~oCl|?YXW#9HFnEAlV&5mF% zTTJV+W zB2TEf#M^?OjENmnltrdRR5m4cP1BayR`HZcuqVVwvtVDCmCVPAkBp2SB8oyC zK88+=o}#K^Ghzyx7`?u@JjCcNuDkEhQ}G>tW}5Bz({lQ~c*5_8pMDDK)!Y2N eEhE^g{*pP+Pr+XIX_h9?YYd*QelF{r5}E+syJ{o= literal 0 HcmV?d00001 diff --git a/apps/mobile/assets/link-icons/github.png b/apps/mobile/assets/link-icons/github.png index 87eab9f5218ffce249bd6019b59b55d9106f44f5..502b7ada4e77bde078f00923558d629462da5002 100644 GIT binary patch delta 1327 zcmV+~1K& zun<@dd<66ZgTPMx8Pwm&-<|{R0xl9;*Ny2^D`S zdk$BI#QE(Wj-|+%0nF7R=C>w@`BOK!E*<*}V5ou3FTl~3_kOL%M83+g9S__J3{XDu zf!qg7${F`n5Bv8ed-Yj>Ydt6QL>(Xd0gJTMt+5OEOn*PUCxOMl9l-6tL*fs39_R)B z$W(++=Lt`ZCk2uZbA1Y%pFEw-_gKu6fE$1_fw2u&|3ACEfN8)y;4F{-pXoX0Ep-bh zlB&*^Yg{A%CmZIyuwFesSlP~6A^N2G@^kKa0?A>9g&SQY0OuL!ep8;4KBJQnH^F9O z*aT~A%zrz>v+|{j1mI=!>|t8k!NUO#u5yu}$xu4v`mi|dKu%sQJmVrm^9c)|rqAtn z8&lCKE;6M42R0jYh~`O7^ZB;i{IUQGj9O-k(cMO)nJ!X*BaB+^bXvvv@_T7VO%-7b z=g*44hX#X9E^>fX20guE@U6k%T^BjP;|4wb&VNw-39q=w0Uk8y8E_ibd|B-x$9Rr8 zRZU;)-Oy)C5y zz<;s;9f#J8Hw73kaFGDa3TVgMkan;nU;*TDJGZ6ElewQ5*s`-I?PNM(47KuXP;QNk zwM?4Rv(7?ZgBo%n$~eieF7Nbk`gBfI7_$Qe;d{{qF^yp+};{36@V^{T~MGC|UfEatYjM z;OiT-q;?t)A68k#r~E%kxpesqI$GOFMq*y3hw#O!`Urq?N)-Q|clJuMagFr&{eJ-L zo9PM<6Qwf!=UswPc1S|=qu5s`E@-eRJZ^GnuN9P ze3E3RulL2ti3R=V6j8!DnD2*a6y(&cfqC@Gq1hr0VK-XrIgR5lc1^8PEX!{IDhiJ)a%d=@4O$h-&PETNGqzZ ztXl_1idpuISOTLEAZ)X z7^^`ec89;9cK{p>e0mi|Rsz(gm?o_UlNbnTYxyL|BgrgF5jqV1y#?@)qc{q9e5PiJgR%UDH= z(-gIUe}=(t#W;SS;7-fsbYxBsFECwoRfR0RD1S!9I!0~+wD?&_S+5FNeVG*N*+ddM z^%J?_szi?;FBb5B!X4va0R=ZyC;{i-i#ABf!-**s^EiD%EoM+6^Yr-0>QwGF#-Q8g zG13BJxw_W2^MwC~R_d=dQ`gE7 zep8I49wp$&SZU)=g3@?NI_oVx^Ij z`~rh~vqiP7KiOb(L;-Cx43OxBM)_unTYcN61Z**SSf#5l-*xLf<>yjx705d|`TZC9 zmMhTi#s}?EeP6TMSIY0>jDw``9iZ7$C0dU&S;2<-w5X4fuD3!0S-h3Cu~jH8D}Uhs zghP{lRmloA)TiloX$7L!k1szJ@PESH!MOz#C@H#xur53dsT-Y#rkx=4fbw_?eJ^y> z%^Q@~{{&+#r%K?C&#wrM3*=z9J1Ao)ywDYE&ZkZni`otj%Cvt#-;6v*Zp|>NY3NR8Eq-|py^?1zqyO;;{eOA(-G0TE zOPYRf19zZXU*Tvth`QDYx8JWXbL6enOLBDkwzAr9p(}d#s)g&f6ytjL-DxDHcYv=w zc}?FJ!1#8Px|L=zAS*a5|WpB4cG2{noq5xz1`dkzYr} zRg~1VP#%>CHMks2@geaguYY{3jDMQm&!;%>A$<4KSjJA)*fI&wD;WLWr#Uf=jtwA` zX{`tMfm1+;4uR=zqCzrxyuAp7Z0GUbV4N1|x{#9a9LO@NZ|nzPs8)_p>W4}tp$zH3 zm1Q^7_Yz~|b6rVOX%K`AX7LU!>4koc%F^%m>794?DkaTN9)2w>%YP0nb>BT%b&#_U zt{-Hmtxw&JK@AOH&PC@&5OT!l>5x^1w3YUC`fZ(3d&(FoGFGp|a-etLcgSsPGgF4N zj=1{V`j7P84jMY;^PHc7zyL_QmMVKu;s%`5^`GNw!4aS`q<8&JFbon$zoK6DGx(9C zZ!7gRpzN$dMPq3Z>L`~09cjx_7Ydew^UEswe|#VV{{pUGd4ZqLelGw3002ovPDHLk FV1iy}F0B9n diff --git a/apps/mobile/assets/link-icons/gitlab.png b/apps/mobile/assets/link-icons/gitlab.png new file mode 100644 index 0000000000000000000000000000000000000000..28d26431001c5b37a587bb4c5268369d6599e9aa GIT binary patch literal 1723 zcmV;s21NOZP)^5ZAnByRCt{2TU%@tRTw@-Oo$SK(TMtDG~NH4vq5~QiP)fPXWDYp zfFJ?7U0R^qmbM^4@Pajs0>z+06f_V}119=_A^4y%nh2U0UVI=iUZOVbOm9LX%c&DY+qrhr!MjBK%#`5C1YGOzxbH^} zqg_}qUlQm@I*oi?Fg<`Q&{JuHYe{g|BZnagv>}r|S)`wu3jWv9qPgQ2mo)qiISg4K z-)Ej666PNA4laqLE{l1-+79=4;9leqxCN7HzS823v`PP zeLv?Fyt@=*Nu;B>29QMZHF602(4!*(tW$8ijRzOVK|R4`ktC$WkKT<6j1JFZ^JIa< z^=RZ?3z4{VBS!XHrp#nn^$ws~UWRd*)OL6fX27=R-`h#l=%{f$H$Rhd5 zh(_*7e{g}EGn4?4s;0IZgShN*&yhqLwPljpg$46@UmHvSA7DKy5ad#_!KgmIUFy0Z z$)$9cVe`p8J5vz%$?f^e{4~3n?|+)OEJ0` zvw<~)bpPb4%@FArdE{y+Xio=b-6fa>tOPn{5FMF0!rc_}z+%(l6 zXV3>fpy(DDNcY3BL^J^lRAQ}koHnGFTx-CK*>H1aU)QU>AI0ij@oW#J7;nUkuMMi@o$Mi@2vi`v@I3~X?%^GO(3O)@TWxhE2?AywxZkgX;7HdAQhd`zPy zKbf&Q97Ewvt`0Lnv_SR1dRG*#8v2$KF5Z-8&hTd3j+82V)@&fHC7M7xY}j7%YxwqN z-aMyLh0h{o9WJpVSc*X(w&fJrRhB%|SgCce(v|%9JX#BbRt8piODwm}eD5;KVt(uN zX&U`-b?i2%RFZvG22Zs_|7!Cs(Qjgv&szRI*gN5!fLfdVNNlPl%1B-%AMg^(^<1C* z!6|QqhN|c)J2K4~^JCV#On(yiv#Q_PejlsMW?p~;0*c5v9#t>P^#f4y9VdvS8QJ1X-mG+o>?C=UxO#U3(SOYHJq_ih*}HU%APXc zvvlgq3d_xJ0%L#3G33Q<`Q8)ske4|x_sceyJolh@C$0E;5v{@{svkmjEk@B@m_^>e zY}l?!^Fw%W11v^nz^Xm7Lp5;G;#py_Hq1so1C8n-%;MXvH6N*gU;C;o{IFK3B>Uo9 z&9{WQ6|=;#r19}goCFr#i7hGlkPo*ZI}q^}EmHliVBWK`W2*59JPj<;ntw~Ebr2pq z-`fRN?U|kFO>!nCnlMxM-6;87RNn;_H)uXojW1bR@>aEk+5jx^kv`)STH*v|>Prrn zd@d;KT~B0diRgCBMot+wKcOXBGQZv~NXh5Z5`14*-!x%-a!b4iEV13;lFx-9Z_!@f z5^4ixi4UzapJMztu;>o#Y{}=s)hb*PJ?siuZ9c{5AqYf#3m!+)8LZVaJG37z^8U5V z{ApmJ{rFN(UGbghX2H;FxGjDXM$MW}d<>((7I+8Uj8I<2CF=W-cT2>MKzQ&KhgW&C z7}!=cmzS?m^Wg*d_sgyBIcTOpX@L(CpX6={Uh>N8_-!;35q_0as7FD~xZfXv=+JHu z(M-nBix15UAHaW1HXk_%?ZKVS{R39P5op1O)x&UAJp=>F9(${0EE(-^seQ RCgT7A002ovPDHLkV1fiAGxq=h literal 0 HcmV?d00001 diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 2225adf5273b..01b54ef2f139 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -41,6 +41,7 @@ "config:prod": "APP_VARIANT=production expo config", "profile:android:hermes": "mkdir -p profiles/review && react-native profile-hermes profiles/review", "sync:chip-icons": "node scripts/sync-context-chip-icons.mjs", + "sync:link-icons": "node scripts/sync-link-icons.ts", "sync:pierre-icons": "node scripts/sync-pierre-file-icons.mjs", "measure-renders": "node scripts/measure-renders.mts", "generate": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/generate-uniwind-themes.mts", diff --git a/apps/mobile/scripts/sync-link-icons.ts b/apps/mobile/scripts/sync-link-icons.ts new file mode 100644 index 000000000000..981c2485a04e --- /dev/null +++ b/apps/mobile/scripts/sync-link-icons.ts @@ -0,0 +1,79 @@ +// The native markdown renderer draws a link's icon from a raster image, so each source control +// host's mark is rendered from its definition's artwork into a bundled PNG. +import * as NodeFS from "node:fs"; +import * as NodeModule from "node:module"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +import { + sourceControlClients, + type SourceControlIcon, +} from "@t3tools/client-runtime/source-control-clients"; + +const appDirectory = NodePath.resolve( + NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), + "..", +); +const outputDirectory = NodePath.join(appDirectory, "assets/link-icons"); +const generatedModulePath = NodePath.join(appDirectory, "src/lib/markdownLinkIcons.generated.ts"); +const sharp = NodeModule.createRequire( + NodePath.resolve(appDirectory, "../../scripts/package.json"), +)("sharp"); + +const SIZE = 64; + +function attribute(name: string, value: string | number | undefined): string { + return value === undefined ? "" : ` ${name}="${value}"`; +} + +// A monochrome mark is tinted by the renderer, which only reads its alpha. +function svgMarkup(icon: SourceControlIcon): string { + const paint = (value: SourceControlIcon["paths"][number]["fill"]) => + value === undefined + ? "none" + : typeof value === "string" + ? value.replace("currentColor", "#ffffff") + : `url(#${value.gradient})`; + const gradients = (icon.gradients ?? []) + .map( + (gradient) => + `${gradient.stops + .map( + (stop) => + ``, + ) + .join("")}`, + ) + .join(""); + const paths = icon.paths + .map( + (path) => + ``, + ) + .join(""); + return `${gradients}${paths}`; +} + +NodeFS.rmSync(outputDirectory, { recursive: true, force: true }); +NodeFS.mkdirSync(outputDirectory, { recursive: true }); + +const kinds: string[] = []; +for (const definition of sourceControlClients.definitions) { + if (definition.icon === null) continue; + await sharp(Buffer.from(svgMarkup(definition.icon)), { density: 72 * 4 }) + .resize(SIZE, SIZE, { fit: "contain", background: { r: 0, g: 0, b: 0, alpha: 0 } }) + .png() + .toFile(NodePath.join(outputDirectory, `${definition.kind}.png`)); + kinds.push(definition.kind); +} + +NodeFS.writeFileSync( + generatedModulePath, + `import type { ImageSourcePropType } from "react-native";\n\nexport const SOURCE_CONTROL_LINK_ICON_SOURCES: Readonly> = {\n${kinds + .toSorted() + .map( + (kind) => + ` ${/^\w+$/u.test(kind) ? kind : `"${kind}"`}: require("../../assets/link-icons/${kind}.png"),`, + ) + .join("\n")}\n};\n`, +); diff --git a/apps/mobile/src/connection/storage.ts b/apps/mobile/src/connection/storage.ts index 0bb20a769ed8..83805e11942b 100644 --- a/apps/mobile/src/connection/storage.ts +++ b/apps/mobile/src/connection/storage.ts @@ -13,8 +13,8 @@ import { ConnectionTransientError, CredentialStore, ProfileStore, - GitHubRoutingPermissions, - makeGitHubRoutingPermissions, + PullRequestRoutingPermissions, + makePullRequestRoutingPermissions, } from "@t3tools/client-runtime/connection"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -42,7 +42,7 @@ function targetPersistenceError( export const layer = Layer.effectContext( Effect.gen(function* () { const catalog = yield* CatalogStore.make(); - const githubRoutingPermissions = yield* makeGitHubRoutingPermissions({ + const githubRoutingPermissions = yield* makePullRequestRoutingPermissions({ read: catalog.read.pipe(Effect.map((document) => document.githubRoutingPermissions ?? [])), write: (githubRoutingPermissions) => catalog.update((document) => ({ ...document, githubRoutingPermissions })), @@ -150,7 +150,7 @@ export const layer = Layer.effectContext( })), }); return Context.make(Persistence.ConnectionTargetStore, targetStore).pipe( - Context.add(GitHubRoutingPermissions, githubRoutingPermissions), + Context.add(PullRequestRoutingPermissions, githubRoutingPermissions), Context.add(Persistence.ConnectionRegistrationStore, registrationStore), Context.add(ProfileStore.ConnectionProfileStore, profileStore), Context.add(CredentialStore.ConnectionCredentialStore, credentialStore), diff --git a/apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx b/apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx index 4696b459ef0f..6192b53301cb 100644 --- a/apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx +++ b/apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx @@ -10,7 +10,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AndroidScreenHeader } from "../../components/ui/AndroidScreenHeader"; import { useRemoteConnections } from "../../state/use-remote-environment-registry"; import { LocalEnvironmentList } from "./LocalEnvironmentList"; -import { GitHubRoutingSettings } from "./GitHubRoutingSettings"; +import { PullRequestRoutingSettings } from "./PullRequestRoutingSettings"; export function ConnectionsRouteScreen() { const { @@ -69,7 +69,7 @@ export function ConnectionsRouteScreen() { onSetEnabled={onSetEnvironmentEnabled} onUpdate={onUpdateEnvironment} /> - +
); diff --git a/apps/mobile/src/features/connection/GitHubRoutingSettings.tsx b/apps/mobile/src/features/connection/PullRequestRoutingSettings.tsx similarity index 76% rename from apps/mobile/src/features/connection/GitHubRoutingSettings.tsx rename to apps/mobile/src/features/connection/PullRequestRoutingSettings.tsx index 199174208fd8..44383db74b2a 100644 --- a/apps/mobile/src/features/connection/GitHubRoutingSettings.tsx +++ b/apps/mobile/src/features/connection/PullRequestRoutingSettings.tsx @@ -3,9 +3,9 @@ import * as Dialog from "../../components/ui/Dialog"; import { useAtomValue } from "@effect/atom-react"; import { connectionCatalogDisplayUrl, - gitHubRoutingConnectionKey, - gitHubRoutingPermissionFor, - type GitHubRoutingPermission, + pullRequestRoutingConnectionKey, + pullRequestRoutingPermissionFor, + type PullRequestRoutingPermission, } from "@t3tools/client-runtime/connection"; import { useState } from "react"; import { Pressable, View } from "react-native"; @@ -17,11 +17,11 @@ import { useAtomCommand } from "../../state/use-atom-command"; import { SettingsSection } from "../settings/components/SettingsSection"; const options: ReadonlyArray<{ - value: GitHubRoutingPermission; + value: PullRequestRoutingPermission; label: string; description: string; }> = [ - { value: "off", label: "Off", description: "Keep GitHub requests on this environment." }, + { value: "off", label: "Off", description: "Keep source control requests on this environment." }, { value: "read", label: "Read PRs", @@ -30,30 +30,32 @@ const options: ReadonlyArray<{ { value: "read-write", label: "Read and act", - description: "Actions may use broader GitHub permissions than the original environment.", + description: + "Actions may use broader source control permissions than the original environment.", }, ]; -export function GitHubRoutingSettings() { +export function PullRequestRoutingSettings() { const catalog = useAtomValue(environmentCatalog.catalogValueAtom); - const permissions = useAtomValue(environmentCatalog.githubRoutingPermissionsValueAtom); - const update = useAtomCommand(environmentCatalog.setGitHubRoutingPermission); + const permissions = useAtomValue(environmentCatalog.pullRequestRoutingPermissionsValueAtom); + const update = useAtomCommand(environmentCatalog.setPullRequestRoutingPermission); const [expanded, setExpanded] = useState(null); const [saving, setSaving] = useState(false); if (catalog.entries.size === 0) return null; return ( - + {[...catalog.entries.values()].map((entry) => { const environmentId = entry.target.environmentId; - const selected = gitHubRoutingPermissionFor(entry, permissions); - const disabled = !catalog.isReady || saving || gitHubRoutingConnectionKey(entry) === null; + const selected = pullRequestRoutingPermissionFor(entry, permissions); + const disabled = + !catalog.isReady || saving || pullRequestRoutingConnectionKey(entry) === null; return ( setExpanded(expanded === environmentId ? null : environmentId)} @@ -89,7 +91,7 @@ export function GitHubRoutingSettings() { setSaving(false); if (result._tag === "Failure") Dialog.alert( - "Could not save GitHub routing permission", + "Could not save pull request routing permission", "Try again before leaving this screen.", ); }); @@ -102,8 +104,8 @@ export function GitHubRoutingSettings() { })} - Choose environments you trust to share PR data and use each other's GitHub access. Enable - both environments. This applies only to this client. + Choose environments you trust to share PR data and use each other's source control access. + Enable both environments. This applies only to this client. ); diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index 9ac01547d035..903a16140d6d 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -475,7 +475,7 @@ function SourceControlRow(props: { /> ) : ( @@ -1113,7 +1113,7 @@ export function AddProjectNewScreen(props: { readonly environmentId?: string | s } icon={ @@ -1137,7 +1137,7 @@ export function AddProjectNewScreen(props: { readonly environmentId?: string | s title={target.definition.label} icon={ diff --git a/apps/mobile/src/features/projects/SourceControlIcon.tsx b/apps/mobile/src/features/projects/SourceControlIcon.tsx index fe31f90416ac..94b72ce3505b 100644 --- a/apps/mobile/src/features/projects/SourceControlIcon.tsx +++ b/apps/mobile/src/features/projects/SourceControlIcon.tsx @@ -1,261 +1,76 @@ -import Svg, { Circle, Defs, G, LinearGradient, Path, Rect, Stop } from "react-native-svg"; +import type { SourceControlIcon as SourceControlIconArtwork } from "@t3tools/client-runtime/source-control-clients"; +import { useId } from "react"; +import Svg, { Defs, LinearGradient, Path, Stop } from "react-native-svg"; import { withUniwind } from "uniwind"; const ThemedSvg = withUniwind(Svg); +/** Draws a host's mark from its definition's artwork; `currentColor` follows `color`. */ export function SourceControlIcon(props: { - readonly kind: string; + readonly icon: SourceControlIconArtwork | null; readonly size?: number; readonly color?: string; readonly colorClassName?: string; }) { + // Gradient ids must not collide when one screen shows the same mark more than once. + const idPrefix = useId().replace(/[^\w-]/gu, ""); const size = props.size ?? 18; + const { icon } = props; + if (icon === null) return null; + const paint = (value: SourceControlIconArtwork["paths"][number]["fill"]) => + value === undefined + ? "none" + : typeof value === "string" + ? value + : `url(#${idPrefix}-${value.gradient})`; - switch (props.kind) { - case "forgejo": - return ( - - - - - - - - - - ); - case "github": - return ( - - - - ); - case "gitlab": - return ( - - - - - - - ); - case "azure-devops": - // Microsoft Azure DevOps mark via selfhst/icons (CC BY 4.0): https://github.com/selfhst/icons/blob/main/svg/azure-devops.svg - return ( - - + return ( + + {icon.gradients ? ( + + {icon.gradients.map((gradient) => ( - - - - + {gradient.stops.map((stop) => ( + + ))} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); - case "bitbucket": - return ( - - - - - - - - - - - ); - case "gitcafe": - return ( - - - - ); - default: - return null; - } + ))} + + ) : null} + {icon.paths.map((path, index) => ( + + ))} + + ); } diff --git a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx index 3710c2f1e919..4a23fdf98a3a 100644 --- a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx @@ -12,7 +12,7 @@ import { AndroidAnchoredMenu } from "../../components/ui/AndroidAnchoredMenu"; import { AndroidHeaderIconButton } from "../../components/ui/AndroidScreenHeader"; import { CloudEnvironmentRows } from "../connection/CloudEnvironmentRows"; import { LocalEnvironmentList } from "../connection/LocalEnvironmentList"; -import { GitHubRoutingSettings } from "../connection/GitHubRoutingSettings"; +import { PullRequestRoutingSettings } from "../connection/PullRequestRoutingSettings"; import { splitEnvironmentSections } from "../connection/environmentSections"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { useRemoteConnections } from "../../state/use-remote-environment-registry"; @@ -179,7 +179,7 @@ export function SettingsEnvironmentsRouteScreen() { } : {})} /> - + ); diff --git a/apps/mobile/src/lib/markdownLinkIcons.generated.ts b/apps/mobile/src/lib/markdownLinkIcons.generated.ts new file mode 100644 index 000000000000..d6b13c789047 --- /dev/null +++ b/apps/mobile/src/lib/markdownLinkIcons.generated.ts @@ -0,0 +1,10 @@ +import type { ImageSourcePropType } from "react-native"; + +export const SOURCE_CONTROL_LINK_ICON_SOURCES: Readonly> = { + "azure-devops": require("../../assets/link-icons/azure-devops.png"), + bitbucket: require("../../assets/link-icons/bitbucket.png"), + forgejo: require("../../assets/link-icons/forgejo.png"), + gitcafe: require("../../assets/link-icons/gitcafe.png"), + github: require("../../assets/link-icons/github.png"), + gitlab: require("../../assets/link-icons/gitlab.png"), +}; diff --git a/apps/mobile/src/lib/markdownLinkIcons.ts b/apps/mobile/src/lib/markdownLinkIcons.ts index f88ac2f52bca..2aa25166daf1 100644 --- a/apps/mobile/src/lib/markdownLinkIcons.ts +++ b/apps/mobile/src/lib/markdownLinkIcons.ts @@ -1,13 +1,22 @@ -import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; +import { + isMonochromeSourceControlIcon, + sourceControlClients, +} from "@t3tools/client-runtime/source-control-clients"; import type { ImageSourcePropType } from "react-native"; -// The native renderer draws a link's icon from an image, so a host's mark is a bundled image keyed -// by its definition's `icon`. A host without one keeps a plain link. -const SOURCE_CONTROL_LINK_ICON_SOURCES: Partial> = { - github: require("../../assets/link-icons/github.png"), -}; +import { SOURCE_CONTROL_LINK_ICON_SOURCES } from "./markdownLinkIcons.generated"; -export function markdownLinkIconSource(host: string): ImageSourcePropType | null { - const icon = sourceControlClients.findByPublicDomain(host)?.icon; - return (icon === undefined ? undefined : SOURCE_CONTROL_LINK_ICON_SOURCES[icon]) ?? null; +export interface MarkdownLinkIcon { + readonly source: ImageSourcePropType; + /** A mark drawn in the text colour takes the link's colour; a coloured mark keeps its own. */ + readonly tints: boolean; +} + +// `vp run sync:link-icons` renders each host's artwork into the bundled image this reads. +export function markdownLinkIcon(host: string): MarkdownLinkIcon | null { + const definition = sourceControlClients.findByPublicDomain(host); + const source = definition && SOURCE_CONTROL_LINK_ICON_SOURCES[definition.kind]; + return definition?.icon && source + ? { source, tints: isMonochromeSourceControlIcon(definition.icon) } + : null; } diff --git a/apps/mobile/src/native/useEnrichedLinkVariants.ts b/apps/mobile/src/native/useEnrichedLinkVariants.ts index dda01bd75451..575c6231d0fa 100644 --- a/apps/mobile/src/native/useEnrichedLinkVariants.ts +++ b/apps/mobile/src/native/useEnrichedLinkVariants.ts @@ -11,7 +11,7 @@ import { CONTEXT_CHIP_ICON_SOURCES } from "../lib/contextChipIcons.generated"; import { FONT_FAMILIES } from "../lib/fontFamilies"; import { contextChipIconSource } from "../lib/contextChipIconSource"; import { markdownFileIconSource } from "../lib/markdownFileIcons"; -import { markdownLinkIconSource } from "../lib/markdownLinkIcons"; +import { markdownLinkIcon } from "../lib/markdownLinkIcons"; import { useUniwindTheme } from "../lib/useUniwindTheme"; import { resolveMarkdownLinkPresentation } from "../lib/markdownLinks"; import type { @@ -83,12 +83,12 @@ function presentation( icon: markdownFileIconSource(link.icon), chip: { accent: contextChipPresentation("mention").accent, tintsIcon: false }, }; - const icon = link.kind === "external" ? markdownLinkIconSource(link.host) : null; + const icon = link.kind === "external" ? markdownLinkIcon(link.host) : null; return icon ? { color: props.textStyle.linkColor, - icon, - iconTintColor: props.textStyle.linkColor, + icon: icon.source, + ...(icon.tints ? { iconTintColor: props.textStyle.linkColor } : {}), } : undefined; } diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 8faedef400f3..551312e23f1d 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -69,18 +69,6 @@ export const LinuxIcon: Icon = ({ className, ...props }) => ( ); -export const GitHubIcon: Icon = (props) => ( - - - -); - export const GitIcon: Icon = (props) => ( { ); }; -export const GitLabIcon = ({ - monochrome = false, - ...props -}: SVGProps & { readonly monochrome?: boolean }) => ( - - - - - - -); - -// Microsoft Azure DevOps mark via selfhst/icons (CC BY 4.0): https://github.com/selfhst/icons/blob/main/svg/azure-devops.svg -export const AzureDevOpsIcon: Icon = (props) => { - const id = useId().replaceAll(":", ""); - const gradientId = `${id}-azure`; - - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); -}; - -export const BitbucketIcon: Icon = (props) => { - const id = useId().replaceAll(":", ""); - const gradientId = `${id}-bitbucket-a`; - - return ( - - - - - - - - - - ); -}; - export const CursorIcon: Icon = ({ className, ...props }) => ( ( ); -export const ForgejoIcon: Icon = (props) => ( - -); - -export const GitCafeIcon: Icon = (props) => ( - -); - export const MacScreenRecordingIcon: Icon = (props) => { const gradientId = useId(); return ( diff --git a/apps/web/src/components/SourceControlIconArtwork.tsx b/apps/web/src/components/SourceControlIconArtwork.tsx new file mode 100644 index 000000000000..7f2ddc3914d3 --- /dev/null +++ b/apps/web/src/components/SourceControlIconArtwork.tsx @@ -0,0 +1,59 @@ +import type { SourceControlIcon } from "@t3tools/client-runtime/source-control-clients"; +import { useId, type SVGProps } from "react"; + +/** Draws a host's mark from its definition's artwork. */ +export function SourceControlIconArtwork({ + icon, + ...props +}: { readonly icon: SourceControlIcon } & SVGProps) { + // Gradient ids are document-wide, and one page can show the same mark many times. + const idPrefix = useId().replace(/[^\w-]/gu, ""); + const paint = (value: SourceControlIcon["paths"][number]["fill"]) => + value === undefined + ? "none" + : typeof value === "string" + ? value + : `url(#${idPrefix}-${value.gradient})`; + return ( + + {icon.gradients ? ( + + {icon.gradients.map((gradient) => ( + + {gradient.stops.map((stop) => ( + + ))} + + ))} + + ) : null} + {icon.paths.map((path, index) => ( + + ))} + + ); +} diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 3639c921f356..fea873dc4e52 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -37,7 +37,7 @@ import { searchableSetting } from "./settingsSearch"; import { EnvironmentIconMenu } from "./EnvironmentIconPicker"; import { FoldedSettingsSection } from "./FoldedSettingsSection"; import { LoadBalancingSettings } from "./LoadBalancingSettings"; -import { GitHubRoutingSettings } from "./GitHubRoutingSettings"; +import { PullRequestRoutingSettings } from "./PullRequestRoutingSettings"; import { Input } from "../ui/input"; import { Dialog, @@ -1533,7 +1533,7 @@ export function ConnectionsSettings() { /> ) : null} - + ); } diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index 11e6603391ba..3262f44d75d8 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -780,7 +780,7 @@ function PullRequestLinksOpenInAppSetting() { return ( = [ +const options: ReadonlyArray<{ value: PullRequestRoutingPermission; label: string }> = [ { value: "off", label: "Off" }, { value: "read", label: "Read PRs" }, { value: "read-write", label: "Read and act" }, @@ -24,8 +24,11 @@ const options: ReadonlyArray<{ value: GitHubRoutingPermission; label: string }> const summaryLabels = { "read-write": "read and act", read: "read PRs" } as const; -function summarizeGitHubRouting( - entries: ReadonlyArray<{ readonly label: string; readonly permission: GitHubRoutingPermission }>, +function summarizePullRequestRouting( + entries: ReadonlyArray<{ + readonly label: string; + readonly permission: PullRequestRoutingPermission; + }>, ): string | null { const groups = (["read-write", "read"] as const).flatMap((permission) => { const labels = entries.filter((entry) => entry.permission === permission); @@ -36,36 +39,36 @@ function summarizeGitHubRouting( return groups.length === 0 ? null : groups.join(" · "); } -export function GitHubRoutingSettings({ +export function PullRequestRoutingSettings({ environments, }: { readonly environments: ReadonlyArray; }) { - const permissions = useAtomValue(environmentCatalog.githubRoutingPermissionsValueAtom); + const permissions = useAtomValue(environmentCatalog.pullRequestRoutingPermissionsValueAtom); const catalog = useAtomValue(environmentCatalog.catalogValueAtom); - const update = useAtomCommand(environmentCatalog.setGitHubRoutingPermission); + const update = useAtomCommand(environmentCatalog.setPullRequestRoutingPermission); const [saving, setSaving] = useState(false); if (environments.length < 2) return null; - const { id, title } = searchableSetting("github-routing"); + const { id, title } = searchableSetting("pull-request-routing"); return ( ({ label: environment.label, - permission: gitHubRoutingPermissionFor(environment.entry, permissions), + permission: pullRequestRoutingPermissionFor(environment.entry, permissions), })), ) ?? "Off" } >

- Machines you trust here can read PR data through each other's GitHub access. Enable both - machines. Read and act may use broader permissions than the machine that owns them. This - applies only to this device. + Machines you trust here can read PR data through each other's source control access. Enable + both machines. Read and act may use broader permissions than the machine that owns them. + This applies only to this device.

{environments.map((environment) => ( setHost(event.target.value)} />
- +

{savedHosts.length === 0 - ? "No token saved; the server uses GH_TOKEN or the gh login." + ? `No token saved; the server uses ${credentials.tokenEnvVar} or the ${credentials.cliName} login.` : `Saved for ${savedHosts.join(", ")}.`}

diff --git a/apps/web/src/components/settings/settingsSearch.test.ts b/apps/web/src/components/settings/settingsSearch.test.ts index 82995946155f..1584bbe719a4 100644 --- a/apps/web/src/components/settings/settingsSearch.test.ts +++ b/apps/web/src/components/settings/settingsSearch.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it, vi } from "vite-plus/test"; -import { EnvironmentId } from "@t3tools/contracts"; +import { + EnvironmentId, + makeProviderSettingsSchema, + SourceControlProviderKind, +} from "@t3tools/contracts"; +import { UNKNOWN_SOURCE_CONTROL_CLIENT } from "@t3tools/client-runtime/source-control-clients"; +import * as Schema from "effect/Schema"; import { filterAvailableSettingsSearchItems, @@ -8,6 +14,8 @@ import { isSettingsSearchScopeAvailable, searchSettings, SETTINGS_SEARCH_ITEMS, + sourceControlHostSearchItems, + sourceControlHostSettingsSearchId, type SettingsSearchItem, } from "./settingsSearch"; @@ -234,3 +242,46 @@ describe("auto-settlement search availability", () => { ).toBe(true); }); }); + +describe("source control host search items", () => { + const definition = { + ...UNKNOWN_SOURCE_CONTROL_CLIENT, + kind: SourceControlProviderKind.make("example"), + label: "Example", + searchKeywords: ["sample"], + settings: makeProviderSettingsSchema({ apiToken: Schema.String }), + }; + + it("lists a host by its label, its keywords, and its settings fields", () => { + const items = sourceControlHostSearchItems([UNKNOWN_SOURCE_CONTROL_CLIENT, definition]); + expect(items.map((item) => item.title)).toEqual(["Example credentials"]); + expect(searchSettings("sample apitoken", items).map((item) => item.id)).toEqual([ + sourceControlHostSettingsSearchId("example"), + ]); + }); + + it("lists a host with credentials by its accounts, token and CLI", () => { + const items = sourceControlHostSearchItems([ + { + ...definition, + credentials: { + cliName: "ex", + cliLabel: "Example CLI", + loginCommand: "ex login", + tokenEnvVar: "EX_TOKEN", + tokenAccess: "access", + newTokenUrl: (host) => `https://${host}/tokens`, + }, + }, + ]); + expect(items.map((item) => item.title)).toEqual(["Example accounts and token"]); + expect(searchSettings("ex switch accounts", items)).toHaveLength(1); + }); + + it("finds the built-in hosts by their own words", () => { + expect(searchSettings("codeberg").map((item) => item.id)).toContain("source-control"); + expect(searchSettings("ghes").map((item) => item.id)).toContain( + sourceControlHostSettingsSearchId("github"), + ); + }); +}); diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index eac65e29742e..8d177d78859a 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -4,7 +4,10 @@ import { isMacPlatform, isWindowsPlatform, normalizeSearchText } from "~/lib/uti import { STATIC_KEYBINDING_COMMANDS, type KeybindingCommand } from "@t3tools/contracts"; import type { EnvironmentId } from "@t3tools/contracts"; import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; -import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; +import { + sourceControlClients, + type SourceControlClientDefinition, +} from "@t3tools/client-runtime/source-control-clients"; import { DEFAULT_KEYBINDINGS } from "@t3tools/shared/keybindings"; import { commandLabel } from "./KeybindingsSettings.logic"; import { @@ -111,22 +114,42 @@ export function sourceControlHostSettingsSearchId(kind: string) { return `source-control-host-${kind}` as const; } -const SOURCE_CONTROL_HOST_SEARCH_ITEMS = sourceControlClients.definitions.flatMap((definition) => - definition.settings && definition.kind !== "github" - ? [ - { - id: sourceControlHostSettingsSearchId(definition.kind), - title: `${definition.label} credentials`, - to: "/settings/source-control" as const, - searchTerms: [ - `${definition.label} ${Object.keys(definition.settings.fields).join(" ")} token credentials sign in`, - ], - environmentOnly: true, - scope: "environment-defaults" as const, - }, - ] - : [], -); +/** Each host's own settings: its accounts and token, or the fields of its settings form. */ +export function sourceControlHostSearchItems( + definitions: ReadonlyArray, +): ReadonlyArray< + SettingsSearchItem & { readonly id: ReturnType } +> { + return definitions.flatMap((definition) => { + if (!definition.settings) return []; + const names = [definition.label, ...(definition.searchKeywords ?? [])].join(" "); + const { credentials } = definition; + return [ + { + id: sourceControlHostSettingsSearchId(definition.kind), + title: credentials + ? `${definition.label} accounts and token` + : `${definition.label} credentials`, + to: "/settings/source-control" as const, + searchTerms: [ + credentials + ? `${names} ${credentials.cliName} account login user host switch multiple accounts disable sign in token personal access token pat api key credential` + : `${names} ${Object.keys(definition.settings.fields).join(" ")} token credentials sign in`, + ], + environmentOnly: true, + scope: "environment-defaults" as const, + }, + ]; + }); +} + +/** For settings that apply to every host. */ +const SOURCE_CONTROL_HOST_LABELS = sourceControlClients.definitions + .map((definition) => definition.label) + .join(" "); +const SOURCE_CONTROL_HOST_TERMS = sourceControlClients.definitions + .flatMap((definition) => [definition.label, ...(definition.searchKeywords ?? [])]) + .join(" "); export const SETTINGS_SEARCH_ITEMS = [ { @@ -775,7 +798,9 @@ export const SETTINGS_SEARCH_ITEMS = [ id: "pull-request-links-open-in-app", title: "Open pull requests in T3 Code", to: "/settings/integrations", - searchTerms: ["pr links github browser external click default pull request view panel"], + searchTerms: [ + `pr links ${SOURCE_CONTROL_HOST_LABELS} browser external click default pull request view panel`, + ], }, { id: "browser-auto-show-floating-preview", @@ -795,7 +820,9 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "Remove agent credits when merging", to: "/settings/source-control", scope: "project-defaults", - searchTerms: ["pull request github squash co-authored-by attribution claude codex generated"], + searchTerms: [ + `pull request ${SOURCE_CONTROL_HOST_LABELS} squash co-authored-by attribution claude codex generated`, + ], }, { id: "pull-request-merge-method", @@ -810,7 +837,7 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/source-control", scope: "environment-defaults", searchTerms: [ - "version control git github gitlab forgejo gitea tea codeberg bitbucket azure devops hosting integrations credentials scan server environment", + `version control git ${SOURCE_CONTROL_HOST_TERMS} hosting integrations credentials scan server environment`, ], }, { @@ -831,17 +858,7 @@ export const SETTINGS_SEARCH_ITEMS = [ environmentOnly: true, scope: "project-defaults", }, - { - id: "github-accounts", - title: "GitHub accounts and token", - to: "/settings/source-control", - searchTerms: [ - "github gh account login user host enterprise ghes switch multiple accounts disable sign in token personal access token pat api key credential", - ], - environmentOnly: true, - scope: "environment-defaults", - }, - ...SOURCE_CONTROL_HOST_SEARCH_ITEMS, + ...sourceControlHostSearchItems(sourceControlClients.definitions), { id: "source-control-writing-style", title: "Source control writing style", @@ -996,10 +1013,12 @@ export const SETTINGS_SEARCH_ITEMS = [ ], }, { - id: "github-routing", - title: "GitHub sharing", + id: "pull-request-routing", + title: "Pull request sharing", to: "/settings/connections", - searchTerms: ["pull request trusted environments shared credentials permissions read actions"], + searchTerms: [ + `pull request routing trusted environments shared credentials permissions read actions source control ${SOURCE_CONTROL_HOST_LABELS}`, + ], }, { id: "archive", diff --git a/apps/web/src/connection/storage.test.ts b/apps/web/src/connection/storage.test.ts index edec73309831..7afe2b1e8573 100644 --- a/apps/web/src/connection/storage.test.ts +++ b/apps/web/src/connection/storage.test.ts @@ -219,8 +219,8 @@ describe("browser GitHub routing permissions", () => { }; const firstBrowser = Object.assign(new EventTarget(), { localStorage }); const secondBrowser = Object.assign(new EventTarget(), { localStorage }); - const first = ConnectionStorage.makeBrowserGitHubRoutingPermissions(firstBrowser); - const second = ConnectionStorage.makeBrowserGitHubRoutingPermissions(secondBrowser); + const first = ConnectionStorage.makeBrowserPullRequestRoutingPermissions(firstBrowser); + const second = ConnectionStorage.makeBrowserPullRequestRoutingPermissions(secondBrowser); const entry = { target: new PrimaryConnectionTarget({ environmentId: EnvironmentId.make("first"), @@ -268,7 +268,7 @@ describe("browser GitHub routing permissions", () => { expect(yield* second.get(entry)).toBe("off"); expect(yield* first.get(other)).toBe("read"); expect( - yield* ConnectionStorage.makeBrowserGitHubRoutingPermissions(firstBrowser).get(entry), + yield* ConnectionStorage.makeBrowserPullRequestRoutingPermissions(firstBrowser).get(entry), ).toBe("off"); yield* first.set(entry, "read-write"); diff --git a/apps/web/src/connection/storage.ts b/apps/web/src/connection/storage.ts index 4e7bcf3e58ec..bee5b0bc06c1 100644 --- a/apps/web/src/connection/storage.ts +++ b/apps/web/src/connection/storage.ts @@ -23,10 +23,10 @@ import { ConnectionBlockedError, CredentialStore, ProfileStore, - GitHubRoutingPermissions, - StoredGitHubRoutingPermission, - gitHubRoutingConnectionKey, - gitHubRoutingPermissionFor, + PullRequestRoutingPermissions, + StoredPullRequestRoutingPermission, + pullRequestRoutingConnectionKey, + pullRequestRoutingPermissionFor, } from "@t3tools/client-runtime/connection"; import { EnvironmentId, ServerConfig, ThreadId, VcsListRefsResult } from "@t3tools/contracts"; import * as Context from "effect/Context"; @@ -496,36 +496,38 @@ export const makeCatalogStore = Effect.fn("web.connectionStorage.makeCatalogStor return { read, update } satisfies CatalogStore; }); -const GITHUB_ROUTING_KEY_PREFIX = "t3code:github-routing:"; -const GITHUB_ROUTING_CHANGED = "t3code:github-routing-changed"; -const isStoredGitHubRoutingPermission = Schema.is(StoredGitHubRoutingPermission); -const encodeStoredGitHubRoutingPermission = Schema.encodeSync( - Schema.fromJsonString(StoredGitHubRoutingPermission), +// The stored names predate the rename and stay, so a tab still on an older version and an +// updated one read and revoke the same grants. +const PULL_REQUEST_ROUTING_KEY_PREFIX = "t3code:github-routing:"; +const PULL_REQUEST_ROUTING_CHANGED = "t3code:github-routing-changed"; +const isStoredPullRequestRoutingPermission = Schema.is(StoredPullRequestRoutingPermission); +const encodeStoredPullRequestRoutingPermission = Schema.encodeSync( + Schema.fromJsonString(StoredPullRequestRoutingPermission), ); /** Each grant has its own key so stale tabs and unrelated catalog saves cannot restore trust. */ -export function makeBrowserGitHubRoutingPermissions( +export function makeBrowserPullRequestRoutingPermissions( browser: Pick & EventTarget = window, ) { - const read = (key: string): StoredGitHubRoutingPermission | null => { + const read = (key: string): StoredPullRequestRoutingPermission | null => { try { const raw = browser.localStorage.getItem(key); const value: unknown = raw === null ? null : JSON.parse(raw); - return isStoredGitHubRoutingPermission(value) && - key === `${GITHUB_ROUTING_KEY_PREFIX}${value.environmentId}` + return isStoredPullRequestRoutingPermission(value) && + key === `${PULL_REQUEST_ROUTING_KEY_PREFIX}${value.environmentId}` ? value : null; } catch { return null; } }; - const readAll = (): ReadonlyArray => { + const readAll = (): ReadonlyArray => { try { - const values: StoredGitHubRoutingPermission[] = []; + const values: StoredPullRequestRoutingPermission[] = []; const storage = browser.localStorage; for (let index = 0; index < storage.length; index++) { const key = storage.key(index); - if (key?.startsWith(GITHUB_ROUTING_KEY_PREFIX)) { + if (key?.startsWith(PULL_REQUEST_ROUTING_KEY_PREFIX)) { const value = read(key); if (value !== null) values.push(value); } @@ -535,46 +537,46 @@ export function makeBrowserGitHubRoutingPermissions( return []; } }; - const write = (environmentId: EnvironmentId, value: StoredGitHubRoutingPermission | null) => + const write = (environmentId: EnvironmentId, value: StoredPullRequestRoutingPermission | null) => Effect.try({ try: () => { - const key = `${GITHUB_ROUTING_KEY_PREFIX}${environmentId}`; + const key = `${PULL_REQUEST_ROUTING_KEY_PREFIX}${environmentId}`; if (value === null) browser.localStorage.removeItem(key); - else browser.localStorage.setItem(key, encodeStoredGitHubRoutingPermission(value)); - browser.dispatchEvent(new Event(GITHUB_ROUTING_CHANGED)); + else browser.localStorage.setItem(key, encodeStoredPullRequestRoutingPermission(value)); + browser.dispatchEvent(new Event(PULL_REQUEST_ROUTING_CHANGED)); }, - catch: (cause) => catalogError("save GitHub routing permissions in", cause), + catch: (cause) => catalogError("save pull request routing permissions in", cause), }); - return GitHubRoutingPermissions.of({ + return PullRequestRoutingPermissions.of({ get: (entry) => Effect.sync(() => { - const value = read(`${GITHUB_ROUTING_KEY_PREFIX}${entry.target.environmentId}`); - return gitHubRoutingPermissionFor(entry, value === null ? [] : [value]); + const value = read(`${PULL_REQUEST_ROUTING_KEY_PREFIX}${entry.target.environmentId}`); + return pullRequestRoutingPermissionFor(entry, value === null ? [] : [value]); }), - changes: Stream.callback>((queue) => + changes: Stream.callback>((queue) => Effect.acquireRelease( Effect.sync(() => { const listener = (event: Event) => { if (event.type === "storage") { const key = (event as StorageEvent).key; - if (key !== null && !key?.startsWith(GITHUB_ROUTING_KEY_PREFIX)) return; + if (key !== null && !key?.startsWith(PULL_REQUEST_ROUTING_KEY_PREFIX)) return; } Queue.offerUnsafe(queue, readAll()); }; browser.addEventListener("storage", listener); - browser.addEventListener(GITHUB_ROUTING_CHANGED, listener); + browser.addEventListener(PULL_REQUEST_ROUTING_CHANGED, listener); Queue.offerUnsafe(queue, readAll()); return listener; }), (listener) => Effect.sync(() => { browser.removeEventListener("storage", listener); - browser.removeEventListener(GITHUB_ROUTING_CHANGED, listener); + browser.removeEventListener(PULL_REQUEST_ROUTING_CHANGED, listener); }), ).pipe(Effect.asVoid), ), set: (entry, permission) => { - const connectionKey = gitHubRoutingConnectionKey(entry); + const connectionKey = pullRequestRoutingConnectionKey(entry); if (connectionKey === null) return Effect.fail( new ConnectionBlockedError({ @@ -623,7 +625,7 @@ export const layer = Layer.effectContext( (owned) => owned.close, ); const catalog = yield* makeCatalogStore(makeCatalogBackend(database)); - const githubRoutingPermissions = makeBrowserGitHubRoutingPermissions(); + const pullRequestRoutingPermissions = makeBrowserPullRequestRoutingPermissions(); // Remembers each thread row's encoding, so a save only encodes rows that changed. const encodeStoredShellSnapshot = makeStoredShellSnapshotEncoder({ yieldToHost: yieldToBrowserTask, @@ -930,7 +932,7 @@ export const layer = Layer.effectContext( }); return Context.make(Persistence.ConnectionTargetStore, targetStore).pipe( - Context.add(GitHubRoutingPermissions, githubRoutingPermissions), + Context.add(PullRequestRoutingPermissions, pullRequestRoutingPermissions), Context.add(Persistence.ConnectionRegistrationStore, registrationStore), Context.add(ProfileStore.ConnectionProfileStore, profileStore), Context.add(CredentialStore.ConnectionCredentialStore, credentialStore), diff --git a/apps/web/src/sourceControlPresentation.ts b/apps/web/src/sourceControlPresentation.tsx similarity index 72% rename from apps/web/src/sourceControlPresentation.ts rename to apps/web/src/sourceControlPresentation.tsx index ec8b4c7242dd..8fbc14708704 100644 --- a/apps/web/src/sourceControlPresentation.ts +++ b/apps/web/src/sourceControlPresentation.tsx @@ -5,31 +5,27 @@ import type { ChangeRequestTerminology, SourceControlClientDefinition, } from "@t3tools/client-runtime/source-control-clients"; -import { - AzureDevOpsIcon, - BitbucketIcon, - ForgejoIcon, - GitCafeIcon, - GitHubIcon, - GitLabIcon, -} from "./components/Icons"; import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; +import { SourceControlIconArtwork } from "./components/SourceControlIconArtwork"; export type { ChangeRequestTerminology } from "@t3tools/client-runtime/source-control-clients"; type SourceControlIcon = ElementType<{ className?: string }>; -const SOURCE_CONTROL_ICONS: Partial> = { - github: GitHubIcon, - gitlab: GitLabIcon, - forgejo: ForgejoIcon, - bitbucket: BitbucketIcon, - "azure-devops": AzureDevOpsIcon, - gitcafe: GitCafeIcon, -}; +const SOURCE_CONTROL_ICONS = new WeakMap(); +/** One component per definition, so a host's icon keeps its identity across renders. */ export function sourceControlIcon(definition: SourceControlClientDefinition): SourceControlIcon { - return SOURCE_CONTROL_ICONS[definition.icon] ?? PullRequestGlyph.pullRequest; + const artwork = definition.icon; + if (artwork === null) return PullRequestGlyph.pullRequest; + let Icon = SOURCE_CONTROL_ICONS.get(definition); + if (Icon === undefined) { + Icon = (props: { className?: string }) => ( + + ); + SOURCE_CONTROL_ICONS.set(definition, Icon); + } + return Icon; } export interface SourceControlPresentation { diff --git a/docs/internals/remote.md b/docs/internals/remote.md index 5fcec03bbaf8..b581e1be1d55 100644 --- a/docs/internals/remote.md +++ b/docs/internals/remote.md @@ -54,7 +54,7 @@ dropped, which is how a changed LAN address replaces the old one; routes the user saved are never touched. The reported addresses are hints like any advertised endpoint, so a learned route still has to answer as this environment before it is used. -GitHub routing trust covers the whole route list. Adding or changing a route +Pull request routing trust covers the whole route list. Adding or changing a route revokes it; reordering does not, because the same addresses remain trusted. ## Hosted web is a client diff --git a/docs/user/source-control.md b/docs/user/source-control.md index cb323ea4c3bf..de63202c51eb 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -183,15 +183,16 @@ Press **Escape** before releasing to cancel. Failed closes stay in the list so y GitHub, GitLab, and Azure DevOps support auto-merge while checks are outstanding. GitHub also supports approving waiting fork workflows and opening a revert pull request for a merged change. -GitHub sharing is off by default. In Settings → Connections → GitHub sharing (Environments on mobile), choose -**Read PRs** or **Read and act** for each environment you trust to share GitHub access. +Pull request sharing is off by default. In Settings → Connections → Pull request sharing (Pull +request routing under Environments on mobile), choose **Read PRs** or **Read and act** for each +environment you trust to share its source control access. Enable both the original environment and the environment answering its requests on this client. -**Read and act** can use broader GitHub permissions than the original environment's credential; +**Read and act** can use broader permissions than the original environment's credential; only enable it for environments you control and trust. Changing a saved endpoint or removing an environment clears its permission. -GitHub review details, linked PR status, and permitted review actions can then use another -connected environment signed in to the same GitHub account. Each needs a project on that host. +Review details, linked PR status, and permitted review actions can then use another connected +environment signed in to the same account on the same host. Each needs a project on that host. A connected local environment is preferred for actions and can answer slow or failed reads. Browsers and mobile clients need a paired environment to use its GitHub credentials. Credentials stay on their machines. Previously verified credentials remain usable for routing diff --git a/packages/client-runtime/src/connection/index.ts b/packages/client-runtime/src/connection/index.ts index 5686b3c3df70..02a30adb57fa 100644 --- a/packages/client-runtime/src/connection/index.ts +++ b/packages/client-runtime/src/connection/index.ts @@ -4,7 +4,7 @@ export * as Connectivity from "./connectivity.ts"; export * as CredentialStore from "./credentialStore.ts"; export { type ConnectionDriverProgress, type EnvironmentConnectionLease } from "./driver.ts"; export * from "./errors.ts"; -export * from "./githubRoutingPermissions.ts"; +export * from "./pullRequestRoutingPermissions.ts"; export * from "./hostFreeze.ts"; export * as Connection from "./layer.ts"; export * from "./model.ts"; diff --git a/packages/client-runtime/src/connection/githubRoutingPermissions.ts b/packages/client-runtime/src/connection/pullRequestRoutingPermissions.ts similarity index 75% rename from packages/client-runtime/src/connection/githubRoutingPermissions.ts rename to packages/client-runtime/src/connection/pullRequestRoutingPermissions.ts index 3da40fb65156..23f7eb59844f 100644 --- a/packages/client-runtime/src/connection/githubRoutingPermissions.ts +++ b/packages/client-runtime/src/connection/pullRequestRoutingPermissions.ts @@ -10,21 +10,21 @@ import * as SubscriptionRef from "effect/SubscriptionRef"; import type { ConnectionCatalogEntry } from "./catalog.ts"; import { ConnectionBlockedError, type ConnectionAttemptError } from "./model.ts"; -export const GitHubRoutingPermission = Schema.Literals(["off", "read", "read-write"]); -export type GitHubRoutingPermission = typeof GitHubRoutingPermission.Type; -export const StoredGitHubRoutingPermission = Schema.Struct({ +export const PullRequestRoutingPermission = Schema.Literals(["off", "read", "read-write"]); +export type PullRequestRoutingPermission = typeof PullRequestRoutingPermission.Type; +export const StoredPullRequestRoutingPermission = Schema.Struct({ environmentId: EnvironmentId, connectionKey: Schema.String, - permission: GitHubRoutingPermission, + permission: PullRequestRoutingPermission, }); -export type StoredGitHubRoutingPermission = typeof StoredGitHubRoutingPermission.Type; +export type StoredPullRequestRoutingPermission = typeof StoredPullRequestRoutingPermission.Type; /** * Trust belongs to the saved endpoints, never to an environment id advertised * by a server alone. With several routes the key covers all of them, sorted so * reordering keeps trust but adding or changing an address revokes it. */ -export function gitHubRoutingConnectionKey(entry: ConnectionCatalogEntry): string | null { +export function pullRequestRoutingConnectionKey(entry: ConnectionCatalogEntry): string | null { const routes = [ { target: entry.target, profile: entry.profile }, ...(entry.alternateRoutes ?? []), @@ -75,25 +75,25 @@ function routeConnectionKey( } } -export function gitHubRoutingPermissionFor( +export function pullRequestRoutingPermissionFor( entry: ConnectionCatalogEntry, - permissions: ReadonlyArray, -): GitHubRoutingPermission { - const key = gitHubRoutingConnectionKey(entry); + permissions: ReadonlyArray, +): PullRequestRoutingPermission { + const key = pullRequestRoutingConnectionKey(entry); return key === null ? "off" : (permissions.find((permission) => permission.connectionKey === key)?.permission ?? "off"); } -export class GitHubRoutingPermissions extends Context.Reference<{ - readonly get: (entry: ConnectionCatalogEntry) => Effect.Effect; - readonly changes: Stream.Stream>; +export class PullRequestRoutingPermissions extends Context.Reference<{ + readonly get: (entry: ConnectionCatalogEntry) => Effect.Effect; + readonly changes: Stream.Stream>; readonly set: ( entry: ConnectionCatalogEntry, - permission: GitHubRoutingPermission, + permission: PullRequestRoutingPermission, ) => Effect.Effect; readonly forget: (environmentId: EnvironmentId) => Effect.Effect; -}>("@t3tools/client-runtime/connection/GitHubRoutingPermissions", { +}>("@t3tools/client-runtime/connection/PullRequestRoutingPermissions", { defaultValue: () => ({ get: () => Effect.succeed("off"), changes: Stream.succeed([]), @@ -101,29 +101,29 @@ export class GitHubRoutingPermissions extends Context.Reference<{ Effect.fail( new ConnectionBlockedError({ reason: "unsupported", - detail: "GitHub routing preferences are unavailable on this client.", + detail: "Pull request routing preferences are unavailable on this client.", }), ), forget: () => Effect.void, }), }) {} -export const makeGitHubRoutingPermissions = Effect.fn("makeGitHubRoutingPermissions")( +export const makePullRequestRoutingPermissions = Effect.fn("makePullRequestRoutingPermissions")( function* (storage: { readonly read: Effect.Effect< - ReadonlyArray, + ReadonlyArray, ConnectionAttemptError >; readonly write: ( - permissions: ReadonlyArray, + permissions: ReadonlyArray, ) => Effect.Effect; }) { const state = yield* SubscriptionRef.make(yield* storage.read); const lock = yield* Semaphore.make(1); const update = ( transform: ( - current: ReadonlyArray, - ) => ReadonlyArray, + current: ReadonlyArray, + ) => ReadonlyArray, ) => lock.withPermits(1)( Effect.gen(function* () { @@ -134,14 +134,14 @@ export const makeGitHubRoutingPermissions = Effect.fn("makeGitHubRoutingPermissi yield* SubscriptionRef.set(state, next); }), ); - return GitHubRoutingPermissions.of({ + return PullRequestRoutingPermissions.of({ get: (entry) => SubscriptionRef.get(state).pipe( - Effect.map((permissions) => gitHubRoutingPermissionFor(entry, permissions)), + Effect.map((permissions) => pullRequestRoutingPermissionFor(entry, permissions)), ), changes: SubscriptionRef.changes(state), set: (entry, permission) => { - const connectionKey = gitHubRoutingConnectionKey(entry); + const connectionKey = pullRequestRoutingConnectionKey(entry); if (connectionKey === null) return Effect.fail( new ConnectionBlockedError({ diff --git a/packages/client-runtime/src/connection/registry.test.ts b/packages/client-runtime/src/connection/registry.test.ts index b9f58c63346f..0d0327c53af2 100644 --- a/packages/client-runtime/src/connection/registry.test.ts +++ b/packages/client-runtime/src/connection/registry.test.ts @@ -57,10 +57,10 @@ import * as Persistence from "../platform/persistence.ts"; import * as ConnectionProfileStore from "./profileStore.ts"; import * as EnvironmentRegistry from "./registry.ts"; import { - GitHubRoutingPermissions, - type StoredGitHubRoutingPermission, - makeGitHubRoutingPermissions, -} from "./githubRoutingPermissions.ts"; + PullRequestRoutingPermissions, + type StoredPullRequestRoutingPermission, + makePullRequestRoutingPermissions, +} from "./pullRequestRoutingPermissions.ts"; import * as RpcSession from "../rpc/session.ts"; import * as EnvironmentSupervisor from "./supervisor.ts"; import * as ConnectionWakeups from "./wakeups.ts"; @@ -1553,7 +1553,7 @@ describe("EnvironmentRegistry", () => { label: "Shadowed relay environment", }); const harness = yield* makeHarness([shadowedTarget]); - const permissions = yield* makeGitHubRoutingPermissions({ + const permissions = yield* makePullRequestRoutingPermissions({ read: Effect.succeed([]), write: () => Effect.void, }); @@ -1578,7 +1578,7 @@ describe("EnvironmentRegistry", () => { expect(hasRoutes(yield* Ref.get(harness.storedTargets), TARGET.environmentId)).toBe(false); }).pipe( Effect.provide(harness.layer), - Effect.provideService(GitHubRoutingPermissions, permissions), + Effect.provideService(PullRequestRoutingPermissions, permissions), Effect.scoped, ); }), @@ -1765,7 +1765,7 @@ describe("EnvironmentRegistry", () => { Effect.gen(function* () { const harness = yield* makeHarness([]); let failWrites = false; - const permissions = yield* makeGitHubRoutingPermissions({ + const permissions = yield* makePullRequestRoutingPermissions({ read: Effect.succeed([]), write: () => failWrites @@ -1822,7 +1822,7 @@ describe("EnvironmentRegistry", () => { expect(yield* permissions.get(entry)).toBe("off"); }).pipe( Effect.provide(harness.layer), - Effect.provideService(GitHubRoutingPermissions, permissions), + Effect.provideService(PullRequestRoutingPermissions, permissions), Effect.scoped, ); }), @@ -2021,33 +2021,38 @@ describe("EnvironmentRegistry routes", () => { }), ); - it.effect("revokes GitHub routing trust when a route is added but keeps it on reorder", () => - Effect.gen(function* () { - const harness = yield* makeHarness([RELAY_TARGET]); - let stored: ReadonlyArray = []; - const permissions = yield* makeGitHubRoutingPermissions({ - read: Effect.sync(() => stored), - write: (next) => Effect.sync(() => void (stored = next)), - }); - yield* Effect.gen(function* () { - const registry = yield* EnvironmentRegistry.EnvironmentRegistry; - const current = () => - SubscriptionRef.get(registry.entries).pipe( - Effect.map((entries) => entries.get(RELAY_TARGET.environmentId)!), - ); - yield* permissions.set(yield* current(), "read"); - yield* registry.register(lanRegistration()); - expect(yield* permissions.get(yield* current())).toBe("off"); - - yield* permissions.set(yield* current(), "read"); - yield* registry.reorderRoutes(LAN_TARGET.environmentId, ["relay", LAN_TARGET.connectionId]); - expect(yield* permissions.get(yield* current())).toBe("read"); - }).pipe( - Effect.provide(harness.layer), - Effect.provideService(GitHubRoutingPermissions, permissions), - Effect.scoped, - ); - }), + it.effect( + "revokes pull request routing trust when a route is added but keeps it on reorder", + () => + Effect.gen(function* () { + const harness = yield* makeHarness([RELAY_TARGET]); + let stored: ReadonlyArray = []; + const permissions = yield* makePullRequestRoutingPermissions({ + read: Effect.sync(() => stored), + write: (next) => Effect.sync(() => void (stored = next)), + }); + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + const current = () => + SubscriptionRef.get(registry.entries).pipe( + Effect.map((entries) => entries.get(RELAY_TARGET.environmentId)!), + ); + yield* permissions.set(yield* current(), "read"); + yield* registry.register(lanRegistration()); + expect(yield* permissions.get(yield* current())).toBe("off"); + + yield* permissions.set(yield* current(), "read"); + yield* registry.reorderRoutes(LAN_TARGET.environmentId, [ + "relay", + LAN_TARGET.connectionId, + ]); + expect(yield* permissions.get(yield* current())).toBe("read"); + }).pipe( + Effect.provide(harness.layer), + Effect.provideService(PullRequestRoutingPermissions, permissions), + Effect.scoped, + ); + }), ); it.effect("loads saved routes grouped by environment, in saved order", () => diff --git a/packages/client-runtime/src/connection/registry.ts b/packages/client-runtime/src/connection/registry.ts index 76c49bc86eae..debd9d65e92a 100644 --- a/packages/client-runtime/src/connection/registry.ts +++ b/packages/client-runtime/src/connection/registry.ts @@ -40,9 +40,9 @@ import * as EnvironmentSupervisor from "./supervisor.ts"; import * as ConnectionDriver from "./driver.ts"; import * as ConnectionWakeups from "./wakeups.ts"; import { - GitHubRoutingPermissions, - gitHubRoutingConnectionKey, -} from "./githubRoutingPermissions.ts"; + PullRequestRoutingPermissions, + pullRequestRoutingConnectionKey, +} from "./pullRequestRoutingPermissions.ts"; import { RELAY_ROUTE_ID, connectionRouteId, @@ -208,7 +208,7 @@ export const make = Effect.gen(function* () { const ownedDataCleanup = yield* Persistence.EnvironmentOwnedDataCleanup; const profiles = yield* ConnectionProfileStore.ConnectionProfileStore; const credentials = yield* ConnectionCredentialStore.ConnectionCredentialStore; - const githubRoutingPermissions = yield* GitHubRoutingPermissions; + const pullRequestRoutingPermissions = yield* PullRequestRoutingPermissions; const connectivity = yield* Connectivity.Connectivity; const driver = yield* ConnectionDriver.ConnectionDriver; const wakeups = yield* ConnectionWakeups.ConnectionWakeups; @@ -508,7 +508,7 @@ export const make = Effect.gen(function* () { environmentId: EnvironmentId, operation: "register-connection" | "set-connection-routes", ) => - githubRoutingPermissions.forget(environmentId).pipe( + pullRequestRoutingPermissions.forget(environmentId).pipe( Effect.mapError( (error) => new Persistence.ConnectionPersistenceError({ @@ -522,12 +522,12 @@ export const make = Effect.gen(function* () { connectionRoutes(entry).map((route) => route.target as PersistedConnectionTarget); /** - * GitHub trust and an unsupported verdict both belong to the saved addresses: a changed set may + * Pull request routing trust and an unsupported verdict both belong to the saved addresses: a changed set may * reach a different server, so both are dropped and the next connection decides. */ const withRoutes = (previous: ConnectionCatalogEntry, routes: ReadonlyArray) => { const next = entryWithRoutes(previous, routes); - if (gitHubRoutingConnectionKey(previous) === gitHubRoutingConnectionKey(next)) { + if (pullRequestRoutingConnectionKey(previous) === pullRequestRoutingConnectionKey(next)) { return { entry: next, addressesChanged: false }; } const { unsupportedReason: _reason, serverUpdateRequired: _update, ...rest } = next; @@ -604,19 +604,20 @@ export const make = Effect.gen(function* () { const previous = (yield* SubscriptionRef.get(entries)).get(target.environmentId); const entry: ConnectionCatalogEntry = previous?.unsupportedReason !== undefined && - gitHubRoutingConnectionKey(previous) === gitHubRoutingConnectionKey(registered) + pullRequestRoutingConnectionKey(previous) === + pullRequestRoutingConnectionKey(registered) ? { ...registered, ...unsupportedState(previous) } : registered; const persisted = (yield* Ref.get(persistedEnvironmentIds)).has(target.environmentId); if ( persisted || (previous !== undefined && - gitHubRoutingConnectionKey(previous) !== gitHubRoutingConnectionKey(entry)) + pullRequestRoutingConnectionKey(previous) !== pullRequestRoutingConnectionKey(entry)) ) { - const revoked = yield* githubRoutingPermissions.forget(target.environmentId).pipe( + const revoked = yield* pullRequestRoutingPermissions.forget(target.environmentId).pipe( Effect.tapError((error) => Effect.logWarning( - "Could not clear GitHub routing permission for a platform environment.", + "Could not clear pull request routing permission for a platform environment.", { environmentId: target.environmentId, error, @@ -725,10 +726,10 @@ export const make = Effect.gen(function* () { environmentId, Effect.gen(function* () { const entry = (yield* SubscriptionRef.get(entries)).get(environmentId); - const revoked = yield* githubRoutingPermissions.forget(environmentId).pipe( + const revoked = yield* pullRequestRoutingPermissions.forget(environmentId).pipe( Effect.tapError((error) => Effect.logWarning( - "Could not clear GitHub routing permission after platform removal.", + "Could not clear pull request routing permission after platform removal.", { environmentId, error, @@ -811,7 +812,7 @@ export const make = Effect.gen(function* () { } const entry = yield* getEntry(environmentId); - yield* githubRoutingPermissions.forget(environmentId); + yield* pullRequestRoutingPermissions.forget(environmentId); yield* registrations.remove(environmentId); yield* Ref.update(persistedEnvironmentIds, (current) => { const next = new Set(current); @@ -867,7 +868,7 @@ export const make = Effect.gen(function* () { typeof globalThis.location === "undefined" || globalThis.location.protocol !== "https:"; /** - * Learned routes reuse the active route's credential, so they do not revoke GitHub routing + * Learned routes reuse the active route's credential, so they do not revoke pull request routing * trust the way a newly paired address does. */ const learnRoutes = Effect.fn("EnvironmentRegistry.learnRoutes")(function* (input: { diff --git a/packages/client-runtime/src/connection/resolver.test.ts b/packages/client-runtime/src/connection/resolver.test.ts index 849cacbab3a5..1a5fbffddb50 100644 --- a/packages/client-runtime/src/connection/resolver.test.ts +++ b/packages/client-runtime/src/connection/resolver.test.ts @@ -29,10 +29,10 @@ import { import * as ConnectionProfileStore from "./profileStore.ts"; import * as RpcHttp from "../rpc/http.ts"; import { - GitHubRoutingPermissions, - gitHubRoutingConnectionKey, - makeGitHubRoutingPermissions, -} from "./githubRoutingPermissions.ts"; + PullRequestRoutingPermissions, + pullRequestRoutingConnectionKey, + makePullRequestRoutingPermissions, +} from "./pullRequestRoutingPermissions.ts"; const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); const ENDPOINT = { @@ -275,11 +275,11 @@ describe("ConnectionResolver", () => { reason: "remote-unavailable", detail: "Could not persist routing consent.", }); - const permissions = yield* makeGitHubRoutingPermissions({ + const permissions = yield* makePullRequestRoutingPermissions({ read: Effect.succeed([ { environmentId: ENVIRONMENT_ID, - connectionKey: gitHubRoutingConnectionKey(entry)!, + connectionKey: pullRequestRoutingConnectionKey(entry)!, permission: "read-write", }, ]), @@ -328,7 +328,7 @@ describe("ConnectionResolver", () => { ); const prepare = broker .prepare(entry) - .pipe(Effect.provideService(GitHubRoutingPermissions, permissions)); + .pipe(Effect.provideService(PullRequestRoutingPermissions, permissions)); if (scenario === "revocation-failed") { expect(yield* Effect.flip(prepare)).toBe(failure); expect(savedProfile).toBe(profile); diff --git a/packages/client-runtime/src/connection/resolver.ts b/packages/client-runtime/src/connection/resolver.ts index cfcf719d9bf9..647fbab1dba5 100644 --- a/packages/client-runtime/src/connection/resolver.ts +++ b/packages/client-runtime/src/connection/resolver.ts @@ -31,9 +31,9 @@ import { profileMissingError, } from "./errors.ts"; import { - GitHubRoutingPermissions, - gitHubRoutingConnectionKey, -} from "./githubRoutingPermissions.ts"; + PullRequestRoutingPermissions, + pullRequestRoutingConnectionKey, +} from "./pullRequestRoutingPermissions.ts"; import type { BearerConnectionTarget, ConnectionTarget, @@ -250,10 +250,10 @@ const makeSshBroker = Effect.fn("clientRuntime.connection.broker.makeSsh")(funct target: prepared.bootstrap.target, }); if ( - gitHubRoutingConnectionKey(entry) !== - gitHubRoutingConnectionKey({ ...entry, profile: Option.some(preparedProfile) }) + pullRequestRoutingConnectionKey(entry) !== + pullRequestRoutingConnectionKey({ ...entry, profile: Option.some(preparedProfile) }) ) { - const permissions = yield* GitHubRoutingPermissions; + const permissions = yield* PullRequestRoutingPermissions; yield* permissions.forget(target.environmentId); } yield* profiles.put(preparedProfile); diff --git a/packages/client-runtime/src/connection/routes.test.ts b/packages/client-runtime/src/connection/routes.test.ts index 7e93017334cf..44bca8a7a9ae 100644 --- a/packages/client-runtime/src/connection/routes.test.ts +++ b/packages/client-runtime/src/connection/routes.test.ts @@ -7,7 +7,7 @@ import { type ConnectionCatalogEntry, type ConnectionRoute, } from "./catalog.ts"; -import { gitHubRoutingConnectionKey } from "./githubRoutingPermissions.ts"; +import { pullRequestRoutingConnectionKey } from "./pullRequestRoutingPermissions.ts"; import { BearerConnectionTarget, RelayConnectionTarget } from "./model.ts"; import { connectionRouteId, @@ -347,7 +347,7 @@ describe("learned routes", () => { expect(removedWithRelay(entryWithRoutes(relayOnly, [LAN, RELAY]))).toBe(false); }); - it("keeps GitHub routing trust when a route is learned", () => { + it("keeps pull request routing trust when a route is learned", () => { const learned = mergeLearnedRoutes({ entry: relayOnly, activeRoute: RELAY, @@ -360,7 +360,9 @@ describe("learned routes", () => { profile: learned[0]!.profile, alternateRoutes: learned.slice(1), }; - expect(gitHubRoutingConnectionKey(withLearned)).toBe(gitHubRoutingConnectionKey(relayOnly)); + expect(pullRequestRoutingConnectionKey(withLearned)).toBe( + pullRequestRoutingConnectionKey(relayOnly), + ); }); it("keeps the T3 Connect credential when learning over a learned T3 Connect route", () => { diff --git a/packages/client-runtime/src/platform/storageDocument.test.ts b/packages/client-runtime/src/platform/storageDocument.test.ts index 1ea0c9d7f1b3..60f561e3dae5 100644 --- a/packages/client-runtime/src/platform/storageDocument.test.ts +++ b/packages/client-runtime/src/platform/storageDocument.test.ts @@ -21,9 +21,9 @@ import { SshConnectionTarget, } from "../connection/model.ts"; import { - GitHubRoutingPermissions, - makeGitHubRoutingPermissions, -} from "../connection/githubRoutingPermissions.ts"; + PullRequestRoutingPermissions, + makePullRequestRoutingPermissions, +} from "../connection/pullRequestRoutingPermissions.ts"; import { ConnectionCatalogDocument, EMPTY_CONNECTION_CATALOG_DOCUMENT, @@ -87,10 +87,10 @@ describe("ConnectionCatalogDocument", () => { }).pipe(Effect.orDie); }), }; - const permissions = yield* makeGitHubRoutingPermissions(storage); + const permissions = yield* makePullRequestRoutingPermissions(storage); expect(yield* permissions.get(entry)).toBe("off"); yield* permissions.set(entry, "read"); - const restarted = yield* makeGitHubRoutingPermissions(storage); + const restarted = yield* makePullRequestRoutingPermissions(storage); expect(yield* restarted.get(entry)).toBe("read"); const changedEndpoint = { @@ -121,19 +121,19 @@ describe("ConnectionCatalogDocument", () => { expect(yield* restarted.get(entry)).toBe("read-write"); yield* restarted.forget(ENVIRONMENT_ID); expect(yield* restarted.get(entry)).toBe("off"); - const afterRemoval = yield* makeGitHubRoutingPermissions(storage); + const afterRemoval = yield* makePullRequestRoutingPermissions(storage); expect(yield* afterRemoval.get(entry)).toBe("off"); yield* permissions.set(entry, "read"); document = removeConnectionFromCatalog(document, ENVIRONMENT_ID); - const afterCatalogRemoval = yield* makeGitHubRoutingPermissions(storage); + const afterCatalogRemoval = yield* makePullRequestRoutingPermissions(storage); expect(yield* afterCatalogRemoval.get(entry)).toBe("off"); }), ); it.effect("requires explicit trust for primary, relay, and SSH connections independently", () => Effect.gen(function* () { - const permissions = yield* makeGitHubRoutingPermissions({ + const permissions = yield* makePullRequestRoutingPermissions({ read: Effect.succeed([]), write: () => Effect.void, }); @@ -179,8 +179,8 @@ describe("ConnectionCatalogDocument", () => { it.effect("does not enable GitHub routing when permission persistence fails", () => Effect.gen(function* () { const entry = { target: BEARER_TARGET, profile: Option.some(BEARER_PROFILE), enabled: true }; - expect(yield* (yield* GitHubRoutingPermissions).get(entry)).toBe("off"); - const permissions = yield* makeGitHubRoutingPermissions({ + expect(yield* (yield* PullRequestRoutingPermissions).get(entry)).toBe("off"); + const permissions = yield* makePullRequestRoutingPermissions({ read: Effect.succeed([]), write: () => Effect.fail( diff --git a/packages/client-runtime/src/platform/storageDocument.ts b/packages/client-runtime/src/platform/storageDocument.ts index 8fe8533b3c56..1af8e0e3d67a 100644 --- a/packages/client-runtime/src/platform/storageDocument.ts +++ b/packages/client-runtime/src/platform/storageDocument.ts @@ -9,7 +9,7 @@ import { } from "../connection/catalog.ts"; import { type ConnectionTarget, PersistedConnectionTarget } from "../connection/model.ts"; import * as TokenStore from "../authorization/tokenStore.ts"; -import { StoredGitHubRoutingPermission } from "../connection/githubRoutingPermissions.ts"; +import { StoredPullRequestRoutingPermission } from "../connection/pullRequestRoutingPermissions.ts"; export const StoredConnectionCredential = Schema.Struct({ connectionId: Schema.String, @@ -23,7 +23,9 @@ export const ConnectionCatalogDocument = Schema.Struct({ profiles: Schema.Array(ConnectionProfile), credentials: Schema.Array(StoredConnectionCredential), remoteDpopTokens: Schema.Array(TokenStore.RemoteDpopAccessToken), - githubRoutingPermissions: Schema.optionalKey(Schema.Array(StoredGitHubRoutingPermission)), + // Pull request routing grants. The field name predates the rename and stays, so older and + // newer clients sharing this document keep reading and revoking the same grants. + githubRoutingPermissions: Schema.optionalKey(Schema.Array(StoredPullRequestRoutingPermission)), disabledEnvironmentIds: Schema.Array(EnvironmentId).pipe( Schema.withDecodingDefaultKey(Effect.succeed([])), ), diff --git a/packages/client-runtime/src/sourceControlClients.ts b/packages/client-runtime/src/sourceControlClients.ts index 87da337cdee8..ade5732df681 100644 --- a/packages/client-runtime/src/sourceControlClients.ts +++ b/packages/client-runtime/src/sourceControlClients.ts @@ -1,18 +1,24 @@ import * as AzureDevOps from "@t3tools/source-control-azure-devops/client/definition"; import * as Bitbucket from "@t3tools/source-control-bitbucket/client/definition"; import { + HostCredentialSettings, makeSourceControlClientRegistry, readSourceControlHostSettings, } from "@t3tools/source-control-core/client/definition"; import type { ServerSettings } from "@t3tools/contracts"; export { + isMonochromeSourceControlIcon, UNKNOWN_SOURCE_CONTROL_CLIENT, type ChangeRequestLink, type ChangeRequestTerminology, + type HostAccountChoice, + type HostCredentialSettings, type ParsedChangeRequestUrl, type ReferenceAutolink, type SourceControlClientDefinition, + type SourceControlHostCredentials, + type SourceControlIcon, } from "@t3tools/source-control-core/client/definition"; import * as Forgejo from "@t3tools/source-control-forgejo/client/definition"; import * as GitCafe from "@t3tools/source-control-gitcafe/client/definition"; @@ -51,8 +57,10 @@ export const sourceControlClients = makeSourceControlClientRegistry( }, ); -export type { GitHubSettings } from "@t3tools/source-control-github/client/definition"; - -export function readGitHubSettings(settings: Pick) { - return readSourceControlHostSettings(GitHub.settings, settings.sourceControlHosts.github); +/** The saved per-hostname accounts and tokens of a host whose definition has `credentials`. */ +export function readHostCredentialSettings( + settings: Pick, + kind: string, +): HostCredentialSettings { + return readSourceControlHostSettings(HostCredentialSettings, settings.sourceControlHosts[kind]); } diff --git a/packages/client-runtime/src/state/connections.ts b/packages/client-runtime/src/state/connections.ts index 686bcfd83154..779bbd054ad3 100644 --- a/packages/client-runtime/src/state/connections.ts +++ b/packages/client-runtime/src/state/connections.ts @@ -10,10 +10,10 @@ import type { ConnectionCatalogEntry } from "../connection/catalog.ts"; import { AVAILABLE_CONNECTION_STATE } from "../connection/model.ts"; import * as EnvironmentSupervisor from "../connection/supervisor.ts"; import { - GitHubRoutingPermissions, - type GitHubRoutingPermission, - type StoredGitHubRoutingPermission, -} from "../connection/githubRoutingPermissions.ts"; + PullRequestRoutingPermissions, + type PullRequestRoutingPermission, + type StoredPullRequestRoutingPermission, +} from "../connection/pullRequestRoutingPermissions.ts"; import { createAtomCommandScheduler, createRuntimeCommand, @@ -65,20 +65,22 @@ export function createEnvironmentCatalogAtoms( Option.getOrElse(AsyncResult.value(get(catalogAtom)), () => EMPTY_ENVIRONMENT_CATALOG_STATE), ).pipe(Atom.withLabel("environment-catalog-value")); - const githubRoutingPermissionsAtom = runtime.atom( - Stream.unwrap(GitHubRoutingPermissions.pipe(Effect.map((permissions) => permissions.changes))), - { initialValue: [] as ReadonlyArray }, + const pullRequestRoutingPermissionsAtom = runtime.atom( + Stream.unwrap( + PullRequestRoutingPermissions.pipe(Effect.map((permissions) => permissions.changes)), + ), + { initialValue: [] as ReadonlyArray }, ); - const githubRoutingPermissionsValueAtom = Atom.make((get) => - Option.getOrElse(AsyncResult.value(get(githubRoutingPermissionsAtom)), () => []), - ).pipe(Atom.withLabel("environment-github-routing-permissions")); - const setGitHubRoutingPermission = createRuntimeCommand(runtime, { - label: "environment-catalog:github-routing-permission", + const pullRequestRoutingPermissionsValueAtom = Atom.make((get) => + Option.getOrElse(AsyncResult.value(get(pullRequestRoutingPermissionsAtom)), () => []), + ).pipe(Atom.withLabel("environment-pull-request-routing-permissions")); + const setPullRequestRoutingPermission = createRuntimeCommand(runtime, { + label: "environment-catalog:pull-request-routing-permission", scheduler: commandScheduler, concurrency: serial, execute: Effect.fn(function* (input: { readonly environmentId: EnvironmentIdType; - readonly permission: GitHubRoutingPermission; + readonly permission: PullRequestRoutingPermission; }) { const registry = yield* EnvironmentRegistry.EnvironmentRegistry; const entry = (yield* SubscriptionRef.get(registry.entries)).get(input.environmentId); @@ -86,7 +88,7 @@ export function createEnvironmentCatalogAtoms( return yield* new EnvironmentRegistry.EnvironmentNotRegisteredError({ environmentId: input.environmentId, }); - const permissions = yield* GitHubRoutingPermissions; + const permissions = yield* PullRequestRoutingPermissions; yield* permissions.set(entry, input.permission); }), }); @@ -190,8 +192,8 @@ export function createEnvironmentCatalogAtoms( return { catalogAtom, catalogValueAtom, - githubRoutingPermissionsValueAtom, - setGitHubRoutingPermission, + pullRequestRoutingPermissionsValueAtom, + setPullRequestRoutingPermission, networkStatusAtom, networkStatusValueAtom, stateAtom, diff --git a/packages/client-runtime/src/state/pullRequestRouting.ts b/packages/client-runtime/src/state/pullRequestRouting.ts index a6cd89ee8643..ef146fb1f30f 100644 --- a/packages/client-runtime/src/state/pullRequestRouting.ts +++ b/packages/client-runtime/src/state/pullRequestRouting.ts @@ -18,9 +18,9 @@ import * as SubscriptionRef from "effect/SubscriptionRef"; import * as EnvironmentRegistry from "../connection/registry.ts"; import * as EnvironmentSupervisor from "../connection/supervisor.ts"; import { - GitHubRoutingPermissions, - gitHubRoutingConnectionKey, -} from "../connection/githubRoutingPermissions.ts"; + PullRequestRoutingPermissions, + pullRequestRoutingConnectionKey, +} from "../connection/pullRequestRoutingPermissions.ts"; import type { ConnectionCatalogEntry } from "../connection/catalog.ts"; import * as ConnectionProfileStore from "../connection/profileStore.ts"; import type { RpcSession } from "../rpc/session.ts"; @@ -171,7 +171,7 @@ const routingAllowed = Effect.fn("PullRequestRouting.allowed")(function* ( destination.unsupportedReason !== undefined ) return false; - const permissions = yield* GitHubRoutingPermissions; + const permissions = yield* PullRequestRoutingPermissions; const source = yield* permissions.get(origin); const target = yield* permissions.get(destination); const allowed = write @@ -185,8 +185,9 @@ const routingAllowed = Effect.fn("PullRequestRouting.allowed")(function* ( const profile = yield* profiles.value .get(entry.target.connectionId) .pipe(Effect.orElseSucceed(() => Option.none())); - const key = gitHubRoutingConnectionKey(entry); - if (key === null || key !== gitHubRoutingConnectionKey({ ...entry, profile })) return false; + const key = pullRequestRoutingConnectionKey(entry); + if (key === null || key !== pullRequestRoutingConnectionKey({ ...entry, profile })) + return false; } return true; }); diff --git a/packages/client-runtime/src/state/pullRequests.test.ts b/packages/client-runtime/src/state/pullRequests.test.ts index 74493ff95fbc..474ef49de53d 100644 --- a/packages/client-runtime/src/state/pullRequests.test.ts +++ b/packages/client-runtime/src/state/pullRequests.test.ts @@ -44,7 +44,7 @@ import { import * as PullRequestDiffLoader from "./pullRequestDiffHttp.ts"; import { executeAtomQuery } from "./runtime.ts"; import { createPullRequestRouter } from "./pullRequestRouting.ts"; -import { GitHubRoutingPermissions } from "../connection/githubRoutingPermissions.ts"; +import { PullRequestRoutingPermissions } from "../connection/pullRequestRoutingPermissions.ts"; const trustedRouting = { get: () => Effect.succeed("read-write" as const), @@ -198,7 +198,7 @@ for (const scenario of [ ).pipe( Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }), - Effect.provideService(GitHubRoutingPermissions, { + Effect.provideService(PullRequestRoutingPermissions, { ...trustedRouting, get: () => Effect.succeed(trusted ? "read-write" : "off"), }), @@ -283,7 +283,7 @@ it.effect.each([ }).pipe( Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), - Effect.provideService(GitHubRoutingPermissions, trustedRouting), + Effect.provideService(PullRequestRoutingPermissions, trustedRouting), ); const first = yield* read().pipe(Effect.forkChild); yield* Deferred.await(started); @@ -347,7 +347,7 @@ it.effect.each(["revoked", "disabled"] as const)( ).pipe( Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), - Effect.provideService(GitHubRoutingPermissions, { + Effect.provideService(PullRequestRoutingPermissions, { ...trustedRouting, get: () => Effect.sync(() => (trusted ? "read-write" : "off")), }), @@ -415,7 +415,7 @@ it.effect.each(["origin", "alternate"] as const)( }).pipe( Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), - Effect.provideService(GitHubRoutingPermissions, trustedRouting), + Effect.provideService(PullRequestRoutingPermissions, trustedRouting), ); const first = yield* read().pipe(Effect.forkChild); yield* Deferred.await(started); @@ -479,7 +479,7 @@ it.effect("forgets routed viewed marks when a viewed-files batch fails", () => ); }).pipe( Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), - Effect.provideService(GitHubRoutingPermissions, trustedRouting), + Effect.provideService(PullRequestRoutingPermissions, trustedRouting), Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }), Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), ); @@ -678,7 +678,7 @@ it.effect.each([ ? request : request.pipe( Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }), - Effect.provideService(GitHubRoutingPermissions, { + Effect.provideService(PullRequestRoutingPermissions, { ...trustedRouting, get: (entry) => Effect.succeed( @@ -797,7 +797,7 @@ it.effect.each( }).pipe( Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), - Effect.provideService(GitHubRoutingPermissions, trustedRouting), + Effect.provideService(PullRequestRoutingPermissions, trustedRouting), Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }), ); yield* stored === "unavailable" @@ -860,7 +860,7 @@ it.live.each([undefined, true, false])( number: 7, }).pipe( Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), - Effect.provideService(GitHubRoutingPermissions, trustedRouting), + Effect.provideService(PullRequestRoutingPermissions, trustedRouting), Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }), Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), ); @@ -956,7 +956,7 @@ it.live( expect(invalidations).toContainEqual({ environment: "local", input: {} }); }).pipe( Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), - Effect.provideService(GitHubRoutingPermissions, trustedRouting), + Effect.provideService(PullRequestRoutingPermissions, trustedRouting), Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }), Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), ); @@ -1504,7 +1504,7 @@ it.effect("denies a routed write when only the origin has source-control permiss }).pipe( Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, fixture.environmentRegistry), Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, fixture.supervisor), - Effect.provideService(GitHubRoutingPermissions, trustedRouting), + Effect.provideService(PullRequestRoutingPermissions, trustedRouting), Effect.provideService(RpcPermissionGuard, { authorize: (id, method, input) => createCommandPermissions(fixture.runtime, method).authorize( diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 7923879541af..9f84636d12be 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -827,10 +827,6 @@ export const UsageLimitSourceConfig = Schema.Struct({ }); export type UsageLimitSourceConfig = typeof UsageLimitSourceConfig.Type; -export const GitHubHost = TrimmedNonEmptyString.pipe( - Schema.decodeTo(Schema.String, SchemaTransformation.toLowerCase()), -); - export const SourceControlHostSettings = Schema.Record(Schema.String, Schema.Unknown); export type SourceControlHostSettings = typeof SourceControlHostSettings.Type; diff --git a/packages/source-control-azure-devops/src/client/definition.ts b/packages/source-control-azure-devops/src/client/definition.ts index bc203ef1dcf4..e209192a45a1 100644 --- a/packages/source-control-azure-devops/src/client/definition.ts +++ b/packages/source-control-azure-devops/src/client/definition.ts @@ -6,6 +6,7 @@ import { parseChangeRequestPath, remoteWebOrigin, } from "@t3tools/source-control-core/client/definition"; +import { icon } from "./icon.ts"; // Azure DevOps Server runs on any hostname; its `_git` route identifies it. const CHANGE_REQUEST_PATH = /^\/((?:[^/]+\/)*_git\/[^/]+)\/pullrequest\/(\d+)(?:\/|$)/u; @@ -66,7 +67,7 @@ export const definition = defineSourceControlClient({ kind: KIND, label: "Azure DevOps", pickerLabel: "Azure DevOps", - icon: "azure-devops", + icon, changeRequest: { shortLabel: "PR", singular: "pull request" }, repositoryPathHint: "project/repository", // `az repos` reads need the checkout's organization and project, so a bare hostname names no diff --git a/packages/source-control-azure-devops/src/client/icon.ts b/packages/source-control-azure-devops/src/client/icon.ts new file mode 100644 index 000000000000..58cfef61e4d8 --- /dev/null +++ b/packages/source-control-azure-devops/src/client/icon.ts @@ -0,0 +1,162 @@ +import type { SourceControlIcon } from "@t3tools/source-control-core/client/definition"; + +// Microsoft Azure DevOps mark via selfhst/icons (CC BY 4.0): https://github.com/selfhst/icons/blob/main/svg/azure-devops.svg +export const icon: SourceControlIcon = { + viewBox: "0 0 512 512", + gradients: [ + { + id: "a", + x1: 58.027, + y1: 356.668, + x2: 58.027, + y2: 100.668, + transform: "matrix(1 0 0 -1 0 514)", + stops: [ + { offset: 0, color: "#163697" }, + { offset: 0.276, color: "#2052cb" }, + { offset: 0.518, color: "#2764e7" }, + { offset: 0.879, color: "#367af2" }, + ], + }, + { + id: "b", + x1: 58.027, + y1: 351.334, + x2: 58.027, + y2: 292.668, + transform: "matrix(1 0 0 -1 0 514)", + stops: [ + { offset: 0, color: "#102784" }, + { offset: 1, color: "#2052cb", opacity: 0 }, + ], + }, + { + id: "c", + x1: 389.98, + y1: 57.935, + x2: 107.314, + y2: 193.935, + transform: "matrix(1 0 0 -1 0 514)", + stops: [ + { offset: 0, color: "#163697" }, + { offset: 0.301, color: "#2052cb" }, + { offset: 0.626, color: "#2764e7" }, + { offset: 0.926, color: "#367af2" }, + ], + }, + { + id: "d", + x1: 391.996, + y1: 76.552, + x2: 341.385, + y2: 78.224, + transform: "matrix(1 0 0 -1 0 514)", + stops: [ + { offset: 0, color: "#102784" }, + { offset: 1, color: "#2052cb", opacity: 0 }, + ], + }, + { + id: "e", + x1: 442.24, + y1: 54.769, + x2: 442.24, + y2: 419.38, + transform: "matrix(1 0 0 -1 0 514)", + stops: [ + { offset: 0.043, color: "#2052cb" }, + { offset: 0.489, color: "#367af2" }, + { offset: 0.943, color: "#16bbda" }, + ], + }, + { + id: "f", + x1: 435.596, + y1: 270.413, + x2: 425.732, + y2: 385.266, + transform: "matrix(1 0 0 -1 0 514)", + stops: [ + { offset: 0.134, color: "#16bbda", opacity: 0 }, + { offset: 0.932, color: "#6be7a0", opacity: 0.8629 }, + ], + }, + { + id: "g", + x1: 437.081, + y1: 204.362, + x2: 507.893, + y2: 119.081, + transform: "matrix(1 0 0 -1 0 514)", + stops: [ + { offset: 0, color: "#2c68e0", opacity: 0 }, + { offset: 0.898, color: "#66c0ff", opacity: 0.5 }, + ], + }, + { + id: "h", + x1: 99.399, + y1: 279.26, + x2: 352.75, + y2: 498.676, + transform: "matrix(1 0 0 -1 0 514)", + stops: [ + { offset: 0.005, color: "#367af2" }, + { offset: 0.507, color: "#0fafff" }, + { offset: 1, color: "#26cfe8" }, + ], + }, + { + id: "i", + x1: 184.342, + y1: 356.795, + x2: 338.581, + y2: 480.642, + transform: "matrix(1 0 0 -1 0 514)", + stops: [ + { offset: 0.443, color: "#26cfe8", opacity: 0 }, + { offset: 0.897, color: "#6be7a0", opacity: 0.8629 }, + ], + }, + ], + paths: [ + { + d: "M116.1 73.4c-5.8 5.8-13.7 13.8-13.7 22.6v216.4c0 8.9-3.3 17.3-9 23.4s-13.3 9.2-21.1 8.5l-42.7-3.1c-7.9-.5-15-3.9-20.3-9.3-5.7-5.7-9.3-13.7-9.3-22.6V202.7c0-8.9 3.6-16.9 9.4-22.6l2-1.9z", + fill: { gradient: "a" }, + }, + { + d: "M116.1 73.4c-5.8 5.8-13.7 13.8-13.7 22.6v216.4c0 8.9-3.3 17.3-9 23.4s-13.3 9.2-21.1 8.5l-42.7-3.1c-7.9-.5-15-3.9-20.3-9.3-5.7-5.7-9.3-13.7-9.3-22.6V202.7c0-8.9 3.6-16.9 9.4-22.6l2-1.9z", + fill: { gradient: "b" }, + }, + { + d: "m500.5 366-128 106.7c-5.5 4.6-12.7 7.4-20.5 7.4q-2.4 0-4.8-.3l-112.5-16.9v27.8c0 11.7-9.6 21.3-21.3 21.3-7.3 0-13.5-3.6-17.5-9.1l-36.2-51.4-5.4-7.6-36.6-51.9-7-10c-2.6-3.5-3.9-7.8-3.9-12.3 0-6 2.5-11.6 6.8-15.7 3.8-3.7 9.1-5.8 14.5-5.8.4 0 1 .1 1.5.1l105.2 7.5L384 366.5l93.7 6.7c.7.1 1.6.1 2.3.1 7.8 0 14.9-2.7 20.5-7.3", + fill: { gradient: "c" }, + }, + { + d: "m500.5 366-128 106.7c-5.5 4.6-12.7 7.4-20.5 7.4q-2.4 0-4.8-.3l-112.5-16.9v27.8c0 11.7-9.6 21.3-21.3 21.3-7.3 0-13.5-3.6-17.5-9.1l-36.2-51.4-5.4-7.6-36.6-51.9-7-10c-2.6-3.5-3.9-7.8-3.9-12.3 0-6 2.5-11.6 6.8-15.7 3.8-3.7 9.1-5.8 14.5-5.8.4 0 1 .1 1.5.1l105.2 7.5L384 366.5l93.7 6.7c.7.1 1.6.1 2.3.1 7.8 0 14.9-2.7 20.5-7.3", + fill: { gradient: "d" }, + }, + { + d: "M512 170.7v170.7c0 9.9-4.5 18.8-11.5 24.6l-128 106.7c7-5.9 11.5-14.7 11.5-24.6V175.3c0-16.7 13-30.7 29.8-31.9l63.9-4.6c.7-.1 1.6-.1 2.3-.1 7.8 0 14.9 2.8 20.5 7.4 7 5.8 11.5 14.6 11.5 24.6", + fill: { gradient: "e" }, + }, + { + d: "M512 170.7v170.7c0 9.9-4.5 18.8-11.5 24.6l-128 106.7c7-5.9 11.5-14.7 11.5-24.6V175.3c0-16.7 13-30.7 29.8-31.9l63.9-4.6c.7-.1 1.6-.1 2.3-.1 7.8 0 14.9 2.8 20.5 7.4 7 5.8 11.5 14.6 11.5 24.6", + fill: { gradient: "f" }, + fillOpacity: 0.7, + }, + { + d: "M512 170.7v170.7c0 9.9-4.5 18.8-11.5 24.6l-128 106.7c7-5.9 11.5-14.7 11.5-24.6V175.3c0-16.7 13-30.7 29.8-31.9l63.9-4.6c.7-.1 1.6-.1 2.3-.1 7.8 0 14.9 2.8 20.5 7.4 7 5.8 11.5 14.6 11.5 24.6", + fill: { gradient: "g" }, + }, + { + d: "M384 125.5c0 11.2-8.6 20.5-19.8 21.3-.5 0-1.1.1-1.6.1l-127.9 9.2-132.3 9.1-72.7 5.5c-6.9.4-13.2 3.1-18.2 7.4L116.1 73.4c4.7-4.7 10.9-8 17.8-9.1l100.8-15.1V21.3C234.7 9.6 244.3 0 256 0c5.8 0 11.1 2.3 14.9 6.1l33.4 32.6 16.9 16.5 40.3 39.3 16.1 15.8c4.1 3.9 6.4 9.4 6.4 15.2", + fill: { gradient: "h" }, + }, + { + d: "M384 125.5c0 11.2-8.6 20.5-19.8 21.3-.5 0-1.1.1-1.6.1l-127.9 9.2-132.3 9.1-72.7 5.5c-6.9.4-13.2 3.1-18.2 7.4L116.1 73.4c4.7-4.7 10.9-8 17.8-9.1l100.8-15.1V21.3C234.7 9.6 244.3 0 256 0c5.8 0 11.1 2.3 14.9 6.1l33.4 32.6 16.9 16.5 40.3 39.3 16.1 15.8c4.1 3.9 6.4 9.4 6.4 15.2", + fill: { gradient: "i" }, + fillOpacity: 0.5, + }, + ], +}; diff --git a/packages/source-control-bitbucket/src/client/definition.ts b/packages/source-control-bitbucket/src/client/definition.ts index 3ed6ddeaad85..b4540f4f3bd5 100644 --- a/packages/source-control-bitbucket/src/client/definition.ts +++ b/packages/source-control-bitbucket/src/client/definition.ts @@ -13,6 +13,7 @@ import { parseChangeRequestPath, projectChangeRequestHost, } from "@t3tools/source-control-core/client/definition"; +import { icon } from "./icon.ts"; const safeShellArgument = /^[A-Za-z0-9._/@+=,-]+$/; const repositoryName = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/; @@ -66,7 +67,7 @@ export const definition = defineSourceControlClient({ kind: KIND, label: "Bitbucket", pickerLabel: "Bitbucket", - icon: "bitbucket", + icon, changeRequest: { shortLabel: "PR", singular: "pull request" }, repositoryPathHint: "workspace/repository", publicHost: "bitbucket.org", diff --git a/packages/source-control-bitbucket/src/client/icon.ts b/packages/source-control-bitbucket/src/client/icon.ts new file mode 100644 index 000000000000..56902ec6148e --- /dev/null +++ b/packages/source-control-bitbucket/src/client/icon.ts @@ -0,0 +1,29 @@ +import type { SourceControlIcon } from "@t3tools/source-control-core/client/definition"; + +export const icon: SourceControlIcon = { + viewBox: "8.4 14.39 2481.29 2231.21", + gradients: [ + { + id: "a", + x1: 945.1094, + y1: 1524.8389, + x2: 944.4923, + y2: 1524.1893, + transform: "matrix(1996.6343 0 0 -1480.3047 -1884485.625 2258195)", + stops: [ + { offset: 0.18, color: "#0052CC" }, + { offset: 1, color: "#2684FF" }, + ], + }, + ], + paths: [ + { + d: "M88.92,14.4C45.02,13.83,8.97,48.96,8.41,92.86c-0.06,4.61,0.28,9.22,1.02,13.77l337.48,2048.72 c8.68,51.75,53.26,89.8,105.74,90.24h1619.03c39.38,0.5,73.19-27.9,79.49-66.78l337.49-2071.78c7.03-43.34-22.41-84.17-65.75-91.2 c-4.55-0.74-9.15-1.08-13.76-1.02L88.92,14.4z M1509.99,1495.09H993.24l-139.92-731h781.89L1509.99,1495.09z", + fill: "#2684FF", + }, + { + d: "M2379.27,763.06h-745.5l-125.12,730.42H992.31l-609.67,723.67c19.32,16.71,43.96,26,69.5,26.21h1618.13 c39.35,0.51,73.14-27.88,79.44-66.72L2379.27,763.06z", + fill: { gradient: "a" }, + }, + ], +}; diff --git a/packages/source-control-core/src/client/definition.ts b/packages/source-control-core/src/client/definition.ts index 53101b59955e..45dda11fedc6 100644 --- a/packages/source-control-core/src/client/definition.ts +++ b/packages/source-control-core/src/client/definition.ts @@ -2,11 +2,16 @@ import { type PullRequestAction, type PullRequestReviewVerdict, type RepositoryIdentity, + secretSetting, SourceControlProviderKind, type SourceControlProviderInfo, + TrimmedNonEmptyString, + TrimmedString, } from "@t3tools/contracts"; import { normalizeGitRemoteUrl, parseGitRemoteRepository } from "@t3tools/shared/git"; +import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; +import * as SchemaTransformation from "effect/SchemaTransformation"; /** * The two strings are what `changeRequestHost` and the project's `repositoryIdentity` produce @@ -94,11 +99,107 @@ export interface ReferenceAutolink { readonly kind: "reference" | "commit" | "mention"; } +/** + * A host's mark as data, so web and mobile draw the same artwork without React in the host's + * package. Paths only: a circle or rectangle is written as a path. + */ +export interface SourceControlIcon { + readonly viewBox: string; + /** Always in user space, as `gradientUnits="userSpaceOnUse"`. */ + readonly gradients?: ReadonlyArray; + readonly paths: ReadonlyArray; +} + +export interface SourceControlIconGradient { + /** Unique within the icon; renderers make it unique on the page. */ + readonly id: string; + readonly x1: number; + readonly y1: number; + readonly x2: number; + readonly y2: number; + readonly transform?: string; + readonly stops: ReadonlyArray<{ + readonly offset: number; + readonly color: string; + readonly opacity?: number; + }>; +} + +/** A colour, `currentColor` for a mark drawn in the text colour, or one of the icon's gradients. */ +export type SourceControlIconPaint = string | { readonly gradient: string }; + +export interface SourceControlIconPath { + readonly d: string; + /** Absent means unfilled. */ + readonly fill?: SourceControlIconPaint; + readonly fillRule?: "evenodd" | "nonzero"; + readonly fillOpacity?: number; + readonly stroke?: string; + readonly strokeWidth?: number; +} + +/** Whether the mark is drawn in the text colour alone, so a client may tint it. */ +export function isMonochromeSourceControlIcon(icon: SourceControlIcon): boolean { + return icon.paths.every( + (path) => + (path.fill === undefined || path.fill === "currentColor") && + (path.stroke === undefined || path.stroke === "currentColor"), + ); +} + +/** + * For a host whose server signs in per hostname, with a saved token or one of a CLI's logins. + * Its `settings` include `hostCredentialSettingsFields`, and clients show controls for those + * instead of the generic settings form. + */ +export interface SourceControlHostCredentials { + /** The command name, as `gh`. */ + readonly cliName: string; + /** The CLI's product name, as `GitHub CLI`. */ + readonly cliLabel: string; + readonly loginCommand: string; + /** The variable the server reads a token from, which a saved token takes precedence over. */ + readonly tokenEnvVar: string; + /** What a token must be granted, completing "Give it …". */ + readonly tokenAccess: string; + readonly newTokenUrl: (host: string) => string; +} + +export const SourceControlHostName = TrimmedNonEmptyString.pipe( + Schema.decodeTo(Schema.String, SchemaTransformation.toLowerCase()), +); + +export const HostAccountChoice = Schema.Struct({ + account: Schema.optionalKey(TrimmedNonEmptyString), + enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), +}); +export type HostAccountChoice = typeof HostAccountChoice.Type; + +/** Per-hostname settings of a host with `credentials`, keyed by lower-case hostname. */ +export const hostCredentialSettingsFields = { + hosts: Schema.Record(SourceControlHostName, HostAccountChoice).pipe( + Schema.withDecodingDefault(Effect.succeed({})), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + tokens: Schema.Record(SourceControlHostName, TrimmedString).pipe( + Schema.withDecodingDefault(Effect.succeed({})), + secretSetting, + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), +}; + +export const HostCredentialSettings = Schema.Struct(hostCredentialSettingsFields); +export type HostCredentialSettings = typeof HostCredentialSettings.Type; + export interface SourceControlClientDefinition { readonly kind: SourceControlProviderKind; readonly label: string; readonly pickerLabel: string; - readonly icon: string; + /** Null for a host without a mark of its own; clients draw a generic change request glyph. */ + readonly icon: SourceControlIcon | null; + /** Further words settings search finds this host by, besides its label. */ + readonly searchKeywords?: ReadonlyArray; + readonly credentials?: SourceControlHostCredentials; readonly changeRequest: ChangeRequestTerminology; readonly repositoryPathHint: string; readonly publicHost: string | null; @@ -363,7 +464,7 @@ export const UNKNOWN_SOURCE_CONTROL_CLIENT: SourceControlClientDefinition = { kind: SourceControlProviderKind.make("unknown"), label: "source control", pickerLabel: "source control", - icon: "change-request", + icon: null, changeRequest: { shortLabel: "change request", singular: "change request" }, repositoryPathHint: "URL", publicHost: null, diff --git a/packages/source-control-forgejo/src/client/definition.ts b/packages/source-control-forgejo/src/client/definition.ts index ea2759a61b73..949cc9d04435 100644 --- a/packages/source-control-forgejo/src/client/definition.ts +++ b/packages/source-control-forgejo/src/client/definition.ts @@ -11,6 +11,7 @@ import { projectChangeRequestHost, remoteWebOrigin, } from "@t3tools/source-control-core/client/definition"; +import { icon } from "./icon.ts"; const CHECKOUT_COMMAND = /^tea\s+(?:pr|pulls)\s+checkout\s+(.+)$/i; const CHANGE_REQUEST_REFERENCE = @@ -54,7 +55,8 @@ export const definition = defineSourceControlClient({ kind: KIND, label: "Forgejo", pickerLabel: "Forgejo / Gitea", - icon: "forgejo", + icon, + searchKeywords: ["gitea", "tea", "codeberg"], changeRequest: { shortLabel: "PR", singular: "pull request" }, repositoryPathHint: "owner/repo", // Codeberg is the public Forgejo; any other server is self-hosted. diff --git a/packages/source-control-forgejo/src/client/icon.ts b/packages/source-control-forgejo/src/client/icon.ts new file mode 100644 index 000000000000..61c5ee88ba7f --- /dev/null +++ b/packages/source-control-forgejo/src/client/icon.ts @@ -0,0 +1,13 @@ +import type { SourceControlIcon } from "@t3tools/source-control-core/client/definition"; + +// The view box starts at -6 to move the mark 6 units in, as the published artwork's group does. +export const icon: SourceControlIcon = { + viewBox: "-6 -6 212 212", + paths: [ + { d: "M58 168 v-98 a50 50 0 0 1 50-50 h20", stroke: "#ff6600", strokeWidth: 25 }, + { d: "M58 168 v-30 a50 50 0 0 1 50-50 h20", stroke: "#d40000", strokeWidth: 25 }, + { d: "M124 20 a18 18 0 1 0 36 0 a18 18 0 1 0 -36 0z", stroke: "#ff6600", strokeWidth: 15 }, + { d: "M124 88 a18 18 0 1 0 36 0 a18 18 0 1 0 -36 0z", stroke: "#d40000", strokeWidth: 15 }, + { d: "M40 180 a18 18 0 1 0 36 0 a18 18 0 1 0 -36 0z", stroke: "#d40000", strokeWidth: 15 }, + ], +}; diff --git a/packages/source-control-gitcafe/src/client/definition.ts b/packages/source-control-gitcafe/src/client/definition.ts index 22c96ce70c82..a2617c2db91d 100644 --- a/packages/source-control-gitcafe/src/client/definition.ts +++ b/packages/source-control-gitcafe/src/client/definition.ts @@ -14,6 +14,7 @@ import { parseChangeRequestPath, projectChangeRequestHost, } from "@t3tools/source-control-core/client/definition"; +import { icon } from "./icon.ts"; /** GitCafe has no self-hosted installs: production and staging are its only hosts. */ const GITCAFE_HOSTS = new Set(["git.cafe", "staging.git.cafe"]); @@ -41,7 +42,7 @@ export const definition = defineSourceControlClient({ kind: KIND, label: "GitCafe", pickerLabel: "GitCafe", - icon: "gitcafe", + icon, changeRequest: { shortLabel: "PR", singular: "pull request" }, repositoryPathHint: "owner/repo", publicHost: null, diff --git a/packages/source-control-gitcafe/src/client/icon.ts b/packages/source-control-gitcafe/src/client/icon.ts new file mode 100644 index 000000000000..97f38fbf41ee --- /dev/null +++ b/packages/source-control-gitcafe/src/client/icon.ts @@ -0,0 +1,6 @@ +import type { SourceControlIcon } from "@t3tools/source-control-core/client/definition"; + +export const icon: SourceControlIcon = { + viewBox: "0 0 64 64", + paths: [{ d: "M4.8 4.8h54.4v54.4H4.8z", fill: "#a78bfa" }], +}; diff --git a/packages/source-control-github/src/client/definition.ts b/packages/source-control-github/src/client/definition.ts index 1a45ab704cd5..e583b900be43 100644 --- a/packages/source-control-github/src/client/definition.ts +++ b/packages/source-control-github/src/client/definition.ts @@ -1,16 +1,8 @@ -import { - GitHubHost, - makeProviderSettingsSchema, - secretSetting, - SourceControlProviderKind, - TrimmedNonEmptyString, - TrimmedString, -} from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; -import * as Schema from "effect/Schema"; +import { makeProviderSettingsSchema, SourceControlProviderKind } from "@t3tools/contracts"; import { changeRequestUrlBelow, defineSourceControlClient, + hostCredentialSettingsFields, isChangeRequestInProjectRepository, isChangeRequestOnProjectHost, parseChangeRequestPath, @@ -19,6 +11,7 @@ import { } from "@t3tools/source-control-core/client/definition"; import { gitHubMediaUrl } from "./gitHubMedia.ts"; +import { icon } from "./icon.ts"; import { gitHubReferenceAutolinks } from "./referenceAutolinks.ts"; const CHECKOUT_COMMAND = /^gh\s+pr\s+checkout\s+(.+)$/i; @@ -49,30 +42,23 @@ function isRepositoryPath(repository: string): boolean { ); } -export const GitHubHostChoice = Schema.Struct({ - account: Schema.optionalKey(TrimmedNonEmptyString), - enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), -}); -export type GitHubHostChoice = typeof GitHubHostChoice.Type; - -export const settings = makeProviderSettingsSchema({ - hosts: Schema.Record(GitHubHost, GitHubHostChoice).pipe( - Schema.withDecodingDefault(Effect.succeed({})), - Schema.annotateKey({ providerSettingsForm: { hidden: true } }), - ), - tokens: Schema.Record(GitHubHost, TrimmedString).pipe( - Schema.withDecodingDefault(Effect.succeed({})), - secretSetting, - Schema.annotateKey({ providerSettingsForm: { hidden: true } }), - ), -}); +export const settings = makeProviderSettingsSchema(hostCredentialSettingsFields); export type GitHubSettings = typeof settings.Type; export const definition = defineSourceControlClient({ kind: KIND, label: "GitHub", pickerLabel: "GitHub", - icon: "github", + icon, + searchKeywords: ["ghes", "enterprise"], + credentials: { + cliName: "gh", + cliLabel: "GitHub CLI", + loginCommand: "gh auth login", + tokenEnvVar: "GH_TOKEN", + tokenAccess: "read and write access to pull requests and contents", + newTokenUrl: (host) => `https://${host}/settings/personal-access-tokens/new`, + }, changeRequest: { shortLabel: "PR", singular: "pull request" }, repositoryPathHint: "owner/repo", publicHost: "github.com", diff --git a/packages/source-control-github/src/client/icon.ts b/packages/source-control-github/src/client/icon.ts new file mode 100644 index 000000000000..45ee6149f12e --- /dev/null +++ b/packages/source-control-github/src/client/icon.ts @@ -0,0 +1,12 @@ +import type { SourceControlIcon } from "@t3tools/source-control-core/client/definition"; + +export const icon: SourceControlIcon = { + viewBox: "0 0 16 16", + paths: [ + { + d: "M8 0C3.58 0 0 3.58 0 8C0 11.54 2.29 14.53 5.47 15.59C5.87 15.66 6.02 15.42 6.02 15.21C6.02 15.02 6.01 14.39 6.01 13.72C4 14.09 3.48 13.23 3.32 12.78C3.23 12.55 2.84 11.84 2.5 11.65C2.22 11.5 1.82 11.13 2.49 11.12C3.12 11.11 3.57 11.7 3.72 11.94C4.44 13.15 5.59 12.81 6.05 12.6C6.12 12.08 6.33 11.73 6.56 11.53C4.78 11.33 2.92 10.64 2.92 7.58C2.92 6.71 3.23 5.99 3.74 5.43C3.66 5.23 3.38 4.41 3.82 3.31C3.82 3.31 4.49 3.1 6.02 4.13C6.66 3.95 7.34 3.86 8.02 3.86C8.7 3.86 9.38 3.95 10.02 4.13C11.55 3.09 12.22 3.31 12.22 3.31C12.66 4.41 12.38 5.23 12.3 5.43C12.81 5.99 13.12 6.7 13.12 7.58C13.12 10.65 11.25 11.33 9.47 11.53C9.76 11.78 10.01 12.26 10.01 13.01C10.01 14.08 10 14.94 10 15.21C10 15.42 10.15 15.67 10.55 15.59C13.71 14.53 16 11.53 16 8C16 3.58 12.42 0 8 0Z", + fill: "currentColor", + fillRule: "evenodd", + }, + ], +}; diff --git a/packages/source-control-gitlab/src/client/definition.ts b/packages/source-control-gitlab/src/client/definition.ts index ce1262f42fb8..c99b47559e92 100644 --- a/packages/source-control-gitlab/src/client/definition.ts +++ b/packages/source-control-gitlab/src/client/definition.ts @@ -7,6 +7,7 @@ import { parseChangeRequestPath, projectChangeRequestHost, } from "@t3tools/source-control-core/client/definition"; +import { icon } from "./icon.ts"; const CHECKOUT_COMMAND = /^glab\s+mr\s+checkout\s+(.+)$/i; const CHANGE_REQUEST_REFERENCE = @@ -21,7 +22,7 @@ export const definition = defineSourceControlClient({ kind: KIND, label: "GitLab", pickerLabel: "GitLab", - icon: "gitlab", + icon, changeRequest: { shortLabel: "MR", singular: "merge request" }, repositoryPathHint: "group/project", publicHost: "gitlab.com", diff --git a/packages/source-control-gitlab/src/client/icon.ts b/packages/source-control-gitlab/src/client/icon.ts new file mode 100644 index 000000000000..b825fac21984 --- /dev/null +++ b/packages/source-control-gitlab/src/client/icon.ts @@ -0,0 +1,23 @@ +import type { SourceControlIcon } from "@t3tools/source-control-core/client/definition"; + +export const icon: SourceControlIcon = { + viewBox: "0 0 32 32", + paths: [ + { + d: "m31.46 12.78-.04-.12-4.35-11.35A1.14 1.14 0 0 0 25.94.6c-.24 0-.47.1-.66.24-.19.15-.33.36-.39.6l-2.94 9h-11.9l-2.94-9A1.14 1.14 0 0 0 6.07.58a1.15 1.15 0 0 0-1.14.72L.58 12.68l-.05.11a8.1 8.1 0 0 0 2.68 9.34l.02.01.04.03 6.63 4.97 3.28 2.48 2 1.52a1.35 1.35 0 0 0 1.62 0l2-1.52 3.28-2.48 6.67-5h.02a8.09 8.09 0 0 0 2.7-9.36Z", + fill: "#E24329", + }, + { + d: "m31.46 12.78-.04-.12a14.75 14.75 0 0 0-5.86 2.64l-9.55 7.24 6.09 4.6 6.67-5h.02a8.09 8.09 0 0 0 2.67-9.36Z", + fill: "#FC6D26", + }, + { + d: "m9.9 27.14 3.28 2.48 2 1.52a1.35 1.35 0 0 0 1.62 0l2-1.52 3.28-2.48-6.1-4.6-6.07 4.6Z", + fill: "#FCA326", + }, + { + d: "M6.44 15.3a14.71 14.71 0 0 0-5.86-2.63l-.05.12a8.1 8.1 0 0 0 2.68 9.34l.02.01.04.03 6.63 4.97 6.1-4.6-9.56-7.24Z", + fill: "#FC6D26", + }, + ], +}; From 218e5c09dff92c7ad6380036411233e533a18904 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 24 Oct 2026 04:24:36 -0700 Subject: [PATCH 08/27] fix(clients): change requests use their host's own term (#23999) Co-authored-by: Claude Opus 5.5 --- .../src/components/ComposerContextSheet.tsx | 11 +- .../pull-requests/PullRequestControls.tsx | 10 +- .../pull-requests/PullRequestFiles.tsx | 15 ++- .../pull-requests/PullRequestListRow.tsx | 6 +- .../PullRequestMergeControls.tsx | 13 ++- .../pull-requests/PullRequestPresentation.tsx | 8 +- .../pull-requests/PullRequestReviewSheet.tsx | 4 +- .../PullRequestReviewersScreen.tsx | 8 +- .../pull-requests/PullRequestRouteScreen.tsx | 36 ++++-- .../pull-requests/PullRequestStatusRows.tsx | 34 +++--- .../guide/PullRequestGuideScreen.tsx | 6 +- .../pull-requests/pullRequestActions.test.ts | 20 +++- .../pull-requests/pullRequestActions.ts | 62 +++++++---- .../pullRequestAgentTasks.test.ts | 37 ++++-- .../pull-requests/pullRequestAgentTasks.ts | 31 ++++-- .../pullRequestDiffPages.test.ts | 23 +++- .../pull-requests/pullRequestDiffPages.ts | 4 +- .../features/pull-requests/pullRequestLink.ts | 6 +- .../pull-requests/pullRequestStack.test.ts | 32 ++++-- .../pull-requests/pullRequestStack.ts | 11 +- .../pull-requests/usePullRequestAction.ts | 3 +- .../pull-requests/usePullRequestDiff.ts | 10 +- .../usePullRequestListActions.ts | 105 ++++++++++-------- .../pull-requests/usePullRequestThreads.ts | 14 ++- .../threads/GitActionProgressOverlay.tsx | 8 +- .../threads/LinkedPullRequestList.tsx | 12 +- .../features/threads/ThreadGitControls.tsx | 13 ++- .../threads/ThreadPullRequestsSheet.tsx | 14 +-- .../features/threads/git/GitConfirmSheet.tsx | 9 +- .../features/threads/git/GitOverviewSheet.tsx | 17 ++- .../threads/git/gitSheetComponents.tsx | 7 +- .../new-task-context-presentation.test.ts | 17 +++ .../threads/new-task-context-presentation.ts | 4 +- .../threads/use-open-thread-pull-requests.ts | 6 +- .../threads/useLinkedPullRequestActions.ts | 36 ++++-- apps/mobile/src/lib/composerContext.test.ts | 19 ++++ apps/mobile/src/lib/composerContext.ts | 6 +- .../src/state/thread-pr-presentation.ts | 16 ++- .../src/state/use-thread-outbox-drain.ts | 10 +- apps/mobile/src/state/use-thread-pr.test.ts | 15 +++ .../BranchToolbarBranchSelector.tsx | 2 +- apps/web/src/components/CommandPalette.tsx | 2 +- .../GitActionsControl.logic.test.ts | 33 +++++- .../src/components/GitActionsControl.logic.ts | 57 +++------- apps/web/src/components/GitActionsControl.tsx | 3 +- .../components/PullRequestContextDetails.tsx | 12 +- .../components/PullRequestThreadDialog.tsx | 4 +- .../src/components/ThreadStatusIndicators.tsx | 12 +- .../components/chat/ThreadDetailsPrRow.tsx | 19 +++- .../pullRequest/PullRequestActionsMenu.tsx | 37 ++++-- .../PullRequestActivityUnavailableState.tsx | 5 +- .../pullRequest/PullRequestCodeTab.tsx | 13 ++- .../pullRequest/PullRequestCommentForm.tsx | 3 +- .../pullRequest/PullRequestComposer.tsx | 17 ++- .../pullRequest/PullRequestDetailActions.tsx | 5 +- .../pullRequest/PullRequestDetailCrumbs.tsx | 2 +- .../pullRequest/PullRequestDetailPanel.tsx | 76 +++++++------ .../PullRequestDiffUnavailableState.tsx | 8 +- .../pullRequest/PullRequestGhosts.tsx | 33 ++++-- .../pullRequest/PullRequestReviewForm.tsx | 14 ++- .../components/pullRequest/PullRequestRow.tsx | 3 +- .../pullRequest/PullRequestSpeedActions.tsx | 8 +- .../pullRequest/PullRequestStackMenu.tsx | 9 +- .../pullRequest/PullRequestSummaryTab.tsx | 19 +++- .../pullRequest/PullRequestThreadLinks.tsx | 9 +- .../pullRequest/PullRequestTimelineTab.tsx | 16 ++- .../pullRequest/PullRequestTitleEditor.tsx | 6 +- .../pullRequest/PullRequestsPage.tsx | 13 ++- .../pullRequest/ThreadPullRequestsPanel.tsx | 14 ++- .../pullRequest/pullRequestCheckoutToast.ts | 15 ++- .../pullRequestDetail.logic.test.ts | 49 +++++++- .../pullRequest/pullRequestDetail.logic.ts | 56 ++++++---- .../pullRequest/pullRequestDiffError.test.ts | 10 +- .../pullRequest/pullRequestDiffError.ts | 4 +- .../pullRequest/usePullRequestActions.ts | 65 ++++++----- .../src/lib/composerContextRecords.test.ts | 33 ++++++ apps/web/src/lib/composerContextRecords.ts | 11 +- .../src/pullRequestLinkInput.test.ts | 18 +++ .../src/pullRequestLinkInput.ts | 8 +- .../src/sourceControlClients.ts | 5 + .../src/state/gitActions.test.ts | 96 ++++++++++++++++ .../client-runtime/src/state/gitActions.ts | 56 ++++++---- .../src/work-log/presentation.test.ts | 13 +++ .../src/work-log/presentation.ts | 10 +- .../src/client/definition.ts | 7 +- .../src/client/definition.ts | 7 +- .../src/client/definition.ts | 10 +- .../src/client/definition.ts | 7 +- .../src/client/definition.ts | 7 +- .../src/client/definition.ts | 7 +- .../src/client/definition.ts | 7 +- 91 files changed, 1220 insertions(+), 453 deletions(-) create mode 100644 packages/client-runtime/src/state/gitActions.test.ts diff --git a/apps/mobile/src/components/ComposerContextSheet.tsx b/apps/mobile/src/components/ComposerContextSheet.tsx index 1cafc5814c54..598e3cf10d17 100644 --- a/apps/mobile/src/components/ComposerContextSheet.tsx +++ b/apps/mobile/src/components/ComposerContextSheet.tsx @@ -11,6 +11,7 @@ import type { ScopedThreadRef, } from "@t3tools/contracts"; import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { videoMimeType } from "@t3tools/shared/video"; import { Platform, Pressable, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -182,6 +183,10 @@ export function ComposerContextSheet(props: { record?.kind === "review-comment" && "pullRequest" in record ? record.pullRequest?.url : undefined; + const changeRequestTerm = ( + (pullRequestUrl ? sourceControlClients.findByChangeRequestUrl(pullRequestUrl) : undefined) ?? + sourceControlClients.get(undefined) + ).changeRequest.singular; const terminal = record?.kind === "terminal" && !("payload" in record) ? record : null; return ( @@ -354,12 +359,12 @@ export function ComposerContextSheet(props: { onPress={() => { props.onClose(); void openPullRequest(pullRequestUrl).catch(() => - Dialog.alert("Could not open pull request", "Try again when connected."), + Dialog.alert(`Could not open ${changeRequestTerm}`, "Try again when connected."), ); }} className="rounded-xl bg-subtle p-4" > - Open pull request + Open {changeRequestTerm} ) : null} {props.onOpenAttachment ? ( @@ -372,7 +377,7 @@ export function ComposerContextSheet(props: { ) : null} {props.onOpenPullRequest && !pullRequestUrl ? (
); + const term = sourceControlClients.get(detail.provider).changeRequest.singular; if (diffQuery.isPending && loadedSlices.length === 0) { - return withToolbar(); + return withToolbar(); } if (diffQuery.error && loadedSlices.length === 0) { @@ -1349,9 +1354,7 @@ function PullRequestCodeTab({ if (items.length === 0 && nextCursor === null) { return withToolbar(

- {commit === null - ? "This pull request has no file changes." - : "This commit has no file changes."} + {commit === null ? `This ${term} has no file changes.` : "This commit has no file changes."}

, ); } @@ -1477,7 +1480,7 @@ function PullRequestCodeTab({ {fileTreeOpen ? (