@openhands/extensions 0.22.1 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/.agents/skills/custom-codereview-guide.md +86 -17
  2. package/.claude-plugin/marketplace.json +14 -0
  3. package/.codex-plugin/marketplace.json +14 -0
  4. package/.github/workflows/check-extensions.yml +6 -6
  5. package/.github/workflows/deprecation-check.yml +1 -1
  6. package/.github/workflows/issue-readiness-check.yml +37 -106
  7. package/.github/workflows/npm-publish.yml +2 -2
  8. package/.github/workflows/pr-description-check.yml +1 -1
  9. package/.github/workflows/pypi-publish.yml +3 -3
  10. package/.github/workflows/tests.yml +2 -2
  11. package/.plugin/marketplace.json +14 -0
  12. package/.release-please-manifest.json +1 -1
  13. package/AGENTS.md +32 -2
  14. package/README.md +4 -3
  15. package/automations/bundle-index.js +12 -11
  16. package/automations/catalog/github-issue-triage/manifest.json +2 -2
  17. package/automations/catalog/github-pr-reviewer/manifest.json +42 -5
  18. package/automations/catalog.schema.json +0 -4
  19. package/integrations/catalog/granola.json +38 -0
  20. package/integrations/catalog-index.js +16 -14
  21. package/marketplaces/openhands-extensions.json +14 -0
  22. package/package.json +1 -1
  23. package/plugins/openhands/skills/openhands-sdk/SKILL.md +3 -1
  24. package/plugins/pr-review/README.md +2 -1
  25. package/plugins/pr-review/scripts/agent_script.py +198 -81
  26. package/plugins/pr-review/scripts/prompt.py +35 -11
  27. package/plugins/pr-review/skills/code-review/SKILL.md +165 -200
  28. package/plugins/pr-review/skills/code-review/commands/codereview-roasted.md +1 -1
  29. package/plugins/pr-review/skills/code-review/commands/codereview.md +1 -1
  30. package/plugins/vulnerability-remediation/skills/github/scripts/agent_conversation.py +8 -3
  31. package/plugins/vulnerability-remediation/skills/github/scripts/github_client.py +4 -0
  32. package/pyproject.toml +2 -2
  33. package/python/openhands_extensions/_version.py +1 -1
  34. package/skills/add-skill/SKILL.md +5 -2
  35. package/skills/canvas-extension-api/.claude-plugin/plugin.json +19 -0
  36. package/skills/canvas-extension-api/.codex-plugin/plugin.json +19 -0
  37. package/skills/canvas-extension-api/.plugin/plugin.json +19 -0
  38. package/skills/canvas-extension-api/README.md +33 -0
  39. package/skills/canvas-extension-api/SKILL.md +254 -0
  40. package/skills/canvas-extension-api/references/acceptance-checklist.md +20 -0
  41. package/skills/canvas-extension-api/references/backend-safety.md +9 -0
  42. package/skills/canvas-extension-api/references/connections.md +126 -0
  43. package/skills/canvas-extension-api/references/packaging-recipes.md +43 -0
  44. package/skills/canvas-extension-api/references/sidecar-pattern.md +49 -0
  45. package/skills/canvas-extension-api/references/testing-and-installation.md +286 -0
  46. package/skills/canvas-extension-api/references/v1-contract.md +250 -0
  47. package/skills/canvas-extension-api/scripts/validate-extension.mjs +92 -0
  48. package/skills/canvas-extension-api/scripts/validate-extension.test.mjs +60 -0
  49. package/skills/code-review/SKILL.md +165 -200
  50. package/skills/code-review/commands/codereview-roasted.md +1 -1
  51. package/skills/code-review/commands/codereview.md +1 -1
  52. package/skills/github/scripts/agent_conversation.py +8 -3
  53. package/skills/github/scripts/github_client.py +4 -0
  54. package/skills/github-agents-md-maintainer/scripts/github_client.py +4 -0
  55. package/skills/github-delivery-watchdog/scripts/github_client.py +4 -0
  56. package/skills/github-issue-to-pr/scripts/agent_conversation.py +8 -3
  57. package/skills/github-issue-to-pr/scripts/github_client.py +4 -0
  58. package/skills/github-issue-triage/.claude-plugin/plugin.json +1 -1
  59. package/skills/github-issue-triage/.codex-plugin/plugin.json +1 -1
  60. package/skills/github-issue-triage/.plugin/plugin.json +1 -1
  61. package/skills/github-issue-triage/README.md +7 -0
  62. package/skills/github-issue-triage/SKILL.md +26 -3
  63. package/skills/github-issue-triage/scripts/agent_conversation.py +8 -3
  64. package/skills/github-issue-triage/scripts/github_client.py +4 -0
  65. package/skills/github-issue-triage/scripts/worker.py +142 -26
  66. package/skills/github-pr-reviewer/README.md +21 -10
  67. package/skills/github-pr-reviewer/SKILL.md +36 -10
  68. package/skills/github-pr-reviewer/commands/pr-reviewer-setup.md +1 -1
  69. package/skills/github-pr-reviewer/scripts/agent_conversation.py +8 -3
  70. package/skills/github-pr-reviewer/scripts/github_client.py +4 -0
  71. package/skills/github-pr-reviewer/scripts/main.py +63 -16
  72. package/skills/github-pr-reviewer/scripts/maintainer_handoff.py +151 -0
  73. package/skills/github-pr-reviewer/scripts/worker.py +218 -21
  74. package/skills/github-pr-reviewer/tests/test_main.py +85 -0
  75. package/skills/github-repo-monitor/scripts/github_client.py +4 -0
  76. package/skills/index.js +16 -8
  77. package/skills/openhands-sdk/SKILL.md +3 -1
  78. package/tests/fixtures/automations/github-pr-reviewer.json +120 -26
  79. package/tests/github_automation_helpers.py +1 -0
  80. package/tests/test_agent_conversation_dispatch.py +19 -4
  81. package/tests/test_automation_setup.py +15 -0
  82. package/tests/test_code_review_risk_evaluation.py +2 -2
  83. package/tests/test_github_reviewer_delivery.py +448 -5
  84. package/tests/test_github_triage_delivery.py +200 -6
  85. package/tests/test_maintainer_handoff.py +198 -0
  86. package/tests/test_pr_review_prompt.py +6 -2
  87. package/tests/test_pr_review_review_context.py +144 -0
  88. package/.github/scripts/check_issue_readiness.py +0 -272
  89. package/.github/scripts/post-readiness-comment.mjs +0 -184
  90. package/tests/test_check_issue_readiness.py +0 -135
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "github-issue-triage",
3
- "version": "1.1.1",
3
+ "version": "1.3.0",
4
4
  "name": "GitHub issue triage",
5
5
  "category": "Project management",
6
6
  "description": "Prioritize open issues and establish acceptance criteria before marking them ready for development.",
@@ -54,7 +54,7 @@
54
54
  }
55
55
  },
56
56
  "bundle": {
57
- "version": "1.1.1",
57
+ "version": "1.3.0",
58
58
  "entrypoint": "python3 worker.py",
59
59
  "timeout": 300,
60
60
  "files": {
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "id": "github-pr-reviewer",
3
- "version": "1.1.1",
3
+ "version": "1.5.0",
4
4
  "name": "GitHub code review",
5
5
  "category": "Code review",
6
- "description": "Review labeled pull-request heads, publish readable findings, and record exact-head test and review decisions.",
6
+ "description": "Review requested pull-request heads, record exact-head decisions, stop early on out-of-scope changes, and optionally request a code-aware human maintainer after approval or a maintainer-decision scope stop.",
7
7
  "requires": {
8
8
  "integrations": {},
9
9
  "features": [
@@ -13,7 +13,7 @@
13
13
  },
14
14
  "popularityRank": 100,
15
15
  "estimatedSetupMinutes": 4,
16
- "exampleImplementation": "A scheduled scanner selects every labeled PR head and submits an idempotent subject turn. The profile-backed agent clones that exact head in its runtime, reuses the established review workflow, runs appropriate tests, publishes a readable native review, and records exact-head review and test statuses.",
16
+ "exampleImplementation": "A scheduled label scan or GitHub reviewer-request event selects a PR head and submits an idempotent subject turn. The profile-backed agent clones that exact head in its runtime, applies an early repository-ownership and product/architecture scope gate, then reuses the established review workflow, runs appropriate tests, and publishes a readable native review. A native approval, or a maintainer-decision scope stop, can then be handed to a code-aware human maintainer.",
17
17
  "impact": {
18
18
  "basis": "completed-runs",
19
19
  "one": "1 PR review sweep completed",
@@ -39,6 +39,21 @@
39
39
  "default": "UTC",
40
40
  "required": true
41
41
  }
42
+ },
43
+ "event": {
44
+ "on": {
45
+ "type": "select",
46
+ "label": "GitHub events",
47
+ "help": "Handle requests for the configured bot and its resulting native reviews.",
48
+ "default": "*",
49
+ "required": true,
50
+ "options": [
51
+ {
52
+ "value": "*",
53
+ "label": "Reviewer request and completion"
54
+ }
55
+ ]
56
+ }
42
57
  }
43
58
  },
44
59
  "args": {
@@ -61,6 +76,17 @@
61
76
  "maxLength": 50
62
77
  }
63
78
  },
79
+ "triggerReviewer": {
80
+ "type": "text",
81
+ "label": "Requested reviewer",
82
+ "help": "GitHub login whose review request starts an event-driven review.",
83
+ "default": "all-hands-bot",
84
+ "required": true,
85
+ "constraints": {
86
+ "minLength": 1,
87
+ "maxLength": 39
88
+ }
89
+ },
64
90
  "reviewTone": {
65
91
  "type": "select",
66
92
  "label": "Review tone",
@@ -82,6 +108,13 @@
82
108
  }
83
109
  ]
84
110
  },
111
+ "maintainers": {
112
+ "type": "text",
113
+ "label": "Maintainers",
114
+ "help": "Optional comma-separated GitHub collaborator logins. Include at least two so a maintainer-authored PR still has an eligible reviewer. After an exact-head approval, request one based on changed-path history and current review load.",
115
+ "default": "",
116
+ "required": false
117
+ },
85
118
  "githubTokenSecret": {
86
119
  "type": "text",
87
120
  "label": "GitHub token secret",
@@ -92,22 +125,26 @@
92
125
  }
93
126
  },
94
127
  "bundle": {
95
- "version": "1.1.1",
128
+ "version": "1.5.0",
96
129
  "entrypoint": "python3 worker.py",
97
130
  "timeout": 300,
98
131
  "files": {
99
132
  "main.py": "skills/github-pr-reviewer/scripts/main.py",
100
133
  "agent_conversation.py": "skills/github/scripts/agent_conversation.py",
101
134
  "github_client.py": "skills/github/scripts/github_client.py",
135
+ "maintainer_handoff.py": "skills/github-pr-reviewer/scripts/maintainer_handoff.py",
102
136
  "worker.py": "skills/github-pr-reviewer/scripts/worker.py"
103
137
  },
104
138
  "config": {
105
139
  "repos": "{{form.repositories}}",
106
140
  "trigger_label": "{{form.triggerLabel}}",
141
+ "trigger_reviewer": "{{form.triggerReviewer}}",
107
142
  "review_tone": "{{form.reviewTone}}",
143
+ "maintainers": "{{form.maintainers}}",
108
144
  "github_token_secret": "{{form.githubTokenSecret}}"
109
145
  }
110
146
  },
111
- "message": "Configure the repositories, review label, tone, credential name, agent profile, and schedule."
147
+ "filter": "contains({{form.repositories}}, repository.full_name) && contains(keys(@), 'pull_request') && ((action == 'review_requested' && requested_reviewer.login == '{{form.triggerReviewer}}') || (action == 'submitted' && contains(keys(@), 'review') && review.user.login == '{{form.triggerReviewer}}'))",
148
+ "message": "Configure the repositories, trigger, review tone, optional maintainer roster, credential name, and agent profile."
112
149
  }
113
150
  }
@@ -156,10 +156,6 @@
156
156
  "required": ["form"]
157
157
  },
158
158
  "then": { "not": { "required": ["filter"] } }
159
- },
160
- {
161
- "if": { "required": ["bundle"] },
162
- "then": { "not": { "required": ["filter"] } }
163
159
  }
164
160
  ]
165
161
  },
@@ -0,0 +1,38 @@
1
+ {
2
+ "id": "granola",
3
+ "name": "Granola",
4
+ "description": "Search and query your Granola meeting notes, transcripts, folders, and action items.",
5
+ "categories": [
6
+ "Meetings",
7
+ "Knowledge base"
8
+ ],
9
+ "appUrl": "https://www.granola.ai",
10
+ "docsUrl": "https://docs.granola.ai/help-center/sharing/integrations/mcp",
11
+ "notes": "Uses Granola's hosted MCP server. OAuth is handled by the server itself via browser sign-in with Dynamic Client Registration, so no client ID or client secret is required. MCP connects as an individual user and follows the active workspace selected in the Granola app; there is no API key access for MCP.",
12
+ "connectionOptions": [
13
+ {
14
+ "id": "oauth",
15
+ "provider": "mcp",
16
+ "transport": {
17
+ "kind": "shttp",
18
+ "url": "https://mcp.granola.ai/mcp"
19
+ },
20
+ "auth": {
21
+ "strategy": "oauth2",
22
+ "oauth": {
23
+ "clientAuthentication": "none"
24
+ }
25
+ }
26
+ }
27
+ ],
28
+ "iconBg": "#D1E043",
29
+ "iconColor": "var(--oh-surface-deep)",
30
+ "keywords": [
31
+ "meeting notes",
32
+ "transcripts",
33
+ "meetings",
34
+ "notes",
35
+ "action items"
36
+ ],
37
+ "installHint": "Sign in to Granola through the browser OAuth flow when prompted; no client credentials or API key are needed. MCP follows the active workspace selected in your Granola app, so switch workspaces there before querying if you belong to more than one. Some folder, search, and transcript tools require a paid Granola plan."
38
+ }
@@ -66,20 +66,21 @@ import entry60 from "./catalog/fetch.json" with { type: "json" };
66
66
  import entry61 from "./catalog/filesystem.json" with { type: "json" };
67
67
  import entry62 from "./catalog/firecrawl.json" with { type: "json" };
68
68
  import entry63 from "./catalog/git.json" with { type: "json" };
69
- import entry64 from "./catalog/huggingface.json" with { type: "json" };
70
- import entry65 from "./catalog/kagi.json" with { type: "json" };
71
- import entry66 from "./catalog/memory.json" with { type: "json" };
72
- import entry67 from "./catalog/mongodb.json" with { type: "json" };
73
- import entry68 from "./catalog/neon.json" with { type: "json" };
74
- import entry69 from "./catalog/obsidian.json" with { type: "json" };
75
- import entry70 from "./catalog/paypal.json" with { type: "json" };
76
- import entry71 from "./catalog/playwright.json" with { type: "json" };
77
- import entry72 from "./catalog/redis.json" with { type: "json" };
78
- import entry73 from "./catalog/reportportal.json" with { type: "json" };
79
- import entry74 from "./catalog/resend.json" with { type: "json" };
80
- import entry75 from "./catalog/sequential-thinking.json" with { type: "json" };
81
- import entry76 from "./catalog/superhuman-mail.json" with { type: "json" };
82
- import entry77 from "./catalog/time.json" with { type: "json" };
69
+ import entry64 from "./catalog/granola.json" with { type: "json" };
70
+ import entry65 from "./catalog/huggingface.json" with { type: "json" };
71
+ import entry66 from "./catalog/kagi.json" with { type: "json" };
72
+ import entry67 from "./catalog/memory.json" with { type: "json" };
73
+ import entry68 from "./catalog/mongodb.json" with { type: "json" };
74
+ import entry69 from "./catalog/neon.json" with { type: "json" };
75
+ import entry70 from "./catalog/obsidian.json" with { type: "json" };
76
+ import entry71 from "./catalog/paypal.json" with { type: "json" };
77
+ import entry72 from "./catalog/playwright.json" with { type: "json" };
78
+ import entry73 from "./catalog/redis.json" with { type: "json" };
79
+ import entry74 from "./catalog/reportportal.json" with { type: "json" };
80
+ import entry75 from "./catalog/resend.json" with { type: "json" };
81
+ import entry76 from "./catalog/sequential-thinking.json" with { type: "json" };
82
+ import entry77 from "./catalog/superhuman-mail.json" with { type: "json" };
83
+ import entry78 from "./catalog/time.json" with { type: "json" };
83
84
 
84
85
  export const INTEGRATION_CATALOG_ENTRIES = [
85
86
  entry0,
@@ -160,4 +161,5 @@ export const INTEGRATION_CATALOG_ENTRIES = [
160
161
  entry75,
161
162
  entry76,
162
163
  entry77,
164
+ entry78,
163
165
  ];
@@ -106,6 +106,20 @@
106
106
  "delegation"
107
107
  ]
108
108
  },
109
+ {
110
+ "name": "canvas-extension-api",
111
+ "source": "./skills/canvas-extension-api",
112
+ "description": "Build and validate Apps for Agent Canvas using the supported Canvas Extensions API v1 routed-page contract.",
113
+ "category": "agent-authoring",
114
+ "defaultEnabled": true,
115
+ "keywords": [
116
+ "agent-canvas",
117
+ "canvas-app",
118
+ "canvas-extension",
119
+ "register-page",
120
+ "app-development"
121
+ ]
122
+ },
109
123
  {
110
124
  "name": "openhands-sdk",
111
125
  "source": "./skills/openhands-sdk",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openhands/extensions",
3
- "version": "0.22.1",
3
+ "version": "0.24.0",
4
4
  "description": "Public OpenHands extension catalogs for skills, plugins, integrations, and automation templates.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -82,6 +82,7 @@ print("All done!")
82
82
  - [API-based Sandbox](https://docs.openhands.dev/sdk/guides/agent-server/api-sandbox.md): Connect to hosted API-based agent server for fully managed infrastructure.
83
83
  - [Apptainer Sandbox](https://docs.openhands.dev/sdk/guides/agent-server/apptainer-sandbox.md): Run agent server in rootless Apptainer containers for HPC and shared computing environments.
84
84
  - [Ask Agent Questions](https://docs.openhands.dev/sdk/guides/convo-ask-agent.md): Get sidebar replies from the agent during conversation execution without interrupting the main flow.
85
+ - [Ask Oracle](https://docs.openhands.dev/sdk/guides/agent-ask-oracle.md): Let an agent consult a saved Oracle LLM profile for stateless second-opinion advice.
85
86
  - [Assign Reviews](https://docs.openhands.dev/sdk/guides/github-workflows/assign-reviews.md): Automate PR management with intelligent reviewer assignment and workflow notifications using OpenHands Agent
86
87
  - [Browser Session Recording](https://docs.openhands.dev/sdk/guides/browser-session-recording.md): Record and replay your agent's browser sessions using rrweb.
87
88
  - [Browser Use](https://docs.openhands.dev/sdk/guides/agent-browser-use.md): Enable web browsing and interaction capabilities for your agent.
@@ -117,7 +118,7 @@ print("All done!")
117
118
  - [Model Context Protocol](https://docs.openhands.dev/sdk/guides/mcp.md): Model Context Protocol (MCP) enables dynamic tool integration from external servers. Agents can discover and use MCP-provided tools automatically.
118
119
  - [Model Routing](https://docs.openhands.dev/sdk/guides/llm-routing.md): Route agent's LLM requests to different models.
119
120
  - [Observability & Tracing](https://docs.openhands.dev/sdk/guides/observability.md): Enable OpenTelemetry tracing to monitor and debug your agent's execution with tools like Laminar, MLflow, Honeycomb, or any OTLP-compatible backend.
120
- - [OpenAI-Compatible Endpoint](https://docs.openhands.dev/sdk/guides/agent-server/openai-gateway.md): Call an OpenHands agent-server through the OpenAI Chat Completions protocol.
121
+ - [OpenAI-Compatible Endpoint](https://docs.openhands.dev/sdk/guides/agent-server/openai-gateway.md): Call an OpenHands agent-server through the OpenAI Chat Completions or Responses protocol.
121
122
  - [OpenHands Cloud Workspace](https://docs.openhands.dev/sdk/guides/agent-server/cloud-workspace.md): Connect to OpenHands Cloud for fully managed sandbox environments with optional SaaS credential inheritance.
122
123
  - [Overview](https://docs.openhands.dev/sdk/guides/agent-server/overview.md): Run agents on remote servers with isolated workspaces for production deployments.
123
124
  - [Parallel Tool Execution](https://docs.openhands.dev/sdk/guides/parallel-tool-execution.md): Execute multiple tools concurrently within a single LLM response to improve throughput for independent operations.
@@ -131,6 +132,7 @@ print("All done!")
131
132
  - [Security & Action Confirmation](https://docs.openhands.dev/sdk/guides/security.md): Control agent action execution through confirmation policy and security analyzer.
132
133
  - [Send Message While Running](https://docs.openhands.dev/sdk/guides/convo-send-message-while-running.md): Interrupt running agents to provide additional context or corrections.
133
134
  - [Software Agent SDK](https://docs.openhands.dev/sdk.md): Build AI agents that write software. A clean, modular SDK with production-ready tools.
135
+ - [Structured Output](https://docs.openhands.dev/sdk/guides/structured-output.md): Attach a schema to any tool so the LLM returns typed, validated fields alongside the tool's own arguments.
134
136
  - [Stuck Detector](https://docs.openhands.dev/sdk/guides/agent-stuck-detector.md): Detect and handle stuck agents automatically with timeout mechanisms.
135
137
  - [Task Tool Set](https://docs.openhands.dev/sdk/guides/task-tool-set.md): Delegate complex work to specialized sub-agents that run synchronously and return results to the parent agent.
136
138
  - [Theory of Mind (TOM) Agent](https://docs.openhands.dev/sdk/guides/agent-tom-agent.md): Enable your agent to understand user intent and preferences through Theory of Mind capabilities, providing personalized guidance based on user modeling.
@@ -22,7 +22,8 @@ Then configure the required secrets (see [Installation](#installation) below).
22
22
  - **Inline Code Comments**: Posts review comments directly on specific lines of code
23
23
  - **Unified Review Style**: Rigorous code review combining pragmatic engineering analysis with data structure and simplicity focus
24
24
  - **A/B Testing**: Support for testing multiple LLM models
25
- - **Review Context Awareness**: Considers previous reviews and unresolved threads
25
+ - **Review Context Awareness**: Considers linked issue acceptance criteria,
26
+ top-level PR discussion, previous reviews, and unresolved threads
26
27
  - **Evidence Enforcement**: Optional check that PR descriptions include concrete end-to-end proof the code works, not just test output
27
28
  - **Sub-Agent Delegation**: Split large PR reviews across multiple sub-agents, one per file, then consolidate findings (see [Known Limitations](#known-limitations-sub-agent-delegation))
28
29
  - **Observability**: Optional Laminar integration for tracing and evaluation
@@ -169,6 +169,61 @@ query($owner: String!, $repo: String!, $pr_number: Int!, $cursor: String) {
169
169
  }
170
170
  """
171
171
 
172
+ LINKED_ISSUES_QUERY = """
173
+ query(
174
+ $owner: String!
175
+ $repo: String!
176
+ $pr_number: Int!
177
+ $count: Int!
178
+ $cursor: String
179
+ ) {
180
+ repository(owner: $owner, name: $repo) {
181
+ pullRequest(number: $pr_number) {
182
+ closingIssuesReferences(last: $count, before: $cursor) {
183
+ pageInfo {
184
+ hasPreviousPage
185
+ startCursor
186
+ }
187
+ nodes {
188
+ number
189
+ title
190
+ body
191
+ repository { nameWithOwner }
192
+ }
193
+ }
194
+ }
195
+ }
196
+ }
197
+ """
198
+
199
+ ISSUE_COMMENTS_QUERY = """
200
+ query(
201
+ $owner: String!
202
+ $repo: String!
203
+ $pr_number: Int!
204
+ $count: Int!
205
+ $cursor: String
206
+ ) {
207
+ repository(owner: $owner, name: $repo) {
208
+ pullRequest(number: $pr_number) {
209
+ comments(last: $count, before: $cursor) {
210
+ pageInfo {
211
+ hasPreviousPage
212
+ startCursor
213
+ }
214
+ nodes {
215
+ id
216
+ author { login __typename }
217
+ authorAssociation
218
+ body
219
+ createdAt
220
+ }
221
+ }
222
+ }
223
+ }
224
+ }
225
+ """
226
+
172
227
 
173
228
  def _get_required_env(name: str) -> str:
174
229
  value = os.getenv(name)
@@ -379,6 +434,58 @@ def get_pr_reviews(pr_number: str, max_reviews: int = 100) -> list[dict[str, Any
379
434
  return reviews
380
435
 
381
436
 
437
+ def get_pr_issue_comments(
438
+ pr_number: str, max_comments: int = 100
439
+ ) -> list[dict[str, Any]]:
440
+ """Fetch the latest top-level PR comments in chronological order."""
441
+ repo = _get_required_env("REPO_NAME")
442
+ owner, repo_name = repo.split("/")
443
+ nodes = _paginate_graphql(
444
+ query=ISSUE_COMMENTS_QUERY,
445
+ variables={
446
+ "owner": owner,
447
+ "repo": repo_name,
448
+ "pr_number": int(pr_number),
449
+ "count": max_comments,
450
+ },
451
+ path_to_nodes=["pullRequest", "comments"],
452
+ max_items=max_comments,
453
+ item_name="top-level PR comments",
454
+ )
455
+ comments = [
456
+ {
457
+ "id": node.get("id"),
458
+ "user": {
459
+ "login": (node.get("author") or {}).get("login", "unknown"),
460
+ "type": (node.get("author") or {}).get("__typename", "User"),
461
+ },
462
+ "author_association": node.get("authorAssociation", "UNKNOWN"),
463
+ "body": node.get("body", ""),
464
+ "created_at": node.get("createdAt"),
465
+ }
466
+ for node in nodes
467
+ ]
468
+ return sorted(comments, key=lambda comment: comment.get("created_at") or "")
469
+
470
+
471
+ def get_linked_issues(pr_number: str, max_issues: int = 5) -> list[dict[str, Any]]:
472
+ """Fetch the issues GitHub recognizes as closed by this PR."""
473
+ repo = _get_required_env("REPO_NAME")
474
+ owner, repo_name = repo.split("/")
475
+ return _paginate_graphql(
476
+ query=LINKED_ISSUES_QUERY,
477
+ variables={
478
+ "owner": owner,
479
+ "repo": repo_name,
480
+ "pr_number": int(pr_number),
481
+ "count": max_issues,
482
+ },
483
+ path_to_nodes=["pullRequest", "closingIssuesReferences"],
484
+ max_items=max_issues,
485
+ item_name="linked issues",
486
+ )
487
+
488
+
382
489
  def get_review_threads_graphql(pr_number: str) -> list[dict[str, Any]]:
383
490
  """Fetch the latest review threads with resolution status using GraphQL API.
384
491
 
@@ -419,44 +526,90 @@ def get_review_threads_graphql(pr_number: str) -> list[dict[str, Any]]:
419
526
  def format_review_context(
420
527
  reviews: list[dict[str, Any]],
421
528
  threads: list[dict[str, Any]],
529
+ issue_comments: list[dict[str, Any]] | None = None,
530
+ linked_issues: list[dict[str, Any]] | None = None,
422
531
  max_size: int = MAX_REVIEW_CONTEXT,
423
532
  ) -> str:
424
- """Format review history into a context string for the agent.
425
-
426
- Args:
427
- reviews: List of review objects from get_pr_reviews()
428
- threads: List of thread objects from get_review_threads_graphql()
429
- max_size: Maximum size of the formatted context
430
-
431
- Returns:
432
- Formatted markdown string with review history
433
- """
434
- if not reviews and not threads:
533
+ """Format the highest-signal PR and linked-issue context for the agent."""
534
+ issue_comments = issue_comments or []
535
+ linked_issues = linked_issues or []
536
+ if not reviews and not threads and not issue_comments and not linked_issues:
435
537
  return ""
436
538
 
437
539
  sections: list[str] = []
438
540
  current_size = 0
439
541
 
440
542
  def _add_section(section: str) -> bool:
441
- """Add a section if it fits within max_size. Returns True if added."""
442
543
  nonlocal current_size
443
- section_size = len(section) + 1 # +1 for newline separator
544
+ section_size = len(section) + 1
444
545
  if current_size + section_size > max_size:
445
546
  return False
446
547
  sections.append(section)
447
548
  current_size += section_size
448
549
  return True
449
550
 
450
- # Format reviews (high-level review decisions)
551
+ if linked_issues:
552
+ _add_section("### Linked Issues and Acceptance Criteria\n")
553
+ for issue in linked_issues:
554
+ title = issue.get("title") or "(untitled issue)"
555
+ number = issue.get("number", "?")
556
+ repository = (issue.get("repository") or {}).get("nameWithOwner")
557
+ body = (issue.get("body") or "").strip()
558
+ body_preview = body[:2000] + "..." if len(body) > 2000 else body
559
+ reference = f"{repository}#{number}" if repository else f"Issue #{number}"
560
+ section = f"**{reference}: {title}**"
561
+ if body_preview:
562
+ section += "\n" + "\n".join(
563
+ f"> {line}" for line in body_preview.splitlines()
564
+ )
565
+ if not _add_section(section):
566
+ sections.append("... [remaining linked issues omitted] ...")
567
+ break
568
+
569
+ human_comments = [
570
+ comment
571
+ for comment in issue_comments
572
+ if ((comment.get("user") or {}).get("type") or "").lower() != "bot"
573
+ and ((comment.get("user") or {}).get("login") or "").lower() != "all-hands-bot"
574
+ ]
575
+ if human_comments:
576
+ _add_section("### Top-level PR Discussion\n")
577
+ for comment in human_comments[-12:]:
578
+ author = ((comment.get("user") or {}).get("login")) or "unknown"
579
+ association = comment.get("author_association") or "UNKNOWN"
580
+ body = (comment.get("body") or "").strip()
581
+ if not body:
582
+ continue
583
+ body_preview = body[:750] + "..." if len(body) > 750 else body
584
+ section = f"- **{author}** ({association})\n" + "\n".join(
585
+ f" > {line}" for line in body_preview.splitlines()
586
+ )
587
+ if not _add_section(section):
588
+ sections.append("... [older PR discussion omitted] ...")
589
+ break
590
+
591
+ resolved_threads = [thread for thread in threads if thread.get("isResolved")]
592
+ unresolved_threads = [thread for thread in threads if not thread.get("isResolved")]
593
+
594
+ if unresolved_threads:
595
+ _add_section(
596
+ "### Unresolved Review Threads\n\n"
597
+ "*These concerns may still need to be addressed:*"
598
+ )
599
+ for index, thread in enumerate(unresolved_threads):
600
+ if not _add_section("\n".join(_format_thread(thread))):
601
+ remaining = len(unresolved_threads) - index
602
+ sections.append(
603
+ f"... [{remaining} unresolved review threads omitted] ..."
604
+ )
605
+ break
606
+
451
607
  if reviews:
452
- review_lines: list[str] = ["### Previous Reviews\n"]
608
+ review_lines: list[str] = ["### Previous Review Decisions\n"]
453
609
  for review in reviews:
454
- user_data = review.get("user") or {}
455
- user = user_data.get("login", "unknown")
610
+ user = ((review.get("user") or {}).get("login")) or "unknown"
456
611
  state = review.get("state") or "UNKNOWN"
457
612
  body = (review.get("body") or "").strip()
458
-
459
- # Map state to emoji for visual clarity
460
613
  state_emoji = {
461
614
  "APPROVED": "✅",
462
615
  "CHANGES_REQUESTED": "🔴",
@@ -464,72 +617,29 @@ def format_review_context(
464
617
  "DISMISSED": "❌",
465
618
  "PENDING": "⏳",
466
619
  }.get(state, "❓")
467
-
468
620
  review_lines.append(f"- {state_emoji} **{user}** ({state})")
469
621
  if body:
470
- # Indent the body and truncate if too long
471
622
  body_preview = body[:500] + "..." if len(body) > 500 else body
472
- indented = "\n".join(f" > {line}" for line in body_preview.split("\n"))
473
- review_lines.append(indented)
623
+ review_lines.extend(f" > {line}" for line in body_preview.splitlines())
474
624
  review_lines.append("")
475
-
476
- review_section = "\n".join(review_lines)
477
- if not _add_section(review_section):
478
- # Even reviews section doesn't fit, return truncation message
479
- return (
480
- f"... [review context truncated, "
481
- f"content exceeds {max_size:,} chars] ..."
482
- )
483
-
484
- # Format review threads with resolution status
485
- if threads:
486
- resolved_threads = [t for t in threads if t.get("isResolved")]
487
- unresolved_threads = [t for t in threads if not t.get("isResolved")]
488
-
489
- # Unresolved threads (higher priority)
490
- if unresolved_threads:
491
- header = (
492
- "### Unresolved Review Threads\n\n"
493
- "*These threads have not been resolved and may need attention:*\n"
494
- )
495
- if not _add_section(header):
496
- count = len(unresolved_threads)
625
+ if not _add_section("\n".join(review_lines)):
626
+ sections.append("... [previous review decisions omitted] ...")
627
+
628
+ if (
629
+ resolved_threads
630
+ and current_size < max_size
631
+ and _add_section(
632
+ "### Resolved Review Threads\n\n"
633
+ "*Use these as context; do not repeat resolved findings:*"
634
+ )
635
+ ):
636
+ for index, thread in enumerate(resolved_threads):
637
+ if not _add_section("\n".join(_format_thread(thread))):
638
+ remaining = len(resolved_threads) - index
497
639
  sections.append(
498
- f"\n... [truncated, {count} unresolved threads omitted] ..."
640
+ f"... [{remaining} resolved review threads omitted] ..."
499
641
  )
500
- else:
501
- threads_added = 0
502
- for thread in unresolved_threads:
503
- thread_lines = _format_thread(thread)
504
- thread_section = "\n".join(thread_lines)
505
- if not _add_section(thread_section):
506
- remaining = len(unresolved_threads) - threads_added
507
- sections.append(
508
- f"\n... [truncated, {remaining} unresolved "
509
- "threads omitted] ..."
510
- )
511
- break
512
- threads_added += 1
513
-
514
- # Resolved threads (lower priority, add if space remains)
515
- if resolved_threads and current_size < max_size:
516
- header = (
517
- "### Resolved Review Threads\n\n"
518
- "*These threads have been resolved but provide context:*\n"
519
- )
520
- if _add_section(header):
521
- threads_added = 0
522
- for thread in resolved_threads:
523
- thread_lines = _format_thread(thread)
524
- thread_section = "\n".join(thread_lines)
525
- if not _add_section(thread_section):
526
- remaining = len(resolved_threads) - threads_added
527
- sections.append(
528
- f"\n... [truncated, {remaining} resolved "
529
- "threads omitted] ..."
530
- )
531
- break
532
- threads_added += 1
642
+ break
533
643
 
534
644
  return "\n".join(sections)
535
645
 
@@ -650,7 +760,8 @@ def _fetch_with_fallback(
650
760
  def get_pr_review_context(pr_number: str) -> str:
651
761
  """Get all review context for a PR.
652
762
 
653
- Fetches reviews and review threads, then formats them into a context string.
763
+ Fetches linked issues, top-level discussion, reviews, and review threads,
764
+ then formats them into a context string.
654
765
 
655
766
  Args:
656
767
  pr_number: The PR number
@@ -662,8 +773,14 @@ def get_pr_review_context(pr_number: str) -> str:
662
773
  threads = _fetch_with_fallback(
663
774
  "review threads", lambda: get_review_threads_graphql(pr_number)
664
775
  )
776
+ comments = _fetch_with_fallback(
777
+ "top-level PR comments", lambda: get_pr_issue_comments(pr_number)
778
+ )
779
+ linked_issues = _fetch_with_fallback(
780
+ "linked issues", lambda: get_linked_issues(pr_number)
781
+ )
665
782
 
666
- return format_review_context(reviews, threads)
783
+ return format_review_context(reviews, threads, comments, linked_issues)
667
784
 
668
785
 
669
786
  def get_pr_files(pr_number: str) -> list[dict[str, Any]]: