diff --git a/web/src/lib/api/_request.test.ts b/web/src/lib/api/_request.test.ts new file mode 100644 index 000000000..3e08429d8 --- /dev/null +++ b/web/src/lib/api/_request.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { hostForKnot } from "$lib/api/_request"; + +describe("hostForKnot", () => { + it.each([ + ["did:web:knot2.tngl.boltless.dev", "knot2.tngl.boltless.dev"], + ["did:web:KNOT2.TNGL.BOLTLESS.DEV", "knot2.tngl.boltless.dev"], + ["did:web:knot.oyster.cafe%3A8443", "knot.oyster.cafe:8443"], + ["did:web:knot.oyster.cafe%3a8443", "knot.oyster.cafe:8443"], + ["did:web:localhost%3A3000", "localhost:3000"], + ["knot2.tngl.boltless.dev", "knot2.tngl.boltless.dev"], + ["knot.oyster.cafe:8443", "knot.oyster.cafe:8443"], + ["localhost", "localhost"], + ["localhost:3000", "localhost:3000"], + ["127.0.0.1:8000", "127.0.0.1:8000"] + ])("normalizes %s to %s", (input, expected) => { + expect(hostForKnot(input)).toBe(expected); + }); + + it.each([ + "did:plc:iz6r437h6kndu44y7u7f7rly", + "https://knot2.tngl.boltless.dev", + "knot2.tngl.boltless.dev/path", + "did:web:", + "did:web:foo/bar", + "did:web:knot.dev:8443", + "garbage text", + ":::", + "", + " ", + "knot..dev", + "-knot.dev", + "knot.dev:99999", + "knot.dev:0", + "foo@bar.com" + ])("rejects garbage %j", (input) => { + expect(() => hostForKnot(input)).toThrow(); + }); +}); diff --git a/web/src/lib/api/_request.ts b/web/src/lib/api/_request.ts index adcbdc59b..f6501fad6 100644 --- a/web/src/lib/api/_request.ts +++ b/web/src/lib/api/_request.ts @@ -6,6 +6,7 @@ import { type QueryValue, type XrpcRequestInit } from "$lib/api/client"; +import { hostForServiceDid } from "$lib/auth/agent"; export const serviceUrlFor = (host: string): string => /^[a-z][a-z0-9+.-]*:\/\//i.test(host) ? host.replace(/\/+$/, "") : `https://${host}`; @@ -13,6 +14,33 @@ export const serviceUrlFor = (host: string): string => export const serviceClient = (agent: OAuthUserAgent, host: string): BobbinContext => createBobbinClient({ serviceUrl: serviceUrlFor(host), agent }); +const BARE_HOST_PATTERN = + /^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?::[0-9]{1,5})?$/i; + +const isBareHost = (value: string): boolean => { + if (!value || !BARE_HOST_PATTERN.test(value)) return false; + const colon = value.indexOf(":"); + if (colon !== -1) { + const port = Number(value.slice(colon + 1)); + if (port < 1 || port > 65535) return false; + } + return true; +}; + +export const hostForKnot = (knot: string): string => { + if (knot.startsWith("did:web:")) { + if (knot.slice("did:web:".length).includes(":")) { + throw new Error(`invalid knot identifier: ${knot}`); + } + const host = hostForServiceDid(knot); + if (host && isBareHost(host)) return host; + throw new Error(`invalid knot identifier: ${knot}`); + } + if (isBareHost(knot)) return knot; + throw new Error(`invalid knot identifier: ${knot}`); +}; + + export const buildUrl = ( origin: string, nsid: string, diff --git a/web/src/lib/api/githubImport.test.ts b/web/src/lib/api/githubImport.test.ts index 75bf8d4a4..b3cbbdfd6 100644 --- a/web/src/lib/api/githubImport.test.ts +++ b/web/src/lib/api/githubImport.test.ts @@ -10,12 +10,20 @@ import { findRepoCollisions, importGitHubProfile, importFromUrl, - importGitHubRepo, importGitHubRepos, invalidateGitHubAccount, validateGitHubRepoUrl } from "$lib/api/githubImport"; +const mockCreateOptimisticRepoRecord = vi.fn(); +vi.mock("$lib/api/repoCreate", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createOptimisticRepoRecord: (...args: unknown[]) => mockCreateOptimisticRepoRecord(...args) + }; +}); + const mockCreateMigrationTask = vi.fn(); vi.mock("$lib/api/migrator", async (importOriginal) => { const actual = await importOriginal(); @@ -238,7 +246,7 @@ describe("findRepoCollisions", () => { }); }); -describe("importGitHubRepo", () => { +describe("importGitHubRepos", () => { beforeEach(() => { vi.clearAllMocks(); mockCreateMigrationTask.mockImplementation( @@ -265,19 +273,21 @@ describe("importGitHubRepo", () => { }); it("hands the clone url to the migrator, with the knot to create the repository on", async () => { - const started = await importGitHubRepo( + const task = await importGitHubRepos( testAgent, "https://migrator.test", "https://github.com", - { - ownerDid: "did:plc:alice" as Did, - ownerHandle: "alice.tangled.org", - knot: "knot.test", - repo: baseRepo - } + [ + { + ownerDid: "did:plc:alice" as Did, + ownerHandle: "alice.tangled.org", + knot: "knot.test", + repo: baseRepo + } + ] ); - expect(started).toEqual({ taskId: "task-1", name: "sample-repo", jobId: "1" }); + expect(task.id).toBe("task-1"); expect(mockCreateMigrationTask).toHaveBeenCalledTimes(1); const [, host, requestId, jobs] = mockCreateMigrationTask.mock.calls[0]; expect(host).toBe("https://migrator.test"); @@ -291,6 +301,37 @@ describe("importGitHubRepo", () => { ]); }); + it("creates optimistic records ahead of migration task when bobbinUrl is supplied", async () => { + await importGitHubRepos( + testAgent, + "https://migrator.test", + "https://github.com", + [ + { + ownerDid: "did:plc:alice" as Did, + ownerHandle: "alice.tangled.org", + knot: "knot.test", + repo: baseRepo + } + ], + { bobbinUrl: "https://bobbin.test" } + ); + + expect(mockCreateOptimisticRepoRecord).toHaveBeenCalledTimes(1); + expect(mockCreateOptimisticRepoRecord).toHaveBeenCalledWith( + testAgent, + "https://bobbin.test", + expect.objectContaining({ + ownerDid: "did:plc:alice", + ownerHandle: "alice.tangled.org", + name: "sample-repo", + knot: "knot.test", + source: { kind: "import", url: "https://github.com/octocat/sample-repo.git" } + }) + ); + expect(mockCreateMigrationTask).toHaveBeenCalledTimes(1); + }); + it("batches multiple repositories, marking private jobs, preserving knot DIDs, and normalising names", async () => { const task = await importGitHubRepos( testAgent, @@ -342,12 +383,14 @@ describe("importGitHubRepo", () => { }; await expect( - importGitHubRepo(testAgent, "https://migrator.test", "https://github.com", { - ownerDid: "did:plc:alice" as Did, - ownerHandle: "alice.tangled.org", - knot: "knot.test", - repo: mismatchedRepo - }) + importGitHubRepos(testAgent, "https://migrator.test", "https://github.com", [ + { + ownerDid: "did:plc:alice" as Did, + ownerHandle: "alice.tangled.org", + knot: "knot.test", + repo: mismatchedRepo + } + ]) ).rejects.toThrow(/pathname must match repository/); expect(mockCreateMigrationTask).not.toHaveBeenCalled(); @@ -468,6 +511,7 @@ describe("importGitHubProfile", () => { size: 3 }); + // network failure must not be treated as a missing profile mockReadRecord.mockRejectedValue(new Error("Connection reset by peer")); const result = await importGitHubProfile( @@ -743,6 +787,32 @@ describe("importFromUrl", () => { const [, , , jobs] = mockCreateMigrationTask.mock.calls[0]; expect(jobs[0].sourceUrl).toBe("https://git.example.com/team/project.git"); }); + + it("creates an optimistic record ahead of migration task when bobbinUrl and owner are supplied", async () => { + await importFromUrl( + testAgent, + "https://migrator.test", + { + sourceUrl: "https://git.example.com/team/project.git", + name: "project", + description: "a small tool", + knot: "knot.test", + ownerDid: "did:plc:alice" as Did, + ownerHandle: "alice.tangled.org" + }, + { bobbinUrl: "https://bobbin.test" } + ); + expect(mockCreateOptimisticRepoRecord).toHaveBeenCalledWith( + testAgent, + "https://bobbin.test", + expect.objectContaining({ + name: "project", + knot: "knot.test", + source: { kind: "import", url: "https://git.example.com/team/project.git" } + }) + ); + expect(mockCreateMigrationTask).toHaveBeenCalledTimes(1); + }); }); describe("fetchGitHubAccount", () => { diff --git a/web/src/lib/api/githubImport.ts b/web/src/lib/api/githubImport.ts index 4ed4951f0..01880eb60 100644 --- a/web/src/lib/api/githubImport.ts +++ b/web/src/lib/api/githubImport.ts @@ -3,7 +3,7 @@ import { mainSchema as listRecordsSchema } from "@atcute/atproto/types/repo/list import { isDid, type Did, type Nsid } from "@atcute/lexicons/syntax"; import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; import { createClient, serviceDidForHost } from "$lib/auth/agent"; -import { validateRepoName } from "$lib/api/repoCreate"; +import { createOptimisticRepoRecord, validateRepoName } from "$lib/api/repoCreate"; import { createMigrationTask, type MigrationTask } from "$lib/api/migrator"; import { uploadProfileAvatar } from "$lib/api/profile"; import { createRecord, putRecord, readRecord } from "$lib/api/write"; @@ -109,7 +109,7 @@ export const validateGitHubRepoUrl = ( export const canonicalRepoName = (rawName: string): string => validateRepoName(rawName).toLowerCase(); -export interface RepoCollision { +interface RepoCollision { canonical: string; candidates: T[]; conflictsWithExisting: boolean; @@ -158,7 +158,7 @@ export const findRepoCollisions = ( ); }; -export interface ImportGitHubRepoInput { +interface ImportGitHubRepoInput { ownerDid: Did; ownerHandle: string; knot: string; @@ -168,11 +168,14 @@ export interface ImportGitHubRepoInput { description?: string; } -export interface ImportUrlInput { +interface ImportUrlInput { sourceUrl: string; name: string; description?: string; knot: string; + ownerDid?: Did; + ownerHandle?: string; + spindle?: string; } const knotDidOf = (knot: string): Did => { @@ -210,11 +213,24 @@ export const importFromUrl = async ( agent: OAuthUserAgent, migratorUrl: string, input: ImportUrlInput, - options?: { fetch?: typeof globalThis.fetch; signal?: AbortSignal } + options?: { fetch?: typeof globalThis.fetch; signal?: AbortSignal; bobbinUrl?: string } ): Promise => { const sourceUrl = validateUrlSource(input.sourceUrl); const name = validateRepoName(input.name); const description = input.description?.trim() || undefined; + + if (options?.bobbinUrl && input.ownerDid && input.ownerHandle) { + await createOptimisticRepoRecord(agent, options.bobbinUrl, { + ownerDid: input.ownerDid, + ownerHandle: input.ownerHandle, + name, + knot: input.knot, + spindle: input.spindle, + description, + source: { kind: "import", url: sourceUrl } + }); + } + const task = await createMigrationTask( agent, migratorUrl, @@ -238,10 +254,11 @@ export const importGitHubRepos = async ( migratorUrl: string, origin: string, inputs: ImportGitHubRepoInput[], - options?: { fetch?: typeof globalThis.fetch; signal?: AbortSignal } + options?: { fetch?: typeof globalThis.fetch; signal?: AbortSignal; bobbinUrl?: string } ): Promise => { if (inputs.length === 0) throw new Error("Select at least one repository to import."); - const jobs = inputs.map((input) => { + const jobs = []; + for (const input of inputs) { const cloneUrl = validateGitHubRepoUrl( input.repo.cloneUrl, input.repo.fullName, @@ -250,33 +267,30 @@ export const importGitHubRepos = async ( ); const name = validateRepoName(input.name ?? input.repo.name); const description = input.description?.trim() || undefined; - return { + + if (options?.bobbinUrl) { + await createOptimisticRepoRecord(agent, options.bobbinUrl, { + ownerDid: input.ownerDid, + ownerHandle: input.ownerHandle, + name, + knot: input.knot, + spindle: input.spindle, + description, + source: { kind: "import", url: cloneUrl } + }); + } + + jobs.push({ name, knotDid: knotDidOf(input.knot), sourceUrl: validateUrlSource(cloneUrl), ...(input.repo.private === true ? { private: true } : {}), ...(description !== undefined ? { description } : {}) - }; - }); + }); + } return createMigrationTask(agent, migratorUrl, crypto.randomUUID(), jobs, options); }; -export const importGitHubRepo = async ( - agent: OAuthUserAgent, - migratorUrl: string, - origin: string, - input: ImportGitHubRepoInput, - options?: { fetch?: typeof globalThis.fetch; signal?: AbortSignal } -): Promise => { - const task = await importGitHubRepos(agent, migratorUrl, origin, [input], options); - const first = task.jobs[0]; - return { - taskId: task.id, - name: first?.name ?? input.name ?? input.repo.name, - jobId: first?.id - }; -}; - const normalizeSshKey = (rawKey: string): string | null => { const parts = rawKey.trim().split(/\s+/); if (parts.length < 2) return null; @@ -286,25 +300,25 @@ const normalizeSshKey = (rawKey: string): string | null => { return `${algorithm} ${base64Blob}`; }; -export interface ImportGitHubProfileEndpoints { +interface ImportGitHubProfileEndpoints { bobbinUrl?: string; deliberiUrl: string; fetch?: typeof globalThis.fetch; } -export interface ImportGitHubProfileOptions { +interface ImportGitHubProfileOptions { avatar: boolean; keys: boolean; email: boolean; } -export interface ImportedGitHubKey { +interface ImportedGitHubKey { rkey: string; name: string; key: string; } -export interface ImportGitHubProfileResult { +interface ImportGitHubProfileResult { imported: string[]; errors: string[]; keys: ImportedGitHubKey[]; diff --git a/web/src/lib/api/migrator.test.ts b/web/src/lib/api/migrator.test.ts index d1d74b370..479307db3 100644 --- a/web/src/lib/api/migrator.test.ts +++ b/web/src/lib/api/migrator.test.ts @@ -264,21 +264,21 @@ describe("grant return path", () => { }) as unknown as MigrationTask; it("drops the migrator's own transport params and keeps the rest", () => { - expect(returnPathFrom({ pathname: "/repo/import/github", search: "" })).toBe( - "/repo/import/github" + expect(returnPathFrom({ pathname: "/repo/migrate", search: "" })).toBe( + "/repo/migrate" ); expect( returnPathFrom({ - pathname: "/repo/import/github", + pathname: "/repo/migrate", search: "?return_to=%2Fwelcome&oauth_error=grant_failed&migrator=granted" }) - ).toBe("/repo/import/github"); + ).toBe("/repo/migrate"); expect( returnPathFrom({ - pathname: "/repo/import/github", + pathname: "/repo/migrate", search: "?oauth_error=grant_failed&page=2" }) - ).toBe("/repo/import/github?page=2"); + ).toBe("/repo/migrate?page=2"); }); it("flowReturnPath keeps the params that reopen the flow and drops the error", () => { @@ -289,8 +289,8 @@ describe("grant return path", () => { flowReturnPath({ pathname: "/welcome", search: "?step=github&github_error=denied" }) ).toBe("/welcome?step=github"); expect( - flowReturnPath({ pathname: "/repo/import/github", search: "?github_error=denied" }) - ).toBe("/repo/import/github"); + flowReturnPath({ pathname: "/repo/migrate", search: "?github_error=denied" }) + ).toBe("/repo/migrate"); expect(flowReturnPath({ pathname: "/welcome", search: "" })).toBe("/welcome"); }); @@ -307,7 +307,7 @@ describe("grant return path", () => { it("startUrl sends the grant back to the page that asked for it", async () => { vi.stubGlobal("window", { location: { - pathname: "/repo/import/github", + pathname: "/repo/migrate", search: "?oauth_error=grant_failed&page=2" } }); @@ -330,7 +330,7 @@ describe("grant return path", () => { expect(caught).toBeInstanceOf(MigrationGrantRequired); expect((caught as MigrationGrantRequired).startUrl).toBe( - "https://migrator.test/oauth/start?did=did%3Aplc%3Aalice&return_to=%2Frepo%2Fimport%2Fgithub%3Fpage%3D2" + "https://migrator.test/oauth/start?did=did%3Aplc%3Aalice&return_to=%2Frepo%2Fmigrate%3Fpage%3D2" ); }); diff --git a/web/src/lib/api/migrator.ts b/web/src/lib/api/migrator.ts index 8bf082203..a775b8912 100644 --- a/web/src/lib/api/migrator.ts +++ b/web/src/lib/api/migrator.ts @@ -38,7 +38,6 @@ export const missingDescribeSourceScope = ( return wanted === null ? [] : missingPermissions(agent.session.token.scope, [wanted]); }; -export type MigrationJob = Migrator.Job; export type MigrationTask = Migrator.Task; export type NewMigrationJob = Migrator.NewJob; export type MigrationJobStatus = @@ -53,9 +52,6 @@ export class MigrationGrantRequired extends Error { } } -const messageOf = (cause: unknown): string => - cause instanceof Error ? cause.message : String(cause); - // migrator echoes return_to and appends oauth_error; round trips must not nest them const transportParams = ["return_to", "oauth_error", "migrator"]; @@ -101,7 +97,7 @@ const extractGrantError = ( ? new MigrationGrantRequired(startUrlFrom(host, did)) : null; -export interface CreateMigrationTaskOptions { +interface CreateMigrationTaskOptions { signal?: AbortSignal; fetch?: typeof globalThis.fetch; } @@ -214,7 +210,3 @@ export const migrationsUnfinished = (tasks: MigrationTask[]): boolean => migrationsInFlight(tasks) || (tasks[0]?.jobs.some((job) => job.status === "authorization_required") ?? false); -export const migrationFailed = (job: MigrationJob): string | null => - job.status === "failed" ? job.error?.trim() || "the migration failed" : null; - -export const migrationError = messageOf; diff --git a/web/src/lib/api/repoCreate.test.ts b/web/src/lib/api/repoCreate.test.ts index d63e1e57a..daff55523 100644 --- a/web/src/lib/api/repoCreate.test.ts +++ b/web/src/lib/api/repoCreate.test.ts @@ -262,6 +262,46 @@ describe("repository creation", () => { source: { url: "https://knot.source/did:plc:upstream", kind: "fork" } }); }); + + it("creates optimistic record without repoDid and awaits bobbin indexing", async () => { + const { api, records, events } = await load(); + const result = await api.createOptimisticRepoRecord(agent, "https://bobbin.test", input); + + expect(result.rkey).toBe("demo"); + expect(result.name).toBe("Demo"); + expect(records[0]).toMatchObject({ + $type: "sh.tangled.repo", + knot: "knot.test" + }); + expect((records[0] as Record).repoDid).toBeUndefined(); + expect(events).toEqual(["record:create", "bobbin:await"]); + }); + + it("normalizes knot did:web to hostname in the created record", async () => { + const { api, records } = await load(); + await api.createOptimisticRepoRecord(agent, "https://bobbin.test", { + ...input, + knot: "did:web:knot2.tngl.boltless.dev" + }); + + expect(records[0]).toMatchObject({ + $type: "sh.tangled.repo", + knot: "knot2.tngl.boltless.dev" + }); + }); + + it("normalizes knot did:web with encoded port to hostname in the created repo", async () => { + const { api, records } = await load(); + await api.createRepo(agent, "https://bobbin.test", { + ...input, + knot: "did:web:knot.oyster.cafe%3A8443" + }); + + expect(records[0]).toMatchObject({ + $type: "sh.tangled.repo", + knot: "knot.oyster.cafe:8443" + }); + }); }); describe("repository names", () => { diff --git a/web/src/lib/api/repoCreate.ts b/web/src/lib/api/repoCreate.ts index 83d39a205..35028d070 100644 --- a/web/src/lib/api/repoCreate.ts +++ b/web/src/lib/api/repoCreate.ts @@ -3,7 +3,7 @@ import type { Cid, Datetime, Did, Nsid } from "@atcute/lexicons/syntax"; import { recordKeyOf } from "$lib/api/syntax"; import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; import { awaitIndexedRecord } from "$lib/api/awaitRecord"; -import { serviceUrlFor } from "$lib/api/_request"; +import { hostForKnot, serviceUrlFor } from "$lib/api/_request"; import { createBobbinClient, type BobbinContext } from "$lib/api/client"; import { DEFAULT_LABELS } from "$lib/api/labels"; import { mainSchema as createRepoSchema } from "$lib/api/lexicons/types/sh/tangled/repo/create"; @@ -20,7 +20,7 @@ import { pingIndexNow } from "$lib/api/indexnow"; const REPO_COLLECTION = "sh.tangled.repo" as Nsid; -export type RepoCreationSource = +type RepoCreationSource = { kind: "fork"; url: string; record: string } | { kind: "import"; url: string }; export interface RepoCreationInput { @@ -89,8 +89,9 @@ const deleteKnotRepo = async ( rkey: string, repoDid: Did ): Promise => { + const knotHost = hostForKnot(input.knot); const ctx = createBobbinClient({ - serviceUrl: serviceUrlFor(input.knot), + serviceUrl: serviceUrlFor(knotHost), agent }); let lastError = "unknown error"; @@ -112,7 +113,7 @@ const deleteKnotRepo = async ( lastError = messageOf(cause); } } - return `the repository on ${input.knot} could not be reverted: ${lastError}`; + return `the repository on ${knotHost} could not be reverted: ${lastError}`; }; const completeCreation = async ( @@ -127,7 +128,7 @@ const completeCreation = async ( let currentCid = initial.cid; try { const knot = createBobbinClient({ - serviceUrl: serviceUrlFor(input.knot), + serviceUrl: serviceUrlFor(record.knot), agent }); const created = await ok( @@ -169,12 +170,20 @@ const completeCreation = async ( } }; +interface OptimisticRepoRecord { + rkey: string; + name: string; + record: RepoRecord; + initial: WrittenRecord; + bobbin: BobbinContext; +} + // announce first so bobbin observes each side of the transaction in order -export const createRepo = async ( +export const createOptimisticRepoRecord = async ( agent: OAuthUserAgent, bobbinUrl: string, input: RepoCreationInput -): Promise => { +): Promise => { const name = validateRepoName(input.name); const rkey = name.toLowerCase(); const description = input.description?.trim() || undefined; @@ -185,9 +194,11 @@ export const createRepo = async ( if (!input.knot) throw new Error("Select a knot."); if (agent.sub !== input.ownerDid) throw new Error("The active account changed. Try again."); + const knot = hostForKnot(input.knot); + const record: RepoRecord = { $type: "sh.tangled.repo", - knot: input.knot, + knot, createdAt: new Date().toISOString() as Datetime, labels: DEFAULT_LABELS, ...(name !== rkey ? { name } : {}), @@ -199,7 +210,7 @@ export const createRepo = async ( // records without repoDid are interrupted optimistic creates on this knot, resume to avoid stranding rkey const existing = await readRecord(agent, REPO_COLLECTION, rkey); const resuming = - existing !== null && !existing.value.repoDid && existing.value.knot === input.knot + existing !== null && !existing.value.repoDid && existing.value.knot === knot ? existing : null; if (existing && !resuming) @@ -221,6 +232,20 @@ export const createRepo = async ( ); } + return { rkey, name, record, initial, bobbin }; +}; + +export const createRepo = async ( + agent: OAuthUserAgent, + bobbinUrl: string, + input: RepoCreationInput +): Promise => { + const { rkey, name, record, initial, bobbin } = await createOptimisticRepoRecord( + agent, + bobbinUrl, + input + ); + const { repoDid } = await completeCreation(agent, bobbin, input, rkey, record, initial); pingIndexNow(`https://tangled.org/${input.ownerHandle}/${name}`); diff --git a/web/src/lib/api/repoIndex.test.ts b/web/src/lib/api/repoIndex.test.ts index 67f045a04..a3f7e96dc 100644 --- a/web/src/lib/api/repoIndex.test.ts +++ b/web/src/lib/api/repoIndex.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it, vi } from "vitest"; import { ClientResponseError } from "$lib/api/client"; -import { classifyRepoAvailability, withAuthorHandles } from "$lib/api/repoIndex"; +import { + classifyRepoAvailability, + withAuthorHandles, + withImportAvailability, + type RepoAvailability +} from "$lib/api/repoIndex"; +import type { ImportState } from "$lib/api/repo"; import type { CommitSummary } from "$lib/components/repo/types"; const unsupported = () => @@ -37,6 +43,30 @@ describe("classifyRepoAvailability", () => { }); }); +describe("withImportAvailability", () => { + it.each([ + ["ok", { content: "fetching" as const }, "ok"], + ["ok", { content: "partial" as const }, "ok"], + ["ok", { content: "failed" as const, reason: "err" }, "ok"], + ["needs-upgrade", { content: "fetching" as const }, "needs-upgrade"], + ["needs-upgrade", { content: "partial" as const }, "needs-upgrade"], + ["needs-upgrade", { content: "failed" as const }, "needs-upgrade"], + ["empty", null, "empty"], + ["unreachable", null, "unreachable"], + ["empty", { content: "failed" as const, reason: "clone error" }, "import-failed"], + ["unreachable", { content: "failed" as const, reason: "timeout" }, "import-failed"], + ["empty", { content: "fetching" as const }, "importing"], + ["empty", { content: "partial" as const }, "importing"], + ["unreachable", { content: "fetching" as const }, "importing"], + ["unreachable", { content: "partial" as const }, "importing"] + ] satisfies [RepoAvailability, ImportState | null, RepoAvailability][])( + "maps (%s, %j) -> %s", + (availability, status, expected) => { + expect(withImportAvailability(availability, status)).toBe(expected); + } + ); +}); + const commit = (c: Partial): CommitSummary => ({ hash: "h", shortHash: "h", diff --git a/web/src/lib/api/repoIndex.ts b/web/src/lib/api/repoIndex.ts index 3e364cfb0..c8689fe25 100644 --- a/web/src/lib/api/repoIndex.ts +++ b/web/src/lib/api/repoIndex.ts @@ -57,17 +57,36 @@ const attempt = (promise: Promise): Promise> => const isUnsupported = (cause: unknown): boolean => cause instanceof ClientResponseError && cause.status === 404; -export type RepoAvailability = "ok" | "empty" | "needs-upgrade" | "unreachable"; +export type RepoAvailability = + | "ok" + | "empty" + | "needs-upgrade" + | "unreachable" + | "importing" + | "import-failed"; export const classifyRepoAvailability = ( attempts: readonly { value: unknown | null; error: unknown | null }[] -): Exclude => { +): Exclude => { if (attempts.every((result) => result.value === null && isUnsupported(result.error))) { return "needs-upgrade"; } return attempts.every((result) => result.value === null) ? "unreachable" : "ok"; }; +export const withImportAvailability = ( + availability: RepoAvailability, + status: ImportState | null +): RepoAvailability => { + if (availability === "ok" || availability === "needs-upgrade") { + return availability; + } + if (!status) { + return availability; + } + return status.content === "failed" ? "import-failed" : "importing"; +}; + export const toLanguageSlices = (languages: { name: string; size: number }[]): LanguageSlice[] => { const sized = languages.filter((language) => language.size > 0); const total = sized.reduce((sum, language) => sum + language.size, 0); diff --git a/web/src/lib/auth/onboarding.test.ts b/web/src/lib/auth/onboarding.test.ts new file mode 100644 index 000000000..af24a734a --- /dev/null +++ b/web/src/lib/auth/onboarding.test.ts @@ -0,0 +1,69 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("$app/environment", () => ({ browser: true })); + +const { markOnboardingDone, markProfileImported, onboardingDone, profileImported } = + await import("./onboarding"); + +const alice = "did:plc:alice"; +const bob = "did:plc:bob"; + +const store = new Map(); +const fakeStorage = { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => void store.set(key, value), + removeItem: (key: string) => void store.delete(key) +}; + +beforeEach(() => { + store.clear(); + vi.stubGlobal("localStorage", fakeStorage); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("onboarding flag", () => { + it("is per account, and absent until the flow finishes", () => { + expect(onboardingDone(alice)).toBe(false); + markOnboardingDone(alice); + expect(onboardingDone(alice)).toBe(true); + expect(onboardingDone(bob)).toBe(false); + }); + + it("records when it finished, not just that it did", () => { + markOnboardingDone(alice); + expect(JSON.parse(store.get(`tangled.onboarding.${alice}`)!).done).toMatch( + /^\d{4}-\d{2}-\d{2}T/ + ); + }); + + it("records the profile import as its own fact", () => { + expect(profileImported(alice)).toBe(false); + markProfileImported(alice); + expect(profileImported(alice)).toBe(true); + expect(onboardingDone(alice)).toBe(false); + markOnboardingDone(alice); + expect(profileImported(alice)).toBe(true); + }); + + it("still reads the bare timestamp this key used to hold", () => { + store.set(`tangled.onboarding.${alice}`, JSON.stringify("2026-09-17T00:00:00.000Z")); + expect(onboardingDone(alice)).toBe(true); + expect(profileImported(alice)).toBe(false); + }); + + it("treats a browser that refuses storage as not onboarded, and does not throw", () => { + vi.stubGlobal("localStorage", { + getItem: () => { + throw new Error("denied"); + }, + setItem: () => { + throw new Error("denied"); + } + }); + expect(() => markOnboardingDone(alice)).not.toThrow(); + expect(onboardingDone(alice)).toBe(false); + }); +}); diff --git a/web/src/lib/auth/onboarding.ts b/web/src/lib/auth/onboarding.ts new file mode 100644 index 000000000..37fe0cb09 --- /dev/null +++ b/web/src/lib/auth/onboarding.ts @@ -0,0 +1,50 @@ +import { browser } from "$app/environment"; + +const KEY_PREFIX = "tangled.onboarding."; + +interface Onboarding { + done: string | null; + profile: string | null; +} + +const keyOf = (did: string) => `${KEY_PREFIX}${did}`; + +const read = (did: string): Onboarding => { + const empty: Onboarding = { done: null, profile: null }; + if (!browser) return empty; + try { + const raw = localStorage.getItem(keyOf(did)); + if (!raw) return empty; + const parsed: unknown = JSON.parse(raw); + // legacy storage stored done timestamp directly as string + if (typeof parsed === "string") return { ...empty, done: parsed }; + const value = parsed as Partial | null; + return { + done: typeof value?.done === "string" ? value.done : null, + profile: typeof value?.profile === "string" ? value.profile : null + }; + } catch { + return empty; + } +}; + +const write = (did: string, patch: Partial): void => { + if (!browser) return; + try { + localStorage.setItem(keyOf(did), JSON.stringify({ ...read(did), ...patch })); + } catch { + // storage access can fail in private browsing; flow is idempotent + } +}; + +export const onboardingDone = (did: string): boolean => read(did).done !== null; + +export const markOnboardingDone = (did: string): void => { + write(did, { done: new Date().toISOString() }); +}; + +export const profileImported = (did: string): boolean => read(did).profile !== null; + +export const markProfileImported = (did: string): void => { + write(did, { profile: new Date().toISOString() }); +}; diff --git a/web/src/lib/components/import/ConnectAccount.svelte b/web/src/lib/components/import/ConnectAccount.svelte new file mode 100644 index 000000000..54d78e4f2 --- /dev/null +++ b/web/src/lib/components/import/ConnectAccount.svelte @@ -0,0 +1,47 @@ + + +
+
+ + {#if pending} + + Loading… + + {:else if connected} + + + {/if} +
+ {#if connected} + + {/if} +
diff --git a/web/src/lib/components/import/GitHubAuthorizeStep.svelte b/web/src/lib/components/import/GitHubAuthorizeStep.svelte new file mode 100644 index 000000000..e7824fcf2 --- /dev/null +++ b/web/src/lib/components/import/GitHubAuthorizeStep.svelte @@ -0,0 +1,33 @@ + + + +

+ The migrator needs your permission to create the imported repositories in your account. +

+
+ {#if authorized} + + + {/if} + +
+
diff --git a/web/src/lib/components/import/GitHubConnectStep.svelte b/web/src/lib/components/import/GitHubConnectStep.svelte new file mode 100644 index 000000000..3ded6d063 --- /dev/null +++ b/web/src/lib/components/import/GitHubConnectStep.svelte @@ -0,0 +1,24 @@ + + + + + diff --git a/web/src/lib/components/import/GitHubImporter.stories.svelte b/web/src/lib/components/import/GitHubImporter.stories.svelte new file mode 100644 index 000000000..55a8d2f2d --- /dev/null +++ b/web/src/lib/components/import/GitHubImporter.stories.svelte @@ -0,0 +1,49 @@ + + + { + const canvas = within(canvasElement); + await waitFor(() => expect(canvas.getByText("ada/alpha")).toBeVisible()); + await waitFor(() => expect(canvas.getByRole("checkbox", { name: "Select ada/alpha" })).toBeEnabled()); + await userEvent.click(canvas.getByRole("checkbox", { name: "Select ada/alpha" })); + await userEvent.click(canvas.getByRole("checkbox", { name: "Select ada/beta" })); + await waitFor(() => + expect(canvas.getByRole("button", { name: /migrate 2 repositor/i })).toBeEnabled() + ); + await userEvent.click(canvas.getByRole("button", { name: /migrate 2 repositor/i })); + await waitFor(() => expect(canvas.getAllByText("Failed")).toHaveLength(2)); + }} +/> diff --git a/web/src/lib/components/import/GitHubImporter.svelte b/web/src/lib/components/import/GitHubImporter.svelte new file mode 100644 index 000000000..d37de243b --- /dev/null +++ b/web/src/lib/components/import/GitHubImporter.svelte @@ -0,0 +1,101 @@ + + +
+ {#if !onError} + + {/if} + + {#if leadingStep} + {@render leadingStep()} + {/if} + + + + + +
diff --git a/web/src/lib/components/import/GitHubImporterFixture.svelte b/web/src/lib/components/import/GitHubImporterFixture.svelte new file mode 100644 index 000000000..f6c93e638 --- /dev/null +++ b/web/src/lib/components/import/GitHubImporterFixture.svelte @@ -0,0 +1,86 @@ + + + diff --git a/web/src/lib/components/import/GitHubRepoSelectStep.svelte b/web/src/lib/components/import/GitHubRepoSelectStep.svelte new file mode 100644 index 000000000..28a4124b9 --- /dev/null +++ b/web/src/lib/components/import/GitHubRepoSelectStep.svelte @@ -0,0 +1,139 @@ + + + + {#snippet action()} + {#if repos.length} + + {/if} + {/snippet} + {#if account} +
+ {#each repos as repo (repo.id)} + {@const draft = drafts[repo.id]} + {@const locked = stepDisabled || !isMigratable(draft.migration)} + onSelectRepo(repo.id, !draft.selected)} + onExpand={() => onToggleExpand(repo.id)} + language={repo.language ?? undefined} + stars={repo.stars ?? undefined} + issues={repo.issues ?? undefined} + forks={repo.forks ?? undefined} + updatedAt={repo.updatedAt ?? undefined} + > + {#snippet body()} + onUpdateDraft(repo.id, { name: v })} + onDescription={(v) => onUpdateDraft(repo.id, { description: v })} + onKnot={(v) => onUpdateDraft(repo.id, { knot: v })} + disabled={locked} + /> + {/snippet} + + {:else} +

+ No public repositories found. +

+ {/each} +
+ {:else} +

+ No repositories yet, connect your account first. +

+ {/if} + {#if knotOptions.length === 0} +

+ No knots are available yet. Manage your knots. +

+ {/if} +
+ +
+
diff --git a/web/src/lib/components/import/MethodPicker.svelte b/web/src/lib/components/import/MethodPicker.svelte new file mode 100644 index 000000000..79c10eff8 --- /dev/null +++ b/web/src/lib/components/import/MethodPicker.svelte @@ -0,0 +1,51 @@ + + +
+ {#each methods as method (method.value)} + {@const Icon = method.icon} + {@const selected = value === method.value} + + {/each} +
diff --git a/web/src/lib/components/import/MigrationRow.svelte b/web/src/lib/components/import/MigrationRow.svelte new file mode 100644 index 000000000..e6e18ec81 --- /dev/null +++ b/web/src/lib/components/import/MigrationRow.svelte @@ -0,0 +1,95 @@ + + + + +
+
+ {#if state === "running"} +
+
+ {#if barPercent !== null} +
+ {/if} +
+
diff --git a/web/src/lib/components/import/MigrationStep.svelte b/web/src/lib/components/import/MigrationStep.svelte new file mode 100644 index 000000000..948f40727 --- /dev/null +++ b/web/src/lib/components/import/MigrationStep.svelte @@ -0,0 +1,145 @@ + + + + + + {#if !items.length} +

No migrations started yet.

+ {:else} + {#if bannerVisible} +
+ (bannerVisible = false)} note={bannerNote} /> +
+ {/if} +
+ {#each items as item (item.id)} + {@const m = item.migration} + {@const itemFailed = + m.status === "error" || + (m.status === "success" && + (m.jobStatus === "failed" || m.jobStatus === "authorization_required"))} + {@const state = itemFailed + ? "failed" + : m.status === "success" && m.jobStatus === "completed" + ? "done" + : "running"} +
+ + {#if itemFailed && m.status === "error"} +

+ {m.error} +

+ {:else if itemFailed && m.status === "success"} +

+ {m.jobStatus === "authorization_required" + ? "The source rejected its saved credential. Reconnect GitHub and start the import again." + : (m.jobError ?? "The migration failed")} +

+ {/if} +
+ {/each} +
+ {#if !isRunning} +
+ +
+ {/if} + {/if} +
diff --git a/web/src/lib/components/import/RepoConfig.svelte b/web/src/lib/components/import/RepoConfig.svelte new file mode 100644 index 000000000..b3f5760e3 --- /dev/null +++ b/web/src/lib/components/import/RepoConfig.svelte @@ -0,0 +1,77 @@ + + +
+ + {#snippet children({ id })} + onName(event.currentTarget.value)} + /> + {/snippet} + + + {#snippet children({ id })} + onDescription(event.currentTarget.value)} + /> + {/snippet} + + +
+ {#each knots as option (option)} + {option} + {/each} +
+
+
diff --git a/web/src/lib/components/import/RepoRow.svelte b/web/src/lib/components/import/RepoRow.svelte new file mode 100644 index 000000000..32302dd24 --- /dev/null +++ b/web/src/lib/components/import/RepoRow.svelte @@ -0,0 +1,115 @@ + + +
+
+ + +
+ {#if expanded && body} +
+ {@render body()} +
+ {/if} +
diff --git a/web/src/lib/components/import/StartedBanner.svelte b/web/src/lib/components/import/StartedBanner.svelte new file mode 100644 index 000000000..6fa7982a7 --- /dev/null +++ b/web/src/lib/components/import/StartedBanner.svelte @@ -0,0 +1,25 @@ + + +
+
+

Repository migration has successfully started. You can now close this page.

+

{note}

+
+ +
diff --git a/web/src/lib/components/import/Step.svelte b/web/src/lib/components/import/Step.svelte new file mode 100644 index 000000000..c1f0ba367 --- /dev/null +++ b/web/src/lib/components/import/Step.svelte @@ -0,0 +1,41 @@ + + +
+ + {number} + +
+
+

+ {title} +

+ {#if action} + {@render action()} + {/if} +
+
+ {@render children()} +
+
+
diff --git a/web/src/lib/components/import/StepFlow.svelte b/web/src/lib/components/import/StepFlow.svelte new file mode 100644 index 000000000..469f01e2f --- /dev/null +++ b/web/src/lib/components/import/StepFlow.svelte @@ -0,0 +1,14 @@ + + +
+ + {@render children()} +
diff --git a/web/src/lib/components/import/UrlRepoImporter.svelte b/web/src/lib/components/import/UrlRepoImporter.svelte new file mode 100644 index 000000000..e8285e52c --- /dev/null +++ b/web/src/lib/components/import/UrlRepoImporter.svelte @@ -0,0 +1,341 @@ + + +
+ + {#snippet children({ id })} + + {/snippet} + + +
+

+ Use a publicly reachable HTTPS clone URL without credentials. + {#if fetched && defaultBranch} + Default branch on the remote: {defaultBranch} + {/if} +

+ +{#if needsSignIn} +
+ + + Fetching details needs the migrator's grant, which this session does not carry yet. + +
+{/if} +
+ (urlName = v)} + onDescription={(v) => (urlDescription = v)} + onKnot={(v) => (urlKnot = v)} + disabled={!fetched || running || migration.status === "success"} + /> +
+ +
+
diff --git a/web/src/lib/components/import/gitHubImportState.svelte.ts b/web/src/lib/components/import/gitHubImportState.svelte.ts new file mode 100644 index 000000000..ed287f2c6 --- /dev/null +++ b/web/src/lib/components/import/gitHubImportState.svelte.ts @@ -0,0 +1,772 @@ +import { page } from "$app/state"; +import { isDid, type Did } from "@atcute/lexicons/syntax"; +import { getAuth } from "$lib/auth.svelte"; +import { serviceDidForHost } from "$lib/auth/agent"; +import { validateRepoName } from "$lib/api/repoCreate"; +import type { GitHubAccount, GitHubRepo } from "$lib/github"; +import { flowReturnPath, returnPathFrom, startUrlFrom } from "$lib/api/migrator"; +import { hasGrantEvidence } from "./grantEvidence"; +import { + findRepoCollisions, + fetchGitHubAccount, + invalidateGitHubAccount, + importGitHubRepos +} from "$lib/api/githubImport"; +import { + getMigrationTask, + listMigrationTasks, + migrationRunning, + MigrationGrantRequired, + type MigrationJobStatus, + type MigrationTask +} from "$lib/api/migrator"; +import type { MigrationItem } from "./MigrationStep.svelte"; +import { migrationRows } from "./migrationRows"; + +type Migration = + | { status: "idle" } + | { status: "submitting" } + | { status: "error"; error: string } + | { + status: "success"; + task: { taskId: string; name: string }; + jobId?: string; + jobStatus: MigrationJobStatus | string; + jobError?: string; + repoDid?: string; + }; + +export type Draft = { + selected: boolean; + name: string; + description: string; + knot: string; + rkey?: string; + migration: Migration; +}; + +type Stash = { id: number; name: string; description: string; knot: string }[]; + +interface GitHubImporterOptions { + knots: () => readonly string[] | Promise; + migratorUrl: () => string; + fetcher?: () => typeof fetch | undefined; +} + +type JobRef = { id?: string; name: string }; + +type MigrationJob = MigrationTask["jobs"][number]; + +type DraftLookup = { name: string; rkey?: string; migration?: { status: string; jobId?: string } }; + +type GrantLatch = Pick | null; + +const MAX_JOBS_PER_TASK = 50; +const POLL_MIN_MS = 4_000; +const POLL_MAX_MS = 60_000; + +const STASH_KEY = "tangled.import.github"; +const GRANT_KEY = "tangled.import.github.grant_confirmed"; +const TRANSPORT_PARAMS = ["return_to", "oauth_error", "migrator"] as const; + +const messageOf = (cause: unknown): string => + cause instanceof Error ? cause.message : String(cause); + +export const resolveKnotOptions = async ( + knots: readonly string[] | Promise +): Promise => { + if (Array.isArray(knots)) return knots; + return await knots; +}; + +export const isMigratable = (m: Migration | undefined): boolean => + !m || + m.status === "idle" || + m.status === "error" || + (m.status === "success" && + (m.jobStatus === "failed" || m.jobStatus === "authorization_required")); + +export const matchJobToRepo = ( + job: JobRef, + repos: readonly T[], + drafts: Record +): T | undefined => { + const jobName = job.name.toLowerCase(); + if (job.id) { + const byJobId = repos.find((r) => { + const m = drafts[r.id]?.migration; + return m?.status === "success" && m.jobId === job.id; + }); + if (byJobId) return byJobId; + } + const byDraft = repos.find((r) => { + const d = drafts[r.id]; + if (!d) return false; + return d.name.toLowerCase() === jobName || d.rkey?.toLowerCase() === jobName; + }); + if (byDraft) return byDraft; + return repos.find((r) => r.name.toLowerCase() === jobName); +}; + +export const createRunGuard = () => { + let current = 0; + return { + begin: (): number => ++current, + owns: (token: number): boolean => token === current + }; +}; + +// the migrator's callback is the only proof the grant was completed, and the app strips the +// parameter it arrives with, so it is remembered here until the account is disconnected +export const createRememberedGrant = (storage: GrantLatch, key: string) => ({ + restore: (): boolean => storage?.getItem(key) === "true", + confirm: (): void => { + try { + storage?.setItem(key, "true"); + } catch {} + }, + clear: (): void => { + try { + storage?.removeItem(key); + } catch {} + } +}); + +export const createGitHubImporter = (options: GitHubImporterOptions) => { + const auth = getAuth(); + const user = $derived(auth.currentUser); + + let account = $state(null); + let accountLoading = $state(false); + let drafts = $state>({}); + let expanded = $state>(new Set()); + let errorMessage = $state(""); + let fetchedKnots = $state([]); + let grantConfirmed = $state(false); + let loadedTasks = $state([]); + let running = $state(false); + let sessionTaskCreated = $state(false); + + let accountDid: string | undefined; + let consumedTransport = false; + let destroyed = false; + let generation = 0; + let pollDelay = POLL_MIN_MS; + let restoredFromStash = false; + let stash: Stash | undefined; + let statusTimer: ReturnType | undefined; + + const grantLatch = createRememberedGrant( + typeof sessionStorage === "undefined" ? null : sessionStorage, + GRANT_KEY + ); + const runGuard = createRunGuard(); + + const loadKnots = async (): Promise => { + const knots = await resolveKnotOptions(options.knots()); + fetchedKnots = knots; + return knots; + }; + + $effect(() => { + const knots = options.knots(); + if (Array.isArray(knots)) return; + void Promise.resolve(knots).then((res) => { + fetchedKnots = res; + }); + }); + + const knotOptions = $derived( + Array.isArray(options.knots()) ? (options.knots() as readonly string[]) : fetchedKnots + ); + const defaultKnot = $derived(knotOptions[0] ?? ""); + + $effect(() => { + if (!defaultKnot) return; + for (const id of Object.keys(drafts)) { + if (!drafts[Number(id)].knot) updateDraft(Number(id), { knot: defaultKnot }); + } + }); + + const repos = $derived(account?.repos ?? []); + const selected = $derived(repos.filter((repo) => drafts[repo.id]?.selected)); + const allSelected = $derived(repos.length > 0 && selected.length === repos.length); + const disabled = $derived(running || !user); + + const migratable = $derived( + selected.filter((repo) => isMigratable(drafts[repo.id]?.migration)) + ); + const migrationItems = $derived(migrationRows(loadedTasks, drafts)); + + const anyRunning = $derived( + running || + migrationItems.some((item) => { + const m = item.migration; + return ( + m.status === "submitting" || + (m.status === "success" && + m.jobStatus !== undefined && + migrationRunning(m.jobStatus)) + ); + }) + ); + const anyFailed = $derived( + migrationItems.some((item) => { + const m = item.migration; + return m.status === "error" || (m.status === "success" && m.jobStatus !== "completed"); + }) + ); + + const authorized = $derived( + hasGrantEvidence({ + granted: grantConfirmed, + url: page.url, + tasks: loadedTasks, + sessionCreated: sessionTaskCreated + }) + ); + + const connectUrl = $derived( + `/_internal/github/connect?return_to=${encodeURIComponent(flowReturnPath(page.url))}` + ); + const connectError = $derived(page.url.searchParams.get("github_error")); + + const repoRkey = (name: string): string => { + try { + return validateRepoName(name).toLowerCase(); + } catch { + return name.toLowerCase(); + } + }; + + const freshDraft = (repo: GitHubRepo, fallbackKnot = defaultKnot): Draft => ({ + selected: false, + name: repo.name, + description: repo.description ? Array.from(repo.description).slice(0, 140).join("") : "", + knot: fallbackKnot || defaultKnot, + rkey: repoRkey(repo.name), + migration: { status: "idle" } + }); + + const updateDraft = (id: number, patch: Partial) => { + const next = { ...patch }; + if (patch.name !== undefined && patch.rkey === undefined) next.rkey = repoRkey(patch.name); + drafts = { ...drafts, [id]: { ...drafts[id], ...next } }; + }; + + const migrationFromJob = ( + task: MigrationTask, + job: MigrationJob | undefined, + taskName: string + ): Migration => ({ + status: "success", + task: { taskId: task.id, name: taskName }, + jobId: job?.id, + jobStatus: job?.status ?? "queued", + jobError: job?.error, + repoDid: job?.repoDid + }); + + const findRepoForJob = (job: JobRef): GitHubRepo | undefined => + matchJobToRepo(job, repos, drafts); + + const syncTasksToDrafts = (tasks: MigrationTask[]) => { + for (const task of tasks) { + for (const job of task.jobs) { + const repo = findRepoForJob(job); + if (repo && drafts[repo.id]?.migration.status === "idle") { + updateDraft(repo.id, { migration: migrationFromJob(task, job, job.name) }); + } + } + } + }; + + const adoptSubmittedJob = (task: MigrationTask, repo: GitHubRepo) => { + const draft = drafts[repo.id]; + const wanted = [draft.name.toLowerCase(), (draft.rkey ?? draft.name).toLowerCase()]; + const job = task.jobs.find((j) => wanted.includes(j.name.toLowerCase())) ?? task.jobs[0]; + updateDraft(repo.id, { + selected: false, + migration: migrationFromJob(task, job, draft.name) + }); + }; + + const draftsFor = (nextRepos: GitHubRepo[], knot: string) => + Object.fromEntries( + nextRepos.map((repo) => [repo.id, drafts[repo.id] ?? freshDraft(repo, knot)]) + ); + + // the migrator round trip reloads the page, so the rows picked before the redirect are restored + // once, then forgotten + const restoreStashedPicks = (next: Record): Record => { + if (!stash || restoredFromStash) return next; + restoredFromStash = true; + for (const picked of stash) { + const draft = next[picked.id]; + if (draft?.migration.status === "idle") { + next[picked.id] = { + ...draft, + selected: true, + name: picked.name, + description: picked.description, + knot: picked.knot + }; + } + } + expanded = new Set([...expanded, ...stash.map((picked) => picked.id)]); + return next; + }; + + const resetDrafts = (nextRepos: GitHubRepo[], initialKnot?: string) => { + drafts = restoreStashedPicks(draftsFor(nextRepos, initialKnot || defaultKnot)); + syncTasksToDrafts(loadedTasks); + }; + + const select = (id: number, checked: boolean) => { + if (running) return; + updateDraft(id, { selected: checked }); + }; + + const setAllSelected = (checked: boolean) => { + if (!account || running) return; + drafts = Object.fromEntries( + account.repos.map((repo) => [repo.id, { ...drafts[repo.id], selected: checked }]) + ); + }; + + const toggleExpand = (id: number) => { + const next = new Set(expanded); + if (next.has(id)) next.delete(id); + else next.add(id); + expanded = next; + }; + + const collidingNames = (names: string[]): string[] => + [...findRepoCollisions(names).values()] + .filter((collision) => collision.hasCollision) + .map((collision) => collision.canonical); + + const validateSelections = (picked: GitHubRepo[]) => { + for (const repo of picked) { + const draft = drafts[repo.id]; + if (!draft.knot) throw new Error(`Choose a knot for ${repo.name}.`); + if (!isDid(serviceDidForHost(draft.knot))) + throw new Error(`Choose a valid knot for ${repo.name}.`); + try { + validateRepoName(draft.name); + } catch (cause) { + throw new Error(`${repo.name}: ${messageOf(cause)}`, { cause }); + } + } + const collisions = collidingNames(picked.map((repo) => drafts[repo.id].name)); + if (collisions.length) + throw new Error(`Imported repository names collide: ${collisions.join(", ")}.`); + }; + + // teardown and account switches both bump the generation, so a run holding an old version can + // stop early + const stale = (version: number): boolean => destroyed || generation !== version; + const superseded = (version: number, ownerDid: string): boolean => + stale(version) || auth.currentUser?.did !== ownerDid; + + const assertOwner = (ownerDid: string, version: number) => { + if (generation !== version || auth.currentUser?.did !== ownerDid) + throw new Error("The active account changed. Try again."); + }; + + const failMigrations = (repos: GitHubRepo[], message: string) => { + for (const repo of repos) + updateDraft(repo.id, { migration: { status: "error", error: message } }); + }; + + const loadTasks = async (ownerDid: string, version: number) => { + try { + const agent = await auth.agentFor(ownerDid as Did, { relogin: false }); + if (stale(version)) return; + const tasks = await listMigrationTasks(agent, options.migratorUrl(), { limit: 20 }); + if (stale(version)) return; + loadedTasks = tasks; + syncTasksToDrafts(tasks); + if (tasks.some((task) => task.jobs.some((job) => migrationRunning(job.status)))) { + scheduleStatusPoll(); + } + } catch { + // the migrator may want authorization, or be down for a moment + } + }; + + const loadAccount = async (ownerDid: string, version: number) => { + accountLoading = true; + try { + const [next, knots] = await Promise.all([ + fetchGitHubAccount(options.fetcher?.()), + loadKnots() + ]); + if (superseded(version, ownerDid) || !next) return; + assertOwner(ownerDid, version); + account = next; + resetDrafts(next.repos, knots[0] ?? ""); + } catch (cause) { + if (generation === version) errorMessage = messageOf(cause); + } finally { + if (generation === version) accountLoading = false; + } + }; + + $effect(() => { + const did = auth.currentUser?.did; + if (did === accountDid) return; + // the first settlement is not an account switch, so a confirmed grant survives it + const switched = accountDid !== undefined && did !== accountDid; + accountDid = did; + const version = ++generation; + if (switched) { + account = null; + drafts = {}; + loadedTasks = []; + running = false; + expanded = new Set(); + grantConfirmed = false; + sessionTaskCreated = false; + } + if (did) { + void loadAccount(did, version); + void loadTasks(did, version); + } + }); + + const readStashedPicks = () => { + const raw = sessionStorage.getItem(STASH_KEY); + if (!raw) return; + sessionStorage.removeItem(STASH_KEY); + try { + stash = JSON.parse(raw) as Stash; + } catch {} + }; + + const settleGrantLatch = (oauthError: string | null) => { + if (page.url.searchParams.get("migrator") === "granted" && !oauthError) { + grantConfirmed = true; + grantLatch.confirm(); + return; + } + if (oauthError) { + grantLatch.clear(); + return; + } + if (grantLatch.restore()) grantConfirmed = true; + }; + + const stripTransportParams = (params: URLSearchParams) => { + if (!TRANSPORT_PARAMS.some((name) => params.has(name))) return; + // sveltekit's replaceState does nothing while the page is still starting up, so the + // browser's own is used; nothing reads these parameters off page.url after this point + history.replaceState(history.state, "", returnPathFrom(page.url)); + }; + + $effect(() => { + if (consumedTransport) return; + consumedTransport = true; + const oauthError = page.url.searchParams.get("oauth_error"); + if (oauthError === "grant_failed") + errorMessage = + "The migrator could not be authorized. Try starting the migration again."; + readStashedPicks(); + settleGrantLatch(oauthError); + stripTransportParams(page.url.searchParams); + }); + + $effect(() => { + return () => { + destroyed = true; + generation++; + if (statusTimer) clearTimeout(statusTimer); + }; + }); + + const disconnect = async () => { + if (running) return; + try { + const fetchFn = options.fetcher?.() ?? fetch; + const response = await fetchFn("/_internal/github", { method: "DELETE" }); + if (!response.ok) throw new Error("Could not disconnect GitHub. Try again."); + invalidateGitHubAccount(options.fetcher?.()); + grantLatch.clear(); + generation++; + account = null; + errorMessage = ""; + } catch (cause) { + errorMessage = messageOf(cause); + } + }; + + const stashSelections = (picked: GitHubRepo[]) => { + const payload: Stash = picked.map((repo) => { + const draft = drafts[repo.id]; + return { + id: repo.id, + name: draft.name, + description: draft.description, + knot: draft.knot + }; + }); + sessionStorage.setItem(STASH_KEY, JSON.stringify(payload)); + }; + + const redirectToGrant = (startUrl: string | null, picked: GitHubRepo[]) => { + if (!startUrl) { + const error = "The migrator needs authorization but its start URL is unknown."; + errorMessage = error; + failMigrations(picked, error); + return; + } + stashSelections(picked); + window.location.assign(startUrl); + }; + + const authorize = () => { + if (running) return; + const did = auth.currentUser?.did; + if (!did) { + errorMessage = "Sign in first."; + return; + } + redirectToGrant(startUrlFrom(options.migratorUrl(), did), migratable); + }; + + const markSubmitting = (picked: GitHubRepo[]) => { + for (const repo of picked) updateDraft(repo.id, { migration: { status: "submitting" } }); + }; + + const migrationInputs = (picked: GitHubRepo[], owner: NonNullable) => + picked.map((repo) => ({ + ownerDid: owner.did, + ownerHandle: owner.handle, + knot: drafts[repo.id].knot, + repo, + name: drafts[repo.id].name, + description: drafts[repo.id].description + })); + + // the migrator takes at most MAX_JOBS_PER_TASK repos per task, so picks go out in batches + const submitBatch = async ( + agent: Awaited>, + owner: NonNullable, + version: number, + publicOrigin: string, + picked: GitHubRepo[], + inputs: ReturnType, + start: number + ): Promise<"submitted" | "failed" | "stale" | "grant"> => { + const batch = picked.slice(start, start + MAX_JOBS_PER_TASK); + try { + const task = await importGitHubRepos( + agent, + options.migratorUrl(), + publicOrigin, + inputs.slice(start, start + MAX_JOBS_PER_TASK), + { fetch: options.fetcher?.(), bobbinUrl: auth.bobbinUrl } + ); + assertOwner(owner.did, version); + sessionTaskCreated = true; + loadedTasks = [task, ...loadedTasks.filter((t) => t.id !== task.id)]; + for (const repo of batch) adoptSubmittedJob(task, repo); + return "submitted"; + } catch (cause) { + if (superseded(version, owner.did)) return "stale"; + if (cause instanceof MigrationGrantRequired) { + redirectToGrant(cause.startUrl, batch); + return "grant"; + } + failMigrations(batch, messageOf(cause)); + errorMessage = messageOf(cause); + return "failed"; + } + }; + + const runQueue = async (picked: GitHubRepo[]) => { + if (!account || !user || running || !picked.length) return; + const runToken = runGuard.begin(); + const owner = { ...user }; + const version = generation; + const publicOrigin = account.publicOrigin; + try { + validateSelections(picked); + running = true; + errorMessage = ""; + const agent = await auth.agentFor(owner.did); + assertOwner(owner.did, version); + markSubmitting(picked); + const inputs = migrationInputs(picked, owner); + for (let start = 0; start < inputs.length; start += MAX_JOBS_PER_TASK) { + const outcome = await submitBatch( + agent, + owner, + version, + publicOrigin, + picked, + inputs, + start + ); + if (outcome === "failed") break; + if (outcome !== "submitted") return; + } + pollDelay = POLL_MIN_MS; + scheduleStatusPoll(); + } catch (cause) { + if (superseded(version, owner.did)) return; + if (cause instanceof MigrationGrantRequired) { + redirectToGrant(cause.startUrl, picked); + return; + } + failMigrations(picked, messageOf(cause)); + errorMessage = messageOf(cause); + } finally { + if (runGuard.owns(runToken)) running = false; + if (generation === version) scheduleStatusPoll(); + } + }; + + const migrate = () => void runQueue([...migratable]); + + const statusFingerprint = () => + JSON.stringify( + repos.map((repo) => { + const m = drafts[repo.id]?.migration; + return m?.status === "success" ? [m.task.taskId, m.jobStatus] : null; + }) + ); + + const runningTaskIds = (): string[] => + repos.flatMap((repo) => { + const m = drafts[repo.id]?.migration; + return m?.status === "success" && migrationRunning(m.jobStatus) ? [m.task.taskId] : []; + }); + + const applyTaskStatus = (task: MigrationTask) => { + loadedTasks = loadedTasks.map((t) => (t.id === task.id ? task : t)); + for (const job of task.jobs) { + const repo = findRepoForJob(job); + const current = repo ? drafts[repo.id]?.migration : undefined; + if (!repo || current?.status !== "success") continue; + updateDraft(repo.id, { + migration: { + ...current, + jobId: job.id, + jobStatus: job.status, + ...(job.error !== undefined ? { jobError: job.error } : {}), + ...(job.repoDid !== undefined ? { repoDid: job.repoDid } : {}) + } + }); + } + }; + + const refreshStatuses = async () => { + const owner = auth.currentUser; + if (!owner || running || destroyed) return; + const version = generation; + const watched = runningTaskIds(); + if (!watched.length) return; + const before = statusFingerprint(); + + let agent: Awaited>; + try { + agent = await auth.agentFor(owner.did, { relogin: false }); + } catch { + return; + } + + let reached = 0; + for (const taskId of new Set(watched)) { + if (stale(version)) return; + try { + const task = await getMigrationTask(agent, options.migratorUrl(), taskId); + reached++; + applyTaskStatus(task); + } catch {} + } + + const changed = statusFingerprint() !== before; + pollDelay = + reached < watched.length || !changed + ? Math.min(pollDelay * 2, POLL_MAX_MS) + : POLL_MIN_MS; + }; + + const scheduleStatusPoll = () => { + if (destroyed) return; + if (statusTimer) clearTimeout(statusTimer); + statusTimer = setTimeout(async () => { + statusTimer = undefined; + if (destroyed) return; + await refreshStatuses(); + if (!destroyed && anyRunning) scheduleStatusPoll(); + }, pollDelay); + }; + + return { + get account() { + return account; + }, + get accountLoading() { + return accountLoading; + }, + get errorMessage() { + return errorMessage; + }, + set errorMessage(v: string) { + errorMessage = v; + }, + get disabled() { + return disabled; + }, + get connectUrl() { + return connectUrl; + }, + get connectError() { + return connectError; + }, + get userHandle() { + return user?.handle; + }, + get authorized() { + return authorized; + }, + authorize, + disconnect, + + get repos() { + return repos; + }, + get drafts() { + return drafts; + }, + get allSelected() { + return allSelected; + }, + get expanded() { + return expanded; + }, + get knotOptions() { + return knotOptions; + }, + get migratable() { + return migratable; + }, + get migrationItems() { + return migrationItems; + }, + get anyRunning() { + return anyRunning; + }, + get anyFailed() { + return anyFailed; + }, + + updateDraft, + select, + setAll: setAllSelected, + toggleExpand, + migrate + }; +}; diff --git a/web/src/lib/components/import/gitHubImportState.test.ts b/web/src/lib/components/import/gitHubImportState.test.ts new file mode 100644 index 000000000..28cd4658b --- /dev/null +++ b/web/src/lib/components/import/gitHubImportState.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from "vitest"; +import { createRememberedGrant, createRunGuard, isMigratable, matchJobToRepo, resolveKnotOptions } from "./gitHubImportState.svelte"; + +describe("matchJobToRepo", () => { + const repos = [ + { id: 1, name: "original-name" }, + { id: 2, name: "another-repo" } + ]; + + it("matches renamed repository by draft name when target name differs from github name", () => { + const drafts = { + 1: { name: "renamed-tangled-repo", rkey: "renamed-tangled-repo" }, + 2: { name: "another-repo", rkey: "another-repo" } + }; + + const matched = matchJobToRepo({ name: "renamed-tangled-repo" }, repos, drafts); + expect(matched).toEqual({ id: 1, name: "original-name" }); + }); + + it("matches renamed repository case-insensitively by draft rkey", () => { + const drafts = { + 1: { name: "CamelCaseRepo", rkey: "camelcaserepo" } + }; + + const matched = matchJobToRepo({ name: "camelcaserepo" }, repos, drafts); + expect(matched).toEqual({ id: 1, name: "original-name" }); + }); + + it("matches repository by job ID when migration has succeeded", () => { + const drafts = { + 1: { + name: "custom-name", + migration: { status: "success", jobId: "job-123" } + } + }; + + const matched = matchJobToRepo({ id: "job-123", name: "unexpected-name" }, repos, drafts); + expect(matched).toEqual({ id: 1, name: "original-name" }); + }); + + it("falls back to original github repository name when draft name does not match", () => { + const drafts = { + 2: { name: "another-repo" } + }; + + const matched = matchJobToRepo({ name: "another-repo" }, repos, drafts); + expect(matched).toEqual({ id: 2, name: "another-repo" }); + }); + + it("returns undefined when no repository or draft matches", () => { + const matched = matchJobToRepo({ name: "unknown-repo" }, repos, {}); + expect(matched).toBeUndefined(); + }); +}); + +describe("resolveKnotOptions", () => { + it("resolves array synchronously", async () => { + const result = await resolveKnotOptions(["knot1.test", "knot2.test"]); + expect(result).toEqual(["knot1.test", "knot2.test"]); + }); + + it("resolves promise asynchronously before returning", async () => { + const promise = Promise.resolve(["async-knot.test"]); + const result = await resolveKnotOptions(promise); + expect(result).toEqual(["async-knot.test"]); + }); +}); + +describe("isMigratable", () => { + it.each([ + ["no attempt yet", undefined, true], + ["idle", { status: "idle" }, true], + ["failed attempt", { status: "error" }, true], + ["queued", { status: "queued" }, false], + ["cloning", { status: "cloning" }, false], + ["pushing", { status: "pushing" }, false], + ["submitting", { status: "submitting" }, false], + ["landed", { status: "success", jobStatus: "completed" }, false], + ["landed but failed", { status: "success", jobStatus: "failed" }, true], + ["landed but needs authorization", { status: "success", jobStatus: "authorization_required" }, true] + ])("treats a row with %s as migratable: %s", (_name, migration, expected) => { + expect(isMigratable(migration as never)).toBe(expected); + }); +}); + +describe("createRunGuard", () => { + it("lets the newest run own the busy flag", () => { + const guard = createRunGuard(); + const first = guard.begin(); + expect(guard.owns(first)).toBe(true); + const second = guard.begin(); + expect(guard.owns(first)).toBe(false); + expect(guard.owns(second)).toBe(true); + }); + + it("keeps ownership when nothing newer started", () => { + const guard = createRunGuard(); + const only = guard.begin(); + expect(guard.owns(only)).toBe(true); + expect(guard.owns(only)).toBe(true); + }); +}); + +describe("createRememberedGrant", () => { + const fakeStorage = () => { + const map = new Map(); + return { + map, + getItem: (k: string) => map.get(k) ?? null, + setItem: (k: string, v: string) => void map.set(k, v), + removeItem: (k: string) => void map.delete(k) + }; + }; + + it("remembers a confirmed grant across loads", () => { + const storage = fakeStorage(); + const latch = createRememberedGrant(storage, "k"); + expect(latch.restore()).toBe(false); + latch.confirm(); + expect(storage.map.get("k")).toBe("true"); + expect(createRememberedGrant(storage, "k").restore()).toBe(true); + }); + + it("forgets the grant when the owner disconnects", () => { + const storage = fakeStorage(); + const latch = createRememberedGrant(storage, "k"); + latch.confirm(); + latch.clear(); + expect(latch.restore()).toBe(false); + }); + + it("survives a storage that refuses to write", () => { + const hostile = { + getItem: () => null, + setItem: () => { + throw new Error("private mode"); + }, + removeItem: () => { + throw new Error("private mode"); + } + }; + const latch = createRememberedGrant(hostile, "k"); + expect(() => latch.confirm()).not.toThrow(); + expect(() => latch.clear()).not.toThrow(); + expect(createRememberedGrant(null, "k").restore()).toBe(false); + }); +}); diff --git a/web/src/lib/components/import/grantEvidence.test.ts b/web/src/lib/components/import/grantEvidence.test.ts new file mode 100644 index 000000000..7cc5d17d1 --- /dev/null +++ b/web/src/lib/components/import/grantEvidence.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { hasGrantEvidence } from "./grantEvidence"; + +const url = (search: string) => ({ searchParams: new URLSearchParams(search) }); + +describe("hasGrantEvidence", () => { + it("assumes nothing from an empty flow", () => { + expect(hasGrantEvidence()).toBe(false); + expect(hasGrantEvidence({})).toBe(false); + expect(hasGrantEvidence({ granted: true })).toBe(false); + expect(hasGrantEvidence({ url: url("") })).toBe(false); + expect(hasGrantEvidence({ granted: false, url: url(""), tasks: [] })).toBe(false); + }); + + it("takes an existing task or a create this session as proof", () => { + expect(hasGrantEvidence({ tasks: [{ id: "task-1" }] })).toBe(true); + expect(hasGrantEvidence({ sessionCreated: true })).toBe(true); + }); + + it("trusts the callback's attestation only when it came back without an error", () => { + expect(hasGrantEvidence({ granted: true, url: url("") })).toBe(true); + expect( + hasGrantEvidence({ granted: true, url: url("?oauth_error=grant_failed") }) + ).toBe(false); + expect(hasGrantEvidence({ granted: false, url: url("?page=2") })).toBe(false); + }); +}); diff --git a/web/src/lib/components/import/grantEvidence.ts b/web/src/lib/components/import/grantEvidence.ts new file mode 100644 index 000000000..d18473a5f --- /dev/null +++ b/web/src/lib/components/import/grantEvidence.ts @@ -0,0 +1,17 @@ +interface GrantEvidenceInput { + granted?: boolean; + url?: { searchParams: URLSearchParams } | null; + // task creation requires an active grant + tasks?: readonly unknown[] | null; + sessionCreated?: boolean; +} + +export const hasGrantEvidence = ({ + granted, + url, + tasks, + sessionCreated +}: GrantEvidenceInput = {}): boolean => + sessionCreated === true || + (tasks?.length ?? 0) > 0 || + (granted === true && url != null && !url.searchParams.has("oauth_error")); diff --git a/web/src/lib/components/import/migrationRows.test.ts b/web/src/lib/components/import/migrationRows.test.ts new file mode 100644 index 000000000..edccc882f --- /dev/null +++ b/web/src/lib/components/import/migrationRows.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from "vitest"; +import type { MigrationTask } from "$lib/api/migrator"; +import { migrationRows } from "./migrationRows"; + +const makeTask = ( + id: string, + jobs: { + id: string; + name: string; + status: string; + error?: string; + repoDid?: string; + }[] +): MigrationTask => + ({ + id, + ownerDid: "did:plc:testuser", + createdAt: "2026-09-15T10:00:00Z", + jobs: jobs.map((j) => ({ + id: j.id, + name: j.name, + knotDid: "did:plc:knot", + sourceUrl: `https://github.com/test/${j.name}`, + private: false, + attempts: 1, + status: j.status, + error: j.error, + repoDid: j.repoDid + })) + }) as MigrationTask; + +describe("migrationRows derivation", () => { + it("empty tasks produce none", () => { + expect(migrationRows([], [])).toEqual([]); + expect(migrationRows()).toEqual([]); + expect( + migrationRows( + [], + [ + { + name: "idle-repo", + migration: { status: "idle" } + } + ] + ) + ).toEqual([]); + expect( + migrationRows([], { + 1: { + name: "idle-repo", + migration: { status: "idle" } + } + }) + ).toEqual([]); + }); + + it("tasks with no drafts produce rows (include an authorization_required job)", () => { + const tasks: MigrationTask[] = [ + makeTask("task-1", [ + { + id: "job-auth-req", + name: "repo-needs-auth", + status: "authorization_required", + error: "credentials rejected" + }, + { + id: "job-done", + name: "repo-completed", + status: "completed", + repoDid: "did:plc:repo-done" + } + ]) + ]; + + const rows = migrationRows(tasks, []); + expect(rows).toHaveLength(2); + + const authRow = rows.find((r) => r.name === "repo-needs-auth"); + expect(authRow).toBeDefined(); + expect(authRow).toMatchObject({ + id: "job-auth-req", + name: "repo-needs-auth", + migration: { + status: "success", + task: { taskId: "task-1", name: "repo-needs-auth" }, + jobId: "job-auth-req", + jobStatus: "authorization_required", + jobError: "credentials rejected" + } + }); + + const doneRow = rows.find((r) => r.name === "repo-completed"); + expect(doneRow).toBeDefined(); + expect(doneRow).toMatchObject({ + id: "job-done", + name: "repo-completed", + migration: { + status: "success", + task: { taskId: "task-1", name: "repo-completed" }, + jobId: "job-done", + jobStatus: "completed", + repoDid: "did:plc:repo-done" + } + }); + }); + + it("a job already covered by a draft produces exactly one row", () => { + const tasks: MigrationTask[] = [ + makeTask("task-1", [ + { + id: "job-1", + name: "covered-repo", + status: "cloning" + }, + { + id: "job-2", + name: "uncovered-repo", + status: "queued" + } + ]) + ]; + + const rowsWithJobId = migrationRows(tasks, [ + { + id: 101, + name: "covered-repo", + migration: { + status: "success", + task: { taskId: "task-1", name: "covered-repo" }, + jobId: "job-1", + jobStatus: "cloning" + } + } + ]); + expect(rowsWithJobId).toHaveLength(2); + expect(rowsWithJobId.filter((r) => r.name === "covered-repo")).toHaveLength(1); + expect(rowsWithJobId.filter((r) => r.name === "uncovered-repo")).toHaveLength(1); + expect(rowsWithJobId.find((r) => r.name === "covered-repo")?.id).toBe(101); + + const rowsWithSubmitting = migrationRows(tasks, [ + { + id: 101, + name: "covered-repo", + migration: { + status: "submitting" + } + } + ]); + expect(rowsWithSubmitting).toHaveLength(2); + expect(rowsWithSubmitting.filter((r) => r.name === "covered-repo")).toHaveLength(1); + expect(rowsWithSubmitting.find((r) => r.name === "covered-repo")?.migration.status).toBe( + "submitting" + ); + + const rowsWithRecord = migrationRows(tasks, { + 101: { + id: 101, + name: "covered-repo", + migration: { + status: "success", + task: { taskId: "task-1", name: "covered-repo" }, + jobId: "job-1", + jobStatus: "cloning" + } + } + }); + expect(rowsWithRecord).toHaveLength(2); + expect(rowsWithRecord.filter((r) => r.name === "covered-repo")).toHaveLength(1); + }); +}); diff --git a/web/src/lib/components/import/migrationRows.ts b/web/src/lib/components/import/migrationRows.ts new file mode 100644 index 000000000..67df2d648 --- /dev/null +++ b/web/src/lib/components/import/migrationRows.ts @@ -0,0 +1,83 @@ +import type { MigrationTask } from "$lib/api/migrator"; +import type { MigrationItem } from "./MigrationStep.svelte"; + +type MigrationRowDraft = { + id?: string | number; + name: string; + migration: { + status: "idle" | "submitting" | "error" | "success"; + error?: string; + task?: { taskId: string; name: string }; + jobId?: string; + jobStatus?: string; + jobError?: string; + repoDid?: string; + }; +}; + +type DraftInput = + | readonly MigrationRowDraft[] + | Record + | null + | undefined; + +export const migrationRows = ( + tasks?: readonly MigrationTask[] | null, + drafts?: DraftInput +): MigrationItem[] => { + const rows: MigrationItem[] = []; + const seenNames = new Set(); + const seenJobIds = new Set(); + + const draftList: MigrationRowDraft[] = Array.isArray(drafts) + ? drafts + : drafts && typeof drafts === "object" + ? Object.values(drafts).filter((d): d is MigrationRowDraft => d !== undefined) + : []; + + for (const draft of draftList) { + if (!draft || !draft.migration || draft.migration.status === "idle") continue; + const id = + draft.id !== undefined + ? draft.id + : draft.migration.status === "success" && draft.migration.jobId + ? draft.migration.jobId + : draft.name; + + seenNames.add(draft.name); + if (draft.migration.status === "success" && draft.migration.jobId) { + seenJobIds.add(draft.migration.jobId); + } + + rows.push({ + id, + name: draft.name, + migration: draft.migration + }); + } + + for (const task of tasks ?? []) { + for (const job of task.jobs ?? []) { + if (seenJobIds.has(job.id) || seenNames.has(job.name)) { + continue; + } + seenJobIds.add(job.id); + seenNames.add(job.name); + + rows.push({ + id: job.id, + name: job.name, + migration: { + status: "success", + task: { taskId: task.id, name: job.name }, + jobId: job.id, + jobStatus: job.status, + jobError: job.error, + repoDid: job.repoDid + } + }); + } + } + + return rows; +}; diff --git a/web/src/lib/components/import/stepGating.test.ts b/web/src/lib/components/import/stepGating.test.ts new file mode 100644 index 000000000..42452af42 --- /dev/null +++ b/web/src/lib/components/import/stepGating.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; +import { isAuthorizeGated, isMigrateDisabled, isRepoSelectGated } from "./stepGating"; + +describe("stepGating", () => { + const dummyAccount = { login: "octocat" }; + + describe("isAuthorizeGated", () => { + it("is gated when there is no GitHub account", () => { + expect(isAuthorizeGated({ account: null })).toBe(true); + expect(isAuthorizeGated({ account: undefined })).toBe(true); + }); + + it("is not gated when a GitHub account is present", () => { + expect(isAuthorizeGated({ account: dummyAccount })).toBe(false); + }); + + it("is gated when the flow is disabled even with an account", () => { + expect(isAuthorizeGated({ account: dummyAccount, disabled: true })).toBe(true); + }); + }); + + describe("isRepoSelectGated", () => { + it("is gated when there is no account", () => { + expect(isRepoSelectGated({ account: null, authorized: false })).toBe(true); + expect(isRepoSelectGated({ account: null, authorized: true })).toBe(true); + }); + + it("is gated when there is an account but not authorized", () => { + expect(isRepoSelectGated({ account: dummyAccount, authorized: false })).toBe(true); + }); + + it("is not gated when account is present and authorized", () => { + expect(isRepoSelectGated({ account: dummyAccount, authorized: true })).toBe(false); + }); + + it("is gated when disabled is true", () => { + expect( + isRepoSelectGated({ account: dummyAccount, authorized: true, disabled: true }) + ).toBe(true); + }); + }); + + describe("isMigrateDisabled", () => { + it("is disabled without account or without authorization", () => { + expect( + isMigrateDisabled({ + account: null, + authorized: true, + migratableCount: 1, + knotCount: 1 + }) + ).toBe(true); + expect( + isMigrateDisabled({ + account: dummyAccount, + authorized: false, + migratableCount: 1, + knotCount: 1 + }) + ).toBe(true); + }); + + it("is disabled when there are no migratable repos or no knots", () => { + expect( + isMigrateDisabled({ + account: dummyAccount, + authorized: true, + migratableCount: 0, + knotCount: 1 + }) + ).toBe(true); + expect( + isMigrateDisabled({ + account: dummyAccount, + authorized: true, + migratableCount: 1, + knotCount: 0 + }) + ).toBe(true); + }); + + it("is enabled when prerequisites and selections are satisfied", () => { + expect( + isMigrateDisabled({ + account: dummyAccount, + authorized: true, + migratableCount: 2, + knotCount: 1 + }) + ).toBe(false); + }); + + it("is disabled when flow disabled is true", () => { + expect( + isMigrateDisabled({ + account: dummyAccount, + authorized: true, + migratableCount: 2, + knotCount: 1, + disabled: true + }) + ).toBe(true); + }); + }); +}); diff --git a/web/src/lib/components/import/stepGating.ts b/web/src/lib/components/import/stepGating.ts new file mode 100644 index 000000000..9ae043a13 --- /dev/null +++ b/web/src/lib/components/import/stepGating.ts @@ -0,0 +1,28 @@ +interface StepGatingInput { + account: unknown; + authorized?: boolean; + disabled?: boolean; +} + +interface MigrateButtonInput extends StepGatingInput { + migratableCount?: number; + knotCount?: number; +} + +export const isAuthorizeGated = ({ account, disabled = false }: StepGatingInput): boolean => + !account || disabled; + +export const isRepoSelectGated = ({ + account, + authorized = false, + disabled = false +}: StepGatingInput): boolean => !account || !authorized || disabled; + +export const isMigrateDisabled = ({ + account, + authorized = false, + migratableCount = 0, + knotCount = 0, + disabled = false +}: MigrateButtonInput): boolean => + !account || !authorized || disabled || migratableCount === 0 || knotCount === 0; diff --git a/web/src/lib/components/repo/ImportStatusBanner.svelte b/web/src/lib/components/repo/ImportStatusBanner.svelte deleted file mode 100644 index e91023274..000000000 --- a/web/src/lib/components/repo/ImportStatusBanner.svelte +++ /dev/null @@ -1,70 +0,0 @@ - - -{#if view.content === "failed"} - -{:else} -
- -

- This repository is still importing from {view.source ?? "its source"}. It will fill in - here as the import progresses. -

-
-{/if} diff --git a/web/src/lib/components/repo/RepoForkForm.svelte b/web/src/lib/components/repo/RepoForkForm.svelte index a6a4c1d51..0075e635c 100644 --- a/web/src/lib/components/repo/RepoForkForm.svelte +++ b/web/src/lib/components/repo/RepoForkForm.svelte @@ -24,8 +24,9 @@ ...values, name: values.name.toLowerCase(), source: { - record: repo.repoDid ?? repo.uri, - cloneUrl: `${serviceUrlFor(repo.knot)}/${sourceIdentifier}` + url: `${serviceUrlFor(repo.knot)}/${sourceIdentifier}`, + kind: "fork", + record: repo.repoDid ?? repo.uri } }); }; diff --git a/web/src/lib/components/repo/RepoIndexView.stories.svelte b/web/src/lib/components/repo/RepoIndexView.stories.svelte index a42ecdcbc..11d4c3029 100644 --- a/web/src/lib/components/repo/RepoIndexView.stories.svelte +++ b/web/src/lib/components/repo/RepoIndexView.stories.svelte @@ -208,6 +208,47 @@ + { + const canvas = within(canvasElement); + await waitFor(() => + expect( + canvas.getByText( + "This repository is still importing from GitHub. Its content will appear here as it lands." + ) + ).toBeVisible() + ); + expect(canvas.queryByText("This is an empty repository.")).toBeNull(); + }} +/> + { + const canvas = within(canvasElement); + await waitFor(() => + expect(canvas.getByText("The import from GitHub failed.")).toBeVisible() + ); + await waitFor(() => + expect(canvas.getByText("clone timed out after 300s")).toBeVisible() + ); + expect(canvas.queryByText("This is an empty repository.")).toBeNull(); + }} +/> >>; bobbinUrl: string; pipelineStatuses?: Promise; + importStatus?: ImportState | null; } - let { repo, data, bobbinUrl, pipelineStatuses }: Props = $props(); + let { repo, data, bobbinUrl, pipelineStatuses, importStatus = null }: Props = $props(); const base = $derived(`/${repo.ownerHandle}/${repo.name}`); @@ -39,8 +42,9 @@ {/snippet} {@const content = await data.content} + {@const availability = withImportAvailability(content.availability, importStatus)} - {#if content.availability === "needs-upgrade"} + {#if availability === "needs-upgrade"}
@@ -57,14 +61,35 @@

- {:else if content.availability === "unreachable"} + {:else if availability === "unreachable"}
- {:else if content.availability === "empty"} + {:else if availability === "importing"} +
+ + + This repository is still importing from {repo.importSource ?? "its source"}. Its content will appear here as it lands. + +
+ {:else if availability === "import-failed"} +
+
+ + + {#if importStatus?.reason} +

+ {importStatus.reason} +

+ {/if} +
+
+ {:else if availability === "empty"} {:else} diff --git a/web/src/lib/components/repo/importPoll.test.ts b/web/src/lib/components/repo/importPoll.test.ts new file mode 100644 index 000000000..4017cf380 --- /dev/null +++ b/web/src/lib/components/repo/importPoll.test.ts @@ -0,0 +1,56 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { startImportPoll } from "$lib/components/repo/importPoll"; +import type { ImportState } from "$lib/api/repo"; + +const importing: ImportState = { content: "fetching", source: "GitHub" }; + +describe("startImportPoll", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("reports the terminal state and stops once the import is gone", async () => { + vi.useFakeTimers(); + const fetchState = vi.fn().mockResolvedValueOnce(importing).mockResolvedValue(null); + const onState = vi.fn(); + const onSettled = vi.fn(); + const stop = startImportPoll({ fetchState, onState, onSettled, minDelayMs: 100, maxDelayMs: 200 }); + + await vi.advanceTimersByTimeAsync(100); + expect(onState).toHaveBeenLastCalledWith(importing); + + await vi.advanceTimersByTimeAsync(200); + expect(onState).toHaveBeenLastCalledWith(null); + + const calls = fetchState.mock.calls.length; + await vi.advanceTimersByTimeAsync(10_000); + expect(fetchState).toHaveBeenCalledTimes(calls); + stop(); + }); + + it("stops after a failed import", async () => { + vi.useFakeTimers(); + const failed: ImportState = { content: "failed", reason: "clone timed out" }; + const fetchState = vi.fn().mockResolvedValue(failed); + const onSettled = vi.fn(); + const stop = startImportPoll({ fetchState, onState: vi.fn(), onSettled, minDelayMs: 100 }); + + await vi.advanceTimersByTimeAsync(100); + expect(onSettled).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(10_000); + expect(fetchState).toHaveBeenCalledTimes(1); + stop(); + }); + + it("stops polling after cancellation", async () => { + vi.useFakeTimers(); + const fetchState = vi.fn().mockResolvedValue(importing); + const stop = startImportPoll({ fetchState, onState: vi.fn(), minDelayMs: 100 }); + + await vi.advanceTimersByTimeAsync(100); + stop(); + await vi.advanceTimersByTimeAsync(10_000); + expect(fetchState).toHaveBeenCalledTimes(1); + }); +}); diff --git a/web/src/lib/components/repo/importPoll.ts b/web/src/lib/components/repo/importPoll.ts new file mode 100644 index 000000000..c6678f44e --- /dev/null +++ b/web/src/lib/components/repo/importPoll.ts @@ -0,0 +1,44 @@ +import type { ImportState } from "$lib/api/repo"; + +interface ImportPollOptions { + fetchState: () => Promise; + onState: (state: ImportState | null) => void; + onSettled?: () => void; + minDelayMs?: number; + maxDelayMs?: number; +} + +const MIN_DELAY_MS = 4_000; +const MAX_DELAY_MS = 60_000; + +export const startImportPoll = ({ + fetchState, + onState, + onSettled, + minDelayMs = MIN_DELAY_MS, + maxDelayMs = MAX_DELAY_MS +}: ImportPollOptions): (() => void) => { + let delay = minDelayMs; + let timer: ReturnType | undefined; + let cancelled = false; + + const recheck = async () => { + const next = await fetchState(); + if (cancelled) return; + // a null answer means the import finished and bobbin dropped the record; reporting it is what + // dismisses the banner, so it must not be skipped + onState(next); + if (!next || next.content === "failed") { + onSettled?.(); + return; + } + delay = Math.min(delay * 2, maxDelayMs); + timer = setTimeout(recheck, delay); + }; + + timer = setTimeout(recheck, delay); + return () => { + cancelled = true; + if (timer) clearTimeout(timer); + }; +}; diff --git a/web/src/lib/components/settings/VerifyEmailCard.stories.svelte b/web/src/lib/components/settings/VerifyEmailCard.stories.svelte new file mode 100644 index 000000000..57fef732c --- /dev/null +++ b/web/src/lib/components/settings/VerifyEmailCard.stories.svelte @@ -0,0 +1,34 @@ + + + + + { + const canvas = within(canvasElement); + await waitFor(() => expect(canvas.getByText("Email verified")).toBeVisible()); + await waitFor(() => expect(canvas.getByText(/Return to the tab/)).toBeVisible()); + await waitFor(() => expect(canvas.queryByRole("link")).toBeNull()); + }} +/> + + diff --git a/web/src/lib/components/settings/VerifyEmailCard.svelte b/web/src/lib/components/settings/VerifyEmailCard.svelte new file mode 100644 index 000000000..bd14ec537 --- /dev/null +++ b/web/src/lib/components/settings/VerifyEmailCard.svelte @@ -0,0 +1,42 @@ + + +
+ {#if state.kind === "verifying"} + Verifying your email address… + {:else if state.kind === "done"} +
diff --git a/web/src/lib/components/settings/tabs/EmailsTab.svelte b/web/src/lib/components/settings/tabs/EmailsTab.svelte index 829580874..d0d86e8d6 100644 --- a/web/src/lib/components/settings/tabs/EmailsTab.svelte +++ b/web/src/lib/components/settings/tabs/EmailsTab.svelte @@ -1,4 +1,5 @@ - +{#if !embedded} + +{/if}
- - {#snippet end()} - - {/snippet} - + {#if addHref} + + {#snippet end()} + + {/snippet} + + {:else if inlineAdd} + + {#snippet end()} + {#if !addingEmail} + + {/if} + {/snippet} + + + {#if addingEmail} +
{ + e.preventDefault(); + if (validNewEmail && !addOne.loading) { + addOne.run(newEmail.trim()); + } + }} + > +
+ +
+ + +
+
+ + + {/if} + {/if} - {#if load.loading} + {#if load.loading && !initialEmails} {#each [0, 1, 2] as _, i (i)}
@@ -163,7 +282,7 @@ {/if} - + {#if resendNotice}

{resendNotice}

{/if} diff --git a/web/src/lib/components/settings/tabs/emailRecheck.test.ts b/web/src/lib/components/settings/tabs/emailRecheck.test.ts new file mode 100644 index 000000000..e7dde40d1 --- /dev/null +++ b/web/src/lib/components/settings/tabs/emailRecheck.test.ts @@ -0,0 +1,210 @@ +import { describe, expect, it, vi } from "vitest"; +import { render } from "svelte/server"; +import type { ListEmailItem } from "$lib/api/emails"; +import { + announceEmailVerified, + recheckUnverifiedEmails, + type EmailVerificationChannel, + type Visibility +} from "$lib/components/settings/tabs/emailRecheck"; +import EmailsTab from "$lib/components/settings/tabs/EmailsTab.svelte"; +import { AUTH_KEY, type Auth } from "$lib/auth.svelte"; +import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; + +const email = (address: string, verified: boolean, primary = false): ListEmailItem => ({ + address, + verified, + primary, + createdAt: "2026-09-17T00:00:00Z" +}); + +const settled = async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +}; + +const fakeVisibility = (hidden = false) => { + let onVisible: () => void = () => {}; + let onFocus: () => void = () => {}; + const visibility: Visibility = { + isHidden: () => hidden, + subscribe: (v, f) => { + onVisible = v; + onFocus = f; + return () => {}; + } + }; + return { visibility, visible: () => onVisible(), focus: () => onFocus() }; +}; + +const fakeChannel = () => { + let onVerified: () => void = () => {}; + let unsubscribed = false; + const channel: EmailVerificationChannel = { + announce: () => {}, + subscribe: (handler) => { + onVerified = handler; + return () => { + unsubscribed = true; + }; + } + }; + return { channel, verifiedElsewhere: () => onVerified(), wasUnsubscribed: () => unsubscribed }; +}; + +describe("recheckUnverifiedEmails", () => { + it("refetches when the tab becomes visible", async () => { + const { visibility, visible } = fakeVisibility(); + const { channel } = fakeChannel(); + const initial = [email("alice@example.com", false)]; + const fetchLatest = vi.fn().mockResolvedValue(initial); + const stop = recheckUnverifiedEmails(() => initial, fetchLatest, () => {}, { visibility, channel }); + + expect(fetchLatest).not.toHaveBeenCalled(); + visible(); + await settled(); + expect(fetchLatest).toHaveBeenCalledOnce(); + stop(); + }); + + it("refetches on window focus", async () => { + const { visibility, focus } = fakeVisibility(); + const { channel } = fakeChannel(); + const initial = [email("alice@example.com", false)]; + const fetchLatest = vi.fn().mockResolvedValue(initial); + const stop = recheckUnverifiedEmails(() => initial, fetchLatest, () => {}, { visibility, channel }); + + focus(); + await settled(); + expect(fetchLatest).toHaveBeenCalledOnce(); + stop(); + }); + + it("refetches when another tab reports a verification", async () => { + const { visibility } = fakeVisibility(); + const { channel, verifiedElsewhere } = fakeChannel(); + const unverified = [email("alice@example.com", false)]; + const verified = [email("alice@example.com", true)]; + const fetchLatest = vi.fn().mockResolvedValue(verified); + let current = unverified; + const stop = recheckUnverifiedEmails( + () => current, + fetchLatest, + (updated) => { + current = updated; + }, + { visibility, channel } + ); + + verifiedElsewhere(); + await settled(); + expect(fetchLatest).toHaveBeenCalledOnce(); + expect(current[0].verified).toBe(true); + stop(); + }); + + it("ignores triggers while the tab is hidden", async () => { + const { visibility, visible } = fakeVisibility(true); + const { channel } = fakeChannel(); + const initial = [email("alice@example.com", false)]; + const fetchLatest = vi.fn().mockResolvedValue(initial); + const stop = recheckUnverifiedEmails(() => initial, fetchLatest, () => {}, { visibility, channel }); + + visible(); + await settled(); + expect(fetchLatest).not.toHaveBeenCalled(); + stop(); + }); + + it("runs once more when a trigger lands during an in-flight fetch", async () => { + const { visibility, visible } = fakeVisibility(); + const { channel } = fakeChannel(); + const initial = [email("alice@example.com", false)]; + let release: (rows: ListEmailItem[]) => void = () => {}; + const fetchLatest = vi.fn( + () => new Promise((resolve) => { release = resolve; }) + ); + const stop = recheckUnverifiedEmails(() => initial, fetchLatest, () => {}, { visibility, channel }); + + visible(); + await settled(); + expect(fetchLatest).toHaveBeenCalledOnce(); + + visible(); + await settled(); + expect(fetchLatest).toHaveBeenCalledOnce(); + + release(initial); + await settled(); + expect(fetchLatest).toHaveBeenCalledTimes(2); + stop(); + }); + + it("stops refetching and unsubscribes once torn down", async () => { + const { visibility, visible } = fakeVisibility(); + const { channel, verifiedElsewhere, wasUnsubscribed } = fakeChannel(); + const initial = [email("alice@example.com", false)]; + const fetchLatest = vi.fn().mockResolvedValue(initial); + const stop = recheckUnverifiedEmails(() => initial, fetchLatest, () => {}, { visibility, channel }); + + stop(); + expect(wasUnsubscribed()).toBe(true); + + visible(); + verifiedElsewhere(); + await settled(); + expect(fetchLatest).not.toHaveBeenCalled(); + }); + + it("does nothing when every row is already verified", async () => { + const { visibility, visible } = fakeVisibility(); + const { channel } = fakeChannel(); + const verified = [email("alice@example.com", true)]; + const fetchLatest = vi.fn().mockResolvedValue(verified); + const stop = recheckUnverifiedEmails(() => verified, fetchLatest, () => {}, { visibility, channel }); + + visible(); + await settled(); + expect(fetchLatest).not.toHaveBeenCalled(); + stop(); + }); +}); + +describe("announceEmailVerified", () => { + it("is a no-op without a document, so the server render cannot throw", () => { + expect(() => announceEmailVerified("alice@example.com")).not.toThrow(); + }); +}); + +describe("EmailsTab rows", () => { + const mockAuth = (): Map => + new Map([ + [ + AUTH_KEY, + { + agent: { session: { token: { sub: "did:plc:alice" } } } as unknown as OAuthUserAgent, + currentDid: "did:plc:alice", + agentPending: false + } as unknown as Auth + ] + ]); + + it("renders the verified row with the actions that row supports", () => { + const unverifiedHtml = render(EmailsTab, { + props: { initialEmails: [email("alice@example.com", false)] }, + context: mockAuth() + }).body; + expect(unverifiedHtml).toContain("Unverified"); + expect(unverifiedHtml).toContain("Resend link"); + expect(unverifiedHtml).not.toContain("Set as primary"); + + const verifiedHtml = render(EmailsTab, { + props: { initialEmails: [email("alice@example.com", true)] }, + context: mockAuth() + }).body; + expect(verifiedHtml).toContain("Verified"); + expect(verifiedHtml).toContain("Set as primary"); + expect(verifiedHtml).not.toContain("Unverified"); + }); +}); diff --git a/web/src/lib/components/settings/tabs/emailRecheck.ts b/web/src/lib/components/settings/tabs/emailRecheck.ts new file mode 100644 index 000000000..2b7e4b1ba --- /dev/null +++ b/web/src/lib/components/settings/tabs/emailRecheck.ts @@ -0,0 +1,121 @@ +import type { ListEmailItem } from "$lib/api/emails"; + +const EMAIL_VERIFIED_KEY = "tangled.email.verified"; + +// broadcast verification across tabs via storage events +export interface EmailVerificationChannel { + announce: (email: string) => void; + subscribe: (onVerified: () => void) => () => void; +} + +const defaultVerificationChannel: EmailVerificationChannel = { + announce: (email) => { + if (typeof localStorage === "undefined") return; + try { + localStorage.setItem(EMAIL_VERIFIED_KEY, email); + } catch { + // ignore private-mode storage errors + } + }, + subscribe: (onVerified) => { + if (typeof window === "undefined") return () => {}; + const handler = (event: StorageEvent) => { + if (event.key === EMAIL_VERIFIED_KEY) onVerified(); + }; + window.addEventListener("storage", handler); + return () => window.removeEventListener("storage", handler); + } +}; + +export const announceEmailVerified = (email: string): void => + defaultVerificationChannel.announce(email); + +export interface Visibility { + isHidden: () => boolean; + subscribe: (onVisible: () => void, onFocus: () => void) => () => void; +} + +const defaultVisibility: Visibility = { + isHidden: () => typeof document !== "undefined" && document.hidden, + subscribe: (onVisible, onFocus) => { + if (typeof document === "undefined" && typeof window === "undefined") return () => {}; + const handleVisibility = () => { + if (typeof document !== "undefined" && !document.hidden) onVisible(); + }; + const handleFocus = () => { + if (typeof document === "undefined" || !document.hidden) onFocus(); + }; + if (typeof document !== "undefined") { + document.addEventListener("visibilitychange", handleVisibility); + } + if (typeof window !== "undefined") { + window.addEventListener("focus", handleFocus); + } + return () => { + if (typeof document !== "undefined") { + document.removeEventListener("visibilitychange", handleVisibility); + } + if (typeof window !== "undefined") { + window.removeEventListener("focus", handleFocus); + } + }; + } +}; + +export const hasUnverifiedEmails = (emails: ListEmailItem[]): boolean => + emails.some((email) => !email.verified); + +interface RecheckEmailOptions { + visibility?: Visibility; + channel?: EmailVerificationChannel; +} + +export const recheckUnverifiedEmails = ( + getInitial: () => ListEmailItem[], + fetchLatest: () => Promise, + onUpdate: (emails: ListEmailItem[]) => void, + options: RecheckEmailOptions = {} +): (() => void) => { + const initial = getInitial(); + if (!hasUnverifiedEmails(initial)) return () => {}; + + const visibility = options.visibility ?? defaultVisibility; + const channel = options.channel ?? defaultVerificationChannel; + + let stopped = false; + let inFlight = false; + let coalesced = false; + + const runFetch = async (): Promise => { + if (stopped || inFlight) { + if (inFlight) coalesced = true; + return; + } + if (visibility.isHidden()) return; + inFlight = true; + try { + const updated = await fetchLatest(); + if (!stopped) onUpdate(updated); + } catch { + } finally { + inFlight = false; + if (coalesced) { + coalesced = false; + void runFetch(); + } + } + }; + + const trigger = () => { + void runFetch(); + }; + + const unsubscribeVisibility = visibility.subscribe(trigger, trigger); + const unsubscribeChannel = channel.subscribe(trigger); + + return () => { + stopped = true; + unsubscribeVisibility(); + unsubscribeChannel(); + }; +}; diff --git a/web/src/lib/components/shell/Topbar.svelte b/web/src/lib/components/shell/Topbar.svelte index 64962ea49..671410059 100644 --- a/web/src/lib/components/shell/Topbar.svelte +++ b/web/src/lib/components/shell/Topbar.svelte @@ -44,6 +44,7 @@ import BookMarked from "$icon/book-marked"; import BookPlus from "$icon/book-plus"; import Building2 from "$icon/building-2"; + import Import from "$icon/import"; import Cog from "$icon/cog"; import LineSquiggle from "$icon/line-squiggle"; import LogOut from "$icon/log-out"; @@ -142,6 +143,9 @@ New organization + Import repository
diff --git a/web/src/lib/components/welcome/EmailsStep.stories.svelte b/web/src/lib/components/welcome/EmailsStep.stories.svelte new file mode 100644 index 000000000..4f3ef3a2d --- /dev/null +++ b/web/src/lib/components/welcome/EmailsStep.stories.svelte @@ -0,0 +1,84 @@ + + + rows(listed)) as unknown as typeof fetch + }} + play={async ({ canvasElement }) => { + const canvas = within(canvasElement); + await waitFor(() => expect(canvas.getByText("Verify your emails")).toBeVisible()); + await waitFor(() => + expect(canvas.getByText("octostub+db937e2b@octostub.dev")).toBeVisible() + ); + await waitFor(() => expect(canvas.getByText("Verified")).toBeVisible()); + await waitFor(() => + expect(canvas.getByText("octostub-secondary@octostub.dev")).toBeVisible() + ); + await waitFor(() => expect(canvas.getByText("Unverified")).toBeVisible()); + await waitFor(() => expect(canvas.queryAllByText("Emails")).toHaveLength(0)); + }} +/> + + nothing) as unknown as typeof fetch + }} + play={async ({ canvasElement }) => { + const canvas = within(canvasElement); + await waitFor(() => expect(canvas.getByText("No emails added yet")).toBeVisible()); + await waitFor(() => expect(canvas.getByText("Add email")).toBeVisible()); + }} +/> diff --git a/web/src/lib/components/welcome/EmailsStep.svelte b/web/src/lib/components/welcome/EmailsStep.svelte new file mode 100644 index 000000000..d8322841c --- /dev/null +++ b/web/src/lib/components/welcome/EmailsStep.svelte @@ -0,0 +1,20 @@ + + +
+ + + +
diff --git a/web/src/lib/components/welcome/FinishStep.svelte b/web/src/lib/components/welcome/FinishStep.svelte index caccedb6b..7d499003f 100644 --- a/web/src/lib/components/welcome/FinishStep.svelte +++ b/web/src/lib/components/welcome/FinishStep.svelte @@ -47,6 +47,7 @@ import { resolve } from "$app/paths"; import ArrowRight from "$icon/arrow-right"; import BookMarked from "$icon/book-marked"; + import Download from "$icon/download"; import GitPullRequest from "$icon/git-pull-request"; import Layers2 from "$icon/layers-2"; import MessageCircle from "$icon/message-circle"; @@ -68,6 +69,7 @@ const coreHref = resolve("/tangled.org/core" as "/"); const newRepoHref = resolve("/repo/new"); + const migrateHref = resolve("/repo/migrate"); const docs: { icon: Component; @@ -120,7 +122,18 @@
-
+
+ {@render heading(Download, "Migrate your repositories")} +

+ Point us at your GitHub account and move your repositories to a knot of your choice. +

+ + Migrate + +
+ +
-
+
+ {#if start && step === 0} + {@render start()} + {/if} {#if step > 0}
diff --git a/web/src/lib/components/welcome/steps.ts b/web/src/lib/components/welcome/steps.ts index b51b4e347..5be16e911 100644 --- a/web/src/lib/components/welcome/steps.ts +++ b/web/src/lib/components/welcome/steps.ts @@ -1,8 +1,7 @@ -// mirrors appview/models/onboarding.go. the appview -// persists the index in the `onboarding` table, so these values are storage -// keys, not just display order — keep them aligned with the Go constants. +// display order only; not shared with appview's onboarding schema export const WELCOME_STEPS = [ { key: "profile", label: "Profile" }, + { key: "emails", label: "Emails" }, { key: "social", label: "Discover" }, { key: "keys", label: "SSH keys" }, { key: "finish", label: "Finish" } diff --git a/web/src/routes/+layout.server.ts b/web/src/routes/+layout.server.ts index cffa484f9..c7d612d74 100644 --- a/web/src/routes/+layout.server.ts +++ b/web/src/routes/+layout.server.ts @@ -1,12 +1,12 @@ import { getPublicConfig } from "$lib/server/config"; import type { LayoutServerLoad } from "./$types"; -export const load: LayoutServerLoad = ({ cookies }) => { +export const load: LayoutServerLoad = ({ cookies, platform }) => { const did = cookies.get("tangled.currentDid"); const handle = cookies.get("tangled.currentHandle"); return { - publicConfig: getPublicConfig(), + publicConfig: getPublicConfig(platform), auth: did ? { did, handle: handle ?? did } : null }; }; diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index c99d4378f..2171dfc3e 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -65,13 +65,15 @@ }; const signedIn = $derived(Boolean(auth.currentUser)); - // bare frame routes prevent shell flash during redirects const isBareShell = $derived( page.url.pathname === "/login" || page.url.pathname === "/oauth/callback" || page.url.pathname.startsWith("/signup") ); - const isWelcomeShell = $derived(page.url.pathname === "/welcome"); + // email verification lands mid-onboarding without app chrome + const isWelcomeShell = $derived( + page.url.pathname === "/welcome" || page.url.pathname === "/verify/email" + ); const isMarketingShell = $derived( !signedIn && (page.url.pathname === "/about" || page.url.pathname === "/") ); diff --git a/web/src/routes/+page.ts b/web/src/routes/+page.ts index 7e1b2ca90..5d7cfa471 100644 --- a/web/src/routes/+page.ts +++ b/web/src/routes/+page.ts @@ -1,4 +1,3 @@ -import { browser } from "$app/environment"; import { redirect } from "@sveltejs/kit"; import { createBobbinClient } from "$lib/api/client"; import { httpStatusFor, settle, toHttpError } from "$lib/api/load"; @@ -16,15 +15,11 @@ export const load: PageLoad = async (event) => { fetch: event.fetch }); - // onboarding already knows the profile exists; skip the round-trip - if (!(browser && sessionStorage.getItem("tangled.onboarding.profile") === viewer)) { - try { - await getProfile(ctx, viewer); - if (browser) sessionStorage.removeItem("tangled.onboarding.profile"); - } catch (cause) { - if (httpStatusFor(cause) === 404) redirect(307, "/welcome"); - toHttpError(cause, "Could not load profile"); - } + try { + await getProfile(ctx, viewer); + } catch (cause) { + if (httpStatusFor(cause) === 404) redirect(307, "/welcome"); + toHttpError(cause, "Could not load profile"); } const followingOnly = event.url.searchParams.get("following") === "true"; diff --git a/web/src/routes/[handle]/[repo]/+page.svelte b/web/src/routes/[handle]/[repo]/+page.svelte index ac0b3dca6..e37f5e0b0 100644 --- a/web/src/routes/[handle]/[repo]/+page.svelte +++ b/web/src/routes/[handle]/[repo]/+page.svelte @@ -1,9 +1,13 @@ @@ -21,12 +54,11 @@ {/snippet} {@const repo = await data.repo} - {#await data.importStatus then status} - {#if status} -
- -
- {/if} - {/await} - +
diff --git a/web/src/routes/repo/migrate/+page.server.ts b/web/src/routes/repo/migrate/+page.server.ts new file mode 100644 index 000000000..a68ccc6c9 --- /dev/null +++ b/web/src/routes/repo/migrate/+page.server.ts @@ -0,0 +1,6 @@ +import { requireAuth } from "$lib/auth/guards"; +import type { PageServerLoad } from "./$types"; + +export const load: PageServerLoad = (event) => { + requireAuth(event); +}; diff --git a/web/src/routes/repo/migrate/+page.svelte b/web/src/routes/repo/migrate/+page.svelte new file mode 100644 index 000000000..209f13843 --- /dev/null +++ b/web/src/routes/repo/migrate/+page.svelte @@ -0,0 +1,88 @@ + + +{#snippet methodStep()} + + (source = v)} /> + +{/snippet} + + +
+
+
+ + {#if source === "github"} + + {:else} + + {@render methodStep()} + + + + + + {/if} + +
diff --git a/web/src/routes/repo/migrate/+page.ts b/web/src/routes/repo/migrate/+page.ts new file mode 100644 index 000000000..c045bc7be --- /dev/null +++ b/web/src/routes/repo/migrate/+page.ts @@ -0,0 +1,5 @@ +import { repoCreationOptions } from "$lib/api/repoCreationOptions"; +import type { PageLoad } from "./$types"; + +export const load: PageLoad = async (event) => + repoCreationOptions(await event.parent(), event.fetch); diff --git a/web/src/routes/repo/new/+page.svelte b/web/src/routes/repo/new/+page.svelte index a7f2547ec..5ca2987ea 100644 --- a/web/src/routes/repo/new/+page.svelte +++ b/web/src/routes/repo/new/+page.svelte @@ -6,9 +6,9 @@ import RepoCreationForm, { type RepoCreationSubmit } from "$lib/components/repo/RepoCreationForm.svelte"; + import type { PageData } from "./$types"; - let { data } = $props(); - + let { data }: { data: PageData } = $props(); const submit: RepoCreationSubmit = (values, { user, agent }) => createRepo(agent, data.publicConfig.bobbinUrl, { ownerDid: user.did, @@ -28,6 +28,10 @@ Repositories contain a project's files and version history. All repositories are publicly accessible.

+

+ You can also + migrate an existing repository instead. +

@@ -39,6 +43,6 @@ pendingLabel="Creating repository…" icon={BookPlus} {submit} - /> - + />
diff --git a/web/src/routes/repo/new/+page.ts b/web/src/routes/repo/new/+page.ts new file mode 100644 index 000000000..9fb93ef81 --- /dev/null +++ b/web/src/routes/repo/new/+page.ts @@ -0,0 +1,8 @@ +import { redirect } from "@sveltejs/kit"; +import { repoCreationOptions } from "$lib/api/repoCreationOptions"; +import type { PageLoad } from "./$types"; + +export const load: PageLoad = async (event) => { + if (event.url.searchParams.has("import")) redirect(308, "/repo/migrate"); + return repoCreationOptions(await event.parent(), event.fetch); +}; diff --git a/web/src/routes/verify/email/+page.svelte b/web/src/routes/verify/email/+page.svelte index 2c64ec1dd..256d00704 100644 --- a/web/src/routes/verify/email/+page.svelte +++ b/web/src/routes/verify/email/+page.svelte @@ -1,11 +1,10 @@ -
- - {#if state.kind === "verifying"} - Verifying your email address… - {:else if state.kind === "done"} - +
+
+

+ +

+ +
+ +
+
+
diff --git a/web/src/routes/welcome/+page.svelte b/web/src/routes/welcome/+page.svelte index 2679f325f..18766cac0 100644 --- a/web/src/routes/welcome/+page.svelte +++ b/web/src/routes/welcome/+page.svelte @@ -1,41 +1,48 @@ @@ -210,7 +339,21 @@ - + {#if unfinishedMigration} +
+

+ You have an unfinished repository migration from GitHub. +

+ + View migration + +
+ {/if} void next()} onSelect={(index) => void select(index)} > + {#snippet start()} + + {/snippet} {#snippet children(current)} {#if current === 0} {:else if current === 1} - (socialTouched = true)} /> + {:else if current === 2} + (socialTouched = true)} /> + {:else if current === 3} {:else} @@ -236,6 +397,13 @@ {/snippet} + {#if profileError || githubError} +
+ + +
+ {/if} + {#if step < LAST_STEP}