From 9aeaca8f4b1efb2dc7bc582448fca97e8ebbc466 Mon Sep 17 00:00:00 2001 From: "Joakim L. Engeset" Date: Wed, 28 Jan 2026 15:01:21 +0100 Subject: [PATCH 1/9] feat: remove all non-github repo related code --- src/cli/commands/definition.ts | 21 - src/cli/commands/definition/dump-setup.ts | 425 --------------- src/cli/commands/definition/util.test.ts | 44 -- src/cli/commands/definition/util.ts | 53 -- src/cli/commands/definition/validate.ts | 23 - src/cli/commands/delete-cache.ts | 16 - src/cli/commands/getting-started.ts | 15 - src/cli/commands/github.ts | 14 +- src/cli/commands/github/analyze-directory.ts | 91 ---- src/cli/commands/github/configure.ts | 176 ------ .../github/list-pull-requests-stats.ts | 104 ---- src/cli/commands/github/list-webhooks.ts | 100 ---- src/cli/commands/github/util.ts | 18 - src/cli/commands/snyk.ts | 29 - src/cli/commands/snyk/report.ts | 160 ------ src/cli/commands/snyk/set-token.ts | 46 -- src/cli/commands/snyk/sync.ts | 77 --- src/cli/index.ts | 8 - src/github/changeset/changeset.ts | 369 ------------- src/github/changeset/execute.ts | 196 ------- src/github/changeset/types.ts | 116 ---- src/index.ts | 2 - src/snyk/index.ts | 3 - src/snyk/service.ts | 136 ----- src/snyk/token.ts | 39 -- src/snyk/types.ts | 71 --- src/snyk/util.test.ts | 75 --- src/snyk/util.ts | 42 -- src/testing/executor.ts | 102 ---- src/testing/index.ts | 12 - src/testing/lib.ts | 506 ------------------ 31 files changed, 1 insertion(+), 3088 deletions(-) delete mode 100644 src/cli/commands/definition.ts delete mode 100644 src/cli/commands/definition/dump-setup.ts delete mode 100644 src/cli/commands/definition/util.test.ts delete mode 100644 src/cli/commands/definition/util.ts delete mode 100644 src/cli/commands/definition/validate.ts delete mode 100644 src/cli/commands/delete-cache.ts delete mode 100644 src/cli/commands/getting-started.ts delete mode 100644 src/cli/commands/github/analyze-directory.ts delete mode 100644 src/cli/commands/github/configure.ts delete mode 100644 src/cli/commands/github/list-pull-requests-stats.ts delete mode 100644 src/cli/commands/github/list-webhooks.ts delete mode 100644 src/cli/commands/github/util.ts delete mode 100644 src/cli/commands/snyk.ts delete mode 100644 src/cli/commands/snyk/report.ts delete mode 100644 src/cli/commands/snyk/set-token.ts delete mode 100644 src/cli/commands/snyk/sync.ts delete mode 100644 src/github/changeset/changeset.ts delete mode 100644 src/github/changeset/execute.ts delete mode 100644 src/github/changeset/types.ts delete mode 100644 src/snyk/index.ts delete mode 100644 src/snyk/service.ts delete mode 100644 src/snyk/token.ts delete mode 100644 src/snyk/types.ts delete mode 100644 src/snyk/util.test.ts delete mode 100644 src/snyk/util.ts delete mode 100644 src/testing/executor.ts delete mode 100644 src/testing/index.ts delete mode 100644 src/testing/lib.ts diff --git a/src/cli/commands/definition.ts b/src/cli/commands/definition.ts deleted file mode 100644 index 127d41f9..00000000 --- a/src/cli/commands/definition.ts +++ /dev/null @@ -1,21 +0,0 @@ -import process from "node:process" -import yargs, { type CommandModule } from "yargs" -import { hideBin } from "yargs/helpers" -import dumpSetup from "./definition/dump-setup" -import validate from "./definition/validate" - -const command: CommandModule = { - command: "definition", - describe: "CALS definition file management", - builder: (yargs) => - yargs - .command(dumpSetup) - .command(validate) - .demandCommand() - .usage("cals definition"), - handler: () => { - yargs(hideBin(process.argv)).showHelp() - }, -} - -export default command diff --git a/src/cli/commands/definition/dump-setup.ts b/src/cli/commands/definition/dump-setup.ts deleted file mode 100644 index c0ffbaa0..00000000 --- a/src/cli/commands/definition/dump-setup.ts +++ /dev/null @@ -1,425 +0,0 @@ -import fs from "node:fs" -import yaml from "js-yaml" -import pMap from "p-map" -import type { CommandModule } from "yargs" -import type { Config } from "../../../config" -import { - type DefinitionFile, - getGitHubOrgs, - getRepoId, - getRepos, -} from "../../../definition" -import type { - Definition, - DefinitionRepo, - GetReposResponse, - Project, - RepoTeam, - Team, - User, -} from "../../../definition/types" -import { createGitHubService, type GitHubService } from "../../../github" -import type { - OrgsGetResponse, - Permission, - Repo, - ReposGetResponse, - ReposListTeamsResponseItem, - TeamMemberOrInvited, - TeamsListResponseItem, -} from "../../../github/types" -import { - createSnykService, - getGitHubRepo, - type SnykGitHubRepo, - type SnykService, -} from "../../../snyk" -import type { Reporter } from "../../reporter" -import { - createCacheProvider, - createConfig, - createReporter, - definitionFileOptionName, - definitionFileOptionValue, - getDefinitionFile, -} from "../../util" -import { reportRateLimit } from "../github/util" -import { reorderListToSimilarAsBefore } from "./util" - -interface DetailedProject { - name: string - repos: { - [org: string]: { - basic: Repo - repository: ReposGetResponse - teams: ReposListTeamsResponseItem[] - }[] - } -} - -async function getReposFromGitHub( - github: GitHubService, - orgs: OrgsGetResponse[], -): Promise { - return ( - await pMap(orgs, async (org) => { - const repos = await github.getOrgRepoList({ org: org.login }) - return pMap(repos, async (repo) => { - const detailedRepo = await github.getRepository( - repo.owner.login, - repo.name, - ) - if (detailedRepo === undefined) { - throw Error(`Repo not found: ${repo.owner.login}/${repo.name}`) - } - - return { - basic: repo, - repository: detailedRepo, - teams: await github.getRepositoryTeamsList(detailedRepo), - } - }) - }) - ).flat() -} - -async function getTeams(github: GitHubService, orgs: OrgsGetResponse[]) { - const intermediate = await pMap(orgs, async (org) => { - const teams = await github.getTeamList(org) - return { - org, - teams: await pMap(teams, async (team) => ({ - team, - users: await github.getTeamMemberListIncludingInvited(org, team), - })), - } - }) - - // Transform output. - return intermediate.reduce<{ - [org: string]: { - team: TeamsListResponseItem - users: TeamMemberOrInvited[] - }[] - }>((prev, cur) => { - prev[cur.org.login] = cur.teams - return prev - }, {}) -} - -function getCommonTeams(ownerRepos: DetailedProject["repos"][0]) { - return ownerRepos.length === 0 - ? [] - : ownerRepos[0].teams.filter((team) => - ownerRepos.every((repo) => - repo.teams.some( - (otherTeam) => - otherTeam.name === team.name && - otherTeam.permission === team.permission, - ), - ), - ) -} - -function getSpecificTeams( - teams: ReposListTeamsResponseItem[], - commonTeams: ReposListTeamsResponseItem[], -) { - return teams.filter( - (team) => - !commonTeams.some( - (it) => it.name === team.name && it.permission === team.permission, - ), - ) -} - -function getFormattedTeams( - oldTeams: RepoTeam[], - teams: ReposListTeamsResponseItem[], -) { - const result = - teams.length === 0 - ? undefined - : teams.map((it) => ({ - name: it.name, - permission: it.permission as Permission, - })) - - return result - ? reorderListToSimilarAsBefore(oldTeams ?? [], result, (it) => it.name) - : undefined -} - -async function getOrgs(github: GitHubService, orgs: string[]) { - return pMap(orgs, (it) => github.getOrg(it)) -} - -function removeDuplicates(items: T[], selector: (item: T) => R): T[] { - const ids: R[] = [] - const result: T[] = [] - for (const item of items) { - const id = selector(item) - if (!ids.includes(id)) { - result.push(item) - ids.push(id) - } - } - return result -} - -async function getMembers(github: GitHubService, orgs: OrgsGetResponse[]) { - return removeDuplicates( - ( - await pMap(orgs, (org) => - github.getOrgMembersListIncludingInvited(org.login), - ) - ) - .flat() - .map((it) => it.login), - (it) => it, - ) -} - -async function getSnykRepos(snyk: SnykService, definition: Definition) { - return (await snyk.getProjects(definition)) - .map((it) => getGitHubRepo(it)) - .filter((it): it is SnykGitHubRepo => it !== undefined) - .map((it) => getRepoId(it.owner, it.name)) -} - -async function getProjects( - github: GitHubService, - orgs: OrgsGetResponse[], - definition: Definition, - snyk: SnykService, -) { - const snykReposPromise = getSnykRepos(snyk, definition) - - const repos = await getReposFromGitHub(github, orgs) - const snykRepos = await snykReposPromise - - const definitionRepos = Object.fromEntries( - getRepos(definition).map((repo: GetReposResponse) => [repo.id, repo]), - ) - - const projectGroups = Object.values( - repos.reduce<{ - [project: string]: { - name: string - definition?: Project - repos: { - [owner: string]: typeof repos - } - } - }>((acc, cur) => { - const org = cur.repository.owner.login - const repoId = getRepoId(org, cur.repository.name) - - const projectName = definitionRepos[repoId]?.project?.name ?? "Unknown" - const project = acc[projectName] || { - name: projectName, - definition: definitionRepos[repoId]?.project, - repos: [], - } - - return { - ...acc, - [projectName]: { - ...project, - repos: { - ...project.repos, - [org]: [...(project.repos[org] || []), cur], - }, - }, - } - }, {}), - ) - - const projects = projectGroups.map((project) => { - const github = Object.entries(project.repos).map(([org, list]) => { - const commonTeams = getCommonTeams(list) - const oldOrg = project.definition?.github?.find( - (it) => it.organization == org, - ) - - const repos = list.map((repo) => { - const repoId = getRepoId(repo.basic.owner.login, repo.basic.name) - const definitionRepo: GetReposResponse = definitionRepos[repoId] - - const result: DefinitionRepo = { - name: repo.basic.name, - previousNames: definitionRepo?.repo.previousNames, - archived: repo.repository.archived ? true : undefined, - issues: repo.repository.has_issues ? undefined : false, - wiki: repo.repository.has_wiki ? undefined : false, - teams: getFormattedTeams( - definitionRepo?.repo?.teams ?? [], - getSpecificTeams(repo.teams, commonTeams), - ), - snyk: snykRepos.includes(repoId) ? true : undefined, - public: repo.repository.private ? undefined : true, - responsible: definitionRepo?.repo.responsible, - } - - // Try to preserve property order. - return Object.fromEntries( - reorderListToSimilarAsBefore( - definitionRepo ? Object.entries(definitionRepo.repo) : [], - Object.entries(result), - (it) => it[0], - true, - ), - ) as DefinitionRepo - }) - - const teams = getFormattedTeams(oldOrg?.teams ?? [], commonTeams) - - return { - organization: org, - teams: teams, - repos: reorderListToSimilarAsBefore( - oldOrg?.repos ?? [], - repos, - (it) => it.name, - ), - } - }) - - return { - name: project.name, - github: reorderListToSimilarAsBefore( - project.definition?.github ?? [], - github, - (it) => it.organization, - ), - } - }) - - return reorderListToSimilarAsBefore( - definition.projects, - projects, - (it) => it.name, - ) -} - -function buildGitHubTeamsList( - definition: Definition, - list: Record< - string, - { - team: TeamsListResponseItem - users: TeamMemberOrInvited[] - }[] - >, -) { - const result = Object.entries(list).map(([org, teams]) => ({ - organization: org, - teams: teams.map((team) => ({ - name: team.team.name, - members: team.users - .map((it) => it.login) - .sort((a, b) => a.localeCompare(b)), - })), - })) - - return reorderListToSimilarAsBefore( - definition.github.teams, - result, - (it) => it.organization, - ) -} - -function buildGitHubUsersList( - definition: Definition, - members: string[], -): User[] { - const result = members.map( - (memberLogin) => - definition.github.users.find((user) => user.login === memberLogin) || { - type: "external", - login: memberLogin, - // TODO: Fetch name from GitHub? - name: "*Unknown*", - }, - ) - - return reorderListToSimilarAsBefore( - definition.github.users, - result, - (it) => it.login, - ) -} - -async function dumpSetup( - _config: Config, - reporter: Reporter, - github: GitHubService, - snyk: SnykService, - outfile: string, - definitionFile: DefinitionFile, -) { - reporter.info("Fetching data. This might take some time") - const definition = await definitionFile.getDefinition() - const orgs = await getOrgs(github, getGitHubOrgs(definition)) - - const teams = getTeams(github, orgs) - const members = getMembers(github, orgs) - const projects = getProjects(github, orgs, definition, snyk) - - const generatedDefinition: Definition = { - snyk: definition.snyk, - github: { - users: buildGitHubUsersList(definition, await members), - teams: buildGitHubTeamsList(definition, await teams), - }, - projects: await projects, - } - - // TODO: An earlier version we had preserved comments by using yawn-yaml - // package. However it often produced invalid yaml, so we have removed - // it. We might want to revisit it to preserve comments. - - const doc = yaml.load(await definitionFile.getContents()) as any - doc.snyk = generatedDefinition.snyk - doc.projects = generatedDefinition.projects - doc.github = generatedDefinition.github - - // Convert to/from plain JSON so that undefined elements are removed. - fs.writeFileSync(outfile, yaml.dump(JSON.parse(JSON.stringify(doc)))) - reporter.info(`Saved to ${outfile}`) - reporter.info(`Number of GitHub requests: ${github.requestCount}`) -} - -const command: CommandModule = { - command: "dump-setup", - describe: - "Dump active setup as YAML. Will be formated same as the definition file.", - builder: (yargs) => - yargs - .positional("outfile", { - type: "string", - }) - .option(definitionFileOptionName, definitionFileOptionValue) - .demandOption("outfile"), - handler: async (argv) => { - const reporter = createReporter(argv) - const config = createConfig() - const github = await createGitHubService({ - config, - cache: createCacheProvider(config, argv), - }) - const snyk = createSnykService({ config }) - await reportRateLimit(reporter, github, () => - dumpSetup( - config, - reporter, - github, - snyk, - argv.outfile as string, - getDefinitionFile(argv), - ), - ) - }, -} - -export default command diff --git a/src/cli/commands/definition/util.test.ts b/src/cli/commands/definition/util.test.ts deleted file mode 100644 index 652dd5e2..00000000 --- a/src/cli/commands/definition/util.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, expect, it } from "vitest" -import { reorderListToSimilarAsBefore } from "./util" - -describe("function reorderListToSimilarAsBefore", () => { - describe("having the same list", () => { - it("should give expected order", () => { - const old = ["a", "c", "b", "e", "d"] - const updated = ["a", "b", "c", "d", "e"] - - const res = reorderListToSimilarAsBefore(old, updated, (it) => it) - expect(res).toStrictEqual(old) - }) - }) - - describe("having extra items in updated list", () => { - it("should give expected order", () => { - const old = ["a", "c", "b", "e"] - const updated = ["a", "b", "c", "d", "e"] - - const res = reorderListToSimilarAsBefore(old, updated, (it) => it) - expect(res).toStrictEqual(["a", "c", "b", "d", "e"]) - }) - }) - - describe("different list", () => { - it("should give expected order", () => { - const old = ["a", "c", "b", "d"] - const updated = ["g", "h", "i", "x"] - - const res = reorderListToSimilarAsBefore(old, updated, (it) => it) - expect(res).toStrictEqual(["g", "h", "i", "x"]) - }) - }) - - describe("with duplicate keys", () => { - it("should give expected order", () => { - const old = ["a", "c", "b", "b"] - const updated = ["a", "c", "b", "b"] - - const res = reorderListToSimilarAsBefore(old, updated, (it) => it) - expect(res).toStrictEqual(["a", "c", "b", "b"]) - }) - }) -}) diff --git a/src/cli/commands/definition/util.ts b/src/cli/commands/definition/util.ts deleted file mode 100644 index 0882228c..00000000 --- a/src/cli/commands/definition/util.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * Reorder a list to preserve same order as it had previously. - * - * Not a very pretty algorithm but it works for us. - */ -export function reorderListToSimilarAsBefore( - oldList: T[], - updatedList: T[], - selector: (item: T) => string, - insertLast = false, -): T[] { - let result: T[] = [] - - // Keep items present in old list. - let remaining1 = [...updatedList] - for (const old of oldList) { - let found = false - for (const it of remaining1) { - if (selector(it) === selector(old)) { - found = true - result.push(it) - } - } - if (found) { - remaining1 = remaining1.filter((it) => selector(old) !== selector(it)) - } - } - - const remaining = updatedList.filter( - (updated) => !result.some((it) => selector(it) == selector(updated)), - ) - - if (insertLast) { - result.push(...remaining) - } else { - // Insert remaining at first position by ordering. - for (const it of remaining) { - let found = false - for (let i = 0; i < result.length; i++) { - if (selector(result[i]).localeCompare(selector(it)) > 0) { - found = true - result = [...result.slice(0, i), it, ...result.slice(i)] - break - } - } - if (!found) { - result.push(it) - } - } - } - - return result -} diff --git a/src/cli/commands/definition/validate.ts b/src/cli/commands/definition/validate.ts deleted file mode 100644 index 14240202..00000000 --- a/src/cli/commands/definition/validate.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { CommandModule } from "yargs" -import { - createReporter, - definitionFileOptionName, - definitionFileOptionValue, - getDefinitionFile, -} from "../../util" - -const command: CommandModule = { - command: "validate", - describe: "Validate definition file.", - builder: (yargs) => - yargs.option(definitionFileOptionName, definitionFileOptionValue), - handler: async (argv) => { - const reporter = createReporter(argv) - - await getDefinitionFile(argv).getDefinition() - - reporter.info("Valid!") - }, -} - -export default command diff --git a/src/cli/commands/delete-cache.ts b/src/cli/commands/delete-cache.ts deleted file mode 100644 index 483a0f2e..00000000 --- a/src/cli/commands/delete-cache.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { CommandModule } from "yargs" -import { createCacheProvider, createConfig, createReporter } from "../util" - -const command: CommandModule = { - command: "delete-cache", - describe: "Delete cached data", - handler: (argv) => { - const config = createConfig() - const cache = createCacheProvider(config, argv) - const reporter = createReporter(argv) - cache.cleanup() - reporter.info("Cache deleted") - }, -} - -export default command diff --git a/src/cli/commands/getting-started.ts b/src/cli/commands/getting-started.ts deleted file mode 100644 index 923839ff..00000000 --- a/src/cli/commands/getting-started.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { CommandModule } from "yargs" -import { createReporter } from "../util" - -const command: CommandModule = { - command: "getting-started", - describe: "Getting started", - handler: (argv) => { - const reporter = createReporter(argv) - reporter.log( - "For getting started, see https://liflig.atlassian.net/wiki/x/E8MNAQ", - ) - }, -} - -export default command diff --git a/src/cli/commands/github.ts b/src/cli/commands/github.ts index b5210e2e..525196d9 100644 --- a/src/cli/commands/github.ts +++ b/src/cli/commands/github.ts @@ -1,12 +1,8 @@ import process from "node:process" import yargs, { type CommandModule } from "yargs" import { hideBin } from "yargs/helpers" -import analyzeDirectory from "./github/analyze-directory" -import configure from "./github/configure" import generateCloneCommands from "./github/generate-clone-commands" -import listPullRequestsStats from "./github/list-pull-requests-stats" import listRepos from "./github/list-repos" -import listWebhooks from "./github/list-webhooks" import setToken from "./github/set-token" import sync from "./github/sync" @@ -15,12 +11,8 @@ const command: CommandModule = { describe: "Integration with GitHub", builder: (yargs) => yargs - .command(analyzeDirectory) - .command(configure) .command(generateCloneCommands) - .command(listPullRequestsStats) .command(listRepos) - .command(listWebhooks) .command(setToken) .command(sync) .demandCommand() @@ -38,12 +30,8 @@ Notes: And for a specific project: $ cals github generate-clone-commands --org capralifecycle -x buildtools | bash - Keeping up to date with removed/renamed repos: - $ cals github analyze-directory --org capralifecycle - Some responses are cached for some time. Use the --validate-cache - option to avoid stale cache. The cache can also be cleared with - the "cals delete-cache" command.`), + option to avoid stale cache.`), handler: () => { yargs(hideBin(process.argv)).showHelp() }, diff --git a/src/cli/commands/github/analyze-directory.ts b/src/cli/commands/github/analyze-directory.ts deleted file mode 100644 index 2498a6df..00000000 --- a/src/cli/commands/github/analyze-directory.ts +++ /dev/null @@ -1,91 +0,0 @@ -import fs from "node:fs" -import path from "node:path" -import { sprintf } from "sprintf-js" -import type { CommandModule } from "yargs" -import type { Config } from "../../../config" -import { createGitHubService, type GitHubService } from "../../../github" -import type { Repo } from "../../../github/types" -import type { Reporter } from "../../reporter" -import { createCacheProvider, createConfig, createReporter } from "../../util" - -async function analyzeDirectory( - reporter: Reporter, - config: Config, - github: GitHubService, - org: string, -) { - const repos = await github.getOrgRepoList({ org }) - - const reposDict = repos.reduce<{ - [key: string]: Repo - }>((acc, cur) => ({ ...acc, [cur.name]: cur }), {}) - - const dirs = fs - .readdirSync(config.cwd) - .filter((it) => fs.statSync(path.join(config.cwd, it)).isDirectory()) - // Skip hidden folders - .filter((it) => !it.startsWith(".")) - .sort((a, b) => a.localeCompare(b)) - - const stats = { - unknown: 0, - archived: 0, - ok: 0, - } - - dirs.forEach((it) => { - if (!(it in reposDict)) { - reporter.warn( - sprintf( - "%-30s <-- Not found in repository list (maybe changed name?)", - it, - ), - ) - stats.unknown++ - return - } - - if (reposDict[it].isArchived) { - reporter.info(sprintf("%-30s <-- Archived", it)) - stats.archived++ - return - } - - stats.ok += 1 - }) - - reporter.info( - sprintf( - "Stats: unknown=%d archived=%d ok=%d", - stats.unknown, - stats.archived, - stats.ok, - ), - ) - - reporter.info( - "Use `cals github generate-clone-commands` to check for repositories not checked out", - ) -} - -const command: CommandModule = { - command: "analyze-directory", - describe: "Analyze directory for git repos", - builder: (yargs) => - yargs.options("org", { - required: true, - describe: "Specify GitHub organization", - type: "string", - }), - handler: async (argv) => { - const config = createConfig() - const github = await createGitHubService({ - config, - cache: createCacheProvider(config, argv), - }) - const reporter = createReporter(argv) - return analyzeDirectory(reporter, config, github, argv.org as string) - }, -} - -export default command diff --git a/src/cli/commands/github/configure.ts b/src/cli/commands/github/configure.ts deleted file mode 100644 index e5f63620..00000000 --- a/src/cli/commands/github/configure.ts +++ /dev/null @@ -1,176 +0,0 @@ -import pLimit from "p-limit" -import { read } from "read" -import type { CommandModule } from "yargs" -import { type Definition, getGitHubOrgs } from "../../../definition" -import { - cleanupChangeSetItems, - createChangeSetItemsForMembers, - createChangeSetItemsForProjects, - createChangeSetItemsForTeams, -} from "../../../github/changeset/changeset" -import { - executeChangeSet, - isNotImplementedChangeSetItem, -} from "../../../github/changeset/execute" -import type { ChangeSetItem } from "../../../github/changeset/types" -import { - createGitHubService, - type GitHubService, -} from "../../../github/service" -import type { - OrgsGetResponse, - TeamsListResponseItem, -} from "../../../github/types" -import type { Reporter } from "../../reporter" -import { - createCacheProvider, - createConfig, - createReporter, - definitionFileOptionName, - definitionFileOptionValue, - getDefinitionFile, -} from "../../util" -import { reportRateLimit } from "./util" - -function createOrgGetter(github: GitHubService) { - const orgs: { - [name: string]: { - org: OrgsGetResponse - teams: TeamsListResponseItem[] - } - } = {} - - // Use a semaphore for each orgName to restrict multiple - // concurrent requests of the same org. - const semaphores: Record> = {} - - function getSemaphore(orgName: string) { - if (!(orgName in semaphores)) { - semaphores[orgName] = pLimit(1) - } - return semaphores[orgName] - } - - return async (orgName: string) => - await getSemaphore(orgName)(async () => { - if (!(orgName in orgs)) { - const org = await github.getOrg(orgName) - orgs[orgName] = { - org, - teams: await github.getTeamList(org), - } - } - return orgs[orgName] - }) -} - -async function process( - reporter: Reporter, - github: GitHubService, - definition: Definition, - getOrg: ReturnType, - execute: boolean, - limitToOrg: string | undefined, -) { - let changes: ChangeSetItem[] = [] - - for (const orgName of getGitHubOrgs(definition)) { - if (limitToOrg !== undefined && limitToOrg !== orgName) { - continue - } - - const org = (await getOrg(orgName)).org - - changes = [ - ...changes, - ...(await createChangeSetItemsForMembers(github, definition, org)), - ] - - changes = [ - ...changes, - ...(await createChangeSetItemsForTeams(github, definition, org)), - ] - } - - changes = [ - ...changes, - ...(await createChangeSetItemsForProjects(github, definition, limitToOrg)), - ] - - changes = cleanupChangeSetItems(changes) - - const ignored: ChangeSetItem[] = changes.filter(isNotImplementedChangeSetItem) - changes = changes.filter((it) => !ignored.includes(it)) - - if (ignored.length > 0) { - reporter.info("Not implemented:") - for (const change of ignored) { - reporter.info(` - ${JSON.stringify(change)}`) - } - } - - if (changes.length === 0) { - reporter.info("No actions to be performed") - } else { - reporter.info("To be performed:") - for (const change of changes) { - reporter.info(` - ${JSON.stringify(change)}`) - } - } - - if (execute && changes.length > 0) { - const answer: string = await read({ - prompt: "Confirm you want to execute the changes [y/N]: ", - timeout: 60000, - }) - - if (answer.toLowerCase() === "y") { - reporter.info("Executing changes") - await executeChangeSet(github, changes, reporter) - } else { - reporter.info("Skipping") - } - } - - reporter.info(`Number of GitHub requests: ${github.requestCount}`) -} - -const command: CommandModule = { - command: "configure", - describe: "Configure CALS GitHub resources", - builder: (yargs) => - yargs - .options("execute", { - describe: "Execute the detected changes", - type: "boolean", - }) - .options("org", { - describe: "Filter resources by GitHub organization", - type: "string", - }) - .option(definitionFileOptionName, definitionFileOptionValue), - handler: async (argv) => { - const reporter = createReporter(argv) - const config = createConfig() - const github = await createGitHubService({ - config, - cache: createCacheProvider(config, argv), - }) - const definition = await getDefinitionFile(argv).getDefinition() - - await reportRateLimit(reporter, github, async () => { - const orgGetter = createOrgGetter(github) - - await process( - reporter, - github, - definition, - orgGetter, - !!argv.execute, - argv.org as string | undefined, - ) - }) - }, -} - -export default command diff --git a/src/cli/commands/github/list-pull-requests-stats.ts b/src/cli/commands/github/list-pull-requests-stats.ts deleted file mode 100644 index f49c04e2..00000000 --- a/src/cli/commands/github/list-pull-requests-stats.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { sprintf } from "sprintf-js" -import type { CommandModule } from "yargs" -import { createGitHubService, type GitHubService } from "../../../github" -import type { Reporter } from "../../reporter" -import { createCacheProvider, createConfig, createReporter } from "../../util" - -async function listPullRequestsStats({ - reporter, - github, -}: { - reporter: Reporter - github: GitHubService -}) { - // This is only an initial attempt to get some insights into - // open pull requests. Feel free to change. - - const pulls = await github.getSearchedPullRequestList("capralifecycle") - - interface Category { - key: string - old: typeof pulls - oldSnyk: typeof pulls - recent: typeof pulls - recentSnyk: typeof pulls - } - - const cutoffOld = new Date(Date.now() - 86400 * 1000 * 60) - const categories = pulls - .reduce((acc, cur) => { - const key = `${cur.baseRepository.owner.login}/${cur.baseRepository.name}` - const old = new Date(cur.createdAt) < cutoffOld - const snyk = cur.title.includes("[Snyk]") - - // Cheat by mutating. - let t = acc.find((it) => it.key === key) - if (t === undefined) { - t = { - key, - old: [], - oldSnyk: [], - recent: [], - recentSnyk: [], - } - acc.push(t) - } - - t[snyk ? (old ? "oldSnyk" : "recentSnyk") : old ? "old" : "recent"].push( - cur, - ) - return acc - }, []) - .sort((a, b) => a.key.localeCompare(b.key)) - - if (categories.length === 0) { - reporter.log("No pull requests found") - } else { - reporter.log("Pull requests stats:") - reporter.log("A pull request is considered old after 60 days") - reporter.log("") - reporter.log( - sprintf("%-40s %12s %2s %12s %2s", "", "normal", "", "snyk", ""), - ) - reporter.log( - sprintf( - "%-40s %7s %7s %7s %7s", - "Repo", - "old", - "recent", - "old", - "recent", - ), - ) - - categories.forEach((cat) => { - reporter.log( - sprintf( - "%-40s %7s %7s %7s %7s", - cat.key, - cat.old.length === 0 ? "" : cat.old.length, - cat.recent.length === 0 ? "" : cat.recent.length, - cat.oldSnyk.length === 0 ? "" : cat.oldSnyk.length, - cat.recentSnyk.length === 0 ? "" : cat.recentSnyk.length, - ), - ) - }) - } -} - -const command: CommandModule = { - command: "list-pull-requests-stats", - describe: "List stats for pull requests with special filter", - handler: async (argv) => { - const config = createConfig() - await listPullRequestsStats({ - reporter: createReporter(argv), - github: await createGitHubService({ - config, - cache: createCacheProvider(config, argv), - }), - }) - }, -} - -export default command diff --git a/src/cli/commands/github/list-webhooks.ts b/src/cli/commands/github/list-webhooks.ts deleted file mode 100644 index 704ba277..00000000 --- a/src/cli/commands/github/list-webhooks.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { sprintf } from "sprintf-js" -import type { CommandModule } from "yargs" -import type { CacheProvider } from "../../../cache" -import { createGitHubService, type GitHubService } from "../../../github" -import type { Reporter } from "../../reporter" -import { createCacheProvider, createConfig, createReporter } from "../../util" - -const e = encodeURIComponent - -async function listWebhooks( - reporter: Reporter, - _cache: CacheProvider, - github: GitHubService, - org: string, -) { - const repos = (await github.getOrgRepoList({ org })).filter( - (it) => !it.isArchived, - ) - - for (const repo of repos) { - reporter.log("") - reporter.log( - `${repo.name}: https://github.com/capralifecycle/${e( - repo.name, - )}/settings/hooks`, - ) - - const hooks = await github.getRepositoryHooks(repo.owner.login, repo.name) - for (const hook of hooks) { - if ( - hook.config.url === undefined || - !hook.config.url.includes("jenkins") - ) { - continue - } - - switch (hook.name) { - case "web": - reporter.log( - sprintf( - " web: %s (%s) (%s)", - hook.config.url, - hook.last_response.code, - hook.events.join(", "), - ), - ) - break - - case "jenkinsgit": - reporter.log( - sprintf( - " jenkinsgit: %s (%s) (%s)", - // This is undocumented. - (hook.config as Record).jenkins_url, - hook.last_response.code, - hook.events.join(", "), - ), - ) - break - - case "docker": - reporter.log( - sprintf( - " docker (%s) (%s)", - hook.last_response.code, - hook.events.join(", "), - ), - ) - break - - default: - reporter.log(` ${hook.name}: `) - reporter.log(JSON.stringify(hook)) - } - } - } -} - -const command: CommandModule = { - command: "list-webhooks", - describe: "List webhooks for repositories in for a GitHub organization", - builder: (yargs) => - yargs.options("org", { - required: true, - describe: "Specify GitHub organization", - type: "string", - }), - handler: async (argv) => { - const config = createConfig() - const cacheProvider = createCacheProvider(config, argv) - await listWebhooks( - createReporter(argv), - cacheProvider, - await createGitHubService({ config, cache: cacheProvider }), - argv.org as string, - ) - }, -} - -export default command diff --git a/src/cli/commands/github/util.ts b/src/cli/commands/github/util.ts deleted file mode 100644 index 3f10d9ec..00000000 --- a/src/cli/commands/github/util.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { GitHubService } from "../../../github" -import type { Reporter } from "../../reporter" - -export async function reportRateLimit( - reporter: Reporter, - github: GitHubService, - block: () => Promise, -): Promise { - reporter.info( - `Rate limit: ${(await github.octokit.rateLimit.get()).data.rate.remaining}`, - ) - - await block() - - reporter.info( - `Rate limit: ${(await github.octokit.rateLimit.get()).data.rate.remaining}`, - ) -} diff --git a/src/cli/commands/snyk.ts b/src/cli/commands/snyk.ts deleted file mode 100644 index 6dbc582b..00000000 --- a/src/cli/commands/snyk.ts +++ /dev/null @@ -1,29 +0,0 @@ -import process from "node:process" -import yargs, { type CommandModule } from "yargs" -import { hideBin } from "yargs/helpers" -import report from "./snyk/report" -import setToken from "./snyk/set-token" -import sync from "./snyk/sync" - -const command: CommandModule = { - command: "snyk", - describe: "Integration with Snyk", - builder: (yargs) => - yargs - .command(setToken) - .command(report) - .command(sync) - .demandCommand() - .usage(`cals snyk - -Notes: - Before doing anything against Snyk you need to configure a token - used for authentication. The following command will ask for a token - and provide a link to generate one: - $ cals snyk set-token`), - handler: () => { - yargs(hideBin(process.argv)).showHelp() - }, -} - -export default command diff --git a/src/cli/commands/snyk/report.ts b/src/cli/commands/snyk/report.ts deleted file mode 100644 index 7afd9843..00000000 --- a/src/cli/commands/snyk/report.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { sprintf } from "sprintf-js" -import type { CommandModule } from "yargs" -import { groupBy, sortBy, sumBy } from "../../../collections/collections" -import { - type DefinitionFile, - getRepos, - type Project, -} from "../../../definition" -import { - createSnykService, - getGitHubRepo, - getGitHubRepoId, - type SnykProject, - type SnykService, -} from "../../../snyk" -import type { Reporter } from "../../reporter" -import { - createConfig, - createReporter, - definitionFileOptionName, - definitionFileOptionValue, - getDefinitionFile, -} from "../../util" - -function totalSeverityCount(project: SnykProject) { - return ( - (project.issueCountsBySeverity.critical ?? 0) + - project.issueCountsBySeverity.high + - project.issueCountsBySeverity.medium + - project.issueCountsBySeverity.low - ) -} - -function buildStatsLine(stats: SnykProject["issueCountsBySeverity"]) { - function item(num: number, str: string) { - return num === 0 ? " ".repeat(str.length + 4) : sprintf("%3d %s", num, str) - } - - return sprintf( - "%s %s %s %s", - item(stats.critical ?? 0, "critical"), - item(stats.high, "high"), - item(stats.medium, "medium"), - item(stats.low, "low"), - ) -} - -async function report({ - reporter, - snyk, - definitionFile, -}: { - reporter: Reporter - snyk: SnykService - definitionFile: DefinitionFile -}) { - const definition = await definitionFile.getDefinition() - - const reposWithIssues = (await snyk.getProjects(definition)).filter( - (it) => totalSeverityCount(it) > 0, - ) - - const definitionRepos = getRepos(definition) - - function getProject(p: SnykProject) { - const id = getGitHubRepoId(getGitHubRepo(p)) - const def = - id === undefined ? undefined : definitionRepos.find((it) => it.id === id) - return def === undefined ? undefined : def.project - } - - const enhancedRepos = reposWithIssues.map((repo) => ({ - repo, - project: getProject(repo), - })) - - function getProjectName(project: Project | undefined) { - return project ? project.name : "unknown project" - } - - const byProjects = sortBy( - Object.values( - groupBy(enhancedRepos, (it) => - it.project ? it.project.name : "unknown", - ), - ), - (it) => getProjectName(it[0].project), - ) - - if (byProjects.length === 0) { - reporter.info("No issues found") - } else { - reporter.info( - sprintf( - "%-70s %s", - "Total count", - buildStatsLine({ - critical: sumBy( - reposWithIssues, - (it) => it.issueCountsBySeverity.critical ?? 0, - ), - high: sumBy(reposWithIssues, (it) => it.issueCountsBySeverity.high), - medium: sumBy( - reposWithIssues, - (it) => it.issueCountsBySeverity.medium, - ), - low: sumBy(reposWithIssues, (it) => it.issueCountsBySeverity.low), - }), - ), - ) - - reporter.info("Issues by project:") - byProjects.forEach((repos) => { - const project = repos[0].project - const totalCount = { - critical: sumBy( - repos, - (it) => it.repo.issueCountsBySeverity.critical ?? 0, - ), - high: sumBy(repos, (it) => it.repo.issueCountsBySeverity.high), - medium: sumBy(repos, (it) => it.repo.issueCountsBySeverity.medium), - low: sumBy(repos, (it) => it.repo.issueCountsBySeverity.low), - } - - reporter.info("") - reporter.info( - sprintf( - "%-70s %s", - getProjectName(project), - buildStatsLine(totalCount), - ), - ) - - for (const { repo } of repos) { - reporter.info( - sprintf( - " %-68s %s", - repo.name, - buildStatsLine(repo.issueCountsBySeverity), - ), - ) - } - }) - } -} - -const command: CommandModule = { - command: "report", - describe: "Report Snyk projects status", - builder: (yargs) => - yargs.option(definitionFileOptionName, definitionFileOptionValue), - handler: async (argv) => - report({ - reporter: createReporter(argv), - snyk: createSnykService({ config: createConfig() }), - definitionFile: getDefinitionFile(argv), - }), -} - -export default command diff --git a/src/cli/commands/snyk/set-token.ts b/src/cli/commands/snyk/set-token.ts deleted file mode 100644 index 50ff9b24..00000000 --- a/src/cli/commands/snyk/set-token.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { read } from "read" -import type { CommandModule } from "yargs" -import { SnykTokenCliProvider } from "../../../snyk/token" -import type { Reporter } from "../../reporter" -import { createReporter } from "../../util" - -async function setToken({ - reporter, - token, - tokenProvider, -}: { - reporter: Reporter - token: string | undefined - tokenProvider: SnykTokenCliProvider -}) { - if (token === undefined) { - reporter.info("Need API token to talk to Snyk") - reporter.info("See https://app.snyk.io/account") - // noinspection UnnecessaryLocalVariableJS - const inputToken = await read({ - prompt: "Enter new Snyk API token: ", - silent: true, - }) - token = inputToken - } - - await tokenProvider.setToken(token) - reporter.info("Token saved") -} - -const command: CommandModule = { - command: "set-token", - describe: "Set Snyk token for API calls", - builder: (yargs) => - yargs.positional("token", { - describe: "Token. If not provided it will be requested as input", - }), - handler: async (argv) => - setToken({ - reporter: createReporter(argv), - token: argv.token as string | undefined, - tokenProvider: new SnykTokenCliProvider(), - }), -} - -export default command diff --git a/src/cli/commands/snyk/sync.ts b/src/cli/commands/snyk/sync.ts deleted file mode 100644 index c37af926..00000000 --- a/src/cli/commands/snyk/sync.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { CommandModule } from "yargs" -import { type DefinitionFile, getRepoId, getRepos } from "../../../definition" -import { - createSnykService, - getGitHubRepo, - type SnykGitHubRepo, - type SnykService, -} from "../../../snyk" -import type { Reporter } from "../../reporter" -import { - createConfig, - createReporter, - definitionFileOptionName, - definitionFileOptionValue, - getDefinitionFile, -} from "../../util" - -async function sync({ - reporter, - snyk, - definitionFile, -}: { - reporter: Reporter - snyk: SnykService - definitionFile: DefinitionFile -}) { - const definition = await definitionFile.getDefinition() - - const knownRepos = (await snyk.getProjects(definition)) - .map((it) => getGitHubRepo(it)) - .filter((it): it is SnykGitHubRepo => it !== undefined) - - const allReposWithSnyk = getRepos(definition).filter( - (it) => it.repo.snyk === true, - ) - - const allReposWithSnykStr = allReposWithSnyk.map((it) => - getRepoId(it.orgName, it.repo.name), - ) - - const missingInSnyk = allReposWithSnyk.filter( - (it) => - !knownRepos.some( - (r) => r.owner === it.orgName && r.name === it.repo.name, - ), - ) - - const extraInSnyk = knownRepos.filter( - (it) => !allReposWithSnykStr.includes(`${it.owner}/${it.name}`), - ) - - if (missingInSnyk.length === 0) { - reporter.info("All seems fine") - } else { - missingInSnyk.forEach((it) => { - reporter.info(`Not in Snyk: ${it.project.name} / ${it.repo.name}`) - }) - extraInSnyk.forEach((it) => { - reporter.info(`Should not be in Snyk? ${it.owner}/${it.name}`) - }) - } -} - -const command: CommandModule = { - command: "sync", - describe: "Sync Snyk projects (currently only reports, no automation)", - builder: (yargs) => - yargs.option(definitionFileOptionName, definitionFileOptionValue), - handler: async (argv) => - sync({ - reporter: createReporter(argv), - snyk: createSnykService({ config: createConfig() }), - definitionFile: getDefinitionFile(argv), - }), -} - -export default command diff --git a/src/cli/index.ts b/src/cli/index.ts index a2d07dfc..0c003489 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -3,11 +3,7 @@ import semver from "semver" import yargs from "yargs" import { hideBin } from "yargs/helpers" import { engines, version } from "../../package.json" -import definition from "./commands/definition" -import deleteCache from "./commands/delete-cache" -import gettingStarted from "./commands/getting-started" import github from "./commands/github" -import snyk from "./commands/snyk" declare const BUILD_TIMESTAMP: string @@ -24,11 +20,7 @@ export async function main(): Promise { .scriptName("cals") .locale("en") .help("help") - .command(deleteCache) - .command(definition) .command(github) - .command(gettingStarted) - .command(snyk) .version(version) .demandCommand() .option("non-interactive", { diff --git a/src/github/changeset/changeset.ts b/src/github/changeset/changeset.ts deleted file mode 100644 index f514ace9..00000000 --- a/src/github/changeset/changeset.ts +++ /dev/null @@ -1,369 +0,0 @@ -import pMap from "p-map" -import type { - Definition, - DefinitionRepo, - Project, - RepoTeam, - Team, -} from "../../definition/types" -import type { GitHubService } from "../service" -import type { OrgsGetResponse, Permission, ReposGetResponse } from "../types" -import type { ChangeSetItem, RepoAttribUpdateItem } from "./types" - -function getChangedRepoAttribs( - definitionRepo: DefinitionRepo, - actualRepo: ReposGetResponse, -) { - const attribs: RepoAttribUpdateItem["attribs"] = [] - - const archived = definitionRepo.archived || false - if (archived !== actualRepo.archived) { - attribs.push({ - archived, - }) - } - - const issues = definitionRepo.issues ?? true - if (issues !== actualRepo.has_issues && !actualRepo.archived) { - attribs.push({ - issues, - }) - } - - const wiki = definitionRepo.wiki ?? true - if (wiki !== actualRepo.has_wiki && !actualRepo.archived) { - attribs.push({ - wiki, - }) - } - - const isPrivate = definitionRepo.public !== true - if (isPrivate !== actualRepo.private) { - attribs.push({ - private: isPrivate, - }) - } - - return attribs -} - -/** - * Get teams from both the project level and the repository level - * and ensure that repository level override project level. - */ -function getExpectedTeams( - projectTeams: RepoTeam[], - repoTeams: RepoTeam[], -): RepoTeam[] { - return [ - ...repoTeams, - ...projectTeams.filter( - (it) => !repoTeams.find((repoTeam) => repoTeam.name === it.name), - ), - ] -} - -async function getRepoTeamChanges({ - github, - org, - projectRepo, - repo, -}: { - github: GitHubService - org: Project["github"][0] - projectRepo: DefinitionRepo - repo: ReposGetResponse -}) { - const changes: ChangeSetItem[] = [] - const expectedTeams = getExpectedTeams( - org.teams ?? [], - projectRepo.teams ?? [], - ) - const existingTeams = await github.getRepositoryTeamsList(repo) - - // Check for teams to be added / modified. - for (const repoteam of expectedTeams) { - const found = existingTeams.find((it) => repoteam.name === it.name) - if (found !== undefined) { - if (found.permission !== repoteam.permission) { - changes.push({ - type: "repo-team-permission", - org: org.organization, - repo: repo.name, - team: found.name, - permission: repoteam.permission, - current: { - permission: found.permission as Permission, - }, - }) - } - } else { - changes.push({ - type: "repo-team-add", - org: org.organization, - repo: repo.name, - team: repoteam.name, - permission: repoteam.permission, - }) - } - } - - // Check for teams that should not be registered. - for (const team of existingTeams) { - if (!expectedTeams.some((it) => team.name === it.name)) { - changes.push({ - type: "repo-team-remove", - org: org.organization, - repo: repo.name, - team: team.name, - }) - } - } - - return changes -} - -async function getProjectRepoChanges({ - github, - org, - projectRepo, -}: { - github: GitHubService - org: Project["github"][0] - projectRepo: DefinitionRepo -}) { - const changes: ChangeSetItem[] = [] - - const repo = await github.getRepository(org.organization, projectRepo.name) - if (repo === undefined) { - changes.push({ - type: "repo-create", - org: org.organization, - repo: projectRepo.name, - }) - return changes - } - - const attribs = getChangedRepoAttribs(projectRepo, repo) - if (attribs.length > 0) { - changes.push({ - type: "repo-update", - org: org.organization, - repo: repo.name, - attribs, - }) - } - - changes.push( - ...(await getRepoTeamChanges({ - github, - org, - projectRepo, - repo, - })), - ) - - return changes -} - -/** - * Generate change set items for projects. - */ -export async function createChangeSetItemsForProjects( - github: GitHubService, - definition: Definition, - limitToOrg: string | undefined, -): Promise { - const changes: ChangeSetItem[] = [] - - const orgs = definition.projects - .flatMap((it) => it.github) - .filter( - (org) => limitToOrg === undefined || limitToOrg === org.organization, - ) - - changes.push( - ...( - await pMap(orgs, async (org) => - pMap(org.repos || [], (projectRepo) => - getProjectRepoChanges({ - github, - org, - projectRepo, - }), - ), - ) - ) - .flat() - .flat(), - ) - - return changes -} - -/** - * Get user list based on team memberships in an organization. - */ -function getUsersForOrg(definition: Definition, org: string) { - const teams = definition.github.teams.find((it) => it.organization == org) - if (teams === undefined) return [] - - const memberLogins = new Set(teams.teams.flatMap((it) => it.members)) - return definition.github.users.filter((user) => memberLogins.has(user.login)) -} - -/** - * Generate change set items for organization members. - */ -export async function createChangeSetItemsForMembers( - github: GitHubService, - definition: Definition, - org: OrgsGetResponse, -): Promise { - const changes: ChangeSetItem[] = [] - const users = getUsersForOrg(definition, org.login) - - const usersLogins = users.map((it) => it.login) - const foundLogins: string[] = [] - - const members = await github.getOrgMembersListIncludingInvited(org.login) - members.forEach((user) => { - if (usersLogins.includes(user.login)) { - foundLogins.push(user.login) - } else { - changes.push({ - type: "member-remove", - org: org.login, - user: user.login, - }) - } - }) - - for (const user of users.filter((it) => !foundLogins.includes(it.login))) { - changes.push({ - type: "member-add", - org: org.login, - user: user.login, - }) - } - - return changes -} - -/** - * Generate change set items for organization teams. - */ -export async function createChangeSetItemsForTeams( - github: GitHubService, - definition: Definition, - org: OrgsGetResponse, -): Promise { - const changes: ChangeSetItem[] = [] - - const teams = ( - definition.github.teams.find((it) => it.organization === org.login) || { - teams: [] as Team[], - } - ).teams - - const actualTeams = await github.getTeamList(org) - const actualTeamNames = actualTeams.map((it) => it.name) - const wantedTeamNames = teams.map((it) => it.name) - - actualTeams - .filter((it) => !wantedTeamNames.includes(it.name)) - .forEach((it) => { - changes.push({ - type: "team-remove", - org: org.login, - team: it.name, - }) - }) - - teams - .filter((it) => !actualTeamNames.includes(it.name)) - .forEach((team) => { - changes.push({ - type: "team-add", - org: org.login, - team: team.name, - }) - - // Must add all members when creating new team. - for (const member of team.members) { - changes.push({ - type: "team-member-add", - org: org.login, - team: team.name, - user: member, - // TODO: Allow to specify maintainers? - role: "member", - }) - } - }) - - const overlappingTeams = actualTeams.filter((it) => - wantedTeamNames.includes(it.name), - ) - - await pMap(overlappingTeams, async (actualTeam) => { - const wantedTeam = teams.find((it) => it.name === actualTeam.name)! - const actualMembers = await github.getTeamMemberListIncludingInvited( - org, - actualTeam, - ) - - actualMembers - .filter((it) => !wantedTeam.members.includes(it.login)) - .forEach((it) => { - changes.push({ - type: "team-member-remove", - org: org.login, - team: actualTeam.name, - user: it.login, - }) - }) - - const actualMembersNames = actualMembers.map((it) => it.login) - - wantedTeam.members - .filter((it) => !actualMembersNames.includes(it)) - .forEach((it) => { - changes.push({ - type: "team-member-add", - org: org.login, - team: actualTeam.name, - user: it, - // TODO: Allow to specify maintainers? - role: "member", - }) - }) - - // TODO: team-member-permission (member/maintainer) - }) - - return changes -} - -/** - * Remove redundant change set items due to effects by other - * change set items. - */ -export function cleanupChangeSetItems(items: ChangeSetItem[]): ChangeSetItem[] { - const hasTeamRemove = ({ org, team }: { org: string; team: string }) => - items.some( - (it) => it.type === "team-remove" && it.org === org && it.team === team, - ) - - const hasMemberRemove = ({ org }: { org: string }) => - items.some((it) => it.type === "member-remove" && it.org === org) - - return items.filter( - (item) => - !( - (item.type === "team-member-remove" && hasTeamRemove(item)) || - (item.type === "repo-team-remove" && hasTeamRemove(item)) || - (item.type === "team-member-remove" && hasMemberRemove(item)) - ), - ) -} diff --git a/src/github/changeset/execute.ts b/src/github/changeset/execute.ts deleted file mode 100644 index b22c6e72..00000000 --- a/src/github/changeset/execute.ts +++ /dev/null @@ -1,196 +0,0 @@ -import type { Reporter } from "../../cli/reporter" -import type { GitHubService } from "../service" -import type { - OrgsGetResponse, - ReposUpdateParams, - TeamsListResponseItem, -} from "../types" -import type { ChangeSetItem, RepoCreateItem } from "./types" - -function buildLookup(github: GitHubService) { - // We operate using the Octokit SDK, so cache the objects to avoid - // excessive lookups to the API for them. - - const orgCache: Record = {} - const orgTeamListCache: Record = {} - - async function getOrg(orgName: string) { - if (!(orgName in orgCache)) { - orgCache[orgName] = await github.getOrg(orgName) - } - - return orgCache[orgName] - } - - async function getOrgTeamList(orgName: string) { - if (!(orgName in orgTeamListCache)) { - const org = await getOrg(orgName) - orgTeamListCache[orgName] = await github.getTeamList(org) - } - - return orgTeamListCache[orgName] - } - - async function getOrgTeam(orgName: string, teamName: string) { - const teams = await getOrgTeamList(orgName) - const team = teams.find((it) => it.name === teamName) - if (team === undefined) { - throw new Error(`Team ${orgName}/${teamName} not found`) - } - return team - } - - return { - getOrgTeam, - } -} - -type NotImplementedChangeSetItem = RepoCreateItem - -const notImplementedChangeSetItems: NotImplementedChangeSetItem["type"][] = [ - "repo-create", -] - -export function isNotImplementedChangeSetItem( - changeItem: ChangeSetItem, -): changeItem is NotImplementedChangeSetItem { - return (notImplementedChangeSetItems as string[]).includes(changeItem.type) -} - -/** - * Execute a change set item. - */ -async function executeChangeSetItem( - github: GitHubService, - changeItem: ChangeSetItem, - reporter: Reporter, - lookup: ReturnType, -): Promise { - // We return to ensure all code paths are followed during compiling. - // If a change item type is missing we will get a compile error. - - if (isNotImplementedChangeSetItem(changeItem)) { - reporter.warn("Not currently implemented - do it manually") - return true - } - - switch (changeItem.type) { - case "member-remove": - await github.octokit.orgs.removeMembershipForUser({ - org: changeItem.org, - username: changeItem.user, - }) - return true - - case "member-add": - await github.octokit.orgs.setMembershipForUser({ - org: changeItem.org, - username: changeItem.user, - role: "member", - }) - return true - - case "team-remove": - await github.octokit.teams.deleteInOrg({ - org: changeItem.org, - team_slug: (await lookup.getOrgTeam(changeItem.org, changeItem.team)) - .slug, - }) - return true - - case "team-add": - await github.octokit.teams.create({ - org: changeItem.org, - name: changeItem.team, - privacy: "closed", - }) - return true - - case "team-member-permission": - await github.octokit.teams.addOrUpdateMembershipForUserInOrg({ - org: changeItem.org, - team_slug: (await lookup.getOrgTeam(changeItem.org, changeItem.team)) - .slug, - username: changeItem.user, - role: changeItem.role, - }) - return true - - case "team-member-remove": - await github.octokit.teams.removeMembershipForUserInOrg({ - org: changeItem.org, - team_slug: (await lookup.getOrgTeam(changeItem.org, changeItem.team)) - .slug, - username: changeItem.user, - }) - return true - - case "team-member-add": - await github.octokit.teams.addOrUpdateMembershipForUserInOrg({ - org: changeItem.org, - team_slug: (await lookup.getOrgTeam(changeItem.org, changeItem.team)) - .slug, - username: changeItem.user, - }) - return true - - case "repo-update": { - const upd: ReposUpdateParams = { - owner: changeItem.org, - repo: changeItem.repo, - } - - for (const attrib of changeItem.attribs) { - if ("archived" in attrib) { - upd.archived = attrib.archived - } else if ("issues" in attrib) { - upd.has_issues = attrib.issues - } else if ("wiki" in attrib) { - upd.has_wiki = attrib.wiki - } else if ("private" in attrib) { - upd.private = attrib.private - } - } - - await github.octokit.repos.update(upd) - return true - } - - case "repo-team-remove": - await github.octokit.teams.removeRepoInOrg({ - org: changeItem.org, - owner: changeItem.org, - repo: changeItem.repo, - team_slug: (await lookup.getOrgTeam(changeItem.org, changeItem.team)) - .slug, - }) - return true - - case "repo-team-add": - case "repo-team-permission": - await github.octokit.teams.addOrUpdateRepoPermissionsInOrg({ - org: changeItem.org, - owner: changeItem.org, - repo: changeItem.repo, - team_slug: (await lookup.getOrgTeam(changeItem.org, changeItem.team)) - .slug, - permission: changeItem.permission, - }) - return true - } -} - -/** - * Execute a change set. - */ -export async function executeChangeSet( - github: GitHubService, - changes: ChangeSetItem[], - reporter: Reporter, -): Promise { - const lookup = buildLookup(github) - for (const changeItem of changes) { - reporter.info(`Executing ${JSON.stringify(changeItem)}`) - await executeChangeSetItem(github, changeItem, reporter, lookup) - } -} diff --git a/src/github/changeset/types.ts b/src/github/changeset/types.ts deleted file mode 100644 index 5efff841..00000000 --- a/src/github/changeset/types.ts +++ /dev/null @@ -1,116 +0,0 @@ -import type { Permission } from "../types" - -// GitHub repos - -export interface RepoCreateItem { - type: "repo-create" - org: string - repo: string -} - -export interface RepoAttribUpdateItem { - type: "repo-update" - org: string - repo: string - attribs: ( - | { archived: boolean } - | { issues: boolean } - | { wiki: boolean } - | { private: boolean } - )[] -} - -export interface RepoTeamAddItem { - type: "repo-team-add" - org: string - repo: string - team: string - permission: Permission -} - -export interface RepoTeamRemoveItem { - type: "repo-team-remove" - org: string - repo: string - team: string -} - -export interface RepoTeamPermissionItem { - type: "repo-team-permission" - org: string - repo: string - team: string - permission: Permission - current: { - permission: Permission - } -} - -// GitHub members - -export interface MemberRemoveItem { - type: "member-remove" - org: string - user: string -} - -export interface MemberAddItem { - type: "member-add" - org: string - user: string -} - -// GitHub teams - -export interface TeamRemoveItem { - type: "team-remove" - org: string - team: string -} - -export interface TeamAddItem { - type: "team-add" - org: string - team: string -} - -export interface TeamMemberRemoveItem { - type: "team-member-remove" - org: string - team: string - user: string -} - -export interface TeamMemberAddItem { - type: "team-member-add" - org: string - team: string - user: string - role: "member" | "maintainer" -} - -export interface TeamMemberPermissionItem { - type: "team-member-permission" - org: string - team: string - user: string - role: "member" | "maintainer" -} - -/** - * A change set item describes a transition on the end - * service to become in sync with the desired definition. - */ -export type ChangeSetItem = - | RepoCreateItem - | RepoAttribUpdateItem - | RepoTeamPermissionItem - | RepoTeamAddItem - | RepoTeamRemoveItem - | MemberAddItem - | MemberRemoveItem - | TeamRemoveItem - | TeamAddItem - | TeamMemberRemoveItem - | TeamMemberAddItem - | TeamMemberPermissionItem diff --git a/src/index.ts b/src/index.ts index 5674b05e..e8734789 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,8 +10,6 @@ export const VERSION = version export * as definition from "./definition" export * as github from "./github" -export * as snyk from "./snyk" -export * from "./testing" // Consider removing old exports later. export { CacheProvider, diff --git a/src/snyk/index.ts b/src/snyk/index.ts deleted file mode 100644 index d48db3cb..00000000 --- a/src/snyk/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { createSnykService, SnykService } from "./service" -export type { SnykGitHubRepo, SnykProject } from "./types" -export { getGitHubRepo, getGitHubRepoId } from "./util" diff --git a/src/snyk/service.ts b/src/snyk/service.ts deleted file mode 100644 index a69179f0..00000000 --- a/src/snyk/service.ts +++ /dev/null @@ -1,136 +0,0 @@ -import process from "node:process" -import fetch from "node-fetch" -import type { Config } from "../config" -import type { Definition } from "../definition" -import { SnykTokenCliProvider, type SnykTokenProvider } from "./token" -import type { ProjectResponse, RestAPIProject, SnykProject } from "./types" - -interface SnykServiceProps { - config: Config - tokenProvider: SnykTokenProvider -} - -export class SnykService { - private config: Config - private tokenProvider: SnykTokenProvider - - public constructor(props: SnykServiceProps) { - this.config = props.config - this.tokenProvider = props.tokenProvider - } - - public async getProjects(definition: Definition): Promise { - const snykAccountId = definition.snyk?.accountId - if (snykAccountId === undefined) { - return [] - } - - return this.getProjectsByAccountId(snykAccountId) - } - - public async getProjectsByAccountId( - snykAccountId: string, - /** - * The slug name of a Snyk organization. - * - * NOTE: This is only used to construct the browsable URL for a given project, and is not being used - * in API calls to Snyk. - * - * @default - the slug corresponding to Lifligs Snyk organization ("it"). - */ - snykOrgSlugId?: string, - ): Promise { - const token = await this.tokenProvider.getToken() - if (token === undefined) { - throw new Error("Missing token for Snyk") - } - - let backportedProjects: SnykProject[] = [] - const snykRestApiVersion = "2025-11-05" - - let nextUrl: string | undefined = `/rest/orgs/${encodeURIComponent( - snykAccountId, - )}/projects?version=${snykRestApiVersion}&meta.latest_dependency_total=true&meta.latest_issue_counts=true&limit=100` - - /* The Snyk REST API only allows us to retrieve 100 projects at a time. - * The "links.next" value in the response gives us a pointer to the next 100 results. - * We continue calling the Snyk API and retrieving more projects until links.next is null - * */ - while (nextUrl) { - const response = await fetch(`https://api.snyk.io${nextUrl}`, { - method: "GET", - headers: { - Accept: "application/json", - Authorization: `token ${token}`, - }, - agent: this.config.agent, - }) - - if (response.status === 401) { - process.stderr.write("Unauthorized - removing token\n") - await this.tokenProvider.markInvalid() - } - - if (!response.ok) { - throw new Error( - `Response from Snyk not OK (${response.status}): ${JSON.stringify( - response, - )}`, - ) - } - - // Check if the Sunset header is present in the response - const sunsetHeader = - response.headers.get("Sunset") || response.headers.get("sunset") - if (sunsetHeader) { - console.warn( - `Snyk endpoint with version ${snykRestApiVersion} has been marked as deprecated with deprecation date ${sunsetHeader}`, - ) - } - - const jsonResponse = (await response.json()) as ProjectResponse - - /* We transform the data to a standard format that we used for data from Snyk API v1 in order for - the data to be backover compatible with existing consuments */ - backportedProjects = [ - ...backportedProjects, - ...jsonResponse.data.map((project: RestAPIProject) => { - return { - id: project.id, - name: project.attributes.name, - type: project.attributes.type, - created: project.attributes.created, - origin: project.attributes.origin, - testFrequency: - project.attributes.settings.recurring_tests.frequency, - isMonitored: project.attributes.status === "active", - totalDependencies: project.meta.latest_dependency_total.total, - issueCountsBySeverity: project.meta.latest_issue_counts, - lastTestedDate: project.meta.latest_dependency_total.updated_at, - browseUrl: `https://app.snyk.io/org/${ - snykOrgSlugId ?? "it" - }/project/${project.id}`, - } - }), - ] - - /* Update nextUrl with pointer to the next page of results based - * on the "links.next" field in the JSON response */ - nextUrl = jsonResponse.links.next - } - - return backportedProjects - } -} - -interface CreateSnykServiceProps { - config: Config - tokenProvider?: SnykTokenProvider -} - -export function createSnykService(props: CreateSnykServiceProps): SnykService { - return new SnykService({ - config: props.config, - tokenProvider: props.tokenProvider ?? new SnykTokenCliProvider(), - }) -} diff --git a/src/snyk/token.ts b/src/snyk/token.ts deleted file mode 100644 index 810c36e2..00000000 --- a/src/snyk/token.ts +++ /dev/null @@ -1,39 +0,0 @@ -import process from "node:process" -import keytar from "keytar" - -export interface SnykTokenProvider { - getToken(): Promise - markInvalid(): Promise -} - -export class SnykTokenCliProvider implements SnykTokenProvider { - private keyringService = "cals" - private keyringAccount = "snyk-token" - - async getToken(): Promise { - if (process.env.CALS_SNYK_TOKEN) { - return process.env.CALS_SNYK_TOKEN - } - - const result = await keytar.getPassword( - this.keyringService, - this.keyringAccount, - ) - if (result == null) { - process.stderr.write( - "No token found. Register using `cals snyk set-token`\n", - ) - return undefined - } - - return result - } - - async markInvalid(): Promise { - await keytar.deletePassword(this.keyringService, this.keyringAccount) - } - - public async setToken(value: string): Promise { - await keytar.setPassword(this.keyringService, this.keyringAccount, value) - } -} diff --git a/src/snyk/types.ts b/src/snyk/types.ts deleted file mode 100644 index 88e9c1a0..00000000 --- a/src/snyk/types.ts +++ /dev/null @@ -1,71 +0,0 @@ -// See https://apidocs.snyk.io/?version=2025-11-05#get-/orgs/-org_id-/projects -export interface ProjectResponse { - data: RestAPIProject[] - links: { - next?: string - } -} - -export interface RestAPIProject { - id: string - attributes: { - name: string - type: string - origin: string - created: string - status: string - settings: { - recurring_tests: { - frequency: string - } - } - } - meta: { - latest_dependency_total: { - updated_at: string - total: number - } - latest_issue_counts: { - critical?: number - high: number - medium: number - low: number - } - } -} - -/** Type represents format of responses from the deprecated List all projects v1 API - https://snyk.docs.apiary.io/#reference/projects/all-projects/list-all-projects **/ -export interface SnykProject { - name: string - id: string - created: string - origin: string - type: string - testFrequency: string - isMonitored: boolean - totalDependencies: number - issueCountsBySeverity: { - // Not always present (e.g. for old tests still being latest). - critical?: number - high: number - medium: number - low: number - } - /** - * E.g. http://github.com/capralifecycle/some-repo.git - * Set when using the CLI. - */ - // Will be null because it is not yet implemented in the new API - remoteRepoUrl?: string - // TODO: Check if lastTestedDate is actually always given - just to be safe now. - lastTestedDate?: string | null - browseUrl: string - // undocumented - // imageTag: string -} - -export interface SnykGitHubRepo { - owner: string - name: string -} diff --git a/src/snyk/util.test.ts b/src/snyk/util.test.ts deleted file mode 100644 index bd69b39c..00000000 --- a/src/snyk/util.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { describe, expect, it } from "vitest" -import type { SnykProject } from "./types" -import { getGitHubRepo } from "./util" - -describe("getGitHubRepo", () => { - it("can parse value that contains a file path", () => { - const project = { - name: "capralifecycle/some-repo:package.json", - origin: "github", - } as SnykProject - - expect(getGitHubRepo(project)).toStrictEqual({ - owner: "capralifecycle", - name: "some-repo", - }) - }) - - it("can parse value that contains a deep file path", () => { - const project = { - name: "capralifecycle/some-repo:some/dir/package.json", - origin: "github", - } as SnykProject - - expect(getGitHubRepo(project)).toStrictEqual({ - owner: "capralifecycle", - name: "some-repo", - }) - }) - - it("can parse value that does not contain a file path", () => { - const project = { - name: "capralifecycle/some-repo", - origin: "github", - } as SnykProject - - expect(getGitHubRepo(project)).toStrictEqual({ - owner: "capralifecycle", - name: "some-repo", - }) - }) - - it("can extract value for cli project with http url", () => { - // noinspection HttpUrlsUsage - const project = { - origin: "cli", - remoteRepoUrl: "http://github.com/capralifecycle/some-repo.git", - } as SnykProject - - expect(getGitHubRepo(project)).toStrictEqual({ - owner: "capralifecycle", - name: "some-repo", - }) - }) - - it("can extract value for cli project with https url", () => { - const project = { - origin: "cli", - remoteRepoUrl: "https://github.com/capralifecycle/some-repo.git", - } as SnykProject - - expect(getGitHubRepo(project)).toStrictEqual({ - owner: "capralifecycle", - name: "some-repo", - }) - }) - - it("does not fail for unknown cli project", () => { - const project = { - origin: "cli", - remoteRepoUrl: "garbage", - } as SnykProject - - expect(getGitHubRepo(project)).toBeUndefined() - }) -}) diff --git a/src/snyk/util.ts b/src/snyk/util.ts deleted file mode 100644 index 1c49b756..00000000 --- a/src/snyk/util.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { SnykGitHubRepo, SnykProject } from "./types" - -export function getGitHubRepo( - snykProject: SnykProject, -): SnykGitHubRepo | undefined { - if (snykProject.origin === "github") { - const match = /^([^/]+)\/([^:]+)(:(.+))?$/.exec(snykProject.name) - if (match === null) { - throw Error( - `Could not extract components from Snyk project name: ${snykProject.name} (id: ${snykProject.id})`, - ) - } - - return { - owner: match[1], - name: match[2], - } - } - if (snykProject.origin === "cli" && snykProject.remoteRepoUrl != null) { - // The remoteRepoUrl can be overridden when using the CLI, so don't - // fail if we cannot extract the value. - - const match = /github.com\/([^/]+)\/(.+)\.git$/.exec( - snykProject.remoteRepoUrl, - ) - if (match === null) { - return undefined - } - - return { - owner: match[1], - name: match[2], - } - } - return undefined -} - -export function getGitHubRepoId( - repo: SnykGitHubRepo | undefined, -): string | undefined { - return repo ? `${repo.owner}/${repo.name}` : undefined -} diff --git a/src/testing/executor.ts b/src/testing/executor.ts deleted file mode 100644 index 7cbddb7f..00000000 --- a/src/testing/executor.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { strict as assert } from "node:assert" -import process from "node:process" - -export class TestExecutor { - private shutdown = false - private cleanupTask: Promise | null = null - private usingWithCleanupTasks = false - private readonly tasks: (() => Promise)[] = [] - - /** - * Check if we are currently in shutdown state due to user - * asking to abort (Ctrl+C). - */ - public checkCanContinue(): void { - if (this.shutdown) { - throw new Error("In shutdown mode - aborting") - } - } - - async runTasks(): Promise { - console.warn("Running cleanup tasks") - - while (true) { - // We must run tasks in reverse order due to dependencies. - // E.g. we cannot delete a Docker network before deleting - // the container using it. - const task = this.tasks.pop() - if (task === undefined) { - return - } - - try { - await task() - } catch (error) { - console.error(error) - } - } - } - - /** - * Register a task that will be run during cleanup phase. - */ - public registerCleanupTask(task: () => Promise): void { - if (!this.usingWithCleanupTasks) { - throw new Error("registerCleanupTask run outside runWithCleanupTasks") - } - - this.tasks.push(task) - this.checkCanContinue() - } - - /** - * Run the code block while ensuring we can run cleanup tasks - * after the execution or if the process is interrupted. - * - * The main method of the program should be executed by using - * this method. - */ - public async runWithCleanupTasks( - body: (executor: TestExecutor) => Promise, - ): Promise { - try { - assert.strictEqual(this.usingWithCleanupTasks, false) - this.usingWithCleanupTasks = true - - // We capture Ctrl+C so that we can perform cleanup task, - // since the cleanup tasks involve async code which is not - // supported during NodeJS normal exit handling. - // - // This will not abort the running tasks until after - // we have completed the cleanup tasks. The running tasks - // can stop earlier by calling checkCanContinue. - process.on("SIGINT", () => { - console.warn("Caught interrupt signal - forcing termination") - if (this.cleanupTask != null) { - return - } - - this.shutdown = true - this.cleanupTask = this.runTasks().then(() => { - process.exit(1) - }) - }) - - await body(this) - } catch (error) { - console.error(error.stack || error.message || error) - process.exitCode = 1 - } finally { - console.log("Reached finally block") - this.usingWithCleanupTasks = false - if (this.cleanupTask == null) { - this.cleanupTask = this.runTasks() - } - await this.cleanupTask - } - } -} - -export function createTestExecutor(): TestExecutor { - return new TestExecutor() -} diff --git a/src/testing/index.ts b/src/testing/index.ts deleted file mode 100644 index 7e397c6e..00000000 --- a/src/testing/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -export { createTestExecutor, TestExecutor } from "./executor" -export { - createNetwork, - curl, - getDockerHostAddress, - pollForCondition, - runNpmRunScript, - startContainer, - waitForEnterToContinue, - waitForHttpOk, - waitForPostgresAvailable, -} from "./lib" diff --git a/src/testing/lib.ts b/src/testing/lib.ts deleted file mode 100644 index 6e466c07..00000000 --- a/src/testing/lib.ts +++ /dev/null @@ -1,506 +0,0 @@ -import type { Buffer } from "node:buffer" -import fs from "node:fs" -import { performance } from "node:perf_hooks" -import process from "node:process" -import { Transform } from "node:stream" -import { execa, type Subprocess } from "execa" -import { read } from "read" -import type { TestExecutor } from "./executor" - -export interface Container { - id: string - name: string - network: Network - process: Subprocess - executor: TestExecutor -} - -export interface Network { - id: string -} - -/** - * Generate a value that can be used as part of resource names. - * - * Gives a value formatted as "yyyymmdd-xxxxxx", e.g. "20200523-3f2c87". - */ -function generateRunId(): string { - const low = 0x100000 - const high = 0xffffff - const range = high - low + 1 - const now = new Date() - return [ - now.getUTCFullYear(), - (now.getUTCMonth() + 1).toString().padStart(2, "0"), - now.getUTCDate().toString().padStart(2, "0"), - "-", - (Math.floor(Math.random() * range) + low).toString(16), - ].join("") -} - -/** - * Generate a name that can be used for a resource. - */ -function generateName(extra?: string): string { - const s = extra === undefined ? "" : `-${extra}` - return `autotest-${generateRunId()}${s}` -} - -/** - * Create a new Docker network. - */ -export async function createNetwork(executor: TestExecutor): Promise { - executor.checkCanContinue() - const networkName = generateName() - - await execa("docker", ["network", "create", networkName]) - - const lsRes = await execa("docker", [ - "network", - "ls", - "-q", - "-f", - `name=${networkName}`, - ]) - const networkId = lsRes.stdout.trim() - - console.log(`Network ${networkName} (${networkId}) created`) - - executor.registerCleanupTask(async () => { - await execa("docker", ["network", "rm", networkId]) - console.log(`Network ${networkName} (${networkId}) deleted`) - }) - - return { - id: networkId, - } -} - -/** - * Execute curl within the Docker network. - */ -export async function curl( - executor: TestExecutor, - network: Network, - ...args: string[] -): Promise { - executor.checkCanContinue() - const result = await execa("docker", [ - "run", - "-i", - "--rm", - "--network", - network.id, - "byrnedo/alpine-curl", - ...args, - ]) - return result.stdout -} - -/** - * Repeatedly check for a condition until timeout. - * - * The condition can throw an error without aborting the loop. - * To abort the condition must return false. - */ -export async function pollForCondition({ - container, - attempts, - waitIntervalSec, - condition, -}: { - container: Container - attempts: number - waitIntervalSec: number - condition: () => Promise -}): Promise { - function log(value: string) { - console.log(`${container.name} (poll): ${value}`) - } - - container.executor.checkCanContinue() - log( - `Waiting for condition.. Checking ${attempts} times by ${waitIntervalSec} sec`, - ) - - const start = performance.now() - const duration = () => { - const end = performance.now() - return Math.round((end - start) / 1000) - } - - for (let i = 0; i < attempts; i++) { - container.executor.checkCanContinue() - if (!(await isRunning(container.executor, container))) { - throw new Error(`Container ${container.name} not running as expected`) - } - try { - const result = await condition() - if (!result) { - break - } - - log(`Took ${duration()} seconds for condition`) - return - } catch { - log("Still waiting...") - await new Promise((resolve) => - setTimeout(resolve, waitIntervalSec * 1000), - ) - } - } - - throw new Error(`Failed to wait for container ${container.name}`) -} - -export async function waitForHttpOk({ - container, - url, - attempts = 30, - waitIntervalSec = 1, -}: { - container: Container - url: string - attempts?: number - waitIntervalSec?: number -}): Promise { - await pollForCondition({ - container, - attempts, - waitIntervalSec, - condition: async () => { - await curl(container.executor, container.network, "-fsS", url) - return true - }, - }) -} - -export async function waitForPostgresAvailable({ - container, - attempts = 30, - waitIntervalSec = 1, - username = "user", - password = "password", - dbname, -}: { - container: Container - attempts?: number - waitIntervalSec?: number - username?: string - password?: string - dbname: string -}): Promise { - await pollForCondition({ - container, - attempts, - waitIntervalSec, - condition: async () => { - await execa("docker", [ - "exec", - "-e", - `PGPASSWORD=${password}`, - container.name, - "psql", - "-h", - "localhost", - "-U", - username, - "-c", - "select 1", - dbname, - ]) - return true - }, - }) -} - -async function isRunning( - executor: TestExecutor, - container: Container, -): Promise { - executor.checkCanContinue() - - try { - await execa("docker", ["inspect", container.name]) - return true - } catch { - return false - } -} - -/** - * A stream transform that injects a prefix into every line - * and also forces every chunk to end with a newline so that - * it can be interleaved with other output. - */ -class OutputPrefixTransform extends Transform { - constructor(prefix: string) { - super({ - objectMode: true, - transform: (chunk: Buffer, encoding, callback) => { - let result = chunk.toString(encoding) - - if (result.endsWith("\n")) { - result = result.slice(0, -1) - } - - // Some loggers emit newline then ANSI reset code causing - // blank lines if we do not remove the newline. - // TODO: Consider removing all ANSI escape codes as it causes - // some confusing output when interleaved. - if (result.endsWith("\n\u001B[0m")) { - result = result.slice(0, -5) + result.slice(-4) - } - - result = `${prefix + result.replace(/\n/g, `\n${prefix}`)}\n` - callback(null, result) - }, - }) - } -} - -function pipeToConsole(result: Subprocess, name: string) { - result.stdout - ?.pipe(new OutputPrefixTransform(`${name}: `)) - .pipe(process.stdout) - result.stderr - ?.pipe(new OutputPrefixTransform(`${name} (stderr): `)) - .pipe(process.stderr) -} - -function checkPidRunning(pid: number): boolean { - try { - process.kill(pid, 0) - return true - } catch { - return false - } -} - -async function getContainerId({ - executor, - name, - hasFailed, - pid, -}: { - executor: TestExecutor - name: string - hasFailed(this: void): boolean - pid: number -}) { - function log(value: string) { - console.log(`${name} (get-container-id): ${value}`) - } - - async function check() { - let result: string | null - - try { - result = (await execa("docker", ["inspect", name, "-f", "{{.Id}}"])) - .stdout - } catch { - result = null - } - - // Debugging to help us solve CALS-366. - const ps = execa("docker", ["ps"]) - pipeToConsole(ps, `${name} (ps)`) - await ps - - // Debugging to help us solve CALS-366. - if (!checkPidRunning(pid)) { - log("Process not running") - } - - return result - } - - // If the container is not running, retry a few times to cover - // the initial starting where we might check before the container - // is running. - // Increased from 25 to 100 to see if it helps for solving CALS-366. - for (let i = 0; i < 100; i++) { - if (i > 0) { - // Delay a bit before checking again. - log("Retrying in a bit...") - await new Promise((resolve) => setTimeout(resolve, 200)) - } - - executor.checkCanContinue() - if (hasFailed()) { - break - } - - const id = await check() - if (id !== null) { - log(`Resolved to ${id}`) - return id - } - } - - throw new Error(`Could not find ID for container with name ${name}`) -} - -async function pullImage({ imageId }: { imageId: string }): Promise { - console.log(`Pulling ${imageId}`) - const process = execa("docker", ["pull", imageId]) - pipeToConsole(process, `pull-image (${imageId})`) - await process -} - -async function checkImageExistsLocally({ - imageId, -}: { - imageId: string -}): Promise { - const result = await execa("docker", ["images", "-q", imageId]) - const found = result.stdout != "" - console.log( - `image ${imageId} ${ - found ? "was present locally" : "was not found locally" - }`, - ) - return found -} - -export async function startContainer({ - executor, - network, - imageId, - alias, - env, - dockerArgs = [], - pull = false, -}: { - executor: TestExecutor - network: Network - imageId: string - alias?: string - env?: Record - dockerArgs?: string[] - pull?: boolean -}): Promise { - executor.checkCanContinue() - const containerName = generateName(alias) - - // Prefer pulling image here so that the call on getContainerId - // will not time out due to pulling the image. - // If pull is false, we will still fallback to pulling if we cannot - // find the image locally. - if (pull || !(await checkImageExistsLocally({ imageId }))) { - await pullImage({ - imageId, - }) - } - - const args = [ - "run", - "--rm", - "--network", - network.id, - "--name", - containerName, - ...dockerArgs, - ] - - if (alias != null) { - args.push(`--network-alias=${alias}`) - } - - if (env != null) { - for (const [key, value] of Object.entries(env)) { - args.push("-e", `${key}=${value}`) - } - } - - args.push(imageId) - - console.log(`Starting ${imageId}`) - const process = execa("docker", args) - pipeToConsole(process, alias ?? containerName) - - let failed = false - process.catch(() => { - failed = true - }) - if (!process.pid) { - throw new Error( - "No process identifier (PID) was returned for the process that was started when running trying to run Docker container", - ) - } - - const id = await getContainerId({ - executor, - name: containerName, - hasFailed: () => failed, - pid: process.pid, - }) - - executor.registerCleanupTask(async () => { - console.log(`Stopping container ${containerName}`) - const r = execa("docker", ["stop", containerName]) - pipeToConsole(r, `${alias ?? containerName} (stop)`) - try { - await r - } catch (e) { - if (!(e.stderr || "").includes("No such container")) { - throw e - } - } - }) - - return { - id, - name: containerName, - network, - process, - executor, - } -} - -export async function runNpmRunScript( - name: string, - options?: { - env: NodeJS.ProcessEnv - }, -): Promise { - const result = execa("npm", ["run", name], { - env: options?.env, - }) - pipeToConsole(result, `npm run ${name}`) - await result -} - -/** - * This likely does not cover all situations. - */ -export async function getDockerHostAddress(): Promise { - if (process.platform === "darwin" || process.platform === "win32") { - return "host.docker.internal" - } - - if (fs.existsSync("/.dockerenv")) { - const process = execa("ip", ["route"]) - pipeToConsole(process, "ip route") - const res = await process - try { - return res.stdout - .split("\n") - .filter((it) => it.includes("default via")) - .map((it) => /default via ([\d.]+) /.exec(it)![1])[0] - } catch { - throw new Error("Failed to extract docker host address") - } - } - - return "localhost" -} - -export async function waitForEnterToContinue( - prompt = "Press enter to continue", -): Promise { - await read({ - prompt, - silent: true, - }) -} From 3a4699b3c5d32a09cf674f9558c03aa645966cca Mon Sep 17 00:00:00 2001 From: "Joakim L. Engeset" Date: Wed, 28 Jan 2026 15:21:00 +0100 Subject: [PATCH 2/9] feat: cleanup unused code paths --- package-lock.json | 39 +- package.json | 4 - rollup.config.js | 5 +- .../github/generate-clone-commands.ts | 2 +- src/cli/commands/github/list-repos.ts | 2 +- src/cli/commands/github/set-token.ts | 2 +- src/cli/commands/github/sync.ts | 2 +- src/cli/index.ts | 8 - src/cli/reporter.ts | 12 - src/cli/util.ts | 40 +- .../__snapshots__/definition.test.ts.snap | 200 ------- src/definition/definition.test.ts | 6 +- src/definition/definition.ts | 63 -- src/definition/index.ts | 1 - src/definition/types.ts | 59 -- src/github/index.ts | 5 - src/github/service.ts | 545 +----------------- src/github/types.ts | 97 ---- src/github/util.ts | 13 - src/index.ts | 16 - 20 files changed, 12 insertions(+), 1109 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3b1e7567..e4ef3fca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,6 @@ "keytar": "^7.9.0", "node-fetch": "^3.3.2", "p-limit": "^7.2.0", - "p-map": "^7.0.4", "read": "^5.0.1", "semver": "^7.7.3", "sprintf-js": "^1.1.3", @@ -36,16 +35,13 @@ "@rollup/plugin-alias": "6.0.0", "@rollup/plugin-json": "6.1.0", "@rollup/plugin-replace": "6.0.3", - "@types/dateformat": "5.0.3", "@types/js-yaml": "4.0.9", - "@types/lodash-es": "4.17.12", "@types/node": "24.10.9", "@types/node-fetch": "2.6.13", "@types/semver": "7.7.1", "@types/sprintf-js": "1.1.4", "@types/yargs": "17.0.35", "@vitest/coverage-v8": "4.0.18", - "dateformat": "5.0.3", "husky": "9.1.7", "npm-check-updates": "19.3.2", "rollup": "4.56.0", @@ -2359,13 +2355,6 @@ "@types/node": "*" } }, - "node_modules/@types/dateformat": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/@types/dateformat/-/dateformat-5.0.3.tgz", - "integrity": "sha512-hvskLa9ObgUwJe0rtfkyBbU+VIK8Ia3BACsxRsp4CPnyPBAOyIHbqImfBGFfaVVxn82wGx5SD8Twll6jMc/BaQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -2394,23 +2383,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/lodash-es": { - "version": "4.17.12", - "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", - "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/lodash": "*" - } - }, "node_modules/@types/node": { "version": "24.10.9", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.9.tgz", @@ -3488,16 +3460,6 @@ "node": ">= 12" } }, - "node_modules/dateformat": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-5.0.3.tgz", - "integrity": "sha512-Kvr6HmPXUMerlLcLF+Pwq3K7apHpYmGDVqrxcDasBg86UcKeTSNWbEzU8bwdXnxnR44FtMhJAxI4Bov6Y/KUfA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.20" - } - }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -7925,6 +7887,7 @@ "version": "7.0.4", "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" diff --git a/package.json b/package.json index 1f15799c..77366c96 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,6 @@ "keytar": "^7.9.0", "node-fetch": "^3.3.2", "p-limit": "^7.2.0", - "p-map": "^7.0.4", "read": "^5.0.1", "semver": "^7.7.3", "sprintf-js": "^1.1.3", @@ -49,16 +48,13 @@ "@rollup/plugin-alias": "6.0.0", "@rollup/plugin-json": "6.1.0", "@rollup/plugin-replace": "6.0.3", - "@types/dateformat": "5.0.3", "@types/js-yaml": "4.0.9", - "@types/lodash-es": "4.17.12", "@types/node": "24.10.9", "@types/node-fetch": "2.6.13", "@types/semver": "7.7.1", "@types/sprintf-js": "1.1.4", "@types/yargs": "17.0.35", "@vitest/coverage-v8": "4.0.18", - "dateformat": "5.0.3", "husky": "9.1.7", "npm-check-updates": "19.3.2", "rollup": "4.56.0", diff --git a/rollup.config.js b/rollup.config.js index e5ae5949..b09cad5e 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -2,7 +2,6 @@ import alias from "@rollup/plugin-alias" import json from "@rollup/plugin-json" import replace from "@rollup/plugin-replace" import typescript from "rollup-plugin-typescript2" -import dateFormat from "dateformat" import path from "path" import pkg from "./package.json" with { type: "json" } @@ -33,9 +32,7 @@ const plugins = [ typescript(), json(), replace({ - BUILD_TIMESTAMP: JSON.stringify( - dateFormat(new Date(), "isoDateTime", true), - ), + BUILD_TIMESTAMP: JSON.stringify(new Date().toISOString()), preventAssignment: true, }), ] diff --git a/src/cli/commands/github/generate-clone-commands.ts b/src/cli/commands/github/generate-clone-commands.ts index af6d09dc..983c1a1b 100644 --- a/src/cli/commands/github/generate-clone-commands.ts +++ b/src/cli/commands/github/generate-clone-commands.ts @@ -113,7 +113,7 @@ const command: CommandModule = { const config = createConfig() return generateCloneCommands({ - reporter: createReporter(argv), + reporter: createReporter(), config, github: await createGitHubService({ config, diff --git a/src/cli/commands/github/list-repos.ts b/src/cli/commands/github/list-repos.ts index 4bc3b8e4..e99286e1 100644 --- a/src/cli/commands/github/list-repos.ts +++ b/src/cli/commands/github/list-repos.ts @@ -166,7 +166,7 @@ const command: CommandModule = { handler: async (argv) => { const config = createConfig() await listRepos({ - reporter: createReporter(argv), + reporter: createReporter(), github: await createGitHubService({ config, cache: createCacheProvider(config, argv), diff --git a/src/cli/commands/github/set-token.ts b/src/cli/commands/github/set-token.ts index e95b68f6..a0a1d9c8 100644 --- a/src/cli/commands/github/set-token.ts +++ b/src/cli/commands/github/set-token.ts @@ -39,7 +39,7 @@ const command: CommandModule = { }), handler: async (argv) => { await setToken({ - reporter: createReporter(argv), + reporter: createReporter(), token: argv.token as string | undefined, tokenProvider: new GitHubTokenCliProvider(), }) diff --git a/src/cli/commands/github/sync.ts b/src/cli/commands/github/sync.ts index 2b0e33a0..71e0587a 100644 --- a/src/cli/commands/github/sync.ts +++ b/src/cli/commands/github/sync.ts @@ -622,7 +622,7 @@ will be stored there.`), config, cache: createCacheProvider(config, argv), }) - const reporter = createReporter(argv) + const reporter = createReporter() const manifest = await loadCalsManifest(config, reporter) if (manifest === null) return diff --git a/src/cli/index.ts b/src/cli/index.ts index 0c003489..66e9e9fd 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -23,14 +23,6 @@ export async function main(): Promise { .command(github) .version(version) .demandCommand() - .option("non-interactive", { - describe: "Non-interactive mode", - type: "boolean", - }) - .option("verbose", { - describe: "Verbose output", - type: "boolean", - }) .option("validate-cache", { describe: "Only read from cache if validated against server", type: "boolean", diff --git a/src/cli/reporter.ts b/src/cli/reporter.ts index 554c9c90..4d4436af 100644 --- a/src/cli/reporter.ts +++ b/src/cli/reporter.ts @@ -10,20 +10,8 @@ function clearLine(stdout: NodeJS.WriteStream) { } export class Reporter { - public constructor( - opts: { - nonInteractive?: boolean - verbose?: boolean - } = {}, - ) { - this.nonInteractive = !!opts.nonInteractive - this.isVerbose = !!opts.verbose - } - public stdout = process.stdout public stderr = process.stderr - public nonInteractive: boolean - public isVerbose: boolean public format: typeof chalk = chalk public error(msg: string): void { diff --git a/src/cli/util.ts b/src/cli/util.ts index 40a04311..3f4a075e 100644 --- a/src/cli/util.ts +++ b/src/cli/util.ts @@ -1,16 +1,9 @@ -import fs from "node:fs" -import { deprecate } from "node:util" -import type { Arguments, Options } from "yargs" import { CacheProvider } from "../cache" import { Config } from "../config" -import { DefinitionFile } from "../definition" import { Reporter } from "./reporter" -export function createReporter(argv: Record): Reporter { - return new Reporter({ - verbose: !!argv.verbose, - nonInteractive: !!argv.nonInteractive, - }) +export function createReporter(): Reporter { + return new Reporter() } export function createCacheProvider( @@ -19,42 +12,13 @@ export function createCacheProvider( ): CacheProvider { const cache = new CacheProvider(config) - // --validate-cache if (argv.validateCache === true) { cache.mustValidate = true } - // old option: --no-cache - if (argv.cache === false) { - deprecate(() => { - cache.mustValidate = true - }, "The --no-cache option is deprecated. See new --validate-cache option")() - } - return cache } export function createConfig(): Config { return new Config() } - -export const definitionFileOptionName = "definition-file" -export const definitionFileOptionValue: Options = { - describe: - "Path to definition file, which should be the latest resources.yaml file from https://github.com/capralifecycle/resources-definition", - demandOption: true, - type: "string", -} - -export function getDefinitionFile(argv: Arguments): DefinitionFile { - if (argv.definitionFile === undefined) { - throw Error("Missing --definition-file option") - } - - const definitionFile = argv.definitionFile as string - if (!fs.existsSync(definitionFile)) { - throw Error(`The file ${definitionFile} does not exist`) - } - - return new DefinitionFile(definitionFile) -} diff --git a/src/definition/__snapshots__/definition.test.ts.snap b/src/definition/__snapshots__/definition.test.ts.snap index bd879029..8f87fc98 100644 --- a/src/definition/__snapshots__/definition.test.ts.snap +++ b/src/definition/__snapshots__/definition.test.ts.snap @@ -9,9 +9,6 @@ exports[`definition > should match expected schema 1`] = ` "archived": { "type": "boolean", }, - "issues": { - "type": "boolean", - }, "name": { "type": "string", }, @@ -21,27 +18,6 @@ exports[`definition > should match expected schema 1`] = ` }, "type": "array", }, - "public": { - "type": "boolean", - }, - "responsible": { - "description": "Some external-defined entity being responsible for the repository. - -Will override the project-defined responsible.", - "type": "string", - }, - "snyk": { - "type": "boolean", - }, - "teams": { - "items": { - "$ref": "#/definitions/RepoTeam", - }, - "type": "array", - }, - "wiki": { - "type": "boolean", - }, }, "required": [ "name", @@ -63,14 +39,6 @@ Will override the project-defined responsible.", ], "type": "object", }, - "Permission": { - "enum": [ - "admin", - "pull", - "push", - ], - "type": "string", - }, "Project": { "properties": { "github": { @@ -85,12 +53,6 @@ Will override the project-defined responsible.", }, "type": "array", }, - "teams": { - "items": { - "$ref": "#/definitions/RepoTeam", - }, - "type": "array", - }, }, "required": [ "organization", @@ -102,10 +64,6 @@ Will override the project-defined responsible.", "name": { "type": "string", }, - "responsible": { - "description": "Some external-defined entity being responsible for the project.", - "type": "string", - }, "tags": { "items": { "type": "string", @@ -119,174 +77,16 @@ Will override the project-defined responsible.", ], "type": "object", }, - "RepoTeam": { - "properties": { - "name": { - "type": "string", - }, - "permission": { - "$ref": "#/definitions/Permission", - }, - }, - "required": [ - "name", - "permission", - ], - "type": "object", - }, - "Team": { - "properties": { - "members": { - "items": { - "type": "string", - }, - "type": "array", - }, - "name": { - "type": "string", - }, - }, - "required": [ - "members", - "name", - ], - "type": "object", - }, - "User": { - "anyOf": [ - { - "$ref": "#/definitions/UserBot", - }, - { - "$ref": "#/definitions/UserEmployee", - }, - { - "$ref": "#/definitions/UserExternal", - }, - ], - }, - "UserBot": { - "properties": { - "login": { - "type": "string", - }, - "name": { - "type": "string", - }, - "type": { - "const": "bot", - "type": "string", - }, - }, - "required": [ - "login", - "name", - "type", - ], - "type": "object", - }, - "UserEmployee": { - "properties": { - "capraUsername": { - "type": "string", - }, - "login": { - "type": "string", - }, - "name": { - "type": "string", - }, - "type": { - "const": "employee", - "type": "string", - }, - }, - "required": [ - "capraUsername", - "login", - "name", - "type", - ], - "type": "object", - }, - "UserExternal": { - "properties": { - "login": { - "type": "string", - }, - "name": { - "type": "string", - }, - "type": { - "const": "external", - "type": "string", - }, - }, - "required": [ - "login", - "name", - "type", - ], - "type": "object", - }, }, "properties": { - "github": { - "properties": { - "teams": { - "items": { - "properties": { - "organization": { - "type": "string", - }, - "teams": { - "items": { - "$ref": "#/definitions/Team", - }, - "type": "array", - }, - }, - "required": [ - "organization", - "teams", - ], - "type": "object", - }, - "type": "array", - }, - "users": { - "items": { - "$ref": "#/definitions/User", - }, - "type": "array", - }, - }, - "required": [ - "teams", - "users", - ], - "type": "object", - }, "projects": { "items": { "$ref": "#/definitions/Project", }, "type": "array", }, - "snyk": { - "properties": { - "accountId": { - "type": "string", - }, - }, - "required": [ - "accountId", - ], - "type": "object", - }, }, "required": [ - "github", "projects", ], "type": "object", diff --git a/src/definition/definition.test.ts b/src/definition/definition.test.ts index 7fd29bb1..43f7679c 100644 --- a/src/definition/definition.test.ts +++ b/src/definition/definition.test.ts @@ -28,7 +28,7 @@ describe("definition", () => { await expect( definitionFile.getDefinition(), ).rejects.toThrowErrorMatchingInlineSnapshot( - `[Error: Definition content invalid: data must have required property 'github', data must have required property 'projects']`, + `[Error: Definition content invalid: data must have required property 'projects']`, ) await rm(tmpFile, { force: true }) @@ -36,10 +36,6 @@ describe("definition", () => { it("should successfully parse correct file", async () => { const data: Definition = { - github: { - teams: [], - users: [], - }, projects: [ { name: "myproject", diff --git a/src/definition/definition.ts b/src/definition/definition.ts index cddd3cb5..4bcae0fb 100644 --- a/src/definition/definition.ts +++ b/src/definition/definition.ts @@ -7,10 +7,6 @@ import type { Definition, GetReposResponse } from "./types" export { schema } -function getTeamId(org: string, teamName: string) { - return `${org}/${teamName}` -} - export function getRepoId(orgName: string, repoName: string): string { return `${orgName}/${repoName}` } @@ -27,41 +23,6 @@ function checkAgainstSchema( } function requireValidDefinition(definition: Definition) { - // Verify no duplicates in users and extract known logins. - const loginList = definition.github.users.reduce((acc, user) => { - if (acc.includes(user.login)) { - throw new Error(`Duplicate login: ${user.login}`) - } - return [...acc, user.login] - }, []) - - // Verify no duplicates in teams and extract team names. - const teamIdList = definition.github.teams.reduce( - (acc, orgTeams) => { - return orgTeams.teams.reduce((acc1, team) => { - const id = getTeamId(orgTeams.organization, team.name) - if (acc1.includes(id)) { - throw new Error(`Duplicate team: ${id}`) - } - return [...acc1, id] - }, acc) - }, - [], - ) - - // Verify team members exists as users. - definition.github.teams - .flatMap((it) => it.teams) - .forEach((team) => { - team.members.forEach((login) => { - if (!loginList.includes(login)) { - throw new Error( - `Team member ${login} in team ${team.name} is not registered in user list`, - ) - } - }) - }) - // Verify no duplicates in project names. definition.projects.reduce((acc, project) => { if (acc.includes(project.name)) { @@ -70,30 +31,6 @@ function requireValidDefinition(definition: Definition) { return [...acc, project.name] }, []) - definition.projects.forEach((project) => { - project.github.forEach((org) => { - // Verify project teams exists as teams. - ;(org.teams || []).forEach((team) => { - const id = getTeamId(org.organization, team.name) - if (!teamIdList.includes(id)) { - throw new Error( - `Project team ${id} in project ${project.name} is not registered in team list`, - ) - } - }) // Verify repo teams exists as teams. - ;(org.repos || []).forEach((repo) => { - ;(repo.teams || []).forEach((team) => { - const id = getTeamId(org.organization, team.name) - if (!teamIdList.includes(id)) { - throw new Error( - `Repo team ${id} for repo ${repo.name} in project ${project.name} is not registered in team list`, - ) - } - }) - }) - }) - }) - // Verify no duplicates in repos. definition.projects .flatMap((project) => diff --git a/src/definition/index.ts b/src/definition/index.ts index 4faad629..82612e99 100644 --- a/src/definition/index.ts +++ b/src/definition/index.ts @@ -10,5 +10,4 @@ export type { DefinitionRepo, GetReposResponse, Project, - RepoTeam, } from "./types" diff --git a/src/definition/types.ts b/src/definition/types.ts index 30c2a668..e2c545f9 100644 --- a/src/definition/types.ts +++ b/src/definition/types.ts @@ -1,16 +1,4 @@ -import type { Permission } from "../github/types" - export interface Definition { - snyk?: { - accountId: string - } - github: { - users: User[] - teams: { - organization: string - teams: Team[] - }[] - } projects: Project[] } @@ -19,51 +7,14 @@ export interface Project { github: { organization: string repos?: DefinitionRepo[] - teams?: RepoTeam[] }[] tags?: string[] - /** - * Some external-defined entity being responsible for the project. - */ - responsible?: string -} - -export type User = UserBot | UserEmployee | UserExternal - -export interface UserBot { - type: "bot" - login: string - name: string -} - -export interface UserEmployee { - type: "employee" - login: string - capraUsername: string - name: string -} - -export interface UserExternal { - type: "external" - login: string - name: string } export interface DefinitionRepo { name: string previousNames?: DefinitionRepoPreviousName[] archived?: boolean - issues?: boolean - wiki?: boolean - teams?: RepoTeam[] - snyk?: boolean - public?: boolean - /** - * Some external-defined entity being responsible for the repository. - * - * Will override the project-defined responsible. - */ - responsible?: string } export interface DefinitionRepoPreviousName { @@ -71,16 +22,6 @@ export interface DefinitionRepoPreviousName { project: string } -export interface RepoTeam { - name: string - permission: Permission -} - -export interface Team { - name: string - members: string[] // Set -} - export interface GetReposResponse { id: string orgName: string diff --git a/src/github/index.ts b/src/github/index.ts index 0701f352..f1c7a421 100644 --- a/src/github/index.ts +++ b/src/github/index.ts @@ -1,6 +1 @@ -export type { SearchedPullRequestListItem } from "./service" export { createGitHubService, GitHubService } from "./service" -export type { - RenovateDependencyDashboardIssue, - VulnerabilityAlert, -} from "./types" diff --git a/src/github/service.ts b/src/github/service.ts index b7cd87a2..2c78bd5c 100644 --- a/src/github/service.ts +++ b/src/github/service.ts @@ -2,113 +2,13 @@ import { Buffer } from "node:buffer" import { performance } from "node:perf_hooks" import * as process from "node:process" import { Octokit } from "@octokit/rest" -import type { EndpointOptions, OctokitResponse } from "@octokit/types" +import type { OctokitResponse } from "@octokit/types" import fetch from "node-fetch" import pLimit, { type LimitFunction } from "p-limit" import type { CacheProvider } from "../cache" import type { Config } from "../config" import { GitHubTokenCliProvider, type GitHubTokenProvider } from "./token" -import type { - OrgMemberOrInvited, - OrgsGetResponse, - OrgsListMembersResponseItem, - OrgsListPendingInvitationsResponseItem, - RenovateDependencyDashboardIssue, - Repo, - ReposGetResponse, - ReposListHooksResponseItem, - ReposListTeamsResponseItem, - TeamMemberOrInvited, - TeamsListMembersResponseItem, - TeamsListPendingInvitationsResponseItem, - TeamsListResponseItem, - VulnerabilityAlert, -} from "./types" -import { undefinedForNotFound } from "./util" - -interface SearchedPullRequestListQueryResult { - search: { - pageInfo: { - hasNextPage: boolean - endCursor: string | null - } - edges: { - node: { - __typename: string - number: number - baseRepository: { - name: string - owner: { - login: string - } - defaultBranchRef: { - name: string - } - } - author: { - login: string - } - title: string - commits: { - nodes: { - commit: { - messageHeadline: string - } - }[] - } - createdAt: string - updatedAt: string - } - }[] - } -} - -export type SearchedPullRequestListItem = - SearchedPullRequestListQueryResult["search"]["edges"][0]["node"] - -interface RenovateDependencyDashboardIssueQueryResult { - repository: { - issues: { - pageInfo: { - hasNextPage: boolean - endCursor: string | null - } - edges: { - node: { - __typename: string - number: number - state: string - title: string - body: string - userContentEdits: { - nodes: - | { - createdAt: string - editor: { - login: string - } | null - }[] - | null - } | null - } - }[] - } - } -} - -interface VulnerabilityAlertsQueryResult { - repository: { - vulnerabilityAlerts: { - pageInfo: { - hasNextPage: boolean - endCursor: string | null - } - edges: Array<{ - node: VulnerabilityAlert - }> | null - } - } | null -} +import type { Repo } from "./types" interface EtagCacheItem { etag: string @@ -140,8 +40,6 @@ export class GitHubService { this.semaphore = pLimit(6) this.octokit.hook.wrap("request", async (request, options) => { - this._requestCount++ - if (options.method !== "GET") { return this.semaphore(() => request(options)) } @@ -216,13 +114,7 @@ export class GitHubService { }) } - private _requestCount = 0 - - public get requestCount(): number { - return this._requestCount - } - - public async runGraphqlQuery(query: string): Promise { + private async runGraphqlQuery(query: string): Promise { const token = await this.tokenProvider.getToken() if (token === undefined) { throw new Error("Missing token for GitHub") @@ -363,437 +255,6 @@ export class GitHubService { return repos.sort((a, b) => a.name.localeCompare(b.name)) }) } - - public async getOrgMembersList( - org: string, - ): Promise { - const options = this.octokit.orgs.listMembers.endpoint.merge({ - org, - }) - return ( - (await undefinedForNotFound( - this.octokit.paginate(options as EndpointOptions), - )) || [] - ) - } - - public async getOrgMembersInvitedList( - org: string, - ): Promise { - const options = this.octokit.orgs.listPendingInvitations.endpoint.merge({ - org, - }) - return ( - (await undefinedForNotFound( - this.octokit.paginate(options as EndpointOptions), - )) || [] - ) - } - - public async getOrgMembersListIncludingInvited( - org: string, - ): Promise { - return [ - ...(await this.getOrgMembersList(org)).map((it) => ({ - type: "member", - login: it.login, - data: it, - })), - ...(await this.getOrgMembersInvitedList(org)).map( - (it) => ({ - type: "invited", - // TODO: Fix ?? case properly - login: it.login ?? "invalid", - data: it, - }), - ), - ] - } - - public async getRepository( - owner: string, - repo: string, - ): Promise { - return this.cache.json(`get-repository-${owner}-${repo}`, async () => { - const response = await undefinedForNotFound( - this.octokit.repos.get({ - owner, - repo, - }), - ) - - return response === undefined ? undefined : response.data - }) - } - - public async getRepositoryTeamsList( - repo: ReposGetResponse, - ): Promise { - return this.cache.json(`repository-teams-list-${repo.id}`, async () => { - const options = this.octokit.repos.listTeams.endpoint.merge({ - owner: repo.owner.login, - repo: repo.name, - }) - return ( - (await undefinedForNotFound( - this.octokit.paginate(options as EndpointOptions), - )) || [] - ) - }) - } - - public async getRepositoryHooks( - owner: string, - repo: string, - ): Promise { - return this.cache.json(`repository-hooks-${owner}-${repo}`, async () => { - const options = this.octokit.repos.listWebhooks.endpoint.merge({ - owner, - repo, - }) - return ( - (await undefinedForNotFound( - this.octokit.paginate(options as EndpointOptions), - )) || [] - ) - }) - } - - public async getOrg(org: string): Promise { - const orgResponse = await this.octokit.orgs.get({ - org, - }) - return orgResponse.data - } - - public async getTeamList( - org: OrgsGetResponse, - ): Promise { - return this.cache.json(`team-list-${org.login}`, async () => { - const options = this.octokit.teams.list.endpoint.merge({ - org: org.login, - }) - return (await this.octokit.paginate( - options as EndpointOptions, - )) as TeamsListResponseItem[] - }) - } - - public async getTeamMemberList( - org: OrgsGetResponse, - team: TeamsListResponseItem, - ): Promise { - return this.cache.json(`team-member-list-${team.id}`, async () => { - const options = this.octokit.teams.listMembersInOrg.endpoint.merge({ - org: org.login, - team_slug: team.slug, - }) - return (await this.octokit.paginate( - options as EndpointOptions, - )) as TeamsListMembersResponseItem[] - }) - } - - public async getTeamMemberInvitedList( - org: OrgsGetResponse, - team: TeamsListResponseItem, - ): Promise { - return this.cache.json(`team-member-invited-list-${team.id}`, async () => { - const options = - this.octokit.teams.listPendingInvitationsInOrg.endpoint.merge({ - org: org.login, - team_slug: team.slug, - }) - return (await this.octokit.paginate( - options as EndpointOptions, - )) as TeamsListPendingInvitationsResponseItem[] - }) - } - - public async getTeamMemberListIncludingInvited( - org: OrgsGetResponse, - team: TeamsListResponseItem, - ): Promise { - return [ - ...(await this.getTeamMemberList(org, team)).map( - (it) => ({ - type: "member", - login: it.login, - data: it, - }), - ), - ...( - await this.getTeamMemberInvitedList(org, team) - ).map((it) => ({ - type: "invited", - // TODO: Fix ?? case properly - login: it.login ?? "invalid", - data: it, - })), - ] - } - - public async getSearchedPullRequestList( - owner: string, - ): Promise { - // NOTE: Changes to this must by synced with SearchedPullRequestListQueryResult. - const getQuery = (after: string | null) => `{ - search( - query: "is:open is:pr user:${owner} owner:${owner} archived:false", - type: ISSUE, - first: 50${ - after === null - ? "" - : `, - after: "${after}"` - } - ) { - pageInfo { - hasNextPage - endCursor - } - edges { - node { - __typename - ... on PullRequest { - number - baseRepository { - name - owner { - login - } - defaultBranchRef { - name - } - } - author { - login - } - title - commits(first: 3) { - nodes { - commit { - messageHeadline - } - } - } - createdAt - updatedAt - } - } - } - } -}` - - const pulls: SearchedPullRequestListItem[] = [] - let after = null - - while (true) { - const query = getQuery(after) - const res = - await this.runGraphqlQuery(query) - - pulls.push(...res.search.edges.map((it) => it.node)) - - if (!res.search.pageInfo.hasNextPage) { - break - } - - after = res.search.pageInfo.endCursor - } - - return pulls.sort((a, b) => a.createdAt.localeCompare(b.createdAt)) - } - - public async getHasVulnerabilityAlertsEnabled( - owner: string, - repo: string, - ): Promise { - try { - const response = await this.octokit.repos.checkVulnerabilityAlerts({ - owner: owner, - repo: repo, - }) - - if (response.status !== 204) { - console.log(response) - throw new Error("Unknown response - see previous log line") - } - - return true - } catch (e) { - if (e.status === 404) { - return false - } - throw e - } - } - - public async enableVulnerabilityAlerts( - owner: string, - repo: string, - ): Promise { - await this.octokit.repos.enableVulnerabilityAlerts({ - owner: owner, - repo: repo, - }) - } - - /** - * Get the vulnerability alerts for a repository. - */ - public async getVulnerabilityAlerts( - owner: string, - repo: string, - ): Promise { - // NOTE: Changes to this must by synced with VulnerabilityAlertsQueryResult. - const getQuery = (after: string | null) => `{ - repository(owner: "${owner}", name: "${repo}") { - vulnerabilityAlerts(first: 100${ - after === null ? "" : `, after: "${after}"` - }) { - pageInfo { - hasNextPage - endCursor - } - edges { - node { - state - dismissReason - vulnerableManifestFilename - vulnerableManifestPath - vulnerableRequirements - securityAdvisory { - description - identifiers { type value } - references { url } - severity - } - securityVulnerability { - package { name ecosystem } - firstPatchedVersion { identifier } - vulnerableVersionRange - } - } - } - } - } -}` - - return this.cache.json( - `vulnerability-alerts-${owner}-${repo}`, - async () => { - const result: VulnerabilityAlert[] = [] - let after = null - - while (true) { - const query = getQuery(after) - const res = - await this.runGraphqlQuery(query) - - result.push( - ...(res.repository?.vulnerabilityAlerts.edges?.map( - (it) => it.node, - ) ?? []), - ) - - if (!res.repository?.vulnerabilityAlerts.pageInfo.hasNextPage) { - break - } - - after = res.repository?.vulnerabilityAlerts.pageInfo.endCursor - } - - return result - }, - ) - } - - /** - * Get the Renovate Dependency Dashboard issue. - */ - public async getRenovateDependencyDashboardIssue( - owner: string, - repo: string, - ): Promise { - // NOTE: Changes to this must by synced with RenovateDependencyDashboardIssueQueryResult. - const getQuery = (after: string | null) => `{ - repository(owner: "${owner}", name: "${repo}") { - issues( - orderBy: {field: UPDATED_AT, direction: DESC}, - filterBy: {createdBy: "renovate[bot]"}, - states: [OPEN], - first: 100${after === null ? "" : `, after: "${after}"`} - ) { - pageInfo { - hasNextPage - endCursor - } - edges { - node { - number - state - title - body - userContentEdits(first: 5) { - nodes { - createdAt - editor { - login - } - } - } - } - } - } - } -}` - - const issues = await this.cache.json( - `renovate-bot-issues-${owner}-${repo}`, - async () => { - const result: RenovateDependencyDashboardIssue[] = [] - let after = null - - while (true) { - const query = getQuery(after) - const res = - await this.runGraphqlQuery( - query, - ) - - const nodes = res.repository?.issues.edges?.map((it) => it.node) ?? [] - - result.push( - ...nodes - .filter((it) => it.title === "Dependency Dashboard") - .map((it) => ({ - number: it.number, - body: it.body, - lastUpdatedByRenovate: - it.userContentEdits?.nodes?.filter( - (it) => it.editor?.login === "renovate", - )?.[0]?.createdAt ?? null, - })), - ) - - if (!res.repository?.issues.pageInfo.hasNextPage) { - break - } - - after = res.repository?.issues.pageInfo.endCursor - } - - return result - }, - ) - - if (issues.length == 0) { - return undefined - } - - return issues[0] - } } async function createOctokit( diff --git a/src/github/types.ts b/src/github/types.ts index fb8dd122..0d9e71ba 100644 --- a/src/github/types.ts +++ b/src/github/types.ts @@ -1,38 +1,3 @@ -import type { Endpoints } from "@octokit/types" - -export type OrgsGetResponse = Endpoints["GET /orgs/{org}"]["response"]["data"] - -export type OrgsListMembersResponseItem = Exclude< - Endpoints["GET /orgs/{org}/members"]["response"]["data"][0], - null -> - -export type OrgsListPendingInvitationsResponseItem = - Endpoints["GET /orgs/{org}/invitations"]["response"]["data"][0] - -export type ReposGetResponse = - Endpoints["GET /repos/{owner}/{repo}"]["response"]["data"] - -export type ReposListTeamsResponseItem = - Endpoints["GET /repos/{owner}/{repo}/teams"]["response"]["data"][0] - -export type ReposListHooksResponseItem = - Endpoints["GET /repos/{owner}/{repo}/hooks"]["response"]["data"][0] - -export type ReposUpdateParams = - Endpoints["PATCH /repos/{owner}/{repo}"]["parameters"] - -export type TeamsListMembersResponseItem = Exclude< - Endpoints["GET /teams/{team_id}/members"]["response"]["data"][0], - null -> - -export type TeamsListPendingInvitationsResponseItem = - Endpoints["GET /teams/{team_id}/invitations"]["response"]["data"][0] - -export type TeamsListResponseItem = - Endpoints["GET /orgs/{org}/teams"]["response"]["data"][0] - export interface Repo { name: string owner: { @@ -47,65 +12,3 @@ export interface Repo { sshUrl: string repositoryTopics: { edges: { node: { topic: { name: string } } }[] } } - -// See https://developer.github.com/v4/object/repositoryvulnerabilityalert/ -export interface VulnerabilityAlert { - dismissReason: string | null - state: "DISMISSED" | "FIXED" | "OPEN" - vulnerableManifestFilename: string - vulnerableManifestPath: string - vulnerableRequirements: string | null - securityAdvisory: { - description: string - identifiers: Array<{ - type: string - value: string - }> - references: Array<{ - url: string // URI - }> - severity: "CRITICAL" | "HIGH" | "LOW" | "MODERATE" - } | null - securityVulnerability: { - package: { - name: string - ecosystem: "COMPOSER" | "MAVEN" | "NPM" | "NUGET" | "PIP" | "RUBYGEMS" - } - firstPatchedVersion: { - identifier: string - } - vulnerableVersionRange: string - } | null -} - -export type Permission = "admin" | "push" | "pull" - -export type TeamMemberOrInvited = - | { - type: "member" - login: string - data: TeamsListMembersResponseItem - } - | { - type: "invited" - login: string - data: TeamsListPendingInvitationsResponseItem - } - -export type OrgMemberOrInvited = - | { - type: "member" - login: string - data: OrgsListMembersResponseItem - } - | { - type: "invited" - login: string - data: OrgsListPendingInvitationsResponseItem - } - -export interface RenovateDependencyDashboardIssue { - number: number - body: string - lastUpdatedByRenovate: string | null -} diff --git a/src/github/util.ts b/src/github/util.ts index c0915636..e85281da 100644 --- a/src/github/util.ts +++ b/src/github/util.ts @@ -68,16 +68,3 @@ export function getGroupedRepos(repos: Repo[]): { export function includesTopic(repo: Repo, topic: string): boolean { return repo.repositoryTopics.edges.some((it) => it.node.topic.name === topic) } - -export async function undefinedForNotFound( - value: Promise, -): Promise { - try { - return await value - } catch (e) { - if (e.name === "HttpError" && e.status === 404) { - return undefined - } - throw e - } -} diff --git a/src/index.ts b/src/index.ts index e8734789..b2bd4504 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,22 +1,6 @@ import { version } from "package.json" -import { CacheProvider } from "./cache" -import { Reporter } from "./cli/reporter" -import { createReporter } from "./cli/util" -import { Config } from "./config" -import { DefinitionFile } from "./definition" -import { createGitHubService, GitHubService } from "./github" export const VERSION = version export * as definition from "./definition" export * as github from "./github" -// Consider removing old exports later. -export { - CacheProvider, - Config, - createGitHubService, - createReporter, - DefinitionFile, - GitHubService, - Reporter, -} From 26526fc0f8945e05b1e3414d0752fdf2761c0a5b Mon Sep 17 00:00:00 2001 From: "Joakim L. Engeset" Date: Fri, 30 Jan 2026 14:37:57 +0100 Subject: [PATCH 3/9] fix: replace tsx with direct node invocation node can run ts files directly now --- package-lock.json | 24 ++---------------------- package.json | 3 +-- 2 files changed, 3 insertions(+), 24 deletions(-) diff --git a/package-lock.json b/package-lock.json index e4ef3fca..627002ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,7 +47,6 @@ "rollup": "4.56.0", "rollup-plugin-typescript2": "0.36.0", "semantic-release": "25.0.2", - "tsx": "4.21.0", "typescript": "5.9.3", "typescript-json-schema": "0.67.1", "vitest": "4.0.18" @@ -4311,6 +4310,7 @@ "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", "dev": true, "license": "MIT", + "optional": true, "dependencies": { "resolve-pkg-maps": "^1.0.0" }, @@ -8509,6 +8509,7 @@ "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", "dev": true, "license": "MIT", + "optional": true, "funding": { "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } @@ -9455,27 +9456,6 @@ "dev": true, "license": "0BSD" }, - "node_modules/tsx": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", - "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "esbuild": "~0.27.0", - "get-tsconfig": "^4.7.5" - }, - "bin": { - "tsx": "dist/cli.mjs" - }, - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - } - }, "node_modules/tunnel": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", diff --git a/package.json b/package.json index 77366c96..4d19f680 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "license": "Apache-2.0", "scripts": { - "prepare": "tsx scripts/create-definition-schema.ts && husky", + "prepare": "node scripts/create-definition-schema.ts && husky", "build": "rollup -c", "test": "vitest run --coverage src", "test:watch": "vitest --coverage src", @@ -60,7 +60,6 @@ "rollup": "4.56.0", "rollup-plugin-typescript2": "0.36.0", "semantic-release": "25.0.2", - "tsx": "4.21.0", "typescript": "5.9.3", "typescript-json-schema": "0.67.1", "vitest": "4.0.18" From f75f4cb2df9f795a622645716ca0ea713a576059 Mon Sep 17 00:00:00 2001 From: "Joakim L. Engeset" Date: Fri, 30 Jan 2026 14:50:24 +0100 Subject: [PATCH 4/9] refactor: replace node-fetch with native fetch --- package-lock.json | 391 ------------------ package.json | 2 - .../github/generate-clone-commands.ts | 1 - src/cli/commands/github/list-repos.ts | 1 - src/cli/commands/github/sync.ts | 1 - src/config.ts | 4 - src/github/service.ts | 18 +- 7 files changed, 2 insertions(+), 416 deletions(-) diff --git a/package-lock.json b/package-lock.json index 627002ba..edbd2219 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,6 @@ "find-up": "^8.0.0", "js-yaml": "^4.1.1", "keytar": "^7.9.0", - "node-fetch": "^3.3.2", "p-limit": "^7.2.0", "read": "^5.0.1", "semver": "^7.7.3", @@ -37,7 +36,6 @@ "@rollup/plugin-replace": "6.0.3", "@types/js-yaml": "4.0.9", "@types/node": "24.10.9", - "@types/node-fetch": "2.6.13", "@types/semver": "7.7.1", "@types/sprintf-js": "1.1.4", "@types/yargs": "17.0.35", @@ -2393,17 +2391,6 @@ "undici-types": "~7.16.0" } }, - "node_modules/@types/node-fetch": { - "version": "2.6.13", - "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", - "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "form-data": "^4.0.4" - } - }, "node_modules/@types/normalize-package-data": { "version": "2.4.4", "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", @@ -2769,13 +2756,6 @@ "@types/estree": "^1.0.0" } }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, - "license": "MIT" - }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2898,20 +2878,6 @@ "node": ">=6" } }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -3178,19 +3144,6 @@ "dev": true, "license": "MIT" }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/commondir": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", @@ -3450,15 +3403,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/data-uri-to-buffer": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", - "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -3501,16 +3445,6 @@ "node": ">=4.0.0" } }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -3556,21 +3490,6 @@ "node": ">=8" } }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/duplexer2": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", @@ -3753,26 +3672,6 @@ "is-arrayish": "^0.2.1" } }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -3780,35 +3679,6 @@ "dev": true, "license": "MIT" }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/esbuild": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", @@ -3981,29 +3851,6 @@ } } }, - "node_modules/fetch-blob": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", - "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "paypal", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "dependencies": { - "node-domexception": "^1.0.0", - "web-streams-polyfill": "^3.0.3" - }, - "engines": { - "node": "^12.20 || >= 14.13" - } - }, "node_modules/figures": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", @@ -4122,35 +3969,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "dev": true, - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/formdata-polyfill": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", - "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", - "license": "MIT", - "dependencies": { - "fetch-blob": "^3.1.2" - }, - "engines": { - "node": ">=12.20.0" - } - }, "node_modules/from2": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/from2/-/from2-2.3.0.tgz", @@ -4205,16 +4023,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/function-timeout": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/function-timeout/-/function-timeout-1.0.2.tgz", @@ -4249,45 +4057,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/get-stream": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", @@ -4304,20 +4073,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/get-tsconfig": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", - "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" - } - }, "node_modules/git-log-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/git-log-parser/-/git-log-parser-1.2.1.tgz", @@ -4405,19 +4160,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -4457,48 +4199,6 @@ "node": ">=8" } }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/highlight.js": { "version": "10.7.3", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", @@ -5275,16 +4975,6 @@ "marked": ">=1 <16" } }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/meow": { "version": "12.1.1", "resolved": "https://registry.npmjs.org/meow/-/meow-12.1.1.tgz", @@ -5348,29 +5038,6 @@ "node": ">=16" } }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/mimic-fn": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", @@ -5509,26 +5176,6 @@ "integrity": "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==", "license": "MIT" }, - "node_modules/node-domexception": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", - "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", - "deprecated": "Use your platform's native DOMException instead", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "github", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "engines": { - "node": ">=10.5.0" - } - }, "node_modules/node-emoji": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-2.2.0.tgz", @@ -5545,24 +5192,6 @@ "node": ">=18" } }, - "node_modules/node-fetch": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", - "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", - "license": "MIT", - "dependencies": { - "data-uri-to-buffer": "^4.0.0", - "fetch-blob": "^3.1.4", - "formdata-polyfill": "^4.0.10" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/node-fetch" - } - }, "node_modules/normalize-package-data": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-8.0.0.tgz", @@ -8503,17 +8132,6 @@ "node": ">=8" } }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, - "license": "MIT", - "optional": true, - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" - } - }, "node_modules/rollup": { "version": "4.56.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.56.0.tgz", @@ -9930,15 +9548,6 @@ "node": ">=6.0" } }, - "node_modules/web-streams-polyfill": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", - "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, "node_modules/web-worker": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.2.0.tgz", diff --git a/package.json b/package.json index 4d19f680..393c7c52 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,6 @@ "find-up": "^8.0.0", "js-yaml": "^4.1.1", "keytar": "^7.9.0", - "node-fetch": "^3.3.2", "p-limit": "^7.2.0", "read": "^5.0.1", "semver": "^7.7.3", @@ -50,7 +49,6 @@ "@rollup/plugin-replace": "6.0.3", "@types/js-yaml": "4.0.9", "@types/node": "24.10.9", - "@types/node-fetch": "2.6.13", "@types/semver": "7.7.1", "@types/sprintf-js": "1.1.4", "@types/yargs": "17.0.35", diff --git a/src/cli/commands/github/generate-clone-commands.ts b/src/cli/commands/github/generate-clone-commands.ts index 983c1a1b..827a3e6f 100644 --- a/src/cli/commands/github/generate-clone-commands.ts +++ b/src/cli/commands/github/generate-clone-commands.ts @@ -116,7 +116,6 @@ const command: CommandModule = { reporter: createReporter(), config, github: await createGitHubService({ - config, cache: createCacheProvider(config, argv), }), all: !!argv.all, diff --git a/src/cli/commands/github/list-repos.ts b/src/cli/commands/github/list-repos.ts index e99286e1..11042d9e 100644 --- a/src/cli/commands/github/list-repos.ts +++ b/src/cli/commands/github/list-repos.ts @@ -168,7 +168,6 @@ const command: CommandModule = { await listRepos({ reporter: createReporter(), github: await createGitHubService({ - config, cache: createCacheProvider(config, argv), }), includeArchived: !!argv["include-archived"], diff --git a/src/cli/commands/github/sync.ts b/src/cli/commands/github/sync.ts index 71e0587a..be882942 100644 --- a/src/cli/commands/github/sync.ts +++ b/src/cli/commands/github/sync.ts @@ -619,7 +619,6 @@ will be stored there.`), handler: async (argv) => { const config = createConfig() const github = await createGitHubService({ - config, cache: createCacheProvider(config, argv), }) const reporter = createReporter() diff --git a/src/config.ts b/src/config.ts index ea25ba62..11181390 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,5 +1,4 @@ import fs from "node:fs" -import https from "node:https" import os from "node:os" import path from "node:path" import process from "node:process" @@ -9,9 +8,6 @@ export class Config { public cwd = path.resolve(process.cwd()) public configFile = path.join(os.homedir(), ".cals-config.json") public cacheDir = cachedir("cals-cli") - public agent = new https.Agent({ - keepAlive: true, - }) private configCached?: Record = undefined private get config() { diff --git a/src/github/service.ts b/src/github/service.ts index 2c78bd5c..9583559f 100644 --- a/src/github/service.ts +++ b/src/github/service.ts @@ -3,10 +3,8 @@ import { performance } from "node:perf_hooks" import * as process from "node:process" import { Octokit } from "@octokit/rest" import type { OctokitResponse } from "@octokit/types" -import fetch from "node-fetch" import pLimit, { type LimitFunction } from "p-limit" import type { CacheProvider } from "../cache" -import type { Config } from "../config" import { GitHubTokenCliProvider, type GitHubTokenProvider } from "./token" import type { Repo } from "./types" @@ -16,21 +14,18 @@ interface EtagCacheItem { } interface GitHubServiceProps { - config: Config octokit: Octokit cache: CacheProvider tokenProvider: GitHubTokenProvider } export class GitHubService { - private config: Config public octokit: Octokit private cache: CacheProvider private tokenProvider: GitHubTokenProvider private semaphore: LimitFunction public constructor(props: GitHubServiceProps) { - this.config = props.config this.octokit = props.octokit this.cache = props.cache this.tokenProvider = props.tokenProvider @@ -132,7 +127,6 @@ export class GitHubService { method: "POST", headers, body: JSON.stringify({ query }), - agent: this.config.agent, }) requestDuration = performance.now() - requestStart return result @@ -257,20 +251,13 @@ export class GitHubService { } } -async function createOctokit( - config: Config, - tokenProvider: GitHubTokenProvider, -) { +async function createOctokit(tokenProvider: GitHubTokenProvider) { return new Octokit({ auth: await tokenProvider.getToken(), - request: { - agent: config.agent, - }, }) } interface CreateGitHubServiceProps { - config: Config cache: CacheProvider tokenProvider?: GitHubTokenProvider } @@ -281,8 +268,7 @@ export async function createGitHubService( const tokenProvider = props.tokenProvider ?? new GitHubTokenCliProvider() return new GitHubService({ - config: props.config, - octokit: await createOctokit(props.config, tokenProvider), + octokit: await createOctokit(tokenProvider), cache: props.cache, tokenProvider, }) From 94f193b94478ee50055f0c283f50e27de2c74b1d Mon Sep 17 00:00:00 2001 From: "Joakim L. Engeset" Date: Fri, 30 Jan 2026 14:51:17 +0100 Subject: [PATCH 5/9] refactor: replace semver with simple version check --- package-lock.json | 9 --------- package.json | 2 -- src/cli/index.ts | 17 +++++++++++++++-- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/package-lock.json b/package-lock.json index edbd2219..5a5823e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,6 @@ "keytar": "^7.9.0", "p-limit": "^7.2.0", "read": "^5.0.1", - "semver": "^7.7.3", "sprintf-js": "^1.1.3", "yargs": "18.0.0" }, @@ -36,7 +35,6 @@ "@rollup/plugin-replace": "6.0.3", "@types/js-yaml": "4.0.9", "@types/node": "24.10.9", - "@types/semver": "7.7.1", "@types/sprintf-js": "1.1.4", "@types/yargs": "17.0.35", "@vitest/coverage-v8": "4.0.18", @@ -2398,13 +2396,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/semver": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", - "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/sprintf-js": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@types/sprintf-js/-/sprintf-js-1.1.4.tgz", diff --git a/package.json b/package.json index 393c7c52..7c6e9eef 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,6 @@ "keytar": "^7.9.0", "p-limit": "^7.2.0", "read": "^5.0.1", - "semver": "^7.7.3", "sprintf-js": "^1.1.3", "yargs": "18.0.0" }, @@ -49,7 +48,6 @@ "@rollup/plugin-replace": "6.0.3", "@types/js-yaml": "4.0.9", "@types/node": "24.10.9", - "@types/semver": "7.7.1", "@types/sprintf-js": "1.1.4", "@types/yargs": "17.0.35", "@vitest/coverage-v8": "4.0.18", diff --git a/src/cli/index.ts b/src/cli/index.ts index 66e9e9fd..f8879aca 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,5 +1,4 @@ import process from "node:process" -import semver from "semver" import yargs from "yargs" import { hideBin } from "yargs/helpers" import { engines, version } from "../../package.json" @@ -7,8 +6,22 @@ import github from "./commands/github" declare const BUILD_TIMESTAMP: string +function parseVersion(v: string): number[] { + return v.replace(/^[^\d]*/, "").split(".").map(Number) +} + +function satisfiesMinVersion(current: string, required: string): boolean { + const cur = parseVersion(current) + const req = parseVersion(required.replace(/^>=?\s*/, "")) + for (let i = 0; i < 3; i++) { + if ((cur[i] ?? 0) > (req[i] ?? 0)) return true + if ((cur[i] ?? 0) < (req[i] ?? 0)) return false + } + return true +} + export async function main(): Promise { - if (!semver.satisfies(process.version, engines.node)) { + if (!satisfiesMinVersion(process.version, engines.node)) { console.error( `Required node version ${engines.node} not satisfied with current version ${process.version}.`, ) From b144f994e44c111d9dac35489aeaaa225ff4d1f5 Mon Sep 17 00:00:00 2001 From: "Joakim L. Engeset" Date: Fri, 30 Jan 2026 14:52:12 +0100 Subject: [PATCH 6/9] refactor: replace sprintf-js with template literal --- package-lock.json | 15 --------------- package.json | 2 -- .../commands/github/generate-clone-commands.ts | 3 +-- 3 files changed, 1 insertion(+), 19 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5a5823e4..04f0e783 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,6 @@ "keytar": "^7.9.0", "p-limit": "^7.2.0", "read": "^5.0.1", - "sprintf-js": "^1.1.3", "yargs": "18.0.0" }, "bin": { @@ -35,7 +34,6 @@ "@rollup/plugin-replace": "6.0.3", "@types/js-yaml": "4.0.9", "@types/node": "24.10.9", - "@types/sprintf-js": "1.1.4", "@types/yargs": "17.0.35", "@vitest/coverage-v8": "4.0.18", "husky": "9.1.7", @@ -2396,13 +2394,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/sprintf-js": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@types/sprintf-js/-/sprintf-js-1.1.4.tgz", - "integrity": "sha512-aWK1reDYWxcjgcIIPmQi3u+OQDuYa9b+lr6eIsGWrekJ9vr1NSjr4Eab8oQ1iKuH1ltFHpXGyerAv1a3FMKxzQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -8607,12 +8598,6 @@ "node": ">= 10.x" } }, - "node_modules/sprintf-js": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", - "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", - "license": "BSD-3-Clause" - }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", diff --git a/package.json b/package.json index 7c6e9eef..144b8acb 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,6 @@ "keytar": "^7.9.0", "p-limit": "^7.2.0", "read": "^5.0.1", - "sprintf-js": "^1.1.3", "yargs": "18.0.0" }, "devDependencies": { @@ -48,7 +47,6 @@ "@rollup/plugin-replace": "6.0.3", "@types/js-yaml": "4.0.9", "@types/node": "24.10.9", - "@types/sprintf-js": "1.1.4", "@types/yargs": "17.0.35", "@vitest/coverage-v8": "4.0.18", "husky": "9.1.7", diff --git a/src/cli/commands/github/generate-clone-commands.ts b/src/cli/commands/github/generate-clone-commands.ts index 827a3e6f..d2e9f895 100644 --- a/src/cli/commands/github/generate-clone-commands.ts +++ b/src/cli/commands/github/generate-clone-commands.ts @@ -1,7 +1,6 @@ import fs from "node:fs" import path from "node:path" import process from "node:process" -import { sprintf } from "sprintf-js" import yargs, { type CommandModule } from "yargs" import { hideBin } from "yargs/helpers" import type { Config } from "../../../config" @@ -62,7 +61,7 @@ async function generateCloneCommands({ // The output of this is used to pipe into e.g. bash. // We cannot use reporter.log as it adds additional characters. process.stdout.write( - sprintf('[ ! -e "%s" ] && git clone %s\n', repo.name, repo.sshUrl), + `[ ! -e "${repo.name}" ] && git clone ${repo.sshUrl}\n`, ) }) }) From 27113dc035d9cc7f33cf8158c840be0cd8d62fe3 Mon Sep 17 00:00:00 2001 From: "Joakim L. Engeset" Date: Fri, 30 Jan 2026 14:54:04 +0100 Subject: [PATCH 7/9] refactor: replace read with native readline --- package-lock.json | 22 ------------------ package.json | 1 - src/cli/commands/github/set-token.ts | 5 ++-- src/cli/commands/github/sync.ts | 5 ++-- src/cli/reporter.ts | 34 ++++++++++++++++++++++++++++ 5 files changed, 38 insertions(+), 29 deletions(-) diff --git a/package-lock.json b/package-lock.json index 04f0e783..885a3f91 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,6 @@ "js-yaml": "^4.1.1", "keytar": "^7.9.0", "p-limit": "^7.2.0", - "read": "^5.0.1", "yargs": "18.0.0" }, "bin": { @@ -5080,15 +5079,6 @@ "dev": true, "license": "MIT" }, - "node_modules/mute-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", - "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -7975,18 +7965,6 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "license": "ISC" }, - "node_modules/read": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/read/-/read-5.0.1.tgz", - "integrity": "sha512-+nsqpqYkkpet2UVPG8ZiuE8d113DK4vHYEoEhcrXBAlPiq6di7QRTuNiKQAbaRYegobuX2BpZ6QjanKOXnJdTA==", - "license": "ISC", - "dependencies": { - "mute-stream": "^3.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, "node_modules/read-package-up": { "version": "12.0.0", "resolved": "https://registry.npmjs.org/read-package-up/-/read-package-up-12.0.0.tgz", diff --git a/package.json b/package.json index 144b8acb..90cf38c1 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,6 @@ "js-yaml": "^4.1.1", "keytar": "^7.9.0", "p-limit": "^7.2.0", - "read": "^5.0.1", "yargs": "18.0.0" }, "devDependencies": { diff --git a/src/cli/commands/github/set-token.ts b/src/cli/commands/github/set-token.ts index a0a1d9c8..a9d35756 100644 --- a/src/cli/commands/github/set-token.ts +++ b/src/cli/commands/github/set-token.ts @@ -1,7 +1,6 @@ -import { read } from "read" import type { CommandModule } from "yargs" import { GitHubTokenCliProvider } from "../../../github/token" -import type { Reporter } from "../../reporter" +import { type Reporter, readInput } from "../../reporter" import { createReporter } from "../../util" async function setToken({ @@ -18,7 +17,7 @@ async function setToken({ reporter.info( "https://github.com/settings/tokens/new?scopes=repo:status,read:repo_hook", ) - const inputToken = await read({ + const inputToken = await readInput({ prompt: "Enter new GitHub API token: ", silent: true, }) diff --git a/src/cli/commands/github/sync.ts b/src/cli/commands/github/sync.ts index be882942..83be7953 100644 --- a/src/cli/commands/github/sync.ts +++ b/src/cli/commands/github/sync.ts @@ -4,7 +4,6 @@ import process from "node:process" import { findUp } from "find-up" import yaml from "js-yaml" import pLimit from "p-limit" -import { read } from "read" import type { CommandModule } from "yargs" import type { Config } from "../../../config" import { DefinitionFile, getRepos } from "../../../definition" @@ -19,7 +18,7 @@ import { createGitHubService, type GitHubService, } from "../../../github/service" -import type { Reporter } from "../../reporter" +import { type Reporter, readInput } from "../../reporter" import { createCacheProvider, createConfig, createReporter } from "../../util" const CALS_YAML = ".cals.yaml" @@ -338,7 +337,7 @@ async function getExpectedRepos( } async function getInput(prompt: string): Promise { - return read({ + return readInput({ prompt, timeout: 60000, }) diff --git a/src/cli/reporter.ts b/src/cli/reporter.ts index 4d4436af..dfd5b11f 100644 --- a/src/cli/reporter.ts +++ b/src/cli/reporter.ts @@ -9,6 +9,40 @@ function clearLine(stdout: NodeJS.WriteStream) { readline.cursorTo(stdout, 0) } +export async function readInput(options: { + prompt: string + silent?: boolean + timeout?: number +}): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }) + + if (options.silent) { + // Mute output for password entry + ;(rl as any)._writeToOutput = () => {} + } + + return new Promise((resolve, reject) => { + const timer = options.timeout + ? setTimeout(() => { + rl.close() + reject(new Error("Input timed out")) + }, options.timeout) + : null + + rl.question(options.prompt, (answer) => { + if (timer) clearTimeout(timer) + rl.close() + if (options.silent) { + process.stdout.write("\n") + } + resolve(answer) + }) + }) +} + export class Reporter { public stdout = process.stdout public stderr = process.stderr From 4a5ab670a0677104ab8ce57bbc49fbe608833344 Mon Sep 17 00:00:00 2001 From: "Joakim L. Engeset" Date: Fri, 30 Jan 2026 14:59:50 +0100 Subject: [PATCH 8/9] fix: use correct fmt makefile target --- Makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index ba4a7836..2b8d4a04 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ all: build .PHONY: build -build: clean install format lint-fix prepare build-cli test +build: clean install fmt lint-fix prepare build-cli test .PHONY: ci ci: install fmt-check lint prepare build-cli test @@ -40,7 +40,7 @@ fmt: npm run format .PHONY: fmt-check -format: +fmt-check: npm run format:check .PHONY: clean From cc2c2735d78c80f96736487fe04735dd61e191b8 Mon Sep 17 00:00:00 2001 From: "Joakim L. Engeset" Date: Fri, 30 Jan 2026 15:00:12 +0100 Subject: [PATCH 9/9] chore: format code --- src/cli/index.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index f8879aca..cb37ecb0 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -7,7 +7,10 @@ import github from "./commands/github" declare const BUILD_TIMESTAMP: string function parseVersion(v: string): number[] { - return v.replace(/^[^\d]*/, "").split(".").map(Number) + return v + .replace(/^[^\d]*/, "") + .split(".") + .map(Number) } function satisfiesMinVersion(current: string, required: string): boolean {