Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions apps/server/src/git/GitManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5215,16 +5215,16 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
}),
);

it.effect("creates PR when one does not already exist", () =>
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.md"),
NodePath.join(repoDir, ".github", "PuLl_ReQuEsT_TeMpLaTe.TXT"),
"## What changed?\n\n## Verification",
);
yield* runGit(repoDir, ["add", ".github/pull_request_template.md"]);
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();
Expand Down
95 changes: 93 additions & 2 deletions apps/server/src/sourceControl/gitHubChangeRequestTemplate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,22 @@ import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
const SINGLE_TEMPLATE_PATHS = [
".github/pull_request_template.md",
".github/PULL_REQUEST_TEMPLATE.md",
".github/pull_request_template.txt",
".github/PULL_REQUEST_TEMPLATE.txt",
".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",
"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",
"docs/PULL_REQUEST_TEMPLATE.TXT",
"docs/PuLl_ReQuEsT_TeMpLaTe.TxT",
] as const;

const TEMPLATE_DIRECTORIES = [
Expand Down Expand Up @@ -95,6 +107,68 @@ it.effect.each(SINGLE_TEMPLATE_PATHS)("recognizes $0", (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("reads templates from the requested base tree", () =>
runWithTempDirectory((cwd) =>
Effect.gen(function* () {
Expand Down Expand Up @@ -128,10 +202,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);
Expand Down Expand Up @@ -165,6 +243,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* () {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
".github/PULL_REQUEST_TEMPLATE.txt",
Comment on lines +14 to +15

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Match text template filenames case-insensitively

When a repository commits .github/PULL_REQUEST_TEMPLATE.TXT (or any mixed-case stem/extension), GitHub accepts it because pull request template filenames are case-insensitive, but this exact-path list only asks ls-tree for two stem casings with lowercase .txt; the tree entry is therefore absent and template detection silently returns none. List the containing locations and compare single-file names case-insensitively, as the directory-template regex already does.

Useful? React with 👍 / 👎.

"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 = [
Expand All @@ -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"];

Expand Down Expand Up @@ -105,7 +111,7 @@ function readTemplateDirectory(input: {
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[] = [];
Expand Down Expand Up @@ -133,31 +139,51 @@ 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<string>) =>
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) {
result = yield* listTemplates(templatePaths);
}
const template = yield* readTemplateBlob({ cwd, executeGit, entry });
if (Option.isSome(template)) {
return template;
if (result.stdoutTruncated) {
return Option.none();
}

const entries = parseTemplateTreeEntries(result.stdout);
for (const templatePath of templatePaths) {
const candidates = entries
.filter((entry) => entry.path.toLowerCase() === templatePath.toLowerCase())
.toSorted(
(left, right) =>
Number(right.path === templatePath) - Number(left.path === templatePath),
);
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,
Expand Down
Loading