From e341647c9f62c80c1ff2ee9eb34b5e4e51241a37 Mon Sep 17 00:00:00 2001 From: Arsen Shkrumelyak Date: Thu, 20 Aug 2026 17:26:59 +0100 Subject: [PATCH 1/3] fix(manage): unarchive before changing visibility of archived repos --- src/github/github.test.ts | 102 ++++++++++++++++++++++++++++++++++++++ src/github/manage.ts | 75 +++++++++++++++++++++++++--- 2 files changed, 171 insertions(+), 6 deletions(-) diff --git a/src/github/github.test.ts b/src/github/github.test.ts index 428e0ab..75faf7b 100644 --- a/src/github/github.test.ts +++ b/src/github/github.test.ts @@ -1135,6 +1135,108 @@ describe("manageGitHubRepositories", () => { expect(results[0]?.visibility?.outcome).toBe("changed"); }); + const createArchivedRepositoryFetch = (requestBodies: unknown[]) => + createFetchImplementation((_url, init) => { + if ((init.method ?? "GET") === "GET") { + return new Response( + JSON.stringify(createManagedRepositoryResponse(true, "public")), + { status: 200, statusText: "OK" } + ); + } + + requestBodies.push(JSON.parse(String(init.body))); + + return new Response("", { status: 200, statusText: "OK" }); + }); + + it("unarchives and re-archives around a visibility change on an archived repository", async () => { + const requestBodies: unknown[] = []; + + const results = await manageGitHubRepositories( + "token", + "owner", + [ + { + actions: { ...noChangeActions, visibilityAction: "private" }, + repository: "repo", + }, + ], + createArchivedRepositoryFetch(requestBodies) + ); + + expect(requestBodies).toEqual([ + { archived: false }, + { archived: true, visibility: "private" }, + ]); + expect(results[0]?.ok).toBe(true); + expect(results[0]?.archive).toBeNull(); + expect(results[0]?.visibility?.outcome).toBe("changed"); + }); + + it("unarchives before changing visibility when the repository should end up unarchived", async () => { + const requestBodies: unknown[] = []; + + const results = await manageGitHubRepositories( + "token", + "owner", + [ + { + actions: { + archiveAction: "unarchived", + subscriptionAction: "current", + visibilityAction: "private", + }, + repository: "repo", + }, + ], + createArchivedRepositoryFetch(requestBodies) + ); + + expect(requestBodies).toEqual([ + { archived: false }, + { visibility: "private" }, + ]); + expect(results[0]?.archive?.outcome).toBe("changed"); + expect(results[0]?.visibility?.outcome).toBe("changed"); + }); + + it("reports a failed re-archive after unarchiving for a visibility change", async () => { + const fetchImplementation = createFetchImplementation((_url, init) => { + if ((init.method ?? "GET") === "GET") { + return new Response( + JSON.stringify(createManagedRepositoryResponse(true, "public")), + { status: 200, statusText: "OK" } + ); + } + + const body = JSON.parse(String(init.body)) as { archived?: boolean }; + + return body.archived === false + ? new Response("", { status: 200, statusText: "OK" }) + : new Response(JSON.stringify({ message: "Nope" }), { + status: 422, + statusText: "Unprocessable Entity", + }); + }); + + const results = await manageGitHubRepositories( + "token", + "owner", + [ + { + actions: { ...noChangeActions, visibilityAction: "private" }, + repository: "repo", + }, + ], + fetchImplementation + ); + + expect(results[0]?.ok).toBe(false); + expect(results[0]?.visibility?.outcome).toBe("failed"); + expect(results[0]?.archive?.outcome).toBe("failed"); + expect(results[0]?.archive?.error).toContain("could not be archived again"); + }); + describe("notification subscription updates", () => { it("watches a repository that has no existing subscription", async () => { const methods: string[] = []; diff --git a/src/github/manage.ts b/src/github/manage.ts index d37da74..5476f05 100644 --- a/src/github/manage.ts +++ b/src/github/manage.ts @@ -279,6 +279,63 @@ const loadManagedRepository = async ( return (await response.json()) as GitHubManagedRepositoryResponse; }; +const patchRepositorySettings = async ( + context: ManageContext, + repositoryName: string, + body: RepositorySettingsBody +): Promise => { + const pathname = repositoryPathname(context.owner, repositoryName); + const response = await fetchWithRateLimitRetry(context, () => + fetchGitHubResponse(context, pathname, jsonRequestInit("PATCH", body)) + ); + + return await toSettingResult(response, `Failed to update ${repositoryName}.`); +}; + +/** + * GitHub rejects every write to an archived repository except unarchiving it, + * so a visibility change unarchives first and re-archives unless the repository + * is meant to end up unarchived. + */ +const applyVisibilityToArchivedRepository = async ( + context: ManageContext, + repositoryName: string, + body: RepositorySettingsBody +): Promise => { + const staysArchived = body.archived !== false; + const unarchiveResult = await patchRepositorySettings( + context, + repositoryName, + { archived: false } + ); + const archiveResultAfterUnarchive = staysArchived ? null : unarchiveResult; + + if (unarchiveResult.outcome === "failed") { + return { + archive: staysArchived ? null : unarchiveResult, + visibility: { ...unarchiveResult }, + }; + } + + const visibilityResult = await patchRepositorySettings( + context, + repositoryName, + { archived: staysArchived ? true : undefined, visibility: body.visibility } + ); + const rearchiveFailed = + staysArchived && visibilityResult.outcome === "failed"; + + return { + archive: rearchiveFailed + ? { + ...visibilityResult, + error: `${repositoryName} was unarchived to change its visibility and could not be archived again: ${visibilityResult.error}`, + } + : archiveResultAfterUnarchive, + visibility: visibilityResult, + }; +}; + const applyRepositorySettings = async ( context: ManageContext, repositoryName: string, @@ -315,12 +372,18 @@ const applyRepositorySettings = async ( return { archive, visibility }; } - const patchResponse = await fetchWithRateLimitRetry(context, () => - fetchGitHubResponse(context, pathname, jsonRequestInit("PATCH", body)) - ); - const patchResult = await toSettingResult( - patchResponse, - `Failed to update ${repositoryName}.` + if (current.archived && body.visibility !== undefined) { + return await applyVisibilityToArchivedRepository( + context, + repositoryName, + body + ); + } + + const patchResult = await patchRepositorySettings( + context, + repositoryName, + body ); return { From d0ea61c2162d01586cf0f046e7817372e6226e35 Mon Sep 17 00:00:00 2001 From: Arsen Shkrumelyak Date: Thu, 20 Aug 2026 17:56:54 +0100 Subject: [PATCH 2/3] feat(manage): warn that visibility changes reset an archived repo's archive date --- src/components/manage/change-warning.tsx | 25 +++++++++++ src/components/manage/manage-edit-dialog.tsx | 5 +++ .../manage/manage-review-dialog.tsx | 11 ++++- src/components/manage/utils.test.ts | 41 +++++++++++++++++++ src/components/manage/utils.ts | 20 +++++++++ 5 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 src/components/manage/change-warning.tsx diff --git a/src/components/manage/change-warning.tsx b/src/components/manage/change-warning.tsx new file mode 100644 index 0000000..2cfd1cd --- /dev/null +++ b/src/components/manage/change-warning.tsx @@ -0,0 +1,25 @@ +import clsx from "clsx"; + +/** A caution line under a repository's change preview; renders nothing without a warning. */ +export function ChangeWarning({ + className, + warning, +}: Readonly<{ + className?: string; + warning: string | null; +}>) { + if (warning === null) { + return null; + } + + return ( +

+ {warning} +

+ ); +} diff --git a/src/components/manage/manage-edit-dialog.tsx b/src/components/manage/manage-edit-dialog.tsx index 21966ce..030a8e8 100644 --- a/src/components/manage/manage-edit-dialog.tsx +++ b/src/components/manage/manage-edit-dialog.tsx @@ -15,11 +15,13 @@ import { Field, Fieldset, Label } from "@/components/ui/fieldset"; import { Strong, Text } from "@/components/ui/text"; import { formatRepositoryCount } from "@/format"; import type { GitHubRepository, ManageRepositoryActions } from "@/github/types"; +import { ChangeWarning } from "./change-warning"; import { MANAGE_ARCHIVE_ACTION_OPTIONS, MANAGE_SUBSCRIPTION_ACTION_OPTIONS, } from "./types"; import { + getArchivedVisibilityWarning, getManageActionsSummary, getManageVisibilityActionOptions, getRepositoryChangeLines, @@ -160,6 +162,9 @@ function RepositoryChangePreview({ ) : ( No change needed. )} + ); } diff --git a/src/components/manage/manage-review-dialog.tsx b/src/components/manage/manage-review-dialog.tsx index 3762e44..77ebe1c 100644 --- a/src/components/manage/manage-review-dialog.tsx +++ b/src/components/manage/manage-review-dialog.tsx @@ -15,7 +15,12 @@ import { import { Strong, Text } from "@/components/ui/text"; import { formatRepositoryCount } from "@/format"; import type { GitHubRepository, ManageRepositoryActions } from "@/github/types"; -import { getRepositoryChangeLines, type StagedChanges } from "./utils"; +import { ChangeWarning } from "./change-warning"; +import { + getArchivedVisibilityWarning, + getRepositoryChangeLines, + type StagedChanges, +} from "./utils"; /** Lists every staged change so the whole batch is confirmed at once. */ export function ManageReviewDialog({ @@ -105,6 +110,9 @@ function StagedChangeItem({ const changeLines = actions ? getRepositoryChangeLines(repository, actions) : []; + const warning = actions + ? getArchivedVisibilityWarning(repository, actions) + : null; return (
  • @@ -115,6 +123,7 @@ function StagedChangeItem({ {changeLine} ))} +