@openhands/extensions 0.5.0 → 0.7.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/.github/workflows/pypi-publish.yml +104 -0
- package/.github/workflows/tests.yml +6 -0
- package/.release-please-manifest.json +1 -1
- package/AGENTS.md +1 -1
- package/README.md +34 -4
- package/integrations/README.md +25 -9
- package/integrations/catalog/airtable.json +16 -10
- package/integrations/catalog/apify.json +0 -2
- package/integrations/catalog/asana.json +33 -0
- package/integrations/catalog/atlassian.json +5 -8
- package/integrations/catalog/bitbucket.json +34 -0
- package/integrations/catalog/box.json +34 -0
- package/integrations/catalog/brave-search.json +1 -2
- package/integrations/catalog/browser-mcp.json +0 -3
- package/integrations/catalog/canva.json +31 -0
- package/integrations/catalog/clickhouse.json +1 -2
- package/integrations/catalog/cloudflare-bindings.json +5 -8
- package/integrations/catalog/cloudflare-browser-rendering.json +5 -8
- package/integrations/catalog/cloudflare-builds.json +6 -9
- package/integrations/catalog/cloudflare-docs.json +4 -7
- package/integrations/catalog/cloudflare-observability.json +5 -8
- package/integrations/catalog/confluence.json +33 -0
- package/integrations/catalog/datadog.json +34 -0
- package/integrations/catalog/deepwiki.json +3 -7
- package/integrations/catalog/discord.json +34 -0
- package/integrations/catalog/elevenlabs.json +27 -28
- package/integrations/catalog/everything.json +0 -2
- package/integrations/catalog/exa.json +0 -2
- package/integrations/catalog/fetch.json +0 -2
- package/integrations/catalog/figma.json +35 -9
- package/integrations/catalog/filesystem.json +0 -3
- package/integrations/catalog/firecrawl.json +0 -2
- package/integrations/catalog/git.json +0 -3
- package/integrations/catalog/github.json +34 -10
- package/integrations/catalog/gmail.json +33 -0
- package/integrations/catalog/google-calendar.json +33 -0
- package/integrations/catalog/google-docs.json +33 -0
- package/integrations/catalog/google-drive.json +33 -0
- package/integrations/catalog/google-sheets.json +33 -0
- package/integrations/catalog/hubspot.json +34 -0
- package/integrations/catalog/huggingface.json +1 -2
- package/integrations/catalog/intercom.json +34 -0
- package/integrations/catalog/jira.json +33 -0
- package/integrations/catalog/kagi.json +2 -3
- package/integrations/catalog/linear.json +24 -16
- package/integrations/catalog/memory.json +0 -2
- package/integrations/catalog/microsoft-outlook.json +33 -0
- package/integrations/catalog/microsoft-teams.json +33 -0
- package/integrations/catalog/miro.json +42 -0
- package/integrations/catalog/monday.json +42 -0
- package/integrations/catalog/mongodb.json +1 -2
- package/integrations/catalog/neon.json +0 -2
- package/integrations/catalog/netlify.json +33 -0
- package/integrations/catalog/notion.json +30 -10
- package/integrations/catalog/obsidian.json +1 -3
- package/integrations/catalog/okta.json +33 -0
- package/integrations/catalog/onedrive.json +33 -0
- package/integrations/catalog/ordinal.json +33 -0
- package/integrations/catalog/paypal.json +5 -8
- package/integrations/catalog/playwright.json +0 -2
- package/integrations/catalog/posthog.json +38 -0
- package/integrations/catalog/quickbooks.json +86 -0
- package/integrations/catalog/redis.json +1 -3
- package/integrations/catalog/resend.json +1 -2
- package/integrations/catalog/salesforce.json +44 -0
- package/integrations/catalog/sentry.json +47 -14
- package/integrations/catalog/sequential-thinking.json +0 -2
- package/integrations/catalog/sharepoint.json +33 -0
- package/integrations/catalog/slack.json +49 -10
- package/integrations/catalog/stripe.json +39 -14
- package/integrations/catalog/supabase.json +31 -9
- package/integrations/catalog/tavily.json +0 -2
- package/integrations/catalog/time.json +0 -2
- package/integrations/catalog/trello.json +34 -0
- package/integrations/catalog/vercel.json +33 -0
- package/integrations/catalog/webflow.json +34 -0
- package/integrations/catalog/xero.json +33 -0
- package/integrations/catalog/zoom.json +33 -0
- package/integrations/catalog-index.js +151 -0
- package/integrations/catalog.schema.json +255 -0
- package/integrations/index.d.ts +49 -69
- package/integrations/index.js +36 -206
- package/marketplaces/openhands-extensions.json +40 -2
- package/package.json +4 -7
- package/plugins/cobol-modernization/README.md +3 -3
- package/plugins/cobol-modernization/skills/{cobol-modernization-overview → cobol-modernization}/SKILL.md +1 -1
- package/plugins/magic-test/skills/magic-word/SKILL.md +1 -0
- package/plugins/migration-scoring/README.md +1 -1
- package/plugins/pr-review/scripts/prompt.py +1 -1
- package/pyproject.toml +18 -3
- package/python/openhands_extensions/__init__.py +24 -0
- package/python/openhands_extensions/_version.py +25 -0
- package/python/openhands_extensions/integrations.py +92 -0
- package/release-please-config.json +2 -1
- package/scripts/build-integration-catalog.mjs +45 -0
- package/skills/agent-canvas-environment/.plugin/plugin.json +19 -0
- package/skills/agent-canvas-environment/README.md +5 -0
- package/skills/agent-canvas-environment/SKILL.md +202 -0
- package/skills/bitbucket/README.md +23 -40
- package/skills/bitbucket/SKILL.md +43 -31
- package/skills/bitbucket-cloud/.plugin/plugin.json +18 -0
- package/skills/bitbucket-cloud/README.md +15 -0
- package/skills/bitbucket-cloud/SKILL.md +46 -0
- package/skills/bitbucket-data-center/.plugin/plugin.json +19 -0
- package/skills/bitbucket-data-center/README.md +20 -0
- package/skills/bitbucket-data-center/SKILL.md +53 -0
- package/skills/code-review/SKILL.md +1 -1
- package/skills/code-review/references/supply-chain-security.md +2 -2
- package/skills/github-pr-reviewer/references/state-schema.md +18 -4
- package/skills/github-pr-reviewer/scripts/main.py +66 -12
- package/skills/github-repo-monitor/references/state-schema.md +11 -4
- package/skills/github-repo-monitor/scripts/main.py +67 -19
- package/skills/github-repo-monitor/tests/test_main.py +67 -8
- package/skills/index.js +34 -8
- package/skills/openhands-api/README.md +5 -1
- package/skills/openhands-api/SKILL.md +74 -5
- package/skills/openhands-automation/SKILL.md +3 -1
- package/skills/openhands-automation/references/custom-automation.md +166 -0
- package/skills/openhands-sdk/SKILL.md +8 -3
- package/skills/slack-channel-monitor/README.md +3 -3
- package/skills/slack-channel-monitor/SKILL.md +3 -2
- package/skills/slack-channel-monitor/references/state-schema.md +11 -4
- package/skills/slack-channel-monitor/scripts/main.py +111 -41
- package/tests/test_catalog_schema.py +47 -0
- package/tests/test_catalogs.py +47 -3
- package/tests/test_integration_catalog_in_sync.py +252 -0
- package/tests/test_slack_channel_monitor.py +142 -0
- package/tests/test_version_alignment.py +64 -0
- package/integrations/logos.d.ts +0 -5
- package/integrations/logos.js +0 -94
- package/integrations/oauth-provider-catalog.js +0 -553
- package/integrations/oauth-provider-registration-defaults.js +0 -610
- /package/plugins/cobol-modernization/skills/{mainfraime-removal → mainframe-removal}/SKILL.md +0 -0
- /package/plugins/cobol-modernization/skills/{mainfraime-removal → mainframe-removal}/references/cics-transformation-examples.md +0 -0
- /package/plugins/migration-scoring/skills/{migration-scoring-overview → migration-scoring}/SKILL.md +0 -0
|
@@ -67,6 +67,45 @@ def fire_callback(
|
|
|
67
67
|
print(f"Callback error (non-fatal): {exc}")
|
|
68
68
|
|
|
69
69
|
|
|
70
|
+
# ── State persistence (KV store with local-file fallback) ─────────────────────
|
|
71
|
+
|
|
72
|
+
_KV_TOKEN = os.environ.get("AUTOMATION_KV_TOKEN", "")
|
|
73
|
+
_KV_BASE = os.environ.get("AUTOMATION_API_URL", "").rstrip("/")
|
|
74
|
+
_STATE_KEY = "state"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _kv_available() -> bool:
|
|
78
|
+
return bool(_KV_TOKEN and _KV_BASE)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _kv_get(key: str) -> dict | None:
|
|
82
|
+
req = urllib.request.Request(
|
|
83
|
+
f"{_KV_BASE}/v1/kv/{key}",
|
|
84
|
+
headers={"Authorization": f"Bearer {_KV_TOKEN}"},
|
|
85
|
+
)
|
|
86
|
+
try:
|
|
87
|
+
with urllib.request.urlopen(req) as r:
|
|
88
|
+
return json.loads(r.read())["value"]
|
|
89
|
+
except urllib.error.HTTPError as exc:
|
|
90
|
+
if exc.code == 404:
|
|
91
|
+
return None
|
|
92
|
+
raise
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _kv_set(key: str, value: dict) -> None:
|
|
96
|
+
req = urllib.request.Request(
|
|
97
|
+
f"{_KV_BASE}/v1/kv/{key}",
|
|
98
|
+
data=json.dumps(value).encode(),
|
|
99
|
+
headers={
|
|
100
|
+
"Authorization": f"Bearer {_KV_TOKEN}",
|
|
101
|
+
"Content-Type": "application/json",
|
|
102
|
+
},
|
|
103
|
+
method="PUT",
|
|
104
|
+
)
|
|
105
|
+
with urllib.request.urlopen(req) as r:
|
|
106
|
+
r.read()
|
|
107
|
+
|
|
108
|
+
|
|
70
109
|
def _state_file_path() -> str:
|
|
71
110
|
workspace_base = os.environ.get("WORKSPACE_BASE", "")
|
|
72
111
|
event_payload = json.loads(os.environ.get("AUTOMATION_EVENT_PAYLOAD", "{}"))
|
|
@@ -82,13 +121,7 @@ def _state_file_path() -> str:
|
|
|
82
121
|
return str(state_dir / f"github_pr_reviewer_label_event_{automation_id}.json")
|
|
83
122
|
|
|
84
123
|
|
|
85
|
-
def
|
|
86
|
-
if os.path.exists(path):
|
|
87
|
-
try:
|
|
88
|
-
with open(path) as f:
|
|
89
|
-
return json.load(f)
|
|
90
|
-
except (json.JSONDecodeError, OSError) as exc:
|
|
91
|
-
print(f"Warning: state file {path} unreadable ({exc}); starting fresh")
|
|
124
|
+
def _default_state() -> dict:
|
|
92
125
|
return {
|
|
93
126
|
"version": 2,
|
|
94
127
|
"repo": REPO,
|
|
@@ -98,11 +131,34 @@ def load_state(path: str) -> dict:
|
|
|
98
131
|
}
|
|
99
132
|
|
|
100
133
|
|
|
101
|
-
def
|
|
134
|
+
def load_state() -> dict:
|
|
135
|
+
if _kv_available():
|
|
136
|
+
data = _kv_get(_STATE_KEY)
|
|
137
|
+
if data is not None:
|
|
138
|
+
print("State loaded from KV store")
|
|
139
|
+
return data
|
|
140
|
+
return _default_state()
|
|
141
|
+
path = _state_file_path()
|
|
142
|
+
if os.path.exists(path):
|
|
143
|
+
try:
|
|
144
|
+
with open(path) as f:
|
|
145
|
+
return json.load(f)
|
|
146
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
147
|
+
print(f"Warning: state file {path} unreadable ({exc}); starting fresh")
|
|
148
|
+
return _default_state()
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def save_state(state: dict) -> None:
|
|
152
|
+
if _kv_available():
|
|
153
|
+
_kv_set(_STATE_KEY, state)
|
|
154
|
+
print("State saved to KV store")
|
|
155
|
+
return
|
|
156
|
+
path = _state_file_path()
|
|
102
157
|
tmp_path = f"{path}.tmp"
|
|
103
158
|
with open(tmp_path, "w") as f:
|
|
104
159
|
json.dump(state, f, indent=2, sort_keys=True)
|
|
105
160
|
os.replace(tmp_path, path)
|
|
161
|
+
print(f"State saved to {path}")
|
|
106
162
|
|
|
107
163
|
|
|
108
164
|
def _github_request(
|
|
@@ -522,8 +578,7 @@ def _check_conversation_completion(
|
|
|
522
578
|
|
|
523
579
|
|
|
524
580
|
def main() -> str | None:
|
|
525
|
-
|
|
526
|
-
state = load_state(state_path)
|
|
581
|
+
state = load_state()
|
|
527
582
|
agent_url = os.environ.get("AGENT_SERVER_URL", "").rstrip("/")
|
|
528
583
|
api_key = _get_env_key()
|
|
529
584
|
|
|
@@ -591,8 +646,7 @@ def main() -> str | None:
|
|
|
591
646
|
state["repo"] = REPO
|
|
592
647
|
state["trigger_label"] = TRIGGER_LABEL
|
|
593
648
|
state["updated_at"] = time.time()
|
|
594
|
-
save_state(
|
|
595
|
-
print(f"State saved → {state_path}")
|
|
649
|
+
save_state(state)
|
|
596
650
|
return last_conversation_id
|
|
597
651
|
|
|
598
652
|
|
|
@@ -1,12 +1,19 @@
|
|
|
1
|
-
# State
|
|
1
|
+
# State Schema
|
|
2
2
|
|
|
3
|
-
The automation maintains a JSON state
|
|
4
|
-
This
|
|
3
|
+
The automation maintains a JSON state document that persists across polling runs.
|
|
4
|
+
This document is the source of truth for which conversations are active, which
|
|
5
5
|
comment IDs have already been processed, and the timestamp of the last poll.
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
9
|
-
##
|
|
9
|
+
## Storage
|
|
10
|
+
|
|
11
|
+
**Primary (cloud):** The state is stored in the automation service's built-in KV
|
|
12
|
+
store under the key `"state"`. The KV store is available when `AUTOMATION_KV_TOKEN`
|
|
13
|
+
is injected into the run environment. Each automation has its own isolated namespace.
|
|
14
|
+
|
|
15
|
+
**Fallback (local/dev):** When the KV store is not available, the state is written
|
|
16
|
+
to a local JSON file at:
|
|
10
17
|
|
|
11
18
|
```
|
|
12
19
|
{WORKSPACE_BASE_ROOT}/automation-state/github_poller_{automation_id}.json
|
|
@@ -108,14 +108,47 @@ def fire_callback(
|
|
|
108
108
|
print(f"Callback error (non-fatal): {exc}")
|
|
109
109
|
|
|
110
110
|
|
|
111
|
-
# ── State
|
|
111
|
+
# ── State persistence (KV store with local-file fallback) ─────────────────────
|
|
112
112
|
|
|
113
|
-
|
|
114
|
-
|
|
113
|
+
_KV_TOKEN = os.environ.get("AUTOMATION_KV_TOKEN", "")
|
|
114
|
+
_KV_BASE = os.environ.get("AUTOMATION_API_URL", "").rstrip("/")
|
|
115
|
+
_STATE_KEY = "state"
|
|
115
116
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
117
|
+
|
|
118
|
+
def _kv_available() -> bool:
|
|
119
|
+
return bool(_KV_TOKEN and _KV_BASE)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _kv_get(key: str) -> dict | None:
|
|
123
|
+
req = urllib.request.Request(
|
|
124
|
+
f"{_KV_BASE}/v1/kv/{key}",
|
|
125
|
+
headers={"Authorization": f"Bearer {_KV_TOKEN}"},
|
|
126
|
+
)
|
|
127
|
+
try:
|
|
128
|
+
with urllib.request.urlopen(req) as r:
|
|
129
|
+
return json.loads(r.read())["value"]
|
|
130
|
+
except urllib.error.HTTPError as exc:
|
|
131
|
+
if exc.code == 404:
|
|
132
|
+
return None
|
|
133
|
+
raise
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _kv_set(key: str, value: dict) -> None:
|
|
137
|
+
req = urllib.request.Request(
|
|
138
|
+
f"{_KV_BASE}/v1/kv/{key}",
|
|
139
|
+
data=json.dumps(value).encode(),
|
|
140
|
+
headers={
|
|
141
|
+
"Authorization": f"Bearer {_KV_TOKEN}",
|
|
142
|
+
"Content-Type": "application/json",
|
|
143
|
+
},
|
|
144
|
+
method="PUT",
|
|
145
|
+
)
|
|
146
|
+
with urllib.request.urlopen(req) as r:
|
|
147
|
+
r.read()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _state_file_path() -> str:
|
|
151
|
+
"""Derive a persistent storage path from WORKSPACE_BASE (file fallback only)."""
|
|
119
152
|
workspace_base = os.environ.get("WORKSPACE_BASE", "")
|
|
120
153
|
event_payload = json.loads(os.environ.get("AUTOMATION_EVENT_PAYLOAD", "{}"))
|
|
121
154
|
automation_id = event_payload.get("automation_id", "default")
|
|
@@ -137,25 +170,42 @@ def _default_since() -> str:
|
|
|
137
170
|
).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
138
171
|
|
|
139
172
|
|
|
140
|
-
def
|
|
173
|
+
def _default_state() -> dict:
|
|
174
|
+
return {
|
|
175
|
+
"version": 1,
|
|
176
|
+
"repo": REPO,
|
|
177
|
+
"last_poll": _default_since(),
|
|
178
|
+
"conversations": {},
|
|
179
|
+
"processed_comment_ids": [],
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def load_state() -> dict:
|
|
184
|
+
if _kv_available():
|
|
185
|
+
data = _kv_get(_STATE_KEY)
|
|
186
|
+
if data is not None:
|
|
187
|
+
print("State loaded from KV store")
|
|
188
|
+
return data
|
|
189
|
+
return _default_state()
|
|
190
|
+
path = _state_file_path()
|
|
141
191
|
if os.path.exists(path):
|
|
142
192
|
try:
|
|
143
193
|
with open(path) as f:
|
|
144
194
|
return json.load(f)
|
|
145
195
|
except (json.JSONDecodeError, OSError) as exc:
|
|
146
196
|
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
|
-
}
|
|
197
|
+
return _default_state()
|
|
154
198
|
|
|
155
199
|
|
|
156
|
-
def save_state(
|
|
200
|
+
def save_state(state: dict) -> None:
|
|
201
|
+
if _kv_available():
|
|
202
|
+
_kv_set(_STATE_KEY, state)
|
|
203
|
+
print("State saved to KV store")
|
|
204
|
+
return
|
|
205
|
+
path = _state_file_path()
|
|
157
206
|
with open(path, "w") as f:
|
|
158
207
|
json.dump(state, f, indent=2)
|
|
208
|
+
print(f"State saved to {path}")
|
|
159
209
|
|
|
160
210
|
|
|
161
211
|
# ── GitHub API helpers ─────────────────────────────────────────────────────────
|
|
@@ -797,8 +847,7 @@ def _check_conversation_completion(
|
|
|
797
847
|
|
|
798
848
|
def main() -> str | None:
|
|
799
849
|
"""Run one polling cycle. Returns the last conversation ID created, if any."""
|
|
800
|
-
|
|
801
|
-
state = load_state(state_path)
|
|
850
|
+
state = load_state()
|
|
802
851
|
|
|
803
852
|
agent_url = os.environ.get("AGENT_SERVER_URL", "").rstrip("/")
|
|
804
853
|
api_key = _get_env_key()
|
|
@@ -899,8 +948,7 @@ def main() -> str | None:
|
|
|
899
948
|
state["processed_comment_ids"] = trimmed
|
|
900
949
|
state["conversations"] = conversations
|
|
901
950
|
|
|
902
|
-
save_state(
|
|
903
|
-
print(f"State saved → {state_path}")
|
|
951
|
+
save_state(state)
|
|
904
952
|
return last_conversation_id
|
|
905
953
|
|
|
906
954
|
|
|
@@ -29,10 +29,20 @@ def _make_comment(body="hello @openhands", login="octocat", user_type="User"):
|
|
|
29
29
|
|
|
30
30
|
# ── State file tests ───────────────────────────────────────────────────────────
|
|
31
31
|
|
|
32
|
+
def _no_kv(path):
|
|
33
|
+
"""Return a pair of patches: KV disabled, file path pinned to *path*."""
|
|
34
|
+
return (
|
|
35
|
+
patch("main._kv_available", return_value=False),
|
|
36
|
+
patch("main._state_file_path", return_value=path),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
32
40
|
class TestLoadState(unittest.TestCase):
|
|
33
41
|
|
|
34
42
|
def test_missing_file_returns_default(self):
|
|
35
|
-
|
|
43
|
+
with patch("main._kv_available", return_value=False), \
|
|
44
|
+
patch("main._state_file_path", return_value="/nonexistent/path/state.json"):
|
|
45
|
+
state = main.load_state()
|
|
36
46
|
self.assertIn("conversations", state)
|
|
37
47
|
self.assertIn("processed_comment_ids", state)
|
|
38
48
|
self.assertEqual(state["version"], 1)
|
|
@@ -42,7 +52,9 @@ class TestLoadState(unittest.TestCase):
|
|
|
42
52
|
json.dump({"version": 1, "custom": "value", "conversations": {}}, f)
|
|
43
53
|
path = f.name
|
|
44
54
|
try:
|
|
45
|
-
|
|
55
|
+
with patch("main._kv_available", return_value=False), \
|
|
56
|
+
patch("main._state_file_path", return_value=path):
|
|
57
|
+
state = main.load_state()
|
|
46
58
|
self.assertEqual(state["custom"], "value")
|
|
47
59
|
finally:
|
|
48
60
|
os.unlink(path)
|
|
@@ -52,7 +64,9 @@ class TestLoadState(unittest.TestCase):
|
|
|
52
64
|
f.write("{this is not valid json!!!}")
|
|
53
65
|
path = f.name
|
|
54
66
|
try:
|
|
55
|
-
|
|
67
|
+
with patch("main._kv_available", return_value=False), \
|
|
68
|
+
patch("main._state_file_path", return_value=path):
|
|
69
|
+
state = main.load_state()
|
|
56
70
|
# Should return the default state rather than raising.
|
|
57
71
|
self.assertIn("conversations", state)
|
|
58
72
|
self.assertEqual(state["version"], 1)
|
|
@@ -64,7 +78,9 @@ class TestLoadState(unittest.TestCase):
|
|
|
64
78
|
f.write("")
|
|
65
79
|
path = f.name
|
|
66
80
|
try:
|
|
67
|
-
|
|
81
|
+
with patch("main._kv_available", return_value=False), \
|
|
82
|
+
patch("main._state_file_path", return_value=path):
|
|
83
|
+
state = main.load_state()
|
|
68
84
|
self.assertIn("conversations", state)
|
|
69
85
|
finally:
|
|
70
86
|
os.unlink(path)
|
|
@@ -81,14 +97,55 @@ class TestSaveAndLoadRoundtrip(unittest.TestCase):
|
|
|
81
97
|
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f:
|
|
82
98
|
path = f.name
|
|
83
99
|
try:
|
|
84
|
-
main.
|
|
85
|
-
|
|
100
|
+
with patch("main._kv_available", return_value=False), \
|
|
101
|
+
patch("main._state_file_path", return_value=path):
|
|
102
|
+
main.save_state(data)
|
|
103
|
+
loaded = main.load_state()
|
|
86
104
|
self.assertEqual(loaded["conversations"]["42"]["conversation_id"], "abc")
|
|
87
105
|
self.assertEqual(loaded["processed_comment_ids"], [1, 2, 3])
|
|
88
106
|
finally:
|
|
89
107
|
os.unlink(path)
|
|
90
108
|
|
|
91
109
|
|
|
110
|
+
class TestKVState(unittest.TestCase):
|
|
111
|
+
|
|
112
|
+
def test_load_reads_value_from_kv(self):
|
|
113
|
+
kv_data = {"version": 1, "conversations": {"7": {"conversation_id": "kv-c"}},
|
|
114
|
+
"processed_comment_ids": []}
|
|
115
|
+
with patch("main._kv_available", return_value=True), \
|
|
116
|
+
patch("main._kv_get", return_value=kv_data) as mock_get:
|
|
117
|
+
state = main.load_state()
|
|
118
|
+
self.assertEqual(state["conversations"]["7"]["conversation_id"], "kv-c")
|
|
119
|
+
mock_get.assert_called_once_with("state")
|
|
120
|
+
|
|
121
|
+
def test_load_returns_default_on_kv_miss(self):
|
|
122
|
+
with patch("main._kv_available", return_value=True), \
|
|
123
|
+
patch("main._kv_get", return_value=None):
|
|
124
|
+
state = main.load_state()
|
|
125
|
+
self.assertIn("conversations", state)
|
|
126
|
+
self.assertEqual(state["version"], 1)
|
|
127
|
+
|
|
128
|
+
def test_save_writes_to_kv(self):
|
|
129
|
+
data = {"version": 1, "conversations": {}, "processed_comment_ids": []}
|
|
130
|
+
with patch("main._kv_available", return_value=True), \
|
|
131
|
+
patch("main._kv_set") as mock_set:
|
|
132
|
+
main.save_state(data)
|
|
133
|
+
mock_set.assert_called_once_with("state", data)
|
|
134
|
+
|
|
135
|
+
def test_kv_error_propagates_on_load(self):
|
|
136
|
+
with patch("main._kv_available", return_value=True), \
|
|
137
|
+
patch("main._kv_get", side_effect=RuntimeError("network error")):
|
|
138
|
+
with self.assertRaises(RuntimeError):
|
|
139
|
+
main.load_state()
|
|
140
|
+
|
|
141
|
+
def test_kv_error_propagates_on_save(self):
|
|
142
|
+
data = {"version": 1, "conversations": {}, "processed_comment_ids": []}
|
|
143
|
+
with patch("main._kv_available", return_value=True), \
|
|
144
|
+
patch("main._kv_set", side_effect=RuntimeError("network error")):
|
|
145
|
+
with self.assertRaises(RuntimeError):
|
|
146
|
+
main.save_state(data)
|
|
147
|
+
|
|
148
|
+
|
|
92
149
|
# ── Bot detection tests ────────────────────────────────────────────────────────
|
|
93
150
|
|
|
94
151
|
class TestIsBotComment(unittest.TestCase):
|
|
@@ -214,8 +271,10 @@ class TestProcessedIdDeduplication(unittest.TestCase):
|
|
|
214
271
|
try:
|
|
215
272
|
state = {"version": 1, "conversations": {},
|
|
216
273
|
"processed_comment_ids": [101, 202, 303]}
|
|
217
|
-
main.
|
|
218
|
-
|
|
274
|
+
with patch("main._kv_available", return_value=False), \
|
|
275
|
+
patch("main._state_file_path", return_value=path):
|
|
276
|
+
main.save_state(state)
|
|
277
|
+
loaded = main.load_state()
|
|
219
278
|
self.assertIn(101, loaded["processed_comment_ids"])
|
|
220
279
|
self.assertIn(303, loaded["processed_comment_ids"])
|
|
221
280
|
self.assertNotIn(404, loaded["processed_comment_ids"])
|