Attach pull request feedback to chat (#1400)
* feat(app): add pull request panel context actions Attach pull request comments, reviews, and failed check details to chat context. The PR panel now lives in its own module so the activity, query, and context policies have one home. * fix(server): mark capped check details as truncated * fix(app): preserve concurrent PR panel attachments * fix(server): preserve annotation truncation state * fix(app): keep matrix check attachments distinct * fix(app): streamline pull request context attachments * fix(app): simplify pull request feedback rendering * feat(app): group pull request review threads * fix(app): tidy pull request panel boundaries * fix: use worktree cwd for dev services * fix(app): keep git query keys UI-free * feat(app): collapse resolved comments and add chat actions to headers * fix: address PR feedback and Playwright flakes * fix(app): hide check chat action without attachment scope * fix(server): dedupe inline review timeline comments
This commit is contained in:
@@ -282,7 +282,8 @@ describe("opencode tool-call mapper", () => {
|
||||
callId: "opencode-write-success-text",
|
||||
status: "completed",
|
||||
input: {
|
||||
filePath: "/Users/moboudra/.paseo/worktrees/1luy0po7/cold-ladybug/dummy.txt",
|
||||
filePath:
|
||||
"/Users/moboudra/dev/paseo/.dev/paseo-home/worktrees/1luy0po7/cold-ladybug/dummy.txt",
|
||||
content: "hello world\n",
|
||||
},
|
||||
output: "Wrote file successfully.",
|
||||
@@ -291,7 +292,8 @@ describe("opencode tool-call mapper", () => {
|
||||
|
||||
expect(item.detail).toEqual({
|
||||
type: "write",
|
||||
filePath: "/Users/moboudra/.paseo/worktrees/1luy0po7/cold-ladybug/dummy.txt",
|
||||
filePath:
|
||||
"/Users/moboudra/dev/paseo/.dev/paseo-home/worktrees/1luy0po7/cold-ladybug/dummy.txt",
|
||||
content: "hello world\n",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,7 +41,11 @@ import {
|
||||
createProviderSnapshotManagerStub,
|
||||
} from "./test-utils/session-stubs.js";
|
||||
import { isPlatform } from "../test-utils/platform.js";
|
||||
import type { GitHubPullRequestStatusFacts } from "../services/github-service.js";
|
||||
import type {
|
||||
GitHubCheckDetails,
|
||||
GitHubPullRequestStatusFacts,
|
||||
GitHubService,
|
||||
} from "../services/github-service.js";
|
||||
|
||||
interface SessionHandlerInternals {
|
||||
startVoiceTurnController(): Promise<void>;
|
||||
@@ -207,12 +211,7 @@ vi.mock("./worktree-bootstrap.js", async (importOriginal) => {
|
||||
});
|
||||
|
||||
interface SessionForTestOptions {
|
||||
github?: {
|
||||
invalidate: ReturnType<typeof vi.fn>;
|
||||
isAuthenticated?: ReturnType<typeof vi.fn>;
|
||||
getPullRequestTimeline?: ReturnType<typeof vi.fn>;
|
||||
searchIssuesAndPrs?: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
github?: Partial<GitHubService>;
|
||||
checkoutDiffManager?: { scheduleRefreshForCwd: ReturnType<typeof vi.fn> };
|
||||
workspaceGitService?: {
|
||||
getCheckoutDiff?: ReturnType<typeof vi.fn>;
|
||||
@@ -3669,6 +3668,7 @@ describe("session pull request timeline handling", () => {
|
||||
kind: "review",
|
||||
author: "octocat",
|
||||
authorUrl: "https://github.com/octocat",
|
||||
avatarUrl: "https://avatars.githubusercontent.com/u/1?v=4",
|
||||
body: "Looks good",
|
||||
createdAt: 1710000000000,
|
||||
url: "https://github.com/getpaseo/paseo/pull/42#pullrequestreview-1",
|
||||
@@ -3706,6 +3706,8 @@ describe("session pull request timeline handling", () => {
|
||||
id: "review-1",
|
||||
kind: "review",
|
||||
author: "octocat",
|
||||
authorUrl: "https://github.com/octocat",
|
||||
avatarUrl: "https://avatars.githubusercontent.com/u/1?v=4",
|
||||
body: "Looks good",
|
||||
createdAt: 1710000000000,
|
||||
url: "https://github.com/getpaseo/paseo/pull/42#pullrequestreview-1",
|
||||
@@ -3799,4 +3801,88 @@ describe("session pull request timeline handling", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("emits GitHub check details responses", async () => {
|
||||
const messages: unknown[] = [];
|
||||
const checkDetailRequests: Array<{
|
||||
cwd: string;
|
||||
repoOwner: string;
|
||||
repoName: string;
|
||||
checkRunId: number;
|
||||
workflowRunId?: number;
|
||||
}> = [];
|
||||
const checkDetails: GitHubCheckDetails = {
|
||||
checkRunId: 12345,
|
||||
workflowRunId: 456,
|
||||
name: "server-tests",
|
||||
status: "completed",
|
||||
conclusion: "failure",
|
||||
url: "https://github.com/getpaseo/paseo/actions/runs/456/job/789",
|
||||
detailsUrl: "https://github.com/getpaseo/paseo/actions/runs/456/job/789",
|
||||
output: { title: "Tests failed", summary: "1 failure", text: "Assertion failed" },
|
||||
annotations: [],
|
||||
failedJobs: [],
|
||||
truncated: false,
|
||||
};
|
||||
const github: Partial<GitHubService> = {
|
||||
invalidate() {},
|
||||
async isAuthenticated() {
|
||||
return true;
|
||||
},
|
||||
async getGitHubCheckDetails(request) {
|
||||
checkDetailRequests.push({
|
||||
cwd: request.cwd,
|
||||
repoOwner: request.repoOwner,
|
||||
repoName: request.repoName,
|
||||
checkRunId: request.checkRunId,
|
||||
workflowRunId: request.workflowRunId,
|
||||
});
|
||||
return checkDetails;
|
||||
},
|
||||
};
|
||||
const session = createSessionForTest({ github, messages });
|
||||
|
||||
await session.handleMessage({
|
||||
type: "checkout.github.get_check_details.request",
|
||||
cwd: "/tmp/repo",
|
||||
repoOwner: "getpaseo",
|
||||
repoName: "paseo",
|
||||
checkRunId: 12345,
|
||||
workflowRunId: 456,
|
||||
requestId: "request-check-details",
|
||||
});
|
||||
|
||||
expect(checkDetailRequests).toEqual([
|
||||
{
|
||||
cwd: "/tmp/repo",
|
||||
repoOwner: "getpaseo",
|
||||
repoName: "paseo",
|
||||
checkRunId: 12345,
|
||||
workflowRunId: 456,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(messages).toContainEqual({
|
||||
type: "checkout.github.get_check_details.response",
|
||||
payload: {
|
||||
cwd: "/tmp/repo",
|
||||
success: true,
|
||||
details: {
|
||||
checkRunId: 12345,
|
||||
workflowRunId: 456,
|
||||
name: "server-tests",
|
||||
status: "completed",
|
||||
conclusion: "failure",
|
||||
url: "https://github.com/getpaseo/paseo/actions/runs/456/job/789",
|
||||
detailsUrl: "https://github.com/getpaseo/paseo/actions/runs/456/job/789",
|
||||
output: { title: "Tests failed", summary: "1 failure", text: "Assertion failed" },
|
||||
annotations: [],
|
||||
failedJobs: [],
|
||||
truncated: false,
|
||||
},
|
||||
error: null,
|
||||
requestId: "request-check-details",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2077,6 +2077,8 @@ export class Session {
|
||||
return this.handleCheckoutPrMergeRequest(msg);
|
||||
case "checkout.github.set_auto_merge.request":
|
||||
return this.handleCheckoutGithubSetAutoMergeRequest(msg);
|
||||
case "checkout.github.get_check_details.request":
|
||||
return this.handleCheckoutGithubGetCheckDetailsRequest(msg);
|
||||
case "checkout_pr_status_request":
|
||||
return this.handleCheckoutPrStatusRequest(msg);
|
||||
case "pull_request_timeline_request":
|
||||
@@ -5692,6 +5694,46 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleCheckoutGithubGetCheckDetailsRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "checkout.github.get_check_details.request" }>,
|
||||
): Promise<void> {
|
||||
const { cwd, repoOwner, repoName, checkRunId, workflowRunId, requestId } = msg;
|
||||
|
||||
try {
|
||||
const details = await this.github.getGitHubCheckDetails({
|
||||
cwd,
|
||||
repoOwner,
|
||||
repoName,
|
||||
checkRunId,
|
||||
workflowRunId,
|
||||
});
|
||||
this.emit({
|
||||
type: "checkout.github.get_check_details.response",
|
||||
payload: {
|
||||
cwd,
|
||||
success: true,
|
||||
details,
|
||||
error: null,
|
||||
requestId,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.emit({
|
||||
type: "checkout.github.get_check_details.response",
|
||||
payload: {
|
||||
cwd,
|
||||
success: false,
|
||||
details: null,
|
||||
error: {
|
||||
code: "UNKNOWN",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
requestId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async handlePaseoWorktreeListRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "paseo_worktree_list_request" }>,
|
||||
): Promise<void> {
|
||||
@@ -9252,6 +9294,5 @@ function isValidGitHubRepoSegment(value: string): boolean {
|
||||
function toPullRequestTimelinePayloadItem(
|
||||
item: PullRequestTimelineItem,
|
||||
): PullRequestTimelinePayloadItem {
|
||||
const { authorUrl: _authorUrl, ...payload } = item;
|
||||
return payload;
|
||||
return item;
|
||||
}
|
||||
|
||||
@@ -1055,6 +1055,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
providersSnapshot: true,
|
||||
// COMPAT(checkoutGithubSetAutoMerge): added in v0.1.75, remove gate after 2026-11-13.
|
||||
checkoutGithubSetAutoMerge: true,
|
||||
githubCheckDetails: true,
|
||||
// COMPAT(daemonStatusRpc): added in v0.1.76, remove gate after 2026-11-18.
|
||||
daemonStatusRpc: true,
|
||||
// COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.
|
||||
|
||||
@@ -308,6 +308,7 @@ function pullRequestTimelineJson(overrides: Record<string, unknown> = {}): strin
|
||||
author: {
|
||||
login: "reviewer",
|
||||
url: "https://github.com/reviewer",
|
||||
avatarUrl: "https://avatars.githubusercontent.com/u/1?v=4",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -331,6 +332,7 @@ function pullRequestTimelineJson(overrides: Record<string, unknown> = {}): strin
|
||||
author: {
|
||||
login: "commenter",
|
||||
url: "https://github.com/commenter",
|
||||
avatarUrl: "https://avatars.githubusercontent.com/u/2?v=4",
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -873,6 +875,8 @@ describe("GitHubService", () => {
|
||||
});
|
||||
expect(runner.calls[0]?.args[3]).toContain("reviews(first: 100)");
|
||||
expect(runner.calls[0]?.args[3]).toContain("comments(first: 100)");
|
||||
expect(runner.calls[0]?.args[3]).toContain("avatarUrl");
|
||||
expect(runner.calls[0]?.args[3]).toContain("reviewThreads(first: 100)");
|
||||
expect(timeline).toEqual({
|
||||
prNumber: 42,
|
||||
repoOwner: "parentOwner",
|
||||
@@ -885,6 +889,7 @@ describe("GitHubService", () => {
|
||||
id: "PRR_empty_commented",
|
||||
author: "unknown",
|
||||
authorUrl: null,
|
||||
avatarUrl: null,
|
||||
body: "",
|
||||
createdAt: Date.parse("2026-04-02T13:50:00Z"),
|
||||
url: "https://github.com/parentOwner/parentRepo/pull/42#pullrequestreview-2",
|
||||
@@ -895,6 +900,7 @@ describe("GitHubService", () => {
|
||||
id: "PRR_approved",
|
||||
author: "reviewer",
|
||||
authorUrl: "https://github.com/reviewer",
|
||||
avatarUrl: "https://avatars.githubusercontent.com/u/1?v=4",
|
||||
body: "Looks good to me.",
|
||||
createdAt: Date.parse("2026-04-02T13:52:14Z"),
|
||||
url: "https://github.com/parentOwner/parentRepo/pull/42#pullrequestreview-1",
|
||||
@@ -905,6 +911,7 @@ describe("GitHubService", () => {
|
||||
id: "IC_later",
|
||||
author: "commenter",
|
||||
authorUrl: "https://github.com/commenter",
|
||||
avatarUrl: "https://avatars.githubusercontent.com/u/2?v=4",
|
||||
body: "Can we add a regression test?",
|
||||
createdAt: Date.parse("2026-04-02T13:55:00Z"),
|
||||
url: "https://github.com/parentOwner/parentRepo/pull/42#issuecomment-3",
|
||||
@@ -913,6 +920,176 @@ describe("GitHubService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("maps inline review thread comments as chronological PR timeline comments with location", async () => {
|
||||
const runner = createRunner([
|
||||
pullRequestTimelineJson({
|
||||
reviewThreads: {
|
||||
nodes: [
|
||||
{
|
||||
id: "PRRT_1",
|
||||
path: "packages/app/src/git/pull-request-panel/data.ts",
|
||||
line: 24,
|
||||
startLine: 20,
|
||||
isResolved: true,
|
||||
isOutdated: false,
|
||||
comments: {
|
||||
nodes: [
|
||||
{
|
||||
id: "PRRC_1",
|
||||
body: "This should include line context.",
|
||||
url: "https://github.com/parentOwner/parentRepo/pull/42#discussion_r1",
|
||||
createdAt: "2026-04-02T13:51:00Z",
|
||||
author: {
|
||||
login: "inline-reviewer",
|
||||
url: "https://github.com/inline-reviewer",
|
||||
avatarUrl: "https://avatars.githubusercontent.com/u/3?v=4",
|
||||
},
|
||||
pullRequestReview: { id: "PRR_empty_commented" },
|
||||
},
|
||||
],
|
||||
pageInfo: { hasNextPage: true },
|
||||
},
|
||||
},
|
||||
],
|
||||
pageInfo: { hasNextPage: false },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const service = createGitHubService({
|
||||
runner: runner.runner,
|
||||
resolveGhPath: async () => "/usr/bin/gh",
|
||||
now: () => 100,
|
||||
});
|
||||
|
||||
const timeline = await service.getPullRequestTimeline({
|
||||
cwd: "/repo",
|
||||
prNumber: 42,
|
||||
repoOwner: "parentOwner",
|
||||
repoName: "parentRepo",
|
||||
});
|
||||
|
||||
expect(timeline.truncated).toBe(true);
|
||||
expect(timeline.items.map((item) => item.id)).toEqual([
|
||||
"PRR_empty_commented",
|
||||
"PRRC_1",
|
||||
"PRR_approved",
|
||||
"IC_later",
|
||||
]);
|
||||
expect(timeline.items[1]).toEqual({
|
||||
kind: "comment",
|
||||
id: "PRRC_1",
|
||||
author: "inline-reviewer",
|
||||
authorUrl: "https://github.com/inline-reviewer",
|
||||
avatarUrl: "https://avatars.githubusercontent.com/u/3?v=4",
|
||||
body: "This should include line context.",
|
||||
createdAt: Date.parse("2026-04-02T13:51:00Z"),
|
||||
url: "https://github.com/parentOwner/parentRepo/pull/42#discussion_r1",
|
||||
reviewId: "PRR_empty_commented",
|
||||
location: {
|
||||
path: "packages/app/src/git/pull-request-panel/data.ts",
|
||||
line: 24,
|
||||
startLine: 20,
|
||||
threadId: "PRRT_1",
|
||||
isResolved: true,
|
||||
isOutdated: false,
|
||||
},
|
||||
});
|
||||
expect(runner.calls[0]?.args[3]).toContain("pullRequestReview");
|
||||
});
|
||||
|
||||
it("keeps inline review thread comments once when they also appear in PR comments", async () => {
|
||||
const runner = createRunner([
|
||||
pullRequestTimelineJson({
|
||||
comments: {
|
||||
nodes: [
|
||||
{
|
||||
id: "PRRC_1",
|
||||
body: "This should include line context.",
|
||||
url: "https://github.com/parentOwner/parentRepo/pull/42#discussion_r1",
|
||||
createdAt: "2026-04-02T13:51:00Z",
|
||||
author: {
|
||||
login: "inline-reviewer",
|
||||
url: "https://github.com/inline-reviewer",
|
||||
avatarUrl: "https://avatars.githubusercontent.com/u/3?v=4",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "IC_later",
|
||||
body: "Can we add a regression test?",
|
||||
url: "https://github.com/parentOwner/parentRepo/pull/42#issuecomment-3",
|
||||
createdAt: "2026-04-02T13:55:00Z",
|
||||
author: {
|
||||
login: "commenter",
|
||||
url: "https://github.com/commenter",
|
||||
avatarUrl: "https://avatars.githubusercontent.com/u/2?v=4",
|
||||
},
|
||||
},
|
||||
],
|
||||
pageInfo: { hasNextPage: false },
|
||||
},
|
||||
reviewThreads: {
|
||||
nodes: [
|
||||
{
|
||||
id: "PRRT_1",
|
||||
path: "packages/app/src/git/pull-request-panel/data.ts",
|
||||
line: 24,
|
||||
startLine: 20,
|
||||
isResolved: false,
|
||||
isOutdated: false,
|
||||
comments: {
|
||||
nodes: [
|
||||
{
|
||||
id: "PRRC_1",
|
||||
body: "This should include line context.",
|
||||
url: "https://github.com/parentOwner/parentRepo/pull/42#discussion_r1",
|
||||
createdAt: "2026-04-02T13:51:00Z",
|
||||
author: {
|
||||
login: "inline-reviewer",
|
||||
url: "https://github.com/inline-reviewer",
|
||||
avatarUrl: "https://avatars.githubusercontent.com/u/3?v=4",
|
||||
},
|
||||
pullRequestReview: { id: "PRR_empty_commented" },
|
||||
},
|
||||
],
|
||||
pageInfo: { hasNextPage: false },
|
||||
},
|
||||
},
|
||||
],
|
||||
pageInfo: { hasNextPage: false },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const service = createGitHubService({
|
||||
runner: runner.runner,
|
||||
resolveGhPath: async () => "/usr/bin/gh",
|
||||
now: () => 100,
|
||||
});
|
||||
|
||||
const timeline = await service.getPullRequestTimeline({
|
||||
cwd: "/repo",
|
||||
prNumber: 42,
|
||||
repoOwner: "parentOwner",
|
||||
repoName: "parentRepo",
|
||||
});
|
||||
|
||||
expect(timeline.items.map((item) => item.id)).toEqual([
|
||||
"PRR_empty_commented",
|
||||
"PRRC_1",
|
||||
"PRR_approved",
|
||||
"IC_later",
|
||||
]);
|
||||
expect(timeline.items[1]).toMatchObject({
|
||||
id: "PRRC_1",
|
||||
reviewId: "PRR_empty_commented",
|
||||
location: {
|
||||
path: "packages/app/src/git/pull-request-panel/data.ts",
|
||||
line: 24,
|
||||
startLine: 20,
|
||||
threadId: "PRRT_1",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the passed parent repository identity for fork PR timelines", async () => {
|
||||
const runner = createRunner([pullRequestTimelineJson()]);
|
||||
const service = createGitHubService({
|
||||
@@ -1336,6 +1513,7 @@ describe("GitHubService", () => {
|
||||
statusCheckRollup: [
|
||||
{
|
||||
__typename: "CheckRun",
|
||||
databaseId: 12345,
|
||||
completedAt: "2026-04-02T13:52:14Z",
|
||||
conclusion: "SUCCESS",
|
||||
detailsUrl: "https://github.com/acme/repo/actions/runs/123",
|
||||
@@ -1390,6 +1568,8 @@ describe("GitHubService", () => {
|
||||
url: "https://github.com/acme/repo/actions/runs/123",
|
||||
workflow: "Server CI",
|
||||
duration: "2m 14s",
|
||||
checkRunId: 12345,
|
||||
workflowRunId: 123,
|
||||
},
|
||||
{
|
||||
name: "deploy/preview",
|
||||
@@ -1402,6 +1582,293 @@ describe("GitHubService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("fetches GitHub check details with capped failed job log tails and reuses cached logs", async () => {
|
||||
const longLog = Array.from({ length: 220 }, (_, index) => `line ${index + 1}`).join("\n");
|
||||
const runner = createRunner([
|
||||
JSON.stringify({
|
||||
id: 12345,
|
||||
name: "server-tests",
|
||||
status: "completed",
|
||||
conclusion: "failure",
|
||||
html_url: "https://github.com/acme/repo/actions/runs/456/job/789",
|
||||
details_url: "https://github.com/acme/repo/actions/runs/456/job/789",
|
||||
output: {
|
||||
title: "Tests failed",
|
||||
summary: "1 failure",
|
||||
text: "Assertion failed",
|
||||
},
|
||||
check_suite: {
|
||||
workflow_run: {
|
||||
id: 456,
|
||||
},
|
||||
},
|
||||
}),
|
||||
JSON.stringify([
|
||||
{
|
||||
path: "packages/server/src/index.ts",
|
||||
start_line: 10,
|
||||
end_line: 12,
|
||||
annotation_level: "failure",
|
||||
message: "Expected true",
|
||||
title: "server test failed",
|
||||
raw_details: "stack trace",
|
||||
},
|
||||
]),
|
||||
JSON.stringify({
|
||||
jobs: [
|
||||
{
|
||||
id: 789,
|
||||
name: "test",
|
||||
status: "completed",
|
||||
conclusion: "failure",
|
||||
html_url: "https://github.com/acme/repo/actions/runs/456/job/789",
|
||||
completed_at: "2026-04-02T13:52:14Z",
|
||||
},
|
||||
{
|
||||
id: 790,
|
||||
name: "lint",
|
||||
status: "completed",
|
||||
conclusion: "success",
|
||||
html_url: "https://github.com/acme/repo/actions/runs/456/job/790",
|
||||
completed_at: "2026-04-02T13:52:15Z",
|
||||
},
|
||||
],
|
||||
}),
|
||||
longLog,
|
||||
JSON.stringify({
|
||||
id: 12345,
|
||||
name: "server-tests",
|
||||
status: "completed",
|
||||
conclusion: "failure",
|
||||
html_url: "https://github.com/acme/repo/actions/runs/456/job/789",
|
||||
check_suite: { workflow_run: { id: 456 } },
|
||||
}),
|
||||
JSON.stringify([]),
|
||||
JSON.stringify({
|
||||
jobs: [
|
||||
{
|
||||
id: 789,
|
||||
name: "test",
|
||||
status: "completed",
|
||||
conclusion: "failure",
|
||||
html_url: "https://github.com/acme/repo/actions/runs/456/job/789",
|
||||
completed_at: "2026-04-02T13:52:14Z",
|
||||
},
|
||||
],
|
||||
}),
|
||||
]);
|
||||
const service = createGitHubService({
|
||||
runner: runner.runner,
|
||||
resolveGhPath: async () => "/usr/bin/gh",
|
||||
now: () => 100,
|
||||
});
|
||||
|
||||
const first = await service.getGitHubCheckDetails({
|
||||
cwd: "/repo",
|
||||
repoOwner: "acme",
|
||||
repoName: "repo",
|
||||
checkRunId: 12345,
|
||||
});
|
||||
const second = await service.getGitHubCheckDetails({
|
||||
cwd: "/repo",
|
||||
repoOwner: "acme",
|
||||
repoName: "repo",
|
||||
checkRunId: 12345,
|
||||
force: true,
|
||||
reason: "verify-log-cache",
|
||||
});
|
||||
|
||||
expect(first).toEqual({
|
||||
checkRunId: 12345,
|
||||
workflowRunId: 456,
|
||||
name: "server-tests",
|
||||
status: "completed",
|
||||
conclusion: "failure",
|
||||
url: "https://github.com/acme/repo/actions/runs/456/job/789",
|
||||
detailsUrl: "https://github.com/acme/repo/actions/runs/456/job/789",
|
||||
output: {
|
||||
title: "Tests failed",
|
||||
summary: "1 failure",
|
||||
text: "Assertion failed",
|
||||
},
|
||||
annotations: [
|
||||
{
|
||||
path: "packages/server/src/index.ts",
|
||||
startLine: 10,
|
||||
endLine: 12,
|
||||
annotationLevel: "failure",
|
||||
message: "Expected true",
|
||||
title: "server test failed",
|
||||
rawDetails: "stack trace",
|
||||
},
|
||||
],
|
||||
failedJobs: [
|
||||
{
|
||||
jobId: 789,
|
||||
name: "test",
|
||||
status: "completed",
|
||||
conclusion: "failure",
|
||||
url: "https://github.com/acme/repo/actions/runs/456/job/789",
|
||||
logTail: Array.from({ length: 200 }, (_, index) => `line ${index + 21}`).join("\n"),
|
||||
logTruncated: true,
|
||||
},
|
||||
],
|
||||
truncated: true,
|
||||
});
|
||||
expect(second.failedJobs[0]?.logTail).toBe(first.failedJobs[0]?.logTail);
|
||||
expect(runner.calls.map((call) => call.args)).toEqual([
|
||||
["api", "repos/acme/repo/check-runs/12345"],
|
||||
["api", "repos/acme/repo/check-runs/12345/annotations", "-f", "per_page=20"],
|
||||
["api", "repos/acme/repo/actions/runs/456/jobs", "-f", "per_page=100"],
|
||||
["api", "repos/acme/repo/actions/jobs/789/logs"],
|
||||
["api", "repos/acme/repo/check-runs/12345"],
|
||||
["api", "repos/acme/repo/check-runs/12345/annotations", "-f", "per_page=20"],
|
||||
["api", "repos/acme/repo/actions/runs/456/jobs", "-f", "per_page=100"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("caps failed check jobs at five and caps each log tail to 16 KiB", async () => {
|
||||
const oversizedLog = "x".repeat(20 * 1024);
|
||||
const failedJobs = Array.from({ length: 6 }, (_, index) => ({
|
||||
id: 800 + index,
|
||||
name: `failed-${index + 1}`,
|
||||
status: "completed",
|
||||
conclusion: "failure",
|
||||
html_url: `https://github.com/acme/repo/actions/runs/456/job/${800 + index}`,
|
||||
completed_at: `2026-04-02T13:52:${10 + index}Z`,
|
||||
}));
|
||||
const runner = createRunner([
|
||||
JSON.stringify({
|
||||
id: 12345,
|
||||
name: "server-tests",
|
||||
status: "completed",
|
||||
conclusion: "failure",
|
||||
html_url: "https://github.com/acme/repo/actions/runs/456/job/789",
|
||||
check_suite: { workflow_run: { id: 456 } },
|
||||
}),
|
||||
JSON.stringify([]),
|
||||
JSON.stringify({ jobs: failedJobs }),
|
||||
oversizedLog,
|
||||
oversizedLog,
|
||||
oversizedLog,
|
||||
oversizedLog,
|
||||
oversizedLog,
|
||||
]);
|
||||
const service = createGitHubService({
|
||||
runner: runner.runner,
|
||||
resolveGhPath: async () => "/usr/bin/gh",
|
||||
now: () => 100,
|
||||
});
|
||||
|
||||
const details = await service.getGitHubCheckDetails({
|
||||
cwd: "/repo",
|
||||
repoOwner: "acme",
|
||||
repoName: "repo",
|
||||
checkRunId: 12345,
|
||||
});
|
||||
|
||||
expect(details.failedJobs.map((job) => job.jobId)).toEqual([800, 801, 802, 803, 804]);
|
||||
expect(details.failedJobs.map((job) => job.logTruncated)).toEqual([
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
]);
|
||||
expect(details.failedJobs.map((job) => Buffer.byteLength(job.logTail ?? "", "utf8"))).toEqual([
|
||||
16 * 1024,
|
||||
16 * 1024,
|
||||
16 * 1024,
|
||||
16 * 1024,
|
||||
16 * 1024,
|
||||
]);
|
||||
expect(runner.calls.map((call) => call.args)).toEqual([
|
||||
["api", "repos/acme/repo/check-runs/12345"],
|
||||
["api", "repos/acme/repo/check-runs/12345/annotations", "-f", "per_page=20"],
|
||||
["api", "repos/acme/repo/actions/runs/456/jobs", "-f", "per_page=100"],
|
||||
["api", "repos/acme/repo/actions/jobs/800/logs"],
|
||||
["api", "repos/acme/repo/actions/jobs/801/logs"],
|
||||
["api", "repos/acme/repo/actions/jobs/802/logs"],
|
||||
["api", "repos/acme/repo/actions/jobs/803/logs"],
|
||||
["api", "repos/acme/repo/actions/jobs/804/logs"],
|
||||
]);
|
||||
expect(details.truncated).toBe(true);
|
||||
});
|
||||
|
||||
it("marks check details truncated when annotations hit the page cap", async () => {
|
||||
const annotations = Array.from({ length: 20 }, (_, index) => ({
|
||||
path: `packages/server/src/file-${index}.ts`,
|
||||
start_line: index + 1,
|
||||
annotation_level: "failure",
|
||||
message: `Failure ${index + 1}`,
|
||||
}));
|
||||
const runner = createRunner([
|
||||
JSON.stringify({
|
||||
id: 12345,
|
||||
name: "server-tests",
|
||||
status: "completed",
|
||||
conclusion: "failure",
|
||||
html_url: "https://github.com/acme/repo/actions/runs/456/job/789",
|
||||
check_suite: { workflow_run: { id: 456 } },
|
||||
}),
|
||||
JSON.stringify(annotations),
|
||||
JSON.stringify({ jobs: [] }),
|
||||
]);
|
||||
const service = createGitHubService({
|
||||
runner: runner.runner,
|
||||
resolveGhPath: async () => "/usr/bin/gh",
|
||||
now: () => 100,
|
||||
});
|
||||
|
||||
const details = await service.getGitHubCheckDetails({
|
||||
cwd: "/repo",
|
||||
repoOwner: "acme",
|
||||
repoName: "repo",
|
||||
checkRunId: 12345,
|
||||
});
|
||||
|
||||
expect(details.annotations).toHaveLength(20);
|
||||
expect(details.truncated).toBe(true);
|
||||
});
|
||||
|
||||
it("marks check details truncated when workflow jobs hit the page cap", async () => {
|
||||
const jobs = Array.from({ length: 100 }, (_, index) => ({
|
||||
id: 900 + index,
|
||||
name: `job-${index + 1}`,
|
||||
status: "completed",
|
||||
conclusion: "success",
|
||||
html_url: `https://github.com/acme/repo/actions/runs/456/job/${900 + index}`,
|
||||
completed_at: `2026-04-02T13:52:${String(index % 60).padStart(2, "0")}Z`,
|
||||
}));
|
||||
const runner = createRunner([
|
||||
JSON.stringify({
|
||||
id: 12345,
|
||||
name: "server-tests",
|
||||
status: "completed",
|
||||
conclusion: "failure",
|
||||
html_url: "https://github.com/acme/repo/actions/runs/456/job/789",
|
||||
check_suite: { workflow_run: { id: 456 } },
|
||||
}),
|
||||
JSON.stringify([]),
|
||||
JSON.stringify({ jobs }),
|
||||
]);
|
||||
const service = createGitHubService({
|
||||
runner: runner.runner,
|
||||
resolveGhPath: async () => "/usr/bin/gh",
|
||||
now: () => 100,
|
||||
});
|
||||
|
||||
const details = await service.getGitHubCheckDetails({
|
||||
cwd: "/repo",
|
||||
repoOwner: "acme",
|
||||
repoName: "repo",
|
||||
checkRunId: 12345,
|
||||
});
|
||||
|
||||
expect(details.failedJobs).toEqual([]);
|
||||
expect(details.truncated).toBe(true);
|
||||
});
|
||||
|
||||
it("retries current PR view without statusCheckRollup when token permissions are insufficient", async () => {
|
||||
const runner = createScriptedRunner([
|
||||
{
|
||||
|
||||
@@ -6,6 +6,12 @@ import { runGitCommand } from "../utils/run-git-command.js";
|
||||
import { execCommand } from "../utils/spawn.js";
|
||||
|
||||
const DEFAULT_GITHUB_CACHE_TTL_MS = 30_000;
|
||||
const CHECK_ANNOTATION_PAGE_MAX = 20;
|
||||
const CHECK_LOG_TAIL_MAX_LINES = 200;
|
||||
const CHECK_LOG_TAIL_MAX_BYTES = 16 * 1024;
|
||||
const CHECK_LOG_TAIL_CACHE_MAX_ENTRIES = 128;
|
||||
const ACTIONS_JOB_PAGE_MAX = 100;
|
||||
const FAILED_CHECK_JOB_LIMIT = 5;
|
||||
export const GITHUB_POLL_FAST_INTERVAL_MS = 20_000;
|
||||
export const GITHUB_POLL_SLOW_INTERVAL_MS = 120_000;
|
||||
export const GITHUB_POLL_ERROR_BACKOFF_CAP_MS = 300_000;
|
||||
@@ -41,6 +47,7 @@ const GitHubPullRequestSummarySchema = z.object({
|
||||
|
||||
const PullRequestCheckRunNodeSchema = z.object({
|
||||
__typename: z.literal("CheckRun"),
|
||||
databaseId: z.number().nullable().optional(),
|
||||
name: z.string(),
|
||||
workflowName: z.string().nullable().optional(),
|
||||
conclusion: z.string().nullable().optional(),
|
||||
@@ -79,6 +86,59 @@ const LegacyPullRequestStatusCheckRollupSchema = z.object({
|
||||
contexts: z.array(z.unknown()),
|
||||
});
|
||||
|
||||
const GitHubCheckRunDetailsSchema = z.object({
|
||||
id: z.number(),
|
||||
name: z.string().catch(""),
|
||||
status: z.string().nullable().optional(),
|
||||
conclusion: z.string().nullable().optional(),
|
||||
html_url: z.string().nullable().optional(),
|
||||
details_url: z.string().nullable().optional(),
|
||||
output: z
|
||||
.object({
|
||||
title: z.string().nullable().optional(),
|
||||
summary: z.string().nullable().optional(),
|
||||
text: z.string().nullable().optional(),
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
check_suite: z
|
||||
.object({
|
||||
workflow_run: z
|
||||
.object({
|
||||
id: z.number().nullable().optional(),
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const GitHubCheckAnnotationSchema = z.object({
|
||||
path: z.string().optional(),
|
||||
start_line: z.number().optional(),
|
||||
end_line: z.number().optional(),
|
||||
annotation_level: z.string().optional(),
|
||||
message: z.string().optional(),
|
||||
title: z.string().optional(),
|
||||
raw_details: z.string().optional(),
|
||||
});
|
||||
|
||||
const GitHubCheckAnnotationsSchema = z.array(GitHubCheckAnnotationSchema).catch([]);
|
||||
|
||||
const GitHubActionsJobSchema = z.object({
|
||||
id: z.number(),
|
||||
name: z.string().catch(""),
|
||||
status: z.string().nullable().optional(),
|
||||
conclusion: z.string().nullable().optional(),
|
||||
html_url: z.string().nullable().optional(),
|
||||
completed_at: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
const GitHubActionsJobsSchema = z.object({
|
||||
jobs: z.array(GitHubActionsJobSchema).catch([]),
|
||||
});
|
||||
|
||||
const PullRequestReviewDecisionSchema = z
|
||||
.enum(["APPROVED", "CHANGES_REQUESTED", "REVIEW_REQUIRED"])
|
||||
.nullable()
|
||||
@@ -158,6 +218,7 @@ const TimelineAuthorSchema = z
|
||||
.object({
|
||||
login: z.string().optional(),
|
||||
url: z.string().nullable().optional(),
|
||||
avatarUrl: z.string().nullable().optional(),
|
||||
})
|
||||
.nullable()
|
||||
.optional();
|
||||
@@ -179,6 +240,29 @@ const PullRequestTimelineCommentNodeSchema = z.object({
|
||||
author: TimelineAuthorSchema,
|
||||
});
|
||||
|
||||
const PullRequestReviewThreadCommentNodeSchema = PullRequestTimelineCommentNodeSchema.extend({
|
||||
pullRequestReview: z
|
||||
.object({ id: z.string().catch("") })
|
||||
.nullable()
|
||||
.optional()
|
||||
.catch(null),
|
||||
});
|
||||
|
||||
const PullRequestReviewThreadNodeSchema = z.object({
|
||||
id: z.string().catch(""),
|
||||
path: z.string().catch(""),
|
||||
line: z.number().nullable().optional().catch(null),
|
||||
startLine: z.number().nullable().optional().catch(null),
|
||||
isResolved: z.boolean().catch(false),
|
||||
isOutdated: z.boolean().catch(false),
|
||||
comments: z
|
||||
.object({
|
||||
nodes: z.array(PullRequestReviewThreadCommentNodeSchema).catch([]),
|
||||
pageInfo: z.object({ hasNextPage: z.boolean().catch(false) }).catch({ hasNextPage: false }),
|
||||
})
|
||||
.catch({ nodes: [], pageInfo: { hasNextPage: false } }),
|
||||
});
|
||||
|
||||
const PullRequestTimelinePageInfoSchema = z.object({
|
||||
hasNextPage: z.boolean().catch(false),
|
||||
});
|
||||
@@ -203,6 +287,12 @@ const PullRequestTimelineGraphqlSchema = z.object({
|
||||
pageInfo: PullRequestTimelinePageInfoSchema.catch({ hasNextPage: false }),
|
||||
})
|
||||
.catch({ nodes: [], pageInfo: { hasNextPage: false } }),
|
||||
reviewThreads: z
|
||||
.object({
|
||||
nodes: z.array(PullRequestReviewThreadNodeSchema).catch([]),
|
||||
pageInfo: PullRequestTimelinePageInfoSchema.catch({ hasNextPage: false }),
|
||||
})
|
||||
.catch({ nodes: [], pageInfo: { hasNextPage: false } }),
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
@@ -328,6 +418,7 @@ query PullRequestTimeline($owner: String!, $name: String!, $number: Int!) {
|
||||
author {
|
||||
login
|
||||
url
|
||||
avatarUrl
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
@@ -343,6 +434,39 @@ query PullRequestTimeline($owner: String!, $name: String!, $number: Int!) {
|
||||
author {
|
||||
login
|
||||
url
|
||||
avatarUrl
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
}
|
||||
}
|
||||
reviewThreads(first: 100) {
|
||||
nodes {
|
||||
id
|
||||
path
|
||||
line
|
||||
startLine
|
||||
isResolved
|
||||
isOutdated
|
||||
comments(first: 100) {
|
||||
nodes {
|
||||
id
|
||||
body
|
||||
url
|
||||
createdAt
|
||||
author {
|
||||
login
|
||||
url
|
||||
avatarUrl
|
||||
}
|
||||
pullRequestReview {
|
||||
id
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
}
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
@@ -420,6 +544,8 @@ export interface PullRequestCheck {
|
||||
url: string | null;
|
||||
workflow?: string;
|
||||
duration?: string;
|
||||
checkRunId?: number;
|
||||
workflowRunId?: number;
|
||||
}
|
||||
|
||||
export type PullRequestChecksStatus = "none" | "pending" | "success" | "failure";
|
||||
@@ -472,6 +598,7 @@ interface PullRequestTimelineItemBase {
|
||||
id: string;
|
||||
author: string;
|
||||
authorUrl: string | null;
|
||||
avatarUrl: string | null;
|
||||
body: string;
|
||||
createdAt: number;
|
||||
url: string;
|
||||
@@ -484,8 +611,19 @@ export type PullRequestTimelineItem =
|
||||
})
|
||||
| (PullRequestTimelineItemBase & {
|
||||
kind: "comment";
|
||||
reviewId?: string;
|
||||
location?: PullRequestTimelineCommentLocation;
|
||||
});
|
||||
|
||||
export interface PullRequestTimelineCommentLocation {
|
||||
path: string;
|
||||
line?: number;
|
||||
startLine?: number;
|
||||
threadId?: string;
|
||||
isResolved?: boolean;
|
||||
isOutdated?: boolean;
|
||||
}
|
||||
|
||||
export type GitHubPullRequestTimelineErrorKind = "not_found" | "forbidden" | "unknown";
|
||||
|
||||
export interface GitHubPullRequestTimelineError {
|
||||
@@ -577,6 +715,53 @@ export type GetGitHubPullRequestTimelineOptions = {
|
||||
repoName: string;
|
||||
} & GitHubReadOptions;
|
||||
|
||||
export type GetGitHubCheckDetailsOptions = {
|
||||
cwd: string;
|
||||
repoOwner: string;
|
||||
repoName: string;
|
||||
checkRunId: number;
|
||||
workflowRunId?: number;
|
||||
} & GitHubReadOptions;
|
||||
|
||||
export interface GitHubCheckAnnotation {
|
||||
path?: string;
|
||||
startLine?: number;
|
||||
endLine?: number;
|
||||
annotationLevel?: string;
|
||||
message?: string;
|
||||
title?: string;
|
||||
rawDetails?: string;
|
||||
}
|
||||
|
||||
export interface GitHubCheckFailedJob {
|
||||
jobId: number;
|
||||
name: string;
|
||||
status?: string | null;
|
||||
conclusion?: string | null;
|
||||
url?: string | null;
|
||||
completedAt?: string;
|
||||
logTail?: string;
|
||||
logTruncated?: boolean;
|
||||
}
|
||||
|
||||
export interface GitHubCheckDetails {
|
||||
checkRunId: number;
|
||||
workflowRunId?: number | null;
|
||||
name: string;
|
||||
status?: string | null;
|
||||
conclusion?: string | null;
|
||||
url?: string | null;
|
||||
detailsUrl?: string | null;
|
||||
output?: {
|
||||
title?: string | null;
|
||||
summary?: string | null;
|
||||
text?: string | null;
|
||||
} | null;
|
||||
annotations: GitHubCheckAnnotation[];
|
||||
failedJobs: GitHubCheckFailedJob[];
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
export interface GitHubSearchResult {
|
||||
items: Array<{
|
||||
kind: "issue" | "pr";
|
||||
@@ -627,6 +812,7 @@ export interface GitHubService {
|
||||
getPullRequestTimeline(
|
||||
options: GetGitHubPullRequestTimelineOptions,
|
||||
): Promise<GitHubPullRequestTimeline>;
|
||||
getGitHubCheckDetails(options: GetGitHubCheckDetailsOptions): Promise<GitHubCheckDetails>;
|
||||
searchIssuesAndPrs(options: SearchGitHubIssuesAndPrsOptions): Promise<GitHubSearchResult>;
|
||||
createPullRequest(
|
||||
options: CreateGitHubPullRequestOptions,
|
||||
@@ -745,6 +931,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
||||
const cache = new Map<string, CacheEntry>();
|
||||
const inFlight = new Map<string, InFlightCacheEntry>();
|
||||
const pollTargets = new Map<string, GitHubPollTarget>();
|
||||
const checkLogTailCache = new Map<string, { logTail: string; logTruncated: boolean }>();
|
||||
let api!: GitHubService;
|
||||
|
||||
async function cached<T>(params: {
|
||||
@@ -1088,6 +1275,94 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
||||
});
|
||||
},
|
||||
|
||||
getGitHubCheckDetails(input) {
|
||||
return cached({
|
||||
cwd: input.cwd,
|
||||
method: "getGitHubCheckDetails",
|
||||
args: {
|
||||
repoOwner: input.repoOwner,
|
||||
repoName: input.repoName,
|
||||
checkRunId: input.checkRunId,
|
||||
workflowRunId: input.workflowRunId,
|
||||
},
|
||||
readOptions: input,
|
||||
load: async () => {
|
||||
const repoPath = `repos/${input.repoOwner}/${input.repoName}`;
|
||||
const checkRun = parseGitHubCheckRunDetails(
|
||||
await run(["api", `${repoPath}/check-runs/${input.checkRunId}`], { cwd: input.cwd }),
|
||||
);
|
||||
const annotations = parseGitHubCheckAnnotations(
|
||||
await run(
|
||||
[
|
||||
"api",
|
||||
`${repoPath}/check-runs/${input.checkRunId}/annotations`,
|
||||
"-f",
|
||||
`per_page=${CHECK_ANNOTATION_PAGE_MAX}`,
|
||||
],
|
||||
{
|
||||
cwd: input.cwd,
|
||||
},
|
||||
),
|
||||
);
|
||||
const workflowRunId = input.workflowRunId ?? checkRun.workflowRunId ?? null;
|
||||
const failedJobs: GitHubCheckFailedJob[] = [];
|
||||
let truncated = annotations.length >= CHECK_ANNOTATION_PAGE_MAX;
|
||||
|
||||
if (typeof workflowRunId === "number") {
|
||||
const jobs = parseGitHubActionsJobs(
|
||||
await run(
|
||||
[
|
||||
"api",
|
||||
`${repoPath}/actions/runs/${workflowRunId}/jobs`,
|
||||
"-f",
|
||||
`per_page=${ACTIONS_JOB_PAGE_MAX}`,
|
||||
],
|
||||
{
|
||||
cwd: input.cwd,
|
||||
},
|
||||
),
|
||||
);
|
||||
const failed = jobs.filter(isFailedActionsJob);
|
||||
truncated ||= jobs.length >= ACTIONS_JOB_PAGE_MAX;
|
||||
truncated ||= failed.length > FAILED_CHECK_JOB_LIMIT;
|
||||
for (const job of failed.slice(0, FAILED_CHECK_JOB_LIMIT)) {
|
||||
const log = await getCachedCheckLogTail({
|
||||
cwd: input.cwd,
|
||||
repoPath,
|
||||
job,
|
||||
run,
|
||||
cache: checkLogTailCache,
|
||||
});
|
||||
truncated ||= log.logTruncated;
|
||||
failedJobs.push({
|
||||
jobId: job.jobId,
|
||||
name: job.name,
|
||||
status: job.status,
|
||||
conclusion: job.conclusion,
|
||||
url: job.url,
|
||||
logTail: log.logTail,
|
||||
logTruncated: log.logTruncated,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
checkRunId: checkRun.checkRunId,
|
||||
workflowRunId,
|
||||
name: checkRun.name,
|
||||
status: checkRun.status,
|
||||
conclusion: checkRun.conclusion,
|
||||
url: checkRun.url,
|
||||
detailsUrl: checkRun.detailsUrl,
|
||||
output: checkRun.output,
|
||||
annotations,
|
||||
failedJobs,
|
||||
truncated,
|
||||
};
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async searchIssuesAndPrs(input) {
|
||||
if (input.force && !input.reason) {
|
||||
throw new Error("GitHubService forced read requires a reason");
|
||||
@@ -1945,10 +2220,19 @@ function parsePullRequestTimeline(
|
||||
): GitHubPullRequestTimeline {
|
||||
const parsed = PullRequestTimelineGraphqlSchema.parse(JSON.parse(stdout || "{}"));
|
||||
const pullRequest = parsed.data?.repository?.pullRequest;
|
||||
const reviewThreadItems = pullRequest
|
||||
? pullRequest.reviewThreads.nodes.flatMap(toPullRequestTimelineReviewThreadItems)
|
||||
: [];
|
||||
const reviewThreadItemIds = new Set(
|
||||
reviewThreadItems.map((item) => item.id).filter((id) => id.length > 0),
|
||||
);
|
||||
const items = pullRequest
|
||||
? [
|
||||
...pullRequest.reviews.nodes.flatMap(toPullRequestTimelineReviewItem),
|
||||
...pullRequest.comments.nodes.map(toPullRequestTimelineCommentItem),
|
||||
...pullRequest.comments.nodes
|
||||
.filter((comment) => !reviewThreadItemIds.has(comment.id))
|
||||
.map(toPullRequestTimelineCommentItem),
|
||||
...reviewThreadItems,
|
||||
].sort(compareTimelineItems)
|
||||
: [];
|
||||
return {
|
||||
@@ -1956,9 +2240,12 @@ function parsePullRequestTimeline(
|
||||
repoOwner: identity.repoOwner,
|
||||
repoName: identity.repoName,
|
||||
items,
|
||||
// S3 deliberately caps timeline fetches at the first 100 reviews and first 100 comments.
|
||||
// S3 deliberately caps timeline fetches at the first 100 reviews, comments, and review threads.
|
||||
truncated: Boolean(
|
||||
pullRequest?.reviews.pageInfo.hasNextPage || pullRequest?.comments.pageInfo.hasNextPage,
|
||||
pullRequest?.reviews.pageInfo.hasNextPage ||
|
||||
pullRequest?.comments.pageInfo.hasNextPage ||
|
||||
pullRequest?.reviewThreads.pageInfo.hasNextPage ||
|
||||
pullRequest?.reviewThreads.nodes.some((thread) => thread.comments.pageInfo.hasNextPage),
|
||||
),
|
||||
error: pullRequest ? null : { kind: "not_found", message: "Pull request not found" },
|
||||
};
|
||||
@@ -1977,6 +2264,7 @@ function toPullRequestTimelineReviewItem(
|
||||
id: review.id,
|
||||
author: review.author?.login ?? "unknown",
|
||||
authorUrl: review.author?.url ?? null,
|
||||
avatarUrl: review.author?.avatarUrl ?? null,
|
||||
body: review.body ?? "",
|
||||
createdAt: parseOptionalTime(review.submittedAt ?? null),
|
||||
url: review.url,
|
||||
@@ -1993,12 +2281,146 @@ function toPullRequestTimelineCommentItem(
|
||||
id: comment.id,
|
||||
author: comment.author?.login ?? "unknown",
|
||||
authorUrl: comment.author?.url ?? null,
|
||||
avatarUrl: comment.author?.avatarUrl ?? null,
|
||||
body: comment.body ?? "",
|
||||
createdAt: parseOptionalTime(comment.createdAt ?? null),
|
||||
url: comment.url,
|
||||
};
|
||||
}
|
||||
|
||||
function toPullRequestTimelineReviewThreadItems(
|
||||
thread: z.infer<typeof PullRequestReviewThreadNodeSchema>,
|
||||
): PullRequestTimelineItem[] {
|
||||
return thread.comments.nodes.map((comment) => ({
|
||||
...toPullRequestTimelineCommentItem(comment),
|
||||
...(comment.pullRequestReview?.id ? { reviewId: comment.pullRequestReview.id } : {}),
|
||||
location: {
|
||||
path: thread.path,
|
||||
...(thread.line !== null && thread.line !== undefined ? { line: thread.line } : {}),
|
||||
...(thread.startLine !== null && thread.startLine !== undefined
|
||||
? { startLine: thread.startLine }
|
||||
: {}),
|
||||
...(thread.id ? { threadId: thread.id } : {}),
|
||||
isResolved: thread.isResolved,
|
||||
isOutdated: thread.isOutdated,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
function parseGitHubCheckRunDetails(stdout: string): GitHubCheckDetails {
|
||||
const parsed = GitHubCheckRunDetailsSchema.parse(JSON.parse(stdout || "{}"));
|
||||
return {
|
||||
checkRunId: parsed.id,
|
||||
workflowRunId: parsed.check_suite?.workflow_run?.id ?? null,
|
||||
name: parsed.name,
|
||||
status: parsed.status,
|
||||
conclusion: parsed.conclusion,
|
||||
url: parsed.html_url,
|
||||
detailsUrl: parsed.details_url,
|
||||
output: parsed.output,
|
||||
annotations: [],
|
||||
failedJobs: [],
|
||||
truncated: false,
|
||||
};
|
||||
}
|
||||
|
||||
function parseGitHubCheckAnnotations(stdout: string): GitHubCheckAnnotation[] {
|
||||
return GitHubCheckAnnotationsSchema.parse(JSON.parse(stdout || "[]")).map((annotation) => {
|
||||
const result: GitHubCheckAnnotation = {};
|
||||
if (annotation.path) result.path = annotation.path;
|
||||
if (annotation.start_line !== undefined) result.startLine = annotation.start_line;
|
||||
if (annotation.end_line !== undefined) result.endLine = annotation.end_line;
|
||||
if (annotation.annotation_level) result.annotationLevel = annotation.annotation_level;
|
||||
if (annotation.message) result.message = annotation.message;
|
||||
if (annotation.title) result.title = annotation.title;
|
||||
if (annotation.raw_details) result.rawDetails = annotation.raw_details;
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
function parseGitHubActionsJobs(stdout: string): GitHubCheckFailedJob[] {
|
||||
return GitHubActionsJobsSchema.parse(JSON.parse(stdout || "{}")).jobs.map((job) => {
|
||||
const result: GitHubCheckFailedJob = {
|
||||
jobId: job.id,
|
||||
name: job.name,
|
||||
status: job.status,
|
||||
conclusion: job.conclusion,
|
||||
url: job.html_url,
|
||||
};
|
||||
if (job.completed_at) result.completedAt = job.completed_at;
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
function isFailedActionsJob(job: GitHubCheckFailedJob): boolean {
|
||||
return (
|
||||
job.conclusion === "failure" ||
|
||||
job.conclusion === "cancelled" ||
|
||||
job.conclusion === "timed_out" ||
|
||||
job.conclusion === "action_required"
|
||||
);
|
||||
}
|
||||
|
||||
async function getCachedCheckLogTail(input: {
|
||||
cwd: string;
|
||||
repoPath: string;
|
||||
job: GitHubCheckFailedJob & { completedAt?: string };
|
||||
run: (args: string[], options: GitHubCommandRunnerOptions) => Promise<string>;
|
||||
cache: Map<string, { logTail: string; logTruncated: boolean }>;
|
||||
}): Promise<{ logTail: string; logTruncated: boolean }> {
|
||||
const key = `${input.job.jobId}:${input.job.completedAt ?? ""}`;
|
||||
const cached = input.cache.get(key);
|
||||
if (cached) {
|
||||
input.cache.delete(key);
|
||||
input.cache.set(key, cached);
|
||||
return cached;
|
||||
}
|
||||
|
||||
const capped = capCheckLogTail(
|
||||
await input.run(["api", `${input.repoPath}/actions/jobs/${input.job.jobId}/logs`], {
|
||||
cwd: input.cwd,
|
||||
}),
|
||||
);
|
||||
input.cache.set(key, capped);
|
||||
while (input.cache.size > CHECK_LOG_TAIL_CACHE_MAX_ENTRIES) {
|
||||
const oldestKey = input.cache.keys().next().value;
|
||||
if (!oldestKey) {
|
||||
break;
|
||||
}
|
||||
input.cache.delete(oldestKey);
|
||||
}
|
||||
return capped;
|
||||
}
|
||||
|
||||
function capCheckLogTail(log: string): { logTail: string; logTruncated: boolean } {
|
||||
const lines = log.split("\n");
|
||||
let truncated = lines.length > CHECK_LOG_TAIL_MAX_LINES;
|
||||
let tail = lines.slice(-CHECK_LOG_TAIL_MAX_LINES).join("\n");
|
||||
|
||||
if (Buffer.byteLength(tail, "utf8") > CHECK_LOG_TAIL_MAX_BYTES) {
|
||||
truncated = true;
|
||||
tail = utf8SuffixWithinBytes(tail, CHECK_LOG_TAIL_MAX_BYTES);
|
||||
}
|
||||
|
||||
return { logTail: tail, logTruncated: truncated };
|
||||
}
|
||||
|
||||
function utf8SuffixWithinBytes(value: string, maxBytes: number): string {
|
||||
let lowerBound = 0;
|
||||
let upperBound = value.length;
|
||||
|
||||
while (lowerBound < upperBound) {
|
||||
const midpoint = Math.floor((lowerBound + upperBound) / 2);
|
||||
if (Buffer.byteLength(value.slice(midpoint), "utf8") > maxBytes) {
|
||||
lowerBound = midpoint + 1;
|
||||
} else {
|
||||
upperBound = midpoint;
|
||||
}
|
||||
}
|
||||
|
||||
return value.slice(lowerBound);
|
||||
}
|
||||
|
||||
function mapTimelineReviewState(
|
||||
state: string,
|
||||
body: string,
|
||||
@@ -2161,6 +2583,10 @@ function buildPullRequestCheck(
|
||||
...(typeof context.workflowName === "string" && context.workflowName.trim().length > 0
|
||||
? { workflow: context.workflowName }
|
||||
: {}),
|
||||
...(typeof context.databaseId === "number" ? { checkRunId: context.databaseId } : {}),
|
||||
...(typeof context.checkSuite?.workflowRun?.databaseId === "number"
|
||||
? { workflowRunId: context.checkSuite.workflowRun.databaseId }
|
||||
: {}),
|
||||
...formatCheckRunDuration(context),
|
||||
recency: getCheckRunRecency(context),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user