From a00463e4cf30c23f6362a384436c575e1bbd8323 Mon Sep 17 00:00:00 2001 From: Aei <256851514+aeitwoen@users.noreply.github.com> Date: Fri, 11 Sep 2026 15:17:11 +0200 Subject: [PATCH] fix: honor GitHub's single issue template file --- src/providers/github.ts | 54 ++++++++++++++---- test/github.test.ts | 120 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 162 insertions(+), 12 deletions(-) diff --git a/src/providers/github.ts b/src/providers/github.ts index a40bc06..467d324 100644 --- a/src/providers/github.ts +++ b/src/providers/github.ts @@ -51,7 +51,7 @@ import { normalizeChangedFileStatus } from "../changed-file.ts"; const MAX_COMMIT_FILE_PAGES = 30; const GITHUB_SEARCH_RESULT_LIMIT = 1000; const GITHUB_ISSUE_TEMPLATE_DIRECTORY = ".github/ISSUE_TEMPLATE"; -const GITHUB_PULL_REQUEST_TEMPLATE_LOCATIONS = [".github", "", "docs"] as const; +const GITHUB_COMMUNITY_FILE_DIRECTORIES = [".github", "", "docs"] as const; // --- GitHub API response types (snake_case) --- @@ -584,17 +584,47 @@ export class GitHubProvider extends Provider { const hasConfiguration = entries.some( (entry) => entry.type === "file" && /^config\.ya?ml$/iu.test(entry.name), ); - return { - templates: files.map((file) => - this.templateSummary( - "issue", - repository.full_name, - repository.default_branch, - file, - inherited, + if (files.length > 0 || hasConfiguration) { + return { + templates: files.map((file) => + this.templateSummary( + "issue", + repository.full_name, + repository.default_branch, + file, + inherited, + ), ), + overrides: true, + }; + } + + const listings = await Promise.all( + GITHUB_COMMUNITY_FILE_DIRECTORIES.map((location) => + this.tryListContents(repository.full_name, location, repository.default_branch), ), - overrides: files.length > 0 || hasConfiguration, + ); + const singular = listings + .flatMap((listing) => + listing.filter( + (entry) => entry.type === "file" && /^issue_template(?:\.[^/]+)?$/iu.test(entry.name), + ), + ) + .at(0); + return { + templates: + singular === undefined + ? [] + : [ + this.templateSummary( + "issue", + repository.full_name, + repository.default_branch, + singular, + inherited, + ), + ], + overrides: singular !== undefined, }; } @@ -603,12 +633,12 @@ export class GitHubProvider extends Provider { inherited: boolean, ): Promise<{ templates: ContributionTemplateSummary[]; overrides: boolean }> { const baseEntries = await Promise.all( - GITHUB_PULL_REQUEST_TEMPLATE_LOCATIONS.map((location) => + GITHUB_COMMUNITY_FILE_DIRECTORIES.map((location) => this.tryListContents(repository.full_name, location, repository.default_branch), ), ); const templateDirectories = await Promise.all( - GITHUB_PULL_REQUEST_TEMPLATE_LOCATIONS.map((location) => { + GITHUB_COMMUNITY_FILE_DIRECTORIES.map((location) => { const path = location === "" ? "PULL_REQUEST_TEMPLATE" : `${location}/PULL_REQUEST_TEMPLATE`; return this.tryListContents(repository.full_name, path, repository.default_branch); diff --git a/test/github.test.ts b/test/github.test.ts index 6cff478..5b3cae7 100644 --- a/test/github.test.ts +++ b/test/github.test.ts @@ -238,6 +238,13 @@ describe("GitHubProvider", () => { if (url === "/repos/octocat/hello-world/contents/.github/ISSUE_TEMPLATE") { throw makeFetchError(404); } + if ( + url === "/repos/octocat/hello-world/contents/.github" || + url === "/repos/octocat/hello-world/contents" || + url === "/repos/octocat/hello-world/contents/docs" + ) { + return []; + } if (url === "/repos/octocat/.github/contents/.github/ISSUE_TEMPLATE") { return [ contentFile("config.yml", ".github/ISSUE_TEMPLATE/config.yml"), @@ -302,6 +309,13 @@ describe("GitHubProvider", () => { if (url === "/repos/octocat/hello-world/contents/.github/ISSUE_TEMPLATE") { return [contentFile("notes.txt", ".github/ISSUE_TEMPLATE/notes.txt")]; } + if ( + url === "/repos/octocat/hello-world/contents/.github" || + url === "/repos/octocat/hello-world/contents" || + url === "/repos/octocat/hello-world/contents/docs" + ) { + return []; + } if (url === "/repos/octocat/.github") return defaultRepo; if (url === "/repos/octocat/.github/contents/.github/ISSUE_TEMPLATE") { return [contentFile("bug.md", ".github/ISSUE_TEMPLATE/bug.md")]; @@ -317,6 +331,91 @@ describe("GitHubProvider", () => { }); }); + it("discovers a singular issue template when the template directory is absent", async () => { + mocks.client.mockImplementation(async (url: string) => { + if (url === "/repos/octocat/hello-world") return ghRepo; + if (url === "/repos/octocat/hello-world/contents/.github/ISSUE_TEMPLATE") { + throw makeFetchError(404); + } + if (url === "/repos/octocat/hello-world/contents/.github") { + return [contentFile("ISSUE_TEMPLATE.md", ".github/ISSUE_TEMPLATE.md")]; + } + if (url === "/repos/octocat/hello-world/contents") return []; + if (url === "/repos/octocat/hello-world/contents/docs") return []; + if (url === "/repos/octocat/.github") throw makeFetchError(404); + throw new Error(`Unexpected URL: ${url}`); + }); + + const page = await gh.contributionTemplates.list("octocat", "hello-world", "issue"); + + expect( + page.items.map(({ sourcePath, scope, inherited }) => ({ sourcePath, scope, inherited })), + ).toEqual([ + { sourcePath: ".github/ISSUE_TEMPLATE.md", scope: "repository", inherited: false }, + ]); + expect(mocks.client).not.toHaveBeenCalledWith("/repos/octocat/.github"); + }); + + it("honors path precedence for a singular issue template without an extension", async () => { + mocks.client.mockImplementation(async (url: string) => { + if (url === "/repos/octocat/hello-world") return ghRepo; + if (url === "/repos/octocat/hello-world/contents/.github/ISSUE_TEMPLATE") { + throw makeFetchError(404); + } + if (url === "/repos/octocat/hello-world/contents/.github") return []; + if (url === "/repos/octocat/hello-world/contents") { + return [contentFile("ISSUE_TEMPLATE", "ISSUE_TEMPLATE")]; + } + if (url === "/repos/octocat/hello-world/contents/docs") { + return [contentFile("ISSUE_TEMPLATE.md", "docs/ISSUE_TEMPLATE.md")]; + } + if (url === "/repos/octocat/.github") throw makeFetchError(404); + throw new Error(`Unexpected URL: ${url}`); + }); + + const page = await gh.contributionTemplates.list("octocat", "hello-world", "issue"); + + expect(page.items).toHaveLength(1); + expect(page.items[0]?.sourcePath).toBe("ISSUE_TEMPLATE"); + expect(page.items[0]).toMatchObject({ scope: "repository", inherited: false }); + }); + + it("inherits a singular issue template from the owner defaults repository", async () => { + mocks.client.mockImplementation(async (url: string) => { + if (url === "/repos/octocat/hello-world") return ghRepo; + if (url === "/repos/octocat/hello-world/contents/.github/ISSUE_TEMPLATE") { + throw makeFetchError(404); + } + if ( + url === "/repos/octocat/hello-world/contents/.github" || + url === "/repos/octocat/hello-world/contents" || + url === "/repos/octocat/hello-world/contents/docs" + ) { + return []; + } + if (url === "/repos/octocat/.github") return defaultRepo; + if (url === "/repos/octocat/.github/contents/.github/ISSUE_TEMPLATE") { + throw makeFetchError(404); + } + if (url === "/repos/octocat/.github/contents/.github") { + return [contentFile("ISSUE_TEMPLATE.md", ".github/ISSUE_TEMPLATE.md")]; + } + if (url === "/repos/octocat/.github/contents") return []; + if (url === "/repos/octocat/.github/contents/docs") return []; + throw new Error(`Unexpected URL: ${url}`); + }); + + const page = await gh.contributionTemplates.list("octocat", "hello-world", "issue"); + + expect(page.items).toHaveLength(1); + expect(page.items[0]).toMatchObject({ + sourcePath: ".github/ISSUE_TEMPLATE.md", + sourceRepository: "octocat/.github", + scope: "owner", + inherited: true, + }); + }); + it("honors pull-request path precedence and keeps independently selectable files", async () => { mocks.client.mockImplementation(async (url: string) => { if (url === "/repos/octocat/hello-world") return ghRepo; @@ -381,6 +480,13 @@ describe("GitHubProvider", () => { if (url === "/repos/octocat/hello-world/contents/.github/ISSUE_TEMPLATE") { throw makeFetchError(404); } + if ( + url === "/repos/octocat/hello-world/contents/.github" || + url === "/repos/octocat/hello-world/contents" || + url === "/repos/octocat/hello-world/contents/docs" + ) { + return []; + } throw new Error(`Unexpected URL: ${url}`); }); mocks.rawFetch.mockResolvedValue({ @@ -410,6 +516,13 @@ describe("GitHubProvider", () => { if (url === "/repos/octocat/hello-world/contents/.github/ISSUE_TEMPLATE") { throw makeFetchError(404); } + if ( + url === "/repos/octocat/hello-world/contents/.github" || + url === "/repos/octocat/hello-world/contents" || + url === "/repos/octocat/hello-world/contents/docs" + ) { + return []; + } if (url === "/repos/octocat/.github") return internalDefaults; if (url === "/repos/octocat/.github/contents/.github/ISSUE_TEMPLATE") { return [contentFile("bug.md", ".github/ISSUE_TEMPLATE/bug.md")]; @@ -442,6 +555,13 @@ describe("GitHubProvider", () => { mocks.client.mockImplementation(async (url: string) => { if (url === "/repos/octocat/hello-world") return ghRepo; if (url === "/repos/octocat/hello-world/contents/.github/ISSUE_TEMPLATE") return []; + if ( + url === "/repos/octocat/hello-world/contents/.github" || + url === "/repos/octocat/hello-world/contents" || + url === "/repos/octocat/hello-world/contents/docs" + ) { + return []; + } if (url === "/repos/octocat/.github") throw makeFetchError(404); throw new Error(`Unexpected URL: ${url}`); });