diff --git a/README.md b/README.md index b6dc747..79536bc 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,9 @@ reference GitHub Actions by providing: - Ready-to-use SHA-pinned references - **Workflow analysis** with update level detection (major/minor/patch) - **Safe update suggestions** that avoid breaking changes +- **Documentation retrieval** for actions at specific versions +- **Version comparison** to identify changes and breaking updates between + releases ## Why Use This? @@ -108,6 +111,8 @@ Once configured, ask Claude to look up GitHub Actions: - "Analyze my workflow file for outdated actions" - "Suggest safe updates for my CI workflow" - "What's the latest v4.x version of actions/checkout?" +- "Show me the documentation for actions/checkout@v4" +- "Compare changes between actions/setup-node@v4.0.0 and v6.0.0" ## Tool: `lookup_action` @@ -248,6 +253,72 @@ Recommended Usage (SHA-pinned): uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 ``` +## Tool: `get_action_documentation` + +Get README documentation for a GitHub Action at a specific version. Useful for +understanding how to use an action at a particular release. + +### Parameters + +| Parameter | Type | Required | Description | +| --------- | ------ | -------- | ----------------------------------------------------------------------------- | +| `action` | string | Yes | Action reference (e.g., `actions/checkout` or `actions/checkout@v4`) | +| `ref` | string | No | Optional ref override (tag/branch/commit). Defaults to version or main branch | + +### Example Output + +``` +# actions/checkout Documentation +Ref: v4.2.0 + +--- + +[Full README markdown content for the action at the specified version] +``` + +## Tool: `compare_action_versions` + +Compare changes between two versions of a GitHub Action. Shows release notes and +identifies version update levels to help with upgrade decisions. + +### Parameters + +| Parameter | Type | Required | Description | +| ---------------- | ------ | -------- | ------------------------------------------------------------- | +| `action` | string | Yes | Action with current version (e.g., `actions/checkout@v4.0.2`) | +| `target_version` | string | No | Target version (defaults to latest) | + +### Example Output + +``` +# Version Comparison: actions/checkout + +From: v4.0.0 +To: v4.2.0 + +## Summary +- Total releases: 3 +- Major updates: 0 +- Minor updates: 2 +- Patch updates: 1 + +## Release History (chronological) + +### v4.1.0 (2025-02-15) - Minor Update +Added support for sparse checkouts and improved performance. + +### v4.1.1 (2025-02-20) - Patch Update +Fixed bug with submodule handling on Windows. + +### v4.2.0 (2025-03-01) - Minor Update +Added new input parameter for custom checkout paths. + +--- + +Note: Major version updates (marked with ⚠️) may contain breaking changes. +Review the release notes above to understand the impact of each update. +``` + ## Authentication The service supports multiple authentication methods, checked in the following diff --git a/main.ts b/main.ts index 8b54bc5..7f38da7 100644 --- a/main.ts +++ b/main.ts @@ -19,6 +19,14 @@ import { getLatestInMajorVersion, suggestUpdates, } from "./src/tools/suggest-updates.ts"; +import { + formatDocumentationResultAsText, + getActionDocumentation, +} from "./src/tools/get-action-documentation.ts"; +import { + compareActionVersions, + formatCompareResultAsText, +} from "./src/tools/compare-action-versions.ts"; // Create the MCP server const server = new McpServer({ @@ -213,6 +221,98 @@ server.tool( }, ); +// Register the get_action_documentation tool +server.tool( + "get_action_documentation", + "Get README documentation for a GitHub Action at a specific version. " + + "Useful for understanding how to use an action at a particular release.", + { + action: z + .string() + .describe( + "Action reference (e.g., 'actions/checkout' or 'actions/checkout@v4')", + ), + ref: z + .string() + .optional() + .describe("Optional ref override (tag/branch/commit)"), + }, + async ({ action, ref }) => { + try { + const result = await getActionDocumentation({ action, ref }); + const text = formatDocumentationResultAsText(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 compare_action_versions tool +server.tool( + "compare_action_versions", + "Compare changes between two versions of a GitHub Action. " + + "Shows release notes and identifies version update levels to help with upgrade decisions.", + { + action: z + .string() + .describe( + "Action with current version (e.g., 'actions/checkout@v4.0.2')", + ), + target_version: z + .string() + .optional() + .describe("Target version (defaults to latest)"), + }, + async ({ action, target_version }) => { + try { + const result = await compareActionVersions({ action, target_version }); + const text = formatCompareResultAsText(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 b46bb4f..ba60260 100644 --- a/src/github/client.ts +++ b/src/github/client.ts @@ -6,6 +6,7 @@ import type { GitHubError, GitHubRef, GitHubRelease, + GitHubRepository, GitHubTag, RateLimitInfo, } from "./types.ts"; @@ -323,4 +324,72 @@ export class GitHubClient { return matchingReleases[0] || null; } + + /** + * Get repository information including default branch + */ + async getRepository(owner: string, repo: string): Promise { + const url = `${GITHUB_API_BASE}/repos/${owner}/${repo}`; + return await this.fetch(url); + } + + /** + * Get repository default branch name + */ + async getDefaultBranch(owner: string, repo: string): Promise { + const repository = await this.getRepository(owner, repo); + return repository.default_branch; + } + + /** + * Get file content from repository at specific ref + * @param owner - Repository owner + * @param repo - Repository name + * @param path - File path (e.g., "README.md") + * @param ref - Branch, tag, or commit SHA (optional, defaults to repo default branch) + */ + async getFileContent( + owner: string, + repo: string, + path: string, + ref?: string, + ): Promise { + // Ensure token is resolved before making request + await this.ensureToken(); + + // Build URL with optional ref parameter + let url = `${GITHUB_API_BASE}/repos/${owner}/${repo}/contents/${path}`; + if (ref) { + url += `?ref=${encodeURIComponent(ref)}`; + } + + // Use Accept header to get raw content instead of JSON + const response = await fetch(url, { + headers: { + ...this.getHeaders(), + Accept: "application/vnd.github.raw", + }, + }); + + this.updateRateLimitInfo(response); + + if (!response.ok) { + const error: GitHubError = await response.json(); + if (response.status === 404) { + throw new Error( + `File not found: ${path}${ref ? ` at ref ${ref}` : ""}`, + ); + } + if (response.status === 403 && this.rateLimitInfo?.remaining === 0) { + const resetDate = new Date(this.rateLimitInfo.reset * 1000); + throw new Error( + `Rate limit exceeded. Resets at ${resetDate.toISOString()}. ` + + `Consider setting GITHUB_TOKEN for higher limits.`, + ); + } + throw new Error(`GitHub API error: ${error.message}`); + } + + return response.text(); + } } diff --git a/src/github/types.ts b/src/github/types.ts index e648235..6757425 100644 --- a/src/github/types.ts +++ b/src/github/types.ts @@ -12,6 +12,8 @@ export interface GitHubRelease { created_at: string; published_at: string | null; html_url: string; + /** Release notes in markdown format */ + body?: string | null; /** Whether or not the release is immutable (protected from modification) */ immutable?: boolean; assets: GitHubAsset[]; @@ -64,3 +66,18 @@ export interface RateLimitInfo { reset: number; used: number; } + +export interface GitHubContent { + name: string; + path: string; + sha: string; + size: number; + type: "file" | "dir"; + content?: string; // base64 encoded + encoding?: string; // "base64" + download_url: string | null; +} + +export interface GitHubRepository { + default_branch: string; +} diff --git a/src/tools/compare-action-versions.ts b/src/tools/compare-action-versions.ts new file mode 100644 index 0000000..9ab546f --- /dev/null +++ b/src/tools/compare-action-versions.ts @@ -0,0 +1,287 @@ +/** + * Implementation of the compare_action_versions tool + */ + +import { GitHubClient } from "../github/client.ts"; +import { + getUpdateLevel, + parseAction, + parseVersion, +} from "../utils/parse-action.ts"; + +export interface CompareActionVersionsInput { + action: string; // Must include version: "actions/checkout@v4.0.2" + target_version?: string; // Optional, defaults to latest +} + +export interface VersionChange { + tag: string; + published_at: string | null; + body: string | null; // Release notes + is_prerelease: boolean; + version_type: "major" | "minor" | "patch" | "unknown"; // Type of version change from previous +} + +export interface CompareActionVersionsResult { + action: string; + from_version: string; + to_version: string; + changes: VersionChange[]; + summary: { + total_releases: number; + major_updates: number; + minor_updates: number; + patch_updates: number; + }; + error?: string; +} + +/** + * Format the result as a human-readable string for the MCP response + */ +export function formatCompareResultAsText( + result: CompareActionVersionsResult, +): string { + if (result.error) { + return `Error: ${result.error}`; + } + + const lines: string[] = []; + + lines.push(`# Version Comparison: ${result.action}`); + lines.push(""); + lines.push(`From: ${result.from_version}`); + lines.push(`To: ${result.to_version}`); + lines.push(""); + + // Summary section + lines.push("## Summary"); + lines.push(`- Total releases: ${result.summary.total_releases}`); + lines.push(`- Major updates: ${result.summary.major_updates}`); + lines.push(`- Minor updates: ${result.summary.minor_updates}`); + lines.push(`- Patch updates: ${result.summary.patch_updates}`); + lines.push(""); + + // Release history section + if (result.changes.length > 0) { + lines.push("## Release History (chronological)"); + lines.push(""); + + for (const change of result.changes) { + const warningFlag = change.version_type === "major" ? " ⚠️" : ""; + const versionTypeLabel = change.version_type.charAt(0).toUpperCase() + + change.version_type.slice(1); + const dateStr = change.published_at + ? new Date(change.published_at).toISOString().split("T")[0] + : "Unknown"; + + lines.push( + `### ${change.tag} (${dateStr}) - ${versionTypeLabel} Update${warningFlag}`, + ); + + if (change.body) { + lines.push(change.body.trim()); + } else { + lines.push("(No release notes provided)"); + } + + lines.push(""); + } + + lines.push("---"); + lines.push(""); + lines.push( + "Note: Major version updates (marked with ⚠️) may contain breaking changes.", + ); + lines.push( + "Review the release notes above to understand the impact of each update.", + ); + } else { + lines.push("No releases found in the specified range."); + } + + return lines.join("\n"); +} + +/** + * Compare semantic versions to determine sort order + * Returns: -1 if a < b, 1 if a > b, 0 if equal + */ +function compareVersions(a: string, b: string): number { + const parsedA = parseVersion(a); + const parsedB = parseVersion(b); + + if (parsedA.major !== parsedB.major) { + return parsedA.major - parsedB.major; + } + if (parsedA.minor !== parsedB.minor) { + return parsedA.minor - parsedB.minor; + } + if (parsedA.patch !== parsedB.patch) { + return parsedA.patch - parsedB.patch; + } + + return 0; +} + +/** + * Check if version is within range (inclusive) + */ +function isVersionInRange( + version: string, + from: string, + to: string, +): boolean { + return compareVersions(version, from) >= 0 && + compareVersions(version, to) <= 0; +} + +/** + * Compare changes between two versions of a GitHub Action + */ +export async function compareActionVersions( + input: CompareActionVersionsInput, +): Promise { + try { + const parsed = parseAction(input.action); + + // Require version in action string + if (!parsed.version || parsed.isCommitSha) { + return { + action: `${parsed.owner}/${parsed.repo}`, + from_version: "", + to_version: "", + changes: [], + summary: { + total_releases: 0, + major_updates: 0, + minor_updates: 0, + patch_updates: 0, + }, + error: + "Action must include a version tag (e.g., 'actions/checkout@v4.0.2'). Commit SHAs are not supported.", + }; + } + + const client = new GitHubClient(parsed.owner); + const fromVersion = parsed.version; + + // Get target version (default to latest) + let toVersion: string; + if (input.target_version) { + toVersion = input.target_version; + } else { + const latestRelease = await client.getLatestRelease( + parsed.owner, + parsed.repo, + ); + toVersion = latestRelease.tag_name; + } + + // Ensure from <= to (swap if needed) + if (compareVersions(fromVersion, toVersion) > 0) { + [toVersion] = [fromVersion]; + // Note: We keep fromVersion as the user provided it for error clarity + } + + // Fetch all releases + const allReleases = await client.listReleases(parsed.owner, parsed.repo); + + // Filter releases in range and exclude drafts + const releasesInRange = allReleases.filter( + (r) => + !r.draft && + isVersionInRange(r.tag_name, fromVersion, toVersion), + ); + + // Sort chronologically (oldest first) + const sortedReleases = releasesInRange.sort((a, b) => { + const dateA = a.published_at ? new Date(a.published_at).getTime() : 0; + const dateB = b.published_at ? new Date(b.published_at).getTime() : 0; + return dateA - dateB; + }); + + // Build version changes with type classification + const changes: VersionChange[] = []; + let majorCount = 0; + let minorCount = 0; + let patchCount = 0; + + for (let i = 0; i < sortedReleases.length; i++) { + const release = sortedReleases[i]; + let versionType: "major" | "minor" | "patch" | "unknown" = "unknown"; + + // Determine version type by comparing with previous release + if (i > 0) { + const prevRelease = sortedReleases[i - 1]; + const updateLevel = getUpdateLevel( + prevRelease.tag_name, + release.tag_name, + ); + + if (updateLevel === "major") { + versionType = "major"; + majorCount++; + } else if (updateLevel === "minor") { + versionType = "minor"; + minorCount++; + } else if (updateLevel === "patch") { + versionType = "patch"; + patchCount++; + } + } else { + // First release in range - determine type from fromVersion + const updateLevel = getUpdateLevel(fromVersion, release.tag_name); + if (updateLevel === "major") { + versionType = "major"; + majorCount++; + } else if (updateLevel === "minor") { + versionType = "minor"; + minorCount++; + } else if (updateLevel === "patch") { + versionType = "patch"; + patchCount++; + } + } + + changes.push({ + tag: release.tag_name, + published_at: release.published_at, + body: release.body || null, + is_prerelease: release.prerelease, + version_type: versionType, + }); + } + + return { + action: `${parsed.owner}/${parsed.repo}`, + from_version: fromVersion, + to_version: toVersion, + changes, + summary: { + total_releases: changes.length, + major_updates: majorCount, + minor_updates: minorCount, + patch_updates: patchCount, + }, + }; + } catch (error) { + const message = error instanceof Error + ? error.message + : "Unknown error occurred"; + + return { + action: input.action, + from_version: "", + to_version: "", + changes: [], + summary: { + total_releases: 0, + major_updates: 0, + minor_updates: 0, + patch_updates: 0, + }, + error: message, + }; + } +} diff --git a/src/tools/get-action-documentation.ts b/src/tools/get-action-documentation.ts new file mode 100644 index 0000000..6cc7345 --- /dev/null +++ b/src/tools/get-action-documentation.ts @@ -0,0 +1,91 @@ +/** + * Implementation of the get_action_documentation tool + */ + +import { GitHubClient } from "../github/client.ts"; +import { parseAction } from "../utils/parse-action.ts"; + +export interface GetActionDocumentationInput { + action: string; // "actions/checkout" or "actions/checkout@v4" + ref?: string; // Optional override (tag/branch/commit) +} + +export interface GetActionDocumentationResult { + action: string; + ref: string; // Actual ref used + content: string; // README markdown + error?: string; +} + +/** + * Format the result as a human-readable string for the MCP response + */ +export function formatDocumentationResultAsText( + result: GetActionDocumentationResult, +): string { + if (result.error) { + return `Error: ${result.error}`; + } + + const lines: string[] = []; + + lines.push(`# ${result.action} Documentation`); + lines.push(`Ref: ${result.ref}`); + lines.push(""); + lines.push("---"); + lines.push(""); + lines.push(result.content); + + return lines.join("\n"); +} + +/** + * Get README documentation for a GitHub Action at a specific version + */ +export async function getActionDocumentation( + input: GetActionDocumentationInput, +): Promise { + try { + const parsed = parseAction(input.action); + const client = new GitHubClient(parsed.owner); + + // Determine which ref to use + let ref: string; + + if (input.ref) { + // Explicit ref override provided + ref = input.ref; + } else if (parsed.version) { + // Use version from action string + ref = parsed.version; + } else { + // Get the default branch from the repository + ref = await client.getDefaultBranch(parsed.owner, parsed.repo); + } + + // Fetch README.md at the determined ref + const content = await client.getFileContent( + parsed.owner, + parsed.repo, + "README.md", + ref, + ); + + return { + action: `${parsed.owner}/${parsed.repo}`, + ref, + content, + }; + } catch (error) { + const message = error instanceof Error + ? error.message + : "Unknown error occurred"; + + return { + action: input.action, + ref: input.ref || "unknown", + content: "", + error: message, + }; + } +}