authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-09 14:49:59-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-09 14:49:59-07:00
log28f11ed7a5a2251ebe4689643b64425760ee5fe4
treec1daf589bc29a685baed1f06e6b2576b2265b3a7
parent0e7abea7c98060db537fe81f31ce5ba7c6d376d0
signature Signed by PGP key B5690EEEBB952194

refactor(source-control): add @t3tools/source-control-core (#17573)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

75 files changed, 2046 insertions(+), 1848 deletions(-)

apps/server/package.json+1
...@@ -45,6 +45,7 @@...@@ -45,6 +45,7 @@
45 "@t3tools/provider-muse": "workspace:*",45 "@t3tools/provider-muse": "workspace:*",
46 "@t3tools/provider-opencode": "workspace:*",46 "@t3tools/provider-opencode": "workspace:*",
47 "@t3tools/provider-pi": "workspace:*",47 "@t3tools/provider-pi": "workspace:*",
48 "@t3tools/source-control-core": "workspace:*",
48 "diff": "8.0.3",49 "diff": "8.0.3",
49 "effect": "catalog:",50 "effect": "catalog:",
50 "jose": "catalog:",51 "jose": "catalog:",
apps/server/scripts/evaluate-thread-titles.ts+2
...@@ -23,6 +23,7 @@ import {...@@ -23,6 +23,7 @@ import {
23} from "@t3tools/provider-core/server/threadTitleContext";23} from "@t3tools/provider-core/server/threadTitleContext";
24import * as ThreadTitleLinks from "../src/textGeneration/ThreadTitleLinks.ts";24import * as ThreadTitleLinks from "../src/textGeneration/ThreadTitleLinks.ts";
25import * as SourceControlProviderRegistry from "../src/sourceControl/SourceControlProviderRegistry.ts";25import * as SourceControlProviderRegistry from "../src/sourceControl/SourceControlProviderRegistry.ts";
26import * as ServerSourceControlHost from "../src/sourceControl/ServerSourceControlHost.ts";
26import * as GitHubApi from "../src/sourceControl/GitHubApi.ts";27import * as GitHubApi from "../src/sourceControl/GitHubApi.ts";
27import * as GitLabCli from "../src/sourceControl/GitLabCli.ts";28import * as GitLabCli from "../src/sourceControl/GitLabCli.ts";
28import * as ForgejoCli from "../src/sourceControl/ForgejoCli.ts";29import * as ForgejoCli from "../src/sourceControl/ForgejoCli.ts";
...@@ -158,6 +159,7 @@ await Effect.runPromise(...@@ -158,6 +159,7 @@ await Effect.runPromise(
158 AzureDevOpsCli.layer,159 AzureDevOpsCli.layer,
159 // No saved credentials here; Bitbucket falls back to T3CODE_BITBUCKET_* variables.160 // No saved credentials here; Bitbucket falls back to T3CODE_BITBUCKET_* variables.
160 BitbucketApi.layer,161 BitbucketApi.layer,
162 ServerSourceControlHost.layer,
161 ),163 ),
162 ),164 ),
163 // Default settings: no saved Bitbucket token, gh's own GitHub account choice.165 // Default settings: no saved Bitbucket token, gh's own GitHub account choice.
apps/server/scripts/measure-pr-preview.ts+1-1
...@@ -16,7 +16,7 @@ import * as GitHubApi from "../src/sourceControl/GitHubApi.ts";...@@ -16,7 +16,7 @@ import * as GitHubApi from "../src/sourceControl/GitHubApi.ts";
16import * as GitHubCredentials from "../src/sourceControl/GitHubCredentials.ts";16import * as GitHubCredentials from "../src/sourceControl/GitHubCredentials.ts";
17import * as ServerSettings from "../src/serverSettings.ts";17import * as ServerSettings from "../src/serverSettings.ts";
18import * as GitHubQuota from "../src/sourceControl/githubQuota.ts";18import * as GitHubQuota from "../src/sourceControl/githubQuota.ts";
19import * as SourceControlRateLimit from "../src/sourceControl/SourceControlRateLimit.ts";19import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit";
20import * as VcsProcess from "../src/vcs/VcsProcess.ts";20import * as VcsProcess from "../src/vcs/VcsProcess.ts";
2121
22const [repository, ...numbers] = process.argv.slice(2);22const [repository, ...numbers] = process.argv.slice(2);
apps/server/src/git/GitManager.test.ts+1-1
...@@ -58,7 +58,7 @@ import {...@@ -58,7 +58,7 @@ import {
58 ForgejoPullRequestSchema,58 ForgejoPullRequestSchema,
59 toForgejoChangeRequest,59 toForgejoChangeRequest,
60} from "../sourceControl/forgejoPullRequests.ts";60} from "../sourceControl/forgejoPullRequests.ts";
61import type { SourceControlProvider } from "../sourceControl/SourceControlProvider.ts";61import type { SourceControlProvider } from "@t3tools/source-control-core/server/SourceControlProvider";
62import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";62import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
63import * as ServerConfig from "../config.ts";63import * as ServerConfig from "../config.ts";
64import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts";64import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts";
apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts+1-1
...@@ -31,7 +31,7 @@ import * as Queue from "effect/Queue";...@@ -31,7 +31,7 @@ import * as Queue from "effect/Queue";
31import * as Ref from "effect/Ref";31import * as Ref from "effect/Ref";
32import { TestClock } from "effect/testing";32import { TestClock } from "effect/testing";
3333
34import { PullRequestProviderError } from "../pullRequest/PullRequestProvider.ts";34import { PullRequestProviderError } from "@t3tools/source-control-core/server/PullRequestProvider";
35import * as GitManager from "../git/GitManager.ts";35import * as GitManager from "../git/GitManager.ts";
36import * as PullRequestService from "../pullRequest/PullRequestService.ts";36import * as PullRequestService from "../pullRequest/PullRequestService.ts";
37import * as ServerActivation from "../serverActivation.ts";37import * as ServerActivation from "../serverActivation.ts";
apps/server/src/orchestration-v2/PullRequestSyncReactor.ts+1-1
...@@ -29,7 +29,7 @@ import * as Semaphore from "effect/Semaphore";...@@ -29,7 +29,7 @@ import * as Semaphore from "effect/Semaphore";
29import * as Stream from "effect/Stream";29import * as Stream from "effect/Stream";
3030
31import * as GitManager from "../git/GitManager.ts";31import * as GitManager from "../git/GitManager.ts";
32import { PullRequestProviderError } from "../pullRequest/PullRequestProvider.ts";32import { PullRequestProviderError } from "@t3tools/source-control-core/server/PullRequestProvider";
33import * as PullRequestService from "../pullRequest/PullRequestService.ts";33import * as PullRequestService from "../pullRequest/PullRequestService.ts";
34import { forkParked } from "../serverActivation.ts";34import { forkParked } from "../serverActivation.ts";
35import * as Orchestrator from "./Orchestrator.ts";35import * as Orchestrator from "./Orchestrator.ts";
apps/server/src/orchestration-v2/PullRequestWatchReactor.ts+1-1
...@@ -29,7 +29,7 @@ import type * as Scope from "effect/Scope";...@@ -29,7 +29,7 @@ import type * as Scope from "effect/Scope";
29import {29import {
30 type ProviderChangeRequestWatchFingerprint,30 type ProviderChangeRequestWatchFingerprint,
31 PullRequestProviderError,31 PullRequestProviderError,
32} from "../pullRequest/PullRequestProvider.ts";32} from "@t3tools/source-control-core/server/PullRequestProvider";
33import * as PullRequestService from "../pullRequest/PullRequestService.ts";33import * as PullRequestService from "../pullRequest/PullRequestService.ts";
34import { forkParked } from "../serverActivation.ts";34import { forkParked } from "../serverActivation.ts";
35import * as Orchestrator from "./Orchestrator.ts";35import * as Orchestrator from "./Orchestrator.ts";
apps/server/src/orchestration-v2/runtimeLayer.test.ts+1-1
...@@ -65,7 +65,7 @@ import * as ProviderRuntimeRecoveryService from "./ProviderRuntimeRecoveryServic...@@ -65,7 +65,7 @@ import * as ProviderRuntimeRecoveryService from "./ProviderRuntimeRecoveryServic
65import * as ProjectionMaintenance from "./ProjectionMaintenance.ts";65import * as ProjectionMaintenance from "./ProjectionMaintenance.ts";
66import * as ProjectionStore from "./ProjectionStore.ts";66import * as ProjectionStore from "./ProjectionStore.ts";
67import * as PullRequestWatchReactor from "./PullRequestWatchReactor.ts";67import * as PullRequestWatchReactor from "./PullRequestWatchReactor.ts";
68import { PullRequestProviderError } from "../pullRequest/PullRequestProvider.ts";68import { PullRequestProviderError } from "@t3tools/source-control-core/server/PullRequestProvider";
69import * as PullRequestService from "../pullRequest/PullRequestService.ts";69import * as PullRequestService from "../pullRequest/PullRequestService.ts";
70import * as ProjectStore from "./ProjectStore.ts";70import * as ProjectStore from "./ProjectStore.ts";
71import * as ProviderSessionManager from "./ProviderSessionManager.ts";71import * as ProviderSessionManager from "./ProviderSessionManager.ts";
apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts+1-1
...@@ -26,7 +26,7 @@ import {...@@ -26,7 +26,7 @@ import {
26 type AzureDevOpsPullRequest,26 type AzureDevOpsPullRequest,
27 type AzureDevOpsRepositoryLocation,27 type AzureDevOpsRepositoryLocation,
28} from "./azureDevOpsPullRequestJson.ts";28} from "./azureDevOpsPullRequestJson.ts";
29import type { ProviderListCursor } from "./PullRequestProvider.ts";29import type { ProviderListCursor } from "@t3tools/source-control-core/server/PullRequestProvider";
3030
31/**31/**
32 * Names the read that produced unusable output, so a failure reports the call it came from32 * Names the read that produced unusable output, so a failure reports the call it came from
apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts+1-1
...@@ -24,7 +24,7 @@ import {...@@ -24,7 +24,7 @@ import {
24 type ProviderChangeRequestSummary,24 type ProviderChangeRequestSummary,
25 type ProviderDiffSlice,25 type ProviderDiffSlice,
26 type PullRequestProviderApi,26 type PullRequestProviderApi,
27} from "./PullRequestProvider.ts";27} from "@t3tools/source-control-core/server/PullRequestProvider";
28import type {28import type {
29 AzureDevOpsChangeEntry,29 AzureDevOpsChangeEntry,
30 AzureDevOpsItemContent,30 AzureDevOpsItemContent,
apps/server/src/pullRequest/BitbucketPullRequestApi.ts+1-1
...@@ -39,7 +39,7 @@ import {...@@ -39,7 +39,7 @@ import {
39 type BitbucketPullRequest,39 type BitbucketPullRequest,
40 type BitbucketRawComment,40 type BitbucketRawComment,
41} from "./bitbucketPullRequestJson.ts";41} from "./bitbucketPullRequestJson.ts";
42import type { ProviderListCursor } from "./PullRequestProvider.ts";42import type { ProviderListCursor } from "@t3tools/source-control-core/server/PullRequestProvider";
4343
44/**44/**
45 * Names the read that produced unusable output, so a failure reports the call it came from45 * Names the read that produced unusable output, so a failure reports the call it came from
apps/server/src/pullRequest/BitbucketPullRequestProvider.ts+1-1
...@@ -9,7 +9,7 @@ import {...@@ -9,7 +9,7 @@ import {
9 type ProviderChangeRequestActivity,9 type ProviderChangeRequestActivity,
10 type ProviderChangeRequestDetail,10 type ProviderChangeRequestDetail,
11 type PullRequestProviderApi,11 type PullRequestProviderApi,
12} from "./PullRequestProvider.ts";12} from "@t3tools/source-control-core/server/PullRequestProvider";
13import type { BitbucketPullRequest } from "./bitbucketPullRequestJson.ts";13import type { BitbucketPullRequest } from "./bitbucketPullRequestJson.ts";
1414
15const CAPABILITIES: PullRequestCapabilities = {15const CAPABILITIES: PullRequestCapabilities = {
apps/server/src/pullRequest/ForgejoPullRequestProvider.ts+1-1
...@@ -10,7 +10,7 @@ import {...@@ -10,7 +10,7 @@ import {
10 type ProviderChangeRequestDetail,10 type ProviderChangeRequestDetail,
11 type ProviderRepositoryRef,11 type ProviderRepositoryRef,
12 type PullRequestProviderApi,12 type PullRequestProviderApi,
13} from "./PullRequestProvider.ts";13} from "@t3tools/source-control-core/server/PullRequestProvider";
14import {14import {
15 ForgejoPullRequest,15 ForgejoPullRequest,
16 ForgejoRepository,16 ForgejoRepository,
apps/server/src/pullRequest/GitHubPullRequestApi.test.ts+1-1
...@@ -16,7 +16,7 @@ import { AllowGitHubReserve } from "../sourceControl/GitHubApi.ts";...@@ -16,7 +16,7 @@ import { AllowGitHubReserve } from "../sourceControl/GitHubApi.ts";
16import * as GitHubApi from "../sourceControl/GitHubApi.ts";16import * as GitHubApi from "../sourceControl/GitHubApi.ts";
17import * as GitHubCredentials from "../sourceControl/GitHubCredentials.ts";17import * as GitHubCredentials from "../sourceControl/GitHubCredentials.ts";
18import * as GitHubQuota from "../sourceControl/githubQuota.ts";18import * as GitHubQuota from "../sourceControl/githubQuota.ts";
19import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts";19import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit";
20import * as VcsProcess from "../vcs/VcsProcess.ts";20import * as VcsProcess from "../vcs/VcsProcess.ts";
21import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts";21import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts";
22import { KnownWorkflowRuns } from "./gitHubConditionalChecks.ts";22import { KnownWorkflowRuns } from "./gitHubConditionalChecks.ts";
apps/server/src/pullRequest/GitHubPullRequestApi.ts+6-3
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { removeAgentCredits } from "./mergeMessage.ts";1import { removeAgentCredits } from "@t3tools/source-control-core/server/mergeMessage";
2import { KnownWorkflowRuns, makeChecksRevalidator } from "./gitHubConditionalChecks.ts";2import { KnownWorkflowRuns, makeChecksRevalidator } from "./gitHubConditionalChecks.ts";
3import { runGitHubStackAction, type GitHubStackActionError } from "./githubStackActions.ts";3import { runGitHubStackAction, type GitHubStackActionError } from "./githubStackActions.ts";
4import * as Cause from "effect/Cause";4import * as Cause from "effect/Cause";
...@@ -45,7 +45,7 @@ import {...@@ -45,7 +45,7 @@ import {
45import * as GitHubApi from "../sourceControl/GitHubApi.ts";45import * as GitHubApi from "../sourceControl/GitHubApi.ts";
46import { readGraphQlPages } from "../sourceControl/githubGraphQl.ts";46import { readGraphQlPages } from "../sourceControl/githubGraphQl.ts";
47import * as VcsProcess from "../vcs/VcsProcess.ts";47import * as VcsProcess from "../vcs/VcsProcess.ts";
48import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts";48import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit";
49import {49import {
50 ACTOR_AVATARS_GRAPHQL_QUERY,50 ACTOR_AVATARS_GRAPHQL_QUERY,
51 ADD_REACTION_GRAPHQL_MUTATION,51 ADD_REACTION_GRAPHQL_MUTATION,
...@@ -128,7 +128,10 @@ import {...@@ -128,7 +128,10 @@ import {
128 type GitHubReviewThreadPage,128 type GitHubReviewThreadPage,
129 type GitHubViewerAccess,129 type GitHubViewerAccess,
130} from "./gitHubPullRequestJson.ts";130} from "./gitHubPullRequestJson.ts";
131import type { ProviderChangeRequestSummary, ProviderListCursor } from "./PullRequestProvider.ts";131import type {
132 ProviderChangeRequestSummary,
133 ProviderListCursor,
134} from "@t3tools/source-control-core/server/PullRequestProvider";
132135
133/**136/**
134 * Names the read that produced unusable output, so a failure reports the call it came from137 * Names the read that produced unusable output, so a failure reports the call it came from
apps/server/src/pullRequest/GitHubPullRequestProvider.ts+1-1
...@@ -16,7 +16,7 @@ import {...@@ -16,7 +16,7 @@ import {
16 type ProviderChangeRequestDetail,16 type ProviderChangeRequestDetail,
17 type PullRequestProviderApi,17 type PullRequestProviderApi,
18 type ProviderRepositoryRef,18 type ProviderRepositoryRef,
19} from "./PullRequestProvider.ts";19} from "@t3tools/source-control-core/server/PullRequestProvider";
20import type { GitHubViewerAccess, GitHubWorkflowRunApproval } from "./gitHubPullRequestJson.ts";20import type { GitHubViewerAccess, GitHubWorkflowRunApproval } from "./gitHubPullRequestJson.ts";
2121
22const CAPABILITIES: PullRequestCapabilities = {22const CAPABILITIES: PullRequestCapabilities = {
apps/server/src/pullRequest/GitLabPullRequestCli.ts+1-1
...@@ -44,7 +44,7 @@ import {...@@ -44,7 +44,7 @@ import {
44 type GitLabMergeRequestListItem,44 type GitLabMergeRequestListItem,
45 type GitLabProjectUsers,45 type GitLabProjectUsers,
46} from "./gitLabMergeRequestJson.ts";46} from "./gitLabMergeRequestJson.ts";
47import type { ProviderListCursor } from "./PullRequestProvider.ts";47import type { ProviderListCursor } from "@t3tools/source-control-core/server/PullRequestProvider";
4848
49/**49/**
50 * Names the read that produced unusable output, so a failure reports the call it came from50 * Names the read that produced unusable output, so a failure reports the call it came from
apps/server/src/pullRequest/GitLabPullRequestProvider.ts+1-1
...@@ -12,7 +12,7 @@ import {...@@ -12,7 +12,7 @@ import {
12 type ProviderChangeRequestActivity,12 type ProviderChangeRequestActivity,
13 type ProviderChangeRequestDetail,13 type ProviderChangeRequestDetail,
14 type PullRequestProviderApi,14 type PullRequestProviderApi,
15} from "./PullRequestProvider.ts";15} from "@t3tools/source-control-core/server/PullRequestProvider";
1616
17const CAPABILITIES: PullRequestCapabilities = {17const CAPABILITIES: PullRequestCapabilities = {
18 diff: true,18 diff: true,
apps/server/src/pullRequest/PullRequestProvider.ts deleted-702
...@@ -1,702 +0,0 @@
1import * as Effect from "effect/Effect";
2import * as Schema from "effect/Schema";
3import type {
4 PullRequestStackMembership,
5 PullRequestAction,
6 PullRequestStackHead,
7 PullRequestActor,
8 PullRequestBaseComparison,
9 PullRequestCapabilities,
10 PullRequestChecksState,
11 PullRequestCheck,
12 PullRequestChecks,
13 PullRequestComment,
14 PullRequestFileViewed,
15 PullRequestCommit,
16 PullRequestInvolvement,
17 PullRequestLabel,
18 PullRequestListFilters,
19 PullRequestListState,
20 PullRequestMergeCapabilities,
21 PullRequestMergeMethod,
22 PullRequestMergeability,
23 PullRequestOmittedFileStat,
24 PullRequestReaction,
25 PullRequestReactionContent,
26 PullRequestReviewCommentDraft,
27 PullRequestReviewDecision,
28 PullRequestReviewThread,
29 PullRequestThreadCommentsResult,
30 PullRequestReviewVerdict,
31 PullRequestReviewerCandidateList,
32 PullRequestReviewerKind,
33 PullRequestLabelCandidateList,
34 PullRequestState,
35 PullRequestPreview,
36 PullRequestUpdateMethod,
37 PullRequestViewerPermissions,
38 SourceControlProviderKind,
39} from "@t3tools/contracts";
40import { SourceControlProviderKind as SourceControlProviderKindSchema } from "@t3tools/contracts";
41
42/**
43 * The one failure shape every provider reports, so the service can decide what a failure means
44 * without knowing which CLI or API produced it.
45 *
46 * `reason` is the part the service acts on: a missing or unauthenticated tool disables the
47 * provider for the whole workspace, a rate limit pauses its host, and anything else is specific
48 * to the request.
49 */
50export class PullRequestProviderError extends Schema.TaggedError<PullRequestProviderError>()(
51 "PullRequestProviderError",
52 {
53 provider: SourceControlProviderKindSchema,
54 operation: Schema.String,
55 reason: Schema.Literals([
56 "missing-tool",
57 "unauthenticated",
58 "rate-limited",
59 "not-found",
60 "failed",
61 ]),
62 detail: Schema.String,
63 retryAt: Schema.optional(Schema.Number),
64 cause: Schema.optional(Schema.Defect()),
65 },
66) {
67 override get message(): string {
68 return `${this.provider} failed in ${this.operation}: ${this.detail}`;
69 }
70}
71
72/**
73 * `status` moves with the state, mergeability, head, or checks, which the detail read reports;
74 * `remarks` moves with comments and reviews, which only the activity read reports.
75 */
76export interface ProviderChangeRequestWatchFingerprint {
77 readonly status: string;
78 readonly remarks: string;
79}
80
81export interface PullRequestProviderFailure {
82 readonly reason: PullRequestProviderError["reason"];
83 readonly retryAt?: number | undefined;
84}
85
86/** A change request as the provider sees it, before the service attaches project context. */
87export interface ProviderChangeRequest {
88 readonly stack?: PullRequestStackMembership;
89 readonly number: number;
90 readonly title: string;
91 readonly url: string;
92 readonly author: PullRequestActor | null;
93 readonly headBranch: string;
94 readonly headRepositoryNameWithOwner?: string | null;
95 readonly baseBranch: string;
96 readonly state: PullRequestState;
97 readonly isDraft: boolean;
98 readonly mergeability: PullRequestMergeability;
99 readonly additions: number;
100 readonly deletions: number;
101 readonly createdAt: string;
102 readonly closedAt?: string | null;
103 readonly mergedAt?: string | null;
104 readonly updatedAt: string;
105 /** Accounts with a review requested. Team-level requests are excluded by each provider. */
106 readonly reviewRequestLogins: ReadonlyArray<string>;
107 readonly labels: ReadonlyArray<PullRequestLabel>;
108 /** Absent from a host that does not summarise its reviews, which is every host but GitHub. */
109 readonly reviewDecision?: PullRequestReviewDecision | null | undefined;
110 /** Absent from a host that reports no check rollup on its listings. */
111 readonly checksState?: PullRequestChecksState | null | undefined;
112}
113
114/** The fields needed to keep a linked thread's pull request status live. */
115export interface ProviderChangeRequestSummary {
116 readonly number: number;
117 readonly title: string;
118 readonly url: string;
119 readonly headBranch: string;
120 readonly baseBranch: string;
121 readonly state: PullRequestState;
122 /** Present when the host says an open pull request is still a draft. */
123 readonly isDraft?: boolean;
124 readonly closedAt?: string | null;
125 readonly mergedAt?: string | null;
126 readonly updatedAt: string;
127 /** Overview fields, present where the host's single read returns them at no extra cost. */
128 readonly author?: PullRequestActor | null | undefined;
129 readonly additions?: number | undefined;
130 readonly deletions?: number | undefined;
131 readonly changedFiles?: number | undefined;
132 readonly reviewDecision?: PullRequestReviewDecision | null | undefined;
133 readonly checksState?: PullRequestChecksState | null | undefined;
134 readonly mergeability?: PullRequestMergeability | undefined;
135 /**
136 * The host-native stack the pull request sits in, from the same read. Null when the host says
137 * it is in none; absent when the read did not ask.
138 */
139 readonly stack?: PullRequestStackMembership | null | undefined;
140}
141
142/** One layer of a host-native stack, bottom to top order is the array's. */
143export interface ProviderChangeRequestStackLayer {
144 readonly title?: string;
145 readonly isDraft?: boolean;
146 readonly headSha?: string;
147 readonly number: number;
148 readonly headBranch: string;
149 readonly state: PullRequestState;
150}
151
152/**
153 * A host-native stack: an ordered set of change requests the host itself merges and retargets as
154 * a unit. Only GitHub offers one today; the neutral shape lets the sync reactor and the UI stay
155 * ignorant of which host said so.
156 */
157export interface ProviderChangeRequestStack {
158 readonly id: string;
159 readonly number: number;
160 readonly url: string;
161 readonly base: string;
162 readonly layers: ReadonlyArray<ProviderChangeRequestStackLayer>;
163}
164
165export interface ProviderChangeRequestPage {
166 readonly items: ReadonlyArray<ProviderChangeRequest>;
167 /** True when the host has more rows than the page size asked for. */
168 readonly truncated: boolean;
169 /**
170 * Optional count-based cursor advance. Most hosts advance by the rows delivered after local
171 * de-duplication; an offset-paged host may need to count malformed raw rows it consumed too.
172 */
173 readonly cursorAdvance?: number;
174 /**
175 * This page can be carried on from, so the service may hand the caller a cursor for it. False
176 * where the host answered in an order a cursor means nothing in, which leaves a larger `limit`
177 * as the only way to the rest — what every listing did before there were cursors.
178 */
179 readonly continues: boolean;
180}
181
182/**
183 * Where a repository's next slice starts, as the provider that has to ask for it needs it. Built
184 * by the service out of the slice it just handed over, so the boundary that decides whether a row
185 * arrives twice or not at all is decided in one place rather than in four.
186 */
187export interface ProviderListCursor {
188 /**
189 * The instant of the oldest row already handed over, checked against a timestamp's shape before
190 * it gets here because it goes into a host's own filter. Asked for inclusively: several rows
191 * share one instant often enough — a bot that touches eight change requests writes one timestamp
192 * on all eight — and asking for strictly older would lose whichever of them the slice ended
193 * before. The service drops the ones it has already sent.
194 */
195 readonly updatedBefore: string;
196 /**
197 * How many provider rows this repository has consumed so far, for a host that carries on by
198 * counting rather than by date. Usually this is the number handed over; malformed raw rows may
199 * count too when the provider reports a `cursorAdvance`.
200 */
201 readonly delivered: number;
202}
203
204/** One repository's row inside an answer that spans several of them. */
205export interface ProviderBatchedChangeRequest extends ProviderChangeRequest {
206 /** Provider-native identity, exactly as it was asked for, so the caller can file the row. */
207 readonly repository: string;
208}
209
210/**
211 * One slice of a host read across several repositories at once, newest update first across all
212 * of them. There is no per-repository page here because the host was asked one question: the
213 * caller splits the rows by `repository` and works out where each of them carries on from the
214 * oldest row in the slice, which every repository the slice covers is now read up to.
215 */
216export interface ProviderBatchedChangeRequestPage {
217 readonly items: ReadonlyArray<ProviderBatchedChangeRequest>;
218 /** True when the host has more rows than the slice asked for, for any of the repositories. */
219 readonly truncated: boolean;
220}
221
222/** The line counts for one change request, which a listing may leave for a second read. */
223export interface ProviderChangeRequestStat {
224 readonly repository: string;
225 readonly number: number;
226 readonly additions: number;
227 readonly deletions: number;
228}
229
230export interface ProviderChangeRequestDetail extends ProviderChangeRequest {
231 /** The head commit, where the host's detail read reports it. */
232 readonly headSha?: string | null;
233 readonly body: string;
234 readonly changedFiles: number;
235 readonly mergedAt: string | null;
236 readonly closedAt: string | null;
237 readonly reviewers: ReadonlyArray<PullRequestActor>;
238 readonly checks: ReadonlyArray<PullRequestCheck>;
239 readonly mergeCapabilities: PullRequestMergeCapabilities;
240 readonly viewerPermissions: PullRequestViewerPermissions;
241 /** Absent from a host that cannot compare the branch with its base, which is most of them. */
242 readonly baseComparison?: PullRequestBaseComparison;
243 readonly behindBy?: number;
244 /** Absent from a host that does not report whether it is armed to merge this on its own. */
245 readonly autoMergeEnabled?: boolean;
246 /** The strategy stored with an armed auto-merge, where the host reports it. */
247 readonly autoMergeMethod?: PullRequestMergeMethod;
248 /** Workflow runs on this head commit that still need a maintainer's approval. */
249 readonly workflowApprovalsRequired?: number;
250}
251
252/** The conversation-shaped half of a detail, loaded after the core can already render. */
253export interface ProviderChangeRequestActivity {
254 /** An optional richer actor, e.g. after GitHub's GraphQL read supplies an avatar. */
255 readonly author?: PullRequestActor | null;
256 /** Optional because most hosts already report their reviewer list in the core detail. */
257 readonly reviewers?: ReadonlyArray<PullRequestActor>;
258 readonly comments: ReadonlyArray<PullRequestComment>;
259 /**
260 * The host's own count of the conversation, which a bounded read can fall short of. A host
261 * that reports no count of its own answers with what it handed over, which is the same number
262 * once the read went to the end.
263 */
264 readonly commentCount: number;
265 readonly commentsTruncated: boolean;
266 readonly reviewThreadsTruncated?: boolean;
267 readonly reviewThreads: ReadonlyArray<PullRequestReviewThread>;
268 readonly commits: ReadonlyArray<PullRequestCommit>;
269 /** The change request's own reactions, from a host that has them. */
270 readonly reactions?: ReadonlyArray<PullRequestReaction>;
271}
272
273export interface ProviderDiffSlice {
274 readonly patch: string;
275 /** Something in this slice could not be shown, as opposed to there being more slices. */
276 readonly truncated: boolean;
277 readonly nextCursor: string | null;
278 /** The host's own counts for the files whose hunks it withheld from this slice. */
279 readonly omittedFileStats?: ReadonlyArray<PullRequestOmittedFileStat>;
280}
281
282export interface ProviderDiffFileContents {
283 readonly oldContents: string;
284 readonly newContents: string;
285}
286
287export interface ProviderFilesViewed {
288 readonly files: ReadonlyArray<PullRequestFileViewed>;
289 /** The host has more files than were read, so the ones missing here are not "unviewed". */
290 readonly truncated: boolean;
291}
292
293/**
294 * What version each of the asked-for files is at, on the change request's head. Opaque strings,
295 * compared only against one another, where the empty string is the answer for a file the change
296 * request deletes rather than a gap. A path is absent only where the read could not say, so a
297 * provider converts its host's own absences on the way here.
298 *
299 * The whole path a version travels, and the three senses of null along it, are in
300 * `docs/internals/pull-request-file-revisions.md`.
301 */
302export interface ProviderFileRevisions {
303 readonly revisions: ReadonlyMap<string, string>;
304 /**
305 * Whether these are every file the change request carries rather than only the paths asked
306 * about. A host with no per-file version reads the whole change to answer for one file, and
307 * saying so is what keeps the next tick, naming a path nothing asked about before, from making
308 * it read the whole change again. False for a read cut short, which cannot speak past the cut.
309 */
310 readonly complete?: boolean;
311}
312
313export interface ProviderRepositoryRef {
314 readonly cwd: string;
315 /** Provider-native repository identity, e.g. `owner/repo` or `group/subgroup/project`. */
316 readonly repository: string;
317 /**
318 * The host it lives on, which `repository` deliberately leaves out — the same `owner/repo`
319 * exists on github.com and on a GitHub Enterprise install, and only the caller knows which
320 * one a project's remote points at.
321 */
322 readonly host: string;
323}
324
325/**
326 * One host's change requests. Implementations own their own tool and JSON shapes and hand back
327 * the neutral types above; anything a host cannot do is declared in `capabilities` rather than
328 * failing at call time.
329 */
330export interface PullRequestProviderApi {
331 readonly withVerifiedCredential?: <A, E, R>(
332 input: { readonly cwd: string; readonly host: string },
333 use: (identity: {
334 readonly accountId: string;
335 readonly viewer: string;
336 readonly credentialFingerprint: string;
337 }) => Effect.Effect<A, E, R>,
338 ) => Effect.Effect<A, E | PullRequestProviderError, R>;
339 readonly getRoutingIdentity?: (input: {
340 readonly cwd: string;
341 readonly host: string;
342 }) => Effect.Effect<
343 { readonly accountId: string; readonly viewer: string },
344 PullRequestProviderError
345 >;
346 readonly kind: SourceControlProviderKind;
347 readonly capabilities: PullRequestCapabilities;
348
349 /** The signed-in account, which is what involvement filtering compares against. */
350 readonly getViewer: (input: {
351 readonly cwd: string;
352 readonly host?: string;
353 }) => Effect.Effect<string, PullRequestProviderError>;
354
355 readonly listChangeRequests: (
356 input: ProviderRepositoryRef & {
357 readonly state: PullRequestListState;
358 readonly involvement: PullRequestInvolvement;
359 readonly viewer: string;
360 readonly limit: number;
361 /**
362 * Free text to narrow the listing by, as the host understands it. A host with no text
363 * filter of its own ignores it and answers with the page it would have answered with
364 * anyway — the caller narrows what it gets, so an unfiltered page is a wider answer
365 * rather than a wrong one.
366 */
367 readonly query?: string | undefined;
368 /**
369 * Where to carry on from, rather than reading this repository from its newest row. Absent
370 * asks for the first slice, which is every listing that has not been continued.
371 */
372 readonly cursor?: ProviderListCursor | undefined;
373 /**
374 * Further narrowings, which a host applies as far as it can and ignores the rest of —
375 * an unnarrowed page is a wider answer rather than a wrong one, and the caller narrows
376 * what it gets for the fields a row carries.
377 */
378 readonly filters?: PullRequestListFilters | undefined;
379 },
380 ) => Effect.Effect<ProviderChangeRequestPage, PullRequestProviderError>;
381
382 /**
383 * The same listing for a whole host in one request, for a host that has a search across
384 * repositories. Optional: three of the four hosts here have no such API, and breaking the port
385 * for them to spare GitHub a fan-out would be paying for the fix with everyone else's clarity.
386 * The caller falls back to `listChangeRequests` per repository where this is absent, and where
387 * it fails.
388 *
389 * `limit` is the whole slice rather than a size per repository, because that is the shape of
390 * the answer: the newest `limit` rows across every repository named, which is exactly the rows
391 * a page ordered by update shows.
392 *
393 * `cursor` is one boundary for all of them, so a caller with repositories standing at different
394 * boundaries asks in groups rather than in one call.
395 */
396 readonly listChangeRequestsAcross?: (input: {
397 /** Any checkout on the host, which is what the tool is run in. */
398 readonly cwd: string;
399 readonly host: string;
400 readonly repositories: ReadonlyArray<string>;
401 readonly state: PullRequestListState;
402 readonly involvement: PullRequestInvolvement;
403 readonly viewer: string;
404 readonly limit: number;
405 readonly query?: string | undefined;
406 readonly cursor?: ProviderListCursor | undefined;
407 readonly filters?: PullRequestListFilters | undefined;
408 }) => Effect.Effect<ProviderBatchedChangeRequestPage, PullRequestProviderError>;
409
410 /**
411 * The line counts for rows a listing has already handed over. Only implemented by a provider
412 * whose listing leaves them out — for everyone else the numbers arrived with the row, and the
413 * caller has nothing to ask for.
414 */
415 readonly listChangeRequestStats?: (input: {
416 readonly cwd: string;
417 readonly host: string;
418 readonly changeRequests: ReadonlyArray<{
419 readonly repository: string;
420 readonly number: number;
421 }>;
422 }) => Effect.Effect<ReadonlyArray<ProviderChangeRequestStat>, PullRequestProviderError>;
423
424 readonly getChangeRequestChecks?: (
425 input: ProviderRepositoryRef & { readonly number: number },
426 ) => Effect.Effect<PullRequestChecks, PullRequestProviderError>;
427
428 readonly getChangeRequest: (
429 input: ProviderRepositoryRef & { readonly number: number },
430 ) => Effect.Effect<ProviderChangeRequestDetail, PullRequestProviderError>;
431
432 /** Hosts without a narrow read use their existing detail response for hover cards. */
433 readonly getChangeRequestPreview?: (
434 input: ProviderRepositoryRef & { readonly number: number },
435 ) => Effect.Effect<
436 Omit<PullRequestPreview, "projectId" | "repository">,
437 PullRequestProviderError
438 >;
439
440 /**
441 * The cheap live fields used by linked threads. Optional because a provider without a narrow
442 * endpoint can fall back to its full detail read at the service boundary.
443 */
444 readonly getChangeRequestSummary?: (
445 input: ProviderRepositoryRef & { readonly number: number },
446 ) => Effect.Effect<ProviderChangeRequestSummary, PullRequestProviderError>;
447
448 /**
449 * A cheap fingerprint of what a pull request watch reports, so it reads the change request in
450 * full only when this moves. Null when the host gave no answer for it. Optional: a host
451 * without one has its watched change requests read in full on every pass.
452 */
453 readonly getChangeRequestWatchFingerprint?: (
454 input: ProviderRepositoryRef & { readonly number: number },
455 ) => Effect.Effect<ProviderChangeRequestWatchFingerprint | null, PullRequestProviderError>;
456
457 /**
458 * The host-native stack a change request belongs to, or null when it is not stacked. Optional
459 * because most hosts have no such object; the service derives chains from base branches there.
460 */
461 readonly getChangeRequestStack?: (
462 input: ProviderRepositoryRef & { readonly includeDetails?: boolean; readonly number: number },
463 ) => Effect.Effect<ProviderChangeRequestStack | null, PullRequestProviderError>;
464
465 /** Comments, line threads, and commits, kept off the critical path for the core detail. */
466 readonly getChangeRequestActivity: (
467 input: ProviderRepositoryRef & { readonly number: number },
468 ) => Effect.Effect<ProviderChangeRequestActivity, PullRequestProviderError>;
469
470 /** One explicit page after a reader asks to continue an unfinished review thread. */
471 readonly getReviewThreadComments?: (
472 input: ProviderRepositoryRef & {
473 readonly number: number;
474 readonly threadId: string;
475 readonly cursor: string;
476 },
477 ) => Effect.Effect<PullRequestThreadCommentsResult, PullRequestProviderError>;
478
479 /**
480 * The same answer `getChangeRequest` carries, on its own. Asked before anything is written, so
481 * a request that reached the server without going past the page is refused by what the host
482 * says rather than by what the client claimed — and asked freshly, because access granted or
483 * taken away since the page loaded is exactly the case this guards.
484 *
485 * Implementations read the cheapest thing that answers it, which for a host with nothing to say
486 * is no request at all.
487 */
488 readonly getViewerPermissions: (
489 input: ProviderRepositoryRef & {
490 readonly number: number;
491 /** Skip branch comparison when checking permission for an unrelated operation. */
492 readonly includeUpdateBranch?: boolean;
493 },
494 ) => Effect.Effect<PullRequestViewerPermissions, PullRequestProviderError>;
495
496 /**
497 * One slice of the patch. Only called when `capabilities.diff` is true. A provider that can
498 * serve the whole diff at once answers with `nextCursor: null` and is done; one that pages
499 * hands back whatever it needs to find the next slice.
500 */
501 readonly getDiff: (
502 input: ProviderRepositoryRef & {
503 readonly number: number;
504 readonly cursor?: string | undefined;
505 /** One commit's own changes, rather than everything the change request carries. */
506 readonly commit?: string | undefined;
507 },
508 ) => Effect.Effect<ProviderDiffSlice, PullRequestProviderError>;
509
510 /**
511 * Full files at the exact revisions the host used for its patch. Optional where the provider
512 * exposes no diff at all; the service refuses expansion there just as it refuses the patch.
513 */
514 readonly getDiffFileContents?: (
515 input: ProviderRepositoryRef & {
516 readonly number: number;
517 readonly commit?: string | undefined;
518 readonly changeType: "change" | "rename-pure" | "rename-changed" | "new" | "deleted";
519 readonly oldPath: string;
520 readonly newPath: string;
521 },
522 ) => Effect.Effect<ProviderDiffFileContents, PullRequestProviderError>;
523
524 /**
525 * Which files the reader has already cleared. Only called when `capabilities.viewedFiles` is
526 * `"host"`, and read apart from the patch: this moves with every press rather than every push.
527 */
528 readonly getFilesViewed?: (
529 input: ProviderRepositoryRef & { readonly number: number },
530 ) => Effect.Effect<ProviderFilesViewed, PullRequestProviderError>;
531
532 /**
533 * Clears files, or puts them back. Only called when `capabilities.viewedFiles` is `"host"`.
534 * A host with no bulk form is still owed one round trip for the batch rather than one per file,
535 * since the point of gathering presses is that the host is asked once.
536 */
537 readonly setFilesViewed?: (
538 input: ProviderRepositoryRef & {
539 readonly number: number;
540 readonly files: ReadonlyArray<{ readonly path: string; readonly viewed: boolean }>;
541 },
542 ) => Effect.Effect<void, PullRequestProviderError>;
543
544 /**
545 * What version the head has of each of these files. Required of a host whose
546 * `capabilities.viewedFiles` is `"environment"`, and unused by one that keeps the marks itself:
547 * the marks live here, but only the host can say whether what a reader cleared last week is
548 * still what is in front of them. Asked for the marked paths alone, so the cost follows how
549 * much of the change request has been read rather than how large it is.
550 */
551 readonly getFileRevisions?: (
552 input: ProviderRepositoryRef & {
553 readonly number: number;
554 readonly paths: ReadonlyArray<string>;
555 },
556 ) => Effect.Effect<ProviderFileRevisions, PullRequestProviderError>;
557
558 readonly runAction: (
559 input: ProviderRepositoryRef & {
560 readonly number: number;
561 readonly action: PullRequestAction;
562 readonly stackNumber?: number;
563 readonly expectedStackHeads?: ReadonlyArray<PullRequestStackHead>;
564 /** GitHub merge message cleanup; ignored by hosts without support. */
565 readonly removeAgentCreditsOnMerge?: boolean;
566 /** Meaningful for `merge` and `enable-auto-merge`; absent takes the host's own default. */
567 readonly mergeMethod?: PullRequestMergeMethod;
568 /** Only meaningful for `update-branch`; absent takes the host's own default. */
569 readonly updateMethod?: PullRequestUpdateMethod;
570 },
571 ) => Effect.Effect<void, PullRequestProviderError>;
572
573 /**
574 * Rewrites the change request's own words. Only called when `capabilities.edit.changeRequest`
575 * is true, and never with both fields absent — the caller refuses that before it gets here,
576 * because a host asked to change nothing answers differently on each of them.
577 */
578 readonly updateChangeRequest?: (
579 input: ProviderRepositoryRef & {
580 readonly number: number;
581 readonly title?: string | undefined;
582 readonly body?: string | undefined;
583 },
584 ) => Effect.Effect<void, PullRequestProviderError>;
585
586 readonly comment: (
587 input: ProviderRepositoryRef & { readonly number: number; readonly body: string },
588 ) => Effect.Effect<void, PullRequestProviderError>;
589
590 /**
591 * Rewrites a remark somebody already posted. Only called when `capabilities.edit.comment` is
592 * true, with an id exactly as the conversation carried it.
593 *
594 * Whether this remark is the reader's to rewrite is the host's own answer: no read here can
595 * settle it, since access can be taken away between the conversation being read and the
596 * rewrite being sent, and a host refuses a stranger's remark with a sentence saying so.
597 */
598 readonly updateComment?: (
599 input: ProviderRepositoryRef & {
600 readonly number: number;
601 readonly commentId: string;
602 readonly kind: "issue-comment" | "review-comment";
603 readonly body: string;
604 },
605 ) => Effect.Effect<void, PullRequestProviderError>;
606
607 /**
608 * Sends a whole review at once. Only called for a verdict the host declared in
609 * `capabilities.review.verdicts`, and with line comments only where it declared
610 * `inlineComment`.
611 */
612 readonly submitReview: (
613 input: ProviderRepositoryRef & {
614 readonly number: number;
615 readonly verdict: PullRequestReviewVerdict;
616 readonly body: string;
617 readonly comments: ReadonlyArray<PullRequestReviewCommentDraft>;
618 },
619 ) => Effect.Effect<void, PullRequestProviderError>;
620
621 /**
622 * The people this viewer may ask for a review, with whoever has already been asked marked as
623 * such. Only called when `capabilities.reviewers.listCandidates` is true.
624 *
625 * The author is left out by each provider rather than by the caller, because only the provider
626 * knows how the host spells the same person in a candidate list and on a pull request.
627 */
628 readonly listReviewerCandidates: (
629 input: ProviderRepositoryRef & { readonly number: number },
630 ) => Effect.Effect<PullRequestReviewerCandidateList, PullRequestProviderError>;
631
632 /**
633 * Asks for a review, or takes the request back. Only called when
634 * `capabilities.reviewers.request` is true.
635 *
636 * One call for both directions, because that is what every host does with them: GitHub posts and
637 * deletes the same collection, and GitLab and Bitbucket write the whole reviewer set either way.
638 * Asking again somebody who has already reviewed is a request like any other — which is how a
639 * re-request is made.
640 */
641 readonly setReviewerRequest: (
642 input: ProviderRepositoryRef & {
643 readonly number: number;
644 readonly reviewers: ReadonlyArray<{
645 readonly id: string;
646 readonly kind: PullRequestReviewerKind;
647 }>;
648 readonly requested: boolean;
649 },
650 ) => Effect.Effect<void, PullRequestProviderError>;
651
652 /**
653 * The repository's labels, with the ones already on the change request marked. Present with
654 * `setLabels` only where `capabilities.labels` is true; the service refuses both without it.
655 */
656 readonly listLabelCandidates?: (
657 input: ProviderRepositoryRef & { readonly number: number },
658 ) => Effect.Effect<PullRequestLabelCandidateList, PullRequestProviderError>;
659
660 /** Puts labels on the change request, or takes them off. One call for both directions. */
661 readonly setLabels?: (
662 input: ProviderRepositoryRef & {
663 readonly number: number;
664 readonly labels: ReadonlyArray<string>;
665 readonly applied: boolean;
666 },
667 ) => Effect.Effect<void, PullRequestProviderError>;
668
669 /** Only called when `capabilities.review.reply` is true. */
670 readonly replyToThread: (
671 input: ProviderRepositoryRef & {
672 readonly number: number;
673 readonly threadId: string;
674 readonly body: string;
675 },
676 ) => Effect.Effect<void, PullRequestProviderError>;
677
678 /**
679 * Adds a reaction, or takes it back. Only called when `capabilities.reactions` is true.
680 *
681 * `subjectId` is a remark's id as the conversation carried it; absent means the change request
682 * itself, whose reactions sit on its description. Whatever a host needs to address either of
683 * them is worked out here, because the id a conversation travels with is the one the reader has.
684 */
685 readonly setReaction: (
686 input: ProviderRepositoryRef & {
687 readonly number: number;
688 readonly subjectId?: string | undefined;
689 readonly content: PullRequestReactionContent;
690 readonly reacted: boolean;
691 },
692 ) => Effect.Effect<void, PullRequestProviderError>;
693
694 /** Only called when `capabilities.review.resolve` is true. */
695 readonly setThreadResolution: (
696 input: ProviderRepositoryRef & {
697 readonly number: number;
698 readonly threadId: string;
699 readonly resolved: boolean;
700 },
701 ) => Effect.Effect<void, PullRequestProviderError>;
702}
apps/server/src/pullRequest/PullRequestProviderRateLimit.test.ts+1-1
...@@ -3,7 +3,7 @@ import { assert, it } from "@effect/vitest";...@@ -3,7 +3,7 @@ import { assert, it } from "@effect/vitest";
3import * as AzureDevOpsCli from "../sourceControl/AzureDevOpsCli.ts";3import * as AzureDevOpsCli from "../sourceControl/AzureDevOpsCli.ts";
4import * as BitbucketApi from "../sourceControl/BitbucketApi.ts";4import * as BitbucketApi from "../sourceControl/BitbucketApi.ts";
5import * as GitHubApi from "../sourceControl/GitHubApi.ts";5import * as GitHubApi from "../sourceControl/GitHubApi.ts";
6import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts";6import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit";
7import * as GitLabCli from "../sourceControl/GitLabCli.ts";7import * as GitLabCli from "../sourceControl/GitLabCli.ts";
8import { azureDevOpsProviderFailure } from "./AzureDevOpsPullRequestProvider.ts";8import { azureDevOpsProviderFailure } from "./AzureDevOpsPullRequestProvider.ts";
9import { bitbucketProviderFailure } from "./BitbucketPullRequestProvider.ts";9import { bitbucketProviderFailure } from "./BitbucketPullRequestProvider.ts";
apps/server/src/pullRequest/PullRequestProviderRegistry.ts+1-1
...@@ -17,7 +17,7 @@ import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts";...@@ -17,7 +17,7 @@ import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts";
17import * as GitHubPullRequestProvider from "./GitHubPullRequestProvider.ts";17import * as GitHubPullRequestProvider from "./GitHubPullRequestProvider.ts";
18import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts";18import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts";
19import * as GitLabPullRequestProvider from "./GitLabPullRequestProvider.ts";19import * as GitLabPullRequestProvider from "./GitLabPullRequestProvider.ts";
20import type { PullRequestProviderApi } from "./PullRequestProvider.ts";20import type { PullRequestProviderApi } from "@t3tools/source-control-core/server/PullRequestProvider";
2121
22export class PullRequestProviderRegistry extends Context.Service<22export class PullRequestProviderRegistry extends Context.Service<
23 PullRequestProviderRegistry,23 PullRequestProviderRegistry,
apps/server/src/pullRequest/PullRequestService.test.ts+2-2
...@@ -29,14 +29,14 @@ import * as SqlitePersistence from "../persistence/Sqlite.ts";...@@ -29,14 +29,14 @@ import * as SqlitePersistence from "../persistence/Sqlite.ts";
29import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts";29import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts";
30import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts";30import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts";
31import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";31import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
32import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts";32import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit";
33import * as ForgejoCli from "../sourceControl/ForgejoCli.ts";33import * as ForgejoCli from "../sourceControl/ForgejoCli.ts";
34import * as ForgejoPullRequestProvider from "./ForgejoPullRequestProvider.ts";34import * as ForgejoPullRequestProvider from "./ForgejoPullRequestProvider.ts";
35import {35import {
36 PullRequestProviderError,36 PullRequestProviderError,
37 type ProviderChangeRequest,37 type ProviderChangeRequest,
38 type PullRequestProviderApi,38 type PullRequestProviderApi,
39} from "./PullRequestProvider.ts";39} from "@t3tools/source-control-core/server/PullRequestProvider";
40import * as PullRequestProviderRegistry from "./PullRequestProviderRegistry.ts";40import * as PullRequestProviderRegistry from "./PullRequestProviderRegistry.ts";
41import * as PullRequestService from "./PullRequestService.ts";41import * as PullRequestService from "./PullRequestService.ts";
42import * as PullRequestReadCache from "./PullRequestReadCache.ts";42import * as PullRequestReadCache from "./PullRequestReadCache.ts";
apps/server/src/pullRequest/PullRequestService.ts+2-2
...@@ -83,14 +83,14 @@ import * as ServerSettings from "../serverSettings.ts";...@@ -83,14 +83,14 @@ import * as ServerSettings from "../serverSettings.ts";
83import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts";83import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts";
84import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts";84import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts";
85import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";85import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
86import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts";86import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit";
87import {87import {
88 type ProviderChangeRequest,88 type ProviderChangeRequest,
89 type ProviderListCursor,89 type ProviderListCursor,
90 type ProviderChangeRequestWatchFingerprint,90 type ProviderChangeRequestWatchFingerprint,
91 type PullRequestProviderApi,91 type PullRequestProviderApi,
92 PullRequestProviderError,92 PullRequestProviderError,
93} from "./PullRequestProvider.ts";93} from "@t3tools/source-control-core/server/PullRequestProvider";
94import * as PullRequestReadCache from "./PullRequestReadCache.ts";94import * as PullRequestReadCache from "./PullRequestReadCache.ts";
95import * as PullRequestProviderRegistry from "./PullRequestProviderRegistry.ts";95import * as PullRequestProviderRegistry from "./PullRequestProviderRegistry.ts";
96import * as ViewedFiles from "./pullRequestViewedFiles.ts";96import * as ViewedFiles from "./pullRequestViewedFiles.ts";
apps/server/src/pullRequest/bitbucketPullRequestJson.ts+1-1
...@@ -18,7 +18,7 @@ import type {...@@ -18,7 +18,7 @@ import type {
18import { TrimmedNonEmptyString } from "@t3tools/contracts";18import { TrimmedNonEmptyString } from "@t3tools/contracts";
19import { decodeJsonResult } from "@t3tools/shared/schemaJson";19import { decodeJsonResult } from "@t3tools/shared/schemaJson";
2020
21import { dedupeChecks } from "./pullRequestChecks.ts";21import { dedupeChecks } from "@t3tools/source-control-core/server/pullRequestChecks";
2222
23/**23/**
24 * Bitbucket's enums are decoded as plain strings and normalized here, in the same tolerant24 * Bitbucket's enums are decoded as plain strings and normalized here, in the same tolerant
apps/server/src/pullRequest/forgejoPullRequestJson.ts+2-2
...@@ -10,8 +10,8 @@ import type {...@@ -10,8 +10,8 @@ import type {
10 PullRequestReactionContent,10 PullRequestReactionContent,
11 PullRequestReviewThread,11 PullRequestReviewThread,
12} from "@t3tools/contracts";12} from "@t3tools/contracts";
13import type { ProviderChangeRequest } from "./PullRequestProvider.ts";13import type { ProviderChangeRequest } from "@t3tools/source-control-core/server/PullRequestProvider";
14import { dedupeChecks } from "./pullRequestChecks.ts";14import { dedupeChecks } from "@t3tools/source-control-core/server/pullRequestChecks";
1515
16export const ForgejoUser = Schema.Struct({16export const ForgejoUser = Schema.Struct({
17 login: Schema.String,17 login: Schema.String,
apps/server/src/pullRequest/gitHubConditionalChecks.ts+1-1
...@@ -10,7 +10,7 @@ import { PositiveInt, type PullRequestChecks } from "@t3tools/contracts";...@@ -10,7 +10,7 @@ import { PositiveInt, type PullRequestChecks } from "@t3tools/contracts";
10import * as GitHubApi from "../sourceControl/GitHubApi.ts";10import * as GitHubApi from "../sourceControl/GitHubApi.ts";
11import type { GitHubPullRequestDetail } from "./gitHubPullRequestJson.ts";11import type { GitHubPullRequestDetail } from "./gitHubPullRequestJson.ts";
12import type { GitHubPullRequestApiError } from "./GitHubPullRequestApi.ts";12import type { GitHubPullRequestApiError } from "./GitHubPullRequestApi.ts";
13import type { ProviderRepositoryRef } from "./PullRequestProvider.ts";13import type { ProviderRepositoryRef } from "@t3tools/source-control-core/server/PullRequestProvider";
1414
15const HeadSchema = Schema.Struct({15const HeadSchema = Schema.Struct({
16 head: Schema.Struct({16 head: Schema.Struct({
apps/server/src/pullRequest/gitHubPullRequestJson.ts+1-1
...@@ -36,7 +36,7 @@ import { quoteGitPatchPath } from "@t3tools/shared/gitPatchPath";...@@ -36,7 +36,7 @@ import { quoteGitPatchPath } from "@t3tools/shared/gitPatchPath";
36import { decodeJsonResult } from "@t3tools/shared/schemaJson";36import { decodeJsonResult } from "@t3tools/shared/schemaJson";
3737
38import { aliasedGraphQlDocument, type GraphQlDocument } from "../sourceControl/githubGraphQl.ts";38import { aliasedGraphQlDocument, type GraphQlDocument } from "../sourceControl/githubGraphQl.ts";
39import { dedupeChecks } from "./pullRequestChecks.ts";39import { dedupeChecks } from "@t3tools/source-control-core/server/pullRequestChecks";
4040
41/**41/**
42 * Enum-ish GitHub CLI fields are decoded as plain strings and normalized here: a `gh`42 * Enum-ish GitHub CLI fields are decoded as plain strings and normalized here: a `gh`
apps/server/src/pullRequest/mergeMessage.test.ts deleted-79
...@@ -1,79 +0,0 @@
1import { describe, expect, it } from "vite-plus/test";
2import { removeAgentCredits } from "./mergeMessage.ts";
3
4describe("removeAgentCredits", () => {
5 it("removes recognized agent trailers and generated footers, keeping human co-authors", () => {
6 expect(
7 removeAgentCredits(
8 [
9 "Details about Claude support.",
10 "",
11 "",
12 "Co-authored-by: Alice <alice@example.com>",
13 "Co-authored-by: Claude <noreply@anthropic.com>",
14 "* Co-authored-by: Codex <noreply@openai.com>",
15 "co-authored-by: Cursor <cursoragent@cursor.com>",
16 "Co-authored-by: Copilot <copilot@github.com>",
17 "",
18 "🤖 Generated with [Claude Code](https://claude.ai/code)",
19 ].join("\n"),
20 ),
21 ).toBe("Details about Claude support.\n\n\nCo-authored-by: Alice <alice@example.com>");
22 });
23
24 it("preserves unknown identities, humans named after agents, and ordinary prose", () => {
25 const message = [
26 "Co-authored-by: Claude <claude@example.com>",
27 "Co-authored-by: Alice <noreply@github.com>",
28 "Co-authored-by: Release Bot <release@example.com>",
29 "Generated by our new Claude integration.",
30 "Generated with [Claude Code](https://example.com)",
31 "",
32 ].join("\n");
33 expect(removeAgentCredits(message)).toBe(message);
34 });
35
36 it("preserves code fences and block quotes containing attribution examples", () => {
37 const example = "Co-authored-by: Claude <noreply@anthropic.com>";
38 const message = `Example:\n\n\`\`\`text\n${example}\n\`\`\`\n\n> ${example}\n\n${example}`;
39 expect(removeAgentCredits(message)).toBe(
40 `Example:\n\n\`\`\`text\n${example}\n\`\`\`\n\n> ${example}`,
41 );
42 });
43
44 it("preserves indented examples and whitespace on surviving lines", () => {
45 const example = "Co-authored-by: Claude <noreply@anthropic.com>";
46 expect(removeAgentCredits(`Example:\n\n ${example}\n\n${example}\nDetails \n`)).toBe(
47 `Example:\n\n ${example}\n\nDetails `,
48 );
49 });
50
51 it.each(["``` text ```", " ```", "\t~~~"])(
52 "removes trailers after a line that cannot open a fence: %s",
53 (line) => {
54 expect(removeAgentCredits(`${line}\n\nCo-authored-by: Claude <noreply@anthropic.com>`)).toBe(
55 line,
56 );
57 },
58 );
59
60 it.each(["```", "~~~"])("keeps examples after a false closing fence: %s", (fence) => {
61 const example = "Co-authored-by: Claude <noreply@anthropic.com>";
62 const content = `${fence}text\n${fence} trailing text\n${example}\n ${fence}\n${example}\n${fence.slice(0, 2)}\n${example}\n ${fence}${fence[0]} \t`;
63 expect(removeAgentCredits(`${content}\n\n${example}`)).toBe(content);
64 });
65
66 it("preserves CRLF line endings", () => {
67 expect(
68 removeAgentCredits(
69 "Details\r\n\r\nCo-authored-by: Claude <noreply@anthropic.com>\r\nCo-authored-by: Alice <alice@example.com>",
70 ),
71 ).toBe("Details\r\n\r\nCo-authored-by: Alice <alice@example.com>");
72 });
73
74 it("returns an empty body when only agent credits remain", () => {
75 expect(
76 removeAgentCredits("Co-authored-by: Claude <noreply@anthropic.com>\n\nGenerated by Codex\n"),
77 ).toBe("");
78 });
79});
apps/server/src/pullRequest/mergeMessage.ts deleted-54
...@@ -1,54 +0,0 @@
1const AGENT_EMAILS = new Set([
2 "noreply@anthropic.com",
3 "claude@anthropic.com",
4 "noreply@openai.com",
5 "codex@openai.com",
6 "cursoragent@cursor.com",
7 "copilot@github.com",
8 "noreply@github.com",
9]);
10
11const GENERATED_BY_AGENT =
12 /^(?:🤖\s*)?generated (?:with|by)\s+(?:claude(?: code)?|codex|cursor|github copilot|copilot|opencode|grok|antigravity)[.!]?$/i;
13
14/** Remove standalone agent credits while keeping human signatures and quoted examples. */
15export function removeAgentCredits(message: string): string {
16 let fence: string | undefined;
17 let removed = false;
18 const newline = message.includes("\r\n") ? "\r\n" : "\n";
19 const lines = message.split(/\r?\n/).filter((line) => {
20 if (/^(?: {4}|\t)/.test(line)) return true;
21 const delimiter = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
22 const marker = delimiter?.[1];
23 const remainder = delimiter?.[2] ?? "";
24 if (marker !== undefined) {
25 if (fence === undefined) {
26 if (marker[0] === "~" || !remainder.includes("`")) fence = marker;
27 } else if (
28 marker[0] === fence[0] &&
29 marker.length >= fence.length &&
30 /^[ \t]*$/.test(remainder)
31 ) {
32 fence = undefined;
33 }
34 return true;
35 }
36 if (fence !== undefined) return true;
37 const text = line.trim().replace(/^(?:[-*]\s+)/, "");
38 const author = /^co-authored-by:\s*(.*?)\s*<([^<>]+)>\s*$/i.exec(text);
39 // GitHub's generic noreply address is also used by people; require Copilot's name.
40 const email = author?.[2]?.toLowerCase();
41 const agentAuthor =
42 email !== undefined &&
43 AGENT_EMAILS.has(email) &&
44 (email !== "noreply@github.com" || /^github copilot$|^copilot$/i.test(author![1]!));
45 const footer = text.replace(
46 /\[([^\]]+)\]\(https:\/\/(?:claude\.ai|www\.anthropic\.com|chatgpt\.com|openai\.com|cursor\.com|github\.com|opencode\.ai|x\.ai|antigravity\.google)(?:\/[^\s)]*)?\)/gi,
47 "$1",
48 );
49 if (!agentAuthor && !GENERATED_BY_AGENT.test(footer)) return true;
50 removed = true;
51 return false;
52 });
53 return removed ? lines.join(newline).replace(/(?:\r?\n[ \t]*)+$/, "") : message;
54}
apps/server/src/pullRequest/pullRequestChecks.test.ts deleted-93
...@@ -1,93 +0,0 @@
1import type { PullRequestCheck, PullRequestCheckStatus } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import { dedupeChecks } from "./pullRequestChecks.ts";
5
6function entry(
7 name: string,
8 status: PullRequestCheckStatus,
9 extra: { readonly workflowName?: string | null; readonly at?: string | null } = {},
10): {
11 readonly check: PullRequestCheck;
12 readonly workflowName: string | null;
13 readonly at: string | null;
14} {
15 return {
16 check: { name, status, description: null, url: null },
17 workflowName: extra.workflowName ?? null,
18 at: extra.at ?? null,
19 };
20}
21
22describe("dedupeChecks", () => {
23 it("keeps the newest run of a check the host listed twice", () => {
24 const checks = dedupeChecks([
25 entry("Prepare PR size config", "success", {
26 workflowName: "PR Size",
27 at: "2026-08-11T16:06:25Z",
28 }),
29 entry("Prepare PR size config", "pending", {
30 workflowName: "PR Size",
31 at: "2026-08-11T17:01:04Z",
32 }),
33 ]);
34
35 expect(checks.map((check) => [check.name, check.status])).toEqual([
36 ["Prepare PR size config", "pending"],
37 ]);
38 });
39
40 it("holds a check at the place it first appeared, so a re-run does not reshuffle the list", () => {
41 const checks = dedupeChecks([
42 entry("lint", "success", { workflowName: "CI", at: "2026-08-11T16:00:00Z" }),
43 entry("test", "success", { workflowName: "CI", at: "2026-08-11T16:00:00Z" }),
44 entry("lint", "failure", { workflowName: "CI", at: "2026-08-11T18:00:00Z" }),
45 ]);
46
47 expect(checks.map((check) => [check.name, check.status])).toEqual([
48 ["lint", "failure"],
49 ["test", "success"],
50 ]);
51 });
52
53 it("loses a run that never said when it happened to one that did, whichever came first", () => {
54 const undated = dedupeChecks([
55 entry("build", "success", { at: "2026-08-11T16:00:00Z" }),
56 entry("build", "pending"),
57 ]);
58 const dated = dedupeChecks([
59 entry("build", "pending"),
60 entry("build", "success", { at: "2026-08-11T16:00:00Z" }),
61 ]);
62
63 expect([undated[0]?.status, dated[0]?.status]).toEqual(["success", "success"]);
64 });
65
66 it("takes the last copy when neither run is dated, which is how an update is listed", () => {
67 const checks = dedupeChecks([entry("build", "pending"), entry("build", "failure")]);
68
69 expect(checks.map((check) => check.status)).toEqual(["failure"]);
70 });
71
72 it("keeps two workflows that name a job the same thing, and says which is which", () => {
73 const checks = dedupeChecks([
74 entry("build", "success", { workflowName: "CI", at: "2026-08-11T16:00:00Z" }),
75 entry("build", "failure", { workflowName: "Release", at: "2026-08-11T16:00:00Z" }),
76 ]);
77
78 expect(checks.map((check) => [check.name, check.status])).toEqual([
79 ["CI / build", "success"],
80 ["Release / build", "failure"],
81 ]);
82 });
83
84 it("leaves a colliding check with no workflow of its own unqualified", () => {
85 // An app-provided check run belongs to no workflow, which GitHub reports as an empty name.
86 const checks = dedupeChecks([
87 entry("build", "success", { workflowName: "", at: "2026-08-11T16:00:00Z" }),
88 entry("build", "failure", { workflowName: "CI", at: "2026-08-11T16:00:00Z" }),
89 ]);
90
91 expect(checks.map((check) => check.name)).toEqual(["build", "CI / build"]);
92 });
93});
apps/server/src/pullRequest/pullRequestChecks.ts deleted-55
...@@ -1,55 +0,0 @@
1import type { PullRequestCheck } from "@t3tools/contracts";
2
3/** ISO-8601 timestamps in UTC compare correctly as plain text, which is all the ordering needs. */
4function isAtLeastAsNew(candidate: string | null, kept: string | null): boolean {
5 if (candidate === null) return kept === null;
6 return kept === null || candidate >= kept;
7}
8
9/**
10 * One row per check rather than one per run of it.
11 *
12 * A host's rollup is a list of runs, not a list of checks: while a workflow is being re-run — or
13 * while a second run of it is already live — the same check arrives twice, and both copies reach
14 * the reader as what looks like a duplicate. Nothing in a check carries an id, so the name is what
15 * identifies it, qualified by the workflow it belongs to since two workflows are free to name a
16 * job the same thing.
17 *
18 * Within a group the newest run is the one worth showing: a re-run is the answer that replaces the
19 * one before it, and a run that never said when it happened loses to one that did. A tie goes to
20 * whichever came last, because a host lists a re-run after the run it repeats.
21 *
22 * The order is the host's own, held at the place each check first appeared, so a re-run landing
23 * mid-read replaces a row where it stands instead of reshuffling the list under the reader.
24 *
25 * Two checks that survive under the same name are then genuinely different ones, since they came
26 * from different workflows — each is shown as `workflow / name`, the way GitHub writes it itself.
27 * A survivor whose workflow the host did not name keeps its bare name rather than being qualified
28 * with nothing.
29 */
30export function dedupeChecks(
31 entries: ReadonlyArray<{
32 readonly check: PullRequestCheck;
33 readonly workflowName: string | null;
34 readonly at: string | null;
35 }>,
36): ReadonlyArray<PullRequestCheck> {
37 const newestByCheck = new Map<string, (typeof entries)[number]>();
38 for (const entry of entries) {
39 const key = `${entry.workflowName ?? ""} ${entry.check.name}`;
40 const kept = newestByCheck.get(key);
41 // Re-setting a key a Map already holds keeps its first position, which is the order wanted.
42 if (kept === undefined || isAtLeastAsNew(entry.at, kept.at)) newestByCheck.set(key, entry);
43 }
44 const survivors = [...newestByCheck.values()];
45 const countsByName = new Map<string, number>();
46 for (const entry of survivors) {
47 countsByName.set(entry.check.name, (countsByName.get(entry.check.name) ?? 0) + 1);
48 }
49 return survivors.map((entry) => {
50 const workflowName = entry.workflowName ?? "";
51 return workflowName.length > 0 && (countsByName.get(entry.check.name) ?? 0) > 1
52 ? { ...entry.check, name: `${workflowName} / ${entry.check.name}` }
53 : entry.check;
54 });
55}
apps/server/src/pullRequest/pullRequestViewedFiles.ts+4-1
...@@ -16,7 +16,10 @@ import {...@@ -16,7 +16,10 @@ import {
16} from "@t3tools/contracts";16} from "@t3tools/contracts";
1717
18import type * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts";18import type * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts";
19import type { ProviderFileRevisions, PullRequestProviderError } from "./PullRequestProvider.ts";19import type {
20 ProviderFileRevisions,
21 PullRequestProviderError,
22} from "@t3tools/source-control-core/server/PullRequestProvider";
20import type { PullRequestError, SupportedProject } from "./PullRequestService.ts";23import type { PullRequestError, SupportedProject } from "./PullRequestService.ts";
2124
22/**25/**
apps/server/src/server.ts+3-1
...@@ -104,8 +104,9 @@ import * as ProjectCloneTracker from "./project/ProjectCloneTracker.ts";...@@ -104,8 +104,9 @@ import * as ProjectCloneTracker from "./project/ProjectCloneTracker.ts";
104import * as GitWorkflowService from "./git/GitWorkflowService.ts";104import * as GitWorkflowService from "./git/GitWorkflowService.ts";
105import * as ReviewService from "./review/ReviewService.ts";105import * as ReviewService from "./review/ReviewService.ts";
106import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts";106import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts";
107import * as ServerSourceControlHost from "./sourceControl/ServerSourceControlHost.ts";
107import * as PullRequestReadCache from "./pullRequest/PullRequestReadCache.ts";108import * as PullRequestReadCache from "./pullRequest/PullRequestReadCache.ts";
108import * as SourceControlRateLimit from "./sourceControl/SourceControlRateLimit.ts";109import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit";
109import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";110import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";
110import * as WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts";111import * as WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts";
111import * as Observability from "./observability/Observability.ts";112import * as Observability from "./observability/Observability.ts";
...@@ -305,6 +306,7 @@ const layerSourceControlProviderRegistry = SourceControlProviderRegistry.layer.p...@@ -305,6 +306,7 @@ const layerSourceControlProviderRegistry = SourceControlProviderRegistry.layer.p
305 GitHubApi.layerWithDependencies,306 GitHubApi.layerWithDependencies,
306 GitLabCli.layer,307 GitLabCli.layer,
307 ForgejoCli.layer,308 ForgejoCli.layer,
309 ServerSourceControlHost.layer,
308 ),310 ),
309 ),311 ),
310 Layer.provideMerge(GitVcsDriver.layer),312 Layer.provideMerge(GitVcsDriver.layer),
apps/server/src/sourceControl/AzureDevOpsCli.ts+1-1
...@@ -17,7 +17,7 @@ import {...@@ -17,7 +17,7 @@ import {
17 decodeAzureDevOpsPullRequestListJson,17 decodeAzureDevOpsPullRequestListJson,
18 type NormalizedAzureDevOpsPullRequestRecord,18 type NormalizedAzureDevOpsPullRequestRecord,
19} from "./azureDevOpsPullRequests.ts";19} from "./azureDevOpsPullRequests.ts";
20import * as SourceControlProvider from "./SourceControlProvider.ts";20import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider";
2121
22const DEFAULT_TIMEOUT_MS = 30_000;22const DEFAULT_TIMEOUT_MS = 30_000;
2323
apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts+2-2
...@@ -2,14 +2,14 @@ import * as Effect from "effect/Effect";...@@ -2,14 +2,14 @@ import * as Effect from "effect/Effect";
2import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts";2import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts";
33
4import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";4import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";
5import * as SourceControlProvider from "./SourceControlProvider.ts";5import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider";
6import {6import {
7 combinedAuthOutput,7 combinedAuthOutput,
8 firstSafeAuthLine,8 firstSafeAuthLine,
9 providerAuth,9 providerAuth,
10 type SourceControlAuthProbeInput,10 type SourceControlAuthProbeInput,
11 type SourceControlCliDiscoverySpec,11 type SourceControlCliDiscoverySpec,
12} from "./SourceControlProviderDiscovery.ts";12} from "@t3tools/source-control-core/server/discovery";
1313
14function parseAzureAuth(input: SourceControlAuthProbeInput) {14function parseAzureAuth(input: SourceControlAuthProbeInput) {
15 const account = input.stdout.trim().split(/\r?\n/)[0]?.trim();15 const account = input.stdout.trim().split(/\r?\n/)[0]?.trim();
apps/server/src/sourceControl/BitbucketApi.ts+2-2
...@@ -29,11 +29,11 @@ import {...@@ -29,11 +29,11 @@ import {
29 type NormalizedBitbucketPullRequestRecord,29 type NormalizedBitbucketPullRequestRecord,
30} from "./bitbucketPullRequests.ts";30} from "./bitbucketPullRequests.ts";
31import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText";31import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText";
32import * as SourceControlProvider from "./SourceControlProvider.ts";32import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider";
33import * as ServerSettings from "../serverSettings.ts";33import * as ServerSettings from "../serverSettings.ts";
34import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";34import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
35import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";35import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
36import { retryAtFromHeader } from "./SourceControlRateLimit.ts";36import { retryAtFromHeader } from "@t3tools/source-control-core/server/SourceControlRateLimit";
3737
38const DEFAULT_API_BASE_URL = "https://api.bitbucket.org/2.0";38const DEFAULT_API_BASE_URL = "https://api.bitbucket.org/2.0";
39/** A response body past this is cut short, so one huge diff cannot exhaust the server. */39/** A response body past this is cut short, so one huge diff cannot exhaust the server. */
apps/server/src/sourceControl/BitbucketSourceControlProvider.ts+2-2
...@@ -4,8 +4,8 @@ import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contrac...@@ -4,8 +4,8 @@ import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contrac
44
5import * as BitbucketApi from "./BitbucketApi.ts";5import * as BitbucketApi from "./BitbucketApi.ts";
6import type { NormalizedBitbucketPullRequestRecord } from "./bitbucketPullRequests.ts";6import type { NormalizedBitbucketPullRequestRecord } from "./bitbucketPullRequests.ts";
7import * as SourceControlProvider from "./SourceControlProvider.ts";7import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider";
8import type { SourceControlApiDiscoverySpec } from "./SourceControlProviderDiscovery.ts";8import type { SourceControlApiDiscoverySpec } from "@t3tools/source-control-core/server/discovery";
99
10function toChangeRequest(summary: NormalizedBitbucketPullRequestRecord): ChangeRequest {10function toChangeRequest(summary: NormalizedBitbucketPullRequestRecord): ChangeRequest {
11 return {11 return {
apps/server/src/sourceControl/ForgejoCli.ts+1-1
...@@ -15,7 +15,7 @@ import { decodeJsonResult } from "@t3tools/shared/schemaJson";...@@ -15,7 +15,7 @@ import { decodeJsonResult } from "@t3tools/shared/schemaJson";
15import { HostProcessPlatform } from "@t3tools/shared/hostProcess";15import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
16import * as VcsProcess from "../vcs/VcsProcess.ts";16import * as VcsProcess from "../vcs/VcsProcess.ts";
17import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText";17import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText";
18import type { SourceControlProviderContext } from "./SourceControlProvider.ts";18import type { SourceControlProviderContext } from "@t3tools/source-control-core/server/SourceControlProvider";
1919
20const encodeApiBody = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));20const encodeApiBody = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
2121
apps/server/src/sourceControl/ForgejoSourceControlProvider.ts+2-2
...@@ -5,13 +5,13 @@ import * as Result from "effect/Result";...@@ -5,13 +5,13 @@ import * as Result from "effect/Result";
5import { SourceControlProviderError } from "@t3tools/contracts";5import { SourceControlProviderError } from "@t3tools/contracts";
6import * as VcsProcess from "../vcs/VcsProcess.ts";6import * as VcsProcess from "../vcs/VcsProcess.ts";
7import * as ForgejoCli from "./ForgejoCli.ts";7import * as ForgejoCli from "./ForgejoCli.ts";
8import * as SourceControlProvider from "./SourceControlProvider.ts";8import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider";
9import {9import {
10 providerAuth,10 providerAuth,
11 probeSourceControlProvider,11 probeSourceControlProvider,
12 type SourceControlCliDiscoverySpec,12 type SourceControlCliDiscoverySpec,
13 type SourceControlManagedCliDiscoverySpec,13 type SourceControlManagedCliDiscoverySpec,
14} from "./SourceControlProviderDiscovery.ts";14} from "@t3tools/source-control-core/server/discovery";
15import { ForgejoPullRequestSchema, toForgejoChangeRequest } from "./forgejoPullRequests.ts";15import { ForgejoPullRequestSchema, toForgejoChangeRequest } from "./forgejoPullRequests.ts";
1616
17const isForgejoCliError = Schema.is(ForgejoCli.ForgejoCliError);17const isForgejoCliError = Schema.is(ForgejoCli.ForgejoCliError);
apps/server/src/sourceControl/GitHubApi.test.ts+1-1
...@@ -13,7 +13,7 @@ import { HttpClient, HttpClientResponse, type HttpClientRequest } from "effect/h...@@ -13,7 +13,7 @@ import { HttpClient, HttpClientResponse, type HttpClientRequest } from "effect/h
13import * as GitHubApi from "./GitHubApi.ts";13import * as GitHubApi from "./GitHubApi.ts";
14import * as GitHubCredentials from "./GitHubCredentials.ts";14import * as GitHubCredentials from "./GitHubCredentials.ts";
15import * as GitHubQuota from "./githubQuota.ts";15import * as GitHubQuota from "./githubQuota.ts";
16import * as SourceControlRateLimit from "./SourceControlRateLimit.ts";16import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit";
17import * as VcsProcess from "../vcs/VcsProcess.ts";17import * as VcsProcess from "../vcs/VcsProcess.ts";
18import * as ServerSettings from "../serverSettings.ts";18import * as ServerSettings from "../serverSettings.ts";
1919
apps/server/src/sourceControl/GitHubApi.ts+1-1
...@@ -13,7 +13,7 @@ import { HttpClient, HttpClientRequest, type HttpClientResponse } from "effect/h...@@ -13,7 +13,7 @@ import { HttpClient, HttpClientRequest, type HttpClientResponse } from "effect/h
13import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText";13import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText";
14import * as GitHubCredentials from "./GitHubCredentials.ts";14import * as GitHubCredentials from "./GitHubCredentials.ts";
15import * as GitHubQuota from "./githubQuota.ts";15import * as GitHubQuota from "./githubQuota.ts";
16import * as SourceControlRateLimit from "./SourceControlRateLimit.ts";16import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit";
1717
18const DEFAULT_TIMEOUT = Duration.seconds(30);18const DEFAULT_TIMEOUT = Duration.seconds(30);
19const DEFAULT_MAX_RESPONSE_BYTES = 8 * 1024 * 1024;19const DEFAULT_MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
apps/server/src/sourceControl/GitHubSourceControlProvider.ts+3-3
...@@ -37,14 +37,14 @@ import {...@@ -37,14 +37,14 @@ import {
37 resolveGitHubRepository,37 resolveGitHubRepository,
38 type GitHubRepositoryLocator,38 type GitHubRepositoryLocator,
39} from "./gitHubRepositoryResolution.ts";39} from "./gitHubRepositoryResolution.ts";
40import * as SourceControlRateLimit from "./SourceControlRateLimit.ts";40import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit";
41import {41import {
42 effectiveGitHubAccount,42 effectiveGitHubAccount,
43 findAuthenticatedGitHubAccount,43 findAuthenticatedGitHubAccount,
44 parseGitHubAuthStatus,44 parseGitHubAuthStatus,
45 type GitHubAuthStatusAccount,45 type GitHubAuthStatusAccount,
46} from "./gitHubAuthStatus.ts";46} from "./gitHubAuthStatus.ts";
47import * as SourceControlProvider from "./SourceControlProvider.ts";47import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider";
48import {48import {
49 combinedAuthOutput,49 combinedAuthOutput,
50 firstSafeAuthLine,50 firstSafeAuthLine,
...@@ -53,7 +53,7 @@ import {...@@ -53,7 +53,7 @@ import {
53 type SourceControlAuthProbeInput,53 type SourceControlAuthProbeInput,
54 type SourceControlCliDiscoverySpec,54 type SourceControlCliDiscoverySpec,
55 type SourceControlManagedCliDiscoverySpec,55 type SourceControlManagedCliDiscoverySpec,
56} from "./SourceControlProviderDiscovery.ts";56} from "@t3tools/source-control-core/server/discovery";
57import * as VcsProcess from "../vcs/VcsProcess.ts";57import * as VcsProcess from "../vcs/VcsProcess.ts";
5858
59const decodeLinkSubject = Schema.decodeUnknownEffect(59const decodeLinkSubject = Schema.decodeUnknownEffect(
apps/server/src/sourceControl/GitLabCli.ts+1-1
...@@ -18,7 +18,7 @@ import {...@@ -18,7 +18,7 @@ import {
18 decodeGitLabMergeRequestJson,18 decodeGitLabMergeRequestJson,
19 decodeGitLabMergeRequestListJson,19 decodeGitLabMergeRequestListJson,
20} from "./gitLabMergeRequests.ts";20} from "./gitLabMergeRequests.ts";
21import type * as SourceControlProvider from "./SourceControlProvider.ts";21import type * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider";
2222
23const DEFAULT_TIMEOUT_MS = 30_000;23const DEFAULT_TIMEOUT_MS = 30_000;
2424
apps/server/src/sourceControl/GitLabSourceControlProvider.ts+2-2
...@@ -4,7 +4,7 @@ import * as Option from "effect/Option";...@@ -4,7 +4,7 @@ import * as Option from "effect/Option";
4import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts";4import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts";
55
6import * as GitLabCli from "./GitLabCli.ts";6import * as GitLabCli from "./GitLabCli.ts";
7import * as SourceControlProvider from "./SourceControlProvider.ts";7import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider";
8import {8import {
9 combinedAuthOutput,9 combinedAuthOutput,
10 firstSafeAuthLine,10 firstSafeAuthLine,
...@@ -14,7 +14,7 @@ import {...@@ -14,7 +14,7 @@ import {
14 type SourceControlAuthProbeInput,14 type SourceControlAuthProbeInput,
15 type SourceControlCliDiscoverySpec,15 type SourceControlCliDiscoverySpec,
16 type SourceControlUnknownRemoteRefinementInput,16 type SourceControlUnknownRemoteRefinementInput,
17} from "./SourceControlProviderDiscovery.ts";17} from "@t3tools/source-control-core/server/discovery";
18import { findAuthenticatedGitLabHost, parseGitLabAuthStatusHosts } from "./gitLabAuthStatus.ts";18import { findAuthenticatedGitLabHost, parseGitLabAuthStatusHosts } from "./gitLabAuthStatus.ts";
1919
20const decodeLinkSubject = Schema.decodeUnknownEffect(20const decodeLinkSubject = Schema.decodeUnknownEffect(
apps/server/src/sourceControl/ServerSourceControlHost.ts created+24
...@@ -0,0 +1,24 @@
1/**
2 * The server's implementation of `SourceControlHost.SourceControlHost`, the only server surface
3 * source control provider packages may use.
4 *
5 * @module sourceControl/ServerSourceControlHost
6 */
7import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";
8import * as Effect from "effect/Effect";
9import * as Layer from "effect/Layer";
10
11import * as ServerSettings from "../serverSettings.ts";
12import * as VcsProcess from "../vcs/VcsProcess.ts";
13
14export const layer = Layer.effect(
15 SourceControlHost.SourceControlHost,
16 Effect.gen(function* () {
17 const serverSettings = yield* ServerSettings.ServerSettingsService;
18 const process = yield* VcsProcess.VcsProcess;
19 return SourceControlHost.SourceControlHost.of({
20 settings: { get: serverSettings.getSettings },
21 process: { run: process.run },
22 });
23 }),
24);
apps/server/src/sourceControl/SourceControlDiscovery.test.ts+10-1
...@@ -25,7 +25,8 @@ import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts...@@ -25,7 +25,8 @@ import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts
25import * as ForgejoPullRequestProvider from "../pullRequest/ForgejoPullRequestProvider.ts";25import * as ForgejoPullRequestProvider from "../pullRequest/ForgejoPullRequestProvider.ts";
26import * as SourceControlDiscovery from "./SourceControlDiscovery.ts";26import * as SourceControlDiscovery from "./SourceControlDiscovery.ts";
27import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";27import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
28import { firstNonEmptyLine } from "./SourceControlProviderDiscovery.ts";28import * as ServerSourceControlHost from "./ServerSourceControlHost.ts";
29import { firstNonEmptyLine } from "@t3tools/source-control-core/server/discovery";
2930
30const layerSourceControlProviderRegistryTest = (input: {31const layerSourceControlProviderRegistryTest = (input: {
31 readonly bitbucket: Partial<BitbucketApi.BitbucketApi["Service"]>;32 readonly bitbucket: Partial<BitbucketApi.BitbucketApi["Service"]>;
...@@ -46,6 +47,14 @@ const layerSourceControlProviderRegistryTest = (input: {...@@ -46,6 +47,14 @@ const layerSourceControlProviderRegistryTest = (input: {
46 Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }),47 Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }),
47 Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}),48 Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}),
48 Layer.mock(VcsProcess.VcsProcess)(input.process),49 Layer.mock(VcsProcess.VcsProcess)(input.process),
50 ServerSourceControlHost.layer.pipe(
51 Layer.provide(
52 Layer.merge(
53 Layer.mock(VcsProcess.VcsProcess)(input.process),
54 ServerSettings.ServerSettingsService.layerTest(),
55 ),
56 ),
57 ),
49 ),58 ),
50 ),59 ),
51 );60 );
apps/server/src/sourceControl/SourceControlDiscovery.ts+1-1
...@@ -10,7 +10,7 @@ import * as Option from "effect/Option";...@@ -10,7 +10,7 @@ import * as Option from "effect/Option";
1010
11import * as ServerConfig from "../config.ts";11import * as ServerConfig from "../config.ts";
12import * as VcsProcess from "../vcs/VcsProcess.ts";12import * as VcsProcess from "../vcs/VcsProcess.ts";
13import { detailFromCause, firstNonEmptyLine } from "./SourceControlProviderDiscovery.ts";13import { detailFromCause, firstNonEmptyLine } from "@t3tools/source-control-core/server/discovery";
14import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";14import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
1515
16interface DiscoveryProbe {16interface DiscoveryProbe {
apps/server/src/sourceControl/SourceControlProvider.test.ts deleted-19
...@@ -1,19 +0,0 @@
1import { assert, it } from "@effect/vitest";
2
3import { transportSafeSourceControlErrorValue } from "./SourceControlProvider.ts";
4
5it("removes URL credentials, query parameters, and fragments from error transport values", () => {
6 assert.strictEqual(
7 transportSafeSourceControlErrorValue(
8 "https://user:secret@example.test/org/repo/pull/42?token=secret#discussion",
9 ),
10 "https://example.test/org/repo/pull/42",
11 );
12});
13
14it("normalizes control characters and bounds error transport values", () => {
15 assert.strictEqual(
16 transportSafeSourceControlErrorValue(` owner/repo\n\t${"x".repeat(300)} `),
17 `owner/repo ${"x".repeat(245)}`,
18 );
19});
apps/server/src/sourceControl/SourceControlProvider.ts deleted-145
...@@ -1,145 +0,0 @@
1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import type {
4 ChangeRequest,
5 ChangeRequestState,
6 SourceControlProviderError,
7 SourceControlProviderInfo,
8 SourceControlProviderKind,
9 SourceControlRepositoryCloneUrls,
10 SourceControlRepositoryVisibility,
11} from "@t3tools/contracts";
12
13export interface SourceControlLinkSubject {
14 readonly title: string;
15 readonly body: string | null;
16}
17
18/** Return undefined synchronously for unsupported URLs, without starting a lookup. */
19export type ResolveSourceControlLink = (input: {
20 readonly cwd: string;
21 readonly url: URL;
22}) => Effect.Effect<SourceControlLinkSubject, SourceControlProviderError> | undefined;
23
24export interface SourceControlProviderContext {
25 readonly provider: SourceControlProviderInfo;
26 readonly remoteName: string;
27 readonly remoteUrl: string;
28 /** An explicit web authority can disambiguate Forgejo logins sharing an SSH alias. */
29 readonly requestedHost?: string;
30}
31
32export interface SourceControlRefSelector {
33 readonly refName: string;
34 readonly owner?: string;
35 readonly repository?: string;
36}
37
38const MAX_ERROR_TRANSPORT_VALUE_LENGTH = 256;
39
40/**
41 * Sanitizes user-provided source-control identifiers before attaching them to
42 * contract errors. This is intentionally narrower than request validation: it
43 * only strips URL secrets and bounds diagnostic values sent over transport.
44 */
45export function transportSafeSourceControlErrorValue(value: string): string {
46 let printable = "";
47 for (const character of value) {
48 const codePoint = character.codePointAt(0);
49 printable += codePoint !== undefined && (codePoint < 32 || codePoint === 127) ? " " : character;
50 }
51 const normalized = printable.trim().replace(/\s+/gu, " ");
52
53 let safe = normalized;
54 try {
55 const url = new URL(normalized);
56 url.username = "";
57 url.password = "";
58 url.search = "";
59 url.hash = "";
60 safe = url.toString();
61 } catch {
62 // Plain repository and change-request identifiers are not URLs.
63 }
64
65 return safe.slice(0, MAX_ERROR_TRANSPORT_VALUE_LENGTH);
66}
67
68export function parseSourceControlOwnerRef(
69 headSelector: string,
70): SourceControlRefSelector | undefined {
71 const match = /^([^:/\s]+):(.+)$/u.exec(headSelector.trim());
72 const owner = match?.[1]?.trim();
73 const refName = match?.[2]?.trim();
74 return owner && refName ? { owner, refName } : undefined;
75}
76
77function normalizeSourceBranch(headSelector: string): string {
78 return parseSourceControlOwnerRef(headSelector)?.refName ?? headSelector.trim();
79}
80
81export function sourceBranch(input: {
82 readonly headSelector: string;
83 readonly source?: SourceControlRefSelector;
84}): string {
85 return input.source?.refName ?? normalizeSourceBranch(input.headSelector);
86}
87
88export function sourceControlRefFromInput(input: {
89 readonly headSelector: string;
90 readonly source?: SourceControlRefSelector;
91}): SourceControlRefSelector | undefined {
92 return input.source ?? parseSourceControlOwnerRef(input.headSelector);
93}
94
95export class SourceControlProvider extends Context.Service<
96 SourceControlProvider,
97 {
98 readonly kind: SourceControlProviderKind;
99 /** Optional capability for issue and change-request subjects. */
100 readonly resolveLink?: ResolveSourceControlLink;
101 readonly listChangeRequests: (input: {
102 readonly cwd: string;
103 readonly context?: SourceControlProviderContext;
104 readonly source?: SourceControlRefSelector;
105 readonly headSelector: string;
106 readonly state: ChangeRequestState | "all";
107 readonly limit?: number;
108 }) => Effect.Effect<ReadonlyArray<ChangeRequest>, SourceControlProviderError>;
109 readonly getChangeRequest: (input: {
110 readonly cwd: string;
111 readonly context?: SourceControlProviderContext;
112 readonly reference: string;
113 }) => Effect.Effect<ChangeRequest, SourceControlProviderError>;
114 readonly createChangeRequest: (input: {
115 readonly cwd: string;
116 readonly context?: SourceControlProviderContext;
117 readonly source?: SourceControlRefSelector;
118 readonly target?: SourceControlRefSelector;
119 readonly baseRefName: string;
120 readonly headSelector: string;
121 readonly title: string;
122 readonly bodyFile: string;
123 }) => Effect.Effect<void, SourceControlProviderError>;
124 readonly getRepositoryCloneUrls: (input: {
125 readonly cwd: string;
126 readonly context?: SourceControlProviderContext;
127 readonly repository: string;
128 }) => Effect.Effect<SourceControlRepositoryCloneUrls, SourceControlProviderError>;
129 readonly createRepository: (input: {
130 readonly cwd: string;
131 readonly repository: string;
132 readonly visibility: SourceControlRepositoryVisibility;
133 }) => Effect.Effect<SourceControlRepositoryCloneUrls, SourceControlProviderError>;
134 readonly getDefaultBranch: (input: {
135 readonly cwd: string;
136 readonly context?: SourceControlProviderContext;
137 }) => Effect.Effect<string | null, SourceControlProviderError>;
138 readonly checkoutChangeRequest: (input: {
139 readonly cwd: string;
140 readonly context?: SourceControlProviderContext;
141 readonly reference: string;
142 readonly force?: boolean;
143 }) => Effect.Effect<void, SourceControlProviderError>;
144 }
145>()("t3/sourceControl/SourceControlProvider") {}
apps/server/src/sourceControl/SourceControlProviderDiscovery.ts deleted-335
...@@ -1,335 +0,0 @@
1import * as NodeUtil from "node:util";
2import type {
3 SourceControlProviderAuth,
4 SourceControlProviderDiscoveryItem,
5 SourceControlProviderInfo,
6 SourceControlProviderKind,
7} from "@t3tools/contracts";
8import * as Effect from "effect/Effect";
9import * as Option from "effect/Option";
10
11import type * as SourceControlProvider from "./SourceControlProvider.ts";
12import type * as VcsProcess from "../vcs/VcsProcess.ts";
13
14export interface SourceControlAuthProbeInput {
15 readonly stdout: string;
16 readonly stderr: string;
17 readonly exitCode: VcsProcess.VcsProcessOutput["exitCode"];
18}
19
20export interface SourceControlUnknownRemoteRefinementInput {
21 readonly cwd: string;
22 readonly context: SourceControlProvider.SourceControlProviderContext;
23 readonly auth: SourceControlAuthProbeInput;
24}
25
26interface SourceControlDiscoverySpecBase {
27 readonly kind: SourceControlProviderKind;
28 readonly label: string;
29 readonly installHint: string;
30}
31
32export type SourceControlCliDiscoverySpec = SourceControlDiscoverySpecBase & {
33 readonly type: "cli";
34 readonly executable: string;
35 readonly versionArgs: ReadonlyArray<string>;
36 readonly authArgs: ReadonlyArray<string>;
37 readonly remoteRefinementArgs?: ReadonlyArray<string>;
38 readonly probeTimeoutMs?: number;
39 readonly parseAuth: (input: SourceControlAuthProbeInput) => SourceControlProviderAuth;
40 readonly refineUnknownRemote?: (
41 input: SourceControlUnknownRemoteRefinementInput,
42 ) => SourceControlProviderInfo | null;
43};
44
45export type SourceControlApiDiscoverySpec = SourceControlDiscoverySpecBase & {
46 readonly type: "api";
47 readonly probeAuth: Effect.Effect<SourceControlProviderAuth, never>;
48};
49
50export type SourceControlManagedCliDiscoverySpec = SourceControlDiscoverySpecBase & {
51 readonly type: "managed-cli";
52 readonly probe: (cwd: string) => Effect.Effect<SourceControlProviderDiscoveryItem>;
53 readonly refineUnknownRemote: (input: {
54 readonly cwd: string;
55 readonly context: SourceControlProvider.SourceControlProviderContext;
56 }) => Effect.Effect<SourceControlProviderInfo | null>;
57};
58
59export type SourceControlProviderDiscoverySpec =
60 | SourceControlCliDiscoverySpec
61 | SourceControlManagedCliDiscoverySpec
62 | SourceControlApiDiscoverySpec;
63
64type SourceControlCliRemoteRefinementSpec = SourceControlCliDiscoverySpec & {
65 readonly refineUnknownRemote: NonNullable<SourceControlCliDiscoverySpec["refineUnknownRemote"]>;
66};
67
68// Most provider CLIs answer `--version` in well under a second, so a short budget keeps
69// discovery snappy. Specs whose CLI is known to be slower can raise it via probeTimeoutMs.
70const DEFAULT_PROBE_TIMEOUT_MS = 5_000;
71
72function probeTimeoutMs(spec: SourceControlCliDiscoverySpec): number {
73 return spec.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
74}
75
76interface DiscoveryProbeResult {
77 readonly kind: SourceControlProviderKind;
78 readonly label: string;
79 readonly executable: string;
80 readonly status: "available" | "missing";
81 readonly version: Option.Option<string>;
82 readonly installHint: string;
83 readonly detail: Option.Option<string>;
84}
85
86export function firstNonEmptyLine(text: string): Option.Option<string> {
87 const line = NodeUtil.stripVTControlCharacters(text)
88 .split(/\r?\n/)
89 .map((entry) => entry.trim())
90 .find((entry) => entry.length > 0);
91 return line === undefined ? Option.none() : Option.some(line);
92}
93
94export function detailFromCause(cause: unknown): Option.Option<string> {
95 if (cause instanceof Error && cause.message.trim().length > 0) {
96 return Option.some(cause.message.trim());
97 }
98 return Option.none();
99}
100
101function authAccount(account: string | undefined): Option.Option<string> {
102 const trimmed = account?.trim();
103 return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed);
104}
105
106function authHost(host: string | undefined): Option.Option<string> {
107 const trimmed = host?.trim();
108 return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed);
109}
110
111function authDetail(detail: string | undefined): Option.Option<string> {
112 const trimmed = detail?.trim();
113 return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed);
114}
115
116export function providerAuth(input: {
117 readonly status: SourceControlProviderAuth["status"];
118 readonly account?: string | undefined;
119 readonly host?: string | undefined;
120 readonly detail?: string | undefined;
121}): SourceControlProviderAuth {
122 return {
123 status: input.status,
124 account: authAccount(input.account),
125 host: authHost(input.host),
126 detail: authDetail(input.detail),
127 };
128}
129
130function unknownAuth(detail?: string): SourceControlProviderAuth {
131 return providerAuth({ status: "unknown", detail });
132}
133
134export function combinedAuthOutput(input: SourceControlAuthProbeInput): string {
135 const parts: string[] = [];
136 for (const entry of [input.stdout, input.stderr]) {
137 if (entry.trim().length > 0) {
138 parts.push(entry);
139 }
140 }
141 return parts.join("\n");
142}
143
144function sanitizedAuthLines(text: string): ReadonlyArray<string> {
145 const lines: string[] = [];
146 for (const entry of text.split(/\r?\n/)) {
147 const line = entry.trim();
148 if (line.length === 0) continue;
149 if (/^[-\s]*token(?:\s+scopes?)?:/iu.test(line)) continue;
150 lines.push(line);
151 }
152 return lines;
153}
154
155export function firstSafeAuthLine(text: string): string | undefined {
156 return sanitizedAuthLines(text)[0];
157}
158
159export function parseCliHost(text: string): string | undefined {
160 return sanitizedAuthLines(text)
161 .map((line) => line.replace(/^[^a-z0-9]+/iu, ""))
162 .find((line) => /^[a-z0-9][a-z0-9.-]*(?::\d+)?$/iu.test(line));
163}
164
165export function matchFirst(text: string, patterns: ReadonlyArray<RegExp>): string | undefined {
166 for (const pattern of patterns) {
167 const match = pattern.exec(text);
168 const value = match?.[1]?.trim();
169 if (value && value.length > 0) return value;
170 }
171 return undefined;
172}
173
174function isCliRemoteRefinementSpec(
175 spec: SourceControlProviderDiscoverySpec,
176): spec is SourceControlCliRemoteRefinementSpec {
177 return spec.type === "cli" && spec.refineUnknownRemote !== undefined;
178}
179
180function probeCli(input: {
181 readonly spec: SourceControlCliDiscoverySpec;
182 readonly process: VcsProcess.VcsProcess["Service"];
183 readonly cwd: string;
184}): Effect.Effect<DiscoveryProbeResult> {
185 return input.process
186 .run({
187 operation: "source-control.discovery.probe",
188 command: input.spec.executable,
189 args: input.spec.versionArgs,
190 cwd: input.cwd,
191 timeoutMs: probeTimeoutMs(input.spec),
192 maxOutputBytes: 8_000,
193 appendTruncationMarker: true,
194 })
195 .pipe(
196 Effect.map(
197 (result) =>
198 ({
199 kind: input.spec.kind,
200 label: input.spec.label,
201 executable: input.spec.executable,
202 status: "available" as const,
203 version: Option.orElse(firstNonEmptyLine(result.stdout), () =>
204 firstNonEmptyLine(result.stderr),
205 ),
206 installHint: input.spec.installHint,
207 detail: Option.none<string>(),
208 }) satisfies DiscoveryProbeResult,
209 ),
210 Effect.catch((cause) =>
211 Effect.succeed({
212 kind: input.spec.kind,
213 label: input.spec.label,
214 executable: input.spec.executable,
215 status: "missing" as const,
216 version: Option.none<string>(),
217 installHint: input.spec.installHint,
218 detail: detailFromCause(cause),
219 } satisfies DiscoveryProbeResult),
220 ),
221 );
222}
223
224export function probeSourceControlProvider(input: {
225 readonly spec: SourceControlProviderDiscoverySpec;
226 readonly process: VcsProcess.VcsProcess["Service"];
227 readonly cwd: string;
228}): Effect.Effect<SourceControlProviderDiscoveryItem> {
229 if (input.spec.type === "managed-cli") return input.spec.probe(input.cwd);
230 if (input.spec.type === "api") {
231 return input.spec.probeAuth.pipe(
232 Effect.map(
233 (auth) =>
234 ({
235 kind: input.spec.kind,
236 label: input.spec.label,
237 status: "available" as const,
238 version: Option.none<string>(),
239 installHint: input.spec.installHint,
240 detail: Option.none<string>(),
241 auth,
242 }) satisfies SourceControlProviderDiscoveryItem,
243 ),
244 );
245 }
246
247 const spec = input.spec;
248
249 return probeCli({
250 spec,
251 process: input.process,
252 cwd: input.cwd,
253 }).pipe(
254 Effect.flatMap((item) => {
255 if (item.status !== "available") {
256 return Effect.succeed({
257 ...item,
258 auth: unknownAuth("Hosting integration command was not found on the server PATH."),
259 } satisfies SourceControlProviderDiscoveryItem);
260 }
261
262 return input.process
263 .run({
264 operation: "source-control.discovery.auth",
265 command: spec.executable,
266 args: spec.authArgs,
267 cwd: input.cwd,
268 allowNonZeroExit: true,
269 timeoutMs: probeTimeoutMs(spec),
270 maxOutputBytes: 8_000,
271 appendTruncationMarker: true,
272 })
273 .pipe(
274 Effect.map(
275 (result) =>
276 ({
277 ...item,
278 auth: spec.parseAuth(result),
279 }) satisfies SourceControlProviderDiscoveryItem,
280 ),
281 Effect.catch((cause) =>
282 Effect.succeed({
283 ...item,
284 auth: unknownAuth(Option.getOrUndefined(detailFromCause(cause))),
285 } satisfies SourceControlProviderDiscoveryItem),
286 ),
287 );
288 }),
289 );
290}
291
292export const refineUnknownRemoteProvider = Effect.fn("refineUnknownRemoteProvider")(
293 function* (input: {
294 readonly specs: ReadonlyArray<SourceControlProviderDiscoverySpec>;
295 readonly process: VcsProcess.VcsProcess["Service"];
296 readonly cwd: string;
297 readonly context: SourceControlProvider.SourceControlProviderContext | null;
298 }): Effect.fn.Return<SourceControlProvider.SourceControlProviderContext | null> {
299 if (input.context === null || input.context.provider.kind !== "unknown") {
300 return input.context;
301 }
302 const context = input.context;
303
304 const providers = yield* Effect.forEach(input.specs, (spec) => {
305 if (spec.type === "managed-cli") {
306 return spec.refineUnknownRemote({ cwd: input.cwd, context });
307 }
308 if (!isCliRemoteRefinementSpec(spec)) return Effect.succeed(null);
309 return input.process
310 .run({
311 operation: "source-control.discovery.refine-unknown-remote",
312 command: spec.executable,
313 args: spec.remoteRefinementArgs ?? spec.authArgs,
314 cwd: input.cwd,
315 allowNonZeroExit: true,
316 timeoutMs: probeTimeoutMs(spec),
317 maxOutputBytes: 8_000,
318 appendTruncationMarker: true,
319 })
320 .pipe(
321 Effect.map((auth) =>
322 spec.refineUnknownRemote({
323 cwd: input.cwd,
324 context,
325 auth,
326 }),
327 ),
328 Effect.orElseSucceed(() => null),
329 );
330 });
331 const provider = providers.find((candidate) => candidate !== null);
332
333 return provider ? { ...context, provider } : context;
334 },
335);
apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts+6
...@@ -21,6 +21,7 @@ import * as GitHubCredentials from "./GitHubCredentials.ts";...@@ -21,6 +21,7 @@ import * as GitHubCredentials from "./GitHubCredentials.ts";
21import * as GitLabCli from "./GitLabCli.ts";21import * as GitLabCli from "./GitLabCli.ts";
22import * as ForgejoCli from "./ForgejoCli.ts";22import * as ForgejoCli from "./ForgejoCli.ts";
23import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";23import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
24import * as ServerSourceControlHost from "./ServerSourceControlHost.ts";
2425
25const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z");26const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z");
2627
...@@ -96,6 +97,11 @@ function makeRegistry(input: {...@@ -96,6 +97,11 @@ function makeRegistry(input: {
96 NodeServices.layer,97 NodeServices.layer,
97 layerRegistry,98 layerRegistry,
98 layerProcess,99 layerProcess,
100 ServerSourceControlHost.layer.pipe(
101 Layer.provide(
102 Layer.merge(layerProcess, ServerSettings.ServerSettingsService.layerTest()),
103 ),
104 ),
99 Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}),105 Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}),
100 Layer.mock(BitbucketApi.BitbucketApi)({}),106 Layer.mock(BitbucketApi.BitbucketApi)({}),
101 ServerSettings.ServerSettingsService.layerTest(),107 ServerSettings.ServerSettingsService.layerTest(),
apps/server/src/sourceControl/SourceControlProviderRegistry.ts+5-4
...@@ -16,15 +16,16 @@ import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvide...@@ -16,15 +16,16 @@ import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvide
16import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts";16import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts";
17import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts";17import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts";
18import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts";18import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts";
19import * as SourceControlProvider from "./SourceControlProvider.ts";19import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider";
20import {20import {
21 probeSourceControlProvider,21 probeSourceControlProvider,
22 refineUnknownRemoteProvider,22 refineUnknownRemoteProvider,
23 type SourceControlProviderDiscoverySpec,23 type SourceControlProviderDiscoverySpec,
24} from "./SourceControlProviderDiscovery.ts";24} from "@t3tools/source-control-core/server/discovery";
25import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";
26
25import * as ServerConfig from "../config.ts";27import * as ServerConfig from "../config.ts";
26import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";28import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
27import * as VcsProcess from "../vcs/VcsProcess.ts";
2829
29const PROVIDER_DETECTION_CACHE_CAPACITY = 2_048;30const PROVIDER_DETECTION_CACHE_CAPACITY = 2_048;
30const PROVIDER_DETECTION_CACHE_TTL = Duration.seconds(5);31const PROVIDER_DETECTION_CACHE_TTL = Duration.seconds(5);
...@@ -201,7 +202,7 @@ function bindProviderContext(...@@ -201,7 +202,7 @@ function bindProviderContext(
201export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWithProviders")(202export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWithProviders")(
202 function* (registrations: ReadonlyArray<SourceControlProviderRegistration>) {203 function* (registrations: ReadonlyArray<SourceControlProviderRegistration>) {
203 const config = yield* ServerConfig.ServerConfig;204 const config = yield* ServerConfig.ServerConfig;
204 const process = yield* VcsProcess.VcsProcess;205 const { process } = yield* SourceControlHost.SourceControlHost;
205 const vcsRegistry = yield* VcsDriverRegistry.VcsDriverRegistry;206 const vcsRegistry = yield* VcsDriverRegistry.VcsDriverRegistry;
206 const providers = new Map<207 const providers = new Map<
207 SourceControlProviderKind,208 SourceControlProviderKind,
apps/server/src/sourceControl/SourceControlRateLimit.test.ts deleted-138
...@@ -1,138 +0,0 @@
1import { assert, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as TestClock from "effect/testing/TestClock";
4
5import * as SourceControlRateLimit from "./SourceControlRateLimit.ts";
6
7const github = { provider: "github" as const, host: "github.com" };
8
9it.effect("isolates cooldowns for verified credentials on the same host", () =>
10 Effect.gen(function* () {
11 const limits = yield* SourceControlRateLimit.SourceControlRateLimit;
12 yield* limits
13 .recordRateLimit({ ...github, lease: 0 })
14 .pipe(Effect.provideService(SourceControlRateLimit.CredentialScope, "first"));
15 yield* limits
16 .check(github)
17 .pipe(Effect.provideService(SourceControlRateLimit.CredentialScope, "second"));
18 const error = yield* limits
19 .check(github)
20 .pipe(Effect.provideService(SourceControlRateLimit.CredentialScope, "first"), Effect.flip);
21 assert.strictEqual(error._tag, "SourceControlRateLimitPausedError");
22 }).pipe(Effect.provide(SourceControlRateLimit.layer)),
23);
24
25it("parses Retry-After seconds and HTTP dates", () => {
26 assert.equal(SourceControlRateLimit.retryAtFromHeader("120", 1_000), 121_000);
27 assert.equal(
28 SourceControlRateLimit.retryAtFromHeader("Thu, 01 Jan 1970 00:02:01 GMT", 1_000),
29 121_000,
30 );
31 assert.isUndefined(SourceControlRateLimit.retryAtFromHeader("later", 1_000));
32});
33
34it.effect("backs off repeated rate limits until a successful request", () =>
35 Effect.gen(function* () {
36 yield* TestClock.setTime(0);
37 const limits = yield* SourceControlRateLimit.SourceControlRateLimit;
38
39 const firstLease = yield* limits.check(github);
40 yield* limits.recordRateLimit({ ...github, lease: firstLease });
41 const firstPause = yield* Effect.flip(limits.check(github));
42 assert.deepInclude(firstPause, {
43 _tag: "SourceControlRateLimitPausedError",
44 provider: "github",
45 host: "github.com",
46 retryAt: 30_000,
47 });
48 assert.equal(
49 firstPause.detail,
50 "github requests to github.com are paused until the rate limit resets.",
51 );
52 assert.equal(firstPause.message, firstPause.detail);
53
54 yield* TestClock.adjust("30 seconds");
55 const secondLease = yield* limits.check(github);
56 yield* limits.recordRateLimit({ ...github, lease: secondLease });
57 assert.equal((yield* Effect.flip(limits.check(github))).retryAt, 90_000);
58
59 yield* TestClock.adjust("60 seconds");
60 const successLease = yield* limits.check(github);
61 yield* limits.recordSuccess({ ...github, lease: successLease });
62 const resetLease = yield* limits.check(github);
63 yield* limits.recordRateLimit({ ...github, lease: resetLease });
64 assert.equal((yield* Effect.flip(limits.check(github))).retryAt, 120_000);
65 }).pipe(Effect.provide(SourceControlRateLimit.layer)),
66);
67
68it.effect("honors a provider reset time", () =>
69 Effect.gen(function* () {
70 yield* TestClock.setTime(1_000);
71 const limits = yield* SourceControlRateLimit.SourceControlRateLimit;
72 const lease = yield* limits.check(github);
73
74 yield* limits.recordRateLimit({ ...github, lease, retryAt: 121_000 });
75 assert.equal((yield* Effect.flip(limits.check(github))).retryAt, 121_000);
76
77 yield* TestClock.adjust("119999 millis");
78 assert.equal((yield* Effect.flip(limits.check(github))).retryAt, 121_000);
79 yield* TestClock.adjust("1 millis");
80 assert.equal(yield* limits.check(github), 1);
81 }).pipe(Effect.provide(SourceControlRateLimit.layer)),
82);
83
84it.effect("lets an interactive request through without clearing an active pause", () =>
85 Effect.gen(function* () {
86 yield* TestClock.setTime(1_000);
87 const limits = yield* SourceControlRateLimit.SourceControlRateLimit;
88 const lease = yield* limits.check(github);
89 yield* limits.recordRateLimit({ ...github, lease, retryAt: 121_000 });
90
91 const interactiveLease = yield* limits.check(github, { allowPaused: true });
92 yield* limits.recordSuccess({ ...github, lease: interactiveLease });
93
94 assert.equal(interactiveLease, 1);
95 assert.equal((yield* Effect.flip(limits.check(github))).retryAt, 121_000);
96
97 yield* limits.recordRateLimit({ ...github, lease: interactiveLease, retryAt: 61_000 });
98 assert.equal((yield* Effect.flip(limits.check(github))).retryAt, 121_000);
99 }).pipe(Effect.provide(SourceControlRateLimit.layer)),
100);
101
102it.effect("keeps providers and hosts isolated", () =>
103 Effect.gen(function* () {
104 const limits = yield* SourceControlRateLimit.SourceControlRateLimit;
105 const lease = yield* limits.check(github);
106 yield* limits.recordRateLimit({ ...github, lease });
107
108 assert.equal(yield* limits.check({ provider: "gitlab", host: "github.com" }), 0);
109 assert.equal(yield* limits.check({ provider: "github", host: "github.example.com" }), 0);
110 }).pipe(Effect.provide(SourceControlRateLimit.layer)),
111);
112
113it.effect("does not let an older success clear a concurrent pause", () =>
114 Effect.gen(function* () {
115 const limits = yield* SourceControlRateLimit.SourceControlRateLimit;
116 const firstLease = yield* limits.check(github);
117 const concurrentLease = yield* limits.check(github);
118
119 yield* limits.recordRateLimit({ ...github, lease: firstLease });
120 yield* limits.recordSuccess({ ...github, lease: concurrentLease });
121
122 assert.equal((yield* Effect.flip(limits.check(github))).retryAt, 30_000);
123 }).pipe(Effect.provide(SourceControlRateLimit.layer)),
124);
125
126it.effect("keeps a fresh provider reset from an older request", () =>
127 Effect.gen(function* () {
128 yield* TestClock.setTime(0);
129 const limits = yield* SourceControlRateLimit.SourceControlRateLimit;
130 const staleLease = yield* limits.check(github);
131 yield* limits.recordRateLimit({ ...github, lease: staleLease });
132
133 yield* TestClock.adjust("31 seconds");
134 yield* limits.recordRateLimit({ ...github, lease: staleLease, retryAt: 61_000 });
135
136 assert.equal((yield* Effect.flip(limits.check(github))).retryAt, 61_000);
137 }).pipe(Effect.provide(SourceControlRateLimit.layer)),
138);
apps/server/src/sourceControl/SourceControlRateLimit.ts deleted-168
...@@ -1,168 +0,0 @@
1import * as Clock from "effect/Clock";
2import * as Context from "effect/Context";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Ref from "effect/Ref";
7import * as Schema from "effect/Schema";
8import {
9 SourceControlProviderKind as SourceControlProviderKindSchema,
10 type SourceControlProviderKind,
11} from "@t3tools/contracts";
12
13const FALLBACK_COOLDOWN = Duration.seconds(30);
14const MAX_FALLBACK_COOLDOWN = Duration.minutes(15);
15
16export const CredentialScope = Context.Reference<string>("t3/sourceControl/CredentialScope", {
17 defaultValue: () => "",
18});
19
20interface RateLimitKey {
21 readonly provider: SourceControlProviderKind;
22 readonly host: string;
23}
24
25interface RateLimitLease extends RateLimitKey {
26 readonly lease: number;
27}
28
29interface RateLimitEntry {
30 readonly attempt: number;
31 readonly generation: number;
32 readonly retryAt: number;
33}
34
35export class SourceControlRateLimitPausedError extends Schema.TaggedError<SourceControlRateLimitPausedError>()(
36 "SourceControlRateLimitPausedError",
37 {
38 provider: SourceControlProviderKindSchema,
39 host: Schema.String,
40 retryAt: Schema.Number,
41 },
42) {
43 get detail(): string {
44 return `${this.provider} requests to ${this.host} are paused until the rate limit resets.`;
45 }
46
47 override get message(): string {
48 return this.detail;
49 }
50}
51
52export class SourceControlRateLimit extends Context.Service<
53 SourceControlRateLimit,
54 {
55 readonly check: (
56 key: RateLimitKey,
57 options?: { readonly allowPaused: boolean },
58 ) => Effect.Effect<number, SourceControlRateLimitPausedError>;
59 readonly recordRateLimit: (
60 input: RateLimitLease & { readonly retryAt?: number | undefined },
61 ) => Effect.Effect<void>;
62 readonly recordSuccess: (input: RateLimitLease) => Effect.Effect<void>;
63 }
64>()("t3/sourceControl/SourceControlRateLimit") {}
65
66function normalizedKey(key: RateLimitKey, scope: string): string {
67 return `${key.provider}\0${key.host.trim().toLowerCase()}\0${scope}`;
68}
69
70function fallbackCooldownMs(attempt: number): number {
71 return Math.min(
72 Duration.toMillis(FALLBACK_COOLDOWN) * 2 ** Math.max(0, attempt - 1),
73 Duration.toMillis(MAX_FALLBACK_COOLDOWN),
74 );
75}
76
77export function retryAtFromHeader(value: string | undefined, now: number): number | undefined {
78 if (value === undefined) return undefined;
79 const normalized = value.trim();
80 if (/^\d+$/u.test(normalized)) {
81 const seconds = Number(normalized);
82 const retryAt = now + seconds * 1_000;
83 return Number.isSafeInteger(retryAt) ? retryAt : undefined;
84 }
85 const retryAt = Date.parse(normalized);
86 return Number.isFinite(retryAt) && retryAt > now ? retryAt : undefined;
87}
88
89/** @public Service construction is part of the canonical Effect module API. */
90export const make = Effect.gen(function* () {
91 const entries = yield* Ref.make<ReadonlyMap<string, RateLimitEntry>>(new Map());
92
93 const check: SourceControlRateLimit["Service"]["check"] = Effect.fn(
94 "SourceControlRateLimit.check",
95 )(function* (input, options) {
96 const now = yield* Clock.currentTimeMillis;
97 const key = normalizedKey(input, yield* CredentialScope);
98 const entry = (yield* Ref.get(entries)).get(key);
99 if (entry !== undefined && entry.retryAt > now && options?.allowPaused !== true) {
100 return yield* new SourceControlRateLimitPausedError({
101 provider: input.provider,
102 host: input.host.trim().toLowerCase(),
103 retryAt: entry.retryAt,
104 });
105 }
106 return entry?.generation ?? 0;
107 });
108
109 const recordRateLimit: SourceControlRateLimit["Service"]["recordRateLimit"] = Effect.fn(
110 "SourceControlRateLimit.recordRateLimit",
111 )(function* (input) {
112 const now = yield* Clock.currentTimeMillis;
113 const key = normalizedKey(input, yield* CredentialScope);
114 yield* Ref.update(entries, (current) => {
115 const previous = current.get(key);
116 if (previous !== undefined && previous.generation > input.lease) {
117 if (previous.retryAt <= now && (input.retryAt === undefined || input.retryAt <= now)) {
118 return current;
119 }
120 const retryAt =
121 input.retryAt !== undefined && input.retryAt > previous.retryAt
122 ? input.retryAt
123 : previous.retryAt;
124 if (retryAt === previous.retryAt) return current;
125 const next = new Map(current);
126 next.set(key, { ...previous, retryAt });
127 return next;
128 }
129
130 const attempt = (previous?.attempt ?? 0) + 1;
131 const proposedRetryAt =
132 input.retryAt !== undefined && input.retryAt > now
133 ? input.retryAt
134 : now + fallbackCooldownMs(attempt);
135 const retryAt =
136 previous !== undefined && previous.retryAt > now
137 ? Math.max(previous.retryAt, proposedRetryAt)
138 : proposedRetryAt;
139 const next = new Map(current);
140 next.set(key, {
141 attempt,
142 generation: Math.max(previous?.generation ?? 0, input.lease) + 1,
143 retryAt,
144 });
145 return next;
146 });
147 });
148
149 const recordSuccess: SourceControlRateLimit["Service"]["recordSuccess"] = Effect.fn(
150 "SourceControlRateLimit.recordSuccess",
151 )(function* (input) {
152 const now = yield* Clock.currentTimeMillis;
153 const key = normalizedKey(input, yield* CredentialScope);
154 yield* Ref.update(entries, (current) => {
155 const previous = current.get(key);
156 if (previous === undefined || previous.generation !== input.lease || previous.retryAt > now) {
157 return current;
158 }
159 const next = new Map(current);
160 next.set(key, { attempt: 0, generation: previous.generation, retryAt: 0 });
161 return next;
162 });
163 });
164
165 return SourceControlRateLimit.of({ check, recordRateLimit, recordSuccess });
166});
167
168export const layer = Layer.effect(SourceControlRateLimit, make);
apps/server/src/sourceControl/SourceControlRepositoryService.test.ts+1-1
...@@ -15,7 +15,7 @@ import * as ServerConfig from "../config.ts";...@@ -15,7 +15,7 @@ import * as ServerConfig from "../config.ts";
15import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";15import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
16import * as BitbucketApi from "./BitbucketApi.ts";16import * as BitbucketApi from "./BitbucketApi.ts";
17import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts";17import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts";
18import type * as SourceControlProvider from "./SourceControlProvider.ts";18import type * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider";
19import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";19import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
20import * as SourceControlRepositoryService from "./SourceControlRepositoryService.ts";20import * as SourceControlRepositoryService from "./SourceControlRepositoryService.ts";
2121
apps/server/src/sourceControl/githubQuota.test.ts+1-1
...@@ -3,7 +3,7 @@ import * as Effect from "effect/Effect";...@@ -3,7 +3,7 @@ import * as Effect from "effect/Effect";
3import * as TestClock from "effect/testing/TestClock";3import * as TestClock from "effect/testing/TestClock";
44
5import * as GitHubQuota from "./githubQuota.ts";5import * as GitHubQuota from "./githubQuota.ts";
6import { CredentialScope } from "./SourceControlRateLimit.ts";6import { CredentialScope } from "@t3tools/source-control-core/server/SourceControlRateLimit";
77
8const RESET = Date.parse("2099-08-13T14:00:00Z");8const RESET = Date.parse("2099-08-13T14:00:00Z");
9const headers = (remaining: number, resource = "graphql", reset = RESET) => ({9const headers = (remaining: number, resource = "graphql", reset = RESET) => ({
apps/server/src/sourceControl/githubQuota.ts+1-1
...@@ -4,7 +4,7 @@ import * as Effect from "effect/Effect";...@@ -4,7 +4,7 @@ import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";4import * as Layer from "effect/Layer";
5import * as Ref from "effect/Ref";5import * as Ref from "effect/Ref";
66
7import * as SourceControlRateLimit from "./SourceControlRateLimit.ts";7import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit";
88
9/** The share of each quota a background read leaves for a user's next click. */9/** The share of each quota a background read leaves for a user's next click. */
10const RESERVE_RATIO = 0.1;10const RESERVE_RATIO = 0.1;
apps/server/src/ws.ts+2
...@@ -235,6 +235,7 @@ import * as GitHubApi from "./sourceControl/GitHubApi.ts";...@@ -235,6 +235,7 @@ import * as GitHubApi from "./sourceControl/GitHubApi.ts";
235import * as GitLabCli from "./sourceControl/GitLabCli.ts";235import * as GitLabCli from "./sourceControl/GitLabCli.ts";
236import * as ForgejoCli from "./sourceControl/ForgejoCli.ts";236import * as ForgejoCli from "./sourceControl/ForgejoCli.ts";
237import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts";237import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts";
238import * as ServerSourceControlHost from "./sourceControl/ServerSourceControlHost.ts";
238import * as GitVcsDriver from "./vcs/GitVcsDriver.ts";239import * as GitVcsDriver from "./vcs/GitVcsDriver.ts";
239import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts";240import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts";
240import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts";241import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts";
...@@ -3209,6 +3210,7 @@ export const layer = Layer.unwrap(...@@ -3209,6 +3210,7 @@ export const layer = Layer.unwrap(
3209 GitHubApi.layerWithDependencies,3210 GitHubApi.layerWithDependencies,
3210 GitLabCli.layer,3211 GitLabCli.layer,
3211 ForgejoCli.layer,3212 ForgejoCli.layer,
3213 ServerSourceControlHost.layer,
3212 ),3214 ),
3213 ),3215 ),
3214 Layer.provideMerge(GitVcsDriver.layer),3216 Layer.provideMerge(GitVcsDriver.layer),
docs/internals/pull-request-file-revisions.md+1-1
...@@ -17,7 +17,7 @@ a project the token cannot see....@@ -17,7 +17,7 @@ a project the token cannot see.
1717
18Below the boundary, in a patch parser or a response decoder, a missing path means this revision18Below the boundary, in a patch parser or a response decoder, a missing path means this revision
19does not carry that file. Above it, in19does not carry that file. Above it, in
20[`ProviderFileRevisions`](../../apps/server/src/pullRequest/PullRequestProvider.ts), a missing path20[`ProviderFileRevisions`](../../packages/source-control-core/src/server/PullRequestProvider.ts), a missing path
21means the read could not say. A provider's `getFileRevisions` converts on the way up: a path its21means the read could not say. A provider's `getFileRevisions` converts on the way up: a path its
22host looked at and has no version for arrives as the empty string, and absence is kept for what22host looked at and has no version for arrives as the empty string, and absence is kept for what
23the provider never got to look at. Absence below the boundary does not survive it.23the provider never got to look at. Absence below the boundary does not survive it.
package.json+1-1
...@@ -28,7 +28,7 @@...@@ -28,7 +28,7 @@
28 "tc": "vp run -r --concurrency-limit 2 typecheck",28 "tc": "vp run -r --concurrency-limit 2 typecheck",
29 "lint": "vp lint --report-unused-disable-directives",29 "lint": "vp lint --report-unused-disable-directives",
30 "knip": "knip --preprocessor ./scripts/knip-schemas.ts",30 "knip": "knip --preprocessor ./scripts/knip-schemas.ts",
31 "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-acp-registry --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --workspace packages/provider-grok --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints",31 "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-acp-registry --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --workspace packages/provider-grok --workspace packages/source-control-core --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints",
32 "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts",32 "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts",
33 "lint:mobile": "node scripts/mobile-native-static-check.ts",33 "lint:mobile": "node scripts/mobile-native-static-check.ts",
34 "test": "vp run -r test",34 "test": "vp run -r test",
packages/source-control-core/package.json created+48
...@@ -0,0 +1,48 @@
1{
2 "name": "@t3tools/source-control-core",
3 "private": true,
4 "type": "module",
5 "exports": {
6 "./server/discovery": {
7 "types": "./src/server/discovery.ts",
8 "import": "./src/server/discovery.ts"
9 },
10 "./server/mergeMessage": {
11 "types": "./src/server/mergeMessage.ts",
12 "import": "./src/server/mergeMessage.ts"
13 },
14 "./server/pullRequestChecks": {
15 "types": "./src/server/pullRequestChecks.ts",
16 "import": "./src/server/pullRequestChecks.ts"
17 },
18 "./server/PullRequestProvider": {
19 "types": "./src/server/PullRequestProvider.ts",
20 "import": "./src/server/PullRequestProvider.ts"
21 },
22 "./server/SourceControlHost": {
23 "types": "./src/server/SourceControlHost.ts",
24 "import": "./src/server/SourceControlHost.ts"
25 },
26 "./server/SourceControlProvider": {
27 "types": "./src/server/SourceControlProvider.ts",
28 "import": "./src/server/SourceControlProvider.ts"
29 },
30 "./server/SourceControlRateLimit": {
31 "types": "./src/server/SourceControlRateLimit.ts",
32 "import": "./src/server/SourceControlRateLimit.ts"
33 }
34 },
35 "scripts": {
36 "typecheck": "tsc --noEmit",
37 "test": "vp test run --config ../../vite.config.ts --dir ."
38 },
39 "dependencies": {
40 "@t3tools/contracts": "workspace:*",
41 "effect": "catalog:"
42 },
43 "devDependencies": {
44 "@effect/vitest": "catalog:",
45 "@types/node": "catalog:",
46 "vite-plus": "catalog:"
47 }
48}
packages/source-control-core/src/server/PullRequestProvider.ts created+702
...@@ -0,0 +1,702 @@
1import * as Effect from "effect/Effect";
2import * as Schema from "effect/Schema";
3import type {
4 PullRequestStackMembership,
5 PullRequestAction,
6 PullRequestStackHead,
7 PullRequestActor,
8 PullRequestBaseComparison,
9 PullRequestCapabilities,
10 PullRequestChecksState,
11 PullRequestCheck,
12 PullRequestChecks,
13 PullRequestComment,
14 PullRequestFileViewed,
15 PullRequestCommit,
16 PullRequestInvolvement,
17 PullRequestLabel,
18 PullRequestListFilters,
19 PullRequestListState,
20 PullRequestMergeCapabilities,
21 PullRequestMergeMethod,
22 PullRequestMergeability,
23 PullRequestOmittedFileStat,
24 PullRequestReaction,
25 PullRequestReactionContent,
26 PullRequestReviewCommentDraft,
27 PullRequestReviewDecision,
28 PullRequestReviewThread,
29 PullRequestThreadCommentsResult,
30 PullRequestReviewVerdict,
31 PullRequestReviewerCandidateList,
32 PullRequestReviewerKind,
33 PullRequestLabelCandidateList,
34 PullRequestState,
35 PullRequestPreview,
36 PullRequestUpdateMethod,
37 PullRequestViewerPermissions,
38 SourceControlProviderKind,
39} from "@t3tools/contracts";
40import { SourceControlProviderKind as SourceControlProviderKindSchema } from "@t3tools/contracts";
41
42/**
43 * The one failure shape every provider reports, so the service can decide what a failure means
44 * without knowing which CLI or API produced it.
45 *
46 * `reason` is the part the service acts on: a missing or unauthenticated tool disables the
47 * provider for the whole workspace, a rate limit pauses its host, and anything else is specific
48 * to the request.
49 */
50export class PullRequestProviderError extends Schema.TaggedError<PullRequestProviderError>()(
51 "PullRequestProviderError",
52 {
53 provider: SourceControlProviderKindSchema,
54 operation: Schema.String,
55 reason: Schema.Literals([
56 "missing-tool",
57 "unauthenticated",
58 "rate-limited",
59 "not-found",
60 "failed",
61 ]),
62 detail: Schema.String,
63 retryAt: Schema.optional(Schema.Number),
64 cause: Schema.optional(Schema.Defect()),
65 },
66) {
67 override get message(): string {
68 return `${this.provider} failed in ${this.operation}: ${this.detail}`;
69 }
70}
71
72/**
73 * `status` moves with the state, mergeability, head, or checks, which the detail read reports;
74 * `remarks` moves with comments and reviews, which only the activity read reports.
75 */
76export interface ProviderChangeRequestWatchFingerprint {
77 readonly status: string;
78 readonly remarks: string;
79}
80
81export interface PullRequestProviderFailure {
82 readonly reason: PullRequestProviderError["reason"];
83 readonly retryAt?: number | undefined;
84}
85
86/** A change request as the provider sees it, before the service attaches project context. */
87export interface ProviderChangeRequest {
88 readonly stack?: PullRequestStackMembership;
89 readonly number: number;
90 readonly title: string;
91 readonly url: string;
92 readonly author: PullRequestActor | null;
93 readonly headBranch: string;
94 readonly headRepositoryNameWithOwner?: string | null;
95 readonly baseBranch: string;
96 readonly state: PullRequestState;
97 readonly isDraft: boolean;
98 readonly mergeability: PullRequestMergeability;
99 readonly additions: number;
100 readonly deletions: number;
101 readonly createdAt: string;
102 readonly closedAt?: string | null;
103 readonly mergedAt?: string | null;
104 readonly updatedAt: string;
105 /** Accounts with a review requested. Team-level requests are excluded by each provider. */
106 readonly reviewRequestLogins: ReadonlyArray<string>;
107 readonly labels: ReadonlyArray<PullRequestLabel>;
108 /** Absent from a host that does not summarise its reviews, which is every host but GitHub. */
109 readonly reviewDecision?: PullRequestReviewDecision | null | undefined;
110 /** Absent from a host that reports no check rollup on its listings. */
111 readonly checksState?: PullRequestChecksState | null | undefined;
112}
113
114/** The fields needed to keep a linked thread's pull request status live. */
115export interface ProviderChangeRequestSummary {
116 readonly number: number;
117 readonly title: string;
118 readonly url: string;
119 readonly headBranch: string;
120 readonly baseBranch: string;
121 readonly state: PullRequestState;
122 /** Present when the host says an open pull request is still a draft. */
123 readonly isDraft?: boolean;
124 readonly closedAt?: string | null;
125 readonly mergedAt?: string | null;
126 readonly updatedAt: string;
127 /** Overview fields, present where the host's single read returns them at no extra cost. */
128 readonly author?: PullRequestActor | null | undefined;
129 readonly additions?: number | undefined;
130 readonly deletions?: number | undefined;
131 readonly changedFiles?: number | undefined;
132 readonly reviewDecision?: PullRequestReviewDecision | null | undefined;
133 readonly checksState?: PullRequestChecksState | null | undefined;
134 readonly mergeability?: PullRequestMergeability | undefined;
135 /**
136 * The host-native stack the pull request sits in, from the same read. Null when the host says
137 * it is in none; absent when the read did not ask.
138 */
139 readonly stack?: PullRequestStackMembership | null | undefined;
140}
141
142/** One layer of a host-native stack, bottom to top order is the array's. */
143export interface ProviderChangeRequestStackLayer {
144 readonly title?: string;
145 readonly isDraft?: boolean;
146 readonly headSha?: string;
147 readonly number: number;
148 readonly headBranch: string;
149 readonly state: PullRequestState;
150}
151
152/**
153 * A host-native stack: an ordered set of change requests the host itself merges and retargets as
154 * a unit. Only GitHub offers one today; the neutral shape lets the sync reactor and the UI stay
155 * ignorant of which host said so.
156 */
157export interface ProviderChangeRequestStack {
158 readonly id: string;
159 readonly number: number;
160 readonly url: string;
161 readonly base: string;
162 readonly layers: ReadonlyArray<ProviderChangeRequestStackLayer>;
163}
164
165export interface ProviderChangeRequestPage {
166 readonly items: ReadonlyArray<ProviderChangeRequest>;
167 /** True when the host has more rows than the page size asked for. */
168 readonly truncated: boolean;
169 /**
170 * Optional count-based cursor advance. Most hosts advance by the rows delivered after local
171 * de-duplication; an offset-paged host may need to count malformed raw rows it consumed too.
172 */
173 readonly cursorAdvance?: number;
174 /**
175 * This page can be carried on from, so the service may hand the caller a cursor for it. False
176 * where the host answered in an order a cursor means nothing in, which leaves a larger `limit`
177 * as the only way to the rest — what every listing did before there were cursors.
178 */
179 readonly continues: boolean;
180}
181
182/**
183 * Where a repository's next slice starts, as the provider that has to ask for it needs it. Built
184 * by the service out of the slice it just handed over, so the boundary that decides whether a row
185 * arrives twice or not at all is decided in one place rather than in four.
186 */
187export interface ProviderListCursor {
188 /**
189 * The instant of the oldest row already handed over, checked against a timestamp's shape before
190 * it gets here because it goes into a host's own filter. Asked for inclusively: several rows
191 * share one instant often enough — a bot that touches eight change requests writes one timestamp
192 * on all eight — and asking for strictly older would lose whichever of them the slice ended
193 * before. The service drops the ones it has already sent.
194 */
195 readonly updatedBefore: string;
196 /**
197 * How many provider rows this repository has consumed so far, for a host that carries on by
198 * counting rather than by date. Usually this is the number handed over; malformed raw rows may
199 * count too when the provider reports a `cursorAdvance`.
200 */
201 readonly delivered: number;
202}
203
204/** One repository's row inside an answer that spans several of them. */
205export interface ProviderBatchedChangeRequest extends ProviderChangeRequest {
206 /** Provider-native identity, exactly as it was asked for, so the caller can file the row. */
207 readonly repository: string;
208}
209
210/**
211 * One slice of a host read across several repositories at once, newest update first across all
212 * of them. There is no per-repository page here because the host was asked one question: the
213 * caller splits the rows by `repository` and works out where each of them carries on from the
214 * oldest row in the slice, which every repository the slice covers is now read up to.
215 */
216export interface ProviderBatchedChangeRequestPage {
217 readonly items: ReadonlyArray<ProviderBatchedChangeRequest>;
218 /** True when the host has more rows than the slice asked for, for any of the repositories. */
219 readonly truncated: boolean;
220}
221
222/** The line counts for one change request, which a listing may leave for a second read. */
223export interface ProviderChangeRequestStat {
224 readonly repository: string;
225 readonly number: number;
226 readonly additions: number;
227 readonly deletions: number;
228}
229
230export interface ProviderChangeRequestDetail extends ProviderChangeRequest {
231 /** The head commit, where the host's detail read reports it. */
232 readonly headSha?: string | null;
233 readonly body: string;
234 readonly changedFiles: number;
235 readonly mergedAt: string | null;
236 readonly closedAt: string | null;
237 readonly reviewers: ReadonlyArray<PullRequestActor>;
238 readonly checks: ReadonlyArray<PullRequestCheck>;
239 readonly mergeCapabilities: PullRequestMergeCapabilities;
240 readonly viewerPermissions: PullRequestViewerPermissions;
241 /** Absent from a host that cannot compare the branch with its base, which is most of them. */
242 readonly baseComparison?: PullRequestBaseComparison;
243 readonly behindBy?: number;
244 /** Absent from a host that does not report whether it is armed to merge this on its own. */
245 readonly autoMergeEnabled?: boolean;
246 /** The strategy stored with an armed auto-merge, where the host reports it. */
247 readonly autoMergeMethod?: PullRequestMergeMethod;
248 /** Workflow runs on this head commit that still need a maintainer's approval. */
249 readonly workflowApprovalsRequired?: number;
250}
251
252/** The conversation-shaped half of a detail, loaded after the core can already render. */
253export interface ProviderChangeRequestActivity {
254 /** An optional richer actor, e.g. after GitHub's GraphQL read supplies an avatar. */
255 readonly author?: PullRequestActor | null;
256 /** Optional because most hosts already report their reviewer list in the core detail. */
257 readonly reviewers?: ReadonlyArray<PullRequestActor>;
258 readonly comments: ReadonlyArray<PullRequestComment>;
259 /**
260 * The host's own count of the conversation, which a bounded read can fall short of. A host
261 * that reports no count of its own answers with what it handed over, which is the same number
262 * once the read went to the end.
263 */
264 readonly commentCount: number;
265 readonly commentsTruncated: boolean;
266 readonly reviewThreadsTruncated?: boolean;
267 readonly reviewThreads: ReadonlyArray<PullRequestReviewThread>;
268 readonly commits: ReadonlyArray<PullRequestCommit>;
269 /** The change request's own reactions, from a host that has them. */
270 readonly reactions?: ReadonlyArray<PullRequestReaction>;
271}
272
273export interface ProviderDiffSlice {
274 readonly patch: string;
275 /** Something in this slice could not be shown, as opposed to there being more slices. */
276 readonly truncated: boolean;
277 readonly nextCursor: string | null;
278 /** The host's own counts for the files whose hunks it withheld from this slice. */
279 readonly omittedFileStats?: ReadonlyArray<PullRequestOmittedFileStat>;
280}
281
282export interface ProviderDiffFileContents {
283 readonly oldContents: string;
284 readonly newContents: string;
285}
286
287export interface ProviderFilesViewed {
288 readonly files: ReadonlyArray<PullRequestFileViewed>;
289 /** The host has more files than were read, so the ones missing here are not "unviewed". */
290 readonly truncated: boolean;
291}
292
293/**
294 * What version each of the asked-for files is at, on the change request's head. Opaque strings,
295 * compared only against one another, where the empty string is the answer for a file the change
296 * request deletes rather than a gap. A path is absent only where the read could not say, so a
297 * provider converts its host's own absences on the way here.
298 *
299 * The whole path a version travels, and the three senses of null along it, are in
300 * `docs/internals/pull-request-file-revisions.md`.
301 */
302export interface ProviderFileRevisions {
303 readonly revisions: ReadonlyMap<string, string>;
304 /**
305 * Whether these are every file the change request carries rather than only the paths asked
306 * about. A host with no per-file version reads the whole change to answer for one file, and
307 * saying so is what keeps the next tick, naming a path nothing asked about before, from making
308 * it read the whole change again. False for a read cut short, which cannot speak past the cut.
309 */
310 readonly complete?: boolean;
311}
312
313export interface ProviderRepositoryRef {
314 readonly cwd: string;
315 /** Provider-native repository identity, e.g. `owner/repo` or `group/subgroup/project`. */
316 readonly repository: string;
317 /**
318 * The host it lives on, which `repository` deliberately leaves out — the same `owner/repo`
319 * exists on github.com and on a GitHub Enterprise install, and only the caller knows which
320 * one a project's remote points at.
321 */
322 readonly host: string;
323}
324
325/**
326 * One host's change requests. Implementations own their own tool and JSON shapes and hand back
327 * the neutral types above; anything a host cannot do is declared in `capabilities` rather than
328 * failing at call time.
329 */
330export interface PullRequestProviderApi {
331 readonly withVerifiedCredential?: <A, E, R>(
332 input: { readonly cwd: string; readonly host: string },
333 use: (identity: {
334 readonly accountId: string;
335 readonly viewer: string;
336 readonly credentialFingerprint: string;
337 }) => Effect.Effect<A, E, R>,
338 ) => Effect.Effect<A, E | PullRequestProviderError, R>;
339 readonly getRoutingIdentity?: (input: {
340 readonly cwd: string;
341 readonly host: string;
342 }) => Effect.Effect<
343 { readonly accountId: string; readonly viewer: string },
344 PullRequestProviderError
345 >;
346 readonly kind: SourceControlProviderKind;
347 readonly capabilities: PullRequestCapabilities;
348
349 /** The signed-in account, which is what involvement filtering compares against. */
350 readonly getViewer: (input: {
351 readonly cwd: string;
352 readonly host?: string;
353 }) => Effect.Effect<string, PullRequestProviderError>;
354
355 readonly listChangeRequests: (
356 input: ProviderRepositoryRef & {
357 readonly state: PullRequestListState;
358 readonly involvement: PullRequestInvolvement;
359 readonly viewer: string;
360 readonly limit: number;
361 /**
362 * Free text to narrow the listing by, as the host understands it. A host with no text
363 * filter of its own ignores it and answers with the page it would have answered with
364 * anyway — the caller narrows what it gets, so an unfiltered page is a wider answer
365 * rather than a wrong one.
366 */
367 readonly query?: string | undefined;
368 /**
369 * Where to carry on from, rather than reading this repository from its newest row. Absent
370 * asks for the first slice, which is every listing that has not been continued.
371 */
372 readonly cursor?: ProviderListCursor | undefined;
373 /**
374 * Further narrowings, which a host applies as far as it can and ignores the rest of —
375 * an unnarrowed page is a wider answer rather than a wrong one, and the caller narrows
376 * what it gets for the fields a row carries.
377 */
378 readonly filters?: PullRequestListFilters | undefined;
379 },
380 ) => Effect.Effect<ProviderChangeRequestPage, PullRequestProviderError>;
381
382 /**
383 * The same listing for a whole host in one request, for a host that has a search across
384 * repositories. Optional: three of the four hosts here have no such API, and breaking the port
385 * for them to spare GitHub a fan-out would be paying for the fix with everyone else's clarity.
386 * The caller falls back to `listChangeRequests` per repository where this is absent, and where
387 * it fails.
388 *
389 * `limit` is the whole slice rather than a size per repository, because that is the shape of
390 * the answer: the newest `limit` rows across every repository named, which is exactly the rows
391 * a page ordered by update shows.
392 *
393 * `cursor` is one boundary for all of them, so a caller with repositories standing at different
394 * boundaries asks in groups rather than in one call.
395 */
396 readonly listChangeRequestsAcross?: (input: {
397 /** Any checkout on the host, which is what the tool is run in. */
398 readonly cwd: string;
399 readonly host: string;
400 readonly repositories: ReadonlyArray<string>;
401 readonly state: PullRequestListState;
402 readonly involvement: PullRequestInvolvement;
403 readonly viewer: string;
404 readonly limit: number;
405 readonly query?: string | undefined;
406 readonly cursor?: ProviderListCursor | undefined;
407 readonly filters?: PullRequestListFilters | undefined;
408 }) => Effect.Effect<ProviderBatchedChangeRequestPage, PullRequestProviderError>;
409
410 /**
411 * The line counts for rows a listing has already handed over. Only implemented by a provider
412 * whose listing leaves them out — for everyone else the numbers arrived with the row, and the
413 * caller has nothing to ask for.
414 */
415 readonly listChangeRequestStats?: (input: {
416 readonly cwd: string;
417 readonly host: string;
418 readonly changeRequests: ReadonlyArray<{
419 readonly repository: string;
420 readonly number: number;
421 }>;
422 }) => Effect.Effect<ReadonlyArray<ProviderChangeRequestStat>, PullRequestProviderError>;
423
424 readonly getChangeRequestChecks?: (
425 input: ProviderRepositoryRef & { readonly number: number },
426 ) => Effect.Effect<PullRequestChecks, PullRequestProviderError>;
427
428 readonly getChangeRequest: (
429 input: ProviderRepositoryRef & { readonly number: number },
430 ) => Effect.Effect<ProviderChangeRequestDetail, PullRequestProviderError>;
431
432 /** Hosts without a narrow read use their existing detail response for hover cards. */
433 readonly getChangeRequestPreview?: (
434 input: ProviderRepositoryRef & { readonly number: number },
435 ) => Effect.Effect<
436 Omit<PullRequestPreview, "projectId" | "repository">,
437 PullRequestProviderError
438 >;
439
440 /**
441 * The cheap live fields used by linked threads. Optional because a provider without a narrow
442 * endpoint can fall back to its full detail read at the service boundary.
443 */
444 readonly getChangeRequestSummary?: (
445 input: ProviderRepositoryRef & { readonly number: number },
446 ) => Effect.Effect<ProviderChangeRequestSummary, PullRequestProviderError>;
447
448 /**
449 * A cheap fingerprint of what a pull request watch reports, so it reads the change request in
450 * full only when this moves. Null when the host gave no answer for it. Optional: a host
451 * without one has its watched change requests read in full on every pass.
452 */
453 readonly getChangeRequestWatchFingerprint?: (
454 input: ProviderRepositoryRef & { readonly number: number },
455 ) => Effect.Effect<ProviderChangeRequestWatchFingerprint | null, PullRequestProviderError>;
456
457 /**
458 * The host-native stack a change request belongs to, or null when it is not stacked. Optional
459 * because most hosts have no such object; the service derives chains from base branches there.
460 */
461 readonly getChangeRequestStack?: (
462 input: ProviderRepositoryRef & { readonly includeDetails?: boolean; readonly number: number },
463 ) => Effect.Effect<ProviderChangeRequestStack | null, PullRequestProviderError>;
464
465 /** Comments, line threads, and commits, kept off the critical path for the core detail. */
466 readonly getChangeRequestActivity: (
467 input: ProviderRepositoryRef & { readonly number: number },
468 ) => Effect.Effect<ProviderChangeRequestActivity, PullRequestProviderError>;
469
470 /** One explicit page after a reader asks to continue an unfinished review thread. */
471 readonly getReviewThreadComments?: (
472 input: ProviderRepositoryRef & {
473 readonly number: number;
474 readonly threadId: string;
475 readonly cursor: string;
476 },
477 ) => Effect.Effect<PullRequestThreadCommentsResult, PullRequestProviderError>;
478
479 /**
480 * The same answer `getChangeRequest` carries, on its own. Asked before anything is written, so
481 * a request that reached the server without going past the page is refused by what the host
482 * says rather than by what the client claimed — and asked freshly, because access granted or
483 * taken away since the page loaded is exactly the case this guards.
484 *
485 * Implementations read the cheapest thing that answers it, which for a host with nothing to say
486 * is no request at all.
487 */
488 readonly getViewerPermissions: (
489 input: ProviderRepositoryRef & {
490 readonly number: number;
491 /** Skip branch comparison when checking permission for an unrelated operation. */
492 readonly includeUpdateBranch?: boolean;
493 },
494 ) => Effect.Effect<PullRequestViewerPermissions, PullRequestProviderError>;
495
496 /**
497 * One slice of the patch. Only called when `capabilities.diff` is true. A provider that can
498 * serve the whole diff at once answers with `nextCursor: null` and is done; one that pages
499 * hands back whatever it needs to find the next slice.
500 */
501 readonly getDiff: (
502 input: ProviderRepositoryRef & {
503 readonly number: number;
504 readonly cursor?: string | undefined;
505 /** One commit's own changes, rather than everything the change request carries. */
506 readonly commit?: string | undefined;
507 },
508 ) => Effect.Effect<ProviderDiffSlice, PullRequestProviderError>;
509
510 /**
511 * Full files at the exact revisions the host used for its patch. Optional where the provider
512 * exposes no diff at all; the service refuses expansion there just as it refuses the patch.
513 */
514 readonly getDiffFileContents?: (
515 input: ProviderRepositoryRef & {
516 readonly number: number;
517 readonly commit?: string | undefined;
518 readonly changeType: "change" | "rename-pure" | "rename-changed" | "new" | "deleted";
519 readonly oldPath: string;
520 readonly newPath: string;
521 },
522 ) => Effect.Effect<ProviderDiffFileContents, PullRequestProviderError>;
523
524 /**
525 * Which files the reader has already cleared. Only called when `capabilities.viewedFiles` is
526 * `"host"`, and read apart from the patch: this moves with every press rather than every push.
527 */
528 readonly getFilesViewed?: (
529 input: ProviderRepositoryRef & { readonly number: number },
530 ) => Effect.Effect<ProviderFilesViewed, PullRequestProviderError>;
531
532 /**
533 * Clears files, or puts them back. Only called when `capabilities.viewedFiles` is `"host"`.
534 * A host with no bulk form is still owed one round trip for the batch rather than one per file,
535 * since the point of gathering presses is that the host is asked once.
536 */
537 readonly setFilesViewed?: (
538 input: ProviderRepositoryRef & {
539 readonly number: number;
540 readonly files: ReadonlyArray<{ readonly path: string; readonly viewed: boolean }>;
541 },
542 ) => Effect.Effect<void, PullRequestProviderError>;
543
544 /**
545 * What version the head has of each of these files. Required of a host whose
546 * `capabilities.viewedFiles` is `"environment"`, and unused by one that keeps the marks itself:
547 * the marks live here, but only the host can say whether what a reader cleared last week is
548 * still what is in front of them. Asked for the marked paths alone, so the cost follows how
549 * much of the change request has been read rather than how large it is.
550 */
551 readonly getFileRevisions?: (
552 input: ProviderRepositoryRef & {
553 readonly number: number;
554 readonly paths: ReadonlyArray<string>;
555 },
556 ) => Effect.Effect<ProviderFileRevisions, PullRequestProviderError>;
557
558 readonly runAction: (
559 input: ProviderRepositoryRef & {
560 readonly number: number;
561 readonly action: PullRequestAction;
562 readonly stackNumber?: number;
563 readonly expectedStackHeads?: ReadonlyArray<PullRequestStackHead>;
564 /** GitHub merge message cleanup; ignored by hosts without support. */
565 readonly removeAgentCreditsOnMerge?: boolean;
566 /** Meaningful for `merge` and `enable-auto-merge`; absent takes the host's own default. */
567 readonly mergeMethod?: PullRequestMergeMethod;
568 /** Only meaningful for `update-branch`; absent takes the host's own default. */
569 readonly updateMethod?: PullRequestUpdateMethod;
570 },
571 ) => Effect.Effect<void, PullRequestProviderError>;
572
573 /**
574 * Rewrites the change request's own words. Only called when `capabilities.edit.changeRequest`
575 * is true, and never with both fields absent — the caller refuses that before it gets here,
576 * because a host asked to change nothing answers differently on each of them.
577 */
578 readonly updateChangeRequest?: (
579 input: ProviderRepositoryRef & {
580 readonly number: number;
581 readonly title?: string | undefined;
582 readonly body?: string | undefined;
583 },
584 ) => Effect.Effect<void, PullRequestProviderError>;
585
586 readonly comment: (
587 input: ProviderRepositoryRef & { readonly number: number; readonly body: string },
588 ) => Effect.Effect<void, PullRequestProviderError>;
589
590 /**
591 * Rewrites a remark somebody already posted. Only called when `capabilities.edit.comment` is
592 * true, with an id exactly as the conversation carried it.
593 *
594 * Whether this remark is the reader's to rewrite is the host's own answer: no read here can
595 * settle it, since access can be taken away between the conversation being read and the
596 * rewrite being sent, and a host refuses a stranger's remark with a sentence saying so.
597 */
598 readonly updateComment?: (
599 input: ProviderRepositoryRef & {
600 readonly number: number;
601 readonly commentId: string;
602 readonly kind: "issue-comment" | "review-comment";
603 readonly body: string;
604 },
605 ) => Effect.Effect<void, PullRequestProviderError>;
606
607 /**
608 * Sends a whole review at once. Only called for a verdict the host declared in
609 * `capabilities.review.verdicts`, and with line comments only where it declared
610 * `inlineComment`.
611 */
612 readonly submitReview: (
613 input: ProviderRepositoryRef & {
614 readonly number: number;
615 readonly verdict: PullRequestReviewVerdict;
616 readonly body: string;
617 readonly comments: ReadonlyArray<PullRequestReviewCommentDraft>;
618 },
619 ) => Effect.Effect<void, PullRequestProviderError>;
620
621 /**
622 * The people this viewer may ask for a review, with whoever has already been asked marked as
623 * such. Only called when `capabilities.reviewers.listCandidates` is true.
624 *
625 * The author is left out by each provider rather than by the caller, because only the provider
626 * knows how the host spells the same person in a candidate list and on a pull request.
627 */
628 readonly listReviewerCandidates: (
629 input: ProviderRepositoryRef & { readonly number: number },
630 ) => Effect.Effect<PullRequestReviewerCandidateList, PullRequestProviderError>;
631
632 /**
633 * Asks for a review, or takes the request back. Only called when
634 * `capabilities.reviewers.request` is true.
635 *
636 * One call for both directions, because that is what every host does with them: GitHub posts and
637 * deletes the same collection, and GitLab and Bitbucket write the whole reviewer set either way.
638 * Asking again somebody who has already reviewed is a request like any other — which is how a
639 * re-request is made.
640 */
641 readonly setReviewerRequest: (
642 input: ProviderRepositoryRef & {
643 readonly number: number;
644 readonly reviewers: ReadonlyArray<{
645 readonly id: string;
646 readonly kind: PullRequestReviewerKind;
647 }>;
648 readonly requested: boolean;
649 },
650 ) => Effect.Effect<void, PullRequestProviderError>;
651
652 /**
653 * The repository's labels, with the ones already on the change request marked. Present with
654 * `setLabels` only where `capabilities.labels` is true; the service refuses both without it.
655 */
656 readonly listLabelCandidates?: (
657 input: ProviderRepositoryRef & { readonly number: number },
658 ) => Effect.Effect<PullRequestLabelCandidateList, PullRequestProviderError>;
659
660 /** Puts labels on the change request, or takes them off. One call for both directions. */
661 readonly setLabels?: (
662 input: ProviderRepositoryRef & {
663 readonly number: number;
664 readonly labels: ReadonlyArray<string>;
665 readonly applied: boolean;
666 },
667 ) => Effect.Effect<void, PullRequestProviderError>;
668
669 /** Only called when `capabilities.review.reply` is true. */
670 readonly replyToThread: (
671 input: ProviderRepositoryRef & {
672 readonly number: number;
673 readonly threadId: string;
674 readonly body: string;
675 },
676 ) => Effect.Effect<void, PullRequestProviderError>;
677
678 /**
679 * Adds a reaction, or takes it back. Only called when `capabilities.reactions` is true.
680 *
681 * `subjectId` is a remark's id as the conversation carried it; absent means the change request
682 * itself, whose reactions sit on its description. Whatever a host needs to address either of
683 * them is worked out here, because the id a conversation travels with is the one the reader has.
684 */
685 readonly setReaction: (
686 input: ProviderRepositoryRef & {
687 readonly number: number;
688 readonly subjectId?: string | undefined;
689 readonly content: PullRequestReactionContent;
690 readonly reacted: boolean;
691 },
692 ) => Effect.Effect<void, PullRequestProviderError>;
693
694 /** Only called when `capabilities.review.resolve` is true. */
695 readonly setThreadResolution: (
696 input: ProviderRepositoryRef & {
697 readonly number: number;
698 readonly threadId: string;
699 readonly resolved: boolean;
700 },
701 ) => Effect.Effect<void, PullRequestProviderError>;
702}
packages/source-control-core/src/server/SourceControlHost.ts created+59
...@@ -0,0 +1,59 @@
1/**
2 * SourceControlHost — what a source control provider package may ask of the server it runs in.
3 *
4 * Providers run inside a T3 server but must not import it. The server provides this one
5 * service; everything a provider needs from its environment (settings, the process runner)
6 * goes through it, so a provider package depends only on `@t3tools/source-control-core` and
7 * its own API and CLI code. HTTP, the filesystem, and paths come from Effect's platform
8 * services directly.
9 *
10 * @module source-control-core/server/SourceControlHost
11 */
12import type { ServerSettings, ServerSettingsError, VcsError } from "@t3tools/contracts";
13import * as Context from "effect/Context";
14import type * as Effect from "effect/Effect";
15import type * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
16
17/** One CLI invocation. The server bounds concurrency, output, and time for every run. */
18export interface SourceControlProcessInput {
19 readonly operation: string;
20 readonly command: string;
21 readonly args: ReadonlyArray<string>;
22 readonly cwd: string;
23 readonly spawnCwd?: string;
24 readonly stdin?: string;
25 readonly onStdoutChunk?: (chunk: Uint8Array) => void;
26 readonly env?: NodeJS.ProcessEnv;
27 readonly allowNonZeroExit?: boolean;
28 readonly timeoutMs?: number;
29 readonly maxOutputBytes?: number;
30 /** What happens past `maxOutputBytes`: fail the run, or keep the first bytes. */
31 readonly outputMode?: "error" | "truncate" | undefined;
32 readonly appendTruncationMarker?: boolean;
33}
34
35export interface SourceControlProcessOutput {
36 readonly exitCode: ChildProcessSpawner.ExitCode;
37 readonly stdout: string;
38 readonly stderr: string;
39 readonly stdoutTruncated: boolean;
40 readonly stderrTruncated: boolean;
41 /** Present on real process output; optional so narrow test doubles remain lightweight. */
42 readonly stdoutInvalidUtf8?: boolean;
43 readonly stderrInvalidUtf8?: boolean;
44}
45
46export class SourceControlHost extends Context.Service<
47 SourceControlHost,
48 {
49 readonly settings: {
50 /** Read fresh on each call, so a credential saved in Settings applies without a restart. */
51 readonly get: Effect.Effect<ServerSettings, ServerSettingsError>;
52 };
53 readonly process: {
54 readonly run: (
55 input: SourceControlProcessInput,
56 ) => Effect.Effect<SourceControlProcessOutput, VcsError>;
57 };
58 }
59>()("@t3tools/source-control-core/server/SourceControlHost") {}
packages/source-control-core/src/server/SourceControlProvider.test.ts created+19
...@@ -0,0 +1,19 @@
1import { assert, it } from "@effect/vitest";
2
3import { transportSafeSourceControlErrorValue } from "./SourceControlProvider.ts";
4
5it("removes URL credentials, query parameters, and fragments from error transport values", () => {
6 assert.strictEqual(
7 transportSafeSourceControlErrorValue(
8 "https://user:secret@example.test/org/repo/pull/42?token=secret#discussion",
9 ),
10 "https://example.test/org/repo/pull/42",
11 );
12});
13
14it("normalizes control characters and bounds error transport values", () => {
15 assert.strictEqual(
16 transportSafeSourceControlErrorValue(` owner/repo\n\t${"x".repeat(300)} `),
17 `owner/repo ${"x".repeat(245)}`,
18 );
19});
packages/source-control-core/src/server/SourceControlProvider.ts created+145
...@@ -0,0 +1,145 @@
1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import type {
4 ChangeRequest,
5 ChangeRequestState,
6 SourceControlProviderError,
7 SourceControlProviderInfo,
8 SourceControlProviderKind,
9 SourceControlRepositoryCloneUrls,
10 SourceControlRepositoryVisibility,
11} from "@t3tools/contracts";
12
13export interface SourceControlLinkSubject {
14 readonly title: string;
15 readonly body: string | null;
16}
17
18/** Return undefined synchronously for unsupported URLs, without starting a lookup. */
19export type ResolveSourceControlLink = (input: {
20 readonly cwd: string;
21 readonly url: URL;
22}) => Effect.Effect<SourceControlLinkSubject, SourceControlProviderError> | undefined;
23
24export interface SourceControlProviderContext {
25 readonly provider: SourceControlProviderInfo;
26 readonly remoteName: string;
27 readonly remoteUrl: string;
28 /** An explicit web authority can disambiguate Forgejo logins sharing an SSH alias. */
29 readonly requestedHost?: string;
30}
31
32export interface SourceControlRefSelector {
33 readonly refName: string;
34 readonly owner?: string;
35 readonly repository?: string;
36}
37
38const MAX_ERROR_TRANSPORT_VALUE_LENGTH = 256;
39
40/**
41 * Sanitizes user-provided source-control identifiers before attaching them to
42 * contract errors. This is intentionally narrower than request validation: it
43 * only strips URL secrets and bounds diagnostic values sent over transport.
44 */
45export function transportSafeSourceControlErrorValue(value: string): string {
46 let printable = "";
47 for (const character of value) {
48 const codePoint = character.codePointAt(0);
49 printable += codePoint !== undefined && (codePoint < 32 || codePoint === 127) ? " " : character;
50 }
51 const normalized = printable.trim().replace(/\s+/gu, " ");
52
53 let safe = normalized;
54 try {
55 const url = new URL(normalized);
56 url.username = "";
57 url.password = "";
58 url.search = "";
59 url.hash = "";
60 safe = url.toString();
61 } catch {
62 // Plain repository and change-request identifiers are not URLs.
63 }
64
65 return safe.slice(0, MAX_ERROR_TRANSPORT_VALUE_LENGTH);
66}
67
68export function parseSourceControlOwnerRef(
69 headSelector: string,
70): SourceControlRefSelector | undefined {
71 const match = /^([^:/\s]+):(.+)$/u.exec(headSelector.trim());
72 const owner = match?.[1]?.trim();
73 const refName = match?.[2]?.trim();
74 return owner && refName ? { owner, refName } : undefined;
75}
76
77function normalizeSourceBranch(headSelector: string): string {
78 return parseSourceControlOwnerRef(headSelector)?.refName ?? headSelector.trim();
79}
80
81export function sourceBranch(input: {
82 readonly headSelector: string;
83 readonly source?: SourceControlRefSelector;
84}): string {
85 return input.source?.refName ?? normalizeSourceBranch(input.headSelector);
86}
87
88export function sourceControlRefFromInput(input: {
89 readonly headSelector: string;
90 readonly source?: SourceControlRefSelector;
91}): SourceControlRefSelector | undefined {
92 return input.source ?? parseSourceControlOwnerRef(input.headSelector);
93}
94
95export class SourceControlProvider extends Context.Service<
96 SourceControlProvider,
97 {
98 readonly kind: SourceControlProviderKind;
99 /** Optional capability for issue and change-request subjects. */
100 readonly resolveLink?: ResolveSourceControlLink;
101 readonly listChangeRequests: (input: {
102 readonly cwd: string;
103 readonly context?: SourceControlProviderContext;
104 readonly source?: SourceControlRefSelector;
105 readonly headSelector: string;
106 readonly state: ChangeRequestState | "all";
107 readonly limit?: number;
108 }) => Effect.Effect<ReadonlyArray<ChangeRequest>, SourceControlProviderError>;
109 readonly getChangeRequest: (input: {
110 readonly cwd: string;
111 readonly context?: SourceControlProviderContext;
112 readonly reference: string;
113 }) => Effect.Effect<ChangeRequest, SourceControlProviderError>;
114 readonly createChangeRequest: (input: {
115 readonly cwd: string;
116 readonly context?: SourceControlProviderContext;
117 readonly source?: SourceControlRefSelector;
118 readonly target?: SourceControlRefSelector;
119 readonly baseRefName: string;
120 readonly headSelector: string;
121 readonly title: string;
122 readonly bodyFile: string;
123 }) => Effect.Effect<void, SourceControlProviderError>;
124 readonly getRepositoryCloneUrls: (input: {
125 readonly cwd: string;
126 readonly context?: SourceControlProviderContext;
127 readonly repository: string;
128 }) => Effect.Effect<SourceControlRepositoryCloneUrls, SourceControlProviderError>;
129 readonly createRepository: (input: {
130 readonly cwd: string;
131 readonly repository: string;
132 readonly visibility: SourceControlRepositoryVisibility;
133 }) => Effect.Effect<SourceControlRepositoryCloneUrls, SourceControlProviderError>;
134 readonly getDefaultBranch: (input: {
135 readonly cwd: string;
136 readonly context?: SourceControlProviderContext;
137 }) => Effect.Effect<string | null, SourceControlProviderError>;
138 readonly checkoutChangeRequest: (input: {
139 readonly cwd: string;
140 readonly context?: SourceControlProviderContext;
141 readonly reference: string;
142 readonly force?: boolean;
143 }) => Effect.Effect<void, SourceControlProviderError>;
144 }
145>()("@t3tools/source-control-core/server/SourceControlProvider") {}
packages/source-control-core/src/server/SourceControlRateLimit.test.ts created+138
...@@ -0,0 +1,138 @@
1import { assert, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as TestClock from "effect/testing/TestClock";
4
5import * as SourceControlRateLimit from "./SourceControlRateLimit.ts";
6
7const github = { provider: "github" as const, host: "github.com" };
8
9it.effect("isolates cooldowns for verified credentials on the same host", () =>
10 Effect.gen(function* () {
11 const limits = yield* SourceControlRateLimit.SourceControlRateLimit;
12 yield* limits
13 .recordRateLimit({ ...github, lease: 0 })
14 .pipe(Effect.provideService(SourceControlRateLimit.CredentialScope, "first"));
15 yield* limits
16 .check(github)
17 .pipe(Effect.provideService(SourceControlRateLimit.CredentialScope, "second"));
18 const error = yield* limits
19 .check(github)
20 .pipe(Effect.provideService(SourceControlRateLimit.CredentialScope, "first"), Effect.flip);
21 assert.strictEqual(error._tag, "SourceControlRateLimitPausedError");
22 }).pipe(Effect.provide(SourceControlRateLimit.layer)),
23);
24
25it("parses Retry-After seconds and HTTP dates", () => {
26 assert.equal(SourceControlRateLimit.retryAtFromHeader("120", 1_000), 121_000);
27 assert.equal(
28 SourceControlRateLimit.retryAtFromHeader("Thu, 01 Jan 1970 00:02:01 GMT", 1_000),
29 121_000,
30 );
31 assert.isUndefined(SourceControlRateLimit.retryAtFromHeader("later", 1_000));
32});
33
34it.effect("backs off repeated rate limits until a successful request", () =>
35 Effect.gen(function* () {
36 yield* TestClock.setTime(0);
37 const limits = yield* SourceControlRateLimit.SourceControlRateLimit;
38
39 const firstLease = yield* limits.check(github);
40 yield* limits.recordRateLimit({ ...github, lease: firstLease });
41 const firstPause = yield* Effect.flip(limits.check(github));
42 assert.deepInclude(firstPause, {
43 _tag: "SourceControlRateLimitPausedError",
44 provider: "github",
45 host: "github.com",
46 retryAt: 30_000,
47 });
48 assert.equal(
49 firstPause.detail,
50 "github requests to github.com are paused until the rate limit resets.",
51 );
52 assert.equal(firstPause.message, firstPause.detail);
53
54 yield* TestClock.adjust("30 seconds");
55 const secondLease = yield* limits.check(github);
56 yield* limits.recordRateLimit({ ...github, lease: secondLease });
57 assert.equal((yield* Effect.flip(limits.check(github))).retryAt, 90_000);
58
59 yield* TestClock.adjust("60 seconds");
60 const successLease = yield* limits.check(github);
61 yield* limits.recordSuccess({ ...github, lease: successLease });
62 const resetLease = yield* limits.check(github);
63 yield* limits.recordRateLimit({ ...github, lease: resetLease });
64 assert.equal((yield* Effect.flip(limits.check(github))).retryAt, 120_000);
65 }).pipe(Effect.provide(SourceControlRateLimit.layer)),
66);
67
68it.effect("honors a provider reset time", () =>
69 Effect.gen(function* () {
70 yield* TestClock.setTime(1_000);
71 const limits = yield* SourceControlRateLimit.SourceControlRateLimit;
72 const lease = yield* limits.check(github);
73
74 yield* limits.recordRateLimit({ ...github, lease, retryAt: 121_000 });
75 assert.equal((yield* Effect.flip(limits.check(github))).retryAt, 121_000);
76
77 yield* TestClock.adjust("119999 millis");
78 assert.equal((yield* Effect.flip(limits.check(github))).retryAt, 121_000);
79 yield* TestClock.adjust("1 millis");
80 assert.equal(yield* limits.check(github), 1);
81 }).pipe(Effect.provide(SourceControlRateLimit.layer)),
82);
83
84it.effect("lets an interactive request through without clearing an active pause", () =>
85 Effect.gen(function* () {
86 yield* TestClock.setTime(1_000);
87 const limits = yield* SourceControlRateLimit.SourceControlRateLimit;
88 const lease = yield* limits.check(github);
89 yield* limits.recordRateLimit({ ...github, lease, retryAt: 121_000 });
90
91 const interactiveLease = yield* limits.check(github, { allowPaused: true });
92 yield* limits.recordSuccess({ ...github, lease: interactiveLease });
93
94 assert.equal(interactiveLease, 1);
95 assert.equal((yield* Effect.flip(limits.check(github))).retryAt, 121_000);
96
97 yield* limits.recordRateLimit({ ...github, lease: interactiveLease, retryAt: 61_000 });
98 assert.equal((yield* Effect.flip(limits.check(github))).retryAt, 121_000);
99 }).pipe(Effect.provide(SourceControlRateLimit.layer)),
100);
101
102it.effect("keeps providers and hosts isolated", () =>
103 Effect.gen(function* () {
104 const limits = yield* SourceControlRateLimit.SourceControlRateLimit;
105 const lease = yield* limits.check(github);
106 yield* limits.recordRateLimit({ ...github, lease });
107
108 assert.equal(yield* limits.check({ provider: "gitlab", host: "github.com" }), 0);
109 assert.equal(yield* limits.check({ provider: "github", host: "github.example.com" }), 0);
110 }).pipe(Effect.provide(SourceControlRateLimit.layer)),
111);
112
113it.effect("does not let an older success clear a concurrent pause", () =>
114 Effect.gen(function* () {
115 const limits = yield* SourceControlRateLimit.SourceControlRateLimit;
116 const firstLease = yield* limits.check(github);
117 const concurrentLease = yield* limits.check(github);
118
119 yield* limits.recordRateLimit({ ...github, lease: firstLease });
120 yield* limits.recordSuccess({ ...github, lease: concurrentLease });
121
122 assert.equal((yield* Effect.flip(limits.check(github))).retryAt, 30_000);
123 }).pipe(Effect.provide(SourceControlRateLimit.layer)),
124);
125
126it.effect("keeps a fresh provider reset from an older request", () =>
127 Effect.gen(function* () {
128 yield* TestClock.setTime(0);
129 const limits = yield* SourceControlRateLimit.SourceControlRateLimit;
130 const staleLease = yield* limits.check(github);
131 yield* limits.recordRateLimit({ ...github, lease: staleLease });
132
133 yield* TestClock.adjust("31 seconds");
134 yield* limits.recordRateLimit({ ...github, lease: staleLease, retryAt: 61_000 });
135
136 assert.equal((yield* Effect.flip(limits.check(github))).retryAt, 61_000);
137 }).pipe(Effect.provide(SourceControlRateLimit.layer)),
138);
packages/source-control-core/src/server/SourceControlRateLimit.ts created+171
...@@ -0,0 +1,171 @@
1import * as Clock from "effect/Clock";
2import * as Context from "effect/Context";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Ref from "effect/Ref";
7import * as Schema from "effect/Schema";
8import {
9 SourceControlProviderKind as SourceControlProviderKindSchema,
10 type SourceControlProviderKind,
11} from "@t3tools/contracts";
12
13const FALLBACK_COOLDOWN = Duration.seconds(30);
14const MAX_FALLBACK_COOLDOWN = Duration.minutes(15);
15
16export const CredentialScope = Context.Reference<string>(
17 "@t3tools/source-control-core/server/SourceControlRateLimit/CredentialScope",
18 {
19 defaultValue: () => "",
20 },
21);
22
23interface RateLimitKey {
24 readonly provider: SourceControlProviderKind;
25 readonly host: string;
26}
27
28interface RateLimitLease extends RateLimitKey {
29 readonly lease: number;
30}
31
32interface RateLimitEntry {
33 readonly attempt: number;
34 readonly generation: number;
35 readonly retryAt: number;
36}
37
38export class SourceControlRateLimitPausedError extends Schema.TaggedError<SourceControlRateLimitPausedError>()(
39 "SourceControlRateLimitPausedError",
40 {
41 provider: SourceControlProviderKindSchema,
42 host: Schema.String,
43 retryAt: Schema.Number,
44 },
45) {
46 get detail(): string {
47 return `${this.provider} requests to ${this.host} are paused until the rate limit resets.`;
48 }
49
50 override get message(): string {
51 return this.detail;
52 }
53}
54
55export class SourceControlRateLimit extends Context.Service<
56 SourceControlRateLimit,
57 {
58 readonly check: (
59 key: RateLimitKey,
60 options?: { readonly allowPaused: boolean },
61 ) => Effect.Effect<number, SourceControlRateLimitPausedError>;
62 readonly recordRateLimit: (
63 input: RateLimitLease & { readonly retryAt?: number | undefined },
64 ) => Effect.Effect<void>;
65 readonly recordSuccess: (input: RateLimitLease) => Effect.Effect<void>;
66 }
67>()("@t3tools/source-control-core/server/SourceControlRateLimit") {}
68
69function normalizedKey(key: RateLimitKey, scope: string): string {
70 return `${key.provider}\0${key.host.trim().toLowerCase()}\0${scope}`;
71}
72
73function fallbackCooldownMs(attempt: number): number {
74 return Math.min(
75 Duration.toMillis(FALLBACK_COOLDOWN) * 2 ** Math.max(0, attempt - 1),
76 Duration.toMillis(MAX_FALLBACK_COOLDOWN),
77 );
78}
79
80export function retryAtFromHeader(value: string | undefined, now: number): number | undefined {
81 if (value === undefined) return undefined;
82 const normalized = value.trim();
83 if (/^\d+$/u.test(normalized)) {
84 const seconds = Number(normalized);
85 const retryAt = now + seconds * 1_000;
86 return Number.isSafeInteger(retryAt) ? retryAt : undefined;
87 }
88 const retryAt = Date.parse(normalized);
89 return Number.isFinite(retryAt) && retryAt > now ? retryAt : undefined;
90}
91
92/** @public Service construction is part of the canonical Effect module API. */
93export const make = Effect.gen(function* () {
94 const entries = yield* Ref.make<ReadonlyMap<string, RateLimitEntry>>(new Map());
95
96 const check: SourceControlRateLimit["Service"]["check"] = Effect.fn(
97 "SourceControlRateLimit.check",
98 )(function* (input, options) {
99 const now = yield* Clock.currentTimeMillis;
100 const key = normalizedKey(input, yield* CredentialScope);
101 const entry = (yield* Ref.get(entries)).get(key);
102 if (entry !== undefined && entry.retryAt > now && options?.allowPaused !== true) {
103 return yield* new SourceControlRateLimitPausedError({
104 provider: input.provider,
105 host: input.host.trim().toLowerCase(),
106 retryAt: entry.retryAt,
107 });
108 }
109 return entry?.generation ?? 0;
110 });
111
112 const recordRateLimit: SourceControlRateLimit["Service"]["recordRateLimit"] = Effect.fn(
113 "SourceControlRateLimit.recordRateLimit",
114 )(function* (input) {
115 const now = yield* Clock.currentTimeMillis;
116 const key = normalizedKey(input, yield* CredentialScope);
117 yield* Ref.update(entries, (current) => {
118 const previous = current.get(key);
119 if (previous !== undefined && previous.generation > input.lease) {
120 if (previous.retryAt <= now && (input.retryAt === undefined || input.retryAt <= now)) {
121 return current;
122 }
123 const retryAt =
124 input.retryAt !== undefined && input.retryAt > previous.retryAt
125 ? input.retryAt
126 : previous.retryAt;
127 if (retryAt === previous.retryAt) return current;
128 const next = new Map(current);
129 next.set(key, { ...previous, retryAt });
130 return next;
131 }
132
133 const attempt = (previous?.attempt ?? 0) + 1;
134 const proposedRetryAt =
135 input.retryAt !== undefined && input.retryAt > now
136 ? input.retryAt
137 : now + fallbackCooldownMs(attempt);
138 const retryAt =
139 previous !== undefined && previous.retryAt > now
140 ? Math.max(previous.retryAt, proposedRetryAt)
141 : proposedRetryAt;
142 const next = new Map(current);
143 next.set(key, {
144 attempt,
145 generation: Math.max(previous?.generation ?? 0, input.lease) + 1,
146 retryAt,
147 });
148 return next;
149 });
150 });
151
152 const recordSuccess: SourceControlRateLimit["Service"]["recordSuccess"] = Effect.fn(
153 "SourceControlRateLimit.recordSuccess",
154 )(function* (input) {
155 const now = yield* Clock.currentTimeMillis;
156 const key = normalizedKey(input, yield* CredentialScope);
157 yield* Ref.update(entries, (current) => {
158 const previous = current.get(key);
159 if (previous === undefined || previous.generation !== input.lease || previous.retryAt > now) {
160 return current;
161 }
162 const next = new Map(current);
163 next.set(key, { attempt: 0, generation: previous.generation, retryAt: 0 });
164 return next;
165 });
166 });
167
168 return SourceControlRateLimit.of({ check, recordRateLimit, recordSuccess });
169});
170
171export const layer = Layer.effect(SourceControlRateLimit, make);
packages/source-control-core/src/server/discovery.ts created+340
...@@ -0,0 +1,340 @@
1/**
2 * Discovery specs: how each provider reports its CLI or API, and the shared probe that runs them.
3 *
4 * @module source-control-core/server/discovery
5 */
6import * as NodeUtil from "node:util";
7import type {
8 SourceControlProviderAuth,
9 SourceControlProviderDiscoveryItem,
10 SourceControlProviderInfo,
11 SourceControlProviderKind,
12} from "@t3tools/contracts";
13import * as Effect from "effect/Effect";
14import * as Option from "effect/Option";
15
16import type * as SourceControlProvider from "./SourceControlProvider.ts";
17import type * as SourceControlHost from "./SourceControlHost.ts";
18
19export interface SourceControlAuthProbeInput {
20 readonly stdout: string;
21 readonly stderr: string;
22 readonly exitCode: SourceControlHost.SourceControlProcessOutput["exitCode"];
23}
24
25export interface SourceControlUnknownRemoteRefinementInput {
26 readonly cwd: string;
27 readonly context: SourceControlProvider.SourceControlProviderContext;
28 readonly auth: SourceControlAuthProbeInput;
29}
30
31interface SourceControlDiscoverySpecBase {
32 readonly kind: SourceControlProviderKind;
33 readonly label: string;
34 readonly installHint: string;
35}
36
37export type SourceControlCliDiscoverySpec = SourceControlDiscoverySpecBase & {
38 readonly type: "cli";
39 readonly executable: string;
40 readonly versionArgs: ReadonlyArray<string>;
41 readonly authArgs: ReadonlyArray<string>;
42 readonly remoteRefinementArgs?: ReadonlyArray<string>;
43 readonly probeTimeoutMs?: number;
44 readonly parseAuth: (input: SourceControlAuthProbeInput) => SourceControlProviderAuth;
45 readonly refineUnknownRemote?: (
46 input: SourceControlUnknownRemoteRefinementInput,
47 ) => SourceControlProviderInfo | null;
48};
49
50export type SourceControlApiDiscoverySpec = SourceControlDiscoverySpecBase & {
51 readonly type: "api";
52 readonly probeAuth: Effect.Effect<SourceControlProviderAuth, never>;
53};
54
55export type SourceControlManagedCliDiscoverySpec = SourceControlDiscoverySpecBase & {
56 readonly type: "managed-cli";
57 readonly probe: (cwd: string) => Effect.Effect<SourceControlProviderDiscoveryItem>;
58 readonly refineUnknownRemote: (input: {
59 readonly cwd: string;
60 readonly context: SourceControlProvider.SourceControlProviderContext;
61 }) => Effect.Effect<SourceControlProviderInfo | null>;
62};
63
64export type SourceControlProviderDiscoverySpec =
65 | SourceControlCliDiscoverySpec
66 | SourceControlManagedCliDiscoverySpec
67 | SourceControlApiDiscoverySpec;
68
69type SourceControlCliRemoteRefinementSpec = SourceControlCliDiscoverySpec & {
70 readonly refineUnknownRemote: NonNullable<SourceControlCliDiscoverySpec["refineUnknownRemote"]>;
71};
72
73// Most provider CLIs answer `--version` in well under a second, so a short budget keeps
74// discovery snappy. Specs whose CLI is known to be slower can raise it via probeTimeoutMs.
75const DEFAULT_PROBE_TIMEOUT_MS = 5_000;
76
77function probeTimeoutMs(spec: SourceControlCliDiscoverySpec): number {
78 return spec.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
79}
80
81interface DiscoveryProbeResult {
82 readonly kind: SourceControlProviderKind;
83 readonly label: string;
84 readonly executable: string;
85 readonly status: "available" | "missing";
86 readonly version: Option.Option<string>;
87 readonly installHint: string;
88 readonly detail: Option.Option<string>;
89}
90
91export function firstNonEmptyLine(text: string): Option.Option<string> {
92 const line = NodeUtil.stripVTControlCharacters(text)
93 .split(/\r?\n/)
94 .map((entry) => entry.trim())
95 .find((entry) => entry.length > 0);
96 return line === undefined ? Option.none() : Option.some(line);
97}
98
99export function detailFromCause(cause: unknown): Option.Option<string> {
100 if (cause instanceof Error && cause.message.trim().length > 0) {
101 return Option.some(cause.message.trim());
102 }
103 return Option.none();
104}
105
106function authAccount(account: string | undefined): Option.Option<string> {
107 const trimmed = account?.trim();
108 return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed);
109}
110
111function authHost(host: string | undefined): Option.Option<string> {
112 const trimmed = host?.trim();
113 return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed);
114}
115
116function authDetail(detail: string | undefined): Option.Option<string> {
117 const trimmed = detail?.trim();
118 return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed);
119}
120
121export function providerAuth(input: {
122 readonly status: SourceControlProviderAuth["status"];
123 readonly account?: string | undefined;
124 readonly host?: string | undefined;
125 readonly detail?: string | undefined;
126}): SourceControlProviderAuth {
127 return {
128 status: input.status,
129 account: authAccount(input.account),
130 host: authHost(input.host),
131 detail: authDetail(input.detail),
132 };
133}
134
135function unknownAuth(detail?: string): SourceControlProviderAuth {
136 return providerAuth({ status: "unknown", detail });
137}
138
139export function combinedAuthOutput(input: SourceControlAuthProbeInput): string {
140 const parts: string[] = [];
141 for (const entry of [input.stdout, input.stderr]) {
142 if (entry.trim().length > 0) {
143 parts.push(entry);
144 }
145 }
146 return parts.join("\n");
147}
148
149function sanitizedAuthLines(text: string): ReadonlyArray<string> {
150 const lines: string[] = [];
151 for (const entry of text.split(/\r?\n/)) {
152 const line = entry.trim();
153 if (line.length === 0) continue;
154 if (/^[-\s]*token(?:\s+scopes?)?:/iu.test(line)) continue;
155 lines.push(line);
156 }
157 return lines;
158}
159
160export function firstSafeAuthLine(text: string): string | undefined {
161 return sanitizedAuthLines(text)[0];
162}
163
164export function parseCliHost(text: string): string | undefined {
165 return sanitizedAuthLines(text)
166 .map((line) => line.replace(/^[^a-z0-9]+/iu, ""))
167 .find((line) => /^[a-z0-9][a-z0-9.-]*(?::\d+)?$/iu.test(line));
168}
169
170export function matchFirst(text: string, patterns: ReadonlyArray<RegExp>): string | undefined {
171 for (const pattern of patterns) {
172 const match = pattern.exec(text);
173 const value = match?.[1]?.trim();
174 if (value && value.length > 0) return value;
175 }
176 return undefined;
177}
178
179function isCliRemoteRefinementSpec(
180 spec: SourceControlProviderDiscoverySpec,
181): spec is SourceControlCliRemoteRefinementSpec {
182 return spec.type === "cli" && spec.refineUnknownRemote !== undefined;
183}
184
185function probeCli(input: {
186 readonly spec: SourceControlCliDiscoverySpec;
187 readonly process: SourceControlHost.SourceControlHost["Service"]["process"];
188 readonly cwd: string;
189}): Effect.Effect<DiscoveryProbeResult> {
190 return input.process
191 .run({
192 operation: "source-control.discovery.probe",
193 command: input.spec.executable,
194 args: input.spec.versionArgs,
195 cwd: input.cwd,
196 timeoutMs: probeTimeoutMs(input.spec),
197 maxOutputBytes: 8_000,
198 appendTruncationMarker: true,
199 })
200 .pipe(
201 Effect.map(
202 (result) =>
203 ({
204 kind: input.spec.kind,
205 label: input.spec.label,
206 executable: input.spec.executable,
207 status: "available" as const,
208 version: Option.orElse(firstNonEmptyLine(result.stdout), () =>
209 firstNonEmptyLine(result.stderr),
210 ),
211 installHint: input.spec.installHint,
212 detail: Option.none<string>(),
213 }) satisfies DiscoveryProbeResult,
214 ),
215 Effect.catch((cause) =>
216 Effect.succeed({
217 kind: input.spec.kind,
218 label: input.spec.label,
219 executable: input.spec.executable,
220 status: "missing" as const,
221 version: Option.none<string>(),
222 installHint: input.spec.installHint,
223 detail: detailFromCause(cause),
224 } satisfies DiscoveryProbeResult),
225 ),
226 );
227}
228
229export function probeSourceControlProvider(input: {
230 readonly spec: SourceControlProviderDiscoverySpec;
231 readonly process: SourceControlHost.SourceControlHost["Service"]["process"];
232 readonly cwd: string;
233}): Effect.Effect<SourceControlProviderDiscoveryItem> {
234 if (input.spec.type === "managed-cli") return input.spec.probe(input.cwd);
235 if (input.spec.type === "api") {
236 return input.spec.probeAuth.pipe(
237 Effect.map(
238 (auth) =>
239 ({
240 kind: input.spec.kind,
241 label: input.spec.label,
242 status: "available" as const,
243 version: Option.none<string>(),
244 installHint: input.spec.installHint,
245 detail: Option.none<string>(),
246 auth,
247 }) satisfies SourceControlProviderDiscoveryItem,
248 ),
249 );
250 }
251
252 const spec = input.spec;
253
254 return probeCli({
255 spec,
256 process: input.process,
257 cwd: input.cwd,
258 }).pipe(
259 Effect.flatMap((item) => {
260 if (item.status !== "available") {
261 return Effect.succeed({
262 ...item,
263 auth: unknownAuth("Hosting integration command was not found on the server PATH."),
264 } satisfies SourceControlProviderDiscoveryItem);
265 }
266
267 return input.process
268 .run({
269 operation: "source-control.discovery.auth",
270 command: spec.executable,
271 args: spec.authArgs,
272 cwd: input.cwd,
273 allowNonZeroExit: true,
274 timeoutMs: probeTimeoutMs(spec),
275 maxOutputBytes: 8_000,
276 appendTruncationMarker: true,
277 })
278 .pipe(
279 Effect.map(
280 (result) =>
281 ({
282 ...item,
283 auth: spec.parseAuth(result),
284 }) satisfies SourceControlProviderDiscoveryItem,
285 ),
286 Effect.catch((cause) =>
287 Effect.succeed({
288 ...item,
289 auth: unknownAuth(Option.getOrUndefined(detailFromCause(cause))),
290 } satisfies SourceControlProviderDiscoveryItem),
291 ),
292 );
293 }),
294 );
295}
296
297export const refineUnknownRemoteProvider = Effect.fn("refineUnknownRemoteProvider")(
298 function* (input: {
299 readonly specs: ReadonlyArray<SourceControlProviderDiscoverySpec>;
300 readonly process: SourceControlHost.SourceControlHost["Service"]["process"];
301 readonly cwd: string;
302 readonly context: SourceControlProvider.SourceControlProviderContext | null;
303 }): Effect.fn.Return<SourceControlProvider.SourceControlProviderContext | null> {
304 if (input.context === null || input.context.provider.kind !== "unknown") {
305 return input.context;
306 }
307 const context = input.context;
308
309 const providers = yield* Effect.forEach(input.specs, (spec) => {
310 if (spec.type === "managed-cli") {
311 return spec.refineUnknownRemote({ cwd: input.cwd, context });
312 }
313 if (!isCliRemoteRefinementSpec(spec)) return Effect.succeed(null);
314 return input.process
315 .run({
316 operation: "source-control.discovery.refine-unknown-remote",
317 command: spec.executable,
318 args: spec.remoteRefinementArgs ?? spec.authArgs,
319 cwd: input.cwd,
320 allowNonZeroExit: true,
321 timeoutMs: probeTimeoutMs(spec),
322 maxOutputBytes: 8_000,
323 appendTruncationMarker: true,
324 })
325 .pipe(
326 Effect.map((auth) =>
327 spec.refineUnknownRemote({
328 cwd: input.cwd,
329 context,
330 auth,
331 }),
332 ),
333 Effect.orElseSucceed(() => null),
334 );
335 });
336 const provider = providers.find((candidate) => candidate !== null);
337
338 return provider ? { ...context, provider } : context;
339 },
340);
packages/source-control-core/src/server/mergeMessage.test.ts created+79
...@@ -0,0 +1,79 @@
1import { describe, expect, it } from "vite-plus/test";
2import { removeAgentCredits } from "./mergeMessage.ts";
3
4describe("removeAgentCredits", () => {
5 it("removes recognized agent trailers and generated footers, keeping human co-authors", () => {
6 expect(
7 removeAgentCredits(
8 [
9 "Details about Claude support.",
10 "",
11 "",
12 "Co-authored-by: Alice <alice@example.com>",
13 "Co-authored-by: Claude <noreply@anthropic.com>",
14 "* Co-authored-by: Codex <noreply@openai.com>",
15 "co-authored-by: Cursor <cursoragent@cursor.com>",
16 "Co-authored-by: Copilot <copilot@github.com>",
17 "",
18 "🤖 Generated with [Claude Code](https://claude.ai/code)",
19 ].join("\n"),
20 ),
21 ).toBe("Details about Claude support.\n\n\nCo-authored-by: Alice <alice@example.com>");
22 });
23
24 it("preserves unknown identities, humans named after agents, and ordinary prose", () => {
25 const message = [
26 "Co-authored-by: Claude <claude@example.com>",
27 "Co-authored-by: Alice <noreply@github.com>",
28 "Co-authored-by: Release Bot <release@example.com>",
29 "Generated by our new Claude integration.",
30 "Generated with [Claude Code](https://example.com)",
31 "",
32 ].join("\n");
33 expect(removeAgentCredits(message)).toBe(message);
34 });
35
36 it("preserves code fences and block quotes containing attribution examples", () => {
37 const example = "Co-authored-by: Claude <noreply@anthropic.com>";
38 const message = `Example:\n\n\`\`\`text\n${example}\n\`\`\`\n\n> ${example}\n\n${example}`;
39 expect(removeAgentCredits(message)).toBe(
40 `Example:\n\n\`\`\`text\n${example}\n\`\`\`\n\n> ${example}`,
41 );
42 });
43
44 it("preserves indented examples and whitespace on surviving lines", () => {
45 const example = "Co-authored-by: Claude <noreply@anthropic.com>";
46 expect(removeAgentCredits(`Example:\n\n ${example}\n\n${example}\nDetails \n`)).toBe(
47 `Example:\n\n ${example}\n\nDetails `,
48 );
49 });
50
51 it.each(["``` text ```", " ```", "\t~~~"])(
52 "removes trailers after a line that cannot open a fence: %s",
53 (line) => {
54 expect(removeAgentCredits(`${line}\n\nCo-authored-by: Claude <noreply@anthropic.com>`)).toBe(
55 line,
56 );
57 },
58 );
59
60 it.each(["```", "~~~"])("keeps examples after a false closing fence: %s", (fence) => {
61 const example = "Co-authored-by: Claude <noreply@anthropic.com>";
62 const content = `${fence}text\n${fence} trailing text\n${example}\n ${fence}\n${example}\n${fence.slice(0, 2)}\n${example}\n ${fence}${fence[0]} \t`;
63 expect(removeAgentCredits(`${content}\n\n${example}`)).toBe(content);
64 });
65
66 it("preserves CRLF line endings", () => {
67 expect(
68 removeAgentCredits(
69 "Details\r\n\r\nCo-authored-by: Claude <noreply@anthropic.com>\r\nCo-authored-by: Alice <alice@example.com>",
70 ),
71 ).toBe("Details\r\n\r\nCo-authored-by: Alice <alice@example.com>");
72 });
73
74 it("returns an empty body when only agent credits remain", () => {
75 expect(
76 removeAgentCredits("Co-authored-by: Claude <noreply@anthropic.com>\n\nGenerated by Codex\n"),
77 ).toBe("");
78 });
79});
packages/source-control-core/src/server/mergeMessage.ts created+54
...@@ -0,0 +1,54 @@
1const AGENT_EMAILS = new Set([
2 "noreply@anthropic.com",
3 "claude@anthropic.com",
4 "noreply@openai.com",
5 "codex@openai.com",
6 "cursoragent@cursor.com",
7 "copilot@github.com",
8 "noreply@github.com",
9]);
10
11const GENERATED_BY_AGENT =
12 /^(?:🤖\s*)?generated (?:with|by)\s+(?:claude(?: code)?|codex|cursor|github copilot|copilot|opencode|grok|antigravity)[.!]?$/i;
13
14/** Remove standalone agent credits while keeping human signatures and quoted examples. */
15export function removeAgentCredits(message: string): string {
16 let fence: string | undefined;
17 let removed = false;
18 const newline = message.includes("\r\n") ? "\r\n" : "\n";
19 const lines = message.split(/\r?\n/).filter((line) => {
20 if (/^(?: {4}|\t)/.test(line)) return true;
21 const delimiter = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
22 const marker = delimiter?.[1];
23 const remainder = delimiter?.[2] ?? "";
24 if (marker !== undefined) {
25 if (fence === undefined) {
26 if (marker[0] === "~" || !remainder.includes("`")) fence = marker;
27 } else if (
28 marker[0] === fence[0] &&
29 marker.length >= fence.length &&
30 /^[ \t]*$/.test(remainder)
31 ) {
32 fence = undefined;
33 }
34 return true;
35 }
36 if (fence !== undefined) return true;
37 const text = line.trim().replace(/^(?:[-*]\s+)/, "");
38 const author = /^co-authored-by:\s*(.*?)\s*<([^<>]+)>\s*$/i.exec(text);
39 // GitHub's generic noreply address is also used by people; require Copilot's name.
40 const email = author?.[2]?.toLowerCase();
41 const agentAuthor =
42 email !== undefined &&
43 AGENT_EMAILS.has(email) &&
44 (email !== "noreply@github.com" || /^github copilot$|^copilot$/i.test(author![1]!));
45 const footer = text.replace(
46 /\[([^\]]+)\]\(https:\/\/(?:claude\.ai|www\.anthropic\.com|chatgpt\.com|openai\.com|cursor\.com|github\.com|opencode\.ai|x\.ai|antigravity\.google)(?:\/[^\s)]*)?\)/gi,
47 "$1",
48 );
49 if (!agentAuthor && !GENERATED_BY_AGENT.test(footer)) return true;
50 removed = true;
51 return false;
52 });
53 return removed ? lines.join(newline).replace(/(?:\r?\n[ \t]*)+$/, "") : message;
54}
packages/source-control-core/src/server/pullRequestChecks.test.ts created+93
...@@ -0,0 +1,93 @@
1import type { PullRequestCheck, PullRequestCheckStatus } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import { dedupeChecks } from "./pullRequestChecks.ts";
5
6function entry(
7 name: string,
8 status: PullRequestCheckStatus,
9 extra: { readonly workflowName?: string | null; readonly at?: string | null } = {},
10): {
11 readonly check: PullRequestCheck;
12 readonly workflowName: string | null;
13 readonly at: string | null;
14} {
15 return {
16 check: { name, status, description: null, url: null },
17 workflowName: extra.workflowName ?? null,
18 at: extra.at ?? null,
19 };
20}
21
22describe("dedupeChecks", () => {
23 it("keeps the newest run of a check the host listed twice", () => {
24 const checks = dedupeChecks([
25 entry("Prepare PR size config", "success", {
26 workflowName: "PR Size",
27 at: "2026-08-11T16:06:25Z",
28 }),
29 entry("Prepare PR size config", "pending", {
30 workflowName: "PR Size",
31 at: "2026-08-11T17:01:04Z",
32 }),
33 ]);
34
35 expect(checks.map((check) => [check.name, check.status])).toEqual([
36 ["Prepare PR size config", "pending"],
37 ]);
38 });
39
40 it("holds a check at the place it first appeared, so a re-run does not reshuffle the list", () => {
41 const checks = dedupeChecks([
42 entry("lint", "success", { workflowName: "CI", at: "2026-08-11T16:00:00Z" }),
43 entry("test", "success", { workflowName: "CI", at: "2026-08-11T16:00:00Z" }),
44 entry("lint", "failure", { workflowName: "CI", at: "2026-08-11T18:00:00Z" }),
45 ]);
46
47 expect(checks.map((check) => [check.name, check.status])).toEqual([
48 ["lint", "failure"],
49 ["test", "success"],
50 ]);
51 });
52
53 it("loses a run that never said when it happened to one that did, whichever came first", () => {
54 const undated = dedupeChecks([
55 entry("build", "success", { at: "2026-08-11T16:00:00Z" }),
56 entry("build", "pending"),
57 ]);
58 const dated = dedupeChecks([
59 entry("build", "pending"),
60 entry("build", "success", { at: "2026-08-11T16:00:00Z" }),
61 ]);
62
63 expect([undated[0]?.status, dated[0]?.status]).toEqual(["success", "success"]);
64 });
65
66 it("takes the last copy when neither run is dated, which is how an update is listed", () => {
67 const checks = dedupeChecks([entry("build", "pending"), entry("build", "failure")]);
68
69 expect(checks.map((check) => check.status)).toEqual(["failure"]);
70 });
71
72 it("keeps two workflows that name a job the same thing, and says which is which", () => {
73 const checks = dedupeChecks([
74 entry("build", "success", { workflowName: "CI", at: "2026-08-11T16:00:00Z" }),
75 entry("build", "failure", { workflowName: "Release", at: "2026-08-11T16:00:00Z" }),
76 ]);
77
78 expect(checks.map((check) => [check.name, check.status])).toEqual([
79 ["CI / build", "success"],
80 ["Release / build", "failure"],
81 ]);
82 });
83
84 it("leaves a colliding check with no workflow of its own unqualified", () => {
85 // An app-provided check run belongs to no workflow, which GitHub reports as an empty name.
86 const checks = dedupeChecks([
87 entry("build", "success", { workflowName: "", at: "2026-08-11T16:00:00Z" }),
88 entry("build", "failure", { workflowName: "CI", at: "2026-08-11T16:00:00Z" }),
89 ]);
90
91 expect(checks.map((check) => check.name)).toEqual(["build", "CI / build"]);
92 });
93});
packages/source-control-core/src/server/pullRequestChecks.ts created+55
...@@ -0,0 +1,55 @@
1import type { PullRequestCheck } from "@t3tools/contracts";
2
3/** ISO-8601 timestamps in UTC compare correctly as plain text, which is all the ordering needs. */
4function isAtLeastAsNew(candidate: string | null, kept: string | null): boolean {
5 if (candidate === null) return kept === null;
6 return kept === null || candidate >= kept;
7}
8
9/**
10 * One row per check rather than one per run of it.
11 *
12 * A host's rollup is a list of runs, not a list of checks: while a workflow is being re-run — or
13 * while a second run of it is already live — the same check arrives twice, and both copies reach
14 * the reader as what looks like a duplicate. Nothing in a check carries an id, so the name is what
15 * identifies it, qualified by the workflow it belongs to since two workflows are free to name a
16 * job the same thing.
17 *
18 * Within a group the newest run is the one worth showing: a re-run is the answer that replaces the
19 * one before it, and a run that never said when it happened loses to one that did. A tie goes to
20 * whichever came last, because a host lists a re-run after the run it repeats.
21 *
22 * The order is the host's own, held at the place each check first appeared, so a re-run landing
23 * mid-read replaces a row where it stands instead of reshuffling the list under the reader.
24 *
25 * Two checks that survive under the same name are then genuinely different ones, since they came
26 * from different workflows — each is shown as `workflow / name`, the way GitHub writes it itself.
27 * A survivor whose workflow the host did not name keeps its bare name rather than being qualified
28 * with nothing.
29 */
30export function dedupeChecks(
31 entries: ReadonlyArray<{
32 readonly check: PullRequestCheck;
33 readonly workflowName: string | null;
34 readonly at: string | null;
35 }>,
36): ReadonlyArray<PullRequestCheck> {
37 const newestByCheck = new Map<string, (typeof entries)[number]>();
38 for (const entry of entries) {
39 const key = `${entry.workflowName ?? ""} ${entry.check.name}`;
40 const kept = newestByCheck.get(key);
41 // Re-setting a key a Map already holds keeps its first position, which is the order wanted.
42 if (kept === undefined || isAtLeastAsNew(entry.at, kept.at)) newestByCheck.set(key, entry);
43 }
44 const survivors = [...newestByCheck.values()];
45 const countsByName = new Map<string, number>();
46 for (const entry of survivors) {
47 countsByName.set(entry.check.name, (countsByName.get(entry.check.name) ?? 0) + 1);
48 }
49 return survivors.map((entry) => {
50 const workflowName = entry.workflowName ?? "";
51 return workflowName.length > 0 && (countsByName.get(entry.check.name) ?? 0) > 1
52 ? { ...entry.check, name: `${workflowName} / ${entry.check.name}` }
53 : entry.check;
54 });
55}
packages/source-control-core/tsconfig.json created+7
...@@ -0,0 +1,7 @@
1{
2 "extends": "../../tsconfig.base.json",
3 "compilerOptions": {
4 "types": ["node"]
5 },
6 "include": ["src"]
7}
pnpm-lock.yaml+22
...@@ -648,6 +648,9 @@ importers:...@@ -648,6 +648,9 @@ importers:
648 '@t3tools/provider-pi':648 '@t3tools/provider-pi':
649 specifier: workspace:*649 specifier: workspace:*
650 version: link:../../packages/provider-pi650 version: link:../../packages/provider-pi
651 '@t3tools/source-control-core':
652 specifier: workspace:*
653 version: link:../../packages/source-control-core
651 diff:654 diff:
652 specifier: 8.0.3655 specifier: 8.0.3
653 version: 8.0.3656 version: 8.0.3
...@@ -1510,6 +1513,25 @@ importers:...@@ -1510,6 +1513,25 @@ importers:
1510 specifier: 1.0.01513 specifier: 1.0.0
1511 version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)1514 version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
15121515
1516 packages/source-control-core:
1517 dependencies:
1518 '@t3tools/contracts':
1519 specifier: workspace:*
1520 version: link:../contracts
1521 effect:
1522 specifier: 4.0.2
1523 version: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688)
1524 devDependencies:
1525 '@effect/vitest':
1526 specifier: 4.0.2
1527 version: 4.0.2(patch_hash=595b7d26fb635caba9c3d39fcb189bb78a000192f8d3b60ead7174b310d1ade9)(@types/node@24.12.4)(@vitest/ui@5.0.1)(bufferutil@4.1.0)(effect@4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
1528 '@types/node':
1529 specifier: 24.12.4
1530 version: 24.12.4
1531 vite-plus:
1532 specifier: 1.0.0
1533 version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
1534
1513 packages/ssh:1535 packages/ssh:
1514 dependencies:1536 dependencies:
1515 '@t3tools/contracts':1537 '@t3tools/contracts':
scripts/release-smoke.ts+1
...@@ -37,6 +37,7 @@ const workspaceFiles = [...@@ -37,6 +37,7 @@ const workspaceFiles = [
37 "packages/provider-grok/package.json",37 "packages/provider-grok/package.json",
38 "packages/provider-muse/package.json",38 "packages/provider-muse/package.json",
39 "packages/provider-opencode/package.json",39 "packages/provider-opencode/package.json",
40 "packages/source-control-core/package.json",
40 "packages/effect-acp/package.json",41 "packages/effect-acp/package.json",
41 "packages/effect-codex-app-server/package.json",42 "packages/effect-codex-app-server/package.json",
42 "scripts/package.json",43 "scripts/package.json",