Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 17ad3bd0e7 | |||
| 25896559f0 | |||
| 5353d80388 | |||
| 2dea842981 | |||
| 04c695038f | |||
| e9a585ce47 | |||
| 7407b6cbc5 | |||
| 507efb0c25 | |||
| 6d572f3ce8 | |||
| 73139a169c | |||
| d5bec7499b |
@@ -45360,14 +45360,14 @@ var require_retry_agent = __commonJS({
|
|||||||
this.#options = options;
|
this.#options = options;
|
||||||
}
|
}
|
||||||
dispatch(opts, handler2) {
|
dispatch(opts, handler2) {
|
||||||
const retry = new RetryHandler({
|
const retry2 = new RetryHandler({
|
||||||
...opts,
|
...opts,
|
||||||
retryOptions: this.#options
|
retryOptions: this.#options
|
||||||
}, {
|
}, {
|
||||||
dispatch: this.#agent.dispatch.bind(this.#agent),
|
dispatch: this.#agent.dispatch.bind(this.#agent),
|
||||||
handler: handler2
|
handler: handler2
|
||||||
});
|
});
|
||||||
return this.#agent.dispatch(opts, retry);
|
return this.#agent.dispatch(opts, retry2);
|
||||||
}
|
}
|
||||||
close() {
|
close() {
|
||||||
return this.#agent.close();
|
return this.#agent.close();
|
||||||
@@ -83328,7 +83328,7 @@ function query({
|
|||||||
// package.json
|
// package.json
|
||||||
var package_default = {
|
var package_default = {
|
||||||
name: "@pullfrog/action",
|
name: "@pullfrog/action",
|
||||||
version: "0.0.148",
|
version: "0.0.153",
|
||||||
type: "module",
|
type: "module",
|
||||||
files: [
|
files: [
|
||||||
"index.js",
|
"index.js",
|
||||||
@@ -83653,12 +83653,9 @@ var log = {
|
|||||||
* Log tool call information to console with formatted output
|
* Log tool call information to console with formatted output
|
||||||
*/
|
*/
|
||||||
toolCall: ({ toolName, input }) => {
|
toolCall: ({ toolName, input }) => {
|
||||||
let output = `\u2192 ${toolName}
|
|
||||||
`;
|
|
||||||
const inputFormatted = formatJsonValue(input);
|
const inputFormatted = formatJsonValue(input);
|
||||||
if (inputFormatted !== "{}") {
|
const timestamp = isDebugEnabled() ? ` [${(/* @__PURE__ */ new Date()).toISOString()}]` : "";
|
||||||
output += formatIndentedField("input", inputFormatted);
|
const output = inputFormatted !== "{}" ? `\u2192 ${toolName}(${inputFormatted})${timestamp}` : `\u2192 ${toolName}()${timestamp}`;
|
||||||
}
|
|
||||||
log.info(output.trimEnd());
|
log.info(output.trimEnd());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -83666,20 +83663,6 @@ function formatJsonValue(value2) {
|
|||||||
const compact = JSON.stringify(value2);
|
const compact = JSON.stringify(value2);
|
||||||
return compact.length > 80 || compact.includes("\n") ? JSON.stringify(value2, null, 2) : compact;
|
return compact.length > 80 || compact.includes("\n") ? JSON.stringify(value2, null, 2) : compact;
|
||||||
}
|
}
|
||||||
function formatIndentedField(label, content) {
|
|
||||||
if (!content.includes("\n")) {
|
|
||||||
return ` ${label}: ${content}
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
const lines = content.split("\n");
|
|
||||||
let formatted = ` ${label}: ${lines[0]}
|
|
||||||
`;
|
|
||||||
for (let i = 1; i < lines.length; i++) {
|
|
||||||
formatted += ` ${lines[i]}
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
return formatted;
|
|
||||||
}
|
|
||||||
|
|
||||||
// agents/instructions.ts
|
// agents/instructions.ts
|
||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
@@ -92645,39 +92628,38 @@ ${disableProgressComment ? "" : `
|
|||||||
name: "Review",
|
name: "Review",
|
||||||
description: "Review code, PRs, or implementations; provide feedback or suggestions; identify issues; or check code quality, style, and correctness",
|
description: "Review code, PRs, or implementations; provide feedback or suggestions; identify issues; or check code quality, style, and correctness",
|
||||||
prompt: `Follow these steps:
|
prompt: `Follow these steps:
|
||||||
1. Checkout the PR using ${ghPullfrogMcpName}/checkout_pr with the PR number. This fetches the PR branch and base branch, preparing the repo for review.
|
|
||||||
|
|
||||||
2. **IMPORTANT**: After calling ${ghPullfrogMcpName}/checkout_pr, the PR branch is checked out locally. View diff using: \`git diff origin/<base>..HEAD\` (replace <base> with 'base' from checkout_pr result, e.g., \`git diff origin/main..HEAD\`). Use two dots (..) not three dots (...) for reliable diffs. Do NOT use \`origin/<head>\` - the branch is checked out locally, not as a remote tracking branch. This works for both same-repo and fork PRs.
|
1. **CHECKOUT** - Use ${ghPullfrogMcpName}/checkout_pr with the PR number. This fetches the PR branch and returns the diff. Use this diff for your review - it shows exactly what's in the PR.
|
||||||
|
|
||||||
3. Start review session using ${ghPullfrogMcpName}/start_review. This creates a scratchpad file at a temp path (e.g., \`/tmp/pullfrog-review-abc123.md\`) and returns a session ID. The scratchpad file header contains the session ID for reference. Use this file as free-form space to gather your thoughts before adding comments.
|
2. **UNDERSTAND CONTEXT** - Read the modified files to understand the changes in context. Don't just look at the diff - understand how the changes affect the overall codebase.
|
||||||
|
|
||||||
4. **ANALYZE** - Use the scratchpad to gather your thoughts:
|
3. **ANALYZE** - Think through:
|
||||||
- Summarize what changes this PR makes
|
- What does this PR change? Summarize in 1-2 sentences.
|
||||||
- Evaluate the approach - is it sound? If not, **stop here** and leave feedback on the approach. Don't waste time on implementation details if the approach is wrong.
|
- Is the approach sound? If not, focus on the approach first. Don't waste time on implementation details if the approach is wrong.
|
||||||
- If approach is sound, analyze implementation - consider potential issues per file
|
- What bugs, edge cases, or security issues exist?
|
||||||
- Identify bugs, security issues, edge cases
|
|
||||||
|
|
||||||
5. **SELF-CRITIQUE** - Before adding comments, review your scratchpad:
|
4. **DRAFT** - Mentally list all comments you would make. For each:
|
||||||
- Remove nitpicks unless explicitly requested. Think documentation, JSDoc/docstrings, useless comments (compliments)
|
- Note the file path (relative to repo root, e.g., \`packages/core/src/utils.ts\`)
|
||||||
- Your level of nitpickiness should be proportional to the current state of the codebase. Try to guess how much the user will care about a specific critique.
|
- Note the line number from the diff (use NEW file line number - shown after \`+\` in hunk headers like \`@@ -10,5 +12,8 @@\` means new file starts at line 12)
|
||||||
|
- Draft the comment text
|
||||||
|
|
||||||
6. Add inline review comments one-by-one using ${ghPullfrogMcpName}/add_review_comment
|
5. **SELF-CRITIQUE** - Before submitting, review your draft:
|
||||||
- Use **relative paths** from repo root (e.g., \`packages/core/src/utils.ts\`)
|
- Remove nitpicks unless the user explicitly told you to be nitpicky. Nitpicks may include: requesting documentation/docstrings/JSDoc, commenting on minor code/whitespace formatting, commenting on small changes unrelated to the main changes.
|
||||||
- Use the NEW file line number from the diff (shown after \`+\` in hunk headers like \`@@ -10,5 +12,8 @@\` means new file starts at line 12)
|
- Ensure each comment is actionable - would the author know exactly what to do?
|
||||||
- Only comment on lines that appear in the diff. GitHub will reject comments on unchanged lines.
|
- Would the codebase maintainer care about this feedback?
|
||||||
- For issues appearing in multiple places, comment on the FIRST occurrence and reference others (e.g., "also at lines X, Y")
|
- If you have approach-level concerns, consider whether implementation-level comments are worth including
|
||||||
|
- For issues appearing in multiple places, keep only the FIRST occurrence and reference others (e.g., "also at lines X, Y")
|
||||||
|
|
||||||
7. Submit the review using ${ghPullfrogMcpName}/submit_review
|
6. **SUBMIT** - Use ${ghPullfrogMcpName}/create_pull_request_review with:
|
||||||
- The "body" field is ONLY for: (1) a 1-3 sentence high-level overview, (2) urgency level (e.g., "minor suggestions" vs "blocking issues"), (3) critical security callouts (e.g., API key exposure)
|
- \`comments\`: Array of all inline comments with file paths and line numbers
|
||||||
|
- \`body\`: 1-3 sentence summary with urgency level (e.g., "minor suggestions" vs "blocking issues") and any critical callouts (e.g., API key exposure)
|
||||||
|
|
||||||
**GENERAL GUIDANCE**
|
**CRITICAL RULES**
|
||||||
|
- 95%+ of review content should be in inline \`comments\` array, not the \`body\`
|
||||||
- Do not leave any comments that are not potentially actionable. Do not leave complimentary comments just to be nice.
|
- Only comment on lines that appear in the diff - GitHub will reject comments on unchanged lines
|
||||||
- Do not nitpick unless instructed explicitly to do so by the user's additional instructions. This includes: requesting documentation/docstrings/JSDoc.
|
- Do not leave complimentary comments just to be nice
|
||||||
- **CRITICAL: Prioritize per-line feedback over summary text.**
|
- Do not leave comments that are not actionable
|
||||||
- All specific feedback MUST go in inline review comments with file paths and line numbers from the diff
|
`
|
||||||
- The vast majority of review content should be in inline review comments; the body should be brief and only summarize the urgency of the review and any cross-cutting concerns.
|
|
||||||
`
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Plan",
|
name: "Plan",
|
||||||
@@ -92886,16 +92868,44 @@ import { pipeline } from "node:stream/promises";
|
|||||||
// utils/github.ts
|
// utils/github.ts
|
||||||
var core2 = __toESM(require_core(), 1);
|
var core2 = __toESM(require_core(), 1);
|
||||||
import { createSign } from "node:crypto";
|
import { createSign } from "node:crypto";
|
||||||
|
|
||||||
|
// utils/retry.ts
|
||||||
|
var defaultShouldRetry = (error41) => {
|
||||||
|
if (!(error41 instanceof Error)) return false;
|
||||||
|
return error41.name === "AbortError" || error41.message.includes("fetch failed") || error41.message.includes("ECONNRESET") || error41.message.includes("ETIMEDOUT");
|
||||||
|
};
|
||||||
|
async function retry(fn2, options = {}) {
|
||||||
|
const maxAttempts = options.maxAttempts ?? 3;
|
||||||
|
const delayMs = options.delayMs ?? 1e3;
|
||||||
|
const shouldRetry = options.shouldRetry ?? defaultShouldRetry;
|
||||||
|
const label = options.label ?? "operation";
|
||||||
|
let lastError;
|
||||||
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||||
|
try {
|
||||||
|
return await fn2();
|
||||||
|
} catch (error41) {
|
||||||
|
lastError = error41;
|
||||||
|
if (attempt === maxAttempts || !shouldRetry(error41)) {
|
||||||
|
throw error41;
|
||||||
|
}
|
||||||
|
const delay2 = delayMs * attempt;
|
||||||
|
log.warning(`\xBB ${label} failed (attempt ${attempt}/${maxAttempts}), retrying in ${delay2}ms...`);
|
||||||
|
await new Promise((resolve2) => setTimeout(resolve2, delay2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
|
||||||
|
// utils/github.ts
|
||||||
function isGitHubActionsEnvironment() {
|
function isGitHubActionsEnvironment() {
|
||||||
return Boolean(process.env.GITHUB_ACTIONS);
|
return Boolean(process.env.GITHUB_ACTIONS);
|
||||||
}
|
}
|
||||||
async function acquireTokenViaOIDC() {
|
async function acquireTokenViaOIDC() {
|
||||||
log.debug("\xBB generating OIDC token...");
|
log.info("\xBB generating OIDC token...");
|
||||||
const oidcToken = await core2.getIDToken("pullfrog-api");
|
const oidcToken = await core2.getIDToken("pullfrog-api");
|
||||||
log.debug("\xBB OIDC token generated successfully");
|
|
||||||
const apiUrl = process.env.API_URL || "https://pullfrog.com";
|
const apiUrl = process.env.API_URL || "https://pullfrog.com";
|
||||||
log.debug("\xBB exchanging OIDC token for installation token...");
|
log.info("\xBB exchanging OIDC token for installation token...");
|
||||||
const timeoutMs = 5e3;
|
const timeoutMs = 3e4;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
try {
|
try {
|
||||||
@@ -92912,7 +92922,7 @@ async function acquireTokenViaOIDC() {
|
|||||||
throw new Error(`Token exchange failed: ${tokenResponse.status} ${tokenResponse.statusText}`);
|
throw new Error(`Token exchange failed: ${tokenResponse.status} ${tokenResponse.statusText}`);
|
||||||
}
|
}
|
||||||
const tokenData = await tokenResponse.json();
|
const tokenData = await tokenResponse.json();
|
||||||
log.debug(`\xBB installation token obtained for ${tokenData.repository || "all repositories"}`);
|
log.info(`\xBB installation token obtained for ${tokenData.repository || "all repositories"}`);
|
||||||
return tokenData.token;
|
return tokenData.token;
|
||||||
} catch (error41) {
|
} catch (error41) {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
@@ -93019,7 +93029,7 @@ async function acquireTokenViaGitHubApp() {
|
|||||||
}
|
}
|
||||||
async function acquireNewToken() {
|
async function acquireNewToken() {
|
||||||
if (isGitHubActionsEnvironment()) {
|
if (isGitHubActionsEnvironment()) {
|
||||||
return await acquireTokenViaOIDC();
|
return await retry(() => acquireTokenViaOIDC(), { label: "token exchange" });
|
||||||
} else {
|
} else {
|
||||||
return await acquireTokenViaGitHubApp();
|
return await acquireTokenViaGitHubApp();
|
||||||
}
|
}
|
||||||
@@ -98177,7 +98187,7 @@ var DEFAULT_REPO_SETTINGS = {
|
|||||||
};
|
};
|
||||||
async function fetchWorkflowRunInfo(runId) {
|
async function fetchWorkflowRunInfo(runId) {
|
||||||
const apiUrl = process.env.API_URL || "https://pullfrog.com";
|
const apiUrl = process.env.API_URL || "https://pullfrog.com";
|
||||||
const timeoutMs = 5e3;
|
const timeoutMs = 3e4;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
try {
|
try {
|
||||||
@@ -98208,7 +98218,7 @@ async function fetchRepoSettings({
|
|||||||
}
|
}
|
||||||
async function getRepoSettings(token, repoContext) {
|
async function getRepoSettings(token, repoContext) {
|
||||||
const apiUrl = process.env.API_URL || "https://pullfrog.com";
|
const apiUrl = process.env.API_URL || "https://pullfrog.com";
|
||||||
const timeoutMs = 5e3;
|
const timeoutMs = 3e4;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
try {
|
try {
|
||||||
@@ -98576,11 +98586,18 @@ async function deleteProgressComment(ctx) {
|
|||||||
if (!existingCommentId) {
|
if (!existingCommentId) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
await ctx.octokit.rest.issues.deleteComment({
|
try {
|
||||||
owner: ctx.owner,
|
await ctx.octokit.rest.issues.deleteComment({
|
||||||
repo: ctx.name,
|
owner: ctx.owner,
|
||||||
comment_id: existingCommentId
|
repo: ctx.name,
|
||||||
});
|
comment_id: existingCommentId
|
||||||
|
});
|
||||||
|
} catch (error41) {
|
||||||
|
if (error41 instanceof Error && error41.message.includes("Not Found")) {
|
||||||
|
} else {
|
||||||
|
throw error41;
|
||||||
|
}
|
||||||
|
}
|
||||||
progressCommentId = null;
|
progressCommentId = null;
|
||||||
progressCommentIdInitialized = true;
|
progressCommentIdInitialized = true;
|
||||||
progressCommentWasUpdated = true;
|
progressCommentWasUpdated = true;
|
||||||
@@ -122954,6 +122971,10 @@ var FastMCP = class extends FastMCPEventEmitter {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// mcp/checkout.ts
|
||||||
|
import { writeFileSync as writeFileSync4 } from "node:fs";
|
||||||
|
import { join as join8 } from "node:path";
|
||||||
|
|
||||||
// utils/shell.ts
|
// utils/shell.ts
|
||||||
import { spawnSync as spawnSync4 } from "node:child_process";
|
import { spawnSync as spawnSync4 } from "node:child_process";
|
||||||
function $(cmd, args3, options) {
|
function $(cmd, args3, options) {
|
||||||
@@ -123015,17 +123036,18 @@ async function checkoutPrBranch(params) {
|
|||||||
const isFork = headRepo.full_name !== pr.data.base.repo.full_name;
|
const isFork = headRepo.full_name !== pr.data.base.repo.full_name;
|
||||||
const baseBranch = pr.data.base.ref;
|
const baseBranch = pr.data.base.ref;
|
||||||
const headBranch = pr.data.head.ref;
|
const headBranch = pr.data.head.ref;
|
||||||
const currentBranch = $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false }).trim();
|
const localBranch = `pr-${pullNumber}`;
|
||||||
const alreadyOnBranch = currentBranch === headBranch;
|
const currentSha = $("git", ["rev-parse", "HEAD"], { log: false }).trim();
|
||||||
|
const alreadyOnBranch = currentSha === pr.data.head.sha;
|
||||||
if (alreadyOnBranch) {
|
if (alreadyOnBranch) {
|
||||||
log.debug(`already on PR branch ${headBranch}, skipping checkout`);
|
log.debug(`already on PR branch ${localBranch}, skipping checkout`);
|
||||||
} else {
|
} else {
|
||||||
log.debug(`\u{1F4E5} fetching base branch (${baseBranch})...`);
|
log.debug(`\u{1F4E5} fetching base branch (${baseBranch})...`);
|
||||||
$("git", ["fetch", "--no-tags", "origin", baseBranch]);
|
$("git", ["fetch", "--no-tags", "origin", baseBranch]);
|
||||||
$("git", ["checkout", "-B", baseBranch, `origin/${baseBranch}`]);
|
$("git", ["checkout", "-B", baseBranch, `origin/${baseBranch}`]);
|
||||||
log.debug(`\u{1F33F} fetching PR #${pullNumber} (${headBranch})...`);
|
log.debug(`\u{1F33F} fetching PR #${pullNumber} (${localBranch})...`);
|
||||||
$("git", ["fetch", "--no-tags", "origin", `pull/${pullNumber}/head:${headBranch}`]);
|
$("git", ["fetch", "--no-tags", "origin", `pull/${pullNumber}/head:${localBranch}`]);
|
||||||
$("git", ["checkout", headBranch]);
|
$("git", ["checkout", localBranch]);
|
||||||
log.debug(`\u2713 checked out PR #${pullNumber}`);
|
log.debug(`\u2713 checked out PR #${pullNumber}`);
|
||||||
}
|
}
|
||||||
if (alreadyOnBranch) {
|
if (alreadyOnBranch) {
|
||||||
@@ -123036,28 +123058,30 @@ async function checkoutPrBranch(params) {
|
|||||||
const remoteName = `pr-${pullNumber}`;
|
const remoteName = `pr-${pullNumber}`;
|
||||||
const forkUrl = `https://x-access-token:${token}@github.com/${headRepo.full_name}.git`;
|
const forkUrl = `https://x-access-token:${token}@github.com/${headRepo.full_name}.git`;
|
||||||
try {
|
try {
|
||||||
$("git", ["remote", "add", remoteName, forkUrl]);
|
$("git", ["remote", "add", remoteName, forkUrl], { log: false });
|
||||||
log.debug(`\u{1F4CC} added remote '${remoteName}' for fork ${headRepo.full_name}`);
|
log.debug(`\u{1F4CC} added remote '${remoteName}' for fork ${headRepo.full_name}`);
|
||||||
} catch {
|
} catch {
|
||||||
$("git", ["remote", "set-url", remoteName, forkUrl]);
|
$("git", ["remote", "set-url", remoteName, forkUrl], { log: false });
|
||||||
log.debug(`\u{1F4CC} updated remote '${remoteName}' for fork ${headRepo.full_name}`);
|
log.debug(`\u{1F4CC} updated remote '${remoteName}' for fork ${headRepo.full_name}`);
|
||||||
}
|
}
|
||||||
$("git", ["config", `branch.${headBranch}.pushRemote`, remoteName]);
|
$("git", ["config", `branch.${localBranch}.pushRemote`, remoteName]);
|
||||||
log.debug(`\u{1F4CC} configured branch '${headBranch}' to push to '${remoteName}'`);
|
$("git", ["config", `branch.${localBranch}.merge`, `refs/heads/${headBranch}`]);
|
||||||
|
log.debug(`\u{1F4CC} configured branch '${localBranch}' to push to '${remoteName}/${headBranch}'`);
|
||||||
if (!pr.data.maintainer_can_modify) {
|
if (!pr.data.maintainer_can_modify) {
|
||||||
log.warning(
|
log.warning(
|
||||||
`\u26A0\uFE0F fork PR has maintainer_can_modify=false - push operations will fail. ask the PR author to enable "Allow edits from maintainers" or the fork may be owned by an organization.`
|
`\u26A0\uFE0F fork PR has maintainer_can_modify=false - push operations will fail. ask the PR author to enable "Allow edits from maintainers" or the fork may be owned by an organization.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
$("git", ["config", `branch.${headBranch}.pushRemote`, "origin"]);
|
$("git", ["config", `branch.${localBranch}.pushRemote`, "origin"]);
|
||||||
|
$("git", ["config", `branch.${localBranch}.merge`, `refs/heads/${headBranch}`]);
|
||||||
}
|
}
|
||||||
return { prNumber: pullNumber };
|
return { prNumber: pullNumber };
|
||||||
}
|
}
|
||||||
function CheckoutPrTool(ctx) {
|
function CheckoutPrTool(ctx) {
|
||||||
return tool({
|
return tool({
|
||||||
name: "checkout_pr",
|
name: "checkout_pr",
|
||||||
description: "Checkout a pull request branch locally. This fetches the PR branch and sets up push configuration for fork PRs. Use this when you need to work on an existing PR.",
|
description: "Checkout a pull request branch locally. This fetches the PR branch and sets up push configuration for fork PRs. The PR diff is written to a file (diffPath) for grep access. For small diffs, it's also returned inline.",
|
||||||
parameters: CheckoutPr,
|
parameters: CheckoutPr,
|
||||||
execute: execute(async ({ pull_number }) => {
|
execute: execute(async ({ pull_number }) => {
|
||||||
const result = await checkoutPrBranch({
|
const result = await checkoutPrBranch({
|
||||||
@@ -123077,6 +123101,24 @@ function CheckoutPrTool(ctx) {
|
|||||||
if (!headRepo) {
|
if (!headRepo) {
|
||||||
throw new Error(`PR #${pull_number} source repository was deleted`);
|
throw new Error(`PR #${pull_number} source repository was deleted`);
|
||||||
}
|
}
|
||||||
|
const diffResponse = await ctx.octokit.rest.pulls.get({
|
||||||
|
owner: ctx.owner,
|
||||||
|
repo: ctx.name,
|
||||||
|
pull_number,
|
||||||
|
mediaType: { format: "diff" }
|
||||||
|
});
|
||||||
|
const diffContent = diffResponse.data;
|
||||||
|
const tempDir = process.env.PULLFROG_TEMP_DIR;
|
||||||
|
if (!tempDir) {
|
||||||
|
throw new Error(
|
||||||
|
"PULLFROG_TEMP_DIR not set - checkout_pr must run in pullfrog action context"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const diffPath = join8(tempDir, `pr-${pull_number}.diff`);
|
||||||
|
writeFileSync4(diffPath, diffContent);
|
||||||
|
log.debug(`wrote diff to ${diffPath} (${diffContent.length} bytes)`);
|
||||||
|
const MAX_INLINE_DIFF_SIZE = 50 * 1024;
|
||||||
|
const diff = diffContent.length <= MAX_INLINE_DIFF_SIZE ? diffContent : `Diff is ${(diffContent.length / 1024).toFixed(0)}KB - use grep or read from ${diffPath}`;
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
number: pr.data.number,
|
number: pr.data.number,
|
||||||
@@ -123086,7 +123128,9 @@ function CheckoutPrTool(ctx) {
|
|||||||
isFork: headRepo.full_name !== pr.data.base.repo.full_name,
|
isFork: headRepo.full_name !== pr.data.base.repo.full_name,
|
||||||
maintainerCanModify: pr.data.maintainer_can_modify,
|
maintainerCanModify: pr.data.maintainer_can_modify,
|
||||||
url: pr.data.html_url,
|
url: pr.data.html_url,
|
||||||
headRepo: headRepo.full_name
|
headRepo: headRepo.full_name,
|
||||||
|
diff,
|
||||||
|
diffPath
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
@@ -123195,7 +123239,7 @@ function DebugShellCommandTool(_ctx) {
|
|||||||
|
|
||||||
// prep/installNodeDependencies.ts
|
// prep/installNodeDependencies.ts
|
||||||
import { existsSync as existsSync3, readFileSync as readFileSync2 } from "node:fs";
|
import { existsSync as existsSync3, readFileSync as readFileSync2 } from "node:fs";
|
||||||
import { join as join8 } from "node:path";
|
import { join as join9 } from "node:path";
|
||||||
|
|
||||||
// node_modules/.pnpm/package-manager-detector@1.6.0/node_modules/package-manager-detector/dist/commands.mjs
|
// node_modules/.pnpm/package-manager-detector@1.6.0/node_modules/package-manager-detector/dist/commands.mjs
|
||||||
function dashDashArg(agent2, agentCommand) {
|
function dashDashArg(agent2, agentCommand) {
|
||||||
@@ -123507,7 +123551,7 @@ async function isCommandAvailable(command) {
|
|||||||
return result.exitCode === 0;
|
return result.exitCode === 0;
|
||||||
}
|
}
|
||||||
function getPackageManagerFromPackageJson() {
|
function getPackageManagerFromPackageJson() {
|
||||||
const packageJsonPath = join8(process.cwd(), "package.json");
|
const packageJsonPath = join9(process.cwd(), "package.json");
|
||||||
try {
|
try {
|
||||||
const content = readFileSync2(packageJsonPath, "utf-8");
|
const content = readFileSync2(packageJsonPath, "utf-8");
|
||||||
const pkg = JSON.parse(content);
|
const pkg = JSON.parse(content);
|
||||||
@@ -123538,7 +123582,7 @@ async function installPackageManager(name, installSpec) {
|
|||||||
return result.stderr || `failed to install ${name}`;
|
return result.stderr || `failed to install ${name}`;
|
||||||
}
|
}
|
||||||
if (name === "deno") {
|
if (name === "deno") {
|
||||||
const denoPath = join8(process.env.HOME || "", ".deno", "bin");
|
const denoPath = join9(process.env.HOME || "", ".deno", "bin");
|
||||||
process.env.PATH = `${denoPath}:${process.env.PATH}`;
|
process.env.PATH = `${denoPath}:${process.env.PATH}`;
|
||||||
}
|
}
|
||||||
log.info(`\u2705 installed ${name}`);
|
log.info(`\u2705 installed ${name}`);
|
||||||
@@ -123547,7 +123591,7 @@ async function installPackageManager(name, installSpec) {
|
|||||||
var installNodeDependencies = {
|
var installNodeDependencies = {
|
||||||
name: "installNodeDependencies",
|
name: "installNodeDependencies",
|
||||||
shouldRun: () => {
|
shouldRun: () => {
|
||||||
const packageJsonPath = join8(process.cwd(), "package.json");
|
const packageJsonPath = join9(process.cwd(), "package.json");
|
||||||
return existsSync3(packageJsonPath);
|
return existsSync3(packageJsonPath);
|
||||||
},
|
},
|
||||||
run: async () => {
|
run: async () => {
|
||||||
@@ -123610,7 +123654,7 @@ var installNodeDependencies = {
|
|||||||
|
|
||||||
// prep/installPythonDependencies.ts
|
// prep/installPythonDependencies.ts
|
||||||
import { existsSync as existsSync4 } from "node:fs";
|
import { existsSync as existsSync4 } from "node:fs";
|
||||||
import { join as join9 } from "node:path";
|
import { join as join10 } from "node:path";
|
||||||
var PYTHON_CONFIGS = [
|
var PYTHON_CONFIGS = [
|
||||||
{
|
{
|
||||||
file: "requirements.txt",
|
file: "requirements.txt",
|
||||||
@@ -123682,11 +123726,11 @@ var installPythonDependencies = {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const cwd2 = process.cwd();
|
const cwd2 = process.cwd();
|
||||||
return PYTHON_CONFIGS.some((config2) => existsSync4(join9(cwd2, config2.file)));
|
return PYTHON_CONFIGS.some((config2) => existsSync4(join10(cwd2, config2.file)));
|
||||||
},
|
},
|
||||||
run: async () => {
|
run: async () => {
|
||||||
const cwd2 = process.cwd();
|
const cwd2 = process.cwd();
|
||||||
const config2 = PYTHON_CONFIGS.find((c) => existsSync4(join9(cwd2, c.file)));
|
const config2 = PYTHON_CONFIGS.find((c) => existsSync4(join10(cwd2, c.file)));
|
||||||
if (!config2) {
|
if (!config2) {
|
||||||
return {
|
return {
|
||||||
language: "python",
|
language: "python",
|
||||||
@@ -124095,8 +124139,15 @@ function PushBranchTool(_ctx) {
|
|||||||
remote = $("git", ["config", `branch.${branch}.pushRemote`], { log: false }).trim();
|
remote = $("git", ["config", `branch.${branch}.pushRemote`], { log: false }).trim();
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
const args3 = force ? ["push", "--force", "-u", remote, branch] : ["push", "-u", remote, branch];
|
let remoteBranch = branch;
|
||||||
log.debug(`pushing branch ${branch} to ${remote}`);
|
try {
|
||||||
|
const mergeRef = $("git", ["config", `branch.${branch}.merge`], { log: false }).trim();
|
||||||
|
remoteBranch = mergeRef.replace("refs/heads/", "");
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
const refspec = branch === remoteBranch ? branch : `${branch}:${remoteBranch}`;
|
||||||
|
const args3 = force ? ["push", "--force", "-u", remote, refspec] : ["push", "-u", remote, refspec];
|
||||||
|
log.debug(`pushing ${branch} to ${remote}/${remoteBranch}`);
|
||||||
if (force) {
|
if (force) {
|
||||||
log.warning(`force pushing - this will overwrite remote history`);
|
log.warning(`force pushing - this will overwrite remote history`);
|
||||||
}
|
}
|
||||||
@@ -124104,9 +124155,10 @@ function PushBranchTool(_ctx) {
|
|||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
branch,
|
branch,
|
||||||
|
remoteBranch,
|
||||||
remote,
|
remote,
|
||||||
force,
|
force,
|
||||||
message: `successfully pushed branch ${branch}`
|
message: `successfully pushed ${branch} to ${remote}/${remoteBranch}`
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
@@ -124422,187 +124474,7 @@ function PullRequestInfoTool(ctx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// mcp/review.ts
|
// mcp/review.ts
|
||||||
import { randomBytes } from "node:crypto";
|
var CreatePullRequestReview = type({
|
||||||
import { writeFileSync as writeFileSync4 } from "node:fs";
|
|
||||||
import { join as join10 } from "node:path";
|
|
||||||
var ADD_PULL_REQUEST_REVIEW_THREAD = `
|
|
||||||
mutation AddPullRequestReviewThread($pullRequestReviewId: ID!, $path: String!, $line: Int!, $body: String!, $side: DiffSide) {
|
|
||||||
addPullRequestReviewThread(input: {
|
|
||||||
pullRequestReviewId: $pullRequestReviewId,
|
|
||||||
path: $path,
|
|
||||||
line: $line,
|
|
||||||
body: $body,
|
|
||||||
side: $side
|
|
||||||
}) {
|
|
||||||
thread {
|
|
||||||
id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
async function findPendingReview(ctx, pull_number) {
|
|
||||||
const reviews = await ctx.octokit.rest.pulls.listReviews({
|
|
||||||
owner: ctx.owner,
|
|
||||||
repo: ctx.name,
|
|
||||||
pull_number,
|
|
||||||
per_page: 100
|
|
||||||
});
|
|
||||||
const pendingReview = reviews.data.find((r) => r.state === "PENDING");
|
|
||||||
if (pendingReview) {
|
|
||||||
return { id: pendingReview.id, node_id: pendingReview.node_id };
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
var StartReview = type({
|
|
||||||
pull_number: type.number.describe("The pull request number to review")
|
|
||||||
});
|
|
||||||
function StartReviewTool(ctx) {
|
|
||||||
return tool({
|
|
||||||
name: "start_review",
|
|
||||||
description: "Start a new review session for a pull request. Creates a scratchpad file for gathering thoughts and a pending review on GitHub. Must be called before add_review_comment.",
|
|
||||||
parameters: StartReview,
|
|
||||||
execute: execute(async ({ pull_number }) => {
|
|
||||||
if (ctx.toolState.review) {
|
|
||||||
throw new Error(
|
|
||||||
`Review session already in progress. Call submit_review first to finish it.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const pr = await ctx.octokit.rest.pulls.get({
|
|
||||||
owner: ctx.owner,
|
|
||||||
repo: ctx.name,
|
|
||||||
pull_number
|
|
||||||
});
|
|
||||||
let reviewId;
|
|
||||||
let reviewNodeId;
|
|
||||||
log.debug(`creating pending review for PR #${pull_number}...`);
|
|
||||||
try {
|
|
||||||
const result = await ctx.octokit.rest.pulls.createReview({
|
|
||||||
owner: ctx.owner,
|
|
||||||
repo: ctx.name,
|
|
||||||
pull_number,
|
|
||||||
commit_id: pr.data.head.sha
|
|
||||||
// no 'event' = PENDING review
|
|
||||||
});
|
|
||||||
reviewId = result.data.id;
|
|
||||||
reviewNodeId = result.data.node_id;
|
|
||||||
log.debug(`created new pending review: id=${reviewId}`);
|
|
||||||
} catch (error41) {
|
|
||||||
const errorMessage = error41 instanceof Error ? error41.message : String(error41);
|
|
||||||
log.debug(`createReview failed: ${errorMessage}`);
|
|
||||||
if (errorMessage.includes("pending review")) {
|
|
||||||
log.debug(`pending review already exists, fetching existing review...`);
|
|
||||||
const existing = await findPendingReview(ctx, pull_number);
|
|
||||||
if (!existing) {
|
|
||||||
throw new Error(
|
|
||||||
"GitHub says a pending review exists but we couldn't find it. Try again or check the PR reviews."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
reviewId = existing.id;
|
|
||||||
reviewNodeId = existing.node_id;
|
|
||||||
log.debug(`reusing existing pending review: id=${reviewId}`);
|
|
||||||
} else {
|
|
||||||
throw error41;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const scratchpadId = randomBytes(4).toString("hex");
|
|
||||||
const scratchpadPath = join10(ctx.sharedTempDir, `pullfrog-review-${scratchpadId}.md`);
|
|
||||||
const scratchpadContent = `# Review ${scratchpadId}
|
|
||||||
|
|
||||||
`;
|
|
||||||
writeFileSync4(scratchpadPath, scratchpadContent);
|
|
||||||
ctx.toolState.prNumber = pull_number;
|
|
||||||
ctx.toolState.review = {
|
|
||||||
nodeId: reviewNodeId,
|
|
||||||
id: reviewId
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
reviewId: scratchpadId,
|
|
||||||
scratchpadPath,
|
|
||||||
message: `Review session started. Use the scratchpad file to gather your thoughts, then call add_review_comment for each comment.`
|
|
||||||
};
|
|
||||||
})
|
|
||||||
});
|
|
||||||
}
|
|
||||||
var AddReviewComment = type({
|
|
||||||
path: type.string.describe("The file path to comment on (relative to repo root)"),
|
|
||||||
line: type.number.describe(
|
|
||||||
"The line number in the file (use line numbers from the diff - the NEW file line number)"
|
|
||||||
),
|
|
||||||
body: type.string.describe("The comment text for this specific line"),
|
|
||||||
side: type.enumerated("LEFT", "RIGHT").describe("Side of the diff: LEFT (old code) or RIGHT (new code). Defaults to RIGHT.").optional()
|
|
||||||
});
|
|
||||||
function AddReviewCommentTool(ctx) {
|
|
||||||
return tool({
|
|
||||||
name: "add_review_comment",
|
|
||||||
description: "Add a comment to the current review session. Must call start_review first. Comments are stored in draft state until submit_review is called.",
|
|
||||||
parameters: AddReviewComment,
|
|
||||||
execute: execute(async ({ path: path4, line, body, side }) => {
|
|
||||||
if (!ctx.toolState.review) {
|
|
||||||
throw new Error("No review session started. Call start_review first.");
|
|
||||||
}
|
|
||||||
await ctx.octokit.graphql(
|
|
||||||
ADD_PULL_REQUEST_REVIEW_THREAD,
|
|
||||||
{
|
|
||||||
pullRequestReviewId: ctx.toolState.review.nodeId,
|
|
||||||
path: path4,
|
|
||||||
line,
|
|
||||||
body,
|
|
||||||
side: side || "RIGHT"
|
|
||||||
}
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: `Comment added to ${path4}:${line}`
|
|
||||||
};
|
|
||||||
})
|
|
||||||
});
|
|
||||||
}
|
|
||||||
var SubmitReview = type({
|
|
||||||
body: type.string.describe(
|
|
||||||
"Review body text. Typically 1-3 sentences with high-level overview and urgency level. Action links are auto-appended."
|
|
||||||
).optional()
|
|
||||||
});
|
|
||||||
function SubmitReviewTool(ctx) {
|
|
||||||
return tool({
|
|
||||||
name: "submit_review",
|
|
||||||
description: "Submit the current review session. All comments added via add_review_comment will be published. Must call start_review first.",
|
|
||||||
parameters: SubmitReview,
|
|
||||||
execute: execute(async ({ body }) => {
|
|
||||||
if (!ctx.toolState.review) {
|
|
||||||
throw new Error("No review session started. Call start_review first.");
|
|
||||||
}
|
|
||||||
if (ctx.toolState.prNumber === void 0) {
|
|
||||||
throw new Error("No PR context. Call checkout_pr or start_review first.");
|
|
||||||
}
|
|
||||||
const reviewId = ctx.toolState.review.id;
|
|
||||||
const apiUrl = process.env.API_URL || "https://pullfrog.com";
|
|
||||||
const fixAllUrl = `${apiUrl}/trigger/${ctx.owner}/${ctx.name}/${ctx.toolState.prNumber}?action=fix&review_id=${reviewId}`;
|
|
||||||
const fixApprovedUrl = `${apiUrl}/trigger/${ctx.owner}/${ctx.name}/${ctx.toolState.prNumber}?action=fix-approved&review_id=${reviewId}`;
|
|
||||||
const footer = buildPullfrogFooter({
|
|
||||||
workflowRun: { owner: ctx.owner, repo: ctx.name, runId: ctx.runId, jobId: ctx.jobId },
|
|
||||||
customParts: [`[Fix all \u2794](${fixAllUrl})`, `[Fix \u{1F44D}s \u2794](${fixApprovedUrl})`]
|
|
||||||
});
|
|
||||||
const bodyWithFooter = (body || "") + footer;
|
|
||||||
const result = await ctx.octokit.rest.pulls.submitReview({
|
|
||||||
owner: ctx.owner,
|
|
||||||
repo: ctx.name,
|
|
||||||
pull_number: ctx.toolState.prNumber,
|
|
||||||
review_id: reviewId,
|
|
||||||
event: "COMMENT",
|
|
||||||
body: bodyWithFooter
|
|
||||||
});
|
|
||||||
delete ctx.toolState.review;
|
|
||||||
await deleteProgressComment(ctx);
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
reviewId: result.data.id,
|
|
||||||
html_url: result.data.html_url,
|
|
||||||
state: result.data.state
|
|
||||||
};
|
|
||||||
})
|
|
||||||
});
|
|
||||||
}
|
|
||||||
var Review = type({
|
|
||||||
pull_number: type.number.describe("The pull request number to review"),
|
pull_number: type.number.describe("The pull request number to review"),
|
||||||
body: type.string.describe(
|
body: type.string.describe(
|
||||||
"1-2 sentence high-level summary ONLY. Include urgency level and critical callouts (e.g., API key leak). ALL specific feedback MUST go in 'comments' array instead."
|
"1-2 sentence high-level summary ONLY. Include urgency level and critical callouts (e.g., API key leak). ALL specific feedback MUST go in 'comments' array instead."
|
||||||
@@ -124621,11 +124493,80 @@ var Review = type({
|
|||||||
),
|
),
|
||||||
start_line: type.number.describe("Start line for multi-line comments (optional, for commenting on ranges)").optional()
|
start_line: type.number.describe("Start line for multi-line comments (optional, for commenting on ranges)").optional()
|
||||||
}).array().describe(
|
}).array().describe(
|
||||||
// FORK PR NOTE: use HEAD not origin/<head> - for fork PRs, origin/<head> doesn't exist
|
// FORK PR NOTE: checkout_pr returns the diff via GitHub API - use that for line numbers
|
||||||
// because the head branch is in a different repo (the fork). HEAD is the locally checked out PR branch.
|
"PRIMARY location for ALL feedback. 95%+ of review content should be here. Use the diff returned from checkout_pr to find correct line numbers (RIGHT side for new code, LEFT for old)."
|
||||||
"PRIMARY location for ALL feedback. 95%+ of review content should be here. Use 'git diff origin/<base>..HEAD' to find correct line numbers (RIGHT side for new code, LEFT for old). Works for both fork and same-repo PRs."
|
|
||||||
).optional()
|
).optional()
|
||||||
});
|
});
|
||||||
|
function CreatePullRequestReviewTool(ctx) {
|
||||||
|
return tool({
|
||||||
|
name: "create_pull_request_review",
|
||||||
|
description: "Submit a review for an existing pull request. IMPORTANT: 95%+ of feedback should be in 'comments' array with file paths and line numbers. Only use 'body' for a 1-2 sentence summary with urgency and critical callouts.",
|
||||||
|
parameters: CreatePullRequestReview,
|
||||||
|
execute: execute(async ({ pull_number, body, commit_id, comments = [] }) => {
|
||||||
|
ctx.toolState.prNumber = pull_number;
|
||||||
|
const pr = await ctx.octokit.rest.pulls.get({
|
||||||
|
owner: ctx.owner,
|
||||||
|
repo: ctx.name,
|
||||||
|
pull_number
|
||||||
|
});
|
||||||
|
const params = {
|
||||||
|
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) {
|
||||||
|
params.comments = comments.map((comment) => {
|
||||||
|
const 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);
|
||||||
|
log.debug(`createReview response: ${JSON.stringify(result.data)}`);
|
||||||
|
if (!result.data.id) {
|
||||||
|
throw new Error(`createReview returned invalid data: ${JSON.stringify(result.data)}`);
|
||||||
|
}
|
||||||
|
const reviewId = result.data.id;
|
||||||
|
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 footer = buildPullfrogFooter({
|
||||||
|
workflowRun: { owner: ctx.owner, repo: ctx.name, runId: ctx.runId, jobId: ctx.jobId },
|
||||||
|
customParts: [`[Fix all \u2794](${fixAllUrl})`, `[Fix \u{1F44D}s \u2794](${fixApprovedUrl})`]
|
||||||
|
});
|
||||||
|
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(ctx);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
reviewId,
|
||||||
|
html_url: result.data.html_url,
|
||||||
|
state: result.data.state,
|
||||||
|
user: result.data.user?.login,
|
||||||
|
submitted_at: result.data.submitted_at
|
||||||
|
};
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// mcp/reviewComments.ts
|
// mcp/reviewComments.ts
|
||||||
var REVIEW_THREADS_QUERY = `
|
var REVIEW_THREADS_QUERY = `
|
||||||
@@ -124837,10 +124778,7 @@ async function startMcpHttpServer(ctx) {
|
|||||||
GetIssueCommentsTool(ctx),
|
GetIssueCommentsTool(ctx),
|
||||||
GetIssueEventsTool(ctx),
|
GetIssueEventsTool(ctx),
|
||||||
PullRequestTool(ctx),
|
PullRequestTool(ctx),
|
||||||
// ReviewTool(ctx),
|
CreatePullRequestReviewTool(ctx),
|
||||||
StartReviewTool(ctx),
|
|
||||||
AddReviewCommentTool(ctx),
|
|
||||||
SubmitReviewTool(ctx),
|
|
||||||
PullRequestInfoTool(ctx),
|
PullRequestInfoTool(ctx),
|
||||||
CheckoutPrTool(ctx),
|
CheckoutPrTool(ctx),
|
||||||
GetReviewCommentsTool(ctx),
|
GetReviewCommentsTool(ctx),
|
||||||
|
|||||||
+56
-14
@@ -1,3 +1,5 @@
|
|||||||
|
import { writeFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
import type { Octokit } from "@octokit/rest";
|
import type { Octokit } from "@octokit/rest";
|
||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
import type { ToolContext } from "../main.ts";
|
import type { ToolContext } from "../main.ts";
|
||||||
@@ -19,6 +21,8 @@ export type CheckoutPrResult = {
|
|||||||
maintainerCanModify: boolean;
|
maintainerCanModify: boolean;
|
||||||
url: string;
|
url: string;
|
||||||
headRepo: string;
|
headRepo: string;
|
||||||
|
diff: string;
|
||||||
|
diffPath: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface CheckoutPrBranchParams {
|
interface CheckoutPrBranchParams {
|
||||||
@@ -60,12 +64,17 @@ export async function checkoutPrBranch(
|
|||||||
const baseBranch = pr.data.base.ref;
|
const baseBranch = pr.data.base.ref;
|
||||||
const headBranch = pr.data.head.ref;
|
const headBranch = pr.data.head.ref;
|
||||||
|
|
||||||
// check if we're already on the correct branch
|
// always use pr-{number} as local branch name for consistency
|
||||||
const currentBranch = $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false }).trim();
|
// this avoids naming conflicts and makes push config simpler
|
||||||
const alreadyOnBranch = currentBranch === headBranch;
|
const localBranch = `pr-${pullNumber}`;
|
||||||
|
|
||||||
|
// check if we're already on the correct commit (not just branch name)
|
||||||
|
// this handles fork PRs where head branch name might match base branch name
|
||||||
|
const currentSha = $("git", ["rev-parse", "HEAD"], { log: false }).trim();
|
||||||
|
const alreadyOnBranch = currentSha === pr.data.head.sha;
|
||||||
|
|
||||||
if (alreadyOnBranch) {
|
if (alreadyOnBranch) {
|
||||||
log.debug(`already on PR branch ${headBranch}, skipping checkout`);
|
log.debug(`already on PR branch ${localBranch}, skipping checkout`);
|
||||||
} else {
|
} else {
|
||||||
// fetch base branch so origin/<base> exists for diff operations
|
// fetch base branch so origin/<base> exists for diff operations
|
||||||
log.debug(`📥 fetching base branch (${baseBranch})...`);
|
log.debug(`📥 fetching base branch (${baseBranch})...`);
|
||||||
@@ -76,11 +85,11 @@ export async function checkoutPrBranch(
|
|||||||
$("git", ["checkout", "-B", baseBranch, `origin/${baseBranch}`]);
|
$("git", ["checkout", "-B", baseBranch, `origin/${baseBranch}`]);
|
||||||
|
|
||||||
// fetch PR branch using pull/{n}/head refspec (works for both fork and same-repo PRs)
|
// fetch PR branch using pull/{n}/head refspec (works for both fork and same-repo PRs)
|
||||||
log.debug(`🌿 fetching PR #${pullNumber} (${headBranch})...`);
|
log.debug(`🌿 fetching PR #${pullNumber} (${localBranch})...`);
|
||||||
$("git", ["fetch", "--no-tags", "origin", `pull/${pullNumber}/head:${headBranch}`]);
|
$("git", ["fetch", "--no-tags", "origin", `pull/${pullNumber}/head:${localBranch}`]);
|
||||||
|
|
||||||
// checkout the branch
|
// checkout the branch
|
||||||
$("git", ["checkout", headBranch]);
|
$("git", ["checkout", localBranch]);
|
||||||
log.debug(`✓ checked out PR #${pullNumber}`);
|
log.debug(`✓ checked out PR #${pullNumber}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,19 +107,21 @@ export async function checkoutPrBranch(
|
|||||||
const remoteName = `pr-${pullNumber}`;
|
const remoteName = `pr-${pullNumber}`;
|
||||||
const forkUrl = `https://x-access-token:${token}@github.com/${headRepo.full_name}.git`;
|
const forkUrl = `https://x-access-token:${token}@github.com/${headRepo.full_name}.git`;
|
||||||
|
|
||||||
// add fork as a named remote (ignore error if already exists)
|
// add fork as a named remote (suppress logging to avoid "error: remote already exists" spam)
|
||||||
try {
|
try {
|
||||||
$("git", ["remote", "add", remoteName, forkUrl]);
|
$("git", ["remote", "add", remoteName, forkUrl], { log: false });
|
||||||
log.debug(`📌 added remote '${remoteName}' for fork ${headRepo.full_name}`);
|
log.debug(`📌 added remote '${remoteName}' for fork ${headRepo.full_name}`);
|
||||||
} catch {
|
} catch {
|
||||||
// remote already exists, update its URL
|
// remote already exists, update its URL
|
||||||
$("git", ["remote", "set-url", remoteName, forkUrl]);
|
$("git", ["remote", "set-url", remoteName, forkUrl], { log: false });
|
||||||
log.debug(`📌 updated remote '${remoteName}' for fork ${headRepo.full_name}`);
|
log.debug(`📌 updated remote '${remoteName}' for fork ${headRepo.full_name}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// set branch push config so `git push` knows where to push
|
// set branch push config so `git push` knows where to push
|
||||||
$("git", ["config", `branch.${headBranch}.pushRemote`, remoteName]);
|
$("git", ["config", `branch.${localBranch}.pushRemote`, remoteName]);
|
||||||
log.debug(`📌 configured branch '${headBranch}' to push to '${remoteName}'`);
|
// set merge ref so git knows the remote branch name (may differ from local)
|
||||||
|
$("git", ["config", `branch.${localBranch}.merge`, `refs/heads/${headBranch}`]);
|
||||||
|
log.debug(`📌 configured branch '${localBranch}' to push to '${remoteName}/${headBranch}'`);
|
||||||
|
|
||||||
// warn if maintainer can't modify (push will likely fail)
|
// warn if maintainer can't modify (push will likely fail)
|
||||||
if (!pr.data.maintainer_can_modify) {
|
if (!pr.data.maintainer_can_modify) {
|
||||||
@@ -121,7 +132,8 @@ export async function checkoutPrBranch(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// for same-repo PRs, push to origin
|
// for same-repo PRs, push to origin
|
||||||
$("git", ["config", `branch.${headBranch}.pushRemote`, "origin"]);
|
$("git", ["config", `branch.${localBranch}.pushRemote`, "origin"]);
|
||||||
|
$("git", ["config", `branch.${localBranch}.merge`, `refs/heads/${headBranch}`]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { prNumber: pullNumber };
|
return { prNumber: pullNumber };
|
||||||
@@ -131,7 +143,8 @@ export function CheckoutPrTool(ctx: ToolContext) {
|
|||||||
return tool({
|
return tool({
|
||||||
name: "checkout_pr",
|
name: "checkout_pr",
|
||||||
description:
|
description:
|
||||||
"Checkout a pull request branch locally. This fetches the PR branch and sets up push configuration for fork PRs. Use this when you need to work on an existing PR.",
|
"Checkout a pull request branch locally. This fetches the PR branch and sets up push configuration for fork PRs. " +
|
||||||
|
"The PR diff is written to a file (diffPath) for grep access. For small diffs, it's also returned inline.",
|
||||||
parameters: CheckoutPr,
|
parameters: CheckoutPr,
|
||||||
execute: execute(async ({ pull_number }) => {
|
execute: execute(async ({ pull_number }) => {
|
||||||
const result = await checkoutPrBranch({
|
const result = await checkoutPrBranch({
|
||||||
@@ -157,6 +170,33 @@ export function CheckoutPrTool(ctx: ToolContext) {
|
|||||||
throw new Error(`PR #${pull_number} source repository was deleted`);
|
throw new Error(`PR #${pull_number} source repository was deleted`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fetch PR diff via API (authoritative source - not affected by main advancing)
|
||||||
|
const diffResponse = await ctx.octokit.rest.pulls.get({
|
||||||
|
owner: ctx.owner,
|
||||||
|
repo: ctx.name,
|
||||||
|
pull_number,
|
||||||
|
mediaType: { format: "diff" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// write diff to file for grep access
|
||||||
|
const diffContent = diffResponse.data as unknown as string;
|
||||||
|
const tempDir = process.env.PULLFROG_TEMP_DIR;
|
||||||
|
if (!tempDir) {
|
||||||
|
throw new Error(
|
||||||
|
"PULLFROG_TEMP_DIR not set - checkout_pr must run in pullfrog action context"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const diffPath = join(tempDir, `pr-${pull_number}.diff`);
|
||||||
|
writeFileSync(diffPath, diffContent);
|
||||||
|
log.debug(`wrote diff to ${diffPath} (${diffContent.length} bytes)`);
|
||||||
|
|
||||||
|
// return diff inline only if small enough
|
||||||
|
const MAX_INLINE_DIFF_SIZE = 50 * 1024; // 50KB
|
||||||
|
const diff =
|
||||||
|
diffContent.length <= MAX_INLINE_DIFF_SIZE
|
||||||
|
? diffContent
|
||||||
|
: `Diff is ${(diffContent.length / 1024).toFixed(0)}KB - use grep or read from ${diffPath}`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
number: pr.data.number,
|
number: pr.data.number,
|
||||||
@@ -167,6 +207,8 @@ export function CheckoutPrTool(ctx: ToolContext) {
|
|||||||
maintainerCanModify: pr.data.maintainer_can_modify,
|
maintainerCanModify: pr.data.maintainer_can_modify,
|
||||||
url: pr.data.html_url,
|
url: pr.data.html_url,
|
||||||
headRepo: headRepo.full_name,
|
headRepo: headRepo.full_name,
|
||||||
|
diff,
|
||||||
|
diffPath,
|
||||||
} satisfies CheckoutPrResult;
|
} satisfies CheckoutPrResult;
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
+14
-5
@@ -275,11 +275,20 @@ export async function deleteProgressComment(ctx: ToolContext): Promise<boolean>
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
await ctx.octokit.rest.issues.deleteComment({
|
try {
|
||||||
owner: ctx.owner,
|
await ctx.octokit.rest.issues.deleteComment({
|
||||||
repo: ctx.name,
|
owner: ctx.owner,
|
||||||
comment_id: existingCommentId,
|
repo: ctx.name,
|
||||||
});
|
comment_id: existingCommentId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// ignore 404 - comment already deleted
|
||||||
|
if (error instanceof Error && error.message.includes("Not Found")) {
|
||||||
|
// comment already deleted, continue
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// reset state but mark as "updated" so ensureProgressCommentUpdated doesn't try to handle it
|
// reset state but mark as "updated" so ensureProgressCommentUpdated doesn't try to handle it
|
||||||
progressCommentId = null;
|
progressCommentId = null;
|
||||||
|
|||||||
+18
-5
@@ -158,11 +158,23 @@ export function PushBranchTool(_ctx: ToolContext) {
|
|||||||
// no configured pushRemote, default to origin
|
// no configured pushRemote, default to origin
|
||||||
}
|
}
|
||||||
|
|
||||||
const args = force
|
// check if branch has a configured merge ref (remote branch name may differ from local)
|
||||||
? ["push", "--force", "-u", remote, branch]
|
let remoteBranch = branch;
|
||||||
: ["push", "-u", remote, branch];
|
try {
|
||||||
|
const mergeRef = $("git", ["config", `branch.${branch}.merge`], { log: false }).trim();
|
||||||
|
// merge ref is like "refs/heads/main", extract the branch name
|
||||||
|
remoteBranch = mergeRef.replace("refs/heads/", "");
|
||||||
|
} catch {
|
||||||
|
// no configured merge ref, use local branch name
|
||||||
|
}
|
||||||
|
|
||||||
log.debug(`pushing branch ${branch} to ${remote}`);
|
// use refspec when local and remote branch names differ
|
||||||
|
const refspec = branch === remoteBranch ? branch : `${branch}:${remoteBranch}`;
|
||||||
|
const args = force
|
||||||
|
? ["push", "--force", "-u", remote, refspec]
|
||||||
|
: ["push", "-u", remote, refspec];
|
||||||
|
|
||||||
|
log.debug(`pushing ${branch} to ${remote}/${remoteBranch}`);
|
||||||
if (force) {
|
if (force) {
|
||||||
log.warning(`force pushing - this will overwrite remote history`);
|
log.warning(`force pushing - this will overwrite remote history`);
|
||||||
}
|
}
|
||||||
@@ -171,9 +183,10 @@ export function PushBranchTool(_ctx: ToolContext) {
|
|||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
branch,
|
branch,
|
||||||
|
remoteBranch,
|
||||||
remote,
|
remote,
|
||||||
force,
|
force,
|
||||||
message: `successfully pushed branch ${branch}`,
|
message: `successfully pushed ${branch} to ${remote}/${remoteBranch}`,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
+205
-149
@@ -1,6 +1,3 @@
|
|||||||
import { randomBytes } from "node:crypto";
|
|
||||||
import { writeFileSync } from "node:fs";
|
|
||||||
import { join } from "node:path";
|
|
||||||
import type { RestEndpointMethodTypes } from "@octokit/rest";
|
import type { RestEndpointMethodTypes } from "@octokit/rest";
|
||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
import type { ToolContext } from "../main.ts";
|
import type { ToolContext } from "../main.ts";
|
||||||
@@ -9,16 +6,150 @@ import { log } from "../utils/cli.ts";
|
|||||||
import { deleteProgressComment } from "./comment.ts";
|
import { deleteProgressComment } from "./comment.ts";
|
||||||
import { execute, tool } from "./shared.ts";
|
import { execute, tool } from "./shared.ts";
|
||||||
|
|
||||||
|
// one-shot review tool
|
||||||
|
export const CreatePullRequestReview = type({
|
||||||
|
pull_number: type.number.describe("The pull request number to review"),
|
||||||
|
body: type.string
|
||||||
|
.describe(
|
||||||
|
"1-2 sentence high-level summary ONLY. Include urgency level and critical callouts (e.g., API key leak). ALL specific feedback MUST go in 'comments' array instead."
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
commit_id: type.string
|
||||||
|
.describe("Optional SHA of the commit being reviewed. Defaults to latest.")
|
||||||
|
.optional(),
|
||||||
|
comments: type({
|
||||||
|
path: type.string.describe("The file path to comment on (relative to repo root)"),
|
||||||
|
line: type.number.describe(
|
||||||
|
"The line number in the file (use line numbers from the diff - usually the RIGHT side/new code)"
|
||||||
|
),
|
||||||
|
side: type
|
||||||
|
.enumerated("LEFT", "RIGHT")
|
||||||
|
.describe(
|
||||||
|
"Side of the diff: LEFT (old code) or RIGHT (new code). Defaults to RIGHT if not provided."
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
body: type.string.describe(
|
||||||
|
"The comment text for this specific line. For issues appearing multiple times, comment on the first occurrence and reference others."
|
||||||
|
),
|
||||||
|
start_line: type.number
|
||||||
|
.describe("Start line for multi-line comments (optional, for commenting on ranges)")
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.array()
|
||||||
|
.describe(
|
||||||
|
// FORK PR NOTE: checkout_pr returns the diff via GitHub API - use that for line numbers
|
||||||
|
"PRIMARY location for ALL feedback. 95%+ of review content should be here. Use the diff returned from checkout_pr to find correct line numbers (RIGHT side for new code, LEFT for old)."
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export function CreatePullRequestReviewTool(ctx: ToolContext) {
|
||||||
|
return tool({
|
||||||
|
name: "create_pull_request_review",
|
||||||
|
description:
|
||||||
|
"Submit a review for an existing pull request. " +
|
||||||
|
"IMPORTANT: 95%+ of feedback should be in 'comments' array with file paths and line numbers. " +
|
||||||
|
"Only use 'body' for a 1-2 sentence summary with urgency and critical callouts.",
|
||||||
|
parameters: CreatePullRequestReview,
|
||||||
|
execute: execute(async ({ pull_number, body, commit_id, comments = [] }) => {
|
||||||
|
// set PR context
|
||||||
|
ctx.toolState.prNumber = pull_number;
|
||||||
|
|
||||||
|
// get the PR to determine the head commit if commit_id not provided
|
||||||
|
const pr = await ctx.octokit.rest.pulls.get({
|
||||||
|
owner: ctx.owner,
|
||||||
|
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);
|
||||||
|
log.debug(`createReview response: ${JSON.stringify(result.data)}`);
|
||||||
|
if (!result.data.id) {
|
||||||
|
throw new Error(`createReview returned invalid data: ${JSON.stringify(result.data)}`);
|
||||||
|
}
|
||||||
|
const reviewId = result.data.id;
|
||||||
|
|
||||||
|
// build quick links footer and update the review body
|
||||||
|
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 footer = buildPullfrogFooter({
|
||||||
|
workflowRun: { owner: ctx.owner, repo: ctx.name, runId: ctx.runId, jobId: ctx.jobId },
|
||||||
|
customParts: [`[Fix all ➔](${fixAllUrl})`, `[Fix 👍s ➔](${fixApprovedUrl})`],
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedBody = (body || "") + footer;
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
});
|
||||||
|
|
||||||
|
await deleteProgressComment(ctx);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
reviewId,
|
||||||
|
html_url: result.data.html_url,
|
||||||
|
state: result.data.state,
|
||||||
|
user: result.data.user?.login,
|
||||||
|
submitted_at: result.data.submitted_at,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// COMMENTED OUT: Three-step review flow (start_review, add_review_comment, submit_review)
|
||||||
|
// This approach used GraphQL to add comments to a pending review one-by-one,
|
||||||
|
// but GitHub's API was returning null for valid lines. Keeping for reference.
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/*
|
||||||
// graphql mutation to add a comment thread to a pending review
|
// graphql mutation to add a comment thread to a pending review
|
||||||
// note: REST API doesn't support adding comments to an existing pending review
|
// note: REST API doesn't support adding comments to an existing pending review
|
||||||
const ADD_PULL_REQUEST_REVIEW_THREAD = `
|
const ADD_PULL_REQUEST_REVIEW_THREAD = `
|
||||||
mutation AddPullRequestReviewThread($pullRequestReviewId: ID!, $path: String!, $line: Int!, $body: String!, $side: DiffSide) {
|
mutation AddPullRequestReviewThread($pullRequestReviewId: ID!, $path: String!, $line: Int!, $body: String!, $side: DiffSide, $subjectType: PullRequestReviewThreadSubjectType) {
|
||||||
addPullRequestReviewThread(input: {
|
addPullRequestReviewThread(input: {
|
||||||
pullRequestReviewId: $pullRequestReviewId,
|
pullRequestReviewId: $pullRequestReviewId,
|
||||||
path: $path,
|
path: $path,
|
||||||
line: $line,
|
line: $line,
|
||||||
body: $body,
|
body: $body,
|
||||||
side: $side
|
side: $side,
|
||||||
|
subjectType: $subjectType
|
||||||
}) {
|
}) {
|
||||||
thread {
|
thread {
|
||||||
id
|
id
|
||||||
@@ -65,7 +196,7 @@ export function StartReviewTool(ctx: ToolContext) {
|
|||||||
return tool({
|
return tool({
|
||||||
name: "start_review",
|
name: "start_review",
|
||||||
description:
|
description:
|
||||||
"Start a new review session for a pull request. Creates a scratchpad file for gathering thoughts and a pending review on GitHub. Must be called before add_review_comment.",
|
"Start a new review session for a pull request. Creates a pending review on GitHub. Must be called before add_review_comment.",
|
||||||
parameters: StartReview,
|
parameters: StartReview,
|
||||||
execute: execute(async ({ pull_number }) => {
|
execute: execute(async ({ pull_number }) => {
|
||||||
// check if review already started in this session
|
// check if review already started in this session
|
||||||
@@ -95,6 +226,13 @@ export function StartReviewTool(ctx: ToolContext) {
|
|||||||
commit_id: pr.data.head.sha,
|
commit_id: pr.data.head.sha,
|
||||||
// no 'event' = PENDING review
|
// no 'event' = PENDING review
|
||||||
});
|
});
|
||||||
|
log.debug(`createReview response: ${JSON.stringify(result.data)}`);
|
||||||
|
if (!result.data.id || !result.data.node_id) {
|
||||||
|
log.debug(result);
|
||||||
|
throw new Error(
|
||||||
|
`createReview returned invalid data: id=${result.data.id}, node_id=${result.data.node_id}`
|
||||||
|
);
|
||||||
|
}
|
||||||
reviewId = result.data.id;
|
reviewId = result.data.id;
|
||||||
reviewNodeId = result.data.node_id;
|
reviewNodeId = result.data.node_id;
|
||||||
log.debug(`created new pending review: id=${reviewId}`);
|
log.debug(`created new pending review: id=${reviewId}`);
|
||||||
@@ -119,12 +257,6 @@ export function StartReviewTool(ctx: ToolContext) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// create scratchpad file
|
|
||||||
const scratchpadId = randomBytes(4).toString("hex");
|
|
||||||
const scratchpadPath = join(ctx.sharedTempDir, `pullfrog-review-${scratchpadId}.md`);
|
|
||||||
const scratchpadContent = `# Review ${scratchpadId}\n\n`;
|
|
||||||
writeFileSync(scratchpadPath, scratchpadContent);
|
|
||||||
|
|
||||||
// set PR context and review state
|
// set PR context and review state
|
||||||
ctx.toolState.prNumber = pull_number;
|
ctx.toolState.prNumber = pull_number;
|
||||||
ctx.toolState.review = {
|
ctx.toolState.review = {
|
||||||
@@ -132,10 +264,10 @@ export function StartReviewTool(ctx: ToolContext) {
|
|||||||
id: reviewId,
|
id: reviewId,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
log.debug(`review session started: id=${reviewId}, nodeId=${reviewNodeId}`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
reviewId: scratchpadId,
|
message: `Review session started for PR #${pull_number}. Add comments with add_review_comment, then submit with submit_review.`,
|
||||||
scratchpadPath,
|
|
||||||
message: `Review session started. Use the scratchpad file to gather your thoughts, then call add_review_comment for each comment.`,
|
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -166,21 +298,59 @@ export function AddReviewCommentTool(ctx: ToolContext) {
|
|||||||
throw new Error("No review session started. Call start_review first.");
|
throw new Error("No review session started. Call start_review first.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// add comment thread via GraphQL (REST doesn't support adding to existing pending review)
|
const reviewNodeId = ctx.toolState.review.nodeId;
|
||||||
await ctx.octokit.graphql<AddPullRequestReviewThreadResponse>(
|
log.debug(
|
||||||
ADD_PULL_REQUEST_REVIEW_THREAD,
|
`adding review comment: reviewNodeId=${reviewNodeId}, path=${path}, line=${line}, side=${side || "RIGHT"}`
|
||||||
{
|
|
||||||
pullRequestReviewId: ctx.toolState.review.nodeId,
|
|
||||||
path,
|
|
||||||
line,
|
|
||||||
body,
|
|
||||||
side: side || "RIGHT",
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// add comment thread via GraphQL (REST doesn't support adding to existing pending review)
|
||||||
|
let result: AddPullRequestReviewThreadResponse;
|
||||||
|
try {
|
||||||
|
result = await ctx.octokit.graphql<AddPullRequestReviewThreadResponse>(
|
||||||
|
ADD_PULL_REQUEST_REVIEW_THREAD,
|
||||||
|
{
|
||||||
|
pullRequestReviewId: reviewNodeId,
|
||||||
|
path,
|
||||||
|
line,
|
||||||
|
body,
|
||||||
|
side: side || "RIGHT",
|
||||||
|
subjectType: "LINE",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
log.debug(`addPullRequestReviewThread response: ${JSON.stringify(result)}`);
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
log.debug(`addPullRequestReviewThread error: ${errorMsg}`);
|
||||||
|
throw new Error(
|
||||||
|
`Failed to add comment to ${path}:${line}. GraphQL error: ${errorMsg}. ` +
|
||||||
|
`Ensure the line is part of the diff and the path is correct.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if the mutation succeeded - null means the line is not in the diff
|
||||||
|
if (!result) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to add comment to ${path}:${line}. GraphQL returned null response.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!result.addPullRequestReviewThread) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to add comment to ${path}:${line}. addPullRequestReviewThread is null. Response: ${JSON.stringify(result)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!result.addPullRequestReviewThread.thread) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to add comment to ${path}:${line}. thread is null. The line must be part of the diff. Response: ${JSON.stringify(result)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const threadId = result.addPullRequestReviewThread.thread.id;
|
||||||
|
log.debug(`review comment added: threadId=${threadId}`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: `Comment added to ${path}:${line}`,
|
message: `Comment added to ${path}:${line}`,
|
||||||
|
threadId,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -211,6 +381,9 @@ export function SubmitReviewTool(ctx: ToolContext) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const reviewId = ctx.toolState.review.id;
|
const reviewId = ctx.toolState.review.id;
|
||||||
|
log.debug(
|
||||||
|
`submitting review: id=${reviewId}, nodeId=${ctx.toolState.review.nodeId}, prNumber=${ctx.toolState.prNumber}`
|
||||||
|
);
|
||||||
|
|
||||||
// build quick links footer
|
// build quick links footer
|
||||||
const apiUrl = process.env.API_URL || "https://pullfrog.com";
|
const apiUrl = process.env.API_URL || "https://pullfrog.com";
|
||||||
@@ -234,6 +407,12 @@ export function SubmitReviewTool(ctx: ToolContext) {
|
|||||||
body: bodyWithFooter,
|
body: bodyWithFooter,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
log.debug(`submitReview response: ${JSON.stringify(result.data)}`);
|
||||||
|
if (!result.data.id) {
|
||||||
|
throw new Error(`submitReview returned invalid data: ${JSON.stringify(result.data)}`);
|
||||||
|
}
|
||||||
|
log.debug(`review submitted: reviewId=${result.data.id}, state=${result.data.state}`);
|
||||||
|
|
||||||
// clear review state
|
// clear review state
|
||||||
delete ctx.toolState.review;
|
delete ctx.toolState.review;
|
||||||
|
|
||||||
@@ -249,127 +428,4 @@ export function SubmitReviewTool(ctx: ToolContext) {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
// legacy tool - kept for backwards compatibility
|
|
||||||
export const Review = type({
|
|
||||||
pull_number: type.number.describe("The pull request number to review"),
|
|
||||||
body: type.string
|
|
||||||
.describe(
|
|
||||||
"1-2 sentence high-level summary ONLY. Include urgency level and critical callouts (e.g., API key leak). ALL specific feedback MUST go in 'comments' array instead."
|
|
||||||
)
|
|
||||||
.optional(),
|
|
||||||
commit_id: type.string
|
|
||||||
.describe("Optional SHA of the commit being reviewed. Defaults to latest.")
|
|
||||||
.optional(),
|
|
||||||
comments: type({
|
|
||||||
path: type.string.describe("The file path to comment on (relative to repo root)"),
|
|
||||||
line: type.number.describe(
|
|
||||||
"The line number in the file (use line numbers from the diff - usually the RIGHT side/new code)"
|
|
||||||
),
|
|
||||||
side: type
|
|
||||||
.enumerated("LEFT", "RIGHT")
|
|
||||||
.describe(
|
|
||||||
"Side of the diff: LEFT (old code) or RIGHT (new code). Defaults to RIGHT if not provided."
|
|
||||||
)
|
|
||||||
.optional(),
|
|
||||||
body: type.string.describe(
|
|
||||||
"The comment text for this specific line. For issues appearing multiple times, comment on the first occurrence and reference others."
|
|
||||||
),
|
|
||||||
start_line: type.number
|
|
||||||
.describe("Start line for multi-line comments (optional, for commenting on ranges)")
|
|
||||||
.optional(),
|
|
||||||
})
|
|
||||||
.array()
|
|
||||||
.describe(
|
|
||||||
// FORK PR NOTE: use HEAD not origin/<head> - for fork PRs, origin/<head> doesn't exist
|
|
||||||
// because the head branch is in a different repo (the fork). HEAD is the locally checked out PR branch.
|
|
||||||
"PRIMARY location for ALL feedback. 95%+ of review content should be here. Use 'git diff origin/<base>..HEAD' to find correct line numbers (RIGHT side for new code, LEFT for old). Works for both fork and same-repo PRs."
|
|
||||||
)
|
|
||||||
.optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export function ReviewTool(ctx: ToolContext) {
|
|
||||||
return tool({
|
|
||||||
name: "submit_pull_request_review",
|
|
||||||
description:
|
|
||||||
"DEPRECATED: Use start_review, add_review_comment, and submit_review instead for iterative review workflow. " +
|
|
||||||
"Submit a review for an existing pull request. " +
|
|
||||||
"IMPORTANT: 95%+ of feedback should be in 'comments' array with file paths and line numbers. " +
|
|
||||||
"Only use 'body' for a 1-2 sentence summary with urgency and critical callouts.",
|
|
||||||
parameters: Review,
|
|
||||||
execute: execute(async ({ pull_number, body, commit_id, comments = [] }) => {
|
|
||||||
// set PR context
|
|
||||||
ctx.toolState.prNumber = pull_number;
|
|
||||||
|
|
||||||
// get the PR to determine the head commit if commit_id not provided
|
|
||||||
const pr = await ctx.octokit.rest.pulls.get({
|
|
||||||
owner: ctx.owner,
|
|
||||||
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
|
|
||||||
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 footer = buildPullfrogFooter({
|
|
||||||
workflowRun: { owner: ctx.owner, repo: ctx.name, runId: ctx.runId, jobId: ctx.jobId },
|
|
||||||
customParts: [`[Fix all ➔](${fixAllUrl})`, `[Fix 👍s ➔](${fixApprovedUrl})`],
|
|
||||||
});
|
|
||||||
|
|
||||||
const updatedBody = (body || "") + footer;
|
|
||||||
|
|
||||||
// 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,
|
|
||||||
});
|
|
||||||
|
|
||||||
await deleteProgressComment(ctx);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
reviewId,
|
|
||||||
html_url: result.data.html_url,
|
|
||||||
state: result.data.state,
|
|
||||||
user: result.data.user?.login,
|
|
||||||
submitted_at: result.data.submitted_at,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
+2
-5
@@ -26,7 +26,7 @@ import { IssueInfoTool } from "./issueInfo.ts";
|
|||||||
import { AddLabelsTool } from "./labels.ts";
|
import { AddLabelsTool } from "./labels.ts";
|
||||||
import { PullRequestTool } from "./pr.ts";
|
import { PullRequestTool } from "./pr.ts";
|
||||||
import { PullRequestInfoTool } from "./prInfo.ts";
|
import { PullRequestInfoTool } from "./prInfo.ts";
|
||||||
import { AddReviewCommentTool, StartReviewTool, SubmitReviewTool } from "./review.ts";
|
import { CreatePullRequestReviewTool } 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 { addTools } from "./shared.ts";
|
import { addTools } from "./shared.ts";
|
||||||
@@ -84,10 +84,7 @@ export async function startMcpHttpServer(
|
|||||||
GetIssueCommentsTool(ctx),
|
GetIssueCommentsTool(ctx),
|
||||||
GetIssueEventsTool(ctx),
|
GetIssueEventsTool(ctx),
|
||||||
PullRequestTool(ctx),
|
PullRequestTool(ctx),
|
||||||
// ReviewTool(ctx),
|
CreatePullRequestReviewTool(ctx),
|
||||||
StartReviewTool(ctx),
|
|
||||||
AddReviewCommentTool(ctx),
|
|
||||||
SubmitReviewTool(ctx),
|
|
||||||
PullRequestInfoTool(ctx),
|
PullRequestInfoTool(ctx),
|
||||||
CheckoutPrTool(ctx),
|
CheckoutPrTool(ctx),
|
||||||
GetReviewCommentsTool(ctx),
|
GetReviewCommentsTool(ctx),
|
||||||
|
|||||||
@@ -100,39 +100,38 @@ ${
|
|||||||
description:
|
description:
|
||||||
"Review code, PRs, or implementations; provide feedback or suggestions; identify issues; or check code quality, style, and correctness",
|
"Review code, PRs, or implementations; provide feedback or suggestions; identify issues; or check code quality, style, and correctness",
|
||||||
prompt: `Follow these steps:
|
prompt: `Follow these steps:
|
||||||
1. Checkout the PR using ${ghPullfrogMcpName}/checkout_pr with the PR number. This fetches the PR branch and base branch, preparing the repo for review.
|
|
||||||
|
|
||||||
2. **IMPORTANT**: After calling ${ghPullfrogMcpName}/checkout_pr, the PR branch is checked out locally. View diff using: \`git diff origin/<base>..HEAD\` (replace <base> with 'base' from checkout_pr result, e.g., \`git diff origin/main..HEAD\`). Use two dots (..) not three dots (...) for reliable diffs. Do NOT use \`origin/<head>\` - the branch is checked out locally, not as a remote tracking branch. This works for both same-repo and fork PRs.
|
1. **CHECKOUT** - Use ${ghPullfrogMcpName}/checkout_pr with the PR number. This fetches the PR branch and returns the diff. Use this diff for your review - it shows exactly what's in the PR.
|
||||||
|
|
||||||
3. Start review session using ${ghPullfrogMcpName}/start_review. This creates a scratchpad file at a temp path (e.g., \`/tmp/pullfrog-review-abc123.md\`) and returns a session ID. The scratchpad file header contains the session ID for reference. Use this file as free-form space to gather your thoughts before adding comments.
|
2. **UNDERSTAND CONTEXT** - Read the modified files to understand the changes in context. Don't just look at the diff - understand how the changes affect the overall codebase.
|
||||||
|
|
||||||
4. **ANALYZE** - Use the scratchpad to gather your thoughts:
|
3. **ANALYZE** - Think through:
|
||||||
- Summarize what changes this PR makes
|
- What does this PR change? Summarize in 1-2 sentences.
|
||||||
- Evaluate the approach - is it sound? If not, **stop here** and leave feedback on the approach. Don't waste time on implementation details if the approach is wrong.
|
- Is the approach sound? If not, focus on the approach first. Don't waste time on implementation details if the approach is wrong.
|
||||||
- If approach is sound, analyze implementation - consider potential issues per file
|
- What bugs, edge cases, or security issues exist?
|
||||||
- Identify bugs, security issues, edge cases
|
|
||||||
|
|
||||||
5. **SELF-CRITIQUE** - Before adding comments, review your scratchpad:
|
4. **DRAFT** - Mentally list all comments you would make. For each:
|
||||||
- Remove nitpicks unless explicitly requested. Think documentation, JSDoc/docstrings, useless comments (compliments)
|
- Note the file path (relative to repo root, e.g., \`packages/core/src/utils.ts\`)
|
||||||
- Your level of nitpickiness should be proportional to the current state of the codebase. Try to guess how much the user will care about a specific critique.
|
- Note the line number from the diff (use NEW file line number - shown after \`+\` in hunk headers like \`@@ -10,5 +12,8 @@\` means new file starts at line 12)
|
||||||
|
- Draft the comment text
|
||||||
|
|
||||||
6. Add inline review comments one-by-one using ${ghPullfrogMcpName}/add_review_comment
|
5. **SELF-CRITIQUE** - Before submitting, review your draft:
|
||||||
- Use **relative paths** from repo root (e.g., \`packages/core/src/utils.ts\`)
|
- Remove nitpicks unless the user explicitly told you to be nitpicky. Nitpicks may include: requesting documentation/docstrings/JSDoc, commenting on minor code/whitespace formatting, commenting on small changes unrelated to the main changes.
|
||||||
- Use the NEW file line number from the diff (shown after \`+\` in hunk headers like \`@@ -10,5 +12,8 @@\` means new file starts at line 12)
|
- Ensure each comment is actionable - would the author know exactly what to do?
|
||||||
- Only comment on lines that appear in the diff. GitHub will reject comments on unchanged lines.
|
- Would the codebase maintainer care about this feedback?
|
||||||
- For issues appearing in multiple places, comment on the FIRST occurrence and reference others (e.g., "also at lines X, Y")
|
- If you have approach-level concerns, consider whether implementation-level comments are worth including
|
||||||
|
- For issues appearing in multiple places, keep only the FIRST occurrence and reference others (e.g., "also at lines X, Y")
|
||||||
|
|
||||||
7. Submit the review using ${ghPullfrogMcpName}/submit_review
|
6. **SUBMIT** - Use ${ghPullfrogMcpName}/create_pull_request_review with:
|
||||||
- The "body" field is ONLY for: (1) a 1-3 sentence high-level overview, (2) urgency level (e.g., "minor suggestions" vs "blocking issues"), (3) critical security callouts (e.g., API key exposure)
|
- \`comments\`: Array of all inline comments with file paths and line numbers
|
||||||
|
- \`body\`: 1-3 sentence summary with urgency level (e.g., "minor suggestions" vs "blocking issues") and any critical callouts (e.g., API key exposure)
|
||||||
|
|
||||||
**GENERAL GUIDANCE**
|
**CRITICAL RULES**
|
||||||
|
- 95%+ of review content should be in inline \`comments\` array, not the \`body\`
|
||||||
- Do not leave any comments that are not potentially actionable. Do not leave complimentary comments just to be nice.
|
- Only comment on lines that appear in the diff - GitHub will reject comments on unchanged lines
|
||||||
- Do not nitpick unless instructed explicitly to do so by the user's additional instructions. This includes: requesting documentation/docstrings/JSDoc.
|
- Do not leave complimentary comments just to be nice
|
||||||
- **CRITICAL: Prioritize per-line feedback over summary text.**
|
- Do not leave comments that are not actionable
|
||||||
- All specific feedback MUST go in inline review comments with file paths and line numbers from the diff
|
`,
|
||||||
- The vast majority of review content should be in inline review comments; the body should be brief and only summarize the urgency of the review and any cross-cutting concerns.
|
|
||||||
`,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Plan",
|
name: "Plan",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@pullfrog/action",
|
"name": "@pullfrog/action",
|
||||||
"version": "0.0.148",
|
"version": "0.0.154",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
"index.js",
|
"index.js",
|
||||||
|
|||||||
+4
-4
@@ -36,8 +36,8 @@ export interface WorkflowRunInfo {
|
|||||||
export async function fetchWorkflowRunInfo(runId: string): Promise<WorkflowRunInfo> {
|
export async function fetchWorkflowRunInfo(runId: string): Promise<WorkflowRunInfo> {
|
||||||
const apiUrl = process.env.API_URL || "https://pullfrog.com";
|
const apiUrl = process.env.API_URL || "https://pullfrog.com";
|
||||||
|
|
||||||
// add timeout to prevent hanging (5 seconds)
|
// add timeout to prevent hanging (30 seconds)
|
||||||
const timeoutMs = 5000;
|
const timeoutMs = 30000;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
|
||||||
@@ -90,8 +90,8 @@ export async function getRepoSettings(
|
|||||||
): Promise<RepoSettings> {
|
): Promise<RepoSettings> {
|
||||||
const apiUrl = process.env.API_URL || "https://pullfrog.com";
|
const apiUrl = process.env.API_URL || "https://pullfrog.com";
|
||||||
|
|
||||||
// Add timeout to prevent hanging (5 seconds)
|
// Add timeout to prevent hanging (30 seconds)
|
||||||
const timeoutMs = 5000;
|
const timeoutMs = 30000;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -278,7 +278,7 @@ export const log = {
|
|||||||
/**
|
/**
|
||||||
* Print debug message (only if LOG_LEVEL=debug)
|
* Print debug message (only if LOG_LEVEL=debug)
|
||||||
*/
|
*/
|
||||||
debug: (message: string): void => {
|
debug: (message: string | unknown): void => {
|
||||||
if (isDebugEnabled()) {
|
if (isDebugEnabled()) {
|
||||||
if (isGitHubActions) {
|
if (isGitHubActions) {
|
||||||
// using this instead of core.debug
|
// using this instead of core.debug
|
||||||
@@ -341,12 +341,12 @@ export const log = {
|
|||||||
* Log tool call information to console with formatted output
|
* Log tool call information to console with formatted output
|
||||||
*/
|
*/
|
||||||
toolCall: ({ toolName, input }: { toolName: string; input: unknown }): void => {
|
toolCall: ({ toolName, input }: { toolName: string; input: unknown }): void => {
|
||||||
let output = `→ ${toolName}\n`;
|
|
||||||
|
|
||||||
const inputFormatted = formatJsonValue(input);
|
const inputFormatted = formatJsonValue(input);
|
||||||
if (inputFormatted !== "{}") {
|
const timestamp = isDebugEnabled() ? ` [${new Date().toISOString()}]` : "";
|
||||||
output += formatIndentedField("input", inputFormatted);
|
const output =
|
||||||
}
|
inputFormatted !== "{}"
|
||||||
|
? `→ ${toolName}(${inputFormatted})${timestamp}`
|
||||||
|
: `→ ${toolName}()${timestamp}`;
|
||||||
|
|
||||||
log.info(output.trimEnd());
|
log.info(output.trimEnd());
|
||||||
},
|
},
|
||||||
|
|||||||
+6
-7
@@ -1,6 +1,7 @@
|
|||||||
import { createSign } from "node:crypto";
|
import { createSign } from "node:crypto";
|
||||||
import * as core from "@actions/core";
|
import * as core from "@actions/core";
|
||||||
import { log } from "./cli.ts";
|
import { log } from "./cli.ts";
|
||||||
|
import { retry } from "./retry.ts";
|
||||||
|
|
||||||
export interface InstallationToken {
|
export interface InstallationToken {
|
||||||
token: string;
|
token: string;
|
||||||
@@ -48,17 +49,15 @@ function isGitHubActionsEnvironment(): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function acquireTokenViaOIDC(): Promise<string> {
|
async function acquireTokenViaOIDC(): Promise<string> {
|
||||||
log.debug("» generating OIDC token...");
|
log.info("» generating OIDC token...");
|
||||||
|
|
||||||
const oidcToken = await core.getIDToken("pullfrog-api");
|
const oidcToken = await core.getIDToken("pullfrog-api");
|
||||||
log.debug("» OIDC token generated successfully");
|
|
||||||
|
|
||||||
const apiUrl = process.env.API_URL || "https://pullfrog.com";
|
const apiUrl = process.env.API_URL || "https://pullfrog.com";
|
||||||
|
|
||||||
log.debug("» exchanging OIDC token for installation token...");
|
log.info("» exchanging OIDC token for installation token...");
|
||||||
|
|
||||||
// Add timeout to prevent long waits (5 seconds)
|
const timeoutMs = 30000;
|
||||||
const timeoutMs = 5000;
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
|
||||||
@@ -79,7 +78,7 @@ async function acquireTokenViaOIDC(): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tokenData = (await tokenResponse.json()) as InstallationToken;
|
const tokenData = (await tokenResponse.json()) as InstallationToken;
|
||||||
log.debug(`» installation token obtained for ${tokenData.repository || "all repositories"}`);
|
log.info(`» installation token obtained for ${tokenData.repository || "all repositories"}`);
|
||||||
|
|
||||||
return tokenData.token;
|
return tokenData.token;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -235,7 +234,7 @@ async function acquireTokenViaGitHubApp(): Promise<string> {
|
|||||||
|
|
||||||
async function acquireNewToken(): Promise<string> {
|
async function acquireNewToken(): Promise<string> {
|
||||||
if (isGitHubActionsEnvironment()) {
|
if (isGitHubActionsEnvironment()) {
|
||||||
return await acquireTokenViaOIDC();
|
return await retry(() => acquireTokenViaOIDC(), { label: "token exchange" });
|
||||||
} else {
|
} else {
|
||||||
return await acquireTokenViaGitHubApp();
|
return await acquireTokenViaGitHubApp();
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -77,7 +77,7 @@ interface SetupGitAuthParams {
|
|||||||
* FORK PR ARCHITECTURE:
|
* FORK PR ARCHITECTURE:
|
||||||
* - origin: always points to BASE REPO (where PR targets)
|
* - origin: always points to BASE REPO (where PR targets)
|
||||||
* - checkoutPrBranch sets per-branch pushRemote config for fork PRs
|
* - checkoutPrBranch sets per-branch pushRemote config for fork PRs
|
||||||
* - diff operations use: git diff origin/<base>..HEAD
|
* - checkout_pr returns the PR diff via GitHub API (authoritative source)
|
||||||
*/
|
*/
|
||||||
export async function setupGitAuth(params: SetupGitAuthParams): Promise<void> {
|
export async function setupGitAuth(params: SetupGitAuthParams): Promise<void> {
|
||||||
const repoDir = process.cwd();
|
const repoDir = process.cwd();
|
||||||
|
|||||||
Reference in New Issue
Block a user