import { ClientResponseError } from "@atcute/client";
import { isDid } from "@atcute/lexicons/syntax";
import type { OAuthUserAgent } from "@atcute/oauth-browser-client";
import { mintServiceAuth, serviceDidForHost } from "#lib/auth/agent.ts";
import { missingPermissions, type Permission } from "#lib/auth/scopes.ts";
import {
jsonGet,
jsonPost,
serviceClient,
serviceUrlFor,
toResponseError
} from "#lib/api/_request.ts";
import { nsidOf } from "#lib/api/syntax.ts";
import { mainSchema as createTaskSchema } from "#lib/api/lexicons/types/org/tangled/temp/migrator/createTask.ts";
import type * as Migrator from "#lib/api/lexicons/types/org/tangled/temp/migrator/defs.ts";
import { mainSchema as describeSourceSchema } from "#lib/api/lexicons/types/org/tangled/temp/migrator/describeSource.ts";
import type { $output as DescribeSourceOutput } from "#lib/api/lexicons/types/org/tangled/temp/migrator/describeSource.ts";
import { mainSchema as getTaskSchema } from "#lib/api/lexicons/types/org/tangled/temp/migrator/getTask.ts";
import { mainSchema as listTasksSchema } from "#lib/api/lexicons/types/org/tangled/temp/migrator/listTasks.ts";
import type { $output as ListTasksOutput } from "#lib/api/lexicons/types/org/tangled/temp/migrator/listTasks.ts";
import { mainSchema as retryJobSchema } from "#lib/api/lexicons/types/org/tangled/temp/migrator/retryJob.ts";
import type { Fetch } from "#lib/fetch.ts";
const CREATE_TASK = nsidOf(createTaskSchema.nsid);
const GET_TASK = getTaskSchema.nsid;
const LIST_TASKS = listTasksSchema.nsid;
const RETRY_JOB = retryJobSchema.nsid;
const DESCRIBE_SOURCE = describeSourceSchema.nsid;
export const describeSourcePermission = (host: string): Permission | null => {
const aud = serviceDidForHost(host);
return isDid(aud) ? { resource: "rpc", lxm: DESCRIBE_SOURCE, aud } : null;
};
export const missingDescribeSourceScope = (
agent: OAuthUserAgent,
host: string
): readonly Permission[] => {
const wanted = describeSourcePermission(host);
return wanted === null ? [] : missingPermissions(agent.session.token.scope, [wanted]);
};
export type MigrationTask = Migrator.Task;
export type NewMigrationJob = Migrator.NewJob;
export type MigrationJobStatus =
"authorization_required" | "cloning" | "completed" | "failed" | "importing" | "queued";
export class MigrationGrantRequired extends Error {
readonly startUrl: string | null;
constructor(startUrl: string | null) {
super("Authorize the migrator to start migrating.");
this.name = "MigrationGrantRequired";
this.startUrl = startUrl;
}
}
// migrator echoes return_to and appends oauth_error; round trips must not nest them
const transportParams = ["return_to", "oauth_error", "migrator"];
const withQuery = (pathname: string, params: URLSearchParams): string => {
const query = params.toString();
return query === "" ? pathname : `${pathname}?${query}`;
};
export const returnPathFrom = (location: { pathname: string; search: string }): string => {
const params = new URLSearchParams(location.search);
for (const key of transportParams) params.delete(key);
return withQuery(location.pathname, params);
};
export const flowReturnPath = (location: { pathname: string; search: string }): string => {
const params = new URLSearchParams(location.search);
params.delete("github_error");
return withQuery(location.pathname, params);
};
const currentReturnPath = (): string =>
typeof window === "undefined"
? ""
: returnPathFrom({ pathname: window.location.pathname, search: window.location.search });
export const startUrlFrom = (
host: string,
did: string | undefined,
returnTo = currentReturnPath()
): string | null =>
did === undefined
? null
: `${serviceUrlFor(host)}/oauth/start?did=${encodeURIComponent(did)}` +
(returnTo === "" ? "" : `&return_to=${encodeURIComponent(returnTo)}`);
const extractGrantError = (
cause: unknown,
host: string,
did: string | undefined
): MigrationGrantRequired | null =>
cause instanceof ClientResponseError &&
(cause.status === 428 || cause.error === "GrantRequired")
? new MigrationGrantRequired(startUrlFrom(host, did))
: null;
interface CreateMigrationTaskOptions {
signal?: AbortSignal;
fetch?: Fetch;
}
export const createMigrationTask = async (
agent: OAuthUserAgent,
host: string,
requestId: string,
jobs: NewMigrationJob[],
options?: CreateMigrationTaskOptions | AbortSignal
): Promise<MigrationTask> => {
if (jobs.length === 0) throw new Error("a migration needs at least one repository");
const signal = options instanceof AbortSignal ? options : options?.signal;
const customFetch =
(options && !(options instanceof AbortSignal) ? options.fetch : undefined) ??
globalThis.fetch;
const token = await mintServiceAuth(agent, {
aud: serviceDidForHost(host),
lxm: CREATE_TASK,
signal
});
const response = await customFetch("/_internal/github/migrator/createTask", {
method: "POST",
headers: {
authorization: `Bearer ${token}`,
"content-type": "application/json",
accept: "application/json"
},
body: JSON.stringify({ requestId, jobs }),
signal
});
if (!response.ok) {
const err = await toResponseError(response);
throw extractGrantError(err, host, agent.sub) ?? err;
}
const text = await response.text();
return (text.length > 0 ? JSON.parse(text) : undefined) as MigrationTask;
};
export const getMigrationTask = async (
agent: OAuthUserAgent,
host: string,
taskId: string,
signal?: AbortSignal
): Promise<MigrationTask> => {
const ctx = serviceClient(agent, host);
try {
return await jsonGet<MigrationTask>(ctx, GET_TASK, { taskId }, { signal });
} catch (cause) {
throw extractGrantError(cause, host, agent.sub) ?? cause;
}
};
export const listMigrationTasks = async (
agent: OAuthUserAgent,
host: string,
params?: { limit?: number },
signal?: AbortSignal
): Promise<MigrationTask[]> => {
const ctx = serviceClient(agent, host);
try {
const out = await jsonGet<ListTasksOutput>(ctx, LIST_TASKS, params, { signal });
return out.tasks;
} catch (cause) {
throw extractGrantError(cause, host, agent.sub) ?? cause;
}
};
export const describeSource = async (
agent: OAuthUserAgent,
host: string,
sourceUrl: string,
signal?: AbortSignal
): Promise<DescribeSourceOutput> => {
const ctx = serviceClient(agent, host);
try {
return await jsonGet<DescribeSourceOutput>(ctx, DESCRIBE_SOURCE, { sourceUrl }, { signal });
} catch (cause) {
throw extractGrantError(cause, host, agent.sub) ?? cause;
}
};
export const retryMigrationJob = async (
agent: OAuthUserAgent,
host: string,
input: { taskId: string; jobId: string; githubToken?: string },
signal?: AbortSignal
): Promise<MigrationTask> => {
const ctx = serviceClient(agent, host);
try {
return await jsonPost<MigrationTask>(ctx, RETRY_JOB, input, { signal });
} catch (cause) {
throw extractGrantError(cause, host, agent.sub) ?? cause;
}
};
// mirrors migrator isterminal; unrecognized status keeps poll alive
export const migrationRunning = (status: string): boolean =>
!["completed", "failed", "authorization_required"].includes(status);
// jobs that failed on a scope the pds refused, from before the migrator stopped them as
// authorization_required, need a new grant too
export const grantRefused = (status: string | undefined, error?: string): boolean =>
status === "authorization_required" || /ScopeMissingError|invalid_grant/.test(error ?? "");
export const migrationsInFlight = (tasks: MigrationTask[]): boolean =>
tasks.some((task) => task.jobs.some((job) => migrationRunning(job.status)));
// only newest batch can wait on a grant; older batches are history
export const migratedRepoCount = (tasks: MigrationTask[]): number =>
tasks.flatMap((task) => task.jobs).filter((job) => job.status === "completed").length;
export const migrationsUnfinished = (tasks: MigrationTask[]): boolean =>
migrationsInFlight(tasks) ||
(tasks[0]?.jobs.some((job) => job.status === "authorization_required") ?? false);