import { PipelineRunner } from "./pipeline"; import type { NodePgDatabase } from "drizzle-orm/node-postgres"; import { getDatabase, schema } from "@/db"; import { logger } from "@/lib/logger"; import { eq } from "drizzle-orm"; import path from "path"; import fs from "fs/promises"; import { existsSync } from "fs"; import { getGit } from "@/lib/git"; // Initialize Runner (Singleton-ish for this context) const runner = new PipelineRunner({ workDir: path.join(process.cwd(), "data", "runner", "work"), artifactsDir: path.join(process.cwd(), "data", "runner", "artifacts"), cacheDir: path.join(process.cwd(), "data", "runner", "cache") }); // Ensure dirs exist async function ensureDirs() { await fs.mkdir(path.join(process.cwd(), "data", "runner", "work"), { recursive: true }); await fs.mkdir(path.join(process.cwd(), "data", "runner", "artifacts"), { recursive: true }); await fs.mkdir(path.join(process.cwd(), "data", "runner", "cache"), { recursive: true }); } ensureDirs(); export async function triggerRepoWorkflows(repoId: string, commitSha: string, ref: string, pusherId: string) { logger.info({ repoId, ref, commitSha }, "Checking workflows"); try { // 1. Get Repo Disk Path const db = getDatabase() as NodePgDatabase; const repo = await db.query.repositories.findFirst({ where: eq(schema.repositories.id, repoId) }); if (!repo) return; // 2. For cloud storage, we need to resolve the repo path to a local directory // For local storage, diskPath is already local. const { resolveRepoPath } = await import("./git-storage"); const { getGit } = await import("./git"); let localRepoPath: string; try { localRepoPath = await resolveRepoPath(repo.diskPath); } catch (resolveErr) { // If we can't resolve the path (e.g., repo doesn't exist yet in storage), skip workflows logger.debug({ err: resolveErr, diskPath: repo.diskPath }, "Could not resolve repo path for workflows, skipping"); return; } const git = getGit(localRepoPath); // Look for .github/workflows/*.yml in the actual repo content // List files in .github/workflows const treeInfo = await git.raw(["ls-tree", "-r", "--name-only", commitSha, ".github/workflows"]); const workflowFiles = treeInfo.split("\n").filter(f => f.endsWith(".yml") || f.endsWith(".yaml")); if (workflowFiles.length === 0) { logger.debug("No workflows found"); return; } logger.info({ count: workflowFiles.length, files: workflowFiles }, "Found workflows"); // 3. Process each workflow for (const file of workflowFiles) { try { const content = await git.show([`${commitSha}:${file}`]); // Parse manually since PipelineRunner expects a file path usually, // but we can create a temp file or refactor runner. // Let's write to a temp file for the runner to parse const tempWorkflowPath = path.join("/tmp", `workflow-${crypto.randomUUID()}.yml`); await fs.writeFile(tempWorkflowPath, content); const config = await runner.parseWorkflow(tempWorkflowPath); await fs.unlink(tempWorkflowPath); // Clean up // Get changed files by diffing with parent commit let changedPaths: string[] = []; try { // Get parent commit(s) const parentOutput = await git.raw(["rev-parse", `${commitSha}^`]).catch(() => ""); const parentSha = parentOutput.trim(); if (parentSha) { // Get diff between parent and current commit const diffOutput = await git.raw([ "diff", "--name-only", parentSha, commitSha ]); changedPaths = diffOutput.split("\n").filter(Boolean); logger.debug({ changedPaths, count: changedPaths.length }, "Changed files in push"); } else { // Initial commit - all files are new const treeOutput = await git.raw(["ls-tree", "-r", "--name-only", commitSha]); changedPaths = treeOutput.split("\n").filter(Boolean); logger.debug({ changedPaths, count: changedPaths.length }, "Initial commit files"); } } catch (diffErr) { logger.warn({ err: diffErr }, "Could not get changed paths, skipping path filter"); } // Check Trigger with path filtering const shouldRun = runner.shouldTrigger(config, "push", { ref: ref, paths: changedPaths.length > 0 ? changedPaths : undefined, }); if (shouldRun) { logger.info({ workflow: config.name }, "Triggering workflow"); // Load repository secrets for injection (encrypted at rest; // decrypted only at dispatch, never logged) let secrets: Record = {}; try { const { listRepoSecrets } = await import("./workflow-secrets"); if (typeof listRepoSecrets === "function") { const found = await listRepoSecrets(repoId); for (const s of found) secrets[s.name] = s.value; } } catch (err) { logger.debug({ err }, "No secrets loaded for workflow"); } // Run through the persister so push-triggered runs are // visible in the Actions UI and gated by the merge queue const { persistAndRunWorkflow } = await import("./workflow-run-persister"); persistAndRunWorkflow(runner, { repositoryId: repoId, repositoryPath: repo.diskPath, workflowPath: file, workflowName: config.name || file, config, branch: ref.replace("refs/heads/", ""), commit: commitSha, triggeredBy: "push", pusherId: pusherId || undefined, secrets: Object.keys(secrets).length > 0 ? secrets : undefined, }).catch(err => { logger.error({ err, workflow: config.name }, "Workflow failed"); }); } else { logger.debug({ workflow: config.name }, "Skipping workflow (conditions not met)"); } } catch (e) { logger.error({ err: e, file }, "Error processing workflow"); } } } catch (e) { // likely dir doesn't exist // console.log(" No .github/workflows directory or error checking."); } } /** * Trigger CI on a merge-queue speculative branch (WS1-09). * * The speculative branch contains the combined changes of the queued PRs. * Workflow triggers are evaluated against the PRs' BASE branch (so branch * filters match), while the run is recorded against the spec branch so the * result is attributable to the speculative build. */ export async function triggerSpecWorkflow(options: { repositoryId: string; repositoryPath: string; specBranch: string; baseBranch: string; commitSha: string; }): Promise<{ triggered: number }> { const { repositoryId, repositoryPath, specBranch, baseBranch, commitSha } = options; const git = getGit(repositoryPath); let triggered = 0; // List workflow files at the spec head let workflowFiles: string[] = []; try { const treeInfo = await git.raw(["ls-tree", "-r", "--name-only", commitSha, ".github/workflows"]); workflowFiles = treeInfo.split("\n").filter((f: string) => f.endsWith(".yml") || f.endsWith(".yaml")); } catch { return { triggered: 0 }; } if (workflowFiles.length === 0) return { triggered: 0 }; // Changed files vs base (for path filters) let changedPaths: string[] = []; try { const diffOutput = await git.raw(["diff", "--name-only", `origin/${baseBranch}`, commitSha]); changedPaths = diffOutput.split("\n").filter(Boolean); } catch {} for (const file of workflowFiles) { try { const content = await git.show([`${commitSha}:${file}`]); const tempWorkflowPath = path.join("/tmp", `workflow-${crypto.randomUUID()}.yml`); await fs.writeFile(tempWorkflowPath, content); const config = await runner.parseWorkflow(tempWorkflowPath); await fs.unlink(tempWorkflowPath); const shouldRun = runner.shouldTrigger(config, "push", { ref: `refs/heads/${baseBranch}`, paths: changedPaths.length > 0 ? changedPaths : undefined, }); if (!shouldRun) continue; // Load secrets for injection let secrets: Record = {}; try { const { listRepoSecrets } = await import("./workflow-secrets"); const found = await listRepoSecrets(repositoryId); for (const s of found) secrets[s.name] = s.value; } catch {} const { persistAndRunWorkflow } = await import("./workflow-run-persister"); persistAndRunWorkflow(runner, { repositoryId, repositoryPath, workflowPath: file, workflowName: config.name || file, config, branch: specBranch, commit: commitSha, triggeredBy: "push", secrets: Object.keys(secrets).length > 0 ? secrets : undefined, }).catch((err) => logger.error({ err, file }, "Speculative workflow failed")); triggered++; } catch (e) { logger.error({ err: e, file }, "Error processing speculative workflow"); } } if (triggered > 0) { logger.info({ specBranch, triggered }, "Speculative builds triggered"); } return { triggered }; }