Initial commit
This commit is contained in:
20
src/github/api/client.ts
Normal file
20
src/github/api/client.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Octokit } from "@octokit/rest";
|
||||
import { graphql } from "@octokit/graphql";
|
||||
import { GITHUB_API_URL } from "./config";
|
||||
|
||||
export type Octokits = {
|
||||
rest: Octokit;
|
||||
graphql: typeof graphql;
|
||||
};
|
||||
|
||||
export function createOctokit(token: string): Octokits {
|
||||
return {
|
||||
rest: new Octokit({ auth: token }),
|
||||
graphql: graphql.defaults({
|
||||
baseUrl: GITHUB_API_URL,
|
||||
headers: {
|
||||
authorization: `token ${token}`,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
4
src/github/api/config.ts
Normal file
4
src/github/api/config.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const GITHUB_API_URL =
|
||||
process.env.GITHUB_API_URL || "https://api.github.com";
|
||||
export const GITHUB_SERVER_URL =
|
||||
process.env.GITHUB_SERVER_URL || "https://github.com";
|
||||
106
src/github/api/queries/github.ts
Normal file
106
src/github/api/queries/github.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
// GraphQL queries for GitHub data
|
||||
|
||||
export const PR_QUERY = `
|
||||
query($owner: String!, $repo: String!, $number: Int!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequest(number: $number) {
|
||||
title
|
||||
body
|
||||
author {
|
||||
login
|
||||
}
|
||||
baseRefName
|
||||
headRefName
|
||||
headRefOid
|
||||
createdAt
|
||||
additions
|
||||
deletions
|
||||
state
|
||||
commits(first: 100) {
|
||||
totalCount
|
||||
nodes {
|
||||
commit {
|
||||
oid
|
||||
message
|
||||
author {
|
||||
name
|
||||
email
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
files(first: 100) {
|
||||
nodes {
|
||||
path
|
||||
additions
|
||||
deletions
|
||||
changeType
|
||||
}
|
||||
}
|
||||
comments(first: 100) {
|
||||
nodes {
|
||||
id
|
||||
databaseId
|
||||
body
|
||||
author {
|
||||
login
|
||||
}
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
reviews(first: 100) {
|
||||
nodes {
|
||||
id
|
||||
databaseId
|
||||
author {
|
||||
login
|
||||
}
|
||||
body
|
||||
state
|
||||
submittedAt
|
||||
comments(first: 100) {
|
||||
nodes {
|
||||
id
|
||||
databaseId
|
||||
body
|
||||
path
|
||||
line
|
||||
author {
|
||||
login
|
||||
}
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const ISSUE_QUERY = `
|
||||
query($owner: String!, $repo: String!, $number: Int!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
issue(number: $number) {
|
||||
title
|
||||
body
|
||||
author {
|
||||
login
|
||||
}
|
||||
createdAt
|
||||
state
|
||||
comments(first: 100) {
|
||||
nodes {
|
||||
id
|
||||
databaseId
|
||||
body
|
||||
author {
|
||||
login
|
||||
}
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
139
src/github/context.ts
Normal file
139
src/github/context.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import * as github from "@actions/github";
|
||||
import type {
|
||||
IssuesEvent,
|
||||
IssueCommentEvent,
|
||||
PullRequestEvent,
|
||||
PullRequestReviewEvent,
|
||||
PullRequestReviewCommentEvent,
|
||||
} from "@octokit/webhooks-types";
|
||||
|
||||
export type ParsedGitHubContext = {
|
||||
runId: string;
|
||||
eventName: string;
|
||||
eventAction?: string;
|
||||
repository: {
|
||||
owner: string;
|
||||
repo: string;
|
||||
full_name: string;
|
||||
};
|
||||
actor: string;
|
||||
payload:
|
||||
| IssuesEvent
|
||||
| IssueCommentEvent
|
||||
| PullRequestEvent
|
||||
| PullRequestReviewEvent
|
||||
| PullRequestReviewCommentEvent;
|
||||
entityNumber: number;
|
||||
isPR: boolean;
|
||||
inputs: {
|
||||
triggerPhrase: string;
|
||||
assigneeTrigger: string;
|
||||
allowedTools: string;
|
||||
disallowedTools: string;
|
||||
customInstructions: string;
|
||||
directPrompt: string;
|
||||
};
|
||||
};
|
||||
|
||||
export function parseGitHubContext(): ParsedGitHubContext {
|
||||
const context = github.context;
|
||||
|
||||
const commonFields = {
|
||||
runId: process.env.GITHUB_RUN_ID!,
|
||||
eventName: context.eventName,
|
||||
eventAction: context.payload.action,
|
||||
repository: {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
full_name: `${context.repo.owner}/${context.repo.repo}`,
|
||||
},
|
||||
actor: context.actor,
|
||||
inputs: {
|
||||
triggerPhrase: process.env.TRIGGER_PHRASE ?? "@claude",
|
||||
assigneeTrigger: process.env.ASSIGNEE_TRIGGER ?? "",
|
||||
allowedTools: process.env.ALLOWED_TOOLS ?? "",
|
||||
disallowedTools: process.env.DISALLOWED_TOOLS ?? "",
|
||||
customInstructions: process.env.CUSTOM_INSTRUCTIONS ?? "",
|
||||
directPrompt: process.env.DIRECT_PROMPT ?? "",
|
||||
},
|
||||
};
|
||||
|
||||
switch (context.eventName) {
|
||||
case "issues": {
|
||||
return {
|
||||
...commonFields,
|
||||
payload: context.payload as IssuesEvent,
|
||||
entityNumber: (context.payload as IssuesEvent).issue.number,
|
||||
isPR: false,
|
||||
};
|
||||
}
|
||||
case "issue_comment": {
|
||||
return {
|
||||
...commonFields,
|
||||
payload: context.payload as IssueCommentEvent,
|
||||
entityNumber: (context.payload as IssueCommentEvent).issue.number,
|
||||
isPR: Boolean(
|
||||
(context.payload as IssueCommentEvent).issue.pull_request,
|
||||
),
|
||||
};
|
||||
}
|
||||
case "pull_request": {
|
||||
return {
|
||||
...commonFields,
|
||||
payload: context.payload as PullRequestEvent,
|
||||
entityNumber: (context.payload as PullRequestEvent).pull_request.number,
|
||||
isPR: true,
|
||||
};
|
||||
}
|
||||
case "pull_request_review": {
|
||||
return {
|
||||
...commonFields,
|
||||
payload: context.payload as PullRequestReviewEvent,
|
||||
entityNumber: (context.payload as PullRequestReviewEvent).pull_request
|
||||
.number,
|
||||
isPR: true,
|
||||
};
|
||||
}
|
||||
case "pull_request_review_comment": {
|
||||
return {
|
||||
...commonFields,
|
||||
payload: context.payload as PullRequestReviewCommentEvent,
|
||||
entityNumber: (context.payload as PullRequestReviewCommentEvent)
|
||||
.pull_request.number,
|
||||
isPR: true,
|
||||
};
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unsupported event type: ${context.eventName}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function isIssuesEvent(
|
||||
context: ParsedGitHubContext,
|
||||
): context is ParsedGitHubContext & { payload: IssuesEvent } {
|
||||
return context.eventName === "issues";
|
||||
}
|
||||
|
||||
export function isIssueCommentEvent(
|
||||
context: ParsedGitHubContext,
|
||||
): context is ParsedGitHubContext & { payload: IssueCommentEvent } {
|
||||
return context.eventName === "issue_comment";
|
||||
}
|
||||
|
||||
export function isPullRequestEvent(
|
||||
context: ParsedGitHubContext,
|
||||
): context is ParsedGitHubContext & { payload: PullRequestEvent } {
|
||||
return context.eventName === "pull_request";
|
||||
}
|
||||
|
||||
export function isPullRequestReviewEvent(
|
||||
context: ParsedGitHubContext,
|
||||
): context is ParsedGitHubContext & { payload: PullRequestReviewEvent } {
|
||||
return context.eventName === "pull_request_review";
|
||||
}
|
||||
|
||||
export function isPullRequestReviewCommentEvent(
|
||||
context: ParsedGitHubContext,
|
||||
): context is ParsedGitHubContext & { payload: PullRequestReviewCommentEvent } {
|
||||
return context.eventName === "pull_request_review_comment";
|
||||
}
|
||||
194
src/github/data/fetcher.ts
Normal file
194
src/github/data/fetcher.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { execSync } from "child_process";
|
||||
import type {
|
||||
GitHubPullRequest,
|
||||
GitHubIssue,
|
||||
GitHubComment,
|
||||
GitHubFile,
|
||||
GitHubReview,
|
||||
PullRequestQueryResponse,
|
||||
IssueQueryResponse,
|
||||
} from "../types";
|
||||
import { PR_QUERY, ISSUE_QUERY } from "../api/queries/github";
|
||||
import type { Octokits } from "../api/client";
|
||||
import { downloadCommentImages } from "../utils/image-downloader";
|
||||
import type { CommentWithImages } from "../utils/image-downloader";
|
||||
|
||||
type FetchDataParams = {
|
||||
octokits: Octokits;
|
||||
repository: string;
|
||||
prNumber: string;
|
||||
isPR: boolean;
|
||||
};
|
||||
|
||||
export type GitHubFileWithSHA = GitHubFile & {
|
||||
sha: string;
|
||||
};
|
||||
|
||||
export type FetchDataResult = {
|
||||
contextData: GitHubPullRequest | GitHubIssue;
|
||||
comments: GitHubComment[];
|
||||
changedFiles: GitHubFile[];
|
||||
changedFilesWithSHA: GitHubFileWithSHA[];
|
||||
reviewData: { nodes: GitHubReview[] } | null;
|
||||
imageUrlMap: Map<string, string>;
|
||||
};
|
||||
|
||||
export async function fetchGitHubData({
|
||||
octokits,
|
||||
repository,
|
||||
prNumber,
|
||||
isPR,
|
||||
}: FetchDataParams): Promise<FetchDataResult> {
|
||||
const [owner, repo] = repository.split("/");
|
||||
if (!owner || !repo) {
|
||||
throw new Error("Invalid repository format. Expected 'owner/repo'.");
|
||||
}
|
||||
|
||||
let contextData: GitHubPullRequest | GitHubIssue | null = null;
|
||||
let comments: GitHubComment[] = [];
|
||||
let changedFiles: GitHubFile[] = [];
|
||||
let reviewData: { nodes: GitHubReview[] } | null = null;
|
||||
|
||||
try {
|
||||
if (isPR) {
|
||||
// Fetch PR data with all comments and file information
|
||||
const prResult = await octokits.graphql<PullRequestQueryResponse>(
|
||||
PR_QUERY,
|
||||
{
|
||||
owner,
|
||||
repo,
|
||||
number: parseInt(prNumber),
|
||||
},
|
||||
);
|
||||
|
||||
if (prResult.repository.pullRequest) {
|
||||
const pullRequest = prResult.repository.pullRequest;
|
||||
contextData = pullRequest;
|
||||
changedFiles = pullRequest.files.nodes || [];
|
||||
comments = pullRequest.comments?.nodes || [];
|
||||
reviewData = pullRequest.reviews || [];
|
||||
|
||||
console.log(`Successfully fetched PR #${prNumber} data`);
|
||||
} else {
|
||||
throw new Error(`PR #${prNumber} not found`);
|
||||
}
|
||||
} else {
|
||||
// Fetch issue data
|
||||
const issueResult = await octokits.graphql<IssueQueryResponse>(
|
||||
ISSUE_QUERY,
|
||||
{
|
||||
owner,
|
||||
repo,
|
||||
number: parseInt(prNumber),
|
||||
},
|
||||
);
|
||||
|
||||
if (issueResult.repository.issue) {
|
||||
contextData = issueResult.repository.issue;
|
||||
comments = contextData?.comments?.nodes || [];
|
||||
|
||||
console.log(`Successfully fetched issue #${prNumber} data`);
|
||||
} else {
|
||||
throw new Error(`Issue #${prNumber} not found`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch ${isPR ? "PR" : "issue"} data:`, error);
|
||||
throw new Error(`Failed to fetch ${isPR ? "PR" : "issue"} data`);
|
||||
}
|
||||
|
||||
// Compute SHAs for changed files
|
||||
let changedFilesWithSHA: GitHubFileWithSHA[] = [];
|
||||
if (isPR && changedFiles.length > 0) {
|
||||
changedFilesWithSHA = changedFiles.map((file) => {
|
||||
try {
|
||||
// Use git hash-object to compute the SHA for the current file content
|
||||
const sha = execSync(`git hash-object "${file.path}"`, {
|
||||
encoding: "utf-8",
|
||||
}).trim();
|
||||
return {
|
||||
...file,
|
||||
sha,
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn(`Failed to compute SHA for ${file.path}:`, error);
|
||||
// Return original file without SHA if computation fails
|
||||
return {
|
||||
...file,
|
||||
sha: "unknown",
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Prepare all comments for image processing
|
||||
const issueComments: CommentWithImages[] = comments
|
||||
.filter((c) => c.body)
|
||||
.map((c) => ({
|
||||
type: "issue_comment" as const,
|
||||
id: c.databaseId,
|
||||
body: c.body,
|
||||
}));
|
||||
|
||||
const reviewBodies: CommentWithImages[] =
|
||||
reviewData?.nodes
|
||||
?.filter((r) => r.body)
|
||||
.map((r) => ({
|
||||
type: "review_body" as const,
|
||||
id: r.databaseId,
|
||||
pullNumber: prNumber,
|
||||
body: r.body,
|
||||
})) ?? [];
|
||||
|
||||
const reviewComments: CommentWithImages[] =
|
||||
reviewData?.nodes
|
||||
?.flatMap((r) => r.comments?.nodes ?? [])
|
||||
.filter((c) => c.body)
|
||||
.map((c) => ({
|
||||
type: "review_comment" as const,
|
||||
id: c.databaseId,
|
||||
body: c.body,
|
||||
})) ?? [];
|
||||
|
||||
// Add the main issue/PR body if it has content
|
||||
const mainBody: CommentWithImages[] = contextData.body
|
||||
? [
|
||||
{
|
||||
...(isPR
|
||||
? {
|
||||
type: "pr_body" as const,
|
||||
pullNumber: prNumber,
|
||||
body: contextData.body,
|
||||
}
|
||||
: {
|
||||
type: "issue_body" as const,
|
||||
issueNumber: prNumber,
|
||||
body: contextData.body,
|
||||
}),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
const allComments = [
|
||||
...mainBody,
|
||||
...issueComments,
|
||||
...reviewBodies,
|
||||
...reviewComments,
|
||||
];
|
||||
|
||||
const imageUrlMap = await downloadCommentImages(
|
||||
octokits,
|
||||
owner,
|
||||
repo,
|
||||
allComments,
|
||||
);
|
||||
|
||||
return {
|
||||
contextData,
|
||||
comments,
|
||||
changedFiles,
|
||||
changedFilesWithSHA,
|
||||
reviewData,
|
||||
imageUrlMap,
|
||||
};
|
||||
}
|
||||
123
src/github/data/formatter.ts
Normal file
123
src/github/data/formatter.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import type {
|
||||
GitHubPullRequest,
|
||||
GitHubIssue,
|
||||
GitHubComment,
|
||||
GitHubFile,
|
||||
GitHubReview,
|
||||
} from "../types";
|
||||
import type { GitHubFileWithSHA } from "./fetcher";
|
||||
|
||||
export function formatContext(
|
||||
contextData: GitHubPullRequest | GitHubIssue,
|
||||
isPR: boolean,
|
||||
): string {
|
||||
if (isPR) {
|
||||
const prData = contextData as GitHubPullRequest;
|
||||
return `PR Title: ${prData.title}
|
||||
PR Author: ${prData.author.login}
|
||||
PR Branch: ${prData.headRefName} -> ${prData.baseRefName}
|
||||
PR State: ${prData.state}
|
||||
PR Additions: ${prData.additions}
|
||||
PR Deletions: ${prData.deletions}
|
||||
Total Commits: ${prData.commits.totalCount}
|
||||
Changed Files: ${prData.files.nodes.length} files`;
|
||||
} else {
|
||||
const issueData = contextData as GitHubIssue;
|
||||
return `Issue Title: ${issueData.title}
|
||||
Issue Author: ${issueData.author.login}
|
||||
Issue State: ${issueData.state}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatBody(
|
||||
body: string,
|
||||
imageUrlMap: Map<string, string>,
|
||||
): string {
|
||||
let processedBody = body;
|
||||
|
||||
// Replace image URLs with local paths
|
||||
for (const [originalUrl, localPath] of imageUrlMap) {
|
||||
processedBody = processedBody.replaceAll(originalUrl, localPath);
|
||||
}
|
||||
|
||||
return processedBody;
|
||||
}
|
||||
|
||||
export function formatComments(
|
||||
comments: GitHubComment[],
|
||||
imageUrlMap?: Map<string, string>,
|
||||
): string {
|
||||
return comments
|
||||
.map((comment) => {
|
||||
let body = comment.body;
|
||||
|
||||
// Replace image URLs with local paths if we have a mapping
|
||||
if (imageUrlMap && body) {
|
||||
for (const [originalUrl, localPath] of imageUrlMap) {
|
||||
body = body.replaceAll(originalUrl, localPath);
|
||||
}
|
||||
}
|
||||
|
||||
return `[${comment.author.login} at ${comment.createdAt}]: ${body}`;
|
||||
})
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
export function formatReviewComments(
|
||||
reviewData: { nodes: GitHubReview[] } | null,
|
||||
imageUrlMap?: Map<string, string>,
|
||||
): string {
|
||||
if (!reviewData || !reviewData.nodes) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const formattedReviews = reviewData.nodes.map((review) => {
|
||||
let reviewOutput = `[Review by ${review.author.login} at ${review.submittedAt}]: ${review.state}`;
|
||||
|
||||
if (
|
||||
review.comments &&
|
||||
review.comments.nodes &&
|
||||
review.comments.nodes.length > 0
|
||||
) {
|
||||
const comments = review.comments.nodes
|
||||
.map((comment) => {
|
||||
let body = comment.body;
|
||||
|
||||
// Replace image URLs with local paths if we have a mapping
|
||||
if (imageUrlMap) {
|
||||
for (const [originalUrl, localPath] of imageUrlMap) {
|
||||
body = body.replaceAll(originalUrl, localPath);
|
||||
}
|
||||
}
|
||||
|
||||
return ` [Comment on ${comment.path}:${comment.line || "?"}]: ${body}`;
|
||||
})
|
||||
.join("\n");
|
||||
reviewOutput += `\n${comments}`;
|
||||
}
|
||||
|
||||
return reviewOutput;
|
||||
});
|
||||
|
||||
return formattedReviews.join("\n\n");
|
||||
}
|
||||
|
||||
export function formatChangedFiles(changedFiles: GitHubFile[]): string {
|
||||
return changedFiles
|
||||
.map(
|
||||
(file) =>
|
||||
`- ${file.path} (${file.changeType}) +${file.additions}/-${file.deletions}`,
|
||||
)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function formatChangedFilesWithSHA(
|
||||
changedFiles: GitHubFileWithSHA[],
|
||||
): string {
|
||||
return changedFiles
|
||||
.map(
|
||||
(file) =>
|
||||
`- ${file.path} (${file.changeType}) +${file.additions}/-${file.deletions} SHA: ${file.sha}`,
|
||||
)
|
||||
.join("\n");
|
||||
}
|
||||
59
src/github/operations/branch-cleanup.ts
Normal file
59
src/github/operations/branch-cleanup.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { Octokits } from "../api/client";
|
||||
import { GITHUB_SERVER_URL } from "../api/config";
|
||||
|
||||
export async function checkAndDeleteEmptyBranch(
|
||||
octokit: Octokits,
|
||||
owner: string,
|
||||
repo: string,
|
||||
claudeBranch: string | undefined,
|
||||
defaultBranch: string,
|
||||
): Promise<{ shouldDeleteBranch: boolean; branchLink: string }> {
|
||||
let branchLink = "";
|
||||
let shouldDeleteBranch = false;
|
||||
|
||||
if (claudeBranch) {
|
||||
// Check if Claude made any commits to the branch
|
||||
try {
|
||||
const { data: comparison } =
|
||||
await octokit.rest.repos.compareCommitsWithBasehead({
|
||||
owner,
|
||||
repo,
|
||||
basehead: `${defaultBranch}...${claudeBranch}`,
|
||||
});
|
||||
|
||||
// If there are no commits, mark branch for deletion
|
||||
if (comparison.total_commits === 0) {
|
||||
console.log(
|
||||
`Branch ${claudeBranch} has no commits from Claude, will delete it`,
|
||||
);
|
||||
shouldDeleteBranch = true;
|
||||
} else {
|
||||
// Only add branch link if there are commits
|
||||
const branchUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${claudeBranch}`;
|
||||
branchLink = `\n[View branch](${branchUrl})`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error checking for commits on Claude branch:", error);
|
||||
// If we can't check, assume the branch has commits to be safe
|
||||
const branchUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${claudeBranch}`;
|
||||
branchLink = `\n[View branch](${branchUrl})`;
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the branch if it has no commits
|
||||
if (shouldDeleteBranch && claudeBranch) {
|
||||
try {
|
||||
await octokit.rest.git.deleteRef({
|
||||
owner,
|
||||
repo,
|
||||
ref: `heads/${claudeBranch}`,
|
||||
});
|
||||
console.log(`✅ Deleted empty branch: ${claudeBranch}`);
|
||||
} catch (deleteError) {
|
||||
console.error(`Failed to delete branch ${claudeBranch}:`, deleteError);
|
||||
// Continue even if deletion fails
|
||||
}
|
||||
}
|
||||
|
||||
return { shouldDeleteBranch, branchLink };
|
||||
}
|
||||
121
src/github/operations/branch.ts
Normal file
121
src/github/operations/branch.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
/**
|
||||
* Setup the appropriate branch based on the event type:
|
||||
* - For PRs: Checkout the PR branch
|
||||
* - For Issues: Create a new branch
|
||||
*/
|
||||
|
||||
import { $ } from "bun";
|
||||
import * as core from "@actions/core";
|
||||
import type { ParsedGitHubContext } from "../context";
|
||||
import type { GitHubPullRequest } from "../types";
|
||||
import type { Octokits } from "../api/client";
|
||||
import type { FetchDataResult } from "../data/fetcher";
|
||||
|
||||
export type BranchInfo = {
|
||||
defaultBranch: string;
|
||||
claudeBranch?: string;
|
||||
currentBranch: string;
|
||||
};
|
||||
|
||||
export async function setupBranch(
|
||||
octokits: Octokits,
|
||||
githubData: FetchDataResult,
|
||||
context: ParsedGitHubContext,
|
||||
): Promise<BranchInfo> {
|
||||
const { owner, repo } = context.repository;
|
||||
const entityNumber = context.entityNumber;
|
||||
const isPR = context.isPR;
|
||||
|
||||
// Get the default branch first
|
||||
const repoResponse = await octokits.rest.repos.get({
|
||||
owner,
|
||||
repo,
|
||||
});
|
||||
const defaultBranch = repoResponse.data.default_branch;
|
||||
|
||||
if (isPR) {
|
||||
const prData = githubData.contextData as GitHubPullRequest;
|
||||
const prState = prData.state;
|
||||
|
||||
// Check if PR is closed or merged
|
||||
if (prState === "CLOSED" || prState === "MERGED") {
|
||||
console.log(
|
||||
`PR #${entityNumber} is ${prState}, creating new branch from default...`,
|
||||
);
|
||||
// Fall through to create a new branch like we do for issues
|
||||
} else {
|
||||
// Handle open PR: Checkout the PR branch
|
||||
console.log("This is an open PR, checking out PR branch...");
|
||||
|
||||
const branchName = prData.headRefName;
|
||||
|
||||
// Execute git commands to checkout PR branch
|
||||
await $`git fetch origin ${branchName}`;
|
||||
await $`git checkout ${branchName}`;
|
||||
|
||||
console.log(`Successfully checked out PR branch for PR #${entityNumber}`);
|
||||
|
||||
// For open PRs, return branch info
|
||||
return {
|
||||
defaultBranch,
|
||||
currentBranch: branchName,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Creating a new branch for either an issue or closed/merged PR
|
||||
const entityType = isPR ? "pr" : "issue";
|
||||
console.log(`Creating new branch for ${entityType} #${entityNumber}...`);
|
||||
|
||||
const timestamp = new Date()
|
||||
.toISOString()
|
||||
.replace(/[:-]/g, "")
|
||||
.replace(/\.\d{3}Z/, "")
|
||||
.split("T")
|
||||
.join("_");
|
||||
|
||||
const newBranch = `claude/${entityType}-${entityNumber}-${timestamp}`;
|
||||
|
||||
try {
|
||||
// Get the SHA of the default branch
|
||||
const defaultBranchRef = await octokits.rest.git.getRef({
|
||||
owner,
|
||||
repo,
|
||||
ref: `heads/${defaultBranch}`,
|
||||
});
|
||||
|
||||
const currentSHA = defaultBranchRef.data.object.sha;
|
||||
|
||||
console.log(`Current SHA: ${currentSHA}`);
|
||||
|
||||
// Create branch using GitHub API
|
||||
await octokits.rest.git.createRef({
|
||||
owner,
|
||||
repo,
|
||||
ref: `refs/heads/${newBranch}`,
|
||||
sha: currentSHA,
|
||||
});
|
||||
|
||||
// Checkout the new branch
|
||||
await $`git fetch origin ${newBranch}`;
|
||||
await $`git checkout ${newBranch}`;
|
||||
|
||||
console.log(
|
||||
`Successfully created and checked out new branch: ${newBranch}`,
|
||||
);
|
||||
|
||||
// Set outputs for GitHub Actions
|
||||
core.setOutput("CLAUDE_BRANCH", newBranch);
|
||||
core.setOutput("DEFAULT_BRANCH", defaultBranch);
|
||||
return {
|
||||
defaultBranch,
|
||||
claudeBranch: newBranch,
|
||||
currentBranch: newBranch,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error creating branch:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
194
src/github/operations/comment-logic.ts
Normal file
194
src/github/operations/comment-logic.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { GITHUB_SERVER_URL } from "../api/config";
|
||||
|
||||
export type ExecutionDetails = {
|
||||
cost_usd?: number;
|
||||
duration_ms?: number;
|
||||
duration_api_ms?: number;
|
||||
};
|
||||
|
||||
export type CommentUpdateInput = {
|
||||
currentBody: string;
|
||||
actionFailed: boolean;
|
||||
executionDetails: ExecutionDetails | null;
|
||||
jobUrl: string;
|
||||
branchLink?: string;
|
||||
prLink?: string;
|
||||
branchName?: string;
|
||||
triggerUsername?: string;
|
||||
};
|
||||
|
||||
export function ensureProperlyEncodedUrl(url: string): string | null {
|
||||
try {
|
||||
// First, try to parse the URL to see if it's already properly encoded
|
||||
new URL(url);
|
||||
if (url.includes(" ")) {
|
||||
const [baseUrl, queryString] = url.split("?");
|
||||
if (queryString) {
|
||||
// Parse query parameters and re-encode them properly
|
||||
const params = new URLSearchParams();
|
||||
const pairs = queryString.split("&");
|
||||
for (const pair of pairs) {
|
||||
const [key, value = ""] = pair.split("=");
|
||||
if (key) {
|
||||
// Decode first in case it's partially encoded, then encode properly
|
||||
params.set(key, decodeURIComponent(value));
|
||||
}
|
||||
}
|
||||
return `${baseUrl}?${params.toString()}`;
|
||||
}
|
||||
// If no query string, just encode spaces
|
||||
return url.replace(/ /g, "%20");
|
||||
}
|
||||
return url;
|
||||
} catch (e) {
|
||||
// If URL parsing fails, try basic fixes
|
||||
try {
|
||||
// Replace spaces with %20
|
||||
let fixedUrl = url.replace(/ /g, "%20");
|
||||
|
||||
// Ensure colons in parameter values are encoded (but not in http:// or after domain)
|
||||
const urlParts = fixedUrl.split("?");
|
||||
if (urlParts.length > 1 && urlParts[1]) {
|
||||
const [baseUrl, queryString] = urlParts;
|
||||
// Encode colons in the query string that aren't already encoded
|
||||
const fixedQuery = queryString.replace(/([^%]|^):(?!%2F%2F)/g, "$1%3A");
|
||||
fixedUrl = `${baseUrl}?${fixedQuery}`;
|
||||
}
|
||||
|
||||
// Try to validate the fixed URL
|
||||
new URL(fixedUrl);
|
||||
return fixedUrl;
|
||||
} catch {
|
||||
// If we still can't create a valid URL, return null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function updateCommentBody(input: CommentUpdateInput): string {
|
||||
const originalBody = input.currentBody;
|
||||
const {
|
||||
executionDetails,
|
||||
jobUrl,
|
||||
branchLink,
|
||||
prLink,
|
||||
actionFailed,
|
||||
branchName,
|
||||
triggerUsername,
|
||||
} = input;
|
||||
|
||||
// Extract content from the original comment body
|
||||
// First, remove the "Claude Code is working…" or "Claude Code is working..." message
|
||||
const workingPattern = /Claude Code is working[…\.]{1,3}(?:\s*<img[^>]*>)?/i;
|
||||
let bodyContent = originalBody.replace(workingPattern, "").trim();
|
||||
|
||||
// Check if there's a PR link in the content
|
||||
let prLinkFromContent = "";
|
||||
|
||||
// Match the entire markdown link structure
|
||||
const prLinkPattern = /\[Create .* PR\]\((.*)\)$/m;
|
||||
const prLinkMatch = bodyContent.match(prLinkPattern);
|
||||
|
||||
if (prLinkMatch && prLinkMatch[1]) {
|
||||
const encodedUrl = ensureProperlyEncodedUrl(prLinkMatch[1]);
|
||||
if (encodedUrl) {
|
||||
prLinkFromContent = encodedUrl;
|
||||
// Remove the PR link from the content
|
||||
bodyContent = bodyContent.replace(prLinkMatch[0], "").trim();
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate duration string if available
|
||||
let durationStr = "";
|
||||
if (executionDetails?.duration_ms !== undefined) {
|
||||
const totalSeconds = Math.round(executionDetails.duration_ms / 1000);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
durationStr = minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`;
|
||||
}
|
||||
|
||||
// Build the header
|
||||
let header = "";
|
||||
|
||||
if (actionFailed) {
|
||||
header = "**Claude encountered an error";
|
||||
if (durationStr) {
|
||||
header += ` after ${durationStr}`;
|
||||
}
|
||||
header += "**";
|
||||
} else {
|
||||
// Get the username from triggerUsername or extract from content
|
||||
const usernameMatch = bodyContent.match(/@([a-zA-Z0-9-]+)/);
|
||||
const username =
|
||||
triggerUsername || (usernameMatch ? usernameMatch[1] : "user");
|
||||
|
||||
header = `**Claude finished @${username}'s task`;
|
||||
if (durationStr) {
|
||||
header += ` in ${durationStr}`;
|
||||
}
|
||||
header += "**";
|
||||
}
|
||||
|
||||
// Add links section
|
||||
let links = ` —— [View job](${jobUrl})`;
|
||||
|
||||
// Add branch name with link
|
||||
if (branchName || branchLink) {
|
||||
let finalBranchName = branchName;
|
||||
let branchUrl = "";
|
||||
|
||||
if (branchLink) {
|
||||
// Extract the branch URL from the link
|
||||
const urlMatch = branchLink.match(/\((https:\/\/.*)\)/);
|
||||
if (urlMatch && urlMatch[1]) {
|
||||
branchUrl = urlMatch[1];
|
||||
}
|
||||
|
||||
// Extract branch name from link if not provided
|
||||
if (!finalBranchName) {
|
||||
const branchNameMatch = branchLink.match(/tree\/([^"'\)]+)/);
|
||||
if (branchNameMatch) {
|
||||
finalBranchName = branchNameMatch[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we don't have a URL yet but have a branch name, construct it
|
||||
if (!branchUrl && finalBranchName) {
|
||||
// Extract owner/repo from jobUrl
|
||||
const repoMatch = jobUrl.match(/github\.com\/([^\/]+)\/([^\/]+)\//);
|
||||
if (repoMatch) {
|
||||
branchUrl = `${GITHUB_SERVER_URL}/${repoMatch[1]}/${repoMatch[2]}/tree/${finalBranchName}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (finalBranchName && branchUrl) {
|
||||
links += ` • [\`${finalBranchName}\`](${branchUrl})`;
|
||||
} else if (finalBranchName) {
|
||||
links += ` • \`${finalBranchName}\``;
|
||||
}
|
||||
}
|
||||
|
||||
// Add PR link (either from content or provided)
|
||||
const prUrl =
|
||||
prLinkFromContent || (prLink ? prLink.match(/\(([^)]+)\)/)?.[1] : "");
|
||||
if (prUrl) {
|
||||
links += ` • [Create PR ➔](${prUrl})`;
|
||||
}
|
||||
|
||||
// Build the new body with blank line between header and separator
|
||||
let newBody = `${header}${links}\n\n---\n`;
|
||||
|
||||
// Clean up the body content
|
||||
// Remove any existing View job run, branch links from the bottom
|
||||
bodyContent = bodyContent.replace(/\n?\[View job run\]\([^\)]+\)/g, "");
|
||||
bodyContent = bodyContent.replace(/\n?\[View branch\]\([^\)]+\)/g, "");
|
||||
|
||||
// Remove any existing duration info at the bottom
|
||||
bodyContent = bodyContent.replace(/\n*---\n*Duration: [0-9]+m? [0-9]+s/g, "");
|
||||
|
||||
// Add the cleaned body content
|
||||
newBody += bodyContent;
|
||||
|
||||
return newBody.trim();
|
||||
}
|
||||
33
src/github/operations/comments/common.ts
Normal file
33
src/github/operations/comments/common.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { GITHUB_SERVER_URL } from "../../api/config";
|
||||
|
||||
export const SPINNER_HTML =
|
||||
'<img src="https://github.com/user-attachments/assets/5ac382c7-e004-429b-8e35-7feb3e8f9c6f" width="14px" height="14px" style="vertical-align: middle; margin-left: 4px;" />';
|
||||
|
||||
export function createJobRunLink(
|
||||
owner: string,
|
||||
repo: string,
|
||||
runId: string,
|
||||
): string {
|
||||
const jobRunUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/actions/runs/${runId}`;
|
||||
return `[View job run](${jobRunUrl})`;
|
||||
}
|
||||
|
||||
export function createBranchLink(
|
||||
owner: string,
|
||||
repo: string,
|
||||
branchName: string,
|
||||
): string {
|
||||
const branchUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${branchName}`;
|
||||
return `\n[View branch](${branchUrl})`;
|
||||
}
|
||||
|
||||
export function createCommentBody(
|
||||
jobRunLink: string,
|
||||
branchLink: string = "",
|
||||
): string {
|
||||
return `Claude Code is working… ${SPINNER_HTML}
|
||||
|
||||
I'll analyze this and get back to you.
|
||||
|
||||
${jobRunLink}${branchLink}`;
|
||||
}
|
||||
73
src/github/operations/comments/create-initial.ts
Normal file
73
src/github/operations/comments/create-initial.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
/**
|
||||
* Create the initial tracking comment when Claude Code starts working
|
||||
* This comment shows the working status and includes a link to the job run
|
||||
*/
|
||||
|
||||
import { appendFileSync } from "fs";
|
||||
import { createJobRunLink, createCommentBody } from "./common";
|
||||
import {
|
||||
isPullRequestReviewCommentEvent,
|
||||
type ParsedGitHubContext,
|
||||
} from "../../context";
|
||||
import type { Octokit } from "@octokit/rest";
|
||||
|
||||
export async function createInitialComment(
|
||||
octokit: Octokit,
|
||||
context: ParsedGitHubContext,
|
||||
) {
|
||||
const { owner, repo } = context.repository;
|
||||
|
||||
const jobRunLink = createJobRunLink(owner, repo, context.runId);
|
||||
const initialBody = createCommentBody(jobRunLink);
|
||||
|
||||
try {
|
||||
let response;
|
||||
|
||||
// Only use createReplyForReviewComment if it's a PR review comment AND we have a comment_id
|
||||
if (isPullRequestReviewCommentEvent(context)) {
|
||||
response = await octokit.rest.pulls.createReplyForReviewComment({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: context.entityNumber,
|
||||
comment_id: context.payload.comment.id,
|
||||
body: initialBody,
|
||||
});
|
||||
} else {
|
||||
// For all other cases (issues, issue comments, or missing comment_id)
|
||||
response = await octokit.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: context.entityNumber,
|
||||
body: initialBody,
|
||||
});
|
||||
}
|
||||
|
||||
// Output the comment ID for downstream steps using GITHUB_OUTPUT
|
||||
const githubOutput = process.env.GITHUB_OUTPUT!;
|
||||
appendFileSync(githubOutput, `claude_comment_id=${response.data.id}\n`);
|
||||
console.log(`✅ Created initial comment with ID: ${response.data.id}`);
|
||||
return response.data.id;
|
||||
} catch (error) {
|
||||
console.error("Error in initial comment:", error);
|
||||
|
||||
// Always fall back to regular issue comment if anything fails
|
||||
try {
|
||||
const response = await octokit.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: context.entityNumber,
|
||||
body: initialBody,
|
||||
});
|
||||
|
||||
const githubOutput = process.env.GITHUB_OUTPUT!;
|
||||
appendFileSync(githubOutput, `claude_comment_id=${response.data.id}\n`);
|
||||
console.log(`✅ Created fallback comment with ID: ${response.data.id}`);
|
||||
return response.data.id;
|
||||
} catch (fallbackError) {
|
||||
console.error("Error creating fallback comment:", fallbackError);
|
||||
throw fallbackError;
|
||||
}
|
||||
}
|
||||
}
|
||||
62
src/github/operations/comments/update-with-branch.ts
Normal file
62
src/github/operations/comments/update-with-branch.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
/**
|
||||
* Update the initial tracking comment with branch link
|
||||
* This happens after the branch is created for issues
|
||||
*/
|
||||
|
||||
import {
|
||||
createJobRunLink,
|
||||
createBranchLink,
|
||||
createCommentBody,
|
||||
} from "./common";
|
||||
import { type Octokits } from "../../api/client";
|
||||
import {
|
||||
isPullRequestReviewCommentEvent,
|
||||
type ParsedGitHubContext,
|
||||
} from "../../context";
|
||||
|
||||
export async function updateTrackingComment(
|
||||
octokit: Octokits,
|
||||
context: ParsedGitHubContext,
|
||||
commentId: number,
|
||||
branch?: string,
|
||||
) {
|
||||
const { owner, repo } = context.repository;
|
||||
|
||||
const jobRunLink = createJobRunLink(owner, repo, context.runId);
|
||||
|
||||
// Add branch link for issues (not PRs)
|
||||
let branchLink = "";
|
||||
if (branch && !context.isPR) {
|
||||
branchLink = createBranchLink(owner, repo, branch);
|
||||
}
|
||||
|
||||
const updatedBody = createCommentBody(jobRunLink, branchLink);
|
||||
|
||||
// Update the existing comment with the branch link
|
||||
try {
|
||||
if (isPullRequestReviewCommentEvent(context)) {
|
||||
// For PR review comments (inline comments), use the pulls API
|
||||
await octokit.rest.pulls.updateReviewComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: commentId,
|
||||
body: updatedBody,
|
||||
});
|
||||
console.log(`✅ Updated PR review comment ${commentId} with branch link`);
|
||||
} else {
|
||||
// For all other comments, use the issues API
|
||||
await octokit.rest.issues.updateComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: commentId,
|
||||
body: updatedBody,
|
||||
});
|
||||
console.log(`✅ Updated issue comment ${commentId} with branch link`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error updating comment with branch link:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
123
src/github/token.ts
Normal file
123
src/github/token.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import * as core from "@actions/core";
|
||||
|
||||
type RetryOptions = {
|
||||
maxAttempts?: number;
|
||||
initialDelayMs?: number;
|
||||
maxDelayMs?: number;
|
||||
backoffFactor?: number;
|
||||
};
|
||||
|
||||
async function retryWithBackoff<T>(
|
||||
operation: () => Promise<T>,
|
||||
options: RetryOptions = {},
|
||||
): Promise<T> {
|
||||
const {
|
||||
maxAttempts = 3,
|
||||
initialDelayMs = 5000,
|
||||
maxDelayMs = 20000,
|
||||
backoffFactor = 2,
|
||||
} = options;
|
||||
|
||||
let delayMs = initialDelayMs;
|
||||
let lastError: Error | undefined;
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
console.log(`Attempt ${attempt} of ${maxAttempts}...`);
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
console.error(`Attempt ${attempt} failed:`, lastError.message);
|
||||
|
||||
if (attempt < maxAttempts) {
|
||||
console.log(`Retrying in ${delayMs / 1000} seconds...`);
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
delayMs = Math.min(delayMs * backoffFactor, maxDelayMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Operation failed after ${maxAttempts} attempts. Last error: ${
|
||||
lastError?.message ?? "Unknown error"
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function getOidcToken(): Promise<string> {
|
||||
try {
|
||||
const oidcToken = await core.getIDToken("claude-code-github-action");
|
||||
|
||||
if (!oidcToken) {
|
||||
throw new Error("OIDC token not found");
|
||||
}
|
||||
|
||||
return oidcToken;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to get OIDC token: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function exchangeForAppToken(oidcToken: string): Promise<string> {
|
||||
const response = await fetch(
|
||||
"https://api.anthropic.com/api/github/github-app-token-exchange",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${oidcToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`App token exchange failed: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const appTokenData = (await response.json()) as {
|
||||
token?: string;
|
||||
app_token?: string;
|
||||
};
|
||||
const appToken = appTokenData.token || appTokenData.app_token;
|
||||
|
||||
if (!appToken) {
|
||||
throw new Error("App token not found in response");
|
||||
}
|
||||
|
||||
return appToken;
|
||||
}
|
||||
|
||||
export async function setupGitHubToken(): Promise<string> {
|
||||
try {
|
||||
// Check if GitHub token was provided as override
|
||||
const providedToken = process.env.OVERRIDE_GITHUB_TOKEN;
|
||||
|
||||
if (providedToken) {
|
||||
console.log("Using provided GITHUB_TOKEN for authentication");
|
||||
core.setOutput("GITHUB_TOKEN", providedToken);
|
||||
return providedToken;
|
||||
}
|
||||
|
||||
console.log("Requesting OIDC token...");
|
||||
const oidcToken = await retryWithBackoff(() => getOidcToken());
|
||||
console.log("OIDC token successfully obtained");
|
||||
|
||||
console.log("Exchanging OIDC token for app token...");
|
||||
const appToken = await retryWithBackoff(() =>
|
||||
exchangeForAppToken(oidcToken),
|
||||
);
|
||||
console.log("App token successfully obtained");
|
||||
|
||||
console.log("Using GITHUB_TOKEN from OIDC");
|
||||
core.setOutput("GITHUB_TOKEN", appToken);
|
||||
return appToken;
|
||||
} catch (error) {
|
||||
core.setFailed(`Failed to setup GitHub token: ${error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
96
src/github/types.ts
Normal file
96
src/github/types.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
// Types for GitHub GraphQL query responses
|
||||
export type GitHubAuthor = {
|
||||
login: string;
|
||||
};
|
||||
|
||||
export type GitHubComment = {
|
||||
id: string;
|
||||
databaseId: string;
|
||||
body: string;
|
||||
author: GitHubAuthor;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type GitHubReviewComment = GitHubComment & {
|
||||
path: string;
|
||||
line: number | null;
|
||||
};
|
||||
|
||||
export type GitHubCommit = {
|
||||
oid: string;
|
||||
message: string;
|
||||
author: {
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type GitHubFile = {
|
||||
path: string;
|
||||
additions: number;
|
||||
deletions: number;
|
||||
changeType: string;
|
||||
};
|
||||
|
||||
export type GitHubReview = {
|
||||
id: string;
|
||||
databaseId: string;
|
||||
author: GitHubAuthor;
|
||||
body: string;
|
||||
state: string;
|
||||
submittedAt: string;
|
||||
comments: {
|
||||
nodes: GitHubReviewComment[];
|
||||
};
|
||||
};
|
||||
|
||||
export type GitHubPullRequest = {
|
||||
title: string;
|
||||
body: string;
|
||||
author: GitHubAuthor;
|
||||
baseRefName: string;
|
||||
headRefName: string;
|
||||
headRefOid: string;
|
||||
createdAt: string;
|
||||
additions: number;
|
||||
deletions: number;
|
||||
state: string;
|
||||
commits: {
|
||||
totalCount: number;
|
||||
nodes: Array<{
|
||||
commit: GitHubCommit;
|
||||
}>;
|
||||
};
|
||||
files: {
|
||||
nodes: GitHubFile[];
|
||||
};
|
||||
comments: {
|
||||
nodes: GitHubComment[];
|
||||
};
|
||||
reviews: {
|
||||
nodes: GitHubReview[];
|
||||
};
|
||||
};
|
||||
|
||||
export type GitHubIssue = {
|
||||
title: string;
|
||||
body: string;
|
||||
author: GitHubAuthor;
|
||||
createdAt: string;
|
||||
state: string;
|
||||
comments: {
|
||||
nodes: GitHubComment[];
|
||||
};
|
||||
};
|
||||
|
||||
export type PullRequestQueryResponse = {
|
||||
repository: {
|
||||
pullRequest: GitHubPullRequest;
|
||||
};
|
||||
};
|
||||
|
||||
export type IssueQueryResponse = {
|
||||
repository: {
|
||||
issue: GitHubIssue;
|
||||
};
|
||||
};
|
||||
232
src/github/utils/image-downloader.ts
Normal file
232
src/github/utils/image-downloader.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import type { Octokits } from "../api/client";
|
||||
import { GITHUB_SERVER_URL } from "../api/config";
|
||||
|
||||
const IMAGE_REGEX = new RegExp(
|
||||
`!\\[[^\\]]*\\]\\((${GITHUB_SERVER_URL.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\/user-attachments\\/assets\\/[^)]+)\\)`,
|
||||
"g",
|
||||
);
|
||||
|
||||
type IssueComment = {
|
||||
type: "issue_comment";
|
||||
id: string;
|
||||
body: string;
|
||||
};
|
||||
|
||||
type ReviewComment = {
|
||||
type: "review_comment";
|
||||
id: string;
|
||||
body: string;
|
||||
};
|
||||
|
||||
type ReviewBody = {
|
||||
type: "review_body";
|
||||
id: string;
|
||||
pullNumber: string;
|
||||
body: string;
|
||||
};
|
||||
|
||||
type IssueBody = {
|
||||
type: "issue_body";
|
||||
issueNumber: string;
|
||||
body: string;
|
||||
};
|
||||
|
||||
type PullRequestBody = {
|
||||
type: "pr_body";
|
||||
pullNumber: string;
|
||||
body: string;
|
||||
};
|
||||
|
||||
export type CommentWithImages =
|
||||
| IssueComment
|
||||
| ReviewComment
|
||||
| ReviewBody
|
||||
| IssueBody
|
||||
| PullRequestBody;
|
||||
|
||||
export async function downloadCommentImages(
|
||||
octokits: Octokits,
|
||||
owner: string,
|
||||
repo: string,
|
||||
comments: CommentWithImages[],
|
||||
): Promise<Map<string, string>> {
|
||||
const urlToPathMap = new Map<string, string>();
|
||||
const downloadsDir = "/tmp/github-images";
|
||||
|
||||
await fs.mkdir(downloadsDir, { recursive: true });
|
||||
|
||||
const commentsWithImages: Array<{
|
||||
comment: CommentWithImages;
|
||||
urls: string[];
|
||||
}> = [];
|
||||
|
||||
for (const comment of comments) {
|
||||
const imageMatches = [...comment.body.matchAll(IMAGE_REGEX)];
|
||||
const urls = imageMatches.map((match) => match[1] as string);
|
||||
|
||||
if (urls.length > 0) {
|
||||
commentsWithImages.push({ comment, urls });
|
||||
const id =
|
||||
comment.type === "issue_body"
|
||||
? comment.issueNumber
|
||||
: comment.type === "pr_body"
|
||||
? comment.pullNumber
|
||||
: comment.id;
|
||||
console.log(`Found ${urls.length} image(s) in ${comment.type} ${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Process each comment with images
|
||||
for (const { comment, urls } of commentsWithImages) {
|
||||
try {
|
||||
let bodyHtml: string | undefined;
|
||||
|
||||
// Get the HTML version based on comment type
|
||||
switch (comment.type) {
|
||||
case "issue_comment": {
|
||||
const response = await octokits.rest.issues.getComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: parseInt(comment.id),
|
||||
mediaType: {
|
||||
format: "full+json",
|
||||
},
|
||||
});
|
||||
bodyHtml = response.data.body_html;
|
||||
break;
|
||||
}
|
||||
case "review_comment": {
|
||||
const response = await octokits.rest.pulls.getReviewComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: parseInt(comment.id),
|
||||
mediaType: {
|
||||
format: "full+json",
|
||||
},
|
||||
});
|
||||
bodyHtml = response.data.body_html;
|
||||
break;
|
||||
}
|
||||
case "review_body": {
|
||||
const response = await octokits.rest.pulls.getReview({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: parseInt(comment.pullNumber),
|
||||
review_id: parseInt(comment.id),
|
||||
mediaType: {
|
||||
format: "full+json",
|
||||
},
|
||||
});
|
||||
bodyHtml = response.data.body_html;
|
||||
break;
|
||||
}
|
||||
case "issue_body": {
|
||||
const response = await octokits.rest.issues.get({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: parseInt(comment.issueNumber),
|
||||
mediaType: {
|
||||
format: "full+json",
|
||||
},
|
||||
});
|
||||
bodyHtml = response.data.body_html;
|
||||
break;
|
||||
}
|
||||
case "pr_body": {
|
||||
const response = await octokits.rest.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: parseInt(comment.pullNumber),
|
||||
mediaType: {
|
||||
format: "full+json",
|
||||
},
|
||||
});
|
||||
// Type here seems to be wrong
|
||||
bodyHtml = (response.data as any).body_html;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!bodyHtml) {
|
||||
const id =
|
||||
comment.type === "issue_body"
|
||||
? comment.issueNumber
|
||||
: comment.type === "pr_body"
|
||||
? comment.pullNumber
|
||||
: comment.id;
|
||||
console.warn(`No HTML body found for ${comment.type} ${id}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract signed URLs from HTML
|
||||
const signedUrlRegex =
|
||||
/https:\/\/private-user-images\.githubusercontent\.com\/[^"]+\?jwt=[^"]+/g;
|
||||
const signedUrls = bodyHtml.match(signedUrlRegex) || [];
|
||||
|
||||
// Download each image
|
||||
for (let i = 0; i < Math.min(signedUrls.length, urls.length); i++) {
|
||||
const signedUrl = signedUrls[i];
|
||||
const originalUrl = urls[i];
|
||||
|
||||
if (!signedUrl || !originalUrl) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if we've already downloaded this URL
|
||||
if (urlToPathMap.has(originalUrl)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fileExtension = getImageExtension(originalUrl);
|
||||
const filename = `image-${Date.now()}-${i}${fileExtension}`;
|
||||
const localPath = path.join(downloadsDir, filename);
|
||||
|
||||
try {
|
||||
console.log(`Downloading ${originalUrl}...`);
|
||||
|
||||
const imageResponse = await fetch(signedUrl);
|
||||
if (!imageResponse.ok) {
|
||||
throw new Error(
|
||||
`HTTP ${imageResponse.status}: ${imageResponse.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const arrayBuffer = await imageResponse.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
await fs.writeFile(localPath, buffer);
|
||||
console.log(`✓ Saved: ${localPath}`);
|
||||
|
||||
urlToPathMap.set(originalUrl, localPath);
|
||||
} catch (error) {
|
||||
console.error(`✗ Failed to download ${originalUrl}:`, error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const id =
|
||||
comment.type === "issue_body"
|
||||
? comment.issueNumber
|
||||
: comment.type === "pr_body"
|
||||
? comment.pullNumber
|
||||
: comment.id;
|
||||
console.error(
|
||||
`Failed to process images for ${comment.type} ${id}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return urlToPathMap;
|
||||
}
|
||||
|
||||
function getImageExtension(url: string): string {
|
||||
const urlParts = url.split("/");
|
||||
const filename = urlParts[urlParts.length - 1];
|
||||
if (!filename) {
|
||||
throw new Error("Invalid URL: No filename found");
|
||||
}
|
||||
|
||||
const match = filename.match(/\.(png|jpg|jpeg|gif|webp|svg)$/i);
|
||||
return match ? match[0] : ".png";
|
||||
}
|
||||
31
src/github/validation/actor.ts
Normal file
31
src/github/validation/actor.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
/**
|
||||
* Check if the action trigger is from a human actor
|
||||
* Prevents automated tools or bots from triggering Claude
|
||||
*/
|
||||
|
||||
import type { Octokit } from "@octokit/rest";
|
||||
import type { ParsedGitHubContext } from "../context";
|
||||
|
||||
export async function checkHumanActor(
|
||||
octokit: Octokit,
|
||||
githubContext: ParsedGitHubContext,
|
||||
) {
|
||||
// Fetch user information from GitHub API
|
||||
const { data: userData } = await octokit.users.getByUsername({
|
||||
username: githubContext.actor,
|
||||
});
|
||||
|
||||
const actorType = userData.type;
|
||||
|
||||
console.log(`Actor type: ${actorType}`);
|
||||
|
||||
if (actorType !== "User") {
|
||||
throw new Error(
|
||||
`Workflow initiated by non-human actor: ${githubContext.actor} (type: ${actorType}).`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`Verified human actor: ${githubContext.actor}`);
|
||||
}
|
||||
41
src/github/validation/permissions.ts
Normal file
41
src/github/validation/permissions.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import * as core from "@actions/core";
|
||||
import type { ParsedGitHubContext } from "../context";
|
||||
import type { Octokit } from "@octokit/rest";
|
||||
|
||||
/**
|
||||
* Check if the actor has write permissions to the repository
|
||||
* @param octokit - The Octokit REST client
|
||||
* @param context - The GitHub context
|
||||
* @returns true if the actor has write permissions, false otherwise
|
||||
*/
|
||||
export async function checkWritePermissions(
|
||||
octokit: Octokit,
|
||||
context: ParsedGitHubContext,
|
||||
): Promise<boolean> {
|
||||
const { repository, actor } = context;
|
||||
|
||||
try {
|
||||
core.info(`Checking permissions for actor: ${actor}`);
|
||||
|
||||
// Check permissions directly using the permission endpoint
|
||||
const response = await octokit.repos.getCollaboratorPermissionLevel({
|
||||
owner: repository.owner,
|
||||
repo: repository.repo,
|
||||
username: actor,
|
||||
});
|
||||
|
||||
const permissionLevel = response.data.permission;
|
||||
core.info(`Permission level retrieved: ${permissionLevel}`);
|
||||
|
||||
if (permissionLevel === "admin" || permissionLevel === "write") {
|
||||
core.info(`Actor has write access: ${permissionLevel}`);
|
||||
return true;
|
||||
} else {
|
||||
core.warning(`Actor has insufficient permissions: ${permissionLevel}`);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
core.error(`Failed to check permissions: ${error}`);
|
||||
throw new Error(`Failed to check permissions for ${actor}: ${error}`);
|
||||
}
|
||||
}
|
||||
137
src/github/validation/trigger.ts
Normal file
137
src/github/validation/trigger.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import * as core from "@actions/core";
|
||||
import {
|
||||
isIssuesEvent,
|
||||
isIssueCommentEvent,
|
||||
isPullRequestEvent,
|
||||
isPullRequestReviewEvent,
|
||||
isPullRequestReviewCommentEvent,
|
||||
} from "../context";
|
||||
import type { ParsedGitHubContext } from "../context";
|
||||
|
||||
export function checkContainsTrigger(context: ParsedGitHubContext): boolean {
|
||||
const {
|
||||
inputs: { assigneeTrigger, triggerPhrase, directPrompt },
|
||||
} = context;
|
||||
|
||||
// If direct prompt is provided, always trigger
|
||||
if (directPrompt) {
|
||||
console.log(`Direct prompt provided, triggering action`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for assignee trigger
|
||||
if (isIssuesEvent(context) && context.eventAction === "assigned") {
|
||||
// Remove @ symbol from assignee_trigger if present
|
||||
let triggerUser = assigneeTrigger.replace(/^@/, "");
|
||||
const assigneeUsername = context.payload.issue.assignee?.login || "";
|
||||
|
||||
if (triggerUser && assigneeUsername === triggerUser) {
|
||||
console.log(`Issue assigned to trigger user '${triggerUser}'`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for issue body and title trigger on issue creation
|
||||
if (isIssuesEvent(context) && context.eventAction === "opened") {
|
||||
const issueBody = context.payload.issue.body || "";
|
||||
const issueTitle = context.payload.issue.title || "";
|
||||
// Check for exact match with word boundaries or punctuation
|
||||
const regex = new RegExp(
|
||||
`(^|\\s)${escapeRegExp(triggerPhrase)}([\\s.,!?;:]|$)`,
|
||||
);
|
||||
|
||||
// Check in body
|
||||
if (regex.test(issueBody)) {
|
||||
console.log(
|
||||
`Issue body contains exact trigger phrase '${triggerPhrase}'`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check in title
|
||||
if (regex.test(issueTitle)) {
|
||||
console.log(
|
||||
`Issue title contains exact trigger phrase '${triggerPhrase}'`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for pull request body and title trigger
|
||||
if (isPullRequestEvent(context)) {
|
||||
const prBody = context.payload.pull_request.body || "";
|
||||
const prTitle = context.payload.pull_request.title || "";
|
||||
// Check for exact match with word boundaries or punctuation
|
||||
const regex = new RegExp(
|
||||
`(^|\\s)${escapeRegExp(triggerPhrase)}([\\s.,!?;:]|$)`,
|
||||
);
|
||||
|
||||
// Check in body
|
||||
if (regex.test(prBody)) {
|
||||
console.log(
|
||||
`Pull request body contains exact trigger phrase '${triggerPhrase}'`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check in title
|
||||
if (regex.test(prTitle)) {
|
||||
console.log(
|
||||
`Pull request title contains exact trigger phrase '${triggerPhrase}'`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for pull request review body trigger
|
||||
if (
|
||||
isPullRequestReviewEvent(context) &&
|
||||
(context.eventAction === "submitted" || context.eventAction === "edited")
|
||||
) {
|
||||
const reviewBody = context.payload.review.body || "";
|
||||
// Check for exact match with word boundaries or punctuation
|
||||
const regex = new RegExp(
|
||||
`(^|\\s)${escapeRegExp(triggerPhrase)}([\\s.,!?;:]|$)`,
|
||||
);
|
||||
if (regex.test(reviewBody)) {
|
||||
console.log(
|
||||
`Pull request review contains exact trigger phrase '${triggerPhrase}'`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for comment trigger
|
||||
if (
|
||||
isIssueCommentEvent(context) ||
|
||||
isPullRequestReviewCommentEvent(context)
|
||||
) {
|
||||
const commentBody = isIssueCommentEvent(context)
|
||||
? context.payload.comment.body
|
||||
: context.payload.comment.body;
|
||||
// Check for exact match with word boundaries or punctuation
|
||||
const regex = new RegExp(
|
||||
`(^|\\s)${escapeRegExp(triggerPhrase)}([\\s.,!?;:]|$)`,
|
||||
);
|
||||
if (regex.test(commentBody)) {
|
||||
console.log(`Comment contains exact trigger phrase '${triggerPhrase}'`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`No trigger was met for ${triggerPhrase}`);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function escapeRegExp(string: string) {
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
export async function checkTriggerAction(context: ParsedGitHubContext) {
|
||||
const containsTrigger = checkContainsTrigger(context);
|
||||
core.setOutput("contains_trigger", containsTrigger.toString());
|
||||
return containsTrigger;
|
||||
}
|
||||
Reference in New Issue
Block a user