* plan: issue 447 run artifact tracking and UI (supersedes stale pill notes) Made-with: Cursor * feat: workflow run artifact urls, chips, and safe PATCH validation Made-with: Cursor * chore(action): refresh latest-by-provider model snapshot Made-with: Cursor * refactor: resolve artifact urls via GraphQL nodes(ids), drop stored url columns Made-with: Cursor * docs: finalize issue 447 run-artifacts plan; remove demo backfill script Made-with: Cursor * refactor: DRY node-id constraint, replace margin with padding wrapper Made-with: Cursor * refactor: DRY audit — shared row info, derived types, unified Prisma select - extract WorkflowRunRowInfo component (description + issue link + time + pills) shared by ActiveWorkflowRunsSection and WorkflowRunHistory - derive API payload types via Omit + & instead of manual field lists; serialize with spread + override for bigint/date fields - extract workflowRunListSelect shared Prisma select base; history extends with completedAt - inline updateCommentNodeId → direct patchWorkflowRunFields calls - derive WorkflowRunArtifactSlice from canonical exported types - delete cancelling-out URL column migrations (no schema change vs main) Made-with: Cursor * refactor: artifact chips as inline CTAs with proper vertical alignment - chips now render as action links: "Open PR #N", "View summary", etc. - only render chips with resolved URLs; remove inert span fallback - inline chips in the row (right-justified) instead of a separate line - fix vertical alignment: remove ul/li wrappers that caused line-height mismatch, render chips as direct row siblings via flat flex layout - change row to items-center, remove compensating self-start/pt nudges - cancelled run X icon uses red-600 Made-with: Cursor * chore(action): refresh latest-by-provider model snapshot Made-with: Cursor --------- Co-authored-by: Colin McDonnell <colinmcd94@gmail.com>
This commit is contained in:
committed by
pullfrog[bot]
parent
abdbdc7245
commit
a4c7c0fc15
+7
-44
@@ -1,49 +1,12 @@
|
||||
import { type } from "arktype";
|
||||
import { apiFetch } from "../utils/apiFetch.ts";
|
||||
import { getApiUrl } from "../utils/apiUrl.ts";
|
||||
import { buildPullfrogFooter, stripExistingFooter } from "../utils/buildPullfrogFooter.ts";
|
||||
import { log } from "../utils/cli.ts";
|
||||
import { fixDoubleEscapedString } from "../utils/fixDoubleEscapedString.ts";
|
||||
import { retry } from "../utils/retry.ts";
|
||||
import { patchWorkflowRunFields } from "../utils/patchWorkflowRunFields.ts";
|
||||
import type { ToolContext } from "./server.ts";
|
||||
import { execute, tool } from "./shared.ts";
|
||||
|
||||
type CommentNodeIdField = "planCommentNodeId" | "summaryCommentNodeId";
|
||||
|
||||
// IMPORTANT: this route authenticates via Pullfrog API JWT (verifyApiToken),
|
||||
// NOT a GitHub token. use ctx.apiToken here. see wiki/api-auth.md.
|
||||
export async function updateCommentNodeId(
|
||||
ctx: ToolContext,
|
||||
field: CommentNodeIdField,
|
||||
nodeId: string
|
||||
): Promise<void> {
|
||||
if (ctx.runId === undefined || !ctx.apiToken) return;
|
||||
try {
|
||||
await retry(
|
||||
async () => {
|
||||
const response = await apiFetch({
|
||||
path: `/api/workflow-run/${ctx.runId}`,
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
authorization: `Bearer ${ctx.apiToken}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ [field]: nodeId }),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (!response.ok) throw new Error(`PATCH workflow-run: ${response.status}`);
|
||||
},
|
||||
{
|
||||
maxAttempts: 3,
|
||||
delayMs: 2000,
|
||||
label: `updateCommentNodeId(${field})`,
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
log.warning(`updateCommentNodeId(${field}) exhausted retries: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The prefix text for the initial "leaping into action" comment.
|
||||
* This is used to identify if a comment is still in its initial state
|
||||
@@ -118,7 +81,7 @@ export function CreateCommentTool(ctx: ToolContext) {
|
||||
});
|
||||
|
||||
if (result.data.node_id) {
|
||||
await updateCommentNodeId(ctx, "summaryCommentNodeId", result.data.node_id);
|
||||
await patchWorkflowRunFields(ctx, { summaryCommentNodeId: result.data.node_id });
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -138,7 +101,7 @@ export function CreateCommentTool(ctx: ToolContext) {
|
||||
|
||||
if (commentType === "Plan") {
|
||||
if (result.data.node_id) {
|
||||
await updateCommentNodeId(ctx, "planCommentNodeId", result.data.node_id);
|
||||
await patchWorkflowRunFields(ctx, { planCommentNodeId: result.data.node_id });
|
||||
}
|
||||
// add "Implement plan" link (needs comment ID, so create-then-update)
|
||||
const customParts = [buildImplementPlanLink(ctx, issueNumber, result.data.id)];
|
||||
@@ -161,7 +124,7 @@ export function CreateCommentTool(ctx: ToolContext) {
|
||||
}
|
||||
|
||||
if (commentType === "Summary" && result.data.node_id) {
|
||||
await updateCommentNodeId(ctx, "summaryCommentNodeId", result.data.node_id);
|
||||
await patchWorkflowRunFields(ctx, { summaryCommentNodeId: result.data.node_id });
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -266,7 +229,7 @@ export async function reportProgress(
|
||||
ctx.toolState.wasUpdated = true;
|
||||
|
||||
if (isPlanMode && result.data.node_id) {
|
||||
await updateCommentNodeId(ctx, "planCommentNodeId", result.data.node_id);
|
||||
await patchWorkflowRunFields(ctx, { planCommentNodeId: result.data.node_id });
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -300,7 +263,7 @@ export async function reportProgress(
|
||||
ctx.toolState.wasUpdated = true;
|
||||
|
||||
if (isPlanMode && result.data.node_id) {
|
||||
await updateCommentNodeId(ctx, "planCommentNodeId", result.data.node_id);
|
||||
await patchWorkflowRunFields(ctx, { planCommentNodeId: result.data.node_id });
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -353,7 +316,7 @@ export async function reportProgress(
|
||||
});
|
||||
|
||||
if (updateResult.data.node_id) {
|
||||
await updateCommentNodeId(ctx, "planCommentNodeId", updateResult.data.node_id);
|
||||
await patchWorkflowRunFields(ctx, { planCommentNodeId: updateResult.data.node_id });
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
+13
-5
@@ -1,5 +1,6 @@
|
||||
import { type } from "arktype";
|
||||
import { fixDoubleEscapedString } from "../utils/fixDoubleEscapedString.ts";
|
||||
import { patchWorkflowRunFields } from "../utils/patchWorkflowRunFields.ts";
|
||||
import type { ToolContext } from "./server.ts";
|
||||
import { execute, tool } from "./shared.ts";
|
||||
|
||||
@@ -21,16 +22,23 @@ export function IssueTool(ctx: ToolContext) {
|
||||
name: "create_issue",
|
||||
description: "Create a new GitHub issue",
|
||||
parameters: Issue,
|
||||
execute: execute(async ({ title, body, labels, assignees }) => {
|
||||
execute: execute(async (params) => {
|
||||
const result = await ctx.octokit.rest.issues.create({
|
||||
owner: ctx.repo.owner,
|
||||
repo: ctx.repo.name,
|
||||
title: title,
|
||||
body: fixDoubleEscapedString(body),
|
||||
labels: labels ?? [],
|
||||
assignees: assignees ?? [],
|
||||
title: params.title,
|
||||
body: fixDoubleEscapedString(params.body),
|
||||
labels: params.labels ?? [],
|
||||
assignees: params.assignees ?? [],
|
||||
});
|
||||
|
||||
const nodeId = result.data.node_id;
|
||||
if (typeof nodeId === "string" && nodeId.length > 0) {
|
||||
await patchWorkflowRunFields(ctx, {
|
||||
issueNodeId: nodeId,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
issueId: result.data.id,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { type } from "arktype";
|
||||
import { buildPullfrogFooter, stripExistingFooter } from "../utils/buildPullfrogFooter.ts";
|
||||
import { log } from "../utils/cli.ts";
|
||||
import { fixDoubleEscapedString } from "../utils/fixDoubleEscapedString.ts";
|
||||
import { patchWorkflowRunFields } from "../utils/patchWorkflowRunFields.ts";
|
||||
import { $ } from "../utils/shell.ts";
|
||||
import type { ToolContext } from "./server.ts";
|
||||
import { execute, tool } from "./shared.ts";
|
||||
@@ -94,6 +95,12 @@ export function CreatePullRequestTool(ctx: ToolContext) {
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof result.data.node_id === "string" && result.data.node_id.length > 0) {
|
||||
await patchWorkflowRunFields(ctx, {
|
||||
prNodeId: result.data.node_id,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
pullRequestId: result.data.id,
|
||||
|
||||
+7
-29
@@ -1,11 +1,11 @@
|
||||
import type { RestEndpointMethodTypes } from "@octokit/rest";
|
||||
import { type } from "arktype";
|
||||
import { formatMcpToolRef } from "../external.ts";
|
||||
import { apiFetch } from "../utils/apiFetch.ts";
|
||||
import { getApiUrl } from "../utils/apiUrl.ts";
|
||||
import { buildPullfrogFooter } from "../utils/buildPullfrogFooter.ts";
|
||||
import { log } from "../utils/cli.ts";
|
||||
import { fixDoubleEscapedString } from "../utils/fixDoubleEscapedString.ts";
|
||||
import { patchWorkflowRunFields } from "../utils/patchWorkflowRunFields.ts";
|
||||
import type { ToolContext } from "./server.ts";
|
||||
import { execute, tool } from "./shared.ts";
|
||||
|
||||
@@ -305,34 +305,12 @@ async function createAndSubmitWithFooter(
|
||||
}
|
||||
|
||||
/**
|
||||
* report the review node ID to the server so the WorkflowRun is marked as "review submitted".
|
||||
* report the review node ID so the WorkflowRun is marked as "review submitted".
|
||||
* exported for use in main.ts post-agent cleanup.
|
||||
*/
|
||||
export async function reportReviewNodeId(ctx: ToolContext, reviewNodeId: string): Promise<void> {
|
||||
for (let remaining = 2; remaining >= 0; remaining--) {
|
||||
try {
|
||||
const response = await apiFetch({
|
||||
path: `/api/workflow-run/${ctx.runId}`,
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
authorization: `Bearer ${ctx.apiToken}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ reviewNodeId }),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (response.ok) return;
|
||||
if (remaining > 0) {
|
||||
log.debug(`reportReviewNodeId got ${response.status}, retrying (${remaining} left)`);
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
}
|
||||
} catch (error) {
|
||||
if (remaining > 0) {
|
||||
log.debug(`reportReviewNodeId failed, retrying (${remaining} left): ${error}`);
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
} else {
|
||||
log.debug(`reportReviewNodeId exhausted retries: ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
export async function reportReviewNodeId(
|
||||
ctx: ToolContext,
|
||||
params: { nodeId: string }
|
||||
): Promise<void> {
|
||||
await patchWorkflowRunFields(ctx, { reviewNodeId: params.nodeId });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { ToolContext } from "../mcp/server.ts";
|
||||
import { apiFetch } from "./apiFetch.ts";
|
||||
import { log } from "./cli.ts";
|
||||
import { retry } from "./retry.ts";
|
||||
|
||||
/** Keys accepted by PATCH /api/workflow-run/[runId] — keep in sync with `ALLOWED_FIELDS` in `app/api/workflow-run/[runId]/route.ts`. */
|
||||
export type WorkflowRunArtifactPatchKey =
|
||||
| "prNodeId"
|
||||
| "issueNodeId"
|
||||
| "reviewNodeId"
|
||||
| "planCommentNodeId"
|
||||
| "summaryCommentNodeId";
|
||||
|
||||
export type WorkflowRunArtifactPatch = Partial<Record<WorkflowRunArtifactPatchKey, string>>;
|
||||
|
||||
const ARTIFACT_PATCH_KEYS: WorkflowRunArtifactPatchKey[] = [
|
||||
"prNodeId",
|
||||
"issueNodeId",
|
||||
"reviewNodeId",
|
||||
"planCommentNodeId",
|
||||
"summaryCommentNodeId",
|
||||
];
|
||||
|
||||
/** PATCH workflow-run artifact fields (Pullfrog JWT, not GitHub). */
|
||||
export async function patchWorkflowRunFields(
|
||||
ctx: ToolContext,
|
||||
fields: WorkflowRunArtifactPatch
|
||||
): Promise<void> {
|
||||
if (ctx.runId === undefined || !ctx.apiToken) return;
|
||||
const body: Record<string, string> = {};
|
||||
for (const key of ARTIFACT_PATCH_KEYS) {
|
||||
const value = fields[key];
|
||||
if (typeof value === "string" && value.length > 0) {
|
||||
body[key] = value;
|
||||
}
|
||||
}
|
||||
if (Object.keys(body).length === 0) return;
|
||||
try {
|
||||
await retry(
|
||||
async () => {
|
||||
const response = await apiFetch({
|
||||
path: `/api/workflow-run/${ctx.runId}`,
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
authorization: `Bearer ${ctx.apiToken}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (!response.ok) throw new Error(`PATCH workflow-run: ${response.status}`);
|
||||
},
|
||||
{
|
||||
maxAttempts: 3,
|
||||
delayMs: 2000,
|
||||
label: "patchWorkflowRunFields",
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
log.warning(`patchWorkflowRunFields exhausted retries: ${error}`);
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ export async function postReviewCleanup(ctx: ToolContext): Promise<void> {
|
||||
delete ctx.toolState.review;
|
||||
|
||||
// mark review as submitted — unlocks webhook dedup for new pushes
|
||||
await bestEffort(() => reportReviewNodeId(ctx, review.nodeId), "reportReviewNodeId");
|
||||
await bestEffort(() => reportReviewNodeId(ctx, { nodeId: review.nodeId }), "reportReviewNodeId");
|
||||
|
||||
// dispatch follow-up if PR HEAD moved past the reviewed commit
|
||||
if (review.reviewedSha) {
|
||||
|
||||
Reference in New Issue
Block a user