diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml new file mode 100644 index 0000000..1602311 --- /dev/null +++ b/.github/workflows/pr-check.yml @@ -0,0 +1,31 @@ +name: PR Check + +on: + pull_request: + branches: + - main + +jobs: + check: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Setup Deno + uses: denoland/setup-deno@e95548e56dfa95d4e1a28d6f422fafe75c4c26fb # v2.0.3 + with: + deno-version: v2.x + + - name: Check formatting + run: deno fmt --check + + - name: Lint + run: deno lint + + - name: Type check + run: deno check main.ts + + - name: Run tests + run: deno test diff --git a/README.md b/README.md index 1403502..b6dc747 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,8 @@ reference GitHub Actions by providing: - Commit SHA retrieval for specific version tags - Immutability status checking for releases - Ready-to-use SHA-pinned references +- **Workflow analysis** with update level detection (major/minor/patch) +- **Safe update suggestions** that avoid breaking changes ## Why Use This? @@ -103,6 +105,9 @@ Once configured, ask Claude to look up GitHub Actions: - "Get the secure reference for actions/setup-node@v4" - "Check if actions/cache@v4.2.0 is immutable" - "List all versions of actions/upload-artifact" +- "Analyze my workflow file for outdated actions" +- "Suggest safe updates for my CI workflow" +- "What's the latest v4.x version of actions/checkout?" ## Tool: `lookup_action` @@ -118,19 +123,131 @@ Once configured, ask Claude to look up GitHub Actions: ``` Action: actions/checkout -Latest Version: v4.2.2 - Commit SHA: 11bd71901bbe5b1630ceea73d27597364c9af683 - Immutable: Yes - Published: 2024-10-23T14:05:06Z +Latest Version: v6.0.1 + Commit SHA: 8e8c483db84b4bee98b60c0593521ed34d9990e8 + Immutable: No + Published: 2025-12-02T16:38:59Z Recommended Usage (SHA-pinned): - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 Security Notes: - - This release is immutable - the tag and assets are protected from modification. + - WARNING: This release is NOT immutable. The tag could potentially be moved to a different commit. + - Using the SHA-pinned reference provides protection against tag tampering. - SHA-pinned references prevent supply chain attacks by ensuring you always use the exact same code. ``` +## Tool: `analyze_workflow` + +Analyze a GitHub Actions workflow file and show version status for all actions. +Reports current vs latest versions, update levels (major/minor/patch), and risk +assessment. + +### Parameters + +| Parameter | Type | Required | Description | +| ------------------ | ------- | -------- | ---------------------------------------------------- | +| `workflow_content` | string | Yes | The workflow YAML content to analyze | +| `only_updates` | boolean | No | Only show actions that need updates (default: false) | + +### Example Output + +``` +## Summary +Total actions: 6 +Up to date: 1 +Major updates available: 2 ⚠️ +Minor updates available: 2 +Patch updates available: 1 + +## Actions + +| Action | Current | Latest | Update | Risk | +|--------|---------|--------|--------|------| +| actions/checkout | v4.2.2 | v6.0.1 | ⚠️ Major | 🔴 High | +| actions/setup-node | v4.1.0 | v6.2.0 | ⚠️ Major | 🔴 High | +| docker/login-action | v3.3.0 | v3.6.0 | 📦 Minor | 🟡 Medium | +| docker/build-push-action | v6.9.0 | v6.18.0 | 📦 Minor | 🟡 Medium | +| appleboy/ssh-action | v1.2.0 | v1.2.4 | 🔧 Patch | 🟢 Low | + +## Safe Updates (Minor/Patch) +... + +## Major Updates (Review Required) +... +``` + +## Tool: `suggest_updates` + +Suggest safe updates for GitHub Actions in a workflow. Returns only safe updates +(minor/patch) and suggestions to stay current within major versions. + +### Parameters + +| Parameter | Type | Required | Description | +| ------------------ | ------ | -------- | ---------------------------------------------------------------------------- | +| `workflow_content` | string | Yes | The workflow YAML content to analyze | +| `risk_tolerance` | string | No | `"patch"` = only patches, `"minor"` = patch + minor (default), `"all"` = all | + +### Example Output + +``` +## Summary +Total actions analyzed: 6 +Already up to date: 1 +Safe updates available: 3 +Actions with major updates: 2 (staying on current major) + +## Safe Updates +These updates are safe to apply: + +### 📦 docker/login-action: v3.3.0 → v3.6.0 +Minor version update - new features, backwards compatible + +uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.6.0 + +### 🔧 appleboy/ssh-action: v1.2.0 → v1.2.4 +Patch version update - bug fixes only + +uses: appleboy/ssh-action@2ead5e36573714d0d3cfcbac3646c3e0f09ec849 # v1.2.4 + +## Updates Within Current Major +These actions have major updates available, but you can safely update within your current major version: + +### actions/checkout: v4.2.2 → v4.2.2 +Safe update within v4.x (latest overall is v6.0.1) + +uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 +``` + +## Tool: `get_latest_in_major` + +Get the latest version of a GitHub Action within the same major version. Useful +for safe updates that avoid breaking changes. + +### Parameters + +| Parameter | Type | Required | Description | +| --------- | ------ | -------- | ------------------------------------------------------------------------ | +| `action` | string | Yes | Action reference with version (e.g., `actions/checkout@v4` or `@v4.1.0`) | + +### Example Output + +``` +Action: actions/checkout +Current Version: v4 +Major Version: v4 + +Latest in v4.x: v4.2.2 + Commit SHA: 11bd71901bbe5b1630ceea73d27597364c9af683 + Immutable: Yes + +Note: Latest overall is v6.0.1 + +Recommended Usage (SHA-pinned): + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 +``` + ## Authentication The service supports multiple authentication methods, checked in the following @@ -252,10 +369,10 @@ When set, the service will: ``` Action: actions/checkout -Latest Version: v4.2.1 - Commit SHA: abc123... - Immutable: Yes - Published: 2024-10-15T10:00:00Z (7 days ago) +Latest Version: v6.0.1 + Commit SHA: 8e8c483db84b4bee98b60c0593521ed34d9990e8 + Immutable: No + Published: 2025-12-02T16:38:59Z (52 days ago) Security Notes: - Minimum release age filter active: only considering releases at least 5 days old. diff --git a/deno.json b/deno.json index 0ef795a..fbba78b 100644 --- a/deno.json +++ b/deno.json @@ -8,11 +8,13 @@ "compile": "deno compile --allow-net --allow-env --allow-run=gh -o github-actions-mcp main.ts", "check": "deno check main.ts", "lint": "deno lint", - "fmt": "deno fmt" + "fmt": "deno fmt", + "test": "deno test" }, "imports": { "@modelcontextprotocol/sdk": "npm:@modelcontextprotocol/sdk@1.25.1", - "zod": "npm:zod@3.25.76" + "zod": "npm:zod@3.25.76", + "@std/assert": "jsr:@std/assert@1" }, "compilerOptions": { "strict": true diff --git a/deno.lock b/deno.lock index 2abc003..939f7c9 100644 --- a/deno.lock +++ b/deno.lock @@ -1,9 +1,23 @@ { "version": "5", "specifiers": { + "jsr:@std/assert@*": "1.0.17", + "jsr:@std/assert@1": "1.0.17", + "jsr:@std/internal@^1.0.12": "1.0.12", "npm:@modelcontextprotocol/sdk@1.25.1": "1.25.1_zod@3.25.76_ajv@8.17.1_express@5.2.1", "npm:zod@3.25.76": "3.25.76" }, + "jsr": { + "@std/assert@1.0.17": { + "integrity": "df5ebfffe77c03b3fa1401e11c762cc8f603d51021c56c4d15a8c7ab45e90dbe", + "dependencies": [ + "jsr:@std/internal" + ] + }, + "@std/internal@1.0.12": { + "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" + } + }, "npm": { "@hono/node-server@1.19.9_hono@4.11.4": { "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", @@ -514,6 +528,7 @@ }, "workspace": { "dependencies": [ + "jsr:@std/assert@1", "npm:@modelcontextprotocol/sdk@1.25.1", "npm:zod@3.25.76" ] diff --git a/main.ts b/main.ts index b44cd24..8b54bc5 100644 --- a/main.ts +++ b/main.ts @@ -9,6 +9,16 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; import { formatResultAsText, lookupAction } from "./src/tools/lookup-action.ts"; +import { + analyzeWorkflow, + formatAnalyzeResultAsText, +} from "./src/tools/analyze-workflow.ts"; +import { + formatLatestInMajorAsText, + formatSuggestUpdatesAsText, + getLatestInMajorVersion, + suggestUpdates, +} from "./src/tools/suggest-updates.ts"; // Create the MCP server const server = new McpServer({ @@ -65,6 +75,144 @@ server.tool( }, ); +// Register the analyze_workflow tool +server.tool( + "analyze_workflow", + "Analyze a GitHub Actions workflow file and show version status for all actions. " + + "Reports current vs latest versions, update levels (major/minor/patch), and risk assessment.", + { + workflow_content: z + .string() + .describe("The workflow YAML content to analyze"), + only_updates: z + .boolean() + .optional() + .describe("Only show actions that need updates (default: false)"), + }, + async ({ workflow_content, only_updates }) => { + try { + const result = await analyzeWorkflow({ + workflow_content, + only_updates, + }); + const text = formatAnalyzeResultAsText(result); + + return { + content: [ + { + type: "text" as const, + text, + }, + ], + }; + } catch (error) { + const message = error instanceof Error + ? error.message + : "Unknown error occurred"; + return { + content: [ + { + type: "text" as const, + text: `Error: ${message}`, + }, + ], + isError: true, + }; + } + }, +); + +// Register the suggest_updates tool +server.tool( + "suggest_updates", + "Suggest safe updates for GitHub Actions in a workflow. " + + "Returns only safe updates (minor/patch) and suggestions to stay current within major versions.", + { + workflow_content: z + .string() + .describe("The workflow YAML content to analyze"), + risk_tolerance: z + .enum(["patch", "minor", "all"]) + .optional() + .describe( + "Risk tolerance: 'patch' = only patches, 'minor' = patch + minor (default), 'all' = include major", + ), + }, + async ({ workflow_content, risk_tolerance }) => { + try { + const result = await suggestUpdates({ + workflow_content, + risk_tolerance, + }); + const text = formatSuggestUpdatesAsText(result); + + return { + content: [ + { + type: "text" as const, + text, + }, + ], + }; + } catch (error) { + const message = error instanceof Error + ? error.message + : "Unknown error occurred"; + return { + content: [ + { + type: "text" as const, + text: `Error: ${message}`, + }, + ], + isError: true, + }; + } + }, +); + +// Register the get_latest_in_major tool +server.tool( + "get_latest_in_major", + "Get the latest version of a GitHub Action within the same major version. " + + "Useful for safe updates that avoid breaking changes.", + { + action: z + .string() + .describe( + "Action reference with version, e.g., 'actions/checkout@v4' or 'actions/setup-node@v4.1.0'", + ), + }, + async ({ action }) => { + try { + const result = await getLatestInMajorVersion({ action }); + const text = formatLatestInMajorAsText(result); + + return { + content: [ + { + type: "text" as const, + text, + }, + ], + }; + } catch (error) { + const message = error instanceof Error + ? error.message + : "Unknown error occurred"; + return { + content: [ + { + type: "text" as const, + text: `Error: ${message}`, + }, + ], + isError: true, + }; + } + }, +); + // Start the server with stdio transport async function main() { const transport = new StdioServerTransport(); diff --git a/src/github/client.ts b/src/github/client.ts index 0d6597f..b46bb4f 100644 --- a/src/github/client.ts +++ b/src/github/client.ts @@ -38,7 +38,7 @@ export class GitHubClient { } if (this.tokenPromise) { - return this.tokenPromise; + return await this.tokenPromise; } this.tokenPromise = (async () => { diff --git a/src/tools/analyze-workflow.ts b/src/tools/analyze-workflow.ts new file mode 100644 index 0000000..3e1c6a9 --- /dev/null +++ b/src/tools/analyze-workflow.ts @@ -0,0 +1,399 @@ +/** + * Analyze GitHub Actions workflow files for version updates + */ + +import { GitHubClient } from "../github/client.ts"; +import type { GitHubRelease } from "../github/types.ts"; +import { + formatSecureActionReference, + getUpdateLevel, + getUpdateRisk, + matchesMajorVersion, + parseVersion, + type UpdateLevel, +} from "../utils/parse-action.ts"; +import { parseWorkflow, type WorkflowAction } from "../utils/parse-workflow.ts"; + +/** + * Information about a single action's version status + */ +export interface ActionVersionInfo { + /** Action identifier (owner/repo) */ + action: string; + /** Current version in use */ + currentVersion: string | null; + /** Current commit SHA if using SHA-pinned reference */ + currentSha: string | null; + /** Latest available version */ + latestVersion: string | null; + /** Latest version within the same major */ + latestInMajor: string | null; + /** Commit SHA for latest version */ + latestSha: string | null; + /** Commit SHA for latest in major */ + latestInMajorSha: string | null; + /** Type of update needed */ + updateLevel: UpdateLevel; + /** Risk level for the update */ + risk: "high" | "medium" | "low" | "none"; + /** Whether the latest release is immutable */ + immutable: boolean; + /** Secure reference for the recommended version */ + secureReference: string | null; + /** Jobs/steps where this action is used */ + usedIn: { job?: string; step?: string; line?: number }[]; + /** Error message if lookup failed */ + error?: string; +} + +/** + * Result of analyzing a workflow + */ +export interface AnalyzeWorkflowResult { + /** Workflow name if specified */ + workflowName?: string; + /** All actions analyzed */ + actions: ActionVersionInfo[]; + /** Summary statistics */ + summary: { + total: number; + upToDate: number; + majorUpdates: number; + minorUpdates: number; + patchUpdates: number; + errors: number; + }; + /** Parsing errors from the workflow */ + parsingErrors: string[]; +} + +/** + * Input for analyze_workflow tool + */ +export interface AnalyzeWorkflowInput { + /** Workflow YAML content */ + workflow_content: string; + /** Only show actions that need updates */ + only_updates?: boolean; + /** Include latest-in-major suggestions for major updates */ + include_safe_updates?: boolean; +} + +/** + * Get the latest release within a specific major version + */ +async function getLatestInMajor( + client: GitHubClient, + owner: string, + repo: string, + majorVersion: number, +): Promise<{ release: GitHubRelease; sha: string } | null> { + try { + const releases = await client.listReleases(owner, repo); + const matching = releases + .filter((r) => !r.draft && !r.prerelease) + .filter((r) => matchesMajorVersion(r.tag_name, majorVersion)) + .sort((a, b) => { + const aVer = parseVersion(a.tag_name); + const bVer = parseVersion(b.tag_name); + if (aVer.minor !== bVer.minor) return bVer.minor - aVer.minor; + return bVer.patch - aVer.patch; + }); + + if (matching.length === 0) return null; + + const release = matching[0]; + const sha = await client.getCommitShaForTag(owner, repo, release.tag_name); + return { release, sha }; + } catch { + return null; + } +} + +/** + * Analyze a single action's version status + */ +async function analyzeAction( + action: WorkflowAction, + allActions: WorkflowAction[], +): Promise { + const { owner, repo, version, isCommitSha } = action.parsed; + const actionId = `${owner}/${repo}`; + + // Find all usages of this action + const usedIn = allActions + .filter((a) => a.parsed.owner === owner && a.parsed.repo === repo) + .map((a) => ({ job: a.job, step: a.step, line: a.line })); + + const client = new GitHubClient(owner); + + try { + // Get latest release + const latestRelease = await client.getLatestRelease(owner, repo); + const latestSha = await client.getCommitShaForTag( + owner, + repo, + latestRelease.tag_name, + ); + + // Determine current version + let currentVersion: string | null = version || null; + let currentSha: string | null = null; + + if (isCommitSha && version) { + currentSha = version; + currentVersion = null; // We don't know the version for SHA references + } + + // Calculate update level + let updateLevel: UpdateLevel = "none"; + if (currentVersion) { + updateLevel = getUpdateLevel(currentVersion, latestRelease.tag_name); + } else if (currentSha && currentSha !== latestSha) { + // SHA reference that doesn't match latest - mark as potentially outdated + updateLevel = "major"; // Conservative - we don't know the actual version + } + + // Get latest in major if there's a major update available + let latestInMajor: string | null = null; + let latestInMajorSha: string | null = null; + + if (updateLevel === "major" && currentVersion) { + const currentMajor = parseVersion(currentVersion).major; + const inMajor = await getLatestInMajor(client, owner, repo, currentMajor); + if (inMajor) { + latestInMajor = inMajor.release.tag_name; + latestInMajorSha = inMajor.sha; + } + } + + // Determine which version to recommend + const recommendedVersion = updateLevel === "major" && latestInMajor + ? latestInMajor + : latestRelease.tag_name; + const recommendedSha = updateLevel === "major" && latestInMajorSha + ? latestInMajorSha + : latestSha; + + return { + action: actionId, + currentVersion, + currentSha, + latestVersion: latestRelease.tag_name, + latestInMajor, + latestSha, + latestInMajorSha, + updateLevel, + risk: getUpdateRisk(updateLevel), + immutable: latestRelease.immutable ?? false, + secureReference: formatSecureActionReference( + owner, + repo, + recommendedSha, + recommendedVersion, + ), + usedIn, + }; + } catch (error) { + return { + action: actionId, + currentVersion: version || null, + currentSha: isCommitSha && version ? version : null, + latestVersion: null, + latestInMajor: null, + latestSha: null, + latestInMajorSha: null, + updateLevel: "none", + risk: "none", + immutable: false, + secureReference: null, + usedIn, + error: error instanceof Error ? error.message : "Unknown error", + }; + } +} + +/** + * Analyze a workflow and return version information for all actions + */ +export async function analyzeWorkflow( + input: AnalyzeWorkflowInput, +): Promise { + const workflow = parseWorkflow(input.workflow_content); + + // Deduplicate actions by owner/repo + const seen = new Set(); + const uniqueActions: WorkflowAction[] = []; + + for (const action of workflow.actions) { + const key = `${action.parsed.owner}/${action.parsed.repo}`; + if (!seen.has(key)) { + seen.add(key); + uniqueActions.push(action); + } + } + + // Analyze each unique action + const results = await Promise.all( + uniqueActions.map((action) => analyzeAction(action, workflow.actions)), + ); + + // Filter if only_updates is specified + let actions = results; + if (input.only_updates) { + actions = results.filter((a) => a.updateLevel !== "none" || a.error); + } + + // Calculate summary + const summary = { + total: results.length, + upToDate: results.filter((a) => a.updateLevel === "none" && !a.error) + .length, + majorUpdates: results.filter((a) => a.updateLevel === "major").length, + minorUpdates: results.filter((a) => a.updateLevel === "minor").length, + patchUpdates: results.filter((a) => a.updateLevel === "patch").length, + errors: results.filter((a) => a.error).length, + }; + + return { + workflowName: workflow.name, + actions, + summary, + parsingErrors: workflow.errors, + }; +} + +/** + * Format analyze workflow result as text + */ +export function formatAnalyzeResultAsText( + result: AnalyzeWorkflowResult, +): string { + const lines: string[] = []; + + if (result.workflowName) { + lines.push(`Workflow: ${result.workflowName}`); + lines.push(""); + } + + // Summary + lines.push("## Summary"); + lines.push(`Total actions: ${result.summary.total}`); + lines.push(`Up to date: ${result.summary.upToDate}`); + if (result.summary.majorUpdates > 0) { + lines.push(`Major updates available: ${result.summary.majorUpdates} ⚠️`); + } + if (result.summary.minorUpdates > 0) { + lines.push(`Minor updates available: ${result.summary.minorUpdates}`); + } + if (result.summary.patchUpdates > 0) { + lines.push(`Patch updates available: ${result.summary.patchUpdates}`); + } + if (result.summary.errors > 0) { + lines.push(`Errors: ${result.summary.errors}`); + } + lines.push(""); + + // Action table + lines.push("## Actions"); + lines.push(""); + lines.push( + "| Action | Current | Latest | Update | Risk |", + ); + lines.push("|--------|---------|--------|--------|------|"); + + for (const action of result.actions) { + const current = action.currentVersion || action.currentSha?.slice(0, 7) || + "?"; + const latest = action.latestVersion || "?"; + const updateIcon = action.updateLevel === "major" + ? "⚠️ Major" + : action.updateLevel === "minor" + ? "📦 Minor" + : action.updateLevel === "patch" + ? "🔧 Patch" + : action.error + ? "❌ Error" + : "✅ Current"; + const risk = action.risk === "high" + ? "🔴 High" + : action.risk === "medium" + ? "🟡 Medium" + : action.risk === "low" + ? "🟢 Low" + : "—"; + + lines.push( + `| ${action.action} | ${current} | ${latest} | ${updateIcon} | ${risk} |`, + ); + } + + // Safe updates section + const safeUpdates = result.actions.filter( + (a) => a.updateLevel === "minor" || a.updateLevel === "patch", + ); + if (safeUpdates.length > 0) { + lines.push(""); + lines.push("## Safe Updates (Minor/Patch)"); + lines.push( + "These updates are generally safe and unlikely to break your workflow:", + ); + lines.push(""); + for (const action of safeUpdates) { + if (action.secureReference) { + lines.push(`\`\`\`yaml`); + lines.push(`uses: ${action.secureReference}`); + lines.push(`\`\`\``); + } + } + } + + // Major updates with safe alternatives + const majorUpdates = result.actions.filter((a) => a.updateLevel === "major"); + if (majorUpdates.length > 0) { + lines.push(""); + lines.push("## Major Updates (Review Required)"); + lines.push( + "These updates may contain breaking changes. Consider staying on current major:", + ); + lines.push(""); + for (const action of majorUpdates) { + lines.push(`### ${action.action}`); + lines.push(`Current: ${action.currentVersion}`); + lines.push(`Latest: ${action.latestVersion}`); + if (action.latestInMajor) { + lines.push( + `Latest in current major: ${action.latestInMajor} (safe update)`, + ); + if (action.latestInMajorSha) { + lines.push(""); + lines.push("Safe update (stay on current major):"); + lines.push(`\`\`\`yaml`); + lines.push( + `uses: ${ + formatSecureActionReference( + action.action.split("/")[0], + action.action.split("/")[1], + action.latestInMajorSha, + action.latestInMajor, + ) + }`, + ); + lines.push(`\`\`\``); + } + } + lines.push(""); + } + } + + // Errors + if (result.parsingErrors.length > 0) { + lines.push(""); + lines.push("## Parsing Errors"); + for (const error of result.parsingErrors) { + lines.push(`- ${error}`); + } + } + + return lines.join("\n"); +} diff --git a/src/tools/suggest-updates.ts b/src/tools/suggest-updates.ts new file mode 100644 index 0000000..c82121b --- /dev/null +++ b/src/tools/suggest-updates.ts @@ -0,0 +1,527 @@ +/** + * Suggest safe updates for GitHub Actions in a workflow + */ + +import { GitHubClient } from "../github/client.ts"; +import { + formatSecureActionReference, + getUpdateLevel, + matchesMajorVersion, + parseVersion, + type UpdateLevel, +} from "../utils/parse-action.ts"; +import { parseWorkflow, type WorkflowAction } from "../utils/parse-workflow.ts"; + +/** + * Update suggestion for an action + */ +export interface UpdateSuggestion { + /** Action identifier (owner/repo) */ + action: string; + /** Current version */ + currentVersion: string; + /** Suggested version to update to */ + suggestedVersion: string; + /** Commit SHA for the suggested version */ + suggestedSha: string; + /** Type of update */ + updateLevel: UpdateLevel; + /** Whether the suggested release is immutable */ + immutable: boolean; + /** Ready-to-use secure reference */ + secureReference: string; + /** Reason for the suggestion */ + reason: string; +} + +/** + * Result of suggest_updates tool + */ +export interface SuggestUpdatesResult { + /** Workflow name if specified */ + workflowName?: string; + /** Safe updates (minor/patch) */ + safeUpdates: UpdateSuggestion[]; + /** Updates within same major (for actions with major updates available) */ + majorToLatestInMajor: UpdateSuggestion[]; + /** Summary */ + summary: { + totalActions: number; + safeUpdatesCount: number; + majorUpdatesAvailable: number; + alreadyUpToDate: number; + }; + /** Parsing errors */ + errors: string[]; +} + +/** + * Input for suggest_updates tool + */ +export interface SuggestUpdatesInput { + /** Workflow YAML content */ + workflow_content: string; + /** Risk tolerance: "patch" = only patch, "minor" = patch + minor, "all" = include major */ + risk_tolerance?: "patch" | "minor" | "all"; +} + +/** + * Get the latest release within a specific major version + */ +async function getLatestInMajor( + client: GitHubClient, + owner: string, + repo: string, + majorVersion: number, +): Promise<{ tag: string; sha: string; immutable: boolean } | null> { + try { + const releases = await client.listReleases(owner, repo); + const matching = releases + .filter((r) => !r.draft && !r.prerelease) + .filter((r) => matchesMajorVersion(r.tag_name, majorVersion)) + .sort((a, b) => { + const aVer = parseVersion(a.tag_name); + const bVer = parseVersion(b.tag_name); + if (aVer.minor !== bVer.minor) return bVer.minor - aVer.minor; + return bVer.patch - aVer.patch; + }); + + if (matching.length === 0) return null; + + const release = matching[0]; + const sha = await client.getCommitShaForTag(owner, repo, release.tag_name); + return { + tag: release.tag_name, + sha, + immutable: release.immutable ?? false, + }; + } catch { + return null; + } +} + +/** + * Analyze a single action and generate update suggestions + */ +async function analyzeActionForSuggestions( + action: WorkflowAction, +): Promise<{ + safe?: UpdateSuggestion; + majorToLatestInMajor?: UpdateSuggestion; + upToDate: boolean; + hasMajorUpdate: boolean; + error?: string; +}> { + const { owner, repo, version, isCommitSha } = action.parsed; + const actionId = `${owner}/${repo}`; + + // Skip SHA-pinned references - we can't determine their version + if (isCommitSha || !version) { + return { upToDate: true, hasMajorUpdate: false }; + } + + const client = new GitHubClient(owner); + + try { + const latestRelease = await client.getLatestRelease(owner, repo); + const latestSha = await client.getCommitShaForTag( + owner, + repo, + latestRelease.tag_name, + ); + + const updateLevel = getUpdateLevel(version, latestRelease.tag_name); + + if (updateLevel === "none") { + return { upToDate: true, hasMajorUpdate: false }; + } + + // Safe update (minor or patch) + if (updateLevel === "minor" || updateLevel === "patch") { + return { + safe: { + action: actionId, + currentVersion: version, + suggestedVersion: latestRelease.tag_name, + suggestedSha: latestSha, + updateLevel, + immutable: latestRelease.immutable ?? false, + secureReference: formatSecureActionReference( + owner, + repo, + latestSha, + latestRelease.tag_name, + ), + reason: updateLevel === "minor" + ? "Minor version update - new features, backwards compatible" + : "Patch version update - bug fixes only", + }, + upToDate: false, + hasMajorUpdate: false, + }; + } + + // Major update - suggest latest in current major instead + if (updateLevel === "major") { + const currentMajor = parseVersion(version).major; + const latestInMajor = await getLatestInMajor( + client, + owner, + repo, + currentMajor, + ); + + if (latestInMajor && latestInMajor.tag !== version) { + return { + majorToLatestInMajor: { + action: actionId, + currentVersion: version, + suggestedVersion: latestInMajor.tag, + suggestedSha: latestInMajor.sha, + updateLevel: getUpdateLevel(version, latestInMajor.tag), + immutable: latestInMajor.immutable, + secureReference: formatSecureActionReference( + owner, + repo, + latestInMajor.sha, + latestInMajor.tag, + ), + reason: + `Safe update within v${currentMajor}.x (latest overall is ${latestRelease.tag_name})`, + }, + upToDate: false, + hasMajorUpdate: true, + }; + } + + // Already at latest in major, just flag as having major update + return { upToDate: true, hasMajorUpdate: true }; + } + + return { upToDate: true, hasMajorUpdate: false }; + } catch (error) { + return { + upToDate: false, + hasMajorUpdate: false, + error: error instanceof Error ? error.message : "Unknown error", + }; + } +} + +/** + * Suggest safe updates for a workflow + */ +export async function suggestUpdates( + input: SuggestUpdatesInput, +): Promise { + const workflow = parseWorkflow(input.workflow_content); + const riskTolerance = input.risk_tolerance || "minor"; + + // Deduplicate actions + const seen = new Set(); + const uniqueActions: WorkflowAction[] = []; + + for (const action of workflow.actions) { + const key = `${action.parsed.owner}/${action.parsed.repo}`; + if (!seen.has(key)) { + seen.add(key); + uniqueActions.push(action); + } + } + + // Analyze each action + const results = await Promise.all( + uniqueActions.map((action) => analyzeActionForSuggestions(action)), + ); + + // Collect suggestions based on risk tolerance + const safeUpdates: UpdateSuggestion[] = []; + const majorToLatestInMajor: UpdateSuggestion[] = []; + let alreadyUpToDate = 0; + let majorUpdatesAvailable = 0; + + for (const result of results) { + if (result.error) continue; + + if (result.upToDate && !result.hasMajorUpdate) { + alreadyUpToDate++; + } + + if (result.hasMajorUpdate) { + majorUpdatesAvailable++; + } + + if (result.safe) { + // Apply risk tolerance filter + if ( + riskTolerance === "patch" && + result.safe.updateLevel !== "patch" + ) { + continue; + } + safeUpdates.push(result.safe); + } + + if (result.majorToLatestInMajor) { + majorToLatestInMajor.push(result.majorToLatestInMajor); + } + } + + return { + workflowName: workflow.name, + safeUpdates, + majorToLatestInMajor, + summary: { + totalActions: uniqueActions.length, + safeUpdatesCount: safeUpdates.length, + majorUpdatesAvailable, + alreadyUpToDate, + }, + errors: workflow.errors, + }; +} + +/** + * Format suggest updates result as text + */ +export function formatSuggestUpdatesAsText( + result: SuggestUpdatesResult, +): string { + const lines: string[] = []; + + if (result.workflowName) { + lines.push(`Workflow: ${result.workflowName}`); + lines.push(""); + } + + // Summary + lines.push("## Summary"); + lines.push(`Total actions analyzed: ${result.summary.totalActions}`); + lines.push(`Already up to date: ${result.summary.alreadyUpToDate}`); + lines.push(`Safe updates available: ${result.summary.safeUpdatesCount}`); + if (result.summary.majorUpdatesAvailable > 0) { + lines.push( + `Actions with major updates: ${result.summary.majorUpdatesAvailable} (staying on current major)`, + ); + } + lines.push(""); + + // Safe updates + if (result.safeUpdates.length > 0) { + lines.push("## Safe Updates"); + lines.push("These updates are safe to apply:"); + lines.push(""); + + for (const update of result.safeUpdates) { + const icon = update.updateLevel === "patch" ? "🔧" : "📦"; + lines.push( + `### ${icon} ${update.action}: ${update.currentVersion} → ${update.suggestedVersion}`, + ); + lines.push(`${update.reason}`); + lines.push(""); + lines.push("```yaml"); + lines.push(`uses: ${update.secureReference}`); + lines.push("```"); + lines.push(""); + } + } + + // Major to latest in major + if (result.majorToLatestInMajor.length > 0) { + lines.push("## Updates Within Current Major"); + lines.push( + "These actions have major updates available, but you can safely update within your current major version:", + ); + lines.push(""); + + for (const update of result.majorToLatestInMajor) { + lines.push( + `### ${update.action}: ${update.currentVersion} → ${update.suggestedVersion}`, + ); + lines.push(`${update.reason}`); + lines.push(""); + lines.push("```yaml"); + lines.push(`uses: ${update.secureReference}`); + lines.push("```"); + lines.push(""); + } + } + + if ( + result.safeUpdates.length === 0 && + result.majorToLatestInMajor.length === 0 + ) { + lines.push("✅ All actions are up to date!"); + } + + return lines.join("\n"); +} + +/** + * Input for get_latest_in_major tool + */ +export interface GetLatestInMajorInput { + /** Action reference, e.g., 'actions/checkout@v4' */ + action: string; +} + +/** + * Result of get_latest_in_major tool + */ +export interface GetLatestInMajorResult { + /** Action identifier */ + action: string; + /** Current version specified */ + currentVersion: string; + /** Major version */ + majorVersion: number; + /** Latest version in the same major */ + latestInMajor: string | null; + /** Commit SHA */ + latestInMajorSha: string | null; + /** Whether immutable */ + immutable: boolean; + /** Secure reference */ + secureReference: string | null; + /** Latest overall version (for reference) */ + latestOverall: string | null; + /** Error if any */ + error?: string; +} + +/** + * Get the latest version within the same major version + */ +export async function getLatestInMajorVersion( + input: GetLatestInMajorInput, +): Promise { + // Import parseAction here to avoid circular dependency + const { parseAction } = await import("../utils/parse-action.ts"); + const parsed = parseAction(input.action); + const { owner, repo, version } = parsed; + const actionId = `${owner}/${repo}`; + + if (!version) { + return { + action: actionId, + currentVersion: "not specified", + majorVersion: 0, + latestInMajor: null, + latestInMajorSha: null, + immutable: false, + secureReference: null, + latestOverall: null, + error: "No version specified in action reference", + }; + } + + if (parsed.isCommitSha) { + return { + action: actionId, + currentVersion: version.slice(0, 7), + majorVersion: 0, + latestInMajor: null, + latestInMajorSha: null, + immutable: false, + secureReference: null, + latestOverall: null, + error: "Cannot determine major version from SHA reference", + }; + } + + const currentMajor = parseVersion(version).major; + const client = new GitHubClient(owner); + + try { + // Get latest overall + const latestRelease = await client.getLatestRelease(owner, repo); + + // Get latest in major + const latestInMajor = await getLatestInMajor( + client, + owner, + repo, + currentMajor, + ); + + if (!latestInMajor) { + return { + action: actionId, + currentVersion: version, + majorVersion: currentMajor, + latestInMajor: null, + latestInMajorSha: null, + immutable: false, + secureReference: null, + latestOverall: latestRelease.tag_name, + error: `No releases found for major version ${currentMajor}`, + }; + } + + return { + action: actionId, + currentVersion: version, + majorVersion: currentMajor, + latestInMajor: latestInMajor.tag, + latestInMajorSha: latestInMajor.sha, + immutable: latestInMajor.immutable, + secureReference: formatSecureActionReference( + owner, + repo, + latestInMajor.sha, + latestInMajor.tag, + ), + latestOverall: latestRelease.tag_name, + }; + } catch (error) { + return { + action: actionId, + currentVersion: version, + majorVersion: currentMajor, + latestInMajor: null, + latestInMajorSha: null, + immutable: false, + secureReference: null, + latestOverall: null, + error: error instanceof Error ? error.message : "Unknown error", + }; + } +} + +/** + * Format get_latest_in_major result as text + */ +export function formatLatestInMajorAsText( + result: GetLatestInMajorResult, +): string { + const lines: string[] = []; + + lines.push(`Action: ${result.action}`); + lines.push(`Current Version: ${result.currentVersion}`); + lines.push(`Major Version: v${result.majorVersion}`); + lines.push(""); + + if (result.error) { + lines.push(`Error: ${result.error}`); + return lines.join("\n"); + } + + if (result.latestInMajor) { + lines.push(`Latest in v${result.majorVersion}.x: ${result.latestInMajor}`); + lines.push(` Commit SHA: ${result.latestInMajorSha}`); + lines.push(` Immutable: ${result.immutable ? "Yes" : "No"}`); + } + + if (result.latestOverall && result.latestOverall !== result.latestInMajor) { + lines.push(""); + lines.push(`Note: Latest overall is ${result.latestOverall}`); + } + + if (result.secureReference) { + lines.push(""); + lines.push("Recommended Usage (SHA-pinned):"); + lines.push(` uses: ${result.secureReference}`); + } + + return lines.join("\n"); +} diff --git a/src/utils/parse-action.test.ts b/src/utils/parse-action.test.ts new file mode 100644 index 0000000..abd7cd7 --- /dev/null +++ b/src/utils/parse-action.test.ts @@ -0,0 +1,243 @@ +import { assertEquals, assertThrows } from "@std/assert"; +import { + formatSecureActionReference, + getUpdateLevel, + getUpdateRisk, + isMajorVersionOnly, + isSemverLike, + matchesMajorVersion, + parseAction, + parseVersion, +} from "./parse-action.ts"; + +// ============================================================================ +// parseAction tests +// ============================================================================ + +Deno.test("parseAction - basic action without version", () => { + const result = parseAction("actions/checkout"); + assertEquals(result.owner, "actions"); + assertEquals(result.repo, "checkout"); + assertEquals(result.version, undefined); + assertEquals(result.isCommitSha, false); +}); + +Deno.test("parseAction - action with tag version", () => { + const result = parseAction("actions/checkout@v4"); + assertEquals(result.owner, "actions"); + assertEquals(result.repo, "checkout"); + assertEquals(result.version, "v4"); + assertEquals(result.isCommitSha, false); +}); + +Deno.test("parseAction - action with full semver", () => { + const result = parseAction("actions/checkout@v4.2.1"); + assertEquals(result.owner, "actions"); + assertEquals(result.repo, "checkout"); + assertEquals(result.version, "v4.2.1"); + assertEquals(result.isCommitSha, false); +}); + +Deno.test("parseAction - action with commit SHA", () => { + const result = parseAction( + "actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332", + ); + assertEquals(result.owner, "actions"); + assertEquals(result.repo, "checkout"); + assertEquals(result.version, "692973e3d937129bcbf40652eb9f2f61becf3332"); + assertEquals(result.isCommitSha, true); +}); + +Deno.test("parseAction - handles whitespace", () => { + const result = parseAction(" actions/checkout@v4 "); + assertEquals(result.owner, "actions"); + assertEquals(result.repo, "checkout"); + assertEquals(result.version, "v4"); +}); + +Deno.test("parseAction - throws on invalid format", () => { + assertThrows( + () => parseAction("invalid"), + Error, + "Invalid action reference", + ); +}); + +Deno.test("parseAction - throws on empty string", () => { + assertThrows( + () => parseAction(""), + Error, + "Invalid action reference", + ); +}); + +// ============================================================================ +// parseVersion tests +// ============================================================================ + +Deno.test("parseVersion - major only with v prefix", () => { + const result = parseVersion("v4"); + assertEquals(result.major, 4); + assertEquals(result.minor, 0); + assertEquals(result.patch, 0); + assertEquals(result.prerelease, undefined); + assertEquals(result.raw, "v4"); +}); + +Deno.test("parseVersion - major.minor with v prefix", () => { + const result = parseVersion("v4.2"); + assertEquals(result.major, 4); + assertEquals(result.minor, 2); + assertEquals(result.patch, 0); +}); + +Deno.test("parseVersion - full semver with v prefix", () => { + const result = parseVersion("v4.2.1"); + assertEquals(result.major, 4); + assertEquals(result.minor, 2); + assertEquals(result.patch, 1); +}); + +Deno.test("parseVersion - full semver without v prefix", () => { + const result = parseVersion("1.0.0"); + assertEquals(result.major, 1); + assertEquals(result.minor, 0); + assertEquals(result.patch, 0); +}); + +Deno.test("parseVersion - with prerelease", () => { + const result = parseVersion("v1.0.0-beta.1"); + assertEquals(result.major, 1); + assertEquals(result.minor, 0); + assertEquals(result.patch, 0); + assertEquals(result.prerelease, "beta.1"); +}); + +Deno.test("parseVersion - invalid version returns zeros", () => { + const result = parseVersion("latest"); + assertEquals(result.major, 0); + assertEquals(result.minor, 0); + assertEquals(result.patch, 0); + assertEquals(result.raw, "latest"); +}); + +// ============================================================================ +// isSemverLike tests +// ============================================================================ + +Deno.test("isSemverLike - v4 is semver-like", () => { + assertEquals(isSemverLike("v4"), true); +}); + +Deno.test("isSemverLike - v4.2.1 is semver-like", () => { + assertEquals(isSemverLike("v4.2.1"), true); +}); + +Deno.test("isSemverLike - 1.0.0 is semver-like", () => { + assertEquals(isSemverLike("1.0.0"), true); +}); + +Deno.test("isSemverLike - latest is not semver-like", () => { + assertEquals(isSemverLike("latest"), false); +}); + +Deno.test("isSemverLike - commit SHA is not semver-like", () => { + assertEquals(isSemverLike("692973e3d937129bcbf40652eb9f2f61becf3332"), false); +}); + +// ============================================================================ +// isMajorVersionOnly tests +// ============================================================================ + +Deno.test("isMajorVersionOnly - v4 is major only", () => { + assertEquals(isMajorVersionOnly("v4"), true); +}); + +Deno.test("isMajorVersionOnly - 4 is major only", () => { + assertEquals(isMajorVersionOnly("4"), true); +}); + +Deno.test("isMajorVersionOnly - v4.2 is not major only", () => { + assertEquals(isMajorVersionOnly("v4.2"), false); +}); + +Deno.test("isMajorVersionOnly - v4.2.1 is not major only", () => { + assertEquals(isMajorVersionOnly("v4.2.1"), false); +}); + +// ============================================================================ +// getUpdateLevel tests +// ============================================================================ + +Deno.test("getUpdateLevel - major update", () => { + assertEquals(getUpdateLevel("v3", "v4"), "major"); + assertEquals(getUpdateLevel("v3.9.9", "v4.0.0"), "major"); +}); + +Deno.test("getUpdateLevel - minor update", () => { + assertEquals(getUpdateLevel("v4.1", "v4.2"), "minor"); + assertEquals(getUpdateLevel("v4.1.9", "v4.2.0"), "minor"); +}); + +Deno.test("getUpdateLevel - patch update", () => { + assertEquals(getUpdateLevel("v4.2.0", "v4.2.1"), "patch"); +}); + +Deno.test("getUpdateLevel - no update (same version)", () => { + assertEquals(getUpdateLevel("v4.2.1", "v4.2.1"), "none"); +}); + +Deno.test("getUpdateLevel - no update (latest is older)", () => { + assertEquals(getUpdateLevel("v5", "v4"), "none"); + assertEquals(getUpdateLevel("v4.3", "v4.2"), "none"); + assertEquals(getUpdateLevel("v4.2.2", "v4.2.1"), "none"); +}); + +// ============================================================================ +// getUpdateRisk tests +// ============================================================================ + +Deno.test("getUpdateRisk - major is high risk", () => { + assertEquals(getUpdateRisk("major"), "high"); +}); + +Deno.test("getUpdateRisk - minor is medium risk", () => { + assertEquals(getUpdateRisk("minor"), "medium"); +}); + +Deno.test("getUpdateRisk - patch is low risk", () => { + assertEquals(getUpdateRisk("patch"), "low"); +}); + +Deno.test("getUpdateRisk - none is no risk", () => { + assertEquals(getUpdateRisk("none"), "none"); +}); + +// ============================================================================ +// matchesMajorVersion tests +// ============================================================================ + +Deno.test("matchesMajorVersion - v4.2.1 matches major 4", () => { + assertEquals(matchesMajorVersion("v4.2.1", 4), true); +}); + +Deno.test("matchesMajorVersion - v4.2.1 does not match major 5", () => { + assertEquals(matchesMajorVersion("v4.2.1", 5), false); +}); + +// ============================================================================ +// formatSecureActionReference tests +// ============================================================================ + +Deno.test("formatSecureActionReference - formats correctly", () => { + const result = formatSecureActionReference( + "actions", + "checkout", + "692973e3d937129bcbf40652eb9f2f61becf3332", + "v4.2.0", + ); + assertEquals( + result, + "actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.2.0", + ); +}); diff --git a/src/utils/parse-action.ts b/src/utils/parse-action.ts index 62732a6..1d3d303 100644 --- a/src/utils/parse-action.ts +++ b/src/utils/parse-action.ts @@ -73,3 +73,89 @@ export function isSemverLike(version: string): boolean { export function isMajorVersionOnly(version: string): boolean { return /^v?\d+$/.test(version); } + +/** + * Parsed semantic version + */ +export interface ParsedVersion { + major: number; + minor: number; + patch: number; + prerelease?: string; + raw: string; +} + +/** + * Parse a version string into semantic version components + * Handles: v1, v1.0, v1.0.0, 1.0.0, v1.0.0-beta.1, etc. + */ +export function parseVersion(version: string): ParsedVersion { + const raw = version; + // Remove leading 'v' if present + const normalized = version.startsWith("v") ? version.slice(1) : version; + + // Match semver pattern with optional prerelease + const match = normalized.match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-(.+))?$/); + + if (!match) { + return { major: 0, minor: 0, patch: 0, raw }; + } + + const [, major, minor, patch, prerelease] = match; + + return { + major: parseInt(major, 10), + minor: minor ? parseInt(minor, 10) : 0, + patch: patch ? parseInt(patch, 10) : 0, + prerelease: prerelease || undefined, + raw, + }; +} + +/** + * Update level indicating the type of version change + */ +export type UpdateLevel = "major" | "minor" | "patch" | "none"; + +/** + * Determine the update level between two versions + */ +export function getUpdateLevel(current: string, latest: string): UpdateLevel { + const cur = parseVersion(current); + const lat = parseVersion(latest); + + if (lat.major > cur.major) return "major"; + if (lat.major < cur.major) return "none"; // Latest is older + if (lat.minor > cur.minor) return "minor"; + if (lat.minor < cur.minor) return "none"; // Latest is older + if (lat.patch > cur.patch) return "patch"; + + return "none"; +} + +/** + * Check if a version matches a major version prefix + * e.g., "v4.2.1" matches major 4 + */ +export function matchesMajorVersion(version: string, major: number): boolean { + const parsed = parseVersion(version); + return parsed.major === major; +} + +/** + * Get risk level description for an update level + */ +export function getUpdateRisk( + level: UpdateLevel, +): "high" | "medium" | "low" | "none" { + switch (level) { + case "major": + return "high"; + case "minor": + return "medium"; + case "patch": + return "low"; + case "none": + return "none"; + } +} diff --git a/src/utils/parse-workflow.ts b/src/utils/parse-workflow.ts new file mode 100644 index 0000000..84f7eaa --- /dev/null +++ b/src/utils/parse-workflow.ts @@ -0,0 +1,177 @@ +/** + * Parse GitHub Actions workflow files to extract action references + */ + +import { parseAction, type ParsedAction } from "./parse-action.ts"; + +/** + * Extracted action reference from a workflow + */ +export interface WorkflowAction { + /** Original reference string (e.g., "actions/checkout@v4") */ + reference: string; + /** Parsed action components */ + parsed: ParsedAction; + /** Job name where the action is used */ + job?: string; + /** Step name or index */ + step?: string; + /** Line number in the file (approximate) */ + line?: number; +} + +/** + * Result of parsing a workflow file + */ +export interface ParsedWorkflow { + /** All action references found */ + actions: WorkflowAction[]; + /** Workflow name if specified */ + name?: string; + /** Any parsing errors encountered */ + errors: string[]; +} + +// Regex to match 'uses:' lines in workflow YAML +// Matches: uses: owner/repo@version or uses: "owner/repo@version" +const USES_REGEX = /^\s*uses:\s*["']?([^"'\s#]+)["']?/; + +// Regex to match job names +const JOB_REGEX = /^(\s*)([a-zA-Z_][a-zA-Z0-9_-]*):\s*$/; + +// Regex to match step names +const STEP_NAME_REGEX = /^\s*-?\s*name:\s*["']?([^"'\n]+)["']?/; + +/** + * Parse a GitHub Actions workflow YAML content and extract action references + * + * Note: This is a simple line-based parser, not a full YAML parser. + * It handles the common workflow patterns but may miss edge cases. + */ +export function parseWorkflow(content: string): ParsedWorkflow { + const actions: WorkflowAction[] = []; + const errors: string[] = []; + const lines = content.split("\n"); + + let currentJob: string | undefined; + let currentStep: string | undefined; + let stepIndex = 0; + let inJobs = false; + let inSteps = false; + let workflowName: string | undefined; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const lineNumber = i + 1; + + // Check for workflow name + if (line.match(/^name:\s*["']?([^"'\n]+)["']?/)) { + const match = line.match(/^name:\s*["']?([^"'\n]+)["']?/); + if (match) { + workflowName = match[1].trim(); + } + } + + // Check for jobs section + if (line.match(/^jobs:\s*$/)) { + inJobs = true; + continue; + } + + // Check for job definition (must be in jobs section) + if (inJobs) { + const jobMatch = line.match(JOB_REGEX); + if (jobMatch) { + const indent = jobMatch[1].length; + // Job definitions are typically at indent level 2 + if (indent <= 4) { + currentJob = jobMatch[2]; + inSteps = false; + stepIndex = 0; + } + } + } + + // Check for steps section + if (line.match(/^\s+steps:\s*$/)) { + inSteps = true; + stepIndex = 0; + currentStep = undefined; + continue; + } + + // Check for step name + if (inSteps) { + const stepMatch = line.match(STEP_NAME_REGEX); + if (stepMatch) { + currentStep = stepMatch[1].trim(); + } + + // Check for new step (starts with -) + if (line.match(/^\s+-\s/)) { + stepIndex++; + if (!line.match(STEP_NAME_REGEX)) { + currentStep = undefined; + } + } + } + + // Check for uses directive + const usesMatch = line.match(USES_REGEX); + if (usesMatch) { + const reference = usesMatch[1].trim(); + + // Skip local actions (./path/to/action) + if (reference.startsWith("./") || reference.startsWith("../")) { + continue; + } + + // Skip docker:// references + if (reference.startsWith("docker://")) { + continue; + } + + try { + const parsed = parseAction(reference); + actions.push({ + reference, + parsed, + job: currentJob, + step: currentStep || `Step ${stepIndex}`, + line: lineNumber, + }); + } catch (error) { + errors.push( + `Line ${lineNumber}: Failed to parse action "${reference}": ${ + error instanceof Error ? error.message : "Unknown error" + }`, + ); + } + } + } + + return { + actions, + name: workflowName, + errors, + }; +} + +/** + * Extract unique action references from a workflow + * Returns deduplicated list of owner/repo combinations + */ +export function getUniqueActions(workflow: ParsedWorkflow): string[] { + const seen = new Set(); + const unique: string[] = []; + + for (const action of workflow.actions) { + const key = `${action.parsed.owner}/${action.parsed.repo}`; + if (!seen.has(key)) { + seen.add(key); + unique.push(action.reference); + } + } + + return unique; +}