@openhands/extensions 0.25.0 → 0.26.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.
- package/.claude-plugin/marketplace.json +24 -0
- package/.codex-plugin/marketplace.json +24 -0
- package/.github/pull_request_template.md +17 -2
- package/.github/scripts/check_pr_description.py +64 -5
- package/.github/scripts/markdown_sections.py +95 -0
- package/.github/workflows/build-vscode-app.yml +30 -0
- package/.github/workflows/check-extensions.yml +3 -0
- package/.github/workflows/tests.yml +26 -0
- package/.plugin/marketplace.json +24 -0
- package/.release-please-manifest.json +1 -1
- package/AGENTS.md +3 -2
- package/README.md +9 -3
- package/apps/README.md +20 -0
- package/apps/catalog.json +10 -0
- package/apps/index.d.ts +11 -0
- package/apps/index.js +5 -0
- package/apps/vscode/README.md +13 -0
- package/apps/vscode/canvas-extension.json +19 -0
- package/apps/vscode/extension.js +114 -0
- package/apps/vscode/scripts/package.mjs +84 -0
- package/automations/bundle-index.js +15 -15
- package/automations/catalog/github-agents-md-maintainer/manifest.json +1 -1
- package/automations/catalog/github-pr-reviewer/manifest.json +5 -5
- package/automations/catalog/github-stale-ci-pr-closer/manifest.json +3 -3
- package/automations/catalog/gitlab-issue-to-mr/manifest.json +1 -1
- package/automations/catalog/news-digest/manifest.json +2 -2
- package/index.d.ts +2 -0
- package/index.js +1 -0
- package/marketplaces/openhands-extensions.json +24 -0
- package/package.json +9 -1
- package/plugins/openhands/skills/openhands-api/README.md +1 -1
- package/plugins/openhands/skills/openhands-api/SKILL.md +3 -3
- package/plugins/openhands/skills/openhands-api/scripts/openhands_api.py +13 -0
- package/plugins/openhands/skills/openhands-automation/SKILL.md +66 -4
- package/plugins/openhands/skills/openhands-automation/references/custom-automation.md +21 -7
- package/plugins/runtime-api-configs/.claude-plugin/plugin.json +20 -0
- package/plugins/runtime-api-configs/.codex-plugin/plugin.json +20 -0
- package/plugins/runtime-api-configs/.plugin/plugin.json +20 -0
- package/plugins/runtime-api-configs/README.md +59 -0
- package/plugins/runtime-api-configs/SKILL.md +240 -0
- package/plugins/runtime-api-configs/scripts/warm_runtime_configs.py +331 -0
- package/plugins/runtime-api-configs/tests/test_warm_runtime_configs.py +338 -0
- package/plugins/vulnerability-remediation/skills/github/scripts/agent_conversation.py +223 -43
- package/plugins/vulnerability-remediation/skills/github/scripts/github_client.py +67 -15
- package/pyproject.toml +1 -1
- package/python/openhands_extensions/_version.py +1 -1
- package/scripts/check_plugin_resources.py +116 -0
- package/scripts/sync_extensions.py +32 -5
- package/skills/canvas-extension-api/SKILL.md +34 -3
- package/skills/canvas-extension-api/references/backend-safety.md +14 -0
- package/skills/canvas-extension-api/references/connections.md +24 -0
- package/skills/canvas-extension-api/references/sidecar-pattern.md +49 -10
- package/skills/canvas-extension-api/references/v1-contract.md +53 -3
- package/skills/canvas-extension-api/scripts/validate-extension.mjs +52 -0
- package/skills/canvas-extension-api/scripts/validate-extension.test.mjs +192 -1
- package/skills/github/scripts/agent_conversation.py +223 -43
- package/skills/github/scripts/github_client.py +67 -15
- package/skills/github-agents-md-maintainer/README.md +7 -0
- package/skills/github-agents-md-maintainer/SKILL.md +13 -6
- package/skills/github-agents-md-maintainer/scripts/github_client.py +67 -15
- package/skills/github-agents-md-maintainer/scripts/main.py +199 -29
- package/skills/github-delivery-watchdog/scripts/github_client.py +67 -15
- package/skills/github-issue-to-pr/scripts/agent_conversation.py +223 -43
- package/skills/github-issue-to-pr/scripts/github_client.py +67 -15
- package/skills/github-issue-to-pr/scripts/worker.py +10 -1
- package/skills/github-issue-triage/scripts/agent_conversation.py +223 -43
- package/skills/github-issue-triage/scripts/github_client.py +67 -15
- package/skills/github-pr-reviewer/README.md +5 -4
- package/skills/github-pr-reviewer/SKILL.md +8 -17
- package/skills/github-pr-reviewer/references/state-schema.md +0 -15
- package/skills/github-pr-reviewer/scripts/agent_conversation.py +223 -43
- package/skills/github-pr-reviewer/scripts/github_client.py +67 -15
- package/skills/github-pr-reviewer/scripts/worker.py +131 -108
- package/skills/github-repo-monitor/scripts/github_client.py +67 -15
- package/skills/github-stale-ci-pr-closer/scripts/worker.py +223 -50
- package/skills/github-stale-ci-pr-closer/tests/test_worker.py +155 -15
- package/skills/gitlab-issue-to-mr/README.md +12 -0
- package/skills/gitlab-issue-to-mr/SKILL.md +13 -6
- package/skills/gitlab-issue-to-mr/scripts/main.py +217 -42
- package/skills/incident-retrospective/SKILL.md +5 -5
- package/skills/index.js +23 -14
- package/skills/jira-issue-to-pr/README.md +12 -4
- package/skills/jira-issue-to-pr/SKILL.md +129 -18
- package/skills/jira-issue-to-pr/references/setup.md +8 -0
- package/skills/jira-issue-to-pr/scripts/main.py +181 -54
- package/skills/jira-issue-to-pr/scripts/setup_webhook.py +290 -0
- package/skills/linear-triage/SKILL.md +5 -5
- package/skills/news-digest/README.md +6 -0
- package/skills/news-digest/SKILL.md +19 -9
- package/skills/news-digest/commands/news-digest-setup.md +1 -1
- package/skills/news-digest/scripts/main.py +133 -15
- package/skills/openhands-api/README.md +1 -1
- package/skills/openhands-api/SKILL.md +3 -3
- package/skills/openhands-api/scripts/openhands_api.py +13 -0
- package/skills/openhands-automation/SKILL.md +66 -4
- package/skills/openhands-automation/references/custom-automation.md +21 -7
- package/skills/research-brief/SKILL.md +5 -5
- package/skills/slack-standup-digest/SKILL.md +5 -5
- package/skills/ticket-to-code-change/SKILL.md +6 -3
- package/skills/use-jev/.claude-plugin/plugin.json +8 -0
- package/skills/use-jev/.codex-plugin/plugin.json +8 -0
- package/skills/use-jev/.plugin/plugin.json +8 -0
- package/skills/use-jev/README.md +66 -0
- package/skills/use-jev/SKILL.md +66 -0
- package/tests/fixtures/automations/github-pr-reviewer.json +9 -9
- package/tests/test_agent_conversation_dispatch.py +212 -0
- package/tests/test_apps_catalog.py +26 -0
- package/tests/test_automation_setup.py +44 -0
- package/tests/test_check_pr_description.py +89 -6
- package/tests/test_github_reviewer_delivery.py +254 -118
- package/tests/test_github_stale_ci_pr_closer.py +56 -0
- package/tests/test_jira_issue_to_pr.py +312 -0
- package/tests/test_jira_setup_webhook.py +309 -0
- package/tests/test_openhands_api_python.py +37 -0
- package/tests/test_skill_plugin_loading.py +25 -15
- package/tests/test_sync_extensions.py +102 -0
- package/tests/test_vscode_app.py +33 -0
- package/tests/vscode-app.test.mjs +43 -0
|
@@ -27,11 +27,6 @@ from maintainer_handoff import (
|
|
|
27
27
|
# every current-head check and workflow run, so a red head still blocks.
|
|
28
28
|
CHECK_GATE_MARKER = "<!-- openhands-review-gate:"
|
|
29
29
|
NON_BLOCKING_CHECK_CONCLUSIONS = frozenset({"success", "neutral", "skipped"})
|
|
30
|
-
# How many unrequested heads one scheduled scan examines, per repository. Each
|
|
31
|
-
# one costs a review read plus the exact-head check/workflow reads, so an
|
|
32
|
-
# unbounded scan spends the whole run's API budget in the largest repository.
|
|
33
|
-
# The window rotates by this many PRs per scan, so the backlog is still covered.
|
|
34
|
-
SCAN_WINDOW = 10
|
|
35
30
|
# The gate is deterministic; this disclosure is what tells a reader no model ran.
|
|
36
31
|
WORKFLOW_DISCLOSURE = "no AI was used to generate this comment"
|
|
37
32
|
|
|
@@ -112,56 +107,6 @@ class ReviewIntake:
|
|
|
112
107
|
)
|
|
113
108
|
|
|
114
109
|
|
|
115
|
-
class ScanCursor:
|
|
116
|
-
"""The per-repository position of the rotating unrequested-PR window.
|
|
117
|
-
|
|
118
|
-
Classifying one unrequested head costs a review read, a check-run read, and
|
|
119
|
-
a workflow-run read, so a scan that examined every open PR spent the API
|
|
120
|
-
budget on the largest repository alone. The scan instead examines a bounded
|
|
121
|
-
slice of the unrequested backlog and remembers where the slice ended, in the
|
|
122
|
-
automation service's own KV store - the same facility `main.py` already uses
|
|
123
|
-
for review state, reached with the same `AUTOMATION_KV_TOKEN` and
|
|
124
|
-
`AUTOMATION_API_URL`, so no new secret or store is introduced. The next scan
|
|
125
|
-
resumes past that point, so the whole backlog is covered fairly over several
|
|
126
|
-
scans.
|
|
127
|
-
|
|
128
|
-
When the KV store is unavailable (a local run, or the tests) the position is
|
|
129
|
-
kept in memory, so the scan still rotates within the run and nothing else
|
|
130
|
-
about it changes. A KV read or write failure is not fatal: a scan position is
|
|
131
|
-
not worth aborting reviews over, so the in-memory position is used instead.
|
|
132
|
-
"""
|
|
133
|
-
|
|
134
|
-
def __init__(self, repository):
|
|
135
|
-
self._key = f"review-scan:{workflow._repo_slug(repository)}"
|
|
136
|
-
self._memory = 0
|
|
137
|
-
|
|
138
|
-
def position(self, total):
|
|
139
|
-
"""The stored cursor, reduced to a valid offset into `total` items."""
|
|
140
|
-
return self._read() % total if total else 0
|
|
141
|
-
|
|
142
|
-
def advance(self, cursor):
|
|
143
|
-
"""Record where the next scan's window starts."""
|
|
144
|
-
self._memory = cursor
|
|
145
|
-
if not workflow._kv_available():
|
|
146
|
-
return
|
|
147
|
-
try:
|
|
148
|
-
workflow._kv_set(self._key, {"cursor": cursor})
|
|
149
|
-
except Exception as exc: # noqa: BLE001 - a cursor is not worth failing a scan
|
|
150
|
-
print(f" Warning: scan cursor write failed ({exc})")
|
|
151
|
-
|
|
152
|
-
def _read(self):
|
|
153
|
-
if not workflow._kv_available():
|
|
154
|
-
return self._memory
|
|
155
|
-
try:
|
|
156
|
-
data = workflow._kv_get(self._key) or {}
|
|
157
|
-
except Exception as exc: # noqa: BLE001 - fall back to the in-memory cursor
|
|
158
|
-
print(f" Warning: scan cursor read failed ({exc})")
|
|
159
|
-
return self._memory
|
|
160
|
-
try:
|
|
161
|
-
return int(data.get("cursor") or 0)
|
|
162
|
-
except (TypeError, ValueError):
|
|
163
|
-
return self._memory
|
|
164
|
-
|
|
165
110
|
|
|
166
111
|
class PullRequestReviewer(GitHubRepository):
|
|
167
112
|
name = "github-pr-reviewer"
|
|
@@ -315,6 +260,16 @@ class PullRequestReviewer(GitHubRepository):
|
|
|
315
260
|
"- Inspect the current GitHub Actions results for the exact head and run "
|
|
316
261
|
"the repository's appropriate focused tests in the workspace. Do not "
|
|
317
262
|
"modify tracked files.\n"
|
|
263
|
+
"- Before publishing anything, read the PR's existing reviews and "
|
|
264
|
+
"comments on GitHub. If this account has already published a review on "
|
|
265
|
+
f"`{sha}` and no one else has commented since that review, do NOT "
|
|
266
|
+
"publish a second review and do NOT start another review pass: another "
|
|
267
|
+
"review of identical code is a duplicate. Report the earlier verdict as "
|
|
268
|
+
"still current and stop. Review the head again only when the head has "
|
|
269
|
+
"moved, or when a comment from someone other than this account was "
|
|
270
|
+
"posted after the earlier review.\n"
|
|
271
|
+
"- If a review by this account is currently underway for this head, wait "
|
|
272
|
+
"for it instead of starting a second one.\n"
|
|
318
273
|
f"- Re-read {self.repository} PR #{number} immediately before reporting. "
|
|
319
274
|
"Confirm its current body, labels, review threads, and the head's check "
|
|
320
275
|
"results, and re-read every linked issue's current body and labels: an "
|
|
@@ -745,7 +700,7 @@ class PullRequestReviewer(GitHubRepository):
|
|
|
745
700
|
`explain` is False for an unrequested candidate: a PR nobody asked about
|
|
746
701
|
that is merely red or pending gets no managed comment. Announcing a
|
|
747
702
|
blocked or waiting head for every open PR is what produced the comment
|
|
748
|
-
storm
|
|
703
|
+
storm that full-backlog scans must avoid, and the managed comment is
|
|
749
704
|
the answer to an explicit request or trigger label. The gate still
|
|
750
705
|
classifies the head, so a red or pending unrequested head is skipped
|
|
751
706
|
without starting an agent.
|
|
@@ -765,14 +720,12 @@ class PullRequestReviewer(GitHubRepository):
|
|
|
765
720
|
return state, sha
|
|
766
721
|
|
|
767
722
|
def _outstanding_review_request(self, pr):
|
|
768
|
-
"""Whether an open
|
|
723
|
+
"""Whether an open PR, including a draft, still requests this reviewer.
|
|
769
724
|
|
|
770
725
|
The list endpoint already answers this: `requested_reviewers` is the live
|
|
771
726
|
set, so a review that was submitted, or a request that was withdrawn, is
|
|
772
|
-
simply absent.
|
|
727
|
+
simply absent. An explicit request opts a draft into review.
|
|
773
728
|
"""
|
|
774
|
-
if pr.get("draft"):
|
|
775
|
-
return False
|
|
776
729
|
return any(
|
|
777
730
|
(item.get("login") or "").lower() == self.trigger_reviewer
|
|
778
731
|
for item in pr.get("requested_reviewers") or []
|
|
@@ -794,6 +747,66 @@ class PullRequestReviewer(GitHubRepository):
|
|
|
794
747
|
for review in self.gh_pages(f"/pulls/{number}/reviews")
|
|
795
748
|
)
|
|
796
749
|
|
|
750
|
+
def _clarified_since(self, number, submitted_at):
|
|
751
|
+
"""Whether a human clarified the pull request after a review.
|
|
752
|
+
|
|
753
|
+
A comment by someone other than the reviewer accounts is the one thing
|
|
754
|
+
that makes reviewing an unchanged head worth another conversation: the
|
|
755
|
+
author answered a finding, so the earlier verdict no longer speaks to
|
|
756
|
+
the current state. Any other account (an author, a maintainer, a
|
|
757
|
+
different bot workflow posting a new result) counts, and the reviewer
|
|
758
|
+
account's own comments are excluded so its "reviewing now" and verdict
|
|
759
|
+
comments cannot re-trigger it.
|
|
760
|
+
"""
|
|
761
|
+
if not submitted_at:
|
|
762
|
+
return False
|
|
763
|
+
for comment in self.gh_pages(f"/issues/{number}/comments"):
|
|
764
|
+
login = ((comment.get("user") or {}).get("login") or "").lower()
|
|
765
|
+
if login in {"", self.github_login.lower(), self.trigger_reviewer}:
|
|
766
|
+
continue
|
|
767
|
+
if (comment.get("created_at") or "") > submitted_at:
|
|
768
|
+
return True
|
|
769
|
+
return False
|
|
770
|
+
|
|
771
|
+
def _reviewed_current_head_without_clarification(self, number, sha):
|
|
772
|
+
"""Whether the current head is already reviewed and nothing has changed.
|
|
773
|
+
|
|
774
|
+
A review on this exact head, followed by no clarifying comment from
|
|
775
|
+
anyone else, means another conversation would publish a second review of
|
|
776
|
+
the same code — the same verdict twice, spending a runtime to repeat it.
|
|
777
|
+
The head advancing, or any human comment arriving afterwards, makes the
|
|
778
|
+
head eligible again under the normal rules.
|
|
779
|
+
|
|
780
|
+
Returns the reviewed head's latest submission time (for the log line) or
|
|
781
|
+
None when the head should be reviewed.
|
|
782
|
+
"""
|
|
783
|
+
if not sha:
|
|
784
|
+
return None
|
|
785
|
+
submitted_at = ""
|
|
786
|
+
for review in self.gh_pages(f"/pulls/{number}/reviews"):
|
|
787
|
+
if review.get("commit_id") != sha:
|
|
788
|
+
continue
|
|
789
|
+
if ((review.get("user") or {}).get("login") or "").lower() != (
|
|
790
|
+
self.github_login.lower()
|
|
791
|
+
):
|
|
792
|
+
continue
|
|
793
|
+
# A dismissal removes the review's standing, exactly as
|
|
794
|
+
# maintainer_handoff._reviewer_states treats it: a maintainer who
|
|
795
|
+
# dismisses this account's verdict and re-requests a review on the
|
|
796
|
+
# same head is asking for a fresh look, not repeating a completed
|
|
797
|
+
# one. Keeping the dismissed review here would suppress that review
|
|
798
|
+
# permanently, because nothing else makes an unchanged head eligible.
|
|
799
|
+
if (review.get("state") or "").upper() == "DISMISSED":
|
|
800
|
+
continue
|
|
801
|
+
when = review.get("submitted_at") or ""
|
|
802
|
+
if when > submitted_at:
|
|
803
|
+
submitted_at = when
|
|
804
|
+
if not submitted_at:
|
|
805
|
+
return None
|
|
806
|
+
if self._clarified_since(number, submitted_at):
|
|
807
|
+
return None
|
|
808
|
+
return submitted_at
|
|
809
|
+
|
|
797
810
|
def _unrequested_head(self, pr):
|
|
798
811
|
"""The current-head delivery key for an unrequested PR, or None.
|
|
799
812
|
|
|
@@ -804,56 +817,25 @@ class PullRequestReviewer(GitHubRepository):
|
|
|
804
817
|
sha = pr["head"]["sha"]
|
|
805
818
|
return f"scan:{self.repository}:{pr['number']}:{sha}"
|
|
806
819
|
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
def _explicit_candidate(self, pr, label):
|
|
816
|
-
"""Whether a PR was explicitly requested by a caller.
|
|
817
|
-
|
|
818
|
-
An explicit `all-hands-bot` review request or a trigger label is a
|
|
819
|
-
caller's decision, so it is never subject to the rotating window: every
|
|
820
|
-
explicit candidate is examined on every scan, whatever the stored scan
|
|
821
|
-
position is.
|
|
822
|
-
"""
|
|
823
|
-
if label in {item["name"] for item in pr.get("labels", [])}:
|
|
824
|
-
return True
|
|
825
|
-
return self._outstanding_review_request(pr)
|
|
826
|
-
|
|
827
|
-
def _rotating_window(self, prs, label):
|
|
828
|
-
"""The explicit candidates plus a bounded slice of the unrequested ones.
|
|
829
|
-
|
|
830
|
-
Classifying an unrequested head costs a review read and the exact-head
|
|
831
|
-
check/workflow reads, so examining every open PR in one scan is what let
|
|
832
|
-
a single run exhaust the API budget and post a managed gate comment for
|
|
833
|
-
every red or pending head. The unrequested backlog is therefore examined
|
|
834
|
-
a bounded `SCAN_WINDOW` at a time, starting where the previous scan
|
|
835
|
-
stopped (the per-repository position in the Automation KV store), so
|
|
836
|
-
successive scans rotate through the whole backlog. Explicit candidates
|
|
837
|
-
are always included, so a request is never delayed behind the window.
|
|
820
|
+
def _scan_candidates(self, prs, label):
|
|
821
|
+
"""Return every potentially reviewable PR in the scheduled backlog.
|
|
822
|
+
|
|
823
|
+
The scan must classify the whole backlog so blocked, pending, draft, or
|
|
824
|
+
already-reviewed heads cannot hide eligible heads behind an inspection
|
|
825
|
+
window. The shared ReviewIntake applies the configured maximum only when
|
|
826
|
+
conversations are launched.
|
|
838
827
|
"""
|
|
839
|
-
|
|
828
|
+
candidates = []
|
|
840
829
|
for pr in prs:
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
830
|
+
labels = {item["name"] for item in pr.get("labels", [])}
|
|
831
|
+
if (
|
|
832
|
+
pr.get("draft")
|
|
833
|
+
and label not in labels
|
|
834
|
+
and not self._outstanding_review_request(pr)
|
|
835
|
+
):
|
|
846
836
|
continue
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
else:
|
|
850
|
-
unrequested.append(pr)
|
|
851
|
-
if not unrequested:
|
|
852
|
-
return explicit
|
|
853
|
-
start = self._scan_cursor.position(len(unrequested))
|
|
854
|
-
window = unrequested[start : start + SCAN_WINDOW]
|
|
855
|
-
self._scan_cursor.advance(start + len(window))
|
|
856
|
-
return explicit + window
|
|
837
|
+
candidates.append(pr)
|
|
838
|
+
return candidates
|
|
857
839
|
|
|
858
840
|
def run(self):
|
|
859
841
|
repository_id = self.gh("GET", "")["id"]
|
|
@@ -861,7 +843,7 @@ class PullRequestReviewer(GitHubRepository):
|
|
|
861
843
|
payload = self._event_payload()
|
|
862
844
|
event_mode = payload is not None
|
|
863
845
|
if not event_mode:
|
|
864
|
-
prs = self.
|
|
846
|
+
prs = self._scan_candidates(
|
|
865
847
|
self.gh_pages("/pulls?state=open&sort=updated&direction=asc"), label
|
|
866
848
|
)
|
|
867
849
|
else:
|
|
@@ -913,6 +895,46 @@ class PullRequestReviewer(GitHubRepository):
|
|
|
913
895
|
unrequested_candidate = True
|
|
914
896
|
if trigger is None and delivery_key is None:
|
|
915
897
|
continue
|
|
898
|
+
# A head this account already reviewed, with no clarifying
|
|
899
|
+
# comment since, is done: another conversation would publish a
|
|
900
|
+
# second review of identical code. This must come before the
|
|
901
|
+
# delivery dispatch, because a fresh trigger (a new review
|
|
902
|
+
# request, or a label re-applied after the bot's own handoff)
|
|
903
|
+
# produces a new delivery key that would otherwise start a
|
|
904
|
+
# second review on an unchanged head.
|
|
905
|
+
already = self._reviewed_current_head_without_clarification(
|
|
906
|
+
pr["number"], pr["head"]["sha"]
|
|
907
|
+
)
|
|
908
|
+
if already:
|
|
909
|
+
print(
|
|
910
|
+
json.dumps(
|
|
911
|
+
{
|
|
912
|
+
"repository": self.repository,
|
|
913
|
+
"pr": pr["number"],
|
|
914
|
+
"head_sha": pr["head"]["sha"],
|
|
915
|
+
"disposition": "review-already-published",
|
|
916
|
+
"reviewed_at": already,
|
|
917
|
+
}
|
|
918
|
+
),
|
|
919
|
+
flush=True,
|
|
920
|
+
)
|
|
921
|
+
if trigger is not None:
|
|
922
|
+
# Reconcile the completed review (clearing the label and
|
|
923
|
+
# running the maintainer handoff) where the review is
|
|
924
|
+
# this trigger's result. When it predates the trigger -
|
|
925
|
+
# the #698 ordering, a re-applied label - there is no
|
|
926
|
+
# completion to reconcile, so clear the label directly:
|
|
927
|
+
# otherwise the label stays and every later scan re-reads
|
|
928
|
+
# this head and re-logs with no effect.
|
|
929
|
+
if not self._finish_completed_review(
|
|
930
|
+
pr, trigger, trigger_label
|
|
931
|
+
) and trigger_label:
|
|
932
|
+
self.gh(
|
|
933
|
+
"DELETE",
|
|
934
|
+
f"/issues/{pr['number']}/labels/"
|
|
935
|
+
f"{quote(trigger_label, safe='')}",
|
|
936
|
+
)
|
|
937
|
+
continue
|
|
916
938
|
if delivery_key is None and self._finish_completed_review(
|
|
917
939
|
pr, trigger, trigger_label
|
|
918
940
|
):
|
|
@@ -1008,6 +1030,7 @@ class PullRequestReviewer(GitHubRepository):
|
|
|
1008
1030
|
subject=f"{repository_id}:pr:{pr['number']}",
|
|
1009
1031
|
delivery=delivery_key or f"{trigger['id']}:{sha}",
|
|
1010
1032
|
prompt=self._prompt(pr, trigger, trigger_label, delivery_key),
|
|
1033
|
+
head=sha,
|
|
1011
1034
|
)
|
|
1012
1035
|
print(
|
|
1013
1036
|
json.dumps(
|
|
@@ -12,24 +12,62 @@ from urllib.parse import parse_qsl, urlencode, urlsplit
|
|
|
12
12
|
from urllib.request import Request, urlopen
|
|
13
13
|
|
|
14
14
|
|
|
15
|
+
# The secret name of the GitHub token an OpenHands Cloud or Enterprise user's
|
|
16
|
+
# connected GitHub integration provides: how this run's sandbox serves it, and
|
|
17
|
+
# how a conversation sees it in its environment.
|
|
18
|
+
CLOUD_GITHUB_TOKEN_SECRET = "github_token"
|
|
19
|
+
CLOUD_GITHUB_TOKEN_ENV = "GITHUB_TOKEN"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def is_cloud_run() -> bool:
|
|
23
|
+
"""Whether this run is on OpenHands Cloud or Enterprise.
|
|
24
|
+
|
|
25
|
+
The automation service hands a run the Agent Server URL only on a local
|
|
26
|
+
Agent Canvas; elsewhere the run talks to the OpenHands API instead.
|
|
27
|
+
"""
|
|
28
|
+
return bool(os.environ.get("OPENHANDS_CLOUD_API_URL")) and not os.environ.get(
|
|
29
|
+
"AGENT_SERVER_URL"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _load_cloud_secret(name: str) -> str | None:
|
|
34
|
+
"""Read one named secret of this run's sandbox from the OpenHands API."""
|
|
35
|
+
api = os.environ["OPENHANDS_CLOUD_API_URL"].rstrip("/")
|
|
36
|
+
request = Request(
|
|
37
|
+
f"{api}/api/v1/sandboxes/{os.environ['SANDBOX_ID']}/settings/secrets/{name}",
|
|
38
|
+
headers={"X-Session-API-Key": os.environ["SESSION_API_KEY"]},
|
|
39
|
+
)
|
|
40
|
+
try:
|
|
41
|
+
with urlopen(request, timeout=90) as response:
|
|
42
|
+
return response.read().decode().strip()
|
|
43
|
+
except HTTPError as exc:
|
|
44
|
+
if exc.code == 404:
|
|
45
|
+
return None
|
|
46
|
+
raise
|
|
47
|
+
|
|
48
|
+
|
|
15
49
|
def _load_secret(name: str) -> str:
|
|
16
|
-
"""Read one named secret from the environment or configured Agent
|
|
50
|
+
"""Read one named secret from the environment, or from the configured Agent
|
|
51
|
+
Server on a local run and the OpenHands API on a cloud one."""
|
|
17
52
|
value = os.environ.get(name)
|
|
18
53
|
if value:
|
|
19
54
|
return value
|
|
20
55
|
|
|
21
|
-
|
|
56
|
+
if is_cloud_run():
|
|
57
|
+
value = _load_cloud_secret(name)
|
|
58
|
+
else:
|
|
59
|
+
from openhands.sdk.workspace import RemoteWorkspace
|
|
22
60
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
61
|
+
workspace = RemoteWorkspace(
|
|
62
|
+
host=os.environ["AGENT_SERVER_URL"],
|
|
63
|
+
api_key=os.environ["SESSION_API_KEY"],
|
|
64
|
+
working_dir=os.environ.get("WORKSPACE_BASE", "/workspace"),
|
|
65
|
+
)
|
|
66
|
+
try:
|
|
67
|
+
secret = workspace.get_secrets([name]).get(name)
|
|
68
|
+
value = secret.get_value() if secret else None
|
|
69
|
+
finally:
|
|
70
|
+
workspace.reset_client()
|
|
33
71
|
if not value:
|
|
34
72
|
raise ValueError(f"The GitHub credential {name} is unavailable")
|
|
35
73
|
return value
|
|
@@ -95,11 +133,22 @@ class GitHubRepository:
|
|
|
95
133
|
"Expected the environment variable containing the GitHub token"
|
|
96
134
|
)
|
|
97
135
|
self.token_name = github_token_secret
|
|
98
|
-
|
|
136
|
+
try:
|
|
137
|
+
self.token = _load_secret(github_token_secret)
|
|
138
|
+
except ValueError:
|
|
139
|
+
if not is_cloud_run():
|
|
140
|
+
raise
|
|
141
|
+
# No secret is saved under that name, so use the user's connected
|
|
142
|
+
# GitHub integration, under the name a conversation sees it by.
|
|
143
|
+
self.token = _load_cloud_secret(CLOUD_GITHUB_TOKEN_SECRET)
|
|
144
|
+
if not self.token:
|
|
145
|
+
raise
|
|
146
|
+
self.token_name = CLOUD_GITHUB_TOKEN_ENV
|
|
99
147
|
self.conversation = conversation
|
|
100
148
|
self.conversation_id = str(conversation.id) if conversation else None
|
|
101
149
|
self.dispatcher = dispatcher
|
|
102
|
-
|
|
150
|
+
# A cloud run is not given a workspace base; its sandbox has /workspace.
|
|
151
|
+
self.workspace = Path(os.environ.get("WORKSPACE_BASE", "/workspace"))
|
|
103
152
|
self.project = self.workspace
|
|
104
153
|
self.evidence = self.workspace / "evidence"
|
|
105
154
|
self.evidence.mkdir(exist_ok=True)
|
|
@@ -111,10 +160,13 @@ class GitHubRepository:
|
|
|
111
160
|
|
|
112
161
|
@property
|
|
113
162
|
def github_instructions(self):
|
|
163
|
+
# A cloud conversation runs in a sandbox of its own, so this run's
|
|
164
|
+
# workspace path means nothing to it.
|
|
165
|
+
where = "your working directory" if is_cloud_run() else self.project
|
|
114
166
|
return (
|
|
115
167
|
f"Use `GH_TOKEN=${self.token_name} gh api` for GitHub requests. "
|
|
116
168
|
"Never print the credential value. "
|
|
117
|
-
f"Only {self.repository} is in scope. Work in {
|
|
169
|
+
f"Only {self.repository} is in scope. Work in {where}. "
|
|
118
170
|
"Do not modify the automation bundle or its configuration."
|
|
119
171
|
)
|
|
120
172
|
|