Repository navigation
Description
Before submitting
- I searched existing issues and did not find a duplicate.
- I included enough detail to reproduce or investigate the problem.
Area
apps/server
Steps to reproduce
Use a checkout of the commit under Version or commit with its lockfile dependencies installed. Save the script below outside the checkout as repro.sh and run env -u NODE_ENV sh /path/to/repro.sh from the checkout root. It creates a private HOME, TMPDIR and T3CODE_HOME under a mktemp directory (set VERIFY_TMP_PARENT to choose its parent), installs one temporary test at packages/source-control-github/src/server/verifyTemplateDirectoryCase.test.ts (refusing to overwrite an existing file), runs only that test with the root project, and removes the test and fixtures on exit.
For each case the test creates a fresh Git repository, commits the fixture to main, switches to a feature branch, and calls the exported production detect(cwd, "main", git.execute) from gitHubChangeRequestTemplate.ts with real Git subprocesses. That is the call the GitHub provider makes when reading the change request template. The test then passes the detected value to the production buildPrContentPrompt and checks which body instructions it selects. No server, browser, live model or PR creation runs.
- Commit exactly one nonempty template at
.github/pull_request_template/feature.md. The detector returns no template and the prompt uses the generic Summary/Testing rules. - Control: commit the same contents at
.github/PULL_REQUEST_TEMPLATE/feature.md. The detector returns the contents and the prompt includes the repository template. - Repeat both spellings at the repository root (
pull_request_template/,PULL_REQUEST_TEMPLATE/) and underdocs/, with the same split. The script also recordsPull_Request_Template/spellings, which fail the same way locally. - Controls for intended behavior: a mixed-case single file
.github/PuLl_ReQuEsT_TeMpLaTe.TxTis found; a rootpull_request_template.mdbeside either directory spelling is selected first; and two templates in one directory return none for both spellings, as the existing "does not guess between multiple directory templates" test intends.
The script prints one result line per case and exits 0 only when all 14 cases match these current results.
Reproduction script: repro.sh
#!/bin/sh
# Run from the root of an installed-dependency t3code checkout at the reported commit.
set -eu
checkout=$(pwd)
VERIFY_TMP_PARENT=${VERIFY_TMP_PARENT:-${TMPDIR:-/tmp}}
tmp=$(mktemp -d "$VERIFY_TMP_PARENT/template-probe.XXXXXX")
export HOME="$tmp/profile" TMPDIR="$tmp/tmp" T3CODE_HOME="$tmp/t3state"
mkdir -p "$HOME" "$TMPDIR" "$T3CODE_HOME"
export TEMPLATE_CASE_RESULTS="$tmp/results.txt"
probe="$checkout/packages/source-control-github/src/server/verifyTemplateDirectoryCase.test.ts"
if [ -e "$probe" ]; then echo "Refusing to replace existing probe" >&2; exit 1; fi
cleanup() { rm -f "$probe"; rm -rf "$tmp"; }
trap cleanup EXIT INT TERM
cat > "$probe" <<'TS'
import { appendFileSync } from "node:fs";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";
import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost";
import { buildPrContentPrompt } from "../../../provider-core/src/server/textGenerationPrompts.ts";
import { detect } from "./gitHubChangeRequestTemplate.ts";
const layer = Layer.merge(TestSourceControlHost.layerWithRealGit(), NodeServices.layer);
const template = "## Repository-specific context\n\n## Repository-specific verification";
const cases = [
["CONTROL", ".github/PULL_REQUEST_TEMPLATE/feature.md", template],
["CONTROL", "PULL_REQUEST_TEMPLATE/feature.md", template],
["CONTROL", "docs/PULL_REQUEST_TEMPLATE/feature.md", template],
["FAILURE", ".github/pull_request_template/feature.md", undefined],
["FAILURE", "pull_request_template/feature.md", undefined],
["FAILURE", "docs/pull_request_template/feature.md", undefined],
["FAILURE", ".github/Pull_Request_Template/feature.md", undefined],
["FAILURE", "Pull_Request_Template/feature.md", undefined],
["FAILURE", "docs/Pull_Request_Template/feature.md", undefined],
["SINGLE-FILE CONTROL", ".github/PuLl_ReQuEsT_TeMpLaTe.TxT", template],
["LOWERCASE + SINGLE-FILE", ".github/pull_request_template/feature.md", "single-file template"],
["CANONICAL + SINGLE-FILE", ".github/PULL_REQUEST_TEMPLATE/feature.md", "single-file template"],
["AMBIGUOUS CONTROL", ".github/PULL_REQUEST_TEMPLATE/feature.md", undefined],
["AMBIGUOUS LOWERCASE", ".github/pull_request_template/feature.md", undefined],
] as const;
it.effect.each(cases)("template directory case $0", ([kind, relativePath, expected]) =>
Effect.scoped(Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const { git } = yield* SourceControlHost.SourceControlHost;
const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "repo-" });
const run = (args: ReadonlyArray<string>) => git.execute({operation:"verify.template", cwd, args});
const write = (relative: string, content: string) => Effect.gen(function* () {
const file = path.join(cwd, relative);
yield* fs.makeDirectory(path.dirname(file), {recursive: true});
yield* fs.writeFileString(file, content);
});
yield* run(["init", "--initial-branch=main"]);
yield* run(["config", "user.name", "Template verification"]);
yield* run(["config", "user.email", "template-fixture.invalid"]);
yield* write(relativePath, template);
if (kind.includes("+ SINGLE-FILE")) yield* write("pull_request_template.md", "single-file template");
if (kind.startsWith("AMBIGUOUS")) yield* write(relativePath.replace("feature.md", "second.md"), "second template");
yield* run(["add", "--all"]);
yield* run(["-c", "commit.gpgsign=false", "commit", "-m", "fixture"]);
// A production caller supplies the selected base tree; keep HEAD independently on feature.
yield* run(["checkout", "-b", "feature"]);
const actual = Option.getOrUndefined(yield* detect(cwd, "main", git.execute));
assert.strictEqual(actual, expected);
const prompt = buildPrContentPrompt({baseBranch:"main",headBranch:"feature",commitSummary:"fixture",diffSummary:"",diffPatch:"",...(actual ? {changeRequestTemplate:actual}: {})}).prompt;
assert.strictEqual(prompt.includes("Repository change request template:"), actual !== undefined);
assert.strictEqual(prompt.includes("include headings '## Summary' and '## Testing'"), actual === undefined);
appendFileSync(process.env.TEMPLATE_CASE_RESULTS!, `${kind} ${relativePath} -> ${actual ? JSON.stringify(actual) : "<none>"}; prompt=${actual ? "repository-template" : "generic Summary/Testing"}\n`);
})).pipe(Effect.provide(layer)),
);
TS
cd "$checkout"
echo "REVISION $(git rev-parse HEAD)"
echo "PLATFORM $(uname -sm); NODE $(node --version); $(git --version)"
env -u NODE_ENV "$checkout/node_modules/.bin/vp" test run packages/source-control-github/src/server/verifyTemplateDirectoryCase.test.ts --maxWorkers=1 --project=root
cat "$TEMPLATE_CASE_RESULTS"
[ "$(wc -l < "$TEMPLATE_CASE_RESULTS" | tr -d " ")" = 14 ]Expected behavior
With template following enabled, a usable singleton template in a lowercase pull_request_template/ directory reaches PR-description generation just as it does with the PULL_REQUEST_TEMPLATE/ spelling. The setting is described as "Use the repository's pull request template when it has one." in the source control writing settings and defaults to true, and the existing directory test establishes that one template in a template directory is used.
GitHub treats the lowercase directory as a template directory in practice. For example, a public repository has .github/pull_request_template/ip-compliance.md as the only entry in that directory (file), and GitHub's GraphQL Repository.pullRequestTemplates returns ip-compliance.md with its body for that repository. GitHub's documentation calls template file names case-insensitive and shows the directory as PULL_REQUEST_TEMPLATE; it does not state directory-name casing explicitly, so this expectation rests on the observed API result. In the first 200 GitHub code-search results for path:.github/pull_request_template/, four paths in three repositories use the lowercase directory; that shows real use, not a share of all repositories. Whether GitHub accepts mixed-case directory names such as Pull_Request_Template/ was not tested, so this report is about lowercase directories.
Unchanged by this report: a usable single-file template still takes precedence over directory templates, and multiple usable directory templates still return none.
Actual behavior
Summary. GitHub recognizes a pull request template stored in a lowercase .github/pull_request_template/ directory, but T3 Code's GitHub template detector does not. With "Follow change request templates" enabled, a repository whose only template is .github/pull_request_template/feature.md gets no template passed to PR-description generation, so the generator falls back to its generic ## Summary / ## Testing instructions instead of the repository's template. The same file in .github/PULL_REQUEST_TEMPLATE/ is found. The lowercase spelling also fails at the repository root and under docs/. This is not a regression: #25025 and its predecessor #21449 made single-file template names case-insensitive and accepted .txt in template directories, but neither changed or promised directory-name matching, and neither has a linked issue.
Observed in the reproduction: detect returned no template for .github/pull_request_template/feature.md, pull_request_template/feature.md and docs/pull_request_template/feature.md, while the uppercase singleton directories returned the committed contents. For each missed template, the production prompt builder selected the generic ## Summary / ## Testing body rules instead of "follow the repository change request template structure".
In the PR-creation path, a missing template is omitted from generatePrContent, and the prompt builder then requests the generic sections. The effect is that the repository's template is missing from the generation input; it does not by itself produce an empty PR body. No live model generation or published PR was run, so final description text is not asserted. The path applies to a new PR from a pushed branch with template following enabled, the singleton template committed in the selected base tree, and no usable single-file template.
Evidence
- Expected source: the setting copy and default and the singleton-directory test linked under Expected behavior, plus GitHub's observed
pullRequestTemplatesresult for the public lowercase-directory example. - Failure source: the observed reproduction output under Logs or stack traces;
TEMPLATE_DIRECTORIESlists onlyPULL_REQUEST_TEMPLATEspellings, which are passed as case-sensitivegit ls-treepathspecs and then matched with an exact-case prefix check. These locators are a hypothesis for where lowercase entries drop out, not a prescribed fix. - Evidence provenance: observed
- Local verification: reproduced
- Reproduction completeness: complete
An earlier copy of this script, differing only in its private temporary directory names, produced identical result lines on the first main build under Version or commit. On the later main build the detector file is unchanged, by commit history only: its last commit is #25025, which predates the tested commit.
Restoration check
With the script's input, .github/pull_request_template/feature.md, pull_request_template/feature.md and docs/pull_request_template/feature.md each return the committed template contents and the prompt includes the repository template, matching the uppercase controls. The controls must keep their current results: a usable single-file template is still chosen before either directory spelling, and two templates in a lowercase directory still return none. The script asserts the current failing results, so its expectations for the three lowercase FAILURE cases need updating to the template contents for this check.
Impact
Minor bug or occasional failure
Version or commit
0.1.9 at d9805b2, rerun from a clean checkout; an earlier copy of the same script reproduced on main at e93f2a5; the detector file is unchanged on main at 9771c5f by commit history only
Environment
Darwin arm64, Node v26.12.1, Git 2.56.0, repository-pinned Vitest 5.0.5 run through vp test; production detector and prompt builder against temporary Git repositories, with no server, browser, desktop app, live model or PR creation
Logs or stack traces
The script's result lines and test summary from the clean-checkout rerun:
REVISION d9805b2ca667be7caec8c9035950564ab96704c3
Test Files 1 passed (1)
Tests 14 passed (14)
CONTROL .github/PULL_REQUEST_TEMPLATE/feature.md -> "## Repository-specific context\n\n## Repository-specific verification"; prompt=repository-template
CONTROL PULL_REQUEST_TEMPLATE/feature.md -> "## Repository-specific context\n\n## Repository-specific verification"; prompt=repository-template
CONTROL docs/PULL_REQUEST_TEMPLATE/feature.md -> "## Repository-specific context\n\n## Repository-specific verification"; prompt=repository-template
FAILURE .github/pull_request_template/feature.md -> <none>; prompt=generic Summary/Testing
FAILURE pull_request_template/feature.md -> <none>; prompt=generic Summary/Testing
FAILURE docs/pull_request_template/feature.md -> <none>; prompt=generic Summary/Testing
FAILURE .github/Pull_Request_Template/feature.md -> <none>; prompt=generic Summary/Testing
FAILURE Pull_Request_Template/feature.md -> <none>; prompt=generic Summary/Testing
FAILURE docs/Pull_Request_Template/feature.md -> <none>; prompt=generic Summary/Testing
SINGLE-FILE CONTROL .github/PuLl_ReQuEsT_TeMpLaTe.TxT -> "## Repository-specific context\n\n## Repository-specific verification"; prompt=repository-template
LOWERCASE + SINGLE-FILE .github/pull_request_template/feature.md -> "single-file template"; prompt=repository-template
CANONICAL + SINGLE-FILE .github/PULL_REQUEST_TEMPLATE/feature.md -> "single-file template"; prompt=repository-template
AMBIGUOUS CONTROL .github/PULL_REQUEST_TEMPLATE/feature.md -> <none>; prompt=generic Summary/Testing
AMBIGUOUS LOWERCASE .github/pull_request_template/feature.md -> <none>; prompt=generic Summary/TestingScreenshots, recordings, or supporting files
No response
Workaround
Rename the directory to PULL_REQUEST_TEMPLATE (for example .github/PULL_REQUEST_TEMPLATE/) and commit it to the base branch; the reproduction's uppercase controls return the same contents. A usable single-file template such as .github/pull_request_template.md is also selected before directory templates.
Activity