mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-01-22 22:44:13 +08:00
merge to eap
This commit is contained in:
committed by
Ashwin Bhat
parent
3fdfa8eea7
commit
7bd5b28434
@@ -252,6 +252,10 @@ runs:
|
|||||||
STREAM_CONFIG: ${{ steps.prepare.outputs.stream_config }}
|
STREAM_CONFIG: ${{ steps.prepare.outputs.stream_config }}
|
||||||
CLAUDE_CONCLUSION: ${{ steps.claude-code.outputs.conclusion }}
|
CLAUDE_CONCLUSION: ${{ steps.claude-code.outputs.conclusion }}
|
||||||
CLAUDE_START_TIME: ${{ steps.prepare.outputs.claude_start_time }}
|
CLAUDE_START_TIME: ${{ steps.prepare.outputs.claude_start_time }}
|
||||||
|
CLAUDE_BRANCH: ${{ steps.prepare.outputs.CLAUDE_BRANCH }}
|
||||||
|
USE_COMMIT_SIGNING: ${{ inputs.use_commit_signing }}
|
||||||
|
GITHUB_TOKEN: ${{ steps.prepare.outputs.GITHUB_TOKEN }}
|
||||||
|
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||||
|
|
||||||
- name: Update comment with job link
|
- name: Update comment with job link
|
||||||
if: steps.prepare.outputs.contains_trigger == 'true' && steps.prepare.outputs.claude_comment_id && always()
|
if: steps.prepare.outputs.contains_trigger == 'true' && steps.prepare.outputs.claude_comment_id && always()
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ runs:
|
|||||||
# Install Claude Code
|
# Install Claude Code
|
||||||
bun install -g @anthropic-ai/claude-code
|
bun install -g @anthropic-ai/claude-code
|
||||||
|
|
||||||
|
|
||||||
- name: Run Claude Code Action
|
- name: Run Claude Code Action
|
||||||
shell: bash
|
shell: bash
|
||||||
id: run_claude
|
id: run_claude
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import * as core from "@actions/core";
|
|||||||
import { reportClaudeComplete } from "../modes/remote-agent/system-progress-handler";
|
import { reportClaudeComplete } from "../modes/remote-agent/system-progress-handler";
|
||||||
import type { SystemProgressConfig } from "../modes/remote-agent/progress-types";
|
import type { SystemProgressConfig } from "../modes/remote-agent/progress-types";
|
||||||
import type { StreamConfig } from "../types/stream-config";
|
import type { StreamConfig } from "../types/stream-config";
|
||||||
|
import { commitUncommittedChanges } from "../github/utils/git-common-utils";
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
try {
|
try {
|
||||||
@@ -70,6 +71,42 @@ async function run() {
|
|||||||
`Reporting Claude completion: exitCode=${exitCode}, duration=${durationMs}ms`,
|
`Reporting Claude completion: exitCode=${exitCode}, duration=${durationMs}ms`,
|
||||||
);
|
);
|
||||||
reportClaudeComplete(systemProgressConfig, oidcToken, exitCode, durationMs);
|
reportClaudeComplete(systemProgressConfig, oidcToken, exitCode, durationMs);
|
||||||
|
|
||||||
|
// Ensure that uncommitted changes are committed
|
||||||
|
const claudeBranch = process.env.CLAUDE_BRANCH;
|
||||||
|
const useCommitSigning = process.env.USE_COMMIT_SIGNING === "true";
|
||||||
|
const githubToken = process.env.GITHUB_TOKEN;
|
||||||
|
|
||||||
|
// Parse repository from GITHUB_REPOSITORY (format: owner/repo)
|
||||||
|
const repository = process.env.GITHUB_REPOSITORY;
|
||||||
|
if (!repository) {
|
||||||
|
console.log("No GITHUB_REPOSITORY available, skipping branch cleanup");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [repoOwner, repoName] = repository.split("/");
|
||||||
|
|
||||||
|
if (claudeBranch && githubToken && repoOwner && repoName) {
|
||||||
|
console.log(`Checking for uncommitted changes in remote-agent mode...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const commitResult = await commitUncommittedChanges(
|
||||||
|
repoOwner,
|
||||||
|
repoName,
|
||||||
|
claudeBranch,
|
||||||
|
useCommitSigning,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (commitResult) {
|
||||||
|
console.log(`Committed uncommitted changes: ${commitResult.sha}`);
|
||||||
|
} else {
|
||||||
|
console.log("No uncommitted changes found");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Don't fail the action if commit fails
|
||||||
|
core.warning(`Failed to commit changes: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Don't fail the action if reporting fails
|
// Don't fail the action if reporting fails
|
||||||
core.warning(`Failed to report Claude completion: ${error}`);
|
core.warning(`Failed to report Claude completion: ${error}`);
|
||||||
|
|||||||
533
src/github/utils/git-common-utils.ts
Normal file
533
src/github/utils/git-common-utils.ts
Normal file
@@ -0,0 +1,533 @@
|
|||||||
|
/**
|
||||||
|
* Git Common Utilities
|
||||||
|
*
|
||||||
|
* This module provides utilities for Git operations using both GitHub API and CLI.
|
||||||
|
*
|
||||||
|
* ## When to use API vs CLI:
|
||||||
|
*
|
||||||
|
* ### GitHub API (for signed commits):
|
||||||
|
* - When commit signing is enabled (`useCommitSigning: true`)
|
||||||
|
* - Required for signed commits as GitHub Apps can't sign commits locally
|
||||||
|
* - Functions with "API" in the name use the GitHub REST API
|
||||||
|
*
|
||||||
|
* ### Git CLI (for unsigned commits):
|
||||||
|
* - When commit signing is disabled (`useCommitSigning: false`)
|
||||||
|
* - Faster for simple operations when signing isn't required
|
||||||
|
* - Uses local git commands (`git add`, `git commit`, `git push`)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFile } from "fs/promises";
|
||||||
|
import { join } from "path";
|
||||||
|
import { $ } from "bun";
|
||||||
|
import { GITHUB_API_URL } from "../api/config";
|
||||||
|
import { retryWithBackoff } from "../../utils/retry";
|
||||||
|
import fetch from "node-fetch";
|
||||||
|
|
||||||
|
interface FileEntry {
|
||||||
|
path: string;
|
||||||
|
content?: string;
|
||||||
|
deleted?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommitResult {
|
||||||
|
sha: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GitHubRef {
|
||||||
|
object: {
|
||||||
|
sha: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GitHubCommit {
|
||||||
|
tree: {
|
||||||
|
sha: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GitHubTree {
|
||||||
|
sha: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GitHubNewCommit {
|
||||||
|
sha: string;
|
||||||
|
message: string;
|
||||||
|
author: {
|
||||||
|
name: string;
|
||||||
|
date: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getUncommittedFiles(): Promise<FileEntry[]> {
|
||||||
|
try {
|
||||||
|
console.log("Getting uncommitted files...");
|
||||||
|
const gitStatus = await $`git status --porcelain`.quiet();
|
||||||
|
const statusOutput = gitStatus.stdout.toString().trim();
|
||||||
|
|
||||||
|
if (!statusOutput) {
|
||||||
|
console.log("No uncommitted files found (git status output is empty)");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Git status output:");
|
||||||
|
console.log(statusOutput);
|
||||||
|
|
||||||
|
const files: FileEntry[] = [];
|
||||||
|
const lines = statusOutput.split("\n");
|
||||||
|
console.log(`Found ${lines.length} lines in git status output`);
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmedLine = line.trim();
|
||||||
|
if (!trimmedLine) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse git status output
|
||||||
|
// Format: XY filename (e.g., "M file.txt", "A new.txt", "?? untracked.txt", "D deleted.txt")
|
||||||
|
const statusCode = trimmedLine.substring(0, 1);
|
||||||
|
const filePath = trimmedLine.substring(2).trim();
|
||||||
|
console.log(`Processing: status='${statusCode}' path='${filePath}'`);
|
||||||
|
|
||||||
|
// Skip files we shouldn't auto-commit
|
||||||
|
if (filePath === "output.txt" || filePath.endsWith("/output.txt")) {
|
||||||
|
console.log(`Skipping temporary file: ${filePath}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDeleted = statusCode.includes("D");
|
||||||
|
console.log(`File ${filePath}: deleted=${isDeleted}`);
|
||||||
|
|
||||||
|
files.push({
|
||||||
|
path: filePath,
|
||||||
|
deleted: isDeleted,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Returning ${files.length} files to commit`);
|
||||||
|
return files;
|
||||||
|
} catch (error) {
|
||||||
|
// If git status fails (e.g., not in a git repo), return empty array
|
||||||
|
console.error("Error running git status:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to get or create branch reference via GitHub API
|
||||||
|
* Used when we need to ensure a branch exists before committing via API
|
||||||
|
*/
|
||||||
|
async function getOrCreateBranchRefViaAPI(
|
||||||
|
owner: string,
|
||||||
|
repo: string,
|
||||||
|
branch: string,
|
||||||
|
githubToken: string,
|
||||||
|
): Promise<string> {
|
||||||
|
// Try to get the branch reference
|
||||||
|
const refUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/refs/heads/${branch}`;
|
||||||
|
const refResponse = await fetch(refUrl, {
|
||||||
|
headers: {
|
||||||
|
Accept: "application/vnd.github+json",
|
||||||
|
Authorization: `Bearer ${githubToken}`,
|
||||||
|
"X-GitHub-Api-Version": "2022-11-28",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (refResponse.ok) {
|
||||||
|
const refData = (await refResponse.json()) as GitHubRef;
|
||||||
|
return refData.object.sha;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (refResponse.status !== 404) {
|
||||||
|
throw new Error(`Failed to get branch reference: ${refResponse.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseBranch = process.env.BASE_BRANCH!;
|
||||||
|
|
||||||
|
// Get the SHA of the base branch
|
||||||
|
const baseRefUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/refs/heads/${baseBranch}`;
|
||||||
|
const baseRefResponse = await fetch(baseRefUrl, {
|
||||||
|
headers: {
|
||||||
|
Accept: "application/vnd.github+json",
|
||||||
|
Authorization: `Bearer ${githubToken}`,
|
||||||
|
"X-GitHub-Api-Version": "2022-11-28",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let baseSha: string;
|
||||||
|
|
||||||
|
if (!baseRefResponse.ok) {
|
||||||
|
// If base branch doesn't exist, try default branch
|
||||||
|
const repoUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}`;
|
||||||
|
const repoResponse = await fetch(repoUrl, {
|
||||||
|
headers: {
|
||||||
|
Accept: "application/vnd.github+json",
|
||||||
|
Authorization: `Bearer ${githubToken}`,
|
||||||
|
"X-GitHub-Api-Version": "2022-11-28",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!repoResponse.ok) {
|
||||||
|
throw new Error(`Failed to get repository info: ${repoResponse.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const repoData = (await repoResponse.json()) as {
|
||||||
|
default_branch: string;
|
||||||
|
};
|
||||||
|
const defaultBranch = repoData.default_branch;
|
||||||
|
|
||||||
|
// Try default branch
|
||||||
|
const defaultRefUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/refs/heads/${defaultBranch}`;
|
||||||
|
const defaultRefResponse = await fetch(defaultRefUrl, {
|
||||||
|
headers: {
|
||||||
|
Accept: "application/vnd.github+json",
|
||||||
|
Authorization: `Bearer ${githubToken}`,
|
||||||
|
"X-GitHub-Api-Version": "2022-11-28",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!defaultRefResponse.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to get default branch reference: ${defaultRefResponse.status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultRefData = (await defaultRefResponse.json()) as GitHubRef;
|
||||||
|
baseSha = defaultRefData.object.sha;
|
||||||
|
} else {
|
||||||
|
const baseRefData = (await baseRefResponse.json()) as GitHubRef;
|
||||||
|
baseSha = baseRefData.object.sha;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the new branch using the same pattern as octokit
|
||||||
|
const createRefUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/refs`;
|
||||||
|
const createRefResponse = await fetch(createRefUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/vnd.github+json",
|
||||||
|
Authorization: `Bearer ${githubToken}`,
|
||||||
|
"X-GitHub-Api-Version": "2022-11-28",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
ref: `refs/heads/${branch}`,
|
||||||
|
sha: baseSha,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!createRefResponse.ok) {
|
||||||
|
const errorText = await createRefResponse.text();
|
||||||
|
throw new Error(
|
||||||
|
`Failed to create branch: ${createRefResponse.status} - ${errorText}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Successfully created branch ${branch}`);
|
||||||
|
return baseSha;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a commit via GitHub API with the given files (for signed commits)
|
||||||
|
* Handles both file updates and deletions
|
||||||
|
* Used when commit signing is enabled - GitHub Apps can create signed commits via API
|
||||||
|
*/
|
||||||
|
async function createCommitViaAPI(
|
||||||
|
owner: string,
|
||||||
|
repo: string,
|
||||||
|
branch: string,
|
||||||
|
files: Array<string | FileEntry>,
|
||||||
|
message: string,
|
||||||
|
REPO_DIR: string = process.cwd(),
|
||||||
|
): Promise<CommitResult> {
|
||||||
|
const githubToken = process.env.GITHUB_TOKEN;
|
||||||
|
if (!githubToken) {
|
||||||
|
throw new Error("GITHUB_TOKEN environment variable is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize file entries
|
||||||
|
const fileEntries: FileEntry[] = files.map((f) => {
|
||||||
|
if (typeof f === "string") {
|
||||||
|
// Legacy string path format
|
||||||
|
const path = f.startsWith("/") ? f.slice(1) : f;
|
||||||
|
return { path, deleted: false };
|
||||||
|
}
|
||||||
|
// Already a FileEntry
|
||||||
|
const path = f.path.startsWith("/") ? f.path.slice(1) : f.path;
|
||||||
|
return { ...f, path };
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1. Get the branch reference (create if doesn't exist)
|
||||||
|
const baseSha = await getOrCreateBranchRefViaAPI(
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
branch,
|
||||||
|
githubToken,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Get the base commit
|
||||||
|
const commitUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/commits/${baseSha}`;
|
||||||
|
const commitResponse = await fetch(commitUrl, {
|
||||||
|
headers: {
|
||||||
|
Accept: "application/vnd.github+json",
|
||||||
|
Authorization: `Bearer ${githubToken}`,
|
||||||
|
"X-GitHub-Api-Version": "2022-11-28",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!commitResponse.ok) {
|
||||||
|
throw new Error(`Failed to get base commit: ${commitResponse.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const commitData = (await commitResponse.json()) as GitHubCommit;
|
||||||
|
const baseTreeSha = commitData.tree.sha;
|
||||||
|
|
||||||
|
// 3. Create tree entries for all files
|
||||||
|
const treeEntries = await Promise.all(
|
||||||
|
fileEntries.map(async (fileEntry) => {
|
||||||
|
const { path: filePath, deleted } = fileEntry;
|
||||||
|
|
||||||
|
// Handle deleted files by setting SHA to null
|
||||||
|
if (deleted) {
|
||||||
|
return {
|
||||||
|
path: filePath,
|
||||||
|
mode: "100644",
|
||||||
|
type: "blob" as const,
|
||||||
|
sha: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullPath = filePath.startsWith("/")
|
||||||
|
? filePath
|
||||||
|
: join(REPO_DIR, filePath);
|
||||||
|
|
||||||
|
// Check if file is binary (images, etc.)
|
||||||
|
const isBinaryFile =
|
||||||
|
/\.(png|jpg|jpeg|gif|webp|ico|pdf|zip|tar|gz|exe|bin|woff|woff2|ttf|eot)$/i.test(
|
||||||
|
filePath,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isBinaryFile) {
|
||||||
|
// For binary files, create a blob first using the Blobs API
|
||||||
|
const binaryContent = await readFile(fullPath);
|
||||||
|
|
||||||
|
// Create blob using Blobs API (supports encoding parameter)
|
||||||
|
const blobUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/blobs`;
|
||||||
|
const blobResponse = await fetch(blobUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/vnd.github+json",
|
||||||
|
Authorization: `Bearer ${githubToken}`,
|
||||||
|
"X-GitHub-Api-Version": "2022-11-28",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
content: binaryContent.toString("base64"),
|
||||||
|
encoding: "base64",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!blobResponse.ok) {
|
||||||
|
const errorText = await blobResponse.text();
|
||||||
|
throw new Error(
|
||||||
|
`Failed to create blob for ${filePath}: ${blobResponse.status} - ${errorText}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blobData = (await blobResponse.json()) as { sha: string };
|
||||||
|
|
||||||
|
// Return tree entry with blob SHA
|
||||||
|
return {
|
||||||
|
path: filePath,
|
||||||
|
mode: "100644",
|
||||||
|
type: "blob" as const,
|
||||||
|
sha: blobData.sha,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// For text files, include content directly in tree
|
||||||
|
const content = await readFile(fullPath, "utf-8");
|
||||||
|
return {
|
||||||
|
path: filePath,
|
||||||
|
mode: "100644",
|
||||||
|
type: "blob" as const,
|
||||||
|
content: content,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. Create a new tree
|
||||||
|
const treeUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/trees`;
|
||||||
|
const treeResponse = await fetch(treeUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/vnd.github+json",
|
||||||
|
Authorization: `Bearer ${githubToken}`,
|
||||||
|
"X-GitHub-Api-Version": "2022-11-28",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
base_tree: baseTreeSha,
|
||||||
|
tree: treeEntries,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!treeResponse.ok) {
|
||||||
|
const errorText = await treeResponse.text();
|
||||||
|
throw new Error(
|
||||||
|
`Failed to create tree: ${treeResponse.status} - ${errorText}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const treeData = (await treeResponse.json()) as GitHubTree;
|
||||||
|
|
||||||
|
// 5. Create a new commit
|
||||||
|
const newCommitUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/commits`;
|
||||||
|
const newCommitResponse = await fetch(newCommitUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/vnd.github+json",
|
||||||
|
Authorization: `Bearer ${githubToken}`,
|
||||||
|
"X-GitHub-Api-Version": "2022-11-28",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
message: message,
|
||||||
|
tree: treeData.sha,
|
||||||
|
parents: [baseSha],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!newCommitResponse.ok) {
|
||||||
|
const errorText = await newCommitResponse.text();
|
||||||
|
throw new Error(
|
||||||
|
`Failed to create commit: ${newCommitResponse.status} - ${errorText}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newCommitData = (await newCommitResponse.json()) as GitHubNewCommit;
|
||||||
|
|
||||||
|
// 6. Update the reference to point to the new commit
|
||||||
|
const updateRefUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/refs/heads/${branch}`;
|
||||||
|
|
||||||
|
// We're seeing intermittent 403 "Resource not accessible by integration" errors
|
||||||
|
// on certain repos when updating git references. These appear to be transient
|
||||||
|
// GitHub API issues that succeed on retry.
|
||||||
|
await retryWithBackoff(
|
||||||
|
async () => {
|
||||||
|
const updateRefResponse = await fetch(updateRefUrl, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/vnd.github+json",
|
||||||
|
Authorization: `Bearer ${githubToken}`,
|
||||||
|
"X-GitHub-Api-Version": "2022-11-28",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
sha: newCommitData.sha,
|
||||||
|
force: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!updateRefResponse.ok) {
|
||||||
|
const errorText = await updateRefResponse.text();
|
||||||
|
const error = new Error(
|
||||||
|
`Failed to update reference: ${updateRefResponse.status} - ${errorText}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Only retry on 403 errors - these are the intermittent failures we're targeting
|
||||||
|
if (updateRefResponse.status === 403) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For non-403 errors, fail immediately without retry
|
||||||
|
console.error("Non-retryable error:", updateRefResponse.status);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
maxAttempts: 3,
|
||||||
|
initialDelayMs: 1000, // Start with 1 second delay
|
||||||
|
maxDelayMs: 5000, // Max 5 seconds delay
|
||||||
|
backoffFactor: 2, // Double the delay each time
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sha: newCommitData.sha,
|
||||||
|
message: newCommitData.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Commit uncommitted changes - automatically chooses API or CLI based on signing requirement
|
||||||
|
*
|
||||||
|
* @param useCommitSigning - If true, uses GitHub API for signed commits. If false, uses git CLI.
|
||||||
|
*/
|
||||||
|
export async function commitUncommittedChanges(
|
||||||
|
owner: string,
|
||||||
|
repo: string,
|
||||||
|
branch: string,
|
||||||
|
useCommitSigning: boolean,
|
||||||
|
): Promise<CommitResult | null> {
|
||||||
|
try {
|
||||||
|
// Check for uncommitted changes
|
||||||
|
const gitStatus = await $`git status --porcelain`.quiet();
|
||||||
|
const hasUncommittedChanges = gitStatus.stdout.toString().trim().length > 0;
|
||||||
|
|
||||||
|
if (!hasUncommittedChanges) {
|
||||||
|
console.log("No uncommitted changes found");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Found uncommitted changes, committing them...");
|
||||||
|
|
||||||
|
const runId = process.env.GITHUB_RUN_ID || "unknown";
|
||||||
|
const commitMessage = `Auto-commit: Save uncommitted changes from Claude\n\nRun ID: ${runId}`;
|
||||||
|
|
||||||
|
if (useCommitSigning) {
|
||||||
|
// Use GitHub API when commit signing is required
|
||||||
|
console.log("Using GitHub API for signed commit...");
|
||||||
|
|
||||||
|
const files = await getUncommittedFiles();
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
console.log("No files to commit");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await createCommitViaAPI(
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
branch,
|
||||||
|
files,
|
||||||
|
commitMessage,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Use git CLI when commit signing is not required
|
||||||
|
console.log("Using git CLI for unsigned commit...");
|
||||||
|
|
||||||
|
// Add all changes
|
||||||
|
await $`git add -A`;
|
||||||
|
|
||||||
|
// Commit with a descriptive message
|
||||||
|
await $`git commit -m ${commitMessage}`;
|
||||||
|
|
||||||
|
// Push the changes
|
||||||
|
await $`git push origin ${branch}`;
|
||||||
|
|
||||||
|
console.log("✅ Successfully committed and pushed uncommitted changes");
|
||||||
|
|
||||||
|
// Get the commit SHA
|
||||||
|
const commitSha = await $`git rev-parse HEAD`.quiet();
|
||||||
|
|
||||||
|
return {
|
||||||
|
sha: commitSha.stdout.toString().trim(),
|
||||||
|
message: commitMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// If we can't check git status (e.g., not in a git repo during tests), return null
|
||||||
|
console.error("Error checking/committing changes:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,9 @@ import fetch from "node-fetch";
|
|||||||
import { GITHUB_API_URL } from "../github/api/config";
|
import { GITHUB_API_URL } from "../github/api/config";
|
||||||
import { retryWithBackoff } from "../utils/retry";
|
import { retryWithBackoff } from "../utils/retry";
|
||||||
|
|
||||||
|
// NOTE: We should extract out common git utilities into a shared module
|
||||||
|
// as we need to perform these operations outside of an MCP server. (See git-common-utils.ts)
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
type GitHubRef = {
|
type GitHubRef = {
|
||||||
|
|||||||
Reference in New Issue
Block a user