patchrail 0.1.0__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.
Files changed (47) hide show
  1. patchrail/__init__.py +7 -0
  2. patchrail/__main__.py +7 -0
  3. patchrail/ci/__init__.py +7 -0
  4. patchrail/ci/classify.py +888 -0
  5. patchrail/cli.py +8566 -0
  6. patchrail/funded_issues/__init__.py +138 -0
  7. patchrail/funded_issues/algora_board.py +240 -0
  8. patchrail/funded_issues/blocklist.py +112 -0
  9. patchrail/funded_issues/discovery.py +4091 -0
  10. patchrail/funded_issues/importers.py +316 -0
  11. patchrail/funded_issues/source_noise.py +349 -0
  12. patchrail/funded_issues/store.py +459 -0
  13. patchrail/queue/__init__.py +75 -0
  14. patchrail/queue/server.py +273 -0
  15. patchrail/queue/status.py +756 -0
  16. patchrail/queue/store.py +600 -0
  17. patchrail/reviewer_quick_check.py +650 -0
  18. patchrail/schemas/__init__.py +1 -0
  19. patchrail/schemas/application-dossier.v1.schema.json +305 -0
  20. patchrail/schemas/ci-benchmark.v1.schema.json +174 -0
  21. patchrail/schemas/ci-fixture-check.v1.schema.json +122 -0
  22. patchrail/schemas/ci-pilot-metrics.v1.schema.json +164 -0
  23. patchrail/schemas/ci-pilot-summary.v1.schema.json +146 -0
  24. patchrail/schemas/ci-result.v1.schema.json +133 -0
  25. patchrail/schemas/funded-issues-client-report.v1.schema.json +524 -0
  26. patchrail/schemas/funded-issues-recheck-queue.v1.schema.json +333 -0
  27. patchrail/schemas/funded-issues-recheck-summary.v1.schema.json +136 -0
  28. patchrail/schemas/funded-issues-report.v1.schema.json +836 -0
  29. patchrail/schemas/funded-issues-shortlist.v1.schema.json +953 -0
  30. patchrail/schemas/funded-issues-store-status.v1.schema.json +96 -0
  31. patchrail/schemas/funded-issues-store.v1.schema.json +117 -0
  32. patchrail/schemas/queue-audit-event.v1.schema.json +44 -0
  33. patchrail/schemas/queue-audit-summary.v1.schema.json +169 -0
  34. patchrail/schemas/queue-gate-report.v1.schema.json +158 -0
  35. patchrail/schemas/queue-policy-resolution.v1.schema.json +188 -0
  36. patchrail/schemas/queue-policy-scan.v1.schema.json +175 -0
  37. patchrail/schemas/queue-proposal.v1.schema.json +61 -0
  38. patchrail/schemas/queue-review.v1.schema.json +218 -0
  39. patchrail/schemas/queue-status.v1.schema.json +179 -0
  40. patchrail/schemas/queue-work-item.v1.schema.json +64 -0
  41. patchrail/schemas/reviewer-quick-check-artifacts.v1.schema.json +104 -0
  42. patchrail/web_metrics.py +649 -0
  43. patchrail-0.1.0.dist-info/METADATA +279 -0
  44. patchrail-0.1.0.dist-info/RECORD +47 -0
  45. patchrail-0.1.0.dist-info/WHEEL +4 -0
  46. patchrail-0.1.0.dist-info/entry_points.txt +2 -0
  47. patchrail-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,649 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import hashlib
5
+ import json
6
+ import os
7
+ import re
8
+ import subprocess
9
+ from dataclasses import dataclass
10
+ from datetime import datetime, timezone
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from patchrail.funded_issues import (
15
+ FundedIssue,
16
+ load_funded_issues,
17
+ report_funded_issues,
18
+ score_funded_issues,
19
+ )
20
+ from patchrail.funded_issues.store import load_store, store_status
21
+
22
+
23
+ SOURCE_NAMES = [
24
+ "GitHub Issues",
25
+ "Algora",
26
+ "OpenPledge",
27
+ "Replit Bounties",
28
+ "BountyHub",
29
+ "Bountysource",
30
+ "IssueHunt",
31
+ "Buidl",
32
+ ]
33
+
34
+ PLATFORM_TO_SOURCE = {
35
+ "github": "GitHub Issues",
36
+ "polar": "GitHub Issues",
37
+ "algora": "Algora",
38
+ "openpledge": "OpenPledge",
39
+ "replit": "Replit Bounties",
40
+ "bountyhub": "BountyHub",
41
+ "bountysource": "Bountysource",
42
+ "issuehunt": "IssueHunt",
43
+ "buidl": "Buidl",
44
+ }
45
+
46
+ TEXT_SUFFIXES = {".json", ".md", ".txt", ".csv"}
47
+ SIGNAL_NAME_RE = re.compile(r"(bount|funded|opportunit)", re.IGNORECASE)
48
+ COUNT_PATTERNS = [
49
+ re.compile(
50
+ r"\b(?P<count>\d{1,5})\s+issues?\s+bount(?:y|ies)\s+(?:abiertos|open)\b",
51
+ re.IGNORECASE,
52
+ ),
53
+ re.compile(
54
+ r"\b(?P<count>\d{1,5})\s+(?:open\s+)?(?:paid\s+)?bount(?:y|ies)\s+issues?\b",
55
+ re.IGNORECASE,
56
+ ),
57
+ re.compile(
58
+ r"\b(?P<count>\d{1,5})\s+(?:open\s+)?paid\s+bount(?:y|ies)\b",
59
+ re.IGNORECASE,
60
+ ),
61
+ re.compile(r"\bfilas:\s*(?P<count>\d{1,5})\s+issues?\s+open\b", re.IGNORECASE),
62
+ ]
63
+ WORD_NUMBERS = {
64
+ "one": 1,
65
+ "two": 2,
66
+ "three": 3,
67
+ "four": 4,
68
+ "five": 5,
69
+ "six": 6,
70
+ "seven": 7,
71
+ "eight": 8,
72
+ "nine": 9,
73
+ "ten": 10,
74
+ "eleven": 11,
75
+ "twelve": 12,
76
+ "thirteen": 13,
77
+ "fourteen": 14,
78
+ "fifteen": 15,
79
+ "sixteen": 16,
80
+ "seventeen": 17,
81
+ "eighteen": 18,
82
+ "nineteen": 19,
83
+ "twenty": 20,
84
+ }
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class DeskSignal:
89
+ source_name: str
90
+ active_count: int
91
+ bounty_usd: int
92
+ mtime: float
93
+
94
+
95
+ def utc_iso(epoch: float) -> str:
96
+ return (
97
+ datetime.fromtimestamp(epoch, tz=timezone.utc)
98
+ .replace(microsecond=0)
99
+ .isoformat()
100
+ .replace("+00:00", "Z")
101
+ )
102
+
103
+
104
+ def stable_json(payload: Any) -> str:
105
+ return json.dumps(payload, indent=2, sort_keys=True, ensure_ascii=False) + "\n"
106
+
107
+
108
+ def load_json(path: Path) -> dict[str, Any] | None:
109
+ try:
110
+ payload = json.loads(path.read_text(encoding="utf-8"))
111
+ except (FileNotFoundError, json.JSONDecodeError):
112
+ return None
113
+ return payload if isinstance(payload, dict) else None
114
+
115
+
116
+ def write_if_changed(path: Path, text: str) -> bool:
117
+ path.parent.mkdir(parents=True, exist_ok=True)
118
+ if path.exists() and path.read_text(encoding="utf-8") == text:
119
+ return False
120
+ tmp = path.with_suffix(path.suffix + ".tmp")
121
+ tmp.write_text(text, encoding="utf-8")
122
+ os.replace(tmp, path)
123
+ return True
124
+
125
+
126
+ def default_funded_source(product_repo: Path) -> Path:
127
+ return product_repo / "examples" / "funded-issues-readonly" / "issues.json"
128
+
129
+
130
+ def product_commit(product_repo: Path) -> dict[str, Any]:
131
+ try:
132
+ proc = subprocess.run(
133
+ ["git", "-C", str(product_repo), "log", "-1", "--format=%H%x00%ct%x00%s"],
134
+ text=True,
135
+ capture_output=True,
136
+ check=True,
137
+ )
138
+ except (OSError, subprocess.CalledProcessError):
139
+ return {"hash": None, "short": None, "committed_at": None, "subject": None}
140
+ parts = proc.stdout.rstrip("\n").split("\x00", 2)
141
+ if len(parts) != 3:
142
+ return {"hash": None, "short": None, "committed_at": None, "subject": None}
143
+ commit_hash, epoch_text, subject = parts
144
+ try:
145
+ committed_at = utc_iso(float(epoch_text))
146
+ except ValueError:
147
+ committed_at = None
148
+ return {
149
+ "hash": commit_hash,
150
+ "short": commit_hash[:7],
151
+ "committed_at": committed_at,
152
+ "subject": subject,
153
+ }
154
+
155
+
156
+ def source_for_text(text: str) -> str:
157
+ lower = text.lower()
158
+ for token, source_name in PLATFORM_TO_SOURCE.items():
159
+ if token in lower and source_name != "GitHub Issues":
160
+ return source_name
161
+ return "GitHub Issues"
162
+
163
+
164
+ def extract_active_count(text: str) -> int:
165
+ counts: list[int] = []
166
+ for pattern in COUNT_PATTERNS:
167
+ for match in pattern.finditer(text):
168
+ counts.append(int(match.group("count")))
169
+ word_pattern = re.compile(
170
+ r"\b(?P<word>"
171
+ + "|".join(WORD_NUMBERS)
172
+ + r")\s+(?:open\s+)?(?:paid\s+)?bount(?:y|ies)\s+issues?\b",
173
+ re.IGNORECASE,
174
+ )
175
+ for match in word_pattern.finditer(text):
176
+ counts.append(WORD_NUMBERS[match.group("word").lower()])
177
+ return max(counts) if counts else 0
178
+
179
+
180
+ def extract_bounty_usd(text: str) -> int:
181
+ amounts: list[int] = []
182
+ seen_spans: set[tuple[int, int]] = set()
183
+ patterns = [
184
+ re.compile(r"\[Bounty\s+\$([0-9][0-9,]*)\]", re.IGNORECASE),
185
+ re.compile(r"\bBounty\s+\$([0-9][0-9,]*)\b", re.IGNORECASE),
186
+ ]
187
+ for pattern in patterns:
188
+ for match in pattern.finditer(text):
189
+ if match.span(1) in seen_spans:
190
+ continue
191
+ seen_spans.add(match.span(1))
192
+ amounts.append(int(match.group(1).replace(",", "")))
193
+ return sum(amounts)
194
+
195
+
196
+ def iter_signal_files(desk_dir: Path | None) -> list[Path]:
197
+ if desk_dir is None:
198
+ return []
199
+ roots = [desk_dir / "research", desk_dir / "prospecting"]
200
+ files: list[Path] = []
201
+ for root in roots:
202
+ if not root.exists():
203
+ continue
204
+ for path in root.rglob("*"):
205
+ if not path.is_file():
206
+ continue
207
+ if "__pycache__" in path.parts or path.suffix.lower() not in TEXT_SUFFIXES:
208
+ continue
209
+ if SIGNAL_NAME_RE.search(path.name):
210
+ files.append(path)
211
+ return sorted(files)
212
+
213
+
214
+ def desk_signals(desk_dir: Path | None) -> list[DeskSignal]:
215
+ signals: list[DeskSignal] = []
216
+ for path in iter_signal_files(desk_dir):
217
+ try:
218
+ if path.stat().st_size > 1_000_000:
219
+ continue
220
+ text = path.read_text(encoding="utf-8", errors="ignore")
221
+ active_count = extract_active_count(text)
222
+ bounty_usd = extract_bounty_usd(text)
223
+ if active_count == 0 and bounty_usd == 0:
224
+ continue
225
+ signals.append(
226
+ DeskSignal(
227
+ source_name=source_for_text(text),
228
+ active_count=active_count,
229
+ bounty_usd=bounty_usd,
230
+ mtime=path.stat().st_mtime,
231
+ )
232
+ )
233
+ except OSError:
234
+ continue
235
+ return signals
236
+
237
+
238
+ def source_volumes(report: dict[str, Any], signals: list[DeskSignal]) -> dict[str, int]:
239
+ volumes = {name: 0 for name in SOURCE_NAMES}
240
+ for platform, count in report["breakdown"]["platforms"].items():
241
+ source_name = PLATFORM_TO_SOURCE.get(str(platform).lower(), "GitHub Issues")
242
+ volumes[source_name] += int(count)
243
+ for signal in signals:
244
+ volumes[signal.source_name] += signal.active_count
245
+ return volumes
246
+
247
+
248
+ def source_fingerprint(payload: Any) -> str:
249
+ digest = hashlib.sha256(stable_json(payload).encode("utf-8")).hexdigest()
250
+ return digest[:16]
251
+
252
+
253
+ def known_usd_from_issues(issues: list[FundedIssue]) -> int:
254
+ return int(
255
+ sum(
256
+ issue.funding_amount
257
+ for issue in issues
258
+ if issue.funding_currency == "USD" and issue.funding_amount is not None
259
+ )
260
+ )
261
+
262
+
263
+ def load_tracker_store_status(desk_dir: Path | None, now_iso: str) -> dict[str, Any] | None:
264
+ """Load the canonical tracker store and return its status plus live volumes.
265
+
266
+ Returns ``None`` when ``desk_dir`` is ``None``, the store file does not
267
+ exist, or the store cannot be read. Otherwise returns a mapping with the
268
+ ``store_status`` payload, the source file ``mtime``, and the volume of live
269
+ entries (states ``open`` / ``active``) per ``SOURCE_NAMES`` source.
270
+ """
271
+
272
+ if desk_dir is None:
273
+ return None
274
+ store_path = desk_dir / "tracker" / "funded-issues-store.json"
275
+ if not store_path.exists():
276
+ return None
277
+ try:
278
+ store = load_store(store_path)
279
+ status = store_status(store, now=now_iso)
280
+ mtime = store_path.stat().st_mtime
281
+ except (ValueError, OSError):
282
+ return None
283
+
284
+ live_by_source = {name: 0 for name in SOURCE_NAMES}
285
+ for entry in store.get("entries", {}).values():
286
+ if entry.get("state") not in ("open", "active"):
287
+ continue
288
+ platform = str((entry.get("issue") or {}).get("platform", "")).lower()
289
+ source_name = PLATFORM_TO_SOURCE.get(platform, "GitHub Issues")
290
+ live_by_source[source_name] += 1
291
+
292
+ return {
293
+ "status": status,
294
+ "mtime": mtime,
295
+ "live_by_source": live_by_source,
296
+ }
297
+
298
+
299
+ def build_payloads(
300
+ *,
301
+ web_dir: Path,
302
+ product_repo: Path,
303
+ funded_source: Path,
304
+ desk_dir: Path | None,
305
+ ) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any], dict[str, Any]]:
306
+ issues = load_funded_issues(funded_source)
307
+ report = report_funded_issues(issues)
308
+ score_report = score_funded_issues(issues)
309
+ signals = desk_signals(desk_dir)
310
+ volumes = source_volumes(report, signals)
311
+ commit = product_commit(product_repo)
312
+
313
+ now_epoch = datetime.now(tz=timezone.utc).timestamp()
314
+ now_iso = utc_iso(now_epoch)
315
+ day_ago = now_epoch - 86400
316
+ week_ago = now_epoch - (7 * 86400)
317
+
318
+ tracker = load_tracker_store_status(desk_dir, now_iso)
319
+
320
+ product_usd = known_usd_from_issues(issues)
321
+ evidence_usd_week = sum(signal.bounty_usd for signal in signals if signal.mtime >= week_ago)
322
+ heuristic_usd_week = product_usd + evidence_usd_week
323
+ new_24h = sum(signal.active_count for signal in signals if signal.mtime >= day_ago)
324
+ if funded_source.stat().st_mtime >= day_ago:
325
+ new_24h += report["totals"]["loaded"]
326
+
327
+ if tracker is not None:
328
+ store_summary = tracker["status"]
329
+ live_volumes = tracker["live_by_source"]
330
+ volumes = live_volumes
331
+ active_bounties = sum(live_volumes.values())
332
+ if store_summary["added_24h"] is not None:
333
+ new_24h = store_summary["added_24h"]
334
+ if store_summary["usd_entries"] > 0:
335
+ tracked_this_week_usd = int(round(store_summary["total_usd"]))
336
+ else:
337
+ tracked_this_week_usd = heuristic_usd_week
338
+ else:
339
+ active_bounties = sum(volumes.values())
340
+ tracked_this_week_usd = heuristic_usd_week
341
+
342
+ signal_mtimes = [signal.mtime for signal in signals]
343
+ as_of_inputs = [funded_source.stat().st_mtime, *signal_mtimes]
344
+ if tracker is not None:
345
+ as_of_inputs.append(tracker["mtime"])
346
+ as_of_epoch = max(as_of_inputs or [now_epoch])
347
+ as_of = utc_iso(as_of_epoch)
348
+
349
+ fingerprint_input = {
350
+ "funded_source_mtime": int(funded_source.stat().st_mtime),
351
+ "funded_source_size": funded_source.stat().st_size,
352
+ "report": report,
353
+ "signals": [
354
+ {
355
+ "source": signal.source_name,
356
+ "active_count": signal.active_count,
357
+ "bounty_usd": signal.bounty_usd,
358
+ "mtime": int(signal.mtime),
359
+ }
360
+ for signal in signals
361
+ ],
362
+ "product_commit": commit["hash"],
363
+ "volumes": volumes,
364
+ }
365
+ if tracker is not None:
366
+ store_status_payload = {
367
+ key: value for key, value in tracker["status"].items() if key != "now"
368
+ }
369
+ fingerprint_input["tracker_store"] = {
370
+ "store_status": store_status_payload,
371
+ "mtime": int(tracker["mtime"]),
372
+ }
373
+ fingerprint = source_fingerprint(fingerprint_input)
374
+
375
+ values = {
376
+ "tracked_this_week_usd": tracked_this_week_usd,
377
+ "active_bounties": active_bounties,
378
+ "sources_monitored": sum(1 for volume in volumes.values() if volume > 0),
379
+ "new_24h": new_24h,
380
+ }
381
+
382
+ if tracker is not None:
383
+ tracker_store_block = {
384
+ "present": True,
385
+ "total_entries": tracker["status"]["total_entries"],
386
+ "states": tracker["status"]["states"],
387
+ # Owner-level source-noise breakdown: tracked vs noise-flagged vs
388
+ # clean-active, so consumers stop counting trap entries as "active".
389
+ "tracked_total": tracker["status"]["tracked_total"],
390
+ "noise_flagged": tracker["status"]["noise_flagged"],
391
+ "clean_active": tracker["status"]["clean_active"],
392
+ "added_24h": tracker["status"]["added_24h"],
393
+ "total_usd": tracker["status"]["total_usd"],
394
+ "usd_entries": tracker["status"]["usd_entries"],
395
+ "live_by_source": tracker["live_by_source"],
396
+ }
397
+ else:
398
+ tracker_store_block = {"present": False}
399
+
400
+ evidence = {
401
+ "schema_version": "patchrail.web_evidence_metrics.v1",
402
+ "source_fingerprint": fingerprint,
403
+ "read_only": True,
404
+ "requirements": {
405
+ "network_required": False,
406
+ "github_write_permission_required": False,
407
+ "billing_required": False,
408
+ "external_model_required": False,
409
+ },
410
+ "product_commit": commit,
411
+ "funded_issues": {
412
+ "totals": report["totals"],
413
+ "breakdown": report["breakdown"],
414
+ "no_go_moat": report["no_go_moat"],
415
+ "known_usd_from_source": product_usd,
416
+ },
417
+ "desk_tracker": {
418
+ "evidence_files_scanned": len(iter_signal_files(desk_dir)),
419
+ "evidence_files_with_counts": len(signals),
420
+ "active_count_from_evidence": sum(signal.active_count for signal in signals),
421
+ "active_count_new_24h": sum(
422
+ signal.active_count for signal in signals if signal.mtime >= day_ago
423
+ ),
424
+ "known_bounty_usd_week": evidence_usd_week,
425
+ },
426
+ "tracker_store": tracker_store_block,
427
+ }
428
+
429
+ existing_landing = load_json(web_dir / "public" / "api" / "landing-metrics.json") or {}
430
+ existing_evidence = (
431
+ existing_landing.get("evidence")
432
+ if isinstance(existing_landing.get("evidence"), dict)
433
+ else {}
434
+ )
435
+ if (
436
+ existing_evidence.get("source_fingerprint") == fingerprint
437
+ and existing_landing.get("values") == values
438
+ and isinstance(existing_landing.get("loading_snapshot"), dict)
439
+ ):
440
+ loading_snapshot = existing_landing["loading_snapshot"]
441
+ else:
442
+ loading_snapshot = (
443
+ existing_landing.get("values")
444
+ if isinstance(existing_landing.get("values"), dict)
445
+ else values
446
+ )
447
+
448
+ landing_payload = {
449
+ "as_of": as_of,
450
+ "values": values,
451
+ "loading_snapshot": loading_snapshot,
452
+ "evidence": evidence,
453
+ }
454
+ sources_payload = {
455
+ "as_of": as_of,
456
+ "sources": [{"name": name, "volume": volumes[name]} for name in SOURCE_NAMES],
457
+ "evidence": {
458
+ "schema_version": "patchrail.source_volumes.v1",
459
+ "source_fingerprint": fingerprint,
460
+ "read_only": True,
461
+ "sources_with_volume": sum(1 for volume in volumes.values() if volume > 0),
462
+ },
463
+ }
464
+ product_payload = {
465
+ "as_of": as_of,
466
+ "schema_version": "patchrail.product_metrics.v1",
467
+ "product": {
468
+ "name": "PatchRail Bounty Radar",
469
+ "repository": "patchrail/patchrail",
470
+ "commit": commit,
471
+ },
472
+ "tracker": {
473
+ "active_bounties": values["active_bounties"],
474
+ "tracked_this_week_usd": values["tracked_this_week_usd"],
475
+ "sources_monitored": values["sources_monitored"],
476
+ "new_24h": values["new_24h"],
477
+ "source_fingerprint": fingerprint,
478
+ },
479
+ "readiness": {
480
+ "funded_issues_loaded": report["totals"]["loaded"],
481
+ "safe_to_list": report["totals"]["safe_to_list"],
482
+ "high_risk": report["totals"]["high_risk"],
483
+ "go_candidates": score_report["rating_counts"].get("go_candidate", 0),
484
+ "watchlist": score_report["rating_counts"].get("watchlist", 0),
485
+ "no_go": score_report["rating_counts"].get("no_go", 0),
486
+ "known_usd_from_source": product_usd,
487
+ },
488
+ "opportunity_desk": {
489
+ "evidence_files_scanned": len(iter_signal_files(desk_dir)),
490
+ "evidence_files_with_counts": len(signals),
491
+ "active_count_from_evidence": sum(signal.active_count for signal in signals),
492
+ "known_bounty_usd_week": evidence_usd_week,
493
+ },
494
+ "tracker_store": tracker_store_block,
495
+ "automation": {
496
+ "generated_by": "patchrail web-metrics update",
497
+ "static_api_files": [
498
+ "public/api/landing-metrics.json",
499
+ "public/api/sources-volumes.json",
500
+ "public/api/product-metrics.json",
501
+ ],
502
+ "read_only": True,
503
+ "network_required": False,
504
+ "github_write_permission_required": False,
505
+ },
506
+ "requirements": {
507
+ "network_required": False,
508
+ "github_write_permission_required": False,
509
+ "billing_required": False,
510
+ "external_model_required": False,
511
+ },
512
+ }
513
+ summary = {
514
+ "status": "prepared",
515
+ "fingerprint": fingerprint,
516
+ "as_of": as_of,
517
+ "values": values,
518
+ "source_volumes": {name: volumes[name] for name in SOURCE_NAMES if volumes[name] > 0},
519
+ "product_metrics": {
520
+ "safe_to_list": product_payload["readiness"]["safe_to_list"],
521
+ "go_candidates": product_payload["readiness"]["go_candidates"],
522
+ "no_go": product_payload["readiness"]["no_go"],
523
+ "evidence_files_scanned": product_payload["opportunity_desk"]["evidence_files_scanned"],
524
+ },
525
+ "product_commit": commit["short"],
526
+ }
527
+ return landing_payload, sources_payload, product_payload, summary
528
+
529
+
530
+ def update_web_metrics(
531
+ *,
532
+ web_dir: Path,
533
+ product_repo: Path,
534
+ desk_dir: Path | None = None,
535
+ funded_source: Path | None = None,
536
+ dry_run: bool = False,
537
+ ) -> dict[str, Any]:
538
+ web_dir = web_dir.resolve()
539
+ product_repo = product_repo.resolve()
540
+ desk_dir = desk_dir.resolve() if desk_dir is not None else None
541
+ funded_source = (
542
+ funded_source.resolve()
543
+ if funded_source is not None
544
+ else default_funded_source(product_repo)
545
+ )
546
+
547
+ landing_payload, sources_payload, product_payload, summary = build_payloads(
548
+ web_dir=web_dir,
549
+ product_repo=product_repo,
550
+ funded_source=funded_source,
551
+ desk_dir=desk_dir,
552
+ )
553
+
554
+ landing_path = web_dir / "public" / "api" / "landing-metrics.json"
555
+ sources_path = web_dir / "public" / "api" / "sources-volumes.json"
556
+ product_path = web_dir / "public" / "api" / "product-metrics.json"
557
+ changed_paths: list[str] = []
558
+ if dry_run:
559
+ if not landing_path.exists() or landing_path.read_text(encoding="utf-8") != stable_json(
560
+ landing_payload
561
+ ):
562
+ changed_paths.append("public/api/landing-metrics.json")
563
+ if not sources_path.exists() or sources_path.read_text(encoding="utf-8") != stable_json(
564
+ sources_payload
565
+ ):
566
+ changed_paths.append("public/api/sources-volumes.json")
567
+ if not product_path.exists() or product_path.read_text(encoding="utf-8") != stable_json(
568
+ product_payload
569
+ ):
570
+ changed_paths.append("public/api/product-metrics.json")
571
+ else:
572
+ if write_if_changed(landing_path, stable_json(landing_payload)):
573
+ changed_paths.append("public/api/landing-metrics.json")
574
+ if write_if_changed(sources_path, stable_json(sources_payload)):
575
+ changed_paths.append("public/api/sources-volumes.json")
576
+ if write_if_changed(product_path, stable_json(product_payload)):
577
+ changed_paths.append("public/api/product-metrics.json")
578
+
579
+ status = "updated" if changed_paths else "unchanged"
580
+ if dry_run and changed_paths:
581
+ status = "would_update"
582
+ return {
583
+ **summary,
584
+ "status": status,
585
+ "changed": bool(changed_paths),
586
+ "written": [] if dry_run else changed_paths,
587
+ "would_write": changed_paths if dry_run else [],
588
+ }
589
+
590
+
591
+ def render_text(summary: dict[str, Any]) -> str:
592
+ values = summary["values"]
593
+ lines = [
594
+ f"Status: {summary['status']}",
595
+ f"Fingerprint: {summary['fingerprint']}",
596
+ f"As of: {summary['as_of']}",
597
+ f"Tracked this week USD: {values['tracked_this_week_usd']}",
598
+ f"Active bounties: {values['active_bounties']}",
599
+ f"Sources monitored: {values['sources_monitored']}",
600
+ f"New 24h: {values['new_24h']}",
601
+ f"Safe-to-list funded issues: {summary['product_metrics']['safe_to_list']}",
602
+ f"Go candidates: {summary['product_metrics']['go_candidates']}",
603
+ ]
604
+ written = summary.get("written") or summary.get("would_write") or []
605
+ lines.append(f"Files changed: {', '.join(written) if written else 'none'}")
606
+ return "\n".join(lines) + "\n"
607
+
608
+
609
+ def build_arg_parser() -> argparse.ArgumentParser:
610
+ parser = argparse.ArgumentParser(
611
+ description="Update PatchRail website metrics from read-only product/tracker evidence."
612
+ )
613
+ parser.add_argument("--web-dir", type=Path, required=True)
614
+ parser.add_argument("--product-repo", type=Path, default=Path("."))
615
+ parser.add_argument("--desk-dir", type=Path)
616
+ parser.add_argument("--funded-source", type=Path)
617
+ parser.add_argument("--dry-run", action="store_true")
618
+ parser.add_argument(
619
+ "--format",
620
+ choices=["json", "text"],
621
+ default="json",
622
+ help="Output format.",
623
+ )
624
+ return parser
625
+
626
+
627
+ def run_from_args(args: argparse.Namespace) -> dict[str, Any]:
628
+ return update_web_metrics(
629
+ web_dir=args.web_dir,
630
+ product_repo=args.product_repo,
631
+ desk_dir=args.desk_dir,
632
+ funded_source=args.funded_source,
633
+ dry_run=args.dry_run,
634
+ )
635
+
636
+
637
+ def main(argv: list[str] | None = None) -> int:
638
+ parser = build_arg_parser()
639
+ args = parser.parse_args(argv)
640
+ try:
641
+ summary = run_from_args(args)
642
+ except Exception as exc:
643
+ print(json.dumps({"status": "failed", "error": str(exc)}, sort_keys=True))
644
+ return 1
645
+ if args.format == "text":
646
+ print(render_text(summary), end="")
647
+ else:
648
+ print(json.dumps(summary, sort_keys=True))
649
+ return 0