diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 660e2de9cbd8..4c3012db2e28 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -3286,6 +3286,80 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); + it.effect("creates PR with a text template when one does not already exist", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + NodeFS.mkdirSync(NodePath.join(repoDir, ".github")); + NodeFS.writeFileSync( + NodePath.join(repoDir, ".github", "PuLl_ReQuEsT_TeMpLaTe.TXT"), + "## What changed?\n\n## Verification", + ); + yield* runGit(repoDir, ["add", ".github/PuLl_ReQuEsT_TeMpLaTe.TXT"]); + yield* runGit(repoDir, ["commit", "-m", "Add pull request template"]); + yield* runGit(repoDir, ["checkout", "-b", "feature-create-pr"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + NodeFS.writeFileSync(NodePath.join(repoDir, "changes.txt"), "change\n"); + yield* runGit(repoDir, ["add", "changes.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature-create-pr"]); + yield* runGit(repoDir, ["config", "branch.feature-create-pr.gh-merge-base", "main"]); + let generatedPolicy: TextGeneration.PrContentGenerationInput["policy"] = undefined; + let generatedChangeRequestTemplate: string | undefined; + + const { manager, ghCalls } = yield* makeManager({ + serverSettings: { + sourceControlWritingStyle: { + mode: "custom" as const, + customInstructions: "Lead with user impact.", + }, + }, + textGeneration: { + generatePrContent: (input) => { + generatedPolicy = input.policy; + generatedChangeRequestTemplate = input.changeRequestTemplate; + return Effect.succeed({ + title: "Add stacked git actions", + body: "## What changed?\nAdded stacked git actions.", + }); + }, + }, + ghScenario: { + prListSequence: [ + "[]", + JSON.stringify([ + { + number: 88, + title: "Add stacked git actions", + url: "https://github.com/pingdotgg/codething-mvp/pull/88", + baseRefName: "main", + headRefName: "feature-create-pr", + }, + ]), + ], + }, + }); + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push_pr", + }); + + expect(result.branch.status).toBe("skipped_not_requested"); + expect(result.pr.status).toBe("created"); + expect(result.pr.number).toBe(88); + expect(generatedPolicy).toMatchObject({ + changeRequestInstructions: "Lead with user impact.", + }); + expect(generatedChangeRequestTemplate).toBe("## What changed?\n\n## Verification"); + expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(2); + expect( + ghCalls.some((call) => call.includes("pr create --base main --head feature-create-pr")), + ).toBe(true); + expect(ghCalls.some((call) => call.startsWith("pr view "))).toBe(false); + }), + ); + it.effect("create_pr sends bounded checks to the model and omits rolled-back runs", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/sourceControl/gitHubChangeRequestTemplate.test.ts b/apps/server/src/sourceControl/gitHubChangeRequestTemplate.test.ts index faba4ffdc5f1..5303f44b0c93 100644 --- a/apps/server/src/sourceControl/gitHubChangeRequestTemplate.test.ts +++ b/apps/server/src/sourceControl/gitHubChangeRequestTemplate.test.ts @@ -12,6 +12,21 @@ import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as GitHubChangeRequestTemplate from "@t3tools/source-control-github/server/gitHubChangeRequestTemplate"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; +const SINGLE_TEMPLATE_PATHS = [ + ".github/pull_request_template.txt", + ".github/PULL_REQUEST_TEMPLATE.txt", + ".github/PULL_REQUEST_TEMPLATE.TXT", + ".github/PuLl_ReQuEsT_TeMpLaTe.TxT", + "pull_request_template.txt", + "PULL_REQUEST_TEMPLATE.txt", + "PULL_REQUEST_TEMPLATE.TXT", + "PuLl_ReQuEsT_TeMpLaTe.TxT", + "docs/pull_request_template.txt", + "docs/PULL_REQUEST_TEMPLATE.txt", + "docs/PULL_REQUEST_TEMPLATE.TXT", + "docs/PuLl_ReQuEsT_TeMpLaTe.TxT", +] as const; + const TEMPLATE_DIRECTORIES = [ ".github/PULL_REQUEST_TEMPLATE", "PULL_REQUEST_TEMPLATE", @@ -74,6 +89,108 @@ const detectTemplate = (cwd: string, treeish = "HEAD") => return yield* GitHubChangeRequestTemplate.detect(cwd, treeish, git.execute); }); +it.effect.each(SINGLE_TEMPLATE_PATHS)("recognizes $0", (relativePath) => + runWithTempDirectory((cwd) => + Effect.gen(function* () { + yield* writeTemplate(cwd, relativePath, `template from ${relativePath}`); + yield* commitTemplates(cwd); + + const template = yield* detectTemplate(cwd); + assert.strictEqual(Option.getOrUndefined(template), `template from ${relativePath}`); + }), + ), +); + +it.effect.each([".github/PuLl_ReQuEsT_TeMpLaTe.MD", "docs/PULL_REQUEST_TEMPLATE.Md"])( + "recognizes Markdown case variant $0", + (relativePath) => + runWithTempDirectory((cwd) => + Effect.gen(function* () { + yield* writeTemplate(cwd, relativePath, "Markdown template"); + yield* commitTemplates(cwd); + + assert.strictEqual(Option.getOrUndefined(yield* detectTemplate(cwd)), "Markdown template"); + }), + ), +); + +it.effect("finds case variants from a repository subdirectory", () => + runWithTempDirectory((cwd) => + Effect.gen(function* () { + const path = yield* Path.Path; + yield* writeTemplate(cwd, ".github/Pull_Request_Template.TXT", "base template"); + yield* writeTemplate(cwd, "nested/README.md", "project"); + yield* commitTemplates(cwd); + + assert.strictEqual( + Option.getOrUndefined(yield* detectTemplate(path.join(cwd, "nested"))), + "base template", + ); + }), + ), +); + +it.effect.each([ + ".github/pull_request_template.md", + ".github/PuLl_ReQuEsT_TeMpLaTe.TxT", + "pull_request_template.md", + "docs/pull_request_template.md", +])("preserves $0 when unrelated direct documents exhaust discovery", (relativePath) => + runWithTempDirectory((cwd) => + Effect.gen(function* () { + yield* writeTemplate(cwd, relativePath, "GitHub template"); + for (let index = 0; index < 600; index += 1) { + yield* writeTemplate(cwd, `docs/${index}-${"a".repeat(180)}.md`, "guide"); + } + yield* commitTemplates(cwd); + + assert.strictEqual(Option.getOrUndefined(yield* detectTemplate(cwd)), "GitHub template"); + }), + ), +); + +it.effect("does not let unrelated nested documents exhaust the template listing", () => + runWithTempDirectory((cwd) => + Effect.gen(function* () { + yield* writeTemplate(cwd, "docs/Pull_Request_Template.TXT", "docs template"); + for (let index = 0; index < 600; index += 1) { + yield* writeTemplate(cwd, `docs/guides/${index}-${"a".repeat(180)}.md`, "guide"); + } + yield* commitTemplates(cwd); + + assert.strictEqual(Option.getOrUndefined(yield* detectTemplate(cwd)), "docs template"); + }), + ), +); + +it.effect.each([".github", "docs"])( + "finds a mixed-case template in a crowded $0 directory", + (location) => + runWithTempDirectory((cwd) => + Effect.gen(function* () { + yield* writeTemplate(cwd, `${location}/PULL_REQUEST_TEMPLATE.TXT`, "text template"); + for (let index = 0; index < 600; index += 1) { + yield* writeTemplate(cwd, `${location}/${index}-${"a".repeat(180)}.md`, "guide"); + } + yield* commitTemplates(cwd); + + assert.strictEqual(Option.getOrUndefined(yield* detectTemplate(cwd)), "text template"); + }), + ), +); + +it.effect("prefers an existing exact filename over an additional case variant", () => + runWithTempDirectory((cwd) => + Effect.gen(function* () { + yield* writeTemplate(cwd, ".github/PULL_REQUEST_TEMPLATE.MD", "case variant"); + yield* writeTemplate(cwd, ".github/PULL_REQUEST_TEMPLATE.md", "existing spelling"); + yield* commitTemplates(cwd); + + assert.strictEqual(Option.getOrUndefined(yield* detectTemplate(cwd)), "existing spelling"); + }), + ), +); + it.effect("reads templates from the requested base tree", () => runWithTempDirectory((cwd) => Effect.gen(function* () { @@ -107,10 +224,14 @@ it.effect("uses the first non-empty template in the configured path order", () = ), ); -it.effect.each(TEMPLATE_DIRECTORIES)("recognizes the $0 directory", (relativeDirectory) => +it.effect.each( + TEMPLATE_DIRECTORIES.flatMap((directory) => + ["MD", "TXT"].map((extension) => `${directory}/template.${extension}`), + ), +)("recognizes directory template $0", (relativePath) => runWithTempDirectory((cwd) => Effect.gen(function* () { - yield* writeTemplate(cwd, `${relativeDirectory}/template.MD`, "directory template"); + yield* writeTemplate(cwd, relativePath, "directory template"); yield* commitTemplates(cwd); const template = yield* detectTemplate(cwd); @@ -144,6 +265,19 @@ it.effect.skipIf(!symlinksSupported)( ), ); +it.effect("does not guess between Markdown and text directory templates", () => + runWithTempDirectory((cwd) => + Effect.gen(function* () { + yield* writeTemplate(cwd, ".github/PULL_REQUEST_TEMPLATE/a.md", "first"); + yield* writeTemplate(cwd, ".github/PULL_REQUEST_TEMPLATE/b.txt", "second"); + yield* commitTemplates(cwd); + + const template = yield* detectTemplate(cwd); + assert.isTrue(Option.isNone(template)); + }), + ), +); + it.effect("does not guess between multiple directory templates", () => runWithTempDirectory((cwd) => Effect.gen(function* () { diff --git a/packages/source-control-github/src/server/gitHubChangeRequestTemplate.ts b/packages/source-control-github/src/server/gitHubChangeRequestTemplate.ts index 5b81c198da5a..edfd65815b14 100644 --- a/packages/source-control-github/src/server/gitHubChangeRequestTemplate.ts +++ b/packages/source-control-github/src/server/gitHubChangeRequestTemplate.ts @@ -11,10 +11,16 @@ const TRUNCATION_MARKER = "[truncated]"; const TEMPLATE_PATHS = [ ".github/pull_request_template.md", ".github/PULL_REQUEST_TEMPLATE.md", + ".github/pull_request_template.txt", + ".github/PULL_REQUEST_TEMPLATE.txt", "pull_request_template.md", "PULL_REQUEST_TEMPLATE.md", + "pull_request_template.txt", + "PULL_REQUEST_TEMPLATE.txt", "docs/pull_request_template.md", "docs/PULL_REQUEST_TEMPLATE.md", + "docs/pull_request_template.txt", + "docs/PULL_REQUEST_TEMPLATE.txt", ] as const; const TEMPLATE_DIRECTORIES = [ @@ -23,7 +29,7 @@ const TEMPLATE_DIRECTORIES = [ "docs/PULL_REQUEST_TEMPLATE", ] as const; -const TREE_PATHS = [...TEMPLATE_PATHS, ...TEMPLATE_DIRECTORIES] as const; +const TEMPLATE_LOCATIONS = [".github/", "", "docs/"] as const; type ExecuteGit = SourceControlHost.SourceControlHost["Service"]["git"]["execute"]; @@ -105,7 +111,7 @@ const readTemplateDirectory = Effect.fn("gitHubChangeRequestTemplate.readTemplat return false; } const relativePath = entry.path.slice(prefix.length); - return !relativePath.includes("/") && relativePath.toLowerCase().endsWith(".md"); + return !relativePath.includes("/") && /\.(?:md|txt)$/i.test(relativePath); }); const templates: string[] = []; @@ -133,31 +139,82 @@ export const detect = Effect.fn("gitHubChangeRequestTemplate.detect")(function* return yield* Effect.gen(function* () { // Worktree paths can be replaced between validation and open. Read regular blobs from the // committed base tree so repository-controlled symlinks and path races never reach the host filesystem. - const result = yield* executeGit({ - operation: "gitHubChangeRequestTemplate.listTemplates", - cwd, - args: ["ls-tree", "-r", "-z", "--full-tree", treeish, "--", ...TREE_PATHS], - maxOutputBytes: TREE_LIST_MAX_BYTES, - appendTruncationMarker: true, - }); - if (result.stdoutTruncated) { - return Option.none(); - } + const listTemplates = (paths: ReadonlyArray) => + executeGit({ + operation: "gitHubChangeRequestTemplate.listTemplates", + cwd, + args: ["ls-tree", "-z", "--full-tree", treeish, "--", ...paths], + maxOutputBytes: TREE_LIST_MAX_BYTES, + appendTruncationMarker: true, + }); - const entries = parseTemplateTreeEntries(result.stdout); - const entriesByPath = new Map(entries.map((entry) => [entry.path, entry])); - for (const templatePath of TEMPLATE_PATHS) { - const entry = entriesByPath.get(templatePath); - if (!entry) { - continue; + // A lower-priority directory must not exhaust an earlier template's listing budget. + for (const location of TEMPLATE_LOCATIONS) { + const templatePaths = TEMPLATE_PATHS.filter( + (path) => path.slice(0, path.lastIndexOf("/") + 1) === location, + ); + let result = yield* listTemplates([location || "."]); + if (result.stdoutTruncated) { + // grep supports case-insensitive literal pathspecs; ls-tree does not. It returns only + // matching filenames, keeping unrelated entries out of the fallback's output budget. + const paths = yield* executeGit({ + operation: "gitHubChangeRequestTemplate.findTemplatePaths", + cwd, + args: [ + "grep", + "-l", + "-z", + "--full-name", + "--no-recurse-submodules", + "-e", + "^", + treeish, + "--", + ...templatePaths.map((path) => `:(top,icase,literal)${path}`), + ], + maxOutputBytes: TREE_LIST_MAX_BYTES, + }).pipe( + Effect.map((output) => + output.stdoutTruncated + ? [] + : output.stdout + .split("\0") + .filter((path) => path.startsWith(`${treeish}:`)) + .map((path) => path.slice(treeish.length + 1)), + ), + // No matching nonempty files makes git grep exit with status 1. + Effect.orElseSucceed(() => []), + ); + result = yield* listTemplates([...new Set([...templatePaths, ...paths])]); } - const template = yield* readTemplateBlob({ cwd, executeGit, entry }); - if (Option.isSome(template)) { - return template; + if (result.stdoutTruncated) { + return Option.none(); + } + + const entries = parseTemplateTreeEntries(result.stdout); + const templateNames = new Set(templatePaths.map((path) => path.toLowerCase())); + const candidates = [ + ...templatePaths.flatMap((path) => entries.filter((entry) => entry.path === path)), + ...entries.filter( + (entry) => + !templatePaths.some((path) => path === entry.path) && + templateNames.has(entry.path.toLowerCase()), + ), + ]; + for (const entry of candidates) { + const template = yield* readTemplateBlob({ cwd, executeGit, entry }); + if (Option.isSome(template)) { + return template; + } } } for (const directory of TEMPLATE_DIRECTORIES) { + const result = yield* listTemplates([`${directory}/`]); + if (result.stdoutTruncated) { + return Option.none(); + } + const entries = parseTemplateTreeEntries(result.stdout); const directoryTemplate = yield* readTemplateDirectory({ cwd, executeGit,