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
|
@@ -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
|
+
]
|