mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-01-23 23:14:13 +08:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9278e59355 | ||
|
|
2ef669b4c0 | ||
|
|
7bd5b28434 | ||
|
|
3fdfa8eea7 | ||
|
|
733e2f5302 | ||
|
|
1e24c646ef | ||
|
|
e9ad08ee09 | ||
|
|
baeaddf546 | ||
|
|
e55fe60b4e | ||
|
|
a328bf4b16 |
@@ -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()
|
||||||
|
|||||||
@@ -81,6 +81,44 @@ export function buildAllowedToolsString(
|
|||||||
return allAllowedTools;
|
return allAllowedTools;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Specialized allowed tools string for remote agent mode
|
||||||
|
* Always uses MCP commit signing and excludes dangerous git commands
|
||||||
|
*/
|
||||||
|
export function buildRemoteAgentAllowedToolsString(
|
||||||
|
customAllowedTools?: string[],
|
||||||
|
includeActionsTools: boolean = false,
|
||||||
|
): string {
|
||||||
|
let baseTools = [...BASE_ALLOWED_TOOLS];
|
||||||
|
|
||||||
|
// Always include the comment update tool from the comment server
|
||||||
|
baseTools.push("mcp__github_comment__update_claude_comment");
|
||||||
|
|
||||||
|
// Remote agent mode always uses MCP commit signing
|
||||||
|
baseTools.push(
|
||||||
|
"mcp__github_file_ops__commit_files",
|
||||||
|
"mcp__github_file_ops__delete_files",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add safe git tools only (read-only operations)
|
||||||
|
baseTools.push("Bash(git status:*)", "Bash(git diff:*)", "Bash(git log:*)");
|
||||||
|
|
||||||
|
// Add GitHub Actions MCP tools if enabled
|
||||||
|
if (includeActionsTools) {
|
||||||
|
baseTools.push(
|
||||||
|
"mcp__github_ci__get_ci_status",
|
||||||
|
"mcp__github_ci__get_workflow_run_details",
|
||||||
|
"mcp__github_ci__download_job_log",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let allAllowedTools = baseTools.join(",");
|
||||||
|
if (customAllowedTools && customAllowedTools.length > 0) {
|
||||||
|
allAllowedTools = `${allAllowedTools},${customAllowedTools.join(",")}`;
|
||||||
|
}
|
||||||
|
return allAllowedTools;
|
||||||
|
}
|
||||||
|
|
||||||
export function buildDisallowedToolsString(
|
export function buildDisallowedToolsString(
|
||||||
customDisallowedTools?: string[],
|
customDisallowedTools?: string[],
|
||||||
allowedTools?: string[],
|
allowedTools?: string[],
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,12 +3,20 @@
|
|||||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { readFile } from "fs/promises";
|
import { readFile, access, stat } from "fs/promises";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
|
import { constants } from "fs";
|
||||||
|
import { execFile } from "child_process";
|
||||||
|
import { promisify } from "util";
|
||||||
import fetch from "node-fetch";
|
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);
|
||||||
|
|
||||||
type GitHubRef = {
|
type GitHubRef = {
|
||||||
object: {
|
object: {
|
||||||
sha: string;
|
sha: string;
|
||||||
@@ -162,6 +170,77 @@ async function getOrCreateBranchRef(
|
|||||||
return baseSha;
|
return baseSha;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get the appropriate Git file mode for a file
|
||||||
|
async function getFileMode(filePath: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const fileStat = await stat(filePath);
|
||||||
|
|
||||||
|
if (fileStat.isFile()) {
|
||||||
|
// Check if execute bit is set for user
|
||||||
|
if (fileStat.mode & constants.S_IXUSR) {
|
||||||
|
return "100755"; // Executable file
|
||||||
|
} else {
|
||||||
|
return "100644"; // Regular file
|
||||||
|
}
|
||||||
|
} else if (fileStat.isDirectory()) {
|
||||||
|
return "040000"; // Directory (tree)
|
||||||
|
} else if (fileStat.isSymbolicLink()) {
|
||||||
|
return "120000"; // Symbolic link
|
||||||
|
} else {
|
||||||
|
// Fallback for unknown file types
|
||||||
|
return "100644";
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// If we can't stat the file, default to regular file
|
||||||
|
console.warn(
|
||||||
|
`Could not determine file mode for ${filePath}, using default: ${error}`,
|
||||||
|
);
|
||||||
|
return "100644";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to run pre-commit hooks
|
||||||
|
async function runPreCommitHooks(repoDir: string): Promise<void> {
|
||||||
|
const hookPath = join(repoDir, ".git", "hooks", "pre-commit");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if pre-commit hook exists and is executable
|
||||||
|
await access(hookPath);
|
||||||
|
|
||||||
|
console.log("Running pre-commit hook...");
|
||||||
|
|
||||||
|
// Execute the pre-commit hook
|
||||||
|
const { stdout, stderr } = await execFileAsync(hookPath, [], {
|
||||||
|
cwd: repoDir,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
GIT_INDEX_FILE: join(repoDir, ".git", "index"),
|
||||||
|
GIT_DIR: join(repoDir, ".git"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (stdout) console.log("Pre-commit hook stdout:", stdout);
|
||||||
|
if (stderr) console.log("Pre-commit hook stderr:", stderr);
|
||||||
|
|
||||||
|
console.log("Pre-commit hook passed");
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.code === "ENOENT") {
|
||||||
|
// Hook doesn't exist, that's fine
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.code === "EACCES") {
|
||||||
|
console.log("Pre-commit hook exists but is not executable, skipping");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hook failed with non-zero exit code
|
||||||
|
const errorMessage =
|
||||||
|
error.stderr || error.message || "Pre-commit hook failed";
|
||||||
|
throw new Error(`Pre-commit hook failed: ${errorMessage}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Commit files tool
|
// Commit files tool
|
||||||
server.tool(
|
server.tool(
|
||||||
"commit_files",
|
"commit_files",
|
||||||
@@ -173,8 +252,12 @@ server.tool(
|
|||||||
'Array of file paths relative to repository root (e.g. ["src/main.js", "README.md"]). All files must exist locally.',
|
'Array of file paths relative to repository root (e.g. ["src/main.js", "README.md"]). All files must exist locally.',
|
||||||
),
|
),
|
||||||
message: z.string().describe("Commit message"),
|
message: z.string().describe("Commit message"),
|
||||||
|
noVerify: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.describe("Skip pre-commit hooks (equivalent to git commit --no-verify)"),
|
||||||
},
|
},
|
||||||
async ({ files, message }) => {
|
async ({ files, message, noVerify }) => {
|
||||||
const owner = REPO_OWNER;
|
const owner = REPO_OWNER;
|
||||||
const repo = REPO_NAME;
|
const repo = REPO_NAME;
|
||||||
const branch = BRANCH_NAME;
|
const branch = BRANCH_NAME;
|
||||||
@@ -184,6 +267,11 @@ server.tool(
|
|||||||
throw new Error("GITHUB_TOKEN environment variable is required");
|
throw new Error("GITHUB_TOKEN environment variable is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Run pre-commit hooks unless explicitly skipped
|
||||||
|
if (!noVerify) {
|
||||||
|
await runPreCommitHooks(REPO_DIR);
|
||||||
|
}
|
||||||
|
|
||||||
const processedFiles = files.map((filePath) => {
|
const processedFiles = files.map((filePath) => {
|
||||||
if (filePath.startsWith("/")) {
|
if (filePath.startsWith("/")) {
|
||||||
return filePath.slice(1);
|
return filePath.slice(1);
|
||||||
@@ -223,6 +311,9 @@ server.tool(
|
|||||||
? filePath
|
? filePath
|
||||||
: join(REPO_DIR, filePath);
|
: join(REPO_DIR, filePath);
|
||||||
|
|
||||||
|
// Get the proper file mode based on file permissions
|
||||||
|
const fileMode = await getFileMode(fullPath);
|
||||||
|
|
||||||
// Check if file is binary (images, etc.)
|
// Check if file is binary (images, etc.)
|
||||||
const isBinaryFile =
|
const isBinaryFile =
|
||||||
/\.(png|jpg|jpeg|gif|webp|ico|pdf|zip|tar|gz|exe|bin|woff|woff2|ttf|eot)$/i.test(
|
/\.(png|jpg|jpeg|gif|webp|ico|pdf|zip|tar|gz|exe|bin|woff|woff2|ttf|eot)$/i.test(
|
||||||
@@ -261,7 +352,7 @@ server.tool(
|
|||||||
// Return tree entry with blob SHA
|
// Return tree entry with blob SHA
|
||||||
return {
|
return {
|
||||||
path: filePath,
|
path: filePath,
|
||||||
mode: "100644",
|
mode: fileMode,
|
||||||
type: "blob",
|
type: "blob",
|
||||||
sha: blobData.sha,
|
sha: blobData.sha,
|
||||||
};
|
};
|
||||||
@@ -270,7 +361,7 @@ server.tool(
|
|||||||
const content = await readFile(fullPath, "utf-8");
|
const content = await readFile(fullPath, "utf-8");
|
||||||
return {
|
return {
|
||||||
path: filePath,
|
path: filePath,
|
||||||
mode: "100644",
|
mode: fileMode,
|
||||||
type: "blob",
|
type: "blob",
|
||||||
content: content,
|
content: content,
|
||||||
};
|
};
|
||||||
|
|||||||
157
src/modes/remote-agent/branch.ts
Normal file
157
src/modes/remote-agent/branch.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
/**
|
||||||
|
* Branch handling for remote-agent mode with resume support
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { $ } from "bun";
|
||||||
|
import * as core from "@actions/core";
|
||||||
|
import type { GitHubContext } from "../../github/context";
|
||||||
|
import type { Octokits } from "../../github/api/client";
|
||||||
|
import type { ResumeResponse, ResumeResult } from "../../types/resume";
|
||||||
|
import {
|
||||||
|
setupBranch as setupBaseBranch,
|
||||||
|
type BranchInfo,
|
||||||
|
} from "../../github/operations/branch";
|
||||||
|
|
||||||
|
export type RemoteBranchInfo = BranchInfo & {
|
||||||
|
resumeMessages?: ResumeResult["messages"];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempts to resume from an existing session using the resume endpoint
|
||||||
|
* @param resumeEndpoint The URL to fetch the resume data from
|
||||||
|
* @param headers Headers to include in the request (including auth)
|
||||||
|
* @returns ResumeResult if successful, null otherwise
|
||||||
|
*/
|
||||||
|
async function fetchResumeData(
|
||||||
|
resumeEndpoint: string,
|
||||||
|
headers?: Record<string, string>,
|
||||||
|
): Promise<ResumeResult | null> {
|
||||||
|
try {
|
||||||
|
console.log(`Attempting to resume from: ${resumeEndpoint}`);
|
||||||
|
|
||||||
|
const response = await fetch(resumeEndpoint, {
|
||||||
|
method: "GET",
|
||||||
|
headers: headers || {},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.log(
|
||||||
|
`Resume endpoint returned ${response.status}: ${response.statusText}`,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as ResumeResponse;
|
||||||
|
|
||||||
|
if (!data.log || !Array.isArray(data.log)) {
|
||||||
|
console.log("Resume endpoint returned invalid data structure");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Successfully fetched resume data with ${data.log.length} messages`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// If a branch is specified in the response, we'll use it
|
||||||
|
// Otherwise, we'll determine the branch from the current git state
|
||||||
|
const branchName = data.branch || "";
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages: data.log,
|
||||||
|
branchName,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch resume data:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup branch for remote-agent mode with resume support
|
||||||
|
* @param octokits GitHub API clients
|
||||||
|
* @param context GitHub context
|
||||||
|
* @param oidcToken OIDC token for authentication
|
||||||
|
* @returns Branch information with optional resume messages
|
||||||
|
*/
|
||||||
|
export async function setupBranchWithResume(
|
||||||
|
octokits: Octokits,
|
||||||
|
context: GitHubContext,
|
||||||
|
oidcToken: string,
|
||||||
|
): Promise<RemoteBranchInfo> {
|
||||||
|
const { owner, repo } = context.repository;
|
||||||
|
const { baseBranch } = context.inputs;
|
||||||
|
|
||||||
|
// Check if we have a resume endpoint
|
||||||
|
if (context.progressTracking?.resumeEndpoint) {
|
||||||
|
console.log("Resume endpoint detected, attempting to resume session...");
|
||||||
|
|
||||||
|
// Prepare headers with OIDC token
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
...(context.progressTracking.headers || {}),
|
||||||
|
Authorization: `Bearer ${oidcToken}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const resumeData = await fetchResumeData(
|
||||||
|
context.progressTracking.resumeEndpoint,
|
||||||
|
headers,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (resumeData && resumeData.branchName) {
|
||||||
|
// Try to checkout the resumed branch
|
||||||
|
try {
|
||||||
|
console.log(`Resuming on branch: ${resumeData.branchName}`);
|
||||||
|
|
||||||
|
// Fetch the branch from origin
|
||||||
|
await $`git fetch origin ${resumeData.branchName}`;
|
||||||
|
|
||||||
|
// Checkout the branch
|
||||||
|
await $`git checkout ${resumeData.branchName}`;
|
||||||
|
|
||||||
|
console.log(`Successfully resumed on branch: ${resumeData.branchName}`);
|
||||||
|
|
||||||
|
// Get the base branch for this branch (we'll use the default branch as fallback)
|
||||||
|
let resumeBaseBranch = baseBranch;
|
||||||
|
if (!resumeBaseBranch) {
|
||||||
|
const repoResponse = await octokits.rest.repos.get({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
});
|
||||||
|
resumeBaseBranch = repoResponse.data.default_branch;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set outputs for GitHub Actions
|
||||||
|
core.setOutput("CLAUDE_BRANCH", resumeData.branchName);
|
||||||
|
core.setOutput("BASE_BRANCH", resumeBaseBranch);
|
||||||
|
|
||||||
|
return {
|
||||||
|
baseBranch: resumeBaseBranch,
|
||||||
|
claudeBranch: resumeData.branchName,
|
||||||
|
currentBranch: resumeData.branchName,
|
||||||
|
resumeMessages: resumeData.messages,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`Failed to checkout resumed branch ${resumeData.branchName}:`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
console.log("Falling back to creating a new branch...");
|
||||||
|
// Fall through to normal branch creation
|
||||||
|
}
|
||||||
|
} else if (resumeData) {
|
||||||
|
console.log(
|
||||||
|
"Resume data fetched but no branch specified, will create new branch",
|
||||||
|
);
|
||||||
|
// We have messages but no branch, so we'll create a new branch
|
||||||
|
// but still pass along the messages
|
||||||
|
const branchInfo = await setupBaseBranch(octokits, null, context);
|
||||||
|
return {
|
||||||
|
...branchInfo,
|
||||||
|
resumeMessages: resumeData.messages,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No resume endpoint or resume failed, use normal branch setup
|
||||||
|
console.log("No resume endpoint or resume failed, creating new branch...");
|
||||||
|
return setupBaseBranch(octokits, null, context);
|
||||||
|
}
|
||||||
@@ -3,12 +3,11 @@ import { mkdir, writeFile } from "fs/promises";
|
|||||||
import type { Mode, ModeOptions, ModeResult } from "../types";
|
import type { Mode, ModeOptions, ModeResult } from "../types";
|
||||||
import { isRepositoryDispatchEvent } from "../../github/context";
|
import { isRepositoryDispatchEvent } from "../../github/context";
|
||||||
import type { GitHubContext } from "../../github/context";
|
import type { GitHubContext } from "../../github/context";
|
||||||
import { setupBranch } from "../../github/operations/branch";
|
import { setupBranchWithResume } from "./branch";
|
||||||
import { configureGitAuth } from "../../github/operations/git-config";
|
|
||||||
import { prepareMcpConfig } from "../../mcp/install-mcp-server";
|
import { prepareMcpConfig } from "../../mcp/install-mcp-server";
|
||||||
import { GITHUB_SERVER_URL } from "../../github/api/config";
|
import { GITHUB_SERVER_URL } from "../../github/api/config";
|
||||||
import {
|
import {
|
||||||
buildAllowedToolsString,
|
buildRemoteAgentAllowedToolsString,
|
||||||
buildDisallowedToolsString,
|
buildDisallowedToolsString,
|
||||||
type PreparedContext,
|
type PreparedContext,
|
||||||
} from "../../create-prompt";
|
} from "../../create-prompt";
|
||||||
@@ -205,10 +204,10 @@ export const remoteAgentMode: Mode = {
|
|||||||
context.inputs.directPrompt ||
|
context.inputs.directPrompt ||
|
||||||
"No task description provided";
|
"No task description provided";
|
||||||
|
|
||||||
// Setup branch for work isolation
|
// Setup branch for work isolation with resume support
|
||||||
let branchInfo;
|
let branchInfo;
|
||||||
try {
|
try {
|
||||||
branchInfo = await setupBranch(octokit, null, context);
|
branchInfo = await setupBranchWithResume(octokit, context, oidcToken);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Report failure if we have system progress config
|
// Report failure if we have system progress config
|
||||||
if (systemProgressConfig) {
|
if (systemProgressConfig) {
|
||||||
@@ -223,28 +222,20 @@ export const remoteAgentMode: Mode = {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configure git authentication if not using commit signing
|
// Remote agent mode always uses commit signing for security
|
||||||
if (!context.inputs.useCommitSigning) {
|
// No git authentication configuration needed as we use GitHub API
|
||||||
try {
|
|
||||||
// Force Claude bot as git user
|
// Handle resume messages if they exist
|
||||||
await configureGitAuth(githubToken, context, {
|
if (branchInfo.resumeMessages && branchInfo.resumeMessages.length > 0) {
|
||||||
login: "claude[bot]",
|
console.log(
|
||||||
id: 209825114,
|
`Resumed session with ${branchInfo.resumeMessages.length} previous messages`,
|
||||||
});
|
);
|
||||||
} catch (error) {
|
// Store resume messages for later use
|
||||||
console.error("Failed to configure git authentication:", error);
|
// These will be prepended to the conversation when Claude starts
|
||||||
// Report failure if we have system progress config
|
core.setOutput(
|
||||||
if (systemProgressConfig) {
|
"resume_messages",
|
||||||
reportWorkflowFailed(
|
JSON.stringify(branchInfo.resumeMessages),
|
||||||
systemProgressConfig,
|
|
||||||
oidcToken,
|
|
||||||
"initialization",
|
|
||||||
error as Error,
|
|
||||||
"git_config_failed",
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Report workflow initialized
|
// Report workflow initialized
|
||||||
@@ -337,10 +328,9 @@ export const remoteAgentMode: Mode = {
|
|||||||
const hasActionsReadPermission =
|
const hasActionsReadPermission =
|
||||||
context.inputs.additionalPermissions.get("actions") === "read";
|
context.inputs.additionalPermissions.get("actions") === "read";
|
||||||
|
|
||||||
const allowedToolsString = buildAllowedToolsString(
|
const allowedToolsString = buildRemoteAgentAllowedToolsString(
|
||||||
context.inputs.allowedTools,
|
context.inputs.allowedTools,
|
||||||
hasActionsReadPermission,
|
hasActionsReadPermission,
|
||||||
context.inputs.useCommitSigning,
|
|
||||||
);
|
);
|
||||||
const disallowedToolsString = buildDisallowedToolsString(
|
const disallowedToolsString = buildDisallowedToolsString(
|
||||||
context.inputs.disallowedTools,
|
context.inputs.disallowedTools,
|
||||||
@@ -425,27 +415,13 @@ function generateDispatchSystemPrompt(
|
|||||||
? `Co-authored-by: ${triggerDisplayName ?? triggerUsername} <${triggerUsername}@users.noreply.github.com>`
|
? `Co-authored-by: ${triggerDisplayName ?? triggerUsername} <${triggerUsername}@users.noreply.github.com>`
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
let commitInstructions = "";
|
// Remote agent mode always uses MCP for commit signing
|
||||||
if (context.inputs.useCommitSigning) {
|
let commitInstructions = `- Use mcp__github_file_ops__commit_files and mcp__github_file_ops__delete_files to commit and push changes`;
|
||||||
commitInstructions = `- Use mcp__github_file_ops__commit_files and mcp__github_file_ops__delete_files to commit and push changes`;
|
|
||||||
if (coAuthorLine) {
|
if (coAuthorLine) {
|
||||||
commitInstructions += `
|
commitInstructions += `
|
||||||
- When pushing changes, include a Co-authored-by trailer in the commit message
|
- When pushing changes, include a Co-authored-by trailer in the commit message
|
||||||
- Use: "${coAuthorLine}"`;
|
- Use: "${coAuthorLine}"`;
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
commitInstructions = `- Use git commands via the Bash tool to commit and push your changes:
|
|
||||||
- Stage files: Bash(git add <files>)
|
|
||||||
- Commit with a descriptive message: Bash(git commit -m "<message>")`;
|
|
||||||
if (coAuthorLine) {
|
|
||||||
commitInstructions += `
|
|
||||||
- When committing, include a Co-authored-by trailer:
|
|
||||||
Bash(git commit -m "<message>\\n\\n${coAuthorLine}")`;
|
|
||||||
}
|
|
||||||
commitInstructions += `
|
|
||||||
- Be sure to follow your commit message guidelines
|
|
||||||
- Push to the remote: Bash(git push origin HEAD)`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `You are Claude, an AI assistant designed to help with GitHub issues and pull requests. Think carefully as you analyze the context and respond appropriately. Here's the context for your current task:
|
return `You are Claude, an AI assistant designed to help with GitHub issues and pull requests. Think carefully as you analyze the context and respond appropriately. Here's the context for your current task:
|
||||||
|
|
||||||
|
|||||||
29
src/types/resume.ts
Normal file
29
src/types/resume.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* Types for resume endpoint functionality
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Message structure from the resume endpoint
|
||||||
|
* This matches the structure used in Claude CLI's teleport feature
|
||||||
|
*/
|
||||||
|
export type ResumeMessage = {
|
||||||
|
role: "user" | "assistant" | "system";
|
||||||
|
content: string | Array<{ type: string; text?: string; [key: string]: any }>;
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response structure from the resume endpoint
|
||||||
|
*/
|
||||||
|
export type ResumeResponse = {
|
||||||
|
log: ResumeMessage[];
|
||||||
|
branch?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result after processing resume endpoint
|
||||||
|
*/
|
||||||
|
export type ResumeResult = {
|
||||||
|
messages: ResumeMessage[];
|
||||||
|
branchName: string;
|
||||||
|
};
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
getEventTypeAndContext,
|
getEventTypeAndContext,
|
||||||
buildAllowedToolsString,
|
buildAllowedToolsString,
|
||||||
buildDisallowedToolsString,
|
buildDisallowedToolsString,
|
||||||
|
buildRemoteAgentAllowedToolsString,
|
||||||
} from "../src/create-prompt";
|
} from "../src/create-prompt";
|
||||||
import type { PreparedContext } from "../src/create-prompt";
|
import type { PreparedContext } from "../src/create-prompt";
|
||||||
import type { Mode } from "../src/modes/types";
|
import type { Mode } from "../src/modes/types";
|
||||||
@@ -1149,3 +1150,117 @@ describe("buildDisallowedToolsString", () => {
|
|||||||
expect(result).toBe("BadTool1,BadTool2");
|
expect(result).toBe("BadTool1,BadTool2");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("buildRemoteAgentAllowedToolsString", () => {
|
||||||
|
test("should return correct tools for remote agent mode (always uses commit signing)", () => {
|
||||||
|
const result = buildRemoteAgentAllowedToolsString();
|
||||||
|
|
||||||
|
// Base tools should be present
|
||||||
|
expect(result).toContain("Edit");
|
||||||
|
expect(result).toContain("Glob");
|
||||||
|
expect(result).toContain("Grep");
|
||||||
|
expect(result).toContain("LS");
|
||||||
|
expect(result).toContain("Read");
|
||||||
|
expect(result).toContain("Write");
|
||||||
|
|
||||||
|
// Comment tool should always be included
|
||||||
|
expect(result).toContain("mcp__github_comment__update_claude_comment");
|
||||||
|
|
||||||
|
// MCP commit signing tools should always be included
|
||||||
|
expect(result).toContain("mcp__github_file_ops__commit_files");
|
||||||
|
expect(result).toContain("mcp__github_file_ops__delete_files");
|
||||||
|
|
||||||
|
// Safe git tools should be included
|
||||||
|
expect(result).toContain("Bash(git status:*)");
|
||||||
|
expect(result).toContain("Bash(git diff:*)");
|
||||||
|
expect(result).toContain("Bash(git log:*)");
|
||||||
|
|
||||||
|
// Dangerous git tools should NOT be included
|
||||||
|
expect(result).not.toContain("Bash(git commit:*)");
|
||||||
|
expect(result).not.toContain("Bash(git add:*)");
|
||||||
|
expect(result).not.toContain("Bash(git push:*)");
|
||||||
|
expect(result).not.toContain("Bash(git config");
|
||||||
|
expect(result).not.toContain("Bash(git rm:*)");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should include custom tools when provided", () => {
|
||||||
|
const customTools = ["CustomTool1", "CustomTool2"];
|
||||||
|
const result = buildRemoteAgentAllowedToolsString(customTools);
|
||||||
|
|
||||||
|
// Base tools should be present
|
||||||
|
expect(result).toContain("Edit");
|
||||||
|
expect(result).toContain("Glob");
|
||||||
|
|
||||||
|
// Custom tools should be included
|
||||||
|
expect(result).toContain("CustomTool1");
|
||||||
|
expect(result).toContain("CustomTool2");
|
||||||
|
|
||||||
|
// MCP commit signing tools should still be included
|
||||||
|
expect(result).toContain("mcp__github_file_ops__commit_files");
|
||||||
|
expect(result).toContain("mcp__github_file_ops__delete_files");
|
||||||
|
|
||||||
|
// Dangerous git tools should still NOT be included
|
||||||
|
expect(result).not.toContain("Bash(git commit:*)");
|
||||||
|
expect(result).not.toContain("Bash(git config");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should include GitHub Actions tools when includeActionsTools is true", () => {
|
||||||
|
const result = buildRemoteAgentAllowedToolsString([], true);
|
||||||
|
|
||||||
|
// Base tools should be present
|
||||||
|
expect(result).toContain("Edit");
|
||||||
|
expect(result).toContain("Glob");
|
||||||
|
|
||||||
|
// GitHub Actions tools should be included
|
||||||
|
expect(result).toContain("mcp__github_ci__get_ci_status");
|
||||||
|
expect(result).toContain("mcp__github_ci__get_workflow_run_details");
|
||||||
|
expect(result).toContain("mcp__github_ci__download_job_log");
|
||||||
|
|
||||||
|
// MCP commit signing tools should still be included
|
||||||
|
expect(result).toContain("mcp__github_file_ops__commit_files");
|
||||||
|
expect(result).toContain("mcp__github_file_ops__delete_files");
|
||||||
|
|
||||||
|
// Dangerous git tools should still NOT be included
|
||||||
|
expect(result).not.toContain("Bash(git commit:*)");
|
||||||
|
expect(result).not.toContain("Bash(git config");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should include both custom and Actions tools when both provided", () => {
|
||||||
|
const customTools = ["CustomTool1"];
|
||||||
|
const result = buildRemoteAgentAllowedToolsString(customTools, true);
|
||||||
|
|
||||||
|
// Base tools should be present
|
||||||
|
expect(result).toContain("Edit");
|
||||||
|
|
||||||
|
// Custom tools should be included
|
||||||
|
expect(result).toContain("CustomTool1");
|
||||||
|
|
||||||
|
// GitHub Actions tools should be included
|
||||||
|
expect(result).toContain("mcp__github_ci__get_ci_status");
|
||||||
|
|
||||||
|
// MCP commit signing tools should still be included
|
||||||
|
expect(result).toContain("mcp__github_file_ops__commit_files");
|
||||||
|
|
||||||
|
// Dangerous git tools should still NOT be included
|
||||||
|
expect(result).not.toContain("Bash(git commit:*)");
|
||||||
|
expect(result).not.toContain("Bash(git config");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should never include dangerous git tools regardless of parameters", () => {
|
||||||
|
const dangerousCustomTools = ["Bash(git commit:*)", "Bash(git config:*)"];
|
||||||
|
const result = buildRemoteAgentAllowedToolsString(
|
||||||
|
dangerousCustomTools,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
// The function should still include dangerous tools if explicitly provided in custom tools
|
||||||
|
// This is by design - if someone explicitly adds them, they should be included
|
||||||
|
expect(result).toContain("Bash(git commit:*)");
|
||||||
|
expect(result).toContain("Bash(git config:*)");
|
||||||
|
|
||||||
|
// But the base function should not add them automatically
|
||||||
|
const resultWithoutCustom = buildRemoteAgentAllowedToolsString([], true);
|
||||||
|
expect(resultWithoutCustom).not.toContain("Bash(git commit:*)");
|
||||||
|
expect(resultWithoutCustom).not.toContain("Bash(git config");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user