From 53cad0db5b157160708c958e13b46de711b09867 Mon Sep 17 00:00:00 2001 From: William Easton Date: Sat, 14 Feb 2026 06:54:24 -0600 Subject: [PATCH 1/5] Add resolve-pull-request-review-thread safe output Adds a new safe output type that allows AI agents to resolve review threads on pull requests after addressing feedback. Uses the GitHub GraphQL resolveReviewThread mutation. Changes: - New Go config: resolve_pr_review_thread.go - New JS handler: resolve_pr_review_thread.cjs with tests - Updated 15+ infrastructure files (schemas, config, permissions, handler registries, type definitions, documentation) Co-authored-by: Cursor --- actions/setup/js/resolve_pr_review_thread.cjs | 115 ++++++++++ .../js/resolve_pr_review_thread.test.cjs | 202 ++++++++++++++++++ .../setup/js/safe_output_handler_manager.cjs | 1 + .../safe_output_unified_handler_manager.cjs | 1 + actions/setup/js/safe_outputs_tools.json | 15 ++ .../setup/js/types/safe-outputs-config.d.ts | 13 +- actions/setup/js/types/safe-outputs.d.ts | 13 +- .../content/docs/reference/safe-outputs.md | 20 ++ pkg/parser/schemas/main_workflow_schema.json | 39 ++++ pkg/workflow/compiler_safe_outputs_config.go | 12 ++ pkg/workflow/compiler_safe_outputs_job.go | 1 + pkg/workflow/compiler_types.go | 3 +- pkg/workflow/imports.go | 5 + pkg/workflow/js/safe_outputs_tools.json | 15 ++ pkg/workflow/resolve_pr_review_thread.go | 45 ++++ pkg/workflow/safe_output_validation_config.go | 6 + pkg/workflow/safe_outputs_config.go | 6 + .../safe_outputs_config_generation.go | 14 ++ .../safe_outputs_config_helpers_reflection.go | 1 + pkg/workflow/safe_outputs_permissions.go | 4 +- .../safe_outputs_target_validation.go | 3 + pkg/workflow/safe_outputs_tools_test.go | 12 ++ pkg/workflow/tool_description_enhancer.go | 7 + schemas/agent-output.json | 19 +- 24 files changed, 566 insertions(+), 6 deletions(-) create mode 100644 actions/setup/js/resolve_pr_review_thread.cjs create mode 100644 actions/setup/js/resolve_pr_review_thread.test.cjs create mode 100644 pkg/workflow/resolve_pr_review_thread.go diff --git a/actions/setup/js/resolve_pr_review_thread.cjs b/actions/setup/js/resolve_pr_review_thread.cjs new file mode 100644 index 0000000000..e45b6e0d5e --- /dev/null +++ b/actions/setup/js/resolve_pr_review_thread.cjs @@ -0,0 +1,115 @@ +// @ts-check +/// + +/** + * @typedef {import('./types/handler-factory').HandlerFactoryFunction} HandlerFactoryFunction + */ + +const { getErrorMessage } = require("./error_helpers.cjs"); + +/** + * Type constant for handler identification + */ +const HANDLER_TYPE = "resolve_pull_request_review_thread"; + +/** + * Resolve a pull request review thread using the GraphQL API. + * @param {any} github - GitHub GraphQL instance + * @param {string} threadId - Review thread node ID (e.g., 'PRRT_kwDOABCD...') + * @returns {Promise<{threadId: string, isResolved: boolean}>} Resolved thread details + */ +async function resolveReviewThreadAPI(github, threadId) { + const query = /* GraphQL */ ` + mutation ($threadId: ID!) { + resolveReviewThread(input: { threadId: $threadId }) { + thread { + id + isResolved + } + } + } + `; + + const result = await github.graphql(query, { threadId }); + + return { + threadId: result.resolveReviewThread.thread.id, + isResolved: result.resolveReviewThread.thread.isResolved, + }; +} + +/** + * Main handler factory for resolve_pull_request_review_thread + * Returns a message handler function that processes individual resolve messages + * @type {HandlerFactoryFunction} + */ +async function main(config = {}) { + // Extract configuration + const maxCount = config.max || 10; + + core.info(`Resolve PR review thread configuration: max=${maxCount}`); + + // Track how many items we've processed for max limit + let processedCount = 0; + + /** + * Message handler function that processes a single resolve_pull_request_review_thread message + * @param {Object} message - The resolve message to process + * @param {Object} resolvedTemporaryIds - Map of temporary IDs to {repo, number} + * @returns {Promise} Result with success/error status + */ + return async function handleResolvePRReviewThread(message, resolvedTemporaryIds) { + // Check if we've hit the max limit + if (processedCount >= maxCount) { + core.warning(`Skipping resolve_pull_request_review_thread: max count of ${maxCount} reached`); + return { + success: false, + error: `Max count of ${maxCount} reached`, + }; + } + + processedCount++; + + const item = message; + + try { + // Validate required fields + const threadId = item.thread_id; + if (!threadId || typeof threadId !== "string" || threadId.trim().length === 0) { + core.warning('Missing or invalid required field "thread_id" in resolve message'); + return { + success: false, + error: 'Missing or invalid required field "thread_id" - must be a non-empty string (GraphQL node ID)', + }; + } + + core.info(`Resolving review thread: ${threadId}`); + + const resolveResult = await resolveReviewThreadAPI(github, threadId); + + if (resolveResult.isResolved) { + core.info(`Successfully resolved review thread: ${threadId}`); + return { + success: true, + thread_id: threadId, + is_resolved: true, + }; + } else { + core.error(`Failed to resolve review thread: ${threadId}`); + return { + success: false, + error: `Failed to resolve review thread: ${threadId}`, + }; + } + } catch (error) { + const errorMessage = getErrorMessage(error); + core.error(`Failed to resolve review thread: ${errorMessage}`); + return { + success: false, + error: errorMessage, + }; + } + }; +} + +module.exports = { main, HANDLER_TYPE }; diff --git a/actions/setup/js/resolve_pr_review_thread.test.cjs b/actions/setup/js/resolve_pr_review_thread.test.cjs new file mode 100644 index 0000000000..2ca825494b --- /dev/null +++ b/actions/setup/js/resolve_pr_review_thread.test.cjs @@ -0,0 +1,202 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +const mockCore = { + debug: vi.fn(), + info: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + setFailed: vi.fn(), + setOutput: vi.fn(), + summary: { + addRaw: vi.fn().mockReturnThis(), + write: vi.fn().mockResolvedValue(), + }, +}; + +global.core = mockCore; + +const mockGraphql = vi.fn(); +const mockGithub = { + graphql: mockGraphql, +}; + +global.github = mockGithub; + +const mockContext = { + repo: { owner: "test-owner", repo: "test-repo" }, + runId: 12345, + eventName: "pull_request", + payload: { + pull_request: { number: 42 }, + repository: { html_url: "https://github.com/test-owner/test-repo" }, + }, +}; + +global.context = mockContext; + +describe("resolve_pr_review_thread", () => { + let handler; + + beforeEach(async () => { + vi.clearAllMocks(); + + mockGraphql.mockResolvedValue({ + resolveReviewThread: { + thread: { + id: "PRRT_kwDOABCD123456", + isResolved: true, + }, + }, + }); + + const { main } = require("./resolve_pr_review_thread.cjs"); + handler = await main({ max: 10 }); + }); + + it("should return a function from main()", async () => { + const { main } = require("./resolve_pr_review_thread.cjs"); + const result = await main({}); + expect(typeof result).toBe("function"); + }); + + it("should successfully resolve a review thread", async () => { + const message = { + type: "resolve_pull_request_review_thread", + thread_id: "PRRT_kwDOABCD123456", + }; + + const result = await handler(message, {}); + + expect(result.success).toBe(true); + expect(result.thread_id).toBe("PRRT_kwDOABCD123456"); + expect(result.is_resolved).toBe(true); + expect(mockGraphql).toHaveBeenCalledWith( + expect.stringContaining("resolveReviewThread"), + expect.objectContaining({ + threadId: "PRRT_kwDOABCD123456", + }) + ); + }); + + it("should fail when thread_id is missing", async () => { + const message = { + type: "resolve_pull_request_review_thread", + }; + + const result = await handler(message, {}); + + expect(result.success).toBe(false); + expect(result.error).toContain("thread_id"); + }); + + it("should fail when thread_id is empty string", async () => { + const message = { + type: "resolve_pull_request_review_thread", + thread_id: "", + }; + + const result = await handler(message, {}); + + expect(result.success).toBe(false); + expect(result.error).toContain("thread_id"); + }); + + it("should fail when thread_id is whitespace only", async () => { + const message = { + type: "resolve_pull_request_review_thread", + thread_id: " ", + }; + + const result = await handler(message, {}); + + expect(result.success).toBe(false); + expect(result.error).toContain("thread_id"); + }); + + it("should fail when thread_id is not a string", async () => { + const message = { + type: "resolve_pull_request_review_thread", + thread_id: 12345, + }; + + const result = await handler(message, {}); + + expect(result.success).toBe(false); + expect(result.error).toContain("thread_id"); + }); + + it("should respect max count limit", async () => { + const { main } = require("./resolve_pr_review_thread.cjs"); + const limitedHandler = await main({ max: 2 }); + + const message = { + type: "resolve_pull_request_review_thread", + thread_id: "PRRT_kwDOABCD123456", + }; + + const result1 = await limitedHandler(message, {}); + const result2 = await limitedHandler(message, {}); + const result3 = await limitedHandler(message, {}); + + expect(result1.success).toBe(true); + expect(result2.success).toBe(true); + expect(result3.success).toBe(false); + expect(result3.error).toContain("Max count of 2 reached"); + }); + + it("should handle API errors gracefully", async () => { + mockGraphql.mockRejectedValue(new Error("Could not resolve. Thread not found.")); + + const message = { + type: "resolve_pull_request_review_thread", + thread_id: "PRRT_invalid", + }; + + const result = await handler(message, {}); + + expect(result.success).toBe(false); + expect(result.error).toContain("Could not resolve"); + }); + + it("should handle unexpected resolve failure", async () => { + mockGraphql.mockResolvedValue({ + resolveReviewThread: { + thread: { + id: "PRRT_kwDOABCD123456", + isResolved: false, + }, + }, + }); + + const message = { + type: "resolve_pull_request_review_thread", + thread_id: "PRRT_kwDOABCD123456", + }; + + const result = await handler(message, {}); + + expect(result.success).toBe(false); + expect(result.error).toContain("Failed to resolve"); + }); + + it("should default max to 10", async () => { + const { main } = require("./resolve_pr_review_thread.cjs"); + const defaultHandler = await main({}); + + const message = { + type: "resolve_pull_request_review_thread", + thread_id: "PRRT_kwDOABCD123456", + }; + + // Process 10 messages successfully + for (let i = 0; i < 10; i++) { + const result = await defaultHandler(message, {}); + expect(result.success).toBe(true); + } + + // 11th should fail + const result = await defaultHandler(message, {}); + expect(result.success).toBe(false); + expect(result.error).toContain("Max count of 10 reached"); + }); +}); diff --git a/actions/setup/js/safe_output_handler_manager.cjs b/actions/setup/js/safe_output_handler_manager.cjs index 624b4fd255..8a99b39c97 100644 --- a/actions/setup/js/safe_output_handler_manager.cjs +++ b/actions/setup/js/safe_output_handler_manager.cjs @@ -36,6 +36,7 @@ const HANDLER_MAP = { update_release: "./update_release.cjs", create_pull_request_review_comment: "./create_pr_review_comment.cjs", submit_pull_request_review: "./submit_pr_review.cjs", + resolve_pull_request_review_thread: "./resolve_pr_review_thread.cjs", create_pull_request: "./create_pull_request.cjs", push_to_pull_request_branch: "./push_to_pull_request_branch.cjs", update_pull_request: "./update_pull_request.cjs", diff --git a/actions/setup/js/safe_output_unified_handler_manager.cjs b/actions/setup/js/safe_output_unified_handler_manager.cjs index cb3bb86696..440be937cb 100644 --- a/actions/setup/js/safe_output_unified_handler_manager.cjs +++ b/actions/setup/js/safe_output_unified_handler_manager.cjs @@ -44,6 +44,7 @@ const HANDLER_MAP = { update_release: "./update_release.cjs", create_pull_request_review_comment: "./create_pr_review_comment.cjs", submit_pull_request_review: "./submit_pr_review.cjs", + resolve_pull_request_review_thread: "./resolve_pr_review_thread.cjs", create_pull_request: "./create_pull_request.cjs", push_to_pull_request_branch: "./push_to_pull_request_branch.cjs", update_pull_request: "./update_pull_request.cjs", diff --git a/actions/setup/js/safe_outputs_tools.json b/actions/setup/js/safe_outputs_tools.json index ab5454410a..90c0923692 100644 --- a/actions/setup/js/safe_outputs_tools.json +++ b/actions/setup/js/safe_outputs_tools.json @@ -256,6 +256,21 @@ "additionalProperties": false } }, + { + "name": "resolve_pull_request_review_thread", + "description": "Resolve a review thread on a pull request. Use this to mark a review conversation as resolved after addressing the feedback. The thread_id must be the node ID of the review thread (e.g., PRRT_kwDO...).", + "inputSchema": { + "type": "object", + "required": ["thread_id"], + "properties": { + "thread_id": { + "type": "string", + "description": "The node ID of the review thread to resolve (e.g., 'PRRT_kwDOABCD...'). This is the GraphQL node ID, not a numeric ID." + } + }, + "additionalProperties": false + } + }, { "name": "create_code_scanning_alert", "description": "Create a code scanning alert for security vulnerabilities, code quality issues, or other findings. Alerts appear in the repository's Security tab and integrate with GitHub's security features. Use this for automated security analysis results.", diff --git a/actions/setup/js/types/safe-outputs-config.d.ts b/actions/setup/js/types/safe-outputs-config.d.ts index cb9918c224..7b8e187b13 100644 --- a/actions/setup/js/types/safe-outputs-config.d.ts +++ b/actions/setup/js/types/safe-outputs-config.d.ts @@ -94,6 +94,15 @@ interface CreatePullRequestReviewCommentConfig extends SafeOutputConfig { target?: string; } +/** + * Configuration for resolving pull request review threads + */ +interface ResolvePullRequestReviewThreadConfig extends SafeOutputConfig { + target?: string; + "target-repo"?: string; + allowed_repos?: string[]; +} + /** * Configuration for creating code scanning alerts */ @@ -282,7 +291,8 @@ type SpecificSafeOutputConfig = | NoOpConfig | MissingToolConfig | LinkSubIssueConfig - | ThreatDetectionConfig; + | ThreatDetectionConfig + | ResolvePullRequestReviewThreadConfig; type SafeOutputConfigs = Record; @@ -315,6 +325,7 @@ export { MissingToolConfig, LinkSubIssueConfig, ThreatDetectionConfig, + ResolvePullRequestReviewThreadConfig, SpecificSafeOutputConfig, // Safe job configuration types SafeJobInput, diff --git a/actions/setup/js/types/safe-outputs.d.ts b/actions/setup/js/types/safe-outputs.d.ts index 5064bd2c3c..958e557418 100644 --- a/actions/setup/js/types/safe-outputs.d.ts +++ b/actions/setup/js/types/safe-outputs.d.ts @@ -350,6 +350,15 @@ interface AutofixCodeScanningAlertItem extends BaseSafeOutputItem { fix_code: string; } +/** + * JSONL item for resolving a review thread on a pull request + */ +interface ResolvePullRequestReviewThreadItem extends BaseSafeOutputItem { + type: "resolve_pull_request_review_thread"; + /** The node ID of the review thread to resolve (e.g., 'PRRT_kwDOABCD...') */ + thread_id: string; +} + /** * Union type of all possible safe output items */ @@ -380,7 +389,8 @@ type SafeOutputItem = | LinkSubIssueItem | HideCommentItem | CreateProjectItem - | AutofixCodeScanningAlertItem; + | AutofixCodeScanningAlertItem + | ResolvePullRequestReviewThreadItem; /** * Sanitized safe output items @@ -419,6 +429,7 @@ export { LinkSubIssueItem, HideCommentItem, AutofixCodeScanningAlertItem, + ResolvePullRequestReviewThreadItem, SafeOutputItem, SafeOutputItems, }; diff --git a/docs/src/content/docs/reference/safe-outputs.md b/docs/src/content/docs/reference/safe-outputs.md index 2fa0f91e30..dd66835f98 100644 --- a/docs/src/content/docs/reference/safe-outputs.md +++ b/docs/src/content/docs/reference/safe-outputs.md @@ -704,6 +704,26 @@ safe-outputs: footer: false # omit AI-generated footer from review body (default: true) ``` +### Resolve PR Review Thread (`resolve-pull-request-review-thread:`) + +Resolves review threads on pull requests. Allows AI agents to mark review conversations as resolved after addressing the feedback. Uses the GitHub GraphQL API with the `resolveReviewThread` mutation. + +```yaml wrap +safe-outputs: + resolve-pull-request-review-thread: + max: 10 # max threads to resolve (default: 10) + target: "*" # "triggering" (default), "*", or PR number + target-repo: "owner/repo" # cross-repository + allowed-repos: # allowed repos for cross-repo resolution + - "org/repo1" +``` + +**Agent output format:** + +```json +{"type": "resolve_pull_request_review_thread", "thread_id": "PRRT_kwDOABCD..."} +``` + ### Code Scanning Alerts (`create-code-scanning-alert:`) Creates security advisories in SARIF format and submits to GitHub Code Scanning. Supports severity: error, warning, info, note. diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index cb6dc8addc..1377035db1 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -4999,6 +4999,45 @@ ], "description": "Enable AI agents to submit consolidated pull request reviews with a status decision. Works with create-pull-request-review-comment to batch inline comments into a single review." }, + "resolve-pull-request-review-thread": { + "oneOf": [ + { + "type": "object", + "description": "Configuration for resolving review threads on pull requests. Allows AI agents to mark review conversations as resolved after addressing feedback.", + "properties": { + "max": { + "type": "integer", + "description": "Maximum number of review threads to resolve (default: 10)", + "minimum": 1, + "maximum": 100 + }, + "target": { + "type": "string", + "description": "Target for review thread resolution. Use 'triggering' (default) for the triggering PR, '*' for any PR, or a specific PR number." + }, + "target-repo": { + "type": "string", + "description": "Default target repository for cross-repo thread resolution (format: 'owner/repo')" + }, + "allowed-repos": { + "type": "array", + "items": { "type": "string" }, + "description": "Allowed repositories for cross-repo thread resolution (format: 'owner/repo')" + }, + "github-token": { + "$ref": "#/$defs/github_token", + "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." + } + }, + "additionalProperties": false + }, + { + "type": "null", + "description": "Enable review thread resolution with default configuration" + } + ], + "description": "Enable AI agents to resolve review threads on pull requests after addressing feedback." + }, "create-code-scanning-alert": { "oneOf": [ { diff --git a/pkg/workflow/compiler_safe_outputs_config.go b/pkg/workflow/compiler_safe_outputs_config.go index 567b20292e..07fec1ef9e 100644 --- a/pkg/workflow/compiler_safe_outputs_config.go +++ b/pkg/workflow/compiler_safe_outputs_config.go @@ -316,6 +316,18 @@ var handlerRegistry = map[string]handlerBuilder{ AddBoolPtr("footer", getEffectiveFooter(c.Footer, cfg.Footer)). Build() }, + "resolve_pull_request_review_thread": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.ResolvePullRequestReviewThread == nil { + return nil + } + c := cfg.ResolvePullRequestReviewThread + return newHandlerConfigBuilder(). + AddIfPositive("max", c.Max). + AddIfNotEmpty("target", c.Target). + AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddStringSlice("allowed_repos", c.AllowedRepos). + Build() + }, "create_pull_request": func(cfg *SafeOutputsConfig) map[string]any { if cfg.CreatePullRequests == nil { return nil diff --git a/pkg/workflow/compiler_safe_outputs_job.go b/pkg/workflow/compiler_safe_outputs_job.go index b88e980d81..9e936ea1b2 100644 --- a/pkg/workflow/compiler_safe_outputs_job.go +++ b/pkg/workflow/compiler_safe_outputs_job.go @@ -135,6 +135,7 @@ func (c *Compiler) buildConsolidatedSafeOutputsJob(data *WorkflowData, mainJobNa data.SafeOutputs.UpdateRelease != nil || data.SafeOutputs.CreatePullRequestReviewComments != nil || data.SafeOutputs.SubmitPullRequestReview != nil || + data.SafeOutputs.ResolvePullRequestReviewThread != nil || data.SafeOutputs.CreatePullRequests != nil || data.SafeOutputs.PushToPullRequestBranch != nil || data.SafeOutputs.UpdatePullRequests != nil || diff --git a/pkg/workflow/compiler_types.go b/pkg/workflow/compiler_types.go index 608898e728..f97f23e1bf 100644 --- a/pkg/workflow/compiler_types.go +++ b/pkg/workflow/compiler_types.go @@ -468,7 +468,8 @@ type SafeOutputsConfig struct { AddComments *AddCommentsConfig `yaml:"add-comments,omitempty"` CreatePullRequests *CreatePullRequestsConfig `yaml:"create-pull-requests,omitempty"` CreatePullRequestReviewComments *CreatePullRequestReviewCommentsConfig `yaml:"create-pull-request-review-comments,omitempty"` - SubmitPullRequestReview *SubmitPullRequestReviewConfig `yaml:"submit-pull-request-review,omitempty"` // Submit a PR review with status (APPROVE, REQUEST_CHANGES, COMMENT) + SubmitPullRequestReview *SubmitPullRequestReviewConfig `yaml:"submit-pull-request-review,omitempty"` // Submit a PR review with status (APPROVE, REQUEST_CHANGES, COMMENT) + ResolvePullRequestReviewThread *ResolvePullRequestReviewThreadConfig `yaml:"resolve-pull-request-review-thread,omitempty"` // Resolve a review thread on a pull request CreateCodeScanningAlerts *CreateCodeScanningAlertsConfig `yaml:"create-code-scanning-alerts,omitempty"` AutofixCodeScanningAlert *AutofixCodeScanningAlertConfig `yaml:"autofix-code-scanning-alert,omitempty"` AddLabels *AddLabelsConfig `yaml:"add-labels,omitempty"` diff --git a/pkg/workflow/imports.go b/pkg/workflow/imports.go index f00359ccd9..a049d72867 100644 --- a/pkg/workflow/imports.go +++ b/pkg/workflow/imports.go @@ -445,6 +445,8 @@ func hasSafeOutputType(config *SafeOutputsConfig, key string) bool { return config.CreatePullRequestReviewComments != nil case "submit-pull-request-review": return config.SubmitPullRequestReview != nil + case "resolve-pull-request-review-thread": + return config.ResolvePullRequestReviewThread != nil case "create-code-scanning-alert": return config.CreateCodeScanningAlerts != nil case "add-labels": @@ -531,6 +533,9 @@ func mergeSafeOutputConfig(result *SafeOutputsConfig, config map[string]any, c * if result.SubmitPullRequestReview == nil && importedConfig.SubmitPullRequestReview != nil { result.SubmitPullRequestReview = importedConfig.SubmitPullRequestReview } + if result.ResolvePullRequestReviewThread == nil && importedConfig.ResolvePullRequestReviewThread != nil { + result.ResolvePullRequestReviewThread = importedConfig.ResolvePullRequestReviewThread + } if result.CreateCodeScanningAlerts == nil && importedConfig.CreateCodeScanningAlerts != nil { result.CreateCodeScanningAlerts = importedConfig.CreateCodeScanningAlerts } diff --git a/pkg/workflow/js/safe_outputs_tools.json b/pkg/workflow/js/safe_outputs_tools.json index 46d1a4828c..7a60e5ae14 100644 --- a/pkg/workflow/js/safe_outputs_tools.json +++ b/pkg/workflow/js/safe_outputs_tools.json @@ -312,6 +312,21 @@ "additionalProperties": false } }, + { + "name": "resolve_pull_request_review_thread", + "description": "Resolve a review thread on a pull request. Use this to mark a review conversation as resolved after addressing the feedback. The thread_id must be the node ID of the review thread (e.g., PRRT_kwDO...).", + "inputSchema": { + "type": "object", + "required": ["thread_id"], + "properties": { + "thread_id": { + "type": "string", + "description": "The node ID of the review thread to resolve (e.g., 'PRRT_kwDOABCD...'). This is the GraphQL node ID, not a numeric ID." + } + }, + "additionalProperties": false + } + }, { "name": "create_code_scanning_alert", "description": "Create a code scanning alert for security vulnerabilities, code quality issues, or other findings. Alerts appear in the repository's Security tab and integrate with GitHub's security features. Use this for automated security analysis results.", diff --git a/pkg/workflow/resolve_pr_review_thread.go b/pkg/workflow/resolve_pr_review_thread.go new file mode 100644 index 0000000000..fd09a66e4e --- /dev/null +++ b/pkg/workflow/resolve_pr_review_thread.go @@ -0,0 +1,45 @@ +package workflow + +import ( + "github.com/github/gh-aw/pkg/logger" +) + +var resolvePRReviewThreadLog = logger.New("workflow:resolve_pr_review_thread") + +// ResolvePullRequestReviewThreadConfig holds configuration for resolving PR review threads +type ResolvePullRequestReviewThreadConfig struct { + BaseSafeOutputConfig `yaml:",inline"` + SafeOutputTargetConfig `yaml:",inline"` +} + +// parseResolvePullRequestReviewThreadConfig handles resolve-pull-request-review-thread configuration +func (c *Compiler) parseResolvePullRequestReviewThreadConfig(outputMap map[string]any) *ResolvePullRequestReviewThreadConfig { + if configData, exists := outputMap["resolve-pull-request-review-thread"]; exists { + resolvePRReviewThreadLog.Print("Parsing resolve-pull-request-review-thread configuration") + config := &ResolvePullRequestReviewThreadConfig{} + + if configMap, ok := configData.(map[string]any); ok { + resolvePRReviewThreadLog.Print("Found resolve-pull-request-review-thread config map") + + // Parse target config (target, target-repo, allowed-repos) with validation + targetConfig, isInvalid := ParseTargetConfig(configMap) + if isInvalid { + return nil // Invalid configuration (e.g., wildcard target-repo), return nil to cause validation error + } + config.SafeOutputTargetConfig = targetConfig + + // Parse common base fields with default max of 10 + c.parseBaseSafeOutputConfig(configMap, &config.BaseSafeOutputConfig, 10) + + resolvePRReviewThreadLog.Printf("Parsed resolve-pull-request-review-thread config: max=%d, target_repo=%s", + config.Max, config.TargetRepoSlug) + } else { + // If configData is nil or not a map, still set the default max + config.Max = 10 + } + + return config + } + + return nil +} diff --git a/pkg/workflow/safe_output_validation_config.go b/pkg/workflow/safe_output_validation_config.go index 876674560d..225bbb1e21 100644 --- a/pkg/workflow/safe_output_validation_config.go +++ b/pkg/workflow/safe_output_validation_config.go @@ -160,6 +160,12 @@ var ValidationConfig = map[string]TypeValidationConfig{ "event": {Type: "string", Enum: []string{"APPROVE", "REQUEST_CHANGES", "COMMENT"}}, }, }, + "resolve_pull_request_review_thread": { + DefaultMax: 10, + Fields: map[string]FieldValidation{ + "thread_id": {Required: true, Type: "string"}, + }, + }, "create_discussion": { DefaultMax: 1, Fields: map[string]FieldValidation{ diff --git a/pkg/workflow/safe_outputs_config.go b/pkg/workflow/safe_outputs_config.go index a28950ad36..41307923ab 100644 --- a/pkg/workflow/safe_outputs_config.go +++ b/pkg/workflow/safe_outputs_config.go @@ -107,6 +107,12 @@ func (c *Compiler) extractSafeOutputsConfig(frontmatter map[string]any) *SafeOut config.SubmitPullRequestReview = submitPRReviewConfig } + // Handle resolve-pull-request-review-thread + resolvePRReviewThreadConfig := c.parseResolvePullRequestReviewThreadConfig(outputMap) + if resolvePRReviewThreadConfig != nil { + config.ResolvePullRequestReviewThread = resolvePRReviewThreadConfig + } + // Handle create-code-scanning-alert securityReportsConfig := c.parseCodeScanningAlertsConfig(outputMap) if securityReportsConfig != nil { diff --git a/pkg/workflow/safe_outputs_config_generation.go b/pkg/workflow/safe_outputs_config_generation.go index b5c29368bf..8ce550ecc4 100644 --- a/pkg/workflow/safe_outputs_config_generation.go +++ b/pkg/workflow/safe_outputs_config_generation.go @@ -161,6 +161,12 @@ func generateSafeOutputsConfig(data *WorkflowData) string { 1, // default max ) } + if data.SafeOutputs.ResolvePullRequestReviewThread != nil { + safeOutputsConfig["resolve_pull_request_review_thread"] = generateMaxConfig( + data.SafeOutputs.ResolvePullRequestReviewThread.Max, + 10, // default max + ) + } if data.SafeOutputs.CreateCodeScanningAlerts != nil { safeOutputsConfig["create_code_scanning_alert"] = generateMaxConfig( data.SafeOutputs.CreateCodeScanningAlerts.Max, @@ -632,6 +638,9 @@ func generateFilteredToolsJSON(data *WorkflowData, markdownPath string) (string, if data.SafeOutputs.SubmitPullRequestReview != nil { enabledTools["submit_pull_request_review"] = true } + if data.SafeOutputs.ResolvePullRequestReviewThread != nil { + enabledTools["resolve_pull_request_review_thread"] = true + } if data.SafeOutputs.CreateCodeScanningAlerts != nil { enabledTools["create_code_scanning_alert"] = true } @@ -858,6 +867,11 @@ func addRepoParameterIfNeeded(tool map[string]any, toolName string, safeOutputs hasAllowedRepos = len(config.AllowedRepos) > 0 targetRepoSlug = config.TargetRepoSlug } + case "resolve_pull_request_review_thread": + if config := safeOutputs.ResolvePullRequestReviewThread; config != nil { + hasAllowedRepos = len(config.AllowedRepos) > 0 + targetRepoSlug = config.TargetRepoSlug + } case "create_agent_session": if config := safeOutputs.CreateAgentSessions; config != nil { hasAllowedRepos = len(config.AllowedRepos) > 0 diff --git a/pkg/workflow/safe_outputs_config_helpers_reflection.go b/pkg/workflow/safe_outputs_config_helpers_reflection.go index 3ee430001a..ad997be2c4 100644 --- a/pkg/workflow/safe_outputs_config_helpers_reflection.go +++ b/pkg/workflow/safe_outputs_config_helpers_reflection.go @@ -22,6 +22,7 @@ var safeOutputFieldMapping = map[string]string{ "CreatePullRequests": "create_pull_request", "CreatePullRequestReviewComments": "create_pull_request_review_comment", "SubmitPullRequestReview": "submit_pull_request_review", + "ResolvePullRequestReviewThread": "resolve_pull_request_review_thread", "CreateCodeScanningAlerts": "create_code_scanning_alert", "AddLabels": "add_labels", "RemoveLabels": "remove_labels", diff --git a/pkg/workflow/safe_outputs_permissions.go b/pkg/workflow/safe_outputs_permissions.go index ab047791c3..a57aabab95 100644 --- a/pkg/workflow/safe_outputs_permissions.go +++ b/pkg/workflow/safe_outputs_permissions.go @@ -64,8 +64,8 @@ func computePermissionsForSafeOutputs(safeOutputs *SafeOutputsConfig) *Permissio safeOutputsPermissionsLog.Print("Adding permissions for update-release") permissions.Merge(NewPermissionsContentsWrite()) } - if safeOutputs.CreatePullRequestReviewComments != nil || safeOutputs.SubmitPullRequestReview != nil { - safeOutputsPermissionsLog.Print("Adding permissions for create-pr-review-comment or submit-pr-review") + if safeOutputs.CreatePullRequestReviewComments != nil || safeOutputs.SubmitPullRequestReview != nil || safeOutputs.ResolvePullRequestReviewThread != nil { + safeOutputsPermissionsLog.Print("Adding permissions for create-pr-review-comment, submit-pr-review, or resolve-pr-review-thread") permissions.Merge(NewPermissionsContentsReadPRWrite()) } if safeOutputs.CreatePullRequests != nil { diff --git a/pkg/workflow/safe_outputs_target_validation.go b/pkg/workflow/safe_outputs_target_validation.go index 53762a8dd6..23c1f334c0 100644 --- a/pkg/workflow/safe_outputs_target_validation.go +++ b/pkg/workflow/safe_outputs_target_validation.go @@ -87,6 +87,9 @@ func validateSafeOutputsTarget(config *SafeOutputsConfig) error { if config.PushToPullRequestBranch != nil { configs = append(configs, targetConfig{"push-to-pull-request-branch", config.PushToPullRequestBranch.Target}) } + if config.ResolvePullRequestReviewThread != nil { + configs = append(configs, targetConfig{"resolve-pull-request-review-thread", config.ResolvePullRequestReviewThread.Target}) + } // Validate each target field for _, cfg := range configs { diff --git a/pkg/workflow/safe_outputs_tools_test.go b/pkg/workflow/safe_outputs_tools_test.go index f0062f6dd2..0bde002465 100644 --- a/pkg/workflow/safe_outputs_tools_test.go +++ b/pkg/workflow/safe_outputs_tools_test.go @@ -87,6 +87,15 @@ func TestGenerateFilteredToolsJSON(t *testing.T) { }, expectedTools: []string{"submit_pull_request_review"}, }, + { + name: "resolve pull request review thread enabled", + safeOutputs: &SafeOutputsConfig{ + ResolvePullRequestReviewThread: &ResolvePullRequestReviewThreadConfig{ + BaseSafeOutputConfig: BaseSafeOutputConfig{Max: 10}, + }, + }, + expectedTools: []string{"resolve_pull_request_review_thread"}, + }, { name: "create code scanning alerts enabled", safeOutputs: &SafeOutputsConfig{ @@ -162,6 +171,7 @@ func TestGenerateFilteredToolsJSON(t *testing.T) { CreatePullRequests: &CreatePullRequestsConfig{}, CreatePullRequestReviewComments: &CreatePullRequestReviewCommentsConfig{BaseSafeOutputConfig: BaseSafeOutputConfig{Max: 5}}, SubmitPullRequestReview: &SubmitPullRequestReviewConfig{BaseSafeOutputConfig: BaseSafeOutputConfig{Max: 1}}, + ResolvePullRequestReviewThread: &ResolvePullRequestReviewThreadConfig{BaseSafeOutputConfig: BaseSafeOutputConfig{Max: 10}}, CreateCodeScanningAlerts: &CreateCodeScanningAlertsConfig{BaseSafeOutputConfig: BaseSafeOutputConfig{Max: 100}}, AddLabels: &AddLabelsConfig{BaseSafeOutputConfig: BaseSafeOutputConfig{Max: 3}}, AddReviewer: &AddReviewerConfig{BaseSafeOutputConfig: BaseSafeOutputConfig{Max: 3}}, @@ -178,6 +188,7 @@ func TestGenerateFilteredToolsJSON(t *testing.T) { "create_pull_request", "create_pull_request_review_comment", "submit_pull_request_review", + "resolve_pull_request_review_thread", "create_code_scanning_alert", "add_labels", "add_reviewer", @@ -295,6 +306,7 @@ func TestGetSafeOutputsToolsJSON(t *testing.T) { "create_pull_request", "create_pull_request_review_comment", "submit_pull_request_review", + "resolve_pull_request_review_thread", "create_code_scanning_alert", "add_labels", "remove_labels", diff --git a/pkg/workflow/tool_description_enhancer.go b/pkg/workflow/tool_description_enhancer.go index fb0a8a7615..24399cdd3f 100644 --- a/pkg/workflow/tool_description_enhancer.go +++ b/pkg/workflow/tool_description_enhancer.go @@ -165,6 +165,13 @@ func enhanceToolDescription(toolName, baseDescription string, safeOutputs *SafeO } } + case "resolve_pull_request_review_thread": + if config := safeOutputs.ResolvePullRequestReviewThread; config != nil { + if config.Max > 0 { + constraints = append(constraints, fmt.Sprintf("Maximum %d review thread(s) can be resolved.", config.Max)) + } + } + case "create_code_scanning_alert": if config := safeOutputs.CreateCodeScanningAlerts; config != nil { if config.Max > 0 { diff --git a/schemas/agent-output.json b/schemas/agent-output.json index 7c9652a053..8e40a4a682 100644 --- a/schemas/agent-output.json +++ b/schemas/agent-output.json @@ -53,7 +53,8 @@ { "$ref": "#/$defs/HideCommentOutput" }, { "$ref": "#/$defs/DispatchWorkflowOutput" }, { "$ref": "#/$defs/AutofixCodeScanningAlertOutput" }, - { "$ref": "#/$defs/SubmitPullRequestReviewOutput" } + { "$ref": "#/$defs/SubmitPullRequestReviewOutput" }, + { "$ref": "#/$defs/ResolvePullRequestReviewThreadOutput" } ] }, "CreateIssueOutput": { @@ -339,6 +340,22 @@ } ] }, + "ResolvePullRequestReviewThreadOutput": { + "title": "Resolve Pull Request Review Thread Output", + "description": "Output for resolving a review thread on a pull request. Marks a review conversation as resolved after the feedback has been addressed.", + "type": "object", + "properties": { + "type": { + "const": "resolve_pull_request_review_thread" + }, + "thread_id": { + "type": "string", + "description": "The node ID of the review thread to resolve (e.g., 'PRRT_kwDOABCD...'). This is the GraphQL node ID, not a numeric ID." + } + }, + "required": ["type", "thread_id"], + "additionalProperties": false + }, "CreateDiscussionOutput": { "title": "Create Discussion Output", "description": "Output for creating a GitHub discussion", From a0f2a8e394cba3ef6192d44328ff0463ed51abdf Mon Sep 17 00:00:00 2001 From: William Easton Date: Sat, 14 Feb 2026 07:01:44 -0600 Subject: [PATCH 2/5] Allow resolving PR Comment --- .../js/resolve_pr_review_thread.test.cjs | 1 + .../setup/js/types/safe-outputs-config.d.ts | 2 +- .../docs/reference/frontmatter-full.md | 38 +++++++++++++++++-- .../content/docs/reference/safe-outputs.md | 1 + pkg/parser/schemas/main_workflow_schema.json | 2 +- schemas/agent-output.json | 1 + 6 files changed, 40 insertions(+), 5 deletions(-) diff --git a/actions/setup/js/resolve_pr_review_thread.test.cjs b/actions/setup/js/resolve_pr_review_thread.test.cjs index 2ca825494b..af32bed1f3 100644 --- a/actions/setup/js/resolve_pr_review_thread.test.cjs +++ b/actions/setup/js/resolve_pr_review_thread.test.cjs @@ -38,6 +38,7 @@ describe("resolve_pr_review_thread", () => { let handler; beforeEach(async () => { + vi.resetModules(); vi.clearAllMocks(); mockGraphql.mockResolvedValue({ diff --git a/actions/setup/js/types/safe-outputs-config.d.ts b/actions/setup/js/types/safe-outputs-config.d.ts index 7b8e187b13..ade96867a3 100644 --- a/actions/setup/js/types/safe-outputs-config.d.ts +++ b/actions/setup/js/types/safe-outputs-config.d.ts @@ -100,7 +100,7 @@ interface CreatePullRequestReviewCommentConfig extends SafeOutputConfig { interface ResolvePullRequestReviewThreadConfig extends SafeOutputConfig { target?: string; "target-repo"?: string; - allowed_repos?: string[]; + "allowed-repos"?: string[]; } /** diff --git a/docs/src/content/docs/reference/frontmatter-full.md b/docs/src/content/docs/reference/frontmatter-full.md index 0f8a623283..fc1c32ab86 100644 --- a/docs/src/content/docs/reference/frontmatter-full.md +++ b/docs/src/content/docs/reference/frontmatter-full.md @@ -2191,9 +2191,6 @@ safe-outputs: # available category. Matched first against category IDs, then against category # names, then against category slugs. Numeric values are automatically converted # to strings at runtime. - # - # Best Practice: Use announcement-capable categories (such as "announcements") - # for AI-generated content to ensure proper visibility and notification features. # (optional) category: null @@ -2714,6 +2711,41 @@ safe-outputs: # Option 2: Enable PR review submission with default configuration submit-pull-request-review: null + # Enable AI agents to resolve review threads on pull requests after addressing + # feedback. + # (optional) + # This field supports multiple formats (oneOf): + + # Option 1: Configuration for resolving review threads on pull requests. Allows AI + # agents to mark review conversations as resolved after addressing feedback. + resolve-pull-request-review-thread: + # Maximum number of review threads to resolve (default: 10) + # (optional) + max: 1 + + # Target for review thread resolution. Use 'triggering' (default) for the + # triggering PR, '*' for any PR, or a specific PR number. + # (optional) + target: "example-value" + + # Default target repository for cross-repo thread resolution (format: + # 'owner/repo') + # (optional) + target-repo: "example-value" + + # Allowed repositories for cross-repo thread resolution (format: 'owner/repo') + # (optional) + allowed-repos: [] + # Array of strings + + # GitHub token to use for this specific output type. Overrides global github-token + # if specified. + # (optional) + github-token: "${{ secrets.GITHUB_TOKEN }}" + + # Option 2: Enable review thread resolution with default configuration + resolve-pull-request-review-thread: null + # Enable AI agents to create GitHub Advanced Security code scanning alerts for # detected vulnerabilities or security issues. # (optional) diff --git a/docs/src/content/docs/reference/safe-outputs.md b/docs/src/content/docs/reference/safe-outputs.md index dd66835f98..1ea9389601 100644 --- a/docs/src/content/docs/reference/safe-outputs.md +++ b/docs/src/content/docs/reference/safe-outputs.md @@ -40,6 +40,7 @@ The agent requests issue creation; a separate job with `issues: write` creates i - [**Update PR**](#pull-request-updates-update-pull-request) (`update-pull-request`) - Update PR title or body (max: 1) - [**Close PR**](#close-pull-request-close-pull-request) (`close-pull-request`) - Close pull requests without merging (max: 10) - [**PR Review Comments**](#pr-review-comments-create-pull-request-review-comment) (`create-pull-request-review-comment`) - Create review comments on code lines (max: 10) +- [**Resolve PR Review Thread**](#resolve-pr-review-thread-resolve-pull-request-review-thread) (`resolve-pull-request-review-thread`) - Resolve review threads after addressing feedback (max: 10) - [**Push to PR Branch**](#push-to-pr-branch-push-to-pull-request-branch) (`push-to-pull-request-branch`) - Push changes to PR branch (max: 1, same-repo only) ### Labels, Assignments & Reviews diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 1377035db1..aaac970ca2 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -3847,7 +3847,7 @@ }, "safe-outputs": { "type": "object", - "$comment": "Required if workflow creates or modifies GitHub resources. Operations requiring safe-outputs: autofix-code-scanning-alert, add-comment, add-labels, add-reviewer, assign-milestone, assign-to-agent, close-discussion, close-issue, close-pull-request, create-agent-session, create-agent-task (deprecated, use create-agent-session), create-code-scanning-alert, create-discussion, create-issue, create-project-status-update, create-pull-request, create-pull-request-review-comment, dispatch-workflow, hide-comment, link-sub-issue, mark-pull-request-as-ready-for-review, missing-tool, noop, push-to-pull-request-branch, remove-labels, submit-pull-request-review, threat-detection, update-discussion, update-issue, update-project, update-pull-request, update-release, upload-asset. See documentation for complete details.", + "$comment": "Required if workflow creates or modifies GitHub resources. Operations requiring safe-outputs: autofix-code-scanning-alert, add-comment, add-labels, add-reviewer, assign-milestone, assign-to-agent, close-discussion, close-issue, close-pull-request, create-agent-session, create-agent-task (deprecated, use create-agent-session), create-code-scanning-alert, create-discussion, create-issue, create-project-status-update, create-pull-request, create-pull-request-review-comment, dispatch-workflow, hide-comment, link-sub-issue, mark-pull-request-as-ready-for-review, missing-tool, noop, push-to-pull-request-branch, remove-labels, resolve-pull-request-review-thread, submit-pull-request-review, threat-detection, update-discussion, update-issue, update-project, update-pull-request, update-release, upload-asset. See documentation for complete details.", "description": "Safe output processing configuration that automatically creates GitHub issues, comments, and pull requests from AI workflow output without requiring write permissions in the main job", "examples": [ { diff --git a/schemas/agent-output.json b/schemas/agent-output.json index 8e40a4a682..50a8975bf4 100644 --- a/schemas/agent-output.json +++ b/schemas/agent-output.json @@ -350,6 +350,7 @@ }, "thread_id": { "type": "string", + "minLength": 1, "description": "The node ID of the review thread to resolve (e.g., 'PRRT_kwDOABCD...'). This is the GraphQL node ID, not a numeric ID." } }, From 9b134b9883b4d7106aded0c42aebb742782371cf Mon Sep 17 00:00:00 2001 From: William Easton Date: Sat, 14 Feb 2026 07:11:03 -0600 Subject: [PATCH 3/5] Cleanup PR --- actions/setup/js/resolve_pr_review_thread.cjs | 65 ++++++- .../js/resolve_pr_review_thread.test.cjs | 164 +++++++++++++++--- .../setup/js/types/safe-outputs-config.d.ts | 7 +- .../docs/reference/frontmatter-full.md | 24 +-- .../content/docs/reference/safe-outputs.md | 6 +- pkg/parser/schemas/main_workflow_schema.json | 17 +- pkg/workflow/compiler_safe_outputs_config.go | 3 - pkg/workflow/resolve_pr_review_thread.go | 17 +- .../safe_outputs_config_generation.go | 5 - .../safe_outputs_target_validation.go | 4 - 10 files changed, 220 insertions(+), 92 deletions(-) diff --git a/actions/setup/js/resolve_pr_review_thread.cjs b/actions/setup/js/resolve_pr_review_thread.cjs index e45b6e0d5e..c8aae1aeed 100644 --- a/actions/setup/js/resolve_pr_review_thread.cjs +++ b/actions/setup/js/resolve_pr_review_thread.cjs @@ -12,6 +12,31 @@ const { getErrorMessage } = require("./error_helpers.cjs"); */ const HANDLER_TYPE = "resolve_pull_request_review_thread"; +/** + * Look up a review thread's parent PR number via the GraphQL API. + * Used to validate the thread belongs to the triggering PR before resolving. + * @param {any} github - GitHub GraphQL instance + * @param {string} threadId - Review thread node ID (e.g., 'PRRT_kwDOABCD...') + * @returns {Promise} The PR number the thread belongs to, or null if not found + */ +async function getThreadPullRequestNumber(github, threadId) { + const query = /* GraphQL */ ` + query ($threadId: ID!) { + node(id: $threadId) { + ... on PullRequestReviewThread { + pullRequest { + number + } + } + } + } + `; + + const result = await github.graphql(query, { threadId }); + + return result?.node?.pullRequest?.number ?? null; +} + /** * Resolve a pull request review thread using the GraphQL API. * @param {any} github - GitHub GraphQL instance @@ -40,14 +65,21 @@ async function resolveReviewThreadAPI(github, threadId) { /** * Main handler factory for resolve_pull_request_review_thread - * Returns a message handler function that processes individual resolve messages + * Returns a message handler function that processes individual resolve messages. + * + * Resolution is scoped to the triggering PR only — the handler validates that each + * thread belongs to the triggering pull request before resolving it. This prevents + * agents from resolving threads on unrelated PRs. * @type {HandlerFactoryFunction} */ async function main(config = {}) { // Extract configuration const maxCount = config.max || 10; - core.info(`Resolve PR review thread configuration: max=${maxCount}`); + // Determine the triggering PR number from context + const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); + + core.info(`Resolve PR review thread configuration: max=${maxCount}, triggeringPR=${triggeringPRNumber || "none"}`); // Track how many items we've processed for max limit let processedCount = 0; @@ -83,7 +115,34 @@ async function main(config = {}) { }; } - core.info(`Resolving review thread: ${threadId}`); + // Validate triggering PR context + if (!triggeringPRNumber) { + core.warning("Cannot resolve review thread: not running in a pull request context"); + return { + success: false, + error: "Cannot resolve review threads outside of a pull request context", + }; + } + + // Look up the thread to validate it belongs to the triggering PR + const threadPRNumber = await getThreadPullRequestNumber(github, threadId); + if (threadPRNumber === null) { + core.warning(`Review thread not found or not a PullRequestReviewThread: ${threadId}`); + return { + success: false, + error: `Review thread not found: ${threadId}`, + }; + } + + if (threadPRNumber !== triggeringPRNumber) { + core.warning(`Thread ${threadId} 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 #${triggeringPRNumber})`); const resolveResult = await resolveReviewThreadAPI(github, threadId); diff --git a/actions/setup/js/resolve_pr_review_thread.test.cjs b/actions/setup/js/resolve_pr_review_thread.test.cjs index af32bed1f3..64787a3915 100644 --- a/actions/setup/js/resolve_pr_review_thread.test.cjs +++ b/actions/setup/js/resolve_pr_review_thread.test.cjs @@ -34,6 +34,32 @@ const mockContext = { global.context = mockContext; +/** + * Helper to set up mockGraphql to handle both the lookup query and the resolve mutation. + * @param {number} lookupPRNumber - PR number returned by the thread lookup query + */ +function mockGraphqlForThread(lookupPRNumber) { + mockGraphql.mockImplementation(query => { + if (query.includes("resolveReviewThread")) { + // Mutation + return Promise.resolve({ + resolveReviewThread: { + thread: { + id: "PRRT_kwDOABCD123456", + isResolved: true, + }, + }, + }); + } + // Lookup query + return Promise.resolve({ + node: { + pullRequest: { number: lookupPRNumber }, + }, + }); + }); +} + describe("resolve_pr_review_thread", () => { let handler; @@ -41,14 +67,8 @@ describe("resolve_pr_review_thread", () => { vi.resetModules(); vi.clearAllMocks(); - mockGraphql.mockResolvedValue({ - resolveReviewThread: { - thread: { - id: "PRRT_kwDOABCD123456", - isResolved: true, - }, - }, - }); + // Default: thread belongs to triggering PR #42 + mockGraphqlForThread(42); const { main } = require("./resolve_pr_review_thread.cjs"); handler = await main({ max: 10 }); @@ -60,7 +80,7 @@ describe("resolve_pr_review_thread", () => { expect(typeof result).toBe("function"); }); - it("should successfully resolve a review thread", async () => { + it("should successfully resolve a review thread on the triggering PR", async () => { const message = { type: "resolve_pull_request_review_thread", thread_id: "PRRT_kwDOABCD123456", @@ -71,12 +91,75 @@ describe("resolve_pr_review_thread", () => { expect(result.success).toBe(true); expect(result.thread_id).toBe("PRRT_kwDOABCD123456"); expect(result.is_resolved).toBe(true); - expect(mockGraphql).toHaveBeenCalledWith( - expect.stringContaining("resolveReviewThread"), - expect.objectContaining({ - threadId: "PRRT_kwDOABCD123456", - }) - ); + // Should have made two GraphQL calls: lookup + resolve + expect(mockGraphql).toHaveBeenCalledTimes(2); + expect(mockGraphql).toHaveBeenCalledWith(expect.stringContaining("resolveReviewThread"), expect.objectContaining({ threadId: "PRRT_kwDOABCD123456" })); + }); + + it("should reject a thread that belongs to a different PR", async () => { + // Thread belongs to PR #99, not triggering PR #42 + mockGraphqlForThread(99); + + const { main } = require("./resolve_pr_review_thread.cjs"); + const freshHandler = await main({ max: 10 }); + + const message = { + type: "resolve_pull_request_review_thread", + thread_id: "PRRT_kwDOOtherThread", + }; + + const result = await freshHandler(message, {}); + + expect(result.success).toBe(false); + expect(result.error).toContain("PR #99"); + expect(result.error).toContain("triggering PR #42"); + }); + + it("should reject when thread is not found", async () => { + mockGraphql.mockImplementation(query => { + if (query.includes("resolveReviewThread")) { + return Promise.resolve({}); + } + // Lookup returns null node + return Promise.resolve({ node: null }); + }); + + const { main } = require("./resolve_pr_review_thread.cjs"); + const freshHandler = await main({ max: 10 }); + + const message = { + type: "resolve_pull_request_review_thread", + thread_id: "PRRT_invalid", + }; + + const result = await freshHandler(message, {}); + + expect(result.success).toBe(false); + expect(result.error).toContain("not found"); + }); + + it("should reject when not in a pull request context", async () => { + // Override context to non-PR event + const savedPayload = global.context.payload; + global.context.payload = { + repository: { html_url: "https://github.com/test-owner/test-repo" }, + }; + + const { main } = require("./resolve_pr_review_thread.cjs"); + const freshHandler = await main({ max: 10 }); + + const message = { + type: "resolve_pull_request_review_thread", + thread_id: "PRRT_kwDOABCD123456", + }; + + const result = await freshHandler(message, {}); + + expect(result.success).toBe(false); + expect(result.error).toContain("pull request context"); + + // Restore + global.context.payload = savedPayload; }); it("should fail when thread_id is missing", async () => { @@ -160,21 +243,32 @@ describe("resolve_pr_review_thread", () => { }); it("should handle unexpected resolve failure", async () => { - mockGraphql.mockResolvedValue({ - resolveReviewThread: { - thread: { - id: "PRRT_kwDOABCD123456", - isResolved: false, - }, - }, + mockGraphql.mockImplementation(query => { + if (query.includes("resolveReviewThread")) { + return Promise.resolve({ + resolveReviewThread: { + thread: { + id: "PRRT_kwDOABCD123456", + isResolved: false, + }, + }, + }); + } + // Lookup succeeds - thread is on triggering PR + return Promise.resolve({ + node: { pullRequest: { number: 42 } }, + }); }); + const { main } = require("./resolve_pr_review_thread.cjs"); + const freshHandler = await main({ max: 10 }); + const message = { type: "resolve_pull_request_review_thread", thread_id: "PRRT_kwDOABCD123456", }; - const result = await handler(message, {}); + const result = await freshHandler(message, {}); expect(result.success).toBe(false); expect(result.error).toContain("Failed to resolve"); @@ -200,4 +294,28 @@ describe("resolve_pr_review_thread", () => { expect(result.success).toBe(false); expect(result.error).toContain("Max count of 10 reached"); }); + + it("should work when triggered from issue_comment on a PR", async () => { + // Simulate issue_comment event on a PR + const savedPayload = global.context.payload; + global.context.payload = { + issue: { number: 42, pull_request: { url: "https://api.github.com/..." } }, + repository: { html_url: "https://github.com/test-owner/test-repo" }, + }; + + const { main } = require("./resolve_pr_review_thread.cjs"); + const freshHandler = await main({ max: 10 }); + + const message = { + type: "resolve_pull_request_review_thread", + thread_id: "PRRT_kwDOABCD123456", + }; + + const result = await freshHandler(message, {}); + + expect(result.success).toBe(true); + + // Restore + global.context.payload = savedPayload; + }); }); diff --git a/actions/setup/js/types/safe-outputs-config.d.ts b/actions/setup/js/types/safe-outputs-config.d.ts index ade96867a3..5e65ad7468 100644 --- a/actions/setup/js/types/safe-outputs-config.d.ts +++ b/actions/setup/js/types/safe-outputs-config.d.ts @@ -95,12 +95,11 @@ interface CreatePullRequestReviewCommentConfig extends SafeOutputConfig { } /** - * Configuration for resolving pull request review threads + * Configuration for resolving pull request review threads. + * Resolution is scoped to the triggering PR only. */ interface ResolvePullRequestReviewThreadConfig extends SafeOutputConfig { - target?: string; - "target-repo"?: string; - "allowed-repos"?: string[]; + // Only max is supported — resolution is always scoped to the triggering PR } /** diff --git a/docs/src/content/docs/reference/frontmatter-full.md b/docs/src/content/docs/reference/frontmatter-full.md index fc1c32ab86..bb4979b09f 100644 --- a/docs/src/content/docs/reference/frontmatter-full.md +++ b/docs/src/content/docs/reference/frontmatter-full.md @@ -2711,33 +2711,19 @@ safe-outputs: # Option 2: Enable PR review submission with default configuration submit-pull-request-review: null - # Enable AI agents to resolve review threads on pull requests after addressing - # feedback. + # Enable AI agents to resolve review threads on the triggering pull request after + # addressing feedback. # (optional) # This field supports multiple formats (oneOf): - # Option 1: Configuration for resolving review threads on pull requests. Allows AI - # agents to mark review conversations as resolved after addressing feedback. + # Option 1: Configuration for resolving review threads on pull requests. + # Resolution is scoped to the triggering PR only — threads on other PRs cannot be + # resolved. resolve-pull-request-review-thread: # Maximum number of review threads to resolve (default: 10) # (optional) max: 1 - # Target for review thread resolution. Use 'triggering' (default) for the - # triggering PR, '*' for any PR, or a specific PR number. - # (optional) - target: "example-value" - - # Default target repository for cross-repo thread resolution (format: - # 'owner/repo') - # (optional) - target-repo: "example-value" - - # Allowed repositories for cross-repo thread resolution (format: 'owner/repo') - # (optional) - allowed-repos: [] - # Array of strings - # GitHub token to use for this specific output type. Overrides global github-token # if specified. # (optional) diff --git a/docs/src/content/docs/reference/safe-outputs.md b/docs/src/content/docs/reference/safe-outputs.md index 1ea9389601..a99544c79e 100644 --- a/docs/src/content/docs/reference/safe-outputs.md +++ b/docs/src/content/docs/reference/safe-outputs.md @@ -709,14 +709,12 @@ safe-outputs: Resolves review threads on pull requests. Allows AI agents to mark review conversations as resolved after addressing the feedback. Uses the GitHub GraphQL API with the `resolveReviewThread` mutation. +Resolution is scoped to the triggering PR only — the handler validates that each thread belongs to the triggering pull request before resolving it. + ```yaml wrap safe-outputs: resolve-pull-request-review-thread: max: 10 # max threads to resolve (default: 10) - target: "*" # "triggering" (default), "*", or PR number - target-repo: "owner/repo" # cross-repository - allowed-repos: # allowed repos for cross-repo resolution - - "org/repo1" ``` **Agent output format:** diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index aaac970ca2..782e864a4a 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -5003,7 +5003,7 @@ "oneOf": [ { "type": "object", - "description": "Configuration for resolving review threads on pull requests. Allows AI agents to mark review conversations as resolved after addressing feedback.", + "description": "Configuration for resolving review threads on pull requests. Resolution is scoped to the triggering PR only — threads on other PRs cannot be resolved.", "properties": { "max": { "type": "integer", @@ -5011,19 +5011,6 @@ "minimum": 1, "maximum": 100 }, - "target": { - "type": "string", - "description": "Target for review thread resolution. Use 'triggering' (default) for the triggering PR, '*' for any PR, or a specific PR number." - }, - "target-repo": { - "type": "string", - "description": "Default target repository for cross-repo thread resolution (format: 'owner/repo')" - }, - "allowed-repos": { - "type": "array", - "items": { "type": "string" }, - "description": "Allowed repositories for cross-repo thread resolution (format: 'owner/repo')" - }, "github-token": { "$ref": "#/$defs/github_token", "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." @@ -5036,7 +5023,7 @@ "description": "Enable review thread resolution with default configuration" } ], - "description": "Enable AI agents to resolve review threads on pull requests after addressing feedback." + "description": "Enable AI agents to resolve review threads on the triggering pull request after addressing feedback." }, "create-code-scanning-alert": { "oneOf": [ diff --git a/pkg/workflow/compiler_safe_outputs_config.go b/pkg/workflow/compiler_safe_outputs_config.go index 07fec1ef9e..e8bbd362eb 100644 --- a/pkg/workflow/compiler_safe_outputs_config.go +++ b/pkg/workflow/compiler_safe_outputs_config.go @@ -323,9 +323,6 @@ var handlerRegistry = map[string]handlerBuilder{ c := cfg.ResolvePullRequestReviewThread return newHandlerConfigBuilder(). AddIfPositive("max", c.Max). - AddIfNotEmpty("target", c.Target). - AddIfNotEmpty("target-repo", c.TargetRepoSlug). - AddStringSlice("allowed_repos", c.AllowedRepos). Build() }, "create_pull_request": func(cfg *SafeOutputsConfig) map[string]any { diff --git a/pkg/workflow/resolve_pr_review_thread.go b/pkg/workflow/resolve_pr_review_thread.go index fd09a66e4e..5c101880f9 100644 --- a/pkg/workflow/resolve_pr_review_thread.go +++ b/pkg/workflow/resolve_pr_review_thread.go @@ -6,10 +6,11 @@ import ( var resolvePRReviewThreadLog = logger.New("workflow:resolve_pr_review_thread") -// ResolvePullRequestReviewThreadConfig holds configuration for resolving PR review threads +// ResolvePullRequestReviewThreadConfig holds configuration for resolving PR review threads. +// Resolution is scoped to the triggering PR only — the JavaScript handler validates +// that each thread belongs to the triggering pull request before resolving it. type ResolvePullRequestReviewThreadConfig struct { - BaseSafeOutputConfig `yaml:",inline"` - SafeOutputTargetConfig `yaml:",inline"` + BaseSafeOutputConfig `yaml:",inline"` } // parseResolvePullRequestReviewThreadConfig handles resolve-pull-request-review-thread configuration @@ -21,18 +22,10 @@ func (c *Compiler) parseResolvePullRequestReviewThreadConfig(outputMap map[strin if configMap, ok := configData.(map[string]any); ok { resolvePRReviewThreadLog.Print("Found resolve-pull-request-review-thread config map") - // Parse target config (target, target-repo, allowed-repos) with validation - targetConfig, isInvalid := ParseTargetConfig(configMap) - if isInvalid { - return nil // Invalid configuration (e.g., wildcard target-repo), return nil to cause validation error - } - config.SafeOutputTargetConfig = targetConfig - // Parse common base fields with default max of 10 c.parseBaseSafeOutputConfig(configMap, &config.BaseSafeOutputConfig, 10) - resolvePRReviewThreadLog.Printf("Parsed resolve-pull-request-review-thread config: max=%d, target_repo=%s", - config.Max, config.TargetRepoSlug) + resolvePRReviewThreadLog.Printf("Parsed resolve-pull-request-review-thread config: max=%d", config.Max) } else { // If configData is nil or not a map, still set the default max config.Max = 10 diff --git a/pkg/workflow/safe_outputs_config_generation.go b/pkg/workflow/safe_outputs_config_generation.go index 8ce550ecc4..8e23cdc4f5 100644 --- a/pkg/workflow/safe_outputs_config_generation.go +++ b/pkg/workflow/safe_outputs_config_generation.go @@ -867,11 +867,6 @@ func addRepoParameterIfNeeded(tool map[string]any, toolName string, safeOutputs hasAllowedRepos = len(config.AllowedRepos) > 0 targetRepoSlug = config.TargetRepoSlug } - case "resolve_pull_request_review_thread": - if config := safeOutputs.ResolvePullRequestReviewThread; config != nil { - hasAllowedRepos = len(config.AllowedRepos) > 0 - targetRepoSlug = config.TargetRepoSlug - } case "create_agent_session": if config := safeOutputs.CreateAgentSessions; config != nil { hasAllowedRepos = len(config.AllowedRepos) > 0 diff --git a/pkg/workflow/safe_outputs_target_validation.go b/pkg/workflow/safe_outputs_target_validation.go index 23c1f334c0..0432964d9d 100644 --- a/pkg/workflow/safe_outputs_target_validation.go +++ b/pkg/workflow/safe_outputs_target_validation.go @@ -87,10 +87,6 @@ func validateSafeOutputsTarget(config *SafeOutputsConfig) error { if config.PushToPullRequestBranch != nil { configs = append(configs, targetConfig{"push-to-pull-request-branch", config.PushToPullRequestBranch.Target}) } - if config.ResolvePullRequestReviewThread != nil { - configs = append(configs, targetConfig{"resolve-pull-request-review-thread", config.ResolvePullRequestReviewThread.Target}) - } - // Validate each target field for _, cfg := range configs { if err := validateTargetValue(cfg.name, cfg.target); err != nil { From 4fc06bb833d0b9ea8527317d09cb44b91769bbfd Mon Sep 17 00:00:00 2001 From: William Easton Date: Sat, 14 Feb 2026 07:18:14 -0600 Subject: [PATCH 4/5] Small additional cleanup --- actions/setup/js/resolve_pr_review_thread.cjs | 13 +++++++++++- .../js/resolve_pr_review_thread.test.cjs | 20 +++++++++---------- .../setup/js/types/safe-outputs-config.d.ts | 5 ++++- 3 files changed, 25 insertions(+), 13 deletions(-) diff --git a/actions/setup/js/resolve_pr_review_thread.cjs b/actions/setup/js/resolve_pr_review_thread.cjs index c8aae1aeed..766ed6ee39 100644 --- a/actions/setup/js/resolve_pr_review_thread.cjs +++ b/actions/setup/js/resolve_pr_review_thread.cjs @@ -63,6 +63,17 @@ async function resolveReviewThreadAPI(github, threadId) { }; } +/** + * Extract the triggering pull request number from the GitHub Actions event payload. + * Supports both pull_request events (payload.pull_request.number) and issue_comment + * events on PRs (payload.issue.number when payload.issue.pull_request is present). + * @param {any} payload - The context.payload from the GitHub Actions event + * @returns {number|undefined} The PR number, or undefined if not in a PR context + */ +function getTriggeringPRNumber(payload) { + return payload?.pull_request?.number || (payload?.issue?.pull_request ? payload.issue.number : undefined); +} + /** * Main handler factory for resolve_pull_request_review_thread * Returns a message handler function that processes individual resolve messages. @@ -77,7 +88,7 @@ async function main(config = {}) { const maxCount = config.max || 10; // Determine the triggering PR number from context - const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); + const triggeringPRNumber = getTriggeringPRNumber(context.payload); core.info(`Resolve PR review thread configuration: max=${maxCount}, triggeringPR=${triggeringPRNumber || "none"}`); diff --git a/actions/setup/js/resolve_pr_review_thread.test.cjs b/actions/setup/js/resolve_pr_review_thread.test.cjs index 64787a3915..8dc132dad6 100644 --- a/actions/setup/js/resolve_pr_review_thread.test.cjs +++ b/actions/setup/js/resolve_pr_review_thread.test.cjs @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, vi } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; const mockCore = { debug: vi.fn(), @@ -62,6 +62,7 @@ function mockGraphqlForThread(lookupPRNumber) { describe("resolve_pr_review_thread", () => { let handler; + const originalPayload = mockContext.payload; beforeEach(async () => { vi.resetModules(); @@ -74,6 +75,11 @@ describe("resolve_pr_review_thread", () => { handler = await main({ max: 10 }); }); + afterEach(() => { + // Always restore the global context payload, even if assertions threw + global.context.payload = originalPayload; + }); + it("should return a function from main()", async () => { const { main } = require("./resolve_pr_review_thread.cjs"); const result = await main({}); @@ -139,8 +145,7 @@ describe("resolve_pr_review_thread", () => { }); it("should reject when not in a pull request context", async () => { - // Override context to non-PR event - const savedPayload = global.context.payload; + // Override context to non-PR event (afterEach restores the original payload) global.context.payload = { repository: { html_url: "https://github.com/test-owner/test-repo" }, }; @@ -157,9 +162,6 @@ describe("resolve_pr_review_thread", () => { expect(result.success).toBe(false); expect(result.error).toContain("pull request context"); - - // Restore - global.context.payload = savedPayload; }); it("should fail when thread_id is missing", async () => { @@ -296,8 +298,7 @@ describe("resolve_pr_review_thread", () => { }); it("should work when triggered from issue_comment on a PR", async () => { - // Simulate issue_comment event on a PR - const savedPayload = global.context.payload; + // Simulate issue_comment event on a PR (afterEach restores the original payload) global.context.payload = { issue: { number: 42, pull_request: { url: "https://api.github.com/..." } }, repository: { html_url: "https://github.com/test-owner/test-repo" }, @@ -314,8 +315,5 @@ describe("resolve_pr_review_thread", () => { const result = await freshHandler(message, {}); expect(result.success).toBe(true); - - // Restore - global.context.payload = savedPayload; }); }); diff --git a/actions/setup/js/types/safe-outputs-config.d.ts b/actions/setup/js/types/safe-outputs-config.d.ts index 5e65ad7468..1f5a165672 100644 --- a/actions/setup/js/types/safe-outputs-config.d.ts +++ b/actions/setup/js/types/safe-outputs-config.d.ts @@ -97,9 +97,12 @@ interface CreatePullRequestReviewCommentConfig extends SafeOutputConfig { /** * Configuration for resolving pull request review threads. * Resolution is scoped to the triggering PR only. + * + * Inherits common fields (e.g. "github-token") from SafeOutputConfig. + * The only new field explicitly supported on this interface is "max". */ interface ResolvePullRequestReviewThreadConfig extends SafeOutputConfig { - // Only max is supported — resolution is always scoped to the triggering PR + // "max" is the only field added beyond those inherited from SafeOutputConfig } /** From 2c183737d9a6e8ccd5e5a4523e3d87dfc2baf07a Mon Sep 17 00:00:00 2001 From: William Easton Date: Sat, 14 Feb 2026 07:27:05 -0600 Subject: [PATCH 5/5] additional pr improvements --- actions/setup/js/resolve_pr_review_thread.cjs | 2 +- .../js/resolve_pr_review_thread.test.cjs | 46 +++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/actions/setup/js/resolve_pr_review_thread.cjs b/actions/setup/js/resolve_pr_review_thread.cjs index 766ed6ee39..72806c9938 100644 --- a/actions/setup/js/resolve_pr_review_thread.cjs +++ b/actions/setup/js/resolve_pr_review_thread.cjs @@ -182,4 +182,4 @@ async function main(config = {}) { }; } -module.exports = { main, HANDLER_TYPE }; +module.exports = { main, HANDLER_TYPE, getTriggeringPRNumber }; diff --git a/actions/setup/js/resolve_pr_review_thread.test.cjs b/actions/setup/js/resolve_pr_review_thread.test.cjs index 8dc132dad6..3309dd7fd3 100644 --- a/actions/setup/js/resolve_pr_review_thread.test.cjs +++ b/actions/setup/js/resolve_pr_review_thread.test.cjs @@ -317,3 +317,49 @@ describe("resolve_pr_review_thread", () => { expect(result.success).toBe(true); }); }); + +describe("getTriggeringPRNumber", () => { + it("should return pull_request.number for pull_request events", () => { + const { getTriggeringPRNumber } = require("./resolve_pr_review_thread.cjs"); + const payload = { pull_request: { number: 7 } }; + expect(getTriggeringPRNumber(payload)).toBe(7); + }); + + it("should return issue.number for issue_comment events on a PR", () => { + const { getTriggeringPRNumber } = require("./resolve_pr_review_thread.cjs"); + const payload = { issue: { number: 15, pull_request: { url: "https://api.github.com/..." } } }; + expect(getTriggeringPRNumber(payload)).toBe(15); + }); + + it("should return undefined when payload has no PR context", () => { + const { getTriggeringPRNumber } = require("./resolve_pr_review_thread.cjs"); + const payload = { repository: { html_url: "https://github.com/owner/repo" } }; + expect(getTriggeringPRNumber(payload)).toBeUndefined(); + }); + + it("should return undefined for an empty payload", () => { + const { getTriggeringPRNumber } = require("./resolve_pr_review_thread.cjs"); + expect(getTriggeringPRNumber({})).toBeUndefined(); + }); + + it("should return undefined for a nullish payload", () => { + const { getTriggeringPRNumber } = require("./resolve_pr_review_thread.cjs"); + expect(getTriggeringPRNumber(null)).toBeUndefined(); + expect(getTriggeringPRNumber(undefined)).toBeUndefined(); + }); + + it("should prefer pull_request.number over issue.number", () => { + const { getTriggeringPRNumber } = require("./resolve_pr_review_thread.cjs"); + const payload = { + pull_request: { number: 10 }, + issue: { number: 20, pull_request: { url: "https://api.github.com/..." } }, + }; + expect(getTriggeringPRNumber(payload)).toBe(10); + }); + + it("should not return issue.number when issue has no pull_request field", () => { + const { getTriggeringPRNumber } = require("./resolve_pr_review_thread.cjs"); + const payload = { issue: { number: 30 } }; + expect(getTriggeringPRNumber(payload)).toBeUndefined(); + }); +});