Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
191 changes: 168 additions & 23 deletions actions/setup/js/resolve_pr_review_thread.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,9 @@ const HANDLER_TYPE = "resolve_pull_request_review_thread";
* @param {string} threadId - Review thread node ID (e.g., 'PRRT_kwDOABCD...')
* @returns {Promise<
* | {status: "missing"}
* | {status: "thread", prNumber: number, repoNameWithOwner: string|null, isResolved: boolean}
* | {status: "thread", threadId: string, prNumber: number, repoNameWithOwner: string|null, isResolved: boolean}
* | {status: "invalid_type", nodeType: string}
* | {status: "comment_without_thread"}
* >} Thread lookup result
*/
async function getThreadPullRequestInfo(github, threadId) {
Expand All @@ -34,6 +35,7 @@ async function getThreadPullRequestInfo(github, threadId) {
node(id: $threadId) {
__typename
... on PullRequestReviewThread {
id
isResolved
pullRequest {
number
Expand All @@ -42,6 +44,18 @@ async function getThreadPullRequestInfo(github, threadId) {
}
}
}
... on PullRequestReviewComment {
pullRequest {
number
repository {
name
nameWithOwner
owner {
login
}
}
}
}
}
}
`;
Expand All @@ -52,6 +66,9 @@ async function getThreadPullRequestInfo(github, threadId) {
if (!threadNode) {
return { status: "missing" };
}
if (threadNode.__typename === "PullRequestReviewComment") {
return findThreadInfoForReviewComment(github, threadId, threadNode);
}
if (threadNode.__typename !== "PullRequestReviewThread") {
return {
status: "invalid_type",
Expand All @@ -62,12 +79,130 @@ async function getThreadPullRequestInfo(github, threadId) {

return {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/diagnosing-bugs] threadNode.id || threadId — the id field is now explicitly selected in the GraphQL fragment, so the || threadId fallback silently masks any future schema regression where id is unexpectedly absent. Prefer asserting its presence explicitly.

💡 Suggestion
if (!threadNode.id) {
  core.warning(`PullRequestReviewThread node returned without an id for input ${threadId}`);
  return { status: 'missing' };
}
return {
  status: 'thread',
  threadId: threadNode.id,
  ...
};

This makes the failure observable rather than silently using the input ID as the resolved ID.

@copilot please address this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. The fallback threadNode.id || threadId was removed. The code now checks if (!threadNode.id) and returns { status: "missing" } with a core.warning, making the absence observable rather than silently substituting the input ID.

status: "thread",
threadId: threadNode.id,
prNumber: pullRequest.number,
repoNameWithOwner: pullRequest.repository?.nameWithOwner ?? null,
isResolved: threadNode?.isResolved === true,
};
}

/**
* Resolve a PullRequestReviewComment node ID to its containing review thread.
* @param {any} github - GitHub GraphQL instance
* @param {string} commentId - Pull request review comment node ID (e.g., 'PRRC_kwDOABCD...')
* @param {any} commentNode - PullRequestReviewComment node returned by the initial lookup
* @returns {Promise<
* | {status: "thread", threadId: string, prNumber: number, repoNameWithOwner: string|null, isResolved: boolean}
* | {status: "comment_without_thread"}
* >}
*/
async function findThreadInfoForReviewComment(github, commentId, commentNode) {
const pullRequest = commentNode.pullRequest;
const repository = pullRequest?.repository;
const prNumber = pullRequest?.number;
const repoOwner = repository?.owner?.login;
const repoName = repository?.name;
const repoNameWithOwner = repository?.nameWithOwner ?? (repoOwner && repoName ? `${repoOwner}/${repoName}` : null);

if (!prNumber || !repoOwner || !repoName) {
return { status: "comment_without_thread" };
}

const threadListQuery = /* GraphQL */ `
query ($owner: String!, $repo: String!, $number: Int!, $cursor: String) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) {
reviewThreads(first: 100, after: $cursor) {
pageInfo {
hasNextPage
endCursor
}
nodes {
id
isResolved
comments(first: 100) {
pageInfo {
hasNextPage
endCursor
}
nodes {
id
}
}
}
}
}
}
}
`;

const threadCommentsQuery = /* GraphQL */ `
query ($threadId: ID!, $cursor: String) {
node(id: $threadId) {
... on PullRequestReviewThread {
comments(first: 100, after: $cursor) {
pageInfo {
hasNextPage
endCursor
}
nodes {
id
}
}
}
}
}
`;

let threadCursor = null;
do {
const result = await github.graphql(threadListQuery, {
owner: repoOwner,
repo: repoName,
number: prNumber,
cursor: threadCursor,
});
const reviewThreads = result?.repository?.pullRequest?.reviewThreads;

for (const thread of reviewThreads?.nodes || []) {
// Check first page of comments for this thread
if ((thread.comments?.nodes || []).some(comment => comment?.id === commentId)) {
return {
status: "thread",
threadId: thread.id,
prNumber,
repoNameWithOwner,
isResolved: thread.isResolved === true,
};
}

// If there are more comment pages, paginate within this thread
let commentCursor = thread.comments?.pageInfo?.hasNextPage ? thread.comments.pageInfo.endCursor : null;
while (commentCursor) {
const commentResult = await github.graphql(threadCommentsQuery, {
threadId: thread.id,
cursor: commentCursor,
});
const commentsPage = commentResult?.node?.comments;
if ((commentsPage?.nodes || []).some(comment => comment?.id === commentId)) {
return {
status: "thread",
threadId: thread.id,
prNumber,
repoNameWithOwner,
isResolved: thread.isResolved === true,
};
}
commentCursor = commentsPage?.pageInfo?.hasNextPage ? commentsPage.pageInfo.endCursor : null;
}
}

threadCursor = reviewThreads?.pageInfo?.hasNextPage ? reviewThreads.pageInfo.endCursor : null;
} while (threadCursor);

return { status: "comment_without_thread" };
}

/**
* Resolve a pull request review thread using the GraphQL API.
* @param {any} github - GitHub GraphQL instance
Expand Down Expand Up @@ -199,30 +334,40 @@ async function main(config = {}) {
};
}

if (threadInfo.status === "comment_without_thread") {
return {
success: false,
error: `Could not find a PullRequestReviewThread containing review comment ${threadId}`,
};
}

if (threadInfo.status !== "thread") {
return {
success: false,
error: `thread_id must reference a PullRequestReviewThread node ID (PRRT_...); received ${threadInfo.nodeType} for ${threadId}`,
};
}

const { prNumber: threadPRNumber, repoNameWithOwner: threadRepo, isResolved } = threadInfo;
const { threadId: resolvedThreadId, prNumber: threadPRNumber, repoNameWithOwner: threadRepo, isResolved } = threadInfo;
if (resolvedThreadId !== threadId) {
core.info(`Resolved review comment ${threadId} to review thread ${resolvedThreadId}`);
}

// When the user explicitly configured target-repo or allowed-repos, validate the thread's
// repository using validateTargetRepo (supports wildcards like "*", "org/*").
// Otherwise, fall back to the legacy behavior of scoping to the triggering PR only.
if (hasExplicitTargetConfig) {
// Cross-repo mode: validate thread repo against configured repos (fail closed if missing)
if (!threadRepo) {
core.warning(`Could not determine repository for thread ${threadId}`);
core.warning(`Could not determine repository for thread ${resolvedThreadId}`);
return {
success: false,
error: `Could not determine the repository for thread ${threadId}`,
error: `Could not determine the repository for thread ${resolvedThreadId}`,
};
}
const repoValidation = validateTargetRepo(threadRepo, defaultTargetRepo, allowedRepos);
if (!repoValidation.valid) {
core.warning(`Thread ${threadId} belongs to repo ${threadRepo}, which is not in the allowed repos`);
core.warning(`Thread ${resolvedThreadId} belongs to repo ${threadRepo}, which is not in the allowed repos`);
return {
success: false,
error: repoValidation.error,
Expand All @@ -239,7 +384,7 @@ async function main(config = {}) {
};
}
if (threadPRNumber !== triggeringPRNumber) {
core.warning(`Thread ${threadId} belongs to PR #${threadPRNumber}, not triggering PR #${triggeringPRNumber}`);
core.warning(`Thread ${resolvedThreadId} belongs to PR #${threadPRNumber}, not triggering PR #${triggeringPRNumber}`);
return {
success: false,
error: `Thread belongs to PR #${threadPRNumber}, but only threads on the triggering PR #${triggeringPRNumber} can be resolved`,
Expand All @@ -256,7 +401,7 @@ async function main(config = {}) {
};
}
if (threadPRNumber !== targetPRNumber) {
core.warning(`Thread ${threadId} belongs to PR #${threadPRNumber}, not target PR #${targetPRNumber}`);
core.warning(`Thread ${resolvedThreadId} belongs to PR #${threadPRNumber}, not target PR #${targetPRNumber}`);
return {
success: false,
error: `Thread belongs to PR #${threadPRNumber}, but target is PR #${targetPRNumber}`,
Expand All @@ -268,16 +413,16 @@ async function main(config = {}) {
// Default (legacy) mode: always validate thread repo against defaultTargetRepo to stay
// least-privilege, even when there is no triggering PR (e.g. schedule/workflow_dispatch).
if (!threadRepo) {
core.warning(`Unable to determine repository for review thread ${threadId}; refusing to resolve in legacy mode`);
core.warning(`Unable to determine repository for review thread ${resolvedThreadId}; refusing to resolve in legacy mode`);
return {
success: false,
error: `Unable to determine repository for review thread ${threadId}`,
error: `Unable to determine repository for review thread ${resolvedThreadId}`,
};
}

const legacyRepoValidation = validateTargetRepo(threadRepo, defaultTargetRepo, allowedRepos);
if (!legacyRepoValidation.valid) {
core.warning(`Thread ${threadId} repository ${threadRepo} is not allowed in legacy mode`);
core.warning(`Thread ${resolvedThreadId} repository ${threadRepo} is not allowed in legacy mode`);
return {
success: false,
error: legacyRepoValidation.error || `Repository ${threadRepo} is not allowed for this handler`,
Expand All @@ -288,17 +433,17 @@ async function main(config = {}) {
if (!triggeringPRNumber) {
// No triggering PR (e.g. schedule/workflow_dispatch trigger), but the thread has been
// resolved to a specific allowed repository via the API — allow the resolution to proceed
core.info(`No triggering PR context; resolving thread ${threadId} via explicit thread_id (PR #${threadPRNumber} in ${threadRepo})`);
core.info(`No triggering PR context; resolving thread ${resolvedThreadId} via explicit thread_id (PR #${threadPRNumber} in ${threadRepo})`);
} else if (threadPRNumber !== triggeringPRNumber) {
core.warning(`Thread ${threadId} belongs to PR #${threadPRNumber}, not triggering PR #${triggeringPRNumber}`);
core.warning(`Thread ${resolvedThreadId} belongs to PR #${threadPRNumber}, not triggering PR #${triggeringPRNumber}`);
return {
success: false,
error: `Thread belongs to PR #${threadPRNumber}, but only threads on the triggering PR #${triggeringPRNumber} can be resolved`,
};
}
}

core.info(`Resolving review thread: ${threadId} (PR #${threadPRNumber}${threadRepo ? " in " + threadRepo : ""})`);
core.info(`Resolving review thread: ${resolvedThreadId} (PR #${threadPRNumber}${threadRepo ? " in " + threadRepo : ""})`);

// Apply required-labels/required-title-prefix filter
const [threadOwner, threadRepoName] = (threadRepo || `${context.repo.owner}/${context.repo.repo}`).split("/");
Expand All @@ -307,35 +452,35 @@ async function main(config = {}) {
if (filterResult) return filterResult;

if (isResolved) {
core.info(`Review thread ${threadId} is already resolved; skipping`);
core.info(`Review thread ${resolvedThreadId} is already resolved; skipping`);
return {
success: true,
thread_id: threadId,
thread_id: resolvedThreadId,
is_resolved: true,
skipped: true,
};
}

// If in staged mode, preview without executing
if (isStaged) {
logStagedPreviewInfo(`Would resolve review thread ${threadId}`);
logStagedPreviewInfo(`Would resolve review thread ${resolvedThreadId}`);
return {
success: true,
staged: true,
previewInfo: {
thread_id: threadId,
thread_id: resolvedThreadId,
pr_number: threadPRNumber,
},
};
}

let resolveResult;
try {
resolveResult = await resolveReviewThreadAPI(githubClient, threadId);
resolveResult = await resolveReviewThreadAPI(githubClient, resolvedThreadId);
} catch (error) {
if (isIntegrationAccessError(error)) {
const warningMessage =
`Skipping resolve_pull_request_review_thread for ${threadId}: configuration mismatch ` +
`Skipping resolve_pull_request_review_thread for ${resolvedThreadId}: configuration mismatch ` +
`(GitHub integration token cannot resolve this review thread: Resource not accessible by integration). ` +
`Use safe-outputs.resolve-pull-request-review-thread.github-token with a token that can resolve review threads.`;
core.warning(warningMessage);
Expand All @@ -349,17 +494,17 @@ async function main(config = {}) {
}

if (resolveResult.isResolved) {
core.info(`Successfully resolved review thread: ${threadId}`);
core.info(`Successfully resolved review thread: ${resolvedThreadId}`);
return {
success: true,
thread_id: threadId,
thread_id: resolvedThreadId,
is_resolved: true,
};
} else {
core.error(`Failed to resolve review thread: ${threadId}`);
core.error(`Failed to resolve review thread: ${resolvedThreadId}`);
return {
success: false,
error: `Failed to resolve review thread: ${threadId}`,
error: `Failed to resolve review thread: ${resolvedThreadId}`,
};
}
} catch (error) {
Expand Down
Loading
Loading