Compare commits

...

6 Commits

Author SHA1 Message Date
Colin McDonnell 5353d80388 Retries on oidc. 152 2025-12-22 14:33:18 -08:00
Colin McDonnell 2dea842981 Write diff to file 2025-12-22 14:20:42 -08:00
Colin McDonnell 04c695038f 151 2025-12-22 13:57:51 -08:00
Colin McDonnell e9a585ce47 Improve debug logging for reviews. v0.0.150 2025-12-22 13:50:39 -08:00
Colin McDonnell 7407b6cbc5 Fix timeout 2025-12-22 12:51:04 -08:00
Colin McDonnell 507efb0c25 Fix timeout 2025-12-22 12:50:00 -08:00
8 changed files with 9414 additions and 16294 deletions
+9278 -16231
View File
File diff suppressed because one or more lines are too long
+23 -5
View File
@@ -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";
@@ -20,6 +22,7 @@ export type CheckoutPrResult = {
url: string; url: string;
headRepo: string; headRepo: string;
diff: string; diff: string;
diffPath: string;
}; };
interface CheckoutPrBranchParams { interface CheckoutPrBranchParams {
@@ -104,13 +107,13 @@ 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}`);
} }
@@ -140,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({
@@ -174,6 +178,19 @@ export function CheckoutPrTool(ctx: ToolContext) {
mediaType: { format: "diff" }, mediaType: { format: "diff" },
}); });
// write diff to file for grep access
const diffContent = diffResponse.data as unknown as string;
const diffPath = join(process.env.PULLFROG_TEMP_DIR!, `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,
@@ -184,7 +201,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: diffResponse.data as unknown as string, diff,
diffPath,
} satisfies CheckoutPrResult; } satisfies CheckoutPrResult;
}), }),
}); });
+9
View File
@@ -275,11 +275,20 @@ export async function deleteProgressComment(ctx: ToolContext): Promise<boolean>
return false; return false;
} }
try {
await ctx.octokit.rest.issues.deleteComment({ await ctx.octokit.rest.issues.deleteComment({
owner: ctx.owner, owner: ctx.owner,
repo: ctx.name, repo: ctx.name,
comment_id: existingCommentId, 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;
+62 -14
View File
@@ -92,6 +92,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}`);
@@ -123,19 +130,10 @@ export function StartReviewTool(ctx: ToolContext) {
id: reviewId, id: reviewId,
}; };
log.debug(`review session started: id=${reviewId}, nodeId=${reviewNodeId}`);
return { return {
message: `Review session started for PR #${pull_number}.`, message: `Review session started for PR #${pull_number}. Add comments with add_review_comment, then submit with submit_review.`,
guidance: {
analyze: [
"What does this PR change? Summarize in 1-2 sentences.",
"Is the approach sound? If not, stop and comment on approach first.",
"What bugs, edge cases, or security issues exist?",
],
beforeCommenting: [
"Skip nitpicks unless explicitly requested.",
"Would the codebase maintainer care about this feedback, based on what you can infer about the code quality standards in this repo?",
],
},
}; };
}), }),
}); });
@@ -166,21 +164,58 @@ 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.");
} }
const reviewNodeId = ctx.toolState.review.nodeId;
log.debug(
`adding review comment: reviewNodeId=${reviewNodeId}, path=${path}, line=${line}, side=${side || "RIGHT"}`
);
// add comment thread via GraphQL (REST doesn't support adding to existing pending review) // add comment thread via GraphQL (REST doesn't support adding to existing pending review)
await ctx.octokit.graphql<AddPullRequestReviewThreadResponse>( let result: AddPullRequestReviewThreadResponse;
try {
result = await ctx.octokit.graphql<AddPullRequestReviewThreadResponse>(
ADD_PULL_REQUEST_REVIEW_THREAD, ADD_PULL_REQUEST_REVIEW_THREAD,
{ {
pullRequestReviewId: ctx.toolState.review.nodeId, pullRequestReviewId: reviewNodeId,
path, path,
line, line,
body, body,
side: side || "RIGHT", side: side || "RIGHT",
} }
); );
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 +246,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 +272,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;
@@ -336,6 +380,10 @@ export function ReviewTool(ctx: ToolContext) {
}); });
} }
const result = await ctx.octokit.rest.pulls.createReview(params); const result = await ctx.octokit.rest.pulls.createReview(params);
log.debug(`createReview (legacy) 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 reviewId = result.data.id;
// build quick links footer and update the review body // build quick links footer and update the review body
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@pullfrog/action", "name": "@pullfrog/action",
"version": "0.0.149", "version": "0.0.152",
"type": "module", "type": "module",
"files": [ "files": [
"index.js", "index.js",
+4 -4
View File
@@ -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 -7
View File
@@ -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
@@ -342,12 +342,11 @@ export const log = {
*/ */
toolCall: ({ toolName, input }: { toolName: string; input: unknown }): void => { toolCall: ({ toolName, input }: { toolName: string; input: unknown }): void => {
const inputFormatted = formatJsonValue(input); const inputFormatted = formatJsonValue(input);
// if (inputFormatted !== "{}") const timestamp = isDebugEnabled() ? ` [${new Date().toISOString()}]` : "";
const output = inputFormatted !== "{}" ? `${toolName}(${inputFormatted})` : `${toolName}()`; const output =
inputFormatted !== "{}"
// if (inputFormatted !== "{}") { ? `${toolName}(${inputFormatted})${timestamp}`
// output += formatIndentedField("input", inputFormatted); : `${toolName}()${timestamp}`;
// }
log.info(output.trimEnd()); log.info(output.trimEnd());
}, },
+6 -7
View File
@@ -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();
} }