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,4091 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from collections import Counter
5
+ from dataclasses import dataclass, field
6
+ from datetime import date, datetime
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+
11
+ SCHEMA_VERSION = "patchrail.funded_issues.v1"
12
+ CLIENT_PROFILE_SCHEMA_VERSION = "patchrail.funded_issues.client_profile.v1"
13
+ VALIDATION_SCHEMA_VERSION = "patchrail.funded_issues.validation.v1"
14
+ SHORTLIST_SCHEMA_VERSION = "patchrail.funded_issues.shortlist.v1"
15
+ CLIENT_REPORT_SCHEMA_VERSION = "patchrail.funded_issues.client_report.v1"
16
+ RECHECK_QUEUE_SCHEMA_VERSION = "patchrail.funded_issues.recheck_queue.v1"
17
+ CASH_ACTIONS_SCHEMA_VERSION = "patchrail.funded_issues.cash_actions.v1"
18
+ FULFILLMENT_PACKET_SCHEMA_VERSION = "patchrail.funded_issues.fulfillment_packet.v1"
19
+ COMPETITION_SIGNAL_SCHEMA_VERSION = "patchrail.funded_issues.competition.v1"
20
+ COMPETITION_BATCH_SCHEMA_VERSION = "patchrail.funded_issues.competition_batch.v1"
21
+ PAYOUT_EFFORT_SIGNAL_SCHEMA_VERSION = "patchrail.funded_issues.payout_effort.v1"
22
+ PAYOUT_EFFORT_BATCH_SCHEMA_VERSION = "patchrail.funded_issues.payout_effort_batch.v1"
23
+ STALENESS_SIGNAL_SCHEMA_VERSION = "patchrail.funded_issues.staleness.v1"
24
+ STALENESS_BATCH_SCHEMA_VERSION = "patchrail.funded_issues.staleness_batch.v1"
25
+ TESTABILITY_SIGNAL_SCHEMA_VERSION = "patchrail.funded_issues.testability.v1"
26
+ TESTABILITY_BATCH_SCHEMA_VERSION = "patchrail.funded_issues.testability_batch.v1"
27
+ BLOCKED_ACTIONS = [
28
+ "automatic_claims",
29
+ "automatic_pull_requests",
30
+ "automatic_issue_comments",
31
+ "mass_outreach",
32
+ "ranking_by_money_only",
33
+ ]
34
+
35
+ HIGH_RISK_FLAGS = {
36
+ "ambiguous_scope",
37
+ "bounty_farming_language",
38
+ "closed_or_inactive",
39
+ "requires_external_contact",
40
+ "no_contribution_guidelines",
41
+ "spam_attractive",
42
+ "stale_no_maintainer_signal",
43
+ }
44
+ PRIMARY_SOURCE_REVIEW_FLAGS = {
45
+ "aggregator_only_source",
46
+ "discovery_only_source",
47
+ "primary_source_required",
48
+ }
49
+
50
+ # Competition / noise-trap signal flags. These are intentionally NOT in
51
+ # HIGH_RISK_FLAGS: a contested bounty can still be a real, winnable issue, so it
52
+ # costs score and confidence (via the generic risk-flag penalty) without forcing
53
+ # an automatic no-go. They productize the per-prospect recon the desk does by
54
+ # hand (e.g. counting competing PRs and comment volume on a sponsor's bounties).
55
+ CONTESTED_BOUNTY_FLAG = "contested_bounty"
56
+ CROWDED_NO_ASSIGNMENT_FLAG = "crowded_no_assignment"
57
+ COMPETITION_THRESHOLDS = {
58
+ "competing_pr_count_contested": 3,
59
+ "distinct_claimants_contested": 3,
60
+ "comment_count_busy": 15,
61
+ }
62
+
63
+ # Payout-vs-effort signal. The flag is intentionally NOT in HIGH_RISK_FLAGS: a
64
+ # low-paying bounty can still be worth doing for portfolio/strategic reasons, so
65
+ # it costs score via the generic risk penalty without forcing an automatic
66
+ # no-go. Productizes the desk's effort-budget floor (REVENUE_PLAN §16.1: a
67
+ # $150/h minimum effective rate before engineering time is committed).
68
+ DEFAULT_TARGET_HOURLY_RATE_USD = 150.0
69
+ PAYOUT_EFFORT_FLAG = "payout_too_low_for_effort"
70
+ PAYOUT_EFFORT_THRESHOLDS = {
71
+ "target_hourly_rate_usd": DEFAULT_TARGET_HOURLY_RATE_USD,
72
+ "low_ratio": 0.5,
73
+ "marginal_ratio": 1.0,
74
+ }
75
+
76
+ # Staleness / liveness signal. Productizes the desk's core "filter stale traps"
77
+ # value prop (REVENUE_PLAN §10.2 STALE_NO_MAINTAINER_SIGNAL): derive an
78
+ # opportunity_state recommendation from observable issue age plus whether a
79
+ # maintainer is still engaging, instead of eyeballing it per prospect. A
80
+ # ``stale``/``dormant`` result emits the existing high-risk
81
+ # ``stale_no_maintainer_signal`` flag (forces no-go, like a closed/stale state),
82
+ # while a merely ``aging`` result emits the softer, non-high-risk
83
+ # ``aging_low_activity`` flag (costs score via the generic risk penalty without
84
+ # forcing a no-go). Inputs are public age/activity metadata only; this never
85
+ # claims, comments, or contacts anyone.
86
+ STALE_NO_MAINTAINER_FLAG = "stale_no_maintainer_signal"
87
+ AGING_LOW_ACTIVITY_FLAG = "aging_low_activity"
88
+ STALENESS_THRESHOLDS = {
89
+ "active_max_days": 30,
90
+ "aging_max_days": 90,
91
+ "stale_max_days": 180,
92
+ "long_unresolved_days": 365,
93
+ }
94
+
95
+ # Testability / reproducibility signal. Productizes the desk's "can you verify
96
+ # it?" check (REVENUE_PLAN §9.2 Tests row, §10.2 NO_REPRO_OR_TEST_PATH): a funded
97
+ # issue you cannot objectively reproduce or test is hard to close even when the
98
+ # bounty is real, because the maintainer cannot tell a correct fix from a
99
+ # plausible one. The flag is intentionally NOT in HIGH_RISK_FLAGS: a docs or
100
+ # config issue can be legitimately untestable, so a missing test path costs score
101
+ # via the generic risk penalty without forcing an automatic no-go. Inputs are
102
+ # observable issue-body signals only; this never claims, comments, or contacts
103
+ # anyone.
104
+ NO_REPRO_OR_TEST_PATH_FLAG = "no_repro_or_test_path"
105
+
106
+ VALID_OPPORTUNITY_STATES = {"active", "closed", "stale", "unknown"}
107
+ VALID_RISK_LEVELS = {"high", "low", "medium"}
108
+ VALID_DECISION_GATES = {
109
+ "go_after_recheck",
110
+ "needs_authorization",
111
+ "needs_funding_verification",
112
+ "no_go",
113
+ "watchlist",
114
+ }
115
+
116
+
117
+ @dataclass(frozen=True)
118
+ class FundedIssue:
119
+ id: str
120
+ platform: str
121
+ repository: str
122
+ issue_number: int | None
123
+ title: str
124
+ url: str
125
+ funding_amount: float | None = None
126
+ funding_currency: str | None = None
127
+ language: str | None = None
128
+ labels: list[str] = field(default_factory=list)
129
+ contribution_signals: list[str] = field(default_factory=list)
130
+ risk_flags: list[str] = field(default_factory=list)
131
+ maintainer_permission: str = "public_issue_only"
132
+ contribution_guidelines_url: str | None = None
133
+ opportunity_state: str = "unknown"
134
+
135
+ @property
136
+ def reference(self) -> str:
137
+ if self.issue_number is None:
138
+ return self.repository
139
+ return f"{self.repository}#{self.issue_number}"
140
+
141
+ @property
142
+ def funding_display(self) -> str:
143
+ if self.funding_amount is None or self.funding_currency is None:
144
+ return "unknown"
145
+ amount = (
146
+ int(self.funding_amount) if self.funding_amount.is_integer() else self.funding_amount
147
+ )
148
+ return f"{amount} {self.funding_currency}"
149
+
150
+ @property
151
+ def risk_level(self) -> str:
152
+ if any(flag in HIGH_RISK_FLAGS for flag in self.risk_flags):
153
+ return "high"
154
+ if not self.contribution_guidelines_url:
155
+ return "medium"
156
+ return "low"
157
+
158
+ @property
159
+ def safe_to_list(self) -> bool:
160
+ return (
161
+ self.risk_level != "high"
162
+ and self.opportunity_state not in {"closed", "stale"}
163
+ and not self.primary_source_required
164
+ )
165
+
166
+ @property
167
+ def primary_source_required(self) -> bool:
168
+ return bool(set(self.risk_flags).intersection(PRIMARY_SOURCE_REVIEW_FLAGS))
169
+
170
+ @property
171
+ def source_review(self) -> dict[str, Any]:
172
+ return {
173
+ "primary_source_required": self.primary_source_required,
174
+ "risk_flags": sorted(set(self.risk_flags).intersection(PRIMARY_SOURCE_REVIEW_FLAGS)),
175
+ "required_before_shortlist": self.primary_source_required,
176
+ "next_step": (
177
+ "verify funding and current state from a permitted primary public/API source"
178
+ if self.primary_source_required
179
+ else "standard public-state recheck"
180
+ ),
181
+ }
182
+
183
+ def to_dict(self) -> dict[str, Any]:
184
+ return {
185
+ "id": self.id,
186
+ "platform": self.platform,
187
+ "repository": self.repository,
188
+ "issue_number": self.issue_number,
189
+ "reference": self.reference,
190
+ "title": self.title,
191
+ "url": self.url,
192
+ "funding": {
193
+ "amount": self.funding_amount,
194
+ "currency": self.funding_currency,
195
+ "display": self.funding_display,
196
+ },
197
+ "language": self.language,
198
+ "labels": self.labels,
199
+ "contribution_signals": self.contribution_signals,
200
+ "risk_flags": self.risk_flags,
201
+ "risk_level": self.risk_level,
202
+ "safe_to_list": self.safe_to_list,
203
+ "source_review": self.source_review,
204
+ "opportunity_state": self.opportunity_state,
205
+ "maintainer_permission": self.maintainer_permission,
206
+ "contribution_guidelines_url": self.contribution_guidelines_url,
207
+ "read_only": True,
208
+ "blocked_actions": BLOCKED_ACTIONS,
209
+ }
210
+
211
+
212
+ @dataclass(frozen=True)
213
+ class ClientProfile:
214
+ name: str | None = None
215
+ languages: tuple[str, ...] = ()
216
+ min_usd: float | None = None
217
+ allowed_opportunity_states: tuple[str, ...] = ()
218
+ allowed_risk_levels: tuple[str, ...] = ()
219
+ excluded_risk_flags: tuple[str, ...] = ()
220
+
221
+ def to_dict(self) -> dict[str, Any]:
222
+ return {
223
+ "schema_version": CLIENT_PROFILE_SCHEMA_VERSION,
224
+ "name": self.name,
225
+ "languages": list(self.languages),
226
+ "min_usd": self.min_usd,
227
+ "allowed_opportunity_states": list(self.allowed_opportunity_states),
228
+ "allowed_risk_levels": list(self.allowed_risk_levels),
229
+ "excluded_risk_flags": list(self.excluded_risk_flags),
230
+ "read_only": True,
231
+ }
232
+
233
+
234
+ def _as_string_list(value: Any) -> list[str]:
235
+ if value is None:
236
+ return []
237
+ if not isinstance(value, list):
238
+ raise ValueError("expected a list")
239
+ return [str(item) for item in value]
240
+
241
+
242
+ def _as_normalized_tuple(value: Any) -> tuple[str, ...]:
243
+ return tuple(sorted({item.strip().lower() for item in _as_string_list(value) if item.strip()}))
244
+
245
+
246
+ def _profile_from_mapping(raw: dict[str, Any]) -> ClientProfile:
247
+ schema_version = raw.get("schema_version")
248
+ if schema_version != CLIENT_PROFILE_SCHEMA_VERSION:
249
+ raise ValueError(f"profile must use schema_version {CLIENT_PROFILE_SCHEMA_VERSION}")
250
+ min_usd = raw.get("min_usd")
251
+ if min_usd is not None:
252
+ min_usd = float(min_usd)
253
+ if min_usd < 0:
254
+ raise ValueError("profile min_usd must be >= 0")
255
+ states = tuple(
256
+ _normalize_opportunity_state_filter(state)
257
+ for state in _as_normalized_tuple(raw.get("allowed_opportunity_states"))
258
+ )
259
+ risk_levels = tuple(
260
+ _normalize_risk_level_filter(risk_level)
261
+ for risk_level in _as_normalized_tuple(raw.get("allowed_risk_levels"))
262
+ )
263
+ return ClientProfile(
264
+ name=str(raw["name"]) if raw.get("name") else None,
265
+ languages=_as_normalized_tuple(raw.get("languages")),
266
+ min_usd=min_usd,
267
+ allowed_opportunity_states=states,
268
+ allowed_risk_levels=risk_levels,
269
+ excluded_risk_flags=_as_normalized_tuple(raw.get("excluded_risk_flags")),
270
+ )
271
+
272
+
273
+ def load_client_profile(source: Path) -> ClientProfile:
274
+ payload = json.loads(source.read_text(encoding="utf-8"))
275
+ if not isinstance(payload, dict):
276
+ raise ValueError("profile source must contain an object")
277
+ return _profile_from_mapping(payload)
278
+
279
+
280
+ def _issue_from_mapping(raw: dict[str, Any]) -> FundedIssue:
281
+ funding = raw.get("funding") or {}
282
+ if not isinstance(funding, dict):
283
+ raise ValueError("funding must be an object")
284
+ amount = funding.get("amount")
285
+ if amount is not None:
286
+ amount = float(amount)
287
+ issue_number = raw.get("issue_number")
288
+ if issue_number is not None:
289
+ issue_number = int(issue_number)
290
+ return FundedIssue(
291
+ id=str(raw["id"]),
292
+ platform=str(raw["platform"]),
293
+ repository=str(raw["repository"]),
294
+ issue_number=issue_number,
295
+ title=str(raw["title"]),
296
+ url=str(raw["url"]),
297
+ funding_amount=amount,
298
+ funding_currency=str(funding["currency"]) if funding.get("currency") else None,
299
+ language=str(raw["language"]) if raw.get("language") else None,
300
+ labels=_as_string_list(raw.get("labels")),
301
+ contribution_signals=_as_string_list(raw.get("contribution_signals")),
302
+ risk_flags=_as_string_list(raw.get("risk_flags")),
303
+ maintainer_permission=str(raw.get("maintainer_permission") or "public_issue_only"),
304
+ contribution_guidelines_url=(
305
+ str(raw["contribution_guidelines_url"])
306
+ if raw.get("contribution_guidelines_url")
307
+ else None
308
+ ),
309
+ opportunity_state=_normalize_opportunity_state(
310
+ raw.get("opportunity_state") or raw.get("state") or raw.get("status")
311
+ ),
312
+ )
313
+
314
+
315
+ def load_funded_issues(source: Path) -> list[FundedIssue]:
316
+ payload = json.loads(source.read_text(encoding="utf-8"))
317
+ if payload.get("schema_version") != SCHEMA_VERSION:
318
+ raise ValueError(f"source must use schema_version {SCHEMA_VERSION}")
319
+ raw_issues = payload.get("issues")
320
+ if not isinstance(raw_issues, list):
321
+ raise ValueError("source must contain an issues list")
322
+ issues = []
323
+ for raw_issue in raw_issues:
324
+ if not isinstance(raw_issue, dict):
325
+ raise ValueError("each issue must be an object")
326
+ issues.append(_issue_from_mapping(raw_issue))
327
+ return issues
328
+
329
+
330
+ def funded_issues_payload(
331
+ issues: list[FundedIssue],
332
+ *,
333
+ import_source: dict[str, Any] | None = None,
334
+ ) -> dict[str, Any]:
335
+ payload: dict[str, Any] = {
336
+ "schema_version": SCHEMA_VERSION,
337
+ "read_only": True,
338
+ "blocked_actions": BLOCKED_ACTIONS,
339
+ "issues": [issue.to_dict() for issue in issues],
340
+ "requirements": {
341
+ "network_required": False,
342
+ "github_write_permission_required": False,
343
+ "external_model_required": False,
344
+ "billing_required": False,
345
+ },
346
+ }
347
+ if import_source:
348
+ payload["import_source"] = import_source
349
+ return payload
350
+
351
+
352
+ def validate_funded_issues(issues: list[FundedIssue]) -> dict[str, Any]:
353
+ duplicate_ids = _duplicates(issue.id for issue in issues)
354
+ duplicate_references = _duplicates(issue.reference for issue in issues)
355
+ missing_funding = [
356
+ issue.reference
357
+ for issue in issues
358
+ if issue.funding_amount is None or issue.funding_currency is None
359
+ ]
360
+ missing_guidelines = [
361
+ issue.reference for issue in issues if not issue.contribution_guidelines_url
362
+ ]
363
+ missing_signals = [issue.reference for issue in issues if not issue.contribution_signals]
364
+ high_risk = [issue.reference for issue in issues if issue.risk_level == "high"]
365
+ stale_or_closed = [
366
+ issue.reference for issue in issues if issue.opportunity_state in {"closed", "stale"}
367
+ ]
368
+ warnings = {
369
+ "duplicate_ids": duplicate_ids,
370
+ "duplicate_references": duplicate_references,
371
+ "missing_funding": missing_funding,
372
+ "missing_contribution_guidelines": missing_guidelines,
373
+ "missing_contribution_signals": missing_signals,
374
+ "high_risk": high_risk,
375
+ "stale_or_closed": stale_or_closed,
376
+ }
377
+ warning_count = sum(len(items) for items in warnings.values())
378
+ risk_levels = Counter(issue.risk_level for issue in issues)
379
+ opportunity_states = Counter(issue.opportunity_state for issue in issues)
380
+ return {
381
+ "schema_version": VALIDATION_SCHEMA_VERSION,
382
+ "source_schema_version": SCHEMA_VERSION,
383
+ "status": "ok" if warning_count == 0 else "needs_review",
384
+ "read_only": True,
385
+ "total_loaded": len(issues),
386
+ "warning_count": warning_count,
387
+ "warnings": warnings,
388
+ "counts": {
389
+ "safe_to_list": sum(1 for issue in issues if issue.safe_to_list),
390
+ "high_risk": risk_levels.get("high", 0),
391
+ "medium_risk": risk_levels.get("medium", 0),
392
+ "low_risk": risk_levels.get("low", 0),
393
+ "active": opportunity_states.get("active", 0),
394
+ "stale": opportunity_states.get("stale", 0),
395
+ "closed": opportunity_states.get("closed", 0),
396
+ "unknown_state": opportunity_states.get("unknown", 0),
397
+ },
398
+ "blocked_actions": BLOCKED_ACTIONS,
399
+ "requirements": {
400
+ "network_required": False,
401
+ "github_write_permission_required": False,
402
+ "external_model_required": False,
403
+ "billing_required": False,
404
+ },
405
+ "boundary": (
406
+ "Validation is local and read-only. Warnings require human review before the dataset "
407
+ "is used as paid-opportunity evidence."
408
+ ),
409
+ }
410
+
411
+
412
+ def _duplicates(values: Any) -> list[str]:
413
+ counts = Counter(str(value) for value in values)
414
+ return sorted(value for value, count in counts.items() if count > 1)
415
+
416
+
417
+ def summarize_issues(
418
+ issues: list[FundedIssue],
419
+ *,
420
+ safe_only: bool = True,
421
+ profile: ClientProfile | None = None,
422
+ platform: str | None = None,
423
+ language: str | None = None,
424
+ min_usd: float | None = None,
425
+ opportunity_state: str | None = None,
426
+ risk_level: str | None = None,
427
+ ) -> dict[str, Any]:
428
+ opportunity_state = _normalize_opportunity_state_filter(opportunity_state)
429
+ risk_level = _normalize_risk_level_filter(risk_level)
430
+ filtered: list[FundedIssue] = []
431
+ for issue in issues:
432
+ if safe_only and not issue.safe_to_list:
433
+ continue
434
+ if not _matches_report_filter(
435
+ issue,
436
+ profile=profile,
437
+ platform=platform,
438
+ language=language,
439
+ min_usd=min_usd,
440
+ opportunity_state=opportunity_state,
441
+ risk_level=risk_level,
442
+ ):
443
+ continue
444
+ filtered.append(issue)
445
+ return {
446
+ "schema_version": SCHEMA_VERSION,
447
+ "read_only": True,
448
+ "safe_only": safe_only,
449
+ "blocked_actions": BLOCKED_ACTIONS,
450
+ "total_loaded": len(issues),
451
+ "total_returned": len(filtered),
452
+ "filters": {
453
+ "profile": profile.to_dict() if profile else None,
454
+ "platform": platform,
455
+ "language": language,
456
+ "min_usd": min_usd,
457
+ "opportunity_state": opportunity_state,
458
+ "risk_level": risk_level,
459
+ },
460
+ "issues": [issue.to_dict() for issue in filtered],
461
+ "requirements": {
462
+ "network_required": False,
463
+ "github_write_permission_required": False,
464
+ "external_model_required": False,
465
+ "billing_required": False,
466
+ },
467
+ }
468
+
469
+
470
+ def report_funded_issues(
471
+ issues: list[FundedIssue],
472
+ *,
473
+ safe_only: bool = False,
474
+ profile: ClientProfile | None = None,
475
+ platform: str | None = None,
476
+ language: str | None = None,
477
+ min_usd: float | None = None,
478
+ opportunity_state: str | None = None,
479
+ risk_level: str | None = None,
480
+ ) -> dict[str, Any]:
481
+ opportunity_state = _normalize_opportunity_state_filter(opportunity_state)
482
+ risk_level = _normalize_risk_level_filter(risk_level)
483
+ client_fit_gaps = _client_fit_gaps(issues, profile)
484
+ summary = summarize_issues(
485
+ issues,
486
+ safe_only=safe_only,
487
+ profile=profile,
488
+ platform=platform,
489
+ language=language,
490
+ min_usd=min_usd,
491
+ opportunity_state=opportunity_state,
492
+ risk_level=risk_level,
493
+ )
494
+ scoped_issues = [
495
+ issue
496
+ for issue in issues
497
+ if _matches_report_filter(
498
+ issue,
499
+ profile=profile,
500
+ platform=platform,
501
+ language=language,
502
+ min_usd=min_usd,
503
+ opportunity_state=opportunity_state,
504
+ risk_level=risk_level,
505
+ )
506
+ ]
507
+ returned_issues = [_issue_from_mapping(issue) for issue in summary["issues"]]
508
+ risk_levels = Counter(issue.risk_level for issue in scoped_issues)
509
+ platforms = Counter(issue.platform for issue in scoped_issues)
510
+ languages = Counter(issue.language or "unknown" for issue in scoped_issues)
511
+ risk_flags = Counter(flag for issue in scoped_issues for flag in issue.risk_flags)
512
+ opportunity_states = Counter(issue.opportunity_state for issue in scoped_issues)
513
+ funding_known = sum(1 for issue in scoped_issues if issue.funding_amount is not None)
514
+ funding_unknown = len(scoped_issues) - funding_known
515
+ scored_rows = [_score_issue(issue) for issue in scoped_issues]
516
+ decision_summary = _decision_summary(scored_rows)
517
+ delivery_budget = _delivery_budget(scored_rows)
518
+ source_quality = _source_quality(scored_rows)
519
+ recheck_plan = _recheck_plan(scored_rows)
520
+ evidence_debt = _evidence_debt(recheck_plan)
521
+ client_fit_summary = _client_fit_summary(issues, profile, client_fit_gaps)
522
+ intake_followup = _intake_followup(
523
+ client_fit_summary=client_fit_summary,
524
+ recheck_plan=recheck_plan,
525
+ delivery_budget=delivery_budget,
526
+ source_quality=source_quality,
527
+ decision_summary=decision_summary,
528
+ )
529
+ cash_path_status = _cash_path_status(intake_followup)
530
+ delivery_pack = _delivery_pack(scored_rows)
531
+ safe_candidates = sorted(
532
+ (issue for issue in returned_issues if issue.safe_to_list),
533
+ key=_candidate_sort_key,
534
+ )
535
+ return {
536
+ "schema_version": "patchrail.funded_issues.report.v1",
537
+ "source_schema_version": SCHEMA_VERSION,
538
+ "read_only": True,
539
+ "safe_only": safe_only,
540
+ "blocked_actions": BLOCKED_ACTIONS,
541
+ "filters": {
542
+ "profile": profile.to_dict() if profile else None,
543
+ "platform": platform,
544
+ "language": language,
545
+ "min_usd": min_usd,
546
+ "opportunity_state": opportunity_state,
547
+ "risk_level": risk_level,
548
+ },
549
+ "totals": {
550
+ "loaded": len(issues),
551
+ "in_scope": len(scoped_issues),
552
+ "returned": len(returned_issues),
553
+ "safe_to_list": sum(1 for issue in scoped_issues if issue.safe_to_list),
554
+ "high_risk": risk_levels.get("high", 0),
555
+ "funding_known": funding_known,
556
+ "funding_unknown": funding_unknown,
557
+ },
558
+ "breakdown": {
559
+ "risk_levels": dict(sorted(risk_levels.items())),
560
+ "platforms": dict(sorted(platforms.items())),
561
+ "languages": dict(sorted(languages.items())),
562
+ "risk_flags": dict(sorted(risk_flags.items())),
563
+ "opportunity_states": dict(sorted(opportunity_states.items())),
564
+ },
565
+ "no_go_moat": {
566
+ "high_risk_or_excluded": sum(1 for issue in scoped_issues if not issue.safe_to_list),
567
+ "missing_contribution_guidelines": sum(
568
+ 1 for issue in scoped_issues if not issue.contribution_guidelines_url
569
+ ),
570
+ "ambiguous_scope": risk_flags.get("ambiguous_scope", 0),
571
+ "spam_attractive": risk_flags.get("spam_attractive", 0),
572
+ "funding_unknown": funding_unknown,
573
+ "stale_or_closed": sum(
574
+ 1 for issue in scoped_issues if issue.opportunity_state in {"closed", "stale"}
575
+ ),
576
+ },
577
+ "decision_summary": decision_summary,
578
+ "delivery_budget": delivery_budget,
579
+ "delivery_pack": delivery_pack,
580
+ "source_quality": source_quality,
581
+ "recheck_plan": recheck_plan,
582
+ "evidence_debt": evidence_debt,
583
+ "client_fit_summary": client_fit_summary,
584
+ "client_fit_gaps": client_fit_gaps,
585
+ "intake_followup": intake_followup,
586
+ "cash_path_status": cash_path_status,
587
+ "operator_next_steps": _operator_next_steps(
588
+ cash_path_status=cash_path_status,
589
+ intake_followup=intake_followup,
590
+ recheck_plan=recheck_plan,
591
+ delivery_pack=delivery_pack,
592
+ ),
593
+ "top_safe_candidates": [
594
+ {
595
+ "reference": issue.reference,
596
+ "title": issue.title,
597
+ "platform": issue.platform,
598
+ "funding": issue.funding_display,
599
+ "opportunity_state": issue.opportunity_state,
600
+ "risk_level": issue.risk_level,
601
+ "signals": issue.contribution_signals,
602
+ "url": issue.url,
603
+ }
604
+ for issue in safe_candidates
605
+ ],
606
+ "requirements": {
607
+ "network_required": False,
608
+ "github_write_permission_required": False,
609
+ "external_model_required": False,
610
+ "billing_required": False,
611
+ },
612
+ "boundary": (
613
+ "Local read-only report only. PatchRail does not claim rewards, post comments, "
614
+ "open pull requests, contact maintainers, or rank work by money alone."
615
+ ),
616
+ }
617
+
618
+
619
+ def score_funded_issues(
620
+ issues: list[FundedIssue],
621
+ *,
622
+ safe_only: bool = False,
623
+ profile: ClientProfile | None = None,
624
+ platform: str | None = None,
625
+ language: str | None = None,
626
+ min_usd: float | None = None,
627
+ opportunity_state: str | None = None,
628
+ risk_level: str | None = None,
629
+ ) -> dict[str, Any]:
630
+ opportunity_state = _normalize_opportunity_state_filter(opportunity_state)
631
+ risk_level = _normalize_risk_level_filter(risk_level)
632
+ scored = [
633
+ _score_issue(issue)
634
+ for issue in issues
635
+ if _matches_report_filter(
636
+ issue,
637
+ profile=profile,
638
+ platform=platform,
639
+ language=language,
640
+ min_usd=min_usd,
641
+ opportunity_state=opportunity_state,
642
+ risk_level=risk_level,
643
+ )
644
+ ]
645
+ if safe_only:
646
+ scored = [row for row in scored if row["issue"]["safe_to_list"]]
647
+ ratings = Counter(row["rating"] for row in scored)
648
+ return {
649
+ "schema_version": "patchrail.funded_issues.score.v1",
650
+ "source_schema_version": SCHEMA_VERSION,
651
+ "read_only": True,
652
+ "safe_only": safe_only,
653
+ "blocked_actions": BLOCKED_ACTIONS,
654
+ "filters": {
655
+ "profile": profile.to_dict() if profile else None,
656
+ "platform": platform,
657
+ "language": language,
658
+ "min_usd": min_usd,
659
+ "opportunity_state": opportunity_state,
660
+ "risk_level": risk_level,
661
+ },
662
+ "total_loaded": len(issues),
663
+ "total_scored": len(scored),
664
+ "rating_counts": dict(sorted(ratings.items())),
665
+ "scores": sorted(
666
+ scored,
667
+ key=lambda row: (-int(row["score"]), row["issue"]["reference"]),
668
+ ),
669
+ "requirements": {
670
+ "network_required": False,
671
+ "github_write_permission_required": False,
672
+ "external_model_required": False,
673
+ "billing_required": False,
674
+ },
675
+ "boundary": (
676
+ "Local read-only readiness scoring only. Funding is context, not an instruction to "
677
+ "claim rewards, post comments, open pull requests, or contact maintainers."
678
+ ),
679
+ }
680
+
681
+
682
+ def shortlist_funded_issues(
683
+ issues: list[FundedIssue],
684
+ *,
685
+ safe_only: bool = False,
686
+ profile: ClientProfile | None = None,
687
+ platform: str | None = None,
688
+ language: str | None = None,
689
+ min_usd: float | None = None,
690
+ opportunity_state: str | None = None,
691
+ risk_level: str | None = None,
692
+ limit: int = 5,
693
+ ) -> dict[str, Any]:
694
+ if limit < 1:
695
+ raise ValueError("limit must be >= 1")
696
+ score_payload = score_funded_issues(
697
+ issues,
698
+ safe_only=False,
699
+ profile=profile,
700
+ platform=platform,
701
+ language=language,
702
+ min_usd=min_usd,
703
+ opportunity_state=opportunity_state,
704
+ risk_level=risk_level,
705
+ )
706
+ report_payload = report_funded_issues(
707
+ issues,
708
+ safe_only=safe_only,
709
+ profile=profile,
710
+ platform=platform,
711
+ language=language,
712
+ min_usd=min_usd,
713
+ opportunity_state=opportunity_state,
714
+ risk_level=risk_level,
715
+ )
716
+ candidate_rows = [
717
+ row
718
+ for row in score_payload["scores"]
719
+ if _is_shortlist_candidate_row(row) and (not safe_only or row["issue"]["safe_to_list"])
720
+ ]
721
+ no_go_rows = [row for row in score_payload["scores"] if row["rating"] == "no_go"]
722
+ decision_summary = _decision_summary(score_payload["scores"])
723
+ delivery_budget = _delivery_budget(score_payload["scores"])
724
+ source_quality = _source_quality(score_payload["scores"])
725
+ recheck_plan = _recheck_plan(score_payload["scores"])
726
+ evidence_debt = _evidence_debt(recheck_plan)
727
+ client_fit_summary = _client_fit_summary(issues, profile, report_payload["client_fit_gaps"])
728
+ intake_followup = _intake_followup(
729
+ client_fit_summary=client_fit_summary,
730
+ recheck_plan=recheck_plan,
731
+ delivery_budget=delivery_budget,
732
+ source_quality=source_quality,
733
+ decision_summary=decision_summary,
734
+ )
735
+ cash_path_status = _cash_path_status(intake_followup)
736
+ delivery_pack = _delivery_pack(score_payload["scores"])
737
+ return {
738
+ "schema_version": SHORTLIST_SCHEMA_VERSION,
739
+ "source_schema_version": SCHEMA_VERSION,
740
+ "read_only": True,
741
+ "safe_only": safe_only,
742
+ "limit": limit,
743
+ "blocked_actions": BLOCKED_ACTIONS,
744
+ "filters": score_payload["filters"],
745
+ "summary": {
746
+ "total_loaded": score_payload["total_loaded"],
747
+ "total_scored": score_payload["total_scored"],
748
+ "rating_counts": score_payload["rating_counts"],
749
+ "in_scope": report_payload["totals"]["in_scope"],
750
+ "safe_to_list": report_payload["totals"]["safe_to_list"],
751
+ "high_risk": report_payload["totals"]["high_risk"],
752
+ "opportunity_states": report_payload["breakdown"]["opportunity_states"],
753
+ },
754
+ "shortlist": candidate_rows[:limit],
755
+ "no_go_evidence": no_go_rows,
756
+ "no_go_moat": report_payload["no_go_moat"],
757
+ "decision_summary": {
758
+ **decision_summary,
759
+ "candidate_rows": len(candidate_rows),
760
+ "no_go_rows": len(no_go_rows),
761
+ },
762
+ "delivery_budget": delivery_budget,
763
+ "delivery_pack": delivery_pack,
764
+ "source_quality": source_quality,
765
+ "recheck_plan": recheck_plan,
766
+ "evidence_debt": evidence_debt,
767
+ "client_fit_summary": client_fit_summary,
768
+ "client_fit_gaps": report_payload["client_fit_gaps"],
769
+ "intake_followup": intake_followup,
770
+ "cash_path_status": cash_path_status,
771
+ "operator_next_steps": _operator_next_steps(
772
+ cash_path_status=cash_path_status,
773
+ intake_followup=intake_followup,
774
+ recheck_plan=recheck_plan,
775
+ delivery_pack=delivery_pack,
776
+ ),
777
+ "requirements": {
778
+ "network_required": False,
779
+ "github_write_permission_required": False,
780
+ "external_model_required": False,
781
+ "billing_required": False,
782
+ },
783
+ "boundary": (
784
+ "Decision support only. PatchRail does not claim rewards, post comments, open pull "
785
+ "requests, contact maintainers, or guarantee merge or payout outcomes."
786
+ ),
787
+ }
788
+
789
+
790
+ CLIENT_REPORT_DISCLAIMER = (
791
+ "This report is read-only opportunity intelligence. It does not guarantee bounty "
792
+ "availability, merge acceptance, payout, or maintainer response. No claims, comments, "
793
+ "outreach, or PR submissions were made by PatchRail unless explicitly authorized in writing."
794
+ )
795
+
796
+
797
+ def _client_report_maintainer_activity(issue: dict[str, Any]) -> str:
798
+ state = issue.get("opportunity_state")
799
+ if state == "active":
800
+ return "active opportunity state; recent maintainer signal observed"
801
+ if state == "stale":
802
+ return "stale; no recent maintainer signal"
803
+ if state == "closed":
804
+ return "closed or inactive"
805
+ return "maintainer activity unconfirmed"
806
+
807
+
808
+ def _client_report_scope(issue: dict[str, Any], reason_codes: list[str]) -> str:
809
+ if "SCOPE_TOO_BROAD" in reason_codes:
810
+ return "broad — scope not yet bounded"
811
+ if issue.get("contribution_signals"):
812
+ return "narrow — contribution signals documented"
813
+ return "unconfirmed — no contribution signals recorded"
814
+
815
+
816
+ def _client_report_effort(reason_codes: list[str]) -> str | None:
817
+ if "PAYOUT_TOO_LOW_FOR_EFFORT" in reason_codes:
818
+ return "payout may be low relative to estimated effort"
819
+ return None
820
+
821
+
822
+ def _client_report_recommendation_row(row: dict[str, Any]) -> dict[str, Any]:
823
+ issue = row["issue"]
824
+ reason_codes = list(row["reason_codes"])
825
+ effort = _client_report_effort(reason_codes)
826
+ recommendation: dict[str, Any] = {
827
+ "reference": issue["reference"],
828
+ "title": issue["title"],
829
+ "url": issue["url"],
830
+ "platform": issue["platform"],
831
+ "language": issue.get("language"),
832
+ "payout": issue["funding"]["display"],
833
+ "state": issue["opportunity_state"],
834
+ "maintainer_activity": _client_report_maintainer_activity(issue),
835
+ "scope": _client_report_scope(issue, reason_codes),
836
+ "risk": issue["risk_level"],
837
+ "effort": effort,
838
+ "recommended_next_step": row["recommended_next_step"],
839
+ "confidence": row["confidence"],
840
+ "decision": "Go" if row["decision_gate"] == "go_after_recheck" else "Watchlist",
841
+ }
842
+ return recommendation
843
+
844
+
845
+ def _client_report_watchlist_row(row: dict[str, Any]) -> dict[str, Any]:
846
+ issue = row["issue"]
847
+ reason_codes = list(row["reason_codes"])
848
+ blocker = reason_codes[0] if reason_codes else "review pending"
849
+ return {
850
+ "reference": issue["reference"],
851
+ "title": issue["title"],
852
+ "url": issue["url"],
853
+ "payout": issue["funding"]["display"],
854
+ "blocker": blocker,
855
+ "trigger_to_promote": row["recommended_next_step"],
856
+ }
857
+
858
+
859
+ def _client_report_no_go_row(row: dict[str, Any]) -> dict[str, Any]:
860
+ issue = row["issue"]
861
+ reason_codes = list(row["reason_codes"])
862
+ return {
863
+ "reference": issue["reference"],
864
+ "title": issue["title"],
865
+ "url": issue["url"],
866
+ "payout": issue["funding"]["display"],
867
+ "reason_code": reason_codes[0] if reason_codes else "NO_MAJOR_REVIEW_GAPS",
868
+ "reason_codes": reason_codes,
869
+ "evidence": row["recommended_next_step"],
870
+ }
871
+
872
+
873
+ def _client_report_executive_summary(
874
+ *,
875
+ reviewed: int,
876
+ go_total: int,
877
+ watchlist_total: int,
878
+ go_rows: list[dict[str, Any]],
879
+ no_go_rows: list[dict[str, Any]],
880
+ ) -> dict[str, Any]:
881
+ # Headline counts describe the whole reviewed population, not the display
882
+ # shortlist: `go_rows` is already truncated to `--limit`, so counting it here
883
+ # would understate actionability for any review larger than the limit.
884
+ go_count = go_total
885
+ watchlist_count = watchlist_total
886
+ no_go_count = len(no_go_rows)
887
+ actionable_pct = round(100 * go_count / reviewed, 1) if reviewed else 0.0
888
+ top_recommendation = None
889
+ if go_rows:
890
+ top = go_rows[0]["issue"]
891
+ top_recommendation = f"{top['reference']} ({top['funding']['display']}, Go)"
892
+ no_go_reason_counts = Counter(
893
+ row["reason_code"] for row in (_client_report_no_go_row(r) for r in no_go_rows)
894
+ )
895
+ dominant_no_go_reason = None
896
+ if no_go_reason_counts:
897
+ code, count = no_go_reason_counts.most_common(1)[0]
898
+ dominant_no_go_reason = {
899
+ "reason_code": code,
900
+ "count": count,
901
+ "of_total": no_go_count,
902
+ }
903
+ return {
904
+ "reviewed": reviewed,
905
+ "go": go_count,
906
+ "watchlist": watchlist_count,
907
+ "no_go": no_go_count,
908
+ "actionable_percent": actionable_pct,
909
+ "top_recommendation": top_recommendation,
910
+ "dominant_no_go_reason": dominant_no_go_reason,
911
+ }
912
+
913
+
914
+ def _client_report_patterns(
915
+ *,
916
+ go_rows: list[dict[str, Any]],
917
+ no_go_rows: list[dict[str, Any]],
918
+ no_go_moat: dict[str, Any],
919
+ ) -> dict[str, Any]:
920
+ no_go_reason_counts = Counter(code for row in no_go_rows for code in row["reason_codes"])
921
+ go_platform_counts = Counter(row["issue"]["platform"] for row in go_rows)
922
+ go_language_counts = Counter((row["issue"].get("language") or "unknown") for row in go_rows)
923
+ no_go_platform_counts = Counter(row["issue"]["platform"] for row in no_go_rows)
924
+ return {
925
+ "no_go_reason_code_counts": dict(no_go_reason_counts.most_common()),
926
+ "go_platform_counts": dict(sorted(go_platform_counts.items())),
927
+ "go_language_counts": dict(sorted(go_language_counts.items())),
928
+ "no_go_platform_counts": dict(sorted(no_go_platform_counts.items())),
929
+ "moat_highlights": {
930
+ "high_risk_or_excluded": no_go_moat["high_risk_or_excluded"],
931
+ "funding_unknown": no_go_moat["funding_unknown"],
932
+ "ambiguous_scope": no_go_moat["ambiguous_scope"],
933
+ "stale_or_closed": no_go_moat["stale_or_closed"],
934
+ },
935
+ }
936
+
937
+
938
+ def _client_report_operating_procedure(
939
+ *,
940
+ go_rows: list[dict[str, Any]],
941
+ watchlist_rows: list[dict[str, Any]],
942
+ no_go_rows: list[dict[str, Any]],
943
+ ) -> list[str]:
944
+ steps: list[str] = []
945
+ if go_rows:
946
+ top = go_rows[0]["issue"]
947
+ steps.append(
948
+ f"Start review with {top['reference']} (highest-confidence Go pick); "
949
+ "reproduce locally before any client-side engagement decision."
950
+ )
951
+ for row in go_rows[1:]:
952
+ issue = row["issue"]
953
+ steps.append(
954
+ f"Prepare a local feasibility note for {issue['reference']} and "
955
+ "re-check assignment/competition before any engagement decision."
956
+ )
957
+ else:
958
+ steps.append(
959
+ "No Go candidates in this batch; expand permitted read-only sources before "
960
+ "ranking again."
961
+ )
962
+ if watchlist_rows:
963
+ steps.append(
964
+ "Do not invest in Watchlist items until each promotion trigger fires; "
965
+ "re-check public state on the listed cadence."
966
+ )
967
+ if no_go_rows:
968
+ steps.append(
969
+ f"Skip the entire No-go list ({len(no_go_rows)} rows); keep it as exclusion "
970
+ "evidence rather than spending engineering time."
971
+ )
972
+ steps.append(
973
+ "Do not claim, comment, open pull requests, or contact maintainers without explicit "
974
+ "written client authorization and confirmation that project rules allow it."
975
+ )
976
+ return steps
977
+
978
+
979
+ def _normalize_report_date(report_date: str) -> str:
980
+ """Validate an injected report date and return its canonical string form.
981
+
982
+ Accepts an ISO calendar date (``YYYY-MM-DD``) or an ISO datetime; rejects
983
+ anything else so a client-facing deliverable can never ship a garbage date.
984
+ """
985
+ stripped = report_date.strip()
986
+ try:
987
+ return date.fromisoformat(stripped).isoformat()
988
+ except ValueError:
989
+ pass
990
+ try:
991
+ datetime.fromisoformat(stripped)
992
+ except ValueError:
993
+ raise ValueError(
994
+ f"report_date must be a valid ISO date (YYYY-MM-DD), got {report_date!r}"
995
+ ) from None
996
+ return stripped
997
+
998
+
999
+ def client_report_funded_issues(
1000
+ issues: list[FundedIssue],
1001
+ *,
1002
+ client_name: str,
1003
+ report_date: str,
1004
+ prepared_by: str = "PatchRail Opportunity Desk",
1005
+ safe_only: bool = False,
1006
+ profile: ClientProfile | None = None,
1007
+ platform: str | None = None,
1008
+ language: str | None = None,
1009
+ min_usd: float | None = None,
1010
+ opportunity_state: str | None = None,
1011
+ risk_level: str | None = None,
1012
+ limit: int = 5,
1013
+ ) -> dict[str, Any]:
1014
+ if not client_name or not client_name.strip():
1015
+ raise ValueError("client_name must be a non-empty string")
1016
+ if not report_date or not report_date.strip():
1017
+ raise ValueError("report_date must be a non-empty ISO date string")
1018
+ normalized_date = _normalize_report_date(report_date)
1019
+ shortlist_payload = shortlist_funded_issues(
1020
+ issues,
1021
+ safe_only=safe_only,
1022
+ profile=profile,
1023
+ platform=platform,
1024
+ language=language,
1025
+ min_usd=min_usd,
1026
+ opportunity_state=opportunity_state,
1027
+ risk_level=risk_level,
1028
+ limit=limit,
1029
+ )
1030
+ go_rows = [
1031
+ row for row in shortlist_payload["shortlist"] if row["decision_gate"] == "go_after_recheck"
1032
+ ]
1033
+ watchlist_rows = [
1034
+ row for row in shortlist_payload["shortlist"] if row["decision_gate"] == "watchlist"
1035
+ ]
1036
+ no_go_rows = shortlist_payload["no_go_evidence"]
1037
+ no_go_moat = shortlist_payload["no_go_moat"]
1038
+ reviewed = shortlist_payload["summary"]["total_scored"]
1039
+ gate_counts = shortlist_payload["decision_summary"]["gate_counts"]
1040
+ go_total = gate_counts.get("go_after_recheck", 0)
1041
+ watchlist_total = gate_counts.get("watchlist", 0)
1042
+ return {
1043
+ "schema_version": CLIENT_REPORT_SCHEMA_VERSION,
1044
+ "source_schema_version": SCHEMA_VERSION,
1045
+ "read_only": True,
1046
+ "client_name": client_name.strip(),
1047
+ "prepared_by": prepared_by,
1048
+ "date": normalized_date,
1049
+ "scope": f"{reviewed} public funded open-source issues reviewed",
1050
+ "blocked_actions": BLOCKED_ACTIONS,
1051
+ "filters": shortlist_payload["filters"],
1052
+ "executive_summary": _client_report_executive_summary(
1053
+ reviewed=reviewed,
1054
+ go_total=go_total,
1055
+ watchlist_total=watchlist_total,
1056
+ go_rows=go_rows,
1057
+ no_go_rows=no_go_rows,
1058
+ ),
1059
+ "top_recommendations": [_client_report_recommendation_row(row) for row in go_rows],
1060
+ "watchlist": [_client_report_watchlist_row(row) for row in watchlist_rows],
1061
+ "no_go_list": [_client_report_no_go_row(row) for row in no_go_rows],
1062
+ "no_go_moat_evidence": {
1063
+ "raw_results_reviewed": shortlist_payload["summary"]["total_loaded"],
1064
+ "in_scope_reviewed": reviewed,
1065
+ "high_risk_or_excluded": no_go_moat["high_risk_or_excluded"],
1066
+ "missing_contribution_guidelines": no_go_moat["missing_contribution_guidelines"],
1067
+ "ambiguous_scope": no_go_moat["ambiguous_scope"],
1068
+ "spam_attractive": no_go_moat["spam_attractive"],
1069
+ "funding_unknown": no_go_moat["funding_unknown"],
1070
+ "stale_or_closed": no_go_moat["stale_or_closed"],
1071
+ "final_go_candidates": len(go_rows),
1072
+ },
1073
+ "patterns_observed": _client_report_patterns(
1074
+ go_rows=go_rows,
1075
+ no_go_rows=no_go_rows,
1076
+ no_go_moat=no_go_moat,
1077
+ ),
1078
+ "recommended_operating_procedure": _client_report_operating_procedure(
1079
+ go_rows=go_rows,
1080
+ watchlist_rows=watchlist_rows,
1081
+ no_go_rows=no_go_rows,
1082
+ ),
1083
+ "disclaimer": CLIENT_REPORT_DISCLAIMER,
1084
+ "requirements": {
1085
+ "network_required": False,
1086
+ "github_write_permission_required": False,
1087
+ "external_model_required": False,
1088
+ "billing_required": False,
1089
+ },
1090
+ "boundary": (
1091
+ "Client-facing read-only opportunity intelligence. PatchRail does not claim rewards, "
1092
+ "post comments, open pull requests, contact maintainers, or guarantee merge or payout "
1093
+ "outcomes."
1094
+ ),
1095
+ }
1096
+
1097
+
1098
+ def recheck_funded_issues(
1099
+ issues: list[FundedIssue],
1100
+ *,
1101
+ safe_only: bool = False,
1102
+ profile: ClientProfile | None = None,
1103
+ platform: str | None = None,
1104
+ language: str | None = None,
1105
+ min_usd: float | None = None,
1106
+ opportunity_state: str | None = None,
1107
+ risk_level: str | None = None,
1108
+ max_rows: int | None = None,
1109
+ ) -> dict[str, Any]:
1110
+ if max_rows is not None and max_rows < 1:
1111
+ raise ValueError("max_rows must be at least 1")
1112
+ score_payload = score_funded_issues(
1113
+ issues,
1114
+ safe_only=safe_only,
1115
+ profile=profile,
1116
+ platform=platform,
1117
+ language=language,
1118
+ min_usd=min_usd,
1119
+ opportunity_state=opportunity_state,
1120
+ risk_level=risk_level,
1121
+ )
1122
+ scored_rows = score_payload["scores"]
1123
+ recheck_plan = _recheck_plan(scored_rows)
1124
+ queue_rows = [
1125
+ _recheck_queue_row(row)
1126
+ for row in scored_rows
1127
+ if _recheck_action_for_gate(str(row["decision_gate"])) != "archive_as_no_go_evidence"
1128
+ ]
1129
+ queue_rows.sort(
1130
+ key=lambda row: (
1131
+ _recheck_priority_rank(row["priority"]),
1132
+ row["platform"],
1133
+ row["reference"],
1134
+ )
1135
+ )
1136
+ queue_rows_before_limit = len(queue_rows)
1137
+ if max_rows is not None:
1138
+ queue_rows = queue_rows[:max_rows]
1139
+ focus_batch = _recheck_focus_batch(queue_rows)
1140
+ return {
1141
+ "schema_version": RECHECK_QUEUE_SCHEMA_VERSION,
1142
+ "source_schema_version": SCHEMA_VERSION,
1143
+ "read_only": True,
1144
+ "safe_only": safe_only,
1145
+ "blocked_actions": BLOCKED_ACTIONS,
1146
+ "filters": score_payload["filters"],
1147
+ "queue_limit": max_rows,
1148
+ "total_loaded": score_payload["total_loaded"],
1149
+ "total_scored": score_payload["total_scored"],
1150
+ "queue_rows_before_limit": queue_rows_before_limit,
1151
+ "queue_rows": len(queue_rows),
1152
+ "no_go_archive_rows": recheck_plan["no_go_rows"],
1153
+ "priority_counts": recheck_plan["priority_counts"],
1154
+ "action_counts": recheck_plan["action_counts"],
1155
+ "focus_batch": focus_batch,
1156
+ "items": queue_rows,
1157
+ "requirements": {
1158
+ "network_required": False,
1159
+ "github_write_permission_required": False,
1160
+ "external_model_required": False,
1161
+ "billing_required": False,
1162
+ },
1163
+ "boundary": (
1164
+ "Recheck queue is local read-only tracker work. It schedules evidence review only; "
1165
+ "it does not claim rewards, post comments, contact maintainers, open pull requests, "
1166
+ "or guarantee merge or payout outcomes."
1167
+ ),
1168
+ }
1169
+
1170
+
1171
+ def cash_actions_funded_issues(
1172
+ issues: list[FundedIssue],
1173
+ *,
1174
+ safe_only: bool = False,
1175
+ profile: ClientProfile | None = None,
1176
+ platform: str | None = None,
1177
+ language: str | None = None,
1178
+ min_usd: float | None = None,
1179
+ opportunity_state: str | None = None,
1180
+ risk_level: str | None = None,
1181
+ max_actions: int | None = None,
1182
+ ) -> dict[str, Any]:
1183
+ if max_actions is not None and max_actions < 1:
1184
+ raise ValueError("max_actions must be at least 1")
1185
+ report_payload = report_funded_issues(
1186
+ issues,
1187
+ safe_only=safe_only,
1188
+ profile=profile,
1189
+ platform=platform,
1190
+ language=language,
1191
+ min_usd=min_usd,
1192
+ opportunity_state=opportunity_state,
1193
+ risk_level=risk_level,
1194
+ )
1195
+ actions = _cash_action_rows(report_payload)
1196
+ actions_before_limit = len(actions)
1197
+ if max_actions is not None:
1198
+ actions = actions[:max_actions]
1199
+ return {
1200
+ "schema_version": CASH_ACTIONS_SCHEMA_VERSION,
1201
+ "source_schema_version": SCHEMA_VERSION,
1202
+ "read_only": True,
1203
+ "safe_only": safe_only,
1204
+ "blocked_actions": BLOCKED_ACTIONS,
1205
+ "filters": report_payload["filters"],
1206
+ "cash_path_status": report_payload["cash_path_status"],
1207
+ "intake_followup": report_payload["intake_followup"],
1208
+ "delivery_pack": report_payload["delivery_pack"],
1209
+ "source_quality": report_payload["source_quality"],
1210
+ "operator_next_steps": report_payload["operator_next_steps"],
1211
+ "action_limit": max_actions,
1212
+ "actions_before_limit": actions_before_limit,
1213
+ "action_rows": len(actions),
1214
+ "items": actions,
1215
+ "requirements": {
1216
+ "network_required": False,
1217
+ "github_write_permission_required": False,
1218
+ "external_model_required": False,
1219
+ "billing_required": False,
1220
+ },
1221
+ "boundary": (
1222
+ "Cash actions are internal structured handoff only, not external prose. They do "
1223
+ "not create a payment route, claim rewards, post comments, contact maintainers, "
1224
+ "open pull requests, or guarantee merge or payout outcomes."
1225
+ ),
1226
+ }
1227
+
1228
+
1229
+ def fulfillment_packet_funded_issues(
1230
+ issues: list[FundedIssue],
1231
+ *,
1232
+ safe_only: bool = False,
1233
+ profile: ClientProfile | None = None,
1234
+ platform: str | None = None,
1235
+ language: str | None = None,
1236
+ min_usd: float | None = None,
1237
+ opportunity_state: str | None = None,
1238
+ risk_level: str | None = None,
1239
+ max_items: int | None = None,
1240
+ ) -> dict[str, Any]:
1241
+ if max_items is not None and max_items < 1:
1242
+ raise ValueError("max_items must be at least 1")
1243
+ report_payload = report_funded_issues(
1244
+ issues,
1245
+ safe_only=safe_only,
1246
+ profile=profile,
1247
+ platform=platform,
1248
+ language=language,
1249
+ min_usd=min_usd,
1250
+ opportunity_state=opportunity_state,
1251
+ risk_level=risk_level,
1252
+ )
1253
+ recheck_payload = recheck_funded_issues(
1254
+ issues,
1255
+ safe_only=safe_only,
1256
+ profile=profile,
1257
+ platform=platform,
1258
+ language=language,
1259
+ min_usd=min_usd,
1260
+ opportunity_state=opportunity_state,
1261
+ risk_level=risk_level,
1262
+ )
1263
+ cash_payload = cash_actions_funded_issues(
1264
+ issues,
1265
+ safe_only=safe_only,
1266
+ profile=profile,
1267
+ platform=platform,
1268
+ language=language,
1269
+ min_usd=min_usd,
1270
+ opportunity_state=opportunity_state,
1271
+ risk_level=risk_level,
1272
+ )
1273
+ items = _fulfillment_items(
1274
+ report_payload=report_payload,
1275
+ recheck_payload=recheck_payload,
1276
+ cash_payload=cash_payload,
1277
+ )
1278
+ qa_gates = _fulfillment_qa_gates(report_payload)
1279
+ items_before_limit = len(items)
1280
+ if max_items is not None:
1281
+ items = items[:max_items]
1282
+ return {
1283
+ "schema_version": FULFILLMENT_PACKET_SCHEMA_VERSION,
1284
+ "source_schema_version": SCHEMA_VERSION,
1285
+ "read_only": True,
1286
+ "safe_only": safe_only,
1287
+ "blocked_actions": BLOCKED_ACTIONS,
1288
+ "filters": report_payload["filters"],
1289
+ "packet_limit": max_items,
1290
+ "items_before_limit": items_before_limit,
1291
+ "item_rows": len(items),
1292
+ "status": _fulfillment_status(report_payload),
1293
+ "suggested_package": report_payload["delivery_budget"]["suggested_package"],
1294
+ "cash_path_status": report_payload["cash_path_status"],
1295
+ "operator_next_steps": report_payload["operator_next_steps"],
1296
+ "totals": {
1297
+ "loaded": report_payload["totals"]["loaded"],
1298
+ "in_scope": report_payload["totals"]["in_scope"],
1299
+ "candidate_references": len(
1300
+ report_payload["delivery_pack"]["handoff"]["candidate_references"]
1301
+ ),
1302
+ "verification_references": len(
1303
+ report_payload["delivery_pack"]["handoff"]["verification_references"]
1304
+ ),
1305
+ "no_go_references": len(report_payload["delivery_pack"]["handoff"]["no_go_references"]),
1306
+ "active_rechecks": report_payload["recheck_plan"]["recheck_rows"],
1307
+ "source_count": len(report_payload["source_quality"]["sources"]),
1308
+ },
1309
+ "qa_gates": qa_gates,
1310
+ "delivery_readiness": _fulfillment_delivery_readiness(
1311
+ qa_gates=qa_gates,
1312
+ items=items,
1313
+ report_payload=report_payload,
1314
+ ),
1315
+ "operations_digest": _fulfillment_operations_digest(
1316
+ qa_gates=qa_gates,
1317
+ items=items,
1318
+ report_payload=report_payload,
1319
+ ),
1320
+ "evidence_manifest": _fulfillment_evidence_manifest(
1321
+ report_payload=report_payload,
1322
+ recheck_payload=recheck_payload,
1323
+ cash_payload=cash_payload,
1324
+ ),
1325
+ "report_assembly_plan": _fulfillment_report_assembly_plan(
1326
+ qa_gates=qa_gates,
1327
+ report_payload=report_payload,
1328
+ ),
1329
+ "handoff": {
1330
+ "candidate_references": report_payload["delivery_pack"]["handoff"][
1331
+ "candidate_references"
1332
+ ],
1333
+ "verification_references": report_payload["delivery_pack"]["handoff"][
1334
+ "verification_references"
1335
+ ],
1336
+ "no_go_references": report_payload["delivery_pack"]["handoff"]["no_go_references"],
1337
+ "sections": [
1338
+ "client_fit_summary",
1339
+ "source_quality",
1340
+ "delivery_pack",
1341
+ "recheck_queue",
1342
+ "cash_actions",
1343
+ "evidence_manifest",
1344
+ "report_assembly_plan",
1345
+ ],
1346
+ "external_body_allowed": False,
1347
+ "payment_route_allowed_now": False,
1348
+ "requires_written_acceptance_before_payment_route": True,
1349
+ },
1350
+ "items": items,
1351
+ "requirements": {
1352
+ "network_required": False,
1353
+ "github_write_permission_required": False,
1354
+ "external_model_required": False,
1355
+ "billing_required": False,
1356
+ },
1357
+ "boundary": (
1358
+ "Fulfillment packet is internal delivery operations data for read-only decision "
1359
+ "support. It is not customer-facing prose, does not create a payment route, "
1360
+ "does not claim rewards, post comments, contact maintainers, open pull requests, "
1361
+ "or guarantee merge or payout outcomes."
1362
+ ),
1363
+ }
1364
+
1365
+
1366
+ def _fulfillment_evidence_manifest(
1367
+ *,
1368
+ report_payload: dict[str, Any],
1369
+ recheck_payload: dict[str, Any],
1370
+ cash_payload: dict[str, Any],
1371
+ ) -> dict[str, Any]:
1372
+ source_quality = report_payload["source_quality"]
1373
+ handoff = report_payload["delivery_pack"]["handoff"]
1374
+ recheck_plan = report_payload["recheck_plan"]
1375
+ intake_followup = report_payload["intake_followup"]
1376
+ cash_actions = cash_payload["items"]
1377
+ required_intake_fields = [
1378
+ field["field"]
1379
+ for field in intake_followup["requested_fields"]
1380
+ if field["required_before_paid_delivery"]
1381
+ ]
1382
+ copy_brief_actions = [
1383
+ str(row["action"]) for row in cash_actions if row.get("copy_brief_facts") is not None
1384
+ ]
1385
+ artifacts = [
1386
+ _evidence_manifest_artifact(
1387
+ artifact="source_batch",
1388
+ status="ready" if source_quality["sources"] else "blocked",
1389
+ source_fields=["source_quality.sources", "filters"],
1390
+ references=sorted(source_quality["sources"].keys()),
1391
+ required_before_delivery=True,
1392
+ next_safe_local_action="add permitted public/API source rows",
1393
+ ),
1394
+ _evidence_manifest_artifact(
1395
+ artifact="scored_candidate_set",
1396
+ status="ready" if handoff["candidate_references"] else "blocked",
1397
+ source_fields=["delivery_pack.handoff.candidate_references", "scores"],
1398
+ references=handoff["candidate_references"],
1399
+ required_before_delivery=True,
1400
+ next_safe_local_action="expand permitted sources or run scoring before shortlist assembly",
1401
+ ),
1402
+ _evidence_manifest_artifact(
1403
+ artifact="public_state_recheck_queue",
1404
+ status="blocked" if recheck_plan["recheck_rows"] else "ready",
1405
+ source_fields=["recheck_plan.next_rows"],
1406
+ references=[row["reference"] for row in recheck_plan["next_rows"]],
1407
+ required_before_delivery=True,
1408
+ next_safe_local_action="complete read-only public-state rechecks for active candidates",
1409
+ ),
1410
+ _evidence_manifest_artifact(
1411
+ artifact="no_go_archive",
1412
+ status="ready",
1413
+ source_fields=["delivery_pack.handoff.no_go_references", "no_go_moat"],
1414
+ references=handoff["no_go_references"],
1415
+ required_before_delivery=False,
1416
+ next_safe_local_action="preserve no-go rows as exclusion evidence",
1417
+ ),
1418
+ _evidence_manifest_artifact(
1419
+ artifact="buyer_intake_record",
1420
+ status="blocked" if required_intake_fields else "ready",
1421
+ source_fields=["intake_followup.requested_fields"],
1422
+ references=required_intake_fields,
1423
+ required_before_delivery=True,
1424
+ next_safe_local_action="collect missing buyer-fit fields through a facts-only copy brief",
1425
+ ),
1426
+ _evidence_manifest_artifact(
1427
+ artifact="copy_brief_facts",
1428
+ status="ready" if copy_brief_actions else "not_needed",
1429
+ source_fields=["cash_actions.items.copy_brief_facts"],
1430
+ references=copy_brief_actions,
1431
+ required_before_delivery=False,
1432
+ next_safe_local_action="hand facts-only payload to OpenClaw/Opus only after a real reply branch exists",
1433
+ ),
1434
+ _evidence_manifest_artifact(
1435
+ artifact="payment_acceptance_record",
1436
+ status="blocked",
1437
+ source_fields=["cash_path_status"],
1438
+ references=[],
1439
+ required_before_delivery=True,
1440
+ next_safe_local_action="wait for written buyer acceptance before creating any payment route",
1441
+ ),
1442
+ ]
1443
+ required_artifacts = [
1444
+ artifact for artifact in artifacts if artifact["required_before_delivery"]
1445
+ ]
1446
+ blocked_artifacts = [
1447
+ artifact["artifact"] for artifact in required_artifacts if artifact["status"] != "ready"
1448
+ ]
1449
+ return {
1450
+ "schema_version": "patchrail.funded_issues.evidence_manifest.v1",
1451
+ "status": "ready_for_paid_delivery" if not blocked_artifacts else "blocked_internal",
1452
+ "artifact_count": len(artifacts),
1453
+ "required_artifact_count": len(required_artifacts),
1454
+ "ready_required_artifact_count": len(required_artifacts) - len(blocked_artifacts),
1455
+ "blocked_artifacts": blocked_artifacts,
1456
+ "artifacts": artifacts,
1457
+ "external_body_allowed": False,
1458
+ "payment_route_allowed_now": False,
1459
+ "boundary": (
1460
+ "Evidence manifest is internal readiness data. It does not write customer prose, "
1461
+ "create payment routes, claim rewards, contact maintainers, post comments, open "
1462
+ "pull requests, or guarantee merge/payout outcomes."
1463
+ ),
1464
+ }
1465
+
1466
+
1467
+ def _evidence_manifest_artifact(
1468
+ *,
1469
+ artifact: str,
1470
+ status: str,
1471
+ source_fields: list[str],
1472
+ references: list[str],
1473
+ required_before_delivery: bool,
1474
+ next_safe_local_action: str,
1475
+ ) -> dict[str, Any]:
1476
+ return {
1477
+ "artifact": artifact,
1478
+ "status": status,
1479
+ "source_fields": source_fields,
1480
+ "references": sorted(set(references)),
1481
+ "required_before_delivery": required_before_delivery,
1482
+ "next_safe_local_action": next_safe_local_action,
1483
+ "external_body_allowed": False,
1484
+ "payment_route_allowed_now": False,
1485
+ }
1486
+
1487
+
1488
+ def _fulfillment_report_assembly_plan(
1489
+ *,
1490
+ qa_gates: list[dict[str, Any]],
1491
+ report_payload: dict[str, Any],
1492
+ ) -> dict[str, Any]:
1493
+ gate_map = {str(gate["gate"]): gate for gate in qa_gates}
1494
+ handoff = report_payload["delivery_pack"]["handoff"]
1495
+ decision_summary = report_payload["decision_summary"]
1496
+ source_quality = report_payload["source_quality"]
1497
+ no_go_moat = report_payload["no_go_moat"]
1498
+ sections = [
1499
+ _report_assembly_section(
1500
+ section="executive_summary",
1501
+ source_fields=["decision_summary", "source_quality.summary", "cash_path_status"],
1502
+ references=[],
1503
+ blocked_by=[],
1504
+ next_safe_local_action="summarize batch decision, blocker count, and cash-path status",
1505
+ ),
1506
+ _report_assembly_section(
1507
+ section="top_recommendations",
1508
+ source_fields=[
1509
+ "delivery_pack.handoff.candidate_references",
1510
+ "recheck_plan.next_rows",
1511
+ ],
1512
+ references=handoff["candidate_references"] + handoff["verification_references"],
1513
+ blocked_by=_blocked_report_section_gates(
1514
+ gate_map,
1515
+ ["public_state_recheck_complete", "buyer_intake_fields_complete"],
1516
+ ),
1517
+ next_safe_local_action="complete public-state rechecks before turning candidates into recommendations",
1518
+ ),
1519
+ _report_assembly_section(
1520
+ section="watchlist",
1521
+ source_fields=["recheck_plan.next_rows", "source_quality.sources"],
1522
+ references=handoff["verification_references"],
1523
+ blocked_by=_blocked_report_section_gates(gate_map, ["public_state_recheck_complete"]),
1524
+ next_safe_local_action="keep verification rows as watchlist until funding and state are current",
1525
+ ),
1526
+ _report_assembly_section(
1527
+ section="no_go_list",
1528
+ source_fields=["delivery_pack.handoff.no_go_references", "no_go_moat"],
1529
+ references=handoff["no_go_references"],
1530
+ blocked_by=[],
1531
+ next_safe_local_action="preserve no-go rows as exclusion evidence",
1532
+ ),
1533
+ _report_assembly_section(
1534
+ section="patterns_observed",
1535
+ source_fields=["source_quality.summary", "breakdown.risk_flags", "no_go_moat"],
1536
+ references=[],
1537
+ blocked_by=[],
1538
+ next_safe_local_action="summarize source quality and recurring no-go patterns internally",
1539
+ ),
1540
+ _report_assembly_section(
1541
+ section="recommended_operating_procedure",
1542
+ source_fields=["operator_next_steps", "operations_digest"],
1543
+ references=[],
1544
+ blocked_by=_blocked_report_section_gates(
1545
+ gate_map,
1546
+ ["buyer_intake_fields_complete", "payment_route_written_acceptance"],
1547
+ ),
1548
+ next_safe_local_action="keep the procedure internal until buyer scope and payment route preconditions are met",
1549
+ ),
1550
+ _report_assembly_section(
1551
+ section="disclaimer",
1552
+ source_fields=["boundary", "blocked_actions", "requirements"],
1553
+ references=[],
1554
+ blocked_by=[],
1555
+ next_safe_local_action="keep read-only/no-guarantee boundaries visible in every delivered report",
1556
+ ),
1557
+ ]
1558
+ blocked_sections = [section for section in sections if section["blocked_by"]]
1559
+ customer_delivery_ready = not blocked_sections and bool(
1560
+ gate_map.get("payment_route_written_acceptance", {}).get("passed")
1561
+ )
1562
+ return {
1563
+ "schema_version": "patchrail.funded_issues.report_assembly_plan.v1",
1564
+ "status": (
1565
+ "ready_for_customer_delivery"
1566
+ if customer_delivery_ready
1567
+ else "blocked_before_customer_delivery"
1568
+ ),
1569
+ "internal_assembly_ready": bool(
1570
+ decision_summary["total_rows"] and source_quality["summary"]["source_count"]
1571
+ ),
1572
+ "customer_delivery_ready": customer_delivery_ready,
1573
+ "section_count": len(sections),
1574
+ "ready_sections": [section["section"] for section in sections if not section["blocked_by"]],
1575
+ "blocked_sections": [section["section"] for section in blocked_sections],
1576
+ "source_quality_status": source_quality["summary"]["status"],
1577
+ "candidate_references": list(handoff["candidate_references"]),
1578
+ "verification_references": list(handoff["verification_references"]),
1579
+ "no_go_references": list(handoff["no_go_references"]),
1580
+ "no_go_signal_count": int(no_go_moat["high_risk_or_excluded"]),
1581
+ "sections": sections,
1582
+ "payment_route_allowed_now": False,
1583
+ "external_body_allowed": False,
1584
+ "customer_facing_prose_allowed": False,
1585
+ "requires_written_acceptance_before_delivery": True,
1586
+ "boundary": (
1587
+ "Report assembly plan is internal structure only. It may organize evidence for "
1588
+ "OpenClaw/Opus or a paid delivery workflow, but it does not write customer prose, "
1589
+ "create payment routes, claim rewards, contact maintainers, post comments, open "
1590
+ "pull requests, or guarantee merge/payout outcomes."
1591
+ ),
1592
+ }
1593
+
1594
+
1595
+ def _blocked_report_section_gates(
1596
+ gate_map: dict[str, dict[str, Any]],
1597
+ gate_names: list[str],
1598
+ ) -> list[str]:
1599
+ return [
1600
+ gate_name for gate_name in gate_names if not bool(gate_map.get(gate_name, {}).get("passed"))
1601
+ ]
1602
+
1603
+
1604
+ def _report_assembly_section(
1605
+ *,
1606
+ section: str,
1607
+ source_fields: list[str],
1608
+ references: list[str],
1609
+ blocked_by: list[str],
1610
+ next_safe_local_action: str,
1611
+ ) -> dict[str, Any]:
1612
+ return {
1613
+ "section": section,
1614
+ "status": "blocked_before_customer_delivery" if blocked_by else "ready_for_internal_draft",
1615
+ "source_fields": source_fields,
1616
+ "references": sorted(set(references)),
1617
+ "blocked_by": blocked_by,
1618
+ "next_safe_local_action": next_safe_local_action,
1619
+ "external_body_allowed": False,
1620
+ "payment_route_allowed_now": False,
1621
+ }
1622
+
1623
+
1624
+ def _cash_action_rows(report_payload: dict[str, Any]) -> list[dict[str, Any]]:
1625
+ rows: list[dict[str, Any]] = []
1626
+ cash_path_status = report_payload["cash_path_status"]
1627
+ intake_followup = report_payload["intake_followup"]
1628
+ recheck_plan = report_payload["recheck_plan"]
1629
+ delivery_pack = report_payload["delivery_pack"]
1630
+ source_quality = report_payload["source_quality"]
1631
+
1632
+ if cash_path_status["next_revenue_action"] == "collect_buyer_intake":
1633
+ rows.append(
1634
+ _cash_action_row(
1635
+ action="collect_buyer_intake",
1636
+ priority="high",
1637
+ reason="Required buyer-fit fields are missing before paid delivery.",
1638
+ requested_fields=[
1639
+ field["field"]
1640
+ for field in intake_followup["requested_fields"]
1641
+ if field["required_before_paid_delivery"]
1642
+ ],
1643
+ evidence_references=delivery_pack["handoff"]["candidate_references"],
1644
+ suggested_package=intake_followup["suggested_package"],
1645
+ copy_brief_allowed=True,
1646
+ )
1647
+ )
1648
+
1649
+ if recheck_plan["recheck_rows"]:
1650
+ rows.append(
1651
+ _cash_action_row(
1652
+ action="run_read_only_recheck",
1653
+ priority="high"
1654
+ if cash_path_status["next_revenue_action"] == "run_read_only_recheck"
1655
+ else "medium",
1656
+ reason="Candidate or watchlist rows need current public-state evidence.",
1657
+ requested_fields=[
1658
+ field["field"]
1659
+ for field in intake_followup["requested_fields"]
1660
+ if field["field"] == "public_state_recheck_window"
1661
+ ],
1662
+ evidence_references=[row["reference"] for row in recheck_plan["next_rows"]],
1663
+ suggested_package=intake_followup["suggested_package"],
1664
+ copy_brief_allowed=False,
1665
+ )
1666
+ )
1667
+
1668
+ if cash_path_status["next_revenue_action"] == "confirm_paid_scope":
1669
+ rows.append(
1670
+ _cash_action_row(
1671
+ action="confirm_paid_scope",
1672
+ priority="high",
1673
+ reason="Rows are buyer-ready after local checks; confirm package and written scope.",
1674
+ requested_fields=[],
1675
+ evidence_references=delivery_pack["handoff"]["candidate_references"],
1676
+ suggested_package=intake_followup["suggested_package"],
1677
+ copy_brief_allowed=True,
1678
+ )
1679
+ )
1680
+
1681
+ if cash_path_status["next_revenue_action"] == "expand_permitted_sources":
1682
+ rows.append(
1683
+ _cash_action_row(
1684
+ action="expand_permitted_sources",
1685
+ priority="medium",
1686
+ reason="Current filters produced no buyer-ready candidates.",
1687
+ requested_fields=[
1688
+ field["field"]
1689
+ for field in intake_followup["requested_fields"]
1690
+ if field["field"] in {"permitted_sources", "source_expansion_preferences"}
1691
+ ],
1692
+ evidence_references=[],
1693
+ suggested_package=intake_followup["suggested_package"],
1694
+ copy_brief_allowed=False,
1695
+ source_names=sorted(source_quality["sources"].keys()),
1696
+ )
1697
+ )
1698
+
1699
+ if not rows:
1700
+ rows.append(
1701
+ _cash_action_row(
1702
+ action="expand_permitted_sources",
1703
+ priority="medium",
1704
+ reason="No internal cash action matched this batch; expand permitted sources.",
1705
+ requested_fields=[],
1706
+ evidence_references=[],
1707
+ suggested_package=intake_followup["suggested_package"],
1708
+ copy_brief_allowed=False,
1709
+ source_names=sorted(source_quality["sources"].keys()),
1710
+ )
1711
+ )
1712
+
1713
+ return sorted(rows, key=lambda row: (_recheck_priority_rank(row["priority"]), row["action"]))
1714
+
1715
+
1716
+ def _cash_action_row(
1717
+ *,
1718
+ action: str,
1719
+ priority: str,
1720
+ reason: str,
1721
+ requested_fields: list[str],
1722
+ evidence_references: list[str],
1723
+ suggested_package: str,
1724
+ copy_brief_allowed: bool,
1725
+ source_names: list[str] | None = None,
1726
+ ) -> dict[str, Any]:
1727
+ copy_brief_facts = _copy_brief_facts_for_action(
1728
+ action=action,
1729
+ reason=reason,
1730
+ requested_fields=requested_fields,
1731
+ evidence_references=evidence_references,
1732
+ suggested_package=suggested_package,
1733
+ copy_brief_allowed=copy_brief_allowed,
1734
+ source_names=source_names or [],
1735
+ )
1736
+ return {
1737
+ "action": action,
1738
+ "priority": priority,
1739
+ "reason": reason,
1740
+ "requested_fields": requested_fields,
1741
+ "evidence_references": evidence_references,
1742
+ "source_names": source_names or [],
1743
+ "suggested_package": suggested_package,
1744
+ "copy_brief_allowed": copy_brief_allowed,
1745
+ "copy_brief_facts": copy_brief_facts,
1746
+ "external_body_allowed": False,
1747
+ "payment_route_allowed_now": False,
1748
+ "requires_written_acceptance_before_payment_route": True,
1749
+ "blocked_actions": BLOCKED_ACTIONS,
1750
+ "boundary": (
1751
+ "Internal facts-only handoff. Do not write external prose here, create a payment "
1752
+ "route, claim rewards, post comments, contact maintainers, open pull requests, "
1753
+ "or imply merge/payout certainty."
1754
+ ),
1755
+ }
1756
+
1757
+
1758
+ def _copy_brief_facts_for_action(
1759
+ *,
1760
+ action: str,
1761
+ reason: str,
1762
+ requested_fields: list[str],
1763
+ evidence_references: list[str],
1764
+ suggested_package: str,
1765
+ copy_brief_allowed: bool,
1766
+ source_names: list[str],
1767
+ ) -> dict[str, Any] | None:
1768
+ if not copy_brief_allowed:
1769
+ return None
1770
+ key_facts = [
1771
+ f"internal_action={action}",
1772
+ f"suggested_package={suggested_package}",
1773
+ f"reason={reason}",
1774
+ "payment_route_allowed_now=false",
1775
+ "requires_written_acceptance_before_payment_route=true",
1776
+ ]
1777
+ if requested_fields:
1778
+ key_facts.append(f"requested_fields={','.join(requested_fields)}")
1779
+ if evidence_references:
1780
+ key_facts.append(f"evidence_references={','.join(evidence_references)}")
1781
+ if source_names:
1782
+ key_facts.append(f"source_names={','.join(source_names)}")
1783
+ return {
1784
+ "schema_version": "patchrail.funded_issues.copy_brief_facts.v1",
1785
+ "type": "reply",
1786
+ "lead": "buyer_or_active_thread",
1787
+ "goal": action,
1788
+ "key_facts": key_facts,
1789
+ "tone": "concise, async-only, commercial, brand-safe",
1790
+ "constraints": [
1791
+ "facts-only packet for OpenClaw/Opus",
1792
+ "do not include a customer-facing body in this payload",
1793
+ "no calls, demos, calendar links, claims, comments, pull requests, maintainer outreach",
1794
+ "no merge, payout, legal, financial, availability, or maintainer-response guarantees",
1795
+ "do not create or offer a payment route before written buyer acceptance",
1796
+ ],
1797
+ "urgency": "normal",
1798
+ "thread_ref": "fill_from_live_reply_or_pipeline_record",
1799
+ "forbidden_fields": ["body", "draft", "email_body"],
1800
+ "external_body_allowed": False,
1801
+ "payment_route_allowed_now": False,
1802
+ }
1803
+
1804
+
1805
+ def _fulfillment_status(report_payload: dict[str, Any]) -> str:
1806
+ status = str(report_payload["intake_followup"]["status"])
1807
+ return {
1808
+ "needs_buyer_intake": "needs_buyer_intake",
1809
+ "ready_after_read_only_recheck": "needs_read_only_recheck",
1810
+ "ready_for_scope_confirmation": "ready_for_scope_confirmation",
1811
+ "needs_source_expansion": "needs_source_expansion",
1812
+ }[status]
1813
+
1814
+
1815
+ def _fulfillment_qa_gates(report_payload: dict[str, Any]) -> list[dict[str, Any]]:
1816
+ intake_followup = report_payload["intake_followup"]
1817
+ recheck_plan = report_payload["recheck_plan"]
1818
+ source_quality = report_payload["source_quality"]
1819
+ delivery_pack = report_payload["delivery_pack"]
1820
+ return [
1821
+ _fulfillment_qa_gate(
1822
+ gate="buyer_intake_fields_complete",
1823
+ passed=intake_followup["required_before_paid_delivery"] == 0,
1824
+ reason=("Required buyer-fit fields must be present before paid delivery can start."),
1825
+ evidence=[
1826
+ field["field"]
1827
+ for field in intake_followup["requested_fields"]
1828
+ if field["required_before_paid_delivery"]
1829
+ ],
1830
+ ),
1831
+ _fulfillment_qa_gate(
1832
+ gate="public_state_recheck_complete",
1833
+ passed=recheck_plan["recheck_rows"] == 0,
1834
+ reason="Candidate rows need current public-state evidence before delivery use.",
1835
+ evidence=[row["reference"] for row in recheck_plan["next_rows"]],
1836
+ ),
1837
+ _fulfillment_qa_gate(
1838
+ gate="source_quality_recorded",
1839
+ passed=bool(source_quality["sources"]),
1840
+ reason="The packet needs at least one permitted local source row.",
1841
+ evidence=sorted(source_quality["sources"].keys()),
1842
+ ),
1843
+ _fulfillment_qa_gate(
1844
+ gate="no_go_evidence_preserved",
1845
+ passed=True,
1846
+ reason="No-go rows stay in the packet as exclusion evidence, not work targets.",
1847
+ evidence=delivery_pack["handoff"]["no_go_references"],
1848
+ ),
1849
+ _fulfillment_qa_gate(
1850
+ gate="payment_route_written_acceptance",
1851
+ passed=False,
1852
+ reason="Payment routes require written buyer acceptance or a buyer-requested route.",
1853
+ evidence=[],
1854
+ ),
1855
+ _fulfillment_qa_gate(
1856
+ gate="third_party_write_boundary",
1857
+ passed=True,
1858
+ reason=("Fulfillment is local read-only work and does not authorize external writes."),
1859
+ evidence=BLOCKED_ACTIONS,
1860
+ ),
1861
+ ]
1862
+
1863
+
1864
+ def _fulfillment_qa_gate(
1865
+ *,
1866
+ gate: str,
1867
+ passed: bool,
1868
+ reason: str,
1869
+ evidence: list[str],
1870
+ ) -> dict[str, Any]:
1871
+ return {
1872
+ "gate": gate,
1873
+ "passed": passed,
1874
+ "reason": reason,
1875
+ "evidence": evidence,
1876
+ }
1877
+
1878
+
1879
+ def _fulfillment_delivery_readiness(
1880
+ *,
1881
+ qa_gates: list[dict[str, Any]],
1882
+ items: list[dict[str, Any]],
1883
+ report_payload: dict[str, Any],
1884
+ ) -> dict[str, Any]:
1885
+ blocking_gates = [gate["gate"] for gate in qa_gates if not gate["passed"]]
1886
+ blocking_items = [item for item in items if item["blocks_paid_delivery"]]
1887
+ ready_for_paid_delivery = not blocking_gates and not blocking_items
1888
+ return {
1889
+ "ready_for_paid_delivery": ready_for_paid_delivery,
1890
+ "status": "ready_for_paid_delivery" if ready_for_paid_delivery else "blocked_internal",
1891
+ "passed_gates": [gate["gate"] for gate in qa_gates if gate["passed"]],
1892
+ "blocking_gates": blocking_gates,
1893
+ "blocking_item_actions": sorted({str(item["action"]) for item in blocking_items}),
1894
+ "blocking_reference_scope": sorted(
1895
+ {reference for item in blocking_items for reference in item["reference_scope"]}
1896
+ ),
1897
+ "next_internal_action": report_payload["cash_path_status"]["next_revenue_action"],
1898
+ "payment_route_allowed_now": False,
1899
+ "external_body_allowed": False,
1900
+ "boundary": (
1901
+ "Delivery readiness is internal operations status only. It does not authorize "
1902
+ "customer-facing prose, payment routes, claims, comments, maintainer contact, "
1903
+ "pull requests, or merge/payout guarantees."
1904
+ ),
1905
+ }
1906
+
1907
+
1908
+ def _fulfillment_operations_digest(
1909
+ *,
1910
+ qa_gates: list[dict[str, Any]],
1911
+ items: list[dict[str, Any]],
1912
+ report_payload: dict[str, Any],
1913
+ ) -> dict[str, Any]:
1914
+ blocking_gates = [
1915
+ _operations_digest_step(
1916
+ source="qa_gate",
1917
+ action=str(gate["gate"]),
1918
+ owner=_fulfillment_owner(str(gate["gate"])),
1919
+ priority="high",
1920
+ evidence=list(gate["evidence"]),
1921
+ reason=str(gate["reason"]),
1922
+ blocks_paid_delivery=True,
1923
+ )
1924
+ for gate in qa_gates
1925
+ if not gate["passed"]
1926
+ ]
1927
+ blocking_items = [
1928
+ _operations_digest_step(
1929
+ source=str(item["stage"]),
1930
+ action=str(item["action"]),
1931
+ owner=_fulfillment_owner(str(item["action"])),
1932
+ priority=str(item["priority"]),
1933
+ evidence=list(item["reference_scope"]) or list(item["evidence_required"]),
1934
+ reason=str(item["reason"]),
1935
+ blocks_paid_delivery=True,
1936
+ )
1937
+ for item in items
1938
+ if item["blocks_paid_delivery"]
1939
+ ]
1940
+ critical_path = blocking_gates + blocking_items
1941
+ safe_local_actions = [
1942
+ step
1943
+ for step in critical_path
1944
+ if step["owner"] == "patchrail_operator"
1945
+ and step["action"] != "payment_route_written_acceptance"
1946
+ ]
1947
+ stage_counts = Counter(str(item["stage"]) for item in items)
1948
+ blocking_stage_counts = Counter(
1949
+ str(item["stage"]) for item in items if item["blocks_paid_delivery"]
1950
+ )
1951
+ passed_gates = sum(1 for gate in qa_gates if gate["passed"])
1952
+ gate_pass_rate = round(passed_gates / len(qa_gates), 2) if qa_gates else 1.0
1953
+ return {
1954
+ "schema_version": "patchrail.funded_issues.operations_digest.v1",
1955
+ "status": "ready_for_paid_delivery" if not critical_path else "blocked_internal",
1956
+ "next_blocker": critical_path[0] if critical_path else None,
1957
+ "next_safe_local_action": safe_local_actions[0] if safe_local_actions else None,
1958
+ "blocking_count": len(critical_path),
1959
+ "gate_pass_rate": gate_pass_rate,
1960
+ "stage_counts": dict(sorted(stage_counts.items())),
1961
+ "blocking_stage_counts": dict(sorted(blocking_stage_counts.items())),
1962
+ "non_blocking_actions": sorted(
1963
+ {str(item["action"]) for item in items if not item["blocks_paid_delivery"]}
1964
+ ),
1965
+ "critical_path": critical_path,
1966
+ "cash_path_status": report_payload["cash_path_status"]["status"],
1967
+ "payment_route_allowed_now": False,
1968
+ "external_body_allowed": False,
1969
+ "boundary": (
1970
+ "Operations digest is internal read-only delivery triage. It may guide local "
1971
+ "tracker work or facts-only copy-briefs, but it does not write customer prose, "
1972
+ "create payment routes, claim rewards, contact maintainers, post comments, "
1973
+ "open pull requests, or guarantee merge/payout outcomes."
1974
+ ),
1975
+ }
1976
+
1977
+
1978
+ def _operations_digest_step(
1979
+ *,
1980
+ source: str,
1981
+ action: str,
1982
+ owner: str,
1983
+ priority: str,
1984
+ evidence: list[str],
1985
+ reason: str,
1986
+ blocks_paid_delivery: bool,
1987
+ ) -> dict[str, Any]:
1988
+ return {
1989
+ "source": source,
1990
+ "action": action,
1991
+ "owner": owner,
1992
+ "priority": priority,
1993
+ "evidence": evidence,
1994
+ "reason": reason,
1995
+ "blocks_paid_delivery": blocks_paid_delivery,
1996
+ }
1997
+
1998
+
1999
+ def _fulfillment_owner(action: str) -> str:
2000
+ if action in {
2001
+ "public_state_recheck_complete",
2002
+ "source_quality_recorded",
2003
+ "no_go_evidence_preserved",
2004
+ "run_read_only_recheck",
2005
+ "recheck_public_issue_state",
2006
+ "preserve_no_go_evidence",
2007
+ "expand_permitted_sources",
2008
+ }:
2009
+ return "patchrail_operator"
2010
+ if action in {
2011
+ "buyer_intake_fields_complete",
2012
+ "collect_buyer_intake",
2013
+ "confirm_paid_scope",
2014
+ "payment_route_written_acceptance",
2015
+ }:
2016
+ return "buyer_or_written_acceptance"
2017
+ return "policy_boundary"
2018
+
2019
+
2020
+ def _fulfillment_items(
2021
+ *,
2022
+ report_payload: dict[str, Any],
2023
+ recheck_payload: dict[str, Any],
2024
+ cash_payload: dict[str, Any],
2025
+ ) -> list[dict[str, Any]]:
2026
+ items: list[dict[str, Any]] = []
2027
+ for row in cash_payload["items"]:
2028
+ items.append(
2029
+ _fulfillment_item(
2030
+ stage="cash_path",
2031
+ priority=row["priority"],
2032
+ action=row["action"],
2033
+ reference_scope=row["evidence_references"] or row["source_names"],
2034
+ evidence_required=row["requested_fields"],
2035
+ reason=row["reason"],
2036
+ blocks_paid_delivery=row["action"]
2037
+ in {"collect_buyer_intake", "confirm_paid_scope"},
2038
+ )
2039
+ )
2040
+ for row in recheck_payload["items"]:
2041
+ items.append(
2042
+ _fulfillment_item(
2043
+ stage="public_state_recheck",
2044
+ priority=row["priority"],
2045
+ action=row["action"],
2046
+ reference_scope=[row["reference"]],
2047
+ evidence_required=row["evidence_checklist"],
2048
+ reason=row["reason"],
2049
+ blocks_paid_delivery=True,
2050
+ )
2051
+ )
2052
+ if not report_payload["source_quality"]["sources"]:
2053
+ items.append(
2054
+ _fulfillment_item(
2055
+ stage="source_expansion",
2056
+ priority="high",
2057
+ action="expand_permitted_sources",
2058
+ reference_scope=[],
2059
+ evidence_required=["permitted public/API source name", "source URL"],
2060
+ reason="No permitted source rows matched this packet.",
2061
+ blocks_paid_delivery=True,
2062
+ )
2063
+ )
2064
+ return sorted(
2065
+ items,
2066
+ key=lambda item: (
2067
+ _recheck_priority_rank(str(item["priority"])),
2068
+ str(item["stage"]),
2069
+ str(item["action"]),
2070
+ ),
2071
+ )
2072
+
2073
+
2074
+ def _fulfillment_item(
2075
+ *,
2076
+ stage: str,
2077
+ priority: str,
2078
+ action: str,
2079
+ reference_scope: list[str],
2080
+ evidence_required: list[str],
2081
+ reason: str,
2082
+ blocks_paid_delivery: bool,
2083
+ ) -> dict[str, Any]:
2084
+ return {
2085
+ "stage": stage,
2086
+ "priority": priority,
2087
+ "action": action,
2088
+ "reference_scope": reference_scope,
2089
+ "evidence_required": evidence_required,
2090
+ "reason": reason,
2091
+ "blocks_paid_delivery": blocks_paid_delivery,
2092
+ "external_body_allowed": False,
2093
+ "payment_route_allowed_now": False,
2094
+ "github_write_permission_required": False,
2095
+ "network_required": False,
2096
+ "blocked_actions": BLOCKED_ACTIONS,
2097
+ "boundary": (
2098
+ "Internal read-only fulfillment item. Do not write customer prose here, create "
2099
+ "a payment route, claim rewards, post comments, contact maintainers, open pull "
2100
+ "requests, or imply merge/payout certainty."
2101
+ ),
2102
+ }
2103
+
2104
+
2105
+ def _recheck_queue_row(row: dict[str, Any]) -> dict[str, Any]:
2106
+ issue = row["issue"]
2107
+ decision_gate = str(row["decision_gate"])
2108
+ action = _recheck_action_for_gate(decision_gate)
2109
+ return {
2110
+ "reference": issue["reference"],
2111
+ "title": issue["title"],
2112
+ "url": issue["url"],
2113
+ "platform": issue["platform"],
2114
+ "funding": issue["funding"]["display"],
2115
+ "opportunity_state": issue["opportunity_state"],
2116
+ "risk_level": issue["risk_level"],
2117
+ "score": row["score"],
2118
+ "confidence": row["confidence"],
2119
+ "decision_gate": decision_gate,
2120
+ "priority": _recheck_priority_for_gate(decision_gate),
2121
+ "action": action,
2122
+ "reason": _recheck_reason_for_gate(decision_gate),
2123
+ "evidence_checklist": _recheck_evidence_checklist(action),
2124
+ "recommended_next_step": row["recommended_next_step"],
2125
+ "blocked_actions": BLOCKED_ACTIONS,
2126
+ }
2127
+
2128
+
2129
+ def _recheck_focus_batch(queue_rows: list[dict[str, Any]]) -> dict[str, Any]:
2130
+ if not queue_rows:
2131
+ return {
2132
+ "status": "clear",
2133
+ "primary_action": "none",
2134
+ "priority": "none",
2135
+ "item_count": 0,
2136
+ "references": [],
2137
+ "platform_counts": {},
2138
+ "evidence_checklist": [],
2139
+ "boundary": (
2140
+ "No active recheck batch exists. Do not invent outreach, claims, comments, "
2141
+ "pull requests, payment routes, or payout/merge guarantees."
2142
+ ),
2143
+ }
2144
+
2145
+ first = queue_rows[0]
2146
+ primary_action = str(first["action"])
2147
+ priority = str(first["priority"])
2148
+ focused_rows = [
2149
+ row for row in queue_rows if row["action"] == primary_action and row["priority"] == priority
2150
+ ]
2151
+ platform_counts = Counter(str(row["platform"]) for row in focused_rows)
2152
+ return {
2153
+ "status": "active_recheck_batch",
2154
+ "primary_action": primary_action,
2155
+ "priority": priority,
2156
+ "item_count": len(focused_rows),
2157
+ "references": [str(row["reference"]) for row in focused_rows],
2158
+ "platform_counts": dict(sorted(platform_counts.items())),
2159
+ "evidence_checklist": list(first["evidence_checklist"]),
2160
+ "boundary": (
2161
+ "Focus batch is the next local read-only tracker maintenance slice. It schedules "
2162
+ "evidence checks only and does not authorize external prose, payment routes, claims, "
2163
+ "comments, maintainer contact, pull requests, or payout/merge guarantees."
2164
+ ),
2165
+ }
2166
+
2167
+
2168
+ def _recheck_evidence_checklist(action: str) -> list[str]:
2169
+ checklists = {
2170
+ "recheck_public_issue_state": [
2171
+ "confirm issue is still open from permitted public/API source",
2172
+ "confirm no assignee or active competing pull request",
2173
+ "confirm funding is still visible before paid shortlist use",
2174
+ ],
2175
+ "recheck_scope_and_noise": [
2176
+ "confirm scope is still narrow enough for paid review",
2177
+ "confirm recent maintainer signal or clear acceptance criteria",
2178
+ "confirm row is not only useful as no-go evidence",
2179
+ ],
2180
+ "verify_funding_visibility": [
2181
+ "confirm visible amount and currency from permitted public/API source",
2182
+ "record funding source URL or park as funding unclear",
2183
+ "do not rank by amount until funding is verified",
2184
+ ],
2185
+ "confirm_client_authorization": [
2186
+ "keep row parked unless buyer authorizes bounded review",
2187
+ "do not contact maintainers or touch third-party repositories",
2188
+ "record authorization boundary before any deeper analysis",
2189
+ ],
2190
+ }
2191
+ return checklists.get(
2192
+ action,
2193
+ ["review public evidence locally and preserve read-only boundaries"],
2194
+ )
2195
+
2196
+
2197
+ def _decision_summary(scored_rows: list[dict[str, Any]]) -> dict[str, Any]:
2198
+ gate_counts = Counter(str(row["decision_gate"]) for row in scored_rows)
2199
+ gate_counts = Counter({gate: gate_counts.get(gate, 0) for gate in sorted(VALID_DECISION_GATES)})
2200
+ candidate_rows = sum(1 for row in scored_rows if _is_shortlist_candidate_row(row))
2201
+ no_go_rows = sum(1 for row in scored_rows if row["rating"] == "no_go")
2202
+ return {
2203
+ "total_rows": len(scored_rows),
2204
+ "candidate_rows": candidate_rows,
2205
+ "no_go_rows": no_go_rows,
2206
+ "gate_counts": dict(gate_counts),
2207
+ "verification_needed": gate_counts["needs_funding_verification"],
2208
+ "authorization_needed": gate_counts["needs_authorization"],
2209
+ "recommended_batch_action": _recommended_batch_action(
2210
+ candidate_rows=candidate_rows,
2211
+ no_go_rows=no_go_rows,
2212
+ verification_needed=gate_counts["needs_funding_verification"],
2213
+ authorization_needed=gate_counts["needs_authorization"],
2214
+ ),
2215
+ "safety_boundary": (
2216
+ "Use for local decision support only; re-check public state before any engagement "
2217
+ "decision and do not claim, comment, pull-request, or contact maintainers automatically."
2218
+ ),
2219
+ }
2220
+
2221
+
2222
+ def _recommended_batch_action(
2223
+ *,
2224
+ candidate_rows: int,
2225
+ no_go_rows: int,
2226
+ verification_needed: int,
2227
+ authorization_needed: int,
2228
+ ) -> str:
2229
+ if candidate_rows:
2230
+ return (
2231
+ "Review go-after-recheck and watchlist candidates locally; keep no-go rows as "
2232
+ "exclusion evidence and verify public state before any engagement decision."
2233
+ )
2234
+ if verification_needed:
2235
+ return (
2236
+ "Verify funding and current issue state from permitted public/API sources before "
2237
+ "ranking this batch."
2238
+ )
2239
+ if authorization_needed:
2240
+ return (
2241
+ "Keep authorization-gated rows parked unless the client separately requests a "
2242
+ "bounded review."
2243
+ )
2244
+ if no_go_rows:
2245
+ return "Do not spend engineering time on this batch; use no-go rows as evidence."
2246
+ return "No in-scope rows; expand permitted read-only sources before ranking."
2247
+
2248
+
2249
+ def _delivery_budget(scored_rows: list[dict[str, Any]]) -> dict[str, Any]:
2250
+ minutes_by_gate = {
2251
+ "go_after_recheck": 10,
2252
+ "watchlist": 10,
2253
+ "needs_funding_verification": 4,
2254
+ "needs_authorization": 3,
2255
+ "no_go": 3,
2256
+ }
2257
+ package = _suggested_package_for_rows(len(scored_rows))
2258
+ max_paid_hours = {
2259
+ "none": 0,
2260
+ "mini_diagnostic": 3,
2261
+ "validation_sprint": 5,
2262
+ "opportunity_shortlist": 10,
2263
+ "custom_batch": None,
2264
+ }[package]
2265
+ estimated_minutes = sum(
2266
+ minutes_by_gate.get(str(row["decision_gate"]), 3) for row in scored_rows
2267
+ )
2268
+ estimated_hours = round(estimated_minutes / 60, 2)
2269
+ l2_rows = sum(1 for row in scored_rows if _is_shortlist_candidate_row(row))
2270
+ l1_rows = len(scored_rows) - l2_rows
2271
+ return {
2272
+ "suggested_package": package,
2273
+ "estimated_review_minutes": estimated_minutes,
2274
+ "estimated_review_hours": estimated_hours,
2275
+ "max_paid_hours": max_paid_hours,
2276
+ "within_margin_budget": (
2277
+ True if max_paid_hours is None else estimated_hours <= max_paid_hours
2278
+ ),
2279
+ "analysis_rows": {
2280
+ "l1_state_and_noise_review": l1_rows,
2281
+ "l2_scope_and_readiness_review": l2_rows,
2282
+ "l3_deep_dive_deferred": 0,
2283
+ },
2284
+ "boundary": (
2285
+ "Budget is for local read-only triage. Do not clone repos, run deep repro, "
2286
+ "contact maintainers, claim rewards, or open pull requests before paid scope."
2287
+ ),
2288
+ }
2289
+
2290
+
2291
+ def _delivery_pack(scored_rows: list[dict[str, Any]]) -> dict[str, Any]:
2292
+ candidate_rows = [row for row in scored_rows if _is_shortlist_candidate_row(row)]
2293
+ verification_rows = [
2294
+ row
2295
+ for row in scored_rows
2296
+ if row["decision_gate"] in {"needs_funding_verification", "needs_authorization"}
2297
+ ]
2298
+ no_go_rows = [row for row in scored_rows if row["decision_gate"] == "no_go"]
2299
+ phases = [
2300
+ _delivery_phase(
2301
+ phase="l1_state_and_noise_review",
2302
+ rows=verification_rows + no_go_rows,
2303
+ objective="Confirm current public state, funding visibility, and exclusion evidence.",
2304
+ exit_criteria="Every row is parked for missing evidence or excluded as no-go evidence.",
2305
+ ),
2306
+ _delivery_phase(
2307
+ phase="l2_shortlist_readiness_review",
2308
+ rows=candidate_rows,
2309
+ objective="Re-check active candidate rows before using paid shortlist time.",
2310
+ exit_criteria="Candidate rows have current public state, scope, and contribution rules checked.",
2311
+ ),
2312
+ _delivery_phase(
2313
+ phase="l3_deep_dive_deferred",
2314
+ rows=[],
2315
+ objective="Defer reproduction and implementation research until paid scope is explicit.",
2316
+ exit_criteria="No deep-dive work starts from this read-only tracker artifact.",
2317
+ ),
2318
+ ]
2319
+ return {
2320
+ "suggested_package": _suggested_package_for_rows(len(scored_rows)),
2321
+ "phase_counts": {phase["phase"]: phase["row_count"] for phase in phases},
2322
+ "phases": phases,
2323
+ "handoff": {
2324
+ "candidate_references": _delivery_references(candidate_rows),
2325
+ "verification_references": _delivery_references(verification_rows),
2326
+ "no_go_references": _delivery_references(no_go_rows),
2327
+ },
2328
+ "boundary": (
2329
+ "Delivery pack is a local read-only work plan for paid decision support. It does "
2330
+ "not authorize claiming, commenting, contacting maintainers, opening pull requests, "
2331
+ "or guaranteeing merge or payout outcomes."
2332
+ ),
2333
+ }
2334
+
2335
+
2336
+ def _delivery_phase(
2337
+ *,
2338
+ phase: str,
2339
+ rows: list[dict[str, Any]],
2340
+ objective: str,
2341
+ exit_criteria: str,
2342
+ ) -> dict[str, Any]:
2343
+ return {
2344
+ "phase": phase,
2345
+ "row_count": len(rows),
2346
+ "references": _delivery_references(rows),
2347
+ "objective": objective,
2348
+ "exit_criteria": exit_criteria,
2349
+ }
2350
+
2351
+
2352
+ def _delivery_references(rows: list[dict[str, Any]]) -> list[str]:
2353
+ return sorted(str(row["issue"]["reference"]) for row in rows)
2354
+
2355
+
2356
+ def _source_quality(scored_rows: list[dict[str, Any]]) -> dict[str, Any]:
2357
+ grouped: dict[str, list[dict[str, Any]]] = {}
2358
+ for row in scored_rows:
2359
+ source = str(row["issue"]["platform"])
2360
+ grouped.setdefault(source, []).append(row)
2361
+
2362
+ sources: dict[str, dict[str, Any]] = {}
2363
+ for source, rows in sorted(grouped.items()):
2364
+ total_rows = len(rows)
2365
+ candidate_rows = sum(1 for row in rows if _is_shortlist_candidate_row(row))
2366
+ no_go_rows = sum(1 for row in rows if row["rating"] == "no_go")
2367
+ safe_to_list = sum(1 for row in rows if row["issue"]["safe_to_list"])
2368
+ funding_verification_needed = sum(
2369
+ 1 for row in rows if row["decision_gate"] == "needs_funding_verification"
2370
+ )
2371
+ authorization_needed = sum(
2372
+ 1 for row in rows if row["decision_gate"] == "needs_authorization"
2373
+ )
2374
+ scores = [int(row["score"]) for row in rows]
2375
+ usable_signal_ratio = round(candidate_rows / total_rows, 2) if total_rows else 0
2376
+ sources[source] = {
2377
+ "total_rows": total_rows,
2378
+ "candidate_rows": candidate_rows,
2379
+ "no_go_rows": no_go_rows,
2380
+ "safe_to_list": safe_to_list,
2381
+ "funding_verification_needed": funding_verification_needed,
2382
+ "authorization_needed": authorization_needed,
2383
+ "average_score": round(sum(scores) / total_rows, 2) if total_rows else 0,
2384
+ "usable_signal_ratio": usable_signal_ratio,
2385
+ "recommended_use": _recommended_source_use(
2386
+ candidate_rows=candidate_rows,
2387
+ no_go_rows=no_go_rows,
2388
+ funding_verification_needed=funding_verification_needed,
2389
+ authorization_needed=authorization_needed,
2390
+ ),
2391
+ }
2392
+ return {
2393
+ "summary": _source_quality_rollup(sources),
2394
+ "sources": sources,
2395
+ "boundary": (
2396
+ "Source quality is read-only benchmarking for Opportunity Desk triage. It is not "
2397
+ "permission to scrape aggressively, claim rewards, contact maintainers, comment, "
2398
+ "or open pull requests."
2399
+ ),
2400
+ }
2401
+
2402
+
2403
+ def _is_shortlist_candidate_row(row: dict[str, Any]) -> bool:
2404
+ return row["decision_gate"] in {"go_after_recheck", "watchlist"}
2405
+
2406
+
2407
+ def _source_quality_rollup(sources: dict[str, dict[str, Any]]) -> dict[str, Any]:
2408
+ source_count = len(sources)
2409
+ total_rows = sum(int(source["total_rows"]) for source in sources.values())
2410
+ candidate_rows = sum(int(source["candidate_rows"]) for source in sources.values())
2411
+ no_go_rows = sum(int(source["no_go_rows"]) for source in sources.values())
2412
+ funding_verification_needed = sum(
2413
+ int(source["funding_verification_needed"]) for source in sources.values()
2414
+ )
2415
+ authorization_needed = sum(int(source["authorization_needed"]) for source in sources.values())
2416
+ candidate_source_count = sum(1 for source in sources.values() if source["candidate_rows"])
2417
+ no_go_only_source_count = sum(
2418
+ 1 for source in sources.values() if source["no_go_rows"] and not source["candidate_rows"]
2419
+ )
2420
+ status = _source_quality_status(
2421
+ source_count=source_count,
2422
+ candidate_source_count=candidate_source_count,
2423
+ funding_verification_needed=funding_verification_needed,
2424
+ authorization_needed=authorization_needed,
2425
+ no_go_only_source_count=no_go_only_source_count,
2426
+ )
2427
+ return {
2428
+ "source_count": source_count,
2429
+ "total_rows": total_rows,
2430
+ "candidate_rows": candidate_rows,
2431
+ "no_go_rows": no_go_rows,
2432
+ "candidate_source_count": candidate_source_count,
2433
+ "no_go_only_source_count": no_go_only_source_count,
2434
+ "funding_verification_needed": funding_verification_needed,
2435
+ "authorization_needed": authorization_needed,
2436
+ "status": status,
2437
+ "next_tracker_action": _source_quality_next_tracker_action(status),
2438
+ "boundary": (
2439
+ "Source summary is local tracker evidence only. It does not authorize scraping, "
2440
+ "claims, comments, maintainer contact, pull requests, or payout/merge guarantees."
2441
+ ),
2442
+ }
2443
+
2444
+
2445
+ def _source_quality_status(
2446
+ *,
2447
+ source_count: int,
2448
+ candidate_source_count: int,
2449
+ funding_verification_needed: int,
2450
+ authorization_needed: int,
2451
+ no_go_only_source_count: int,
2452
+ ) -> str:
2453
+ if source_count == 0:
2454
+ return "no_sources"
2455
+ if candidate_source_count:
2456
+ return "candidate_sources_available"
2457
+ if funding_verification_needed:
2458
+ return "needs_funding_verification"
2459
+ if authorization_needed:
2460
+ return "needs_authorization"
2461
+ if no_go_only_source_count:
2462
+ return "no_go_only_sources"
2463
+ return "collect_more_rows"
2464
+
2465
+
2466
+ def _source_quality_next_tracker_action(status: str) -> str:
2467
+ return {
2468
+ "no_sources": "Expand permitted public/API sources before ranking this batch.",
2469
+ "candidate_sources_available": (
2470
+ "Run read-only public-state recheck on candidate sources before paid shortlist use."
2471
+ ),
2472
+ "needs_funding_verification": (
2473
+ "Verify visible funding and current state from permitted public/API sources."
2474
+ ),
2475
+ "needs_authorization": "Keep authorization-gated source rows parked until buyer scope exists.",
2476
+ "no_go_only_sources": (
2477
+ "Use these sources as no-go moat evidence and expand coverage before pitching."
2478
+ ),
2479
+ "collect_more_rows": "Collect more permitted source rows before ranking this batch.",
2480
+ }[status]
2481
+
2482
+
2483
+ def _recheck_plan(scored_rows: list[dict[str, Any]]) -> dict[str, Any]:
2484
+ rows: list[dict[str, Any]] = []
2485
+ for row in scored_rows:
2486
+ issue = row["issue"]
2487
+ decision_gate = str(row["decision_gate"])
2488
+ action = _recheck_action_for_gate(decision_gate)
2489
+ priority = _recheck_priority_for_gate(decision_gate)
2490
+ rows.append(
2491
+ {
2492
+ "reference": issue["reference"],
2493
+ "platform": issue["platform"],
2494
+ "decision_gate": decision_gate,
2495
+ "priority": priority,
2496
+ "action": action,
2497
+ "reason": _recheck_reason_for_gate(decision_gate),
2498
+ }
2499
+ )
2500
+
2501
+ active_rechecks = [row for row in rows if row["action"] != "archive_as_no_go_evidence"]
2502
+ priority_counts = Counter(row["priority"] for row in active_rechecks)
2503
+ action_counts = Counter(row["action"] for row in rows)
2504
+ return {
2505
+ "total_rows": len(rows),
2506
+ "recheck_rows": len(active_rechecks),
2507
+ "no_go_rows": action_counts.get("archive_as_no_go_evidence", 0),
2508
+ "priority_counts": dict(sorted(priority_counts.items())),
2509
+ "action_counts": dict(sorted(action_counts.items())),
2510
+ "next_rows": sorted(
2511
+ active_rechecks,
2512
+ key=lambda row: (
2513
+ _recheck_priority_rank(row["priority"]),
2514
+ row["platform"],
2515
+ row["reference"],
2516
+ ),
2517
+ ),
2518
+ "boundary": (
2519
+ "Recheck plan is local read-only tracker triage. It schedules evidence review only; "
2520
+ "it does not claim, comment, contact maintainers, open pull requests, or guarantee payout."
2521
+ ),
2522
+ }
2523
+
2524
+
2525
+ def _evidence_debt(recheck_plan: dict[str, Any]) -> dict[str, Any]:
2526
+ active_rows = list(recheck_plan["next_rows"])
2527
+ action_counts = Counter(str(row["action"]) for row in active_rows)
2528
+ platform_counts = Counter(str(row["platform"]) for row in active_rows)
2529
+ priority_counts = Counter(str(row["priority"]) for row in active_rows)
2530
+ highest_priority = active_rows[0]["priority"] if active_rows else "none"
2531
+ next_action = active_rows[0]["action"] if active_rows else "ready_for_delivery_readiness_review"
2532
+ return {
2533
+ "status": "active_evidence_debt" if active_rows else "clear",
2534
+ "blocking_rows": len(active_rows),
2535
+ "archive_only_rows": int(recheck_plan["no_go_rows"]),
2536
+ "highest_priority": highest_priority,
2537
+ "next_action": next_action,
2538
+ "action_counts": dict(sorted(action_counts.items())),
2539
+ "platform_counts": dict(sorted(platform_counts.items())),
2540
+ "priority_counts": dict(sorted(priority_counts.items())),
2541
+ "references": [str(row["reference"]) for row in active_rows],
2542
+ "payment_route_allowed_now": False,
2543
+ "external_body_allowed": False,
2544
+ "boundary": (
2545
+ "Evidence debt is internal read-only delivery readiness data. It schedules public/API "
2546
+ "evidence review only and does not authorize claims, comments, maintainer contact, "
2547
+ "pull requests, external prose, payment routes, or payout/merge guarantees."
2548
+ ),
2549
+ }
2550
+
2551
+
2552
+ def _recheck_action_for_gate(decision_gate: str) -> str:
2553
+ return {
2554
+ "go_after_recheck": "recheck_public_issue_state",
2555
+ "watchlist": "recheck_scope_and_noise",
2556
+ "needs_funding_verification": "verify_funding_visibility",
2557
+ "needs_authorization": "confirm_client_authorization",
2558
+ "no_go": "archive_as_no_go_evidence",
2559
+ }.get(decision_gate, "recheck_public_issue_state")
2560
+
2561
+
2562
+ def _recheck_priority_for_gate(decision_gate: str) -> str:
2563
+ return {
2564
+ "go_after_recheck": "high",
2565
+ "needs_funding_verification": "high",
2566
+ "watchlist": "medium",
2567
+ "needs_authorization": "medium",
2568
+ "no_go": "none",
2569
+ }.get(decision_gate, "medium")
2570
+
2571
+
2572
+ def _recheck_reason_for_gate(decision_gate: str) -> str:
2573
+ return {
2574
+ "go_after_recheck": "Candidate rows need current public state recheck before shortlist use.",
2575
+ "watchlist": "Watchlist rows need scope/noise recheck before paid review time.",
2576
+ "needs_funding_verification": (
2577
+ "Funding or current state is unclear and must be verified from permitted sources."
2578
+ ),
2579
+ "needs_authorization": "Row should stay parked until client authorization is explicit.",
2580
+ "no_go": "Keep as exclusion evidence; do not spend delivery time.",
2581
+ }.get(decision_gate, "Review public state before ranking this row.")
2582
+
2583
+
2584
+ def _recheck_priority_rank(priority: str) -> int:
2585
+ return {"high": 0, "medium": 1, "low": 2, "none": 3}.get(priority, 2)
2586
+
2587
+
2588
+ def _client_fit_gaps(
2589
+ issues: list[FundedIssue],
2590
+ profile: ClientProfile | None,
2591
+ ) -> list[dict[str, Any]]:
2592
+ if profile is None:
2593
+ return []
2594
+
2595
+ rows: list[dict[str, Any]] = []
2596
+ for issue in issues:
2597
+ gaps = _client_fit_gap_codes(issue, profile)
2598
+ if not gaps:
2599
+ continue
2600
+ rows.append(
2601
+ {
2602
+ "reference": issue.reference,
2603
+ "title": issue.title,
2604
+ "url": issue.url,
2605
+ "platform": issue.platform,
2606
+ "gap_codes": gaps,
2607
+ "gap_summary": _client_fit_gap_summary(gaps),
2608
+ }
2609
+ )
2610
+ return rows
2611
+
2612
+
2613
+ def _client_fit_summary(
2614
+ issues: list[FundedIssue],
2615
+ profile: ClientProfile | None,
2616
+ client_fit_gaps: list[dict[str, Any]],
2617
+ ) -> dict[str, Any]:
2618
+ total_rows = len(issues)
2619
+ excluded_rows = len(client_fit_gaps) if profile else 0
2620
+ matching_rows = max(0, total_rows - excluded_rows)
2621
+ gap_counts = Counter(gap_code for row in client_fit_gaps for gap_code in row["gap_codes"])
2622
+ return {
2623
+ "profile_name": profile.name if profile and profile.name else None,
2624
+ "status": _client_fit_status(profile, total_rows, matching_rows, excluded_rows),
2625
+ "total_rows": total_rows,
2626
+ "matching_rows": matching_rows,
2627
+ "excluded_rows": excluded_rows,
2628
+ "gap_counts": dict(sorted(gap_counts.items())),
2629
+ "recommended_action": _client_fit_recommended_action(
2630
+ profile=profile,
2631
+ total_rows=total_rows,
2632
+ matching_rows=matching_rows,
2633
+ excluded_rows=excluded_rows,
2634
+ ),
2635
+ "boundary": (
2636
+ "Client fit is local buyer-fit evidence only. It does not authorize claiming "
2637
+ "rewards, contacting maintainers, posting comments, or opening pull requests."
2638
+ ),
2639
+ }
2640
+
2641
+
2642
+ def _client_fit_status(
2643
+ profile: ClientProfile | None,
2644
+ total_rows: int,
2645
+ matching_rows: int,
2646
+ excluded_rows: int,
2647
+ ) -> str:
2648
+ if profile is None:
2649
+ return "no_profile"
2650
+ if total_rows == 0:
2651
+ return "no_rows"
2652
+ if matching_rows == 0:
2653
+ return "no_matching_rows"
2654
+ if excluded_rows:
2655
+ return "partial_match"
2656
+ return "all_rows_match"
2657
+
2658
+
2659
+ def _client_fit_recommended_action(
2660
+ *,
2661
+ profile: ClientProfile | None,
2662
+ total_rows: int,
2663
+ matching_rows: int,
2664
+ excluded_rows: int,
2665
+ ) -> str:
2666
+ if profile is None:
2667
+ return "Attach a read-only client profile before buyer-specific shortlist delivery."
2668
+ if total_rows == 0:
2669
+ return "Expand permitted read-only sources before buyer-fit ranking."
2670
+ if matching_rows == 0:
2671
+ return (
2672
+ "Do not pitch this batch as buyer-ready; expand sources or adjust the client profile."
2673
+ )
2674
+ if excluded_rows:
2675
+ return "Use matching rows for shortlist review and keep excluded rows as fit-gap evidence."
2676
+ return "Use this batch for buyer-specific shortlist review after public-state recheck."
2677
+
2678
+
2679
+ def _intake_followup(
2680
+ *,
2681
+ client_fit_summary: dict[str, Any],
2682
+ recheck_plan: dict[str, Any],
2683
+ delivery_budget: dict[str, Any],
2684
+ source_quality: dict[str, Any],
2685
+ decision_summary: dict[str, Any],
2686
+ ) -> dict[str, Any]:
2687
+ requested_fields: list[dict[str, Any]] = []
2688
+ if client_fit_summary["status"] == "no_profile":
2689
+ requested_fields.extend(
2690
+ [
2691
+ _intake_field(
2692
+ "preferred_languages",
2693
+ "Needed to filter funded issues to stacks the buyer can actually work.",
2694
+ True,
2695
+ ),
2696
+ _intake_field(
2697
+ "minimum_payout_usd",
2698
+ "Needed to keep low-value rows out of a paid shortlist.",
2699
+ True,
2700
+ ),
2701
+ _intake_field(
2702
+ "allowed_risk_levels",
2703
+ "Needed to avoid proposing high-risk rows as buyer-ready.",
2704
+ True,
2705
+ ),
2706
+ ]
2707
+ )
2708
+ elif client_fit_summary["status"] in {"no_matching_rows", "partial_match"}:
2709
+ requested_fields.append(
2710
+ _intake_field(
2711
+ "profile_gap_confirmation",
2712
+ "Needed to decide whether to expand sources or adjust buyer-fit filters.",
2713
+ True,
2714
+ )
2715
+ )
2716
+
2717
+ if recheck_plan["recheck_rows"]:
2718
+ requested_fields.append(
2719
+ _intake_field(
2720
+ "public_state_recheck_window",
2721
+ "Needed because active candidates require read-only public-state recheck before delivery.",
2722
+ False,
2723
+ )
2724
+ )
2725
+
2726
+ if not source_quality["sources"]:
2727
+ requested_fields.append(
2728
+ _intake_field(
2729
+ "permitted_sources",
2730
+ "Needed because no source rows matched the current filters.",
2731
+ True,
2732
+ )
2733
+ )
2734
+ elif decision_summary["candidate_rows"] == 0 and decision_summary["no_go_rows"] > 0:
2735
+ requested_fields.append(
2736
+ _intake_field(
2737
+ "source_expansion_preferences",
2738
+ "Needed because the current batch is useful as no-go evidence but has no candidates.",
2739
+ False,
2740
+ )
2741
+ )
2742
+
2743
+ required_count = sum(1 for field in requested_fields if field["required_before_paid_delivery"])
2744
+ if required_count:
2745
+ status = "needs_buyer_intake"
2746
+ elif recheck_plan["recheck_rows"]:
2747
+ status = "ready_after_read_only_recheck"
2748
+ elif decision_summary["candidate_rows"]:
2749
+ status = "ready_for_scope_confirmation"
2750
+ else:
2751
+ status = "needs_source_expansion"
2752
+
2753
+ return {
2754
+ "status": status,
2755
+ "suggested_package": delivery_budget["suggested_package"],
2756
+ "required_before_paid_delivery": required_count,
2757
+ "requested_fields": requested_fields,
2758
+ "next_internal_action": _intake_next_internal_action(status),
2759
+ "boundary": (
2760
+ "Intake follow-up is structured internal handoff data. It is not customer-facing "
2761
+ "email copy and does not authorize claims, comments, pull requests, maintainer "
2762
+ "outreach, payout guarantees, or payment routes without written buyer acceptance."
2763
+ ),
2764
+ }
2765
+
2766
+
2767
+ def _intake_field(
2768
+ field: str,
2769
+ reason: str,
2770
+ required_before_paid_delivery: bool,
2771
+ ) -> dict[str, Any]:
2772
+ return {
2773
+ "field": field,
2774
+ "reason": reason,
2775
+ "required_before_paid_delivery": required_before_paid_delivery,
2776
+ }
2777
+
2778
+
2779
+ def _intake_next_internal_action(status: str) -> str:
2780
+ return {
2781
+ "needs_buyer_intake": (
2782
+ "Use these fields as facts in a PatchRail copy-brief after buyer interest; do not write "
2783
+ "external prose here."
2784
+ ),
2785
+ "ready_after_read_only_recheck": (
2786
+ "Run read-only public-state recheck before turning candidates into a paid report."
2787
+ ),
2788
+ "ready_for_scope_confirmation": (
2789
+ "Confirm paid scope and package; create payment route only after written buyer acceptance."
2790
+ ),
2791
+ "needs_source_expansion": (
2792
+ "Expand permitted read-only sources before pitching this batch as buyer-ready."
2793
+ ),
2794
+ }[status]
2795
+
2796
+
2797
+ def _cash_path_status(intake_followup: dict[str, Any]) -> dict[str, Any]:
2798
+ status = str(intake_followup["status"])
2799
+ next_actions = {
2800
+ "needs_buyer_intake": "collect_buyer_intake",
2801
+ "ready_after_read_only_recheck": "run_read_only_recheck",
2802
+ "ready_for_scope_confirmation": "confirm_paid_scope",
2803
+ "needs_source_expansion": "expand_permitted_sources",
2804
+ }
2805
+ return {
2806
+ "status": status,
2807
+ "next_revenue_action": next_actions[status],
2808
+ "copy_brief_facts_available": status
2809
+ in {"needs_buyer_intake", "ready_for_scope_confirmation"},
2810
+ "payment_route_allowed_now": False,
2811
+ "requires_written_acceptance_before_payment_route": True,
2812
+ "buyer_ready": status == "ready_for_scope_confirmation",
2813
+ "boundary": (
2814
+ "Cash-path status is internal structured handoff only. It is not external prose, "
2815
+ "does not create a payment route, and does not authorize claims, comments, pull "
2816
+ "requests, maintainer outreach, or payout/merge guarantees."
2817
+ ),
2818
+ }
2819
+
2820
+
2821
+ def _operator_next_steps(
2822
+ *,
2823
+ cash_path_status: dict[str, Any],
2824
+ intake_followup: dict[str, Any],
2825
+ recheck_plan: dict[str, Any],
2826
+ delivery_pack: dict[str, Any],
2827
+ ) -> dict[str, Any]:
2828
+ steps: list[dict[str, Any]] = []
2829
+ primary_action = str(cash_path_status["next_revenue_action"])
2830
+ required_fields = [
2831
+ field["field"]
2832
+ for field in intake_followup["requested_fields"]
2833
+ if field["required_before_paid_delivery"]
2834
+ ]
2835
+ optional_recheck_fields = [
2836
+ field["field"]
2837
+ for field in intake_followup["requested_fields"]
2838
+ if field["field"] == "public_state_recheck_window"
2839
+ ]
2840
+ candidate_refs = delivery_pack["handoff"]["candidate_references"]
2841
+ no_go_refs = delivery_pack["handoff"]["no_go_references"]
2842
+ recheck_refs = [row["reference"] for row in recheck_plan["next_rows"]]
2843
+
2844
+ if primary_action == "collect_buyer_intake":
2845
+ steps.append(
2846
+ _operator_next_step(
2847
+ action="collect_buyer_intake",
2848
+ priority="high",
2849
+ source="cash_path",
2850
+ reason="Buyer-fit fields are missing before the batch can become paid delivery.",
2851
+ reference_scope=candidate_refs,
2852
+ evidence_required=required_fields,
2853
+ blocks_paid_delivery=True,
2854
+ copy_brief_allowed=True,
2855
+ )
2856
+ )
2857
+ elif primary_action == "confirm_paid_scope":
2858
+ steps.append(
2859
+ _operator_next_step(
2860
+ action="confirm_paid_scope",
2861
+ priority="high",
2862
+ source="cash_path",
2863
+ reason="Rows are ready for paid scope confirmation after read-only checks.",
2864
+ reference_scope=candidate_refs,
2865
+ evidence_required=[],
2866
+ blocks_paid_delivery=True,
2867
+ copy_brief_allowed=True,
2868
+ )
2869
+ )
2870
+ elif primary_action == "expand_permitted_sources":
2871
+ steps.append(
2872
+ _operator_next_step(
2873
+ action="expand_permitted_sources",
2874
+ priority="high",
2875
+ source="source_quality",
2876
+ reason="Current permitted rows are not enough for buyer-ready delivery.",
2877
+ reference_scope=[],
2878
+ evidence_required=["permitted public/API source name", "source URL"],
2879
+ blocks_paid_delivery=True,
2880
+ copy_brief_allowed=False,
2881
+ )
2882
+ )
2883
+
2884
+ if recheck_plan["recheck_rows"]:
2885
+ steps.append(
2886
+ _operator_next_step(
2887
+ action="run_read_only_recheck",
2888
+ priority="high" if primary_action == "run_read_only_recheck" else "medium",
2889
+ source="recheck_plan",
2890
+ reason="Active candidate rows need current public-state evidence before delivery.",
2891
+ reference_scope=recheck_refs,
2892
+ evidence_required=optional_recheck_fields
2893
+ or ["public issue state", "funding visibility", "staleness/noise check"],
2894
+ blocks_paid_delivery=True,
2895
+ copy_brief_allowed=False,
2896
+ )
2897
+ )
2898
+
2899
+ if no_go_refs:
2900
+ steps.append(
2901
+ _operator_next_step(
2902
+ action="preserve_no_go_evidence",
2903
+ priority="low",
2904
+ source="delivery_pack",
2905
+ reason="No-go rows strengthen the decision-support moat and should stay in the packet.",
2906
+ reference_scope=no_go_refs,
2907
+ evidence_required=["exclusion reason", "public source reference"],
2908
+ blocks_paid_delivery=False,
2909
+ copy_brief_allowed=False,
2910
+ )
2911
+ )
2912
+
2913
+ if not steps:
2914
+ steps.append(
2915
+ _operator_next_step(
2916
+ action="expand_permitted_sources",
2917
+ priority="medium",
2918
+ source="source_quality",
2919
+ reason="No operator step matched this batch; collect more permitted read-only rows.",
2920
+ reference_scope=[],
2921
+ evidence_required=["permitted public/API source name", "source URL"],
2922
+ blocks_paid_delivery=True,
2923
+ copy_brief_allowed=False,
2924
+ )
2925
+ )
2926
+
2927
+ steps.sort(key=lambda step: (_recheck_priority_rank(step["priority"]), step["action"]))
2928
+ return {
2929
+ "schema_version": "patchrail.funded_issues.operator_next_steps.v1",
2930
+ "status": cash_path_status["status"],
2931
+ "primary_action": primary_action,
2932
+ "copy_brief_facts_available": cash_path_status["copy_brief_facts_available"],
2933
+ "payment_route_allowed_now": False,
2934
+ "external_body_allowed": False,
2935
+ "steps": steps,
2936
+ "boundary": (
2937
+ "Operator next steps are internal structured handoff only. This handoff does "
2938
+ "not write external prose, create payment routes, claim rewards, post comments, "
2939
+ "contact maintainers, open pull requests, or imply merge/payout certainty."
2940
+ ),
2941
+ }
2942
+
2943
+
2944
+ def _operator_next_step(
2945
+ *,
2946
+ action: str,
2947
+ priority: str,
2948
+ source: str,
2949
+ reason: str,
2950
+ reference_scope: list[str],
2951
+ evidence_required: list[str],
2952
+ blocks_paid_delivery: bool,
2953
+ copy_brief_allowed: bool,
2954
+ ) -> dict[str, Any]:
2955
+ return {
2956
+ "priority": priority,
2957
+ "action": action,
2958
+ "source": source,
2959
+ "reason": reason,
2960
+ "reference_scope": reference_scope,
2961
+ "evidence_required": evidence_required,
2962
+ "blocks_paid_delivery": blocks_paid_delivery,
2963
+ "copy_brief_allowed": copy_brief_allowed,
2964
+ "external_body_allowed": False,
2965
+ "payment_route_allowed_now": False,
2966
+ "blocked_actions": BLOCKED_ACTIONS,
2967
+ }
2968
+
2969
+
2970
+ def _client_fit_gap_codes(issue: FundedIssue, profile: ClientProfile) -> list[str]:
2971
+ gaps: list[str] = []
2972
+ if profile.languages and (issue.language or "").lower() not in profile.languages:
2973
+ gaps.append("LANGUAGE_MISMATCH")
2974
+ if profile.min_usd is not None:
2975
+ if issue.funding_amount is None:
2976
+ gaps.append("FUNDING_UNKNOWN")
2977
+ elif issue.funding_currency != "USD":
2978
+ gaps.append("FUNDING_CURRENCY_NOT_USD")
2979
+ elif issue.funding_amount < profile.min_usd:
2980
+ gaps.append("FUNDING_BELOW_MIN_USD")
2981
+ if (
2982
+ profile.allowed_opportunity_states
2983
+ and issue.opportunity_state not in profile.allowed_opportunity_states
2984
+ ):
2985
+ gaps.append("OPPORTUNITY_STATE_NOT_ALLOWED")
2986
+ if profile.allowed_risk_levels and issue.risk_level not in profile.allowed_risk_levels:
2987
+ gaps.append("RISK_LEVEL_NOT_ALLOWED")
2988
+ if profile.excluded_risk_flags:
2989
+ excluded_flags = sorted(set(issue.risk_flags).intersection(profile.excluded_risk_flags))
2990
+ gaps.extend(f"EXCLUDED_RISK_FLAG:{flag}" for flag in excluded_flags)
2991
+ return gaps
2992
+
2993
+
2994
+ def _client_fit_gap_summary(gaps: list[str]) -> str:
2995
+ if not gaps:
2996
+ return "Matches the local client profile."
2997
+ labels = {
2998
+ "LANGUAGE_MISMATCH": "language outside the profile",
2999
+ "FUNDING_UNKNOWN": "funding is unknown",
3000
+ "FUNDING_CURRENCY_NOT_USD": "funding is not in USD",
3001
+ "FUNDING_BELOW_MIN_USD": "funding below profile minimum",
3002
+ "OPPORTUNITY_STATE_NOT_ALLOWED": "opportunity state outside the profile",
3003
+ "RISK_LEVEL_NOT_ALLOWED": "risk level outside the profile",
3004
+ }
3005
+ rendered = [
3006
+ labels.get(gap, f"excluded risk flag {gap.split(':', 1)[1]}")
3007
+ if gap.startswith("EXCLUDED_RISK_FLAG:")
3008
+ else labels.get(gap, gap.lower())
3009
+ for gap in gaps
3010
+ ]
3011
+ return "; ".join(rendered)
3012
+
3013
+
3014
+ def _recommended_source_use(
3015
+ *,
3016
+ candidate_rows: int,
3017
+ no_go_rows: int,
3018
+ funding_verification_needed: int,
3019
+ authorization_needed: int,
3020
+ ) -> str:
3021
+ if candidate_rows:
3022
+ return "Prioritize for L2 review after public-state recheck."
3023
+ if funding_verification_needed:
3024
+ return "Use only after funding and current-state verification."
3025
+ if authorization_needed:
3026
+ return "Park until a client separately authorizes bounded review."
3027
+ if no_go_rows:
3028
+ return "Use as no-go moat evidence before expanding this source."
3029
+ return "Collect more rows before ranking this source."
3030
+
3031
+
3032
+ def _suggested_package_for_rows(row_count: int) -> str:
3033
+ if row_count == 0:
3034
+ return "none"
3035
+ if row_count <= 5:
3036
+ return "mini_diagnostic"
3037
+ if row_count <= 20:
3038
+ return "validation_sprint"
3039
+ if row_count <= 50:
3040
+ return "opportunity_shortlist"
3041
+ return "custom_batch"
3042
+
3043
+
3044
+ def _score_issue(issue: FundedIssue) -> dict[str, Any]:
3045
+ score = 35
3046
+ components: dict[str, int] = {
3047
+ "base": 35,
3048
+ "funding_visible": 0,
3049
+ "guidelines_visible": 0,
3050
+ "contribution_signals": 0,
3051
+ "risk_penalty": 0,
3052
+ "safe_boundary_bonus": 0,
3053
+ }
3054
+ reason_codes: list[str] = []
3055
+
3056
+ if issue.funding_amount is not None and issue.funding_currency:
3057
+ components["funding_visible"] = 15
3058
+ else:
3059
+ reason_codes.append("FUNDING_STATE_UNCLEAR")
3060
+
3061
+ if issue.contribution_guidelines_url:
3062
+ components["guidelines_visible"] = 15
3063
+ else:
3064
+ reason_codes.append("NO_CONTRIBUTION_GUIDELINES")
3065
+
3066
+ components["contribution_signals"] = min(len(issue.contribution_signals), 3) * 8
3067
+ if not issue.contribution_signals:
3068
+ reason_codes.append("NO_REPRO_OR_CONTRIBUTION_SIGNAL")
3069
+
3070
+ if issue.risk_flags:
3071
+ risk_penalty = 15 * len(issue.risk_flags)
3072
+ if issue.risk_level == "high":
3073
+ risk_penalty += 20
3074
+ components["risk_penalty"] = -risk_penalty
3075
+ reason_codes.extend(_risk_reason_code(flag) for flag in issue.risk_flags)
3076
+ else:
3077
+ components["safe_boundary_bonus"] = 10
3078
+
3079
+ if issue.opportunity_state == "closed":
3080
+ components["risk_penalty"] -= 35
3081
+ reason_codes.append("CLOSED_OR_INACTIVE")
3082
+ elif issue.opportunity_state == "stale":
3083
+ components["risk_penalty"] -= 30
3084
+ reason_codes.append("STALE_NO_MAINTAINER_SIGNAL")
3085
+ elif issue.opportunity_state == "unknown":
3086
+ reason_codes.append("OPPORTUNITY_STATE_UNCLEAR")
3087
+
3088
+ score += sum(value for key, value in components.items() if key != "base")
3089
+ score = max(0, min(100, score))
3090
+ if issue.risk_level == "high" or issue.opportunity_state in {"closed", "stale"}:
3091
+ rating = "no_go"
3092
+ elif score >= 80:
3093
+ rating = "go_candidate"
3094
+ elif score >= 55:
3095
+ rating = "watchlist"
3096
+ else:
3097
+ rating = "no_go"
3098
+
3099
+ return {
3100
+ "issue": issue.to_dict(),
3101
+ "score": score,
3102
+ "confidence": _confidence_for_issue(issue),
3103
+ "rating": rating,
3104
+ "decision_gate": _decision_gate_for_score(issue, rating, reason_codes),
3105
+ "reason_codes": sorted(set(reason_codes)) or ["NO_MAJOR_REVIEW_GAPS"],
3106
+ "components": components,
3107
+ "recommended_next_step": _recommended_next_step_for_score(issue, rating, reason_codes),
3108
+ }
3109
+
3110
+
3111
+ def _confidence_for_issue(issue: FundedIssue) -> float:
3112
+ confidence = 0.5
3113
+ if issue.funding_amount is not None and issue.funding_currency:
3114
+ confidence += 0.15
3115
+ if issue.contribution_guidelines_url:
3116
+ confidence += 0.15
3117
+ confidence += min(len(issue.contribution_signals), 3) * 0.05
3118
+
3119
+ if issue.opportunity_state == "active":
3120
+ confidence += 0.05
3121
+ elif issue.opportunity_state == "unknown":
3122
+ confidence -= 0.15
3123
+ elif issue.opportunity_state in {"closed", "stale"}:
3124
+ confidence -= 0.2
3125
+
3126
+ confidence -= min(len(issue.risk_flags), 4) * 0.05
3127
+ if issue.risk_level == "high":
3128
+ confidence -= 0.1
3129
+ return round(max(0.05, min(0.99, confidence)), 2)
3130
+
3131
+
3132
+ def _decision_gate_for_score(
3133
+ issue: FundedIssue,
3134
+ rating: str,
3135
+ reason_codes: list[str],
3136
+ ) -> str:
3137
+ reason_code_set = set(reason_codes)
3138
+ if issue.opportunity_state in {"closed", "stale"}:
3139
+ return "no_go"
3140
+ if "NEEDS_AUTHORIZATION" in reason_code_set:
3141
+ return "needs_authorization"
3142
+ if (
3143
+ "FUNDING_STATE_UNCLEAR" in reason_code_set
3144
+ or "PRIMARY_SOURCE_REQUIRED" in reason_code_set
3145
+ or "DISCOVERY_ONLY_SOURCE" in reason_code_set
3146
+ or issue.opportunity_state == "unknown"
3147
+ ):
3148
+ return "needs_funding_verification"
3149
+ if issue.risk_level == "high":
3150
+ return "no_go"
3151
+ if rating == "go_candidate":
3152
+ return "go_after_recheck"
3153
+ if rating == "watchlist":
3154
+ return "watchlist"
3155
+ return "no_go"
3156
+
3157
+
3158
+ def _recommended_next_step_for_score(
3159
+ issue: FundedIssue,
3160
+ rating: str,
3161
+ reason_codes: list[str],
3162
+ ) -> str:
3163
+ reason_code_set = set(reason_codes)
3164
+ if issue.opportunity_state in {"closed", "stale"}:
3165
+ return "Do not engage unless public project evidence shows the opportunity is live again."
3166
+ if issue.risk_level == "high":
3167
+ return "Keep as no-go evidence unless the client separately authorizes a bounded review."
3168
+ if "PRIMARY_SOURCE_REQUIRED" in reason_code_set or "DISCOVERY_ONLY_SOURCE" in reason_code_set:
3169
+ return (
3170
+ "Verify funding and current issue state from a permitted primary public/API source "
3171
+ "before shortlist ranking."
3172
+ )
3173
+ if "FUNDING_STATE_UNCLEAR" in reason_code_set or issue.opportunity_state == "unknown":
3174
+ return "Verify funding and current issue state from permitted public/API sources before ranking."
3175
+ if "NO_CONTRIBUTION_GUIDELINES" in reason_code_set:
3176
+ return "Treat as watchlist until contribution rules and maintainer expectations are clear."
3177
+ if rating == "go_candidate":
3178
+ return "Reproduce locally and re-check assignment, active PRs, and funding before any engagement decision."
3179
+ if rating == "watchlist":
3180
+ return "Keep in the watchlist and wait for clearer public maintainer or testability signal."
3181
+ return "Do not spend engineering time on this opportunity in the current batch."
3182
+
3183
+
3184
+ def _risk_reason_code(flag: str) -> str:
3185
+ return {
3186
+ "ambiguous_scope": "SCOPE_TOO_BROAD",
3187
+ "bounty_farming_language": "BOUNTY_FARMING_RISK",
3188
+ "requires_external_contact": "NEEDS_AUTHORIZATION",
3189
+ "no_contribution_guidelines": "NO_CONTRIBUTION_GUIDELINES",
3190
+ "aggregator_only_source": "PRIMARY_SOURCE_REQUIRED",
3191
+ "discovery_only_source": "DISCOVERY_ONLY_SOURCE",
3192
+ "primary_source_required": "PRIMARY_SOURCE_REQUIRED",
3193
+ "spam_attractive": "SPAM_ATTRACTIVE",
3194
+ "stale_no_maintainer_signal": "STALE_NO_MAINTAINER_SIGNAL",
3195
+ "closed_or_inactive": "CLOSED_OR_INACTIVE",
3196
+ "contested_bounty": "CONTESTED_HIGH_COMPETITION",
3197
+ "crowded_no_assignment": "CROWDED_NO_CLEAR_OWNER",
3198
+ "payout_too_low_for_effort": "PAYOUT_TOO_LOW_FOR_EFFORT",
3199
+ "aging_low_activity": "AGING_LOW_ACTIVITY",
3200
+ "no_repro_or_test_path": "NO_REPRO_OR_TEST_PATH",
3201
+ }.get(flag, f"RISK_{flag.upper()}")
3202
+
3203
+
3204
+ def _normalize_opportunity_state(value: Any) -> str:
3205
+ if value is None:
3206
+ return "unknown"
3207
+ normalized = str(value).strip().lower().replace("-", "_").replace(" ", "_")
3208
+ if normalized in {"active", "open", "opened", "live", "available"}:
3209
+ return "active"
3210
+ if normalized in {"closed", "completed", "done", "paid", "resolved", "cancelled"}:
3211
+ return "closed"
3212
+ if normalized in {"stale", "inactive", "abandoned", "expired"}:
3213
+ return "stale"
3214
+ return normalized if normalized in VALID_OPPORTUNITY_STATES else "unknown"
3215
+
3216
+
3217
+ def _normalize_opportunity_state_filter(value: str | None) -> str | None:
3218
+ if value is None:
3219
+ return None
3220
+ normalized = _normalize_opportunity_state(value)
3221
+ if normalized not in VALID_OPPORTUNITY_STATES:
3222
+ raise ValueError(f"invalid opportunity_state: {value}")
3223
+ return normalized
3224
+
3225
+
3226
+ def _normalize_risk_level_filter(value: str | None) -> str | None:
3227
+ if value is None:
3228
+ return None
3229
+ normalized = str(value).strip().lower().replace("-", "_").replace(" ", "_")
3230
+ if normalized not in VALID_RISK_LEVELS:
3231
+ raise ValueError(f"invalid risk_level: {value}")
3232
+ return normalized
3233
+
3234
+
3235
+ def _matches_report_filter(
3236
+ issue: FundedIssue,
3237
+ *,
3238
+ profile: ClientProfile | None,
3239
+ platform: str | None,
3240
+ language: str | None,
3241
+ min_usd: float | None,
3242
+ opportunity_state: str | None,
3243
+ risk_level: str | None,
3244
+ ) -> bool:
3245
+ if profile:
3246
+ if profile.languages and (issue.language or "").lower() not in profile.languages:
3247
+ return False
3248
+ effective_min_usd = profile.min_usd
3249
+ if effective_min_usd is not None:
3250
+ if issue.funding_currency != "USD" or issue.funding_amount is None:
3251
+ return False
3252
+ if issue.funding_amount < effective_min_usd:
3253
+ return False
3254
+ if (
3255
+ profile.allowed_opportunity_states
3256
+ and issue.opportunity_state not in profile.allowed_opportunity_states
3257
+ ):
3258
+ return False
3259
+ if profile.allowed_risk_levels and issue.risk_level not in profile.allowed_risk_levels:
3260
+ return False
3261
+ if profile.excluded_risk_flags and set(issue.risk_flags).intersection(
3262
+ profile.excluded_risk_flags
3263
+ ):
3264
+ return False
3265
+ if platform and issue.platform.lower() != platform.lower():
3266
+ return False
3267
+ if language and (issue.language or "").lower() != language.lower():
3268
+ return False
3269
+ if min_usd is not None:
3270
+ if issue.funding_currency != "USD" or issue.funding_amount is None:
3271
+ return False
3272
+ if issue.funding_amount < min_usd:
3273
+ return False
3274
+ if opportunity_state and issue.opportunity_state != opportunity_state:
3275
+ return False
3276
+ if risk_level and issue.risk_level != risk_level:
3277
+ return False
3278
+ return True
3279
+
3280
+
3281
+ def _candidate_sort_key(issue: FundedIssue) -> tuple[int, int, float, str]:
3282
+ has_guidelines = 1 if issue.contribution_guidelines_url else 0
3283
+ signal_count = len(issue.contribution_signals)
3284
+ funding_amount = issue.funding_amount if issue.funding_currency == "USD" else 0.0
3285
+ return (-has_guidelines, -signal_count, -funding_amount, issue.reference)
3286
+
3287
+
3288
+ def _coerce_count(value: Any, name: str) -> int:
3289
+ if isinstance(value, bool) or not isinstance(value, int):
3290
+ raise ValueError(f"{name} must be an integer")
3291
+ if value < 0:
3292
+ raise ValueError(f"{name} must be >= 0")
3293
+ return value
3294
+
3295
+
3296
+ def _coerce_optional_count(value: Any, name: str) -> int | None:
3297
+ if value is None:
3298
+ return None
3299
+ return _coerce_count(value, name)
3300
+
3301
+
3302
+ def _coerce_optional_bool(value: Any, name: str) -> bool | None:
3303
+ if value is None or isinstance(value, bool):
3304
+ return value
3305
+ raise ValueError(f"{name} must be a boolean or null")
3306
+
3307
+
3308
+ def _coerce_amount(
3309
+ value: Any,
3310
+ name: str,
3311
+ *,
3312
+ allow_none: bool = True,
3313
+ allow_zero: bool = True,
3314
+ ) -> float | None:
3315
+ if value is None:
3316
+ if allow_none:
3317
+ return None
3318
+ raise ValueError(f"{name} is required")
3319
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
3320
+ raise ValueError(f"{name} must be a number")
3321
+ value = float(value)
3322
+ if value < 0 or (value == 0 and not allow_zero):
3323
+ raise ValueError(f"{name} must be {'>= 0' if allow_zero else '> 0'}")
3324
+ return value
3325
+
3326
+
3327
+ def assess_bounty_competition(
3328
+ *,
3329
+ competing_pr_count: int = 0,
3330
+ distinct_claimants: int = 0,
3331
+ comment_count: int = 0,
3332
+ assigned: bool = False,
3333
+ ) -> dict[str, Any]:
3334
+ """Derive a read-only competition / noise-trap signal for a funded issue.
3335
+
3336
+ Productizes the per-prospect recon the desk does by hand: many solvers racing
3337
+ the same bounty, or high comment volume with no clear owner, is a noise trap
3338
+ where engineering effort is often wasted even when the issue itself is real.
3339
+ Inputs are public metadata counts only; this never claims, comments, or
3340
+ contacts anyone. Returns risk flags that an operator can merge into a
3341
+ FundedIssue's ``risk_flags`` (they apply the normal risk penalty and emit the
3342
+ curated ``CONTESTED_HIGH_COMPETITION`` / ``CROWDED_NO_CLEAR_OWNER`` codes).
3343
+ """
3344
+ competing_pr_count = _coerce_count(competing_pr_count, "competing_pr_count")
3345
+ distinct_claimants = _coerce_count(distinct_claimants, "distinct_claimants")
3346
+ comment_count = _coerce_count(comment_count, "comment_count")
3347
+ if not isinstance(assigned, bool):
3348
+ raise ValueError("assigned must be a boolean")
3349
+
3350
+ contested = (
3351
+ competing_pr_count >= COMPETITION_THRESHOLDS["competing_pr_count_contested"]
3352
+ or distinct_claimants >= COMPETITION_THRESHOLDS["distinct_claimants_contested"]
3353
+ )
3354
+ crowded_no_owner = not assigned and (
3355
+ comment_count >= COMPETITION_THRESHOLDS["comment_count_busy"]
3356
+ or distinct_claimants >= COMPETITION_THRESHOLDS["distinct_claimants_contested"]
3357
+ )
3358
+
3359
+ flags: list[str] = []
3360
+ if contested:
3361
+ flags.append(CONTESTED_BOUNTY_FLAG)
3362
+ if crowded_no_owner:
3363
+ flags.append(CROWDED_NO_ASSIGNMENT_FLAG)
3364
+
3365
+ if contested and crowded_no_owner:
3366
+ level = "high"
3367
+ elif flags:
3368
+ level = "elevated"
3369
+ else:
3370
+ level = "low"
3371
+
3372
+ reason_codes = sorted({_risk_reason_code(flag) for flag in flags}) or [
3373
+ "NO_COMPETITION_PRESSURE"
3374
+ ]
3375
+
3376
+ if level == "high":
3377
+ next_step = (
3378
+ "Treat as a noise trap: multiple solvers are racing this bounty with no clear "
3379
+ "owner. Keep as no-go/watchlist evidence unless the client specifically wants it "
3380
+ "and assignment is clarified from a permitted public source."
3381
+ )
3382
+ elif level == "elevated":
3383
+ next_step = (
3384
+ "Verify assignment and active PR count from permitted public sources before "
3385
+ "ranking; effort may be wasted if another solver is already ahead."
3386
+ )
3387
+ else:
3388
+ next_step = "No significant competition pressure from the provided public metadata."
3389
+
3390
+ return {
3391
+ "schema_version": COMPETITION_SIGNAL_SCHEMA_VERSION,
3392
+ "read_only": True,
3393
+ "level": level,
3394
+ "risk_flags": flags,
3395
+ "reason_codes": reason_codes,
3396
+ "observed": {
3397
+ "competing_pr_count": competing_pr_count,
3398
+ "distinct_claimants": distinct_claimants,
3399
+ "comment_count": comment_count,
3400
+ "assigned": assigned,
3401
+ },
3402
+ "thresholds": dict(COMPETITION_THRESHOLDS),
3403
+ "recommended_next_step": next_step,
3404
+ }
3405
+
3406
+
3407
+ _COMPETITION_LEVEL_ORDER = {"high": 0, "elevated": 1, "low": 2}
3408
+
3409
+
3410
+ def _competition_sort_key(result: dict[str, Any]) -> tuple[int, int, str]:
3411
+ observed = result["observed"]
3412
+ pressure = (
3413
+ observed["competing_pr_count"] + observed["distinct_claimants"] + observed["comment_count"]
3414
+ )
3415
+ return (
3416
+ _COMPETITION_LEVEL_ORDER.get(result["level"], 3),
3417
+ -pressure,
3418
+ result["reference"],
3419
+ )
3420
+
3421
+
3422
+ def assess_competition_batch(observations: Any) -> dict[str, Any]:
3423
+ """Assess read-only competition / noise-trap pressure across a batch of bounties.
3424
+
3425
+ Productizes the per-prospect recon the desk does by hand into a single pass:
3426
+ given public metadata counts for several funded issues, surface which ones are
3427
+ contested or crowded noise traps so an operator can drop them before spending
3428
+ engineering time. ``observations`` is a list of dicts, each with an optional
3429
+ ``reference`` (or ``id``/``url``) label plus the public counts consumed by
3430
+ :func:`assess_bounty_competition` (``competing_pr_count``, ``distinct_claimants``,
3431
+ ``comment_count``, ``assigned``). Results are sorted highest-pressure first.
3432
+ This never claims, comments on, or contacts anyone.
3433
+ """
3434
+ if not isinstance(observations, list):
3435
+ raise ValueError("competition observations must be a list")
3436
+
3437
+ results: list[dict[str, Any]] = []
3438
+ level_counts = {"high": 0, "elevated": 0, "low": 0}
3439
+ contested = 0
3440
+ crowded = 0
3441
+
3442
+ for index, raw in enumerate(observations):
3443
+ if not isinstance(raw, dict):
3444
+ raise ValueError(f"competition observation #{index} must be an object")
3445
+ reference = raw.get("reference") or raw.get("id") or raw.get("url")
3446
+ if reference is not None and not isinstance(reference, str):
3447
+ raise ValueError(f"competition observation #{index} reference must be a string")
3448
+ signal = assess_bounty_competition(
3449
+ competing_pr_count=raw.get("competing_pr_count", 0),
3450
+ distinct_claimants=raw.get("distinct_claimants", 0),
3451
+ comment_count=raw.get("comment_count", 0),
3452
+ assigned=raw.get("assigned", False),
3453
+ )
3454
+ level_counts[signal["level"]] += 1
3455
+ if CONTESTED_BOUNTY_FLAG in signal["risk_flags"]:
3456
+ contested += 1
3457
+ if CROWDED_NO_ASSIGNMENT_FLAG in signal["risk_flags"]:
3458
+ crowded += 1
3459
+ results.append(
3460
+ {
3461
+ "reference": reference or f"observation-{index + 1}",
3462
+ "level": signal["level"],
3463
+ "risk_flags": signal["risk_flags"],
3464
+ "reason_codes": signal["reason_codes"],
3465
+ "observed": signal["observed"],
3466
+ "recommended_next_step": signal["recommended_next_step"],
3467
+ }
3468
+ )
3469
+
3470
+ results.sort(key=_competition_sort_key)
3471
+ noise_traps = level_counts["high"] + level_counts["elevated"]
3472
+
3473
+ return {
3474
+ "schema_version": COMPETITION_BATCH_SCHEMA_VERSION,
3475
+ "read_only": True,
3476
+ "reviewed": len(results),
3477
+ "summary": {
3478
+ "reviewed": len(results),
3479
+ "noise_traps": noise_traps,
3480
+ "high": level_counts["high"],
3481
+ "elevated": level_counts["elevated"],
3482
+ "low": level_counts["low"],
3483
+ "contested_bounty": contested,
3484
+ "crowded_no_assignment": crowded,
3485
+ },
3486
+ "thresholds": dict(COMPETITION_THRESHOLDS),
3487
+ "results": results,
3488
+ "blocked_actions": list(BLOCKED_ACTIONS),
3489
+ }
3490
+
3491
+
3492
+ def assess_payout_effort(
3493
+ *,
3494
+ funding_amount: float | None = None,
3495
+ funding_currency: str = "USD",
3496
+ estimated_effort_hours: float | None = None,
3497
+ target_hourly_rate_usd: float = DEFAULT_TARGET_HOURLY_RATE_USD,
3498
+ ) -> dict[str, Any]:
3499
+ """Derive a read-only payout-vs-effort signal for a funded issue.
3500
+
3501
+ Productizes the desk's effort-budget check (REVENUE_PLAN §16.1): a bounty
3502
+ whose payout implies an effective hourly rate well below the target floor is
3503
+ rarely worth engineering time even when the issue itself is real. Inputs are
3504
+ public funding metadata plus the operator's own private effort estimate; this
3505
+ never claims, comments, or contacts anyone. A ``low`` result emits the curated
3506
+ ``PAYOUT_TOO_LOW_FOR_EFFORT`` code and a ``payout_too_low_for_effort`` flag an
3507
+ operator can merge into a FundedIssue's ``risk_flags`` (it costs score via the
3508
+ normal risk penalty without forcing an automatic no-go).
3509
+ """
3510
+ funding_amount = _coerce_amount(funding_amount, "funding_amount", allow_zero=True)
3511
+ estimated_effort_hours = _coerce_amount(
3512
+ estimated_effort_hours, "estimated_effort_hours", allow_zero=False
3513
+ )
3514
+ target_hourly_rate_usd = _coerce_amount(
3515
+ target_hourly_rate_usd,
3516
+ "target_hourly_rate_usd",
3517
+ allow_none=False,
3518
+ allow_zero=False,
3519
+ )
3520
+ currency = (funding_currency or "USD").strip().upper() or "USD"
3521
+
3522
+ effective_hourly_rate: float | None = None
3523
+ payout_effort_ratio: float | None = None
3524
+ flags: list[str] = []
3525
+
3526
+ if funding_amount is None or estimated_effort_hours is None:
3527
+ level = "unknown"
3528
+ reason_codes = ["FUNDING_STATE_UNCLEAR"]
3529
+ next_step = (
3530
+ "Provide both the public funding amount and an effort estimate before ranking; "
3531
+ "payout-vs-effort cannot be assessed from the provided metadata."
3532
+ )
3533
+ elif currency != "USD":
3534
+ level = "unverified_currency"
3535
+ reason_codes = ["PAYOUT_CURRENCY_UNVERIFIED"]
3536
+ next_step = (
3537
+ f"Funding is in {currency}; convert to USD from a permitted public rate before "
3538
+ "comparing against the effort-budget floor."
3539
+ )
3540
+ else:
3541
+ effective_hourly_rate = round(funding_amount / estimated_effort_hours, 2)
3542
+ payout_effort_ratio = round(effective_hourly_rate / target_hourly_rate_usd, 4)
3543
+ if payout_effort_ratio >= PAYOUT_EFFORT_THRESHOLDS["marginal_ratio"]:
3544
+ level = "strong"
3545
+ reason_codes = ["PAYOUT_PROPORTIONATE_TO_EFFORT"]
3546
+ next_step = (
3547
+ "Payout is proportionate to the estimated effort against the target rate; safe "
3548
+ "to rank on the usual liveness/scope/maintainer signals."
3549
+ )
3550
+ elif payout_effort_ratio >= PAYOUT_EFFORT_THRESHOLDS["low_ratio"]:
3551
+ level = "marginal"
3552
+ reason_codes = ["PAYOUT_NEAR_EFFORT_FLOOR"]
3553
+ next_step = (
3554
+ "Payout is near the effort-budget floor; only pursue if the effort estimate is "
3555
+ "conservative or the issue carries strategic/portfolio value."
3556
+ )
3557
+ else:
3558
+ level = "low"
3559
+ flags.append(PAYOUT_EFFORT_FLAG)
3560
+ reason_codes = [_risk_reason_code(PAYOUT_EFFORT_FLAG)]
3561
+ next_step = (
3562
+ "Effective hourly rate is well below the target floor; keep as no-go/watchlist "
3563
+ "evidence unless the effort estimate is revised down from real scope review."
3564
+ )
3565
+
3566
+ return {
3567
+ "schema_version": PAYOUT_EFFORT_SIGNAL_SCHEMA_VERSION,
3568
+ "read_only": True,
3569
+ "level": level,
3570
+ "risk_flags": flags,
3571
+ "reason_codes": reason_codes,
3572
+ "observed": {
3573
+ "funding_amount": funding_amount,
3574
+ "funding_currency": currency,
3575
+ "estimated_effort_hours": estimated_effort_hours,
3576
+ "effective_hourly_rate": effective_hourly_rate,
3577
+ "payout_effort_ratio": payout_effort_ratio,
3578
+ },
3579
+ "thresholds": {
3580
+ "target_hourly_rate_usd": target_hourly_rate_usd,
3581
+ "low_ratio": PAYOUT_EFFORT_THRESHOLDS["low_ratio"],
3582
+ "marginal_ratio": PAYOUT_EFFORT_THRESHOLDS["marginal_ratio"],
3583
+ },
3584
+ "recommended_next_step": next_step,
3585
+ }
3586
+
3587
+
3588
+ _PAYOUT_EFFORT_LEVEL_ORDER = {
3589
+ "low": 0,
3590
+ "marginal": 1,
3591
+ "unverified_currency": 2,
3592
+ "unknown": 3,
3593
+ "strong": 4,
3594
+ }
3595
+
3596
+
3597
+ def _payout_effort_sort_key(result: dict[str, Any]) -> tuple[int, float, str]:
3598
+ ratio = result["observed"]["payout_effort_ratio"]
3599
+ ratio_key = ratio if ratio is not None else float("inf")
3600
+ return (
3601
+ _PAYOUT_EFFORT_LEVEL_ORDER.get(result["level"], 5),
3602
+ ratio_key,
3603
+ result["reference"],
3604
+ )
3605
+
3606
+
3607
+ def assess_payout_effort_batch(observations: Any) -> dict[str, Any]:
3608
+ """Assess read-only payout-vs-effort across a batch of bounties.
3609
+
3610
+ Productizes the desk's effort-budget triage into a single pass: given public
3611
+ funding metadata plus an effort estimate for several funded issues, surface
3612
+ which ones pay too little for the work so an operator can drop them before
3613
+ spending engineering time. ``observations`` is a list of dicts, each with an
3614
+ optional ``reference`` (or ``id``/``url``) label plus the values consumed by
3615
+ :func:`assess_payout_effort` (``funding_amount``, ``funding_currency``,
3616
+ ``estimated_effort_hours``, optional ``target_hourly_rate_usd``). Results are
3617
+ sorted worst-payout first. This never claims, comments on, or contacts anyone.
3618
+ """
3619
+ if not isinstance(observations, list):
3620
+ raise ValueError("payout-effort observations must be a list")
3621
+
3622
+ results: list[dict[str, Any]] = []
3623
+ level_counts = {
3624
+ "low": 0,
3625
+ "marginal": 0,
3626
+ "strong": 0,
3627
+ "unknown": 0,
3628
+ "unverified_currency": 0,
3629
+ }
3630
+
3631
+ for index, raw in enumerate(observations):
3632
+ if not isinstance(raw, dict):
3633
+ raise ValueError(f"payout-effort observation #{index} must be an object")
3634
+ reference = raw.get("reference") or raw.get("id") or raw.get("url")
3635
+ if reference is not None and not isinstance(reference, str):
3636
+ raise ValueError(f"payout-effort observation #{index} reference must be a string")
3637
+ signal = assess_payout_effort(
3638
+ funding_amount=raw.get("funding_amount"),
3639
+ funding_currency=raw.get("funding_currency", "USD"),
3640
+ estimated_effort_hours=raw.get("estimated_effort_hours"),
3641
+ target_hourly_rate_usd=raw.get(
3642
+ "target_hourly_rate_usd", DEFAULT_TARGET_HOURLY_RATE_USD
3643
+ ),
3644
+ )
3645
+ level_counts[signal["level"]] += 1
3646
+ results.append(
3647
+ {
3648
+ "reference": reference or f"observation-{index + 1}",
3649
+ "level": signal["level"],
3650
+ "risk_flags": signal["risk_flags"],
3651
+ "reason_codes": signal["reason_codes"],
3652
+ "observed": signal["observed"],
3653
+ "recommended_next_step": signal["recommended_next_step"],
3654
+ }
3655
+ )
3656
+
3657
+ results.sort(key=_payout_effort_sort_key)
3658
+
3659
+ return {
3660
+ "schema_version": PAYOUT_EFFORT_BATCH_SCHEMA_VERSION,
3661
+ "read_only": True,
3662
+ "reviewed": len(results),
3663
+ "summary": {
3664
+ "reviewed": len(results),
3665
+ "underpaid": level_counts["low"],
3666
+ "low": level_counts["low"],
3667
+ "marginal": level_counts["marginal"],
3668
+ "strong": level_counts["strong"],
3669
+ "unknown": level_counts["unknown"],
3670
+ "unverified_currency": level_counts["unverified_currency"],
3671
+ },
3672
+ "thresholds": dict(PAYOUT_EFFORT_THRESHOLDS),
3673
+ "results": results,
3674
+ "blocked_actions": list(BLOCKED_ACTIONS),
3675
+ }
3676
+
3677
+
3678
+ _STALENESS_NEXT_STEP = {
3679
+ "active": (
3680
+ "Recent public activity; safe to rank on the usual scope/competition/payout signals."
3681
+ ),
3682
+ "aging": (
3683
+ "Activity is slowing; confirm a maintainer is still engaged from a permitted public "
3684
+ "source before committing engineering time."
3685
+ ),
3686
+ "stale": (
3687
+ "No recent maintainer signal; treat as stale/no-go evidence unless public project state "
3688
+ "shows the opportunity is live again."
3689
+ ),
3690
+ "dormant": (
3691
+ "Long-dormant with no maintainer signal; keep as no-go evidence — a classic "
3692
+ "stale-bounty trap where effort is wasted even if the issue itself is real."
3693
+ ),
3694
+ "unknown": (
3695
+ "Verify the last public activity date from a permitted source before ranking; staleness "
3696
+ "cannot be assessed from the provided metadata."
3697
+ ),
3698
+ }
3699
+
3700
+
3701
+ def assess_issue_staleness(
3702
+ *,
3703
+ days_since_last_activity: int | None = None,
3704
+ days_since_created: int | None = None,
3705
+ maintainer_recently_active: bool | None = None,
3706
+ ) -> dict[str, Any]:
3707
+ """Derive a read-only staleness / liveness signal for a funded issue.
3708
+
3709
+ Productizes the desk's core value prop ("filter stale traps"): instead of
3710
+ eyeballing per prospect whether a bounty is alive, derive an
3711
+ ``opportunity_state`` recommendation from observable issue age plus whether a
3712
+ maintainer is still engaging. Inputs are public age/activity metadata only;
3713
+ this never claims, comments, or contacts anyone.
3714
+
3715
+ ``days_since_last_activity`` drives the base level (active/aging/stale/
3716
+ dormant). ``maintainer_recently_active`` adjusts severity by one notch: an
3717
+ engaged maintainer softens a stale/dormant issue (it is old but not dead),
3718
+ while an explicitly absent maintainer hardens an active/aging issue (recent
3719
+ solver churn with no maintainer is the classic noise trap). The returned
3720
+ ``risk_flags`` can be merged into a FundedIssue's ``risk_flags``: a
3721
+ ``stale``/``dormant`` result emits the high-risk ``stale_no_maintainer_signal``
3722
+ flag (forces no-go), while ``aging`` emits the softer ``aging_low_activity``
3723
+ flag (costs score via the normal risk penalty without forcing a no-go).
3724
+ """
3725
+ days_since_last_activity = _coerce_optional_count(
3726
+ days_since_last_activity, "days_since_last_activity"
3727
+ )
3728
+ days_since_created = _coerce_optional_count(days_since_created, "days_since_created")
3729
+ maintainer_recently_active = _coerce_optional_bool(
3730
+ maintainer_recently_active, "maintainer_recently_active"
3731
+ )
3732
+
3733
+ long_unresolved = (
3734
+ days_since_created is not None
3735
+ and days_since_created >= STALENESS_THRESHOLDS["long_unresolved_days"]
3736
+ )
3737
+
3738
+ if days_since_last_activity is None:
3739
+ level = "unknown"
3740
+ elif days_since_last_activity <= STALENESS_THRESHOLDS["active_max_days"]:
3741
+ level = "active"
3742
+ elif days_since_last_activity <= STALENESS_THRESHOLDS["aging_max_days"]:
3743
+ level = "aging"
3744
+ elif days_since_last_activity <= STALENESS_THRESHOLDS["stale_max_days"]:
3745
+ level = "stale"
3746
+ else:
3747
+ level = "dormant"
3748
+
3749
+ if level != "unknown" and maintainer_recently_active is True:
3750
+ level = {"dormant": "stale", "stale": "aging", "aging": "active"}.get(level, level)
3751
+ elif level != "unknown" and maintainer_recently_active is False:
3752
+ level = {"active": "aging", "aging": "stale"}.get(level, level)
3753
+
3754
+ if level in {"stale", "dormant"}:
3755
+ flags = [STALE_NO_MAINTAINER_FLAG]
3756
+ recommended_state = "stale"
3757
+ elif level == "aging":
3758
+ flags = [AGING_LOW_ACTIVITY_FLAG]
3759
+ recommended_state = "active"
3760
+ elif level == "active":
3761
+ flags = []
3762
+ recommended_state = "active"
3763
+ else:
3764
+ flags = []
3765
+ recommended_state = "unknown"
3766
+
3767
+ if flags:
3768
+ reason_codes = sorted({_risk_reason_code(flag) for flag in flags})
3769
+ elif level == "active":
3770
+ reason_codes = ["RECENT_PUBLIC_ACTIVITY"]
3771
+ else:
3772
+ reason_codes = ["STALENESS_UNVERIFIED"]
3773
+
3774
+ return {
3775
+ "schema_version": STALENESS_SIGNAL_SCHEMA_VERSION,
3776
+ "read_only": True,
3777
+ "level": level,
3778
+ "recommended_opportunity_state": recommended_state,
3779
+ "risk_flags": flags,
3780
+ "reason_codes": reason_codes,
3781
+ "observed": {
3782
+ "days_since_last_activity": days_since_last_activity,
3783
+ "days_since_created": days_since_created,
3784
+ "maintainer_recently_active": maintainer_recently_active,
3785
+ "long_unresolved": long_unresolved,
3786
+ },
3787
+ "thresholds": dict(STALENESS_THRESHOLDS),
3788
+ "recommended_next_step": _STALENESS_NEXT_STEP[level],
3789
+ }
3790
+
3791
+
3792
+ _STALENESS_LEVEL_ORDER = {"dormant": 0, "stale": 1, "aging": 2, "unknown": 3, "active": 4}
3793
+
3794
+
3795
+ def _staleness_sort_key(result: dict[str, Any]) -> tuple[int, int, str]:
3796
+ days = result["observed"]["days_since_last_activity"]
3797
+ days_key = -days if days is not None else 0
3798
+ return (
3799
+ _STALENESS_LEVEL_ORDER.get(result["level"], 5),
3800
+ days_key,
3801
+ result["reference"],
3802
+ )
3803
+
3804
+
3805
+ def assess_staleness_batch(observations: Any) -> dict[str, Any]:
3806
+ """Assess read-only staleness across a batch of funded issues.
3807
+
3808
+ Productizes the desk's stale-trap triage into a single pass: given public
3809
+ age/activity metadata for several bounties, surface which ones are stale or
3810
+ long-dormant so an operator can drop them before spending engineering time.
3811
+ ``observations`` is a list of dicts, each with an optional ``reference``
3812
+ (or ``id``/``url``) label plus the values consumed by
3813
+ :func:`assess_issue_staleness` (``days_since_last_activity``,
3814
+ ``days_since_created``, optional ``maintainer_recently_active``). Results are
3815
+ sorted most-stale first. This never claims, comments on, or contacts anyone.
3816
+ """
3817
+ if not isinstance(observations, list):
3818
+ raise ValueError("staleness observations must be a list")
3819
+
3820
+ results: list[dict[str, Any]] = []
3821
+ level_counts = {"active": 0, "aging": 0, "stale": 0, "dormant": 0, "unknown": 0}
3822
+
3823
+ for index, raw in enumerate(observations):
3824
+ if not isinstance(raw, dict):
3825
+ raise ValueError(f"staleness observation #{index} must be an object")
3826
+ reference = raw.get("reference") or raw.get("id") or raw.get("url")
3827
+ if reference is not None and not isinstance(reference, str):
3828
+ raise ValueError(f"staleness observation #{index} reference must be a string")
3829
+ signal = assess_issue_staleness(
3830
+ days_since_last_activity=raw.get("days_since_last_activity"),
3831
+ days_since_created=raw.get("days_since_created"),
3832
+ maintainer_recently_active=raw.get("maintainer_recently_active"),
3833
+ )
3834
+ level_counts[signal["level"]] += 1
3835
+ results.append(
3836
+ {
3837
+ "reference": reference or f"observation-{index + 1}",
3838
+ "level": signal["level"],
3839
+ "recommended_opportunity_state": signal["recommended_opportunity_state"],
3840
+ "risk_flags": signal["risk_flags"],
3841
+ "reason_codes": signal["reason_codes"],
3842
+ "observed": signal["observed"],
3843
+ "recommended_next_step": signal["recommended_next_step"],
3844
+ }
3845
+ )
3846
+
3847
+ results.sort(key=_staleness_sort_key)
3848
+
3849
+ return {
3850
+ "schema_version": STALENESS_BATCH_SCHEMA_VERSION,
3851
+ "read_only": True,
3852
+ "reviewed": len(results),
3853
+ "summary": {
3854
+ "reviewed": len(results),
3855
+ "stale_or_dormant": level_counts["stale"] + level_counts["dormant"],
3856
+ "active": level_counts["active"],
3857
+ "aging": level_counts["aging"],
3858
+ "stale": level_counts["stale"],
3859
+ "dormant": level_counts["dormant"],
3860
+ "unknown": level_counts["unknown"],
3861
+ },
3862
+ "thresholds": dict(STALENESS_THRESHOLDS),
3863
+ "results": results,
3864
+ "blocked_actions": list(BLOCKED_ACTIONS),
3865
+ }
3866
+
3867
+
3868
+ _TESTABILITY_NEXT_STEP = {
3869
+ "verifiable": (
3870
+ "An objective verification path exists (failing test or reproduction plus diagnostics); "
3871
+ "safe to rank on the usual liveness/scope/maintainer signals."
3872
+ ),
3873
+ "partially_verifiable": (
3874
+ "Some reproduction signal exists but no clear pass/fail check; estimate the cost of "
3875
+ "writing a failing test before committing engineering time."
3876
+ ),
3877
+ "unverifiable": (
3878
+ "No reproduction steps, failing test, logs, or expected-vs-actual behaviour; keep as "
3879
+ "no-go/watchlist evidence unless the issue body is updated with a way to verify a fix."
3880
+ ),
3881
+ "unknown": (
3882
+ "Read the public issue body for reproduction steps, a failing test, logs, or "
3883
+ "expected-vs-actual behaviour before ranking; testability cannot be assessed from the "
3884
+ "provided metadata."
3885
+ ),
3886
+ }
3887
+
3888
+
3889
+ def assess_issue_testability(
3890
+ *,
3891
+ has_failing_test: bool | None = None,
3892
+ has_reproduction_steps: bool | None = None,
3893
+ has_stack_trace_or_logs: bool | None = None,
3894
+ has_expected_vs_actual: bool | None = None,
3895
+ ) -> dict[str, Any]:
3896
+ """Derive a read-only testability / reproducibility signal for a funded issue.
3897
+
3898
+ Productizes the desk's "can you verify it?" check (REVENUE_PLAN §9.2 Tests row,
3899
+ §10.2 ``NO_REPRO_OR_TEST_PATH``): a funded issue with no way to objectively
3900
+ reproduce or test the fix is hard to close even when the bounty is real,
3901
+ because the maintainer cannot distinguish a correct patch from a plausible one.
3902
+ Inputs are observable issue-body signals only; this never claims, comments, or
3903
+ contacts anyone.
3904
+
3905
+ A failing test gives an objective pass/fail check on its own; reproduction
3906
+ steps combined with diagnostics (a stack trace/logs or an expected-vs-actual
3907
+ statement) also establish a verification path. Any single weaker signal yields
3908
+ ``partially_verifiable``. When every known signal is absent the result is
3909
+ ``unverifiable`` and emits the soft ``no_repro_or_test_path`` flag an operator
3910
+ can merge into a FundedIssue's ``risk_flags`` (it costs score via the normal
3911
+ risk penalty without forcing an automatic no-go, since docs/config issues can
3912
+ be legitimately untestable). When no signal is observed the result is
3913
+ ``unknown`` and is not penalized.
3914
+ """
3915
+ has_failing_test = _coerce_optional_bool(has_failing_test, "has_failing_test")
3916
+ has_reproduction_steps = _coerce_optional_bool(has_reproduction_steps, "has_reproduction_steps")
3917
+ has_stack_trace_or_logs = _coerce_optional_bool(
3918
+ has_stack_trace_or_logs, "has_stack_trace_or_logs"
3919
+ )
3920
+ has_expected_vs_actual = _coerce_optional_bool(has_expected_vs_actual, "has_expected_vs_actual")
3921
+
3922
+ signals = (
3923
+ has_failing_test,
3924
+ has_reproduction_steps,
3925
+ has_stack_trace_or_logs,
3926
+ has_expected_vs_actual,
3927
+ )
3928
+ known = [value for value in signals if value is not None]
3929
+ present_signal_count = sum(1 for value in known if value)
3930
+
3931
+ if not known:
3932
+ level = "unknown"
3933
+ else:
3934
+ objective_check = bool(has_failing_test)
3935
+ repro = bool(has_reproduction_steps)
3936
+ diagnostics = bool(has_stack_trace_or_logs) or bool(has_expected_vs_actual)
3937
+ if objective_check or (repro and diagnostics):
3938
+ level = "verifiable"
3939
+ elif repro or diagnostics:
3940
+ level = "partially_verifiable"
3941
+ else:
3942
+ level = "unverifiable"
3943
+
3944
+ if level == "unverifiable":
3945
+ flags = [NO_REPRO_OR_TEST_PATH_FLAG]
3946
+ reason_codes = [_risk_reason_code(NO_REPRO_OR_TEST_PATH_FLAG)]
3947
+ elif level == "verifiable":
3948
+ flags = []
3949
+ reason_codes = ["VERIFIABLE_TEST_PATH"]
3950
+ elif level == "partially_verifiable":
3951
+ flags = []
3952
+ reason_codes = ["PARTIAL_TEST_PATH"]
3953
+ else:
3954
+ flags = []
3955
+ reason_codes = ["TESTABILITY_UNVERIFIED"]
3956
+
3957
+ return {
3958
+ "schema_version": TESTABILITY_SIGNAL_SCHEMA_VERSION,
3959
+ "read_only": True,
3960
+ "level": level,
3961
+ "risk_flags": flags,
3962
+ "reason_codes": reason_codes,
3963
+ "observed": {
3964
+ "has_failing_test": has_failing_test,
3965
+ "has_reproduction_steps": has_reproduction_steps,
3966
+ "has_stack_trace_or_logs": has_stack_trace_or_logs,
3967
+ "has_expected_vs_actual": has_expected_vs_actual,
3968
+ "known_signal_count": len(known),
3969
+ "present_signal_count": present_signal_count,
3970
+ },
3971
+ "recommended_next_step": _TESTABILITY_NEXT_STEP[level],
3972
+ }
3973
+
3974
+
3975
+ _TESTABILITY_LEVEL_ORDER = {
3976
+ "unverifiable": 0,
3977
+ "partially_verifiable": 1,
3978
+ "unknown": 2,
3979
+ "verifiable": 3,
3980
+ }
3981
+
3982
+
3983
+ def _testability_sort_key(result: dict[str, Any]) -> tuple[int, int, str]:
3984
+ return (
3985
+ _TESTABILITY_LEVEL_ORDER.get(result["level"], 4),
3986
+ result["observed"]["present_signal_count"],
3987
+ result["reference"],
3988
+ )
3989
+
3990
+
3991
+ def assess_testability_batch(observations: Any) -> dict[str, Any]:
3992
+ """Assess read-only testability across a batch of funded issues.
3993
+
3994
+ Productizes the desk's verifiability triage into a single pass: given the
3995
+ observable reproduction/test signals for several bounties, surface which ones
3996
+ cannot be objectively verified so an operator can drop them before spending
3997
+ engineering time. ``observations`` is a list of dicts, each with an optional
3998
+ ``reference`` (or ``id``/``url``) label plus the booleans consumed by
3999
+ :func:`assess_issue_testability` (``has_failing_test``,
4000
+ ``has_reproduction_steps``, ``has_stack_trace_or_logs``,
4001
+ ``has_expected_vs_actual``). Results are sorted least-verifiable first. This
4002
+ never claims, comments on, or contacts anyone.
4003
+ """
4004
+ if not isinstance(observations, list):
4005
+ raise ValueError("testability observations must be a list")
4006
+
4007
+ results: list[dict[str, Any]] = []
4008
+ level_counts = {
4009
+ "verifiable": 0,
4010
+ "partially_verifiable": 0,
4011
+ "unverifiable": 0,
4012
+ "unknown": 0,
4013
+ }
4014
+
4015
+ for index, raw in enumerate(observations):
4016
+ if not isinstance(raw, dict):
4017
+ raise ValueError(f"testability observation #{index} must be an object")
4018
+ reference = raw.get("reference") or raw.get("id") or raw.get("url")
4019
+ if reference is not None and not isinstance(reference, str):
4020
+ raise ValueError(f"testability observation #{index} reference must be a string")
4021
+ signal = assess_issue_testability(
4022
+ has_failing_test=raw.get("has_failing_test"),
4023
+ has_reproduction_steps=raw.get("has_reproduction_steps"),
4024
+ has_stack_trace_or_logs=raw.get("has_stack_trace_or_logs"),
4025
+ has_expected_vs_actual=raw.get("has_expected_vs_actual"),
4026
+ )
4027
+ level_counts[signal["level"]] += 1
4028
+ results.append(
4029
+ {
4030
+ "reference": reference or f"observation-{index + 1}",
4031
+ "level": signal["level"],
4032
+ "risk_flags": signal["risk_flags"],
4033
+ "reason_codes": signal["reason_codes"],
4034
+ "observed": signal["observed"],
4035
+ "recommended_next_step": signal["recommended_next_step"],
4036
+ }
4037
+ )
4038
+
4039
+ results.sort(key=_testability_sort_key)
4040
+
4041
+ return {
4042
+ "schema_version": TESTABILITY_BATCH_SCHEMA_VERSION,
4043
+ "read_only": True,
4044
+ "reviewed": len(results),
4045
+ "summary": {
4046
+ "reviewed": len(results),
4047
+ "unverifiable": level_counts["unverifiable"],
4048
+ "partially_verifiable": level_counts["partially_verifiable"],
4049
+ "verifiable": level_counts["verifiable"],
4050
+ "unknown": level_counts["unknown"],
4051
+ },
4052
+ "results": results,
4053
+ "blocked_actions": list(BLOCKED_ACTIONS),
4054
+ }
4055
+
4056
+
4057
+ def explain_issue(issues: list[FundedIssue], reference: str) -> dict[str, Any]:
4058
+ for issue in issues:
4059
+ if reference in {issue.id, issue.reference, issue.url}:
4060
+ return {
4061
+ "schema_version": SCHEMA_VERSION,
4062
+ "read_only": True,
4063
+ "issue": issue.to_dict(),
4064
+ "ethics": {
4065
+ "allowed": [
4066
+ "read public funding metadata",
4067
+ "review contribution guidelines",
4068
+ "prepare a local maintainer-readiness note",
4069
+ ],
4070
+ "blocked": BLOCKED_ACTIONS,
4071
+ },
4072
+ "recommendation": _recommendation_for(issue),
4073
+ }
4074
+ raise KeyError(reference)
4075
+
4076
+
4077
+ def _recommendation_for(issue: FundedIssue) -> str:
4078
+ if issue.risk_level == "high":
4079
+ return (
4080
+ "Do not pursue automatically. Keep this as read-only metadata unless a maintainer "
4081
+ "explicitly invites help and the scope is clarified."
4082
+ )
4083
+ if issue.risk_level == "medium":
4084
+ return (
4085
+ "Review contribution guidelines before any work. Treat funding as context, not as the "
4086
+ "primary ranking signal."
4087
+ )
4088
+ return (
4089
+ "Safe to keep in a local funded-maintenance shortlist. Any contribution still requires "
4090
+ "normal maintainer review and project rules."
4091
+ )