Skip to content
Merged
104 changes: 81 additions & 23 deletions src/components/navbar-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -322,7 +322,7 @@ export default function EditorNavbar(props: EditorNavbarProps) {
const publishError = useSignal(false);
const publishErrorMessage = useSignal<string | null>(null);
const githubPRUrl = useSignal<string | null>(null);
const publishOutcome = useSignal<"new" | "updated" | "unchanged">("new");
const publishOutcome = useSignal<"new" | "updated" | "unchanged" | "reopened" | "closed_updated">("new");
const publishFormTitle = useSignal<string | null>(null);
const publishFormAuthor = useSignal<string | null>(null);
const publishFormDescription = useSignal("");
Expand Down Expand Up @@ -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");
Expand All @@ -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.");
Expand All @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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");

Expand Down Expand Up @@ -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]}
</p>
<Button onClick={() => { githubPRUrl.value && window.open(githubPRUrl.value, "_blank") }}>
Expand Down
113 changes: 99 additions & 14 deletions src/lib/game-saving/github.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

export class GitHubApiError extends Error {
status: number;
Expand Down Expand Up @@ -142,7 +142,7 @@ export async function fetchGitHubUser(accessToken: string): Promise<any> {
export async function validateGitHubToken(
accessToken: string
): Promise<boolean> {
const response = await fetchWithRetry("https://api.github.com/", {
const response = await fetchWithRetry("https://api.github.com/user", {
headers: getAuthHeaders(accessToken),
});

Expand Down Expand Up @@ -195,7 +195,7 @@ export async function createBranch(
`https://api.github.com/repos/${owner}/${repo}/git/refs`, // GitHub API endpoint to create a branch
{
method: "POST",
headers: getAuthHeaders(accessToken),
headers: getJsonHeaders(accessToken),
body: JSON.stringify({
ref: `refs/heads/${branchName}`,
sha: sha,
Expand All @@ -219,7 +219,7 @@ export async function createCommit(
`https://api.github.com/repos/${owner}/${repo}/git/commits`, // GitHub API endpoint to create a commit
{
method: "POST",
headers: getAuthHeaders(accessToken),
headers: getJsonHeaders(accessToken),
body: JSON.stringify({
message: message,
tree: treeSha,
Expand Down Expand Up @@ -253,7 +253,7 @@ export async function createPullRequest(
`https://api.github.com/repos/${owner}/${repo}/pulls`, // GitHub API endpoint for creating pull requests
{
method: "POST",
headers: getAuthHeaders(accessToken),
headers: getJsonHeaders(accessToken),
body: JSON.stringify({
title: title,
head: fullHead,
Expand Down Expand Up @@ -328,11 +328,12 @@ export async function recordGamePullRequest(
export const EDITOR_BRANCH_PREFIX = "Automated-PR-";
export const EDITOR_BRANCH_REGEX = /^Automated-PR-\d+$/;

function isEditorPullRequest(pullRequest: any, author: string): boolean {
export function isEditorPullRequest(pullRequest: any, author: string, allowClosed = false): boolean {
const validState = pullRequest?.state === "open" || (allowClosed && pullRequest?.state === "closed" && !pullRequest?.merged_at);
return (
pullRequest?.state === "open" &&
pullRequest.head?.repo?.owner?.login?.toLowerCase() === author.toLowerCase() &&
EDITOR_BRANCH_REGEX.test(pullRequest.head?.ref ?? "")
Boolean(validState) &&
pullRequest?.head?.repo?.owner?.login?.toLowerCase() === author.toLowerCase() &&
EDITOR_BRANCH_REGEX.test(pullRequest?.head?.ref ?? "")
);
}

Expand Down Expand Up @@ -372,10 +373,14 @@ export async function findGamePullRequest(
}

const saved = savedPullRequestUrl?.match(new RegExp(`^https://github\\.com/${owner}/${repo}/pull/(\\d+)`));
let closedSavedPR: { pullRequest: any; files: any[] } | null = null;
if (saved) {
try {
const found = await fetchPullRequestWithFiles(accessToken, owner, repo, Number(saved[1]));
if (isEditorPullRequest(found.pullRequest, author)) return found;
if (isEditorPullRequest(found.pullRequest, author, false)) return found;
if (isEditorPullRequest(found.pullRequest, author, true)) {
closedSavedPR = found;
}
} catch (error: any) {
if (error?.status === 404 || error?.message?.includes("404")) {
console.warn(`Saved pull request #${saved[1]} not found (404). Falling through to search.`);
Expand Down Expand Up @@ -442,7 +447,68 @@ export async function findGamePullRequest(
throw new Error(`Failed to check all open pull requests (${skippedCount} failed to load). Please try again.`);
}

return null;
if (closedSavedPR && closedSavedPR.files.some((file: any) => file.filename === gamePath && file.status !== "removed")) {
return closedSavedPR;
}

const closedQuery = encodeURIComponent(`repo:${owner}/${repo} is:pr is:closed is:unmerged author:${author}`);
const closedSearchResponse = await fetchWithRetry(
`https://api.github.com/search/issues?q=${closedQuery}&sort=updated&order=desc&per_page=100`,
{
headers: getAuthHeaders(accessToken),
}
);
const closedResults = await handleResponse(closedSearchResponse);

if (closedResults.incomplete_results) {
throw new Error("GitHub PR search timed out or returned incomplete results. Please try again.");
}

let closedSkippedCount = 0;
for (const item of closedResults.items ?? []) {
let pullRequest;
try {
const pullResponse = await fetchWithRetry(
`https://api.github.com/repos/${owner}/${repo}/pulls/${item.number}`,
{
headers: getAuthHeaders(accessToken),
}
);
pullRequest = await handleResponse(pullResponse);
} catch (error: any) {
if (error?.status !== 404 && !error?.message?.includes("404")) {
closedSkippedCount++;
}
console.warn(`Skipping closed pull request #${item.number} while looking for this game's pull request:`, error);
continue;
}

if (!isEditorPullRequest(pullRequest, author, true)) continue;

try {
const filesResponse = await fetchWithRetry(
`https://api.github.com/repos/${owner}/${repo}/pulls/${item.number}/files?per_page=100`,
{
headers: getAuthHeaders(accessToken),
}
);
const files = await handleResponse(filesResponse);
if (files.some((file: any) => file.filename === gamePath && file.status !== "removed")) {
return { pullRequest, files };
}
} catch (error: any) {
if (error?.status !== 404 && !error?.message?.includes("404")) {
closedSkippedCount++;
}
console.warn(`Skipping files for closed pull request #${item.number}:`, error);
}
}

if (closedSkippedCount > 0) {
throw new Error(`Failed to check all closed pull requests (${closedSkippedCount} failed to load). Please try again.`);
}

return closedSavedPR ?? null;
}

export async function fetchCommitTreeSha(
Expand All @@ -469,7 +535,7 @@ export async function updatePullRequest(
owner: string,
repo: string,
pullNumber: number,
updates: { title?: string; body?: string }
updates: { title?: string; body?: string; state?: "open" | "closed" }
): Promise<any> {
const response = await fetchWithRetry(
`https://api.github.com/repos/${owner}/${repo}/pulls/${pullNumber}`,
Expand Down Expand Up @@ -540,16 +606,35 @@ export async function fetchForkedRepository(
repo: string,
yourGithubUsername: string
): Promise<any> {
if (yourGithubUsername) {
try {
const directResponse = await fetchWithRetry(
`https://api.github.com/repos/${yourGithubUsername}/${repo}`,
{
headers: getAuthHeaders(accessToken),
}
);
if (directResponse.ok) {
const repoData = await handleResponse(directResponse);
if (repoData?.fork) {
return repoData;
}
}
} catch {
// Fall back to listing forks if direct access fails (e.g., custom fork repo name)
}
}

const response = await fetchWithRetry(
`https://api.github.com/repos/${owner}/${repo}/forks`, // GitHub API endpoint to fetch forks
`https://api.github.com/repos/${owner}/${repo}/forks?per_page=100`, // GitHub API endpoint to fetch forks
{
headers: getAuthHeaders(accessToken),
}
);

const forks = await handleResponse(response);
const forkedRepo = forks.find(
(fork: any) => fork.owner.login === yourGithubUsername // Find the fork created by the current user
(fork: any) => fork.owner.login?.toLowerCase() === yourGithubUsername.toLowerCase() // Find the fork created by the current user
);
if (!forkedRepo) {
throw new Error("Forked repository not found.");
Expand Down
Loading