diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.git.test.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.git.test.ts new file mode 100644 index 0000000000000..ed831af6f237f --- /dev/null +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.git.test.ts @@ -0,0 +1,187 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import type { GitCommandError } from "@t3tools/contracts"; + +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; +import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; + +import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; +import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlProvider.ts"; + +const withRepository = ( + test: (input: { + readonly cwd: string; + readonly git: (...args: string[]) => Effect.Effect; + readonly write: (path: string, contents: string) => Effect.Effect; + readonly commit: Effect.Effect; + readonly readTemplate: (treeish?: string) => Effect.Effect>; + }) => Effect.Effect, +) => + Effect.gen(function* () { + const host = yield* SourceControlHost.SourceControlHost; + const cwd = yield* Effect.acquireRelease( + Effect.sync(() => NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-azure-template-"))), + (directory) => Effect.sync(() => NodeFS.rmSync(directory, { recursive: true, force: true })), + ); + const git = (...args: string[]) => + host.git + .execute({ operation: "AzureDevOpsTemplate.test", cwd, args }) + .pipe(Effect.map((result) => result.stdout.trim())); + const write = (path: string, contents: string) => + Effect.sync(() => { + NodeFS.mkdirSync(NodePath.dirname(NodePath.join(cwd, path)), { recursive: true }); + NodeFS.writeFileSync(NodePath.join(cwd, path), contents); + }); + const commit = Effect.gen(function* () { + yield* git("add", "-A"); + yield* git("commit", "--allow-empty", "-m", "Add pull request templates"); + }); + yield* git("init", "--initial-branch=main"); + yield* git("config", "user.email", "test@example.com"); + yield* git("config", "user.name", "Test User"); + yield* git("config", "core.autocrlf", "false"); + yield* git("remote", "add", "origin", cwd); + const provider = yield* AzureDevOpsSourceControlProvider.make.pipe( + Effect.provide(Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({})), + ); + return yield* test({ + cwd, + git, + write, + commit, + readTemplate: (treeish = "main") => provider.readChangeRequestTemplate!({ cwd, treeish }), + }); + }).pipe( + Effect.scoped, + Effect.provide( + TestSourceControlHost.layerWithRealGit({ + git: { resolvePrimaryRemoteName: () => Effect.succeed("origin") }, + }), + ), + ); + +const publishMain = (git: (...args: string[]) => Effect.Effect) => + Effect.gen(function* () { + yield* git("update-ref", "refs/remotes/origin/main", "main"); + yield* git("symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/main"); + }); + +it.effect("reads the default-branch template whatever branch the pull request targets", () => + withRepository(({ git, write, commit, readTemplate }) => + Effect.gen(function* () { + const templatePath = ".azuredevops/pull_request_template.md"; + yield* write(templatePath, "Default branch template"); + yield* commit; + yield* publishMain(git); + yield* git("checkout", "-b", "release"); + yield* write(templatePath, "Release branch template"); + yield* commit; + yield* write(templatePath, "Uncommitted template"); + + assert.deepStrictEqual( + yield* readTemplate("release"), + Option.some("Default branch template"), + ); + }), + ), +); + +// Microsoft searches these locations in order; extension choice must not override folder precedence. +it.effect.each([ + [".azuredevops/pull_request_template.txt", ".vsts/pull_request_template.md"], + [".vsts/pull_request_template.txt", "docs/pull_request_template.md"], + ["docs/pull_request_template.txt", "pull_request_template.md"], +] as const)("prefers %s over %s", ([preferred, fallback]) => + withRepository(({ git, write, commit, readTemplate }) => + Effect.gen(function* () { + yield* write(preferred, "Preferred template"); + yield* write(fallback, "Fallback template"); + yield* commit; + yield* publishMain(git); + + assert.deepStrictEqual(yield* readTemplate(), Option.some("Preferred template")); + }), + ), +); + +it.effect.each([ + ".AzUrEdEvOpS/PuLl_ReQuEsT_TeMpLaTe.MD", + ".VsTs/PULL_REQUEST_TEMPLATE.TxT", + "DoCs/Pull_Request_Template.mD", + "PULL_REQUEST_TEMPLATE.TXT", +])("recognizes case-insensitive Azure template path %s", (templatePath) => + withRepository(({ git, write, commit, readTemplate }) => + Effect.gen(function* () { + yield* write(templatePath, "Case-insensitive template"); + yield* commit; + yield* publishMain(git); + + assert.deepStrictEqual(yield* readTemplate(), Option.some("Case-insensitive template")); + }), + ), +); + +it.effect("ignores missing, blank, symlinked, and nested templates", () => + withRepository(({ cwd, git, write, commit, readTemplate }) => + Effect.gen(function* () { + yield* commit; + yield* publishMain(git); + assert.deepStrictEqual(yield* readTemplate(), Option.none()); + + yield* write(".azuredevops/pull_request_template.md", " \n\t "); + yield* commit; + yield* publishMain(git); + assert.deepStrictEqual(yield* readTemplate(), Option.none()); + + yield* git("rm", "-q", ".azuredevops/pull_request_template.md"); + yield* write("template.md", "Not a default template"); + yield* Effect.sync(() => { + NodeFS.mkdirSync(NodePath.join(cwd, ".azuredevops"), { recursive: true }); + NodeFS.symlinkSync( + NodePath.join(cwd, "template.md"), + NodePath.join(cwd, ".azuredevops/pull_request_template.md"), + ); + }); + yield* write("docs/nested/pull_request_template.md", "Nested template"); + yield* commit; + yield* publishMain(git); + assert.deepStrictEqual(yield* readTemplate(), Option.none()); + }), + ), +); + +it.effect("bounds template content and marks truncation", () => + withRepository(({ git, write, commit, readTemplate }) => + Effect.gen(function* () { + yield* write("pull_request_template.md", "x".repeat(10_000)); + yield* commit; + yield* publishMain(git); + + const template = yield* readTemplate(); + assert.isTrue(Option.isSome(template)); + if (Option.isSome(template)) { + assert.isTrue(template.value.startsWith("x".repeat(8_000))); + assert.isTrue(template.value.endsWith("[truncated]")); + assert.isBelow(template.value.length, 8_100); + } + }), + ), +); + +it.effect("returns no template when the local remote default branch is unknown", () => + withRepository(({ write, commit, readTemplate }) => + Effect.gen(function* () { + yield* write("pull_request_template.md", "Target branch template"); + yield* commit; + + assert.deepStrictEqual(yield* readTemplate(), Option.none()); + }), + ), +); diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.ts index 64c1ada9bc137..aed2d639341af 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.ts @@ -1,7 +1,10 @@ import { SourceControlProviderKind } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; +import * as AzureDevOpsChangeRequestTemplate from "./azureDevOpsChangeRequestTemplate.ts"; +import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; import type { NormalizedAzureDevOpsPullRequestRecord } from "./azureDevOpsPullRequests.ts"; import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; @@ -73,9 +76,29 @@ function toChangeRequest(summary: NormalizedAzureDevOpsPullRequestRecord): Chang export const make = Effect.gen(function* () { const azure = yield* AzureDevOpsCli.AzureDevOpsCli; + const { git } = yield* SourceControlHost.SourceControlHost; return SourceControlProvider.SourceControlProvider.of({ kind: SourceControlProviderKind.make("azure-devops"), + readChangeRequestTemplate: Effect.fn( + "AzureDevOpsSourceControlProvider.readChangeRequestTemplate", + )( + function* ({ cwd }) { + // Azure uses the repository default branch even when the PR targets another branch. + const remoteName = yield* git.resolvePrimaryRemoteName(cwd); + const defaultRef = yield* git.execute({ + operation: "AzureDevOpsSourceControlProvider.resolveDefaultBranch", + cwd, + args: ["symbolic-ref", `refs/remotes/${remoteName}/HEAD`], + }); + return yield* AzureDevOpsChangeRequestTemplate.detect( + cwd, + defaultRef.stdout.trim(), + git.execute, + ); + }, + Effect.orElseSucceed(() => Option.none()), + ), listChangeRequests: Effect.fn("AzureDevOpsSourceControlProvider.listChangeRequests")( function (input) { const source = SourceControlProvider.sourceControlRefFromInput(input); diff --git a/packages/source-control-azure-devops/src/server/azureDevOpsChangeRequestTemplate.ts b/packages/source-control-azure-devops/src/server/azureDevOpsChangeRequestTemplate.ts new file mode 100644 index 0000000000000..af56eabe5c426 --- /dev/null +++ b/packages/source-control-azure-devops/src/server/azureDevOpsChangeRequestTemplate.ts @@ -0,0 +1,81 @@ +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; + +import type * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; + +const TEMPLATE_DIRECTORIES = [".azuredevops", ".vsts", "docs", ""]; +const TEMPLATE_MAX_BYTES = 8_000; +const TREE_LIST_MAX_BYTES = 100_000; +const TRUNCATION_MARKER = "[truncated]"; + +type ExecuteGit = SourceControlHost.SourceControlHost["Service"]["git"]["execute"]; + +export const detect = Effect.fn("azureDevOpsChangeRequestTemplate.detect")( + function* (cwd: string, treeish: string, executeGit: ExecuteGit) { + // Inspect only the root and template directories; never recurse through the repository. + const root = yield* executeGit({ + operation: "azureDevOpsChangeRequestTemplate.listTemplates", + cwd, + args: ["ls-tree", "-z", "--full-tree", treeish], + maxOutputBytes: TREE_LIST_MAX_BYTES, + appendTruncationMarker: true, + }); + if (root.stdoutTruncated) { + return Option.none(); + } + + const rootEntries = root.stdout.split("\0"); + let objectId: string | undefined; + for (const directory of TEMPLATE_DIRECTORIES) { + let entries = rootEntries; + if (directory !== "") { + const trees = rootEntries.flatMap((record) => { + const entry = /^040000 tree ([0-9a-f]{40,64})\t(.+)$/.exec(record); + return entry?.[1] && entry[2]?.toLowerCase() === directory ? [entry[1]] : []; + }); + entries = []; + for (const tree of trees) { + const listing = yield* executeGit({ + operation: "azureDevOpsChangeRequestTemplate.listTemplates", + cwd, + args: ["ls-tree", "-z", "--full-tree", tree], + maxOutputBytes: TREE_LIST_MAX_BYTES, + appendTruncationMarker: true, + }); + if (listing.stdoutTruncated) { + return Option.none(); + } + entries.push(...listing.stdout.split("\0")); + } + } + // Only regular committed blobs qualify, so symlinks never reach the host filesystem. + objectId = entries + .map((record) => /^100(?:644|755) blob ([0-9a-f]{40,64})\t(.+)$/.exec(record)) + .find((entry) => /^pull_request_template\.(?:md|txt)$/i.test(entry?.[2] ?? ""))?.[1]; + if (objectId) { + break; + } + } + if (!objectId) { + return Option.none(); + } + + const blob = yield* executeGit({ + operation: "azureDevOpsChangeRequestTemplate.readTemplate", + cwd, + args: ["cat-file", "blob", objectId], + maxOutputBytes: TEMPLATE_MAX_BYTES, + appendTruncationMarker: true, + }); + const template = blob.stdout.trim(); + if (template.length === 0) { + return Option.none(); + } + return Option.some( + blob.stdoutTruncated && !template.endsWith(TRUNCATION_MARKER) + ? `${template}\n\n${TRUNCATION_MARKER}` + : template, + ); + }, + Effect.orElseSucceed(() => Option.none()), +);