Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 29 additions & 2 deletions sdk/typescript/src/bulk-scan-discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,11 +198,16 @@ async function selectGitHubOwner(
signal?: AbortSignal,
): Promise<string> {
const [orgs, user] = await Promise.all([
github.request("GET /user/orgs", { per_page: 100, request: { signal } }),
listGitHubOrganizations(github, signal),
github.request("GET /user", { request: { signal } }),
]);
signal?.throwIfAborted();
const personal = user.data.login;
const owners = [personal, ...orgs.data.map(({ login }) => login)].sort();
const owners = [
...new Map(
[personal, ...orgs].map((owner) => [owner.toLowerCase(), owner]),
).values(),
].sort();
if (owners.length > 1) {
return await prompt.select(
"Which account or organization should we scan?",
Expand All @@ -213,6 +218,28 @@ async function selectGitHubOwner(
return personal;
}

async function listGitHubOrganizations(
github: Octokit,
signal?: AbortSignal,
): Promise<string[]> {
const organizations: string[] = [];
let page = 1;
while (true) {
signal?.throwIfAborted();
const result = await github.request("GET /user/orgs", {
per_page: 100,
page,
request: { signal },
});
signal?.throwIfAborted();
organizations.push(...result.data.map(({ login }) => login));
if (!result.headers.link?.match(/;\s*rel="next"(?:\s*,|$)/u)) {
return organizations;
}
page += 1;
}
}

async function selectGitHubRepositories(
repositories: GitHubRepository[],
prompt: BulkScanPrompt,
Expand Down
76 changes: 75 additions & 1 deletion sdk/typescript/tests-ts/bulk-scan-discovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ interface Repository {

interface GitHubRequest {
path: string;
page?: number;
query?: string;
variables?: { owner: string; cursor: string | null };
}
Expand All @@ -93,6 +94,8 @@ function discoveryDependencies(
options: {
host?: string;
organizations?: string[];
organizationPages?: string[][];
onOrganizationPage?(page: number): void;
repositories?: Repository[];
authenticated?: boolean;
} = {},
Expand Down Expand Up @@ -153,16 +156,33 @@ function discoveryDependencies(
});
requests.push({
path,
...(path === "/user/orgs"
? { page: Number(url.searchParams.get("page") ?? 1) }
: {}),
...(body === undefined
? {}
: { query: body.query, variables: body.variables }),
});

if (path === "/user/orgs") {
const page = Number(url.searchParams.get("page") ?? 1);
options.onOrganizationPage?.(page);
const organizationPages = options.organizationPages ?? [
options.organizations ?? [],
];
const nextPage =
page < organizationPages.length ? page + 1 : null;
const headers =
nextPage === null
? undefined
: {
Link: `<${url.origin}${url.pathname}?per_page=100&page=${nextPage}>; rel="next"`,
};
return Response.json(
(options.organizations ?? []).map((login) => ({
(organizationPages[page - 1] ?? []).map((login) => ({
login,
})),
{ headers },
);
}
if (path === "/user") {
Expand Down Expand Up @@ -349,6 +369,60 @@ describe("bulk scan repository discovery", () => {
).toBe("personal-account");
});

test("paginates and deduplicates organizations before account selection", async () => {
const root = await temporaryDirectory();
const firstPage = Array.from(
{ length: 100 },
(_, index) => `organization-${String(index).padStart(3, "0")}`,
);
const { dependencies, prompt, requests } = discoveryDependencies(root, {
organizationPages: [firstPage, ["second-page", "ORGANIZATION-000"]],
});
prompt.confirms = [true];
prompt.choices = ["second-page"];

await runBulkScanWizard(dependencies);

expect(
requests
.filter(({ path }) => path === "/user/orgs")
.map(({ page }) => page),
).toEqual([1, 2]);
expect(prompt.searchOptions[0]).toContain("personal-account");
expect(prompt.searchOptions[0]).toContain("second-page");
expect(
prompt.searchOptions[0]!.filter(
(owner) => owner.toLowerCase() === "organization-000",
),
).toHaveLength(1);
expect(
requests.find(({ path }) => path === "/graphql")?.variables?.owner,
).toBe("second-page");
});

test("stops organization pagination when canceled", async () => {
const root = await temporaryDirectory();
const controller = new AbortController();
const { dependencies, prompt, requests } = discoveryDependencies(root, {
organizationPages: [["first-page"], ["second-page"]],
onOrganizationPage: (page) => {
if (page === 1) controller.abort(new Error("stop pagination"));
},
});

await expect(
runBulkScanWizard(dependencies, controller.signal),
).rejects.toThrow("stop pagination");

expect(
requests
.filter(({ path }) => path === "/user/orgs")
.map(({ page }) => page),
).toEqual([1]);
expect(requests.some(({ path }) => path === "/graphql")).toBe(false);
expect(prompt.questions).toEqual([]);
});

test("does not write an inventory when canceled or no repositories match", async () => {
for (const options of [
{ confirms: [false] },
Expand Down