| author | |
| committer | |
| log | 49f849ecce3667abab7ce9e6aaa42dfd2e3c45d7 |
| tree | b737bd3a6ae8b6de10d03797422bc5dc8e675abd |
| parent | c85dc642681a944e644cbbbea7257d274822c21a |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>13 files changed, 615 insertions(+), 532 deletions(-)
apps/server/src/git/GitManager.test.ts+29-6| ... | ... | @@ -44,6 +44,7 @@ import { |
| 44 | 44 | } from "@t3tools/contracts"; |
| 45 | 45 | import * as DateTime from "effect/DateTime"; |
| 46 | 46 | import { decodeGitHubPullRequestListJson } from "@t3tools/source-control-github/server/gitHubPullRequests"; |
| 47 | import * as GitHubChangeRequestTemplate from "@t3tools/source-control-github/server/gitHubChangeRequestTemplate"; | |
| 47 | 48 | import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli"; |
| 48 | 49 | import type * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; |
| 49 | 50 | import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; |
| ... | ... | @@ -55,6 +56,7 @@ import * as VcsProjectConfig from "../vcs/VcsProjectConfig.ts"; |
| 55 | 56 | import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts"; |
| 56 | 57 | import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; |
| 57 | 58 | import * as GitWorkflowService from "./GitWorkflowService.ts"; |
| 59 | import * as ForgejoSourceControlProvider from "@t3tools/source-control-forgejo/server/ForgejoSourceControlProvider"; | |
| 58 | 60 | import * as GitLabSourceControlProvider from "@t3tools/source-control-gitlab/server/GitLabSourceControlProvider"; |
| 59 | 61 | import { |
| 60 | 62 | ForgejoPullRequestSchema, |
| ... | ... | @@ -545,6 +547,13 @@ function createGitHubProviderWithFakeGh(scenario: FakeGhScenario = {}): { |
| 545 | 547 | return { |
| 546 | 548 | service: { |
| 547 | 549 | kind: "github", |
| 550 | // The GitHub provider's own lookup rule and template convention, which GitManager reads | |
| 551 | // instead of the kind. | |
| 552 | headBranchProbe: ({ headSelectors }) => ({ | |
| 553 | headSelectors: headSelectors.filter((selector) => !selector.includes(":")), | |
| 554 | limit: 100, | |
| 555 | }), | |
| 556 | ||
| 548 | 557 | listChangeRequests: (input) => |
| 549 | 558 | input.state === "open" |
| 550 | 559 | ? execute({ |
| ... | ... | @@ -745,7 +754,18 @@ function makeManager(input?: { |
| 745 | 754 | ); |
| 746 | 755 | const layerSourceControlRegistry = Layer.effect( |
| 747 | 756 | SourceControlProviderRegistry.SourceControlProviderRegistry, |
| 748 | Effect.succeed(input?.sourceControlProvider ?? fakeGitHubProvider).pipe( | |
| 757 | Effect.gen(function* () { | |
| 758 | // GitHub reads its PR template with git, which the fake gh cannot answer; give the fake | |
| 759 | // the package's real reader over the test repository's git. | |
| 760 | const git = yield* GitVcsDriver.GitVcsDriver; | |
| 761 | return ( | |
| 762 | input?.sourceControlProvider ?? { | |
| 763 | ...fakeGitHubProvider, | |
| 764 | readChangeRequestTemplate: ({ cwd, treeish }: { cwd: string; treeish: string }) => | |
| 765 | GitHubChangeRequestTemplate.detect(cwd, treeish, git.execute), | |
| 766 | } | |
| 767 | ); | |
| 768 | }).pipe( | |
| 749 | 769 | Effect.map((provider) => |
| 750 | 770 | SourceControlProviderRegistry.SourceControlProviderRegistry.of({ |
| 751 | 771 | resolveLink: (input) => provider.resolveLink?.(input), |
| ... | ... | @@ -769,9 +789,12 @@ function makeManager(input?: { |
| 769 | 789 | runForThread: () => Effect.succeed({ status: "no-script" as const }), |
| 770 | 790 | }, |
| 771 | 791 | ), |
| 772 | layerVcsDriver, | |
| 773 | 792 | layerServerSettings, |
| 774 | ).pipe(Layer.provideMerge(layerSourceControlRegistry), Layer.provideMerge(NodeServices.layer)); | |
| 793 | ).pipe( | |
| 794 | Layer.provideMerge(layerSourceControlRegistry), | |
| 795 | Layer.provideMerge(layerVcsDriver), | |
| 796 | Layer.provideMerge(NodeServices.layer), | |
| 797 | ); | |
| 775 | 798 | // Built into the test's scope: the manager reads these stores after this returns. |
| 776 | 799 | const layerStores = Layer.merge(ProjectionStore.layer, ProjectStore.layer).pipe( |
| 777 | 800 | Layer.provideMerge(SqlitePersistence.layerMemory), |
| ... | ... | @@ -4232,7 +4255,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { |
| 4232 | 4255 | }; |
| 4233 | 4256 | const repository = GitManager.parseRepositoryNameWithOwnerFromRemoteUrl( |
| 4234 | 4257 | `https://forgejo.example/forgejo/${owner}/project.git`, |
| 4235 | "forgejo", | |
| 4258 | { repositoryNameFromRemoteUrl: ForgejoSourceControlProvider.repositoryNameFromRemoteUrl }, | |
| 4236 | 4259 | ); |
| 4237 | 4260 | expect(repository).toBe(`${owner}/project`); |
| 4238 | 4261 | const context = { |
| ... | ... | @@ -4253,13 +4276,13 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { |
| 4253 | 4276 | expect( |
| 4254 | 4277 | GitManager.parseRepositoryNameWithOwnerFromRemoteUrl( |
| 4255 | 4278 | "git@forgejo.example:maria/project.git", |
| 4256 | "forgejo", | |
| 4279 | { repositoryNameFromRemoteUrl: ForgejoSourceControlProvider.repositoryNameFromRemoteUrl }, | |
| 4257 | 4280 | ), |
| 4258 | 4281 | ).toBe("maria/project"); |
| 4259 | 4282 | expect( |
| 4260 | 4283 | GitManager.parseRepositoryNameWithOwnerFromRemoteUrl( |
| 4261 | 4284 | "https://gitlab.example/group/maria/project.git", |
| 4262 | "gitlab", | |
| 4285 | {}, | |
| 4263 | 4286 | ), |
| 4264 | 4287 | ).toBe("group/maria/project"); |
| 4265 | 4288 | }), |
apps/server/src/git/GitManager.ts+40-52| ... | ... | @@ -35,7 +35,6 @@ import { |
| 35 | 35 | ModelSelection, |
| 36 | 36 | type ProjectId, |
| 37 | 37 | SourceControlProviderError, |
| 38 | type SourceControlProviderKind, | |
| 39 | 38 | type SourceControlWritingStyleSettings, |
| 40 | 39 | type ThreadId, |
| 41 | 40 | type ThreadPullRequestKey, |
| ... | ... | @@ -81,8 +80,8 @@ import { detachStackFrame } from "./detachStackFrame.ts"; |
| 81 | 80 | import * as ServerSettings from "../serverSettings.ts"; |
| 82 | 81 | import type { GitManagerServiceError } from "@t3tools/contracts"; |
| 83 | 82 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; |
| 83 | import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; | |
| 84 | 84 | import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; |
| 85 | import { detectPrTemplate } from "../sourceControl/PrTemplateDetection.ts"; | |
| 86 | 85 | import type { ChangeRequest } from "@t3tools/contracts"; |
| 87 | 86 | |
| 88 | 87 | export interface GitActionProgressReporter { |
| ... | ... | @@ -314,27 +313,20 @@ function resolvePullRequestWorktreeLocalBranchName( |
| 314 | 313 | return `t3code/pr-${pullRequest.number}/${suffix}`; |
| 315 | 314 | } |
| 316 | 315 | |
| 316 | /** The repository's `owner/name` from a remote URL, as `provider` reads its remote paths. */ | |
| 317 | 317 | export function parseRepositoryNameWithOwnerFromRemoteUrl( |
| 318 | 318 | url: string | null, |
| 319 | providerKind?: ChangeRequest["provider"], | |
| 319 | provider?: Pick< | |
| 320 | SourceControlProvider.SourceControlProvider["Service"], | |
| 321 | "repositoryNameFromRemoteUrl" | |
| 322 | >, | |
| 320 | 323 | ): string | null { |
| 321 | 324 | const trimmed = url?.trim() ?? ""; |
| 322 | if (trimmed.length === 0) { | |
| 323 | return null; | |
| 324 | } | |
| 325 | ||
| 326 | const match = | |
| 327 | /^(?:[^@/\s]+@[^:/\s]+:|(?:ssh|https?|git):\/\/[^/]+\/)((?:[^/\s]+\/)+[^/\s]+?)(?:\.git)?\/?$/iu.exec( | |
| 328 | trimmed, | |
| 329 | ); | |
| 330 | const repositoryNameWithOwner = match?.[1]?.trim() ?? ""; | |
| 331 | // Forgejo HTTP paths can include an installation mount; its API always names owner/repo. | |
| 332 | if (providerKind === "forgejo" && /^https?:\/\//iu.test(trimmed)) { | |
| 333 | return repositoryNameWithOwner.length > 0 | |
| 334 | ? repositoryNameWithOwner.split("/").slice(-2).join("/") | |
| 335 | : null; | |
| 336 | } | |
| 337 | return repositoryNameWithOwner.length > 0 ? repositoryNameWithOwner : null; | |
| 325 | if (trimmed.length === 0) return null; | |
| 326 | return ( | |
| 327 | provider?.repositoryNameFromRemoteUrl?.(trimmed) ?? | |
| 328 | SourceControlProvider.repositoryPathFromRemoteUrl(trimmed) | |
| 329 | ); | |
| 338 | 330 | } |
| 339 | 331 | |
| 340 | 332 | function parseRepositoryOwnerLogin(nameWithOwner: string | null): string | null { |
| ... | ... | @@ -625,24 +617,18 @@ function parseCustomCommitMessage(raw: string): { subject: string; body: string |
| 625 | 617 | }; |
| 626 | 618 | } |
| 627 | 619 | |
| 628 | // Without the owner selector, a bare branch name also lists same-named | |
| 629 | // branches on other forks (`main`, `patch-1`), so GitHub probes ask for a full | |
| 630 | // page and let matchesBranchHeadContext pick the right head. gh fetches up to | |
| 631 | // 100 in one request, and GitHub prices a first:100 connection like first:1. | |
| 632 | const GITHUB_HEAD_BRANCH_PROBE_LIMIT = 100; | |
| 633 | ||
| 634 | // `gh pr list --head` filters on the head ref name alone and accepts anything, so an | |
| 635 | // `owner:branch` or `remote:branch` selector silently lists zero pull requests | |
| 636 | // while spending a GraphQL call. Git branch names cannot contain ":", and the | |
| 637 | // bare head branch is always among the selectors, so GitHub probes skip them and | |
| 638 | // leave the owner check to matchesBranchHeadContext. | |
| 639 | function probeableHeadSelectors( | |
| 640 | providerKind: SourceControlProviderKind, | |
| 620 | /** The selectors and page size `provider` asks about when looking up a branch's change requests. */ | |
| 621 | function headBranchProbe( | |
| 622 | provider: SourceControlProvider.SourceControlProvider["Service"], | |
| 641 | 623 | headSelectors: ReadonlyArray<string>, |
| 642 | ): ReadonlyArray<string> { | |
| 643 | return providerKind === "github" | |
| 644 | ? headSelectors.filter((selector) => !selector.includes(":")) | |
| 645 | : headSelectors; | |
| 624 | state: "open" | "all", | |
| 625 | ) { | |
| 626 | return ( | |
| 627 | provider.headBranchProbe?.({ headSelectors, state }) ?? { | |
| 628 | headSelectors, | |
| 629 | limit: state === "open" ? 1 : 20, | |
| 630 | } | |
| 631 | ); | |
| 646 | 632 | } |
| 647 | 633 | |
| 648 | 634 | function appendUnique(values: string[], next: string | null | undefined): void { |
| ... | ... | @@ -1430,15 +1416,14 @@ export const make = Effect.gen(function* () { |
| 1430 | 1416 | /^https?:\/\//iu.test(remoteUrl) && |
| 1431 | 1417 | (repositoryNameWithOwner?.split("/").length ?? 0) > 2 |
| 1432 | 1418 | ) { |
| 1433 | const detected = detectSourceControlProviderFromGitRemoteUrl(remoteUrl); | |
| 1434 | const kind = | |
| 1435 | detected?.kind === "unknown" | |
| 1436 | ? yield* sourceControlProvider(cwd).pipe( | |
| 1437 | Effect.map((provider) => provider.kind), | |
| 1438 | Effect.orElseSucceed(() => undefined), | |
| 1439 | ) | |
| 1440 | : detected?.kind; | |
| 1441 | repositoryNameWithOwner = parseRepositoryNameWithOwnerFromRemoteUrl(remoteUrl, kind); | |
| 1419 | const kind = detectSourceControlProviderFromGitRemoteUrl(remoteUrl)?.kind; | |
| 1420 | const provider = | |
| 1421 | kind === undefined | |
| 1422 | ? undefined | |
| 1423 | : yield* ( | |
| 1424 | kind === "unknown" ? sourceControlProvider(cwd) : sourceControlProviders.get(kind) | |
| 1425 | ).pipe(Effect.orElseSucceed(() => undefined)); | |
| 1426 | repositoryNameWithOwner = parseRepositoryNameWithOwnerFromRemoteUrl(remoteUrl, provider); | |
| 1442 | 1427 | } |
| 1443 | 1428 | return { |
| 1444 | 1429 | remoteUrlKey: remoteUrl ? normalizeGitRemoteUrl(remoteUrl) : null, |
| ... | ... | @@ -1701,13 +1686,13 @@ export const make = Effect.gen(function* () { |
| 1701 | 1686 | >, |
| 1702 | 1687 | ) { |
| 1703 | 1688 | const provider = yield* sourceControlProvider(cwd); |
| 1704 | const headSelectors = probeableHeadSelectors(provider.kind, headContext.headSelectors); | |
| 1705 | for (const headSelector of headSelectors) { | |
| 1689 | const probe = headBranchProbe(provider, headContext.headSelectors, "open"); | |
| 1690 | for (const headSelector of probe.headSelectors) { | |
| 1706 | 1691 | const pullRequests = yield* provider.listChangeRequests({ |
| 1707 | 1692 | cwd, |
| 1708 | 1693 | headSelector, |
| 1709 | 1694 | state: "open", |
| 1710 | limit: provider.kind === "github" ? GITHUB_HEAD_BRANCH_PROBE_LIMIT : 1, | |
| 1695 | limit: probe.limit, | |
| 1711 | 1696 | }); |
| 1712 | 1697 | const normalizedPullRequests = pullRequests.map(toPullRequestInfo); |
| 1713 | 1698 | |
| ... | ... | @@ -1733,12 +1718,13 @@ export const make = Effect.gen(function* () { |
| 1733 | 1718 | const parsedByNumber = new Map<number, PullRequestInfo>(); |
| 1734 | 1719 | |
| 1735 | 1720 | const provider = yield* sourceControlProvider(cwd); |
| 1736 | for (const headSelector of probeableHeadSelectors(provider.kind, headContext.headSelectors)) { | |
| 1721 | const probe = headBranchProbe(provider, headContext.headSelectors, "all"); | |
| 1722 | for (const headSelector of probe.headSelectors) { | |
| 1737 | 1723 | const pullRequests = yield* provider.listChangeRequests({ |
| 1738 | 1724 | cwd, |
| 1739 | 1725 | headSelector, |
| 1740 | 1726 | state: "all", |
| 1741 | limit: provider.kind === "github" ? GITHUB_HEAD_BRANCH_PROBE_LIMIT : 20, | |
| 1727 | limit: probe.limit, | |
| 1742 | 1728 | }); |
| 1743 | 1729 | |
| 1744 | 1730 | for (const pr of pullRequests.map(toPullRequestInfo)) { |
| ... | ... | @@ -2129,8 +2115,10 @@ export const make = Effect.gen(function* () { |
| 2129 | 2115 | const rangeContext = yield* gitCore.readRangeContext(cwd, baseRangeRef); |
| 2130 | 2116 | const policy = yield* resolveStylePolicy(cwd, settings); |
| 2131 | 2117 | const changeRequestTemplate = |
| 2132 | settings.style.followChangeRequestTemplates && provider.kind === "github" | |
| 2133 | ? Option.getOrUndefined(yield* detectPrTemplate(cwd, baseRangeRef, gitCore.execute)) | |
| 2118 | settings.style.followChangeRequestTemplates && provider.readChangeRequestTemplate | |
| 2119 | ? Option.getOrUndefined( | |
| 2120 | yield* provider.readChangeRequestTemplate({ cwd, treeish: baseRangeRef }), | |
| 2121 | ) | |
| 2134 | 2122 | : undefined; |
| 2135 | 2123 | |
| 2136 | 2124 | const generated = yield* textGeneration.generatePrContent({ |
apps/server/src/sourceControl/PrTemplateDetection.test.ts deleted-255| ... | ... | @@ -1,255 +0,0 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { assert, it } from "@effect/vitest"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as FileSystem from "effect/FileSystem"; | |
| 5 | import * as Layer from "effect/Layer"; | |
| 6 | import * as Option from "effect/Option"; | |
| 7 | import * as Path from "effect/Path"; | |
| 8 | ||
| 9 | import * as ServerConfig from "../config.ts"; | |
| 10 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; | |
| 11 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | |
| 12 | import { detectPrTemplate } from "./PrTemplateDetection.ts"; | |
| 13 | import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; | |
| 14 | ||
| 15 | const SINGLE_TEMPLATE_PATHS = [ | |
| 16 | ".github/pull_request_template.md", | |
| 17 | ".github/PULL_REQUEST_TEMPLATE.md", | |
| 18 | "pull_request_template.md", | |
| 19 | "PULL_REQUEST_TEMPLATE.md", | |
| 20 | "docs/pull_request_template.md", | |
| 21 | "docs/PULL_REQUEST_TEMPLATE.md", | |
| 22 | ] as const; | |
| 23 | ||
| 24 | const TEMPLATE_DIRECTORIES = [ | |
| 25 | ".github/PULL_REQUEST_TEMPLATE", | |
| 26 | "PULL_REQUEST_TEMPLATE", | |
| 27 | "docs/PULL_REQUEST_TEMPLATE", | |
| 28 | ] as const; | |
| 29 | ||
| 30 | const layerPrTemplateDetectionTest = GitVcsDriver.layer.pipe( | |
| 31 | Layer.provide( | |
| 32 | ServerConfig.layerTest(process.cwd(), { | |
| 33 | prefix: "t3-pr-template-test-", | |
| 34 | }), | |
| 35 | ), | |
| 36 | Layer.provideMerge(VcsProcess.layer), | |
| 37 | Layer.provideMerge(NodeServices.layer), | |
| 38 | ); | |
| 39 | ||
| 40 | const runGit = (cwd: string, args: ReadonlyArray<string>) => | |
| 41 | Effect.gen(function* () { | |
| 42 | const git = yield* GitVcsDriver.GitVcsDriver; | |
| 43 | return yield* git.execute({ | |
| 44 | operation: "PrTemplateDetection.test.runGit", | |
| 45 | cwd, | |
| 46 | args, | |
| 47 | }); | |
| 48 | }); | |
| 49 | ||
| 50 | const runWithTempDirectory = <A, E, R>( | |
| 51 | test: (cwd: string) => Effect.Effect<A, E, R | FileSystem.FileSystem | Path.Path>, | |
| 52 | ) => | |
| 53 | Effect.scoped( | |
| 54 | Effect.gen(function* () { | |
| 55 | const fileSystem = yield* FileSystem.FileSystem; | |
| 56 | const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-pr-template-" }); | |
| 57 | yield* runGit(cwd, ["init", "--initial-branch=main"]); | |
| 58 | yield* runGit(cwd, ["config", "user.email", "test@example.com"]); | |
| 59 | yield* runGit(cwd, ["config", "user.name", "Test User"]); | |
| 60 | return yield* test(cwd); | |
| 61 | }), | |
| 62 | ).pipe(Effect.provide(layerPrTemplateDetectionTest)); | |
| 63 | ||
| 64 | const writeTemplate = (cwd: string, relativePath: string, contents: string) => | |
| 65 | Effect.gen(function* () { | |
| 66 | const fileSystem = yield* FileSystem.FileSystem; | |
| 67 | const path = yield* Path.Path; | |
| 68 | const templatePath = path.join(cwd, relativePath); | |
| 69 | yield* fileSystem.makeDirectory(path.dirname(templatePath), { recursive: true }); | |
| 70 | yield* fileSystem.writeFileString(templatePath, contents); | |
| 71 | return templatePath; | |
| 72 | }); | |
| 73 | ||
| 74 | const commitTemplates = (cwd: string) => | |
| 75 | Effect.gen(function* () { | |
| 76 | yield* runGit(cwd, ["add", "-A"]); | |
| 77 | yield* runGit(cwd, ["commit", "--allow-empty", "-m", "Add pull request templates"]); | |
| 78 | }); | |
| 79 | ||
| 80 | const detectTemplate = (cwd: string, treeish = "HEAD") => | |
| 81 | Effect.gen(function* () { | |
| 82 | const git = yield* GitVcsDriver.GitVcsDriver; | |
| 83 | return yield* detectPrTemplate(cwd, treeish, git.execute); | |
| 84 | }); | |
| 85 | ||
| 86 | it.effect.each(SINGLE_TEMPLATE_PATHS)("recognizes $0", (relativePath) => | |
| 87 | runWithTempDirectory((cwd) => | |
| 88 | Effect.gen(function* () { | |
| 89 | yield* writeTemplate(cwd, relativePath, `template from ${relativePath}`); | |
| 90 | yield* commitTemplates(cwd); | |
| 91 | ||
| 92 | const template = yield* detectTemplate(cwd); | |
| 93 | assert.strictEqual(Option.getOrUndefined(template), `template from ${relativePath}`); | |
| 94 | }), | |
| 95 | ), | |
| 96 | ); | |
| 97 | ||
| 98 | it.effect("reads templates from the requested base tree", () => | |
| 99 | runWithTempDirectory((cwd) => | |
| 100 | Effect.gen(function* () { | |
| 101 | yield* writeTemplate(cwd, "README.md", "initial\n"); | |
| 102 | yield* commitTemplates(cwd); | |
| 103 | yield* runGit(cwd, ["branch", "feature"]); | |
| 104 | yield* writeTemplate(cwd, ".github/pull_request_template.md", "base template"); | |
| 105 | yield* commitTemplates(cwd); | |
| 106 | yield* runGit(cwd, ["checkout", "feature"]); | |
| 107 | ||
| 108 | assert.isTrue(Option.isNone(yield* detectTemplate(cwd))); | |
| 109 | assert.strictEqual( | |
| 110 | Option.getOrUndefined(yield* detectTemplate(cwd, "main")), | |
| 111 | "base template", | |
| 112 | ); | |
| 113 | }), | |
| 114 | ), | |
| 115 | ); | |
| 116 | ||
| 117 | it.effect("uses the first non-empty template in the configured path order", () => | |
| 118 | runWithTempDirectory((cwd) => | |
| 119 | Effect.gen(function* () { | |
| 120 | yield* writeTemplate(cwd, ".github/pull_request_template.md", " \n"); | |
| 121 | yield* writeTemplate(cwd, ".github/PULL_REQUEST_TEMPLATE.md", " ## Preferred template \n"); | |
| 122 | yield* writeTemplate(cwd, "pull_request_template.md", "## Later template"); | |
| 123 | yield* commitTemplates(cwd); | |
| 124 | ||
| 125 | const template = yield* detectTemplate(cwd); | |
| 126 | assert.strictEqual(Option.getOrUndefined(template), "## Preferred template"); | |
| 127 | }), | |
| 128 | ), | |
| 129 | ); | |
| 130 | ||
| 131 | it.effect.each(TEMPLATE_DIRECTORIES)("recognizes the $0 directory", (relativeDirectory) => | |
| 132 | runWithTempDirectory((cwd) => | |
| 133 | Effect.gen(function* () { | |
| 134 | yield* writeTemplate(cwd, `${relativeDirectory}/template.MD`, "directory template"); | |
| 135 | yield* commitTemplates(cwd); | |
| 136 | ||
| 137 | const template = yield* detectTemplate(cwd); | |
| 138 | assert.strictEqual(Option.getOrUndefined(template), "directory template"); | |
| 139 | }), | |
| 140 | ), | |
| 141 | ); | |
| 142 | ||
| 143 | it.effect.skipIf(!symlinksSupported)( | |
| 144 | "skips unusable directory entries and uses the one valid template", | |
| 145 | () => | |
| 146 | runWithTempDirectory((cwd) => | |
| 147 | Effect.gen(function* () { | |
| 148 | const fileSystem = yield* FileSystem.FileSystem; | |
| 149 | const path = yield* Path.Path; | |
| 150 | const templateDirectory = path.join(cwd, ".github", "PULL_REQUEST_TEMPLATE"); | |
| 151 | yield* fileSystem.makeDirectory(path.join(templateDirectory, "b-directory.md"), { | |
| 152 | recursive: true, | |
| 153 | }); | |
| 154 | yield* fileSystem.writeFileString(path.join(templateDirectory, "a-empty.md"), " \n"); | |
| 155 | yield* fileSystem.symlink( | |
| 156 | path.join(templateDirectory, "missing.md"), | |
| 157 | path.join(templateDirectory, "c-broken.md"), | |
| 158 | ); | |
| 159 | yield* fileSystem.writeFileString(path.join(templateDirectory, "z-valid.md"), "valid"); | |
| 160 | yield* commitTemplates(cwd); | |
| 161 | ||
| 162 | const template = yield* detectTemplate(cwd); | |
| 163 | assert.strictEqual(Option.getOrUndefined(template), "valid"); | |
| 164 | }), | |
| 165 | ), | |
| 166 | ); | |
| 167 | ||
| 168 | it.effect("does not guess between multiple directory templates", () => | |
| 169 | runWithTempDirectory((cwd) => | |
| 170 | Effect.gen(function* () { | |
| 171 | yield* writeTemplate(cwd, ".github/PULL_REQUEST_TEMPLATE/a.md", "first"); | |
| 172 | yield* writeTemplate(cwd, ".github/PULL_REQUEST_TEMPLATE/b.md", "second"); | |
| 173 | yield* writeTemplate(cwd, "PULL_REQUEST_TEMPLATE/fallback.md", "fallback"); | |
| 174 | yield* commitTemplates(cwd); | |
| 175 | ||
| 176 | const template = yield* detectTemplate(cwd); | |
| 177 | assert.isTrue(Option.isNone(template)); | |
| 178 | }), | |
| 179 | ), | |
| 180 | ); | |
| 181 | ||
| 182 | it.effect.skipIf(!symlinksSupported)( | |
| 183 | "rejects a committed template symlink escaping the repository", | |
| 184 | () => | |
| 185 | runWithTempDirectory((cwd) => | |
| 186 | Effect.gen(function* () { | |
| 187 | const fileSystem = yield* FileSystem.FileSystem; | |
| 188 | const path = yield* Path.Path; | |
| 189 | const outsideDirectory = yield* fileSystem.makeTempDirectoryScoped({ | |
| 190 | prefix: "t3-pr-template-outside-", | |
| 191 | }); | |
| 192 | const outsideTemplate = path.join(outsideDirectory, "secret.md"); | |
| 193 | yield* fileSystem.writeFileString(outsideTemplate, "LOCAL_SECRET_SENTINEL"); | |
| 194 | const escapedTemplatePath = path.join(cwd, ".github", "pull_request_template.md"); | |
| 195 | yield* fileSystem.makeDirectory(path.dirname(escapedTemplatePath), { recursive: true }); | |
| 196 | yield* fileSystem.symlink(outsideTemplate, escapedTemplatePath); | |
| 197 | yield* writeTemplate(cwd, "pull_request_template.md", "safe template"); | |
| 198 | yield* commitTemplates(cwd); | |
| 199 | ||
| 200 | const template = yield* detectTemplate(cwd); | |
| 201 | assert.strictEqual(Option.getOrUndefined(template), "safe template"); | |
| 202 | assert.notInclude( | |
| 203 | Option.getOrElse(template, () => ""), | |
| 204 | "LOCAL_SECRET_SENTINEL", | |
| 205 | ); | |
| 206 | }), | |
| 207 | ), | |
| 208 | ); | |
| 209 | ||
| 210 | it.effect.skipIf(!symlinksSupported)( | |
| 211 | "reads the committed template when a worktree parent is replaced", | |
| 212 | () => | |
| 213 | runWithTempDirectory((cwd) => | |
| 214 | Effect.gen(function* () { | |
| 215 | const fileSystem = yield* FileSystem.FileSystem; | |
| 216 | const path = yield* Path.Path; | |
| 217 | const outsideDirectory = yield* fileSystem.makeTempDirectoryScoped({ | |
| 218 | prefix: "t3-pr-template-outside-", | |
| 219 | }); | |
| 220 | const templatePath = yield* writeTemplate( | |
| 221 | cwd, | |
| 222 | ".github/pull_request_template.md", | |
| 223 | "committed template", | |
| 224 | ); | |
| 225 | yield* commitTemplates(cwd); | |
| 226 | yield* writeTemplate(outsideDirectory, "pull_request_template.md", "LOCAL_SECRET_SENTINEL"); | |
| 227 | ||
| 228 | const templateDirectory = path.dirname(templatePath); | |
| 229 | yield* fileSystem.rename(templateDirectory, path.join(cwd, ".github-original")); | |
| 230 | yield* fileSystem.symlink(outsideDirectory, templateDirectory); | |
| 231 | ||
| 232 | const template = yield* detectTemplate(cwd); | |
| 233 | assert.strictEqual(Option.getOrUndefined(template), "committed template"); | |
| 234 | assert.notInclude( | |
| 235 | Option.getOrElse(template, () => ""), | |
| 236 | "LOCAL_SECRET_SENTINEL", | |
| 237 | ); | |
| 238 | }), | |
| 239 | ), | |
| 240 | ); | |
| 241 | ||
| 242 | it.effect("bounds template reads and marks truncated content", () => | |
| 243 | runWithTempDirectory((cwd) => | |
| 244 | Effect.gen(function* () { | |
| 245 | const prefix = "a".repeat(8_000); | |
| 246 | yield* writeTemplate(cwd, ".github/pull_request_template.md", `${prefix}SECRET_SENTINEL`); | |
| 247 | yield* commitTemplates(cwd); | |
| 248 | ||
| 249 | const template = Option.getOrThrow(yield* detectTemplate(cwd)); | |
| 250 | assert.strictEqual(template, `${prefix}\n\n[truncated]`); | |
| 251 | assert.lengthOf(template.match(/\[truncated\]/g) ?? [], 1); | |
| 252 | assert.notInclude(template, "SECRET_SENTINEL"); | |
| 253 | }), | |
| 254 | ), | |
| 255 | ); |
apps/server/src/sourceControl/PrTemplateDetection.ts deleted-177| ... | ... | @@ -1,177 +0,0 @@ |
| 1 | import type { GitCommandError } from "@t3tools/contracts"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as Option from "effect/Option"; | |
| 4 | ||
| 5 | import type * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; | |
| 6 | ||
| 7 | const TEMPLATE_MAX_BYTES = 8_000; | |
| 8 | const TREE_LIST_MAX_BYTES = 100_000; | |
| 9 | const TRUNCATION_MARKER = "[truncated]"; | |
| 10 | ||
| 11 | const TEMPLATE_PATHS = [ | |
| 12 | ".github/pull_request_template.md", | |
| 13 | ".github/PULL_REQUEST_TEMPLATE.md", | |
| 14 | "pull_request_template.md", | |
| 15 | "PULL_REQUEST_TEMPLATE.md", | |
| 16 | "docs/pull_request_template.md", | |
| 17 | "docs/PULL_REQUEST_TEMPLATE.md", | |
| 18 | ] as const; | |
| 19 | ||
| 20 | const TEMPLATE_DIRECTORIES = [ | |
| 21 | ".github/PULL_REQUEST_TEMPLATE", | |
| 22 | "PULL_REQUEST_TEMPLATE", | |
| 23 | "docs/PULL_REQUEST_TEMPLATE", | |
| 24 | ] as const; | |
| 25 | ||
| 26 | const TREE_PATHS = [...TEMPLATE_PATHS, ...TEMPLATE_DIRECTORIES] as const; | |
| 27 | ||
| 28 | type ExecuteGit = GitVcsDriver.GitVcsDriver["Service"]["execute"]; | |
| 29 | ||
| 30 | interface TemplateTreeEntry { | |
| 31 | readonly objectId: string; | |
| 32 | readonly path: string; | |
| 33 | } | |
| 34 | ||
| 35 | function parseTemplateTreeEntries(output: string): ReadonlyArray<TemplateTreeEntry> { | |
| 36 | const entries: TemplateTreeEntry[] = []; | |
| 37 | for (const record of output.split("\0")) { | |
| 38 | if (record.length === 0) { | |
| 39 | continue; | |
| 40 | } | |
| 41 | ||
| 42 | const separator = record.indexOf("\t"); | |
| 43 | if (separator < 0) { | |
| 44 | continue; | |
| 45 | } | |
| 46 | ||
| 47 | const [mode, type, objectId] = record.slice(0, separator).split(" "); | |
| 48 | if ( | |
| 49 | type !== "blob" || | |
| 50 | (mode !== "100644" && mode !== "100755") || | |
| 51 | !objectId || | |
| 52 | !/^[0-9a-f]{40,64}$/.test(objectId) | |
| 53 | ) { | |
| 54 | continue; | |
| 55 | } | |
| 56 | ||
| 57 | entries.push({ objectId, path: record.slice(separator + 1) }); | |
| 58 | } | |
| 59 | return entries; | |
| 60 | } | |
| 61 | ||
| 62 | function readTemplateBlob(input: { | |
| 63 | readonly cwd: string; | |
| 64 | readonly executeGit: ExecuteGit; | |
| 65 | readonly entry: TemplateTreeEntry; | |
| 66 | }): Effect.Effect<Option.Option<string>, GitCommandError> { | |
| 67 | return input | |
| 68 | .executeGit({ | |
| 69 | operation: "PrTemplateDetection.readTemplateBlob", | |
| 70 | cwd: input.cwd, | |
| 71 | args: ["cat-file", "blob", input.entry.objectId], | |
| 72 | maxOutputBytes: TEMPLATE_MAX_BYTES, | |
| 73 | appendTruncationMarker: true, | |
| 74 | }) | |
| 75 | .pipe( | |
| 76 | Effect.map((result) => { | |
| 77 | const template = result.stdout.trim(); | |
| 78 | if (template.length === 0) { | |
| 79 | return Option.none(); | |
| 80 | } | |
| 81 | return Option.some( | |
| 82 | result.stdoutTruncated && !template.endsWith(TRUNCATION_MARKER) | |
| 83 | ? `${template}\n\n${TRUNCATION_MARKER}` | |
| 84 | : template, | |
| 85 | ); | |
| 86 | }), | |
| 87 | ); | |
| 88 | } | |
| 89 | ||
| 90 | type DirectoryTemplateResult = | |
| 91 | | { readonly _tag: "None" } | |
| 92 | | { readonly _tag: "Ambiguous" } | |
| 93 | | { readonly _tag: "Template"; readonly template: string }; | |
| 94 | ||
| 95 | function readTemplateDirectory(input: { | |
| 96 | readonly cwd: string; | |
| 97 | readonly executeGit: ExecuteGit; | |
| 98 | readonly entries: ReadonlyArray<TemplateTreeEntry>; | |
| 99 | readonly directory: string; | |
| 100 | }): Effect.Effect<DirectoryTemplateResult, GitCommandError> { | |
| 101 | return Effect.gen(function* () { | |
| 102 | const prefix = `${input.directory}/`; | |
| 103 | const candidates = input.entries.filter((entry) => { | |
| 104 | if (!entry.path.startsWith(prefix)) { | |
| 105 | return false; | |
| 106 | } | |
| 107 | const relativePath = entry.path.slice(prefix.length); | |
| 108 | return !relativePath.includes("/") && relativePath.toLowerCase().endsWith(".md"); | |
| 109 | }); | |
| 110 | ||
| 111 | const templates: string[] = []; | |
| 112 | for (const entry of candidates) { | |
| 113 | const template = yield* readTemplateBlob({ ...input, entry }); | |
| 114 | if (Option.isSome(template)) { | |
| 115 | templates.push(template.value); | |
| 116 | if (templates.length > 1) { | |
| 117 | return { _tag: "Ambiguous" } as const; | |
| 118 | } | |
| 119 | } | |
| 120 | } | |
| 121 | ||
| 122 | return templates[0] | |
| 123 | ? ({ _tag: "Template", template: templates[0] } as const) | |
| 124 | : ({ _tag: "None" } as const); | |
| 125 | }); | |
| 126 | } | |
| 127 | ||
| 128 | export const detectPrTemplate = Effect.fn("detectPrTemplate")(function* ( | |
| 129 | cwd: string, | |
| 130 | treeish: string, | |
| 131 | executeGit: ExecuteGit, | |
| 132 | ) { | |
| 133 | return yield* Effect.gen(function* () { | |
| 134 | // Worktree paths can be replaced between validation and open. Read regular blobs from the | |
| 135 | // committed base tree so repository-controlled symlinks and path races never reach the host filesystem. | |
| 136 | const result = yield* executeGit({ | |
| 137 | operation: "PrTemplateDetection.listTemplates", | |
| 138 | cwd, | |
| 139 | args: ["ls-tree", "-r", "-z", "--full-tree", treeish, "--", ...TREE_PATHS], | |
| 140 | maxOutputBytes: TREE_LIST_MAX_BYTES, | |
| 141 | appendTruncationMarker: true, | |
| 142 | }); | |
| 143 | if (result.stdoutTruncated) { | |
| 144 | return Option.none(); | |
| 145 | } | |
| 146 | ||
| 147 | const entries = parseTemplateTreeEntries(result.stdout); | |
| 148 | const entriesByPath = new Map(entries.map((entry) => [entry.path, entry])); | |
| 149 | for (const templatePath of TEMPLATE_PATHS) { | |
| 150 | const entry = entriesByPath.get(templatePath); | |
| 151 | if (!entry) { | |
| 152 | continue; | |
| 153 | } | |
| 154 | const template = yield* readTemplateBlob({ cwd, executeGit, entry }); | |
| 155 | if (Option.isSome(template)) { | |
| 156 | return template; | |
| 157 | } | |
| 158 | } | |
| 159 | ||
| 160 | for (const directory of TEMPLATE_DIRECTORIES) { | |
| 161 | const directoryTemplate = yield* readTemplateDirectory({ | |
| 162 | cwd, | |
| 163 | executeGit, | |
| 164 | entries, | |
| 165 | directory, | |
| 166 | }); | |
| 167 | if (directoryTemplate._tag === "Template") { | |
| 168 | return Option.some(directoryTemplate.template); | |
| 169 | } | |
| 170 | if (directoryTemplate._tag === "Ambiguous") { | |
| 171 | return Option.none(); | |
| 172 | } | |
| 173 | } | |
| 174 | ||
| 175 | return Option.none(); | |
| 176 | }).pipe(Effect.orElseSucceed(() => Option.none())); | |
| 177 | }); |
apps/server/src/sourceControl/SourceControlProviderRegistry.ts+1-2| ... | ... | @@ -158,8 +158,7 @@ function bindProviderContext( |
| 158 | 158 | } |
| 159 | 159 | |
| 160 | 160 | return SourceControlProvider.SourceControlProvider.of({ |
| 161 | kind: provider.kind, | |
| 162 | ...(provider.resolveLink ? { resolveLink: provider.resolveLink } : {}), | |
| 161 | ...provider, | |
| 163 | 162 | listChangeRequests: (input) => |
| 164 | 163 | provider.listChangeRequests({ |
| 165 | 164 | ...input, |
apps/server/src/sourceControl/gitHubChangeRequestTemplate.test.ts created+255| ... | ... | @@ -0,0 +1,255 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { assert, it } from "@effect/vitest"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as FileSystem from "effect/FileSystem"; | |
| 5 | import * as Layer from "effect/Layer"; | |
| 6 | import * as Option from "effect/Option"; | |
| 7 | import * as Path from "effect/Path"; | |
| 8 | ||
| 9 | import * as ServerConfig from "../config.ts"; | |
| 10 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; | |
| 11 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | |
| 12 | import * as GitHubChangeRequestTemplate from "@t3tools/source-control-github/server/gitHubChangeRequestTemplate"; | |
| 13 | import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; | |
| 14 | ||
| 15 | const SINGLE_TEMPLATE_PATHS = [ | |
| 16 | ".github/pull_request_template.md", | |
| 17 | ".github/PULL_REQUEST_TEMPLATE.md", | |
| 18 | "pull_request_template.md", | |
| 19 | "PULL_REQUEST_TEMPLATE.md", | |
| 20 | "docs/pull_request_template.md", | |
| 21 | "docs/PULL_REQUEST_TEMPLATE.md", | |
| 22 | ] as const; | |
| 23 | ||
| 24 | const TEMPLATE_DIRECTORIES = [ | |
| 25 | ".github/PULL_REQUEST_TEMPLATE", | |
| 26 | "PULL_REQUEST_TEMPLATE", | |
| 27 | "docs/PULL_REQUEST_TEMPLATE", | |
| 28 | ] as const; | |
| 29 | ||
| 30 | const layerPrTemplateDetectionTest = GitVcsDriver.layer.pipe( | |
| 31 | Layer.provide( | |
| 32 | ServerConfig.layerTest(process.cwd(), { | |
| 33 | prefix: "t3-pr-template-test-", | |
| 34 | }), | |
| 35 | ), | |
| 36 | Layer.provideMerge(VcsProcess.layer), | |
| 37 | Layer.provideMerge(NodeServices.layer), | |
| 38 | ); | |
| 39 | ||
| 40 | const runGit = (cwd: string, args: ReadonlyArray<string>) => | |
| 41 | Effect.gen(function* () { | |
| 42 | const git = yield* GitVcsDriver.GitVcsDriver; | |
| 43 | return yield* git.execute({ | |
| 44 | operation: "PrTemplateDetection.test.runGit", | |
| 45 | cwd, | |
| 46 | args, | |
| 47 | }); | |
| 48 | }); | |
| 49 | ||
| 50 | const runWithTempDirectory = <A, E, R>( | |
| 51 | test: (cwd: string) => Effect.Effect<A, E, R | FileSystem.FileSystem | Path.Path>, | |
| 52 | ) => | |
| 53 | Effect.scoped( | |
| 54 | Effect.gen(function* () { | |
| 55 | const fileSystem = yield* FileSystem.FileSystem; | |
| 56 | const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-pr-template-" }); | |
| 57 | yield* runGit(cwd, ["init", "--initial-branch=main"]); | |
| 58 | yield* runGit(cwd, ["config", "user.email", "test@example.com"]); | |
| 59 | yield* runGit(cwd, ["config", "user.name", "Test User"]); | |
| 60 | return yield* test(cwd); | |
| 61 | }), | |
| 62 | ).pipe(Effect.provide(layerPrTemplateDetectionTest)); | |
| 63 | ||
| 64 | const writeTemplate = (cwd: string, relativePath: string, contents: string) => | |
| 65 | Effect.gen(function* () { | |
| 66 | const fileSystem = yield* FileSystem.FileSystem; | |
| 67 | const path = yield* Path.Path; | |
| 68 | const templatePath = path.join(cwd, relativePath); | |
| 69 | yield* fileSystem.makeDirectory(path.dirname(templatePath), { recursive: true }); | |
| 70 | yield* fileSystem.writeFileString(templatePath, contents); | |
| 71 | return templatePath; | |
| 72 | }); | |
| 73 | ||
| 74 | const commitTemplates = (cwd: string) => | |
| 75 | Effect.gen(function* () { | |
| 76 | yield* runGit(cwd, ["add", "-A"]); | |
| 77 | yield* runGit(cwd, ["commit", "--allow-empty", "-m", "Add pull request templates"]); | |
| 78 | }); | |
| 79 | ||
| 80 | const detectTemplate = (cwd: string, treeish = "HEAD") => | |
| 81 | Effect.gen(function* () { | |
| 82 | const git = yield* GitVcsDriver.GitVcsDriver; | |
| 83 | return yield* GitHubChangeRequestTemplate.detect(cwd, treeish, git.execute); | |
| 84 | }); | |
| 85 | ||
| 86 | it.effect.each(SINGLE_TEMPLATE_PATHS)("recognizes $0", (relativePath) => | |
| 87 | runWithTempDirectory((cwd) => | |
| 88 | Effect.gen(function* () { | |
| 89 | yield* writeTemplate(cwd, relativePath, `template from ${relativePath}`); | |
| 90 | yield* commitTemplates(cwd); | |
| 91 | ||
| 92 | const template = yield* detectTemplate(cwd); | |
| 93 | assert.strictEqual(Option.getOrUndefined(template), `template from ${relativePath}`); | |
| 94 | }), | |
| 95 | ), | |
| 96 | ); | |
| 97 | ||
| 98 | it.effect("reads templates from the requested base tree", () => | |
| 99 | runWithTempDirectory((cwd) => | |
| 100 | Effect.gen(function* () { | |
| 101 | yield* writeTemplate(cwd, "README.md", "initial\n"); | |
| 102 | yield* commitTemplates(cwd); | |
| 103 | yield* runGit(cwd, ["branch", "feature"]); | |
| 104 | yield* writeTemplate(cwd, ".github/pull_request_template.md", "base template"); | |
| 105 | yield* commitTemplates(cwd); | |
| 106 | yield* runGit(cwd, ["checkout", "feature"]); | |
| 107 | ||
| 108 | assert.isTrue(Option.isNone(yield* detectTemplate(cwd))); | |
| 109 | assert.strictEqual( | |
| 110 | Option.getOrUndefined(yield* detectTemplate(cwd, "main")), | |
| 111 | "base template", | |
| 112 | ); | |
| 113 | }), | |
| 114 | ), | |
| 115 | ); | |
| 116 | ||
| 117 | it.effect("uses the first non-empty template in the configured path order", () => | |
| 118 | runWithTempDirectory((cwd) => | |
| 119 | Effect.gen(function* () { | |
| 120 | yield* writeTemplate(cwd, ".github/pull_request_template.md", " \n"); | |
| 121 | yield* writeTemplate(cwd, ".github/PULL_REQUEST_TEMPLATE.md", " ## Preferred template \n"); | |
| 122 | yield* writeTemplate(cwd, "pull_request_template.md", "## Later template"); | |
| 123 | yield* commitTemplates(cwd); | |
| 124 | ||
| 125 | const template = yield* detectTemplate(cwd); | |
| 126 | assert.strictEqual(Option.getOrUndefined(template), "## Preferred template"); | |
| 127 | }), | |
| 128 | ), | |
| 129 | ); | |
| 130 | ||
| 131 | it.effect.each(TEMPLATE_DIRECTORIES)("recognizes the $0 directory", (relativeDirectory) => | |
| 132 | runWithTempDirectory((cwd) => | |
| 133 | Effect.gen(function* () { | |
| 134 | yield* writeTemplate(cwd, `${relativeDirectory}/template.MD`, "directory template"); | |
| 135 | yield* commitTemplates(cwd); | |
| 136 | ||
| 137 | const template = yield* detectTemplate(cwd); | |
| 138 | assert.strictEqual(Option.getOrUndefined(template), "directory template"); | |
| 139 | }), | |
| 140 | ), | |
| 141 | ); | |
| 142 | ||
| 143 | it.effect.skipIf(!symlinksSupported)( | |
| 144 | "skips unusable directory entries and uses the one valid template", | |
| 145 | () => | |
| 146 | runWithTempDirectory((cwd) => | |
| 147 | Effect.gen(function* () { | |
| 148 | const fileSystem = yield* FileSystem.FileSystem; | |
| 149 | const path = yield* Path.Path; | |
| 150 | const templateDirectory = path.join(cwd, ".github", "PULL_REQUEST_TEMPLATE"); | |
| 151 | yield* fileSystem.makeDirectory(path.join(templateDirectory, "b-directory.md"), { | |
| 152 | recursive: true, | |
| 153 | }); | |
| 154 | yield* fileSystem.writeFileString(path.join(templateDirectory, "a-empty.md"), " \n"); | |
| 155 | yield* fileSystem.symlink( | |
| 156 | path.join(templateDirectory, "missing.md"), | |
| 157 | path.join(templateDirectory, "c-broken.md"), | |
| 158 | ); | |
| 159 | yield* fileSystem.writeFileString(path.join(templateDirectory, "z-valid.md"), "valid"); | |
| 160 | yield* commitTemplates(cwd); | |
| 161 | ||
| 162 | const template = yield* detectTemplate(cwd); | |
| 163 | assert.strictEqual(Option.getOrUndefined(template), "valid"); | |
| 164 | }), | |
| 165 | ), | |
| 166 | ); | |
| 167 | ||
| 168 | it.effect("does not guess between multiple directory templates", () => | |
| 169 | runWithTempDirectory((cwd) => | |
| 170 | Effect.gen(function* () { | |
| 171 | yield* writeTemplate(cwd, ".github/PULL_REQUEST_TEMPLATE/a.md", "first"); | |
| 172 | yield* writeTemplate(cwd, ".github/PULL_REQUEST_TEMPLATE/b.md", "second"); | |
| 173 | yield* writeTemplate(cwd, "PULL_REQUEST_TEMPLATE/fallback.md", "fallback"); | |
| 174 | yield* commitTemplates(cwd); | |
| 175 | ||
| 176 | const template = yield* detectTemplate(cwd); | |
| 177 | assert.isTrue(Option.isNone(template)); | |
| 178 | }), | |
| 179 | ), | |
| 180 | ); | |
| 181 | ||
| 182 | it.effect.skipIf(!symlinksSupported)( | |
| 183 | "rejects a committed template symlink escaping the repository", | |
| 184 | () => | |
| 185 | runWithTempDirectory((cwd) => | |
| 186 | Effect.gen(function* () { | |
| 187 | const fileSystem = yield* FileSystem.FileSystem; | |
| 188 | const path = yield* Path.Path; | |
| 189 | const outsideDirectory = yield* fileSystem.makeTempDirectoryScoped({ | |
| 190 | prefix: "t3-pr-template-outside-", | |
| 191 | }); | |
| 192 | const outsideTemplate = path.join(outsideDirectory, "secret.md"); | |
| 193 | yield* fileSystem.writeFileString(outsideTemplate, "LOCAL_SECRET_SENTINEL"); | |
| 194 | const escapedTemplatePath = path.join(cwd, ".github", "pull_request_template.md"); | |
| 195 | yield* fileSystem.makeDirectory(path.dirname(escapedTemplatePath), { recursive: true }); | |
| 196 | yield* fileSystem.symlink(outsideTemplate, escapedTemplatePath); | |
| 197 | yield* writeTemplate(cwd, "pull_request_template.md", "safe template"); | |
| 198 | yield* commitTemplates(cwd); | |
| 199 | ||
| 200 | const template = yield* detectTemplate(cwd); | |
| 201 | assert.strictEqual(Option.getOrUndefined(template), "safe template"); | |
| 202 | assert.notInclude( | |
| 203 | Option.getOrElse(template, () => ""), | |
| 204 | "LOCAL_SECRET_SENTINEL", | |
| 205 | ); | |
| 206 | }), | |
| 207 | ), | |
| 208 | ); | |
| 209 | ||
| 210 | it.effect.skipIf(!symlinksSupported)( | |
| 211 | "reads the committed template when a worktree parent is replaced", | |
| 212 | () => | |
| 213 | runWithTempDirectory((cwd) => | |
| 214 | Effect.gen(function* () { | |
| 215 | const fileSystem = yield* FileSystem.FileSystem; | |
| 216 | const path = yield* Path.Path; | |
| 217 | const outsideDirectory = yield* fileSystem.makeTempDirectoryScoped({ | |
| 218 | prefix: "t3-pr-template-outside-", | |
| 219 | }); | |
| 220 | const templatePath = yield* writeTemplate( | |
| 221 | cwd, | |
| 222 | ".github/pull_request_template.md", | |
| 223 | "committed template", | |
| 224 | ); | |
| 225 | yield* commitTemplates(cwd); | |
| 226 | yield* writeTemplate(outsideDirectory, "pull_request_template.md", "LOCAL_SECRET_SENTINEL"); | |
| 227 | ||
| 228 | const templateDirectory = path.dirname(templatePath); | |
| 229 | yield* fileSystem.rename(templateDirectory, path.join(cwd, ".github-original")); | |
| 230 | yield* fileSystem.symlink(outsideDirectory, templateDirectory); | |
| 231 | ||
| 232 | const template = yield* detectTemplate(cwd); | |
| 233 | assert.strictEqual(Option.getOrUndefined(template), "committed template"); | |
| 234 | assert.notInclude( | |
| 235 | Option.getOrElse(template, () => ""), | |
| 236 | "LOCAL_SECRET_SENTINEL", | |
| 237 | ); | |
| 238 | }), | |
| 239 | ), | |
| 240 | ); | |
| 241 | ||
| 242 | it.effect("bounds template reads and marks truncated content", () => | |
| 243 | runWithTempDirectory((cwd) => | |
| 244 | Effect.gen(function* () { | |
| 245 | const prefix = "a".repeat(8_000); | |
| 246 | yield* writeTemplate(cwd, ".github/pull_request_template.md", `${prefix}SECRET_SENTINEL`); | |
| 247 | yield* commitTemplates(cwd); | |
| 248 | ||
| 249 | const template = Option.getOrThrow(yield* detectTemplate(cwd)); | |
| 250 | assert.strictEqual(template, `${prefix}\n\n[truncated]`); | |
| 251 | assert.lengthOf(template.match(/\[truncated\]/g) ?? [], 1); | |
| 252 | assert.notInclude(template, "SECRET_SENTINEL"); | |
| 253 | }), | |
| 254 | ), | |
| 255 | ); |
packages/source-control-core/src/server/SourceControlHost.ts+2| ... | ... | @@ -83,6 +83,8 @@ export class SourceControlHost extends Context.Service< |
| 83 | 83 | readonly operation: string; |
| 84 | 84 | readonly cwd: string; |
| 85 | 85 | readonly args: ReadonlyArray<string>; |
| 86 | readonly maxOutputBytes?: number; | |
| 87 | readonly appendTruncationMarker?: boolean; | |
| 86 | 88 | }) => Effect.Effect<SourceControlGitOutput, GitCommandError>; |
| 87 | 89 | readonly resolveCommit: (input: { |
| 88 | 90 | readonly cwd: string; |
packages/source-control-core/src/server/SourceControlProvider.ts+39| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 | import * as Context from "effect/Context"; |
| 2 | 2 | import * as Effect from "effect/Effect"; |
| 3 | import type * as Option from "effect/Option"; | |
| 3 | 4 | import type { |
| 4 | 5 | ChangeRequest, |
| 5 | 6 | ChangeRequestState, |
| ... | ... | @@ -92,10 +93,48 @@ export function sourceControlRefFromInput(input: { |
| 92 | 93 | return input.source ?? parseSourceControlOwnerRef(input.headSelector); |
| 93 | 94 | } |
| 94 | 95 | |
| 96 | /** The repository path a remote URL names (`owner/name`, or deeper for nested groups). */ | |
| 97 | export function repositoryPathFromRemoteUrl(url: string | null): string | null { | |
| 98 | const trimmed = url?.trim() ?? ""; | |
| 99 | if (trimmed.length === 0) { | |
| 100 | return null; | |
| 101 | } | |
| 102 | ||
| 103 | const match = | |
| 104 | /^(?:[^@/\s]+@[^:/\s]+:|(?:ssh|https?|git):\/\/[^/]+\/)((?:[^/\s]+\/)+[^/\s]+?)(?:\.git)?\/?$/iu.exec( | |
| 105 | trimmed, | |
| 106 | ); | |
| 107 | const path = match?.[1]?.trim() ?? ""; | |
| 108 | return path.length > 0 ? path : null; | |
| 109 | } | |
| 110 | ||
| 95 | 111 | export class SourceControlProvider extends Context.Service< |
| 96 | 112 | SourceControlProvider, |
| 97 | 113 | { |
| 98 | 114 | readonly kind: SourceControlProviderKind; |
| 115 | /** | |
| 116 | * How to look up a branch's change requests: which head selectors to ask about, and how many | |
| 117 | * results to read per selector. The caller checks the owner against what comes back, so a | |
| 118 | * host that cannot search `owner:branch` can drop those selectors without losing anything. | |
| 119 | * Absent asks about every selector, reading 1 for the open lookup and 20 for any state. | |
| 120 | */ | |
| 121 | readonly headBranchProbe?: (input: { | |
| 122 | readonly headSelectors: ReadonlyArray<string>; | |
| 123 | readonly state: "open" | "all"; | |
| 124 | }) => { readonly headSelectors: ReadonlyArray<string>; readonly limit: number }; | |
| 125 | /** | |
| 126 | * The repository's change request template at `treeish`, for change request content | |
| 127 | * generation to follow. Absent means the host has no template convention. | |
| 128 | */ | |
| 129 | readonly readChangeRequestTemplate?: (input: { | |
| 130 | readonly cwd: string; | |
| 131 | readonly treeish: string; | |
| 132 | }) => Effect.Effect<Option.Option<string>>; | |
| 133 | /** | |
| 134 | * The repository's `owner/name` from a remote URL, for a host whose remote paths can carry | |
| 135 | * more than that (Forgejo's HTTP installation mount). Absent means the whole path. | |
| 136 | */ | |
| 137 | readonly repositoryNameFromRemoteUrl?: (url: string) => string | null; | |
| 99 | 138 | /** Optional capability for issue and change-request subjects. */ |
| 100 | 139 | readonly resolveLink?: ResolveSourceControlLink; |
| 101 | 140 | readonly listChangeRequests: (input: { |
packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts+40-40| ... | ... | @@ -1,13 +1,12 @@ |
| 1 | 1 | import { assert, it } from "@effect/vitest"; |
| 2 | 2 | import * as Effect from "effect/Effect"; |
| 3 | import type * as Context from "effect/Context"; | |
| 4 | 3 | import * as FileSystem from "effect/FileSystem"; |
| 5 | 4 | import * as Layer from "effect/Layer"; |
| 6 | 5 | import * as Option from "effect/Option"; |
| 7 | 6 | import * as Schema from "effect/Schema"; |
| 8 | 7 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| 9 | import * as FetchHttpClient from "effect/http/FetchHttpClient"; | |
| 10 | 8 | import * as HttpClient from "effect/http/HttpClient"; |
| 9 | import * as HttpClientRequest from "effect/http/HttpClientRequest"; | |
| 11 | 10 | import * as HttpClientResponse from "effect/http/HttpClientResponse"; |
| 12 | 11 | import { VcsProcessSpawnError } from "@t3tools/contracts"; |
| 13 | 12 | import { firstNonEmptyLine } from "@t3tools/source-control-core/server/discovery"; |
| ... | ... | @@ -20,38 +19,45 @@ import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts |
| 20 | 19 | const processOutput = TestSourceControlHost.processOutput; |
| 21 | 20 | |
| 22 | 21 | const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); |
| 22 | const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); | |
| 23 | ||
| 24 | /** The JSON body a request sends. Forgejo encodes bodies as UTF-8 text. */ | |
| 25 | const requestJson = (request: HttpClientRequest.HttpClientRequest): unknown => { | |
| 26 | assert.strictEqual(request.body._tag, "Uint8Array"); | |
| 27 | return request.body._tag === "Uint8Array" | |
| 28 | ? decodeJson(new TextDecoder().decode(request.body.body)) | |
| 29 | : undefined; | |
| 30 | }; | |
| 23 | 31 | const encodeJsonEffect = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); |
| 24 | 32 | |
| 25 | 33 | it.effect("submits a Forgejo review without sending its summary in the preliminary GET", () => { |
| 26 | 34 | const methods: string[] = []; |
| 27 | const fetchReview = async ( | |
| 28 | ...[input, init]: Parameters<Context.Service.Shape<typeof FetchHttpClient.Fetch>> | |
| 29 | ) => { | |
| 30 | const request = new Request(input instanceof Request ? input.url : String(input), { | |
| 31 | ...(init?.method === undefined ? {} : { method: init.method }), | |
| 32 | ...(init?.headers === undefined ? {} : { headers: init.headers }), | |
| 33 | ...(init?.body === undefined ? {} : { body: init.body }), | |
| 34 | }); | |
| 35 | const reviewClient = HttpClient.make((request) => { | |
| 35 | 36 | methods.push(request.method); |
| 36 | 37 | if (request.method === "GET") { |
| 37 | 38 | assert.strictEqual(request.url, "https://forgejo.test/api/v1/repos/maria/project/pulls/42"); |
| 38 | return new Response( | |
| 39 | encodeJson({ | |
| 40 | number: 42, | |
| 41 | title: "Review target", | |
| 42 | body: "", | |
| 43 | html_url: "https://forgejo.test/maria/project/pulls/42", | |
| 44 | user: { login: "maria" }, | |
| 45 | state: "open", | |
| 46 | merged: false, | |
| 47 | head: { ref: "feature", sha: "head", repo: null }, | |
| 48 | base: { ref: "main", sha: "base", repo: null }, | |
| 49 | created_at: "2026-09-13T00:00:00Z", | |
| 50 | updated_at: "2026-09-13T00:00:00Z", | |
| 51 | closed_at: null, | |
| 52 | merged_at: null, | |
| 53 | labels: [], | |
| 54 | }), | |
| 39 | return Effect.succeed( | |
| 40 | HttpClientResponse.fromWeb( | |
| 41 | request, | |
| 42 | new Response( | |
| 43 | encodeJson({ | |
| 44 | number: 42, | |
| 45 | title: "Review target", | |
| 46 | body: "", | |
| 47 | html_url: "https://forgejo.test/maria/project/pulls/42", | |
| 48 | user: { login: "maria" }, | |
| 49 | state: "open", | |
| 50 | merged: false, | |
| 51 | head: { ref: "feature", sha: "head", repo: null }, | |
| 52 | base: { ref: "main", sha: "base", repo: null }, | |
| 53 | created_at: "2026-09-13T00:00:00Z", | |
| 54 | updated_at: "2026-09-13T00:00:00Z", | |
| 55 | closed_at: null, | |
| 56 | merged_at: null, | |
| 57 | labels: [], | |
| 58 | }), | |
| 59 | ), | |
| 60 | ), | |
| 55 | 61 | ); |
| 56 | 62 | } |
| 57 | 63 | assert.strictEqual(request.method, "POST"); |
| ... | ... | @@ -59,14 +65,16 @@ it.effect("submits a Forgejo review without sending its summary in the prelimina |
| 59 | 65 | request.url, |
| 60 | 66 | "https://forgejo.test/api/v1/repos/maria/project/pulls/42/reviews", |
| 61 | 67 | ); |
| 62 | assert.deepStrictEqual(JSON.parse(await request.text()), { | |
| 68 | assert.deepStrictEqual(requestJson(request), { | |
| 63 | 69 | event: "COMMENT", |
| 64 | 70 | body: "Review summary", |
| 65 | 71 | commit_id: "head", |
| 66 | 72 | comments: [], |
| 67 | 73 | }); |
| 68 | return new Response('{"id":1}', { status: 200 }); | |
| 69 | }; | |
| 74 | return Effect.succeed( | |
| 75 | HttpClientResponse.fromWeb(request, new Response('{"id":1}', { status: 200 })), | |
| 76 | ); | |
| 77 | }); | |
| 70 | 78 | return Effect.gen(function* () { |
| 71 | 79 | const cli = yield* ForgejoCli.make; |
| 72 | 80 | const provider = yield* ForgejoPullRequestProvider.make.pipe( |
| ... | ... | @@ -83,11 +91,7 @@ it.effect("submits a Forgejo review without sending its summary in the prelimina |
| 83 | 91 | }); |
| 84 | 92 | assert.deepStrictEqual(methods, ["GET", "POST"]); |
| 85 | 93 | }).pipe( |
| 86 | Effect.provideService( | |
| 87 | FetchHttpClient.Fetch, | |
| 88 | Object.assign(fetchReview, { preconnect: () => undefined }), | |
| 89 | ), | |
| 90 | Effect.provide(FetchHttpClient.layer), | |
| 94 | Effect.provideService(HttpClient.HttpClient, reviewClient), | |
| 91 | 95 | Effect.provideService( |
| 92 | 96 | FileSystem.FileSystem, |
| 93 | 97 | FileSystem.makeNoop({ |
| ... | ... | @@ -837,11 +841,7 @@ it.effect("prefers fj for HTTP and ported SSH aliases on root servers", () => { |
| 837 | 841 | } |
| 838 | 842 | assert.strictEqual(request.method, "POST"); |
| 839 | 843 | assert.strictEqual(request.headers.authorization, "token test-token"); |
| 840 | assert.strictEqual(request.body._tag, "Uint8Array"); | |
| 841 | if (request.body._tag === "Uint8Array") | |
| 842 | assert.deepStrictEqual(JSON.parse(new TextDecoder().decode(request.body.body)), { | |
| 843 | body: "verified through fj", | |
| 844 | }); | |
| 844 | assert.deepStrictEqual(requestJson(request), { body: "verified through fj" }); | |
| 845 | 845 | return Effect.succeed( |
| 846 | 846 | HttpClientResponse.fromWeb(request, new Response('{"id":99}', { status: 201 })), |
| 847 | 847 | ); |
packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.ts+9| ... | ... | @@ -158,6 +158,14 @@ export const makeDiscovery = Effect.gen(function* () { |
| 158 | 158 | } satisfies SourceControlManagedCliDiscoverySpec; |
| 159 | 159 | }); |
| 160 | 160 | |
| 161 | /** HTTP paths can include an installation mount; Forgejo's API always names owner/repo. */ | |
| 162 | export function repositoryNameFromRemoteUrl(url: string): string | null { | |
| 163 | const path = SourceControlProvider.repositoryPathFromRemoteUrl(url); | |
| 164 | return path === null || !/^https?:\/\//iu.test(url.trim()) | |
| 165 | ? path | |
| 166 | : path.split("/").slice(-2).join("/"); | |
| 167 | } | |
| 168 | ||
| 161 | 169 | const RepositorySchema = Schema.Struct({ |
| 162 | 170 | full_name: Schema.String, |
| 163 | 171 | clone_url: Schema.String, |
| ... | ... | @@ -228,6 +236,7 @@ export const make = Effect.gen(function* () { |
| 228 | 236 | }); |
| 229 | 237 | return SourceControlProvider.SourceControlProvider.of({ |
| 230 | 238 | kind: "forgejo", |
| 239 | repositoryNameFromRemoteUrl, | |
| 231 | 240 | listChangeRequests: (input) => |
| 232 | 241 | Effect.gen(function* () { |
| 233 | 242 | const repo = yield* cli.resolveRepository(input); |
packages/source-control-github/package.json+4| ... | ... | @@ -15,6 +15,10 @@ |
| 15 | 15 | "types": "./src/server/gitHubAuthStatus.ts", |
| 16 | 16 | "import": "./src/server/gitHubAuthStatus.ts" |
| 17 | 17 | }, |
| 18 | "./server/gitHubChangeRequestTemplate": { | |
| 19 | "types": "./src/server/gitHubChangeRequestTemplate.ts", | |
| 20 | "import": "./src/server/gitHubChangeRequestTemplate.ts" | |
| 21 | }, | |
| 18 | 22 | "./server/gitHubConditionalChecks": { |
| 19 | 23 | "types": "./src/server/gitHubConditionalChecks.ts", |
| 20 | 24 | "import": "./src/server/gitHubConditionalChecks.ts" |
packages/source-control-github/src/server/GitHubSourceControlProvider.ts+13| ... | ... | @@ -23,6 +23,7 @@ import { decodeJsonResult } from "@t3tools/shared/schemaJson"; |
| 23 | 23 | import { isSshRemoteUrl } from "@t3tools/shared/sourceControl"; |
| 24 | 24 | |
| 25 | 25 | import * as GitHubApi from "./GitHubApi.ts"; |
| 26 | import * as GitHubChangeRequestTemplate from "./gitHubChangeRequestTemplate.ts"; | |
| 26 | 27 | import { |
| 27 | 28 | decodeGitHubPullRequestEntries, |
| 28 | 29 | type NormalizedGitHubPullRequestRecord, |
| ... | ... | @@ -939,6 +940,18 @@ export const make = Effect.gen(function* () { |
| 939 | 940 | |
| 940 | 941 | return SourceControlProvider.SourceControlProvider.of({ |
| 941 | 942 | kind: "github", |
| 943 | // `gh pr list --head` filters on the head ref name alone and accepts anything, so an | |
| 944 | // `owner:branch` or `remote:branch` selector silently lists zero pull requests while | |
| 945 | // spending a GraphQL call; the bare branch is always among the selectors. Without the owner, | |
| 946 | // a bare branch also lists same-named branches on other forks (`main`, `patch-1`), so read a | |
| 947 | // full page and let the owner check pick the right head. gh fetches up to 100 in one | |
| 948 | // request, and GitHub prices a first:100 connection like first:1. | |
| 949 | headBranchProbe: ({ headSelectors }) => ({ | |
| 950 | headSelectors: headSelectors.filter((selector) => !selector.includes(":")), | |
| 951 | limit: 100, | |
| 952 | }), | |
| 953 | readChangeRequestTemplate: ({ cwd, treeish }) => | |
| 954 | GitHubChangeRequestTemplate.detect(cwd, treeish, git.execute), | |
| 942 | 955 | resolveLink: (input) => { |
| 943 | 956 | // Automatic enrichment must not send ambient CLI credentials to a host from message text. |
| 944 | 957 | if (input.url.host !== "github.com") return undefined; |
packages/source-control-github/src/server/gitHubChangeRequestTemplate.ts created+183| ... | ... | @@ -0,0 +1,183 @@ |
| 1 | /** | |
| 2 | * GitHub's pull request template convention: the first single template found at a known path, | |
| 3 | * or the only Markdown file in a template directory, read from the committed base tree. | |
| 4 | * | |
| 5 | * @module source-control-github/server/gitHubChangeRequestTemplate | |
| 6 | */ | |
| 7 | import type { GitCommandError } from "@t3tools/contracts"; | |
| 8 | import * as Effect from "effect/Effect"; | |
| 9 | import * as Option from "effect/Option"; | |
| 10 | ||
| 11 | import type * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; | |
| 12 | ||
| 13 | const TEMPLATE_MAX_BYTES = 8_000; | |
| 14 | const TREE_LIST_MAX_BYTES = 100_000; | |
| 15 | const TRUNCATION_MARKER = "[truncated]"; | |
| 16 | ||
| 17 | const TEMPLATE_PATHS = [ | |
| 18 | ".github/pull_request_template.md", | |
| 19 | ".github/PULL_REQUEST_TEMPLATE.md", | |
| 20 | "pull_request_template.md", | |
| 21 | "PULL_REQUEST_TEMPLATE.md", | |
| 22 | "docs/pull_request_template.md", | |
| 23 | "docs/PULL_REQUEST_TEMPLATE.md", | |
| 24 | ] as const; | |
| 25 | ||
| 26 | const TEMPLATE_DIRECTORIES = [ | |
| 27 | ".github/PULL_REQUEST_TEMPLATE", | |
| 28 | "PULL_REQUEST_TEMPLATE", | |
| 29 | "docs/PULL_REQUEST_TEMPLATE", | |
| 30 | ] as const; | |
| 31 | ||
| 32 | const TREE_PATHS = [...TEMPLATE_PATHS, ...TEMPLATE_DIRECTORIES] as const; | |
| 33 | ||
| 34 | type ExecuteGit = SourceControlHost.SourceControlHost["Service"]["git"]["execute"]; | |
| 35 | ||
| 36 | interface TemplateTreeEntry { | |
| 37 | readonly objectId: string; | |
| 38 | readonly path: string; | |
| 39 | } | |
| 40 | ||
| 41 | function parseTemplateTreeEntries(output: string): ReadonlyArray<TemplateTreeEntry> { | |
| 42 | const entries: TemplateTreeEntry[] = []; | |
| 43 | for (const record of output.split("\0")) { | |
| 44 | if (record.length === 0) { | |
| 45 | continue; | |
| 46 | } | |
| 47 | ||
| 48 | const separator = record.indexOf("\t"); | |
| 49 | if (separator < 0) { | |
| 50 | continue; | |
| 51 | } | |
| 52 | ||
| 53 | const [mode, type, objectId] = record.slice(0, separator).split(" "); | |
| 54 | if ( | |
| 55 | type !== "blob" || | |
| 56 | (mode !== "100644" && mode !== "100755") || | |
| 57 | !objectId || | |
| 58 | !/^[0-9a-f]{40,64}$/.test(objectId) | |
| 59 | ) { | |
| 60 | continue; | |
| 61 | } | |
| 62 | ||
| 63 | entries.push({ objectId, path: record.slice(separator + 1) }); | |
| 64 | } | |
| 65 | return entries; | |
| 66 | } | |
| 67 | ||
| 68 | function readTemplateBlob(input: { | |
| 69 | readonly cwd: string; | |
| 70 | readonly executeGit: ExecuteGit; | |
| 71 | readonly entry: TemplateTreeEntry; | |
| 72 | }): Effect.Effect<Option.Option<string>, GitCommandError> { | |
| 73 | return input | |
| 74 | .executeGit({ | |
| 75 | operation: "gitHubChangeRequestTemplate.readTemplateBlob", | |
| 76 | cwd: input.cwd, | |
| 77 | args: ["cat-file", "blob", input.entry.objectId], | |
| 78 | maxOutputBytes: TEMPLATE_MAX_BYTES, | |
| 79 | appendTruncationMarker: true, | |
| 80 | }) | |
| 81 | .pipe( | |
| 82 | Effect.map((result) => { | |
| 83 | const template = result.stdout.trim(); | |
| 84 | if (template.length === 0) { | |
| 85 | return Option.none(); | |
| 86 | } | |
| 87 | return Option.some( | |
| 88 | result.stdoutTruncated && !template.endsWith(TRUNCATION_MARKER) | |
| 89 | ? `${template}\n\n${TRUNCATION_MARKER}` | |
| 90 | : template, | |
| 91 | ); | |
| 92 | }), | |
| 93 | ); | |
| 94 | } | |
| 95 | ||
| 96 | type DirectoryTemplateResult = | |
| 97 | | { readonly _tag: "None" } | |
| 98 | | { readonly _tag: "Ambiguous" } | |
| 99 | | { readonly _tag: "Template"; readonly template: string }; | |
| 100 | ||
| 101 | function readTemplateDirectory(input: { | |
| 102 | readonly cwd: string; | |
| 103 | readonly executeGit: ExecuteGit; | |
| 104 | readonly entries: ReadonlyArray<TemplateTreeEntry>; | |
| 105 | readonly directory: string; | |
| 106 | }): Effect.Effect<DirectoryTemplateResult, GitCommandError> { | |
| 107 | return Effect.gen(function* () { | |
| 108 | const prefix = `${input.directory}/`; | |
| 109 | const candidates = input.entries.filter((entry) => { | |
| 110 | if (!entry.path.startsWith(prefix)) { | |
| 111 | return false; | |
| 112 | } | |
| 113 | const relativePath = entry.path.slice(prefix.length); | |
| 114 | return !relativePath.includes("/") && relativePath.toLowerCase().endsWith(".md"); | |
| 115 | }); | |
| 116 | ||
| 117 | const templates: string[] = []; | |
| 118 | for (const entry of candidates) { | |
| 119 | const template = yield* readTemplateBlob({ ...input, entry }); | |
| 120 | if (Option.isSome(template)) { | |
| 121 | templates.push(template.value); | |
| 122 | if (templates.length > 1) { | |
| 123 | return { _tag: "Ambiguous" } as const; | |
| 124 | } | |
| 125 | } | |
| 126 | } | |
| 127 | ||
| 128 | return templates[0] | |
| 129 | ? ({ _tag: "Template", template: templates[0] } as const) | |
| 130 | : ({ _tag: "None" } as const); | |
| 131 | }); | |
| 132 | } | |
| 133 | ||
| 134 | export const detect = Effect.fn("gitHubChangeRequestTemplate.detect")(function* ( | |
| 135 | cwd: string, | |
| 136 | treeish: string, | |
| 137 | executeGit: ExecuteGit, | |
| 138 | ) { | |
| 139 | return yield* Effect.gen(function* () { | |
| 140 | // Worktree paths can be replaced between validation and open. Read regular blobs from the | |
| 141 | // committed base tree so repository-controlled symlinks and path races never reach the host filesystem. | |
| 142 | const result = yield* executeGit({ | |
| 143 | operation: "gitHubChangeRequestTemplate.listTemplates", | |
| 144 | cwd, | |
| 145 | args: ["ls-tree", "-r", "-z", "--full-tree", treeish, "--", ...TREE_PATHS], | |
| 146 | maxOutputBytes: TREE_LIST_MAX_BYTES, | |
| 147 | appendTruncationMarker: true, | |
| 148 | }); | |
| 149 | if (result.stdoutTruncated) { | |
| 150 | return Option.none(); | |
| 151 | } | |
| 152 | ||
| 153 | const entries = parseTemplateTreeEntries(result.stdout); | |
| 154 | const entriesByPath = new Map(entries.map((entry) => [entry.path, entry])); | |
| 155 | for (const templatePath of TEMPLATE_PATHS) { | |
| 156 | const entry = entriesByPath.get(templatePath); | |
| 157 | if (!entry) { | |
| 158 | continue; | |
| 159 | } | |
| 160 | const template = yield* readTemplateBlob({ cwd, executeGit, entry }); | |
| 161 | if (Option.isSome(template)) { | |
| 162 | return template; | |
| 163 | } | |
| 164 | } | |
| 165 | ||
| 166 | for (const directory of TEMPLATE_DIRECTORIES) { | |
| 167 | const directoryTemplate = yield* readTemplateDirectory({ | |
| 168 | cwd, | |
| 169 | executeGit, | |
| 170 | entries, | |
| 171 | directory, | |
| 172 | }); | |
| 173 | if (directoryTemplate._tag === "Template") { | |
| 174 | return Option.some(directoryTemplate.template); | |
| 175 | } | |
| 176 | if (directoryTemplate._tag === "Ambiguous") { | |
| 177 | return Option.none(); | |
| 178 | } | |
| 179 | } | |
| 180 | ||
| 181 | return Option.none(); | |
| 182 | }).pipe(Effect.orElseSucceed(() => Option.none())); | |
| 183 | }); |