mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-08-03 01:38:30 +08:00
fix: handle null comment/review author from deleted accounts (#1490)
GitHub's GraphQL author field is null when the account behind a comment, review, PR, or issue has been deleted (the ghost user). The action typed author as non-null and read author.login directly, so a single comment from a deleted account threw and was swallowed into a generic 'Failed to fetch PR/issue data', failing the entire run. Make author nullable on the four affected types and fall back to 'ghost' at each login read. With the type nullable, tsc flags every dereference, so all sites are covered.
This commit is contained in:
parent
2988cbe14a
commit
3e807ec379
@ -204,11 +204,9 @@ export function isBodySafeToUse(
|
||||
* @param excludeActors - Comma-separated actors to exclude
|
||||
* @returns Filtered array of comments
|
||||
*/
|
||||
export function filterCommentsByActor<T extends { author: { login: string } }>(
|
||||
comments: T[],
|
||||
includeActors: string = "",
|
||||
excludeActors: string = "",
|
||||
): T[] {
|
||||
export function filterCommentsByActor<
|
||||
T extends { author: { login: string } | null },
|
||||
>(comments: T[], includeActors: string = "", excludeActors: string = ""): T[] {
|
||||
const includeParsed = parseActorFilter(includeActors);
|
||||
const excludeParsed = parseActorFilter(excludeActors);
|
||||
|
||||
@ -219,7 +217,9 @@ export function filterCommentsByActor<T extends { author: { login: string } }>(
|
||||
|
||||
return comments.filter((comment) =>
|
||||
shouldIncludeCommentByActor(
|
||||
comment.author.login,
|
||||
// author is null for comments from deleted ("ghost") accounts; treat them
|
||||
// as the "ghost" login so filtering never dereferences null and crashes.
|
||||
comment.author?.login ?? "ghost",
|
||||
includeParsed,
|
||||
excludeParsed,
|
||||
),
|
||||
|
||||
@ -21,7 +21,7 @@ export function formatContext(
|
||||
const prData = contextData as GitHubPullRequest;
|
||||
const sanitizedTitle = sanitizeContent(prData.title);
|
||||
return `PR Title: ${sanitizedTitle}
|
||||
PR Author: ${prData.author.login}
|
||||
PR Author: ${prData.author?.login ?? "ghost"}
|
||||
PR Branch: ${prData.headRefName} -> ${prData.baseRefName}
|
||||
PR State: ${prData.state}
|
||||
PR Labels: ${formatLabels(prData.labels.nodes)}
|
||||
@ -33,7 +33,7 @@ Changed Files: ${prData.files.nodes.length} files`;
|
||||
const issueData = contextData as GitHubIssue;
|
||||
const sanitizedTitle = sanitizeContent(issueData.title);
|
||||
return `Issue Title: ${sanitizedTitle}
|
||||
Issue Author: ${issueData.author.login}
|
||||
Issue Author: ${issueData.author?.login ?? "ghost"}
|
||||
Issue State: ${issueData.state}
|
||||
Issue Labels: ${formatLabels(issueData.labels.nodes)}`;
|
||||
}
|
||||
@ -71,7 +71,7 @@ export function formatComments(
|
||||
|
||||
body = sanitizeContent(body);
|
||||
|
||||
return `[${comment.author.login} at ${comment.createdAt}]: ${body}`;
|
||||
return `[${comment.author?.login ?? "ghost"} at ${comment.createdAt}]: ${body}`;
|
||||
})
|
||||
.join("\n\n");
|
||||
}
|
||||
@ -85,7 +85,7 @@ export function formatReviewComments(
|
||||
}
|
||||
|
||||
const formattedReviews = reviewData.nodes.map((review) => {
|
||||
let reviewOutput = `[Review by ${review.author.login} at ${review.submittedAt}]: ${review.state}`;
|
||||
let reviewOutput = `[Review by ${review.author?.login ?? "ghost"} at ${review.submittedAt}]: ${review.state}`;
|
||||
|
||||
if (review.body && review.body.trim()) {
|
||||
let body = review.body;
|
||||
|
||||
@ -1,4 +1,8 @@
|
||||
// Types for GitHub GraphQL query responses
|
||||
|
||||
// GitHub's GraphQL `author`/`actor` fields resolve to null when the underlying
|
||||
// account has been deleted (the "ghost" user). Any field typed as
|
||||
// `GitHubAuthor | null` can therefore be null at runtime and must be guarded.
|
||||
export type GitHubAuthor = {
|
||||
login: string;
|
||||
name?: string;
|
||||
@ -8,7 +12,7 @@ export type GitHubComment = {
|
||||
id: string;
|
||||
databaseId: string;
|
||||
body: string;
|
||||
author: GitHubAuthor;
|
||||
author: GitHubAuthor | null;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
lastEditedAt?: string;
|
||||
@ -39,7 +43,7 @@ export type GitHubFile = {
|
||||
export type GitHubReview = {
|
||||
id: string;
|
||||
databaseId: string;
|
||||
author: GitHubAuthor;
|
||||
author: GitHubAuthor | null;
|
||||
body: string;
|
||||
state: string;
|
||||
submittedAt: string;
|
||||
@ -53,7 +57,7 @@ export type GitHubReview = {
|
||||
export type GitHubPullRequest = {
|
||||
title: string;
|
||||
body: string;
|
||||
author: GitHubAuthor;
|
||||
author: GitHubAuthor | null;
|
||||
baseRefName: string;
|
||||
headRefName: string;
|
||||
headRefOid: string;
|
||||
@ -95,7 +99,7 @@ export type GitHubPullRequest = {
|
||||
export type GitHubIssue = {
|
||||
title: string;
|
||||
body: string;
|
||||
author: GitHubAuthor;
|
||||
author: GitHubAuthor | null;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
lastEditedAt?: string;
|
||||
|
||||
@ -1499,4 +1499,42 @@ describe("filterCommentsByActor", () => {
|
||||
const filtered = filterCommentsByActor(comments, "user1", "");
|
||||
expect(filtered).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("does not crash on comments from deleted (null-author) accounts", () => {
|
||||
// GitHub's GraphQL returns author: null for comments whose account was
|
||||
// deleted. With an exclude filter set (the exact `*[bot]` config we
|
||||
// recommend), the null author must not throw when dereferenced.
|
||||
const comments = [
|
||||
{ author: { login: "user1" }, body: "comment1" },
|
||||
{ author: null, body: "from a deleted account" },
|
||||
{ author: { login: "bot[bot]" }, body: "comment3" },
|
||||
];
|
||||
|
||||
const { filterCommentsByActor } = require("../src/github/data/fetcher");
|
||||
const filtered = filterCommentsByActor(comments, "", "*[bot]");
|
||||
// ghost comment is retained (it matches no exclude pattern); the bot is dropped.
|
||||
expect(filtered).toHaveLength(2);
|
||||
expect(filtered.map((c: any) => c.body)).toEqual([
|
||||
"comment1",
|
||||
"from a deleted account",
|
||||
]);
|
||||
});
|
||||
|
||||
test("treats null author as the 'ghost' login for include/exclude", () => {
|
||||
const comments = [
|
||||
{ author: null, body: "from a deleted account" },
|
||||
{ author: { login: "user1" }, body: "comment2" },
|
||||
];
|
||||
|
||||
const { filterCommentsByActor } = require("../src/github/data/fetcher");
|
||||
// Excluding "ghost" removes the deleted-account comment.
|
||||
expect(filterCommentsByActor(comments, "", "ghost")).toHaveLength(1);
|
||||
expect(filterCommentsByActor(comments, "", "ghost")[0].body).toBe(
|
||||
"comment2",
|
||||
);
|
||||
// Including only "ghost" keeps just the deleted-account comment.
|
||||
const onlyGhost = filterCommentsByActor(comments, "ghost", "");
|
||||
expect(onlyGhost).toHaveLength(1);
|
||||
expect(onlyGhost[0].body).toBe("from a deleted account");
|
||||
});
|
||||
});
|
||||
|
||||
@ -159,6 +159,21 @@ Issue State: OPEN
|
||||
Issue Labels: architecture, agent-sdk, drift:functional`,
|
||||
);
|
||||
});
|
||||
|
||||
test("renders a deleted (null-author) issue author as 'ghost'", () => {
|
||||
const issueData: GitHubIssue = {
|
||||
title: "Test Issue",
|
||||
body: "Issue body",
|
||||
author: null,
|
||||
createdAt: "2023-01-01T00:00:00Z",
|
||||
state: "OPEN",
|
||||
labels: { nodes: [] },
|
||||
comments: { nodes: [] },
|
||||
};
|
||||
|
||||
const result = formatContext(issueData, false);
|
||||
expect(result).toContain("Issue Author: ghost");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatBody", () => {
|
||||
@ -252,6 +267,24 @@ describe("formatComments", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("renders deleted (null-author) comments as 'ghost'", () => {
|
||||
// GitHub returns author: null for comments from deleted accounts.
|
||||
const comments: GitHubComment[] = [
|
||||
{
|
||||
id: "1",
|
||||
databaseId: "100001",
|
||||
body: "From a deleted account",
|
||||
author: null,
|
||||
createdAt: "2023-01-01T00:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
const result = formatComments(comments);
|
||||
expect(result).toBe(
|
||||
"[ghost at 2023-01-01T00:00:00Z]: From a deleted account",
|
||||
);
|
||||
});
|
||||
|
||||
test("returns empty string for empty comments array", () => {
|
||||
const result = formatComments([]);
|
||||
expect(result).toBe("");
|
||||
@ -494,6 +527,29 @@ describe("formatReviewComments", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("renders deleted (null-author) reviews as 'ghost'", () => {
|
||||
const reviewData = {
|
||||
nodes: [
|
||||
{
|
||||
id: "review1",
|
||||
databaseId: "300099",
|
||||
author: null,
|
||||
body: "Left before deleting the account",
|
||||
state: "COMMENTED",
|
||||
submittedAt: "2023-01-01T00:00:00Z",
|
||||
comments: {
|
||||
nodes: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = formatReviewComments(reviewData);
|
||||
expect(result).toBe(
|
||||
`[Review by ghost at 2023-01-01T00:00:00Z]: COMMENTED\nLeft before deleting the account`,
|
||||
);
|
||||
});
|
||||
|
||||
test("formats multiple reviews correctly", () => {
|
||||
const reviewData = {
|
||||
nodes: [
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user