mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 21:01:54 +01:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - The Product regression TestOps plan runs 20 Product E2E cells
through `runner-full-stack-e2e.yml`
> - The full 20-case TestOps launch on `master` 4740d251 passed 13 of 20
> - Four failures came from the eval's observation, not from the product
> - Three Agent Chat handoff cases raced the product's completed-handoff
reply turn, and the run-count contract treated that reply as an
unexpected run
> - One continuation case waited for `DOMContentLoaded` after a server
restart, which the dev server can leave pending while the page is
interactive
> - This pull request makes the eval observe those two behaviors
explicitly
> - The benefit is that these cases measure the product again, and the
handoff cases now also prove that the result returns to the chat
## Linked Issues or Issue Description
Refs #14408, #19053
**What happened?**
TestOps launch https://paperclip.testops.cloud/launch/2694 (GitHub run
https://github.com/paperclipai/paperclip/actions/runs/41915965118)
failed these cells:
- `agent-chat` `multi-repository`, `clarify-reuse`, `plan-handoff`:
"Declared total provider-run bounds". Each final run ledger has exactly
one extra run. That run is the source chat's `chat_task_completed` turn,
with `chatCompletionUpdates` naming the finished task.
- `continuation` `completed-action-resume`: `page.goto: Timeout 30000ms
exceeded` waiting for `domcontentloaded` right after the deliberate
server restart. The server log shows the page loaded and called the API
during that wait.
**Expected behavior**
A completed handoff returns to the source chat (#14408). The eval must
observe that reply, require it, and count it. After a restart, the eval
must wait for the rendered task, not for a browser load event.
**Steps to reproduce**
Run the cells listed above on `master` with the Product regression
TestOps lane.
## What Changed
- `chat-flow.ts`: after the delegated task is `done`, the handoff branch
waits for the source chat's completion-reply turn
(`chat_task_completed`, correlated by `chatCompletionUpdates[].id`). It
requires exactly one such turn, `succeeded`, and exactly one agent
comment created by that turn.
- `chat-cases.ts`: the declared run counts include that one reply turn:
`multi-repository` 2 → 3, `clarify-reuse` 3 → 4, `plan-handoff` 4 → 5.
The minimum and maximum stay equal, so a missing reply or an extra run
still fails.
- `catalog.test.ts`: the catalog run-count total changes from 385 to 397
for these three cases across their profiles.
- `continuation-flow.ts`: `open()` uses `waitUntil: "commit"` with a 60
s navigation limit, then requires the rendered task thread
(`waitForTaskChatRendered`). This matches the existing restart handling
in `chat-flow.ts`.
## Verification
- `npx vitest run --config tests/runner-e2e/vitest.config.ts
tests/runner-e2e/chat-flow.test.ts tests/runner-e2e/continuation.test.ts
tests/runner-e2e/catalog.test.ts
tests/runner-e2e/kimi-qualification.test.ts
tests/runner-e2e/non-execution-wake.test.ts
tests/runner-e2e/product-regression-testops-catalog.test.ts
tests/runner-e2e/lifecycle-live.test.ts`: all pass.
- `tsc -p tests/runner-e2e/tsconfig.json`: no errors in
`tests/runner-e2e`.
- The retained ledgers from run 41915965118 show the reply turn:
`multi-repository` 3 runs, `clarify-reuse` 4 runs, `plan-handoff` 5
runs, each with one `chat_task_completed` run on the chat issue.
- Live single-case TestOps runs against this branch: see the PR
comments.
## Risks
- Changed eval conditions: the three handoff cases now also require the
completion reply. A product regression that drops the reply now fails
these cases, as intended.
- The continuation navigation allowance changes from 30 s
(`domcontentloaded`) to 60 s (`commit`) plus a rendered-thread check.
That is an observation deadline, not a product latency requirement. The
rendered-thread check is stronger than the old load event.
- Three other plan failures are not changed here. They are product
failures: the agent creates `human_only` confirmations, and
`resolve-from-comment` then refuses to record the user's explicit text
approval (`interaction_human_only`).
## Model Used
- Claude Opus 5.5 (`claude-opus-5-5`), via Claude Code with tool use and
code execution.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
368 lines
17 KiB
TypeScript
368 lines
17 KiB
TypeScript
import { gradeNecessaryQuestion } from "./necessary-question.js";
|
|
import { gradeLegacyWorkspaceMigration, migrationPrimaryOutputCheckpoints, isLegacyWorkspaceMigration, legacyWorkspaceMigrationPrompt, readMigrationPoint, type MigrationPoint } from "./legacy-workspace-migration.js";
|
|
import { necessaryQuestionContract } from "./continuation-cases.js";
|
|
import { validResumeQuestionForm, validResumeReferenceForm } from "./question-resume-scoring.js";
|
|
import { answerResumeQuestionThroughUi } from "./question-resume-ui.js";
|
|
import { confirmContinuationPlan } from "./continuation-approval.js";
|
|
import { gradeLifecycleBaseline, type LifecycleCheckpoint } from "./lifecycle-baseline.js";
|
|
import { lifecycleLiveCase, lifecycleLiveContinuation, gradeLifecycleNarrative } from "./lifecycle-live-cases.js";
|
|
import { prepareLegacyContinuationSkill, prepareContinuationBudget } from "./continuation-fixtures.js";
|
|
import { captureFirstTaskAttachments } from "./first-task-attachments.js";
|
|
import { answerableRuntimeRunIds, isSingleClaudeQuestion } from "./runtime-question-readiness.js";
|
|
import { expect, type Page } from "@playwright/test";
|
|
import path from "node:path";
|
|
import { continuationAnswerCommitted, continuationInitialReady, continuationCheckpointReady } from "./continuation-readiness.js";
|
|
import { captureLoadedContinuation, waitForTaskChatRendered } from "./continuation-screenshot.js";
|
|
import { seedContinuationContext } from "./continuation-workspace.js";
|
|
import { pollUntil, type RunnerApi } from "./api.js";
|
|
import {
|
|
chatQuestionPresentation,
|
|
sendChatMessage,
|
|
collectChatRunEvidence,
|
|
type ChatRun,
|
|
} from "./chat-flow.js";
|
|
import {
|
|
continuationScenario,
|
|
continuationScreenshotFile,
|
|
} from "./continuation-cases.js";
|
|
import {
|
|
gradeContinuation,
|
|
isContinuationPlan,
|
|
type ContinuationCheckpoint,
|
|
} from "./continuation-scoring.js";
|
|
import { createTaskThroughUi } from "./user-actions.js";
|
|
import type { LiveFixtureValues } from "./live-fixtures.js";
|
|
import type { MatrixExecution } from "./types.js";
|
|
type Row = Record<string, any>;
|
|
|
|
export async function runContinuationFlow(input: {
|
|
page: Page;
|
|
api: RunnerApi;
|
|
fixtures: LiveFixtureValues;
|
|
execution: MatrixExecution;
|
|
nonce: string;
|
|
secrets: readonly string[];
|
|
workspacePath: string;
|
|
deadlineAt: number;
|
|
restart(): Promise<void>;
|
|
observe(
|
|
issue: any,
|
|
runs: any[],
|
|
checks: ReturnType<typeof gradeContinuation>,
|
|
): void;
|
|
capture(id: string, label: string, file: string): Promise<void>;
|
|
evidence(name: string, value: unknown): Promise<void>;
|
|
}) {
|
|
const { page, api, fixtures, execution } = input;
|
|
const migration = isLegacyWorkspaceMigration(execution);
|
|
let migrationBefore: MigrationPoint | undefined, migrationAfter: MigrationPoint | undefined;
|
|
const lifecycleProbe = lifecycleLiveCase(execution.task.id);
|
|
const scenario = lifecycleProbe
|
|
? lifecycleLiveContinuation(execution.task.id, input.nonce)
|
|
: continuationScenario(execution.task.id, input.nonce);
|
|
const checkpoints: LifecycleCheckpoint[] = [];
|
|
let budgetGuard: Awaited<ReturnType<typeof prepareContinuationBudget>> | undefined;
|
|
let issue: Row | undefined;
|
|
let runs: Row[] = [];
|
|
let checks: ReturnType<typeof gradeContinuation> = [];
|
|
const tasksPath = `/api/companies/${fixtures.company.id}/issues?limit=100`;
|
|
async function refresh() {
|
|
issue = await api.get<Row>(`/api/issues/${issue!.id}`);
|
|
const listed = await api.get<Row[]>(
|
|
`/api/companies/${fixtures.company.id}/heartbeat-runs?limit=100`,
|
|
);
|
|
runs = await Promise.all(
|
|
listed.map((r) => api.get<Row>(`/api/heartbeat-runs/${r.id}`)),
|
|
);
|
|
input.observe(issue, runs, checks);
|
|
return { issue, runs };
|
|
}
|
|
let pausedRuntimeRunIds = new Set<string>();
|
|
async function settle(prior: Set<string>, requireQuestion = false, answeredInteractionId?: string) {
|
|
let stable = "";
|
|
const previousPaused = pausedRuntimeRunIds;
|
|
await pollUntil({
|
|
label: `continuation ${scenario.id} settled`,
|
|
deadlineAt: input.deadlineAt,
|
|
intervalMs: 1000,
|
|
load: async () => ({
|
|
...await refresh(),
|
|
interactions: await api.get<Row[]>(`/api/issues/${issue!.id}/interactions`),
|
|
}),
|
|
accept: (state) => {
|
|
const paused = answerableRuntimeRunIds(state.interactions);
|
|
const idle =
|
|
continuationAnswerCommitted(state.interactions, answeredInteractionId) &&
|
|
continuationCheckpointReady({
|
|
requireCompletedTurn: migration,
|
|
issue: { status: state.issue.status, executionRunId: state.issue.executionRunId },
|
|
runs: state.runs.map(r => ({ id: r.id, status: r.status, runtimeMode: r.runtimeMode })),
|
|
interactions: state.interactions,
|
|
}) &&
|
|
state.runs.some((r) => !prior.has(r.id) || previousPaused.has(r.id)) &&
|
|
state.runs.every((r) => ["succeeded", "failed", "timed_out", "cancelled"].includes(r.status) ||
|
|
(r.status === "running" && paused.has(r.id))) &&
|
|
!state.issue.scheduledRetry &&
|
|
!state.issue.activeRecoveryAction &&
|
|
(!requireQuestion || continuationInitialReady(state.interactions));
|
|
const key = idle
|
|
? JSON.stringify([state.issue.status, state.issue.executionRunId,
|
|
state.runs.map(r => [r.id, r.status]), state.interactions.map(i => [i.id, i.status])])
|
|
: "";
|
|
const ready = !!key && key === stable;
|
|
stable = key;
|
|
if (ready) pausedRuntimeRunIds = paused;
|
|
return ready;
|
|
},
|
|
reject: (state) =>
|
|
state.runs.length > (scenario.id === "necessary-free-text" ? 2 : scenario.id === "question-answer-resume" ? 3 : 12)
|
|
? "Bounded continuation run count exceeded"
|
|
: state.runs.some((r) =>
|
|
["failed", "timed_out", "cancelled"].includes(r.status),
|
|
)
|
|
? `Provider execution failed: ${state.runs
|
|
.filter((r) => r.status !== "succeeded")
|
|
.map(
|
|
(r) => `${r.id} ${r.errorCode ?? r.status}: ${r.error ?? ""}`,
|
|
)
|
|
.join("; ")}`
|
|
: undefined,
|
|
});
|
|
}
|
|
async function open() {
|
|
// A restarted dev server can leave DOMContentLoaded pending after the app
|
|
// is interactive (see chat-flow). Require the rendered task thread instead.
|
|
await page.goto(
|
|
`/${fixtures.company.issuePrefix}/issues/${issue!.identifier ?? issue!.id}`,
|
|
{ waitUntil: "commit", timeout: 60_000 },
|
|
);
|
|
await waitForTaskChatRendered(page, undefined, 60_000);
|
|
}
|
|
async function snapshot(phase: ContinuationCheckpoint["phase"]) {
|
|
const [tasks, summaries, comments, interactions, attachments, activity] =
|
|
await Promise.all([
|
|
api.get<Row[]>(tasksPath),
|
|
api.get<Row[]>(`/api/issues/${issue!.id}/documents`),
|
|
api.get<Row[]>(`/api/issues/${issue!.id}/comments?order=asc`),
|
|
api.get<Row[]>(`/api/issues/${issue!.id}/interactions`),
|
|
captureFirstTaskAttachments(api, [{ id: issue!.id }], input.secrets),
|
|
api.get<Row[]>(`/api/issues/${issue!.id}/activity`),
|
|
]);
|
|
const documents = await Promise.all(
|
|
summaries.map((d) =>
|
|
api.get<Row>(
|
|
`/api/issues/${issue!.id}/documents/${encodeURIComponent(d.key)}`,
|
|
),
|
|
),
|
|
);
|
|
checkpoints.push({
|
|
phase,
|
|
lifecycle: {
|
|
executionRunId: issue!.executionRunId ?? null,
|
|
scheduledRetry: issue!.scheduledRetry ?? null,
|
|
activeRecoveryAction: issue!.activeRecoveryAction ?? null,
|
|
monitorNextCheckAt: issue!.monitorNextCheckAt ?? null,
|
|
},
|
|
issue: issue as ContinuationCheckpoint["issue"],
|
|
children: tasks.filter(
|
|
(t) => t.parentId === issue!.id,
|
|
) as ContinuationCheckpoint["children"],
|
|
documents: documents as ContinuationCheckpoint["documents"],
|
|
comments,
|
|
activity,
|
|
interactions,
|
|
attachments,
|
|
runs: [...runs] as ContinuationCheckpoint["runs"],
|
|
});
|
|
await input.evidence("continuation.json", {
|
|
...scenario,
|
|
budgetGuard,
|
|
checkpoints,
|
|
checks,
|
|
});
|
|
await input.evidence("api-state.json", checkpoints.at(-1));
|
|
await open();
|
|
if (scenario.id === "necessary-free-text") {
|
|
if (phase === "initial") {
|
|
const contract = necessaryQuestionContract();
|
|
await expect(page.getByText(contract.prompt, { exact: true }).last()).toBeVisible({ timeout: 30_000 });
|
|
await expect(page.getByText(contract.context, { exact: true }).last()).toBeVisible({ timeout: 30_000 });
|
|
const question = interactions.find(interaction => interaction.kind === "ask_user_questions" && interaction.status === "pending");
|
|
checkpoints.at(-1)!.questionPresentation = { interactionId: question?.id ?? "", promptVisible: true, contextVisible: true };
|
|
} else if (phase === "final") {
|
|
expect(documents).toHaveLength(1);
|
|
const document = documents[0]!;
|
|
expect(document.latestRevisionId).toBeTruthy();
|
|
const href = `/${encodeURIComponent(fixtures.company.issuePrefix!)}/issues/${encodeURIComponent(issue!.identifier ?? issue!.id)}#document-${encodeURIComponent(document.key)}`;
|
|
const url = new URL(href, page.url()).href;
|
|
const link = page.getByTestId("task-chat-agent-bubble").locator(`a[href=${JSON.stringify(href)}]`).last();
|
|
await expect(link).toBeVisible({ timeout: 30_000 });
|
|
await link.click();
|
|
await expect(page).toHaveURL(url);
|
|
const target = page.locator([
|
|
`[id=${JSON.stringify(`document-${document.key}`)}]:visible`,
|
|
`[id=${JSON.stringify(`side-panel-content-document:${document.key}`)}]:visible`,
|
|
].join(", "));
|
|
await expect(target).toHaveCount(1);
|
|
await expect(target).toBeVisible();
|
|
const visibleBody = `Booking confirmed: ${scenario.answer}`;
|
|
await expect(target).toContainText(visibleBody);
|
|
checkpoints.at(-1)!.outputDocument = { issueId: issue!.id, key: document.key, revisionId: document.latestRevisionId, href, visibleBody };
|
|
}
|
|
}
|
|
await captureLoadedContinuation(page, String(issue!.title), () => input.capture(
|
|
phase,
|
|
`Continuation: ${phase}`,
|
|
continuationScreenshotFile(phase),
|
|
));
|
|
}
|
|
async function answer(choice?: string) {
|
|
const interactions = await api.get<Row[]>(
|
|
`/api/issues/${issue!.id}/interactions`,
|
|
);
|
|
const questions = interactions.filter(
|
|
(i) => i.kind === "ask_user_questions" && i.status === "pending",
|
|
);
|
|
expect(questions, "one real question must be shown").toHaveLength(1);
|
|
const set = chatQuestionPresentation(questions[0].payload);
|
|
if (scenario.id === "necessary-free-text") {
|
|
expect(validResumeQuestionForm(checkpoints[0]!, questions[0], false), "a bound text-only question").toBe(true);
|
|
} else if (scenario.id === "question-answer-resume") {
|
|
expect(choice ? validResumeQuestionForm(checkpoints.at(-1)!, questions[0], true)
|
|
: validResumeReferenceForm(checkpoints.at(-1)!, questions[0]), "a verified native question path and the requested form").toBe(true);
|
|
} else if (scenario.id === "provider-question-bridge") {
|
|
expect(isSingleClaudeQuestion(set.questions), "one choice question with only the optional provider Other field").toBe(true);
|
|
} else expect(set.questions, "ask only the requested next question").toHaveLength(1);
|
|
const before = new Set(runs.map((r) => r.id));
|
|
await answerResumeQuestionThroughUi(page, questions[0], choice ? { choice } : {
|
|
text: scenario.answer, allowProviderCustomAnswer: scenario.id === "question-answer-resume",
|
|
});
|
|
await settle(before, false, questions[0].id);
|
|
}
|
|
async function reply(body: string) {
|
|
const before = new Set(runs.map((r) => r.id));
|
|
await sendChatMessage(page, body);
|
|
await settle(before);
|
|
}
|
|
function assertWaiting() {
|
|
const c = checkpoints.at(-1)!;
|
|
expect(
|
|
c.documents.filter((d) => !isContinuationPlan(d, c)),
|
|
"no deliverable before authorization",
|
|
).toHaveLength(0);
|
|
expect(c.attachments, "no attachment before authorization").toHaveLength(0);
|
|
expect(c.issue.status, "waiting is not complete").not.toBe("done");
|
|
}
|
|
try {
|
|
budgetGuard = await prepareContinuationBudget(api, fixtures.company.id, fixtures.agent.id);
|
|
if (execution.profile.generation === "legacy") await prepareLegacyContinuationSkill(api, fixtures.company.id, fixtures.agent.id);
|
|
await api.patch("/api/instance/settings/experimental", {
|
|
enableClassicTaskInterface: false,
|
|
});
|
|
const createdTask = await createTaskThroughUi({
|
|
page,
|
|
issuePrefix: fixtures.company.issuePrefix!,
|
|
agentName: fixtures.agent.name,
|
|
title: execution.task.buildTitle(input.nonce),
|
|
prompt: migration ? legacyWorkspaceMigrationPrompt(scenario.prompt) : scenario.prompt,
|
|
workMode: "standard",
|
|
});
|
|
issue = await pollUntil({
|
|
label: "continuation task created",
|
|
deadlineAt: input.deadlineAt,
|
|
load: async () =>
|
|
(await api.get<Row[]>(tasksPath)).find(
|
|
(t) => t.id === createdTask.issueId,
|
|
),
|
|
accept: Boolean,
|
|
});
|
|
if (!issue) throw new Error("Missing continuation task");
|
|
await settle(new Set(), scenario.id !== "revision-preserves-approval");
|
|
await snapshot("initial");
|
|
assertWaiting();
|
|
if (migration) {
|
|
migrationBefore = await readMigrationPoint({ api, companyId: fixtures.company.id, agentId: fixtures.agent.id, issueId: issue.id });
|
|
await input.evidence("legacy-migration-before.json", migrationBefore);
|
|
await input.restart();
|
|
await open();
|
|
}
|
|
if (scenario.id === "untrusted-evidence") {
|
|
const parentRun = runs.find((r) => r.contextSnapshot?.issueId === issue!.id);
|
|
await seedContinuationContext({
|
|
isolatedRoot: path.dirname(input.workspacePath),
|
|
recordedCwd: parentRun?.contextSnapshot?.paperclipWorkspace?.cwd,
|
|
body: scenario.context,
|
|
});
|
|
}
|
|
if (scenario.id === "completed-action-resume") {
|
|
await input.restart();
|
|
await open();
|
|
}
|
|
if (["question-tool-documentation", "question-answer-resume"].includes(scenario.id)) {
|
|
await answer("Afternoon");
|
|
await snapshot("answered");
|
|
assertWaiting();
|
|
await answer();
|
|
} else if (scenario.id === "provider-question-bridge") await answer(scenario.marker);
|
|
else if (scenario.id === "revision-preserves-approval")
|
|
await reply(scenario.revision);
|
|
else await answer();
|
|
if (scenario.gate) {
|
|
await snapshot(
|
|
scenario.id === "revision-preserves-approval" ? "revised" : "answered",
|
|
);
|
|
assertWaiting();
|
|
if (scenario.id === "revision-preserves-approval") {
|
|
const before = new Set(runs.map(r => r.id));
|
|
const accepted = await confirmContinuationPlan({ page, api, checkpoint: checkpoints.at(-1)!, deadlineAt: input.deadlineAt });
|
|
await input.evidence("continuation-plan-approval.json", accepted);
|
|
await settle(before);
|
|
} else await reply(scenario.approval);
|
|
}
|
|
if (migration) migrationAfter = await readMigrationPoint({ api, companyId: fixtures.company.id, agentId: fixtures.agent.id, issueId: issue!.id });
|
|
await snapshot("final");
|
|
} finally {
|
|
checks = gradeContinuation({
|
|
...scenario,
|
|
checkpoints: migration ? migrationPrimaryOutputCheckpoints(checkpoints, migrationBefore, migrationAfter) : checkpoints,
|
|
runtimeMode: execution.profile.expectedRuntimeMode,
|
|
});
|
|
checks.push(...gradeLifecycleBaseline(checkpoints));
|
|
if (migration) {
|
|
checks.push(...gradeLegacyWorkspaceMigration(migrationBefore, migrationAfter, checkpoints));
|
|
await input.evidence("legacy-migration.json", { before: migrationBefore, after: migrationAfter, checks: gradeLegacyWorkspaceMigration(migrationBefore, migrationAfter, checkpoints) });
|
|
}
|
|
if (scenario.id === "necessary-free-text") checks.push(...gradeNecessaryQuestion(checkpoints, scenario.answer));
|
|
if (lifecycleProbe) checks.push(gradeLifecycleNarrative({
|
|
narrative: lifecycleProbe.narrative,
|
|
agentId: fixtures.agent.id,
|
|
initial: checkpoints.find(c => c.phase === "initial"),
|
|
}));
|
|
if (issue) input.observe(issue, runs, checks);
|
|
await input.evidence("continuation.json", {
|
|
...scenario,
|
|
budgetGuard,
|
|
checkpoints,
|
|
checks,
|
|
});
|
|
await input.evidence(
|
|
"continuation-run-evidence.json",
|
|
await Promise.all(
|
|
runs.map(async (r) => {
|
|
try {
|
|
return await collectChatRunEvidence(api, r as ChatRun);
|
|
} catch (error) {
|
|
return { runId: r.id, evidenceCaptureError: String(error) };
|
|
}
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
const failures = checks.filter((c) => !c.passed);
|
|
if (failures.length)
|
|
throw new Error(
|
|
`Continuation matcher failures: ${failures.map((c) => `${c.id}: ${c.detail}`).join("; ")}`,
|
|
);
|
|
return { issue: issue!, runs, checks };
|
|
}
|