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,459 @@
1
+ """Persistent local JSON store for the read-only funded-issues tracker.
2
+
3
+ This module keeps a small append/update store of already-discovered funded
4
+ issues so the read-only tracker can answer "what is new since last time" and
5
+ "how has the public state of this opportunity changed" without ever touching a
6
+ third party. It performs zero network calls and never claims, comments on, or
7
+ otherwise writes to any funded issue: inputs are normalized records produced by
8
+ ``load_funded_issues`` / the importers, merged into a local file keyed by the
9
+ canonical issue URL.
10
+
11
+ Determinism: every mutation takes an explicit ``now`` ISO-8601 UTC timestamp so
12
+ tests (and the CLI ``--now`` flag) get reproducible output. Re-merging the same
13
+ inputs is idempotent -- only ``last_checked`` moves.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ from dataclasses import dataclass, field
20
+ from datetime import datetime, timedelta
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ from patchrail.funded_issues.blocklist import is_blocklisted_record
25
+ from patchrail.funded_issues.discovery import (
26
+ BLOCKED_ACTIONS,
27
+ SCHEMA_VERSION,
28
+ VALID_OPPORTUNITY_STATES,
29
+ FundedIssue,
30
+ )
31
+
32
+ STORE_SCHEMA_VERSION = "patchrail.funded_issues.store.v1"
33
+ STORE_STATUS_SCHEMA_VERSION = "patchrail.funded_issues.store_status.v1"
34
+ RECHECK_SUMMARY_SCHEMA_VERSION = "patchrail.funded_issues.recheck_summary.v1"
35
+
36
+ # State vocabulary tracked per entry. ``open`` is accepted as an inbound alias
37
+ # for ``active`` so imported provider exports that label issues "open" land in a
38
+ # single canonical state, matching the discovery normalizer.
39
+ VALID_STORE_STATES = VALID_OPPORTUNITY_STATES | {"open"}
40
+ _STATE_ALIASES = {"open": "active"}
41
+
42
+ _SAFE_REQUIREMENTS = {
43
+ "network_required": False,
44
+ "github_write_permission_required": False,
45
+ "external_model_required": False,
46
+ "billing_required": False,
47
+ }
48
+
49
+
50
+ def _normalize_store_state(value: Any) -> str:
51
+ if value is None:
52
+ return "unknown"
53
+ normalized = str(value).strip().lower().replace("-", "_").replace(" ", "_")
54
+ normalized = _STATE_ALIASES.get(normalized, normalized)
55
+ return normalized if normalized in VALID_OPPORTUNITY_STATES else "unknown"
56
+
57
+
58
+ @dataclass
59
+ class MergeSummary:
60
+ """Counts describing what a :func:`merge_into_store` call changed."""
61
+
62
+ added: int = 0
63
+ updated: int = 0
64
+ transitioned: int = 0
65
+ unchanged: int = 0
66
+ blocked: int = 0
67
+ transitions: list[dict[str, Any]] = field(default_factory=list)
68
+
69
+ def to_dict(self) -> dict[str, Any]:
70
+ return {
71
+ "added": self.added,
72
+ "updated": self.updated,
73
+ "transitioned": self.transitioned,
74
+ "unchanged": self.unchanged,
75
+ "blocked": self.blocked,
76
+ "transitions": list(self.transitions),
77
+ }
78
+
79
+
80
+ @dataclass
81
+ class RecheckSummary:
82
+ """Counts describing what an :func:`apply_recheck_to_store` call changed.
83
+
84
+ Observations whose URL is not present in the store are ignored for state
85
+ purposes and counted under ``unmatched``. ``checked`` counts every inbound
86
+ observation; ``matched`` counts the subset that hit an existing entry.
87
+ """
88
+
89
+ checked: int = 0
90
+ matched: int = 0
91
+ unmatched: int = 0
92
+ unchanged: int = 0
93
+ to_closed: int = 0
94
+ to_stale: int = 0
95
+ to_active: int = 0
96
+ transitions: list[dict[str, Any]] = field(default_factory=list)
97
+
98
+ def to_dict(self) -> dict[str, Any]:
99
+ return {
100
+ "checked": self.checked,
101
+ "matched": self.matched,
102
+ "unmatched": self.unmatched,
103
+ "transitions": {
104
+ "to_closed": self.to_closed,
105
+ "to_stale": self.to_stale,
106
+ "to_active": self.to_active,
107
+ },
108
+ "unchanged": self.unchanged,
109
+ "transition_log": list(self.transitions),
110
+ }
111
+
112
+
113
+ def empty_store() -> dict[str, Any]:
114
+ """Return a fresh, valid store object with no entries."""
115
+
116
+ return {
117
+ "schema_version": STORE_SCHEMA_VERSION,
118
+ "source_schema_version": SCHEMA_VERSION,
119
+ "read_only": True,
120
+ "blocked_actions": list(BLOCKED_ACTIONS),
121
+ "requirements": dict(_SAFE_REQUIREMENTS),
122
+ "entries": {},
123
+ }
124
+
125
+
126
+ def load_store(path: Path) -> dict[str, Any]:
127
+ """Load a store file, or return an empty store when the file is absent.
128
+
129
+ A missing file is treated as an empty store so the first ``track`` run does
130
+ not require a bootstrap step. An existing file must carry the expected
131
+ ``schema_version``.
132
+ """
133
+
134
+ path = Path(path)
135
+ if not path.exists():
136
+ return empty_store()
137
+ payload = json.loads(path.read_text(encoding="utf-8"))
138
+ if not isinstance(payload, dict):
139
+ raise ValueError("store source must contain an object")
140
+ if payload.get("schema_version") != STORE_SCHEMA_VERSION:
141
+ raise ValueError(f"store must use schema_version {STORE_SCHEMA_VERSION}")
142
+ entries = payload.get("entries")
143
+ if not isinstance(entries, dict):
144
+ raise ValueError("store must contain an entries object")
145
+ store = empty_store()
146
+ store["entries"] = {str(url): dict(entry) for url, entry in entries.items()}
147
+ return store
148
+
149
+
150
+ def save_store(path: Path, store: dict[str, Any]) -> None:
151
+ """Write ``store`` to ``path`` as canonical, sorted JSON."""
152
+
153
+ path = Path(path)
154
+ if path.parent != Path(""):
155
+ path.parent.mkdir(parents=True, exist_ok=True)
156
+ path.write_text(json.dumps(store, indent=2, sort_keys=True) + "\n", encoding="utf-8")
157
+
158
+
159
+ def _issue_record(issue: FundedIssue | dict[str, Any]) -> dict[str, Any]:
160
+ if isinstance(issue, FundedIssue):
161
+ return issue.to_dict()
162
+ if isinstance(issue, dict):
163
+ return dict(issue)
164
+ raise ValueError("each issue must be a FundedIssue or a normalized issue mapping")
165
+
166
+
167
+ def _issue_url(record: dict[str, Any]) -> str:
168
+ url = record.get("url")
169
+ if not url:
170
+ raise ValueError("each issue must carry a canonical url")
171
+ return str(url)
172
+
173
+
174
+ def _issue_state(record: dict[str, Any]) -> str:
175
+ return _normalize_store_state(record.get("opportunity_state"))
176
+
177
+
178
+ def _issue_score(issue: FundedIssue | dict[str, Any], record: dict[str, Any]) -> int | None:
179
+ if isinstance(issue, dict):
180
+ score = issue.get("score")
181
+ if score is not None:
182
+ return int(score)
183
+ score = record.get("score")
184
+ return int(score) if score is not None else None
185
+
186
+
187
+ def merge_into_store(
188
+ store: dict[str, Any],
189
+ issues: list[FundedIssue | dict[str, Any]],
190
+ now: str,
191
+ ) -> MergeSummary:
192
+ """Incrementally merge ``issues`` into ``store`` in place.
193
+
194
+ For each issue (keyed by canonical URL):
195
+
196
+ * new URL -> add the entry with ``first_seen`` / ``last_seen`` /
197
+ ``last_checked`` set to ``now`` and an initial ``state_history`` entry.
198
+ * known URL -> refresh ``last_seen`` / ``last_checked`` and the stored issue
199
+ record; append a ``state_history`` transition only when the normalized
200
+ state actually changed.
201
+
202
+ Merging the same inputs twice is idempotent apart from ``last_checked``.
203
+ Records owned by a permanently blocklisted source
204
+ (:mod:`patchrail.funded_issues.blocklist`) are dropped before any other
205
+ handling and counted under ``blocked`` -- this is the single choke point
206
+ through which issues enter a store, so a blocklisted owner can never
207
+ re-enter one. Returns a :class:`MergeSummary` of what changed.
208
+ """
209
+
210
+ entries = store.setdefault("entries", {})
211
+ summary = MergeSummary()
212
+
213
+ for issue in issues:
214
+ record = _issue_record(issue)
215
+ if is_blocklisted_record(record):
216
+ summary.blocked += 1
217
+ continue
218
+ url = _issue_url(record)
219
+ state = _issue_state(record)
220
+ score = _issue_score(issue, record)
221
+ existing = entries.get(url)
222
+
223
+ if existing is None:
224
+ entry = {
225
+ "issue": record,
226
+ "first_seen": now,
227
+ "last_seen": now,
228
+ "last_checked": now,
229
+ "state": state,
230
+ "state_history": [{"state": state, "at": now, "from": None}],
231
+ # Owner-level source-noise flags are populated out of band by
232
+ # patchrail.funded_issues.source_noise; default empty so every
233
+ # entry carries the field and it survives recheck/merge.
234
+ "noise_flags": [],
235
+ }
236
+ if score is not None:
237
+ entry["score"] = score
238
+ entries[url] = entry
239
+ summary.added += 1
240
+ continue
241
+
242
+ previous_state = existing.get("state")
243
+ # Backfill the field on legacy entries without disturbing the existing
244
+ # owner verdict; a real re-assessment goes through source_noise.
245
+ existing.setdefault("noise_flags", [])
246
+ # last_checked always advances; it is the one field allowed to move on a
247
+ # no-op re-merge, so it never counts as an "update" by itself.
248
+ existing["last_checked"] = now
249
+ existing["last_seen"] = now
250
+
251
+ changed = False
252
+ if existing.get("issue") != record:
253
+ existing["issue"] = record
254
+ changed = True
255
+ if score is not None and existing.get("score") != score:
256
+ existing["score"] = score
257
+ changed = True
258
+
259
+ if state != previous_state:
260
+ transition = {"state": state, "at": now, "from": previous_state}
261
+ existing.setdefault("state_history", []).append(transition)
262
+ existing["state"] = state
263
+ summary.transitioned += 1
264
+ summary.transitions.append({"url": url, **transition})
265
+ elif changed:
266
+ summary.updated += 1
267
+ else:
268
+ summary.unchanged += 1
269
+
270
+ return summary
271
+
272
+
273
+ def _observation_url(observation: dict[str, Any]) -> str | None:
274
+ url = observation.get("url")
275
+ if not url:
276
+ return None
277
+ return str(url)
278
+
279
+
280
+ def _recheck_target_state(
281
+ observation: dict[str, Any],
282
+ *,
283
+ now_dt: datetime,
284
+ stale_after_days: int,
285
+ ) -> str:
286
+ """Map a single recheck observation to a canonical opportunity state.
287
+
288
+ ``state=closed`` always wins. An ``open`` observation becomes ``stale`` once
289
+ its ``updated_at`` is strictly older than ``stale_after_days`` days relative
290
+ to ``now``; otherwise it is ``active`` (which also revives a stale entry).
291
+ A fresh ``open`` with an unparseable/absent ``updated_at`` is treated as
292
+ ``active`` -- we never invent staleness from missing data.
293
+ """
294
+
295
+ raw_state = observation.get("state")
296
+ normalized = str(raw_state).strip().lower() if raw_state is not None else ""
297
+ if normalized == "closed":
298
+ return "closed"
299
+
300
+ updated_at = observation.get("updated_at")
301
+ if updated_at:
302
+ try:
303
+ updated_dt = _parse_iso(str(updated_at))
304
+ except ValueError:
305
+ return "active"
306
+ if now_dt - updated_dt > timedelta(days=stale_after_days):
307
+ return "stale"
308
+ return "active"
309
+
310
+
311
+ def apply_recheck_to_store(
312
+ store: dict[str, Any],
313
+ observations: list[dict[str, Any]],
314
+ now: str,
315
+ stale_after_days: int = 45,
316
+ ) -> RecheckSummary:
317
+ """Apply read-only recheck ``observations`` to ``store`` in place.
318
+
319
+ Each observation is a mapping carrying at least a ``url`` (matched against
320
+ stored entries with the same canonical-URL criterion as
321
+ :func:`merge_into_store`) and a ``state`` (``"open"`` or ``"closed"``). It
322
+ may also carry ``updated_at`` / ``closed_at`` (ISO-8601) and the lightweight
323
+ public signals ``assignee_count`` and ``comments``.
324
+
325
+ State rules, evaluated against ``now``:
326
+
327
+ * ``state=closed`` -> opportunity_state ``"closed"``.
328
+ * ``state=open`` and ``now - updated_at`` exceeds ``stale_after_days`` days
329
+ -> ``"stale"``.
330
+ * ``state=open`` and fresh -> ``"active"`` (this also revives a previously
331
+ ``stale`` entry back to ``active``).
332
+
333
+ ``last_checked`` always advances for a matched entry. A ``state_history``
334
+ transition (same shape as :func:`merge_into_store`) is appended only on a
335
+ real state change, so a second identical pass yields zero transitions.
336
+ Observations whose URL is unknown to the store are ignored and counted as
337
+ ``unmatched``. Returns a :class:`RecheckSummary`.
338
+
339
+ ``now`` must be a parseable ISO-8601 timestamp; otherwise ``ValueError`` is
340
+ raised before any mutation occurs.
341
+ """
342
+
343
+ now_dt = _parse_iso(now)
344
+ entries = store.setdefault("entries", {})
345
+ summary = RecheckSummary()
346
+
347
+ for observation in observations:
348
+ summary.checked += 1
349
+ url = _observation_url(observation)
350
+ existing = entries.get(url) if url is not None else None
351
+ if existing is None:
352
+ summary.unmatched += 1
353
+ continue
354
+
355
+ summary.matched += 1
356
+ previous_state = existing.get("state")
357
+ existing["last_checked"] = now
358
+
359
+ target_state = _recheck_target_state(
360
+ observation,
361
+ now_dt=now_dt,
362
+ stale_after_days=stale_after_days,
363
+ )
364
+
365
+ if target_state != previous_state:
366
+ transition = {"state": target_state, "at": now, "from": previous_state}
367
+ existing.setdefault("state_history", []).append(transition)
368
+ existing["state"] = target_state
369
+ summary.transitions.append({"url": url, **transition})
370
+ if target_state == "closed":
371
+ summary.to_closed += 1
372
+ elif target_state == "stale":
373
+ summary.to_stale += 1
374
+ else:
375
+ summary.to_active += 1
376
+ else:
377
+ summary.unchanged += 1
378
+
379
+ return summary
380
+
381
+
382
+ def _added_within(entries: dict[str, Any], now: str, *, hours: int) -> int | None:
383
+ try:
384
+ reference = _parse_iso(now)
385
+ except ValueError:
386
+ return None
387
+ window = timedelta(hours=hours)
388
+ count = 0
389
+ for entry in entries.values():
390
+ first_seen = entry.get("first_seen")
391
+ if not first_seen:
392
+ return None
393
+ try:
394
+ seen = _parse_iso(str(first_seen))
395
+ except ValueError:
396
+ return None
397
+ delta = reference - seen
398
+ if timedelta(0) <= delta <= window:
399
+ count += 1
400
+ return count
401
+
402
+
403
+ def _parse_iso(value: str) -> datetime:
404
+ text = value.strip()
405
+ if text.endswith("Z"):
406
+ text = text[:-1] + "+00:00"
407
+ return datetime.fromisoformat(text)
408
+
409
+
410
+ def store_status(store: dict[str, Any], now: str | None = None) -> dict[str, Any]:
411
+ """Build a read-only summary payload for a store.
412
+
413
+ Aggregates totals by tracked state, the total USD across entries that carry
414
+ an amount, and (when ``now`` is supplied and all entries carry parseable
415
+ ``first_seen`` timestamps) the number of entries first seen in the last 24h.
416
+ """
417
+
418
+ entries = store.get("entries", {})
419
+ states: dict[str, int] = {state: 0 for state in sorted(VALID_OPPORTUNITY_STATES)}
420
+ total_usd = 0.0
421
+ usd_entries = 0
422
+ noise_flagged = 0
423
+ clean_active = 0
424
+ for entry in entries.values():
425
+ state = _normalize_store_state(entry.get("state"))
426
+ states[state] = states.get(state, 0) + 1
427
+ is_noise = bool(entry.get("noise_flags"))
428
+ if is_noise:
429
+ noise_flagged += 1
430
+ elif state == "active":
431
+ clean_active += 1
432
+ funding = (entry.get("issue") or {}).get("funding") or {}
433
+ amount = funding.get("amount")
434
+ currency = funding.get("currency")
435
+ if amount is not None and str(currency).upper() == "USD":
436
+ total_usd += float(amount)
437
+ usd_entries += 1
438
+
439
+ added_24h = _added_within(entries, now, hours=24) if now is not None else None
440
+
441
+ return {
442
+ "schema_version": STORE_STATUS_SCHEMA_VERSION,
443
+ "source_schema_version": SCHEMA_VERSION,
444
+ "read_only": True,
445
+ "blocked_actions": list(BLOCKED_ACTIONS),
446
+ "requirements": dict(_SAFE_REQUIREMENTS),
447
+ "now": now,
448
+ "total_entries": len(entries),
449
+ "states": states,
450
+ # Owner-level source-noise breakdown: separate the noise-flagged entries
451
+ # from the clean live ones instead of reporting a single inflated
452
+ # "active" total. ``tracked_total`` mirrors ``total_entries``.
453
+ "tracked_total": len(entries),
454
+ "noise_flagged": noise_flagged,
455
+ "clean_active": clean_active,
456
+ "added_24h": added_24h,
457
+ "total_usd": round(total_usd, 2) if usd_entries else None,
458
+ "usd_entries": usd_entries,
459
+ }
@@ -0,0 +1,75 @@
1
+ """Local work queue for reviewable maintainer automation."""
2
+
3
+ from patchrail.queue.store import (
4
+ AuditEvent,
5
+ DEFAULT_QUEUE_PATH,
6
+ ProposalRecord,
7
+ QueueItem,
8
+ add_proposal,
9
+ add_work_item,
10
+ approve_proposal,
11
+ approve_work_item,
12
+ export_audit_events,
13
+ export_work_items,
14
+ init_queue,
15
+ list_audit_events,
16
+ list_proposals,
17
+ list_work_items,
18
+ reject_proposal,
19
+ reject_work_item,
20
+ show_proposal,
21
+ show_work_item,
22
+ skip_work_item,
23
+ )
24
+ from patchrail.queue.status import (
25
+ QUEUE_BUNDLE_SCHEMA_VERSION,
26
+ QUEUE_POLICY_SCAN_SCHEMA_VERSION,
27
+ QUEUE_REVIEW_SCHEMA_VERSION,
28
+ QUEUE_STATUS_SCHEMA_VERSION,
29
+ SAFE_QUEUE_REQUIREMENTS,
30
+ SAFE_QUEUE_STATUS,
31
+ queue_bundle_payload,
32
+ queue_policy_scan_payload,
33
+ queue_review_payload,
34
+ queue_status_payload,
35
+ )
36
+ from patchrail.queue.server import (
37
+ handle_queue_api_request,
38
+ make_queue_api_handler,
39
+ serve_queue_api,
40
+ )
41
+
42
+ __all__ = [
43
+ "AuditEvent",
44
+ "ProposalRecord",
45
+ "QueueItem",
46
+ "DEFAULT_QUEUE_PATH",
47
+ "add_proposal",
48
+ "add_work_item",
49
+ "approve_proposal",
50
+ "approve_work_item",
51
+ "export_audit_events",
52
+ "export_work_items",
53
+ "init_queue",
54
+ "list_audit_events",
55
+ "list_proposals",
56
+ "list_work_items",
57
+ "reject_proposal",
58
+ "reject_work_item",
59
+ "show_proposal",
60
+ "show_work_item",
61
+ "skip_work_item",
62
+ "QUEUE_BUNDLE_SCHEMA_VERSION",
63
+ "QUEUE_POLICY_SCAN_SCHEMA_VERSION",
64
+ "QUEUE_REVIEW_SCHEMA_VERSION",
65
+ "QUEUE_STATUS_SCHEMA_VERSION",
66
+ "SAFE_QUEUE_REQUIREMENTS",
67
+ "SAFE_QUEUE_STATUS",
68
+ "queue_bundle_payload",
69
+ "queue_policy_scan_payload",
70
+ "queue_review_payload",
71
+ "queue_status_payload",
72
+ "handle_queue_api_request",
73
+ "make_queue_api_handler",
74
+ "serve_queue_api",
75
+ ]