diff --git a/apps/server/package.json b/apps/server/package.json index bb166039abd95cb1e49908ad61bf46526b7ac532..ea7b9019e41524976f105e6248587af1872518b5 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -45,6 +45,7 @@ "@t3tools/provider-muse": "workspace:*", "@t3tools/provider-opencode": "workspace:*", "@t3tools/provider-pi": "workspace:*", + "@t3tools/source-control-core": "workspace:*", "diff": "8.0.3", "effect": "catalog:", "jose": "catalog:", diff --git a/apps/server/scripts/evaluate-thread-titles.ts b/apps/server/scripts/evaluate-thread-titles.ts index 0747825dcb5b33fc137b0d05ebc51e784a29f10f..196bcbdfbcaaa6169b8a5088cde6ba2697387f9e 100644 --- a/apps/server/scripts/evaluate-thread-titles.ts +++ b/apps/server/scripts/evaluate-thread-titles.ts @@ -23,6 +23,7 @@ import { } from "@t3tools/provider-core/server/threadTitleContext"; import * as ThreadTitleLinks from "../src/textGeneration/ThreadTitleLinks.ts"; import * as SourceControlProviderRegistry from "../src/sourceControl/SourceControlProviderRegistry.ts"; +import * as ServerSourceControlHost from "../src/sourceControl/ServerSourceControlHost.ts"; import * as GitHubApi from "../src/sourceControl/GitHubApi.ts"; import * as GitLabCli from "../src/sourceControl/GitLabCli.ts"; import * as ForgejoCli from "../src/sourceControl/ForgejoCli.ts"; @@ -158,6 +159,7 @@ await Effect.runPromise( AzureDevOpsCli.layer, // No saved credentials here; Bitbucket falls back to T3CODE_BITBUCKET_* variables. BitbucketApi.layer, + ServerSourceControlHost.layer, ), ), // Default settings: no saved Bitbucket token, gh's own GitHub account choice. diff --git a/apps/server/scripts/measure-pr-preview.ts b/apps/server/scripts/measure-pr-preview.ts index e8f0727ac93cf4fb2d2e8ffe26adb39b6e34b770..0f33ec9a6139c05d476781693d92b46c72dd1509 100644 --- a/apps/server/scripts/measure-pr-preview.ts +++ b/apps/server/scripts/measure-pr-preview.ts @@ -16,7 +16,7 @@ import * as GitHubApi from "../src/sourceControl/GitHubApi.ts"; import * as GitHubCredentials from "../src/sourceControl/GitHubCredentials.ts"; import * as ServerSettings from "../src/serverSettings.ts"; import * as GitHubQuota from "../src/sourceControl/githubQuota.ts"; -import * as SourceControlRateLimit from "../src/sourceControl/SourceControlRateLimit.ts"; +import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; import * as VcsProcess from "../src/vcs/VcsProcess.ts"; const [repository, ...numbers] = process.argv.slice(2); diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 9f5f368d2cf248d15644794322c6f254507edc5f..c7e76f5e71ab5011eada6a7541ac7cb8817d9a1b 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -58,7 +58,7 @@ import { ForgejoPullRequestSchema, toForgejoChangeRequest, } from "../sourceControl/forgejoPullRequests.ts"; -import type { SourceControlProvider } from "../sourceControl/SourceControlProvider.ts"; +import type { SourceControlProvider } from "@t3tools/source-control-core/server/SourceControlProvider"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; import * as ServerConfig from "../config.ts"; import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts index 36f4bdb158913f84d12a8c6f6e5ccc8775dc11c9..19e67f949b611104f5517c23fb8192c59dc2a4e4 100644 --- a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts +++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts @@ -31,7 +31,7 @@ import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import { TestClock } from "effect/testing"; -import { PullRequestProviderError } from "../pullRequest/PullRequestProvider.ts"; +import { PullRequestProviderError } from "@t3tools/source-control-core/server/PullRequestProvider"; import * as GitManager from "../git/GitManager.ts"; import * as PullRequestService from "../pullRequest/PullRequestService.ts"; import * as ServerActivation from "../serverActivation.ts"; diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts index 5f1fcd8269acd4c42c8f1b0903ec075ea17ff421..a966cbd954561dac945e572e4c9d8f290242279f 100644 --- a/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts @@ -29,7 +29,7 @@ import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import * as GitManager from "../git/GitManager.ts"; -import { PullRequestProviderError } from "../pullRequest/PullRequestProvider.ts"; +import { PullRequestProviderError } from "@t3tools/source-control-core/server/PullRequestProvider"; import * as PullRequestService from "../pullRequest/PullRequestService.ts"; import { forkParked } from "../serverActivation.ts"; import * as Orchestrator from "./Orchestrator.ts"; diff --git a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts index 7005534f89edee4aa792019bf50dde60896be58e..5a5f61ae3c4da8ca89eca2bd8988710d58e02508 100644 --- a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts @@ -29,7 +29,7 @@ import type * as Scope from "effect/Scope"; import { type ProviderChangeRequestWatchFingerprint, PullRequestProviderError, -} from "../pullRequest/PullRequestProvider.ts"; +} from "@t3tools/source-control-core/server/PullRequestProvider"; import * as PullRequestService from "../pullRequest/PullRequestService.ts"; import { forkParked } from "../serverActivation.ts"; import * as Orchestrator from "./Orchestrator.ts"; diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index b11184f4463262a8e2d078f28d53cd41049c0191..bb70c880b061554779ccc0a47e4ef3e4e9078c25 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -65,7 +65,7 @@ import * as ProviderRuntimeRecoveryService from "./ProviderRuntimeRecoveryServic import * as ProjectionMaintenance from "./ProjectionMaintenance.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as PullRequestWatchReactor from "./PullRequestWatchReactor.ts"; -import { PullRequestProviderError } from "../pullRequest/PullRequestProvider.ts"; +import { PullRequestProviderError } from "@t3tools/source-control-core/server/PullRequestProvider"; import * as PullRequestService from "../pullRequest/PullRequestService.ts"; import * as ProjectStore from "./ProjectStore.ts"; import * as ProviderSessionManager from "./ProviderSessionManager.ts"; diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts index 5f5453a9b3badf8cd9f0d8b1d78f3190ed4cac07..d00d08b7d05f7649044270ff9bd33599db3b8bf1 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts @@ -26,7 +26,7 @@ import { type AzureDevOpsPullRequest, type AzureDevOpsRepositoryLocation, } from "./azureDevOpsPullRequestJson.ts"; -import type { ProviderListCursor } from "./PullRequestProvider.ts"; +import type { ProviderListCursor } from "@t3tools/source-control-core/server/PullRequestProvider"; /** * Names the read that produced unusable output, so a failure reports the call it came from diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts index 2cdf78c56a1456a264fdad465833c3d7384d0c33..afd0456369473ec9bdbd53f61673d4f7c672c3b6 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts @@ -24,7 +24,7 @@ import { type ProviderChangeRequestSummary, type ProviderDiffSlice, type PullRequestProviderApi, -} from "./PullRequestProvider.ts"; +} from "@t3tools/source-control-core/server/PullRequestProvider"; import type { AzureDevOpsChangeEntry, AzureDevOpsItemContent, diff --git a/apps/server/src/pullRequest/BitbucketPullRequestApi.ts b/apps/server/src/pullRequest/BitbucketPullRequestApi.ts index c7d017071fc61fc54283fc2590ffcdc57db7fafa..f5e108b05d452941b3135a0b07e88f1518b65c9b 100644 --- a/apps/server/src/pullRequest/BitbucketPullRequestApi.ts +++ b/apps/server/src/pullRequest/BitbucketPullRequestApi.ts @@ -39,7 +39,7 @@ import { type BitbucketPullRequest, type BitbucketRawComment, } from "./bitbucketPullRequestJson.ts"; -import type { ProviderListCursor } from "./PullRequestProvider.ts"; +import type { ProviderListCursor } from "@t3tools/source-control-core/server/PullRequestProvider"; /** * Names the read that produced unusable output, so a failure reports the call it came from diff --git a/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts b/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts index f553dfd13bc1a23a4518181ecb8832d447b2fe80..0abcb7d95e7e745350044265fea70c145aae5fba 100644 --- a/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts +++ b/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts @@ -9,7 +9,7 @@ import { type ProviderChangeRequestActivity, type ProviderChangeRequestDetail, type PullRequestProviderApi, -} from "./PullRequestProvider.ts"; +} from "@t3tools/source-control-core/server/PullRequestProvider"; import type { BitbucketPullRequest } from "./bitbucketPullRequestJson.ts"; const CAPABILITIES: PullRequestCapabilities = { diff --git a/apps/server/src/pullRequest/ForgejoPullRequestProvider.ts b/apps/server/src/pullRequest/ForgejoPullRequestProvider.ts index 12d931279a74c4644f31acc3363a4f3a56955857..a9337fdbe8fd46c2b5b67051aaca746a35f8fad2 100644 --- a/apps/server/src/pullRequest/ForgejoPullRequestProvider.ts +++ b/apps/server/src/pullRequest/ForgejoPullRequestProvider.ts @@ -10,7 +10,7 @@ import { type ProviderChangeRequestDetail, type ProviderRepositoryRef, type PullRequestProviderApi, -} from "./PullRequestProvider.ts"; +} from "@t3tools/source-control-core/server/PullRequestProvider"; import { ForgejoPullRequest, ForgejoRepository, diff --git a/apps/server/src/pullRequest/GitHubPullRequestApi.test.ts b/apps/server/src/pullRequest/GitHubPullRequestApi.test.ts index 294d053d6f056d88426a7bc1b572af95c0d6f662..8888f4c11f45a2310dfda82bc1a9829d940abb4c 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestApi.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestApi.test.ts @@ -16,7 +16,7 @@ import { AllowGitHubReserve } from "../sourceControl/GitHubApi.ts"; import * as GitHubApi from "../sourceControl/GitHubApi.ts"; import * as GitHubCredentials from "../sourceControl/GitHubCredentials.ts"; import * as GitHubQuota from "../sourceControl/githubQuota.ts"; -import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts"; +import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; import { KnownWorkflowRuns } from "./gitHubConditionalChecks.ts"; diff --git a/apps/server/src/pullRequest/GitHubPullRequestApi.ts b/apps/server/src/pullRequest/GitHubPullRequestApi.ts index 5e35729154604a3cfe5efd1f6865a1179ac2bd45..dd093df00b775ea32223af3eef936a30418c7f0f 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestApi.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestApi.ts @@ -1,4 +1,4 @@ -import { removeAgentCredits } from "./mergeMessage.ts"; +import { removeAgentCredits } from "@t3tools/source-control-core/server/mergeMessage"; import { KnownWorkflowRuns, makeChecksRevalidator } from "./gitHubConditionalChecks.ts"; import { runGitHubStackAction, type GitHubStackActionError } from "./githubStackActions.ts"; import * as Cause from "effect/Cause"; @@ -45,7 +45,7 @@ import { import * as GitHubApi from "../sourceControl/GitHubApi.ts"; import { readGraphQlPages } from "../sourceControl/githubGraphQl.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; -import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts"; +import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; import { ACTOR_AVATARS_GRAPHQL_QUERY, ADD_REACTION_GRAPHQL_MUTATION, @@ -128,7 +128,10 @@ import { type GitHubReviewThreadPage, type GitHubViewerAccess, } from "./gitHubPullRequestJson.ts"; -import type { ProviderChangeRequestSummary, ProviderListCursor } from "./PullRequestProvider.ts"; +import type { + ProviderChangeRequestSummary, + ProviderListCursor, +} from "@t3tools/source-control-core/server/PullRequestProvider"; /** * Names the read that produced unusable output, so a failure reports the call it came from diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts index f2672dbe991fde29f668c872b4a9487523104601..67f871501ddb2af74eb06c6214c4f4905bcd4145 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts @@ -16,7 +16,7 @@ import { type ProviderChangeRequestDetail, type PullRequestProviderApi, type ProviderRepositoryRef, -} from "./PullRequestProvider.ts"; +} from "@t3tools/source-control-core/server/PullRequestProvider"; import type { GitHubViewerAccess, GitHubWorkflowRunApproval } from "./gitHubPullRequestJson.ts"; const CAPABILITIES: PullRequestCapabilities = { diff --git a/apps/server/src/pullRequest/GitLabPullRequestCli.ts b/apps/server/src/pullRequest/GitLabPullRequestCli.ts index c3b6070024978a013025490cd22301b21a11894a..933e6874218737dae99bb852194175a524be8326 100644 --- a/apps/server/src/pullRequest/GitLabPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitLabPullRequestCli.ts @@ -44,7 +44,7 @@ import { type GitLabMergeRequestListItem, type GitLabProjectUsers, } from "./gitLabMergeRequestJson.ts"; -import type { ProviderListCursor } from "./PullRequestProvider.ts"; +import type { ProviderListCursor } from "@t3tools/source-control-core/server/PullRequestProvider"; /** * Names the read that produced unusable output, so a failure reports the call it came from diff --git a/apps/server/src/pullRequest/GitLabPullRequestProvider.ts b/apps/server/src/pullRequest/GitLabPullRequestProvider.ts index 45034b84787c3210c69bb603a7d71800a06e64bb..1b5b987cd5acb3c25dc6fd8f080b703c6a07d6c9 100644 --- a/apps/server/src/pullRequest/GitLabPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GitLabPullRequestProvider.ts @@ -12,7 +12,7 @@ import { type ProviderChangeRequestActivity, type ProviderChangeRequestDetail, type PullRequestProviderApi, -} from "./PullRequestProvider.ts"; +} from "@t3tools/source-control-core/server/PullRequestProvider"; const CAPABILITIES: PullRequestCapabilities = { diff: true, diff --git a/apps/server/src/pullRequest/PullRequestProvider.ts b/apps/server/src/pullRequest/PullRequestProvider.ts deleted file mode 100644 index c3f588a6227749d634d6b5789133afa2a2e8e563..0000000000000000000000000000000000000000 --- a/apps/server/src/pullRequest/PullRequestProvider.ts +++ /dev/null @@ -1,702 +0,0 @@ -import * as Effect from "effect/Effect"; -import * as Schema from "effect/Schema"; -import type { - PullRequestStackMembership, - PullRequestAction, - PullRequestStackHead, - PullRequestActor, - PullRequestBaseComparison, - PullRequestCapabilities, - PullRequestChecksState, - PullRequestCheck, - PullRequestChecks, - PullRequestComment, - PullRequestFileViewed, - PullRequestCommit, - PullRequestInvolvement, - PullRequestLabel, - PullRequestListFilters, - PullRequestListState, - PullRequestMergeCapabilities, - PullRequestMergeMethod, - PullRequestMergeability, - PullRequestOmittedFileStat, - PullRequestReaction, - PullRequestReactionContent, - PullRequestReviewCommentDraft, - PullRequestReviewDecision, - PullRequestReviewThread, - PullRequestThreadCommentsResult, - PullRequestReviewVerdict, - PullRequestReviewerCandidateList, - PullRequestReviewerKind, - PullRequestLabelCandidateList, - PullRequestState, - PullRequestPreview, - PullRequestUpdateMethod, - PullRequestViewerPermissions, - SourceControlProviderKind, -} from "@t3tools/contracts"; -import { SourceControlProviderKind as SourceControlProviderKindSchema } from "@t3tools/contracts"; - -/** - * The one failure shape every provider reports, so the service can decide what a failure means - * without knowing which CLI or API produced it. - * - * `reason` is the part the service acts on: a missing or unauthenticated tool disables the - * provider for the whole workspace, a rate limit pauses its host, and anything else is specific - * to the request. - */ -export class PullRequestProviderError extends Schema.TaggedError()( - "PullRequestProviderError", - { - provider: SourceControlProviderKindSchema, - operation: Schema.String, - reason: Schema.Literals([ - "missing-tool", - "unauthenticated", - "rate-limited", - "not-found", - "failed", - ]), - detail: Schema.String, - retryAt: Schema.optional(Schema.Number), - cause: Schema.optional(Schema.Defect()), - }, -) { - override get message(): string { - return `${this.provider} failed in ${this.operation}: ${this.detail}`; - } -} - -/** - * `status` moves with the state, mergeability, head, or checks, which the detail read reports; - * `remarks` moves with comments and reviews, which only the activity read reports. - */ -export interface ProviderChangeRequestWatchFingerprint { - readonly status: string; - readonly remarks: string; -} - -export interface PullRequestProviderFailure { - readonly reason: PullRequestProviderError["reason"]; - readonly retryAt?: number | undefined; -} - -/** A change request as the provider sees it, before the service attaches project context. */ -export interface ProviderChangeRequest { - readonly stack?: PullRequestStackMembership; - readonly number: number; - readonly title: string; - readonly url: string; - readonly author: PullRequestActor | null; - readonly headBranch: string; - readonly headRepositoryNameWithOwner?: string | null; - readonly baseBranch: string; - readonly state: PullRequestState; - readonly isDraft: boolean; - readonly mergeability: PullRequestMergeability; - readonly additions: number; - readonly deletions: number; - readonly createdAt: string; - readonly closedAt?: string | null; - readonly mergedAt?: string | null; - readonly updatedAt: string; - /** Accounts with a review requested. Team-level requests are excluded by each provider. */ - readonly reviewRequestLogins: ReadonlyArray; - readonly labels: ReadonlyArray; - /** Absent from a host that does not summarise its reviews, which is every host but GitHub. */ - readonly reviewDecision?: PullRequestReviewDecision | null | undefined; - /** Absent from a host that reports no check rollup on its listings. */ - readonly checksState?: PullRequestChecksState | null | undefined; -} - -/** The fields needed to keep a linked thread's pull request status live. */ -export interface ProviderChangeRequestSummary { - readonly number: number; - readonly title: string; - readonly url: string; - readonly headBranch: string; - readonly baseBranch: string; - readonly state: PullRequestState; - /** Present when the host says an open pull request is still a draft. */ - readonly isDraft?: boolean; - readonly closedAt?: string | null; - readonly mergedAt?: string | null; - readonly updatedAt: string; - /** Overview fields, present where the host's single read returns them at no extra cost. */ - readonly author?: PullRequestActor | null | undefined; - readonly additions?: number | undefined; - readonly deletions?: number | undefined; - readonly changedFiles?: number | undefined; - readonly reviewDecision?: PullRequestReviewDecision | null | undefined; - readonly checksState?: PullRequestChecksState | null | undefined; - readonly mergeability?: PullRequestMergeability | undefined; - /** - * The host-native stack the pull request sits in, from the same read. Null when the host says - * it is in none; absent when the read did not ask. - */ - readonly stack?: PullRequestStackMembership | null | undefined; -} - -/** One layer of a host-native stack, bottom to top order is the array's. */ -export interface ProviderChangeRequestStackLayer { - readonly title?: string; - readonly isDraft?: boolean; - readonly headSha?: string; - readonly number: number; - readonly headBranch: string; - readonly state: PullRequestState; -} - -/** - * A host-native stack: an ordered set of change requests the host itself merges and retargets as - * a unit. Only GitHub offers one today; the neutral shape lets the sync reactor and the UI stay - * ignorant of which host said so. - */ -export interface ProviderChangeRequestStack { - readonly id: string; - readonly number: number; - readonly url: string; - readonly base: string; - readonly layers: ReadonlyArray; -} - -export interface ProviderChangeRequestPage { - readonly items: ReadonlyArray; - /** True when the host has more rows than the page size asked for. */ - readonly truncated: boolean; - /** - * Optional count-based cursor advance. Most hosts advance by the rows delivered after local - * de-duplication; an offset-paged host may need to count malformed raw rows it consumed too. - */ - readonly cursorAdvance?: number; - /** - * This page can be carried on from, so the service may hand the caller a cursor for it. False - * where the host answered in an order a cursor means nothing in, which leaves a larger `limit` - * as the only way to the rest — what every listing did before there were cursors. - */ - readonly continues: boolean; -} - -/** - * Where a repository's next slice starts, as the provider that has to ask for it needs it. Built - * by the service out of the slice it just handed over, so the boundary that decides whether a row - * arrives twice or not at all is decided in one place rather than in four. - */ -export interface ProviderListCursor { - /** - * The instant of the oldest row already handed over, checked against a timestamp's shape before - * it gets here because it goes into a host's own filter. Asked for inclusively: several rows - * share one instant often enough — a bot that touches eight change requests writes one timestamp - * on all eight — and asking for strictly older would lose whichever of them the slice ended - * before. The service drops the ones it has already sent. - */ - readonly updatedBefore: string; - /** - * How many provider rows this repository has consumed so far, for a host that carries on by - * counting rather than by date. Usually this is the number handed over; malformed raw rows may - * count too when the provider reports a `cursorAdvance`. - */ - readonly delivered: number; -} - -/** One repository's row inside an answer that spans several of them. */ -export interface ProviderBatchedChangeRequest extends ProviderChangeRequest { - /** Provider-native identity, exactly as it was asked for, so the caller can file the row. */ - readonly repository: string; -} - -/** - * One slice of a host read across several repositories at once, newest update first across all - * of them. There is no per-repository page here because the host was asked one question: the - * caller splits the rows by `repository` and works out where each of them carries on from the - * oldest row in the slice, which every repository the slice covers is now read up to. - */ -export interface ProviderBatchedChangeRequestPage { - readonly items: ReadonlyArray; - /** True when the host has more rows than the slice asked for, for any of the repositories. */ - readonly truncated: boolean; -} - -/** The line counts for one change request, which a listing may leave for a second read. */ -export interface ProviderChangeRequestStat { - readonly repository: string; - readonly number: number; - readonly additions: number; - readonly deletions: number; -} - -export interface ProviderChangeRequestDetail extends ProviderChangeRequest { - /** The head commit, where the host's detail read reports it. */ - readonly headSha?: string | null; - readonly body: string; - readonly changedFiles: number; - readonly mergedAt: string | null; - readonly closedAt: string | null; - readonly reviewers: ReadonlyArray; - readonly checks: ReadonlyArray; - readonly mergeCapabilities: PullRequestMergeCapabilities; - readonly viewerPermissions: PullRequestViewerPermissions; - /** Absent from a host that cannot compare the branch with its base, which is most of them. */ - readonly baseComparison?: PullRequestBaseComparison; - readonly behindBy?: number; - /** Absent from a host that does not report whether it is armed to merge this on its own. */ - readonly autoMergeEnabled?: boolean; - /** The strategy stored with an armed auto-merge, where the host reports it. */ - readonly autoMergeMethod?: PullRequestMergeMethod; - /** Workflow runs on this head commit that still need a maintainer's approval. */ - readonly workflowApprovalsRequired?: number; -} - -/** The conversation-shaped half of a detail, loaded after the core can already render. */ -export interface ProviderChangeRequestActivity { - /** An optional richer actor, e.g. after GitHub's GraphQL read supplies an avatar. */ - readonly author?: PullRequestActor | null; - /** Optional because most hosts already report their reviewer list in the core detail. */ - readonly reviewers?: ReadonlyArray; - readonly comments: ReadonlyArray; - /** - * The host's own count of the conversation, which a bounded read can fall short of. A host - * that reports no count of its own answers with what it handed over, which is the same number - * once the read went to the end. - */ - readonly commentCount: number; - readonly commentsTruncated: boolean; - readonly reviewThreadsTruncated?: boolean; - readonly reviewThreads: ReadonlyArray; - readonly commits: ReadonlyArray; - /** The change request's own reactions, from a host that has them. */ - readonly reactions?: ReadonlyArray; -} - -export interface ProviderDiffSlice { - readonly patch: string; - /** Something in this slice could not be shown, as opposed to there being more slices. */ - readonly truncated: boolean; - readonly nextCursor: string | null; - /** The host's own counts for the files whose hunks it withheld from this slice. */ - readonly omittedFileStats?: ReadonlyArray; -} - -export interface ProviderDiffFileContents { - readonly oldContents: string; - readonly newContents: string; -} - -export interface ProviderFilesViewed { - readonly files: ReadonlyArray; - /** The host has more files than were read, so the ones missing here are not "unviewed". */ - readonly truncated: boolean; -} - -/** - * What version each of the asked-for files is at, on the change request's head. Opaque strings, - * compared only against one another, where the empty string is the answer for a file the change - * request deletes rather than a gap. A path is absent only where the read could not say, so a - * provider converts its host's own absences on the way here. - * - * The whole path a version travels, and the three senses of null along it, are in - * `docs/internals/pull-request-file-revisions.md`. - */ -export interface ProviderFileRevisions { - readonly revisions: ReadonlyMap; - /** - * Whether these are every file the change request carries rather than only the paths asked - * about. A host with no per-file version reads the whole change to answer for one file, and - * saying so is what keeps the next tick, naming a path nothing asked about before, from making - * it read the whole change again. False for a read cut short, which cannot speak past the cut. - */ - readonly complete?: boolean; -} - -export interface ProviderRepositoryRef { - readonly cwd: string; - /** Provider-native repository identity, e.g. `owner/repo` or `group/subgroup/project`. */ - readonly repository: string; - /** - * The host it lives on, which `repository` deliberately leaves out — the same `owner/repo` - * exists on github.com and on a GitHub Enterprise install, and only the caller knows which - * one a project's remote points at. - */ - readonly host: string; -} - -/** - * One host's change requests. Implementations own their own tool and JSON shapes and hand back - * the neutral types above; anything a host cannot do is declared in `capabilities` rather than - * failing at call time. - */ -export interface PullRequestProviderApi { - readonly withVerifiedCredential?: ( - input: { readonly cwd: string; readonly host: string }, - use: (identity: { - readonly accountId: string; - readonly viewer: string; - readonly credentialFingerprint: string; - }) => Effect.Effect, - ) => Effect.Effect; - readonly getRoutingIdentity?: (input: { - readonly cwd: string; - readonly host: string; - }) => Effect.Effect< - { readonly accountId: string; readonly viewer: string }, - PullRequestProviderError - >; - readonly kind: SourceControlProviderKind; - readonly capabilities: PullRequestCapabilities; - - /** The signed-in account, which is what involvement filtering compares against. */ - readonly getViewer: (input: { - readonly cwd: string; - readonly host?: string; - }) => Effect.Effect; - - readonly listChangeRequests: ( - input: ProviderRepositoryRef & { - readonly state: PullRequestListState; - readonly involvement: PullRequestInvolvement; - readonly viewer: string; - readonly limit: number; - /** - * Free text to narrow the listing by, as the host understands it. A host with no text - * filter of its own ignores it and answers with the page it would have answered with - * anyway — the caller narrows what it gets, so an unfiltered page is a wider answer - * rather than a wrong one. - */ - readonly query?: string | undefined; - /** - * Where to carry on from, rather than reading this repository from its newest row. Absent - * asks for the first slice, which is every listing that has not been continued. - */ - readonly cursor?: ProviderListCursor | undefined; - /** - * Further narrowings, which a host applies as far as it can and ignores the rest of — - * an unnarrowed page is a wider answer rather than a wrong one, and the caller narrows - * what it gets for the fields a row carries. - */ - readonly filters?: PullRequestListFilters | undefined; - }, - ) => Effect.Effect; - - /** - * The same listing for a whole host in one request, for a host that has a search across - * repositories. Optional: three of the four hosts here have no such API, and breaking the port - * for them to spare GitHub a fan-out would be paying for the fix with everyone else's clarity. - * The caller falls back to `listChangeRequests` per repository where this is absent, and where - * it fails. - * - * `limit` is the whole slice rather than a size per repository, because that is the shape of - * the answer: the newest `limit` rows across every repository named, which is exactly the rows - * a page ordered by update shows. - * - * `cursor` is one boundary for all of them, so a caller with repositories standing at different - * boundaries asks in groups rather than in one call. - */ - readonly listChangeRequestsAcross?: (input: { - /** Any checkout on the host, which is what the tool is run in. */ - readonly cwd: string; - readonly host: string; - readonly repositories: ReadonlyArray; - readonly state: PullRequestListState; - readonly involvement: PullRequestInvolvement; - readonly viewer: string; - readonly limit: number; - readonly query?: string | undefined; - readonly cursor?: ProviderListCursor | undefined; - readonly filters?: PullRequestListFilters | undefined; - }) => Effect.Effect; - - /** - * The line counts for rows a listing has already handed over. Only implemented by a provider - * whose listing leaves them out — for everyone else the numbers arrived with the row, and the - * caller has nothing to ask for. - */ - readonly listChangeRequestStats?: (input: { - readonly cwd: string; - readonly host: string; - readonly changeRequests: ReadonlyArray<{ - readonly repository: string; - readonly number: number; - }>; - }) => Effect.Effect, PullRequestProviderError>; - - readonly getChangeRequestChecks?: ( - input: ProviderRepositoryRef & { readonly number: number }, - ) => Effect.Effect; - - readonly getChangeRequest: ( - input: ProviderRepositoryRef & { readonly number: number }, - ) => Effect.Effect; - - /** Hosts without a narrow read use their existing detail response for hover cards. */ - readonly getChangeRequestPreview?: ( - input: ProviderRepositoryRef & { readonly number: number }, - ) => Effect.Effect< - Omit, - PullRequestProviderError - >; - - /** - * The cheap live fields used by linked threads. Optional because a provider without a narrow - * endpoint can fall back to its full detail read at the service boundary. - */ - readonly getChangeRequestSummary?: ( - input: ProviderRepositoryRef & { readonly number: number }, - ) => Effect.Effect; - - /** - * A cheap fingerprint of what a pull request watch reports, so it reads the change request in - * full only when this moves. Null when the host gave no answer for it. Optional: a host - * without one has its watched change requests read in full on every pass. - */ - readonly getChangeRequestWatchFingerprint?: ( - input: ProviderRepositoryRef & { readonly number: number }, - ) => Effect.Effect; - - /** - * The host-native stack a change request belongs to, or null when it is not stacked. Optional - * because most hosts have no such object; the service derives chains from base branches there. - */ - readonly getChangeRequestStack?: ( - input: ProviderRepositoryRef & { readonly includeDetails?: boolean; readonly number: number }, - ) => Effect.Effect; - - /** Comments, line threads, and commits, kept off the critical path for the core detail. */ - readonly getChangeRequestActivity: ( - input: ProviderRepositoryRef & { readonly number: number }, - ) => Effect.Effect; - - /** One explicit page after a reader asks to continue an unfinished review thread. */ - readonly getReviewThreadComments?: ( - input: ProviderRepositoryRef & { - readonly number: number; - readonly threadId: string; - readonly cursor: string; - }, - ) => Effect.Effect; - - /** - * The same answer `getChangeRequest` carries, on its own. Asked before anything is written, so - * a request that reached the server without going past the page is refused by what the host - * says rather than by what the client claimed — and asked freshly, because access granted or - * taken away since the page loaded is exactly the case this guards. - * - * Implementations read the cheapest thing that answers it, which for a host with nothing to say - * is no request at all. - */ - readonly getViewerPermissions: ( - input: ProviderRepositoryRef & { - readonly number: number; - /** Skip branch comparison when checking permission for an unrelated operation. */ - readonly includeUpdateBranch?: boolean; - }, - ) => Effect.Effect; - - /** - * One slice of the patch. Only called when `capabilities.diff` is true. A provider that can - * serve the whole diff at once answers with `nextCursor: null` and is done; one that pages - * hands back whatever it needs to find the next slice. - */ - readonly getDiff: ( - input: ProviderRepositoryRef & { - readonly number: number; - readonly cursor?: string | undefined; - /** One commit's own changes, rather than everything the change request carries. */ - readonly commit?: string | undefined; - }, - ) => Effect.Effect; - - /** - * Full files at the exact revisions the host used for its patch. Optional where the provider - * exposes no diff at all; the service refuses expansion there just as it refuses the patch. - */ - readonly getDiffFileContents?: ( - input: ProviderRepositoryRef & { - readonly number: number; - readonly commit?: string | undefined; - readonly changeType: "change" | "rename-pure" | "rename-changed" | "new" | "deleted"; - readonly oldPath: string; - readonly newPath: string; - }, - ) => Effect.Effect; - - /** - * Which files the reader has already cleared. Only called when `capabilities.viewedFiles` is - * `"host"`, and read apart from the patch: this moves with every press rather than every push. - */ - readonly getFilesViewed?: ( - input: ProviderRepositoryRef & { readonly number: number }, - ) => Effect.Effect; - - /** - * Clears files, or puts them back. Only called when `capabilities.viewedFiles` is `"host"`. - * A host with no bulk form is still owed one round trip for the batch rather than one per file, - * since the point of gathering presses is that the host is asked once. - */ - readonly setFilesViewed?: ( - input: ProviderRepositoryRef & { - readonly number: number; - readonly files: ReadonlyArray<{ readonly path: string; readonly viewed: boolean }>; - }, - ) => Effect.Effect; - - /** - * What version the head has of each of these files. Required of a host whose - * `capabilities.viewedFiles` is `"environment"`, and unused by one that keeps the marks itself: - * the marks live here, but only the host can say whether what a reader cleared last week is - * still what is in front of them. Asked for the marked paths alone, so the cost follows how - * much of the change request has been read rather than how large it is. - */ - readonly getFileRevisions?: ( - input: ProviderRepositoryRef & { - readonly number: number; - readonly paths: ReadonlyArray; - }, - ) => Effect.Effect; - - readonly runAction: ( - input: ProviderRepositoryRef & { - readonly number: number; - readonly action: PullRequestAction; - readonly stackNumber?: number; - readonly expectedStackHeads?: ReadonlyArray; - /** GitHub merge message cleanup; ignored by hosts without support. */ - readonly removeAgentCreditsOnMerge?: boolean; - /** Meaningful for `merge` and `enable-auto-merge`; absent takes the host's own default. */ - readonly mergeMethod?: PullRequestMergeMethod; - /** Only meaningful for `update-branch`; absent takes the host's own default. */ - readonly updateMethod?: PullRequestUpdateMethod; - }, - ) => Effect.Effect; - - /** - * Rewrites the change request's own words. Only called when `capabilities.edit.changeRequest` - * is true, and never with both fields absent — the caller refuses that before it gets here, - * because a host asked to change nothing answers differently on each of them. - */ - readonly updateChangeRequest?: ( - input: ProviderRepositoryRef & { - readonly number: number; - readonly title?: string | undefined; - readonly body?: string | undefined; - }, - ) => Effect.Effect; - - readonly comment: ( - input: ProviderRepositoryRef & { readonly number: number; readonly body: string }, - ) => Effect.Effect; - - /** - * Rewrites a remark somebody already posted. Only called when `capabilities.edit.comment` is - * true, with an id exactly as the conversation carried it. - * - * Whether this remark is the reader's to rewrite is the host's own answer: no read here can - * settle it, since access can be taken away between the conversation being read and the - * rewrite being sent, and a host refuses a stranger's remark with a sentence saying so. - */ - readonly updateComment?: ( - input: ProviderRepositoryRef & { - readonly number: number; - readonly commentId: string; - readonly kind: "issue-comment" | "review-comment"; - readonly body: string; - }, - ) => Effect.Effect; - - /** - * Sends a whole review at once. Only called for a verdict the host declared in - * `capabilities.review.verdicts`, and with line comments only where it declared - * `inlineComment`. - */ - readonly submitReview: ( - input: ProviderRepositoryRef & { - readonly number: number; - readonly verdict: PullRequestReviewVerdict; - readonly body: string; - readonly comments: ReadonlyArray; - }, - ) => Effect.Effect; - - /** - * The people this viewer may ask for a review, with whoever has already been asked marked as - * such. Only called when `capabilities.reviewers.listCandidates` is true. - * - * The author is left out by each provider rather than by the caller, because only the provider - * knows how the host spells the same person in a candidate list and on a pull request. - */ - readonly listReviewerCandidates: ( - input: ProviderRepositoryRef & { readonly number: number }, - ) => Effect.Effect; - - /** - * Asks for a review, or takes the request back. Only called when - * `capabilities.reviewers.request` is true. - * - * One call for both directions, because that is what every host does with them: GitHub posts and - * deletes the same collection, and GitLab and Bitbucket write the whole reviewer set either way. - * Asking again somebody who has already reviewed is a request like any other — which is how a - * re-request is made. - */ - readonly setReviewerRequest: ( - input: ProviderRepositoryRef & { - readonly number: number; - readonly reviewers: ReadonlyArray<{ - readonly id: string; - readonly kind: PullRequestReviewerKind; - }>; - readonly requested: boolean; - }, - ) => Effect.Effect; - - /** - * The repository's labels, with the ones already on the change request marked. Present with - * `setLabels` only where `capabilities.labels` is true; the service refuses both without it. - */ - readonly listLabelCandidates?: ( - input: ProviderRepositoryRef & { readonly number: number }, - ) => Effect.Effect; - - /** Puts labels on the change request, or takes them off. One call for both directions. */ - readonly setLabels?: ( - input: ProviderRepositoryRef & { - readonly number: number; - readonly labels: ReadonlyArray; - readonly applied: boolean; - }, - ) => Effect.Effect; - - /** Only called when `capabilities.review.reply` is true. */ - readonly replyToThread: ( - input: ProviderRepositoryRef & { - readonly number: number; - readonly threadId: string; - readonly body: string; - }, - ) => Effect.Effect; - - /** - * Adds a reaction, or takes it back. Only called when `capabilities.reactions` is true. - * - * `subjectId` is a remark's id as the conversation carried it; absent means the change request - * itself, whose reactions sit on its description. Whatever a host needs to address either of - * them is worked out here, because the id a conversation travels with is the one the reader has. - */ - readonly setReaction: ( - input: ProviderRepositoryRef & { - readonly number: number; - readonly subjectId?: string | undefined; - readonly content: PullRequestReactionContent; - readonly reacted: boolean; - }, - ) => Effect.Effect; - - /** Only called when `capabilities.review.resolve` is true. */ - readonly setThreadResolution: ( - input: ProviderRepositoryRef & { - readonly number: number; - readonly threadId: string; - readonly resolved: boolean; - }, - ) => Effect.Effect; -} diff --git a/apps/server/src/pullRequest/PullRequestProviderRateLimit.test.ts b/apps/server/src/pullRequest/PullRequestProviderRateLimit.test.ts index 873cc32a2c7f102cdd34046d16c2f9167b790a23..ca12da2b8979113bd8561c445da87d99b277db77 100644 --- a/apps/server/src/pullRequest/PullRequestProviderRateLimit.test.ts +++ b/apps/server/src/pullRequest/PullRequestProviderRateLimit.test.ts @@ -3,7 +3,7 @@ import { assert, it } from "@effect/vitest"; import * as AzureDevOpsCli from "../sourceControl/AzureDevOpsCli.ts"; import * as BitbucketApi from "../sourceControl/BitbucketApi.ts"; import * as GitHubApi from "../sourceControl/GitHubApi.ts"; -import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts"; +import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; import * as GitLabCli from "../sourceControl/GitLabCli.ts"; import { azureDevOpsProviderFailure } from "./AzureDevOpsPullRequestProvider.ts"; import { bitbucketProviderFailure } from "./BitbucketPullRequestProvider.ts"; diff --git a/apps/server/src/pullRequest/PullRequestProviderRegistry.ts b/apps/server/src/pullRequest/PullRequestProviderRegistry.ts index 316cd1e7ae92f37d90f5153950dae544a8df8a18..8e5a1d9cfd0b008d3e3e4290c3bb8358f576afb4 100644 --- a/apps/server/src/pullRequest/PullRequestProviderRegistry.ts +++ b/apps/server/src/pullRequest/PullRequestProviderRegistry.ts @@ -17,7 +17,7 @@ import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; import * as GitHubPullRequestProvider from "./GitHubPullRequestProvider.ts"; import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts"; import * as GitLabPullRequestProvider from "./GitLabPullRequestProvider.ts"; -import type { PullRequestProviderApi } from "./PullRequestProvider.ts"; +import type { PullRequestProviderApi } from "@t3tools/source-control-core/server/PullRequestProvider"; export class PullRequestProviderRegistry extends Context.Service< PullRequestProviderRegistry, diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index f9015ecbd9ef83781d35b290a1af728424b1ecf9..ef84de264ec63216f4ff63c6beec602c5099319a 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -29,14 +29,14 @@ import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts"; import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; -import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts"; +import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; import * as ForgejoCli from "../sourceControl/ForgejoCli.ts"; import * as ForgejoPullRequestProvider from "./ForgejoPullRequestProvider.ts"; import { PullRequestProviderError, type ProviderChangeRequest, type PullRequestProviderApi, -} from "./PullRequestProvider.ts"; +} from "@t3tools/source-control-core/server/PullRequestProvider"; import * as PullRequestProviderRegistry from "./PullRequestProviderRegistry.ts"; import * as PullRequestService from "./PullRequestService.ts"; import * as PullRequestReadCache from "./PullRequestReadCache.ts"; diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index a44bd322adcba8db5f9dc103d380ac3c121a2869..fb14ef896cd51a3721ee56db7c6fe35adc229e53 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -83,14 +83,14 @@ import * as ServerSettings from "../serverSettings.ts"; import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts"; import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; -import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts"; +import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; import { type ProviderChangeRequest, type ProviderListCursor, type ProviderChangeRequestWatchFingerprint, type PullRequestProviderApi, PullRequestProviderError, -} from "./PullRequestProvider.ts"; +} from "@t3tools/source-control-core/server/PullRequestProvider"; import * as PullRequestReadCache from "./PullRequestReadCache.ts"; import * as PullRequestProviderRegistry from "./PullRequestProviderRegistry.ts"; import * as ViewedFiles from "./pullRequestViewedFiles.ts"; diff --git a/apps/server/src/pullRequest/bitbucketPullRequestJson.ts b/apps/server/src/pullRequest/bitbucketPullRequestJson.ts index f0bc8a71a82704afb6e38229ed05cc6aff78d69d..8f0b520ca089b64d6851b8c1bd8e159964ac48b1 100644 --- a/apps/server/src/pullRequest/bitbucketPullRequestJson.ts +++ b/apps/server/src/pullRequest/bitbucketPullRequestJson.ts @@ -18,7 +18,7 @@ import type { import { TrimmedNonEmptyString } from "@t3tools/contracts"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; -import { dedupeChecks } from "./pullRequestChecks.ts"; +import { dedupeChecks } from "@t3tools/source-control-core/server/pullRequestChecks"; /** * Bitbucket's enums are decoded as plain strings and normalized here, in the same tolerant diff --git a/apps/server/src/pullRequest/forgejoPullRequestJson.ts b/apps/server/src/pullRequest/forgejoPullRequestJson.ts index cade3b65d9c8389d27b818b2e5ee6a916b4f489f..a937c00c2ef842a273f0b20d70c0bd1caded6e0b 100644 --- a/apps/server/src/pullRequest/forgejoPullRequestJson.ts +++ b/apps/server/src/pullRequest/forgejoPullRequestJson.ts @@ -10,8 +10,8 @@ import type { PullRequestReactionContent, PullRequestReviewThread, } from "@t3tools/contracts"; -import type { ProviderChangeRequest } from "./PullRequestProvider.ts"; -import { dedupeChecks } from "./pullRequestChecks.ts"; +import type { ProviderChangeRequest } from "@t3tools/source-control-core/server/PullRequestProvider"; +import { dedupeChecks } from "@t3tools/source-control-core/server/pullRequestChecks"; export const ForgejoUser = Schema.Struct({ login: Schema.String, diff --git a/apps/server/src/pullRequest/gitHubConditionalChecks.ts b/apps/server/src/pullRequest/gitHubConditionalChecks.ts index 6975da251936bbab015b8544e033ece84e8f4452..353089ebf0f9648d0156fdbb1304bd2f0722a0fc 100644 --- a/apps/server/src/pullRequest/gitHubConditionalChecks.ts +++ b/apps/server/src/pullRequest/gitHubConditionalChecks.ts @@ -10,7 +10,7 @@ import { PositiveInt, type PullRequestChecks } from "@t3tools/contracts"; import * as GitHubApi from "../sourceControl/GitHubApi.ts"; import type { GitHubPullRequestDetail } from "./gitHubPullRequestJson.ts"; import type { GitHubPullRequestApiError } from "./GitHubPullRequestApi.ts"; -import type { ProviderRepositoryRef } from "./PullRequestProvider.ts"; +import type { ProviderRepositoryRef } from "@t3tools/source-control-core/server/PullRequestProvider"; const HeadSchema = Schema.Struct({ head: Schema.Struct({ diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.ts index 028231c513f24219c7e86447e00a7b851e10be5d..edca11f5a0d082ab4a0bec2e4d000ad0aa649418 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.ts @@ -36,7 +36,7 @@ import { quoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; import { aliasedGraphQlDocument, type GraphQlDocument } from "../sourceControl/githubGraphQl.ts"; -import { dedupeChecks } from "./pullRequestChecks.ts"; +import { dedupeChecks } from "@t3tools/source-control-core/server/pullRequestChecks"; /** * Enum-ish GitHub CLI fields are decoded as plain strings and normalized here: a `gh` diff --git a/apps/server/src/pullRequest/mergeMessage.test.ts b/apps/server/src/pullRequest/mergeMessage.test.ts deleted file mode 100644 index 2c62473995008c694b77c7f8e3509ec3770acee8..0000000000000000000000000000000000000000 --- a/apps/server/src/pullRequest/mergeMessage.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import { removeAgentCredits } from "./mergeMessage.ts"; - -describe("removeAgentCredits", () => { - it("removes recognized agent trailers and generated footers, keeping human co-authors", () => { - expect( - removeAgentCredits( - [ - "Details about Claude support.", - "", - "", - "Co-authored-by: Alice ", - "Co-authored-by: Claude ", - "* Co-authored-by: Codex ", - "co-authored-by: Cursor ", - "Co-authored-by: Copilot ", - "", - "🤖 Generated with [Claude Code](https://claude.ai/code)", - ].join("\n"), - ), - ).toBe("Details about Claude support.\n\n\nCo-authored-by: Alice "); - }); - - it("preserves unknown identities, humans named after agents, and ordinary prose", () => { - const message = [ - "Co-authored-by: Claude ", - "Co-authored-by: Alice ", - "Co-authored-by: Release Bot ", - "Generated by our new Claude integration.", - "Generated with [Claude Code](https://example.com)", - "", - ].join("\n"); - expect(removeAgentCredits(message)).toBe(message); - }); - - it("preserves code fences and block quotes containing attribution examples", () => { - const example = "Co-authored-by: Claude "; - const message = `Example:\n\n\`\`\`text\n${example}\n\`\`\`\n\n> ${example}\n\n${example}`; - expect(removeAgentCredits(message)).toBe( - `Example:\n\n\`\`\`text\n${example}\n\`\`\`\n\n> ${example}`, - ); - }); - - it("preserves indented examples and whitespace on surviving lines", () => { - const example = "Co-authored-by: Claude "; - expect(removeAgentCredits(`Example:\n\n ${example}\n\n${example}\nDetails \n`)).toBe( - `Example:\n\n ${example}\n\nDetails `, - ); - }); - - it.each(["``` text ```", " ```", "\t~~~"])( - "removes trailers after a line that cannot open a fence: %s", - (line) => { - expect(removeAgentCredits(`${line}\n\nCo-authored-by: Claude `)).toBe( - line, - ); - }, - ); - - it.each(["```", "~~~"])("keeps examples after a false closing fence: %s", (fence) => { - const example = "Co-authored-by: Claude "; - 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`; - expect(removeAgentCredits(`${content}\n\n${example}`)).toBe(content); - }); - - it("preserves CRLF line endings", () => { - expect( - removeAgentCredits( - "Details\r\n\r\nCo-authored-by: Claude \r\nCo-authored-by: Alice ", - ), - ).toBe("Details\r\n\r\nCo-authored-by: Alice "); - }); - - it("returns an empty body when only agent credits remain", () => { - expect( - removeAgentCredits("Co-authored-by: Claude \n\nGenerated by Codex\n"), - ).toBe(""); - }); -}); diff --git a/apps/server/src/pullRequest/mergeMessage.ts b/apps/server/src/pullRequest/mergeMessage.ts deleted file mode 100644 index 20083b0d8228ef814d651414bcc3d4b97328f269..0000000000000000000000000000000000000000 --- a/apps/server/src/pullRequest/mergeMessage.ts +++ /dev/null @@ -1,54 +0,0 @@ -const AGENT_EMAILS = new Set([ - "noreply@anthropic.com", - "claude@anthropic.com", - "noreply@openai.com", - "codex@openai.com", - "cursoragent@cursor.com", - "copilot@github.com", - "noreply@github.com", -]); - -const GENERATED_BY_AGENT = - /^(?:🤖\s*)?generated (?:with|by)\s+(?:claude(?: code)?|codex|cursor|github copilot|copilot|opencode|grok|antigravity)[.!]?$/i; - -/** Remove standalone agent credits while keeping human signatures and quoted examples. */ -export function removeAgentCredits(message: string): string { - let fence: string | undefined; - let removed = false; - const newline = message.includes("\r\n") ? "\r\n" : "\n"; - const lines = message.split(/\r?\n/).filter((line) => { - if (/^(?: {4}|\t)/.test(line)) return true; - const delimiter = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line); - const marker = delimiter?.[1]; - const remainder = delimiter?.[2] ?? ""; - if (marker !== undefined) { - if (fence === undefined) { - if (marker[0] === "~" || !remainder.includes("`")) fence = marker; - } else if ( - marker[0] === fence[0] && - marker.length >= fence.length && - /^[ \t]*$/.test(remainder) - ) { - fence = undefined; - } - return true; - } - if (fence !== undefined) return true; - const text = line.trim().replace(/^(?:[-*]\s+)/, ""); - const author = /^co-authored-by:\s*(.*?)\s*<([^<>]+)>\s*$/i.exec(text); - // GitHub's generic noreply address is also used by people; require Copilot's name. - const email = author?.[2]?.toLowerCase(); - const agentAuthor = - email !== undefined && - AGENT_EMAILS.has(email) && - (email !== "noreply@github.com" || /^github copilot$|^copilot$/i.test(author![1]!)); - const footer = text.replace( - /\[([^\]]+)\]\(https:\/\/(?:claude\.ai|www\.anthropic\.com|chatgpt\.com|openai\.com|cursor\.com|github\.com|opencode\.ai|x\.ai|antigravity\.google)(?:\/[^\s)]*)?\)/gi, - "$1", - ); - if (!agentAuthor && !GENERATED_BY_AGENT.test(footer)) return true; - removed = true; - return false; - }); - return removed ? lines.join(newline).replace(/(?:\r?\n[ \t]*)+$/, "") : message; -} diff --git a/apps/server/src/pullRequest/pullRequestChecks.test.ts b/apps/server/src/pullRequest/pullRequestChecks.test.ts deleted file mode 100644 index ba685083292449d6c8767db975c116c638fb0c5b..0000000000000000000000000000000000000000 --- a/apps/server/src/pullRequest/pullRequestChecks.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import type { PullRequestCheck, PullRequestCheckStatus } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; - -import { dedupeChecks } from "./pullRequestChecks.ts"; - -function entry( - name: string, - status: PullRequestCheckStatus, - extra: { readonly workflowName?: string | null; readonly at?: string | null } = {}, -): { - readonly check: PullRequestCheck; - readonly workflowName: string | null; - readonly at: string | null; -} { - return { - check: { name, status, description: null, url: null }, - workflowName: extra.workflowName ?? null, - at: extra.at ?? null, - }; -} - -describe("dedupeChecks", () => { - it("keeps the newest run of a check the host listed twice", () => { - const checks = dedupeChecks([ - entry("Prepare PR size config", "success", { - workflowName: "PR Size", - at: "2026-08-11T16:06:25Z", - }), - entry("Prepare PR size config", "pending", { - workflowName: "PR Size", - at: "2026-08-11T17:01:04Z", - }), - ]); - - expect(checks.map((check) => [check.name, check.status])).toEqual([ - ["Prepare PR size config", "pending"], - ]); - }); - - it("holds a check at the place it first appeared, so a re-run does not reshuffle the list", () => { - const checks = dedupeChecks([ - entry("lint", "success", { workflowName: "CI", at: "2026-08-11T16:00:00Z" }), - entry("test", "success", { workflowName: "CI", at: "2026-08-11T16:00:00Z" }), - entry("lint", "failure", { workflowName: "CI", at: "2026-08-11T18:00:00Z" }), - ]); - - expect(checks.map((check) => [check.name, check.status])).toEqual([ - ["lint", "failure"], - ["test", "success"], - ]); - }); - - it("loses a run that never said when it happened to one that did, whichever came first", () => { - const undated = dedupeChecks([ - entry("build", "success", { at: "2026-08-11T16:00:00Z" }), - entry("build", "pending"), - ]); - const dated = dedupeChecks([ - entry("build", "pending"), - entry("build", "success", { at: "2026-08-11T16:00:00Z" }), - ]); - - expect([undated[0]?.status, dated[0]?.status]).toEqual(["success", "success"]); - }); - - it("takes the last copy when neither run is dated, which is how an update is listed", () => { - const checks = dedupeChecks([entry("build", "pending"), entry("build", "failure")]); - - expect(checks.map((check) => check.status)).toEqual(["failure"]); - }); - - it("keeps two workflows that name a job the same thing, and says which is which", () => { - const checks = dedupeChecks([ - entry("build", "success", { workflowName: "CI", at: "2026-08-11T16:00:00Z" }), - entry("build", "failure", { workflowName: "Release", at: "2026-08-11T16:00:00Z" }), - ]); - - expect(checks.map((check) => [check.name, check.status])).toEqual([ - ["CI / build", "success"], - ["Release / build", "failure"], - ]); - }); - - it("leaves a colliding check with no workflow of its own unqualified", () => { - // An app-provided check run belongs to no workflow, which GitHub reports as an empty name. - const checks = dedupeChecks([ - entry("build", "success", { workflowName: "", at: "2026-08-11T16:00:00Z" }), - entry("build", "failure", { workflowName: "CI", at: "2026-08-11T16:00:00Z" }), - ]); - - expect(checks.map((check) => check.name)).toEqual(["build", "CI / build"]); - }); -}); diff --git a/apps/server/src/pullRequest/pullRequestChecks.ts b/apps/server/src/pullRequest/pullRequestChecks.ts deleted file mode 100644 index 819cf47b01af485b83769c0c96f37f58d5769ae9..0000000000000000000000000000000000000000 --- a/apps/server/src/pullRequest/pullRequestChecks.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { PullRequestCheck } from "@t3tools/contracts"; - -/** ISO-8601 timestamps in UTC compare correctly as plain text, which is all the ordering needs. */ -function isAtLeastAsNew(candidate: string | null, kept: string | null): boolean { - if (candidate === null) return kept === null; - return kept === null || candidate >= kept; -} - -/** - * One row per check rather than one per run of it. - * - * A host's rollup is a list of runs, not a list of checks: while a workflow is being re-run — or - * while a second run of it is already live — the same check arrives twice, and both copies reach - * the reader as what looks like a duplicate. Nothing in a check carries an id, so the name is what - * identifies it, qualified by the workflow it belongs to since two workflows are free to name a - * job the same thing. - * - * Within a group the newest run is the one worth showing: a re-run is the answer that replaces the - * one before it, and a run that never said when it happened loses to one that did. A tie goes to - * whichever came last, because a host lists a re-run after the run it repeats. - * - * The order is the host's own, held at the place each check first appeared, so a re-run landing - * mid-read replaces a row where it stands instead of reshuffling the list under the reader. - * - * Two checks that survive under the same name are then genuinely different ones, since they came - * from different workflows — each is shown as `workflow / name`, the way GitHub writes it itself. - * A survivor whose workflow the host did not name keeps its bare name rather than being qualified - * with nothing. - */ -export function dedupeChecks( - entries: ReadonlyArray<{ - readonly check: PullRequestCheck; - readonly workflowName: string | null; - readonly at: string | null; - }>, -): ReadonlyArray { - const newestByCheck = new Map(); - for (const entry of entries) { - const key = `${entry.workflowName ?? ""} ${entry.check.name}`; - const kept = newestByCheck.get(key); - // Re-setting a key a Map already holds keeps its first position, which is the order wanted. - if (kept === undefined || isAtLeastAsNew(entry.at, kept.at)) newestByCheck.set(key, entry); - } - const survivors = [...newestByCheck.values()]; - const countsByName = new Map(); - for (const entry of survivors) { - countsByName.set(entry.check.name, (countsByName.get(entry.check.name) ?? 0) + 1); - } - return survivors.map((entry) => { - const workflowName = entry.workflowName ?? ""; - return workflowName.length > 0 && (countsByName.get(entry.check.name) ?? 0) > 1 - ? { ...entry.check, name: `${workflowName} / ${entry.check.name}` } - : entry.check; - }); -} diff --git a/apps/server/src/pullRequest/pullRequestViewedFiles.ts b/apps/server/src/pullRequest/pullRequestViewedFiles.ts index 1f9aa33ac796e67c19a5c0058e53df67e3297172..9f64c8352af54edda29f3fa195ea14f23f127757 100644 --- a/apps/server/src/pullRequest/pullRequestViewedFiles.ts +++ b/apps/server/src/pullRequest/pullRequestViewedFiles.ts @@ -16,7 +16,10 @@ import { } from "@t3tools/contracts"; import type * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts"; -import type { ProviderFileRevisions, PullRequestProviderError } from "./PullRequestProvider.ts"; +import type { + ProviderFileRevisions, + PullRequestProviderError, +} from "@t3tools/source-control-core/server/PullRequestProvider"; import type { PullRequestError, SupportedProject } from "./PullRequestService.ts"; /** diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 1bbe647dcd0cf5599b3c6bfbf79e75b0dd38cb0c..913f8067d250df07aeb2a73fb281488edf74f941 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -104,8 +104,9 @@ import * as ProjectCloneTracker from "./project/ProjectCloneTracker.ts"; import * as GitWorkflowService from "./git/GitWorkflowService.ts"; import * as ReviewService from "./review/ReviewService.ts"; import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; +import * as ServerSourceControlHost from "./sourceControl/ServerSourceControlHost.ts"; import * as PullRequestReadCache from "./pullRequest/PullRequestReadCache.ts"; -import * as SourceControlRateLimit from "./sourceControl/SourceControlRateLimit.ts"; +import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; import * as WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts"; import * as Observability from "./observability/Observability.ts"; @@ -305,6 +306,7 @@ const layerSourceControlProviderRegistry = SourceControlProviderRegistry.layer.p GitHubApi.layerWithDependencies, GitLabCli.layer, ForgejoCli.layer, + ServerSourceControlHost.layer, ), ), Layer.provideMerge(GitVcsDriver.layer), diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.ts b/apps/server/src/sourceControl/AzureDevOpsCli.ts index f5e436ef6c564386fbabb86ae1d4ac4a2e872207..7fd44de3c40b9a901329ea61bd98d1f61e5a7882 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.ts @@ -17,7 +17,7 @@ import { decodeAzureDevOpsPullRequestListJson, type NormalizedAzureDevOpsPullRequestRecord, } from "./azureDevOpsPullRequests.ts"; -import * as SourceControlProvider from "./SourceControlProvider.ts"; +import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; const DEFAULT_TIMEOUT_MS = 30_000; diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts index c2f6a5efc65ac7ef7977f5bea3b5fddd61195071..dcb07e0632f3c5646f44df552f25c02aaac495e2 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts @@ -2,14 +2,14 @@ import * as Effect from "effect/Effect"; import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; -import * as SourceControlProvider from "./SourceControlProvider.ts"; +import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; import { combinedAuthOutput, firstSafeAuthLine, providerAuth, type SourceControlAuthProbeInput, type SourceControlCliDiscoverySpec, -} from "./SourceControlProviderDiscovery.ts"; +} from "@t3tools/source-control-core/server/discovery"; function parseAzureAuth(input: SourceControlAuthProbeInput) { const account = input.stdout.trim().split(/\r?\n/)[0]?.trim(); diff --git a/apps/server/src/sourceControl/BitbucketApi.ts b/apps/server/src/sourceControl/BitbucketApi.ts index fbff83dfedb56e8b68762d7b34dd18bf0e4c785d..f7fa5cc4568c705fb8df0868298ab93c043eda8c 100644 --- a/apps/server/src/sourceControl/BitbucketApi.ts +++ b/apps/server/src/sourceControl/BitbucketApi.ts @@ -29,11 +29,11 @@ import { type NormalizedBitbucketPullRequestRecord, } from "./bitbucketPullRequests.ts"; import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText"; -import * as SourceControlProvider from "./SourceControlProvider.ts"; +import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; import * as ServerSettings from "../serverSettings.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; -import { retryAtFromHeader } from "./SourceControlRateLimit.ts"; +import { retryAtFromHeader } from "@t3tools/source-control-core/server/SourceControlRateLimit"; const DEFAULT_API_BASE_URL = "https://api.bitbucket.org/2.0"; /** A response body past this is cut short, so one huge diff cannot exhaust the server. */ diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts index f1a01056fbd0a879f5fe7e1d0104db4163b7c325..b9afd60bc389657b4d7c0c04decbab9747a69a0c 100644 --- a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts +++ b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts @@ -4,8 +4,8 @@ import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contrac import * as BitbucketApi from "./BitbucketApi.ts"; import type { NormalizedBitbucketPullRequestRecord } from "./bitbucketPullRequests.ts"; -import * as SourceControlProvider from "./SourceControlProvider.ts"; -import type { SourceControlApiDiscoverySpec } from "./SourceControlProviderDiscovery.ts"; +import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; +import type { SourceControlApiDiscoverySpec } from "@t3tools/source-control-core/server/discovery"; function toChangeRequest(summary: NormalizedBitbucketPullRequestRecord): ChangeRequest { return { diff --git a/apps/server/src/sourceControl/ForgejoCli.ts b/apps/server/src/sourceControl/ForgejoCli.ts index eb284af237c84b5ef4c883e0f374e12857884ad3..573c081e5a42ae3174d0b0bc17ca11057febc931 100644 --- a/apps/server/src/sourceControl/ForgejoCli.ts +++ b/apps/server/src/sourceControl/ForgejoCli.ts @@ -15,7 +15,7 @@ import { decodeJsonResult } from "@t3tools/shared/schemaJson"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText"; -import type { SourceControlProviderContext } from "./SourceControlProvider.ts"; +import type { SourceControlProviderContext } from "@t3tools/source-control-core/server/SourceControlProvider"; const encodeApiBody = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); diff --git a/apps/server/src/sourceControl/ForgejoSourceControlProvider.ts b/apps/server/src/sourceControl/ForgejoSourceControlProvider.ts index 5a501d96b23592b1217b8831a876d1963de2da6f..79a1ca6f2aa030006e104190f0dad5b11f40c049 100644 --- a/apps/server/src/sourceControl/ForgejoSourceControlProvider.ts +++ b/apps/server/src/sourceControl/ForgejoSourceControlProvider.ts @@ -5,13 +5,13 @@ import * as Result from "effect/Result"; import { SourceControlProviderError } from "@t3tools/contracts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as ForgejoCli from "./ForgejoCli.ts"; -import * as SourceControlProvider from "./SourceControlProvider.ts"; +import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; import { providerAuth, probeSourceControlProvider, type SourceControlCliDiscoverySpec, type SourceControlManagedCliDiscoverySpec, -} from "./SourceControlProviderDiscovery.ts"; +} from "@t3tools/source-control-core/server/discovery"; import { ForgejoPullRequestSchema, toForgejoChangeRequest } from "./forgejoPullRequests.ts"; const isForgejoCliError = Schema.is(ForgejoCli.ForgejoCliError); diff --git a/apps/server/src/sourceControl/GitHubApi.test.ts b/apps/server/src/sourceControl/GitHubApi.test.ts index 60e2c6f1b45d5f4d3653d7c4ccce7dbbce462cdd..cd4c2fb384f230a861c4a953b20965f9df78e059 100644 --- a/apps/server/src/sourceControl/GitHubApi.test.ts +++ b/apps/server/src/sourceControl/GitHubApi.test.ts @@ -13,7 +13,7 @@ import { HttpClient, HttpClientResponse, type HttpClientRequest } from "effect/h import * as GitHubApi from "./GitHubApi.ts"; import * as GitHubCredentials from "./GitHubCredentials.ts"; import * as GitHubQuota from "./githubQuota.ts"; -import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; +import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as ServerSettings from "../serverSettings.ts"; diff --git a/apps/server/src/sourceControl/GitHubApi.ts b/apps/server/src/sourceControl/GitHubApi.ts index 5a99cf68943b12fc8a6fde117c6afbeba9ccff19..1cc66a1323c7f42011a64ce924215dffe6f3dc38 100644 --- a/apps/server/src/sourceControl/GitHubApi.ts +++ b/apps/server/src/sourceControl/GitHubApi.ts @@ -13,7 +13,7 @@ import { HttpClient, HttpClientRequest, type HttpClientResponse } from "effect/h import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText"; import * as GitHubCredentials from "./GitHubCredentials.ts"; import * as GitHubQuota from "./githubQuota.ts"; -import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; +import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; const DEFAULT_TIMEOUT = Duration.seconds(30); const DEFAULT_MAX_RESPONSE_BYTES = 8 * 1024 * 1024; diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index da3d53330864c31b07b84b41c2a7612998b249e9..35876794048a0eb0751faff02fc62457b1d90876 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -37,14 +37,14 @@ import { resolveGitHubRepository, type GitHubRepositoryLocator, } from "./gitHubRepositoryResolution.ts"; -import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; +import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; import { effectiveGitHubAccount, findAuthenticatedGitHubAccount, parseGitHubAuthStatus, type GitHubAuthStatusAccount, } from "./gitHubAuthStatus.ts"; -import * as SourceControlProvider from "./SourceControlProvider.ts"; +import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; import { combinedAuthOutput, firstSafeAuthLine, @@ -53,7 +53,7 @@ import { type SourceControlAuthProbeInput, type SourceControlCliDiscoverySpec, type SourceControlManagedCliDiscoverySpec, -} from "./SourceControlProviderDiscovery.ts"; +} from "@t3tools/source-control-core/server/discovery"; import * as VcsProcess from "../vcs/VcsProcess.ts"; const decodeLinkSubject = Schema.decodeUnknownEffect( diff --git a/apps/server/src/sourceControl/GitLabCli.ts b/apps/server/src/sourceControl/GitLabCli.ts index 9d03e1ab8aa5499a64bdd1d2ac3b150b185ff791..2ec69a527fedffa9f1f78dd0cfa74a8a947992d1 100644 --- a/apps/server/src/sourceControl/GitLabCli.ts +++ b/apps/server/src/sourceControl/GitLabCli.ts @@ -18,7 +18,7 @@ import { decodeGitLabMergeRequestJson, decodeGitLabMergeRequestListJson, } from "./gitLabMergeRequests.ts"; -import type * as SourceControlProvider from "./SourceControlProvider.ts"; +import type * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; const DEFAULT_TIMEOUT_MS = 30_000; diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts index 00753bef7dea7f13c402b2401105c0556ee990b0..ef63f11cfdf24553ef43de44df9d89d29c0eb77b 100644 --- a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts @@ -4,7 +4,7 @@ import * as Option from "effect/Option"; import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; import * as GitLabCli from "./GitLabCli.ts"; -import * as SourceControlProvider from "./SourceControlProvider.ts"; +import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; import { combinedAuthOutput, firstSafeAuthLine, @@ -14,7 +14,7 @@ import { type SourceControlAuthProbeInput, type SourceControlCliDiscoverySpec, type SourceControlUnknownRemoteRefinementInput, -} from "./SourceControlProviderDiscovery.ts"; +} from "@t3tools/source-control-core/server/discovery"; import { findAuthenticatedGitLabHost, parseGitLabAuthStatusHosts } from "./gitLabAuthStatus.ts"; const decodeLinkSubject = Schema.decodeUnknownEffect( diff --git a/apps/server/src/sourceControl/ServerSourceControlHost.ts b/apps/server/src/sourceControl/ServerSourceControlHost.ts new file mode 100644 index 0000000000000000000000000000000000000000..f458795ab76e90c5c69b41cc3cf23fdd0a991669 --- /dev/null +++ b/apps/server/src/sourceControl/ServerSourceControlHost.ts @@ -0,0 +1,24 @@ +/** + * The server's implementation of `SourceControlHost.SourceControlHost`, the only server surface + * source control provider packages may use. + * + * @module sourceControl/ServerSourceControlHost + */ +import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as ServerSettings from "../serverSettings.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; + +export const layer = Layer.effect( + SourceControlHost.SourceControlHost, + Effect.gen(function* () { + const serverSettings = yield* ServerSettings.ServerSettingsService; + const process = yield* VcsProcess.VcsProcess; + return SourceControlHost.SourceControlHost.of({ + settings: { get: serverSettings.getSettings }, + process: { run: process.run }, + }); + }), +); diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index 6101d148a632740479c58dcc81225934cd642c5d..e0d1c3efca238fdb8a33920212a7fc21c7793308 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -25,7 +25,8 @@ import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts import * as ForgejoPullRequestProvider from "../pullRequest/ForgejoPullRequestProvider.ts"; import * as SourceControlDiscovery from "./SourceControlDiscovery.ts"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; -import { firstNonEmptyLine } from "./SourceControlProviderDiscovery.ts"; +import * as ServerSourceControlHost from "./ServerSourceControlHost.ts"; +import { firstNonEmptyLine } from "@t3tools/source-control-core/server/discovery"; const layerSourceControlProviderRegistryTest = (input: { readonly bitbucket: Partial; @@ -46,6 +47,14 @@ const layerSourceControlProviderRegistryTest = (input: { Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }), Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}), Layer.mock(VcsProcess.VcsProcess)(input.process), + ServerSourceControlHost.layer.pipe( + Layer.provide( + Layer.merge( + Layer.mock(VcsProcess.VcsProcess)(input.process), + ServerSettings.ServerSettingsService.layerTest(), + ), + ), + ), ), ), ); diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.ts b/apps/server/src/sourceControl/SourceControlDiscovery.ts index e178148f131f2865c81ddf36837612e7bfb3b4c1..f56985fefd63900f99e07dad3aa344a8fad4fa1d 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.ts @@ -10,7 +10,7 @@ import * as Option from "effect/Option"; import * as ServerConfig from "../config.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; -import { detailFromCause, firstNonEmptyLine } from "./SourceControlProviderDiscovery.ts"; +import { detailFromCause, firstNonEmptyLine } from "@t3tools/source-control-core/server/discovery"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; interface DiscoveryProbe { diff --git a/apps/server/src/sourceControl/SourceControlProvider.test.ts b/apps/server/src/sourceControl/SourceControlProvider.test.ts deleted file mode 100644 index 7e53248827998083ef242aa9bb355df32f68ff9d..0000000000000000000000000000000000000000 --- a/apps/server/src/sourceControl/SourceControlProvider.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { assert, it } from "@effect/vitest"; - -import { transportSafeSourceControlErrorValue } from "./SourceControlProvider.ts"; - -it("removes URL credentials, query parameters, and fragments from error transport values", () => { - assert.strictEqual( - transportSafeSourceControlErrorValue( - "https://user:secret@example.test/org/repo/pull/42?token=secret#discussion", - ), - "https://example.test/org/repo/pull/42", - ); -}); - -it("normalizes control characters and bounds error transport values", () => { - assert.strictEqual( - transportSafeSourceControlErrorValue(` owner/repo\n\t${"x".repeat(300)} `), - `owner/repo ${"x".repeat(245)}`, - ); -}); diff --git a/apps/server/src/sourceControl/SourceControlProvider.ts b/apps/server/src/sourceControl/SourceControlProvider.ts deleted file mode 100644 index ec61691fdd424b23066984106f8f63c6ad39bb97..0000000000000000000000000000000000000000 --- a/apps/server/src/sourceControl/SourceControlProvider.ts +++ /dev/null @@ -1,145 +0,0 @@ -import * as Context from "effect/Context"; -import * as Effect from "effect/Effect"; -import type { - ChangeRequest, - ChangeRequestState, - SourceControlProviderError, - SourceControlProviderInfo, - SourceControlProviderKind, - SourceControlRepositoryCloneUrls, - SourceControlRepositoryVisibility, -} from "@t3tools/contracts"; - -export interface SourceControlLinkSubject { - readonly title: string; - readonly body: string | null; -} - -/** Return undefined synchronously for unsupported URLs, without starting a lookup. */ -export type ResolveSourceControlLink = (input: { - readonly cwd: string; - readonly url: URL; -}) => Effect.Effect | undefined; - -export interface SourceControlProviderContext { - readonly provider: SourceControlProviderInfo; - readonly remoteName: string; - readonly remoteUrl: string; - /** An explicit web authority can disambiguate Forgejo logins sharing an SSH alias. */ - readonly requestedHost?: string; -} - -export interface SourceControlRefSelector { - readonly refName: string; - readonly owner?: string; - readonly repository?: string; -} - -const MAX_ERROR_TRANSPORT_VALUE_LENGTH = 256; - -/** - * Sanitizes user-provided source-control identifiers before attaching them to - * contract errors. This is intentionally narrower than request validation: it - * only strips URL secrets and bounds diagnostic values sent over transport. - */ -export function transportSafeSourceControlErrorValue(value: string): string { - let printable = ""; - for (const character of value) { - const codePoint = character.codePointAt(0); - printable += codePoint !== undefined && (codePoint < 32 || codePoint === 127) ? " " : character; - } - const normalized = printable.trim().replace(/\s+/gu, " "); - - let safe = normalized; - try { - const url = new URL(normalized); - url.username = ""; - url.password = ""; - url.search = ""; - url.hash = ""; - safe = url.toString(); - } catch { - // Plain repository and change-request identifiers are not URLs. - } - - return safe.slice(0, MAX_ERROR_TRANSPORT_VALUE_LENGTH); -} - -export function parseSourceControlOwnerRef( - headSelector: string, -): SourceControlRefSelector | undefined { - const match = /^([^:/\s]+):(.+)$/u.exec(headSelector.trim()); - const owner = match?.[1]?.trim(); - const refName = match?.[2]?.trim(); - return owner && refName ? { owner, refName } : undefined; -} - -function normalizeSourceBranch(headSelector: string): string { - return parseSourceControlOwnerRef(headSelector)?.refName ?? headSelector.trim(); -} - -export function sourceBranch(input: { - readonly headSelector: string; - readonly source?: SourceControlRefSelector; -}): string { - return input.source?.refName ?? normalizeSourceBranch(input.headSelector); -} - -export function sourceControlRefFromInput(input: { - readonly headSelector: string; - readonly source?: SourceControlRefSelector; -}): SourceControlRefSelector | undefined { - return input.source ?? parseSourceControlOwnerRef(input.headSelector); -} - -export class SourceControlProvider extends Context.Service< - SourceControlProvider, - { - readonly kind: SourceControlProviderKind; - /** Optional capability for issue and change-request subjects. */ - readonly resolveLink?: ResolveSourceControlLink; - readonly listChangeRequests: (input: { - readonly cwd: string; - readonly context?: SourceControlProviderContext; - readonly source?: SourceControlRefSelector; - readonly headSelector: string; - readonly state: ChangeRequestState | "all"; - readonly limit?: number; - }) => Effect.Effect, SourceControlProviderError>; - readonly getChangeRequest: (input: { - readonly cwd: string; - readonly context?: SourceControlProviderContext; - readonly reference: string; - }) => Effect.Effect; - readonly createChangeRequest: (input: { - readonly cwd: string; - readonly context?: SourceControlProviderContext; - readonly source?: SourceControlRefSelector; - readonly target?: SourceControlRefSelector; - readonly baseRefName: string; - readonly headSelector: string; - readonly title: string; - readonly bodyFile: string; - }) => Effect.Effect; - readonly getRepositoryCloneUrls: (input: { - readonly cwd: string; - readonly context?: SourceControlProviderContext; - readonly repository: string; - }) => Effect.Effect; - readonly createRepository: (input: { - readonly cwd: string; - readonly repository: string; - readonly visibility: SourceControlRepositoryVisibility; - }) => Effect.Effect; - readonly getDefaultBranch: (input: { - readonly cwd: string; - readonly context?: SourceControlProviderContext; - }) => Effect.Effect; - readonly checkoutChangeRequest: (input: { - readonly cwd: string; - readonly context?: SourceControlProviderContext; - readonly reference: string; - readonly force?: boolean; - }) => Effect.Effect; - } ->()("t3/sourceControl/SourceControlProvider") {} diff --git a/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts b/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts deleted file mode 100644 index 466e662306493b2497c8f58ee6a05b604a5a45fd..0000000000000000000000000000000000000000 --- a/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts +++ /dev/null @@ -1,335 +0,0 @@ -import * as NodeUtil from "node:util"; -import type { - SourceControlProviderAuth, - SourceControlProviderDiscoveryItem, - SourceControlProviderInfo, - SourceControlProviderKind, -} from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; - -import type * as SourceControlProvider from "./SourceControlProvider.ts"; -import type * as VcsProcess from "../vcs/VcsProcess.ts"; - -export interface SourceControlAuthProbeInput { - readonly stdout: string; - readonly stderr: string; - readonly exitCode: VcsProcess.VcsProcessOutput["exitCode"]; -} - -export interface SourceControlUnknownRemoteRefinementInput { - readonly cwd: string; - readonly context: SourceControlProvider.SourceControlProviderContext; - readonly auth: SourceControlAuthProbeInput; -} - -interface SourceControlDiscoverySpecBase { - readonly kind: SourceControlProviderKind; - readonly label: string; - readonly installHint: string; -} - -export type SourceControlCliDiscoverySpec = SourceControlDiscoverySpecBase & { - readonly type: "cli"; - readonly executable: string; - readonly versionArgs: ReadonlyArray; - readonly authArgs: ReadonlyArray; - readonly remoteRefinementArgs?: ReadonlyArray; - readonly probeTimeoutMs?: number; - readonly parseAuth: (input: SourceControlAuthProbeInput) => SourceControlProviderAuth; - readonly refineUnknownRemote?: ( - input: SourceControlUnknownRemoteRefinementInput, - ) => SourceControlProviderInfo | null; -}; - -export type SourceControlApiDiscoverySpec = SourceControlDiscoverySpecBase & { - readonly type: "api"; - readonly probeAuth: Effect.Effect; -}; - -export type SourceControlManagedCliDiscoverySpec = SourceControlDiscoverySpecBase & { - readonly type: "managed-cli"; - readonly probe: (cwd: string) => Effect.Effect; - readonly refineUnknownRemote: (input: { - readonly cwd: string; - readonly context: SourceControlProvider.SourceControlProviderContext; - }) => Effect.Effect; -}; - -export type SourceControlProviderDiscoverySpec = - | SourceControlCliDiscoverySpec - | SourceControlManagedCliDiscoverySpec - | SourceControlApiDiscoverySpec; - -type SourceControlCliRemoteRefinementSpec = SourceControlCliDiscoverySpec & { - readonly refineUnknownRemote: NonNullable; -}; - -// Most provider CLIs answer `--version` in well under a second, so a short budget keeps -// discovery snappy. Specs whose CLI is known to be slower can raise it via probeTimeoutMs. -const DEFAULT_PROBE_TIMEOUT_MS = 5_000; - -function probeTimeoutMs(spec: SourceControlCliDiscoverySpec): number { - return spec.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS; -} - -interface DiscoveryProbeResult { - readonly kind: SourceControlProviderKind; - readonly label: string; - readonly executable: string; - readonly status: "available" | "missing"; - readonly version: Option.Option; - readonly installHint: string; - readonly detail: Option.Option; -} - -export function firstNonEmptyLine(text: string): Option.Option { - const line = NodeUtil.stripVTControlCharacters(text) - .split(/\r?\n/) - .map((entry) => entry.trim()) - .find((entry) => entry.length > 0); - return line === undefined ? Option.none() : Option.some(line); -} - -export function detailFromCause(cause: unknown): Option.Option { - if (cause instanceof Error && cause.message.trim().length > 0) { - return Option.some(cause.message.trim()); - } - return Option.none(); -} - -function authAccount(account: string | undefined): Option.Option { - const trimmed = account?.trim(); - return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed); -} - -function authHost(host: string | undefined): Option.Option { - const trimmed = host?.trim(); - return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed); -} - -function authDetail(detail: string | undefined): Option.Option { - const trimmed = detail?.trim(); - return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed); -} - -export function providerAuth(input: { - readonly status: SourceControlProviderAuth["status"]; - readonly account?: string | undefined; - readonly host?: string | undefined; - readonly detail?: string | undefined; -}): SourceControlProviderAuth { - return { - status: input.status, - account: authAccount(input.account), - host: authHost(input.host), - detail: authDetail(input.detail), - }; -} - -function unknownAuth(detail?: string): SourceControlProviderAuth { - return providerAuth({ status: "unknown", detail }); -} - -export function combinedAuthOutput(input: SourceControlAuthProbeInput): string { - const parts: string[] = []; - for (const entry of [input.stdout, input.stderr]) { - if (entry.trim().length > 0) { - parts.push(entry); - } - } - return parts.join("\n"); -} - -function sanitizedAuthLines(text: string): ReadonlyArray { - const lines: string[] = []; - for (const entry of text.split(/\r?\n/)) { - const line = entry.trim(); - if (line.length === 0) continue; - if (/^[-\s]*token(?:\s+scopes?)?:/iu.test(line)) continue; - lines.push(line); - } - return lines; -} - -export function firstSafeAuthLine(text: string): string | undefined { - return sanitizedAuthLines(text)[0]; -} - -export function parseCliHost(text: string): string | undefined { - return sanitizedAuthLines(text) - .map((line) => line.replace(/^[^a-z0-9]+/iu, "")) - .find((line) => /^[a-z0-9][a-z0-9.-]*(?::\d+)?$/iu.test(line)); -} - -export function matchFirst(text: string, patterns: ReadonlyArray): string | undefined { - for (const pattern of patterns) { - const match = pattern.exec(text); - const value = match?.[1]?.trim(); - if (value && value.length > 0) return value; - } - return undefined; -} - -function isCliRemoteRefinementSpec( - spec: SourceControlProviderDiscoverySpec, -): spec is SourceControlCliRemoteRefinementSpec { - return spec.type === "cli" && spec.refineUnknownRemote !== undefined; -} - -function probeCli(input: { - readonly spec: SourceControlCliDiscoverySpec; - readonly process: VcsProcess.VcsProcess["Service"]; - readonly cwd: string; -}): Effect.Effect { - return input.process - .run({ - operation: "source-control.discovery.probe", - command: input.spec.executable, - args: input.spec.versionArgs, - cwd: input.cwd, - timeoutMs: probeTimeoutMs(input.spec), - maxOutputBytes: 8_000, - appendTruncationMarker: true, - }) - .pipe( - Effect.map( - (result) => - ({ - kind: input.spec.kind, - label: input.spec.label, - executable: input.spec.executable, - status: "available" as const, - version: Option.orElse(firstNonEmptyLine(result.stdout), () => - firstNonEmptyLine(result.stderr), - ), - installHint: input.spec.installHint, - detail: Option.none(), - }) satisfies DiscoveryProbeResult, - ), - Effect.catch((cause) => - Effect.succeed({ - kind: input.spec.kind, - label: input.spec.label, - executable: input.spec.executable, - status: "missing" as const, - version: Option.none(), - installHint: input.spec.installHint, - detail: detailFromCause(cause), - } satisfies DiscoveryProbeResult), - ), - ); -} - -export function probeSourceControlProvider(input: { - readonly spec: SourceControlProviderDiscoverySpec; - readonly process: VcsProcess.VcsProcess["Service"]; - readonly cwd: string; -}): Effect.Effect { - if (input.spec.type === "managed-cli") return input.spec.probe(input.cwd); - if (input.spec.type === "api") { - return input.spec.probeAuth.pipe( - Effect.map( - (auth) => - ({ - kind: input.spec.kind, - label: input.spec.label, - status: "available" as const, - version: Option.none(), - installHint: input.spec.installHint, - detail: Option.none(), - auth, - }) satisfies SourceControlProviderDiscoveryItem, - ), - ); - } - - const spec = input.spec; - - return probeCli({ - spec, - process: input.process, - cwd: input.cwd, - }).pipe( - Effect.flatMap((item) => { - if (item.status !== "available") { - return Effect.succeed({ - ...item, - auth: unknownAuth("Hosting integration command was not found on the server PATH."), - } satisfies SourceControlProviderDiscoveryItem); - } - - return input.process - .run({ - operation: "source-control.discovery.auth", - command: spec.executable, - args: spec.authArgs, - cwd: input.cwd, - allowNonZeroExit: true, - timeoutMs: probeTimeoutMs(spec), - maxOutputBytes: 8_000, - appendTruncationMarker: true, - }) - .pipe( - Effect.map( - (result) => - ({ - ...item, - auth: spec.parseAuth(result), - }) satisfies SourceControlProviderDiscoveryItem, - ), - Effect.catch((cause) => - Effect.succeed({ - ...item, - auth: unknownAuth(Option.getOrUndefined(detailFromCause(cause))), - } satisfies SourceControlProviderDiscoveryItem), - ), - ); - }), - ); -} - -export const refineUnknownRemoteProvider = Effect.fn("refineUnknownRemoteProvider")( - function* (input: { - readonly specs: ReadonlyArray; - readonly process: VcsProcess.VcsProcess["Service"]; - readonly cwd: string; - readonly context: SourceControlProvider.SourceControlProviderContext | null; - }): Effect.fn.Return { - if (input.context === null || input.context.provider.kind !== "unknown") { - return input.context; - } - const context = input.context; - - const providers = yield* Effect.forEach(input.specs, (spec) => { - if (spec.type === "managed-cli") { - return spec.refineUnknownRemote({ cwd: input.cwd, context }); - } - if (!isCliRemoteRefinementSpec(spec)) return Effect.succeed(null); - return input.process - .run({ - operation: "source-control.discovery.refine-unknown-remote", - command: spec.executable, - args: spec.remoteRefinementArgs ?? spec.authArgs, - cwd: input.cwd, - allowNonZeroExit: true, - timeoutMs: probeTimeoutMs(spec), - maxOutputBytes: 8_000, - appendTruncationMarker: true, - }) - .pipe( - Effect.map((auth) => - spec.refineUnknownRemote({ - cwd: input.cwd, - context, - auth, - }), - ), - Effect.orElseSucceed(() => null), - ); - }); - const provider = providers.find((candidate) => candidate !== null); - - return provider ? { ...context, provider } : context; - }, -); diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index 705c38b847b7248174cef630807994ca3f4e07f2..08a7259eb1e1fed88d441caccf736a4a804d784f 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -21,6 +21,7 @@ import * as GitHubCredentials from "./GitHubCredentials.ts"; import * as GitLabCli from "./GitLabCli.ts"; import * as ForgejoCli from "./ForgejoCli.ts"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; +import * as ServerSourceControlHost from "./ServerSourceControlHost.ts"; const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); @@ -96,6 +97,11 @@ function makeRegistry(input: { NodeServices.layer, layerRegistry, layerProcess, + ServerSourceControlHost.layer.pipe( + Layer.provide( + Layer.merge(layerProcess, ServerSettings.ServerSettingsService.layerTest()), + ), + ), Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), Layer.mock(BitbucketApi.BitbucketApi)({}), ServerSettings.ServerSettingsService.layerTest(), diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts index 7a626b13f50ff06109bd56e380bee44c45759f33..309f79b1509fca3f63346f8578b74b16e5f8ded2 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -16,15 +16,16 @@ import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvide import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts"; import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts"; -import * as SourceControlProvider from "./SourceControlProvider.ts"; +import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; import { probeSourceControlProvider, refineUnknownRemoteProvider, type SourceControlProviderDiscoverySpec, -} from "./SourceControlProviderDiscovery.ts"; +} from "@t3tools/source-control-core/server/discovery"; +import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; + import * as ServerConfig from "../config.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; -import * as VcsProcess from "../vcs/VcsProcess.ts"; const PROVIDER_DETECTION_CACHE_CAPACITY = 2_048; const PROVIDER_DETECTION_CACHE_TTL = Duration.seconds(5); @@ -201,7 +202,7 @@ function bindProviderContext( export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWithProviders")( function* (registrations: ReadonlyArray) { const config = yield* ServerConfig.ServerConfig; - const process = yield* VcsProcess.VcsProcess; + const { process } = yield* SourceControlHost.SourceControlHost; const vcsRegistry = yield* VcsDriverRegistry.VcsDriverRegistry; const providers = new Map< SourceControlProviderKind, diff --git a/apps/server/src/sourceControl/SourceControlRateLimit.test.ts b/apps/server/src/sourceControl/SourceControlRateLimit.test.ts deleted file mode 100644 index e46005c73f0e73cd34783832037ed8a59504e190..0000000000000000000000000000000000000000 --- a/apps/server/src/sourceControl/SourceControlRateLimit.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { assert, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as TestClock from "effect/testing/TestClock"; - -import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; - -const github = { provider: "github" as const, host: "github.com" }; - -it.effect("isolates cooldowns for verified credentials on the same host", () => - Effect.gen(function* () { - const limits = yield* SourceControlRateLimit.SourceControlRateLimit; - yield* limits - .recordRateLimit({ ...github, lease: 0 }) - .pipe(Effect.provideService(SourceControlRateLimit.CredentialScope, "first")); - yield* limits - .check(github) - .pipe(Effect.provideService(SourceControlRateLimit.CredentialScope, "second")); - const error = yield* limits - .check(github) - .pipe(Effect.provideService(SourceControlRateLimit.CredentialScope, "first"), Effect.flip); - assert.strictEqual(error._tag, "SourceControlRateLimitPausedError"); - }).pipe(Effect.provide(SourceControlRateLimit.layer)), -); - -it("parses Retry-After seconds and HTTP dates", () => { - assert.equal(SourceControlRateLimit.retryAtFromHeader("120", 1_000), 121_000); - assert.equal( - SourceControlRateLimit.retryAtFromHeader("Thu, 01 Jan 1970 00:02:01 GMT", 1_000), - 121_000, - ); - assert.isUndefined(SourceControlRateLimit.retryAtFromHeader("later", 1_000)); -}); - -it.effect("backs off repeated rate limits until a successful request", () => - Effect.gen(function* () { - yield* TestClock.setTime(0); - const limits = yield* SourceControlRateLimit.SourceControlRateLimit; - - const firstLease = yield* limits.check(github); - yield* limits.recordRateLimit({ ...github, lease: firstLease }); - const firstPause = yield* Effect.flip(limits.check(github)); - assert.deepInclude(firstPause, { - _tag: "SourceControlRateLimitPausedError", - provider: "github", - host: "github.com", - retryAt: 30_000, - }); - assert.equal( - firstPause.detail, - "github requests to github.com are paused until the rate limit resets.", - ); - assert.equal(firstPause.message, firstPause.detail); - - yield* TestClock.adjust("30 seconds"); - const secondLease = yield* limits.check(github); - yield* limits.recordRateLimit({ ...github, lease: secondLease }); - assert.equal((yield* Effect.flip(limits.check(github))).retryAt, 90_000); - - yield* TestClock.adjust("60 seconds"); - const successLease = yield* limits.check(github); - yield* limits.recordSuccess({ ...github, lease: successLease }); - const resetLease = yield* limits.check(github); - yield* limits.recordRateLimit({ ...github, lease: resetLease }); - assert.equal((yield* Effect.flip(limits.check(github))).retryAt, 120_000); - }).pipe(Effect.provide(SourceControlRateLimit.layer)), -); - -it.effect("honors a provider reset time", () => - Effect.gen(function* () { - yield* TestClock.setTime(1_000); - const limits = yield* SourceControlRateLimit.SourceControlRateLimit; - const lease = yield* limits.check(github); - - yield* limits.recordRateLimit({ ...github, lease, retryAt: 121_000 }); - assert.equal((yield* Effect.flip(limits.check(github))).retryAt, 121_000); - - yield* TestClock.adjust("119999 millis"); - assert.equal((yield* Effect.flip(limits.check(github))).retryAt, 121_000); - yield* TestClock.adjust("1 millis"); - assert.equal(yield* limits.check(github), 1); - }).pipe(Effect.provide(SourceControlRateLimit.layer)), -); - -it.effect("lets an interactive request through without clearing an active pause", () => - Effect.gen(function* () { - yield* TestClock.setTime(1_000); - const limits = yield* SourceControlRateLimit.SourceControlRateLimit; - const lease = yield* limits.check(github); - yield* limits.recordRateLimit({ ...github, lease, retryAt: 121_000 }); - - const interactiveLease = yield* limits.check(github, { allowPaused: true }); - yield* limits.recordSuccess({ ...github, lease: interactiveLease }); - - assert.equal(interactiveLease, 1); - assert.equal((yield* Effect.flip(limits.check(github))).retryAt, 121_000); - - yield* limits.recordRateLimit({ ...github, lease: interactiveLease, retryAt: 61_000 }); - assert.equal((yield* Effect.flip(limits.check(github))).retryAt, 121_000); - }).pipe(Effect.provide(SourceControlRateLimit.layer)), -); - -it.effect("keeps providers and hosts isolated", () => - Effect.gen(function* () { - const limits = yield* SourceControlRateLimit.SourceControlRateLimit; - const lease = yield* limits.check(github); - yield* limits.recordRateLimit({ ...github, lease }); - - assert.equal(yield* limits.check({ provider: "gitlab", host: "github.com" }), 0); - assert.equal(yield* limits.check({ provider: "github", host: "github.example.com" }), 0); - }).pipe(Effect.provide(SourceControlRateLimit.layer)), -); - -it.effect("does not let an older success clear a concurrent pause", () => - Effect.gen(function* () { - const limits = yield* SourceControlRateLimit.SourceControlRateLimit; - const firstLease = yield* limits.check(github); - const concurrentLease = yield* limits.check(github); - - yield* limits.recordRateLimit({ ...github, lease: firstLease }); - yield* limits.recordSuccess({ ...github, lease: concurrentLease }); - - assert.equal((yield* Effect.flip(limits.check(github))).retryAt, 30_000); - }).pipe(Effect.provide(SourceControlRateLimit.layer)), -); - -it.effect("keeps a fresh provider reset from an older request", () => - Effect.gen(function* () { - yield* TestClock.setTime(0); - const limits = yield* SourceControlRateLimit.SourceControlRateLimit; - const staleLease = yield* limits.check(github); - yield* limits.recordRateLimit({ ...github, lease: staleLease }); - - yield* TestClock.adjust("31 seconds"); - yield* limits.recordRateLimit({ ...github, lease: staleLease, retryAt: 61_000 }); - - assert.equal((yield* Effect.flip(limits.check(github))).retryAt, 61_000); - }).pipe(Effect.provide(SourceControlRateLimit.layer)), -); diff --git a/apps/server/src/sourceControl/SourceControlRateLimit.ts b/apps/server/src/sourceControl/SourceControlRateLimit.ts deleted file mode 100644 index dc6242a60eb6b9a53338ca07e899fde8edf965bd..0000000000000000000000000000000000000000 --- a/apps/server/src/sourceControl/SourceControlRateLimit.ts +++ /dev/null @@ -1,168 +0,0 @@ -import * as Clock from "effect/Clock"; -import * as Context from "effect/Context"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Ref from "effect/Ref"; -import * as Schema from "effect/Schema"; -import { - SourceControlProviderKind as SourceControlProviderKindSchema, - type SourceControlProviderKind, -} from "@t3tools/contracts"; - -const FALLBACK_COOLDOWN = Duration.seconds(30); -const MAX_FALLBACK_COOLDOWN = Duration.minutes(15); - -export const CredentialScope = Context.Reference("t3/sourceControl/CredentialScope", { - defaultValue: () => "", -}); - -interface RateLimitKey { - readonly provider: SourceControlProviderKind; - readonly host: string; -} - -interface RateLimitLease extends RateLimitKey { - readonly lease: number; -} - -interface RateLimitEntry { - readonly attempt: number; - readonly generation: number; - readonly retryAt: number; -} - -export class SourceControlRateLimitPausedError extends Schema.TaggedError()( - "SourceControlRateLimitPausedError", - { - provider: SourceControlProviderKindSchema, - host: Schema.String, - retryAt: Schema.Number, - }, -) { - get detail(): string { - return `${this.provider} requests to ${this.host} are paused until the rate limit resets.`; - } - - override get message(): string { - return this.detail; - } -} - -export class SourceControlRateLimit extends Context.Service< - SourceControlRateLimit, - { - readonly check: ( - key: RateLimitKey, - options?: { readonly allowPaused: boolean }, - ) => Effect.Effect; - readonly recordRateLimit: ( - input: RateLimitLease & { readonly retryAt?: number | undefined }, - ) => Effect.Effect; - readonly recordSuccess: (input: RateLimitLease) => Effect.Effect; - } ->()("t3/sourceControl/SourceControlRateLimit") {} - -function normalizedKey(key: RateLimitKey, scope: string): string { - return `${key.provider}\0${key.host.trim().toLowerCase()}\0${scope}`; -} - -function fallbackCooldownMs(attempt: number): number { - return Math.min( - Duration.toMillis(FALLBACK_COOLDOWN) * 2 ** Math.max(0, attempt - 1), - Duration.toMillis(MAX_FALLBACK_COOLDOWN), - ); -} - -export function retryAtFromHeader(value: string | undefined, now: number): number | undefined { - if (value === undefined) return undefined; - const normalized = value.trim(); - if (/^\d+$/u.test(normalized)) { - const seconds = Number(normalized); - const retryAt = now + seconds * 1_000; - return Number.isSafeInteger(retryAt) ? retryAt : undefined; - } - const retryAt = Date.parse(normalized); - return Number.isFinite(retryAt) && retryAt > now ? retryAt : undefined; -} - -/** @public Service construction is part of the canonical Effect module API. */ -export const make = Effect.gen(function* () { - const entries = yield* Ref.make>(new Map()); - - const check: SourceControlRateLimit["Service"]["check"] = Effect.fn( - "SourceControlRateLimit.check", - )(function* (input, options) { - const now = yield* Clock.currentTimeMillis; - const key = normalizedKey(input, yield* CredentialScope); - const entry = (yield* Ref.get(entries)).get(key); - if (entry !== undefined && entry.retryAt > now && options?.allowPaused !== true) { - return yield* new SourceControlRateLimitPausedError({ - provider: input.provider, - host: input.host.trim().toLowerCase(), - retryAt: entry.retryAt, - }); - } - return entry?.generation ?? 0; - }); - - const recordRateLimit: SourceControlRateLimit["Service"]["recordRateLimit"] = Effect.fn( - "SourceControlRateLimit.recordRateLimit", - )(function* (input) { - const now = yield* Clock.currentTimeMillis; - const key = normalizedKey(input, yield* CredentialScope); - yield* Ref.update(entries, (current) => { - const previous = current.get(key); - if (previous !== undefined && previous.generation > input.lease) { - if (previous.retryAt <= now && (input.retryAt === undefined || input.retryAt <= now)) { - return current; - } - const retryAt = - input.retryAt !== undefined && input.retryAt > previous.retryAt - ? input.retryAt - : previous.retryAt; - if (retryAt === previous.retryAt) return current; - const next = new Map(current); - next.set(key, { ...previous, retryAt }); - return next; - } - - const attempt = (previous?.attempt ?? 0) + 1; - const proposedRetryAt = - input.retryAt !== undefined && input.retryAt > now - ? input.retryAt - : now + fallbackCooldownMs(attempt); - const retryAt = - previous !== undefined && previous.retryAt > now - ? Math.max(previous.retryAt, proposedRetryAt) - : proposedRetryAt; - const next = new Map(current); - next.set(key, { - attempt, - generation: Math.max(previous?.generation ?? 0, input.lease) + 1, - retryAt, - }); - return next; - }); - }); - - const recordSuccess: SourceControlRateLimit["Service"]["recordSuccess"] = Effect.fn( - "SourceControlRateLimit.recordSuccess", - )(function* (input) { - const now = yield* Clock.currentTimeMillis; - const key = normalizedKey(input, yield* CredentialScope); - yield* Ref.update(entries, (current) => { - const previous = current.get(key); - if (previous === undefined || previous.generation !== input.lease || previous.retryAt > now) { - return current; - } - const next = new Map(current); - next.set(key, { attempt: 0, generation: previous.generation, retryAt: 0 }); - return next; - }); - }); - - return SourceControlRateLimit.of({ check, recordRateLimit, recordSuccess }); -}); - -export const layer = Layer.effect(SourceControlRateLimit, make); diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index 3495b2270b3e8c012298e15fa3d892b02382e2ad..8aba02284403b021faff895ecccea956df65f2c7 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -15,7 +15,7 @@ import * as ServerConfig from "../config.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as BitbucketApi from "./BitbucketApi.ts"; import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts"; -import type * as SourceControlProvider from "./SourceControlProvider.ts"; +import type * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; import * as SourceControlRepositoryService from "./SourceControlRepositoryService.ts"; diff --git a/apps/server/src/sourceControl/githubQuota.test.ts b/apps/server/src/sourceControl/githubQuota.test.ts index e21970cecca72cc87f84cbff28d6eb8d1b216b7f..e9c1fac93eb04be46ceee99bcdb53dc45c477ae9 100644 --- a/apps/server/src/sourceControl/githubQuota.test.ts +++ b/apps/server/src/sourceControl/githubQuota.test.ts @@ -3,7 +3,7 @@ import * as Effect from "effect/Effect"; import * as TestClock from "effect/testing/TestClock"; import * as GitHubQuota from "./githubQuota.ts"; -import { CredentialScope } from "./SourceControlRateLimit.ts"; +import { CredentialScope } from "@t3tools/source-control-core/server/SourceControlRateLimit"; const RESET = Date.parse("2099-08-13T14:00:00Z"); const headers = (remaining: number, resource = "graphql", reset = RESET) => ({ diff --git a/apps/server/src/sourceControl/githubQuota.ts b/apps/server/src/sourceControl/githubQuota.ts index c553535019f452209af1f17c2ccbd28e7c1b8a7b..642c48fddc9a107f869c25ca8b3183a3487530c8 100644 --- a/apps/server/src/sourceControl/githubQuota.ts +++ b/apps/server/src/sourceControl/githubQuota.ts @@ -4,7 +4,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; -import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; +import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; /** The share of each quota a background read leaves for a user's next click. */ const RESERVE_RATIO = 0.1; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index a035d6971d454e4009818b96613c3caf21c564ae..666805e1d79eec32d230a2219eaea775c9f10795 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -235,6 +235,7 @@ import * as GitHubApi from "./sourceControl/GitHubApi.ts"; import * as GitLabCli from "./sourceControl/GitLabCli.ts"; import * as ForgejoCli from "./sourceControl/ForgejoCli.ts"; import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; +import * as ServerSourceControlHost from "./sourceControl/ServerSourceControlHost.ts"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts"; import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts"; @@ -3209,6 +3210,7 @@ export const layer = Layer.unwrap( GitHubApi.layerWithDependencies, GitLabCli.layer, ForgejoCli.layer, + ServerSourceControlHost.layer, ), ), Layer.provideMerge(GitVcsDriver.layer), diff --git a/docs/internals/pull-request-file-revisions.md b/docs/internals/pull-request-file-revisions.md index 3a84b8de665d03cd4ab2bdaf447719cb6290c5d5..22eeb63848eaf6156db5e68cedaccf4d48a51c40 100644 --- a/docs/internals/pull-request-file-revisions.md +++ b/docs/internals/pull-request-file-revisions.md @@ -17,7 +17,7 @@ a project the token cannot see. Below the boundary, in a patch parser or a response decoder, a missing path means this revision does not carry that file. Above it, in -[`ProviderFileRevisions`](../../apps/server/src/pullRequest/PullRequestProvider.ts), a missing path +[`ProviderFileRevisions`](../../packages/source-control-core/src/server/PullRequestProvider.ts), a missing path means the read could not say. A provider's `getFileRevisions` converts on the way up: a path its host looked at and has no version for arrives as the empty string, and absence is kept for what the provider never got to look at. Absence below the boundary does not survive it. diff --git a/package.json b/package.json index 55869fc4b3911ff6b6486767dea7fc9a9230ab7a..471e5361eef280ccc93f4fd3b69f51cd5911f805 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "tc": "vp run -r --concurrency-limit 2 typecheck", "lint": "vp lint --report-unused-disable-directives", "knip": "knip --preprocessor ./scripts/knip-schemas.ts", - "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-acp-registry --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --workspace packages/provider-grok --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", + "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-acp-registry --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --workspace packages/provider-grok --workspace packages/source-control-core --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts", "lint:mobile": "node scripts/mobile-native-static-check.ts", "test": "vp run -r test", diff --git a/packages/source-control-core/package.json b/packages/source-control-core/package.json new file mode 100644 index 0000000000000000000000000000000000000000..58742f9f3589bb23360ae5f27628217805467d2a --- /dev/null +++ b/packages/source-control-core/package.json @@ -0,0 +1,48 @@ +{ + "name": "@t3tools/source-control-core", + "private": true, + "type": "module", + "exports": { + "./server/discovery": { + "types": "./src/server/discovery.ts", + "import": "./src/server/discovery.ts" + }, + "./server/mergeMessage": { + "types": "./src/server/mergeMessage.ts", + "import": "./src/server/mergeMessage.ts" + }, + "./server/pullRequestChecks": { + "types": "./src/server/pullRequestChecks.ts", + "import": "./src/server/pullRequestChecks.ts" + }, + "./server/PullRequestProvider": { + "types": "./src/server/PullRequestProvider.ts", + "import": "./src/server/PullRequestProvider.ts" + }, + "./server/SourceControlHost": { + "types": "./src/server/SourceControlHost.ts", + "import": "./src/server/SourceControlHost.ts" + }, + "./server/SourceControlProvider": { + "types": "./src/server/SourceControlProvider.ts", + "import": "./src/server/SourceControlProvider.ts" + }, + "./server/SourceControlRateLimit": { + "types": "./src/server/SourceControlRateLimit.ts", + "import": "./src/server/SourceControlRateLimit.ts" + } + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vp test run --config ../../vite.config.ts --dir ." + }, + "dependencies": { + "@t3tools/contracts": "workspace:*", + "effect": "catalog:" + }, + "devDependencies": { + "@effect/vitest": "catalog:", + "@types/node": "catalog:", + "vite-plus": "catalog:" + } +} diff --git a/packages/source-control-core/src/server/PullRequestProvider.ts b/packages/source-control-core/src/server/PullRequestProvider.ts new file mode 100644 index 0000000000000000000000000000000000000000..c3f588a6227749d634d6b5789133afa2a2e8e563 --- /dev/null +++ b/packages/source-control-core/src/server/PullRequestProvider.ts @@ -0,0 +1,702 @@ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import type { + PullRequestStackMembership, + PullRequestAction, + PullRequestStackHead, + PullRequestActor, + PullRequestBaseComparison, + PullRequestCapabilities, + PullRequestChecksState, + PullRequestCheck, + PullRequestChecks, + PullRequestComment, + PullRequestFileViewed, + PullRequestCommit, + PullRequestInvolvement, + PullRequestLabel, + PullRequestListFilters, + PullRequestListState, + PullRequestMergeCapabilities, + PullRequestMergeMethod, + PullRequestMergeability, + PullRequestOmittedFileStat, + PullRequestReaction, + PullRequestReactionContent, + PullRequestReviewCommentDraft, + PullRequestReviewDecision, + PullRequestReviewThread, + PullRequestThreadCommentsResult, + PullRequestReviewVerdict, + PullRequestReviewerCandidateList, + PullRequestReviewerKind, + PullRequestLabelCandidateList, + PullRequestState, + PullRequestPreview, + PullRequestUpdateMethod, + PullRequestViewerPermissions, + SourceControlProviderKind, +} from "@t3tools/contracts"; +import { SourceControlProviderKind as SourceControlProviderKindSchema } from "@t3tools/contracts"; + +/** + * The one failure shape every provider reports, so the service can decide what a failure means + * without knowing which CLI or API produced it. + * + * `reason` is the part the service acts on: a missing or unauthenticated tool disables the + * provider for the whole workspace, a rate limit pauses its host, and anything else is specific + * to the request. + */ +export class PullRequestProviderError extends Schema.TaggedError()( + "PullRequestProviderError", + { + provider: SourceControlProviderKindSchema, + operation: Schema.String, + reason: Schema.Literals([ + "missing-tool", + "unauthenticated", + "rate-limited", + "not-found", + "failed", + ]), + detail: Schema.String, + retryAt: Schema.optional(Schema.Number), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `${this.provider} failed in ${this.operation}: ${this.detail}`; + } +} + +/** + * `status` moves with the state, mergeability, head, or checks, which the detail read reports; + * `remarks` moves with comments and reviews, which only the activity read reports. + */ +export interface ProviderChangeRequestWatchFingerprint { + readonly status: string; + readonly remarks: string; +} + +export interface PullRequestProviderFailure { + readonly reason: PullRequestProviderError["reason"]; + readonly retryAt?: number | undefined; +} + +/** A change request as the provider sees it, before the service attaches project context. */ +export interface ProviderChangeRequest { + readonly stack?: PullRequestStackMembership; + readonly number: number; + readonly title: string; + readonly url: string; + readonly author: PullRequestActor | null; + readonly headBranch: string; + readonly headRepositoryNameWithOwner?: string | null; + readonly baseBranch: string; + readonly state: PullRequestState; + readonly isDraft: boolean; + readonly mergeability: PullRequestMergeability; + readonly additions: number; + readonly deletions: number; + readonly createdAt: string; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; + readonly updatedAt: string; + /** Accounts with a review requested. Team-level requests are excluded by each provider. */ + readonly reviewRequestLogins: ReadonlyArray; + readonly labels: ReadonlyArray; + /** Absent from a host that does not summarise its reviews, which is every host but GitHub. */ + readonly reviewDecision?: PullRequestReviewDecision | null | undefined; + /** Absent from a host that reports no check rollup on its listings. */ + readonly checksState?: PullRequestChecksState | null | undefined; +} + +/** The fields needed to keep a linked thread's pull request status live. */ +export interface ProviderChangeRequestSummary { + readonly number: number; + readonly title: string; + readonly url: string; + readonly headBranch: string; + readonly baseBranch: string; + readonly state: PullRequestState; + /** Present when the host says an open pull request is still a draft. */ + readonly isDraft?: boolean; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; + readonly updatedAt: string; + /** Overview fields, present where the host's single read returns them at no extra cost. */ + readonly author?: PullRequestActor | null | undefined; + readonly additions?: number | undefined; + readonly deletions?: number | undefined; + readonly changedFiles?: number | undefined; + readonly reviewDecision?: PullRequestReviewDecision | null | undefined; + readonly checksState?: PullRequestChecksState | null | undefined; + readonly mergeability?: PullRequestMergeability | undefined; + /** + * The host-native stack the pull request sits in, from the same read. Null when the host says + * it is in none; absent when the read did not ask. + */ + readonly stack?: PullRequestStackMembership | null | undefined; +} + +/** One layer of a host-native stack, bottom to top order is the array's. */ +export interface ProviderChangeRequestStackLayer { + readonly title?: string; + readonly isDraft?: boolean; + readonly headSha?: string; + readonly number: number; + readonly headBranch: string; + readonly state: PullRequestState; +} + +/** + * A host-native stack: an ordered set of change requests the host itself merges and retargets as + * a unit. Only GitHub offers one today; the neutral shape lets the sync reactor and the UI stay + * ignorant of which host said so. + */ +export interface ProviderChangeRequestStack { + readonly id: string; + readonly number: number; + readonly url: string; + readonly base: string; + readonly layers: ReadonlyArray; +} + +export interface ProviderChangeRequestPage { + readonly items: ReadonlyArray; + /** True when the host has more rows than the page size asked for. */ + readonly truncated: boolean; + /** + * Optional count-based cursor advance. Most hosts advance by the rows delivered after local + * de-duplication; an offset-paged host may need to count malformed raw rows it consumed too. + */ + readonly cursorAdvance?: number; + /** + * This page can be carried on from, so the service may hand the caller a cursor for it. False + * where the host answered in an order a cursor means nothing in, which leaves a larger `limit` + * as the only way to the rest — what every listing did before there were cursors. + */ + readonly continues: boolean; +} + +/** + * Where a repository's next slice starts, as the provider that has to ask for it needs it. Built + * by the service out of the slice it just handed over, so the boundary that decides whether a row + * arrives twice or not at all is decided in one place rather than in four. + */ +export interface ProviderListCursor { + /** + * The instant of the oldest row already handed over, checked against a timestamp's shape before + * it gets here because it goes into a host's own filter. Asked for inclusively: several rows + * share one instant often enough — a bot that touches eight change requests writes one timestamp + * on all eight — and asking for strictly older would lose whichever of them the slice ended + * before. The service drops the ones it has already sent. + */ + readonly updatedBefore: string; + /** + * How many provider rows this repository has consumed so far, for a host that carries on by + * counting rather than by date. Usually this is the number handed over; malformed raw rows may + * count too when the provider reports a `cursorAdvance`. + */ + readonly delivered: number; +} + +/** One repository's row inside an answer that spans several of them. */ +export interface ProviderBatchedChangeRequest extends ProviderChangeRequest { + /** Provider-native identity, exactly as it was asked for, so the caller can file the row. */ + readonly repository: string; +} + +/** + * One slice of a host read across several repositories at once, newest update first across all + * of them. There is no per-repository page here because the host was asked one question: the + * caller splits the rows by `repository` and works out where each of them carries on from the + * oldest row in the slice, which every repository the slice covers is now read up to. + */ +export interface ProviderBatchedChangeRequestPage { + readonly items: ReadonlyArray; + /** True when the host has more rows than the slice asked for, for any of the repositories. */ + readonly truncated: boolean; +} + +/** The line counts for one change request, which a listing may leave for a second read. */ +export interface ProviderChangeRequestStat { + readonly repository: string; + readonly number: number; + readonly additions: number; + readonly deletions: number; +} + +export interface ProviderChangeRequestDetail extends ProviderChangeRequest { + /** The head commit, where the host's detail read reports it. */ + readonly headSha?: string | null; + readonly body: string; + readonly changedFiles: number; + readonly mergedAt: string | null; + readonly closedAt: string | null; + readonly reviewers: ReadonlyArray; + readonly checks: ReadonlyArray; + readonly mergeCapabilities: PullRequestMergeCapabilities; + readonly viewerPermissions: PullRequestViewerPermissions; + /** Absent from a host that cannot compare the branch with its base, which is most of them. */ + readonly baseComparison?: PullRequestBaseComparison; + readonly behindBy?: number; + /** Absent from a host that does not report whether it is armed to merge this on its own. */ + readonly autoMergeEnabled?: boolean; + /** The strategy stored with an armed auto-merge, where the host reports it. */ + readonly autoMergeMethod?: PullRequestMergeMethod; + /** Workflow runs on this head commit that still need a maintainer's approval. */ + readonly workflowApprovalsRequired?: number; +} + +/** The conversation-shaped half of a detail, loaded after the core can already render. */ +export interface ProviderChangeRequestActivity { + /** An optional richer actor, e.g. after GitHub's GraphQL read supplies an avatar. */ + readonly author?: PullRequestActor | null; + /** Optional because most hosts already report their reviewer list in the core detail. */ + readonly reviewers?: ReadonlyArray; + readonly comments: ReadonlyArray; + /** + * The host's own count of the conversation, which a bounded read can fall short of. A host + * that reports no count of its own answers with what it handed over, which is the same number + * once the read went to the end. + */ + readonly commentCount: number; + readonly commentsTruncated: boolean; + readonly reviewThreadsTruncated?: boolean; + readonly reviewThreads: ReadonlyArray; + readonly commits: ReadonlyArray; + /** The change request's own reactions, from a host that has them. */ + readonly reactions?: ReadonlyArray; +} + +export interface ProviderDiffSlice { + readonly patch: string; + /** Something in this slice could not be shown, as opposed to there being more slices. */ + readonly truncated: boolean; + readonly nextCursor: string | null; + /** The host's own counts for the files whose hunks it withheld from this slice. */ + readonly omittedFileStats?: ReadonlyArray; +} + +export interface ProviderDiffFileContents { + readonly oldContents: string; + readonly newContents: string; +} + +export interface ProviderFilesViewed { + readonly files: ReadonlyArray; + /** The host has more files than were read, so the ones missing here are not "unviewed". */ + readonly truncated: boolean; +} + +/** + * What version each of the asked-for files is at, on the change request's head. Opaque strings, + * compared only against one another, where the empty string is the answer for a file the change + * request deletes rather than a gap. A path is absent only where the read could not say, so a + * provider converts its host's own absences on the way here. + * + * The whole path a version travels, and the three senses of null along it, are in + * `docs/internals/pull-request-file-revisions.md`. + */ +export interface ProviderFileRevisions { + readonly revisions: ReadonlyMap; + /** + * Whether these are every file the change request carries rather than only the paths asked + * about. A host with no per-file version reads the whole change to answer for one file, and + * saying so is what keeps the next tick, naming a path nothing asked about before, from making + * it read the whole change again. False for a read cut short, which cannot speak past the cut. + */ + readonly complete?: boolean; +} + +export interface ProviderRepositoryRef { + readonly cwd: string; + /** Provider-native repository identity, e.g. `owner/repo` or `group/subgroup/project`. */ + readonly repository: string; + /** + * The host it lives on, which `repository` deliberately leaves out — the same `owner/repo` + * exists on github.com and on a GitHub Enterprise install, and only the caller knows which + * one a project's remote points at. + */ + readonly host: string; +} + +/** + * One host's change requests. Implementations own their own tool and JSON shapes and hand back + * the neutral types above; anything a host cannot do is declared in `capabilities` rather than + * failing at call time. + */ +export interface PullRequestProviderApi { + readonly withVerifiedCredential?: ( + input: { readonly cwd: string; readonly host: string }, + use: (identity: { + readonly accountId: string; + readonly viewer: string; + readonly credentialFingerprint: string; + }) => Effect.Effect, + ) => Effect.Effect; + readonly getRoutingIdentity?: (input: { + readonly cwd: string; + readonly host: string; + }) => Effect.Effect< + { readonly accountId: string; readonly viewer: string }, + PullRequestProviderError + >; + readonly kind: SourceControlProviderKind; + readonly capabilities: PullRequestCapabilities; + + /** The signed-in account, which is what involvement filtering compares against. */ + readonly getViewer: (input: { + readonly cwd: string; + readonly host?: string; + }) => Effect.Effect; + + readonly listChangeRequests: ( + input: ProviderRepositoryRef & { + readonly state: PullRequestListState; + readonly involvement: PullRequestInvolvement; + readonly viewer: string; + readonly limit: number; + /** + * Free text to narrow the listing by, as the host understands it. A host with no text + * filter of its own ignores it and answers with the page it would have answered with + * anyway — the caller narrows what it gets, so an unfiltered page is a wider answer + * rather than a wrong one. + */ + readonly query?: string | undefined; + /** + * Where to carry on from, rather than reading this repository from its newest row. Absent + * asks for the first slice, which is every listing that has not been continued. + */ + readonly cursor?: ProviderListCursor | undefined; + /** + * Further narrowings, which a host applies as far as it can and ignores the rest of — + * an unnarrowed page is a wider answer rather than a wrong one, and the caller narrows + * what it gets for the fields a row carries. + */ + readonly filters?: PullRequestListFilters | undefined; + }, + ) => Effect.Effect; + + /** + * The same listing for a whole host in one request, for a host that has a search across + * repositories. Optional: three of the four hosts here have no such API, and breaking the port + * for them to spare GitHub a fan-out would be paying for the fix with everyone else's clarity. + * The caller falls back to `listChangeRequests` per repository where this is absent, and where + * it fails. + * + * `limit` is the whole slice rather than a size per repository, because that is the shape of + * the answer: the newest `limit` rows across every repository named, which is exactly the rows + * a page ordered by update shows. + * + * `cursor` is one boundary for all of them, so a caller with repositories standing at different + * boundaries asks in groups rather than in one call. + */ + readonly listChangeRequestsAcross?: (input: { + /** Any checkout on the host, which is what the tool is run in. */ + readonly cwd: string; + readonly host: string; + readonly repositories: ReadonlyArray; + readonly state: PullRequestListState; + readonly involvement: PullRequestInvolvement; + readonly viewer: string; + readonly limit: number; + readonly query?: string | undefined; + readonly cursor?: ProviderListCursor | undefined; + readonly filters?: PullRequestListFilters | undefined; + }) => Effect.Effect; + + /** + * The line counts for rows a listing has already handed over. Only implemented by a provider + * whose listing leaves them out — for everyone else the numbers arrived with the row, and the + * caller has nothing to ask for. + */ + readonly listChangeRequestStats?: (input: { + readonly cwd: string; + readonly host: string; + readonly changeRequests: ReadonlyArray<{ + readonly repository: string; + readonly number: number; + }>; + }) => Effect.Effect, PullRequestProviderError>; + + readonly getChangeRequestChecks?: ( + input: ProviderRepositoryRef & { readonly number: number }, + ) => Effect.Effect; + + readonly getChangeRequest: ( + input: ProviderRepositoryRef & { readonly number: number }, + ) => Effect.Effect; + + /** Hosts without a narrow read use their existing detail response for hover cards. */ + readonly getChangeRequestPreview?: ( + input: ProviderRepositoryRef & { readonly number: number }, + ) => Effect.Effect< + Omit, + PullRequestProviderError + >; + + /** + * The cheap live fields used by linked threads. Optional because a provider without a narrow + * endpoint can fall back to its full detail read at the service boundary. + */ + readonly getChangeRequestSummary?: ( + input: ProviderRepositoryRef & { readonly number: number }, + ) => Effect.Effect; + + /** + * A cheap fingerprint of what a pull request watch reports, so it reads the change request in + * full only when this moves. Null when the host gave no answer for it. Optional: a host + * without one has its watched change requests read in full on every pass. + */ + readonly getChangeRequestWatchFingerprint?: ( + input: ProviderRepositoryRef & { readonly number: number }, + ) => Effect.Effect; + + /** + * The host-native stack a change request belongs to, or null when it is not stacked. Optional + * because most hosts have no such object; the service derives chains from base branches there. + */ + readonly getChangeRequestStack?: ( + input: ProviderRepositoryRef & { readonly includeDetails?: boolean; readonly number: number }, + ) => Effect.Effect; + + /** Comments, line threads, and commits, kept off the critical path for the core detail. */ + readonly getChangeRequestActivity: ( + input: ProviderRepositoryRef & { readonly number: number }, + ) => Effect.Effect; + + /** One explicit page after a reader asks to continue an unfinished review thread. */ + readonly getReviewThreadComments?: ( + input: ProviderRepositoryRef & { + readonly number: number; + readonly threadId: string; + readonly cursor: string; + }, + ) => Effect.Effect; + + /** + * The same answer `getChangeRequest` carries, on its own. Asked before anything is written, so + * a request that reached the server without going past the page is refused by what the host + * says rather than by what the client claimed — and asked freshly, because access granted or + * taken away since the page loaded is exactly the case this guards. + * + * Implementations read the cheapest thing that answers it, which for a host with nothing to say + * is no request at all. + */ + readonly getViewerPermissions: ( + input: ProviderRepositoryRef & { + readonly number: number; + /** Skip branch comparison when checking permission for an unrelated operation. */ + readonly includeUpdateBranch?: boolean; + }, + ) => Effect.Effect; + + /** + * One slice of the patch. Only called when `capabilities.diff` is true. A provider that can + * serve the whole diff at once answers with `nextCursor: null` and is done; one that pages + * hands back whatever it needs to find the next slice. + */ + readonly getDiff: ( + input: ProviderRepositoryRef & { + readonly number: number; + readonly cursor?: string | undefined; + /** One commit's own changes, rather than everything the change request carries. */ + readonly commit?: string | undefined; + }, + ) => Effect.Effect; + + /** + * Full files at the exact revisions the host used for its patch. Optional where the provider + * exposes no diff at all; the service refuses expansion there just as it refuses the patch. + */ + readonly getDiffFileContents?: ( + input: ProviderRepositoryRef & { + readonly number: number; + readonly commit?: string | undefined; + readonly changeType: "change" | "rename-pure" | "rename-changed" | "new" | "deleted"; + readonly oldPath: string; + readonly newPath: string; + }, + ) => Effect.Effect; + + /** + * Which files the reader has already cleared. Only called when `capabilities.viewedFiles` is + * `"host"`, and read apart from the patch: this moves with every press rather than every push. + */ + readonly getFilesViewed?: ( + input: ProviderRepositoryRef & { readonly number: number }, + ) => Effect.Effect; + + /** + * Clears files, or puts them back. Only called when `capabilities.viewedFiles` is `"host"`. + * A host with no bulk form is still owed one round trip for the batch rather than one per file, + * since the point of gathering presses is that the host is asked once. + */ + readonly setFilesViewed?: ( + input: ProviderRepositoryRef & { + readonly number: number; + readonly files: ReadonlyArray<{ readonly path: string; readonly viewed: boolean }>; + }, + ) => Effect.Effect; + + /** + * What version the head has of each of these files. Required of a host whose + * `capabilities.viewedFiles` is `"environment"`, and unused by one that keeps the marks itself: + * the marks live here, but only the host can say whether what a reader cleared last week is + * still what is in front of them. Asked for the marked paths alone, so the cost follows how + * much of the change request has been read rather than how large it is. + */ + readonly getFileRevisions?: ( + input: ProviderRepositoryRef & { + readonly number: number; + readonly paths: ReadonlyArray; + }, + ) => Effect.Effect; + + readonly runAction: ( + input: ProviderRepositoryRef & { + readonly number: number; + readonly action: PullRequestAction; + readonly stackNumber?: number; + readonly expectedStackHeads?: ReadonlyArray; + /** GitHub merge message cleanup; ignored by hosts without support. */ + readonly removeAgentCreditsOnMerge?: boolean; + /** Meaningful for `merge` and `enable-auto-merge`; absent takes the host's own default. */ + readonly mergeMethod?: PullRequestMergeMethod; + /** Only meaningful for `update-branch`; absent takes the host's own default. */ + readonly updateMethod?: PullRequestUpdateMethod; + }, + ) => Effect.Effect; + + /** + * Rewrites the change request's own words. Only called when `capabilities.edit.changeRequest` + * is true, and never with both fields absent — the caller refuses that before it gets here, + * because a host asked to change nothing answers differently on each of them. + */ + readonly updateChangeRequest?: ( + input: ProviderRepositoryRef & { + readonly number: number; + readonly title?: string | undefined; + readonly body?: string | undefined; + }, + ) => Effect.Effect; + + readonly comment: ( + input: ProviderRepositoryRef & { readonly number: number; readonly body: string }, + ) => Effect.Effect; + + /** + * Rewrites a remark somebody already posted. Only called when `capabilities.edit.comment` is + * true, with an id exactly as the conversation carried it. + * + * Whether this remark is the reader's to rewrite is the host's own answer: no read here can + * settle it, since access can be taken away between the conversation being read and the + * rewrite being sent, and a host refuses a stranger's remark with a sentence saying so. + */ + readonly updateComment?: ( + input: ProviderRepositoryRef & { + readonly number: number; + readonly commentId: string; + readonly kind: "issue-comment" | "review-comment"; + readonly body: string; + }, + ) => Effect.Effect; + + /** + * Sends a whole review at once. Only called for a verdict the host declared in + * `capabilities.review.verdicts`, and with line comments only where it declared + * `inlineComment`. + */ + readonly submitReview: ( + input: ProviderRepositoryRef & { + readonly number: number; + readonly verdict: PullRequestReviewVerdict; + readonly body: string; + readonly comments: ReadonlyArray; + }, + ) => Effect.Effect; + + /** + * The people this viewer may ask for a review, with whoever has already been asked marked as + * such. Only called when `capabilities.reviewers.listCandidates` is true. + * + * The author is left out by each provider rather than by the caller, because only the provider + * knows how the host spells the same person in a candidate list and on a pull request. + */ + readonly listReviewerCandidates: ( + input: ProviderRepositoryRef & { readonly number: number }, + ) => Effect.Effect; + + /** + * Asks for a review, or takes the request back. Only called when + * `capabilities.reviewers.request` is true. + * + * One call for both directions, because that is what every host does with them: GitHub posts and + * deletes the same collection, and GitLab and Bitbucket write the whole reviewer set either way. + * Asking again somebody who has already reviewed is a request like any other — which is how a + * re-request is made. + */ + readonly setReviewerRequest: ( + input: ProviderRepositoryRef & { + readonly number: number; + readonly reviewers: ReadonlyArray<{ + readonly id: string; + readonly kind: PullRequestReviewerKind; + }>; + readonly requested: boolean; + }, + ) => Effect.Effect; + + /** + * The repository's labels, with the ones already on the change request marked. Present with + * `setLabels` only where `capabilities.labels` is true; the service refuses both without it. + */ + readonly listLabelCandidates?: ( + input: ProviderRepositoryRef & { readonly number: number }, + ) => Effect.Effect; + + /** Puts labels on the change request, or takes them off. One call for both directions. */ + readonly setLabels?: ( + input: ProviderRepositoryRef & { + readonly number: number; + readonly labels: ReadonlyArray; + readonly applied: boolean; + }, + ) => Effect.Effect; + + /** Only called when `capabilities.review.reply` is true. */ + readonly replyToThread: ( + input: ProviderRepositoryRef & { + readonly number: number; + readonly threadId: string; + readonly body: string; + }, + ) => Effect.Effect; + + /** + * Adds a reaction, or takes it back. Only called when `capabilities.reactions` is true. + * + * `subjectId` is a remark's id as the conversation carried it; absent means the change request + * itself, whose reactions sit on its description. Whatever a host needs to address either of + * them is worked out here, because the id a conversation travels with is the one the reader has. + */ + readonly setReaction: ( + input: ProviderRepositoryRef & { + readonly number: number; + readonly subjectId?: string | undefined; + readonly content: PullRequestReactionContent; + readonly reacted: boolean; + }, + ) => Effect.Effect; + + /** Only called when `capabilities.review.resolve` is true. */ + readonly setThreadResolution: ( + input: ProviderRepositoryRef & { + readonly number: number; + readonly threadId: string; + readonly resolved: boolean; + }, + ) => Effect.Effect; +} diff --git a/packages/source-control-core/src/server/SourceControlHost.ts b/packages/source-control-core/src/server/SourceControlHost.ts new file mode 100644 index 0000000000000000000000000000000000000000..d779f86fe0e2019251667ba9b3a9d40781dd6494 --- /dev/null +++ b/packages/source-control-core/src/server/SourceControlHost.ts @@ -0,0 +1,59 @@ +/** + * SourceControlHost — what a source control provider package may ask of the server it runs in. + * + * Providers run inside a T3 server but must not import it. The server provides this one + * service; everything a provider needs from its environment (settings, the process runner) + * goes through it, so a provider package depends only on `@t3tools/source-control-core` and + * its own API and CLI code. HTTP, the filesystem, and paths come from Effect's platform + * services directly. + * + * @module source-control-core/server/SourceControlHost + */ +import type { ServerSettings, ServerSettingsError, VcsError } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; + +/** One CLI invocation. The server bounds concurrency, output, and time for every run. */ +export interface SourceControlProcessInput { + readonly operation: string; + readonly command: string; + readonly args: ReadonlyArray; + readonly cwd: string; + readonly spawnCwd?: string; + readonly stdin?: string; + readonly onStdoutChunk?: (chunk: Uint8Array) => void; + readonly env?: NodeJS.ProcessEnv; + readonly allowNonZeroExit?: boolean; + readonly timeoutMs?: number; + readonly maxOutputBytes?: number; + /** What happens past `maxOutputBytes`: fail the run, or keep the first bytes. */ + readonly outputMode?: "error" | "truncate" | undefined; + readonly appendTruncationMarker?: boolean; +} + +export interface SourceControlProcessOutput { + readonly exitCode: ChildProcessSpawner.ExitCode; + readonly stdout: string; + readonly stderr: string; + readonly stdoutTruncated: boolean; + readonly stderrTruncated: boolean; + /** Present on real process output; optional so narrow test doubles remain lightweight. */ + readonly stdoutInvalidUtf8?: boolean; + readonly stderrInvalidUtf8?: boolean; +} + +export class SourceControlHost extends Context.Service< + SourceControlHost, + { + readonly settings: { + /** Read fresh on each call, so a credential saved in Settings applies without a restart. */ + readonly get: Effect.Effect; + }; + readonly process: { + readonly run: ( + input: SourceControlProcessInput, + ) => Effect.Effect; + }; + } +>()("@t3tools/source-control-core/server/SourceControlHost") {} diff --git a/packages/source-control-core/src/server/SourceControlProvider.test.ts b/packages/source-control-core/src/server/SourceControlProvider.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..7e53248827998083ef242aa9bb355df32f68ff9d --- /dev/null +++ b/packages/source-control-core/src/server/SourceControlProvider.test.ts @@ -0,0 +1,19 @@ +import { assert, it } from "@effect/vitest"; + +import { transportSafeSourceControlErrorValue } from "./SourceControlProvider.ts"; + +it("removes URL credentials, query parameters, and fragments from error transport values", () => { + assert.strictEqual( + transportSafeSourceControlErrorValue( + "https://user:secret@example.test/org/repo/pull/42?token=secret#discussion", + ), + "https://example.test/org/repo/pull/42", + ); +}); + +it("normalizes control characters and bounds error transport values", () => { + assert.strictEqual( + transportSafeSourceControlErrorValue(` owner/repo\n\t${"x".repeat(300)} `), + `owner/repo ${"x".repeat(245)}`, + ); +}); diff --git a/packages/source-control-core/src/server/SourceControlProvider.ts b/packages/source-control-core/src/server/SourceControlProvider.ts new file mode 100644 index 0000000000000000000000000000000000000000..2bcabceb00e349262672352bc65f76d835335fc9 --- /dev/null +++ b/packages/source-control-core/src/server/SourceControlProvider.ts @@ -0,0 +1,145 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import type { + ChangeRequest, + ChangeRequestState, + SourceControlProviderError, + SourceControlProviderInfo, + SourceControlProviderKind, + SourceControlRepositoryCloneUrls, + SourceControlRepositoryVisibility, +} from "@t3tools/contracts"; + +export interface SourceControlLinkSubject { + readonly title: string; + readonly body: string | null; +} + +/** Return undefined synchronously for unsupported URLs, without starting a lookup. */ +export type ResolveSourceControlLink = (input: { + readonly cwd: string; + readonly url: URL; +}) => Effect.Effect | undefined; + +export interface SourceControlProviderContext { + readonly provider: SourceControlProviderInfo; + readonly remoteName: string; + readonly remoteUrl: string; + /** An explicit web authority can disambiguate Forgejo logins sharing an SSH alias. */ + readonly requestedHost?: string; +} + +export interface SourceControlRefSelector { + readonly refName: string; + readonly owner?: string; + readonly repository?: string; +} + +const MAX_ERROR_TRANSPORT_VALUE_LENGTH = 256; + +/** + * Sanitizes user-provided source-control identifiers before attaching them to + * contract errors. This is intentionally narrower than request validation: it + * only strips URL secrets and bounds diagnostic values sent over transport. + */ +export function transportSafeSourceControlErrorValue(value: string): string { + let printable = ""; + for (const character of value) { + const codePoint = character.codePointAt(0); + printable += codePoint !== undefined && (codePoint < 32 || codePoint === 127) ? " " : character; + } + const normalized = printable.trim().replace(/\s+/gu, " "); + + let safe = normalized; + try { + const url = new URL(normalized); + url.username = ""; + url.password = ""; + url.search = ""; + url.hash = ""; + safe = url.toString(); + } catch { + // Plain repository and change-request identifiers are not URLs. + } + + return safe.slice(0, MAX_ERROR_TRANSPORT_VALUE_LENGTH); +} + +export function parseSourceControlOwnerRef( + headSelector: string, +): SourceControlRefSelector | undefined { + const match = /^([^:/\s]+):(.+)$/u.exec(headSelector.trim()); + const owner = match?.[1]?.trim(); + const refName = match?.[2]?.trim(); + return owner && refName ? { owner, refName } : undefined; +} + +function normalizeSourceBranch(headSelector: string): string { + return parseSourceControlOwnerRef(headSelector)?.refName ?? headSelector.trim(); +} + +export function sourceBranch(input: { + readonly headSelector: string; + readonly source?: SourceControlRefSelector; +}): string { + return input.source?.refName ?? normalizeSourceBranch(input.headSelector); +} + +export function sourceControlRefFromInput(input: { + readonly headSelector: string; + readonly source?: SourceControlRefSelector; +}): SourceControlRefSelector | undefined { + return input.source ?? parseSourceControlOwnerRef(input.headSelector); +} + +export class SourceControlProvider extends Context.Service< + SourceControlProvider, + { + readonly kind: SourceControlProviderKind; + /** Optional capability for issue and change-request subjects. */ + readonly resolveLink?: ResolveSourceControlLink; + readonly listChangeRequests: (input: { + readonly cwd: string; + readonly context?: SourceControlProviderContext; + readonly source?: SourceControlRefSelector; + readonly headSelector: string; + readonly state: ChangeRequestState | "all"; + readonly limit?: number; + }) => Effect.Effect, SourceControlProviderError>; + readonly getChangeRequest: (input: { + readonly cwd: string; + readonly context?: SourceControlProviderContext; + readonly reference: string; + }) => Effect.Effect; + readonly createChangeRequest: (input: { + readonly cwd: string; + readonly context?: SourceControlProviderContext; + readonly source?: SourceControlRefSelector; + readonly target?: SourceControlRefSelector; + readonly baseRefName: string; + readonly headSelector: string; + readonly title: string; + readonly bodyFile: string; + }) => Effect.Effect; + readonly getRepositoryCloneUrls: (input: { + readonly cwd: string; + readonly context?: SourceControlProviderContext; + readonly repository: string; + }) => Effect.Effect; + readonly createRepository: (input: { + readonly cwd: string; + readonly repository: string; + readonly visibility: SourceControlRepositoryVisibility; + }) => Effect.Effect; + readonly getDefaultBranch: (input: { + readonly cwd: string; + readonly context?: SourceControlProviderContext; + }) => Effect.Effect; + readonly checkoutChangeRequest: (input: { + readonly cwd: string; + readonly context?: SourceControlProviderContext; + readonly reference: string; + readonly force?: boolean; + }) => Effect.Effect; + } +>()("@t3tools/source-control-core/server/SourceControlProvider") {} diff --git a/packages/source-control-core/src/server/SourceControlRateLimit.test.ts b/packages/source-control-core/src/server/SourceControlRateLimit.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..e46005c73f0e73cd34783832037ed8a59504e190 --- /dev/null +++ b/packages/source-control-core/src/server/SourceControlRateLimit.test.ts @@ -0,0 +1,138 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as TestClock from "effect/testing/TestClock"; + +import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; + +const github = { provider: "github" as const, host: "github.com" }; + +it.effect("isolates cooldowns for verified credentials on the same host", () => + Effect.gen(function* () { + const limits = yield* SourceControlRateLimit.SourceControlRateLimit; + yield* limits + .recordRateLimit({ ...github, lease: 0 }) + .pipe(Effect.provideService(SourceControlRateLimit.CredentialScope, "first")); + yield* limits + .check(github) + .pipe(Effect.provideService(SourceControlRateLimit.CredentialScope, "second")); + const error = yield* limits + .check(github) + .pipe(Effect.provideService(SourceControlRateLimit.CredentialScope, "first"), Effect.flip); + assert.strictEqual(error._tag, "SourceControlRateLimitPausedError"); + }).pipe(Effect.provide(SourceControlRateLimit.layer)), +); + +it("parses Retry-After seconds and HTTP dates", () => { + assert.equal(SourceControlRateLimit.retryAtFromHeader("120", 1_000), 121_000); + assert.equal( + SourceControlRateLimit.retryAtFromHeader("Thu, 01 Jan 1970 00:02:01 GMT", 1_000), + 121_000, + ); + assert.isUndefined(SourceControlRateLimit.retryAtFromHeader("later", 1_000)); +}); + +it.effect("backs off repeated rate limits until a successful request", () => + Effect.gen(function* () { + yield* TestClock.setTime(0); + const limits = yield* SourceControlRateLimit.SourceControlRateLimit; + + const firstLease = yield* limits.check(github); + yield* limits.recordRateLimit({ ...github, lease: firstLease }); + const firstPause = yield* Effect.flip(limits.check(github)); + assert.deepInclude(firstPause, { + _tag: "SourceControlRateLimitPausedError", + provider: "github", + host: "github.com", + retryAt: 30_000, + }); + assert.equal( + firstPause.detail, + "github requests to github.com are paused until the rate limit resets.", + ); + assert.equal(firstPause.message, firstPause.detail); + + yield* TestClock.adjust("30 seconds"); + const secondLease = yield* limits.check(github); + yield* limits.recordRateLimit({ ...github, lease: secondLease }); + assert.equal((yield* Effect.flip(limits.check(github))).retryAt, 90_000); + + yield* TestClock.adjust("60 seconds"); + const successLease = yield* limits.check(github); + yield* limits.recordSuccess({ ...github, lease: successLease }); + const resetLease = yield* limits.check(github); + yield* limits.recordRateLimit({ ...github, lease: resetLease }); + assert.equal((yield* Effect.flip(limits.check(github))).retryAt, 120_000); + }).pipe(Effect.provide(SourceControlRateLimit.layer)), +); + +it.effect("honors a provider reset time", () => + Effect.gen(function* () { + yield* TestClock.setTime(1_000); + const limits = yield* SourceControlRateLimit.SourceControlRateLimit; + const lease = yield* limits.check(github); + + yield* limits.recordRateLimit({ ...github, lease, retryAt: 121_000 }); + assert.equal((yield* Effect.flip(limits.check(github))).retryAt, 121_000); + + yield* TestClock.adjust("119999 millis"); + assert.equal((yield* Effect.flip(limits.check(github))).retryAt, 121_000); + yield* TestClock.adjust("1 millis"); + assert.equal(yield* limits.check(github), 1); + }).pipe(Effect.provide(SourceControlRateLimit.layer)), +); + +it.effect("lets an interactive request through without clearing an active pause", () => + Effect.gen(function* () { + yield* TestClock.setTime(1_000); + const limits = yield* SourceControlRateLimit.SourceControlRateLimit; + const lease = yield* limits.check(github); + yield* limits.recordRateLimit({ ...github, lease, retryAt: 121_000 }); + + const interactiveLease = yield* limits.check(github, { allowPaused: true }); + yield* limits.recordSuccess({ ...github, lease: interactiveLease }); + + assert.equal(interactiveLease, 1); + assert.equal((yield* Effect.flip(limits.check(github))).retryAt, 121_000); + + yield* limits.recordRateLimit({ ...github, lease: interactiveLease, retryAt: 61_000 }); + assert.equal((yield* Effect.flip(limits.check(github))).retryAt, 121_000); + }).pipe(Effect.provide(SourceControlRateLimit.layer)), +); + +it.effect("keeps providers and hosts isolated", () => + Effect.gen(function* () { + const limits = yield* SourceControlRateLimit.SourceControlRateLimit; + const lease = yield* limits.check(github); + yield* limits.recordRateLimit({ ...github, lease }); + + assert.equal(yield* limits.check({ provider: "gitlab", host: "github.com" }), 0); + assert.equal(yield* limits.check({ provider: "github", host: "github.example.com" }), 0); + }).pipe(Effect.provide(SourceControlRateLimit.layer)), +); + +it.effect("does not let an older success clear a concurrent pause", () => + Effect.gen(function* () { + const limits = yield* SourceControlRateLimit.SourceControlRateLimit; + const firstLease = yield* limits.check(github); + const concurrentLease = yield* limits.check(github); + + yield* limits.recordRateLimit({ ...github, lease: firstLease }); + yield* limits.recordSuccess({ ...github, lease: concurrentLease }); + + assert.equal((yield* Effect.flip(limits.check(github))).retryAt, 30_000); + }).pipe(Effect.provide(SourceControlRateLimit.layer)), +); + +it.effect("keeps a fresh provider reset from an older request", () => + Effect.gen(function* () { + yield* TestClock.setTime(0); + const limits = yield* SourceControlRateLimit.SourceControlRateLimit; + const staleLease = yield* limits.check(github); + yield* limits.recordRateLimit({ ...github, lease: staleLease }); + + yield* TestClock.adjust("31 seconds"); + yield* limits.recordRateLimit({ ...github, lease: staleLease, retryAt: 61_000 }); + + assert.equal((yield* Effect.flip(limits.check(github))).retryAt, 61_000); + }).pipe(Effect.provide(SourceControlRateLimit.layer)), +); diff --git a/packages/source-control-core/src/server/SourceControlRateLimit.ts b/packages/source-control-core/src/server/SourceControlRateLimit.ts new file mode 100644 index 0000000000000000000000000000000000000000..f2ef07dd2529bc9fb453219371cd87cf4fcc7884 --- /dev/null +++ b/packages/source-control-core/src/server/SourceControlRateLimit.ts @@ -0,0 +1,171 @@ +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import { + SourceControlProviderKind as SourceControlProviderKindSchema, + type SourceControlProviderKind, +} from "@t3tools/contracts"; + +const FALLBACK_COOLDOWN = Duration.seconds(30); +const MAX_FALLBACK_COOLDOWN = Duration.minutes(15); + +export const CredentialScope = Context.Reference( + "@t3tools/source-control-core/server/SourceControlRateLimit/CredentialScope", + { + defaultValue: () => "", + }, +); + +interface RateLimitKey { + readonly provider: SourceControlProviderKind; + readonly host: string; +} + +interface RateLimitLease extends RateLimitKey { + readonly lease: number; +} + +interface RateLimitEntry { + readonly attempt: number; + readonly generation: number; + readonly retryAt: number; +} + +export class SourceControlRateLimitPausedError extends Schema.TaggedError()( + "SourceControlRateLimitPausedError", + { + provider: SourceControlProviderKindSchema, + host: Schema.String, + retryAt: Schema.Number, + }, +) { + get detail(): string { + return `${this.provider} requests to ${this.host} are paused until the rate limit resets.`; + } + + override get message(): string { + return this.detail; + } +} + +export class SourceControlRateLimit extends Context.Service< + SourceControlRateLimit, + { + readonly check: ( + key: RateLimitKey, + options?: { readonly allowPaused: boolean }, + ) => Effect.Effect; + readonly recordRateLimit: ( + input: RateLimitLease & { readonly retryAt?: number | undefined }, + ) => Effect.Effect; + readonly recordSuccess: (input: RateLimitLease) => Effect.Effect; + } +>()("@t3tools/source-control-core/server/SourceControlRateLimit") {} + +function normalizedKey(key: RateLimitKey, scope: string): string { + return `${key.provider}\0${key.host.trim().toLowerCase()}\0${scope}`; +} + +function fallbackCooldownMs(attempt: number): number { + return Math.min( + Duration.toMillis(FALLBACK_COOLDOWN) * 2 ** Math.max(0, attempt - 1), + Duration.toMillis(MAX_FALLBACK_COOLDOWN), + ); +} + +export function retryAtFromHeader(value: string | undefined, now: number): number | undefined { + if (value === undefined) return undefined; + const normalized = value.trim(); + if (/^\d+$/u.test(normalized)) { + const seconds = Number(normalized); + const retryAt = now + seconds * 1_000; + return Number.isSafeInteger(retryAt) ? retryAt : undefined; + } + const retryAt = Date.parse(normalized); + return Number.isFinite(retryAt) && retryAt > now ? retryAt : undefined; +} + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const entries = yield* Ref.make>(new Map()); + + const check: SourceControlRateLimit["Service"]["check"] = Effect.fn( + "SourceControlRateLimit.check", + )(function* (input, options) { + const now = yield* Clock.currentTimeMillis; + const key = normalizedKey(input, yield* CredentialScope); + const entry = (yield* Ref.get(entries)).get(key); + if (entry !== undefined && entry.retryAt > now && options?.allowPaused !== true) { + return yield* new SourceControlRateLimitPausedError({ + provider: input.provider, + host: input.host.trim().toLowerCase(), + retryAt: entry.retryAt, + }); + } + return entry?.generation ?? 0; + }); + + const recordRateLimit: SourceControlRateLimit["Service"]["recordRateLimit"] = Effect.fn( + "SourceControlRateLimit.recordRateLimit", + )(function* (input) { + const now = yield* Clock.currentTimeMillis; + const key = normalizedKey(input, yield* CredentialScope); + yield* Ref.update(entries, (current) => { + const previous = current.get(key); + if (previous !== undefined && previous.generation > input.lease) { + if (previous.retryAt <= now && (input.retryAt === undefined || input.retryAt <= now)) { + return current; + } + const retryAt = + input.retryAt !== undefined && input.retryAt > previous.retryAt + ? input.retryAt + : previous.retryAt; + if (retryAt === previous.retryAt) return current; + const next = new Map(current); + next.set(key, { ...previous, retryAt }); + return next; + } + + const attempt = (previous?.attempt ?? 0) + 1; + const proposedRetryAt = + input.retryAt !== undefined && input.retryAt > now + ? input.retryAt + : now + fallbackCooldownMs(attempt); + const retryAt = + previous !== undefined && previous.retryAt > now + ? Math.max(previous.retryAt, proposedRetryAt) + : proposedRetryAt; + const next = new Map(current); + next.set(key, { + attempt, + generation: Math.max(previous?.generation ?? 0, input.lease) + 1, + retryAt, + }); + return next; + }); + }); + + const recordSuccess: SourceControlRateLimit["Service"]["recordSuccess"] = Effect.fn( + "SourceControlRateLimit.recordSuccess", + )(function* (input) { + const now = yield* Clock.currentTimeMillis; + const key = normalizedKey(input, yield* CredentialScope); + yield* Ref.update(entries, (current) => { + const previous = current.get(key); + if (previous === undefined || previous.generation !== input.lease || previous.retryAt > now) { + return current; + } + const next = new Map(current); + next.set(key, { attempt: 0, generation: previous.generation, retryAt: 0 }); + return next; + }); + }); + + return SourceControlRateLimit.of({ check, recordRateLimit, recordSuccess }); +}); + +export const layer = Layer.effect(SourceControlRateLimit, make); diff --git a/packages/source-control-core/src/server/discovery.ts b/packages/source-control-core/src/server/discovery.ts new file mode 100644 index 0000000000000000000000000000000000000000..70d26b4a963c165d3edd877f08ab485d5f7b0bea --- /dev/null +++ b/packages/source-control-core/src/server/discovery.ts @@ -0,0 +1,340 @@ +/** + * Discovery specs: how each provider reports its CLI or API, and the shared probe that runs them. + * + * @module source-control-core/server/discovery + */ +import * as NodeUtil from "node:util"; +import type { + SourceControlProviderAuth, + SourceControlProviderDiscoveryItem, + SourceControlProviderInfo, + SourceControlProviderKind, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; + +import type * as SourceControlProvider from "./SourceControlProvider.ts"; +import type * as SourceControlHost from "./SourceControlHost.ts"; + +export interface SourceControlAuthProbeInput { + readonly stdout: string; + readonly stderr: string; + readonly exitCode: SourceControlHost.SourceControlProcessOutput["exitCode"]; +} + +export interface SourceControlUnknownRemoteRefinementInput { + readonly cwd: string; + readonly context: SourceControlProvider.SourceControlProviderContext; + readonly auth: SourceControlAuthProbeInput; +} + +interface SourceControlDiscoverySpecBase { + readonly kind: SourceControlProviderKind; + readonly label: string; + readonly installHint: string; +} + +export type SourceControlCliDiscoverySpec = SourceControlDiscoverySpecBase & { + readonly type: "cli"; + readonly executable: string; + readonly versionArgs: ReadonlyArray; + readonly authArgs: ReadonlyArray; + readonly remoteRefinementArgs?: ReadonlyArray; + readonly probeTimeoutMs?: number; + readonly parseAuth: (input: SourceControlAuthProbeInput) => SourceControlProviderAuth; + readonly refineUnknownRemote?: ( + input: SourceControlUnknownRemoteRefinementInput, + ) => SourceControlProviderInfo | null; +}; + +export type SourceControlApiDiscoverySpec = SourceControlDiscoverySpecBase & { + readonly type: "api"; + readonly probeAuth: Effect.Effect; +}; + +export type SourceControlManagedCliDiscoverySpec = SourceControlDiscoverySpecBase & { + readonly type: "managed-cli"; + readonly probe: (cwd: string) => Effect.Effect; + readonly refineUnknownRemote: (input: { + readonly cwd: string; + readonly context: SourceControlProvider.SourceControlProviderContext; + }) => Effect.Effect; +}; + +export type SourceControlProviderDiscoverySpec = + | SourceControlCliDiscoverySpec + | SourceControlManagedCliDiscoverySpec + | SourceControlApiDiscoverySpec; + +type SourceControlCliRemoteRefinementSpec = SourceControlCliDiscoverySpec & { + readonly refineUnknownRemote: NonNullable; +}; + +// Most provider CLIs answer `--version` in well under a second, so a short budget keeps +// discovery snappy. Specs whose CLI is known to be slower can raise it via probeTimeoutMs. +const DEFAULT_PROBE_TIMEOUT_MS = 5_000; + +function probeTimeoutMs(spec: SourceControlCliDiscoverySpec): number { + return spec.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS; +} + +interface DiscoveryProbeResult { + readonly kind: SourceControlProviderKind; + readonly label: string; + readonly executable: string; + readonly status: "available" | "missing"; + readonly version: Option.Option; + readonly installHint: string; + readonly detail: Option.Option; +} + +export function firstNonEmptyLine(text: string): Option.Option { + const line = NodeUtil.stripVTControlCharacters(text) + .split(/\r?\n/) + .map((entry) => entry.trim()) + .find((entry) => entry.length > 0); + return line === undefined ? Option.none() : Option.some(line); +} + +export function detailFromCause(cause: unknown): Option.Option { + if (cause instanceof Error && cause.message.trim().length > 0) { + return Option.some(cause.message.trim()); + } + return Option.none(); +} + +function authAccount(account: string | undefined): Option.Option { + const trimmed = account?.trim(); + return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed); +} + +function authHost(host: string | undefined): Option.Option { + const trimmed = host?.trim(); + return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed); +} + +function authDetail(detail: string | undefined): Option.Option { + const trimmed = detail?.trim(); + return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed); +} + +export function providerAuth(input: { + readonly status: SourceControlProviderAuth["status"]; + readonly account?: string | undefined; + readonly host?: string | undefined; + readonly detail?: string | undefined; +}): SourceControlProviderAuth { + return { + status: input.status, + account: authAccount(input.account), + host: authHost(input.host), + detail: authDetail(input.detail), + }; +} + +function unknownAuth(detail?: string): SourceControlProviderAuth { + return providerAuth({ status: "unknown", detail }); +} + +export function combinedAuthOutput(input: SourceControlAuthProbeInput): string { + const parts: string[] = []; + for (const entry of [input.stdout, input.stderr]) { + if (entry.trim().length > 0) { + parts.push(entry); + } + } + return parts.join("\n"); +} + +function sanitizedAuthLines(text: string): ReadonlyArray { + const lines: string[] = []; + for (const entry of text.split(/\r?\n/)) { + const line = entry.trim(); + if (line.length === 0) continue; + if (/^[-\s]*token(?:\s+scopes?)?:/iu.test(line)) continue; + lines.push(line); + } + return lines; +} + +export function firstSafeAuthLine(text: string): string | undefined { + return sanitizedAuthLines(text)[0]; +} + +export function parseCliHost(text: string): string | undefined { + return sanitizedAuthLines(text) + .map((line) => line.replace(/^[^a-z0-9]+/iu, "")) + .find((line) => /^[a-z0-9][a-z0-9.-]*(?::\d+)?$/iu.test(line)); +} + +export function matchFirst(text: string, patterns: ReadonlyArray): string | undefined { + for (const pattern of patterns) { + const match = pattern.exec(text); + const value = match?.[1]?.trim(); + if (value && value.length > 0) return value; + } + return undefined; +} + +function isCliRemoteRefinementSpec( + spec: SourceControlProviderDiscoverySpec, +): spec is SourceControlCliRemoteRefinementSpec { + return spec.type === "cli" && spec.refineUnknownRemote !== undefined; +} + +function probeCli(input: { + readonly spec: SourceControlCliDiscoverySpec; + readonly process: SourceControlHost.SourceControlHost["Service"]["process"]; + readonly cwd: string; +}): Effect.Effect { + return input.process + .run({ + operation: "source-control.discovery.probe", + command: input.spec.executable, + args: input.spec.versionArgs, + cwd: input.cwd, + timeoutMs: probeTimeoutMs(input.spec), + maxOutputBytes: 8_000, + appendTruncationMarker: true, + }) + .pipe( + Effect.map( + (result) => + ({ + kind: input.spec.kind, + label: input.spec.label, + executable: input.spec.executable, + status: "available" as const, + version: Option.orElse(firstNonEmptyLine(result.stdout), () => + firstNonEmptyLine(result.stderr), + ), + installHint: input.spec.installHint, + detail: Option.none(), + }) satisfies DiscoveryProbeResult, + ), + Effect.catch((cause) => + Effect.succeed({ + kind: input.spec.kind, + label: input.spec.label, + executable: input.spec.executable, + status: "missing" as const, + version: Option.none(), + installHint: input.spec.installHint, + detail: detailFromCause(cause), + } satisfies DiscoveryProbeResult), + ), + ); +} + +export function probeSourceControlProvider(input: { + readonly spec: SourceControlProviderDiscoverySpec; + readonly process: SourceControlHost.SourceControlHost["Service"]["process"]; + readonly cwd: string; +}): Effect.Effect { + if (input.spec.type === "managed-cli") return input.spec.probe(input.cwd); + if (input.spec.type === "api") { + return input.spec.probeAuth.pipe( + Effect.map( + (auth) => + ({ + kind: input.spec.kind, + label: input.spec.label, + status: "available" as const, + version: Option.none(), + installHint: input.spec.installHint, + detail: Option.none(), + auth, + }) satisfies SourceControlProviderDiscoveryItem, + ), + ); + } + + const spec = input.spec; + + return probeCli({ + spec, + process: input.process, + cwd: input.cwd, + }).pipe( + Effect.flatMap((item) => { + if (item.status !== "available") { + return Effect.succeed({ + ...item, + auth: unknownAuth("Hosting integration command was not found on the server PATH."), + } satisfies SourceControlProviderDiscoveryItem); + } + + return input.process + .run({ + operation: "source-control.discovery.auth", + command: spec.executable, + args: spec.authArgs, + cwd: input.cwd, + allowNonZeroExit: true, + timeoutMs: probeTimeoutMs(spec), + maxOutputBytes: 8_000, + appendTruncationMarker: true, + }) + .pipe( + Effect.map( + (result) => + ({ + ...item, + auth: spec.parseAuth(result), + }) satisfies SourceControlProviderDiscoveryItem, + ), + Effect.catch((cause) => + Effect.succeed({ + ...item, + auth: unknownAuth(Option.getOrUndefined(detailFromCause(cause))), + } satisfies SourceControlProviderDiscoveryItem), + ), + ); + }), + ); +} + +export const refineUnknownRemoteProvider = Effect.fn("refineUnknownRemoteProvider")( + function* (input: { + readonly specs: ReadonlyArray; + readonly process: SourceControlHost.SourceControlHost["Service"]["process"]; + readonly cwd: string; + readonly context: SourceControlProvider.SourceControlProviderContext | null; + }): Effect.fn.Return { + if (input.context === null || input.context.provider.kind !== "unknown") { + return input.context; + } + const context = input.context; + + const providers = yield* Effect.forEach(input.specs, (spec) => { + if (spec.type === "managed-cli") { + return spec.refineUnknownRemote({ cwd: input.cwd, context }); + } + if (!isCliRemoteRefinementSpec(spec)) return Effect.succeed(null); + return input.process + .run({ + operation: "source-control.discovery.refine-unknown-remote", + command: spec.executable, + args: spec.remoteRefinementArgs ?? spec.authArgs, + cwd: input.cwd, + allowNonZeroExit: true, + timeoutMs: probeTimeoutMs(spec), + maxOutputBytes: 8_000, + appendTruncationMarker: true, + }) + .pipe( + Effect.map((auth) => + spec.refineUnknownRemote({ + cwd: input.cwd, + context, + auth, + }), + ), + Effect.orElseSucceed(() => null), + ); + }); + const provider = providers.find((candidate) => candidate !== null); + + return provider ? { ...context, provider } : context; + }, +); diff --git a/packages/source-control-core/src/server/mergeMessage.test.ts b/packages/source-control-core/src/server/mergeMessage.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..2c62473995008c694b77c7f8e3509ec3770acee8 --- /dev/null +++ b/packages/source-control-core/src/server/mergeMessage.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vite-plus/test"; +import { removeAgentCredits } from "./mergeMessage.ts"; + +describe("removeAgentCredits", () => { + it("removes recognized agent trailers and generated footers, keeping human co-authors", () => { + expect( + removeAgentCredits( + [ + "Details about Claude support.", + "", + "", + "Co-authored-by: Alice ", + "Co-authored-by: Claude ", + "* Co-authored-by: Codex ", + "co-authored-by: Cursor ", + "Co-authored-by: Copilot ", + "", + "🤖 Generated with [Claude Code](https://claude.ai/code)", + ].join("\n"), + ), + ).toBe("Details about Claude support.\n\n\nCo-authored-by: Alice "); + }); + + it("preserves unknown identities, humans named after agents, and ordinary prose", () => { + const message = [ + "Co-authored-by: Claude ", + "Co-authored-by: Alice ", + "Co-authored-by: Release Bot ", + "Generated by our new Claude integration.", + "Generated with [Claude Code](https://example.com)", + "", + ].join("\n"); + expect(removeAgentCredits(message)).toBe(message); + }); + + it("preserves code fences and block quotes containing attribution examples", () => { + const example = "Co-authored-by: Claude "; + const message = `Example:\n\n\`\`\`text\n${example}\n\`\`\`\n\n> ${example}\n\n${example}`; + expect(removeAgentCredits(message)).toBe( + `Example:\n\n\`\`\`text\n${example}\n\`\`\`\n\n> ${example}`, + ); + }); + + it("preserves indented examples and whitespace on surviving lines", () => { + const example = "Co-authored-by: Claude "; + expect(removeAgentCredits(`Example:\n\n ${example}\n\n${example}\nDetails \n`)).toBe( + `Example:\n\n ${example}\n\nDetails `, + ); + }); + + it.each(["``` text ```", " ```", "\t~~~"])( + "removes trailers after a line that cannot open a fence: %s", + (line) => { + expect(removeAgentCredits(`${line}\n\nCo-authored-by: Claude `)).toBe( + line, + ); + }, + ); + + it.each(["```", "~~~"])("keeps examples after a false closing fence: %s", (fence) => { + const example = "Co-authored-by: Claude "; + 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`; + expect(removeAgentCredits(`${content}\n\n${example}`)).toBe(content); + }); + + it("preserves CRLF line endings", () => { + expect( + removeAgentCredits( + "Details\r\n\r\nCo-authored-by: Claude \r\nCo-authored-by: Alice ", + ), + ).toBe("Details\r\n\r\nCo-authored-by: Alice "); + }); + + it("returns an empty body when only agent credits remain", () => { + expect( + removeAgentCredits("Co-authored-by: Claude \n\nGenerated by Codex\n"), + ).toBe(""); + }); +}); diff --git a/packages/source-control-core/src/server/mergeMessage.ts b/packages/source-control-core/src/server/mergeMessage.ts new file mode 100644 index 0000000000000000000000000000000000000000..20083b0d8228ef814d651414bcc3d4b97328f269 --- /dev/null +++ b/packages/source-control-core/src/server/mergeMessage.ts @@ -0,0 +1,54 @@ +const AGENT_EMAILS = new Set([ + "noreply@anthropic.com", + "claude@anthropic.com", + "noreply@openai.com", + "codex@openai.com", + "cursoragent@cursor.com", + "copilot@github.com", + "noreply@github.com", +]); + +const GENERATED_BY_AGENT = + /^(?:🤖\s*)?generated (?:with|by)\s+(?:claude(?: code)?|codex|cursor|github copilot|copilot|opencode|grok|antigravity)[.!]?$/i; + +/** Remove standalone agent credits while keeping human signatures and quoted examples. */ +export function removeAgentCredits(message: string): string { + let fence: string | undefined; + let removed = false; + const newline = message.includes("\r\n") ? "\r\n" : "\n"; + const lines = message.split(/\r?\n/).filter((line) => { + if (/^(?: {4}|\t)/.test(line)) return true; + const delimiter = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line); + const marker = delimiter?.[1]; + const remainder = delimiter?.[2] ?? ""; + if (marker !== undefined) { + if (fence === undefined) { + if (marker[0] === "~" || !remainder.includes("`")) fence = marker; + } else if ( + marker[0] === fence[0] && + marker.length >= fence.length && + /^[ \t]*$/.test(remainder) + ) { + fence = undefined; + } + return true; + } + if (fence !== undefined) return true; + const text = line.trim().replace(/^(?:[-*]\s+)/, ""); + const author = /^co-authored-by:\s*(.*?)\s*<([^<>]+)>\s*$/i.exec(text); + // GitHub's generic noreply address is also used by people; require Copilot's name. + const email = author?.[2]?.toLowerCase(); + const agentAuthor = + email !== undefined && + AGENT_EMAILS.has(email) && + (email !== "noreply@github.com" || /^github copilot$|^copilot$/i.test(author![1]!)); + const footer = text.replace( + /\[([^\]]+)\]\(https:\/\/(?:claude\.ai|www\.anthropic\.com|chatgpt\.com|openai\.com|cursor\.com|github\.com|opencode\.ai|x\.ai|antigravity\.google)(?:\/[^\s)]*)?\)/gi, + "$1", + ); + if (!agentAuthor && !GENERATED_BY_AGENT.test(footer)) return true; + removed = true; + return false; + }); + return removed ? lines.join(newline).replace(/(?:\r?\n[ \t]*)+$/, "") : message; +} diff --git a/packages/source-control-core/src/server/pullRequestChecks.test.ts b/packages/source-control-core/src/server/pullRequestChecks.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..ba685083292449d6c8767db975c116c638fb0c5b --- /dev/null +++ b/packages/source-control-core/src/server/pullRequestChecks.test.ts @@ -0,0 +1,93 @@ +import type { PullRequestCheck, PullRequestCheckStatus } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { dedupeChecks } from "./pullRequestChecks.ts"; + +function entry( + name: string, + status: PullRequestCheckStatus, + extra: { readonly workflowName?: string | null; readonly at?: string | null } = {}, +): { + readonly check: PullRequestCheck; + readonly workflowName: string | null; + readonly at: string | null; +} { + return { + check: { name, status, description: null, url: null }, + workflowName: extra.workflowName ?? null, + at: extra.at ?? null, + }; +} + +describe("dedupeChecks", () => { + it("keeps the newest run of a check the host listed twice", () => { + const checks = dedupeChecks([ + entry("Prepare PR size config", "success", { + workflowName: "PR Size", + at: "2026-08-11T16:06:25Z", + }), + entry("Prepare PR size config", "pending", { + workflowName: "PR Size", + at: "2026-08-11T17:01:04Z", + }), + ]); + + expect(checks.map((check) => [check.name, check.status])).toEqual([ + ["Prepare PR size config", "pending"], + ]); + }); + + it("holds a check at the place it first appeared, so a re-run does not reshuffle the list", () => { + const checks = dedupeChecks([ + entry("lint", "success", { workflowName: "CI", at: "2026-08-11T16:00:00Z" }), + entry("test", "success", { workflowName: "CI", at: "2026-08-11T16:00:00Z" }), + entry("lint", "failure", { workflowName: "CI", at: "2026-08-11T18:00:00Z" }), + ]); + + expect(checks.map((check) => [check.name, check.status])).toEqual([ + ["lint", "failure"], + ["test", "success"], + ]); + }); + + it("loses a run that never said when it happened to one that did, whichever came first", () => { + const undated = dedupeChecks([ + entry("build", "success", { at: "2026-08-11T16:00:00Z" }), + entry("build", "pending"), + ]); + const dated = dedupeChecks([ + entry("build", "pending"), + entry("build", "success", { at: "2026-08-11T16:00:00Z" }), + ]); + + expect([undated[0]?.status, dated[0]?.status]).toEqual(["success", "success"]); + }); + + it("takes the last copy when neither run is dated, which is how an update is listed", () => { + const checks = dedupeChecks([entry("build", "pending"), entry("build", "failure")]); + + expect(checks.map((check) => check.status)).toEqual(["failure"]); + }); + + it("keeps two workflows that name a job the same thing, and says which is which", () => { + const checks = dedupeChecks([ + entry("build", "success", { workflowName: "CI", at: "2026-08-11T16:00:00Z" }), + entry("build", "failure", { workflowName: "Release", at: "2026-08-11T16:00:00Z" }), + ]); + + expect(checks.map((check) => [check.name, check.status])).toEqual([ + ["CI / build", "success"], + ["Release / build", "failure"], + ]); + }); + + it("leaves a colliding check with no workflow of its own unqualified", () => { + // An app-provided check run belongs to no workflow, which GitHub reports as an empty name. + const checks = dedupeChecks([ + entry("build", "success", { workflowName: "", at: "2026-08-11T16:00:00Z" }), + entry("build", "failure", { workflowName: "CI", at: "2026-08-11T16:00:00Z" }), + ]); + + expect(checks.map((check) => check.name)).toEqual(["build", "CI / build"]); + }); +}); diff --git a/packages/source-control-core/src/server/pullRequestChecks.ts b/packages/source-control-core/src/server/pullRequestChecks.ts new file mode 100644 index 0000000000000000000000000000000000000000..819cf47b01af485b83769c0c96f37f58d5769ae9 --- /dev/null +++ b/packages/source-control-core/src/server/pullRequestChecks.ts @@ -0,0 +1,55 @@ +import type { PullRequestCheck } from "@t3tools/contracts"; + +/** ISO-8601 timestamps in UTC compare correctly as plain text, which is all the ordering needs. */ +function isAtLeastAsNew(candidate: string | null, kept: string | null): boolean { + if (candidate === null) return kept === null; + return kept === null || candidate >= kept; +} + +/** + * One row per check rather than one per run of it. + * + * A host's rollup is a list of runs, not a list of checks: while a workflow is being re-run — or + * while a second run of it is already live — the same check arrives twice, and both copies reach + * the reader as what looks like a duplicate. Nothing in a check carries an id, so the name is what + * identifies it, qualified by the workflow it belongs to since two workflows are free to name a + * job the same thing. + * + * Within a group the newest run is the one worth showing: a re-run is the answer that replaces the + * one before it, and a run that never said when it happened loses to one that did. A tie goes to + * whichever came last, because a host lists a re-run after the run it repeats. + * + * The order is the host's own, held at the place each check first appeared, so a re-run landing + * mid-read replaces a row where it stands instead of reshuffling the list under the reader. + * + * Two checks that survive under the same name are then genuinely different ones, since they came + * from different workflows — each is shown as `workflow / name`, the way GitHub writes it itself. + * A survivor whose workflow the host did not name keeps its bare name rather than being qualified + * with nothing. + */ +export function dedupeChecks( + entries: ReadonlyArray<{ + readonly check: PullRequestCheck; + readonly workflowName: string | null; + readonly at: string | null; + }>, +): ReadonlyArray { + const newestByCheck = new Map(); + for (const entry of entries) { + const key = `${entry.workflowName ?? ""} ${entry.check.name}`; + const kept = newestByCheck.get(key); + // Re-setting a key a Map already holds keeps its first position, which is the order wanted. + if (kept === undefined || isAtLeastAsNew(entry.at, kept.at)) newestByCheck.set(key, entry); + } + const survivors = [...newestByCheck.values()]; + const countsByName = new Map(); + for (const entry of survivors) { + countsByName.set(entry.check.name, (countsByName.get(entry.check.name) ?? 0) + 1); + } + return survivors.map((entry) => { + const workflowName = entry.workflowName ?? ""; + return workflowName.length > 0 && (countsByName.get(entry.check.name) ?? 0) > 1 + ? { ...entry.check, name: `${workflowName} / ${entry.check.name}` } + : entry.check; + }); +} diff --git a/packages/source-control-core/tsconfig.json b/packages/source-control-core/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..374bac55202ddcad695ede1c567ec219a61e9c48 --- /dev/null +++ b/packages/source-control-core/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5790c62cc13da7233f2a98f2741d179f0e9e242f..387677cd3d52233f45581c61902da3d2269bfa41 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -648,6 +648,9 @@ importers: '@t3tools/provider-pi': specifier: workspace:* version: link:../../packages/provider-pi + '@t3tools/source-control-core': + specifier: workspace:* + version: link:../../packages/source-control-core diff: specifier: 8.0.3 version: 8.0.3 @@ -1510,6 +1513,25 @@ importers: specifier: 1.0.0 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) + packages/source-control-core: + dependencies: + '@t3tools/contracts': + specifier: workspace:* + version: link:../contracts + effect: + specifier: 4.0.2 + version: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688) + devDependencies: + '@effect/vitest': + specifier: 4.0.2 + 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) + '@types/node': + specifier: 24.12.4 + version: 24.12.4 + vite-plus: + specifier: 1.0.0 + 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) + packages/ssh: dependencies: '@t3tools/contracts': diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index fd8921b67c1265477b7c5db10650179cb6610ca8..f1c903bc8fe1c380cb0adde879771d0bc9974fb9 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -37,6 +37,7 @@ const workspaceFiles = [ "packages/provider-grok/package.json", "packages/provider-muse/package.json", "packages/provider-opencode/package.json", + "packages/source-control-core/package.json", "packages/effect-acp/package.json", "packages/effect-codex-app-server/package.json", "scripts/package.json",