-
Notifications
You must be signed in to change notification settings - Fork 432
Fix add-reviewer cross-repo targeting and Copilot bot review requests #35326
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,7 +9,7 @@ | |
| */ | ||
|
|
||
| /** | ||
| * @typedef {{ reviewers?: Array<string|null|undefined|false>, team_reviewers?: Array<string|null|undefined|false>, pull_request_number?: number|string }} AddReviewerMessage | ||
| * @typedef {{ reviewers?: Array<string|null|undefined|false>, team_reviewers?: Array<string|null|undefined|false>, pull_request_number?: number|string, repo?: string }} AddReviewerMessage | ||
| */ | ||
|
|
||
| /** @type {string} Safe output type handled by this module */ | ||
|
|
@@ -22,7 +22,8 @@ const { logStagedPreviewInfo } = require("./staged_preview.cjs"); | |
| const { isStagedMode, checkRequiredFilter } = require("./safe_output_helpers.cjs"); | ||
| const { createAuthenticatedGitHubClient } = require("./handler_auth.cjs"); | ||
| const { attachExecutionState, extractReviewStateFromData, fetchPullRequestReviewState } = require("./safe_output_execution_metadata.cjs"); | ||
| const { COPILOT_REVIEWER_BOT } = require("./constants.cjs"); | ||
| const { resolveTargetRepoConfig, resolveAndValidateRepo } = require("./repo_helpers.cjs"); | ||
| const { COPILOT_REVIEWER_BOT, COPILOT_REVIEWER_BOT_ID } = require("./constants.cjs"); | ||
|
|
||
| /** | ||
| * Main handler factory for add_reviewer | ||
|
|
@@ -33,6 +34,7 @@ async function main(config = {}) { | |
| const allowedReviewers = config.allowed ?? []; | ||
| const allowedTeamReviewers = config.allowed_team_reviewers ?? []; | ||
| const maxCount = config.max ?? 10; | ||
| const { defaultTargetRepo, allowedRepos } = resolveTargetRepoConfig(config); | ||
| const githubClient = await createAuthenticatedGitHubClient(config); | ||
| const isStaged = isStagedMode(config); | ||
|
|
||
|
|
@@ -42,13 +44,45 @@ async function main(config = {}) { | |
| if (requiredTitlePrefix) core.info(`Required title prefix: ${requiredTitlePrefix}`); | ||
|
|
||
| core.info(`Add reviewer configuration: max=${maxCount}`); | ||
| core.info(`Default target repo: ${defaultTargetRepo}`); | ||
| if (allowedRepos.size > 0) { | ||
| core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); | ||
| } | ||
| if (allowedReviewers.length > 0) { | ||
| core.info(`Allowed reviewers: ${allowedReviewers.join(", ")}`); | ||
| } | ||
| if (allowedTeamReviewers.length > 0) { | ||
| core.info(`Allowed team reviewers: ${allowedTeamReviewers.join(", ")}`); | ||
| } | ||
|
|
||
| /** @type {string|null} Copilot reviewer bot node ID, resolved once and cached per handler instance */ | ||
| let copilotBotNodeIdCache = null; | ||
|
|
||
| /** | ||
| * Resolves the Copilot reviewer bot's GraphQL node ID for the current GitHub instance. | ||
| * Uses the REST users API so the result is correct on GitHub.com and GHES alike. | ||
| * Caches the resolved ID for the lifetime of this handler to avoid redundant requests. | ||
| * Falls back to the built-in GitHub.com constant when the API call fails. | ||
| * @returns {Promise<string>} GraphQL node ID for the Copilot reviewer bot | ||
| */ | ||
| async function resolveCopilotBotNodeId() { | ||
| if (copilotBotNodeIdCache !== null) { | ||
| return copilotBotNodeIdCache; | ||
| } | ||
| try { | ||
| const response = await githubClient.rest.users.getByUsername({ username: COPILOT_REVIEWER_BOT }); | ||
| const nodeId = response?.data?.node_id; | ||
| if (nodeId) { | ||
| copilotBotNodeIdCache = nodeId; | ||
| return nodeId; | ||
| } | ||
| } catch (err) { | ||
| core.warning(`Could not resolve Copilot reviewer bot node ID at runtime (${getErrorMessage(err)}); using built-in fallback`); | ||
| } | ||
| copilotBotNodeIdCache = COPILOT_REVIEWER_BOT_ID; | ||
| return copilotBotNodeIdCache; | ||
| } | ||
|
|
||
| let processedCount = 0; | ||
|
|
||
| /** | ||
|
|
@@ -83,8 +117,17 @@ async function main(config = {}) { | |
| }; | ||
| } | ||
|
|
||
| const repoParts = { owner: context.repo.owner, repo: context.repo.repo }; | ||
| const itemRepo = `${repoParts.owner}/${repoParts.repo}`; | ||
| const repoResult = resolveAndValidateRepo(message, defaultTargetRepo, allowedRepos, "pull request reviewer"); | ||
| if (!repoResult.success) { | ||
| core.warning(`Skipping add_reviewer: ${repoResult.error}`); | ||
| return { | ||
| success: false, | ||
| error: repoResult.error, | ||
| }; | ||
| } | ||
| const { repo: itemRepo, repoParts } = repoResult; | ||
| core.info(`Target repository: ${itemRepo}`); | ||
|
|
||
| const filterResult = await checkRequiredFilter(githubClient, repoParts, prNumber, requiredLabels, requiredTitlePrefix, HANDLER_TYPE); | ||
| if (filterResult) return filterResult; | ||
|
|
||
|
|
@@ -138,8 +181,8 @@ async function main(config = {}) { | |
| if (otherReviewers.length > 0 || uniqueTeamReviewers.length > 0) { | ||
| /** @type {{ owner: string, repo: string, pull_number: number, reviewers: string[], team_reviewers?: string[] }} */ | ||
| const reviewerRequest = { | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| owner: repoParts.owner, | ||
| repo: repoParts.repo, | ||
| pull_number: prNumber, | ||
| reviewers: otherReviewers, | ||
| }; | ||
|
|
@@ -154,11 +197,43 @@ async function main(config = {}) { | |
| // Add copilot reviewer separately if requested | ||
| if (hasCopilot) { | ||
| try { | ||
| const response = await githubClient.rest.pulls.requestReviewers({ | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| const pullRequestQuery = ` | ||
| query($owner: String!, $repo: String!, $number: Int!) { | ||
| repository(owner: $owner, name: $repo) { | ||
| pullRequest(number: $number) { | ||
| id | ||
| } | ||
| } | ||
| } | ||
| `; | ||
| const pullRequestResponse = await githubClient.graphql(pullRequestQuery, { | ||
| owner: repoParts.owner, | ||
| repo: repoParts.repo, | ||
| number: prNumber, | ||
| }); | ||
| const pullRequestId = pullRequestResponse?.repository?.pullRequest?.id; | ||
| if (!pullRequestId) { | ||
| throw new Error(`Could not resolve pull request node ID for ${repoParts.owner}/${repoParts.repo}#${prNumber}`); | ||
| } | ||
|
|
||
| const requestReviewsMutation = ` | ||
| mutation($pullRequestId: ID!, $botIds: [ID!]!) { | ||
| requestReviews(input: { pullRequestId: $pullRequestId, botIds: $botIds, union: true }) { | ||
| pullRequest { | ||
| id | ||
| } | ||
| } | ||
| } | ||
| `; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The 💡 Details and suggested fixAfter Suggested fix: inspect the mutation response: const mutationResult = await githubClient.graphql(requestReviewsMutation, {
pullRequestId,
botIds: [COPILOT_REVIEWER_BOT_ID],
});
// Validate the reviewer actually appears in the response, or at minimum check for errors
throw new Error(`requestReviews mutation returned unexpected response for PR #${prNumber}`);
} |
||
| await githubClient.graphql(requestReviewsMutation, { | ||
| pullRequestId, | ||
| botIds: [await resolveCopilotBotNodeId()], | ||
| }); | ||
|
|
||
| const response = await githubClient.rest.pulls.get({ | ||
| owner: repoParts.owner, | ||
| repo: repoParts.repo, | ||
| pull_number: prNumber, | ||
| reviewers: [COPILOT_REVIEWER_BOT], | ||
| }); | ||
| latestPullRequest = response?.data || latestPullRequest; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When Copilot is the only requested reviewer and the mutation fails, the catch block logs a warning but the function still returns 💡 Details and suggested fixThe outer catch around the Copilot path calls The existing test "should handle copilot reviewer failure gracefully" asserts Suggested fix: track whether the Copilot assignment actually succeeded and exclude it from let copilotAdded = false;
try {
// ... graphql calls ...
copilotAdded = true;
} catch (err) {
core.warning(`Failed to add copilot reviewer: ${err.message}`);
}
if (copilotAdded) reviewersAdded.push("copilot"); |
||
| core.info(`Successfully added copilot as reviewer to PR #${prNumber}`); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -31,6 +31,15 @@ const TMP_GH_AW_PATH = "/tmp/gh-aw"; | |
| */ | ||
| const COPILOT_REVIEWER_BOT = "copilot-pull-request-reviewer[bot]"; | ||
|
|
||
| /** | ||
| * GitHub.com GraphQL node ID for the Copilot pull request reviewer bot. | ||
| * Used as a fallback when the node ID cannot be resolved at runtime (e.g. network error). | ||
| * For GHES and other GitHub instances the node ID differs; prefer runtime resolution via | ||
| * the REST users API ({@link https://docs.github.com/en/rest/users/users#get-a-user}). | ||
| * @type {string} | ||
| */ | ||
| const COPILOT_REVIEWER_BOT_ID = "BOT_kgDOCnlnWA"; | ||
|
Comment on lines
+34
to
+41
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hardcoded bot node ID with no provenance will silently break on GHES or if GitHub reassigns this bot account. 💡 Details and suggested fix
If GitHub ever rotates the bot's node ID, or if this constant differs per deployment, the Minimum fix: add a comment with the derivation command and a stability note, e.g.: // Derived via: gh api graphql -f query='{ user(login: copilot-pull-request-reviewer[bot]) { id } }'
// This is a stable global ID on GitHub.com; GHES installations may differ.
// Re-derive if the Copilot reviewer bot is replaced or renamed.
const COPILOT_REVIEWER_BOT_ID = "BOT_kgDOCnlnWA";Better fix: resolve the ID at runtime via a GraphQL user lookup so GHES and future bot renames are handled automatically. |
||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Documentation URLs | ||
| // --------------------------------------------------------------------------- | ||
|
|
@@ -125,6 +134,7 @@ module.exports = { | |
| AGENT_OUTPUT_FILENAME, | ||
| TMP_GH_AW_PATH, | ||
| COPILOT_REVIEWER_BOT, | ||
| COPILOT_REVIEWER_BOT_ID, | ||
| FAQ_CREATE_PR_PERMISSIONS_URL, | ||
| MAX_LABELS, | ||
| MAX_ASSIGNEES, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[/tdd] The PR node ID lookup failure path (
pullRequestIdis null/undefined) is not covered by a test — only the mutation rejection is tested.💡 Suggested test
Right now, a
nullPR node ID throws synchronously inside the try/catch, so it is handled — but there is no test pinning this behaviour, leaving it vulnerable to silent regression.