Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 333ad29965 | |||
| 26336d0ac2 | |||
| 0fced1dfa6 | |||
| 6f96458e2d | |||
| b038fc574f | |||
| 316b6cb83c | |||
| a19ae49224 | |||
| 1d69f0f3e4 | |||
| 2f16d2ef0e |
+72
-72
@@ -8,84 +8,84 @@ import { getModes } from "../modes.ts";
|
|||||||
* Removes verbose GitHub API metadata (user objects, repository metadata, etc.)
|
* Removes verbose GitHub API metadata (user objects, repository metadata, etc.)
|
||||||
* and keeps only the fields agents actually need.
|
* and keeps only the fields agents actually need.
|
||||||
*/
|
*/
|
||||||
function extractEssentialEventData(event: Payload["event"]): Record<string, unknown> {
|
// function extractEssentialEventData(event: Payload["event"]): Record<string, unknown> {
|
||||||
const trigger = event.trigger;
|
// const trigger = event.trigger;
|
||||||
const essential: Record<string, unknown> = { trigger };
|
// const essential: Record<string, unknown> = { trigger };
|
||||||
|
|
||||||
// common fields
|
// // common fields
|
||||||
if ("issue_number" in event) {
|
// if ("issue_number" in event) {
|
||||||
essential.issue_number = event.issue_number;
|
// essential.issue_number = event.issue_number;
|
||||||
}
|
// }
|
||||||
if ("branch" in event && event.branch) {
|
// if ("branch" in event && event.branch) {
|
||||||
essential.branch = event.branch;
|
// essential.branch = event.branch;
|
||||||
}
|
// }
|
||||||
|
|
||||||
// trigger-specific fields
|
// // trigger-specific fields
|
||||||
switch (trigger) {
|
// switch (trigger) {
|
||||||
case "issue_comment_created":
|
// case "issue_comment_created":
|
||||||
if ("comment_id" in event) essential.comment_id = event.comment_id;
|
// if ("comment_id" in event) essential.comment_id = event.comment_id;
|
||||||
if ("comment_body" in event) essential.comment_body = event.comment_body;
|
// if ("comment_body" in event) essential.comment_body = event.comment_body;
|
||||||
// include issue title/body if available in context (but not the entire context object)
|
// // include issue title/body if available in context (but not the entire context object)
|
||||||
if ("context" in event && event.context && typeof event.context === "object") {
|
// if ("context" in event && event.context && typeof event.context === "object") {
|
||||||
const ctx = event.context as Record<string, unknown>;
|
// const ctx = event.context as Record<string, unknown>;
|
||||||
if (ctx.issue && typeof ctx.issue === "object") {
|
// if (ctx.issue && typeof ctx.issue === "object") {
|
||||||
const issue = ctx.issue as Record<string, unknown>;
|
// const issue = ctx.issue as Record<string, unknown>;
|
||||||
if (issue.title) essential.issue_title = issue.title;
|
// if (issue.title) essential.issue_title = issue.title;
|
||||||
if (issue.body) essential.issue_body = issue.body;
|
// if (issue.body) essential.issue_body = issue.body;
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
break;
|
// break;
|
||||||
|
|
||||||
case "issues_opened":
|
// case "issues_opened":
|
||||||
case "issues_assigned":
|
// case "issues_assigned":
|
||||||
case "issues_labeled":
|
// case "issues_labeled":
|
||||||
if ("issue_title" in event) essential.issue_title = event.issue_title;
|
// if ("issue_title" in event) essential.issue_title = event.issue_title;
|
||||||
if ("issue_body" in event) essential.issue_body = event.issue_body;
|
// if ("issue_body" in event) essential.issue_body = event.issue_body;
|
||||||
break;
|
// break;
|
||||||
|
|
||||||
case "pull_request_opened":
|
// case "pull_request_opened":
|
||||||
case "pull_request_review_requested":
|
// case "pull_request_review_requested":
|
||||||
if ("pr_title" in event) essential.pr_title = event.pr_title;
|
// if ("pr_title" in event) essential.pr_title = event.pr_title;
|
||||||
if ("pr_body" in event) essential.pr_body = event.pr_body;
|
// if ("pr_body" in event) essential.pr_body = event.pr_body;
|
||||||
if ("branch" in event) essential.branch = event.branch;
|
// if ("branch" in event) essential.branch = event.branch;
|
||||||
break;
|
// break;
|
||||||
|
|
||||||
case "pull_request_review_submitted":
|
// case "pull_request_review_submitted":
|
||||||
if ("review_id" in event) essential.review_id = event.review_id;
|
// if ("review_id" in event) essential.review_id = event.review_id;
|
||||||
if ("review_body" in event) essential.review_body = event.review_body;
|
// if ("review_body" in event) essential.review_body = event.review_body;
|
||||||
if ("review_state" in event) essential.review_state = event.review_state;
|
// if ("review_state" in event) essential.review_state = event.review_state;
|
||||||
if ("branch" in event) essential.branch = event.branch;
|
// if ("branch" in event) essential.branch = event.branch;
|
||||||
break;
|
// break;
|
||||||
|
|
||||||
case "pull_request_review_comment_created":
|
// case "pull_request_review_comment_created":
|
||||||
if ("comment_id" in event) essential.comment_id = event.comment_id;
|
// if ("comment_id" in event) essential.comment_id = event.comment_id;
|
||||||
if ("comment_body" in event) essential.comment_body = event.comment_body;
|
// if ("comment_body" in event) essential.comment_body = event.comment_body;
|
||||||
if ("pr_title" in event) essential.pr_title = event.pr_title;
|
// if ("pr_title" in event) essential.pr_title = event.pr_title;
|
||||||
if ("branch" in event) essential.branch = event.branch;
|
// if ("branch" in event) essential.branch = event.branch;
|
||||||
break;
|
// break;
|
||||||
|
|
||||||
case "check_suite_completed":
|
// case "check_suite_completed":
|
||||||
if ("pr_title" in event) essential.pr_title = event.pr_title;
|
// if ("pr_title" in event) essential.pr_title = event.pr_title;
|
||||||
if ("pr_body" in event) essential.pr_body = event.pr_body;
|
// if ("pr_body" in event) essential.pr_body = event.pr_body;
|
||||||
if ("branch" in event) essential.branch = event.branch;
|
// if ("branch" in event) essential.branch = event.branch;
|
||||||
if ("check_suite" in event) {
|
// if ("check_suite" in event) {
|
||||||
essential.check_suite = {
|
// essential.check_suite = {
|
||||||
id: event.check_suite.id,
|
// id: event.check_suite.id,
|
||||||
head_sha: event.check_suite.head_sha,
|
// head_sha: event.check_suite.head_sha,
|
||||||
head_branch: event.check_suite.head_branch,
|
// head_branch: event.check_suite.head_branch,
|
||||||
status: event.check_suite.status,
|
// status: event.check_suite.status,
|
||||||
conclusion: event.check_suite.conclusion,
|
// conclusion: event.check_suite.conclusion,
|
||||||
};
|
// };
|
||||||
}
|
// }
|
||||||
break;
|
// break;
|
||||||
|
|
||||||
case "workflow_dispatch":
|
// case "workflow_dispatch":
|
||||||
if ("inputs" in event) essential.inputs = event.inputs;
|
// if ("inputs" in event) essential.inputs = event.inputs;
|
||||||
break;
|
// break;
|
||||||
}
|
// }
|
||||||
|
|
||||||
return essential;
|
// return essential;
|
||||||
}
|
// }
|
||||||
|
|
||||||
export const addInstructions = (payload: Payload) => {
|
export const addInstructions = (payload: Payload) => {
|
||||||
let encodedEvent = "";
|
let encodedEvent = "";
|
||||||
@@ -95,8 +95,8 @@ export const addInstructions = (payload: Payload) => {
|
|||||||
// no meaningful event data to encode
|
// no meaningful event data to encode
|
||||||
} else {
|
} else {
|
||||||
// extract only essential fields to reduce token usage
|
// extract only essential fields to reduce token usage
|
||||||
const essentialEvent = extractEssentialEventData(payload.event);
|
// const essentialEvent = payload.event;
|
||||||
encodedEvent = toonEncode(essentialEvent);
|
encodedEvent = toonEncode(payload.event);
|
||||||
}
|
}
|
||||||
|
|
||||||
return `
|
return `
|
||||||
@@ -168,7 +168,7 @@ MCP servers provide tools you can call. Inspect your available MCP servers at st
|
|||||||
|
|
||||||
Tool names may be formatted as \`(server name)/(tool name)\`, for example: \`${ghPullfrogMcpName}/create_issue_comment\`
|
Tool names may be formatted as \`(server name)/(tool name)\`, for example: \`${ghPullfrogMcpName}/create_issue_comment\`
|
||||||
|
|
||||||
**GitHub CLI prohibition**: Do not use the \`gh\` CLI under any circumstances. Use the corresponding tool from ${ghPullfrogMcpName} instead.
|
**GitHub CLI**: Prefer using MCP tools from ${ghPullfrogMcpName} for GitHub operations. The \`gh\` CLI is available as a fallback if needed, but MCP tools handle authentication and provide better integration.
|
||||||
|
|
||||||
**Git operations**: All git operations must use ${ghPullfrogMcpName} MCP tools to ensure proper authentication and commit attribution. Do NOT use git commands directly (e.g., \`git commit\`, \`git push\`, \`git checkout\`, \`git branch\`) - these will use incorrect credentials and attribute commits to the wrong author.
|
**Git operations**: All git operations must use ${ghPullfrogMcpName} MCP tools to ensure proper authentication and commit attribution. Do NOT use git commands directly (e.g., \`git commit\`, \`git push\`, \`git checkout\`, \`git branch\`) - these will use incorrect credentials and attribute commits to the wrong author.
|
||||||
|
|
||||||
|
|||||||
+161
-108
@@ -51,117 +51,170 @@ export const AgentName = type.enumerated(...Object.keys(agentsManifest));
|
|||||||
|
|
||||||
export type AgentApiKeyName = (typeof agentsManifest)[AgentName]["apiKeyNames"][number];
|
export type AgentApiKeyName = (typeof agentsManifest)[AgentName]["apiKeyNames"][number];
|
||||||
|
|
||||||
|
// base interface for common payload event fields
|
||||||
|
interface BasePayloadEvent {
|
||||||
|
issue_number?: number;
|
||||||
|
is_pr?: boolean;
|
||||||
|
branch?: string;
|
||||||
|
pr_title?: string;
|
||||||
|
pr_body?: string | null;
|
||||||
|
issue_title?: string;
|
||||||
|
issue_body?: string | null;
|
||||||
|
comment_id?: number;
|
||||||
|
comment_body?: string;
|
||||||
|
review_id?: number;
|
||||||
|
review_body?: string | null;
|
||||||
|
review_state?: string;
|
||||||
|
review_comments?: any[];
|
||||||
|
context?: any;
|
||||||
|
thread?: any;
|
||||||
|
pull_request?: any;
|
||||||
|
check_suite?: {
|
||||||
|
id: number;
|
||||||
|
head_sha: string;
|
||||||
|
head_branch: string | null;
|
||||||
|
status: string | null;
|
||||||
|
conclusion: string | null;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
comment_ids?: number[] | "all";
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PullRequestOpenedEvent extends BasePayloadEvent {
|
||||||
|
trigger: "pull_request_opened";
|
||||||
|
issue_number: number;
|
||||||
|
is_pr: true;
|
||||||
|
pr_title: string;
|
||||||
|
pr_body: string | null;
|
||||||
|
branch: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PullRequestReadyForReviewEvent extends BasePayloadEvent {
|
||||||
|
trigger: "pull_request_ready_for_review";
|
||||||
|
issue_number: number;
|
||||||
|
is_pr: true;
|
||||||
|
pr_title: string;
|
||||||
|
pr_body: string | null;
|
||||||
|
branch: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PullRequestReviewRequestedEvent extends BasePayloadEvent {
|
||||||
|
trigger: "pull_request_review_requested";
|
||||||
|
issue_number: number;
|
||||||
|
is_pr: true;
|
||||||
|
pr_title: string;
|
||||||
|
pr_body: string | null;
|
||||||
|
branch: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PullRequestReviewSubmittedEvent extends BasePayloadEvent {
|
||||||
|
trigger: "pull_request_review_submitted";
|
||||||
|
issue_number: number;
|
||||||
|
is_pr: true;
|
||||||
|
review_id: number;
|
||||||
|
review_body: string | null;
|
||||||
|
review_state: string;
|
||||||
|
review_comments: any[];
|
||||||
|
context: any;
|
||||||
|
branch: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PullRequestReviewCommentCreatedEvent extends BasePayloadEvent {
|
||||||
|
trigger: "pull_request_review_comment_created";
|
||||||
|
issue_number: number;
|
||||||
|
is_pr: true;
|
||||||
|
pr_title: string;
|
||||||
|
comment_id: number;
|
||||||
|
comment_body: string;
|
||||||
|
thread?: any;
|
||||||
|
branch: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IssuesOpenedEvent extends BasePayloadEvent {
|
||||||
|
trigger: "issues_opened";
|
||||||
|
issue_number: number;
|
||||||
|
issue_title: string;
|
||||||
|
issue_body: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IssuesAssignedEvent extends BasePayloadEvent {
|
||||||
|
trigger: "issues_assigned";
|
||||||
|
issue_number: number;
|
||||||
|
issue_title: string;
|
||||||
|
issue_body: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IssuesLabeledEvent extends BasePayloadEvent {
|
||||||
|
trigger: "issues_labeled";
|
||||||
|
issue_number: number;
|
||||||
|
issue_title: string;
|
||||||
|
issue_body: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IssueCommentCreatedEvent extends BasePayloadEvent {
|
||||||
|
trigger: "issue_comment_created";
|
||||||
|
comment_id: number;
|
||||||
|
comment_body: string;
|
||||||
|
issue_number: number;
|
||||||
|
// PR-specific fields (only present when is_pr is true)
|
||||||
|
is_pr?: true;
|
||||||
|
branch?: string;
|
||||||
|
pr_title?: string;
|
||||||
|
pr_body?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CheckSuiteCompletedEvent extends BasePayloadEvent {
|
||||||
|
trigger: "check_suite_completed";
|
||||||
|
issue_number: number;
|
||||||
|
is_pr: true;
|
||||||
|
pr_title: string;
|
||||||
|
pr_body: string | null;
|
||||||
|
pull_request: any;
|
||||||
|
branch: string;
|
||||||
|
check_suite: {
|
||||||
|
id: number;
|
||||||
|
head_sha: string;
|
||||||
|
head_branch: string | null;
|
||||||
|
status: string | null;
|
||||||
|
conclusion: string | null;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkflowDispatchEvent extends BasePayloadEvent {
|
||||||
|
trigger: "workflow_dispatch";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FixReviewEvent extends BasePayloadEvent {
|
||||||
|
trigger: "fix_review";
|
||||||
|
issue_number: number;
|
||||||
|
is_pr: true;
|
||||||
|
review_id: number;
|
||||||
|
/** "all" to fix all comments, or specific comment IDs to fix */
|
||||||
|
comment_ids: number[] | "all";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UnknownEvent extends BasePayloadEvent {
|
||||||
|
trigger: "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
// discriminated union for payload event based on trigger
|
// discriminated union for payload event based on trigger
|
||||||
// note: all events use issue_number for consistency (PRs are issues in GitHub's API)
|
// note: all events use issue_number for consistency (PRs are issues in GitHub's API)
|
||||||
export type PayloadEvent =
|
export type PayloadEvent =
|
||||||
| {
|
| PullRequestOpenedEvent
|
||||||
trigger: "pull_request_opened";
|
| PullRequestReadyForReviewEvent
|
||||||
issue_number: number;
|
| PullRequestReviewRequestedEvent
|
||||||
pr_title: string;
|
| PullRequestReviewSubmittedEvent
|
||||||
pr_body: string | null;
|
| PullRequestReviewCommentCreatedEvent
|
||||||
branch: string;
|
| IssuesOpenedEvent
|
||||||
[key: string]: any;
|
| IssuesAssignedEvent
|
||||||
}
|
| IssuesLabeledEvent
|
||||||
| {
|
| IssueCommentCreatedEvent
|
||||||
trigger: "pull_request_ready_for_review";
|
| CheckSuiteCompletedEvent
|
||||||
issue_number: number;
|
| WorkflowDispatchEvent
|
||||||
pr_title: string;
|
| FixReviewEvent
|
||||||
pr_body: string | null;
|
| UnknownEvent;
|
||||||
branch: string;
|
|
||||||
[key: string]: any;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
trigger: "pull_request_review_requested";
|
|
||||||
issue_number: number;
|
|
||||||
pr_title: string;
|
|
||||||
pr_body: string | null;
|
|
||||||
branch: string;
|
|
||||||
[key: string]: any;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
trigger: "pull_request_review_submitted";
|
|
||||||
issue_number: number;
|
|
||||||
review_id: number;
|
|
||||||
review_body: string | null;
|
|
||||||
review_state: string;
|
|
||||||
review_comments: any[];
|
|
||||||
context: any;
|
|
||||||
branch: string;
|
|
||||||
[key: string]: any;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
trigger: "pull_request_review_comment_created";
|
|
||||||
issue_number: number;
|
|
||||||
pr_title: string;
|
|
||||||
comment_id: number;
|
|
||||||
comment_body: string;
|
|
||||||
thread?: any;
|
|
||||||
branch: string;
|
|
||||||
[key: string]: any;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
trigger: "issues_opened";
|
|
||||||
issue_number: number;
|
|
||||||
issue_title: string;
|
|
||||||
issue_body: string | null;
|
|
||||||
[key: string]: any;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
trigger: "issues_assigned";
|
|
||||||
issue_number: number;
|
|
||||||
issue_title: string;
|
|
||||||
issue_body: string | null;
|
|
||||||
[key: string]: any;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
trigger: "issues_labeled";
|
|
||||||
issue_number: number;
|
|
||||||
issue_title: string;
|
|
||||||
issue_body: string | null;
|
|
||||||
[key: string]: any;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
trigger: "issue_comment_created";
|
|
||||||
comment_id: number;
|
|
||||||
comment_body: string;
|
|
||||||
issue_number: number;
|
|
||||||
branch?: string;
|
|
||||||
[key: string]: any;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
trigger: "check_suite_completed";
|
|
||||||
issue_number: number;
|
|
||||||
pr_title: string;
|
|
||||||
pr_body: string | null;
|
|
||||||
pull_request: any;
|
|
||||||
branch: string;
|
|
||||||
check_suite: {
|
|
||||||
id: number;
|
|
||||||
head_sha: string;
|
|
||||||
head_branch: string | null;
|
|
||||||
status: string | null;
|
|
||||||
conclusion: string | null;
|
|
||||||
url: string;
|
|
||||||
};
|
|
||||||
[key: string]: any;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
trigger: "workflow_dispatch";
|
|
||||||
[key: string]: any;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
trigger: "fix_review";
|
|
||||||
issue_number: number;
|
|
||||||
review_id: number;
|
|
||||||
/** "all" to fix all comments, or specific comment IDs to fix */
|
|
||||||
comment_ids: number[] | "all";
|
|
||||||
branch: string;
|
|
||||||
[key: string]: any;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
trigger: "unknown";
|
|
||||||
[key: string]: any;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface DispatchOptions {
|
export interface DispatchOptions {
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { mkdtemp } from "node:fs/promises";
|
|||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { flatMorph } from "@ark/util";
|
import { flatMorph } from "@ark/util";
|
||||||
|
import { Octokit } from "@octokit/rest";
|
||||||
import { encode as toonEncode } from "@toon-format/toon";
|
import { encode as toonEncode } from "@toon-format/toon";
|
||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
import { agents } from "./agents/index.ts";
|
import { agents } from "./agents/index.ts";
|
||||||
@@ -11,18 +12,17 @@ import { agentsManifest } from "./external.ts";
|
|||||||
import { ensureProgressCommentUpdated, reportProgress } from "./mcp/comment.ts";
|
import { ensureProgressCommentUpdated, reportProgress } from "./mcp/comment.ts";
|
||||||
import { createMcpConfigs } from "./mcp/config.ts";
|
import { createMcpConfigs } from "./mcp/config.ts";
|
||||||
import { startMcpHttpServer } from "./mcp/server.ts";
|
import { startMcpHttpServer } from "./mcp/server.ts";
|
||||||
import { getModes, modes } from "./modes.ts";
|
import { getModes, type Mode, modes } from "./modes.ts";
|
||||||
import packageJson from "./package.json" with { type: "json" };
|
import packageJson from "./package.json" with { type: "json" };
|
||||||
import { fetchRepoSettings, fetchWorkflowRunInfo } from "./utils/api.ts";
|
import { fetchRepoSettings, fetchWorkflowRunInfo, type RepoSettings } from "./utils/api.ts";
|
||||||
import { log } from "./utils/cli.ts";
|
import { log } from "./utils/cli.ts";
|
||||||
import { reportErrorToComment } from "./utils/errorReport.ts";
|
import { reportErrorToComment } from "./utils/errorReport.ts";
|
||||||
import {
|
import {
|
||||||
parseRepoContext,
|
parseRepoContext,
|
||||||
type RepoContext,
|
|
||||||
revokeGitHubInstallationToken,
|
revokeGitHubInstallationToken,
|
||||||
setupGitHubInstallationToken,
|
setupGitHubInstallationToken,
|
||||||
} from "./utils/github.ts";
|
} from "./utils/github.ts";
|
||||||
import { setupGitAuth, setupGitBranch, setupGitConfig } from "./utils/setup.ts";
|
import { setupGit, setupGitConfig } from "./utils/setup.ts";
|
||||||
import { Timer } from "./utils/timer.ts";
|
import { Timer } from "./utils/timer.ts";
|
||||||
|
|
||||||
// runtime validation using agents (needed for ArkType)
|
// runtime validation using agents (needed for ArkType)
|
||||||
@@ -61,19 +61,16 @@ export async function main(inputs: Inputs): Promise<MainResult> {
|
|||||||
payload = parsePayload(inputs);
|
payload = parsePayload(inputs);
|
||||||
|
|
||||||
const partialCtx = await initializeContext(inputs, payload);
|
const partialCtx = await initializeContext(inputs, payload);
|
||||||
const ctx = partialCtx as MainContext;
|
const ctx = partialCtx as Context;
|
||||||
timer.checkpoint("initializeContext");
|
timer.checkpoint("initializeContext");
|
||||||
|
|
||||||
setupGitAuth({
|
const { pushRemote } = setupGit(ctx);
|
||||||
githubInstallationToken: ctx.githubInstallationToken,
|
ctx.pushRemote = pushRemote;
|
||||||
repoContext: ctx.repoContext,
|
timer.checkpoint("setupGit");
|
||||||
});
|
|
||||||
|
|
||||||
await setupTempDirectory(ctx);
|
await setupTempDirectory(ctx);
|
||||||
timer.checkpoint("setupTempDirectory");
|
timer.checkpoint("setupTempDirectory");
|
||||||
|
|
||||||
setupGitBranch(ctx.payload);
|
|
||||||
|
|
||||||
await startMcpServer(ctx);
|
await startMcpServer(ctx);
|
||||||
mcpServerClose = ctx.mcpServerClose;
|
mcpServerClose = ctx.mcpServerClose;
|
||||||
timer.checkpoint("startMcpServer");
|
timer.checkpoint("startMcpServer");
|
||||||
@@ -84,7 +81,7 @@ export async function main(inputs: Inputs): Promise<MainResult> {
|
|||||||
Array.isArray(ctx.payload.event.comment_ids) &&
|
Array.isArray(ctx.payload.event.comment_ids) &&
|
||||||
ctx.payload.event.comment_ids.length === 0
|
ctx.payload.event.comment_ids.length === 0
|
||||||
) {
|
) {
|
||||||
await reportProgress({
|
await reportProgress(ctx, {
|
||||||
body: `👍 **No approved comments found**\n\nTo use "Fix 👍s", add a 👍 reaction to one or more inline review comments you want fixed.`,
|
body: `👍 **No approved comments found**\n\nTo use "Fix 👍s", add a 👍 reaction to one or more inline review comments you want fixed.`,
|
||||||
});
|
});
|
||||||
return { success: true };
|
return { success: true };
|
||||||
@@ -157,36 +154,26 @@ function getAllPossibleKeyNames(): string[] {
|
|||||||
/**
|
/**
|
||||||
* Throw an error for missing API key with helpful message linking to repo settings
|
* Throw an error for missing API key with helpful message linking to repo settings
|
||||||
*/
|
*/
|
||||||
async function throwMissingApiKeyError({
|
async function throwMissingApiKeyError(ctx: Context): Promise<never> {
|
||||||
agent,
|
|
||||||
repoContext,
|
|
||||||
}: {
|
|
||||||
agent: (typeof agents)[AgentName] | null;
|
|
||||||
repoContext: RepoContext;
|
|
||||||
}): Promise<never> {
|
|
||||||
const apiUrl = process.env.API_URL || "https://pullfrog.com";
|
const apiUrl = process.env.API_URL || "https://pullfrog.com";
|
||||||
const settingsUrl = `${apiUrl}/console/${repoContext.owner}/${repoContext.name}`;
|
const settingsUrl = `${apiUrl}/console/${ctx.owner}/${ctx.name}`;
|
||||||
|
|
||||||
const githubRepoUrl = `https://github.com/${repoContext.owner}/${repoContext.name}`;
|
const githubRepoUrl = `https://github.com/${ctx.owner}/${ctx.name}`;
|
||||||
const githubSecretsUrl = `${githubRepoUrl}/settings/secrets/actions`;
|
const githubSecretsUrl = `${githubRepoUrl}/settings/secrets/actions`;
|
||||||
|
|
||||||
// for OpenCode, use a generic message since it accepts any API key
|
// for OpenCode, use a generic message since it accepts any API key
|
||||||
const isOpenCode = agent?.name === "opencode";
|
const isOpenCode = ctx.agent?.name === "opencode";
|
||||||
let secretNameList: string;
|
let secretNameList: string;
|
||||||
if (isOpenCode) {
|
if (isOpenCode) {
|
||||||
secretNameList =
|
secretNameList =
|
||||||
"any API key (e.g., `OPENCODE_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, etc.)";
|
"any API key (e.g., `OPENCODE_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, etc.)";
|
||||||
} else {
|
} else {
|
||||||
const inputKeys = agent?.apiKeyNames || getAllPossibleKeyNames();
|
const inputKeys = ctx.agent?.apiKeyNames || getAllPossibleKeyNames();
|
||||||
const secretNames = inputKeys.map((key) => `\`${key.toUpperCase()}\``);
|
const secretNames = inputKeys.map((key) => `\`${key.toUpperCase()}\``);
|
||||||
secretNameList = inputKeys.length === 1 ? secretNames[0] : `one of ${secretNames.join(" or ")}`;
|
secretNameList = inputKeys.length === 1 ? secretNames[0] : `one of ${secretNames.join(" or ")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
let message = `${
|
const message = `Pullfrog is configured to use ${ctx.agent.displayName}, but the associated API key was not provided.
|
||||||
agent === null
|
|
||||||
? "Pullfrog has no agent configured and no API keys are available in the environment."
|
|
||||||
: `Pullfrog is configured to use ${agent.displayName}, but the associated API key was not provided.`
|
|
||||||
}
|
|
||||||
|
|
||||||
To fix this, add the required secret to your GitHub repository:
|
To fix this, add the required secret to your GitHub repository:
|
||||||
|
|
||||||
@@ -194,28 +181,45 @@ To fix this, add the required secret to your GitHub repository:
|
|||||||
2. Click "New repository secret"
|
2. Click "New repository secret"
|
||||||
3. Set the name to ${secretNameList}
|
3. Set the name to ${secretNameList}
|
||||||
4. Set the value to your API key
|
4. Set the value to your API key
|
||||||
5. Click "Add secret"`;
|
5. Click "Add secret"
|
||||||
|
|
||||||
if (agent === null) {
|
Alternatively, configure Pullfrog to use a different agent at ${settingsUrl}`;
|
||||||
message += `\n\nAlternatively, configure Pullfrog to use an agent at ${settingsUrl}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// report to comment if MCP context is available (server has started)
|
// report to comment if MCP context is available (server has started)
|
||||||
await reportErrorToComment({ error: message });
|
await reportErrorToComment({ error: message });
|
||||||
throw new Error(message);
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MainContext {
|
export interface Context {
|
||||||
|
// flattened from RepoContext
|
||||||
|
owner: string;
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
// core fields
|
||||||
inputs: Inputs;
|
inputs: Inputs;
|
||||||
githubInstallationToken: string;
|
payload: Payload;
|
||||||
repoContext: RepoContext;
|
repo: Awaited<ReturnType<Octokit["repos"]["get"]>>["data"];
|
||||||
agentName: AgentName;
|
agentName: AgentName;
|
||||||
agent: (typeof agents)[AgentName];
|
agent: (typeof agents)[AgentName];
|
||||||
|
githubInstallationToken: string;
|
||||||
|
octokit: Octokit;
|
||||||
|
|
||||||
|
// repo settings from Pullfrog API
|
||||||
|
repoSettings: RepoSettings;
|
||||||
|
|
||||||
|
// modes for MCP tools
|
||||||
|
modes: Mode[];
|
||||||
|
|
||||||
|
// setup fields
|
||||||
|
pushRemote: string;
|
||||||
sharedTempDir: string;
|
sharedTempDir: string;
|
||||||
payload: Payload;
|
|
||||||
|
// mcp fields
|
||||||
mcpServerUrl: string;
|
mcpServerUrl: string;
|
||||||
mcpServerClose: () => Promise<void>;
|
mcpServerClose: () => Promise<void>;
|
||||||
mcpServers: ReturnType<typeof createMcpConfigs>;
|
mcpServers: ReturnType<typeof createMcpConfigs>;
|
||||||
|
|
||||||
|
// agent fields
|
||||||
cliPath: string;
|
cliPath: string;
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
apiKeys: Record<string, string>;
|
apiKeys: Record<string, string>;
|
||||||
@@ -226,8 +230,14 @@ async function initializeContext(
|
|||||||
payload: Payload
|
payload: Payload
|
||||||
): Promise<
|
): Promise<
|
||||||
Omit<
|
Omit<
|
||||||
MainContext,
|
Context,
|
||||||
"mcpServerUrl" | "mcpServerClose" | "mcpServers" | "cliPath" | "apiKey" | "apiKeys"
|
| "mcpServerUrl"
|
||||||
|
| "mcpServerClose"
|
||||||
|
| "mcpServers"
|
||||||
|
| "cliPath"
|
||||||
|
| "apiKey"
|
||||||
|
| "apiKeys"
|
||||||
|
| "pushRemote"
|
||||||
>
|
>
|
||||||
> {
|
> {
|
||||||
log.info(`🐸 Running pullfrog/action@${packageJson.version}...`);
|
log.info(`🐸 Running pullfrog/action@${packageJson.version}...`);
|
||||||
@@ -235,39 +245,65 @@ async function initializeContext(
|
|||||||
setupGitConfig();
|
setupGitConfig();
|
||||||
|
|
||||||
const githubInstallationToken = await setupGitHubInstallationToken();
|
const githubInstallationToken = await setupGitHubInstallationToken();
|
||||||
const repoContext = parseRepoContext();
|
const { owner, name } = parseRepoContext();
|
||||||
|
|
||||||
|
// create octokit instance
|
||||||
|
const octokit = new Octokit({
|
||||||
|
auth: githubInstallationToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
// fetch repo data
|
||||||
|
const response = await octokit.repos.get({
|
||||||
|
owner,
|
||||||
|
repo: name,
|
||||||
|
});
|
||||||
|
const repo = response.data;
|
||||||
|
|
||||||
|
// fetch repo settings
|
||||||
|
const repoSettings = await fetchRepoSettings({
|
||||||
|
token: githubInstallationToken,
|
||||||
|
repoContext: { owner, name },
|
||||||
|
});
|
||||||
|
|
||||||
// resolve agent and update payload with resolved agent name
|
// resolve agent and update payload with resolved agent name
|
||||||
const { agentName, agent } = await resolveAgent(
|
const { agentName, agent } = resolveAgent({
|
||||||
inputs,
|
inputs,
|
||||||
payload,
|
payload,
|
||||||
githubInstallationToken,
|
repoSettings,
|
||||||
repoContext
|
});
|
||||||
);
|
|
||||||
const resolvedPayload = { ...payload, agent: agentName };
|
const resolvedPayload = { ...payload, agent: agentName };
|
||||||
|
|
||||||
|
// compute modes from defaults + payload overrides
|
||||||
|
const computedModes = [
|
||||||
|
...getModes({ disableProgressComment: resolvedPayload.disableProgressComment }),
|
||||||
|
...(resolvedPayload.modes || []),
|
||||||
|
];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
owner,
|
||||||
|
name,
|
||||||
inputs,
|
inputs,
|
||||||
githubInstallationToken,
|
githubInstallationToken,
|
||||||
repoContext,
|
octokit,
|
||||||
|
repo,
|
||||||
agentName,
|
agentName,
|
||||||
agent,
|
agent,
|
||||||
payload: resolvedPayload,
|
payload: resolvedPayload,
|
||||||
|
repoSettings,
|
||||||
|
modes: computedModes,
|
||||||
sharedTempDir: "",
|
sharedTempDir: "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveAgent(
|
function resolveAgent({
|
||||||
inputs: Inputs,
|
inputs,
|
||||||
payload: Payload,
|
payload,
|
||||||
githubInstallationToken: string,
|
repoSettings,
|
||||||
repoContext: RepoContext
|
}: {
|
||||||
): Promise<{ agentName: AgentName; agent: (typeof agents)[AgentName] }> {
|
inputs: Inputs;
|
||||||
const repoSettings = await fetchRepoSettings({
|
payload: Payload;
|
||||||
token: githubInstallationToken,
|
repoSettings: RepoSettings;
|
||||||
repoContext,
|
}): { agentName: AgentName; agent: (typeof agents)[AgentName] } {
|
||||||
});
|
|
||||||
|
|
||||||
const agentOverride = process.env.AGENT_OVERRIDE as AgentName | undefined;
|
const agentOverride = process.env.AGENT_OVERRIDE as AgentName | undefined;
|
||||||
const configuredAgentName = agentOverride || payload.agent || repoSettings.defaultAgent || null;
|
const configuredAgentName = agentOverride || payload.agent || repoSettings.defaultAgent || null;
|
||||||
|
|
||||||
@@ -312,10 +348,8 @@ async function resolveAgent(
|
|||||||
log.debug(`Available agents: ${availableAgentNames || "none"}`);
|
log.debug(`Available agents: ${availableAgentNames || "none"}`);
|
||||||
|
|
||||||
if (availableAgents.length === 0) {
|
if (availableAgents.length === 0) {
|
||||||
await throwMissingApiKeyError({
|
// this will be caught and reported later in validateApiKey
|
||||||
agent: null,
|
throw new Error("no agents available - missing API keys");
|
||||||
repoContext,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const agentName = availableAgents[0].name;
|
const agentName = availableAgents[0].name;
|
||||||
@@ -324,9 +358,7 @@ async function resolveAgent(
|
|||||||
return { agentName, agent };
|
return { agentName, agent };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setupTempDirectory(
|
async function setupTempDirectory(ctx: Context): Promise<void> {
|
||||||
ctx: Omit<MainContext, "payload" | "mcpServers" | "cliPath" | "apiKey">
|
|
||||||
): Promise<void> {
|
|
||||||
ctx.sharedTempDir = await mkdtemp(join(tmpdir(), "pullfrog-"));
|
ctx.sharedTempDir = await mkdtemp(join(tmpdir(), "pullfrog-"));
|
||||||
process.env.PULLFROG_TEMP_DIR = ctx.sharedTempDir;
|
process.env.PULLFROG_TEMP_DIR = ctx.sharedTempDir;
|
||||||
log.info(`📂 PULLFROG_TEMP_DIR has been created at ${ctx.sharedTempDir}`);
|
log.info(`📂 PULLFROG_TEMP_DIR has been created at ${ctx.sharedTempDir}`);
|
||||||
@@ -352,7 +384,7 @@ function parsePayload(inputs: Inputs): Payload {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startMcpServer(ctx: MainContext): Promise<void> {
|
async function startMcpServer(ctx: Context): Promise<void> {
|
||||||
// fetch the pre-created progress comment ID from the database
|
// fetch the pre-created progress comment ID from the database
|
||||||
// this must be set BEFORE starting the MCP server so comment.ts can read it
|
// this must be set BEFORE starting the MCP server so comment.ts can read it
|
||||||
const runId = process.env.GITHUB_RUN_ID;
|
const runId = process.env.GITHUB_RUN_ID;
|
||||||
@@ -364,26 +396,18 @@ async function startMcpServer(ctx: MainContext): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const allModes = [
|
const { url, close } = await startMcpHttpServer(ctx);
|
||||||
...getModes({ disableProgressComment: ctx.payload.disableProgressComment }),
|
|
||||||
...(ctx.payload.modes || []),
|
|
||||||
];
|
|
||||||
const { url, close } = await startMcpHttpServer({
|
|
||||||
payload: ctx.payload,
|
|
||||||
modes: allModes,
|
|
||||||
agentName: ctx.agentName,
|
|
||||||
});
|
|
||||||
ctx.mcpServerUrl = url;
|
ctx.mcpServerUrl = url;
|
||||||
ctx.mcpServerClose = close;
|
ctx.mcpServerClose = close;
|
||||||
log.info(`🚀 MCP server started at ${url}`);
|
log.info(`🚀 MCP server started at ${url}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupMcpServers(ctx: MainContext): void {
|
function setupMcpServers(ctx: Context): void {
|
||||||
ctx.mcpServers = createMcpConfigs(ctx.mcpServerUrl);
|
ctx.mcpServers = createMcpConfigs(ctx.mcpServerUrl);
|
||||||
log.debug(`📋 MCP Config: ${JSON.stringify(ctx.mcpServers, null, 2)}`);
|
log.debug(`📋 MCP Config: ${JSON.stringify(ctx.mcpServers, null, 2)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function installAgentCli(ctx: MainContext): Promise<void> {
|
async function installAgentCli(ctx: Context): Promise<void> {
|
||||||
// gemini is the only agent that needs githubInstallationToken for install
|
// gemini is the only agent that needs githubInstallationToken for install
|
||||||
if (ctx.agentName === "gemini") {
|
if (ctx.agentName === "gemini") {
|
||||||
ctx.cliPath = await ctx.agent.install(ctx.githubInstallationToken);
|
ctx.cliPath = await ctx.agent.install(ctx.githubInstallationToken);
|
||||||
@@ -392,7 +416,7 @@ async function installAgentCli(ctx: MainContext): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function validateApiKey(ctx: MainContext): Promise<void> {
|
async function validateApiKey(ctx: Context): Promise<void> {
|
||||||
// collect all matching API keys for this agent
|
// collect all matching API keys for this agent
|
||||||
const apiKeys: Record<string, string> = {};
|
const apiKeys: Record<string, string> = {};
|
||||||
for (const inputKey of ctx.agent.apiKeyNames) {
|
for (const inputKey of ctx.agent.apiKeyNames) {
|
||||||
@@ -414,10 +438,7 @@ async function validateApiKey(ctx: MainContext): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (Object.keys(apiKeys).length === 0) {
|
if (Object.keys(apiKeys).length === 0) {
|
||||||
await throwMissingApiKeyError({
|
await throwMissingApiKeyError(ctx);
|
||||||
agent: ctx.agent,
|
|
||||||
repoContext: ctx.repoContext,
|
|
||||||
});
|
|
||||||
// unreachable - throwMissingApiKeyError always throws
|
// unreachable - throwMissingApiKeyError always throws
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -427,7 +448,7 @@ async function validateApiKey(ctx: MainContext): Promise<void> {
|
|||||||
ctx.apiKeys = apiKeys;
|
ctx.apiKeys = apiKeys;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runAgent(ctx: MainContext): Promise<AgentResult> {
|
async function runAgent(ctx: Context): Promise<AgentResult> {
|
||||||
log.info(`Running ${ctx.agentName}...`);
|
log.info(`Running ${ctx.agentName}...`);
|
||||||
// strip context from event
|
// strip context from event
|
||||||
const { context: _context, ...eventWithoutContext } = ctx.payload.event;
|
const { context: _context, ...eventWithoutContext } = ctx.payload.event;
|
||||||
|
|||||||
+4
-4
@@ -30,7 +30,7 @@ await mcp.call("gh_pullfrog/get_check_suite_logs", {
|
|||||||
### review tools
|
### review tools
|
||||||
|
|
||||||
#### `get_review_comments`
|
#### `get_review_comments`
|
||||||
get all line-by-line comments for a specific pull request review.
|
get all line-by-line comments and their replies for a specific pull request review.
|
||||||
|
|
||||||
**parameters:**
|
**parameters:**
|
||||||
- `pull_number` (number): the pull request number
|
- `pull_number` (number): the pull request number
|
||||||
@@ -39,11 +39,11 @@ get all line-by-line comments for a specific pull request review.
|
|||||||
**replaces:** `gh api repos/{owner}/{repo}/pulls/{pull_number}/reviews/{review_id}/comments`
|
**replaces:** `gh api repos/{owner}/{repo}/pulls/{pull_number}/reviews/{review_id}/comments`
|
||||||
|
|
||||||
**returns:**
|
**returns:**
|
||||||
array of review comments including:
|
array of review comments including threaded replies:
|
||||||
- file path, line number, comment body
|
- file path, line number, comment body
|
||||||
- side (LEFT/RIGHT) and position in diff
|
- side (LEFT/RIGHT) and position in diff
|
||||||
- user, timestamps, html_url
|
- user, timestamps, html_url
|
||||||
- in_reply_to_id for threaded comments
|
- in_reply_to_id for threaded comments (replies have this set to the parent comment id)
|
||||||
|
|
||||||
**example:**
|
**example:**
|
||||||
```typescript
|
```typescript
|
||||||
@@ -111,7 +111,7 @@ see individual files for documentation on other tools:
|
|||||||
|
|
||||||
## usage in agents
|
## usage in agents
|
||||||
|
|
||||||
agents should never use the `gh` cli. instead, they should use the mcp tools provided by this server.
|
agents should prefer using the mcp tools provided by this server. the `gh` cli is available as a fallback if needed, but mcp tools handle authentication and provide better integration.
|
||||||
|
|
||||||
the agent instructions automatically include guidance on using these tools.
|
the agent instructions automatically include guidance on using these tools.
|
||||||
|
|
||||||
|
|||||||
+84
-81
@@ -1,94 +1,97 @@
|
|||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
import { contextualize, tool } from "./shared.ts";
|
import type { Context } from "../main.ts";
|
||||||
|
import { execute, tool } from "./shared.ts";
|
||||||
|
|
||||||
export const GetCheckSuiteLogs = type({
|
export const GetCheckSuiteLogs = type({
|
||||||
check_suite_id: type.number.describe("the id from check_suite.id"),
|
check_suite_id: type.number.describe("the id from check_suite.id"),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const GetCheckSuiteLogsTool = tool({
|
export function GetCheckSuiteLogsTool(ctx: Context) {
|
||||||
name: "get_check_suite_logs",
|
return tool({
|
||||||
description:
|
name: "get_check_suite_logs",
|
||||||
"get workflow run logs for a failed check suite. pass check_suite.id from the webhook payload.",
|
description:
|
||||||
parameters: GetCheckSuiteLogs,
|
"get workflow run logs for a failed check suite. pass check_suite.id from the webhook payload.",
|
||||||
execute: contextualize(async ({ check_suite_id }, ctx) => {
|
parameters: GetCheckSuiteLogs,
|
||||||
// get workflow runs for this specific check suite
|
execute: execute(ctx, async ({ check_suite_id }) => {
|
||||||
const workflowRuns = await ctx.octokit.paginate(
|
// get workflow runs for this specific check suite
|
||||||
ctx.octokit.rest.actions.listWorkflowRunsForRepo,
|
const workflowRuns = await ctx.octokit.paginate(
|
||||||
{
|
ctx.octokit.rest.actions.listWorkflowRunsForRepo,
|
||||||
owner: ctx.owner,
|
{
|
||||||
repo: ctx.name,
|
|
||||||
check_suite_id,
|
|
||||||
per_page: 100,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const failedRuns = workflowRuns.filter((run) => run.conclusion === "failure");
|
|
||||||
|
|
||||||
if (failedRuns.length === 0) {
|
|
||||||
return {
|
|
||||||
check_suite_id,
|
|
||||||
message: "no failed workflow runs found for this check suite",
|
|
||||||
workflow_runs: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// get logs for each failed run
|
|
||||||
const logsForRuns = await Promise.all(
|
|
||||||
failedRuns.map(async (run) => {
|
|
||||||
const jobs = await ctx.octokit.paginate(ctx.octokit.rest.actions.listJobsForWorkflowRun, {
|
|
||||||
owner: ctx.owner,
|
owner: ctx.owner,
|
||||||
repo: ctx.name,
|
repo: ctx.name,
|
||||||
run_id: run.id,
|
check_suite_id,
|
||||||
});
|
per_page: 100,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const jobLogs = await Promise.all(
|
const failedRuns = workflowRuns.filter((run) => run.conclusion === "failure");
|
||||||
jobs.map(async (job) => {
|
|
||||||
try {
|
|
||||||
const logsResponse = await ctx.octokit.rest.actions.downloadJobLogsForWorkflowRun({
|
|
||||||
owner: ctx.owner,
|
|
||||||
repo: ctx.name,
|
|
||||||
job_id: job.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const logsUrl = logsResponse.url;
|
|
||||||
const logsText = await fetch(logsUrl).then((r) => r.text());
|
|
||||||
|
|
||||||
return {
|
|
||||||
job_id: job.id,
|
|
||||||
job_name: job.name,
|
|
||||||
status: job.status,
|
|
||||||
conclusion: job.conclusion,
|
|
||||||
started_at: job.started_at,
|
|
||||||
completed_at: job.completed_at,
|
|
||||||
logs: logsText,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
job_id: job.id,
|
|
||||||
job_name: job.name,
|
|
||||||
status: job.status,
|
|
||||||
conclusion: job.conclusion,
|
|
||||||
started_at: job.started_at,
|
|
||||||
completed_at: job.completed_at,
|
|
||||||
error: `failed to fetch logs: ${error}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
|
if (failedRuns.length === 0) {
|
||||||
return {
|
return {
|
||||||
workflow_run_id: run.id,
|
check_suite_id,
|
||||||
workflow_name: run.name,
|
message: "no failed workflow runs found for this check suite",
|
||||||
html_url: run.html_url,
|
workflow_runs: [],
|
||||||
conclusion: run.conclusion,
|
|
||||||
jobs: jobLogs,
|
|
||||||
};
|
};
|
||||||
})
|
}
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
// get logs for each failed run
|
||||||
check_suite_id,
|
const logsForRuns = await Promise.all(
|
||||||
workflow_runs: logsForRuns,
|
failedRuns.map(async (run) => {
|
||||||
};
|
const jobs = await ctx.octokit.paginate(ctx.octokit.rest.actions.listJobsForWorkflowRun, {
|
||||||
}),
|
owner: ctx.owner,
|
||||||
});
|
repo: ctx.name,
|
||||||
|
run_id: run.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const jobLogs = await Promise.all(
|
||||||
|
jobs.map(async (job) => {
|
||||||
|
try {
|
||||||
|
const logsResponse = await ctx.octokit.rest.actions.downloadJobLogsForWorkflowRun({
|
||||||
|
owner: ctx.owner,
|
||||||
|
repo: ctx.name,
|
||||||
|
job_id: job.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const logsUrl = logsResponse.url;
|
||||||
|
const logsText = await fetch(logsUrl).then((r) => r.text());
|
||||||
|
|
||||||
|
return {
|
||||||
|
job_id: job.id,
|
||||||
|
job_name: job.name,
|
||||||
|
status: job.status,
|
||||||
|
conclusion: job.conclusion,
|
||||||
|
started_at: job.started_at,
|
||||||
|
completed_at: job.completed_at,
|
||||||
|
logs: logsText,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
job_id: job.id,
|
||||||
|
job_name: job.name,
|
||||||
|
status: job.status,
|
||||||
|
conclusion: job.conclusion,
|
||||||
|
started_at: job.started_at,
|
||||||
|
completed_at: job.completed_at,
|
||||||
|
error: `failed to fetch logs: ${error}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
workflow_run_id: run.id,
|
||||||
|
workflow_name: run.name,
|
||||||
|
html_url: run.html_url,
|
||||||
|
conclusion: run.conclusion,
|
||||||
|
jobs: jobLogs,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
check_suite_id,
|
||||||
|
workflow_runs: logsForRuns,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
+106
-97
@@ -2,10 +2,11 @@ import { Octokit } from "@octokit/rest";
|
|||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
import type { Payload } from "../external.ts";
|
import type { Payload } from "../external.ts";
|
||||||
import { agentsManifest } from "../external.ts";
|
import { agentsManifest } from "../external.ts";
|
||||||
|
import type { Context } from "../main.ts";
|
||||||
import { fetchWorkflowRunInfo } from "../utils/api.ts";
|
import { fetchWorkflowRunInfo } from "../utils/api.ts";
|
||||||
import { buildPullfrogFooter, stripExistingFooter } from "../utils/buildPullfrogFooter.ts";
|
import { buildPullfrogFooter, stripExistingFooter } from "../utils/buildPullfrogFooter.ts";
|
||||||
import { getGitHubInstallationToken, parseRepoContext } from "../utils/github.ts";
|
import { getGitHubInstallationToken, parseRepoContext } from "../utils/github.ts";
|
||||||
import { contextualize, getMcpContext, tool } from "./shared.ts";
|
import { execute, tool } from "./shared.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The prefix text for the initial "leaping into action" comment.
|
* The prefix text for the initial "leaping into action" comment.
|
||||||
@@ -42,58 +43,62 @@ export const Comment = type({
|
|||||||
body: type.string.describe("the comment body content"),
|
body: type.string.describe("the comment body content"),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const CreateCommentTool = tool({
|
export function CreateCommentTool(ctx: Context) {
|
||||||
name: "create_issue_comment",
|
return tool({
|
||||||
description:
|
name: "create_issue_comment",
|
||||||
"Create a comment on a GitHub issue. NOTE: Do NOT use this for progress updates or status summaries - use report_progress instead, which updates the existing progress comment.",
|
description:
|
||||||
parameters: Comment,
|
"Create a comment on a GitHub issue. NOTE: Do NOT use this for progress updates or status summaries - use report_progress instead, which updates the existing progress comment.",
|
||||||
execute: contextualize(async ({ issueNumber, body }, ctx) => {
|
parameters: Comment,
|
||||||
const bodyWithFooter = addFooter(body, ctx.payload);
|
execute: execute(ctx, async ({ issueNumber, body }) => {
|
||||||
|
const bodyWithFooter = addFooter(body, ctx.payload);
|
||||||
|
|
||||||
const result = await ctx.octokit.rest.issues.createComment({
|
const result = await ctx.octokit.rest.issues.createComment({
|
||||||
owner: ctx.owner,
|
owner: ctx.owner,
|
||||||
repo: ctx.name,
|
repo: ctx.name,
|
||||||
issue_number: issueNumber,
|
issue_number: issueNumber,
|
||||||
body: bodyWithFooter,
|
body: bodyWithFooter,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
commentId: result.data.id,
|
commentId: result.data.id,
|
||||||
url: result.data.html_url,
|
url: result.data.html_url,
|
||||||
body: result.data.body,
|
body: result.data.body,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export const EditComment = type({
|
export const EditComment = type({
|
||||||
commentId: type.number.describe("the ID of the comment to edit"),
|
commentId: type.number.describe("the ID of the comment to edit"),
|
||||||
body: type.string.describe("the new comment body content"),
|
body: type.string.describe("the new comment body content"),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const EditCommentTool = tool({
|
export function EditCommentTool(ctx: Context) {
|
||||||
name: "edit_issue_comment",
|
return tool({
|
||||||
description: "Edit a GitHub issue comment by its ID",
|
name: "edit_issue_comment",
|
||||||
parameters: EditComment,
|
description: "Edit a GitHub issue comment by its ID",
|
||||||
execute: contextualize(async ({ commentId, body }, ctx) => {
|
parameters: EditComment,
|
||||||
const bodyWithFooter = addFooter(body, ctx.payload);
|
execute: execute(ctx, async ({ commentId, body }) => {
|
||||||
|
const bodyWithFooter = addFooter(body, ctx.payload);
|
||||||
|
|
||||||
const result = await ctx.octokit.rest.issues.updateComment({
|
const result = await ctx.octokit.rest.issues.updateComment({
|
||||||
owner: ctx.owner,
|
owner: ctx.owner,
|
||||||
repo: ctx.name,
|
repo: ctx.name,
|
||||||
comment_id: commentId,
|
comment_id: commentId,
|
||||||
body: bodyWithFooter,
|
body: bodyWithFooter,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
commentId: result.data.id,
|
commentId: result.data.id,
|
||||||
url: result.data.html_url,
|
url: result.data.html_url,
|
||||||
body: result.data.body,
|
body: result.data.body,
|
||||||
updatedAt: result.data.updated_at,
|
updatedAt: result.data.updated_at,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get progress comment ID from environment variable.
|
* Get progress comment ID from environment variable.
|
||||||
@@ -141,7 +146,10 @@ export const ReportProgress = type({
|
|||||||
* Can be called directly without going through the MCP tool interface.
|
* Can be called directly without going through the MCP tool interface.
|
||||||
* Returns result data if successful, undefined if comment cannot be created.
|
* Returns result data if successful, undefined if comment cannot be created.
|
||||||
*/
|
*/
|
||||||
export async function reportProgress({ body }: { body: string }): Promise<
|
export async function reportProgress(
|
||||||
|
ctx: Context,
|
||||||
|
{ body }: { body: string }
|
||||||
|
): Promise<
|
||||||
| {
|
| {
|
||||||
commentId: number;
|
commentId: number;
|
||||||
url: string;
|
url: string;
|
||||||
@@ -150,8 +158,6 @@ export async function reportProgress({ body }: { body: string }): Promise<
|
|||||||
}
|
}
|
||||||
| undefined
|
| undefined
|
||||||
> {
|
> {
|
||||||
const ctx = getMcpContext();
|
|
||||||
|
|
||||||
const bodyWithFooter = addFooter(body, ctx.payload);
|
const bodyWithFooter = addFooter(body, ctx.payload);
|
||||||
const existingCommentId = getProgressCommentId();
|
const existingCommentId = getProgressCommentId();
|
||||||
|
|
||||||
@@ -177,7 +183,8 @@ export async function reportProgress({ body }: { body: string }): Promise<
|
|||||||
// no existing comment - create one
|
// no existing comment - create one
|
||||||
const issueNumber = ctx.payload.event.issue_number;
|
const issueNumber = ctx.payload.event.issue_number;
|
||||||
if (issueNumber === undefined) {
|
if (issueNumber === undefined) {
|
||||||
throw new Error("cannot create progress comment: no issue_number found in the payload event");
|
// cannot create comment without issue_number (e.g., workflow_dispatch events)
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await ctx.octokit.rest.issues.createComment({
|
const result = await ctx.octokit.rest.issues.createComment({
|
||||||
@@ -199,20 +206,32 @@ export async function reportProgress({ body }: { body: string }): Promise<
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ReportProgressTool = tool({
|
export function ReportProgressTool(ctx: Context) {
|
||||||
name: "report_progress",
|
return tool({
|
||||||
description:
|
name: "report_progress",
|
||||||
"Share progress on the associated GitHub issue/PR. Call this to post updates as you work. The first call creates a comment, subsequent calls update it. Use this throughout your work to keep stakeholders informed.",
|
description:
|
||||||
parameters: ReportProgress,
|
"Share progress on the associated GitHub issue/PR. Call this to post updates as you work. The first call creates a comment, subsequent calls update it. Use this throughout your work to keep stakeholders informed.",
|
||||||
execute: contextualize(async ({ body }) => {
|
parameters: ReportProgress,
|
||||||
const result = await reportProgress({ body });
|
execute: execute(ctx, async ({ body }) => {
|
||||||
|
const result = await reportProgress(ctx, { body });
|
||||||
|
|
||||||
return {
|
if (!result) {
|
||||||
success: true,
|
// gracefully handle case where no comment can be created
|
||||||
...result,
|
// this happens for workflow_dispatch events or when there's no associated issue/PR
|
||||||
};
|
return {
|
||||||
}),
|
success: false,
|
||||||
});
|
message:
|
||||||
|
"cannot create progress comment: no issue_number found in the payload event. this may occur for workflow_dispatch events or when there is no associated issue/PR. if you need to comment on a specific issue or PR, use create_issue_comment with an explicit issueNumber.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
...result,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if the progress comment was updated during execution
|
* Check if the progress comment was updated during execution
|
||||||
@@ -225,14 +244,12 @@ export function wasProgressCommentUpdated(): boolean {
|
|||||||
* Delete the progress comment if it exists.
|
* Delete the progress comment if it exists.
|
||||||
* Used after submitting a PR review since the review body contains all necessary info.
|
* Used after submitting a PR review since the review body contains all necessary info.
|
||||||
*/
|
*/
|
||||||
export async function deleteProgressComment(): Promise<boolean> {
|
export async function deleteProgressComment(ctx: Context): Promise<boolean> {
|
||||||
const existingCommentId = getProgressCommentId();
|
const existingCommentId = getProgressCommentId();
|
||||||
if (!existingCommentId) {
|
if (!existingCommentId) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ctx = getMcpContext();
|
|
||||||
|
|
||||||
await ctx.octokit.rest.issues.deleteComment({
|
await ctx.octokit.rest.issues.deleteComment({
|
||||||
owner: ctx.owner,
|
owner: ctx.owner,
|
||||||
repo: ctx.name,
|
repo: ctx.name,
|
||||||
@@ -310,16 +327,6 @@ export async function ensureProgressCommentUpdated(payload?: Payload): Promise<v
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// try to get payload from MCP context if available, otherwise use provided payload
|
|
||||||
let resolvedPayload: Payload | undefined;
|
|
||||||
try {
|
|
||||||
const ctx = getMcpContext();
|
|
||||||
resolvedPayload = ctx.payload;
|
|
||||||
} catch {
|
|
||||||
// MCP context not initialized, use provided payload
|
|
||||||
resolvedPayload = payload;
|
|
||||||
}
|
|
||||||
|
|
||||||
const runId = process.env.GITHUB_RUN_ID;
|
const runId = process.env.GITHUB_RUN_ID;
|
||||||
const workflowRunLink = runId
|
const workflowRunLink = runId
|
||||||
? `[workflow](https://github.com/${repoContext.owner}/${repoContext.name}/actions/runs/${runId})`
|
? `[workflow](https://github.com/${repoContext.owner}/${repoContext.name}/actions/runs/${runId})`
|
||||||
@@ -330,7 +337,7 @@ export async function ensureProgressCommentUpdated(payload?: Payload): Promise<v
|
|||||||
The workflow encountered an error before any progress could be reported. Please check the ${workflowRunLink} for details.`;
|
The workflow encountered an error before any progress could be reported. Please check the ${workflowRunLink} for details.`;
|
||||||
|
|
||||||
// add footer if we have payload, otherwise use plain message
|
// add footer if we have payload, otherwise use plain message
|
||||||
const body = resolvedPayload ? addFooter(errorMessage, resolvedPayload) : errorMessage;
|
const body = payload ? addFooter(errorMessage, payload) : errorMessage;
|
||||||
|
|
||||||
await octokit.rest.issues.updateComment({
|
await octokit.rest.issues.updateComment({
|
||||||
owner: repoContext.owner,
|
owner: repoContext.owner,
|
||||||
@@ -348,28 +355,30 @@ export const ReplyToReviewComment = type({
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ReplyToReviewCommentTool = tool({
|
export function ReplyToReviewCommentTool(ctx: Context) {
|
||||||
name: "reply_to_review_comment",
|
return tool({
|
||||||
description:
|
name: "reply_to_review_comment",
|
||||||
"Reply to a PR review comment thread. Call this for EACH comment you address. Keep replies extremely brief (1 sentence max).",
|
description:
|
||||||
parameters: ReplyToReviewComment,
|
"Reply to a PR review comment thread. Call this for EACH comment you address. Keep replies extremely brief (1 sentence max).",
|
||||||
execute: contextualize(async ({ pull_number, comment_id, body }, ctx) => {
|
parameters: ReplyToReviewComment,
|
||||||
const bodyWithFooter = addFooter(body, ctx.payload);
|
execute: execute(ctx, async ({ pull_number, comment_id, body }) => {
|
||||||
|
const bodyWithFooter = addFooter(body, ctx.payload);
|
||||||
|
|
||||||
const result = await ctx.octokit.rest.pulls.createReplyForReviewComment({
|
const result = await ctx.octokit.rest.pulls.createReplyForReviewComment({
|
||||||
owner: ctx.owner,
|
owner: ctx.owner,
|
||||||
repo: ctx.name,
|
repo: ctx.name,
|
||||||
pull_number,
|
pull_number,
|
||||||
comment_id,
|
comment_id,
|
||||||
body: bodyWithFooter,
|
body: bodyWithFooter,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
commentId: result.data.id,
|
commentId: result.data.id,
|
||||||
url: result.data.html_url,
|
url: result.data.html_url,
|
||||||
body: result.data.body,
|
body: result.data.body,
|
||||||
in_reply_to_id: result.data.in_reply_to_id,
|
in_reply_to_id: result.data.in_reply_to_id,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
+7
-28
@@ -1,7 +1,6 @@
|
|||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
import { $ } from "../utils/shell.ts";
|
import { $ } from "../utils/shell.ts";
|
||||||
import type { ToolResult } from "./shared.ts";
|
import { handleToolError, handleToolSuccess, tool, type ToolResult } from "./shared.ts";
|
||||||
import { tool } from "./shared.ts";
|
|
||||||
|
|
||||||
export const DebugShellCommand = type({});
|
export const DebugShellCommand = type({});
|
||||||
|
|
||||||
@@ -13,33 +12,13 @@ export const DebugShellCommandTool = tool({
|
|||||||
execute: async (): Promise<ToolResult> => {
|
execute: async (): Promise<ToolResult> => {
|
||||||
try {
|
try {
|
||||||
const result = $("git", ["status"]);
|
const result = $("git", ["status"]);
|
||||||
|
return handleToolSuccess({
|
||||||
return {
|
success: true,
|
||||||
content: [
|
command: "git status",
|
||||||
{
|
output: result.trim(),
|
||||||
type: "text",
|
});
|
||||||
text: JSON.stringify(
|
|
||||||
{
|
|
||||||
success: true,
|
|
||||||
command: "git status",
|
|
||||||
output: result.trim(),
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return handleToolError(error);
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: `Error: ${error instanceof Error ? error.message : String(error)}`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
isError: true,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
+8
-11
@@ -1,6 +1,6 @@
|
|||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
import { $ } from "../utils/shell.ts";
|
import { $ } from "../utils/shell.ts";
|
||||||
import { contextualize, tool } from "./shared.ts";
|
import { handleToolError, handleToolSuccess, tool, type ToolResult } from "./shared.ts";
|
||||||
|
|
||||||
export const ListFiles = type({
|
export const ListFiles = type({
|
||||||
path: type.string
|
path: type.string
|
||||||
@@ -8,12 +8,13 @@ export const ListFiles = type({
|
|||||||
.default("."),
|
.default("."),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// static tool - doesn't need ctx, just runs git/find commands
|
||||||
export const ListFilesTool = tool({
|
export const ListFilesTool = tool({
|
||||||
name: "list_files",
|
name: "list_files",
|
||||||
description:
|
description:
|
||||||
"List files in the repository using git ls-files. Useful for discovering the file structure and locating files.",
|
"List files in the repository using git ls-files. Useful for discovering the file structure and locating files.",
|
||||||
parameters: ListFiles,
|
parameters: ListFiles,
|
||||||
execute: contextualize(async ({ path }) => {
|
execute: async ({ path }: { path?: string }): Promise<ToolResult> => {
|
||||||
try {
|
try {
|
||||||
// Use git ls-files to list tracked files
|
// Use git ls-files to list tracked files
|
||||||
// This respects .gitignore and gives a clean list of source files
|
// This respects .gitignore and gives a clean list of source files
|
||||||
@@ -30,19 +31,15 @@ export const ListFilesTool = tool({
|
|||||||
[pathStr, "-maxdepth", "3", "-not", "-path", "*/.*", "-type", "f"],
|
[pathStr, "-maxdepth", "3", "-not", "-path", "*/.*", "-type", "f"],
|
||||||
{ log: false }
|
{ log: false }
|
||||||
);
|
);
|
||||||
return {
|
return handleToolSuccess({
|
||||||
files: findOutput.split("\n").filter((f) => f.trim() !== ""),
|
files: findOutput.split("\n").filter((f) => f.trim() !== ""),
|
||||||
method: "find",
|
method: "find",
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return { files, method: "git" };
|
return handleToolSuccess({ files, method: "git" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
return handleToolError(error);
|
||||||
return {
|
|
||||||
error: `Failed to list files: ${errorMessage}`,
|
|
||||||
hint: "Try using a specific path if the repository root is not the current directory.",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}),
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
+129
-113
@@ -1,55 +1,64 @@
|
|||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
|
import type { Context } from "../main.ts";
|
||||||
import { log } from "../utils/cli.ts";
|
import { log } from "../utils/cli.ts";
|
||||||
import { containsSecrets } from "../utils/secrets.ts";
|
import { containsSecrets } from "../utils/secrets.ts";
|
||||||
import { $ } from "../utils/shell.ts";
|
import { $ } from "../utils/shell.ts";
|
||||||
import { contextualize, tool } from "./shared.ts";
|
import { execute, tool } from "./shared.ts";
|
||||||
|
|
||||||
export const CreateBranch = type({
|
export function CreateBranchTool(ctx: Context) {
|
||||||
branchName: type.string.describe(
|
const defaultBranch = ctx.repo.default_branch || "main";
|
||||||
"The name of the branch to create (e.g., 'pullfrog/123-fix-bug')"
|
const CreateBranch = type({
|
||||||
),
|
branchName: type.string.describe(
|
||||||
baseBranch: type.string.describe("The base branch to create from (e.g., 'main')").default("main"),
|
"The name of the branch to create (e.g., 'pullfrog/123-fix-bug')"
|
||||||
});
|
),
|
||||||
|
baseBranch: type.string
|
||||||
|
.describe(`The base branch to create from (defaults to '${defaultBranch}')`)
|
||||||
|
.default(defaultBranch),
|
||||||
|
});
|
||||||
|
|
||||||
export const CreateBranchTool = tool({
|
return tool({
|
||||||
name: "create_branch",
|
name: "create_branch",
|
||||||
description:
|
description:
|
||||||
"Create a new git branch from the specified base branch. The branch will be created locally and pushed to the remote repository.",
|
"Create a new git branch from the specified base branch. The branch will be created locally and pushed to the remote repository.",
|
||||||
parameters: CreateBranch,
|
parameters: CreateBranch,
|
||||||
execute: contextualize(async ({ branchName, baseBranch }) => {
|
execute: execute(ctx, async ({ branchName, baseBranch }) => {
|
||||||
// validate branch name for secrets
|
// baseBranch should always be defined due to default, but TypeScript needs help
|
||||||
if (containsSecrets(branchName)) {
|
const resolvedBaseBranch = baseBranch || ctx.repo.default_branch || "main";
|
||||||
throw new Error(
|
|
||||||
"Branch creation blocked: secrets detected in branch name. " +
|
|
||||||
"Please remove any sensitive information (API keys, tokens, passwords) before creating a branch."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info(`Creating branch ${branchName} from ${baseBranch}`);
|
// validate branch name for secrets
|
||||||
|
if (containsSecrets(branchName)) {
|
||||||
|
throw new Error(
|
||||||
|
"Branch creation blocked: secrets detected in branch name. " +
|
||||||
|
"Please remove any sensitive information (API keys, tokens, passwords) before creating a branch."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// fetch base branch to ensure we're up to date
|
log.info(`Creating branch ${branchName} from ${resolvedBaseBranch}`);
|
||||||
$("git", ["fetch", "origin", baseBranch, "--depth=1"]);
|
|
||||||
|
|
||||||
// checkout base branch, ensuring it matches the remote version
|
// fetch base branch to ensure we're up to date
|
||||||
// -B creates or resets the branch to match origin/baseBranch
|
$("git", ["fetch", "origin", resolvedBaseBranch, "--depth=1"]);
|
||||||
$("git", ["checkout", "-B", baseBranch, `origin/${baseBranch}`]);
|
|
||||||
|
|
||||||
// create and checkout new branch
|
// checkout base branch, ensuring it matches the remote version
|
||||||
$("git", ["checkout", "-b", branchName]);
|
// -B creates or resets the branch to match origin/baseBranch
|
||||||
|
$("git", ["checkout", "-B", resolvedBaseBranch, `origin/${resolvedBaseBranch}`]);
|
||||||
|
|
||||||
// push branch to remote (set upstream)
|
// create and checkout new branch
|
||||||
$("git", ["push", "-u", "origin", branchName]);
|
$("git", ["checkout", "-b", branchName]);
|
||||||
|
|
||||||
log.info(`Successfully created and pushed branch ${branchName}`);
|
// push branch to remote (set upstream)
|
||||||
|
$("git", ["push", "-u", "origin", branchName]);
|
||||||
|
|
||||||
return {
|
log.info(`Successfully created and pushed branch ${branchName}`);
|
||||||
success: true,
|
|
||||||
branchName,
|
return {
|
||||||
baseBranch,
|
success: true,
|
||||||
message: `Branch ${branchName} created from ${baseBranch} and pushed to remote`,
|
branchName,
|
||||||
};
|
baseBranch: resolvedBaseBranch,
|
||||||
}),
|
message: `Branch ${branchName} created from ${resolvedBaseBranch} and pushed to remote`,
|
||||||
});
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export const CommitFiles = type({
|
export const CommitFiles = type({
|
||||||
message: type.string.describe("The commit message"),
|
message: type.string.describe("The commit message"),
|
||||||
@@ -60,67 +69,69 @@ export const CommitFiles = type({
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const CommitFilesTool = tool({
|
export function CommitFilesTool(ctx: Context) {
|
||||||
name: "commit_files",
|
return tool({
|
||||||
description:
|
name: "commit_files",
|
||||||
"Stage and commit files with a commit message. If files array is empty, commits all staged changes. The commit will be attributed to the correct bot account.",
|
description:
|
||||||
parameters: CommitFiles,
|
"Stage and commit files with a commit message. If files array is empty, commits all staged changes. The commit will be attributed to the correct bot account.",
|
||||||
execute: contextualize(async ({ message, files }) => {
|
parameters: CommitFiles,
|
||||||
// validate commit message for secrets
|
execute: execute(ctx, async ({ message, files }) => {
|
||||||
if (containsSecrets(message)) {
|
// validate commit message for secrets
|
||||||
throw new Error(
|
if (containsSecrets(message)) {
|
||||||
"Commit blocked: secrets detected in commit message. " +
|
throw new Error(
|
||||||
"Please remove any sensitive information (API keys, tokens, passwords) before committing."
|
"Commit blocked: secrets detected in commit message. " +
|
||||||
);
|
"Please remove any sensitive information (API keys, tokens, passwords) before committing."
|
||||||
}
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// validate files for secrets if provided
|
// validate files for secrets if provided
|
||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
try {
|
try {
|
||||||
// try to read file content - if it exists, check for secrets
|
// try to read file content - if it exists, check for secrets
|
||||||
const content = $("cat", [file], { log: false });
|
const content = $("cat", [file], { log: false });
|
||||||
if (containsSecrets(content)) {
|
if (containsSecrets(content)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Commit blocked: secrets detected in file ${file}. ` +
|
`Commit blocked: secrets detected in file ${file}. ` +
|
||||||
"Please remove any sensitive information (API keys, tokens, passwords) before committing."
|
"Please remove any sensitive information (API keys, tokens, passwords) before committing."
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// if error is about secrets, re-throw it
|
||||||
|
if (error instanceof Error && error.message.includes("Commit blocked")) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
// if file doesn't exist (cat fails), that's ok - it will be created by git add
|
||||||
|
// other errors are also ok - git add will handle them
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
// if error is about secrets, re-throw it
|
|
||||||
if (error instanceof Error && error.message.includes("Commit blocked")) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
// if file doesn't exist (cat fails), that's ok - it will be created by git add
|
|
||||||
// other errors are also ok - git add will handle them
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const currentBranch = $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false });
|
const currentBranch = $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false });
|
||||||
log.info(`Committing files on branch ${currentBranch}`);
|
log.info(`Committing files on branch ${currentBranch}`);
|
||||||
|
|
||||||
// stage files if provided, otherwise stage all changes
|
// stage files if provided, otherwise stage all changes
|
||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
$("git", ["add", ...files]);
|
$("git", ["add", ...files]);
|
||||||
} else {
|
} else {
|
||||||
$("git", ["add", "."]);
|
$("git", ["add", "."]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// commit with message
|
// commit with message
|
||||||
$("git", ["commit", "-m", message]);
|
$("git", ["commit", "-m", message]);
|
||||||
|
|
||||||
const commitSha = $("git", ["rev-parse", "HEAD"], { log: false });
|
const commitSha = $("git", ["rev-parse", "HEAD"], { log: false });
|
||||||
log.info(`Successfully committed: ${commitSha.substring(0, 7)}`);
|
log.info(`Successfully committed: ${commitSha.substring(0, 7)}`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
commitSha,
|
commitSha,
|
||||||
branch: currentBranch,
|
branch: currentBranch,
|
||||||
message: `Committed ${files.length > 0 ? files.length + " file(s)" : "all changes"} with message: ${message}`,
|
message: `Committed ${files.length > 0 ? files.length + " file(s)" : "all changes"} with message: ${message}`,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export const PushBranch = type({
|
export const PushBranch = type({
|
||||||
branchName: type.string
|
branchName: type.string
|
||||||
@@ -129,27 +140,32 @@ export const PushBranch = type({
|
|||||||
force: type.boolean.describe("Force push (use with caution)").default(false),
|
force: type.boolean.describe("Force push (use with caution)").default(false),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const PushBranchTool = tool({
|
export function PushBranchTool(ctx: Context) {
|
||||||
name: "push_branch",
|
const remote = ctx.pushRemote;
|
||||||
description:
|
|
||||||
"Push the current branch (or specified branch) to the remote repository. Never force push unless explicitly requested.",
|
|
||||||
parameters: PushBranch,
|
|
||||||
execute: contextualize(async ({ branchName, force }) => {
|
|
||||||
const branch = branchName || $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false });
|
|
||||||
|
|
||||||
if (force) {
|
return tool({
|
||||||
log.warning(`Force pushing branch ${branch} - this will overwrite remote history`);
|
name: "push_branch",
|
||||||
$("git", ["push", "--force", "origin", branch]);
|
description:
|
||||||
} else {
|
"Push the current branch (or specified branch) to the remote repository. Never force push unless explicitly requested.",
|
||||||
log.info(`Pushing branch ${branch} to remote`);
|
parameters: PushBranch,
|
||||||
$("git", ["push", "origin", branch]);
|
execute: execute(ctx, async ({ branchName, force }) => {
|
||||||
}
|
const branch = branchName || $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false });
|
||||||
|
|
||||||
return {
|
log.info(`Pushing branch ${branch} to ${remote}`);
|
||||||
success: true,
|
if (force) {
|
||||||
branch,
|
log.warning(`Force pushing - this will overwrite remote history`);
|
||||||
force,
|
$("git", ["push", "--force", "-u", remote, branch]);
|
||||||
message: `Successfully pushed branch ${branch} to remote`,
|
} else {
|
||||||
};
|
$("git", ["push", "-u", remote, branch]);
|
||||||
}),
|
}
|
||||||
});
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
branch,
|
||||||
|
remote,
|
||||||
|
force,
|
||||||
|
message: `Successfully pushed branch ${branch} to ${remote}`,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
+29
-26
@@ -1,5 +1,6 @@
|
|||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
import { contextualize, tool } from "./shared.ts";
|
import type { Context } from "../main.ts";
|
||||||
|
import { execute, tool } from "./shared.ts";
|
||||||
|
|
||||||
export const Issue = type({
|
export const Issue = type({
|
||||||
title: type.string.describe("the title of the issue"),
|
title: type.string.describe("the title of the issue"),
|
||||||
@@ -14,29 +15,31 @@ export const Issue = type({
|
|||||||
.optional(),
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const IssueTool = tool({
|
export function IssueTool(ctx: Context) {
|
||||||
name: "create_issue",
|
return tool({
|
||||||
description: "Create a new GitHub issue",
|
name: "create_issue",
|
||||||
parameters: Issue,
|
description: "Create a new GitHub issue",
|
||||||
execute: contextualize(async ({ title, body, labels, assignees }, ctx) => {
|
parameters: Issue,
|
||||||
const result = await ctx.octokit.rest.issues.create({
|
execute: execute(ctx, async ({ title, body, labels, assignees }) => {
|
||||||
owner: ctx.owner,
|
const result = await ctx.octokit.rest.issues.create({
|
||||||
repo: ctx.name,
|
owner: ctx.owner,
|
||||||
title: title,
|
repo: ctx.name,
|
||||||
body: body,
|
title: title,
|
||||||
labels: labels ?? [],
|
body: body,
|
||||||
assignees: assignees ?? [],
|
labels: labels ?? [],
|
||||||
});
|
assignees: assignees ?? [],
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
issueId: result.data.id,
|
issueId: result.data.id,
|
||||||
number: result.data.number,
|
number: result.data.number,
|
||||||
url: result.data.html_url,
|
url: result.data.html_url,
|
||||||
title: result.data.title,
|
title: result.data.title,
|
||||||
state: result.data.state,
|
state: result.data.state,
|
||||||
labels: result.data.labels?.map((label) => (typeof label === "string" ? label : label.name)),
|
labels: result.data.labels?.map((label) => (typeof label === "string" ? label : label.name)),
|
||||||
assignees: result.data.assignees?.map((assignee) => assignee.login),
|
assignees: result.data.assignees?.map((assignee) => assignee.login),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
+31
-28
@@ -1,35 +1,38 @@
|
|||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
import { contextualize, tool } from "./shared.ts";
|
import type { Context } from "../main.ts";
|
||||||
|
import { execute, tool } from "./shared.ts";
|
||||||
|
|
||||||
export const GetIssueComments = type({
|
export const GetIssueComments = type({
|
||||||
issue_number: type.number.describe("The issue number to get comments for"),
|
issue_number: type.number.describe("The issue number to get comments for"),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const GetIssueCommentsTool = tool({
|
export function GetIssueCommentsTool(ctx: Context) {
|
||||||
name: "get_issue_comments",
|
return tool({
|
||||||
description:
|
name: "get_issue_comments",
|
||||||
"Get all comments for a GitHub issue. Returns all comments including the issue body and all subsequent discussion comments.",
|
description:
|
||||||
parameters: GetIssueComments,
|
"Get all comments for a GitHub issue. Returns all comments including the issue body and all subsequent discussion comments.",
|
||||||
execute: contextualize(async ({ issue_number }, ctx) => {
|
parameters: GetIssueComments,
|
||||||
const comments = await ctx.octokit.paginate(ctx.octokit.rest.issues.listComments, {
|
execute: execute(ctx, async ({ issue_number }) => {
|
||||||
owner: ctx.owner,
|
const comments = await ctx.octokit.paginate(ctx.octokit.rest.issues.listComments, {
|
||||||
repo: ctx.name,
|
owner: ctx.owner,
|
||||||
issue_number,
|
repo: ctx.name,
|
||||||
});
|
issue_number,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
issue_number,
|
issue_number,
|
||||||
comments: comments.map((comment) => ({
|
comments: comments.map((comment) => ({
|
||||||
id: comment.id,
|
id: comment.id,
|
||||||
body: comment.body,
|
body: comment.body,
|
||||||
user: comment.user?.login,
|
user: comment.user?.login,
|
||||||
created_at: comment.created_at,
|
created_at: comment.created_at,
|
||||||
updated_at: comment.updated_at,
|
updated_at: comment.updated_at,
|
||||||
html_url: comment.html_url,
|
html_url: comment.html_url,
|
||||||
author_association: comment.author_association,
|
author_association: comment.author_association,
|
||||||
reactions: comment.reactions,
|
reactions: comment.reactions,
|
||||||
})),
|
})),
|
||||||
count: comments.length,
|
count: comments.length,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
+86
-83
@@ -1,93 +1,96 @@
|
|||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
import { contextualize, tool } from "./shared.ts";
|
import type { Context } from "../main.ts";
|
||||||
|
import { execute, tool } from "./shared.ts";
|
||||||
|
|
||||||
export const GetIssueEvents = type({
|
export const GetIssueEvents = type({
|
||||||
issue_number: type.number.describe("The issue number to get events for"),
|
issue_number: type.number.describe("The issue number to get events for"),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const GetIssueEventsTool = tool({
|
export function GetIssueEventsTool(ctx: Context) {
|
||||||
name: "get_issue_events",
|
return tool({
|
||||||
description:
|
name: "get_issue_events",
|
||||||
"Get timeline events for a GitHub issue that aren't reflected in the current state. Returns cross-references to other issues/PRs and commit references. Note: current labels, assignees, state, and milestone are already available via get_issue.",
|
description:
|
||||||
parameters: GetIssueEvents,
|
"Get timeline events for a GitHub issue that aren't reflected in the current state. Returns cross-references to other issues/PRs and commit references. Note: current labels, assignees, state, and milestone are already available via get_issue.",
|
||||||
execute: contextualize(async ({ issue_number }, ctx) => {
|
parameters: GetIssueEvents,
|
||||||
const events = await ctx.octokit.paginate(ctx.octokit.rest.issues.listEventsForTimeline, {
|
execute: execute(ctx, async ({ issue_number }) => {
|
||||||
owner: ctx.owner,
|
const events = await ctx.octokit.paginate(ctx.octokit.rest.issues.listEventsForTimeline, {
|
||||||
repo: ctx.name,
|
owner: ctx.owner,
|
||||||
issue_number,
|
repo: ctx.name,
|
||||||
});
|
issue_number,
|
||||||
|
});
|
||||||
|
|
||||||
// Only include events not reflected in current issue state (get_issue already has labels, assignees, state, etc.)
|
// Only include events not reflected in current issue state (get_issue already has labels, assignees, state, etc.)
|
||||||
// Keep only relationship/reference events that show connections to other issues/PRs/commits
|
// Keep only relationship/reference events that show connections to other issues/PRs/commits
|
||||||
const relevantEventTypes = new Set(["cross_referenced", "referenced"]);
|
const relevantEventTypes = new Set(["cross_referenced", "referenced"]);
|
||||||
|
|
||||||
const parsedEvents = events.flatMap((event) => {
|
const parsedEvents = events.flatMap((event) => {
|
||||||
// Filter to only events with an 'event' property and relevant types
|
// Filter to only events with an 'event' property and relevant types
|
||||||
if (!("event" in event) || !relevantEventTypes.has(event.event)) {
|
if (!("event" in event) || !relevantEventTypes.has(event.event)) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseEvent: Record<string, any> = {
|
const baseEvent: Record<string, any> = {
|
||||||
event: event.event,
|
event: event.event,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Common fields
|
||||||
|
if ("id" in event) {
|
||||||
|
baseEvent.id = event.id;
|
||||||
|
}
|
||||||
|
if ("actor" in event && event.actor) {
|
||||||
|
baseEvent.actor = event.actor.login;
|
||||||
|
} else if ("user" in event && event.user) {
|
||||||
|
baseEvent.actor = event.user.login;
|
||||||
|
}
|
||||||
|
if ("created_at" in event) {
|
||||||
|
baseEvent.created_at = event.created_at;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event-specific data
|
||||||
|
if (event.event === "cross_referenced") {
|
||||||
|
if ("source" in event && event.source) {
|
||||||
|
const source = event.source as {
|
||||||
|
type?: string;
|
||||||
|
issue?: { number: number; title: string; html_url: string };
|
||||||
|
pull_request?: { number: number; title: string; html_url: string };
|
||||||
|
};
|
||||||
|
baseEvent.source = {
|
||||||
|
type: source.type,
|
||||||
|
issue: source.issue
|
||||||
|
? {
|
||||||
|
number: source.issue.number,
|
||||||
|
title: source.issue.title,
|
||||||
|
html_url: source.issue.html_url,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
pull_request: source.pull_request
|
||||||
|
? {
|
||||||
|
number: source.pull_request.number,
|
||||||
|
title: source.pull_request.title,
|
||||||
|
html_url: source.pull_request.html_url,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.event === "referenced") {
|
||||||
|
if ("commit_id" in event) {
|
||||||
|
baseEvent.commit_id = event.commit_id;
|
||||||
|
}
|
||||||
|
if ("commit_url" in event) {
|
||||||
|
baseEvent.commit_url = event.commit_url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [baseEvent];
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
issue_number,
|
||||||
|
events: parsedEvents,
|
||||||
|
count: parsedEvents.length,
|
||||||
};
|
};
|
||||||
|
}),
|
||||||
// Common fields
|
});
|
||||||
if ("id" in event) {
|
}
|
||||||
baseEvent.id = event.id;
|
|
||||||
}
|
|
||||||
if ("actor" in event && event.actor) {
|
|
||||||
baseEvent.actor = event.actor.login;
|
|
||||||
} else if ("user" in event && event.user) {
|
|
||||||
baseEvent.actor = event.user.login;
|
|
||||||
}
|
|
||||||
if ("created_at" in event) {
|
|
||||||
baseEvent.created_at = event.created_at;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Event-specific data
|
|
||||||
if (event.event === "cross_referenced") {
|
|
||||||
if ("source" in event && event.source) {
|
|
||||||
const source = event.source as {
|
|
||||||
type?: string;
|
|
||||||
issue?: { number: number; title: string; html_url: string };
|
|
||||||
pull_request?: { number: number; title: string; html_url: string };
|
|
||||||
};
|
|
||||||
baseEvent.source = {
|
|
||||||
type: source.type,
|
|
||||||
issue: source.issue
|
|
||||||
? {
|
|
||||||
number: source.issue.number,
|
|
||||||
title: source.issue.title,
|
|
||||||
html_url: source.issue.html_url,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
pull_request: source.pull_request
|
|
||||||
? {
|
|
||||||
number: source.pull_request.number,
|
|
||||||
title: source.pull_request.title,
|
|
||||||
html_url: source.pull_request.html_url,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.event === "referenced") {
|
|
||||||
if ("commit_id" in event) {
|
|
||||||
baseEvent.commit_id = event.commit_id;
|
|
||||||
}
|
|
||||||
if ("commit_url" in event) {
|
|
||||||
baseEvent.commit_url = event.commit_url;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [baseEvent];
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
issue_number,
|
|
||||||
events: parsedEvents,
|
|
||||||
count: parsedEvents.length,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|||||||
+49
-46
@@ -1,55 +1,58 @@
|
|||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
import { contextualize, tool } from "./shared.ts";
|
import type { Context } from "../main.ts";
|
||||||
|
import { execute, tool } from "./shared.ts";
|
||||||
|
|
||||||
export const IssueInfo = type({
|
export const IssueInfo = type({
|
||||||
issue_number: type.number.describe("The issue number to fetch"),
|
issue_number: type.number.describe("The issue number to fetch"),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const IssueInfoTool = tool({
|
export function IssueInfoTool(ctx: Context) {
|
||||||
name: "get_issue",
|
return tool({
|
||||||
description: "Retrieve GitHub issue information by issue number",
|
name: "get_issue",
|
||||||
parameters: IssueInfo,
|
description: "Retrieve GitHub issue information by issue number",
|
||||||
execute: contextualize(async ({ issue_number }, ctx) => {
|
parameters: IssueInfo,
|
||||||
const issue = await ctx.octokit.rest.issues.get({
|
execute: execute(ctx, async ({ issue_number }) => {
|
||||||
owner: ctx.owner,
|
const issue = await ctx.octokit.rest.issues.get({
|
||||||
repo: ctx.name,
|
owner: ctx.owner,
|
||||||
issue_number,
|
repo: ctx.name,
|
||||||
});
|
issue_number,
|
||||||
|
});
|
||||||
|
|
||||||
const data = issue.data;
|
const data = issue.data;
|
||||||
|
|
||||||
const hints: string[] = [];
|
const hints: string[] = [];
|
||||||
if (data.comments > 0) {
|
if (data.comments > 0) {
|
||||||
hints.push("use get_issue_comments to retrieve all comments for this issue");
|
hints.push("use get_issue_comments to retrieve all comments for this issue");
|
||||||
}
|
}
|
||||||
hints.push(
|
hints.push(
|
||||||
"use get_issue_events to retrieve cross-references and commit references (relationships not reflected in current state)"
|
"use get_issue_events to retrieve cross-references and commit references (relationships not reflected in current state)"
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
number: data.number,
|
number: data.number,
|
||||||
url: data.html_url,
|
url: data.html_url,
|
||||||
title: data.title,
|
title: data.title,
|
||||||
body: data.body,
|
body: data.body,
|
||||||
state: data.state,
|
state: data.state,
|
||||||
locked: data.locked,
|
locked: data.locked,
|
||||||
labels: data.labels?.map((label) => (typeof label === "string" ? label : label.name)),
|
labels: data.labels?.map((label) => (typeof label === "string" ? label : label.name)),
|
||||||
assignees: data.assignees?.map((assignee) => assignee.login),
|
assignees: data.assignees?.map((assignee) => assignee.login),
|
||||||
user: data.user?.login,
|
user: data.user?.login,
|
||||||
created_at: data.created_at,
|
created_at: data.created_at,
|
||||||
updated_at: data.updated_at,
|
updated_at: data.updated_at,
|
||||||
closed_at: data.closed_at,
|
closed_at: data.closed_at,
|
||||||
comments: data.comments,
|
comments: data.comments,
|
||||||
milestone: data.milestone?.title,
|
milestone: data.milestone?.title,
|
||||||
pull_request: data.pull_request
|
pull_request: data.pull_request
|
||||||
? {
|
? {
|
||||||
url: data.pull_request.url,
|
url: data.pull_request.url,
|
||||||
html_url: data.pull_request.html_url,
|
html_url: data.pull_request.html_url,
|
||||||
diff_url: data.pull_request.diff_url,
|
diff_url: data.pull_request.diff_url,
|
||||||
patch_url: data.pull_request.patch_url,
|
patch_url: data.pull_request.patch_url,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
hints,
|
hints,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
+22
-19
@@ -1,27 +1,30 @@
|
|||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
import { contextualize, tool } from "./shared.ts";
|
import type { Context } from "../main.ts";
|
||||||
|
import { execute, tool } from "./shared.ts";
|
||||||
|
|
||||||
export const AddLabelsParams = type({
|
export const AddLabelsParams = type({
|
||||||
issue_number: type.number.describe("the issue or PR number to add labels to"),
|
issue_number: type.number.describe("the issue or PR number to add labels to"),
|
||||||
labels: type.string.array().atLeastLength(1).describe("array of label names to add"),
|
labels: type.string.array().atLeastLength(1).describe("array of label names to add"),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const AddLabelsTool = tool({
|
export function AddLabelsTool(ctx: Context) {
|
||||||
name: "add_labels",
|
return tool({
|
||||||
description:
|
name: "add_labels",
|
||||||
"Add labels to a GitHub issue or pull request. Only use labels that already exist in the repository.",
|
description:
|
||||||
parameters: AddLabelsParams,
|
"Add labels to a GitHub issue or pull request. Only use labels that already exist in the repository.",
|
||||||
execute: contextualize(async ({ issue_number, labels }, ctx) => {
|
parameters: AddLabelsParams,
|
||||||
const result = await ctx.octokit.rest.issues.addLabels({
|
execute: execute(ctx, async ({ issue_number, labels }) => {
|
||||||
owner: ctx.owner,
|
const result = await ctx.octokit.rest.issues.addLabels({
|
||||||
repo: ctx.name,
|
owner: ctx.owner,
|
||||||
issue_number,
|
repo: ctx.name,
|
||||||
labels,
|
issue_number,
|
||||||
});
|
labels,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
labels: result.data.map((label) => label.name),
|
labels: result.data.map((label) => label.name),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
|
import type { Context } from "../main.ts";
|
||||||
import { log } from "../utils/cli.ts";
|
import { log } from "../utils/cli.ts";
|
||||||
import { containsSecrets } from "../utils/secrets.ts";
|
import { containsSecrets } from "../utils/secrets.ts";
|
||||||
import { $ } from "../utils/shell.ts";
|
import { $ } from "../utils/shell.ts";
|
||||||
import { contextualize, tool } from "./shared.ts";
|
import { execute, tool } from "./shared.ts";
|
||||||
|
|
||||||
export const PullRequest = type({
|
export const PullRequest = type({
|
||||||
title: type.string.describe("the title of the pull request"),
|
title: type.string.describe("the title of the pull request"),
|
||||||
@@ -10,48 +11,50 @@ export const PullRequest = type({
|
|||||||
base: type.string.describe("the base branch to merge into (e.g., 'main')"),
|
base: type.string.describe("the base branch to merge into (e.g., 'main')"),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const PullRequestTool = tool({
|
export function PullRequestTool(ctx: Context) {
|
||||||
name: "create_pull_request",
|
return tool({
|
||||||
description: "Create a pull request from the current branch",
|
name: "create_pull_request",
|
||||||
parameters: PullRequest,
|
description: "Create a pull request from the current branch",
|
||||||
execute: contextualize(async ({ title, body, base }, ctx) => {
|
parameters: PullRequest,
|
||||||
const currentBranch = $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false });
|
execute: execute(ctx, async ({ title, body, base }) => {
|
||||||
log.info(`Current branch: ${currentBranch}`);
|
const currentBranch = $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false });
|
||||||
|
log.info(`Current branch: ${currentBranch}`);
|
||||||
|
|
||||||
// validate PR title and body for secrets
|
// validate PR title and body for secrets
|
||||||
if (containsSecrets(title) || containsSecrets(body)) {
|
if (containsSecrets(title) || containsSecrets(body)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"PR creation blocked: secrets detected in PR title or body. " +
|
"PR creation blocked: secrets detected in PR title or body. " +
|
||||||
"Please remove any sensitive information (API keys, tokens, passwords) before creating a PR."
|
"Please remove any sensitive information (API keys, tokens, passwords) before creating a PR."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// validate all changes that would be in the PR (from base to HEAD)
|
// validate all changes that would be in the PR (from base to HEAD)
|
||||||
const diff = $("git", ["diff", `origin/${base}...HEAD`], { log: false });
|
const diff = $("git", ["diff", `origin/${base}...HEAD`], { log: false });
|
||||||
if (containsSecrets(diff)) {
|
if (containsSecrets(diff)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"PR creation blocked: secrets detected in changes. " +
|
"PR creation blocked: secrets detected in changes. " +
|
||||||
"Please remove any sensitive information (API keys, tokens, passwords) before creating a PR."
|
"Please remove any sensitive information (API keys, tokens, passwords) before creating a PR."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await ctx.octokit.rest.pulls.create({
|
const result = await ctx.octokit.rest.pulls.create({
|
||||||
owner: ctx.owner,
|
owner: ctx.owner,
|
||||||
repo: ctx.name,
|
repo: ctx.name,
|
||||||
title: title,
|
title: title,
|
||||||
body: body,
|
body: body,
|
||||||
head: currentBranch,
|
head: currentBranch,
|
||||||
base: base,
|
base: base,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
pullRequestId: result.data.id,
|
pullRequestId: result.data.id,
|
||||||
number: result.data.number,
|
number: result.data.number,
|
||||||
url: result.data.html_url,
|
url: result.data.html_url,
|
||||||
title: result.data.title,
|
title: result.data.title,
|
||||||
head: result.data.head.ref,
|
head: result.data.head.ref,
|
||||||
base: result.data.base.ref,
|
base: result.data.base.ref,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
+31
-46
@@ -1,55 +1,40 @@
|
|||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
import { log } from "../utils/cli.ts";
|
import type { Context } from "../main.ts";
|
||||||
import { $ } from "../utils/shell.ts";
|
import { execute, tool } from "./shared.ts";
|
||||||
import { contextualize, tool } from "./shared.ts";
|
|
||||||
|
|
||||||
export const PullRequestInfo = type({
|
export const PullRequestInfo = type({
|
||||||
pull_number: type.number.describe("The pull request number to fetch"),
|
pull_number: type.number.describe("The pull request number to fetch"),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const PullRequestInfoTool = tool({
|
export function PullRequestInfoTool(ctx: Context) {
|
||||||
name: "get_pull_request",
|
return tool({
|
||||||
description:
|
name: "get_pull_request",
|
||||||
"Retrieve PR information and automatically prepare the repository for review by fetching and checking out the PR branch.",
|
description:
|
||||||
parameters: PullRequestInfo,
|
"Retrieve PR information (metadata only). PR branch is already checked out during setup.",
|
||||||
execute: contextualize(async ({ pull_number }, ctx) => {
|
parameters: PullRequestInfo,
|
||||||
const pr = await ctx.octokit.rest.pulls.get({
|
execute: execute(ctx, async ({ pull_number }) => {
|
||||||
owner: ctx.owner,
|
const pr = await ctx.octokit.rest.pulls.get({
|
||||||
repo: ctx.name,
|
owner: ctx.owner,
|
||||||
pull_number,
|
repo: ctx.name,
|
||||||
});
|
pull_number,
|
||||||
|
});
|
||||||
|
|
||||||
const data = pr.data;
|
const data = pr.data;
|
||||||
|
|
||||||
const baseBranch = data.base.ref;
|
// detect fork PRs - head repo differs from base repo
|
||||||
const headBranch = data.head.ref;
|
const isFork = data.head.repo.full_name !== data.base.repo.full_name;
|
||||||
|
|
||||||
if (!baseBranch) {
|
return {
|
||||||
throw new Error(`Base branch not found for PR #${pull_number}`);
|
number: data.number,
|
||||||
}
|
url: data.html_url,
|
||||||
|
title: data.title,
|
||||||
// Automatically fetch and checkout branches for review
|
state: data.state,
|
||||||
log.info(`Fetching base branch: origin/${baseBranch}`);
|
draft: data.draft,
|
||||||
$("git", ["fetch", "origin", baseBranch, "--depth=20"]);
|
merged: data.merged,
|
||||||
|
base: data.base.ref,
|
||||||
// use GitHub's PR ref which works for both fork and non-fork PRs
|
head: data.head.ref,
|
||||||
// refs/pull/{number}/head always points to the PR head commit
|
isFork,
|
||||||
log.info(`Fetching PR #${pull_number} using refs/pull/${pull_number}/head`);
|
};
|
||||||
$("git", ["fetch", "origin", `refs/pull/${pull_number}/head`]);
|
}),
|
||||||
|
});
|
||||||
log.info(`Checking out PR branch: ${headBranch}`);
|
}
|
||||||
// check out a local branch from FETCH_HEAD (the PR ref we just fetched)
|
|
||||||
$("git", ["checkout", "-B", headBranch, "FETCH_HEAD"]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
number: data.number,
|
|
||||||
url: data.html_url,
|
|
||||||
title: data.title,
|
|
||||||
state: data.state,
|
|
||||||
draft: data.draft,
|
|
||||||
merged: data.merged,
|
|
||||||
base: baseBranch,
|
|
||||||
head: headBranch,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|||||||
+75
-72
@@ -1,8 +1,9 @@
|
|||||||
import type { RestEndpointMethodTypes } from "@octokit/rest";
|
import type { RestEndpointMethodTypes } from "@octokit/rest";
|
||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
|
import type { Context } from "../main.ts";
|
||||||
import { buildPullfrogFooter } from "../utils/buildPullfrogFooter.ts";
|
import { buildPullfrogFooter } from "../utils/buildPullfrogFooter.ts";
|
||||||
import { deleteProgressComment } from "./comment.ts";
|
import { deleteProgressComment } from "./comment.ts";
|
||||||
import { contextualize, tool } from "./shared.ts";
|
import { execute, tool } from "./shared.ts";
|
||||||
|
|
||||||
export const Review = type({
|
export const Review = type({
|
||||||
pull_number: type.number.describe("The pull request number to review"),
|
pull_number: type.number.describe("The pull request number to review"),
|
||||||
@@ -39,81 +40,83 @@ export const Review = type({
|
|||||||
.optional(),
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ReviewTool = tool({
|
export function ReviewTool(ctx: Context) {
|
||||||
name: "submit_pull_request_review",
|
return tool({
|
||||||
description:
|
name: "submit_pull_request_review",
|
||||||
"Submit a review for an existing pull request. " +
|
description:
|
||||||
"IMPORTANT: 95%+ of feedback should be in 'comments' array with file paths and line numbers. " +
|
"Submit a review for an existing pull request. " +
|
||||||
"Only use 'body' for a 1-2 sentence summary with urgency and critical callouts.",
|
"IMPORTANT: 95%+ of feedback should be in 'comments' array with file paths and line numbers. " +
|
||||||
parameters: Review,
|
"Only use 'body' for a 1-2 sentence summary with urgency and critical callouts.",
|
||||||
execute: contextualize(async ({ pull_number, body, commit_id, comments = [] }, ctx) => {
|
parameters: Review,
|
||||||
// get the PR to determine the head commit if commit_id not provided
|
execute: execute(ctx, async ({ pull_number, body, commit_id, comments = [] }) => {
|
||||||
const pr = await ctx.octokit.rest.pulls.get({
|
// get the PR to determine the head commit if commit_id not provided
|
||||||
owner: ctx.owner,
|
const pr = await ctx.octokit.rest.pulls.get({
|
||||||
repo: ctx.name,
|
owner: ctx.owner,
|
||||||
pull_number,
|
repo: ctx.name,
|
||||||
});
|
pull_number,
|
||||||
|
|
||||||
// compose the request
|
|
||||||
const params: RestEndpointMethodTypes["pulls"]["createReview"]["parameters"] = {
|
|
||||||
owner: ctx.owner,
|
|
||||||
repo: ctx.name,
|
|
||||||
pull_number,
|
|
||||||
event: "COMMENT",
|
|
||||||
};
|
|
||||||
if (body) params.body = body;
|
|
||||||
if (commit_id) {
|
|
||||||
params.commit_id = commit_id;
|
|
||||||
} else {
|
|
||||||
params.commit_id = pr.data.head.sha;
|
|
||||||
}
|
|
||||||
if (comments.length > 0) {
|
|
||||||
type ReviewComment = (typeof params.comments & {})[number];
|
|
||||||
// convert comments to the format expected by GitHub API
|
|
||||||
params.comments = comments.map((comment) => {
|
|
||||||
const reviewComment: ReviewComment = {
|
|
||||||
...comment,
|
|
||||||
};
|
|
||||||
reviewComment.side = comment.side || "RIGHT";
|
|
||||||
if (comment.start_line) {
|
|
||||||
reviewComment.start_line = comment.start_line;
|
|
||||||
reviewComment.start_side = comment.side || "RIGHT";
|
|
||||||
}
|
|
||||||
return reviewComment;
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
const result = await ctx.octokit.rest.pulls.createReview(params);
|
|
||||||
const reviewId = result.data.id;
|
|
||||||
|
|
||||||
// build quick links footer and update the review body
|
// compose the request
|
||||||
const apiUrl = process.env.API_URL || "https://pullfrog.com";
|
const params: RestEndpointMethodTypes["pulls"]["createReview"]["parameters"] = {
|
||||||
const fixAllUrl = `${apiUrl}/trigger/${ctx.owner}/${ctx.name}/${pull_number}?action=fix&review_id=${reviewId}`;
|
owner: ctx.owner,
|
||||||
const fixApprovedUrl = `${apiUrl}/trigger/${ctx.owner}/${ctx.name}/${pull_number}?action=fix-approved&review_id=${reviewId}`;
|
repo: ctx.name,
|
||||||
|
pull_number,
|
||||||
|
event: "COMMENT",
|
||||||
|
};
|
||||||
|
if (body) params.body = body;
|
||||||
|
if (commit_id) {
|
||||||
|
params.commit_id = commit_id;
|
||||||
|
} else {
|
||||||
|
params.commit_id = pr.data.head.sha;
|
||||||
|
}
|
||||||
|
if (comments.length > 0) {
|
||||||
|
type ReviewComment = (typeof params.comments & {})[number];
|
||||||
|
// convert comments to the format expected by GitHub API
|
||||||
|
params.comments = comments.map((comment) => {
|
||||||
|
const reviewComment: ReviewComment = {
|
||||||
|
...comment,
|
||||||
|
};
|
||||||
|
reviewComment.side = comment.side || "RIGHT";
|
||||||
|
if (comment.start_line) {
|
||||||
|
reviewComment.start_line = comment.start_line;
|
||||||
|
reviewComment.start_side = comment.side || "RIGHT";
|
||||||
|
}
|
||||||
|
return reviewComment;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const result = await ctx.octokit.rest.pulls.createReview(params);
|
||||||
|
const reviewId = result.data.id;
|
||||||
|
|
||||||
const footer = buildPullfrogFooter({
|
// build quick links footer and update the review body
|
||||||
customParts: [`[Fix all ➔](${fixAllUrl})`, `[Fix 👍s ➔](${fixApprovedUrl})`],
|
const apiUrl = process.env.API_URL || "https://pullfrog.com";
|
||||||
});
|
const fixAllUrl = `${apiUrl}/trigger/${ctx.owner}/${ctx.name}/${pull_number}?action=fix&review_id=${reviewId}`;
|
||||||
|
const fixApprovedUrl = `${apiUrl}/trigger/${ctx.owner}/${ctx.name}/${pull_number}?action=fix-approved&review_id=${reviewId}`;
|
||||||
|
|
||||||
const updatedBody = (body || "") + footer;
|
const footer = buildPullfrogFooter({
|
||||||
|
customParts: [`[Fix all ➔](${fixAllUrl})`, `[Fix 👍s ➔](${fixApprovedUrl})`],
|
||||||
|
});
|
||||||
|
|
||||||
// update the review with the footer
|
const updatedBody = (body || "") + footer;
|
||||||
await ctx.octokit.rest.pulls.updateReview({
|
|
||||||
owner: ctx.owner,
|
|
||||||
repo: ctx.name,
|
|
||||||
pull_number,
|
|
||||||
review_id: reviewId,
|
|
||||||
body: updatedBody,
|
|
||||||
});
|
|
||||||
|
|
||||||
await deleteProgressComment();
|
// update the review with the footer
|
||||||
|
await ctx.octokit.rest.pulls.updateReview({
|
||||||
|
owner: ctx.owner,
|
||||||
|
repo: ctx.name,
|
||||||
|
pull_number,
|
||||||
|
review_id: reviewId,
|
||||||
|
body: updatedBody,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
await deleteProgressComment(ctx);
|
||||||
success: true,
|
|
||||||
reviewId,
|
return {
|
||||||
html_url: result.data.html_url,
|
success: true,
|
||||||
state: result.data.state,
|
reviewId,
|
||||||
user: result.data.user?.login,
|
html_url: result.data.html_url,
|
||||||
submitted_at: result.data.submitted_at,
|
state: result.data.state,
|
||||||
};
|
user: result.data.user?.login,
|
||||||
}),
|
submitted_at: result.data.submitted_at,
|
||||||
});
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
+206
-62
@@ -1,76 +1,220 @@
|
|||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
import { contextualize, tool } from "./shared.ts";
|
import type { Context } from "../main.ts";
|
||||||
|
import { execute, tool } from "./shared.ts";
|
||||||
|
|
||||||
|
// graphql query to fetch all review threads with comments and replies
|
||||||
|
// note: diffSide and startDiffSide are on the thread, not the comment
|
||||||
|
const REVIEW_THREADS_QUERY = `
|
||||||
|
query ($owner: String!, $repo: String!, $pullNumber: Int!) {
|
||||||
|
repository(owner: $owner, name: $repo) {
|
||||||
|
pullRequest(number: $pullNumber) {
|
||||||
|
reviewThreads(first: 100) {
|
||||||
|
nodes {
|
||||||
|
diffSide
|
||||||
|
startDiffSide
|
||||||
|
comments(first: 100) {
|
||||||
|
nodes {
|
||||||
|
id
|
||||||
|
databaseId
|
||||||
|
body
|
||||||
|
path
|
||||||
|
line
|
||||||
|
startLine
|
||||||
|
url
|
||||||
|
author {
|
||||||
|
login
|
||||||
|
}
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
pullRequestReview {
|
||||||
|
databaseId
|
||||||
|
}
|
||||||
|
replyTo {
|
||||||
|
databaseId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// graphql response types (nodes arrays can contain nulls per GitHub GraphQL spec)
|
||||||
|
type GraphQLReviewComment = {
|
||||||
|
id: string;
|
||||||
|
databaseId: number;
|
||||||
|
body: string;
|
||||||
|
path: string;
|
||||||
|
line: number | null;
|
||||||
|
startLine: number | null;
|
||||||
|
url: string;
|
||||||
|
author: {
|
||||||
|
login: string;
|
||||||
|
} | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
pullRequestReview: {
|
||||||
|
databaseId: number;
|
||||||
|
} | null;
|
||||||
|
replyTo: {
|
||||||
|
databaseId: number;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GraphQLReviewThread = {
|
||||||
|
diffSide: "LEFT" | "RIGHT";
|
||||||
|
startDiffSide: "LEFT" | "RIGHT" | null;
|
||||||
|
comments: {
|
||||||
|
nodes: (GraphQLReviewComment | null)[] | null;
|
||||||
|
} | null;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
type GraphQLResponse = {
|
||||||
|
repository: {
|
||||||
|
pullRequest: {
|
||||||
|
reviewThreads: {
|
||||||
|
nodes: (GraphQLReviewThread | null)[] | null;
|
||||||
|
} | null;
|
||||||
|
} | null;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
export const GetReviewComments = type({
|
export const GetReviewComments = type({
|
||||||
pull_number: type.number.describe("The pull request number"),
|
pull_number: type.number.describe("The pull request number"),
|
||||||
review_id: type.number.describe("The review ID to get comments for"),
|
review_id: type.number.describe("The review ID to get comments for"),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const GetReviewCommentsTool = tool({
|
export function GetReviewCommentsTool(ctx: Context) {
|
||||||
name: "get_review_comments",
|
return tool({
|
||||||
description:
|
name: "get_review_comments",
|
||||||
"Get all review comments for a specific pull request review. Returns line-by-line comments that were left on specific code locations.",
|
description:
|
||||||
parameters: GetReviewComments,
|
"Get all review comments and their replies for a specific pull request review. Returns line-by-line comments that were left on specific code locations, including any threaded replies.",
|
||||||
execute: contextualize(async ({ pull_number, review_id }, ctx) => {
|
parameters: GetReviewComments,
|
||||||
const comments = await ctx.octokit.paginate(ctx.octokit.rest.pulls.listCommentsForReview, {
|
execute: execute(ctx, async ({ pull_number, review_id }) => {
|
||||||
owner: ctx.owner,
|
// fetch all review threads using graphql
|
||||||
repo: ctx.name,
|
const response = await ctx.octokit.graphql<GraphQLResponse>(REVIEW_THREADS_QUERY, {
|
||||||
pull_number,
|
owner: ctx.owner,
|
||||||
review_id,
|
repo: ctx.name,
|
||||||
});
|
pullNumber: pull_number,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
const pullRequest = response.repository?.pullRequest;
|
||||||
review_id,
|
if (!pullRequest) {
|
||||||
pull_number,
|
return {
|
||||||
comments: comments.map((comment) => ({
|
review_id,
|
||||||
id: comment.id,
|
pull_number,
|
||||||
body: comment.body,
|
comments: [],
|
||||||
path: comment.path,
|
count: 0,
|
||||||
line: comment.line,
|
};
|
||||||
side: comment.side,
|
}
|
||||||
start_line: comment.start_line,
|
|
||||||
start_side: comment.start_side,
|
const threadNodes = pullRequest.reviewThreads?.nodes;
|
||||||
user: typeof comment.user === "string" ? comment.user : comment.user?.login,
|
if (!threadNodes) {
|
||||||
created_at: comment.created_at,
|
return {
|
||||||
updated_at: comment.updated_at,
|
review_id,
|
||||||
html_url: comment.html_url,
|
pull_number,
|
||||||
in_reply_to_id: comment.in_reply_to_id,
|
comments: [],
|
||||||
diff_hunk: comment.diff_hunk,
|
count: 0,
|
||||||
reactions: comment.reactions,
|
};
|
||||||
})),
|
}
|
||||||
count: comments.length,
|
|
||||||
};
|
const allComments: {
|
||||||
}),
|
id: number;
|
||||||
});
|
body: string;
|
||||||
|
path: string;
|
||||||
|
line: number | null;
|
||||||
|
side: "LEFT" | "RIGHT";
|
||||||
|
start_line: number | null;
|
||||||
|
start_side: "LEFT" | "RIGHT" | null;
|
||||||
|
user: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
html_url: string;
|
||||||
|
in_reply_to_id: number | null;
|
||||||
|
pull_request_review_id: number | null;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
|
// iterate through all threads (filter out nulls)
|
||||||
|
for (const thread of threadNodes) {
|
||||||
|
if (!thread?.comments?.nodes) continue;
|
||||||
|
|
||||||
|
// filter out null comments
|
||||||
|
const threadComments = thread.comments.nodes.filter(
|
||||||
|
(c): c is GraphQLReviewComment => c !== null
|
||||||
|
);
|
||||||
|
if (threadComments.length === 0) continue;
|
||||||
|
|
||||||
|
// find the root comment (the one with replyTo == null) to determine thread ownership
|
||||||
|
const rootComment = threadComments.find((c) => c.replyTo === null);
|
||||||
|
if (!rootComment) continue;
|
||||||
|
|
||||||
|
// check if this thread belongs to the target review using the root comment
|
||||||
|
const threadBelongsToReview = rootComment.pullRequestReview?.databaseId === review_id;
|
||||||
|
if (!threadBelongsToReview) continue;
|
||||||
|
|
||||||
|
// include all comments from this thread (original + replies)
|
||||||
|
// side info comes from thread level, not comment level
|
||||||
|
for (const comment of threadComments) {
|
||||||
|
allComments.push({
|
||||||
|
id: comment.databaseId,
|
||||||
|
body: comment.body,
|
||||||
|
path: comment.path,
|
||||||
|
line: comment.line,
|
||||||
|
start_line: comment.startLine,
|
||||||
|
side: thread.diffSide,
|
||||||
|
start_side: thread.startDiffSide,
|
||||||
|
user: comment.author?.login ?? null,
|
||||||
|
created_at: comment.createdAt,
|
||||||
|
updated_at: comment.updatedAt,
|
||||||
|
html_url: comment.url,
|
||||||
|
in_reply_to_id: comment.replyTo?.databaseId ?? null,
|
||||||
|
pull_request_review_id: comment.pullRequestReview?.databaseId ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
review_id,
|
||||||
|
pull_number,
|
||||||
|
comments: allComments,
|
||||||
|
count: allComments.length,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export const ListPullRequestReviews = type({
|
export const ListPullRequestReviews = type({
|
||||||
pull_number: type.number.describe("The pull request number to list reviews for"),
|
pull_number: type.number.describe("The pull request number to list reviews for"),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ListPullRequestReviewsTool = tool({
|
export function ListPullRequestReviewsTool(ctx: Context) {
|
||||||
name: "list_pull_request_reviews",
|
return tool({
|
||||||
description:
|
name: "list_pull_request_reviews",
|
||||||
"List all reviews for a pull request. Returns all reviews including approvals, request changes, and comments.",
|
description:
|
||||||
parameters: ListPullRequestReviews,
|
"List all reviews for a pull request. Returns all reviews including approvals, request changes, and comments.",
|
||||||
execute: contextualize(async ({ pull_number }, ctx) => {
|
parameters: ListPullRequestReviews,
|
||||||
const reviews = await ctx.octokit.paginate(ctx.octokit.rest.pulls.listReviews, {
|
execute: execute(ctx, async ({ pull_number }) => {
|
||||||
owner: ctx.owner,
|
const reviews = await ctx.octokit.paginate(ctx.octokit.rest.pulls.listReviews, {
|
||||||
repo: ctx.name,
|
owner: ctx.owner,
|
||||||
pull_number,
|
repo: ctx.name,
|
||||||
});
|
pull_number,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
pull_number,
|
pull_number,
|
||||||
reviews: reviews.map((review) => ({
|
reviews: reviews.map((review) => ({
|
||||||
id: review.id,
|
id: review.id,
|
||||||
body: review.body,
|
body: review.body,
|
||||||
state: review.state,
|
state: review.state,
|
||||||
user: review.user?.login,
|
user: review.user?.login,
|
||||||
commit_id: review.commit_id,
|
commit_id: review.commit_id,
|
||||||
submitted_at: review.submitted_at,
|
submitted_at: review.submitted_at,
|
||||||
html_url: review.html_url,
|
html_url: review.html_url,
|
||||||
})),
|
})),
|
||||||
count: reviews.length,
|
count: reviews.length,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
+24
-21
@@ -1,5 +1,6 @@
|
|||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
import { contextualize, tool } from "./shared.ts";
|
import type { Context } from "../main.ts";
|
||||||
|
import { execute, tool } from "./shared.ts";
|
||||||
|
|
||||||
export const SelectMode = type({
|
export const SelectMode = type({
|
||||||
modeName: type.string.describe(
|
modeName: type.string.describe(
|
||||||
@@ -7,26 +8,28 @@ export const SelectMode = type({
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const SelectModeTool = tool({
|
export function SelectModeTool(ctx: Context) {
|
||||||
name: "select_mode",
|
return tool({
|
||||||
description:
|
name: "select_mode",
|
||||||
"Select a mode and get its detailed prompt instructions. Call this first to determine which mode to use based on the request.",
|
description:
|
||||||
parameters: SelectMode,
|
"Select a mode and get its detailed prompt instructions. Call this first to determine which mode to use based on the request.",
|
||||||
execute: contextualize(async ({ modeName }, ctx) => {
|
parameters: SelectMode,
|
||||||
const selectedMode = ctx.modes.find((m) => m.name.toLowerCase() === modeName.toLowerCase());
|
execute: execute(ctx, async ({ modeName }) => {
|
||||||
|
const selectedMode = ctx.modes.find((m) => m.name.toLowerCase() === modeName.toLowerCase());
|
||||||
|
|
||||||
|
if (!selectedMode) {
|
||||||
|
const availableModes = ctx.modes.map((m) => m.name).join(", ");
|
||||||
|
return {
|
||||||
|
error: `Mode "${modeName}" not found. Available modes: ${availableModes}`,
|
||||||
|
availableModes: ctx.modes.map((m) => ({ name: m.name, description: m.description })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (!selectedMode) {
|
|
||||||
const availableModes = ctx.modes.map((m) => m.name).join(", ");
|
|
||||||
return {
|
return {
|
||||||
error: `Mode "${modeName}" not found. Available modes: ${availableModes}`,
|
modeName: selectedMode.name,
|
||||||
availableModes: ctx.modes.map((m) => ({ name: m.name, description: m.description })),
|
description: selectedMode.description,
|
||||||
|
prompt: selectedMode.prompt,
|
||||||
};
|
};
|
||||||
}
|
}),
|
||||||
|
});
|
||||||
return {
|
}
|
||||||
modeName: selectedMode.name,
|
|
||||||
description: selectedMode.description,
|
|
||||||
prompt: selectedMode.prompt,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|||||||
+25
-30
@@ -3,6 +3,7 @@ import "./arkConfig.ts";
|
|||||||
import { createServer } from "node:net";
|
import { createServer } from "node:net";
|
||||||
import { FastMCP, type Tool } from "fastmcp";
|
import { FastMCP, type Tool } from "fastmcp";
|
||||||
import { ghPullfrogMcpName } from "../external.ts";
|
import { ghPullfrogMcpName } from "../external.ts";
|
||||||
|
import type { Context } from "../main.ts";
|
||||||
import { GetCheckSuiteLogsTool } from "./checkSuite.ts";
|
import { GetCheckSuiteLogsTool } from "./checkSuite.ts";
|
||||||
import {
|
import {
|
||||||
CreateCommentTool,
|
CreateCommentTool,
|
||||||
@@ -22,12 +23,7 @@ import { PullRequestInfoTool } from "./prInfo.ts";
|
|||||||
import { ReviewTool } from "./review.ts";
|
import { ReviewTool } from "./review.ts";
|
||||||
import { GetReviewCommentsTool, ListPullRequestReviewsTool } from "./reviewComments.ts";
|
import { GetReviewCommentsTool, ListPullRequestReviewsTool } from "./reviewComments.ts";
|
||||||
import { SelectModeTool } from "./selectMode.ts";
|
import { SelectModeTool } from "./selectMode.ts";
|
||||||
import {
|
import { addTools, isProgressCommentDisabled } from "./shared.ts";
|
||||||
addTools,
|
|
||||||
initMcpContext,
|
|
||||||
isProgressCommentDisabled,
|
|
||||||
type McpInitContext,
|
|
||||||
} from "./shared.ts";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find an available port starting from the given port
|
* Find an available port starting from the given port
|
||||||
@@ -62,43 +58,42 @@ async function findAvailablePort(startPort: number): Promise<number> {
|
|||||||
* Start the MCP HTTP server and return the URL and close function
|
* Start the MCP HTTP server and return the URL and close function
|
||||||
*/
|
*/
|
||||||
export async function startMcpHttpServer(
|
export async function startMcpHttpServer(
|
||||||
state: McpInitContext
|
ctx: Context
|
||||||
): Promise<{ url: string; close: () => Promise<void> }> {
|
): Promise<{ url: string; close: () => Promise<void> }> {
|
||||||
initMcpContext(state);
|
|
||||||
|
|
||||||
const server = new FastMCP({
|
const server = new FastMCP({
|
||||||
name: ghPullfrogMcpName,
|
name: ghPullfrogMcpName,
|
||||||
version: "0.0.1",
|
version: "0.0.1",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// create all tools as factories, passing ctx
|
||||||
const tools: Tool<any, any>[] = [
|
const tools: Tool<any, any>[] = [
|
||||||
SelectModeTool,
|
SelectModeTool(ctx),
|
||||||
CreateCommentTool,
|
CreateCommentTool(ctx),
|
||||||
EditCommentTool,
|
EditCommentTool(ctx),
|
||||||
ReplyToReviewCommentTool,
|
ReplyToReviewCommentTool(ctx),
|
||||||
IssueTool,
|
IssueTool(ctx),
|
||||||
IssueInfoTool,
|
IssueInfoTool(ctx),
|
||||||
GetIssueCommentsTool,
|
GetIssueCommentsTool(ctx),
|
||||||
GetIssueEventsTool,
|
GetIssueEventsTool(ctx),
|
||||||
PullRequestTool,
|
PullRequestTool(ctx),
|
||||||
ReviewTool,
|
ReviewTool(ctx),
|
||||||
PullRequestInfoTool,
|
PullRequestInfoTool(ctx),
|
||||||
GetReviewCommentsTool,
|
GetReviewCommentsTool(ctx),
|
||||||
ListPullRequestReviewsTool,
|
ListPullRequestReviewsTool(ctx),
|
||||||
GetCheckSuiteLogsTool,
|
GetCheckSuiteLogsTool(ctx),
|
||||||
DebugShellCommandTool,
|
DebugShellCommandTool,
|
||||||
AddLabelsTool,
|
AddLabelsTool(ctx),
|
||||||
CreateBranchTool,
|
CreateBranchTool(ctx),
|
||||||
CommitFilesTool,
|
CommitFilesTool(ctx),
|
||||||
PushBranchTool,
|
PushBranchTool(ctx),
|
||||||
];
|
];
|
||||||
|
|
||||||
// only include ReportProgressTool if progress comment is not disabled
|
// only include ReportProgressTool if progress comment is not disabled
|
||||||
if (!isProgressCommentDisabled()) {
|
if (!isProgressCommentDisabled(ctx)) {
|
||||||
tools.push(ReportProgressTool);
|
tools.push(ReportProgressTool(ctx));
|
||||||
}
|
}
|
||||||
|
|
||||||
addTools(server, tools);
|
addTools(ctx, server, tools);
|
||||||
|
|
||||||
const port = await findAvailablePort(3764);
|
const port = await findAvailablePort(3764);
|
||||||
const host = "127.0.0.1";
|
const host = "127.0.0.1";
|
||||||
|
|||||||
+54
-87
@@ -1,46 +1,60 @@
|
|||||||
import { Octokit } from "@octokit/rest";
|
|
||||||
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
||||||
import type { FastMCP, Tool } from "fastmcp";
|
import type { FastMCP, Tool } from "fastmcp";
|
||||||
import type { Payload } from "../external.ts";
|
import type { Context } from "../main.ts";
|
||||||
import type { Mode } from "../modes.ts";
|
|
||||||
import { getGitHubInstallationToken, parseRepoContext, type RepoContext } from "../utils/github.ts";
|
|
||||||
|
|
||||||
export interface McpInitContext {
|
|
||||||
payload: Payload;
|
|
||||||
modes: Mode[];
|
|
||||||
agentName?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mcpInitContext: McpInitContext | undefined;
|
|
||||||
|
|
||||||
// this must be called on mcp server initialization
|
|
||||||
export function initMcpContext(state: McpInitContext): void {
|
|
||||||
mcpInitContext = state;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface McpContext extends McpInitContext, RepoContext {
|
|
||||||
octokit: Octokit;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getMcpContext(): McpContext {
|
|
||||||
if (!mcpInitContext) {
|
|
||||||
throw new Error("MCP context not initialized. Call initializeMcpContext first.");
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...mcpInitContext,
|
|
||||||
...parseRepoContext(),
|
|
||||||
octokit: new Octokit({
|
|
||||||
auth: getGitHubInstallationToken(),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isProgressCommentDisabled(): boolean {
|
|
||||||
return mcpInitContext?.payload.disableProgressComment === true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const tool = <const params>(toolDef: Tool<any, StandardSchemaV1<params>>) => toolDef;
|
export const tool = <const params>(toolDef: Tool<any, StandardSchemaV1<params>>) => toolDef;
|
||||||
|
|
||||||
|
export interface ToolResult {
|
||||||
|
content: {
|
||||||
|
type: "text";
|
||||||
|
text: string;
|
||||||
|
}[];
|
||||||
|
isError?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handleToolSuccess = (data: Record<string, any>): ToolResult => {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: JSON.stringify(data, null, 2),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const handleToolError = (error: unknown): ToolResult => {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `Error: ${errorMessage}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to wrap a tool execute function with error handling.
|
||||||
|
* Captures ctx in closure so tools don't need to handle try/catch.
|
||||||
|
*/
|
||||||
|
export const execute = <T>(ctx: Context, fn: (params: T) => Promise<Record<string, any>>) => {
|
||||||
|
return async (params: T): Promise<ToolResult> => {
|
||||||
|
try {
|
||||||
|
const result = await fn(params);
|
||||||
|
return handleToolSuccess(result);
|
||||||
|
} catch (error) {
|
||||||
|
return handleToolError(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isProgressCommentDisabled(ctx: Context): boolean {
|
||||||
|
return ctx.payload.disableProgressComment === true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sanitize JSON schema to remove problematic fields that Gemini CLI/API can't handle
|
* Sanitize JSON schema to remove problematic fields that Gemini CLI/API can't handle
|
||||||
* - Removes $schema field (causes "no schema with key or ref" errors)
|
* - Removes $schema field (causes "no schema with key or ref" errors)
|
||||||
@@ -162,11 +176,10 @@ function sanitizeTool<T extends Tool<any, any>>(tool: T): T {
|
|||||||
} as T;
|
} as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const addTools = (server: FastMCP, tools: Tool<any, any>[]) => {
|
export const addTools = (ctx: Context, server: FastMCP, tools: Tool<any, any>[]) => {
|
||||||
// sanitize schemas for gemini agent and opencode (when using Google API)
|
// sanitize schemas for gemini agent and opencode (when using Google API)
|
||||||
// both have issues with draft-2020-12 schemas and any_of enum constructs
|
// both have issues with draft-2020-12 schemas and any_of enum constructs
|
||||||
const shouldSanitize =
|
const shouldSanitize = ctx.agentName === "gemini" || ctx.agentName === "opencode";
|
||||||
mcpInitContext?.agentName === "gemini" || mcpInitContext?.agentName === "opencode";
|
|
||||||
|
|
||||||
for (const tool of tools) {
|
for (const tool of tools) {
|
||||||
const processedTool = shouldSanitize ? sanitizeTool(tool) : tool;
|
const processedTool = shouldSanitize ? sanitizeTool(tool) : tool;
|
||||||
@@ -174,49 +187,3 @@ export const addTools = (server: FastMCP, tools: Tool<any, any>[]) => {
|
|||||||
}
|
}
|
||||||
return server;
|
return server;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const contextualize = <T>(
|
|
||||||
executor: (params: T, ctx: McpContext) => Promise<Record<string, any>>
|
|
||||||
) => {
|
|
||||||
return async (params: T): Promise<ToolResult> => {
|
|
||||||
try {
|
|
||||||
const ctx = getMcpContext();
|
|
||||||
const result = await executor(params, ctx);
|
|
||||||
return handleToolSuccess(result);
|
|
||||||
} catch (error) {
|
|
||||||
return handleToolError(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface ToolResult {
|
|
||||||
content: {
|
|
||||||
type: "text";
|
|
||||||
text: string;
|
|
||||||
}[];
|
|
||||||
isError?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleToolSuccess = (data: Record<string, any>): ToolResult => {
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: JSON.stringify(data, null, 2),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleToolError = (error: unknown): ToolResult => {
|
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: `Error: ${errorMessage}`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
isError: true,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ ${
|
|||||||
prompt: `Follow these steps:
|
prompt: `Follow these steps:
|
||||||
1. Get PR info with ${ghPullfrogMcpName}/get_pull_request (this automatically prepares the repository by fetching and checking out the PR branch)
|
1. Get PR info with ${ghPullfrogMcpName}/get_pull_request (this automatically prepares the repository by fetching and checking out the PR branch)
|
||||||
|
|
||||||
2. View diff: \`git diff origin/<base>...origin/<head>\` (replace <base> and <head> with 'base' and 'head' from PR info)
|
2. **IMPORTANT**: After calling ${ghPullfrogMcpName}/get_pull_request, the PR branch is already checked out locally. View diff using: \`git diff origin/<base>...HEAD\` (replace <base> with 'base' from PR info). Do NOT use \`origin/<head>\` - the branch is checked out locally, not as a remote tracking branch.
|
||||||
|
|
||||||
3. Read files from the checked-out PR branch to understand the implementation. Always use **relative paths** from repo root (e.g., \`src/index.ts\`), never absolute paths.
|
3. Read files from the checked-out PR branch to understand the implementation. Always use **relative paths** from repo root (e.g., \`src/index.ts\`), never absolute paths.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@pullfrog/action",
|
"name": "@pullfrog/action",
|
||||||
"version": "0.0.136",
|
"version": "0.0.140",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
"index.js",
|
"index.js",
|
||||||
|
|||||||
@@ -48,16 +48,9 @@ export async function reportErrorToComment({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// if no comment ID available, try using reportProgress (requires MCP context)
|
// if no comment ID available, can't update comment
|
||||||
if (!commentId) {
|
if (!commentId) {
|
||||||
try {
|
return;
|
||||||
const { reportProgress } = await import("../mcp/comment.ts");
|
|
||||||
await reportProgress({ body: formattedError });
|
|
||||||
return;
|
|
||||||
} catch {
|
|
||||||
// MCP context not available, can't create/update comment
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// update comment directly using GitHub API
|
// update comment directly using GitHub API
|
||||||
|
|||||||
+50
-81
@@ -1,8 +1,7 @@
|
|||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
import { existsSync, rmSync } from "node:fs";
|
import { existsSync, rmSync } from "node:fs";
|
||||||
import type { Payload } from "../external.ts";
|
import type { Context } from "../main.ts";
|
||||||
import { log } from "./cli.ts";
|
import { log } from "./cli.ts";
|
||||||
import type { RepoContext } from "./github.ts";
|
|
||||||
import { $ } from "./shell.ts";
|
import { $ } from "./shell.ts";
|
||||||
|
|
||||||
export interface SetupOptions {
|
export interface SetupOptions {
|
||||||
@@ -63,20 +62,23 @@ export function setupGitConfig(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SetupGitResult = {
|
||||||
|
pushRemote: string;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Setup git authentication using GitHub installation token
|
* Unified git setup: configures authentication and checks out PR branch if applicable.
|
||||||
* Always uses the installation token, scoped to the current repo only
|
* Uses gh as credential helper so git push works with any remote (including forks).
|
||||||
|
* For PR events, gh pr checkout sets up proper remote tracking.
|
||||||
|
* Returns the remote to push to (detected from branch tracking after checkout).
|
||||||
*/
|
*/
|
||||||
export function setupGitAuth(ctx: {
|
export function setupGit(ctx: Context): SetupGitResult {
|
||||||
githubInstallationToken: string;
|
const { githubInstallationToken, payload } = ctx;
|
||||||
repoContext: RepoContext;
|
|
||||||
}): void {
|
|
||||||
const repoDir = process.cwd();
|
const repoDir = process.cwd();
|
||||||
|
|
||||||
log.info("🔐 Setting up git authentication...");
|
log.info("🔧 Setting up git configuration...");
|
||||||
|
|
||||||
// Remove existing git auth headers that actions/checkout might have set
|
// remove existing git auth headers that actions/checkout might have set
|
||||||
// Use --local to scope to this repo only
|
|
||||||
try {
|
try {
|
||||||
execSync("git config --local --unset-all http.https://github.com/.extraheader", {
|
execSync("git config --local --unset-all http.https://github.com/.extraheader", {
|
||||||
cwd: repoDir,
|
cwd: repoDir,
|
||||||
@@ -87,79 +89,46 @@ export function setupGitAuth(ctx: {
|
|||||||
log.debug("No existing authentication headers to remove");
|
log.debug("No existing authentication headers to remove");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update remote URL to embed the token
|
// set up gh as credential helper - this makes git use GH_TOKEN for any remote
|
||||||
// This is scoped to the repo's .git/config, not the user's global config
|
$("git", ["config", "--local", "credential.helper", ""], { cwd: repoDir });
|
||||||
const remoteUrl = `https://x-access-token:${ctx.githubInstallationToken}@github.com/${ctx.repoContext.owner}/${ctx.repoContext.name}.git`;
|
$("git", ["config", "--local", "--add", "credential.helper", "!gh auth git-credential"], {
|
||||||
$("git", ["remote", "set-url", "origin", remoteUrl], { cwd: repoDir });
|
cwd: repoDir,
|
||||||
log.info("✓ Updated remote URL with authentication token (scoped to repo)");
|
env: { GH_TOKEN: githubInstallationToken },
|
||||||
|
});
|
||||||
|
log.info("✓ Configured gh as credential helper");
|
||||||
|
|
||||||
|
// non-PR events: stay on default branch, push to origin
|
||||||
|
if (payload.event.is_pr !== true || !payload.event.issue_number) {
|
||||||
|
log.debug("Not a PR event, staying on default branch");
|
||||||
|
return { pushRemote: "origin" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkout PR branch - gh pr checkout handles fork remotes and tracking automatically
|
||||||
|
const prNumber = payload.event.issue_number;
|
||||||
|
log.info(`🌿 Checking out PR #${prNumber}...`);
|
||||||
|
$("gh", ["pr", "checkout", prNumber.toString()], {
|
||||||
|
cwd: repoDir,
|
||||||
|
env: { GH_TOKEN: githubInstallationToken },
|
||||||
|
});
|
||||||
|
log.info(`✓ Successfully checked out PR #${prNumber}`);
|
||||||
|
|
||||||
|
// detect the push remote from branch tracking (set by gh pr checkout)
|
||||||
|
const pushRemote = detectPushRemote();
|
||||||
|
if (pushRemote !== "origin") {
|
||||||
|
log.info(`🍴 Fork PR detected, will push to remote: ${pushRemote}`);
|
||||||
|
}
|
||||||
|
return { pushRemote };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function detectPushRemote(): string {
|
||||||
* Setup git branch based on payload event context
|
|
||||||
* Automatically checks out the appropriate branch before agent execution
|
|
||||||
*/
|
|
||||||
export function setupGitBranch(payload: Payload): void {
|
|
||||||
const branch = payload.event.branch;
|
|
||||||
const repoDir = process.cwd();
|
|
||||||
|
|
||||||
if (!branch) {
|
|
||||||
log.debug("No branch specified in payload, using default branch");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info(`🌿 Setting up git branch: ${branch}`);
|
|
||||||
|
|
||||||
// if event has issue_number and branch, it's likely a PR - try PR ref first (works for forks)
|
|
||||||
const issueNumber = "issue_number" in payload.event ? payload.event.issue_number : undefined;
|
|
||||||
const isLikelyPR = issueNumber !== undefined && branch !== undefined;
|
|
||||||
|
|
||||||
if (isLikelyPR) {
|
|
||||||
try {
|
|
||||||
// use GitHub's PR ref which works for both fork and non-fork PRs
|
|
||||||
log.debug(`Fetching PR #${issueNumber} using refs/pull/${issueNumber}/head`);
|
|
||||||
execSync(`git fetch origin refs/pull/${issueNumber}/head`, {
|
|
||||||
cwd: repoDir,
|
|
||||||
stdio: "pipe",
|
|
||||||
});
|
|
||||||
|
|
||||||
// checkout from FETCH_HEAD (the PR ref we just fetched)
|
|
||||||
log.debug(`Checking out branch: ${branch}`);
|
|
||||||
execSync(`git checkout -B ${branch} FETCH_HEAD`, {
|
|
||||||
cwd: repoDir,
|
|
||||||
stdio: "pipe",
|
|
||||||
});
|
|
||||||
|
|
||||||
log.info(`✓ Successfully checked out PR branch: ${branch}`);
|
|
||||||
return;
|
|
||||||
} catch (error) {
|
|
||||||
// if PR ref fetch fails, fall back to branch name fetch
|
|
||||||
log.debug(
|
|
||||||
`PR ref fetch failed, falling back to branch name fetch: ${error instanceof Error ? error.message : String(error)}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// fallback: fetch by branch name (for non-PR contexts or if PR ref fetch failed)
|
|
||||||
try {
|
try {
|
||||||
log.debug(`Fetching branch from origin: ${branch}`);
|
const branch = $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false });
|
||||||
execSync(`git fetch origin ${branch}`, {
|
const upstream = $("git", ["rev-parse", "--abbrev-ref", `${branch}@{upstream}`], {
|
||||||
cwd: repoDir,
|
log: false,
|
||||||
stdio: "pipe",
|
|
||||||
});
|
});
|
||||||
|
// upstream is like "remote/branch", extract remote name
|
||||||
// checkout the branch, creating local tracking branch
|
return upstream.split("/")[0];
|
||||||
log.debug(`Checking out branch: ${branch}`);
|
} catch {
|
||||||
execSync(`git checkout -B ${branch} origin/${branch}`, {
|
return "origin";
|
||||||
cwd: repoDir,
|
|
||||||
stdio: "pipe",
|
|
||||||
});
|
|
||||||
|
|
||||||
log.info(`✓ Successfully checked out branch: ${branch}`);
|
|
||||||
} catch (error) {
|
|
||||||
// if git operations fail, log warning but don't fail the action
|
|
||||||
// the agent might still be able to work with the default branch
|
|
||||||
log.warning(
|
|
||||||
`Failed to checkout branch ${branch}: ${error instanceof Error ? error.message : String(error)}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ interface ShellOptions {
|
|||||||
| "ucs2"
|
| "ucs2"
|
||||||
| "utf16le";
|
| "utf16le";
|
||||||
log?: boolean;
|
log?: boolean;
|
||||||
|
env?: Record<string, string>;
|
||||||
onError?: (result: { status: number; stdout: string; stderr: string }) => void;
|
onError?: (result: { status: number; stdout: string; stderr: string }) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@ export function $(cmd: string, args: string[], options?: ShellOptions): string {
|
|||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
encoding,
|
encoding,
|
||||||
cwd: options?.cwd,
|
cwd: options?.cwd,
|
||||||
|
env: options?.env ? { ...process.env, ...options.env } : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const stdout = result.stdout ?? "";
|
const stdout = result.stdout ?? "";
|
||||||
|
|||||||
Reference in New Issue
Block a user