@apolloyh/apollo-agent 0.1.2 → 0.1.4
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/.env.example +2 -1
- package/dist/brand.d.ts +1 -1
- package/dist/brand.js +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +2 -1
- package/dist/index.js +8 -0
- package/dist/runtime/permissions.d.ts.map +1 -1
- package/dist/runtime/permissions.js +17 -0
- package/dist/runtime/prompt-builder.js +1 -1
- package/dist/runtime/tool-policy.d.ts.map +1 -1
- package/dist/runtime/tool-policy.js +2 -1
- package/dist/skills/skills.d.ts.map +1 -1
- package/dist/skills/skills.js +1 -6
- package/dist/tools/builtin.d.ts.map +1 -1
- package/dist/tools/builtin.js +52 -0
- package/dist/trace.js +3 -1
- package/docs/sdk.md +1 -1
- package/package.json +1 -2
- package/src/skills/tech-research-skill/SKILL.md +0 -200
- package/src/skills/tech-research-skill/agents/openai.yaml +0 -4
- package/src/skills/tech-research-skill/repo-analyze +0 -435
- package/src/skills/tech-research-skill/repo-fetch +0 -310
- package/src/skills/tech-research-skill/report-generate +0 -254
|
@@ -1,310 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
import argparse
|
|
3
|
-
import datetime as dt
|
|
4
|
-
import json
|
|
5
|
-
import os
|
|
6
|
-
import sys
|
|
7
|
-
import urllib.error
|
|
8
|
-
import urllib.parse
|
|
9
|
-
import urllib.request
|
|
10
|
-
|
|
11
|
-
VERSION = "1.0.0"
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
def load_env_file():
|
|
15
|
-
env_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env")
|
|
16
|
-
if not os.path.exists(env_path):
|
|
17
|
-
return
|
|
18
|
-
try:
|
|
19
|
-
with open(env_path, "r", encoding="utf-8") as handle:
|
|
20
|
-
for raw_line in handle:
|
|
21
|
-
line = raw_line.strip()
|
|
22
|
-
if not line or line.startswith("#") or "=" not in line:
|
|
23
|
-
continue
|
|
24
|
-
key, value = line.split("=", 1)
|
|
25
|
-
key = key.strip()
|
|
26
|
-
value = value.strip().strip('"').strip("'")
|
|
27
|
-
if key and key not in os.environ:
|
|
28
|
-
os.environ[key] = value
|
|
29
|
-
except OSError as exc:
|
|
30
|
-
fail("env_error", "could not read .env file", {"error": str(exc)})
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def now_iso():
|
|
34
|
-
return dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
def fail(code, message, details=None, exit_code=1):
|
|
38
|
-
payload = {
|
|
39
|
-
"ok": False,
|
|
40
|
-
"error": {
|
|
41
|
-
"code": code,
|
|
42
|
-
"message": message,
|
|
43
|
-
"details": details or {},
|
|
44
|
-
},
|
|
45
|
-
}
|
|
46
|
-
print(json.dumps(payload, ensure_ascii=False, sort_keys=True), file=sys.stderr)
|
|
47
|
-
raise SystemExit(exit_code)
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
def parse_owner_repo(value):
|
|
51
|
-
cleaned = value.strip()
|
|
52
|
-
if "github.com/" in cleaned:
|
|
53
|
-
parsed = urllib.parse.urlparse(cleaned)
|
|
54
|
-
cleaned = parsed.path.strip("/")
|
|
55
|
-
parts = cleaned.strip("/").split("/")
|
|
56
|
-
if len(parts) != 2 or not all(parts):
|
|
57
|
-
fail("invalid_arguments", "repo must be in owner/repo form", {"value": value})
|
|
58
|
-
return parts[0], parts[1]
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
def parse_include(values, include_issues, include_commits):
|
|
62
|
-
selected = set()
|
|
63
|
-
for value in values or []:
|
|
64
|
-
for item in value.split(","):
|
|
65
|
-
item = item.strip().lower()
|
|
66
|
-
if not item:
|
|
67
|
-
continue
|
|
68
|
-
if item not in {"issues", "commits"}:
|
|
69
|
-
fail("invalid_arguments", "include must contain only issues or commits", {"value": item})
|
|
70
|
-
selected.add(item)
|
|
71
|
-
if include_issues:
|
|
72
|
-
selected.add("issues")
|
|
73
|
-
if include_commits:
|
|
74
|
-
selected.add("commits")
|
|
75
|
-
return sorted(selected)
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
def request_json(url):
|
|
79
|
-
headers = {
|
|
80
|
-
"Accept": "application/vnd.github+json",
|
|
81
|
-
"User-Agent": "tech-research-skill/repo-fetch",
|
|
82
|
-
"X-GitHub-Api-Version": "2022-11-28",
|
|
83
|
-
}
|
|
84
|
-
token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
|
|
85
|
-
if token:
|
|
86
|
-
headers["Authorization"] = f"Bearer {token}"
|
|
87
|
-
|
|
88
|
-
request = urllib.request.Request(url, headers=headers)
|
|
89
|
-
try:
|
|
90
|
-
with urllib.request.urlopen(request, timeout=20) as response:
|
|
91
|
-
charset = response.headers.get_content_charset() or "utf-8"
|
|
92
|
-
return json.loads(response.read().decode(charset))
|
|
93
|
-
except urllib.error.HTTPError as exc:
|
|
94
|
-
body = exc.read().decode("utf-8", errors="replace")
|
|
95
|
-
message = body
|
|
96
|
-
try:
|
|
97
|
-
parsed = json.loads(body)
|
|
98
|
-
message = parsed.get("message", body)
|
|
99
|
-
except json.JSONDecodeError:
|
|
100
|
-
pass
|
|
101
|
-
details = {"url": url, "status": exc.code, "message": message}
|
|
102
|
-
if exc.code in {403, 429}:
|
|
103
|
-
details["hint"] = "GitHub API may be rate-limited. Set GITHUB_TOKEN or GH_TOKEN, or put GITHUB_TOKEN in this skill directory's ignored .env file."
|
|
104
|
-
fail("github_http_error", "GitHub API request failed", details)
|
|
105
|
-
except urllib.error.URLError as exc:
|
|
106
|
-
fail("network_error", "GitHub API request failed", {"url": url, "reason": str(exc.reason)})
|
|
107
|
-
except json.JSONDecodeError as exc:
|
|
108
|
-
fail("invalid_response", "GitHub API returned invalid JSON", {"url": url, "error": str(exc)})
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
def build_url(api_url, path, params=None):
|
|
112
|
-
base = api_url.rstrip("/")
|
|
113
|
-
query = urllib.parse.urlencode(params or {})
|
|
114
|
-
return f"{base}{path}" + (f"?{query}" if query else "")
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
def compact_repo(repo):
|
|
118
|
-
return {
|
|
119
|
-
"id": repo.get("id"),
|
|
120
|
-
"name": repo.get("name"),
|
|
121
|
-
"full_name": repo.get("full_name"),
|
|
122
|
-
"description": repo.get("description"),
|
|
123
|
-
"private": repo.get("private"),
|
|
124
|
-
"html_url": repo.get("html_url"),
|
|
125
|
-
"default_branch": repo.get("default_branch"),
|
|
126
|
-
"language": repo.get("language"),
|
|
127
|
-
"topics": repo.get("topics", []),
|
|
128
|
-
"stargazers_count": repo.get("stargazers_count"),
|
|
129
|
-
"watchers_count": repo.get("watchers_count"),
|
|
130
|
-
"forks_count": repo.get("forks_count"),
|
|
131
|
-
"open_issues_count": repo.get("open_issues_count"),
|
|
132
|
-
"license": repo.get("license", {}).get("spdx_id") if repo.get("license") else None,
|
|
133
|
-
"archived": repo.get("archived"),
|
|
134
|
-
"disabled": repo.get("disabled"),
|
|
135
|
-
"created_at": repo.get("created_at"),
|
|
136
|
-
"updated_at": repo.get("updated_at"),
|
|
137
|
-
"pushed_at": repo.get("pushed_at"),
|
|
138
|
-
"owner": {
|
|
139
|
-
"login": (repo.get("owner") or {}).get("login"),
|
|
140
|
-
"type": (repo.get("owner") or {}).get("type"),
|
|
141
|
-
},
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
def compact_search_repo(repo):
|
|
146
|
-
compacted = compact_repo(repo)
|
|
147
|
-
compacted.update(
|
|
148
|
-
{
|
|
149
|
-
"score": repo.get("score"),
|
|
150
|
-
"size": repo.get("size"),
|
|
151
|
-
"homepage": repo.get("homepage"),
|
|
152
|
-
"has_issues": repo.get("has_issues"),
|
|
153
|
-
"has_projects": repo.get("has_projects"),
|
|
154
|
-
"has_wiki": repo.get("has_wiki"),
|
|
155
|
-
}
|
|
156
|
-
)
|
|
157
|
-
return compacted
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
def compact_issue(issue):
|
|
161
|
-
return {
|
|
162
|
-
"id": issue.get("id"),
|
|
163
|
-
"number": issue.get("number"),
|
|
164
|
-
"title": issue.get("title"),
|
|
165
|
-
"state": issue.get("state"),
|
|
166
|
-
"html_url": issue.get("html_url"),
|
|
167
|
-
"labels": [label.get("name") for label in issue.get("labels", []) if label.get("name")],
|
|
168
|
-
"comments": issue.get("comments"),
|
|
169
|
-
"created_at": issue.get("created_at"),
|
|
170
|
-
"updated_at": issue.get("updated_at"),
|
|
171
|
-
"closed_at": issue.get("closed_at"),
|
|
172
|
-
"user": {
|
|
173
|
-
"login": (issue.get("user") or {}).get("login"),
|
|
174
|
-
"type": (issue.get("user") or {}).get("type"),
|
|
175
|
-
},
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
def compact_commit(commit):
|
|
180
|
-
commit_data = commit.get("commit") or {}
|
|
181
|
-
author_data = commit_data.get("author") or {}
|
|
182
|
-
return {
|
|
183
|
-
"sha": commit.get("sha"),
|
|
184
|
-
"html_url": commit.get("html_url"),
|
|
185
|
-
"message": (commit_data.get("message") or "").splitlines()[0],
|
|
186
|
-
"date": author_data.get("date"),
|
|
187
|
-
"author": {
|
|
188
|
-
"name": author_data.get("name"),
|
|
189
|
-
"login": (commit.get("author") or {}).get("login"),
|
|
190
|
-
},
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
def fetch_paged(api_url, path, per_page, max_pages, extra_params=None):
|
|
195
|
-
items = []
|
|
196
|
-
for page in range(1, max_pages + 1):
|
|
197
|
-
params = {"per_page": per_page, "page": page}
|
|
198
|
-
params.update(extra_params or {})
|
|
199
|
-
batch = request_json(build_url(api_url, path, params))
|
|
200
|
-
if not isinstance(batch, list):
|
|
201
|
-
fail("invalid_response", "GitHub API returned a non-list page", {"path": path, "page": page})
|
|
202
|
-
items.extend(batch)
|
|
203
|
-
if len(batch) < per_page:
|
|
204
|
-
break
|
|
205
|
-
return items
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
def main():
|
|
209
|
-
load_env_file()
|
|
210
|
-
|
|
211
|
-
parser = argparse.ArgumentParser(description="Fetch GitHub repository or repository-search data as JSON.")
|
|
212
|
-
parser.add_argument("repo", nargs="?", help="Repository in owner/repo form or a GitHub repository URL.")
|
|
213
|
-
parser.add_argument("--search", help="GitHub repository search query.")
|
|
214
|
-
parser.add_argument("--include", action="append", help="Comma-separated optional datasets: issues,commits.")
|
|
215
|
-
parser.add_argument("--issues", action="store_true", help="Include open issues. Pull requests are filtered out.")
|
|
216
|
-
parser.add_argument("--commits", action="store_true", help="Include recent commits.")
|
|
217
|
-
parser.add_argument("--per-page", type=int, default=30, help="GitHub page size, 1-100. Default: 30.")
|
|
218
|
-
parser.add_argument("--max-pages", type=int, default=1, help="Maximum pages per optional dataset. Default: 1.")
|
|
219
|
-
args = parser.parse_args()
|
|
220
|
-
|
|
221
|
-
if args.per_page < 1 or args.per_page > 100:
|
|
222
|
-
fail("invalid_arguments", "--per-page must be between 1 and 100", {"per_page": args.per_page})
|
|
223
|
-
if args.max_pages < 1:
|
|
224
|
-
fail("invalid_arguments", "--max-pages must be at least 1", {"max_pages": args.max_pages})
|
|
225
|
-
if bool(args.repo) == bool(args.search):
|
|
226
|
-
fail("invalid_arguments", "provide exactly one of repo or --search")
|
|
227
|
-
|
|
228
|
-
include = parse_include(args.include, args.issues, args.commits)
|
|
229
|
-
api_url = os.environ.get("GITHUB_API_URL", "https://api.github.com")
|
|
230
|
-
|
|
231
|
-
if args.search:
|
|
232
|
-
if include:
|
|
233
|
-
fail("invalid_arguments", "--include, --issues, and --commits require a concrete repository")
|
|
234
|
-
search_json = request_json(
|
|
235
|
-
build_url(
|
|
236
|
-
api_url,
|
|
237
|
-
"/search/repositories",
|
|
238
|
-
{
|
|
239
|
-
"q": args.search,
|
|
240
|
-
"sort": "stars",
|
|
241
|
-
"order": "desc",
|
|
242
|
-
"per_page": args.per_page,
|
|
243
|
-
"page": 1,
|
|
244
|
-
},
|
|
245
|
-
)
|
|
246
|
-
)
|
|
247
|
-
items = search_json.get("items")
|
|
248
|
-
if not isinstance(items, list):
|
|
249
|
-
fail("invalid_response", "GitHub search returned invalid results")
|
|
250
|
-
payload = {
|
|
251
|
-
"ok": True,
|
|
252
|
-
"source": "repo-fetch",
|
|
253
|
-
"version": VERSION,
|
|
254
|
-
"fetched_at": now_iso(),
|
|
255
|
-
"mode": "search",
|
|
256
|
-
"input": {
|
|
257
|
-
"query": args.search,
|
|
258
|
-
"per_page": args.per_page,
|
|
259
|
-
"max_pages": 1,
|
|
260
|
-
},
|
|
261
|
-
"search": {
|
|
262
|
-
"query": args.search,
|
|
263
|
-
"total_count": search_json.get("total_count"),
|
|
264
|
-
"incomplete_results": search_json.get("incomplete_results"),
|
|
265
|
-
"items": [compact_search_repo(item) for item in items],
|
|
266
|
-
},
|
|
267
|
-
"data": {},
|
|
268
|
-
}
|
|
269
|
-
print(json.dumps(payload, ensure_ascii=False, sort_keys=True))
|
|
270
|
-
return
|
|
271
|
-
|
|
272
|
-
owner, repo = parse_owner_repo(args.repo)
|
|
273
|
-
repo_json = request_json(build_url(api_url, f"/repos/{owner}/{repo}"))
|
|
274
|
-
data = {}
|
|
275
|
-
|
|
276
|
-
if "issues" in include:
|
|
277
|
-
raw_issues = fetch_paged(
|
|
278
|
-
api_url,
|
|
279
|
-
f"/repos/{owner}/{repo}/issues",
|
|
280
|
-
args.per_page,
|
|
281
|
-
args.max_pages,
|
|
282
|
-
{"state": "all", "sort": "updated", "direction": "desc"},
|
|
283
|
-
)
|
|
284
|
-
data["issues"] = [compact_issue(item) for item in raw_issues if "pull_request" not in item]
|
|
285
|
-
|
|
286
|
-
if "commits" in include:
|
|
287
|
-
raw_commits = fetch_paged(api_url, f"/repos/{owner}/{repo}/commits", args.per_page, args.max_pages)
|
|
288
|
-
data["commits"] = [compact_commit(item) for item in raw_commits]
|
|
289
|
-
|
|
290
|
-
payload = {
|
|
291
|
-
"ok": True,
|
|
292
|
-
"source": "repo-fetch",
|
|
293
|
-
"version": VERSION,
|
|
294
|
-
"fetched_at": now_iso(),
|
|
295
|
-
"mode": "repository",
|
|
296
|
-
"input": {
|
|
297
|
-
"owner": owner,
|
|
298
|
-
"repo": repo,
|
|
299
|
-
"include": include,
|
|
300
|
-
"per_page": args.per_page,
|
|
301
|
-
"max_pages": args.max_pages,
|
|
302
|
-
},
|
|
303
|
-
"repository": compact_repo(repo_json),
|
|
304
|
-
"data": data,
|
|
305
|
-
}
|
|
306
|
-
print(json.dumps(payload, ensure_ascii=False, sort_keys=True))
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
if __name__ == "__main__":
|
|
310
|
-
main()
|
|
@@ -1,254 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
import argparse
|
|
3
|
-
import datetime as dt
|
|
4
|
-
import html
|
|
5
|
-
import json
|
|
6
|
-
import sys
|
|
7
|
-
|
|
8
|
-
VERSION = "1.0.0"
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
def now_iso():
|
|
12
|
-
return dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
def fail(code, message, details=None, exit_code=1):
|
|
16
|
-
payload = {
|
|
17
|
-
"ok": False,
|
|
18
|
-
"error": {
|
|
19
|
-
"code": code,
|
|
20
|
-
"message": message,
|
|
21
|
-
"details": details or {},
|
|
22
|
-
},
|
|
23
|
-
}
|
|
24
|
-
print(json.dumps(payload, ensure_ascii=False, sort_keys=True), file=sys.stderr)
|
|
25
|
-
raise SystemExit(exit_code)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def read_stdin_json():
|
|
29
|
-
raw = sys.stdin.read()
|
|
30
|
-
if not raw.strip():
|
|
31
|
-
fail("invalid_input", "stdin is empty")
|
|
32
|
-
try:
|
|
33
|
-
return json.loads(raw)
|
|
34
|
-
except json.JSONDecodeError as exc:
|
|
35
|
-
fail("invalid_json", "stdin must be valid JSON", {"error": str(exc)})
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def validate_analysis_payload(payload):
|
|
39
|
-
if not isinstance(payload, dict):
|
|
40
|
-
fail("invalid_input", "input JSON must be an object")
|
|
41
|
-
if payload.get("ok") is not True or payload.get("source") != "repo-analyze":
|
|
42
|
-
fail("invalid_input", "input must be successful repo-analyze output")
|
|
43
|
-
analysis = payload.get("analysis")
|
|
44
|
-
if not isinstance(analysis, dict):
|
|
45
|
-
fail("invalid_input", "input missing analysis object")
|
|
46
|
-
return analysis
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def bullet_lines(items, formatter):
|
|
50
|
-
if not items:
|
|
51
|
-
return ["- None reported."]
|
|
52
|
-
return [f"- {formatter(item)}" for item in items]
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
def evidence_source_lines(payload):
|
|
56
|
-
sources = payload.get("evidence_sources") or {}
|
|
57
|
-
lines = ["", "## Evidence Sources", ""]
|
|
58
|
-
has_sources = False
|
|
59
|
-
|
|
60
|
-
query = sources.get("github_search_query")
|
|
61
|
-
if query:
|
|
62
|
-
lines.append(f"- GitHub search query: `{query}`")
|
|
63
|
-
has_sources = True
|
|
64
|
-
|
|
65
|
-
for repo in sources.get("repositories") or []:
|
|
66
|
-
if not isinstance(repo, dict):
|
|
67
|
-
continue
|
|
68
|
-
name = repo.get("name") or "GitHub repository"
|
|
69
|
-
url = repo.get("url")
|
|
70
|
-
lines.append(f"- GitHub repository: {name}" + (f" - {url}" if url else ""))
|
|
71
|
-
has_sources = True
|
|
72
|
-
|
|
73
|
-
for page in sources.get("web_pages") or []:
|
|
74
|
-
if not isinstance(page, dict):
|
|
75
|
-
continue
|
|
76
|
-
title = page.get("title") or page.get("url") or "Web page"
|
|
77
|
-
url = page.get("url")
|
|
78
|
-
lines.append(f"- Web page: {title}" + (f" - {url}" if url else ""))
|
|
79
|
-
has_sources = True
|
|
80
|
-
|
|
81
|
-
if not has_sources:
|
|
82
|
-
repo = payload.get("input", {}).get("repository_full_name")
|
|
83
|
-
if repo and not repo.startswith("github search:"):
|
|
84
|
-
lines.append(f"- GitHub repository: {repo} - https://github.com/{repo}")
|
|
85
|
-
else:
|
|
86
|
-
lines.append("- None captured in the input payload.")
|
|
87
|
-
|
|
88
|
-
return lines
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
def render_daily_markdown(payload, analysis):
|
|
92
|
-
repo = payload.get("input", {}).get("repository_full_name") or "unknown repository"
|
|
93
|
-
lines = [
|
|
94
|
-
f"# Daily Repository Health Report: {repo}",
|
|
95
|
-
"",
|
|
96
|
-
f"- Generated: {now_iso()}",
|
|
97
|
-
f"- Health score: {analysis.get('health_score')}/100",
|
|
98
|
-
f"- Model: {payload.get('model')}",
|
|
99
|
-
"",
|
|
100
|
-
"## Summary",
|
|
101
|
-
"",
|
|
102
|
-
str(analysis.get("summary", "")),
|
|
103
|
-
"",
|
|
104
|
-
"## Signals",
|
|
105
|
-
"",
|
|
106
|
-
]
|
|
107
|
-
lines.extend(bullet_lines(analysis.get("signals", []), lambda x: f"**{x.get('name', 'signal')}** ({x.get('status', 'unknown')}): {x.get('detail', '')}"))
|
|
108
|
-
lines.extend(["", "## Actions", ""])
|
|
109
|
-
lines.extend(bullet_lines(analysis.get("recommendations", []), lambda x: f"[{x.get('priority', 'unknown')}] {x.get('action', '')} - {x.get('reason', '')}"))
|
|
110
|
-
lines.extend(evidence_source_lines(payload))
|
|
111
|
-
return "\n".join(lines).rstrip() + "\n"
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
def render_weekly_markdown(payload, analysis):
|
|
115
|
-
repo = payload.get("input", {}).get("repository_full_name") or "unknown repository"
|
|
116
|
-
lines = [
|
|
117
|
-
f"# Weekly Repository Health Review: {repo}",
|
|
118
|
-
"",
|
|
119
|
-
f"- Generated: {now_iso()}",
|
|
120
|
-
f"- Health score: {analysis.get('health_score')}/100",
|
|
121
|
-
f"- Included data: {', '.join(payload.get('input', {}).get('included', [])) or 'repository metadata only'}",
|
|
122
|
-
"",
|
|
123
|
-
"## Executive Summary",
|
|
124
|
-
"",
|
|
125
|
-
str(analysis.get("summary", "")),
|
|
126
|
-
"",
|
|
127
|
-
"## Health Signals",
|
|
128
|
-
"",
|
|
129
|
-
]
|
|
130
|
-
lines.extend(bullet_lines(analysis.get("signals", []), lambda x: f"**{x.get('name', 'signal')}**: {x.get('status', 'unknown')} - {x.get('detail', '')}"))
|
|
131
|
-
lines.extend(["", "## Risks", ""])
|
|
132
|
-
lines.extend(bullet_lines(analysis.get("risks", []), lambda x: f"[{x.get('severity', 'unknown')}] {x.get('title', '')} - {x.get('detail', '')}"))
|
|
133
|
-
lines.extend(["", "## Recommended Next Steps", ""])
|
|
134
|
-
lines.extend(bullet_lines(analysis.get("recommendations", []), lambda x: f"[{x.get('priority', 'unknown')}] {x.get('action', '')} - {x.get('reason', '')}"))
|
|
135
|
-
lines.extend(evidence_source_lines(payload))
|
|
136
|
-
return "\n".join(lines).rstrip() + "\n"
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
def render_investment_markdown(payload, analysis):
|
|
140
|
-
repo = payload.get("input", {}).get("repository_full_name") or "unknown repository"
|
|
141
|
-
notes = analysis.get("investment_notes") or {}
|
|
142
|
-
lines = [
|
|
143
|
-
f"# Investment Repository Diligence: {repo}",
|
|
144
|
-
"",
|
|
145
|
-
f"- Generated: {now_iso()}",
|
|
146
|
-
f"- Health score: {analysis.get('health_score')}/100",
|
|
147
|
-
f"- Investment fit: {notes.get('fit', 'unknown')}",
|
|
148
|
-
"",
|
|
149
|
-
"## Thesis",
|
|
150
|
-
"",
|
|
151
|
-
str(notes.get("rationale") or analysis.get("summary", "")),
|
|
152
|
-
"",
|
|
153
|
-
"## Key Signals",
|
|
154
|
-
"",
|
|
155
|
-
]
|
|
156
|
-
lines.extend(bullet_lines(analysis.get("signals", []), lambda x: f"**{x.get('name', 'signal')}** ({x.get('status', 'unknown')}): {x.get('detail', '')}"))
|
|
157
|
-
lines.extend(["", "## Diligence Risks", ""])
|
|
158
|
-
lines.extend(bullet_lines(analysis.get("risks", []), lambda x: f"[{x.get('severity', 'unknown')}] {x.get('title', '')} - {x.get('detail', '')}"))
|
|
159
|
-
lines.extend(["", "## Diligence Questions", ""])
|
|
160
|
-
lines.extend(bullet_lines(notes.get("diligence_questions", []), lambda x: str(x)))
|
|
161
|
-
lines.extend(evidence_source_lines(payload))
|
|
162
|
-
return "\n".join(lines).rstrip() + "\n"
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
def markdown_to_html(markdown):
|
|
166
|
-
lines = []
|
|
167
|
-
in_list = False
|
|
168
|
-
for raw_line in markdown.splitlines():
|
|
169
|
-
line = raw_line.rstrip()
|
|
170
|
-
if line.startswith("# "):
|
|
171
|
-
if in_list:
|
|
172
|
-
lines.append("</ul>")
|
|
173
|
-
in_list = False
|
|
174
|
-
lines.append(f"<h1>{html.escape(line[2:])}</h1>")
|
|
175
|
-
elif line.startswith("## "):
|
|
176
|
-
if in_list:
|
|
177
|
-
lines.append("</ul>")
|
|
178
|
-
in_list = False
|
|
179
|
-
lines.append(f"<h2>{html.escape(line[3:])}</h2>")
|
|
180
|
-
elif line.startswith("- "):
|
|
181
|
-
if not in_list:
|
|
182
|
-
lines.append("<ul>")
|
|
183
|
-
in_list = True
|
|
184
|
-
lines.append(f"<li>{html.escape(line[2:])}</li>")
|
|
185
|
-
elif not line:
|
|
186
|
-
if in_list:
|
|
187
|
-
lines.append("</ul>")
|
|
188
|
-
in_list = False
|
|
189
|
-
else:
|
|
190
|
-
if in_list:
|
|
191
|
-
lines.append("</ul>")
|
|
192
|
-
in_list = False
|
|
193
|
-
lines.append(f"<p>{html.escape(line)}</p>")
|
|
194
|
-
if in_list:
|
|
195
|
-
lines.append("</ul>")
|
|
196
|
-
body = "\n".join(lines)
|
|
197
|
-
return f"""<!doctype html>
|
|
198
|
-
<html lang="en">
|
|
199
|
-
<head>
|
|
200
|
-
<meta charset="utf-8">
|
|
201
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
202
|
-
<title>Repository Health Report</title>
|
|
203
|
-
<style>
|
|
204
|
-
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; line-height: 1.55; margin: 40px auto; max-width: 920px; padding: 0 20px; color: #172026; }}
|
|
205
|
-
h1, h2 {{ line-height: 1.2; }}
|
|
206
|
-
h1 {{ margin-bottom: 24px; }}
|
|
207
|
-
h2 {{ border-top: 1px solid #d8dee4; margin-top: 32px; padding-top: 24px; }}
|
|
208
|
-
li {{ margin: 8px 0; }}
|
|
209
|
-
</style>
|
|
210
|
-
</head>
|
|
211
|
-
<body>
|
|
212
|
-
{body}
|
|
213
|
-
</body>
|
|
214
|
-
</html>
|
|
215
|
-
"""
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
def render_report(template, output_format, payload, analysis):
|
|
219
|
-
renderers = {
|
|
220
|
-
"daily": render_daily_markdown,
|
|
221
|
-
"weekly": render_weekly_markdown,
|
|
222
|
-
"investment": render_investment_markdown,
|
|
223
|
-
}
|
|
224
|
-
markdown = renderers[template](payload, analysis)
|
|
225
|
-
if output_format == "markdown":
|
|
226
|
-
return markdown
|
|
227
|
-
if output_format == "html":
|
|
228
|
-
return markdown_to_html(markdown)
|
|
229
|
-
fail("invalid_arguments", "unsupported output format", {"format": output_format})
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
def main():
|
|
233
|
-
parser = argparse.ArgumentParser(description="Generate markdown or HTML reports from repo-analyze JSON.")
|
|
234
|
-
parser.add_argument("--template", choices=["daily", "weekly", "investment"], default="daily", help="Report template. Default: daily.")
|
|
235
|
-
parser.add_argument("--format", choices=["markdown", "html"], default="markdown", help="Output format. Default: markdown.")
|
|
236
|
-
parser.add_argument("--output", help="Write report to this path instead of stdout.")
|
|
237
|
-
args = parser.parse_args()
|
|
238
|
-
|
|
239
|
-
payload = read_stdin_json()
|
|
240
|
-
analysis = validate_analysis_payload(payload)
|
|
241
|
-
report = render_report(args.template, args.format, payload, analysis)
|
|
242
|
-
|
|
243
|
-
if args.output:
|
|
244
|
-
try:
|
|
245
|
-
with open(args.output, "w", encoding="utf-8") as handle:
|
|
246
|
-
handle.write(report)
|
|
247
|
-
except OSError as exc:
|
|
248
|
-
fail("write_error", "could not write output file", {"path": args.output, "error": str(exc)})
|
|
249
|
-
else:
|
|
250
|
-
print(report, end="")
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
if __name__ == "__main__":
|
|
254
|
-
main()
|