@openhands/extensions 0.1.0 → 0.2.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/.agents/skills/custom-codereview-guide.md +25 -0
- package/.github/pull_request_template.md +38 -0
- package/.github/release.yml +14 -0
- package/.github/workflows/check-extensions.yml +72 -0
- package/.github/workflows/npm-publish.yml +89 -0
- package/.github/workflows/pr.yml +30 -0
- package/.github/workflows/release.yml +24 -0
- package/.github/workflows/tests.yml +25 -0
- package/.github/workflows/vulnerability-scan.yml +87 -0
- package/.release-please-manifest.json +3 -0
- package/AGENTS.md +132 -0
- package/README.md +10 -0
- package/analysis_results.md +162 -0
- package/marketplaces/large-codebase.json +66 -0
- package/marketplaces/openhands-extensions.json +682 -0
- package/package.json +4 -10
- package/plugins/README.md +30 -0
- package/plugins/city-weather/.plugin/plugin.json +13 -0
- package/plugins/city-weather/README.md +145 -0
- package/plugins/city-weather/commands/now.md +56 -0
- package/plugins/cobol-modernization/.plugin/plugin.json +19 -0
- package/plugins/cobol-modernization/README.md +201 -0
- package/plugins/cobol-modernization/references/troubleshooting.md +18 -0
- package/plugins/cobol-modernization/skills/build-setup/SKILL.md +78 -0
- package/plugins/cobol-modernization/skills/build-setup/scripts/install-gnucobol.sh +32 -0
- package/plugins/cobol-modernization/skills/cobol-modernization-overview/SKILL.md +113 -0
- package/plugins/cobol-modernization/skills/mainfraime-removal/SKILL.md +62 -0
- package/plugins/cobol-modernization/skills/mainfraime-removal/references/cics-transformation-examples.md +45 -0
- package/plugins/cobol-modernization/skills/mainframe-planning/SKILL.md +78 -0
- package/plugins/cobol-modernization/skills/to-java-migration/SKILL.md +59 -0
- package/plugins/cobol-modernization/skills/to-java-migration/references/cobol-to-java-example.md +58 -0
- package/plugins/cobol-modernization/skills/to-java-migration/references/datatype-mappings.md +19 -0
- package/plugins/issue-duplicate-checker/.plugin/plugin.json +13 -0
- package/plugins/issue-duplicate-checker/README.md +51 -0
- package/plugins/issue-duplicate-checker/action.yml +349 -0
- package/plugins/issue-duplicate-checker/scripts/auto_close_duplicate_issues.py +569 -0
- package/plugins/issue-duplicate-checker/scripts/issue_duplicate_check_openhands.py +681 -0
- package/plugins/issue-duplicate-checker/scripts/post_duplicate_notice.js +220 -0
- package/plugins/issue-duplicate-checker/scripts/remove_duplicate_candidate_label.js +27 -0
- package/plugins/magic-test/.plugin/plugin.json +13 -0
- package/plugins/magic-test/skills/magic-word/SKILL.md +33 -0
- package/plugins/migration-scoring/.plugin/plugin.json +19 -0
- package/plugins/migration-scoring/README.md +244 -0
- package/plugins/migration-scoring/skills/migration-mapping/SKILL.md +72 -0
- package/plugins/migration-scoring/skills/migration-report/SKILL.md +118 -0
- package/plugins/migration-scoring/skills/migration-scoring-overview/SKILL.md +126 -0
- package/plugins/migration-scoring/skills/score-quality/SKILL.md +54 -0
- package/plugins/migration-scoring/skills/score-quality/references/scoring-criteria.md +30 -0
- package/plugins/migration-scoring/skills/score-style/SKILL.md +106 -0
- package/plugins/onboarding/.plugin/plugin.json +20 -0
- package/plugins/onboarding/README.md +30 -0
- package/plugins/onboarding/references/criteria.md +144 -0
- package/plugins/onboarding/skills/agent-readiness-report/README.md +23 -0
- package/plugins/onboarding/skills/agent-readiness-report/SKILL.md +122 -0
- package/plugins/onboarding/skills/agent-readiness-report/scripts/scan_agent_instructions.sh +88 -0
- package/plugins/onboarding/skills/agent-readiness-report/scripts/scan_build_env.sh +114 -0
- package/plugins/onboarding/skills/agent-readiness-report/scripts/scan_feedback_loops.sh +133 -0
- package/plugins/onboarding/skills/agent-readiness-report/scripts/scan_policy.sh +113 -0
- package/plugins/onboarding/skills/agent-readiness-report/scripts/scan_workflows.sh +127 -0
- package/plugins/onboarding/skills/improve-agent-readiness/README.md +19 -0
- package/plugins/onboarding/skills/improve-agent-readiness/SKILL.md +167 -0
- package/plugins/onboarding/skills/setup-agents-md/README.md +15 -0
- package/plugins/onboarding/skills/setup-agents-md/SKILL.md +150 -0
- package/plugins/onboarding/skills/setup-openhands/README.md +20 -0
- package/plugins/onboarding/skills/setup-openhands/SKILL.md +56 -0
- package/plugins/onboarding/skills/setup-pr-review/README.md +23 -0
- package/plugins/onboarding/skills/setup-pr-review/SKILL.md +72 -0
- package/plugins/openhands/.plugin/plugin.json +13 -0
- package/plugins/openhands/README.md +52 -0
- package/plugins/openhands/SKILL.md +61 -0
- package/plugins/openhands/commands/create.md +55 -0
- package/plugins/openhands/commands/openhands-cloud.md +8 -0
- package/plugins/openhands/scripts/run.sh +69 -0
- package/plugins/pr-review/.plugin/plugin.json +13 -0
- package/plugins/pr-review/README.md +393 -0
- package/plugins/pr-review/action.yml +298 -0
- package/plugins/pr-review/scripts/agent_script.py +1282 -0
- package/plugins/pr-review/scripts/evaluate_review.py +655 -0
- package/plugins/pr-review/scripts/prompt.py +260 -0
- package/plugins/pr-review/workflows/pr-review-by-openhands.yml +51 -0
- package/plugins/pr-review/workflows/pr-review-evaluation.yml +85 -0
- package/plugins/qa-changes/.plugin/plugin.json +11 -0
- package/plugins/qa-changes/README.md +185 -0
- package/plugins/qa-changes/action.yml +181 -0
- package/plugins/qa-changes/scripts/agent_script.py +406 -0
- package/plugins/qa-changes/scripts/evaluate_qa_changes.py +385 -0
- package/plugins/qa-changes/scripts/prompt.py +174 -0
- package/plugins/qa-changes/workflows/qa-changes-by-openhands.yml +50 -0
- package/plugins/qa-changes/workflows/qa-changes-evaluation.yml +85 -0
- package/plugins/release-notes/.plugin/plugin.json +19 -0
- package/plugins/release-notes/README.md +283 -0
- package/plugins/release-notes/SKILL.md +83 -0
- package/plugins/release-notes/action.yml +117 -0
- package/plugins/release-notes/commands/release-notes.md +8 -0
- package/plugins/release-notes/scripts/agent_script.py +292 -0
- package/plugins/release-notes/scripts/generate_release_notes.py +733 -0
- package/plugins/release-notes/scripts/prompt.py +90 -0
- package/plugins/release-notes/scripts/validate_release_notes.py +328 -0
- package/plugins/release-notes/workflows/release-notes.yml +76 -0
- package/plugins/vulnerability-remediation/.plugin/plugin.json +19 -0
- package/plugins/vulnerability-remediation/README.md +217 -0
- package/plugins/vulnerability-remediation/action.yml +187 -0
- package/plugins/vulnerability-remediation/scripts/scan_and_remediate.py +561 -0
- package/plugins/vulnerability-remediation/workflows/vulnerability-scan.yml +87 -0
- package/pyproject.toml +12 -0
- package/release-please-config.json +16 -0
- package/scripts/sync_extensions.py +494 -0
- package/scripts/sync_openhands_sdk_skill.py +264 -0
- package/skills/README.md +159 -0
- package/skills/add-javadoc/.plugin/plugin.json +18 -0
- package/skills/add-javadoc/README.md +40 -0
- package/skills/add-javadoc/SKILL.md +35 -0
- package/skills/add-javadoc/references/example.md +32 -0
- package/skills/add-skill/.plugin/plugin.json +18 -0
- package/skills/add-skill/README.md +67 -0
- package/skills/add-skill/SKILL.md +47 -0
- package/skills/add-skill/scripts/fetch_skill.py +259 -0
- package/skills/agent-creator/.plugin/plugin.json +20 -0
- package/skills/agent-creator/README.md +104 -0
- package/skills/agent-creator/SKILL.md +190 -0
- package/skills/agent-creator/commands/agent-creator.md +8 -0
- package/skills/agent-creator/references/fallback.md +117 -0
- package/skills/agent-memory/.plugin/plugin.json +18 -0
- package/skills/agent-memory/README.md +35 -0
- package/skills/agent-memory/SKILL.md +30 -0
- package/skills/agent-memory/commands/remember.md +8 -0
- package/skills/agent-sdk-builder/.plugin/plugin.json +18 -0
- package/skills/agent-sdk-builder/README.md +40 -0
- package/skills/agent-sdk-builder/SKILL.md +37 -0
- package/skills/agent-sdk-builder/commands/agent-builder.md +8 -0
- package/skills/azure-devops/.plugin/plugin.json +18 -0
- package/skills/azure-devops/README.md +55 -0
- package/skills/azure-devops/SKILL.md +50 -0
- package/skills/bitbucket/.plugin/plugin.json +17 -0
- package/skills/bitbucket/README.md +50 -0
- package/skills/bitbucket/SKILL.md +45 -0
- package/skills/code-review/.plugin/plugin.json +19 -0
- package/skills/code-review/README.md +18 -0
- package/skills/code-review/SKILL.md +208 -0
- package/skills/code-review/commands/codereview-roasted.md +8 -0
- package/skills/code-review/commands/codereview.md +8 -0
- package/skills/code-review/references/risk-evaluation.md +41 -0
- package/skills/code-review/references/supply-chain-security.md +31 -0
- package/skills/code-simplifier/.plugin/plugin.json +21 -0
- package/skills/code-simplifier/README.md +30 -0
- package/skills/code-simplifier/SKILL.md +91 -0
- package/skills/code-simplifier/commands/simplify.md +8 -0
- package/skills/code-simplifier/references/code-quality-review.md +86 -0
- package/skills/code-simplifier/references/code-reuse-review.md +63 -0
- package/skills/code-simplifier/references/efficiency-review.md +81 -0
- package/skills/datadog/.plugin/plugin.json +19 -0
- package/skills/datadog/README.md +100 -0
- package/skills/datadog/SKILL.md +95 -0
- package/skills/deno/.plugin/plugin.json +18 -0
- package/skills/deno/README.md +5 -0
- package/skills/deno/SKILL.md +99 -0
- package/skills/deno/references/README.md +6 -0
- package/skills/discord/.plugin/plugin.json +18 -0
- package/skills/discord/README.md +31 -0
- package/skills/discord/SKILL.md +109 -0
- package/skills/discord/__init__.py +0 -0
- package/skills/discord/references/REFERENCE.md +78 -0
- package/skills/discord/scripts/__init__.py +0 -0
- package/skills/discord/scripts/_http.py +127 -0
- package/skills/discord/scripts/post_webhook.py +106 -0
- package/skills/discord/scripts/send_message.py +102 -0
- package/skills/docker/.plugin/plugin.json +17 -0
- package/skills/docker/README.md +34 -0
- package/skills/docker/SKILL.md +29 -0
- package/skills/evidence-based-citations/.plugin/plugin.json +20 -0
- package/skills/evidence-based-citations/README.md +31 -0
- package/skills/evidence-based-citations/SKILL.md +59 -0
- package/skills/flarglebargle/.plugin/plugin.json +16 -0
- package/skills/flarglebargle/README.md +14 -0
- package/skills/flarglebargle/SKILL.md +9 -0
- package/skills/frontend-design/.plugin/plugin.json +21 -0
- package/skills/frontend-design/LICENSE.txt +177 -0
- package/skills/frontend-design/README.md +42 -0
- package/skills/frontend-design/SKILL.md +42 -0
- package/skills/github/.plugin/plugin.json +19 -0
- package/skills/github/README.md +42 -0
- package/skills/github/SKILL.md +106 -0
- package/skills/github-pr-review/.plugin/plugin.json +18 -0
- package/skills/github-pr-review/README.md +145 -0
- package/skills/github-pr-review/SKILL.md +148 -0
- package/skills/github-pr-review/commands/github-pr-review.md +8 -0
- package/skills/github-pr-reviewer/.plugin/plugin.json +20 -0
- package/skills/github-pr-reviewer/README.md +34 -0
- package/skills/github-pr-reviewer/SKILL.md +89 -0
- package/skills/github-pr-reviewer/commands/pr-reviewer:setup.md +8 -0
- package/skills/github-repo-monitor/.plugin/plugin.json +22 -0
- package/skills/github-repo-monitor/README.md +70 -0
- package/skills/github-repo-monitor/SKILL.md +316 -0
- package/skills/github-repo-monitor/commands/github-monitor:poll.md +8 -0
- package/skills/github-repo-monitor/references/github-api.md +241 -0
- package/skills/github-repo-monitor/references/state-schema.md +160 -0
- package/skills/github-repo-monitor/scripts/main.py +915 -0
- package/skills/github-repo-monitor/tests/test_main.py +400 -0
- package/skills/gitlab/.plugin/plugin.json +17 -0
- package/skills/gitlab/README.md +37 -0
- package/skills/gitlab/SKILL.md +32 -0
- package/skills/incident-retrospective/.plugin/plugin.json +21 -0
- package/skills/incident-retrospective/README.md +34 -0
- package/skills/incident-retrospective/SKILL.md +98 -0
- package/skills/incident-retrospective/commands/incident-retro:setup.md +8 -0
- package/skills/iterate/.plugin/plugin.json +13 -0
- package/skills/iterate/README.md +25 -0
- package/skills/iterate/SKILL.md +399 -0
- package/skills/iterate/commands/babysit.md +8 -0
- package/skills/iterate/commands/iterate.md +8 -0
- package/skills/iterate/commands/verify.md +8 -0
- package/skills/iterate/references/heuristics.md +58 -0
- package/skills/iterate/references/verification.md +96 -0
- package/skills/jupyter/.plugin/plugin.json +18 -0
- package/skills/jupyter/README.md +55 -0
- package/skills/jupyter/SKILL.md +50 -0
- package/skills/kubernetes/.plugin/plugin.json +18 -0
- package/skills/kubernetes/README.md +53 -0
- package/skills/kubernetes/SKILL.md +48 -0
- package/skills/learn-from-code-review/.plugin/plugin.json +19 -0
- package/skills/learn-from-code-review/README.md +64 -0
- package/skills/learn-from-code-review/SKILL.md +186 -0
- package/skills/learn-from-code-review/commands/learn-from-reviews.md +8 -0
- package/skills/linear/.plugin/plugin.json +19 -0
- package/skills/linear/README.md +58 -0
- package/skills/linear/SKILL.md +213 -0
- package/skills/linear-triage/.plugin/plugin.json +21 -0
- package/skills/linear-triage/README.md +34 -0
- package/skills/linear-triage/SKILL.md +91 -0
- package/skills/linear-triage/commands/linear-triage:setup.md +8 -0
- package/skills/notion/.plugin/plugin.json +17 -0
- package/skills/notion/README.md +114 -0
- package/skills/notion/SKILL.md +109 -0
- package/skills/npm/.plugin/plugin.json +17 -0
- package/skills/npm/README.md +14 -0
- package/skills/npm/SKILL.md +9 -0
- package/skills/openhands-api/.plugin/plugin.json +22 -0
- package/skills/openhands-api/README.md +48 -0
- package/skills/openhands-api/SKILL.md +399 -0
- package/skills/openhands-api/references/README.md +33 -0
- package/skills/openhands-api/references/TROUBLESHOOTING.md +81 -0
- package/skills/openhands-api/references/example_prompt.md +12 -0
- package/skills/openhands-api/scripts/openhands_api.py +606 -0
- package/skills/openhands-api/scripts/openhands_api.ts +252 -0
- package/skills/openhands-automation/.plugin/plugin.json +19 -0
- package/skills/openhands-automation/README.md +89 -0
- package/skills/openhands-automation/SKILL.md +875 -0
- package/skills/openhands-automation/commands/automation:create.md +8 -0
- package/skills/openhands-automation/references/ab-testing.md +185 -0
- package/skills/openhands-automation/references/custom-automation.md +644 -0
- package/skills/openhands-sdk/.plugin/plugin.json +20 -0
- package/skills/openhands-sdk/README.md +22 -0
- package/skills/openhands-sdk/SKILL.md +229 -0
- package/skills/openhands-sdk/commands/sdk.md +8 -0
- package/skills/pdflatex/.plugin/plugin.json +18 -0
- package/skills/pdflatex/README.md +39 -0
- package/skills/pdflatex/SKILL.md +34 -0
- package/skills/prd/.plugin/plugin.json +19 -0
- package/skills/prd/README.md +28 -0
- package/skills/prd/SKILL.md +237 -0
- package/skills/prd/commands/prd.md +8 -0
- package/skills/qa-changes/README.md +18 -0
- package/skills/qa-changes/SKILL.md +229 -0
- package/skills/qa-changes/commands/qa-changes.md +8 -0
- package/skills/release-notes/README.md +24 -0
- package/skills/release-notes/SKILL.md +19 -0
- package/skills/release-notes/commands/release-notes.md +8 -0
- package/skills/research-brief/.plugin/plugin.json +20 -0
- package/skills/research-brief/README.md +34 -0
- package/skills/research-brief/SKILL.md +99 -0
- package/skills/research-brief/commands/research-brief:setup.md +8 -0
- package/skills/security/.plugin/plugin.json +18 -0
- package/skills/security/README.md +38 -0
- package/skills/security/SKILL.md +33 -0
- package/skills/skill-creator/.plugin/plugin.json +17 -0
- package/skills/skill-creator/LICENSE.txt +202 -0
- package/skills/skill-creator/README.md +182 -0
- package/skills/skill-creator/SKILL.md +545 -0
- package/skills/skill-creator/references/output-patterns.md +82 -0
- package/skills/skill-creator/references/workflows.md +28 -0
- package/skills/skill-creator/scripts/init_skill.py +303 -0
- package/skills/skill-creator/scripts/quick_validate.py +95 -0
- package/skills/slack-channel-monitor/.plugin/plugin.json +21 -0
- package/skills/slack-channel-monitor/README.md +91 -0
- package/skills/slack-channel-monitor/SKILL.md +276 -0
- package/skills/slack-channel-monitor/commands/slack-monitor:poll.md +8 -0
- package/skills/slack-channel-monitor/references/slack-api.md +207 -0
- package/skills/slack-channel-monitor/references/state-schema.md +180 -0
- package/skills/slack-channel-monitor/scripts/main.py +962 -0
- package/skills/slack-standup-digest/.plugin/plugin.json +21 -0
- package/skills/slack-standup-digest/README.md +34 -0
- package/skills/slack-standup-digest/SKILL.md +92 -0
- package/skills/slack-standup-digest/commands/standup-digest:setup.md +8 -0
- package/skills/spark-version-upgrade/.plugin/plugin.json +20 -0
- package/skills/spark-version-upgrade/README.md +54 -0
- package/skills/spark-version-upgrade/SKILL.md +233 -0
- package/skills/ssh/.plugin/plugin.json +18 -0
- package/skills/ssh/README.md +140 -0
- package/skills/ssh/SKILL.md +135 -0
- package/skills/swift-linux/.plugin/plugin.json +17 -0
- package/skills/swift-linux/README.md +86 -0
- package/skills/swift-linux/SKILL.md +81 -0
- package/skills/theme-factory/.plugin/plugin.json +19 -0
- package/skills/theme-factory/LICENSE.txt +202 -0
- package/skills/theme-factory/README.md +58 -0
- package/skills/theme-factory/SKILL.md +59 -0
- package/skills/theme-factory/theme-showcase.pdf +0 -0
- package/skills/theme-factory/themes/arctic-frost.md +19 -0
- package/skills/theme-factory/themes/botanical-garden.md +19 -0
- package/skills/theme-factory/themes/desert-rose.md +19 -0
- package/skills/theme-factory/themes/forest-canopy.md +19 -0
- package/skills/theme-factory/themes/golden-hour.md +19 -0
- package/skills/theme-factory/themes/midnight-galaxy.md +19 -0
- package/skills/theme-factory/themes/modern-minimalist.md +19 -0
- package/skills/theme-factory/themes/ocean-depths.md +19 -0
- package/skills/theme-factory/themes/sunset-boulevard.md +19 -0
- package/skills/theme-factory/themes/tech-innovation.md +19 -0
- package/skills/uv/.plugin/plugin.json +18 -0
- package/skills/uv/README.md +5 -0
- package/skills/uv/SKILL.md +95 -0
- package/skills/uv/references/README.md +5 -0
- package/skills/vercel/.plugin/plugin.json +18 -0
- package/skills/vercel/README.md +108 -0
- package/skills/vercel/SKILL.md +103 -0
- package/tests/test_add_skill_installs_to_agents_dir.py +42 -0
- package/tests/test_catalogs.py +109 -0
- package/tests/test_code_review_risk_evaluation.py +94 -0
- package/tests/test_issue_duplicate_checker.py +240 -0
- package/tests/test_openhands_api_python.py +152 -0
- package/tests/test_plugin_manifest.py +83 -0
- package/tests/test_pr_review_diff_payload.py +202 -0
- package/tests/test_pr_review_feedback.py +263 -0
- package/tests/test_pr_review_prompt.py +152 -0
- package/tests/test_pr_review_review_context.py +253 -0
- package/tests/test_qa_changes.py +232 -0
- package/tests/test_qa_changes_evaluation.py +259 -0
- package/tests/test_release_notes_generator.py +990 -0
- package/tests/test_sdk_loading.py +150 -0
- package/tests/test_skill_plugin_loading.py +149 -0
- package/tests/test_skills_have_readme.py +66 -0
- package/tests/test_sync_extensions.py +292 -0
- package/tests/test_workflow_sync.py +46 -0
- package/utils/analysis/README.md +7 -0
- package/utils/analysis/laminar_signals/README.md +211 -0
- package/utils/analysis/laminar_signals/analyze.py +780 -0
- package/utils/analysis/laminar_signals/templates/default.j2 +49 -0
- package/utils/analysis/laminar_signals/templates/pr_review.j2 +61 -0
|
@@ -0,0 +1,915 @@
|
|
|
1
|
+
"""
|
|
2
|
+
GitHub Repository Monitor - OpenHands Automation Script
|
|
3
|
+
|
|
4
|
+
Polls a GitHub repository on a cron schedule. When an event matching the
|
|
5
|
+
configured trigger phrase and event-type filter is detected it:
|
|
6
|
+
1. Posts a GitHub comment acknowledging the request with a conversation link.
|
|
7
|
+
2. Creates (or resumes) an OpenHands conversation pre-loaded with full
|
|
8
|
+
issue/PR context and recent comment history.
|
|
9
|
+
3. When the conversation reaches a terminal/idle state the agent's final
|
|
10
|
+
response is posted back to the issue/PR as a GitHub comment.
|
|
11
|
+
|
|
12
|
+
On subsequent runs:
|
|
13
|
+
- New trigger comments on a tracked issue/PR are forwarded to the running
|
|
14
|
+
conversation.
|
|
15
|
+
- If the previous conversation was closed/deleted a new one is created.
|
|
16
|
+
|
|
17
|
+
Configuration constants are embedded at automation-creation time by the skill.
|
|
18
|
+
See SKILL.md for the full setup workflow.
|
|
19
|
+
|
|
20
|
+
Required secrets (set in OpenHands Settings → Secrets):
|
|
21
|
+
GITHUB_TOKEN - Personal Access Token
|
|
22
|
+
Classic PAT: 'repo' scope (private) or 'public_repo' (public)
|
|
23
|
+
Fine-grained PAT: Issues: Read and Write
|
|
24
|
+
|
|
25
|
+
Optional secret:
|
|
26
|
+
OPENHANDS_URL - base URL for conversation links (default: http://localhost:8000)
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
import sys
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
import time
|
|
34
|
+
import urllib.error
|
|
35
|
+
import urllib.request
|
|
36
|
+
from datetime import datetime, timedelta, timezone
|
|
37
|
+
from urllib.parse import urlencode
|
|
38
|
+
|
|
39
|
+
# ── Embedded configuration (filled in by the skill at creation time) ──────────
|
|
40
|
+
REPO = "owner/repo" # e.g. "microsoft/vscode"
|
|
41
|
+
TRIGGER_PHRASE = "@openhands" # case-insensitive
|
|
42
|
+
EVENT_TYPES = ["issue_comment"] # e.g. ["issue_comment", "pr_review_comment"]
|
|
43
|
+
# Who may trigger conversations. Default is the authenticated GITHUB_TOKEN owner.
|
|
44
|
+
# Use ["*"] to allow any non-bot commenter, or explicit logins like ["octocat"].
|
|
45
|
+
ALLOWED_GITHUB_LOGINS = ["<TOKEN_OWNER>"]
|
|
46
|
+
DEFAULT_OPENHANDS_URL = "http://localhost:8000"
|
|
47
|
+
|
|
48
|
+
# Context: number of recent issue/PR comments to include in the initial prompt.
|
|
49
|
+
CONTEXT_COMMENT_LIMIT = 10
|
|
50
|
+
|
|
51
|
+
# Lookback slightly over 60 s on the first run to avoid boundary gaps.
|
|
52
|
+
INITIAL_LOOKBACK_SECONDS = 70
|
|
53
|
+
|
|
54
|
+
# Prevent posting summaries in the same run that created the conversation.
|
|
55
|
+
DONE_DEBOUNCE = 15
|
|
56
|
+
|
|
57
|
+
# Rolling window for processed event IDs — sized for ~1 week at high volume.
|
|
58
|
+
MAX_PROCESSED_IDS = 5000
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ── Stdlib helpers ─────────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
def _get_env_key() -> str:
|
|
64
|
+
return (
|
|
65
|
+
os.environ.get("SESSION_API_KEY")
|
|
66
|
+
or os.environ.get("OH_SESSION_API_KEYS_0")
|
|
67
|
+
or ""
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def get_secret(name: str) -> str:
|
|
72
|
+
"""Fetch a named secret from the agent server."""
|
|
73
|
+
url = os.environ.get("AGENT_SERVER_URL", "").rstrip("/")
|
|
74
|
+
key = _get_env_key()
|
|
75
|
+
req = urllib.request.Request(
|
|
76
|
+
f"{url}/api/settings/secrets/{name}",
|
|
77
|
+
headers={"X-Session-API-Key": key},
|
|
78
|
+
)
|
|
79
|
+
with urllib.request.urlopen(req) as r:
|
|
80
|
+
return r.read().decode().strip()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def fire_callback(
|
|
84
|
+
status: str = "COMPLETED",
|
|
85
|
+
error: str | None = None,
|
|
86
|
+
conversation_id: str | None = None,
|
|
87
|
+
) -> None:
|
|
88
|
+
"""Signal run completion to the automation service."""
|
|
89
|
+
url = os.environ.get("AUTOMATION_CALLBACK_URL", "")
|
|
90
|
+
if not url:
|
|
91
|
+
return
|
|
92
|
+
body: dict = {"status": status, "run_id": os.environ.get("AUTOMATION_RUN_ID", "")}
|
|
93
|
+
if error:
|
|
94
|
+
body["error"] = error
|
|
95
|
+
if conversation_id:
|
|
96
|
+
body["conversation_id"] = conversation_id
|
|
97
|
+
req = urllib.request.Request(
|
|
98
|
+
url,
|
|
99
|
+
data=json.dumps(body).encode(),
|
|
100
|
+
headers={
|
|
101
|
+
"Content-Type": "application/json",
|
|
102
|
+
"Authorization": f"Bearer {os.environ.get('AUTOMATION_CALLBACK_API_KEY', '')}",
|
|
103
|
+
},
|
|
104
|
+
)
|
|
105
|
+
try:
|
|
106
|
+
urllib.request.urlopen(req)
|
|
107
|
+
except Exception as exc:
|
|
108
|
+
print(f"Callback error (non-fatal): {exc}")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ── State management ───────────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
def _state_file_path() -> str:
|
|
114
|
+
"""Derive a persistent storage path from WORKSPACE_BASE.
|
|
115
|
+
|
|
116
|
+
WORKSPACE_BASE = {root}/automation-runs/{run_id}
|
|
117
|
+
State lives two levels up at {root}/automation-state/.
|
|
118
|
+
"""
|
|
119
|
+
workspace_base = os.environ.get("WORKSPACE_BASE", "")
|
|
120
|
+
event_payload = json.loads(os.environ.get("AUTOMATION_EVENT_PAYLOAD", "{}"))
|
|
121
|
+
automation_id = event_payload.get("automation_id", "default")
|
|
122
|
+
|
|
123
|
+
if workspace_base:
|
|
124
|
+
root = Path(workspace_base).resolve().parent.parent
|
|
125
|
+
else:
|
|
126
|
+
root = Path.home() / ".openhands" / "workspaces"
|
|
127
|
+
|
|
128
|
+
state_dir = root / "automation-state"
|
|
129
|
+
state_dir.mkdir(parents=True, exist_ok=True)
|
|
130
|
+
return str(state_dir / f"github_poller_{automation_id}.json")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _default_since() -> str:
|
|
134
|
+
"""ISO 8601 UTC timestamp for the initial lookback window."""
|
|
135
|
+
return (
|
|
136
|
+
datetime.now(timezone.utc) - timedelta(seconds=INITIAL_LOOKBACK_SECONDS)
|
|
137
|
+
).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def load_state(path: str) -> dict:
|
|
141
|
+
if os.path.exists(path):
|
|
142
|
+
try:
|
|
143
|
+
with open(path) as f:
|
|
144
|
+
return json.load(f)
|
|
145
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
146
|
+
print(f"Warning: state file {path} unreadable ({exc}); starting fresh")
|
|
147
|
+
return {
|
|
148
|
+
"version": 1,
|
|
149
|
+
"repo": REPO,
|
|
150
|
+
"last_poll": _default_since(),
|
|
151
|
+
"conversations": {}, # issue_number (str) → ConversationRecord
|
|
152
|
+
"processed_comment_ids": [], # list of int comment IDs already handled
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def save_state(path: str, state: dict) -> None:
|
|
157
|
+
with open(path, "w") as f:
|
|
158
|
+
json.dump(state, f, indent=2)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# ── GitHub API helpers ─────────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
def _github_request(
|
|
164
|
+
token: str,
|
|
165
|
+
method: str,
|
|
166
|
+
path: str,
|
|
167
|
+
params: dict | None = None,
|
|
168
|
+
body: dict | None = None,
|
|
169
|
+
) -> tuple[dict | list, dict]:
|
|
170
|
+
"""Low-level GitHub API call. Returns (parsed_body, response_headers).
|
|
171
|
+
Raises urllib.error.HTTPError on non-2xx responses.
|
|
172
|
+
"""
|
|
173
|
+
base = "https://api.github.com"
|
|
174
|
+
url = f"{base}{path}"
|
|
175
|
+
if params:
|
|
176
|
+
url = f"{url}?{urlencode(params)}"
|
|
177
|
+
headers = {
|
|
178
|
+
"Authorization": f"Bearer {token}",
|
|
179
|
+
"Accept": "application/vnd.github+json",
|
|
180
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
181
|
+
"Content-Type": "application/json",
|
|
182
|
+
}
|
|
183
|
+
data = json.dumps(body).encode() if body is not None else None
|
|
184
|
+
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
185
|
+
with urllib.request.urlopen(req) as r:
|
|
186
|
+
resp_headers = dict(r.headers)
|
|
187
|
+
raw = r.read()
|
|
188
|
+
return (json.loads(raw) if raw.strip() else {}), resp_headers
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _github_paginate(token: str, path: str, params: dict | None = None) -> list:
|
|
192
|
+
"""Fetch all pages from a GitHub list endpoint."""
|
|
193
|
+
results = []
|
|
194
|
+
page = 1
|
|
195
|
+
base_params = dict(params or {})
|
|
196
|
+
base_params.setdefault("per_page", 100)
|
|
197
|
+
while True:
|
|
198
|
+
base_params["page"] = page
|
|
199
|
+
data, _ = _github_request(token, "GET", path, params=base_params)
|
|
200
|
+
if not isinstance(data, list):
|
|
201
|
+
break
|
|
202
|
+
results.extend(data)
|
|
203
|
+
if len(data) < base_params["per_page"]:
|
|
204
|
+
break
|
|
205
|
+
page += 1
|
|
206
|
+
return results
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _resolve_github_token() -> str:
|
|
210
|
+
"""Fetch GITHUB_TOKEN from secrets. Raises RuntimeError if absent."""
|
|
211
|
+
try:
|
|
212
|
+
token = get_secret("GITHUB_TOKEN")
|
|
213
|
+
if token:
|
|
214
|
+
return token
|
|
215
|
+
except Exception:
|
|
216
|
+
pass
|
|
217
|
+
raise RuntimeError(
|
|
218
|
+
"GITHUB_TOKEN secret is not set. "
|
|
219
|
+
"Go to OpenHands Settings → Secrets and add your GitHub Personal Access Token."
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _verify_token_and_repo(token: str, repo: str) -> str:
|
|
224
|
+
"""Verify the token is valid, the repo is accessible, and the token can
|
|
225
|
+
post comments. Returns the authenticated GitHub username.
|
|
226
|
+
Raises RuntimeError with a user-friendly message on any failure.
|
|
227
|
+
"""
|
|
228
|
+
# 1. Verify token validity and get scopes.
|
|
229
|
+
try:
|
|
230
|
+
user_data, user_headers = _github_request(token, "GET", "/user")
|
|
231
|
+
except urllib.error.HTTPError as exc:
|
|
232
|
+
if exc.code == 401:
|
|
233
|
+
raise RuntimeError(
|
|
234
|
+
"GITHUB_TOKEN is invalid or expired. "
|
|
235
|
+
"Update it in OpenHands Settings → Secrets."
|
|
236
|
+
)
|
|
237
|
+
raise RuntimeError(f"GitHub /user check failed: {exc.code}")
|
|
238
|
+
|
|
239
|
+
username: str = user_data.get("login", "?")
|
|
240
|
+
scopes_header: str = user_headers.get("X-OAuth-Scopes", "") or ""
|
|
241
|
+
scopes = {s.strip() for s in scopes_header.split(",") if s.strip()}
|
|
242
|
+
print(f"Authenticated as GitHub user: {username} scopes: {scopes or '(fine-grained PAT)'}")
|
|
243
|
+
|
|
244
|
+
# 2. Verify repo access.
|
|
245
|
+
try:
|
|
246
|
+
repo_data, _ = _github_request(token, "GET", f"/repos/{repo}")
|
|
247
|
+
except urllib.error.HTTPError as exc:
|
|
248
|
+
if exc.code == 404:
|
|
249
|
+
raise RuntimeError(
|
|
250
|
+
f"Repository '{repo}' not found or not accessible with the current GITHUB_TOKEN. "
|
|
251
|
+
"Check the repo name (format: owner/repo) and token permissions."
|
|
252
|
+
)
|
|
253
|
+
if exc.code == 403:
|
|
254
|
+
raise RuntimeError(
|
|
255
|
+
f"Access denied to repository '{repo}'. "
|
|
256
|
+
"Ensure GITHUB_TOKEN has the required permissions."
|
|
257
|
+
)
|
|
258
|
+
raise RuntimeError(f"GitHub /repos/{repo} check failed: {exc.code}")
|
|
259
|
+
|
|
260
|
+
# 3. Verify comment-posting permission.
|
|
261
|
+
is_private: bool = repo_data.get("private", False)
|
|
262
|
+
permissions: dict = repo_data.get("permissions", {})
|
|
263
|
+
can_push: bool = permissions.get("push", False)
|
|
264
|
+
has_repo_scope: bool = "repo" in scopes
|
|
265
|
+
has_public_repo_scope: bool = "public_repo" in scopes
|
|
266
|
+
|
|
267
|
+
if is_private:
|
|
268
|
+
# Private repo: must have push access or the 'repo' classic-PAT scope.
|
|
269
|
+
if not can_push and not has_repo_scope and scopes:
|
|
270
|
+
raise RuntimeError(
|
|
271
|
+
f"GITHUB_TOKEN cannot post comments to private repository '{repo}'. "
|
|
272
|
+
"A classic PAT needs the 'repo' scope; "
|
|
273
|
+
"a fine-grained PAT needs 'Issues: Read and Write' permission."
|
|
274
|
+
)
|
|
275
|
+
else:
|
|
276
|
+
# Public repo: need at minimum 'public_repo' scope or push access.
|
|
277
|
+
if scopes and not (can_push or has_public_repo_scope or has_repo_scope):
|
|
278
|
+
raise RuntimeError(
|
|
279
|
+
f"GITHUB_TOKEN cannot post comments to public repository '{repo}'. "
|
|
280
|
+
"A classic PAT needs the 'public_repo' scope; "
|
|
281
|
+
"a fine-grained PAT needs 'Issues: Read and Write' permission."
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
print(f"Repository '{repo}' accessible. Private: {is_private}. Can push: {can_push}")
|
|
285
|
+
return username
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _poll_issue_comments(token: str, repo: str, since: str) -> list[dict]:
|
|
289
|
+
"""Fetch all issue/PR comments created after `since` (ISO 8601 UTC)."""
|
|
290
|
+
return _github_paginate(
|
|
291
|
+
token,
|
|
292
|
+
f"/repos/{repo}/issues/comments",
|
|
293
|
+
{"since": since, "sort": "created", "direction": "asc"},
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _poll_pr_review_comments(token: str, repo: str, since: str) -> list[dict]:
|
|
298
|
+
"""Fetch all PR inline review comments created after `since`."""
|
|
299
|
+
return _github_paginate(
|
|
300
|
+
token,
|
|
301
|
+
f"/repos/{repo}/pulls/comments",
|
|
302
|
+
{"since": since, "sort": "created", "direction": "asc"},
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _extract_issue_number(comment: dict, event_type: str) -> int | None:
|
|
307
|
+
"""Extract the issue/PR number from a comment object."""
|
|
308
|
+
try:
|
|
309
|
+
if event_type == "issue_comment":
|
|
310
|
+
# issue_url: .../repos/owner/repo/issues/42
|
|
311
|
+
return int(comment["issue_url"].rstrip("/").rsplit("/", 1)[-1])
|
|
312
|
+
if event_type == "pr_review_comment":
|
|
313
|
+
# pull_request_url: .../repos/owner/repo/pulls/15
|
|
314
|
+
return int(comment["pull_request_url"].rstrip("/").rsplit("/", 1)[-1])
|
|
315
|
+
except (KeyError, ValueError, AttributeError):
|
|
316
|
+
pass
|
|
317
|
+
return None
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _get_issue_context(token: str, repo: str, issue_number: int) -> dict:
|
|
321
|
+
"""Fetch issue/PR metadata and up to CONTEXT_COMMENT_LIMIT recent comments."""
|
|
322
|
+
issue_data, _ = _github_request(token, "GET", f"/repos/{repo}/issues/{issue_number}")
|
|
323
|
+
|
|
324
|
+
# Fetch last CONTEXT_COMMENT_LIMIT comments (GitHub returns oldest-first by default).
|
|
325
|
+
# We request a larger page and take the tail to get the most recent ones.
|
|
326
|
+
all_comments = _github_paginate(
|
|
327
|
+
token,
|
|
328
|
+
f"/repos/{repo}/issues/{issue_number}/comments",
|
|
329
|
+
{"per_page": 100},
|
|
330
|
+
)
|
|
331
|
+
recent_comments = all_comments[-CONTEXT_COMMENT_LIMIT:]
|
|
332
|
+
|
|
333
|
+
return {
|
|
334
|
+
"issue": issue_data,
|
|
335
|
+
"recent_comments": recent_comments,
|
|
336
|
+
"is_pr": "pull_request" in issue_data,
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _post_github_comment(token: str, repo: str, issue_number: int, body: str) -> int | None:
|
|
341
|
+
"""Post a comment on an issue/PR and return the comment ID."""
|
|
342
|
+
try:
|
|
343
|
+
result, _ = _github_request(
|
|
344
|
+
token,
|
|
345
|
+
"POST",
|
|
346
|
+
f"/repos/{repo}/issues/{issue_number}/comments",
|
|
347
|
+
body={"body": body},
|
|
348
|
+
)
|
|
349
|
+
return result.get("id")
|
|
350
|
+
except Exception as exc:
|
|
351
|
+
print(f" Warning: failed to post GitHub comment on #{issue_number}: {exc}")
|
|
352
|
+
return None
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
# ── OpenHands conversation helpers ────────────────────────────────────────────
|
|
356
|
+
|
|
357
|
+
def _oh_request(
|
|
358
|
+
agent_url: str, api_key: str, method: str, path: str, body: dict | None = None
|
|
359
|
+
) -> dict:
|
|
360
|
+
url = f"{agent_url}{path}"
|
|
361
|
+
headers = {"X-Session-API-Key": api_key, "Content-Type": "application/json"}
|
|
362
|
+
data = json.dumps(body).encode() if body is not None else None
|
|
363
|
+
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
364
|
+
try:
|
|
365
|
+
with urllib.request.urlopen(req) as r:
|
|
366
|
+
raw = r.read()
|
|
367
|
+
return json.loads(raw) if raw.strip() else {}
|
|
368
|
+
except urllib.error.HTTPError as exc:
|
|
369
|
+
body_text = exc.read().decode()
|
|
370
|
+
raise RuntimeError(f"Agent API {method} {path} → {exc.code}: {body_text}") from exc
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _fetch_settings(agent_url: str, api_key: str) -> dict:
|
|
374
|
+
"""Fetch the full user settings from the agent server.
|
|
375
|
+
|
|
376
|
+
Uses X-Expose-Secrets: plaintext so the LLM api_key is a real string
|
|
377
|
+
rather than a masked placeholder.
|
|
378
|
+
"""
|
|
379
|
+
url = f"{agent_url}/api/settings"
|
|
380
|
+
headers = {"X-Session-API-Key": api_key, "X-Expose-Secrets": "plaintext"}
|
|
381
|
+
req = urllib.request.Request(url, headers=headers)
|
|
382
|
+
try:
|
|
383
|
+
with urllib.request.urlopen(req) as r:
|
|
384
|
+
return json.loads(r.read())
|
|
385
|
+
except urllib.error.HTTPError as exc:
|
|
386
|
+
raise RuntimeError(f"GET /api/settings failed: {exc.code}") from exc
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _get_agent_dict(agent_url: str, api_key: str) -> dict:
|
|
390
|
+
"""Fetch configured agent settings for conversation creation."""
|
|
391
|
+
data = _fetch_settings(agent_url, api_key)
|
|
392
|
+
agent_settings = data.get("agent_settings", {})
|
|
393
|
+
llm = agent_settings.get("llm", {})
|
|
394
|
+
# settings["agent_settings"]["agent"] reflects the full-app agent registry
|
|
395
|
+
# (e.g. "CodeActAgent", "BrowsingAgent"). The automation SDK is a separate
|
|
396
|
+
# runtime whose only valid kind is "Agent" — never forward that value.
|
|
397
|
+
return {
|
|
398
|
+
"kind": "Agent",
|
|
399
|
+
"llm": llm,
|
|
400
|
+
# "terminal" and "file_editor" are the runtime-registered tool names.
|
|
401
|
+
# Without an explicit tools list the SDK Agent defaults to think+finish only.
|
|
402
|
+
"tools": [{"name": "terminal"}, {"name": "file_editor"}],
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _get_mcp_config(agent_url: str, api_key: str) -> dict | None:
|
|
407
|
+
"""Extract MCP server configuration from user settings, if any."""
|
|
408
|
+
try:
|
|
409
|
+
data = _fetch_settings(agent_url, api_key)
|
|
410
|
+
agent_settings = data.get("agent_settings", {})
|
|
411
|
+
mcp_config = agent_settings.get("mcp_config")
|
|
412
|
+
if isinstance(mcp_config, dict) and mcp_config.get("mcpServers"):
|
|
413
|
+
return mcp_config
|
|
414
|
+
except Exception as exc:
|
|
415
|
+
print(f"Warning: could not fetch MCP config: {exc}")
|
|
416
|
+
return None
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _list_secret_names(agent_url: str, api_key: str) -> list[dict]:
|
|
420
|
+
"""Fetch user secret names and descriptions from the agent server."""
|
|
421
|
+
try:
|
|
422
|
+
result = _oh_request(agent_url, api_key, "GET", "/api/settings/secrets")
|
|
423
|
+
return result.get("secrets", [])
|
|
424
|
+
except Exception as exc:
|
|
425
|
+
print(f"Warning: could not list secrets: {exc}")
|
|
426
|
+
return []
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _build_secrets_payload(agent_url: str, api_key: str) -> dict:
|
|
430
|
+
"""Build LookupSecret references so spawned conversations can access
|
|
431
|
+
the user's secrets via the agent server's per-secret endpoint.
|
|
432
|
+
"""
|
|
433
|
+
secrets_list = _list_secret_names(agent_url, api_key)
|
|
434
|
+
if not secrets_list:
|
|
435
|
+
return {}
|
|
436
|
+
secrets: dict = {}
|
|
437
|
+
for secret in secrets_list:
|
|
438
|
+
name = secret.get("name", "")
|
|
439
|
+
if not name:
|
|
440
|
+
continue
|
|
441
|
+
lookup: dict = {
|
|
442
|
+
"kind": "LookupSecret",
|
|
443
|
+
"url": f"/api/settings/secrets/{name}",
|
|
444
|
+
}
|
|
445
|
+
if api_key:
|
|
446
|
+
lookup["headers"] = {"X-Session-API-Key": api_key}
|
|
447
|
+
desc = secret.get("description")
|
|
448
|
+
if desc:
|
|
449
|
+
lookup["description"] = desc
|
|
450
|
+
secrets[name] = lookup
|
|
451
|
+
return secrets
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def create_conversation(agent_url: str, api_key: str, initial_message: str) -> str:
|
|
455
|
+
"""Create an OpenHands conversation and return its ID.
|
|
456
|
+
|
|
457
|
+
Inherits the user's secrets (as LookupSecret references) and MCP
|
|
458
|
+
server configuration so the spawned agent has the same capabilities.
|
|
459
|
+
"""
|
|
460
|
+
workspace_dir = os.environ.get("WORKSPACE_BASE", "/workspace")
|
|
461
|
+
agent = _get_agent_dict(agent_url, api_key)
|
|
462
|
+
payload: dict = {
|
|
463
|
+
"workspace": {"working_dir": workspace_dir},
|
|
464
|
+
"agent": agent,
|
|
465
|
+
"initial_message": {"content": [{"text": initial_message}]},
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
# Forward user secrets so the spawned conversation can access them.
|
|
469
|
+
secrets = _build_secrets_payload(agent_url, api_key)
|
|
470
|
+
if secrets:
|
|
471
|
+
payload["secrets"] = secrets
|
|
472
|
+
|
|
473
|
+
# Forward MCP server configuration so MCP tools are available.
|
|
474
|
+
mcp_config = _get_mcp_config(agent_url, api_key)
|
|
475
|
+
if mcp_config:
|
|
476
|
+
payload["mcp_config"] = mcp_config
|
|
477
|
+
|
|
478
|
+
result = _oh_request(agent_url, api_key, "POST", "/api/conversations", payload)
|
|
479
|
+
return result["id"]
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def send_to_conversation(agent_url: str, api_key: str, conv_id: str, text: str) -> None:
|
|
483
|
+
"""Send a user message to an existing conversation and resume the agent."""
|
|
484
|
+
_oh_request(agent_url, api_key, "POST", f"/api/conversations/{conv_id}/events", {
|
|
485
|
+
"role": "user",
|
|
486
|
+
"content": [{"text": text}],
|
|
487
|
+
"run": True,
|
|
488
|
+
})
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def conversation_status(agent_url: str, api_key: str, conv_id: str) -> str:
|
|
492
|
+
result = _oh_request(agent_url, api_key, "GET", f"/api/conversations/{conv_id}")
|
|
493
|
+
return result.get("execution_status", "unknown")
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def conversation_final_response(agent_url: str, api_key: str, conv_id: str) -> str:
|
|
497
|
+
result = _oh_request(
|
|
498
|
+
agent_url, api_key, "GET", f"/api/conversations/{conv_id}/agent_final_response"
|
|
499
|
+
)
|
|
500
|
+
return result.get("response", "")
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
# ── Comment filtering helpers ─────────────────────────────────────────────────
|
|
504
|
+
|
|
505
|
+
def _comment_author_login(comment: dict) -> str:
|
|
506
|
+
"""Return the GitHub login for the comment author, if present."""
|
|
507
|
+
user = comment.get("user") or {}
|
|
508
|
+
return (user.get("login") or "").strip()
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _is_bot_comment(comment: dict) -> bool:
|
|
512
|
+
"""Return True if the comment was posted by a bot account."""
|
|
513
|
+
user = comment.get("user") or {}
|
|
514
|
+
login = _comment_author_login(comment)
|
|
515
|
+
return login.endswith("[bot]") or user.get("type") == "Bot"
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _allowed_login_set(token_owner_login: str) -> set[str]:
|
|
519
|
+
"""Resolve configured login allowlist, including the token-owner sentinel."""
|
|
520
|
+
token_owner = token_owner_login.strip().lower()
|
|
521
|
+
allowed: set[str] = set()
|
|
522
|
+
for login in ALLOWED_GITHUB_LOGINS:
|
|
523
|
+
normalized = login.strip().lower()
|
|
524
|
+
if not normalized:
|
|
525
|
+
continue
|
|
526
|
+
if normalized == "<token_owner>":
|
|
527
|
+
if token_owner:
|
|
528
|
+
allowed.add(token_owner)
|
|
529
|
+
continue
|
|
530
|
+
allowed.add(normalized)
|
|
531
|
+
return allowed
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _is_allowed_comment_author(comment: dict, token_owner_login: str) -> bool:
|
|
535
|
+
"""Return True if this comment author is allowed to trigger conversations."""
|
|
536
|
+
author = _comment_author_login(comment).lower()
|
|
537
|
+
if not author:
|
|
538
|
+
return False
|
|
539
|
+
allowed = _allowed_login_set(token_owner_login)
|
|
540
|
+
return "*" in allowed or author in allowed
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _has_trigger(comment: dict, phrase: str) -> bool:
|
|
544
|
+
"""Return True if the comment body contains *phrase* (case-insensitive)."""
|
|
545
|
+
body = (comment.get("body") or "").strip()
|
|
546
|
+
return phrase.lower() in body.lower()
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
# ── Prompt building ────────────────────────────────────────────────────────────
|
|
550
|
+
|
|
551
|
+
def _build_initial_prompt(ctx: dict, trigger_comment: dict, event_type: str) -> str:
|
|
552
|
+
"""Build the initial prompt for a new OpenHands conversation."""
|
|
553
|
+
issue = ctx["issue"]
|
|
554
|
+
is_pr = ctx["is_pr"]
|
|
555
|
+
item_type = "Pull Request" if is_pr else "Issue"
|
|
556
|
+
number = issue.get("number", "?")
|
|
557
|
+
title = issue.get("title", "(no title)")
|
|
558
|
+
body = (issue.get("body") or "").strip() or "(no description)"
|
|
559
|
+
state = issue.get("state", "?")
|
|
560
|
+
html_url = issue.get("html_url", "")
|
|
561
|
+
labels = [lb["name"] for lb in issue.get("labels", [])]
|
|
562
|
+
label_str = ", ".join(labels) if labels else "(none)"
|
|
563
|
+
|
|
564
|
+
comment_lines: list[str] = []
|
|
565
|
+
for c in ctx["recent_comments"]:
|
|
566
|
+
author = c.get("user", {}).get("login", "?")
|
|
567
|
+
c_body = (c.get("body") or "").strip()
|
|
568
|
+
comment_lines.append(f"[{author}]: {c_body}")
|
|
569
|
+
context_block = "\n".join(comment_lines) if comment_lines else "(no prior comments)"
|
|
570
|
+
|
|
571
|
+
trigger_author = trigger_comment.get("user", {}).get("login", "?")
|
|
572
|
+
trigger_body = (trigger_comment.get("body") or "").strip()
|
|
573
|
+
|
|
574
|
+
path_info = ""
|
|
575
|
+
if event_type == "pr_review_comment":
|
|
576
|
+
path = trigger_comment.get("path", "")
|
|
577
|
+
line = trigger_comment.get("line") or trigger_comment.get("original_line")
|
|
578
|
+
if path:
|
|
579
|
+
path_info = f"\nTriggering comment location: {path}" + (f" line {line}" if line else "")
|
|
580
|
+
|
|
581
|
+
return (
|
|
582
|
+
f"You are an AI assistant responding to a request on a GitHub {item_type}.\n\n"
|
|
583
|
+
f"Repository : {REPO}\n"
|
|
584
|
+
f"{item_type} #{number}: \"{title}\"\n"
|
|
585
|
+
f"State : {state}\n"
|
|
586
|
+
f"Labels : {label_str}\n"
|
|
587
|
+
f"URL : {html_url}\n"
|
|
588
|
+
f"\nDescription:\n---\n{body}\n---\n"
|
|
589
|
+
f"\nRecent comments (oldest → newest, up to {CONTEXT_COMMENT_LIMIT}):\n"
|
|
590
|
+
f"---\n{context_block}\n---\n"
|
|
591
|
+
f"\nTriggering comment by @{trigger_author}:{path_info}\n"
|
|
592
|
+
f"---\n{trigger_body}\n---\n"
|
|
593
|
+
f"\nPlease analyse the request and take the appropriate action.\n"
|
|
594
|
+
f"The GITHUB_TOKEN secret is available if you need to interact with the "
|
|
595
|
+
f"GitHub API (fetch the PR diff, create commits, update labels, etc.).\n"
|
|
596
|
+
f"When you are finished, summarise what you did clearly — that summary "
|
|
597
|
+
f"will be posted back to the GitHub {item_type} as a comment."
|
|
598
|
+
)
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
# ── Core event processing ──────────────────────────────────────────────────────
|
|
602
|
+
|
|
603
|
+
def _ensure_conversation(
|
|
604
|
+
agent_url: str,
|
|
605
|
+
api_key: str,
|
|
606
|
+
conversations: dict[str, dict],
|
|
607
|
+
conv_key: str,
|
|
608
|
+
issue_number: int,
|
|
609
|
+
is_pr: bool,
|
|
610
|
+
html_url: str,
|
|
611
|
+
prompt: str,
|
|
612
|
+
comment: dict,
|
|
613
|
+
item_type: str,
|
|
614
|
+
) -> tuple[str, bool]:
|
|
615
|
+
"""Create a new conversation or re-open a closed one.
|
|
616
|
+
|
|
617
|
+
Returns ``(conv_id, resumed)`` where *resumed* is True when an existing
|
|
618
|
+
closed conversation was successfully re-activated.
|
|
619
|
+
Raises on unrecoverable errors so the caller can log and skip.
|
|
620
|
+
"""
|
|
621
|
+
existing = conversations.get(conv_key)
|
|
622
|
+
|
|
623
|
+
if existing and existing.get("status") == "closed":
|
|
624
|
+
conv_id = existing["conversation_id"]
|
|
625
|
+
author = (comment.get("user") or {}).get("login", "?")
|
|
626
|
+
body_text = (comment.get("body") or "").strip()
|
|
627
|
+
try:
|
|
628
|
+
send_to_conversation(
|
|
629
|
+
agent_url, api_key, conv_id,
|
|
630
|
+
f"New request on GitHub {item_type} #{issue_number} by @{author}:\n\n{body_text}",
|
|
631
|
+
)
|
|
632
|
+
existing["status"] = "active"
|
|
633
|
+
existing["last_activity"] = time.time()
|
|
634
|
+
print(f" Re-opened closed conversation {conv_id}")
|
|
635
|
+
return conv_id, True
|
|
636
|
+
except Exception as exc:
|
|
637
|
+
print(f" Closed conversation {conv_id} unreachable ({exc}) — creating new")
|
|
638
|
+
|
|
639
|
+
conv_id = create_conversation(agent_url, api_key, prompt)
|
|
640
|
+
conversations[conv_key] = {
|
|
641
|
+
"conversation_id": conv_id,
|
|
642
|
+
"issue_number": issue_number,
|
|
643
|
+
"issue_type": "pr" if is_pr else "issue",
|
|
644
|
+
"html_url": html_url,
|
|
645
|
+
"status": "active",
|
|
646
|
+
"last_activity": time.time(),
|
|
647
|
+
}
|
|
648
|
+
print(f" Created conversation {conv_id}")
|
|
649
|
+
return conv_id, False
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def _post_acknowledgement(
|
|
653
|
+
github_token: str,
|
|
654
|
+
repo: str,
|
|
655
|
+
issue_number: int,
|
|
656
|
+
item_type: str,
|
|
657
|
+
conv_url: str,
|
|
658
|
+
resumed: bool,
|
|
659
|
+
) -> None:
|
|
660
|
+
"""Post an acknowledgement comment on the GitHub issue or PR."""
|
|
661
|
+
if resumed:
|
|
662
|
+
body = (
|
|
663
|
+
f"🤖 **OpenHands is resuming work on this {item_type}.**\n\n"
|
|
664
|
+
f"Picking up the existing conversation: {conv_url}\n\n"
|
|
665
|
+
f"_This comment was posted by an AI agent (OpenHands) "
|
|
666
|
+
f"in response to a '{TRIGGER_PHRASE}' mention._"
|
|
667
|
+
)
|
|
668
|
+
else:
|
|
669
|
+
body = (
|
|
670
|
+
f"🤖 **OpenHands is on it!**\n\n"
|
|
671
|
+
f"I've started working on this {item_type}. "
|
|
672
|
+
f"View the conversation here: {conv_url}\n\n"
|
|
673
|
+
f"_This comment was posted by an AI agent (OpenHands) "
|
|
674
|
+
f"in response to a '{TRIGGER_PHRASE}' mention._"
|
|
675
|
+
)
|
|
676
|
+
_post_github_comment(github_token, repo, issue_number, body)
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def _process_trigger_comment(
|
|
680
|
+
github_token: str,
|
|
681
|
+
agent_url: str,
|
|
682
|
+
api_key: str,
|
|
683
|
+
openhands_url: str,
|
|
684
|
+
repo: str,
|
|
685
|
+
issue_number: int,
|
|
686
|
+
comment: dict,
|
|
687
|
+
event_type: str,
|
|
688
|
+
conversations: dict[str, dict],
|
|
689
|
+
) -> str | None:
|
|
690
|
+
"""Handle a new trigger comment: create or resume a conversation.
|
|
691
|
+
|
|
692
|
+
Returns the conversation ID when a new or re-opened conversation is
|
|
693
|
+
created, or None when the comment is forwarded to an existing one.
|
|
694
|
+
"""
|
|
695
|
+
conv_key = str(issue_number)
|
|
696
|
+
print(f" Trigger detected on #{issue_number} (comment {comment.get('id')})")
|
|
697
|
+
|
|
698
|
+
# Fetch full issue/PR context.
|
|
699
|
+
try:
|
|
700
|
+
ctx = _get_issue_context(github_token, repo, issue_number)
|
|
701
|
+
except Exception as exc:
|
|
702
|
+
print(f" Error fetching context for #{issue_number}: {exc}")
|
|
703
|
+
return None
|
|
704
|
+
|
|
705
|
+
is_pr = ctx["is_pr"]
|
|
706
|
+
item_type = "pull request" if is_pr else "issue"
|
|
707
|
+
html_url = ctx["issue"].get("html_url", f"https://github.com/{repo}/issues/{issue_number}")
|
|
708
|
+
|
|
709
|
+
existing = conversations.get(conv_key)
|
|
710
|
+
|
|
711
|
+
# ── Case A: active conversation — forward the new comment ─────────────────
|
|
712
|
+
if existing and existing.get("status") == "active":
|
|
713
|
+
conv_id = existing["conversation_id"]
|
|
714
|
+
print(f" Forwarding to active conversation {conv_id}")
|
|
715
|
+
author = comment.get("user", {}).get("login", "?")
|
|
716
|
+
body = (comment.get("body") or "").strip()
|
|
717
|
+
try:
|
|
718
|
+
send_to_conversation(
|
|
719
|
+
agent_url, api_key, conv_id,
|
|
720
|
+
f"New comment on GitHub {item_type} #{issue_number} by @{author}:\n\n{body}",
|
|
721
|
+
)
|
|
722
|
+
existing["last_activity"] = time.time()
|
|
723
|
+
return None
|
|
724
|
+
except Exception as exc:
|
|
725
|
+
print(f" Warning: could not forward to conversation {conv_id}: {exc} — creating new")
|
|
726
|
+
# Fall through to create a new conversation.
|
|
727
|
+
|
|
728
|
+
# ── Case B: closed or missing — create / re-open via helper ──────────────
|
|
729
|
+
prompt = _build_initial_prompt(ctx, comment, event_type)
|
|
730
|
+
try:
|
|
731
|
+
conv_id, resumed = _ensure_conversation(
|
|
732
|
+
agent_url, api_key, conversations, conv_key,
|
|
733
|
+
issue_number, is_pr, html_url, prompt, comment, item_type,
|
|
734
|
+
)
|
|
735
|
+
except Exception as exc:
|
|
736
|
+
print(f" Error creating conversation for #{issue_number}: {exc}")
|
|
737
|
+
return None
|
|
738
|
+
|
|
739
|
+
conv_url = f"{openhands_url}/conversations/{conv_id}"
|
|
740
|
+
_post_acknowledgement(github_token, repo, issue_number, item_type, conv_url, resumed)
|
|
741
|
+
return conv_id
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
def _check_conversation_completion(
|
|
745
|
+
conv_key: str,
|
|
746
|
+
rec: dict,
|
|
747
|
+
github_token: str,
|
|
748
|
+
repo: str,
|
|
749
|
+
agent_url: str,
|
|
750
|
+
api_key: str,
|
|
751
|
+
) -> None:
|
|
752
|
+
"""Post a summary GitHub comment when a conversation reaches a terminal state."""
|
|
753
|
+
if (time.time() - rec.get("last_activity", 0.0)) < DONE_DEBOUNCE:
|
|
754
|
+
return
|
|
755
|
+
|
|
756
|
+
conv_id = rec["conversation_id"]
|
|
757
|
+
issue_number = rec["issue_number"]
|
|
758
|
+
item_type = rec.get("issue_type", "issue")
|
|
759
|
+
item_label = "pull request" if item_type == "pr" else "issue"
|
|
760
|
+
|
|
761
|
+
try:
|
|
762
|
+
status = conversation_status(agent_url, api_key, conv_id)
|
|
763
|
+
except Exception as exc:
|
|
764
|
+
print(f" Warning: could not get status for {conv_id}: {exc}")
|
|
765
|
+
return
|
|
766
|
+
|
|
767
|
+
print(f" #{issue_number} conversation {conv_id} → status={status}")
|
|
768
|
+
|
|
769
|
+
if status not in ("idle", "finished", "error", "stuck"):
|
|
770
|
+
return
|
|
771
|
+
|
|
772
|
+
try:
|
|
773
|
+
final = conversation_final_response(agent_url, api_key, conv_id)
|
|
774
|
+
except Exception:
|
|
775
|
+
final = ""
|
|
776
|
+
|
|
777
|
+
if status in ("error", "stuck"):
|
|
778
|
+
comment_body = (
|
|
779
|
+
f"⚠️ **OpenHands encountered a problem** (status: `{status}`).\n\n"
|
|
780
|
+
+ (f"{final}\n\n" if final else "")
|
|
781
|
+
+ f"_This message was posted by an AI agent (OpenHands)._"
|
|
782
|
+
)
|
|
783
|
+
else:
|
|
784
|
+
comment_body = (
|
|
785
|
+
(f"✅ **OpenHands completed the task:**\n\n{final}\n\n" if final
|
|
786
|
+
else f"✅ **OpenHands completed the task.** (No summary available.)\n\n")
|
|
787
|
+
+ f"_This summary was generated by an AI agent (OpenHands) "
|
|
788
|
+
f"working on {item_label} #{issue_number}._"
|
|
789
|
+
)
|
|
790
|
+
|
|
791
|
+
_post_github_comment(github_token, repo, issue_number, comment_body)
|
|
792
|
+
rec["status"] = "closed"
|
|
793
|
+
print(f" Posted summary for #{issue_number}")
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
# ── Main ───────────────────────────────────────────────────────────────────────
|
|
797
|
+
|
|
798
|
+
def main() -> str | None:
|
|
799
|
+
"""Run one polling cycle. Returns the last conversation ID created, if any."""
|
|
800
|
+
state_path = _state_file_path()
|
|
801
|
+
state = load_state(state_path)
|
|
802
|
+
|
|
803
|
+
agent_url = os.environ.get("AGENT_SERVER_URL", "").rstrip("/")
|
|
804
|
+
api_key = _get_env_key()
|
|
805
|
+
|
|
806
|
+
github_token = _resolve_github_token()
|
|
807
|
+
token_owner_login = _verify_token_and_repo(github_token, REPO)
|
|
808
|
+
|
|
809
|
+
try:
|
|
810
|
+
openhands_url = get_secret("OPENHANDS_URL").rstrip("/") or DEFAULT_OPENHANDS_URL
|
|
811
|
+
except Exception:
|
|
812
|
+
openhands_url = DEFAULT_OPENHANDS_URL
|
|
813
|
+
|
|
814
|
+
since = state.get("last_poll") or _default_since()
|
|
815
|
+
now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
816
|
+
state["last_poll"] = now_iso # advance before processing so next run doesn't miss events
|
|
817
|
+
|
|
818
|
+
conversations: dict[str, dict] = state.get("conversations", {})
|
|
819
|
+
processed_ids: list[int] = state.get("processed_comment_ids", [])
|
|
820
|
+
processed_set: set[int] = set(processed_ids)
|
|
821
|
+
|
|
822
|
+
print(f"Polling {REPO} since {since} (trigger: '{TRIGGER_PHRASE}' types: {EVENT_TYPES})")
|
|
823
|
+
|
|
824
|
+
# ── Collect new events ─────────────────────────────────────────────────────
|
|
825
|
+
all_events: list[tuple[str, dict]] = [] # (event_type, comment)
|
|
826
|
+
|
|
827
|
+
if "issue_comment" in EVENT_TYPES:
|
|
828
|
+
try:
|
|
829
|
+
comments = _poll_issue_comments(github_token, REPO, since)
|
|
830
|
+
print(f" issue_comment: {len(comments)} new comment(s)")
|
|
831
|
+
for c in comments:
|
|
832
|
+
all_events.append(("issue_comment", c))
|
|
833
|
+
except Exception as exc:
|
|
834
|
+
print(f" Warning: could not poll issue comments: {exc}")
|
|
835
|
+
|
|
836
|
+
if "pr_review_comment" in EVENT_TYPES:
|
|
837
|
+
try:
|
|
838
|
+
review_comments = _poll_pr_review_comments(github_token, REPO, since)
|
|
839
|
+
print(f" pr_review_comment: {len(review_comments)} new comment(s)")
|
|
840
|
+
for c in review_comments:
|
|
841
|
+
all_events.append(("pr_review_comment", c))
|
|
842
|
+
except Exception as exc:
|
|
843
|
+
print(f" Warning: could not poll PR review comments: {exc}")
|
|
844
|
+
|
|
845
|
+
# Sort all events by creation time so they are processed chronologically.
|
|
846
|
+
all_events.sort(key=lambda x: x[1].get("created_at", ""))
|
|
847
|
+
|
|
848
|
+
# ── Process trigger events ─────────────────────────────────────────────────
|
|
849
|
+
last_conversation_id: str | None = None
|
|
850
|
+
for event_type, comment in all_events:
|
|
851
|
+
comment_id: int = comment.get("id", 0)
|
|
852
|
+
if comment_id in processed_set:
|
|
853
|
+
continue
|
|
854
|
+
|
|
855
|
+
if _is_bot_comment(comment):
|
|
856
|
+
processed_set.add(comment_id)
|
|
857
|
+
continue
|
|
858
|
+
|
|
859
|
+
if not _is_allowed_comment_author(comment, token_owner_login):
|
|
860
|
+
if _has_trigger(comment, TRIGGER_PHRASE):
|
|
861
|
+
author = _comment_author_login(comment) or "unknown"
|
|
862
|
+
print(
|
|
863
|
+
f" Skipping trigger comment {comment_id} "
|
|
864
|
+
f"from unauthorized user @{author}"
|
|
865
|
+
)
|
|
866
|
+
processed_set.add(comment_id)
|
|
867
|
+
continue
|
|
868
|
+
|
|
869
|
+
if not _has_trigger(comment, TRIGGER_PHRASE):
|
|
870
|
+
processed_set.add(comment_id)
|
|
871
|
+
continue
|
|
872
|
+
|
|
873
|
+
issue_number = _extract_issue_number(comment, event_type)
|
|
874
|
+
if issue_number is None:
|
|
875
|
+
print(f" Could not extract issue number from comment {comment_id} — skipping")
|
|
876
|
+
processed_set.add(comment_id)
|
|
877
|
+
continue
|
|
878
|
+
|
|
879
|
+
conv_id = _process_trigger_comment(
|
|
880
|
+
github_token, agent_url, api_key, openhands_url,
|
|
881
|
+
REPO, issue_number, comment, event_type, conversations,
|
|
882
|
+
)
|
|
883
|
+
if conv_id:
|
|
884
|
+
last_conversation_id = conv_id
|
|
885
|
+
processed_set.add(comment_id)
|
|
886
|
+
|
|
887
|
+
# ── Check active conversations for completion ──────────────────────────────
|
|
888
|
+
for conv_key, rec in list(conversations.items()):
|
|
889
|
+
if rec.get("status") != "active":
|
|
890
|
+
continue
|
|
891
|
+
_check_conversation_completion(
|
|
892
|
+
conv_key, rec, github_token, REPO, agent_url, api_key,
|
|
893
|
+
)
|
|
894
|
+
|
|
895
|
+
# Trim processed_ids rolling window.
|
|
896
|
+
trimmed = sorted(processed_set)
|
|
897
|
+
if len(trimmed) > MAX_PROCESSED_IDS:
|
|
898
|
+
trimmed = trimmed[-MAX_PROCESSED_IDS:]
|
|
899
|
+
state["processed_comment_ids"] = trimmed
|
|
900
|
+
state["conversations"] = conversations
|
|
901
|
+
|
|
902
|
+
save_state(state_path, state)
|
|
903
|
+
print(f"State saved → {state_path}")
|
|
904
|
+
return last_conversation_id
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
if __name__ == "__main__":
|
|
908
|
+
try:
|
|
909
|
+
conversation_id = main()
|
|
910
|
+
fire_callback("COMPLETED", conversation_id=conversation_id)
|
|
911
|
+
except Exception as exc:
|
|
912
|
+
import traceback
|
|
913
|
+
traceback.print_exc()
|
|
914
|
+
fire_callback("FAILED", str(exc))
|
|
915
|
+
sys.exit(1)
|