feat: workflow run artifact chips + GraphQL url resolution (#447) (#527)

* 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:
David Blass
2026-04-14 04:42:40 +00:00
committed by pullfrog[bot]
parent abdbdc7245
commit a4c7c0fc15
6 changed files with 97 additions and 79 deletions
+7 -44
View File
@@ -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
View File
@@ -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,
+7
View File
@@ -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
View File
@@ -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 });
}
+62
View File
@@ -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}`);
}
}
+1 -1
View File
@@ -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) {