diff --git a/src/components/navbar-editor.tsx b/src/components/navbar-editor.tsx
index 8f9b902542..e9a741645a 100644
--- a/src/components/navbar-editor.tsx
+++ b/src/components/navbar-editor.tsx
@@ -40,7 +40,7 @@ import beautifier from "js-beautify";
import { collapseRanges } from "../lib/codemirror/util";
import { foldAllTemplateLiterals, onRun} from "./big-interactive-pages/editor";
import { showKeyBinding } from '../lib/state';
-import { validateGitHubToken, forkRepository, createBranch, createCommit, fetchLatestCommitSha, createTreeAndCommit, createPullRequest, fetchForkedRepository, updateBranch, createBlobForImage, findGamePullRequest, recordGamePullRequest, fetchCommitTreeSha, updatePullRequest, EDITOR_BRANCH_PREFIX } from "../lib/game-saving/github";
+import { validateGitHubToken, forkRepository, createBranch, createCommit, fetchLatestCommitSha, createTreeAndCommit, createPullRequest, fetchForkedRepository, updateBranch, createBlobForImage, findGamePullRequest, recordGamePullRequest, fetchCommitTreeSha, updatePullRequest, EDITOR_BRANCH_PREFIX, delay } from "../lib/game-saving/github";
const saveName = throttle(500, async (gameId: string, newName: string) => {
try {
@@ -322,7 +322,7 @@ export default function EditorNavbar(props: EditorNavbarProps) {
const publishError = useSignal(false);
const publishErrorMessage = useSignal(null);
const githubPRUrl = useSignal(null);
- const publishOutcome = useSignal<"new" | "updated" | "unchanged">("new");
+ const publishOutcome = useSignal<"new" | "updated" | "unchanged" | "reopened" | "closed_updated">("new");
const publishFormTitle = useSignal(null);
const publishFormAuthor = useSignal(null);
const publishFormDescription = useSignal("");
@@ -640,28 +640,42 @@ export default function EditorNavbar(props: EditorNavbarProps) {
branchName = openPR.pullRequest.head.ref;
try {
baseSha = await fetchLatestCommitSha(accessToken, repoOwner, repoName, branchName);
- } catch (error) {
- trackFailure("github_publish.failure.commit_sha");
- throw new Error("Failed to fetch the pull request's latest commit: " + (error instanceof Error ? error.message : String(error)));
+ } catch (error: any) {
+ if (openPR.pullRequest.state === "closed" && (error?.status === 404 || error?.message?.includes("404"))) {
+ console.warn(`Branch ${branchName} not found on closed PR #${pullNumber}, creating new PR instead.`);
+ openPR = null;
+ } else {
+ trackFailure("github_publish.failure.commit_sha");
+ throw new Error("Failed to fetch the pull request's latest commit: " + (error instanceof Error ? error.message : String(error)));
+ }
}
- if (!openPR.files.some((file: any) => file.filename === gamePath && file.status !== "removed")) {
+ if (openPR && !openPR.files.some((file: any) => file.filename === gamePath && file.status !== "removed")) {
const addedGames = openPR.files.filter((file: any) => file.status === "added" && /^games\/[^/]+\.js$/.test(file.filename));
if (addedGames.length !== 1 || openPR.pullRequest.head.sha !== baseSha) {
- throw new Error(`Couldn't match this game to pull request #${pullNumber}. Try again in a moment, or update it on GitHub.`);
+ if (openPR.pullRequest.state === "closed") {
+ console.warn(`Game mismatch on closed PR #${pullNumber}, creating new PR instead.`);
+ openPR = null;
+ } else {
+ throw new Error(`Couldn't match this game to pull request #${pullNumber}. Try again in a moment, or update it on GitHub.`);
+ }
}
- const oldName = addedGames[0].filename.slice("games/".length, -".js".length);
- if (!window.confirm(`This renames your game in pull request #${pullNumber} from "${oldName}" to "${sanitizedGameTitle}". Continue?`)) {
- reportMetric("github_publish.cancel_rename");
- readyPublish.value = true;
- return;
+ if (openPR) {
+ const oldName = addedGames[0].filename.slice("games/".length, -".js".length);
+ if (!window.confirm(`This renames your game in pull request #${pullNumber} from "${oldName}" to "${sanitizedGameTitle}". Continue?`)) {
+ reportMetric("github_publish.cancel_rename");
+ readyPublish.value = true;
+ return;
+ }
+ renamedFrom = {
+ game: addedGames[0],
+ image: openPR.files.find((file: any) => file.status === "added" && file.filename === `games/img/${oldName}.png`),
+ };
}
- renamedFrom = {
- game: addedGames[0],
- image: openPR.files.find((file: any) => file.status === "added" && file.filename === `games/img/${oldName}.png`),
- };
}
- } else {
+ }
+
+ if (!openPR) {
let forkedRepo;
try {
forkedRepo = await forkRepository(accessToken, "hackclub", "sprig");
@@ -676,7 +690,7 @@ export default function EditorNavbar(props: EditorNavbarProps) {
}
}
- const latestCommitSha = await fetchLatestCommitSha(accessToken, "hackclub", "sprig", forkedRepo.default_branch);
+ const latestCommitSha = await fetchLatestCommitSha(accessToken, "hackclub", "sprig", "main");
if (!latestCommitSha) {
trackFailure("github_publish.failure.commit_sha");
throw new Error("Failed to fetch the latest commit SHA.");
@@ -685,9 +699,20 @@ export default function EditorNavbar(props: EditorNavbarProps) {
const newBranchName = `${EDITOR_BRANCH_PREFIX}${Date.now()}`;
try {
await createBranch(accessToken, forkedRepo.owner.login, forkedRepo.name, newBranchName, latestCommitSha);
- } catch (error) {
- trackFailure("github_publish.failure.branch");
- throw new Error("Failed to create branch: " + (error instanceof Error ? error.message : String(error)));
+ } catch (error: any) {
+ if (error?.status === 404 || error?.message?.includes("404")) {
+ console.warn("Branch creation returned 404, repository may still be provisioning. Retrying after delay...");
+ await delay(3000);
+ try {
+ await createBranch(accessToken, forkedRepo.owner.login, forkedRepo.name, newBranchName, latestCommitSha);
+ } catch (retryError) {
+ trackFailure("github_publish.failure.branch");
+ throw new Error("Failed to create branch: " + (retryError instanceof Error ? retryError.message : String(retryError)));
+ }
+ } else {
+ trackFailure("github_publish.failure.branch");
+ throw new Error("Failed to create branch: " + (error instanceof Error ? error.message : String(error)));
+ }
}
repoOwner = forkedRepo.owner.login;
@@ -764,7 +789,32 @@ export default function EditorNavbar(props: EditorNavbarProps) {
const titleChanged = pullRequest.title !== prTitle;
const normalizeBody = (body?: string | null) => (body ?? "").replace(/\r\n/g, "\n").trim();
const bodyChanged = Boolean(prBody && normalizeBody(pullRequest.body) !== normalizeBody(prBody));
- if (titleChanged || bodyChanged) {
+ let isReopened = false;
+ let closedNeedsReviewer = false;
+
+ if (pullRequest.state === "closed") {
+ try {
+ await updatePullRequest(accessToken, "hackclub", "sprig", pullRequest.number, {
+ state: "open",
+ ...(titleChanged ? { title: prTitle } : {}),
+ ...(bodyChanged ? { body: prBody } : {}),
+ });
+ isReopened = true;
+ } catch (err: any) {
+ console.warn("Could not automatically reopen closed pull request:", err);
+ closedNeedsReviewer = true;
+ if (titleChanged || bodyChanged) {
+ try {
+ await updatePullRequest(accessToken, "hackclub", "sprig", pullRequest.number, {
+ ...(titleChanged ? { title: prTitle } : {}),
+ ...(bodyChanged ? { body: prBody } : {}),
+ });
+ } catch (updateErr) {
+ console.warn("Failed to update closed pull request metadata:", updateErr);
+ }
+ }
+ }
+ } else if (titleChanged || bodyChanged) {
try {
await updatePullRequest(accessToken, "hackclub", "sprig", pullRequest.number, {
title: prTitle,
@@ -787,7 +837,13 @@ export default function EditorNavbar(props: EditorNavbarProps) {
rememberPullRequest(pullRequest.html_url);
const prUpdated = codeChanged || titleChanged || bodyChanged;
- publishOutcome.value = prUpdated ? "updated" : "unchanged";
+ if (isReopened) {
+ publishOutcome.value = "reopened";
+ } else if (closedNeedsReviewer) {
+ publishOutcome.value = "closed_updated";
+ } else {
+ publishOutcome.value = prUpdated ? "updated" : "unchanged";
+ }
reportMetric("github_publish.success");
reportMetric(prUpdated ? "github_publish.success.updated_pr" : "github_publish.success.unchanged_pr");
@@ -1186,6 +1242,8 @@ export default function EditorNavbar(props: EditorNavbarProps) {
new: "Your game has been successfully published to GitHub.",
updated: "Your open pull request for this game was updated with this version.",
unchanged: "Your pull request is already up to date with this version.",
+ reopened: "Your pull request was updated and reopened on GitHub.",
+ closed_updated: `Your game is up to date in pull request #${githubPRUrl.value?.match(/\/pull\/(\d+)/)?.[1] ?? ""}. Because it was closed by a reviewer, ask a reviewer to reopen it when you're ready!`,
}[publishOutcome.value]}