android-agent-harness 0.27.6__py3-none-any.whl
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.
- agents/mcp/zoho_sprints/README.md +26 -0
- agents/mcp/zoho_sprints/_client.py +304 -0
- agents/mcp/zoho_sprints/_config.py +105 -0
- agents/mcp/zoho_sprints/_dns.py +92 -0
- agents/mcp/zoho_sprints/_formatter.py +132 -0
- agents/mcp/zoho_sprints/server.py +353 -0
- agents/scripts/_adb_core.py +1346 -0
- agents/scripts/_adb_core_selftest.py +251 -0
- agents/scripts/_apk_freshness.py +272 -0
- agents/scripts/_apk_freshness_selftest.py +190 -0
- agents/scripts/_baseline_selftest.py +169 -0
- agents/scripts/_env_codes.py +288 -0
- agents/scripts/_env_codes_selftest.py +229 -0
- agents/scripts/_final_verdict_selftest.py +302 -0
- agents/scripts/_gate_results.py +85 -0
- agents/scripts/_graph_core.py +1308 -0
- agents/scripts/_graph_selftest.py +400 -0
- agents/scripts/_hook_selftest.py +2981 -0
- agents/scripts/_hook_state.py +837 -0
- agents/scripts/_live_process.py +130 -0
- agents/scripts/_modules.py +37 -0
- agents/scripts/_product.py +41 -0
- agents/scripts/_repo_files.py +291 -0
- agents/scripts/_risk_and_impact_selftest.py +325 -0
- agents/scripts/_round_cap_selftest.py +141 -0
- agents/scripts/_security_selftest.py +484 -0
- agents/scripts/_variants.py +96 -0
- agents/scripts/approve_risk.py +100 -0
- agents/scripts/baseline_capture.py +203 -0
- agents/scripts/capture_screen.py +103 -0
- agents/scripts/cc_pre_tool_safety.py +84 -0
- agents/scripts/check_kit_update.py +225 -0
- agents/scripts/check_strings.py +491 -0
- agents/scripts/copilot_pre_tool_safety.py +99 -0
- agents/scripts/doctor/__init__.py +20 -0
- agents/scripts/doctor/engine.py +829 -0
- agents/scripts/doctor/models.py +125 -0
- agents/scripts/ensure_hook_selftest.py +185 -0
- agents/scripts/fast_kt_lint.py +449 -0
- agents/scripts/final_verdict.py +365 -0
- agents/scripts/gradle_error_parser.py +88 -0
- agents/scripts/harness_doctor.py +113 -0
- agents/scripts/impact_analyzer.py +343 -0
- agents/scripts/install_or_update.py +667 -0
- agents/scripts/install_tool_adapters.py +788 -0
- agents/scripts/install_zoho_mcp.py +170 -0
- agents/scripts/logcat_doctor.py +147 -0
- agents/scripts/new_feature_scaffold.py +378 -0
- agents/scripts/perf_guard.py +178 -0
- agents/scripts/pm_github.py +256 -0
- agents/scripts/pm_policy.py +297 -0
- agents/scripts/policy_vocab.py +226 -0
- agents/scripts/pre_commit_gate.py +116 -0
- agents/scripts/pre_invocation_reminder.py +151 -0
- agents/scripts/pre_tool_safety.py +1078 -0
- agents/scripts/preflight_check.py +86 -0
- agents/scripts/project_graph.py +235 -0
- agents/scripts/review_package.py +302 -0
- agents/scripts/risk_tier.py +356 -0
- agents/scripts/room_guard.py +259 -0
- agents/scripts/run_device.py +193 -0
- agents/scripts/run_gradle_task.py +263 -0
- agents/scripts/run_tests_gate.py +141 -0
- agents/scripts/setup_wizard.py +217 -0
- agents/scripts/wizard/__init__.py +2 -0
- agents/scripts/wizard/discovery.py +536 -0
- agents/scripts/wizard/i18n.py +281 -0
- agents/scripts/wizard/questions.py +875 -0
- agents/tool-adapters/README.md +32 -0
- android_agent_harness-0.27.6.dist-info/METADATA +163 -0
- android_agent_harness-0.27.6.dist-info/RECORD +76 -0
- android_agent_harness-0.27.6.dist-info/WHEEL +5 -0
- android_agent_harness-0.27.6.dist-info/entry_points.txt +2 -0
- android_agent_harness-0.27.6.dist-info/licenses/LICENSE +21 -0
- android_agent_harness-0.27.6.dist-info/top_level.txt +2 -0
- harness_cli.py +760 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Zoho Sprints MCP
|
|
2
|
+
|
|
3
|
+
Stdio MCP server. Same tools and mutate rules as the original engine (`update zoho`, Arabic templates, `In progress` / `Ready To ReTest`). Playbook: `../../workflows/zoho-sprints.md`.
|
|
4
|
+
|
|
5
|
+
The kit ships **code + empty workflow defaults**, not tokens.
|
|
6
|
+
|
|
7
|
+
Credentials stay on the developer's machine:
|
|
8
|
+
|
|
9
|
+
1. Copy `config.example.json` to `~/.android-harness/zoho_sprints.json`
|
|
10
|
+
2. Fill `client_id`, `client_secret`, `refresh_token`, `team_id`, `project_id`
|
|
11
|
+
3. Never put that file in the app repo or in this kit
|
|
12
|
+
|
|
13
|
+
To get Zoho API credentials:
|
|
14
|
+
|
|
15
|
+
1. Go to https://api-console.zoho.com/ and create a **Self Client**
|
|
16
|
+
2. Generate a grant token with scope: `ZohoSprints.sprints.ALL,ZohoSprints.items.ALL,ZohoSprints.team.READ`
|
|
17
|
+
3. Exchange the grant token for a refresh token using the Zoho OAuth endpoint
|
|
18
|
+
4. Find your `team_id` and `project_id` from your Zoho Sprints URL: `https://sprints.zoho.com/team/<team_id>/project/<project_id>/...`
|
|
19
|
+
|
|
20
|
+
`workflow_defaults.json` has assignee / item-prefix / title-strip settings. Empty values are resolved at runtime by the server. Fill them during install (I.16) or configure later.
|
|
21
|
+
|
|
22
|
+
Setup (`I.16`) wires `.agents/mcp_config.json` (and Cursor `.cursor/mcp.json` when Cursor was selected) to this `server.py` and sets `ZOHO_SPRINTS_CONFIG` to an **existing** user file if one is already on the PC. It does not copy tokens.
|
|
23
|
+
|
|
24
|
+
If a config already exists at `~/.gemini/antigravity/scratch/zoho_sprints/zoho_config.json`, setup points at that path and leaves the file untouched.
|
|
25
|
+
|
|
26
|
+
Zoho Desk is not included.
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""Zoho Sprints API client, OAuth authentication, caching and endpoints."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import time
|
|
8
|
+
import urllib.error
|
|
9
|
+
import urllib.parse
|
|
10
|
+
import urllib.request
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from _formatter import format_zoho_html
|
|
15
|
+
|
|
16
|
+
HERE = Path(__file__).resolve().parent
|
|
17
|
+
|
|
18
|
+
WORKFLOW_KEYS = (
|
|
19
|
+
"team_id",
|
|
20
|
+
"project_id",
|
|
21
|
+
"default_user_id",
|
|
22
|
+
"item_prefix",
|
|
23
|
+
"title_strip_suffixes",
|
|
24
|
+
"fallback_item_type_id",
|
|
25
|
+
"fallback_priority_id",
|
|
26
|
+
"fallback_sprint_id",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _workflow_defaults() -> dict:
|
|
31
|
+
path = HERE / "workflow_defaults.json"
|
|
32
|
+
if not path.is_file():
|
|
33
|
+
return {}
|
|
34
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
35
|
+
return data if isinstance(data, dict) else {}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ZohoSprintsAPI:
|
|
39
|
+
METADATA_CACHE_TTL = 300
|
|
40
|
+
|
|
41
|
+
def __init__(self, config_path: str):
|
|
42
|
+
self.config_path = config_path
|
|
43
|
+
self._meta_cache: dict[str, tuple[float, dict]] = {}
|
|
44
|
+
self.base_url = "https://sprintsapi.zoho.com/zsapi"
|
|
45
|
+
self.load_config()
|
|
46
|
+
|
|
47
|
+
def load_config(self) -> None:
|
|
48
|
+
with open(self.config_path, encoding="utf-8") as f:
|
|
49
|
+
user = json.load(f)
|
|
50
|
+
if not isinstance(user, dict):
|
|
51
|
+
raise RuntimeError("Zoho Sprints config must be a JSON object.")
|
|
52
|
+
defaults = _workflow_defaults()
|
|
53
|
+
self.config = dict(user)
|
|
54
|
+
for key in WORKFLOW_KEYS:
|
|
55
|
+
if key not in user or user.get(key) in (None, "", []):
|
|
56
|
+
if key in defaults:
|
|
57
|
+
self.config[key] = defaults[key]
|
|
58
|
+
self.access_token = user.get("access_token")
|
|
59
|
+
self.refresh_token = user.get("refresh_token")
|
|
60
|
+
self.client_id = user.get("client_id")
|
|
61
|
+
self.client_secret = user.get("client_secret")
|
|
62
|
+
self.auth_domain = user.get("auth_domain") or "https://accounts.zoho.com"
|
|
63
|
+
self.team_id = str(self.config.get("team_id") or "").strip()
|
|
64
|
+
self.project_id = str(self.config.get("project_id") or "").strip()
|
|
65
|
+
self.default_user_id = str(self.config.get("default_user_id") or "").strip()
|
|
66
|
+
self.item_prefix = str(self.config.get("item_prefix") or "").strip()
|
|
67
|
+
self.fallback_item_type_id = str(self.config.get("fallback_item_type_id") or "").strip()
|
|
68
|
+
self.fallback_priority_id = str(self.config.get("fallback_priority_id") or "").strip()
|
|
69
|
+
self.fallback_sprint_id = str(self.config.get("fallback_sprint_id") or "").strip()
|
|
70
|
+
suffixes = self.config.get("title_strip_suffixes") or []
|
|
71
|
+
self.title_strip_suffixes = [str(s) for s in suffixes if str(s).strip()]
|
|
72
|
+
if not self.team_id or not self.project_id:
|
|
73
|
+
raise RuntimeError("Zoho Sprints config needs team_id and project_id.")
|
|
74
|
+
|
|
75
|
+
def save_config(self) -> None:
|
|
76
|
+
path = Path(self.config_path)
|
|
77
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
try:
|
|
79
|
+
if hasattr(os, "O_CREAT") and os.name != "nt":
|
|
80
|
+
fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
81
|
+
with open(fd, "w", encoding="utf-8") as f:
|
|
82
|
+
json.dump(self.config, f, indent=2)
|
|
83
|
+
else:
|
|
84
|
+
with open(self.config_path, "w", encoding="utf-8") as f:
|
|
85
|
+
json.dump(self.config, f, indent=2)
|
|
86
|
+
except Exception:
|
|
87
|
+
with open(self.config_path, "w", encoding="utf-8") as f:
|
|
88
|
+
json.dump(self.config, f, indent=2)
|
|
89
|
+
|
|
90
|
+
def sanitize_task_name(self, name: str) -> str:
|
|
91
|
+
cleaned = name.strip()
|
|
92
|
+
for suffix in self.title_strip_suffixes:
|
|
93
|
+
cleaned = re.sub(
|
|
94
|
+
r"\s*[-–—]\s*" + re.escape(suffix) + r"\s*$",
|
|
95
|
+
"",
|
|
96
|
+
cleaned,
|
|
97
|
+
flags=re.IGNORECASE,
|
|
98
|
+
).strip()
|
|
99
|
+
return cleaned
|
|
100
|
+
|
|
101
|
+
def refresh_token_if_needed(self) -> None:
|
|
102
|
+
url = f"{self.auth_domain}/oauth/v2/token"
|
|
103
|
+
params = {
|
|
104
|
+
"refresh_token": self.refresh_token,
|
|
105
|
+
"client_id": self.client_id,
|
|
106
|
+
"client_secret": self.client_secret,
|
|
107
|
+
"grant_type": "refresh_token",
|
|
108
|
+
}
|
|
109
|
+
encoded = urllib.parse.urlencode(params).encode("utf-8")
|
|
110
|
+
req = urllib.request.Request(url, data=encoded, method="POST")
|
|
111
|
+
try:
|
|
112
|
+
with urllib.request.urlopen(req, timeout=30.0) as resp:
|
|
113
|
+
res = json.loads(resp.read().decode("utf-8"))
|
|
114
|
+
except Exception as e:
|
|
115
|
+
raise RuntimeError(f"Zoho OAuth token refresh network error: {e}") from e
|
|
116
|
+
if "access_token" in res:
|
|
117
|
+
self.access_token = res["access_token"]
|
|
118
|
+
self.config["access_token"] = self.access_token
|
|
119
|
+
self.save_config()
|
|
120
|
+
else:
|
|
121
|
+
err = res.get("error", "unknown_oauth_error")
|
|
122
|
+
raise RuntimeError(f"Zoho OAuth token refresh failed: {err}. Re-authenticate in config.")
|
|
123
|
+
|
|
124
|
+
def _request(self, path: str, method: str = "GET", params: dict | None = None, data: dict | None = None) -> Any:
|
|
125
|
+
url = f"{self.base_url}{path}"
|
|
126
|
+
if params:
|
|
127
|
+
url += "?" + urllib.parse.urlencode(params)
|
|
128
|
+
headers = {"Authorization": f"Zoho-oauthtoken {self.access_token}"}
|
|
129
|
+
encoded_data = None
|
|
130
|
+
if data is not None:
|
|
131
|
+
encoded_data = urllib.parse.urlencode(data).encode("utf-8")
|
|
132
|
+
headers["Content-Type"] = "application/x-www-form-urlencoded"
|
|
133
|
+
|
|
134
|
+
max_retries = 3
|
|
135
|
+
for attempt in range(max_retries):
|
|
136
|
+
req = urllib.request.Request(url, data=encoded_data, headers=headers, method=method)
|
|
137
|
+
try:
|
|
138
|
+
with urllib.request.urlopen(req, timeout=30.0) as resp:
|
|
139
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
140
|
+
except urllib.error.HTTPError as exc:
|
|
141
|
+
err_msg = exc.read().decode("utf-8", errors="replace")
|
|
142
|
+
if exc.code in (429, 502, 503, 504) and attempt < max_retries - 1:
|
|
143
|
+
time.sleep(1.0 * (attempt + 1))
|
|
144
|
+
continue
|
|
145
|
+
if exc.code == 401 or "Invalid oauthToken" in err_msg or "invalid_token" in err_msg:
|
|
146
|
+
self.refresh_token_if_needed()
|
|
147
|
+
headers["Authorization"] = f"Zoho-oauthtoken {self.access_token}"
|
|
148
|
+
req2 = urllib.request.Request(url, data=encoded_data, headers=headers, method=method)
|
|
149
|
+
try:
|
|
150
|
+
with urllib.request.urlopen(req2, timeout=30.0) as resp2:
|
|
151
|
+
return json.loads(resp2.read().decode("utf-8"))
|
|
152
|
+
except Exception as retry_exc:
|
|
153
|
+
raise RuntimeError(f"Zoho request failed after token refresh: {retry_exc}") from retry_exc
|
|
154
|
+
raise RuntimeError(f"HTTP Error {exc.code}: {err_msg}") from exc
|
|
155
|
+
except Exception as exc:
|
|
156
|
+
if attempt < max_retries - 1 and isinstance(exc, (urllib.error.URLError, TimeoutError)):
|
|
157
|
+
time.sleep(1.0 * (attempt + 1))
|
|
158
|
+
continue
|
|
159
|
+
raise RuntimeError(f"Zoho request failed ({method} {path}): {exc}") from exc
|
|
160
|
+
|
|
161
|
+
def get_sprints(self) -> dict:
|
|
162
|
+
return self._request(
|
|
163
|
+
f"/team/{self.team_id}/projects/{self.project_id}/sprints/",
|
|
164
|
+
params={"action": "data", "type": "[1,2,3,4]"},
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
def get_active_sprint_id(self) -> str:
|
|
168
|
+
ids = self.get_sprints().get("sprintIds") or []
|
|
169
|
+
if ids:
|
|
170
|
+
return ids[0]
|
|
171
|
+
if self.fallback_sprint_id:
|
|
172
|
+
return self.fallback_sprint_id
|
|
173
|
+
raise RuntimeError("No sprints found for this Zoho project.")
|
|
174
|
+
|
|
175
|
+
def _cached_request(self, cache_key: str, path: str, params: dict | None = None) -> dict:
|
|
176
|
+
cached = self._meta_cache.get(cache_key)
|
|
177
|
+
if cached:
|
|
178
|
+
ts, data = cached
|
|
179
|
+
if time.time() - ts < self.METADATA_CACHE_TTL:
|
|
180
|
+
return data
|
|
181
|
+
result = self._request(path, params=params)
|
|
182
|
+
self._meta_cache[cache_key] = (time.time(), result)
|
|
183
|
+
return result
|
|
184
|
+
|
|
185
|
+
def get_item_types(self) -> dict:
|
|
186
|
+
return self._cached_request(
|
|
187
|
+
"item_types",
|
|
188
|
+
f"/team/{self.team_id}/projects/{self.project_id}/itemtype/",
|
|
189
|
+
params={"action": "data"},
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
def get_statuses(self) -> dict:
|
|
193
|
+
return self._cached_request(
|
|
194
|
+
"statuses",
|
|
195
|
+
f"/team/{self.team_id}/projects/{self.project_id}/itemstatus/",
|
|
196
|
+
params={"action": "data"},
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
def get_priorities(self) -> dict:
|
|
200
|
+
return self._cached_request(
|
|
201
|
+
"priorities",
|
|
202
|
+
f"/team/{self.team_id}/projects/{self.project_id}/priority/",
|
|
203
|
+
params={"action": "data"},
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
def list_items(self, sprint_id: str) -> dict:
|
|
207
|
+
return self._request(
|
|
208
|
+
f"/team/{self.team_id}/projects/{self.project_id}/sprints/{sprint_id}/item/",
|
|
209
|
+
params={"action": "sprintitems", "subitem": "true"},
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
def resolve_item(self, item_input: str, sprint_id: str | None = None) -> tuple[str, str]:
|
|
213
|
+
raw_input = str(item_input).strip()
|
|
214
|
+
match = re.search(r"(\d+)", raw_input)
|
|
215
|
+
target_num = match.group(1) if match else raw_input
|
|
216
|
+
sprint_ids = self.get_sprints().get("sprintIds") or []
|
|
217
|
+
if len(raw_input) >= 15 and raw_input.isdigit():
|
|
218
|
+
if sprint_id:
|
|
219
|
+
return sprint_id, raw_input
|
|
220
|
+
for sid in sprint_ids:
|
|
221
|
+
try:
|
|
222
|
+
res = self.get_item_details(sid, raw_input)
|
|
223
|
+
if res.get("status") == "success":
|
|
224
|
+
return sid, raw_input
|
|
225
|
+
except Exception:
|
|
226
|
+
continue
|
|
227
|
+
search_sprints = [sprint_id] if sprint_id else sprint_ids
|
|
228
|
+
prefix = self.item_prefix
|
|
229
|
+
for sid in search_sprints:
|
|
230
|
+
items_data = self.list_items(sid)
|
|
231
|
+
item_prop = items_data.get("item_prop") or {}
|
|
232
|
+
item_no_idx = item_prop.get("itemNo")
|
|
233
|
+
item_jobj = items_data.get("itemJObj") or {}
|
|
234
|
+
for iid, row in item_jobj.items():
|
|
235
|
+
if iid == raw_input:
|
|
236
|
+
return sid, iid
|
|
237
|
+
if item_no_idx is not None and len(row) > item_no_idx:
|
|
238
|
+
item_no = str(row[item_no_idx])
|
|
239
|
+
labels = {item_no, f"I{item_no}", target_num}
|
|
240
|
+
if prefix:
|
|
241
|
+
labels.add(f"{prefix}{item_no}")
|
|
242
|
+
if raw_input in labels or item_no == target_num:
|
|
243
|
+
return sid, iid
|
|
244
|
+
raise RuntimeError(
|
|
245
|
+
f"Item '{raw_input}' not found in {len(search_sprints)} sprint(s). "
|
|
246
|
+
"Verify the task id or pass sprint_id."
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
def get_item_details(self, sprint_id: str | None = None, item_id: str | None = None) -> dict:
|
|
250
|
+
if not sprint_id or (item_id and (not str(item_id).isdigit() or len(str(item_id)) < 15)):
|
|
251
|
+
sprint_id, item_id = self.resolve_item(item_id or sprint_id or "", sprint_id)
|
|
252
|
+
return self._request(
|
|
253
|
+
f"/team/{self.team_id}/projects/{self.project_id}/sprints/{sprint_id}/item/{item_id}/",
|
|
254
|
+
params={"action": "details"},
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
def create_item(
|
|
258
|
+
self,
|
|
259
|
+
sprint_id: str,
|
|
260
|
+
name: str,
|
|
261
|
+
item_type_id: str,
|
|
262
|
+
priority_id: str,
|
|
263
|
+
description: str = "",
|
|
264
|
+
points: str = "0",
|
|
265
|
+
parent_item_id: str | None = None,
|
|
266
|
+
users: list | None = None,
|
|
267
|
+
) -> dict:
|
|
268
|
+
data: dict[str, str] = {
|
|
269
|
+
"name": self.sanitize_task_name(name),
|
|
270
|
+
"projitemtypeid": item_type_id,
|
|
271
|
+
"projpriorityid": priority_id,
|
|
272
|
+
"description": format_zoho_html(description),
|
|
273
|
+
}
|
|
274
|
+
assigned = users if users is not None else ([self.default_user_id] if self.default_user_id else None)
|
|
275
|
+
if assigned:
|
|
276
|
+
data["users"] = json.dumps(assigned)
|
|
277
|
+
if points and points != "0":
|
|
278
|
+
data["points"] = points
|
|
279
|
+
if parent_item_id:
|
|
280
|
+
path = f"/team/{self.team_id}/projects/{self.project_id}/sprints/{sprint_id}/item/{parent_item_id}/subitem/"
|
|
281
|
+
else:
|
|
282
|
+
path = f"/team/{self.team_id}/projects/{self.project_id}/sprints/{sprint_id}/item/"
|
|
283
|
+
return self._request(path, method="POST", data=data)
|
|
284
|
+
|
|
285
|
+
def update_item_status(self, sprint_id: str, item_id: str, status_id: str) -> dict:
|
|
286
|
+
return self._request(
|
|
287
|
+
f"/team/{self.team_id}/projects/{self.project_id}/sprints/{sprint_id}/item/{item_id}/",
|
|
288
|
+
method="POST",
|
|
289
|
+
data={"statusid": status_id},
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
def update_item_description(self, sprint_id: str, item_id: str, description: str) -> dict:
|
|
293
|
+
return self._request(
|
|
294
|
+
f"/team/{self.team_id}/projects/{self.project_id}/sprints/{sprint_id}/item/{item_id}/",
|
|
295
|
+
method="POST",
|
|
296
|
+
data={"description": format_zoho_html(description)},
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
def add_comment(self, sprint_id: str, item_id: str, content: str) -> dict:
|
|
300
|
+
return self._request(
|
|
301
|
+
f"/team/{self.team_id}/projects/{self.project_id}/sprints/{sprint_id}/item/{item_id}/notes/",
|
|
302
|
+
method="POST",
|
|
303
|
+
data={"name": format_zoho_html(content)},
|
|
304
|
+
)
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Locate the user-level Zoho Sprints config. Never copy or print secrets."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
SECRET_KEYS = frozenset(
|
|
8
|
+
{
|
|
9
|
+
"access_token",
|
|
10
|
+
"refresh_token",
|
|
11
|
+
"client_secret",
|
|
12
|
+
"client_id",
|
|
13
|
+
"api_key",
|
|
14
|
+
"token",
|
|
15
|
+
"password",
|
|
16
|
+
}
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
ENV_CONFIG = "ZOHO_SPRINTS_CONFIG"
|
|
20
|
+
SERVER_NAME = "zoho-sprints"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def home_config_path() -> Path:
|
|
24
|
+
return Path.home() / ".android-harness" / "zoho_sprints.json"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def example_config_path() -> Path:
|
|
28
|
+
return Path.home() / ".android-harness" / "zoho_sprints.example.json"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def scratch_config_path() -> Path:
|
|
32
|
+
return (
|
|
33
|
+
Path.home()
|
|
34
|
+
/ ".gemini"
|
|
35
|
+
/ "antigravity"
|
|
36
|
+
/ "scratch"
|
|
37
|
+
/ "zoho_sprints"
|
|
38
|
+
/ "zoho_config.json"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def candidate_config_paths() -> list[Path]:
|
|
43
|
+
paths: list[Path] = []
|
|
44
|
+
env = (os.environ.get(ENV_CONFIG) or "").strip()
|
|
45
|
+
if env:
|
|
46
|
+
paths.append(Path(env).expanduser())
|
|
47
|
+
paths.append(home_config_path())
|
|
48
|
+
paths.append(scratch_config_path())
|
|
49
|
+
seen: set[Path] = set()
|
|
50
|
+
out: list[Path] = []
|
|
51
|
+
for path in paths:
|
|
52
|
+
try:
|
|
53
|
+
resolved = path.resolve()
|
|
54
|
+
except OSError:
|
|
55
|
+
continue
|
|
56
|
+
if resolved in seen:
|
|
57
|
+
continue
|
|
58
|
+
seen.add(resolved)
|
|
59
|
+
out.append(path)
|
|
60
|
+
return out
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def resolve_config_path() -> Path | None:
|
|
64
|
+
for path in candidate_config_paths():
|
|
65
|
+
if path.is_file() and path.stat().st_size > 0:
|
|
66
|
+
return path
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def kit_server_path(agents_root: Path) -> Path:
|
|
71
|
+
return agents_root / "mcp" / "zoho_sprints" / "server.py"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def json_contains_secret_keys(value: object) -> bool:
|
|
75
|
+
if isinstance(value, dict):
|
|
76
|
+
for key, child in value.items():
|
|
77
|
+
if str(key).lower() in SECRET_KEYS:
|
|
78
|
+
return True
|
|
79
|
+
if json_contains_secret_keys(child):
|
|
80
|
+
return True
|
|
81
|
+
return False
|
|
82
|
+
if isinstance(value, list):
|
|
83
|
+
return any(json_contains_secret_keys(item) for item in value)
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def text_contains_secret_values(text: str, secret_file: Path) -> bool:
|
|
88
|
+
"""True if any nonempty string value from secret_file appears in text."""
|
|
89
|
+
import json
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
data = json.loads(secret_file.read_text(encoding="utf-8"))
|
|
93
|
+
except (OSError, json.JSONDecodeError):
|
|
94
|
+
return False
|
|
95
|
+
if not isinstance(data, dict):
|
|
96
|
+
return False
|
|
97
|
+
for key, raw in data.items():
|
|
98
|
+
if str(key).lower() not in SECRET_KEYS:
|
|
99
|
+
continue
|
|
100
|
+
if not isinstance(raw, str):
|
|
101
|
+
continue
|
|
102
|
+
token = raw.strip()
|
|
103
|
+
if len(token) >= 8 and token in text:
|
|
104
|
+
return True
|
|
105
|
+
return False
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""DNS fallback queries using direct UDP packets to public resolvers."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import secrets
|
|
5
|
+
import socket
|
|
6
|
+
import struct
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _dns_query_fallback(
|
|
11
|
+
hostname: str,
|
|
12
|
+
dns_servers: tuple[str, ...] = ("8.8.8.8", "1.1.1.1"),
|
|
13
|
+
) -> Optional[str]:
|
|
14
|
+
tx_id = secrets.randbelow(65535) + 1
|
|
15
|
+
packet = struct.pack(">HHHHHH", tx_id, 0x0100, 1, 0, 0, 0)
|
|
16
|
+
for part in hostname.split("."):
|
|
17
|
+
packet += struct.pack("B", len(part)) + part.encode("ascii")
|
|
18
|
+
packet += b"\x00\x00\x01\x00\x01"
|
|
19
|
+
for dns_ip in dns_servers:
|
|
20
|
+
sock = None
|
|
21
|
+
data = b""
|
|
22
|
+
try:
|
|
23
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
24
|
+
sock.settimeout(2.0)
|
|
25
|
+
sock.sendto(packet, (dns_ip, 53))
|
|
26
|
+
data, _ = sock.recvfrom(1024)
|
|
27
|
+
except Exception:
|
|
28
|
+
continue
|
|
29
|
+
finally:
|
|
30
|
+
if sock:
|
|
31
|
+
try:
|
|
32
|
+
sock.close()
|
|
33
|
+
except Exception:
|
|
34
|
+
pass
|
|
35
|
+
if len(data) < 12:
|
|
36
|
+
continue
|
|
37
|
+
|
|
38
|
+
resp_tx_id = struct.unpack(">H", data[0:2])[0]
|
|
39
|
+
if resp_tx_id != tx_id:
|
|
40
|
+
continue
|
|
41
|
+
idx = 12
|
|
42
|
+
while idx < len(data) and data[idx] != 0:
|
|
43
|
+
if (data[idx] & 0xC0) == 0xC0:
|
|
44
|
+
idx += 2
|
|
45
|
+
break
|
|
46
|
+
idx += 1 + data[idx]
|
|
47
|
+
else:
|
|
48
|
+
idx += 5
|
|
49
|
+
if idx > len(data):
|
|
50
|
+
continue
|
|
51
|
+
ancount = struct.unpack(">H", data[6:8])[0]
|
|
52
|
+
for _ in range(ancount):
|
|
53
|
+
if idx >= len(data):
|
|
54
|
+
break
|
|
55
|
+
if (data[idx] & 0xC0) == 0xC0:
|
|
56
|
+
idx += 2
|
|
57
|
+
else:
|
|
58
|
+
while idx < len(data) and data[idx] != 0:
|
|
59
|
+
idx += 1 + data[idx]
|
|
60
|
+
idx += 1
|
|
61
|
+
if idx + 10 > len(data):
|
|
62
|
+
break
|
|
63
|
+
rtype, _rclass, _ttl, rdlength = struct.unpack(">HHIH", data[idx : idx + 10])
|
|
64
|
+
idx += 10
|
|
65
|
+
if rtype == 1 and rdlength == 4 and idx + 4 <= len(data):
|
|
66
|
+
return socket.inet_ntoa(data[idx : idx + 4])
|
|
67
|
+
idx += rdlength
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def apply_dns_fallback() -> None:
|
|
72
|
+
orig = socket.getaddrinfo
|
|
73
|
+
cache: dict[str, str] = {}
|
|
74
|
+
|
|
75
|
+
def custom(host, port, family=0, type=0, proto=0, flags=0):
|
|
76
|
+
if isinstance(host, str) and "zoho" in host.lower():
|
|
77
|
+
if host not in cache:
|
|
78
|
+
resolved = _dns_query_fallback(host)
|
|
79
|
+
if resolved:
|
|
80
|
+
cache[host] = resolved
|
|
81
|
+
if host in cache:
|
|
82
|
+
return orig(cache[host], port, family, type, proto, flags)
|
|
83
|
+
try:
|
|
84
|
+
return orig(host, port, family, type, proto, flags)
|
|
85
|
+
except Exception:
|
|
86
|
+
resolved = _dns_query_fallback(host) if isinstance(host, str) else None
|
|
87
|
+
if resolved:
|
|
88
|
+
cache[host] = resolved
|
|
89
|
+
return orig(resolved, port, family, type, proto, flags)
|
|
90
|
+
raise
|
|
91
|
+
|
|
92
|
+
socket.getaddrinfo = custom # type: ignore[method-assign]
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""HTML and text formatters for Zoho Sprints rich-text descriptions."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
_EMOJI_RE = re.compile(
|
|
7
|
+
"["
|
|
8
|
+
"\U0001F600-\U0001F64F"
|
|
9
|
+
"\U0001F300-\U0001F5FF"
|
|
10
|
+
"\U0001F680-\U0001F6FF"
|
|
11
|
+
"\U0001F1E0-\U0001F1FF"
|
|
12
|
+
"\U00002702-\U000027B0"
|
|
13
|
+
"\U0000FE00-\U0000FE0F"
|
|
14
|
+
"\U0001F900-\U0001F9FF"
|
|
15
|
+
"\U0001FA00-\U0001FA6F"
|
|
16
|
+
"\U0001FA70-\U0001FAFF"
|
|
17
|
+
"\U00002600-\U000026FF"
|
|
18
|
+
"\U0000200D"
|
|
19
|
+
"\U00002B50\U00002B05-\U00002B07\U00002934-\U00002935"
|
|
20
|
+
"\U000023CF\U000023E9-\U000023F3\U000023F8-\U000023FA"
|
|
21
|
+
"]+",
|
|
22
|
+
flags=re.UNICODE,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _strip_emoji(text: str) -> str:
|
|
27
|
+
return _EMOJI_RE.sub("", text).strip()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def format_zoho_html(text: str) -> str:
|
|
31
|
+
if not text:
|
|
32
|
+
return text
|
|
33
|
+
text = _strip_emoji(text)
|
|
34
|
+
if not text:
|
|
35
|
+
return text
|
|
36
|
+
if text.strip().startswith("<div dir="):
|
|
37
|
+
return text
|
|
38
|
+
has_arabic = bool(re.search(r"[\u0600-\u06FF]", text))
|
|
39
|
+
code_blocks: list[str] = []
|
|
40
|
+
|
|
41
|
+
def _save_code_block(match: re.Match[str]) -> str:
|
|
42
|
+
code_blocks.append(match.group(1))
|
|
43
|
+
return f"___CODE_BLOCK_{len(code_blocks) - 1}___"
|
|
44
|
+
|
|
45
|
+
text = re.sub(r"```(?:\w+)?\n?(.*?)```", _save_code_block, text, flags=re.DOTALL)
|
|
46
|
+
html_parts: list[str] = []
|
|
47
|
+
in_list = False
|
|
48
|
+
list_type = "ul"
|
|
49
|
+
|
|
50
|
+
def _format_inline(s: str) -> str:
|
|
51
|
+
s = re.sub(
|
|
52
|
+
r"`([^`]+)`",
|
|
53
|
+
r'<code style="color: #ffffff; font-family: Consolas, Monaco, monospace; font-size: 0.95em; direction: ltr; display: inline-block; font-weight: bold;">\1</code>',
|
|
54
|
+
s,
|
|
55
|
+
)
|
|
56
|
+
return re.sub(r"\*\*(.+?)\*\*", r'<strong style="color: #ffffff; font-weight: 700;">\1</strong>', s)
|
|
57
|
+
|
|
58
|
+
for line in text.strip().splitlines():
|
|
59
|
+
stripped = line.strip()
|
|
60
|
+
if not stripped:
|
|
61
|
+
if in_list:
|
|
62
|
+
html_parts.append(f"</{list_type}>")
|
|
63
|
+
in_list = False
|
|
64
|
+
continue
|
|
65
|
+
cb_match = re.match(r"^___CODE_BLOCK_(\d+)___$", stripped)
|
|
66
|
+
if cb_match:
|
|
67
|
+
if in_list:
|
|
68
|
+
html_parts.append(f"</{list_type}>")
|
|
69
|
+
in_list = False
|
|
70
|
+
code_content = code_blocks[int(cb_match.group(1))].strip()
|
|
71
|
+
code_content = code_content.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
72
|
+
html_parts.append(
|
|
73
|
+
f'<pre style="color: #ffffff; font-family: Consolas, Monaco, monospace; font-size: 13px; direction: ltr; text-align: left; margin: 8px 0; overflow-x: auto;"><code>{code_content}</code></pre>'
|
|
74
|
+
)
|
|
75
|
+
continue
|
|
76
|
+
if stripped in ("---", "***", "___"):
|
|
77
|
+
if in_list:
|
|
78
|
+
html_parts.append(f"</{list_type}>")
|
|
79
|
+
in_list = False
|
|
80
|
+
html_parts.append('<hr style="border: 0; border-top: 1px solid rgba(255, 255, 255, 0.2); margin: 14px 0;">')
|
|
81
|
+
continue
|
|
82
|
+
heading = None
|
|
83
|
+
if stripped.startswith("# "):
|
|
84
|
+
heading = (stripped[2:], "1.25em", "12px 0 10px 0")
|
|
85
|
+
elif stripped.startswith("## "):
|
|
86
|
+
heading = (stripped[3:], "1.1em", "12px 0 6px 0")
|
|
87
|
+
elif stripped.startswith("### "):
|
|
88
|
+
heading = (stripped[4:], "1.05em", "10px 0 4px 0")
|
|
89
|
+
if heading:
|
|
90
|
+
if in_list:
|
|
91
|
+
html_parts.append(f"</{list_type}>")
|
|
92
|
+
in_list = False
|
|
93
|
+
title, size, margin = heading
|
|
94
|
+
html_parts.append(
|
|
95
|
+
f'<div style="font-size: {size}; font-weight: 700; color: #ffffff; margin: {margin};">{_format_inline(title)}</div>'
|
|
96
|
+
)
|
|
97
|
+
continue
|
|
98
|
+
if re.match(r"^\d+\.\s+", stripped):
|
|
99
|
+
if not in_list or list_type != "ol":
|
|
100
|
+
if in_list:
|
|
101
|
+
html_parts.append(f"</{list_type}>")
|
|
102
|
+
html_parts.append("<ol style='margin-right: 22px; padding-right: 0; margin-bottom: 10px; color: #ffffff;'>")
|
|
103
|
+
in_list = True
|
|
104
|
+
list_type = "ol"
|
|
105
|
+
item_text = _format_inline(re.sub(r"^\d+\.\s+", "", stripped))
|
|
106
|
+
html_parts.append(f"<li style='margin-bottom: 6px; color: #ffffff; font-size: 14px;'>{item_text}</li>")
|
|
107
|
+
continue
|
|
108
|
+
if stripped.startswith("- ") or stripped.startswith("* "):
|
|
109
|
+
if not in_list or list_type != "ul":
|
|
110
|
+
if in_list:
|
|
111
|
+
html_parts.append(f"</{list_type}>")
|
|
112
|
+
html_parts.append("<ul style='margin-right: 22px; padding-right: 0; margin-bottom: 10px; color: #ffffff;'>")
|
|
113
|
+
in_list = True
|
|
114
|
+
list_type = "ul"
|
|
115
|
+
html_parts.append(
|
|
116
|
+
f"<li style='margin-bottom: 6px; color: #ffffff; font-size: 14px;'>{_format_inline(stripped[2:])}</li>"
|
|
117
|
+
)
|
|
118
|
+
continue
|
|
119
|
+
if in_list:
|
|
120
|
+
html_parts.append(f"</{list_type}>")
|
|
121
|
+
in_list = False
|
|
122
|
+
html_parts.append(f"<p style='margin: 6px 0; color: #ffffff; font-size: 14px;'>{_format_inline(stripped)}</p>")
|
|
123
|
+
if in_list:
|
|
124
|
+
html_parts.append(f"</{list_type}>")
|
|
125
|
+
inner = "\n".join(html_parts)
|
|
126
|
+
if has_arabic:
|
|
127
|
+
return (
|
|
128
|
+
'<div dir="rtl" style="text-align: right; direction: rtl; line-height: 1.85; '
|
|
129
|
+
"font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Tahoma, Arial, sans-serif; "
|
|
130
|
+
f'color: #ffffff; font-size: 14px;">\n{inner}\n</div>'
|
|
131
|
+
)
|
|
132
|
+
return inner
|