Skip to content
Merged
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
54 changes: 42 additions & 12 deletions src/providers/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) ---

Expand Down Expand Up @@ -584,17 +584,47 @@ export class GitHubProvider extends Provider<GitHubRawTypes> {
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,
};
}

Expand All @@ -603,12 +633,12 @@ export class GitHubProvider extends Provider<GitHubRawTypes> {
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);
Expand Down
120 changes: 120 additions & 0 deletions test/github.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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")];
Expand All @@ -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;
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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")];
Expand Down Expand Up @@ -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}`);
});
Expand Down
Loading