Files
claude-code-action/src/github/data/fetcher.ts
Ashwin Bhat 41dd0aa695 feat: use GitHub display name in Co-authored-by trailers (#163)
* feat: use GitHub display name in Co-authored-by trailers

- Add name field to GitHubAuthor type
- Update GraphQL queries to fetch user display names
- Add triggerDisplayName to CommonFields type
- Extract display name from fetched GitHub data in prepareContext
- Update Co-authored-by trailer generation to use display name when available

This ensures consistency with GitHub's web interface behavior where
Co-authored-by trailers use the user's display name rather than username.

Co-authored-by: ashwin-ant <ashwin-ant@users.noreply.github.com>

* fix: update GraphQL queries to handle Actor type correctly

The name field is only available on the User subtype of Actor in GitHub's
GraphQL API. This commit updates the queries to use inline fragments
(... on User) to conditionally access the name field when the actor is
a User type.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: clarify Co-authored-by instructions in prompt

Replace interpolated values with clear references to XML tags and add
explicit formatting instructions. This makes it clearer how to use the
GitHub display name when available while maintaining the username for
the email portion.

Changes:
- Use explicit references to <trigger_display_name> and <trigger_username> tags
- Add clear formatting instructions and example
- Explain fallback behavior when display name is not available

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: fetch trigger user display name via dedicated GraphQL query

Instead of trying to extract the display name from existing data (which
was incomplete due to Actor type limitations), we now:

- Add a dedicated USER_QUERY to fetch user display names
- Pass the trigger username to fetchGitHubData
- Fetch the display name during data collection phase
- Simplify prepareContext to use the pre-fetched display name

This ensures we always get the correct display name for Co-authored-by
trailers, regardless of where the trigger came from.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
Co-authored-by: ashwin-ant <ashwin-ant@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2025-06-12 18:16:36 -04:00

234 lines
5.9 KiB
TypeScript

import { execSync } from "child_process";
import type { Octokits } from "../api/client";
import { ISSUE_QUERY, PR_QUERY, USER_QUERY } from "../api/queries/github";
import type {
GitHubComment,
GitHubFile,
GitHubIssue,
GitHubPullRequest,
GitHubReview,
IssueQueryResponse,
PullRequestQueryResponse,
} from "../types";
import type { CommentWithImages } from "../utils/image-downloader";
import { downloadCommentImages } from "../utils/image-downloader";
type FetchDataParams = {
octokits: Octokits;
repository: string;
prNumber: string;
isPR: boolean;
triggerUsername?: string;
};
export type GitHubFileWithSHA = GitHubFile & {
sha: string;
};
export type FetchDataResult = {
contextData: GitHubPullRequest | GitHubIssue;
comments: GitHubComment[];
changedFiles: GitHubFile[];
changedFilesWithSHA: GitHubFileWithSHA[];
reviewData: { nodes: GitHubReview[] } | null;
imageUrlMap: Map<string, string>;
triggerDisplayName?: string | null;
};
export async function fetchGitHubData({
octokits,
repository,
prNumber,
isPR,
triggerUsername,
}: FetchDataParams): Promise<FetchDataResult> {
const [owner, repo] = repository.split("/");
if (!owner || !repo) {
throw new Error("Invalid repository format. Expected 'owner/repo'.");
}
let contextData: GitHubPullRequest | GitHubIssue | null = null;
let comments: GitHubComment[] = [];
let changedFiles: GitHubFile[] = [];
let reviewData: { nodes: GitHubReview[] } | null = null;
try {
if (isPR) {
// Fetch PR data with all comments and file information
const prResult = await octokits.graphql<PullRequestQueryResponse>(
PR_QUERY,
{
owner,
repo,
number: parseInt(prNumber),
},
);
if (prResult.repository.pullRequest) {
const pullRequest = prResult.repository.pullRequest;
contextData = pullRequest;
changedFiles = pullRequest.files.nodes || [];
comments = pullRequest.comments?.nodes || [];
reviewData = pullRequest.reviews || [];
console.log(`Successfully fetched PR #${prNumber} data`);
} else {
throw new Error(`PR #${prNumber} not found`);
}
} else {
// Fetch issue data
const issueResult = await octokits.graphql<IssueQueryResponse>(
ISSUE_QUERY,
{
owner,
repo,
number: parseInt(prNumber),
},
);
if (issueResult.repository.issue) {
contextData = issueResult.repository.issue;
comments = contextData?.comments?.nodes || [];
console.log(`Successfully fetched issue #${prNumber} data`);
} else {
throw new Error(`Issue #${prNumber} not found`);
}
}
} catch (error) {
console.error(`Failed to fetch ${isPR ? "PR" : "issue"} data:`, error);
throw new Error(`Failed to fetch ${isPR ? "PR" : "issue"} data`);
}
// Compute SHAs for changed files
let changedFilesWithSHA: GitHubFileWithSHA[] = [];
if (isPR && changedFiles.length > 0) {
changedFilesWithSHA = changedFiles.map((file) => {
// Don't compute SHA for deleted files
if (file.changeType === "DELETED") {
return {
...file,
sha: "deleted",
};
}
try {
// Use git hash-object to compute the SHA for the current file content
const sha = execSync(`git hash-object "${file.path}"`, {
encoding: "utf-8",
}).trim();
return {
...file,
sha,
};
} catch (error) {
console.warn(`Failed to compute SHA for ${file.path}:`, error);
// Return original file without SHA if computation fails
return {
...file,
sha: "unknown",
};
}
});
}
// Prepare all comments for image processing
const issueComments: CommentWithImages[] = comments
.filter((c) => c.body)
.map((c) => ({
type: "issue_comment" as const,
id: c.databaseId,
body: c.body,
}));
const reviewBodies: CommentWithImages[] =
reviewData?.nodes
?.filter((r) => r.body)
.map((r) => ({
type: "review_body" as const,
id: r.databaseId,
pullNumber: prNumber,
body: r.body,
})) ?? [];
const reviewComments: CommentWithImages[] =
reviewData?.nodes
?.flatMap((r) => r.comments?.nodes ?? [])
.filter((c) => c.body)
.map((c) => ({
type: "review_comment" as const,
id: c.databaseId,
body: c.body,
})) ?? [];
// Add the main issue/PR body if it has content
const mainBody: CommentWithImages[] = contextData.body
? [
{
...(isPR
? {
type: "pr_body" as const,
pullNumber: prNumber,
body: contextData.body,
}
: {
type: "issue_body" as const,
issueNumber: prNumber,
body: contextData.body,
}),
},
]
: [];
const allComments = [
...mainBody,
...issueComments,
...reviewBodies,
...reviewComments,
];
const imageUrlMap = await downloadCommentImages(
octokits,
owner,
repo,
allComments,
);
// Fetch trigger user display name if username is provided
let triggerDisplayName: string | null | undefined;
if (triggerUsername) {
triggerDisplayName = await fetchUserDisplayName(octokits, triggerUsername);
}
return {
contextData,
comments,
changedFiles,
changedFilesWithSHA,
reviewData,
imageUrlMap,
triggerDisplayName,
};
}
export type UserQueryResponse = {
user: {
name: string | null;
};
};
export async function fetchUserDisplayName(
octokits: Octokits,
login: string,
): Promise<string | null> {
try {
const result = await octokits.graphql<UserQueryResponse>(USER_QUERY, {
login,
});
return result.user.name;
} catch (error) {
console.warn(`Failed to fetch user display name for ${login}:`, error);
return null;
}
}