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.
- patchrail/__init__.py +7 -0
- patchrail/__main__.py +7 -0
- patchrail/ci/__init__.py +7 -0
- patchrail/ci/classify.py +888 -0
- patchrail/cli.py +8566 -0
- patchrail/funded_issues/__init__.py +138 -0
- patchrail/funded_issues/algora_board.py +240 -0
- patchrail/funded_issues/blocklist.py +112 -0
- patchrail/funded_issues/discovery.py +4091 -0
- patchrail/funded_issues/importers.py +316 -0
- patchrail/funded_issues/source_noise.py +349 -0
- patchrail/funded_issues/store.py +459 -0
- patchrail/queue/__init__.py +75 -0
- patchrail/queue/server.py +273 -0
- patchrail/queue/status.py +756 -0
- patchrail/queue/store.py +600 -0
- patchrail/reviewer_quick_check.py +650 -0
- patchrail/schemas/__init__.py +1 -0
- patchrail/schemas/application-dossier.v1.schema.json +305 -0
- patchrail/schemas/ci-benchmark.v1.schema.json +174 -0
- patchrail/schemas/ci-fixture-check.v1.schema.json +122 -0
- patchrail/schemas/ci-pilot-metrics.v1.schema.json +164 -0
- patchrail/schemas/ci-pilot-summary.v1.schema.json +146 -0
- patchrail/schemas/ci-result.v1.schema.json +133 -0
- patchrail/schemas/funded-issues-client-report.v1.schema.json +524 -0
- patchrail/schemas/funded-issues-recheck-queue.v1.schema.json +333 -0
- patchrail/schemas/funded-issues-recheck-summary.v1.schema.json +136 -0
- patchrail/schemas/funded-issues-report.v1.schema.json +836 -0
- patchrail/schemas/funded-issues-shortlist.v1.schema.json +953 -0
- patchrail/schemas/funded-issues-store-status.v1.schema.json +96 -0
- patchrail/schemas/funded-issues-store.v1.schema.json +117 -0
- patchrail/schemas/queue-audit-event.v1.schema.json +44 -0
- patchrail/schemas/queue-audit-summary.v1.schema.json +169 -0
- patchrail/schemas/queue-gate-report.v1.schema.json +158 -0
- patchrail/schemas/queue-policy-resolution.v1.schema.json +188 -0
- patchrail/schemas/queue-policy-scan.v1.schema.json +175 -0
- patchrail/schemas/queue-proposal.v1.schema.json +61 -0
- patchrail/schemas/queue-review.v1.schema.json +218 -0
- patchrail/schemas/queue-status.v1.schema.json +179 -0
- patchrail/schemas/queue-work-item.v1.schema.json +64 -0
- patchrail/schemas/reviewer-quick-check-artifacts.v1.schema.json +104 -0
- patchrail/web_metrics.py +649 -0
- patchrail-0.1.0.dist-info/METADATA +279 -0
- patchrail-0.1.0.dist-info/RECORD +47 -0
- patchrail-0.1.0.dist-info/WHEEL +4 -0
- patchrail-0.1.0.dist-info/entry_points.txt +2 -0
- patchrail-0.1.0.dist-info/licenses/LICENSE +202 -0
patchrail/web_metrics.py
ADDED
|
@@ -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
|