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