From bf6a4a0c9903f17996ffe9cbccd2fc1774be7f3b Mon Sep 17 00:00:00 2001 From: Mark Cafaro Date: Tue, 23 Sep 2025 11:33:54 -0400 Subject: [PATCH 1/6] Try to detect default branch --- public/scripts/main.js | 52 +++++++++++++++++--------- public/scripts/workflow.js | 37 +++++++++++++++++-- tests/main.test.js | 73 ++++++++++++++++++++++++++++++++++--- tests/workflow.test.js | 75 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 211 insertions(+), 26 deletions(-) diff --git a/public/scripts/main.js b/public/scripts/main.js index 215d6e6..80fe340 100644 --- a/public/scripts/main.js +++ b/public/scripts/main.js @@ -1,23 +1,16 @@ /* global bootstrap */ -import { parseRepositoryURL, generateWorkflow } from "./workflow.js"; +import { + parseRepositoryURL, + detectDefaultBranch, + generateWorkflow, +} from "./workflow.js"; function navigateTo(url) { window.open(url, "_blank"); } window.navigateTo = navigateTo; -function generateWorkflowWithFormInputs() { - return generateWorkflow({ - useBatchToken: document.getElementById("use-batch-token").checked, - useVirtualDisplay: document.getElementById("use-virtual-display").checked, - buildAcrossPlatforms: document.getElementById("build-across-platforms") - .checked, - siteUrl: - window.location.origin + window.location.pathname.replace(/\/[^/]*$/, ""), - }); -} - -function handleFormSubmit(e) { +async function handleFormSubmit(e) { e.preventDefault(); const repoField = document.getElementById("repo"); @@ -28,7 +21,18 @@ function handleFormSubmit(e) { } repoField.classList.remove("is-invalid"); - const workflow = generateWorkflowWithFormInputs(); + let branch = await detectDefaultBranch(repoInfo); + if (!branch) branch = "main"; + + const workflow = generateWorkflow({ + useBatchToken: document.getElementById("use-batch-token").checked, + useVirtualDisplay: document.getElementById("use-virtual-display").checked, + buildAcrossPlatforms: document.getElementById("build-across-platforms") + .checked, + siteUrl: + window.location.origin + window.location.pathname.replace(/\/[^/]*$/, ""), + branch, + }); const encoded = encodeURIComponent(workflow); const filePath = ".github/workflows/matlab.yml"; @@ -37,7 +41,7 @@ function handleFormSubmit(e) { if (repoInfo.enterprise) { url += `/enterprises/${repoInfo.enterprise}`; } - url += `/${repoInfo.owner}/${repoInfo.repo}/new/main?filename=${filePath}&value=${encoded}`; + url += `/${repoInfo.owner}/${repoInfo.repo}/new/${branch}?filename=${filePath}&value=${encoded}`; window.navigateTo(url); @@ -50,10 +54,24 @@ function showDownloadAlert() { alert.focus(); } -function handleDownloadClick(e) { +async function handleDownloadClick(e) { e.preventDefault(); - const workflow = generateWorkflowWithFormInputs(); + const repoField = document.getElementById("repo"); + const repoInfo = parseRepositoryURL(repoField.value.trim()); + + let branch = await detectDefaultBranch(repoInfo); + if (!branch) branch = "main"; + + const workflow = generateWorkflow({ + useBatchToken: document.getElementById("use-batch-token").checked, + useVirtualDisplay: document.getElementById("use-virtual-display").checked, + buildAcrossPlatforms: document.getElementById("build-across-platforms") + .checked, + siteUrl: + window.location.origin + window.location.pathname.replace(/\/[^/]*$/, ""), + branch, + }); const blob = new Blob([workflow], { type: "text/yaml" }); const url = URL.createObjectURL(blob); diff --git a/public/scripts/workflow.js b/public/scripts/workflow.js index b4560d2..40020a4 100644 --- a/public/scripts/workflow.js +++ b/public/scripts/workflow.js @@ -51,11 +51,42 @@ function parseRepositoryURL(repoURL) { return null; } +async function detectDefaultBranch(repoInfo) { + if (!repoInfo) return null; + + let baseUrl = repoInfo.origin; + if (repoInfo.enterprise) { + baseUrl += `/enterprises/${repoInfo.enterprise}`; + } + baseUrl += `/${repoInfo.owner}/${repoInfo.repo}`; + + const branches = ["main", "master", "develop"]; + for (const branch of branches) { + const webUrl = `${baseUrl}/blob/${branch}/README.md`; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 3000); // 3 seconds + try { + const resp = await fetch(webUrl, { + method: "HEAD", + signal: controller.signal, + }); + clearTimeout(timeout); + if (resp.ok) return branch; + } catch { + // Ignore network errors and try next branch + } finally { + clearTimeout(timeout); + } + } + return null; +} + function generateWorkflow({ useBatchToken = false, useVirtualDisplay = false, buildAcrossPlatforms = false, siteUrl = "http://localhost/", + branch = "main", }) { return dedent(` # This workflow was generated using the GitHub Actions Workflow Generator for MATLAB. @@ -65,9 +96,9 @@ function generateWorkflow({ on: push: - branches: [main] + branches: [${branch}] pull_request: - branches: [main] + branches: [${branch}] workflow_dispatch: {} ${ useBatchToken @@ -145,4 +176,4 @@ function dedent(str) { return match ? str.replace(new RegExp("^" + match[0], "gm"), "") : str; } -export { parseRepositoryURL, generateWorkflow }; +export { parseRepositoryURL, detectDefaultBranch, generateWorkflow }; diff --git a/tests/main.test.js b/tests/main.test.js index b7a838f..cf091d1 100644 --- a/tests/main.test.js +++ b/tests/main.test.js @@ -20,38 +20,49 @@ beforeEach(async () => {
`; + const realWorkflow = await import("../public/scripts/workflow.js"); + jest.unstable_mockModule("../public/scripts/workflow.js", () => ({ + ...realWorkflow, + detectDefaultBranch: jest.fn().mockResolvedValue("main"), + })); + await import("../public/scripts/main.js"); window.navigateTo = jest.fn(); }); -test("form submit with invalid repo shows error", () => { +const flushPromises = () => new Promise((r) => setTimeout(r, 0)); + +test("form submit with invalid repo shows error", async () => { const repoInput = document.getElementById("repo"); expect(repoInput.classList.contains("is-invalid")).toBe(false); repoInput.value = "invalidrepo"; document .getElementById("generate-form") .dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); + await flushPromises(); expect(repoInput.classList.contains("is-invalid")).toBe(true); expect(window.navigateTo).not.toHaveBeenCalled(); }); -test("form submit with valid slug works", () => { +test("form submit with valid slug works", async () => { const repoInput = document.getElementById("repo"); repoInput.value = "owner/repo"; document .getElementById("generate-form") .dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); + await flushPromises(); expect(window.navigateTo).toHaveBeenCalledWith( expect.stringContaining("https://github.com/owner/repo/new/main?filename="), ); }); -test("form submit with valid URL works", () => { +test("form submit with valid URL works", async () => { const repoInput = document.getElementById("repo"); repoInput.value = "https://github.com/octocat/Hello-World"; document .getElementById("generate-form") .dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); + await flushPromises(); expect(window.navigateTo).toHaveBeenCalledWith( expect.stringContaining( "https://github.com/octocat/Hello-World/new/main?filename=", @@ -59,12 +70,13 @@ test("form submit with valid URL works", () => { ); }); -test("form submit with valid cloud-hosted enterprise URL works", () => { +test("form submit with valid cloud-hosted enterprise URL works", async () => { const repoInput = document.getElementById("repo"); repoInput.value = "https://github.com/enterprises/gh/octocat/Hello-World"; document .getElementById("generate-form") .dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); + await flushPromises(); expect(window.navigateTo).toHaveBeenCalledWith( expect.stringContaining( "https://github.com/enterprises/gh/octocat/Hello-World/new/main?filename=", @@ -72,12 +84,57 @@ test("form submit with valid cloud-hosted enterprise URL works", () => { ); }); +test("form submit uses detected default branch", async () => { + jest.resetModules(); + jest.unstable_mockModule("../public/scripts/workflow.js", () => ({ + parseRepositoryURL: (v) => ({ + origin: "https://github.com", + owner: "o", + repo: "r", + }), + detectDefaultBranch: jest.fn().mockResolvedValue("master"), + generateWorkflow: () => "yaml-content", + })); + await import("../public/scripts/main.js"); + window.navigateTo = jest.fn(); + document + .getElementById("generate-form") + .dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); + await flushPromises(); + expect(window.navigateTo).toHaveBeenCalledWith( + expect.stringContaining("https://github.com/o/r/new/master?filename="), + ); +}); + +test("form submit defaults to main branch if detection fails", async () => { + jest.resetModules(); + jest.unstable_mockModule("../public/scripts/workflow.js", () => ({ + parseRepositoryURL: (v) => ({ + origin: "https://github.com", + owner: "o", + repo: "r", + }), + detectDefaultBranch: jest.fn().mockResolvedValue(null), + generateWorkflow: () => "yaml-content", + })); + await import("../public/scripts/main.js"); + window.navigateTo = jest.fn(); + document + .getElementById("generate-form") + .dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); + await flushPromises(); + expect(window.navigateTo).toHaveBeenCalledWith( + expect.stringContaining("https://github.com/o/r/new/main?filename="), + ); +}); + test("advanced options are passed to generateWorkflow", async () => { // Re-import main.js with a spy on generateWorkflow jest.resetModules(); const workflowSpy = jest.fn(() => "yaml-content"); jest.unstable_mockModule("../public/scripts/workflow.js", () => ({ parseRepositoryURL: (v) => ({ owner: "o", repo: "r" }), + detectDefaultBranch: jest.fn().mockResolvedValue("main"), generateWorkflow: workflowSpy, })); document.getElementById("repo").value = "o/r"; @@ -89,15 +146,17 @@ test("advanced options are passed to generateWorkflow", async () => { document .getElementById("generate-form") .dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); + await flushPromises(); expect(workflowSpy).toHaveBeenCalledWith({ useBatchToken: true, useVirtualDisplay: false, buildAcrossPlatforms: true, siteUrl: "http://localhost", + branch: "main", }); }); -test("download link triggers file download", () => { +test("download link triggers file download", async () => { const repoInput = document.getElementById("repo"); repoInput.value = "owner/repo"; @@ -110,12 +169,14 @@ test("download link triggers file download", () => { document.body.appendChild(a); jest.spyOn(document, "createElement").mockImplementation((tag) => { if (tag === "a") return a; - return document.createElement(tag); + return originalCreateElement.call(document, tag); }); const clickSpy = jest.spyOn(a, "click"); document.getElementById("download-alert-link").click(); + await flushPromises(); + expect(mockCreateObjectURL).toHaveBeenCalled(); expect(clickSpy).toHaveBeenCalled(); expect(mockRevokeObjectURL).toHaveBeenCalled(); diff --git a/tests/workflow.test.js b/tests/workflow.test.js index a0b4087..9acca0d 100644 --- a/tests/workflow.test.js +++ b/tests/workflow.test.js @@ -1,3 +1,4 @@ +import { jest } from "@jest/globals"; import { parseRepositoryURL, generateWorkflow, @@ -270,6 +271,80 @@ describe("parseRepositoryURL", () => { }); }); +describe("detectDefaultBranch", () => { + // Mock fetch globally for all tests in this suite + const originalFetch = global.fetch; + afterEach(() => { + global.fetch = originalFetch; + jest.clearAllMocks(); + }); + + test("returns null for null input", async () => { + const { detectDefaultBranch } = await import( + "../public/scripts/workflow.js" + ); + const branch = await detectDefaultBranch(null); + expect(branch).toBeNull(); + }); + + test("detects 'main' branch", async () => { + global.fetch = jest.fn().mockResolvedValue({ ok: true }); + const { detectDefaultBranch } = await import( + "../public/scripts/workflow.js" + ); + const repoInfo = { origin: "https://github.com", owner: "o", repo: "r" }; + const branch = await detectDefaultBranch(repoInfo); + expect(branch).toBe("main"); + }); + + test("detects 'master' branch", async () => { + global.fetch = jest + .fn() + .mockResolvedValueOnce({ ok: false }) + .mockResolvedValueOnce({ ok: true }); + const { detectDefaultBranch } = await import( + "../public/scripts/workflow.js" + ); + const repoInfo = { origin: "https://github.com", owner: "o", repo: "r" }; + const branch = await detectDefaultBranch(repoInfo); + expect(branch).toBe("master"); + }); + + test("detects 'develop' branch", async () => { + global.fetch = jest + .fn() + .mockResolvedValueOnce({ ok: false }) + .mockResolvedValueOnce({ ok: false }) + .mockResolvedValueOnce({ ok: true }); + const { detectDefaultBranch } = await import( + "../public/scripts/workflow.js" + ); + const repoInfo = { origin: "https://github.com", owner: "o", repo: "r" }; + const branch = await detectDefaultBranch(repoInfo); + expect(branch).toBe("develop"); + }); + + test("returns null if no common branches found", async () => { + global.fetch = jest.fn().mockResolvedValue({ ok: false }); + const { detectDefaultBranch } = await import( + "../public/scripts/workflow.js" + ); + const repoInfo = { origin: "https://github.com", owner: "o", repo: "r" }; + const branch = await detectDefaultBranch(repoInfo); + expect(branch).toBeNull(); + }); + + test("handles fetch errors gracefully", async () => { + global.fetch = jest.fn().mockRejectedValue(new Error("Network error")); + const { detectDefaultBranch } = await import( + "../public/scripts/workflow.js" + ); + const repoInfo = { origin: "https://github.com", owner: "o", repo: "r" }; + const branch = await detectDefaultBranch(repoInfo); + expect(branch).toBeNull(); + }); +}); + describe("generateWorkflow", () => { test("default workflow", () => { const yaml = generateWorkflow({}); From 504d24045b80ca19e004434a1a74416a8f7b9e2f Mon Sep 17 00:00:00 2001 From: Mark Cafaro Date: Tue, 23 Sep 2025 15:08:33 -0400 Subject: [PATCH 2/6] Do concurrent requests for branches --- public/scripts/workflow.js | 34 +++++++++++++++++----------------- tests/workflow.test.js | 19 +++++++++---------- 2 files changed, 26 insertions(+), 27 deletions(-) diff --git a/public/scripts/workflow.js b/public/scripts/workflow.js index 40020a4..229ca98 100644 --- a/public/scripts/workflow.js +++ b/public/scripts/workflow.js @@ -51,7 +51,7 @@ function parseRepositoryURL(repoURL) { return null; } -async function detectDefaultBranch(repoInfo) { +async function detectDefaultBranch(repoInfo, timeoutMs = 3000) { if (!repoInfo) return null; let baseUrl = repoInfo.origin; @@ -61,24 +61,24 @@ async function detectDefaultBranch(repoInfo) { baseUrl += `/${repoInfo.owner}/${repoInfo.repo}`; const branches = ["main", "master", "develop"]; - for (const branch of branches) { - const webUrl = `${baseUrl}/blob/${branch}/README.md`; + + function fetchWithTimeout(url, timeoutMs) { const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 3000); // 3 seconds - try { - const resp = await fetch(webUrl, { - method: "HEAD", - signal: controller.signal, - }); - clearTimeout(timeout); - if (resp.ok) return branch; - } catch { - // Ignore network errors and try next branch - } finally { - clearTimeout(timeout); - } + const timeout = setTimeout(() => controller.abort(), timeoutMs); + return fetch(url, { method: "HEAD", signal: controller.signal }).finally( + () => clearTimeout(timeout), + ); } - return null; + + const checks = branches.map((branch) => { + const webUrl = `${baseUrl}/blob/${branch}/README.md`; + return fetchWithTimeout(webUrl, timeoutMs) + .then((resp) => (resp && resp.ok ? branch : null)) + .catch(() => null); + }); + + const results = await Promise.all(checks); + return results.find((branch) => branch !== null) || null; } function generateWorkflow({ diff --git a/tests/workflow.test.js b/tests/workflow.test.js index 9acca0d..cefaf39 100644 --- a/tests/workflow.test.js +++ b/tests/workflow.test.js @@ -288,7 +288,9 @@ describe("detectDefaultBranch", () => { }); test("detects 'main' branch", async () => { - global.fetch = jest.fn().mockResolvedValue({ ok: true }); + global.fetch = jest.fn().mockImplementation((url) => { + return Promise.resolve({ ok: url.includes("main") }); + }); const { detectDefaultBranch } = await import( "../public/scripts/workflow.js" ); @@ -298,10 +300,9 @@ describe("detectDefaultBranch", () => { }); test("detects 'master' branch", async () => { - global.fetch = jest - .fn() - .mockResolvedValueOnce({ ok: false }) - .mockResolvedValueOnce({ ok: true }); + global.fetch = jest.fn().mockImplementation((url) => { + return Promise.resolve({ ok: url.includes("master") }); + }); const { detectDefaultBranch } = await import( "../public/scripts/workflow.js" ); @@ -311,11 +312,9 @@ describe("detectDefaultBranch", () => { }); test("detects 'develop' branch", async () => { - global.fetch = jest - .fn() - .mockResolvedValueOnce({ ok: false }) - .mockResolvedValueOnce({ ok: false }) - .mockResolvedValueOnce({ ok: true }); + global.fetch = jest.fn().mockImplementation((url) => { + return Promise.resolve({ ok: url.includes("develop") }); + }); const { detectDefaultBranch } = await import( "../public/scripts/workflow.js" ); From a42481fed2e42041485c63f7abd9a7566c5a450e Mon Sep 17 00:00:00 2001 From: Mark Cafaro Date: Tue, 23 Sep 2025 15:23:14 -0400 Subject: [PATCH 3/6] Don't follow redirects --- public/scripts/workflow.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/public/scripts/workflow.js b/public/scripts/workflow.js index 229ca98..53c8899 100644 --- a/public/scripts/workflow.js +++ b/public/scripts/workflow.js @@ -65,9 +65,11 @@ async function detectDefaultBranch(repoInfo, timeoutMs = 3000) { function fetchWithTimeout(url, timeoutMs) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), timeoutMs); - return fetch(url, { method: "HEAD", signal: controller.signal }).finally( - () => clearTimeout(timeout), - ); + return fetch(url, { + method: "HEAD", + signal: controller.signal, + redirect: "manual", + }).finally(() => clearTimeout(timeout)); } const checks = branches.map((branch) => { From 51abd733db930989bee96dc111ae2fa12aac2969 Mon Sep 17 00:00:00 2001 From: Mark Cafaro Date: Tue, 23 Sep 2025 15:27:41 -0400 Subject: [PATCH 4/6] Fix test --- tests/main.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/main.test.js b/tests/main.test.js index cf091d1..00ac194 100644 --- a/tests/main.test.js +++ b/tests/main.test.js @@ -169,7 +169,7 @@ test("download link triggers file download", async () => { document.body.appendChild(a); jest.spyOn(document, "createElement").mockImplementation((tag) => { if (tag === "a") return a; - return originalCreateElement.call(document, tag); + return document.createElement(tag); }); const clickSpy = jest.spyOn(a, "click"); From 9dbf9cec487912475c19b433ec562ec39ffff0ea Mon Sep 17 00:00:00 2001 From: Mark Cafaro Date: Tue, 23 Sep 2025 17:06:51 -0400 Subject: [PATCH 5/6] Try GitHub API first --- public/scripts/workflow.js | 91 ++++++++++++++++++++++-------- tests/workflow.test.js | 111 ++++++++++++++++++++++++++++++++++--- 2 files changed, 171 insertions(+), 31 deletions(-) diff --git a/public/scripts/workflow.js b/public/scripts/workflow.js index 53c8899..80884b5 100644 --- a/public/scripts/workflow.js +++ b/public/scripts/workflow.js @@ -33,6 +33,7 @@ function parseRepositoryURL(repoURL) { // Enterprise: http(s)://github.com/enterprises/enterprise/owner/repo return { origin: url.origin, + hostname: url.hostname, enterprise: parts[1], owner: parts[2], repo: parts[3], @@ -41,6 +42,7 @@ function parseRepositoryURL(repoURL) { // Standard: http(s)://host/owner/repo return { origin: url.origin, + hostname: url.hostname, owner: parts[0], repo: parts[1], }; @@ -51,36 +53,81 @@ function parseRepositoryURL(repoURL) { return null; } -async function detectDefaultBranch(repoInfo, timeoutMs = 3000) { +async function detectDefaultBranch(repoInfo) { if (!repoInfo) return null; - let baseUrl = repoInfo.origin; - if (repoInfo.enterprise) { - baseUrl += `/enterprises/${repoInfo.enterprise}`; + async function fetchWithTimeout(url, options = {}) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 3000); // 3 seconds timeout + try { + const resp = await fetch(url, { + ...options, + signal: controller.signal, + }); + return resp; + } finally { + clearTimeout(timeout); + } } - baseUrl += `/${repoInfo.owner}/${repoInfo.repo}`; - const branches = ["main", "master", "develop"]; + // Try to detect default branch using the GitHub API + async function tryApi() { + let apiUrl; + if (repoInfo.hostname.replace(/^www\./, "") === "github.com") { + apiUrl = `https://api.github.com/repos/${repoInfo.owner}/${repoInfo.repo}`; + } else { + apiUrl = `${origin}/api/v3/repos/${repoInfo.owner}/${repoInfo.repo}`; + } + + try { + const resp = await fetchWithTimeout(apiUrl, { + headers: { Accept: "application/vnd.github+json" }, + }); - function fetchWithTimeout(url, timeoutMs) { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), timeoutMs); - return fetch(url, { - method: "HEAD", - signal: controller.signal, - redirect: "manual", - }).finally(() => clearTimeout(timeout)); + if (!resp.ok) return null; + + const data = await resp.json(); + return data.default_branch || null; + } catch { + // Network or abort error, fallback + return null; + } } - const checks = branches.map((branch) => { - const webUrl = `${baseUrl}/blob/${branch}/README.md`; - return fetchWithTimeout(webUrl, timeoutMs) - .then((resp) => (resp && resp.ok ? branch : null)) - .catch(() => null); - }); + // Fallback: Probe common branch names by checking for a README.md + async function tryBlobFallback() { + let repoBaseUrl; + if (repoInfo.hostname.replace(/^www\./, "") === "github.com") { + repoBaseUrl = "https://github.com"; + } else { + repoBaseUrl = `${repoInfo.origin}`; + } + if (repoInfo.enterprise) { + repoBaseUrl += `/enterprises/${repoInfo.enterprise}`; + } + repoBaseUrl += `/${repoInfo.owner}/${repoInfo.repo}`; + + const branches = ["main", "master", "develop"]; + const checks = branches.map(async (branch) => { + const url = `${repoBaseUrl}/blob/${branch}/README.md`; + try { + const resp = await fetchWithTimeout(url, { + method: "HEAD", + redirect: "manual", + }); + return resp.ok ? branch : null; + } catch { + return null; + } + }); + const results = await Promise.all(checks); + return results.find((branch) => branch !== null) || null; + } - const results = await Promise.all(checks); - return results.find((branch) => branch !== null) || null; + // Main logic: Try API first, then fallback if needed + const apiBranch = await tryApi(); + if (apiBranch) return apiBranch; + return await tryBlobFallback(); } function generateWorkflow({ diff --git a/tests/workflow.test.js b/tests/workflow.test.js index cefaf39..c51b8d4 100644 --- a/tests/workflow.test.js +++ b/tests/workflow.test.js @@ -10,6 +10,7 @@ describe("parseRepositoryURL", () => { test("shorthand owner/repo", () => { expect(parseRepositoryURL("owner/repo")).toEqual({ origin: "https://github.com", + hostname: "github.com", owner: "owner", repo: "repo", }); @@ -19,6 +20,7 @@ describe("parseRepositoryURL", () => { parseRepositoryURL("https://github.com/octocat/hello-world"), ).toEqual({ origin: "https://github.com", + hostname: "github.com", owner: "octocat", repo: "hello-world", }); @@ -26,6 +28,7 @@ describe("parseRepositoryURL", () => { parseRepositoryURL("http://github.com/octocat/hello-world"), ).toEqual({ origin: "http://github.com", + hostname: "github.com", owner: "octocat", repo: "hello-world", }); @@ -37,6 +40,7 @@ describe("parseRepositoryURL", () => { ), ).toEqual({ origin: "https://github.com", + hostname: "github.com", enterprise: "mycompany", owner: "octocat", repo: "hello-world", @@ -45,6 +49,7 @@ describe("parseRepositoryURL", () => { parseRepositoryURL("https://mycompany.github.com/octocat/hello-world"), ).toEqual({ origin: "https://mycompany.github.com", + hostname: "mycompany.github.com", owner: "octocat", repo: "hello-world", }); @@ -52,6 +57,7 @@ describe("parseRepositoryURL", () => { parseRepositoryURL("https://github.mycompany.com/octocat/hello-world"), ).toEqual({ origin: "https://github.mycompany.com", + hostname: "github.mycompany.com", owner: "octocat", repo: "hello-world", }); @@ -59,6 +65,7 @@ describe("parseRepositoryURL", () => { test("URLs without protocol", () => { expect(parseRepositoryURL("github.com/octocat/hello-world")).toEqual({ origin: "https://github.com", + hostname: "github.com", owner: "octocat", repo: "hello-world", }); @@ -66,6 +73,7 @@ describe("parseRepositoryURL", () => { parseRepositoryURL("mycompany.github.com/octocat/hello-world"), ).toEqual({ origin: "https://mycompany.github.com", + hostname: "mycompany.github.com", owner: "octocat", repo: "hello-world", }); @@ -73,6 +81,7 @@ describe("parseRepositoryURL", () => { parseRepositoryURL("github.mycompany.com/octocat/hello-world"), ).toEqual({ origin: "https://github.mycompany.com", + hostname: "github.mycompany.com", owner: "octocat", repo: "hello-world", }); @@ -82,6 +91,7 @@ describe("parseRepositoryURL", () => { parseRepositoryURL("https://github.com/octocat/hello-world/"), ).toEqual({ origin: "https://github.com", + hostname: "github.com", owner: "octocat", repo: "hello-world", }); @@ -89,6 +99,7 @@ describe("parseRepositoryURL", () => { parseRepositoryURL("https://github.com/octocat/hello-world/README.md"), ).toEqual({ origin: "https://github.com", + hostname: "github.com", owner: "octocat", repo: "hello-world", }); @@ -96,6 +107,7 @@ describe("parseRepositoryURL", () => { parseRepositoryURL("https://github.com/octocat/hello-world.git"), ).toEqual({ origin: "https://github.com", + hostname: "github.com", owner: "octocat", repo: "hello-world", }); @@ -108,6 +120,7 @@ describe("parseRepositoryURL", () => { parseRepositoryURL("git@github.com:octocat/hello-world.git"), ).toEqual({ origin: "https://github.com", + hostname: "github.com", owner: "octocat", repo: "hello-world", }); @@ -115,6 +128,7 @@ describe("parseRepositoryURL", () => { parseRepositoryURL("ssh://git@github.com/octocat/hello-world.git"), ).toEqual({ origin: "https://github.com", + hostname: "github.com", owner: "octocat", repo: "hello-world", }); @@ -124,6 +138,7 @@ describe("parseRepositoryURL", () => { ), ).toEqual({ origin: "https://github.com", + hostname: "github.com", enterprise: "mycompany", owner: "octocat", repo: "hello-world", @@ -134,6 +149,7 @@ describe("parseRepositoryURL", () => { ), ).toEqual({ origin: "https://mycompany.github.com", + hostname: "mycompany.github.com", owner: "octocat", repo: "hello-world", }); @@ -141,6 +157,7 @@ describe("parseRepositoryURL", () => { parseRepositoryURL("git@github.mycompany.com:octocat/hello-world.git"), ).toEqual({ origin: "https://github.mycompany.com", + hostname: "github.mycompany.com", owner: "octocat", repo: "hello-world", }); @@ -150,6 +167,7 @@ describe("parseRepositoryURL", () => { ), ).toEqual({ origin: "https://github.mycompany.com", + hostname: "github.mycompany.com", owner: "octocat", repo: "hello-world", }); @@ -157,6 +175,7 @@ describe("parseRepositoryURL", () => { test("SSH URLs without .git", () => { expect(parseRepositoryURL("git@github.com:octocat/hello-world")).toEqual({ origin: "https://github.com", + hostname: "github.com", owner: "octocat", repo: "hello-world", }); @@ -166,6 +185,7 @@ describe("parseRepositoryURL", () => { parseRepositoryURL("git://github.com/octocat/hello-world.git"), ).toEqual({ origin: "https://github.com", + hostname: "github.com", owner: "octocat", repo: "hello-world", }); @@ -173,6 +193,7 @@ describe("parseRepositoryURL", () => { parseRepositoryURL("git://github.com/octocat/hello-world"), ).toEqual({ origin: "https://github.com", + hostname: "github.com", owner: "octocat", repo: "hello-world", }); @@ -183,11 +204,13 @@ describe("parseRepositoryURL", () => { test("protocol/host case insensitivity, preserve owner/repo case", () => { expect(parseRepositoryURL("OWNER/REPO")).toEqual({ origin: "https://github.com", + hostname: "github.com", owner: "OWNER", repo: "REPO", }); expect(parseRepositoryURL("GitHub.com/OctoCat/Hello-World")).toEqual({ origin: "https://github.com", + hostname: "github.com", owner: "OctoCat", repo: "Hello-World", }); @@ -195,6 +218,7 @@ describe("parseRepositoryURL", () => { parseRepositoryURL("HTTPS://GITHUB.COM/OctoCat/Hello-World"), ).toEqual({ origin: "https://github.com", + hostname: "github.com", owner: "OctoCat", repo: "Hello-World", }); @@ -202,6 +226,7 @@ describe("parseRepositoryURL", () => { parseRepositoryURL("git@github.com:OctoCat/Hello-World.git"), ).toEqual({ origin: "https://github.com", + hostname: "github.com", owner: "OctoCat", repo: "Hello-World", }); @@ -209,6 +234,7 @@ describe("parseRepositoryURL", () => { parseRepositoryURL("ssh://git@github.com/OctoCat/Hello-World.git"), ).toEqual({ origin: "https://github.com", + hostname: "github.com", owner: "OctoCat", repo: "Hello-World", }); @@ -216,6 +242,7 @@ describe("parseRepositoryURL", () => { parseRepositoryURL("git://github.com/OctoCat/Hello-World.git"), ).toEqual({ origin: "https://github.com", + hostname: "github.com", owner: "OctoCat", repo: "Hello-World", }); @@ -241,6 +268,7 @@ describe("parseRepositoryURL", () => { parseRepositoryURL("https://github.com:8080/octocat/hello-world"), ).toEqual({ origin: "https://github.com:8080", + hostname: "github.com", owner: "octocat", repo: "hello-world", }); @@ -250,6 +278,7 @@ describe("parseRepositoryURL", () => { ), ).toEqual({ origin: "https://github.mycompany.com:8080", + hostname: "github.mycompany.com", owner: "octocat", repo: "hello-world", }); @@ -257,6 +286,7 @@ describe("parseRepositoryURL", () => { test("inputs with whitespace", () => { expect(parseRepositoryURL(" owner/repo ")).toEqual({ origin: "https://github.com", + hostname: "github.com", owner: "owner", repo: "repo", }); @@ -264,6 +294,7 @@ describe("parseRepositoryURL", () => { parseRepositoryURL(" https://github.com/octocat/hello-world "), ).toEqual({ origin: "https://github.com", + hostname: "github.com", owner: "octocat", repo: "hello-world", }); @@ -287,48 +318,105 @@ describe("detectDefaultBranch", () => { expect(branch).toBeNull(); }); - test("detects 'main' branch", async () => { + test("detects default branch via GitHub API", async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ default_branch: "main" }), + }); + const { detectDefaultBranch } = await import( + "../public/scripts/workflow.js" + ); + const repoInfo = { + origin: "https://github.com", + hostname: "github.com", + owner: "o", + repo: "r", + }; + const branch = await detectDefaultBranch(repoInfo); + expect(branch).toBe("main"); + expect(global.fetch).toHaveBeenCalledWith( + "https://api.github.com/repos/o/r", + expect.any(Object), + ); + }); + + test("returns null if GitHub API response is not ok", async () => { + global.fetch = jest.fn().mockResolvedValue({ ok: false }); + const { detectDefaultBranch } = await import( + "../public/scripts/workflow.js" + ); + const repoInfo = { + origin: "https://github.com", + hostname: "github.com", + owner: "o", + repo: "r", + }; + const branch = await detectDefaultBranch(repoInfo); + expect(branch).toBeNull(); + }); + + test("detects 'main' branch on fallback", async () => { global.fetch = jest.fn().mockImplementation((url) => { return Promise.resolve({ ok: url.includes("main") }); }); const { detectDefaultBranch } = await import( "../public/scripts/workflow.js" ); - const repoInfo = { origin: "https://github.com", owner: "o", repo: "r" }; + const repoInfo = { + origin: "https://github.com", + hostname: "github.com", + owner: "o", + repo: "r", + }; const branch = await detectDefaultBranch(repoInfo); expect(branch).toBe("main"); }); - test("detects 'master' branch", async () => { + test("detects 'master' branch on fallback", async () => { global.fetch = jest.fn().mockImplementation((url) => { return Promise.resolve({ ok: url.includes("master") }); }); const { detectDefaultBranch } = await import( "../public/scripts/workflow.js" ); - const repoInfo = { origin: "https://github.com", owner: "o", repo: "r" }; + const repoInfo = { + origin: "https://github.com", + hostname: "github.com", + owner: "o", + repo: "r", + }; const branch = await detectDefaultBranch(repoInfo); expect(branch).toBe("master"); }); - test("detects 'develop' branch", async () => { + test("detects 'develop' branch on fallback", async () => { global.fetch = jest.fn().mockImplementation((url) => { return Promise.resolve({ ok: url.includes("develop") }); }); const { detectDefaultBranch } = await import( "../public/scripts/workflow.js" ); - const repoInfo = { origin: "https://github.com", owner: "o", repo: "r" }; + const repoInfo = { + origin: "https://github.com", + hostname: "github.com", + owner: "o", + repo: "r", + }; const branch = await detectDefaultBranch(repoInfo); expect(branch).toBe("develop"); }); - test("returns null if no common branches found", async () => { + test("returns null if no fallback branches found", async () => { global.fetch = jest.fn().mockResolvedValue({ ok: false }); const { detectDefaultBranch } = await import( "../public/scripts/workflow.js" ); - const repoInfo = { origin: "https://github.com", owner: "o", repo: "r" }; + const repoInfo = { + origin: "https://github.com", + hostname: "github.com", + owner: "o", + repo: "r", + }; const branch = await detectDefaultBranch(repoInfo); expect(branch).toBeNull(); }); @@ -338,7 +426,12 @@ describe("detectDefaultBranch", () => { const { detectDefaultBranch } = await import( "../public/scripts/workflow.js" ); - const repoInfo = { origin: "https://github.com", owner: "o", repo: "r" }; + const repoInfo = { + origin: "https://github.com", + hostname: "github.com", + owner: "o", + repo: "r", + }; const branch = await detectDefaultBranch(repoInfo); expect(branch).toBeNull(); }); From ff042fd105ba02feae78bbad9f6f128fc492cd08 Mon Sep 17 00:00:00 2001 From: Mark Cafaro Date: Tue, 23 Sep 2025 17:33:53 -0400 Subject: [PATCH 6/6] Fix enterprise API url --- public/scripts/workflow.js | 3 +-- tests/workflow.test.js | 26 ++++++++++++++++++++++++-- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/public/scripts/workflow.js b/public/scripts/workflow.js index 80884b5..97cd73c 100644 --- a/public/scripts/workflow.js +++ b/public/scripts/workflow.js @@ -76,14 +76,13 @@ async function detectDefaultBranch(repoInfo) { if (repoInfo.hostname.replace(/^www\./, "") === "github.com") { apiUrl = `https://api.github.com/repos/${repoInfo.owner}/${repoInfo.repo}`; } else { - apiUrl = `${origin}/api/v3/repos/${repoInfo.owner}/${repoInfo.repo}`; + apiUrl = `${repoInfo.origin}/api/v3/repos/${repoInfo.owner}/${repoInfo.repo}`; } try { const resp = await fetchWithTimeout(apiUrl, { headers: { Accept: "application/vnd.github+json" }, }); - if (!resp.ok) return null; const data = await resp.json(); diff --git a/tests/workflow.test.js b/tests/workflow.test.js index c51b8d4..ed31ebb 100644 --- a/tests/workflow.test.js +++ b/tests/workflow.test.js @@ -321,7 +321,7 @@ describe("detectDefaultBranch", () => { test("detects default branch via GitHub API", async () => { global.fetch = jest.fn().mockResolvedValue({ ok: true, - json: () => Promise.resolve({ default_branch: "main" }), + json: () => Promise.resolve({ default_branch: "dev" }), }); const { detectDefaultBranch } = await import( "../public/scripts/workflow.js" @@ -333,13 +333,35 @@ describe("detectDefaultBranch", () => { repo: "r", }; const branch = await detectDefaultBranch(repoInfo); - expect(branch).toBe("main"); + expect(branch).toBe("dev"); expect(global.fetch).toHaveBeenCalledWith( "https://api.github.com/repos/o/r", expect.any(Object), ); }); + test("detects default branch via GitHub Enterprise API", async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ default_branch: "dev" }), + }); + const { detectDefaultBranch } = await import( + "../public/scripts/workflow.js" + ); + const repoInfo = { + origin: "https://mycompany.github.com", + hostname: "mycompany.github.com", + owner: "o", + repo: "r", + }; + const branch = await detectDefaultBranch(repoInfo); + expect(branch).toBe("dev"); + expect(global.fetch).toHaveBeenCalledWith( + "https://mycompany.github.com/api/v3/repos/o/r", + expect.any(Object), + ); + }); + test("returns null if GitHub API response is not ok", async () => { global.fetch = jest.fn().mockResolvedValue({ ok: false }); const { detectDefaultBranch } = await import(