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]}