Skip to content

Commit 13d60c3

Browse files
authored
Merge pull request #19 from matlab-actions/branch-detection
Detect default branch
2 parents b5f36bb + ff042fd commit 13d60c3

File tree

4 files changed

+372
-25
lines changed

4 files changed

+372
-25
lines changed

public/scripts/main.js

Lines changed: 35 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,16 @@
11
/* global bootstrap */
2-
import { parseRepositoryURL, generateWorkflow } from "./workflow.js";
2+
import {
3+
parseRepositoryURL,
4+
detectDefaultBranch,
5+
generateWorkflow,
6+
} from "./workflow.js";
37

48
function navigateTo(url) {
59
window.open(url, "_blank");
610
}
711
window.navigateTo = navigateTo;
812

9-
function generateWorkflowWithFormInputs() {
10-
return generateWorkflow({
11-
useBatchToken: document.getElementById("use-batch-token").checked,
12-
useVirtualDisplay: document.getElementById("use-virtual-display").checked,
13-
buildAcrossPlatforms: document.getElementById("build-across-platforms")
14-
.checked,
15-
siteUrl:
16-
window.location.origin + window.location.pathname.replace(/\/[^/]*$/, ""),
17-
});
18-
}
19-
20-
function handleFormSubmit(e) {
13+
async function handleFormSubmit(e) {
2114
e.preventDefault();
2215

2316
const repoField = document.getElementById("repo");
@@ -28,7 +21,18 @@ function handleFormSubmit(e) {
2821
}
2922
repoField.classList.remove("is-invalid");
3023

31-
const workflow = generateWorkflowWithFormInputs();
24+
let branch = await detectDefaultBranch(repoInfo);
25+
if (!branch) branch = "main";
26+
27+
const workflow = generateWorkflow({
28+
useBatchToken: document.getElementById("use-batch-token").checked,
29+
useVirtualDisplay: document.getElementById("use-virtual-display").checked,
30+
buildAcrossPlatforms: document.getElementById("build-across-platforms")
31+
.checked,
32+
siteUrl:
33+
window.location.origin + window.location.pathname.replace(/\/[^/]*$/, ""),
34+
branch,
35+
});
3236

3337
const encoded = encodeURIComponent(workflow);
3438
const filePath = ".github/workflows/matlab.yml";
@@ -37,7 +41,7 @@ function handleFormSubmit(e) {
3741
if (repoInfo.enterprise) {
3842
url += `/enterprises/${repoInfo.enterprise}`;
3943
}
40-
url += `/${repoInfo.owner}/${repoInfo.repo}/new/main?filename=${filePath}&value=${encoded}`;
44+
url += `/${repoInfo.owner}/${repoInfo.repo}/new/${branch}?filename=${filePath}&value=${encoded}`;
4145

4246
window.navigateTo(url);
4347

@@ -50,10 +54,24 @@ function showDownloadAlert() {
5054
alert.focus();
5155
}
5256

53-
function handleDownloadClick(e) {
57+
async function handleDownloadClick(e) {
5458
e.preventDefault();
5559

56-
const workflow = generateWorkflowWithFormInputs();
60+
const repoField = document.getElementById("repo");
61+
const repoInfo = parseRepositoryURL(repoField.value.trim());
62+
63+
let branch = await detectDefaultBranch(repoInfo);
64+
if (!branch) branch = "main";
65+
66+
const workflow = generateWorkflow({
67+
useBatchToken: document.getElementById("use-batch-token").checked,
68+
useVirtualDisplay: document.getElementById("use-virtual-display").checked,
69+
buildAcrossPlatforms: document.getElementById("build-across-platforms")
70+
.checked,
71+
siteUrl:
72+
window.location.origin + window.location.pathname.replace(/\/[^/]*$/, ""),
73+
branch,
74+
});
5775

5876
const blob = new Blob([workflow], { type: "text/yaml" });
5977
const url = URL.createObjectURL(blob);

public/scripts/workflow.js

Lines changed: 82 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ function parseRepositoryURL(repoURL) {
3333
// Enterprise: http(s)://github.com/enterprises/enterprise/owner/repo
3434
return {
3535
origin: url.origin,
36+
hostname: url.hostname,
3637
enterprise: parts[1],
3738
owner: parts[2],
3839
repo: parts[3],
@@ -41,6 +42,7 @@ function parseRepositoryURL(repoURL) {
4142
// Standard: http(s)://host/owner/repo
4243
return {
4344
origin: url.origin,
45+
hostname: url.hostname,
4446
owner: parts[0],
4547
repo: parts[1],
4648
};
@@ -51,11 +53,88 @@ function parseRepositoryURL(repoURL) {
5153
return null;
5254
}
5355

56+
async function detectDefaultBranch(repoInfo) {
57+
if (!repoInfo) return null;
58+
59+
async function fetchWithTimeout(url, options = {}) {
60+
const controller = new AbortController();
61+
const timeout = setTimeout(() => controller.abort(), 3000); // 3 seconds timeout
62+
try {
63+
const resp = await fetch(url, {
64+
...options,
65+
signal: controller.signal,
66+
});
67+
return resp;
68+
} finally {
69+
clearTimeout(timeout);
70+
}
71+
}
72+
73+
// Try to detect default branch using the GitHub API
74+
async function tryApi() {
75+
let apiUrl;
76+
if (repoInfo.hostname.replace(/^www\./, "") === "github.com") {
77+
apiUrl = `https://api.github.com/repos/${repoInfo.owner}/${repoInfo.repo}`;
78+
} else {
79+
apiUrl = `${repoInfo.origin}/api/v3/repos/${repoInfo.owner}/${repoInfo.repo}`;
80+
}
81+
82+
try {
83+
const resp = await fetchWithTimeout(apiUrl, {
84+
headers: { Accept: "application/vnd.github+json" },
85+
});
86+
if (!resp.ok) return null;
87+
88+
const data = await resp.json();
89+
return data.default_branch || null;
90+
} catch {
91+
// Network or abort error, fallback
92+
return null;
93+
}
94+
}
95+
96+
// Fallback: Probe common branch names by checking for a README.md
97+
async function tryBlobFallback() {
98+
let repoBaseUrl;
99+
if (repoInfo.hostname.replace(/^www\./, "") === "github.com") {
100+
repoBaseUrl = "https://github.com";
101+
} else {
102+
repoBaseUrl = `${repoInfo.origin}`;
103+
}
104+
if (repoInfo.enterprise) {
105+
repoBaseUrl += `/enterprises/${repoInfo.enterprise}`;
106+
}
107+
repoBaseUrl += `/${repoInfo.owner}/${repoInfo.repo}`;
108+
109+
const branches = ["main", "master", "develop"];
110+
const checks = branches.map(async (branch) => {
111+
const url = `${repoBaseUrl}/blob/${branch}/README.md`;
112+
try {
113+
const resp = await fetchWithTimeout(url, {
114+
method: "HEAD",
115+
redirect: "manual",
116+
});
117+
return resp.ok ? branch : null;
118+
} catch {
119+
return null;
120+
}
121+
});
122+
const results = await Promise.all(checks);
123+
return results.find((branch) => branch !== null) || null;
124+
}
125+
126+
// Main logic: Try API first, then fallback if needed
127+
const apiBranch = await tryApi();
128+
if (apiBranch) return apiBranch;
129+
return await tryBlobFallback();
130+
}
131+
54132
function generateWorkflow({
55133
useBatchToken = false,
56134
useVirtualDisplay = false,
57135
buildAcrossPlatforms = false,
58136
siteUrl = "http://localhost/",
137+
branch = "main",
59138
}) {
60139
return dedent(`
61140
# This workflow was generated using the GitHub Actions Workflow Generator for MATLAB.
@@ -65,9 +144,9 @@ function generateWorkflow({
65144
66145
on:
67146
push:
68-
branches: [main]
147+
branches: [${branch}]
69148
pull_request:
70-
branches: [main]
149+
branches: [${branch}]
71150
workflow_dispatch: {}
72151
${
73152
useBatchToken
@@ -145,4 +224,4 @@ function dedent(str) {
145224
return match ? str.replace(new RegExp("^" + match[0], "gm"), "") : str;
146225
}
147226

148-
export { parseRepositoryURL, generateWorkflow };
227+
export { parseRepositoryURL, detectDefaultBranch, generateWorkflow };

tests/main.test.js

Lines changed: 66 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,64 +20,121 @@ beforeEach(async () => {
2020
<div id="download-alert" class="d-none"></div>
2121
`;
2222

23+
const realWorkflow = await import("../public/scripts/workflow.js");
24+
jest.unstable_mockModule("../public/scripts/workflow.js", () => ({
25+
...realWorkflow,
26+
detectDefaultBranch: jest.fn().mockResolvedValue("main"),
27+
}));
28+
2329
await import("../public/scripts/main.js");
2430
window.navigateTo = jest.fn();
2531
});
2632

27-
test("form submit with invalid repo shows error", () => {
33+
const flushPromises = () => new Promise((r) => setTimeout(r, 0));
34+
35+
test("form submit with invalid repo shows error", async () => {
2836
const repoInput = document.getElementById("repo");
2937
expect(repoInput.classList.contains("is-invalid")).toBe(false);
3038
repoInput.value = "invalidrepo";
3139
document
3240
.getElementById("generate-form")
3341
.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
42+
await flushPromises();
3443
expect(repoInput.classList.contains("is-invalid")).toBe(true);
3544
expect(window.navigateTo).not.toHaveBeenCalled();
3645
});
3746

38-
test("form submit with valid slug works", () => {
47+
test("form submit with valid slug works", async () => {
3948
const repoInput = document.getElementById("repo");
4049
repoInput.value = "owner/repo";
4150
document
4251
.getElementById("generate-form")
4352
.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
53+
await flushPromises();
4454
expect(window.navigateTo).toHaveBeenCalledWith(
4555
expect.stringContaining("https://github.com/owner/repo/new/main?filename="),
4656
);
4757
});
4858

49-
test("form submit with valid URL works", () => {
59+
test("form submit with valid URL works", async () => {
5060
const repoInput = document.getElementById("repo");
5161
repoInput.value = "https://github.com/octocat/Hello-World";
5262
document
5363
.getElementById("generate-form")
5464
.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
65+
await flushPromises();
5566
expect(window.navigateTo).toHaveBeenCalledWith(
5667
expect.stringContaining(
5768
"https://github.com/octocat/Hello-World/new/main?filename=",
5869
),
5970
);
6071
});
6172

62-
test("form submit with valid cloud-hosted enterprise URL works", () => {
73+
test("form submit with valid cloud-hosted enterprise URL works", async () => {
6374
const repoInput = document.getElementById("repo");
6475
repoInput.value = "https://github.com/enterprises/gh/octocat/Hello-World";
6576
document
6677
.getElementById("generate-form")
6778
.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
79+
await flushPromises();
6880
expect(window.navigateTo).toHaveBeenCalledWith(
6981
expect.stringContaining(
7082
"https://github.com/enterprises/gh/octocat/Hello-World/new/main?filename=",
7183
),
7284
);
7385
});
7486

87+
test("form submit uses detected default branch", async () => {
88+
jest.resetModules();
89+
jest.unstable_mockModule("../public/scripts/workflow.js", () => ({
90+
parseRepositoryURL: (v) => ({
91+
origin: "https://github.com",
92+
owner: "o",
93+
repo: "r",
94+
}),
95+
detectDefaultBranch: jest.fn().mockResolvedValue("master"),
96+
generateWorkflow: () => "yaml-content",
97+
}));
98+
await import("../public/scripts/main.js");
99+
window.navigateTo = jest.fn();
100+
document
101+
.getElementById("generate-form")
102+
.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
103+
await flushPromises();
104+
expect(window.navigateTo).toHaveBeenCalledWith(
105+
expect.stringContaining("https://github.com/o/r/new/master?filename="),
106+
);
107+
});
108+
109+
test("form submit defaults to main branch if detection fails", async () => {
110+
jest.resetModules();
111+
jest.unstable_mockModule("../public/scripts/workflow.js", () => ({
112+
parseRepositoryURL: (v) => ({
113+
origin: "https://github.com",
114+
owner: "o",
115+
repo: "r",
116+
}),
117+
detectDefaultBranch: jest.fn().mockResolvedValue(null),
118+
generateWorkflow: () => "yaml-content",
119+
}));
120+
await import("../public/scripts/main.js");
121+
window.navigateTo = jest.fn();
122+
document
123+
.getElementById("generate-form")
124+
.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
125+
await flushPromises();
126+
expect(window.navigateTo).toHaveBeenCalledWith(
127+
expect.stringContaining("https://github.com/o/r/new/main?filename="),
128+
);
129+
});
130+
75131
test("advanced options are passed to generateWorkflow", async () => {
76132
// Re-import main.js with a spy on generateWorkflow
77133
jest.resetModules();
78134
const workflowSpy = jest.fn(() => "yaml-content");
79135
jest.unstable_mockModule("../public/scripts/workflow.js", () => ({
80136
parseRepositoryURL: (v) => ({ owner: "o", repo: "r" }),
137+
detectDefaultBranch: jest.fn().mockResolvedValue("main"),
81138
generateWorkflow: workflowSpy,
82139
}));
83140
document.getElementById("repo").value = "o/r";
@@ -89,15 +146,17 @@ test("advanced options are passed to generateWorkflow", async () => {
89146
document
90147
.getElementById("generate-form")
91148
.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
149+
await flushPromises();
92150
expect(workflowSpy).toHaveBeenCalledWith({
93151
useBatchToken: true,
94152
useVirtualDisplay: false,
95153
buildAcrossPlatforms: true,
96154
siteUrl: "http://localhost",
155+
branch: "main",
97156
});
98157
});
99158

100-
test("download link triggers file download", () => {
159+
test("download link triggers file download", async () => {
101160
const repoInput = document.getElementById("repo");
102161
repoInput.value = "owner/repo";
103162

@@ -116,6 +175,8 @@ test("download link triggers file download", () => {
116175

117176
document.getElementById("download-alert-link").click();
118177

178+
await flushPromises();
179+
119180
expect(mockCreateObjectURL).toHaveBeenCalled();
120181
expect(clickSpy).toHaveBeenCalled();
121182
expect(mockRevokeObjectURL).toHaveBeenCalled();

0 commit comments

Comments
 (0)