lewtun HF Staff OpenAI Codex commited on
Commit
6aebbdf
·
unverified ·
1 Parent(s): fbc10a2

Add backlog prioritization report tooling (#222)

Browse files

* Add backlog prioritization script

Co-authored-by: OpenAI Codex <codex@openai.com>

* Fix backlog prioritizer LLM output handling

Co-authored-by: OpenAI Codex <codex@openai.com>

* Add backlog resolved-in-main checks

Co-authored-by: OpenAI Codex <codex@openai.com>

* Add GitHub issue publishing for backlog reports

Co-authored-by: OpenAI Codex <codex@openai.com>

* Address backlog prioritization review

Handle GitHub rate limits gracefully, validate publish tokens early, and tighten resolution-link detection.

Co-authored-by: OpenAI Codex <codex@openai.com>

* Exclude generated backlog reports

Apply a default label to generated report issues and skip that label during future GitHub backlog collection.

Co-authored-by: OpenAI Codex <codex@openai.com>

---------

Co-authored-by: OpenAI Codex <codex@openai.com>

.gitignore CHANGED
@@ -56,6 +56,7 @@ frontend/yarn-error.log*
56
  eval/
57
 
58
  # Project-specific
 
59
  session_logs/
60
  /logs
61
  hf-agent-leaderboard/
 
56
  eval/
57
 
58
  # Project-specific
59
+ scratch/
60
  session_logs/
61
  /logs
62
  hf-agent-leaderboard/
scripts/prioritize_backlog.py ADDED
@@ -0,0 +1,1910 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Prioritize the open ML Intern backlog with a product-manager prompt.
3
+
4
+ Collects open GitHub issues, open GitHub pull requests, and open Hugging Face
5
+ Space discussions, then asks an LLM to classify, cluster, and rank them by
6
+ likely product impact.
7
+
8
+ Usage:
9
+ uv run python scripts/prioritize_backlog.py
10
+ uv run python scripts/prioritize_backlog.py --model openai/gpt-5.5
11
+
12
+ Outputs:
13
+ scratch/backlog-prioritization/<timestamp>/sources.json
14
+ scratch/backlog-prioritization/<timestamp>/ranking.json
15
+ scratch/backlog-prioritization/<timestamp>/report.md
16
+ """
17
+
18
+ import argparse
19
+ import asyncio
20
+ import json
21
+ import logging
22
+ import os
23
+ import re
24
+ import subprocess
25
+ import sys
26
+ from datetime import datetime, timezone
27
+ from pathlib import Path
28
+ from typing import Any, Callable
29
+
30
+ import httpx
31
+
32
+ PROJECT_ROOT = Path(__file__).resolve().parent.parent
33
+ if str(PROJECT_ROOT) not in sys.path:
34
+ sys.path.insert(0, str(PROJECT_ROOT))
35
+
36
+ GITHUB_API = "https://api.github.com"
37
+ DEFAULT_GITHUB_REPO = "huggingface/ml-intern"
38
+ DEFAULT_HF_SPACE = "smolagents/ml-intern"
39
+ DEFAULT_CONFIG = "configs/cli_agent_config.json"
40
+ DEFAULT_BATCH_SIZE = 12
41
+ DEFAULT_MAX_COMMENTS = 8
42
+ DEFAULT_MAX_REVIEW_COMMENTS = 8
43
+ DEFAULT_MAX_BODY_CHARS = 6000
44
+ DEFAULT_MAX_COMMENT_CHARS = 1500
45
+ DEFAULT_MAX_OUTPUT_TOKENS = 12000
46
+ DEFAULT_RESOLUTION_REF = "main"
47
+ DEFAULT_RESOLUTION_LOG_COMMITS = 500
48
+ DEFAULT_GITHUB_ISSUE_BODY_CHARS = 60000
49
+ DEFAULT_GITHUB_REPORT_LABEL = "backlog-prioritization-report"
50
+
51
+ logger = logging.getLogger("prioritize_backlog")
52
+
53
+ PM_SYSTEM_PROMPT = """You are a senior product manager for ML Intern.
54
+
55
+ Your job is to turn messy public feedback into a pragmatic implementation
56
+ priority list. Optimize for:
57
+ - user impact and blocked workflows
58
+ - evidence of repeated demand or engagement
59
+ - recency and severity
60
+ - PR readiness and whether an open PR should be reviewed/merged/fixed forward
61
+ - resolved-in-main signals from the local codebase check
62
+ - implementation effort, risk, and strategic fit for ML Intern
63
+
64
+ Separate user-facing features from bug fixes. Treat open PRs as possible
65
+ ready-made implementations rather than duplicate feature requests. Every
66
+ recommendation must cite source ids and/or source URLs from the input.
67
+ If an item has a high-confidence resolved-in-main signal, recommend closure
68
+ instead of implementation.
69
+
70
+ Return valid JSON only. Do not use Markdown fences.
71
+ """
72
+
73
+
74
+ def utc_now() -> datetime:
75
+ return datetime.now(timezone.utc)
76
+
77
+
78
+ def default_output_dir(now: datetime | None = None) -> Path:
79
+ now = now or utc_now()
80
+ stamp = now.strftime("%Y%m%dT%H%M%SZ")
81
+ return PROJECT_ROOT / "scratch" / "backlog-prioritization" / stamp
82
+
83
+
84
+ def resolve_output_dir(value: str | None, now: datetime | None = None) -> Path:
85
+ if value:
86
+ path = Path(value).expanduser()
87
+ return path if path.is_absolute() else PROJECT_ROOT / path
88
+ return default_output_dir(now)
89
+
90
+
91
+ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
92
+ ap = argparse.ArgumentParser(
93
+ description="Prioritize GitHub and HF Space backlog items with an LLM."
94
+ )
95
+ ap.add_argument("--github-repo", default=DEFAULT_GITHUB_REPO)
96
+ ap.add_argument("--hf-space", default=DEFAULT_HF_SPACE)
97
+ ap.add_argument(
98
+ "--config",
99
+ default=DEFAULT_CONFIG,
100
+ help="Config file used to resolve the default model.",
101
+ )
102
+ ap.add_argument(
103
+ "--model",
104
+ default=None,
105
+ help="Override the model from configs/cli_agent_config.json.",
106
+ )
107
+ ap.add_argument(
108
+ "--output-dir",
109
+ default=None,
110
+ help="Defaults to scratch/backlog-prioritization/<UTC timestamp>.",
111
+ )
112
+ ap.add_argument("--github-token", default=None, help="Defaults to GITHUB_TOKEN.")
113
+ ap.add_argument(
114
+ "--hf-token",
115
+ default=None,
116
+ help="Defaults to HF_TOKEN or the local huggingface_hub token cache.",
117
+ )
118
+ ap.add_argument("--batch-size", type=int, default=DEFAULT_BATCH_SIZE)
119
+ ap.add_argument("--max-comments", type=int, default=DEFAULT_MAX_COMMENTS)
120
+ ap.add_argument(
121
+ "--max-review-comments", type=int, default=DEFAULT_MAX_REVIEW_COMMENTS
122
+ )
123
+ ap.add_argument("--max-body-chars", type=int, default=DEFAULT_MAX_BODY_CHARS)
124
+ ap.add_argument("--max-comment-chars", type=int, default=DEFAULT_MAX_COMMENT_CHARS)
125
+ ap.add_argument("--max-output-tokens", type=int, default=DEFAULT_MAX_OUTPUT_TOKENS)
126
+ ap.add_argument(
127
+ "--resolution-ref",
128
+ default=DEFAULT_RESOLUTION_REF,
129
+ help="Git ref used to check whether open items are already resolved.",
130
+ )
131
+ ap.add_argument(
132
+ "--resolution-log-commits",
133
+ type=int,
134
+ default=DEFAULT_RESOLUTION_LOG_COMMITS,
135
+ help="Number of commits on --resolution-ref to scan for closure signals.",
136
+ )
137
+ ap.add_argument(
138
+ "--skip-resolution-check",
139
+ action="store_true",
140
+ help="Skip local resolved-in-main checks before the LLM pass.",
141
+ )
142
+ ap.add_argument(
143
+ "--skip-pr-patch-check",
144
+ action="store_true",
145
+ help="Skip PR patch-id comparison against --resolution-ref history.",
146
+ )
147
+ ap.add_argument(
148
+ "--create-github-issue",
149
+ action="store_true",
150
+ help="Post the generated Markdown report as a new GitHub issue.",
151
+ )
152
+ ap.add_argument(
153
+ "--github-issue-title",
154
+ default=None,
155
+ help="Title for --create-github-issue. Defaults to a dated report title.",
156
+ )
157
+ ap.add_argument(
158
+ "--github-issue-label",
159
+ action="append",
160
+ default=[],
161
+ help="Label to add to the created issue. Repeat or pass comma-separated labels.",
162
+ )
163
+ ap.add_argument(
164
+ "--github-report-label",
165
+ default=DEFAULT_GITHUB_REPORT_LABEL,
166
+ help=(
167
+ "Label applied to generated report issues and excluded from future "
168
+ "GitHub collection. Pass an empty string to disable."
169
+ ),
170
+ )
171
+ ap.add_argument(
172
+ "--github-issue-body-chars",
173
+ type=int,
174
+ default=DEFAULT_GITHUB_ISSUE_BODY_CHARS,
175
+ help="Maximum report body characters to send to GitHub.",
176
+ )
177
+ ap.add_argument(
178
+ "--reasoning-effort",
179
+ default="high",
180
+ help="Reasoning effort preference passed through the repo LLM resolver.",
181
+ )
182
+ ap.add_argument(
183
+ "--log-level",
184
+ default="INFO",
185
+ choices=["DEBUG", "INFO", "WARNING", "ERROR"],
186
+ )
187
+ return ap.parse_args(argv)
188
+
189
+
190
+ def resolve_model(model: str | None, config_path: str) -> str:
191
+ if model:
192
+ return model
193
+
194
+ from agent.config import load_config
195
+
196
+ path = Path(config_path)
197
+ if not path.is_absolute():
198
+ path = PROJECT_ROOT / path
199
+ return load_config(str(path), include_user_defaults=True).model_name
200
+
201
+
202
+ def resolve_hf_token(cli_token: str | None) -> str | None:
203
+ from agent.core.hf_tokens import resolve_hf_token as _resolve_hf_token
204
+
205
+ return _resolve_hf_token(cli_token, os.environ.get("HF_TOKEN"))
206
+
207
+
208
+ def _truncate_text(value: Any, max_chars: int) -> str:
209
+ if value is None:
210
+ return ""
211
+ text = str(value)
212
+ if max_chars <= 0 or len(text) <= max_chars:
213
+ return text
214
+ suffix = "\n... [truncated]"
215
+ return text[: max(0, max_chars - len(suffix))].rstrip() + suffix
216
+
217
+
218
+ def _iso(value: Any) -> str | None:
219
+ if value is None:
220
+ return None
221
+ if isinstance(value, datetime):
222
+ return value.isoformat()
223
+ return str(value)
224
+
225
+
226
+ def _github_headers(token: str | None) -> dict[str, str]:
227
+ headers = {
228
+ "Accept": "application/vnd.github+json",
229
+ "Content-Type": "application/json",
230
+ "X-GitHub-Api-Version": "2022-11-28",
231
+ "User-Agent": "ml-intern-backlog-prioritizer",
232
+ }
233
+ if token:
234
+ headers["Authorization"] = f"Bearer {token}"
235
+ return headers
236
+
237
+
238
+ def _raise_for_status(response: Any) -> None:
239
+ if hasattr(response, "raise_for_status"):
240
+ response.raise_for_status()
241
+
242
+
243
+ def _is_github_rate_limit_error(exc: httpx.HTTPStatusError) -> bool:
244
+ response = getattr(exc, "response", None)
245
+ return getattr(response, "status_code", None) in {403, 429}
246
+
247
+
248
+ def _log_github_rate_limit(exc: httpx.HTTPStatusError, context: str) -> None:
249
+ response = getattr(exc, "response", None)
250
+ status = getattr(response, "status_code", "unknown")
251
+ reset = None
252
+ if response is not None:
253
+ reset = response.headers.get("x-ratelimit-reset")
254
+ reset_msg = f"; reset={reset}" if reset else ""
255
+ logger.warning(
256
+ "GitHub rate limit while %s (status=%s%s); using partial results.",
257
+ context,
258
+ status,
259
+ reset_msg,
260
+ )
261
+
262
+
263
+ def _get_json(client: Any, url: str, headers: dict[str, str]) -> Any:
264
+ response = client.get(url, headers=headers)
265
+ _raise_for_status(response)
266
+ return response.json()
267
+
268
+
269
+ def _paginated_json(
270
+ client: Any,
271
+ url: str,
272
+ headers: dict[str, str],
273
+ params: dict[str, Any] | None = None,
274
+ limit: int | None = None,
275
+ ) -> list[Any]:
276
+ params = dict(params or {})
277
+ page = 1
278
+ out: list[Any] = []
279
+ while True:
280
+ page_params = {**params, "per_page": 100, "page": page}
281
+ response = client.get(url, headers=headers, params=page_params)
282
+ _raise_for_status(response)
283
+ data = response.json()
284
+ if not isinstance(data, list):
285
+ raise ValueError(f"Expected list response from {url}, got {type(data)}")
286
+
287
+ for item in data:
288
+ out.append(item)
289
+ if limit is not None and len(out) >= limit:
290
+ return out
291
+
292
+ link = getattr(response, "headers", {}).get("link", "")
293
+ if not data or 'rel="next"' not in link:
294
+ return out
295
+ page += 1
296
+
297
+
298
+ def _labels(raw_labels: list[Any]) -> list[str]:
299
+ labels: list[str] = []
300
+ for label in raw_labels or []:
301
+ if isinstance(label, dict):
302
+ name = label.get("name")
303
+ else:
304
+ name = str(label)
305
+ if name:
306
+ labels.append(str(name))
307
+ return labels
308
+
309
+
310
+ def _has_excluded_label(
311
+ raw_labels: list[Any], exclude_labels: list[str] | None = None
312
+ ) -> bool:
313
+ excluded = {
314
+ label.casefold() for label in _github_issue_labels(exclude_labels or [])
315
+ }
316
+ if not excluded:
317
+ return False
318
+ return any(label.casefold() in excluded for label in _labels(raw_labels))
319
+
320
+
321
+ def _user_login(raw: dict[str, Any] | None) -> str | None:
322
+ if not raw:
323
+ return None
324
+ return raw.get("login") or raw.get("name")
325
+
326
+
327
+ def _reactions(raw: dict[str, Any] | None) -> dict[str, int]:
328
+ if not raw:
329
+ return {}
330
+ keep = (
331
+ "total_count",
332
+ "+1",
333
+ "-1",
334
+ "laugh",
335
+ "hooray",
336
+ "confused",
337
+ "heart",
338
+ "rocket",
339
+ "eyes",
340
+ )
341
+ return {key: int(raw.get(key) or 0) for key in keep if raw.get(key) is not None}
342
+
343
+
344
+ def _normalize_github_comment(
345
+ raw: dict[str, Any],
346
+ *,
347
+ max_comment_chars: int,
348
+ kind: str = "comment",
349
+ ) -> dict[str, Any]:
350
+ return {
351
+ "kind": kind,
352
+ "author": _user_login(raw.get("user")),
353
+ "created_at": raw.get("created_at"),
354
+ "updated_at": raw.get("updated_at"),
355
+ "url": raw.get("html_url") or raw.get("url"),
356
+ "state": raw.get("state"),
357
+ "body": _truncate_text(raw.get("body"), max_comment_chars),
358
+ "reactions": _reactions(raw.get("reactions")),
359
+ }
360
+
361
+
362
+ def _fetch_github_comments(
363
+ client: Any,
364
+ url: str | None,
365
+ headers: dict[str, str],
366
+ *,
367
+ max_comments: int,
368
+ max_comment_chars: int,
369
+ kind: str = "comment",
370
+ ) -> list[dict[str, Any]]:
371
+ if not url or max_comments <= 0:
372
+ return []
373
+ raw_comments = _paginated_json(client, url, headers, limit=max_comments)
374
+ return [
375
+ _normalize_github_comment(
376
+ comment, max_comment_chars=max_comment_chars, kind=kind
377
+ )
378
+ for comment in raw_comments
379
+ ]
380
+
381
+
382
+ def _normalize_github_issue(
383
+ item: dict[str, Any],
384
+ comments: list[dict[str, Any]],
385
+ *,
386
+ max_body_chars: int,
387
+ ) -> dict[str, Any]:
388
+ number = int(item["number"])
389
+ return {
390
+ "id": f"github_issue#{number}",
391
+ "source": "github_issue",
392
+ "number": number,
393
+ "url": item.get("html_url"),
394
+ "title": item.get("title") or "",
395
+ "body": _truncate_text(item.get("body"), max_body_chars),
396
+ "labels": _labels(item.get("labels") or []),
397
+ "author": _user_login(item.get("user")),
398
+ "state": item.get("state"),
399
+ "created_at": item.get("created_at"),
400
+ "updated_at": item.get("updated_at"),
401
+ "closed_at": item.get("closed_at"),
402
+ "engagement": {
403
+ "comments_count": item.get("comments") or len(comments),
404
+ "reactions": _reactions(item.get("reactions")),
405
+ },
406
+ "comments": comments,
407
+ "metadata": {
408
+ "state_reason": item.get("state_reason"),
409
+ },
410
+ }
411
+
412
+
413
+ def _normalize_github_pr(
414
+ item: dict[str, Any],
415
+ pr_details: dict[str, Any],
416
+ comments: list[dict[str, Any]],
417
+ review_comments: list[dict[str, Any]],
418
+ reviews: list[dict[str, Any]],
419
+ *,
420
+ max_body_chars: int,
421
+ ) -> dict[str, Any]:
422
+ number = int(item["number"])
423
+ combined_comments = [*comments, *reviews, *review_comments]
424
+ base = pr_details.get("base") or {}
425
+ head = pr_details.get("head") or {}
426
+ return {
427
+ "id": f"github_pr#{number}",
428
+ "source": "github_pr",
429
+ "number": number,
430
+ "url": pr_details.get("html_url") or item.get("html_url"),
431
+ "title": pr_details.get("title") or item.get("title") or "",
432
+ "body": _truncate_text(
433
+ pr_details.get("body") or item.get("body"), max_body_chars
434
+ ),
435
+ "labels": _labels(item.get("labels") or []),
436
+ "author": _user_login(pr_details.get("user") or item.get("user")),
437
+ "state": pr_details.get("state") or item.get("state"),
438
+ "created_at": pr_details.get("created_at") or item.get("created_at"),
439
+ "updated_at": pr_details.get("updated_at") or item.get("updated_at"),
440
+ "closed_at": pr_details.get("closed_at") or item.get("closed_at"),
441
+ "engagement": {
442
+ "comments_count": item.get("comments") or len(comments),
443
+ "review_comments_count": pr_details.get("review_comments"),
444
+ "reactions": _reactions(item.get("reactions")),
445
+ },
446
+ "comments": combined_comments,
447
+ "metadata": {
448
+ "draft": pr_details.get("draft"),
449
+ "mergeable_state": pr_details.get("mergeable_state"),
450
+ "base": base.get("ref"),
451
+ "base_sha": base.get("sha"),
452
+ "head": head.get("ref"),
453
+ "head_sha": head.get("sha"),
454
+ "patch_url": pr_details.get("patch_url"),
455
+ "diff_url": pr_details.get("diff_url"),
456
+ "commits": pr_details.get("commits"),
457
+ "additions": pr_details.get("additions"),
458
+ "deletions": pr_details.get("deletions"),
459
+ "changed_files": pr_details.get("changed_files"),
460
+ },
461
+ }
462
+
463
+
464
+ def collect_github_sources(
465
+ repo: str,
466
+ *,
467
+ token: str | None = None,
468
+ max_comments: int = DEFAULT_MAX_COMMENTS,
469
+ max_review_comments: int = DEFAULT_MAX_REVIEW_COMMENTS,
470
+ max_body_chars: int = DEFAULT_MAX_BODY_CHARS,
471
+ max_comment_chars: int = DEFAULT_MAX_COMMENT_CHARS,
472
+ exclude_labels: list[str] | None = None,
473
+ client: Any | None = None,
474
+ ) -> list[dict[str, Any]]:
475
+ headers = _github_headers(token)
476
+ excluded_labels = _github_issue_labels(exclude_labels or [])
477
+ close_client = client is None
478
+ if client is None:
479
+ client = httpx.Client(timeout=30.0, follow_redirects=True)
480
+
481
+ try:
482
+ issues_url = f"{GITHUB_API}/repos/{repo}/issues"
483
+ try:
484
+ raw_items = _paginated_json(
485
+ client,
486
+ issues_url,
487
+ headers,
488
+ params={"state": "open", "sort": "updated", "direction": "desc"},
489
+ )
490
+ except httpx.HTTPStatusError as exc:
491
+ if _is_github_rate_limit_error(exc):
492
+ _log_github_rate_limit(exc, "listing open GitHub issues and PRs")
493
+ return []
494
+ raise
495
+
496
+ records: list[dict[str, Any]] = []
497
+ for item in raw_items:
498
+ if _has_excluded_label(item.get("labels") or [], excluded_labels):
499
+ logger.debug(
500
+ "Skipping GitHub item #%s with excluded label",
501
+ item.get("number"),
502
+ )
503
+ continue
504
+ try:
505
+ issue_comments = _fetch_github_comments(
506
+ client,
507
+ item.get("comments_url"),
508
+ headers,
509
+ max_comments=max_comments,
510
+ max_comment_chars=max_comment_chars,
511
+ )
512
+
513
+ if "pull_request" not in item:
514
+ records.append(
515
+ _normalize_github_issue(
516
+ item, issue_comments, max_body_chars=max_body_chars
517
+ )
518
+ )
519
+ continue
520
+
521
+ number = item["number"]
522
+ pr_url = f"{GITHUB_API}/repos/{repo}/pulls/{number}"
523
+ pr_details = _get_json(client, pr_url, headers)
524
+ review_comments = _fetch_github_comments(
525
+ client,
526
+ f"{pr_url}/comments",
527
+ headers,
528
+ max_comments=max_review_comments,
529
+ max_comment_chars=max_comment_chars,
530
+ kind="review_comment",
531
+ )
532
+ raw_reviews = _paginated_json(
533
+ client,
534
+ f"{pr_url}/reviews",
535
+ headers,
536
+ limit=max_review_comments,
537
+ )
538
+ reviews = [
539
+ _normalize_github_comment(
540
+ review, max_comment_chars=max_comment_chars, kind="review"
541
+ )
542
+ for review in raw_reviews
543
+ if review.get("body")
544
+ ]
545
+ records.append(
546
+ _normalize_github_pr(
547
+ item,
548
+ pr_details,
549
+ issue_comments,
550
+ review_comments,
551
+ reviews,
552
+ max_body_chars=max_body_chars,
553
+ )
554
+ )
555
+ except httpx.HTTPStatusError as exc:
556
+ if _is_github_rate_limit_error(exc):
557
+ _log_github_rate_limit(
558
+ exc,
559
+ f"collecting GitHub details for item #{item.get('number')}",
560
+ )
561
+ break
562
+ raise
563
+ return records
564
+ finally:
565
+ if close_client and hasattr(client, "close"):
566
+ client.close()
567
+
568
+
569
+ def _hf_comment_event(event: Any, max_comment_chars: int) -> dict[str, Any] | None:
570
+ content = getattr(event, "content", None)
571
+ if content is None:
572
+ return None
573
+ if getattr(event, "hidden", False):
574
+ return None
575
+ return {
576
+ "kind": getattr(event, "type", "comment") or "comment",
577
+ "author": getattr(event, "author", None),
578
+ "created_at": _iso(getattr(event, "created_at", None)),
579
+ "updated_at": None,
580
+ "url": None,
581
+ "state": None,
582
+ "body": _truncate_text(content, max_comment_chars),
583
+ "reactions": {},
584
+ }
585
+
586
+
587
+ def normalize_hf_discussion(
588
+ discussion: Any,
589
+ details: Any,
590
+ *,
591
+ max_comments: int = DEFAULT_MAX_COMMENTS,
592
+ max_body_chars: int = DEFAULT_MAX_BODY_CHARS,
593
+ max_comment_chars: int = DEFAULT_MAX_COMMENT_CHARS,
594
+ ) -> dict[str, Any]:
595
+ events = list(getattr(details, "events", []) or [])
596
+ visible_comment_events = [
597
+ event
598
+ for event in events
599
+ if getattr(event, "content", None) is not None
600
+ and not getattr(event, "hidden", False)
601
+ ]
602
+ first_comment = visible_comment_events[0] if visible_comment_events else None
603
+ comments = [
604
+ comment
605
+ for comment in (
606
+ _hf_comment_event(event, max_comment_chars=max_comment_chars)
607
+ for event in visible_comment_events[1 : max_comments + 1]
608
+ )
609
+ if comment is not None
610
+ ]
611
+ number = int(getattr(discussion, "num", getattr(details, "num", 0)))
612
+ repo_id = getattr(
613
+ discussion, "repo_id", getattr(details, "repo_id", DEFAULT_HF_SPACE)
614
+ )
615
+ url = f"https://huggingface.co/spaces/{repo_id}/discussions/{number}"
616
+
617
+ return {
618
+ "id": f"hf_discussion#{number}",
619
+ "source": "hf_discussion",
620
+ "number": number,
621
+ "url": url,
622
+ "title": getattr(details, "title", getattr(discussion, "title", "")) or "",
623
+ "body": _truncate_text(
624
+ getattr(first_comment, "content", "") if first_comment else "",
625
+ max_body_chars,
626
+ ),
627
+ "labels": [],
628
+ "author": getattr(discussion, "author", getattr(details, "author", None)),
629
+ "state": getattr(details, "status", getattr(discussion, "status", None)),
630
+ "created_at": _iso(getattr(discussion, "created_at", None)),
631
+ "updated_at": None,
632
+ "closed_at": None,
633
+ "engagement": {
634
+ "comments_count": len(visible_comment_events),
635
+ "reactions": {},
636
+ },
637
+ "comments": comments,
638
+ "metadata": {
639
+ "repo_id": repo_id,
640
+ "repo_type": getattr(discussion, "repo_type", "space"),
641
+ "events_count": len(events),
642
+ },
643
+ }
644
+
645
+
646
+ def collect_hf_discussions(
647
+ space_id: str,
648
+ *,
649
+ token: str | None = None,
650
+ max_comments: int = DEFAULT_MAX_COMMENTS,
651
+ max_body_chars: int = DEFAULT_MAX_BODY_CHARS,
652
+ max_comment_chars: int = DEFAULT_MAX_COMMENT_CHARS,
653
+ api: Any | None = None,
654
+ ) -> list[dict[str, Any]]:
655
+ if api is None:
656
+ from huggingface_hub import HfApi
657
+
658
+ api = HfApi()
659
+
660
+ records: list[dict[str, Any]] = []
661
+ discussions = api.get_repo_discussions(
662
+ repo_id=space_id,
663
+ repo_type="space",
664
+ discussion_type="discussion",
665
+ discussion_status="open",
666
+ token=token,
667
+ )
668
+ for discussion in discussions:
669
+ details = api.get_discussion_details(
670
+ repo_id=space_id,
671
+ repo_type="space",
672
+ discussion_num=discussion.num,
673
+ token=token,
674
+ )
675
+ records.append(
676
+ normalize_hf_discussion(
677
+ discussion,
678
+ details,
679
+ max_comments=max_comments,
680
+ max_body_chars=max_body_chars,
681
+ max_comment_chars=max_comment_chars,
682
+ )
683
+ )
684
+ return records
685
+
686
+
687
+ def collect_sources(
688
+ github_repo: str,
689
+ hf_space: str,
690
+ *,
691
+ github_token: str | None = None,
692
+ hf_token: str | None = None,
693
+ max_comments: int = DEFAULT_MAX_COMMENTS,
694
+ max_review_comments: int = DEFAULT_MAX_REVIEW_COMMENTS,
695
+ max_body_chars: int = DEFAULT_MAX_BODY_CHARS,
696
+ max_comment_chars: int = DEFAULT_MAX_COMMENT_CHARS,
697
+ github_exclude_labels: list[str] | None = None,
698
+ ) -> list[dict[str, Any]]:
699
+ github_records = collect_github_sources(
700
+ github_repo,
701
+ token=github_token,
702
+ max_comments=max_comments,
703
+ max_review_comments=max_review_comments,
704
+ max_body_chars=max_body_chars,
705
+ max_comment_chars=max_comment_chars,
706
+ exclude_labels=github_exclude_labels,
707
+ )
708
+ hf_records = collect_hf_discussions(
709
+ hf_space,
710
+ token=hf_token,
711
+ max_comments=max_comments,
712
+ max_body_chars=max_body_chars,
713
+ max_comment_chars=max_comment_chars,
714
+ )
715
+ return [*github_records, *hf_records]
716
+
717
+
718
+ def _git(
719
+ args: list[str],
720
+ *,
721
+ repo_root: Path = PROJECT_ROOT,
722
+ input_text: str | None = None,
723
+ check: bool = True,
724
+ ) -> subprocess.CompletedProcess[str]:
725
+ return subprocess.run(
726
+ ["git", "-C", str(repo_root), *args],
727
+ input=input_text,
728
+ text=True,
729
+ capture_output=True,
730
+ check=check,
731
+ )
732
+
733
+
734
+ def _git_ref_sha(ref: str, *, repo_root: Path = PROJECT_ROOT) -> str:
735
+ return _git(["rev-parse", "--verify", ref], repo_root=repo_root).stdout.strip()
736
+
737
+
738
+ def _git_log_entries(
739
+ ref: str,
740
+ *,
741
+ repo_root: Path = PROJECT_ROOT,
742
+ max_commits: int = DEFAULT_RESOLUTION_LOG_COMMITS,
743
+ ) -> list[dict[str, str]]:
744
+ fmt = "%H%x1f%s%x1f%b%x1e"
745
+ output = _git(
746
+ ["log", f"--max-count={max_commits}", f"--format={fmt}", ref],
747
+ repo_root=repo_root,
748
+ ).stdout
749
+ entries: list[dict[str, str]] = []
750
+ for raw in output.strip("\x1e\n").split("\x1e"):
751
+ if not raw.strip():
752
+ continue
753
+ parts = raw.strip("\n").split("\x1f", 2)
754
+ if len(parts) != 3:
755
+ continue
756
+ commit, subject, body = parts
757
+ entries.append({"commit": commit.strip(), "subject": subject, "body": body})
758
+ return entries
759
+
760
+
761
+ def _git_patch_ids_for_ref(
762
+ ref: str,
763
+ *,
764
+ repo_root: Path = PROJECT_ROOT,
765
+ max_commits: int = DEFAULT_RESOLUTION_LOG_COMMITS,
766
+ ) -> dict[str, str]:
767
+ log = _git(
768
+ ["log", "--patch", f"--max-count={max_commits}", "--format=medium", ref],
769
+ repo_root=repo_root,
770
+ )
771
+ patch_ids = _git(
772
+ ["patch-id", "--stable"],
773
+ repo_root=repo_root,
774
+ input_text=log.stdout,
775
+ check=False,
776
+ )
777
+ out: dict[str, str] = {}
778
+ for line in patch_ids.stdout.splitlines():
779
+ parts = line.split()
780
+ if len(parts) >= 2:
781
+ out[parts[0]] = parts[1]
782
+ return out
783
+
784
+
785
+ def _patch_id_for_text(
786
+ patch_text: str,
787
+ *,
788
+ repo_root: Path = PROJECT_ROOT,
789
+ ) -> str | None:
790
+ result = _git(
791
+ ["patch-id", "--stable"],
792
+ repo_root=repo_root,
793
+ input_text=patch_text,
794
+ check=False,
795
+ )
796
+ for line in result.stdout.splitlines():
797
+ parts = line.split()
798
+ if parts:
799
+ return parts[0]
800
+ return None
801
+
802
+
803
+ def _record_text_for_refs(record: dict[str, Any]) -> str:
804
+ pieces = [
805
+ str(record.get("id") or ""),
806
+ str(record.get("url") or ""),
807
+ str(record.get("title") or ""),
808
+ str(record.get("body") or ""),
809
+ ]
810
+ for comment in record.get("comments") or []:
811
+ pieces.append(str(comment.get("url") or ""))
812
+ pieces.append(str(comment.get("body") or ""))
813
+ return "\n".join(pieces)
814
+
815
+
816
+ def _repo_regex(repo: str) -> str:
817
+ return re.escape(repo)
818
+
819
+
820
+ def _commit_text(commit: dict[str, str]) -> str:
821
+ return f"{commit.get('subject', '')}\n{commit.get('body', '')}"
822
+
823
+
824
+ def _commit_evidence(
825
+ commit: dict[str, str],
826
+ detail: str,
827
+ ) -> dict[str, str]:
828
+ return {
829
+ "kind": "commit",
830
+ "commit": commit.get("commit", "")[:12],
831
+ "subject": commit.get("subject", ""),
832
+ "detail": detail,
833
+ }
834
+
835
+
836
+ def _record_evidence(record: dict[str, Any], detail: str) -> dict[str, str]:
837
+ return {
838
+ "kind": "source_link",
839
+ "source_id": str(record.get("id") or ""),
840
+ "title": str(record.get("title") or ""),
841
+ "detail": detail,
842
+ }
843
+
844
+
845
+ def _commit_mentions_pr(
846
+ text: str,
847
+ pr_number: int,
848
+ *,
849
+ github_repo: str,
850
+ ) -> bool:
851
+ repo = _repo_regex(github_repo)
852
+ patterns = [
853
+ rf"\(#{pr_number}\)",
854
+ rf"\bPR\s*#{pr_number}\b",
855
+ rf"\bpull\s+request\s*#{pr_number}\b",
856
+ rf"\bpull\s*/\s*{pr_number}\b",
857
+ rf"github\.com[:/]{repo}/pull/{pr_number}\b",
858
+ ]
859
+ return any(re.search(pattern, text, flags=re.IGNORECASE) for pattern in patterns)
860
+
861
+
862
+ def _commit_closes_record(
863
+ text: str,
864
+ record: dict[str, Any],
865
+ *,
866
+ github_repo: str,
867
+ ) -> bool:
868
+ source = record.get("source")
869
+ number = record.get("number")
870
+ if not isinstance(number, int):
871
+ return False
872
+ close = r"(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)"
873
+ repo = _repo_regex(github_repo)
874
+ if source == "github_issue":
875
+ patterns = [
876
+ rf"\b{close}\s+(?:{repo})?#\s*{number}\b",
877
+ rf"\b{close}\s+https://github\.com[:/]{repo}/issues/{number}\b",
878
+ ]
879
+ return any(
880
+ re.search(pattern, text, flags=re.IGNORECASE) for pattern in patterns
881
+ )
882
+ if source == "hf_discussion":
883
+ url = re.escape(str(record.get("url") or ""))
884
+ return bool(url and re.search(rf"\b{close}\b.*{url}", text, re.IGNORECASE))
885
+ return False
886
+
887
+
888
+ def _linked_pr_numbers(text: str, *, github_repo: str) -> set[int]:
889
+ repo = _repo_regex(github_repo)
890
+ verb = r"(?:fix(?:e[sd])?|resolve[sd]?|close[sd]?|address(?:es|ed)?|implement(?:s|ed)?)"
891
+ patterns = [
892
+ rf"\b{verb}\s+(?:by|in|via|with)?\s*github\.com[:/]{repo}/pull/(\d+)\b",
893
+ rf"\b{verb}\s+(?:by|in|via|with)?\s*PR\s*#(\d+)\b",
894
+ rf"\b{verb}\s+(?:by|in|via|with)?\s*pull\s+request\s*#(\d+)\b",
895
+ ]
896
+ numbers: set[int] = set()
897
+ for pattern in patterns:
898
+ for match in re.finditer(pattern, text, flags=re.IGNORECASE):
899
+ numbers.add(int(match.group(1)))
900
+ return numbers
901
+
902
+
903
+ def _new_resolution(checked_ref: str, checked_sha: str) -> dict[str, Any]:
904
+ return {
905
+ "checked_ref": checked_ref,
906
+ "checked_sha": checked_sha,
907
+ "status": "unresolved",
908
+ "can_close": False,
909
+ "confidence": 0.0,
910
+ "reasons": [],
911
+ "evidence": [],
912
+ }
913
+
914
+
915
+ def _mark_resolution(
916
+ resolution: dict[str, Any],
917
+ *,
918
+ status: str,
919
+ confidence: float,
920
+ reason: str,
921
+ evidence: list[dict[str, Any]],
922
+ ) -> None:
923
+ if confidence < float(resolution.get("confidence") or 0):
924
+ return
925
+ resolution["status"] = status
926
+ resolution["can_close"] = status in {"resolved", "likely_resolved"}
927
+ resolution["confidence"] = confidence
928
+ resolution["reasons"] = [reason]
929
+ resolution["evidence"] = evidence
930
+
931
+
932
+ def apply_resolution_checks(
933
+ records: list[dict[str, Any]],
934
+ *,
935
+ checked_ref: str,
936
+ checked_sha: str,
937
+ commits: list[dict[str, str]],
938
+ github_repo: str,
939
+ pr_patch_matches: dict[int, dict[str, Any]] | None = None,
940
+ ) -> list[dict[str, Any]]:
941
+ pr_patch_matches = pr_patch_matches or {}
942
+ resolved_prs: dict[int, list[dict[str, Any]]] = {}
943
+ direct_closures: dict[str, list[dict[str, Any]]] = {}
944
+
945
+ for commit in commits:
946
+ text = _commit_text(commit)
947
+ for record in records:
948
+ source_id = str(record.get("id") or "")
949
+ number = record.get("number")
950
+ if record.get("source") == "github_pr" and isinstance(number, int):
951
+ if _commit_mentions_pr(text, number, github_repo=github_repo):
952
+ resolved_prs.setdefault(number, []).append(
953
+ _commit_evidence(
954
+ commit, f"main history references PR #{number}"
955
+ )
956
+ )
957
+ elif _commit_closes_record(text, record, github_repo=github_repo):
958
+ direct_closures.setdefault(source_id, []).append(
959
+ _commit_evidence(
960
+ commit, "main history contains a closing reference"
961
+ )
962
+ )
963
+
964
+ for pr_number, evidence in pr_patch_matches.items():
965
+ resolved_prs.setdefault(pr_number, []).append(evidence)
966
+
967
+ checked: list[dict[str, Any]] = []
968
+ for record in records:
969
+ out = dict(record)
970
+ resolution = _new_resolution(checked_ref, checked_sha)
971
+ source_id = str(record.get("id") or "")
972
+ number = record.get("number")
973
+
974
+ if record.get("source") == "github_pr" and isinstance(number, int):
975
+ if evidences := resolved_prs.get(number):
976
+ has_patch = any(ev.get("kind") == "patch_id" for ev in evidences)
977
+ _mark_resolution(
978
+ resolution,
979
+ status="resolved",
980
+ confidence=0.98 if has_patch else 0.95,
981
+ reason=f"PR #{number} appears to already be present on {checked_ref}.",
982
+ evidence=evidences,
983
+ )
984
+ elif evidences := direct_closures.get(source_id):
985
+ _mark_resolution(
986
+ resolution,
987
+ status="likely_resolved",
988
+ confidence=0.9,
989
+ reason=f"{source_id} has a closing reference in {checked_ref} history.",
990
+ evidence=evidences,
991
+ )
992
+ else:
993
+ linked = sorted(
994
+ _linked_pr_numbers(
995
+ _record_text_for_refs(record), github_repo=github_repo
996
+ )
997
+ & set(resolved_prs)
998
+ )
999
+ if linked:
1000
+ evidences = [
1001
+ _record_evidence(
1002
+ record,
1003
+ "source text links to PR(s) already present on main: "
1004
+ + ", ".join(f"#{num}" for num in linked),
1005
+ )
1006
+ ]
1007
+ for pr_number in linked:
1008
+ evidences.extend(resolved_prs[pr_number])
1009
+ _mark_resolution(
1010
+ resolution,
1011
+ status="likely_resolved",
1012
+ confidence=0.85,
1013
+ reason=(
1014
+ f"{source_id} links to PR(s) already present on {checked_ref}: "
1015
+ + ", ".join(f"#{num}" for num in linked)
1016
+ ),
1017
+ evidence=evidences,
1018
+ )
1019
+
1020
+ out["resolution"] = resolution
1021
+ checked.append(out)
1022
+ return checked
1023
+
1024
+
1025
+ def _fetch_pr_patch_matches(
1026
+ records: list[dict[str, Any]],
1027
+ *,
1028
+ github_token: str | None,
1029
+ main_patch_ids: dict[str, str],
1030
+ client: Any | None = None,
1031
+ ) -> dict[int, dict[str, Any]]:
1032
+ if not main_patch_ids:
1033
+ return {}
1034
+
1035
+ headers = _github_headers(github_token)
1036
+ headers["Accept"] = "application/vnd.github.patch"
1037
+ close_client = client is None
1038
+ if client is None:
1039
+ client = httpx.Client(timeout=30.0, follow_redirects=True)
1040
+
1041
+ matches: dict[int, dict[str, Any]] = {}
1042
+ try:
1043
+ for record in records:
1044
+ if record.get("source") != "github_pr":
1045
+ continue
1046
+ number = record.get("number")
1047
+ patch_url = (record.get("metadata") or {}).get("patch_url")
1048
+ if not isinstance(number, int) or not patch_url:
1049
+ continue
1050
+ try:
1051
+ response = client.get(patch_url, headers=headers)
1052
+ _raise_for_status(response)
1053
+ patch_id = _patch_id_for_text(response.text)
1054
+ except httpx.HTTPStatusError as exc:
1055
+ if _is_github_rate_limit_error(exc):
1056
+ _log_github_rate_limit(
1057
+ exc,
1058
+ f"fetching PR patch for #{number}",
1059
+ )
1060
+ break
1061
+ logger.debug("patch-id check failed for PR #%s: %s", number, exc)
1062
+ continue
1063
+ except Exception as exc:
1064
+ logger.debug("patch-id check failed for PR #%s: %s", number, exc)
1065
+ continue
1066
+ if patch_id and patch_id in main_patch_ids:
1067
+ matches[number] = {
1068
+ "kind": "patch_id",
1069
+ "patch_id": patch_id,
1070
+ "commit": main_patch_ids[patch_id][:12],
1071
+ "detail": "PR patch-id matches a commit already in main history",
1072
+ }
1073
+ finally:
1074
+ if close_client and hasattr(client, "close"):
1075
+ client.close()
1076
+ return matches
1077
+
1078
+
1079
+ def add_resolution_checks(
1080
+ records: list[dict[str, Any]],
1081
+ *,
1082
+ checked_ref: str = DEFAULT_RESOLUTION_REF,
1083
+ github_repo: str = DEFAULT_GITHUB_REPO,
1084
+ github_token: str | None = None,
1085
+ max_commits: int = DEFAULT_RESOLUTION_LOG_COMMITS,
1086
+ include_patch_check: bool = True,
1087
+ ) -> list[dict[str, Any]]:
1088
+ checked_sha = _git_ref_sha(checked_ref)
1089
+ commits = _git_log_entries(checked_ref, max_commits=max_commits)
1090
+ pr_patch_matches: dict[int, dict[str, Any]] = {}
1091
+ if include_patch_check:
1092
+ main_patch_ids = _git_patch_ids_for_ref(checked_ref, max_commits=max_commits)
1093
+ pr_patch_matches = _fetch_pr_patch_matches(
1094
+ records,
1095
+ github_token=github_token,
1096
+ main_patch_ids=main_patch_ids,
1097
+ )
1098
+ return apply_resolution_checks(
1099
+ records,
1100
+ checked_ref=checked_ref,
1101
+ checked_sha=checked_sha,
1102
+ commits=commits,
1103
+ github_repo=github_repo,
1104
+ pr_patch_matches=pr_patch_matches,
1105
+ )
1106
+
1107
+
1108
+ def _record_for_llm(record: dict[str, Any]) -> dict[str, Any]:
1109
+ return {
1110
+ "id": record.get("id"),
1111
+ "source": record.get("source"),
1112
+ "number": record.get("number"),
1113
+ "url": record.get("url"),
1114
+ "title": record.get("title"),
1115
+ "body": record.get("body"),
1116
+ "labels": record.get("labels") or [],
1117
+ "author": record.get("author"),
1118
+ "state": record.get("state"),
1119
+ "created_at": record.get("created_at"),
1120
+ "updated_at": record.get("updated_at"),
1121
+ "engagement": record.get("engagement") or {},
1122
+ "metadata": record.get("metadata") or {},
1123
+ "resolution": record.get("resolution") or {},
1124
+ "comments": record.get("comments") or [],
1125
+ }
1126
+
1127
+
1128
+ def _classification_messages(batch: list[dict[str, Any]]) -> list[dict[str, str]]:
1129
+ schema = {
1130
+ "items": [
1131
+ {
1132
+ "id": "source id from input",
1133
+ "category": "feature | fix | other",
1134
+ "impact_score": "integer 1-5",
1135
+ "effort_score": "integer 1-5, where 1 is easiest",
1136
+ "confidence": "number 0-1",
1137
+ "user_problem": "one sentence",
1138
+ "recommended_action": "one sentence",
1139
+ "resolved_in_main": "yes | no | uncertain",
1140
+ "close_recommendation": "if resolved, why it can be closed",
1141
+ "evidence": ["short evidence strings tied to source content"],
1142
+ "related_source_ids": ["optional related source ids"],
1143
+ }
1144
+ ]
1145
+ }
1146
+ return [
1147
+ {"role": "system", "content": PM_SYSTEM_PROMPT},
1148
+ {
1149
+ "role": "user",
1150
+ "content": (
1151
+ "Classify each backlog item. Use only the provided evidence. "
1152
+ "Pay special attention to each item's resolution field, which "
1153
+ "contains deterministic checks against the local main commit. "
1154
+ "Return JSON matching this schema:\n"
1155
+ f"{json.dumps(schema, indent=2)}\n\n"
1156
+ "Backlog items:\n"
1157
+ f"{json.dumps(batch, ensure_ascii=False, indent=2)}"
1158
+ ),
1159
+ },
1160
+ ]
1161
+
1162
+
1163
+ def _synthesis_messages(
1164
+ records: list[dict[str, Any]],
1165
+ classifications: list[dict[str, Any]],
1166
+ ) -> list[dict[str, str]]:
1167
+ source_index = [
1168
+ {
1169
+ "id": record.get("id"),
1170
+ "source": record.get("source"),
1171
+ "url": record.get("url"),
1172
+ "title": record.get("title"),
1173
+ "labels": record.get("labels") or [],
1174
+ "metadata": record.get("metadata") or {},
1175
+ "resolution": record.get("resolution") or {},
1176
+ }
1177
+ for record in records
1178
+ ]
1179
+ schema = {
1180
+ "summary": "short executive summary",
1181
+ "highest_impact_next": [
1182
+ {
1183
+ "rank": 1,
1184
+ "title": "recommendation title",
1185
+ "category": "feature | fix",
1186
+ "recommendation": "what to implement/review next",
1187
+ "impact_score": "integer 1-5",
1188
+ "effort_score": "integer 1-5, where 1 is easiest",
1189
+ "confidence": "number 0-1",
1190
+ "source_ids": ["source ids"],
1191
+ "source_urls": ["source URLs"],
1192
+ "rationale": "why this is high impact",
1193
+ "next_action": "concrete next action",
1194
+ }
1195
+ ],
1196
+ "features": [],
1197
+ "fixes": [],
1198
+ "can_be_closed": [
1199
+ {
1200
+ "title": "item title",
1201
+ "source_ids": ["source ids"],
1202
+ "source_urls": ["source URLs"],
1203
+ "reason": "why main already resolves it",
1204
+ "confidence": "number 0-1",
1205
+ "close_action": "specific closure action",
1206
+ }
1207
+ ],
1208
+ "other": [],
1209
+ "clusters": [
1210
+ {
1211
+ "title": "cluster title",
1212
+ "category": "feature | fix | other",
1213
+ "source_ids": ["source ids"],
1214
+ "summary": "shared user problem",
1215
+ }
1216
+ ],
1217
+ }
1218
+ return [
1219
+ {"role": "system", "content": PM_SYSTEM_PROMPT},
1220
+ {
1221
+ "role": "user",
1222
+ "content": (
1223
+ "Synthesize the item-level classifications into a ranked PM "
1224
+ "implementation plan. Cluster duplicates and related requests. "
1225
+ "Keep features and fixes separate. If an open PR addresses a "
1226
+ "high-impact item, recommend review/merge/fix-forward instead "
1227
+ "of reimplementation unless its resolution field says it is "
1228
+ "already present on main. Create can_be_closed entries only "
1229
+ "for items with strong resolved-in-main evidence. "
1230
+ "Keep the output concise: at most 8 highest_impact_next "
1231
+ "items, 12 features, 12 fixes, 12 can_be_closed items, "
1232
+ "6 other items, and 12 clusters. Keep strings short enough "
1233
+ "for a PM scan. If the output budget is tight, omit "
1234
+ "lower-priority entries but return a complete JSON object. "
1235
+ "Return JSON matching this schema:\n"
1236
+ f"{json.dumps(schema, indent=2)}\n\n"
1237
+ "Source index:\n"
1238
+ f"{json.dumps(source_index, ensure_ascii=False, indent=2)}\n\n"
1239
+ "Item classifications:\n"
1240
+ f"{json.dumps(classifications, ensure_ascii=False, indent=2)}"
1241
+ ),
1242
+ },
1243
+ ]
1244
+
1245
+
1246
+ def _extract_json_object(text: str) -> Any:
1247
+ try:
1248
+ return json.loads(text)
1249
+ except json.JSONDecodeError:
1250
+ pass
1251
+
1252
+ fenced = re.search(r"```(?:json)?\s*(.*?)```", text, flags=re.DOTALL | re.I)
1253
+ if fenced:
1254
+ try:
1255
+ return json.loads(fenced.group(1).strip())
1256
+ except json.JSONDecodeError:
1257
+ pass
1258
+
1259
+ start = text.find("{")
1260
+ end = text.rfind("}")
1261
+ if start != -1 and end != -1 and end > start:
1262
+ try:
1263
+ return json.loads(text[start : end + 1])
1264
+ except json.JSONDecodeError:
1265
+ pass
1266
+
1267
+ raise ValueError("LLM response did not contain valid JSON")
1268
+
1269
+
1270
+ def _response_content(response: Any) -> str:
1271
+ if isinstance(response, dict):
1272
+ choice = response["choices"][0]
1273
+ message = choice.get("message") or {}
1274
+ return message.get("content") or ""
1275
+ choice = response.choices[0]
1276
+ return choice.message.content or ""
1277
+
1278
+
1279
+ def _temperature_for_params(llm_params: dict[str, Any]) -> float:
1280
+ # Anthropic requires temperature=1 when adaptive/extended thinking is active.
1281
+ if llm_params.get("thinking") or llm_params.get("output_config"):
1282
+ return 1.0
1283
+ return 0.2
1284
+
1285
+
1286
+ async def _call_json_llm(
1287
+ messages: list[dict[str, str]],
1288
+ llm_params: dict[str, Any],
1289
+ *,
1290
+ completion_func: Callable[..., Any] | None = None,
1291
+ max_completion_tokens: int = DEFAULT_MAX_OUTPUT_TOKENS,
1292
+ retries: int = 1,
1293
+ ) -> Any:
1294
+ if completion_func is None:
1295
+ from litellm import acompletion
1296
+
1297
+ completion_func = acompletion
1298
+
1299
+ attempt_messages = list(messages)
1300
+ last_error: Exception | None = None
1301
+ for attempt in range(retries + 1):
1302
+ response = await completion_func(
1303
+ messages=attempt_messages,
1304
+ max_completion_tokens=max_completion_tokens,
1305
+ temperature=_temperature_for_params(llm_params),
1306
+ **llm_params,
1307
+ )
1308
+ content = _response_content(response)
1309
+ try:
1310
+ return _extract_json_object(content)
1311
+ except ValueError as exc:
1312
+ last_error = exc
1313
+ if attempt >= retries:
1314
+ break
1315
+ attempt_messages = [
1316
+ *messages,
1317
+ {"role": "assistant", "content": _truncate_text(content, 2000)},
1318
+ {
1319
+ "role": "user",
1320
+ "content": (
1321
+ "The previous response was not valid JSON. Return the "
1322
+ "same answer again as a single valid JSON object only."
1323
+ ),
1324
+ },
1325
+ ]
1326
+ raise ValueError("LLM failed to return valid JSON after retry") from last_error
1327
+
1328
+
1329
+ def _default_classification(record: dict[str, Any]) -> dict[str, Any]:
1330
+ return {
1331
+ "id": record.get("id"),
1332
+ "category": "other",
1333
+ "impact_score": 1,
1334
+ "effort_score": 3,
1335
+ "confidence": 0,
1336
+ "user_problem": "No model classification returned.",
1337
+ "recommended_action": "Triage manually.",
1338
+ "resolved_in_main": "uncertain",
1339
+ "close_recommendation": "",
1340
+ "evidence": [],
1341
+ "related_source_ids": [],
1342
+ }
1343
+
1344
+
1345
+ def _normalize_classifications(
1346
+ payload: Any, batch: list[dict[str, Any]]
1347
+ ) -> list[dict[str, Any]]:
1348
+ items = payload.get("items") if isinstance(payload, dict) else None
1349
+ if not isinstance(items, list):
1350
+ items = []
1351
+ by_id = {
1352
+ str(item.get("id")): item
1353
+ for item in items
1354
+ if isinstance(item, dict) and item.get("id") is not None
1355
+ }
1356
+ normalized: list[dict[str, Any]] = []
1357
+ for record in batch:
1358
+ item = dict(by_id.get(str(record.get("id"))) or _default_classification(record))
1359
+ item["id"] = record.get("id")
1360
+ item.setdefault("category", "other")
1361
+ item.setdefault("impact_score", 1)
1362
+ item.setdefault("effort_score", 3)
1363
+ item.setdefault("confidence", 0)
1364
+ item.setdefault("resolved_in_main", "uncertain")
1365
+ item.setdefault("close_recommendation", "")
1366
+ item.setdefault("evidence", [])
1367
+ item.setdefault("related_source_ids", [])
1368
+ item.setdefault("source_url", record.get("url"))
1369
+ item.setdefault("source_title", record.get("title"))
1370
+ normalized.append(item)
1371
+ return normalized
1372
+
1373
+
1374
+ async def classify_records(
1375
+ records: list[dict[str, Any]],
1376
+ llm_params: dict[str, Any],
1377
+ *,
1378
+ batch_size: int = DEFAULT_BATCH_SIZE,
1379
+ max_completion_tokens: int = DEFAULT_MAX_OUTPUT_TOKENS,
1380
+ completion_func: Callable[..., Any] | None = None,
1381
+ ) -> list[dict[str, Any]]:
1382
+ classifications: list[dict[str, Any]] = []
1383
+ compact_records = [_record_for_llm(record) for record in records]
1384
+ for start in range(0, len(compact_records), max(1, batch_size)):
1385
+ batch = compact_records[start : start + max(1, batch_size)]
1386
+ logger.info(
1387
+ "Classifying backlog batch %d-%d of %d",
1388
+ start + 1,
1389
+ start + len(batch),
1390
+ len(compact_records),
1391
+ )
1392
+ payload = await _call_json_llm(
1393
+ _classification_messages(batch),
1394
+ llm_params,
1395
+ completion_func=completion_func,
1396
+ max_completion_tokens=max_completion_tokens,
1397
+ retries=1,
1398
+ )
1399
+ classifications.extend(_normalize_classifications(payload, batch))
1400
+ return classifications
1401
+
1402
+
1403
+ def _empty_ranking() -> dict[str, Any]:
1404
+ return {
1405
+ "summary": "No open backlog items were found.",
1406
+ "highest_impact_next": [],
1407
+ "features": [],
1408
+ "fixes": [],
1409
+ "can_be_closed": [],
1410
+ "other": [],
1411
+ "clusters": [],
1412
+ "classifications": [],
1413
+ }
1414
+
1415
+
1416
+ def _normalize_ranking(payload: Any) -> dict[str, Any]:
1417
+ ranking = dict(payload) if isinstance(payload, dict) else {}
1418
+ ranking.setdefault("summary", "")
1419
+ for key in (
1420
+ "highest_impact_next",
1421
+ "features",
1422
+ "fixes",
1423
+ "can_be_closed",
1424
+ "other",
1425
+ "clusters",
1426
+ ):
1427
+ if not isinstance(ranking.get(key), list):
1428
+ ranking[key] = []
1429
+ return ranking
1430
+
1431
+
1432
+ async def synthesize_ranking(
1433
+ records: list[dict[str, Any]],
1434
+ classifications: list[dict[str, Any]],
1435
+ llm_params: dict[str, Any],
1436
+ *,
1437
+ max_completion_tokens: int = DEFAULT_MAX_OUTPUT_TOKENS,
1438
+ completion_func: Callable[..., Any] | None = None,
1439
+ ) -> dict[str, Any]:
1440
+ if not records:
1441
+ return _empty_ranking()
1442
+
1443
+ payload = await _call_json_llm(
1444
+ _synthesis_messages(records, classifications),
1445
+ llm_params,
1446
+ completion_func=completion_func,
1447
+ max_completion_tokens=max_completion_tokens,
1448
+ retries=2,
1449
+ )
1450
+ ranking = _normalize_ranking(payload)
1451
+ ranking["classifications"] = classifications
1452
+ return ranking
1453
+
1454
+
1455
+ async def prioritize_records(
1456
+ records: list[dict[str, Any]],
1457
+ model: str,
1458
+ *,
1459
+ reasoning_effort: str | None = "high",
1460
+ batch_size: int = DEFAULT_BATCH_SIZE,
1461
+ max_completion_tokens: int = DEFAULT_MAX_OUTPUT_TOKENS,
1462
+ completion_func: Callable[..., Any] | None = None,
1463
+ ) -> dict[str, Any]:
1464
+ if not records:
1465
+ return _empty_ranking()
1466
+
1467
+ from agent.core.llm_params import _resolve_llm_params
1468
+
1469
+ llm_params = _resolve_llm_params(model, reasoning_effort=reasoning_effort)
1470
+ classifications = await classify_records(
1471
+ records,
1472
+ llm_params,
1473
+ batch_size=batch_size,
1474
+ max_completion_tokens=max_completion_tokens,
1475
+ completion_func=completion_func,
1476
+ )
1477
+ return await synthesize_ranking(
1478
+ records,
1479
+ classifications,
1480
+ llm_params,
1481
+ max_completion_tokens=max_completion_tokens,
1482
+ completion_func=completion_func,
1483
+ )
1484
+
1485
+
1486
+ def _source_lookup(records: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
1487
+ return {str(record.get("id")): record for record in records if record.get("id")}
1488
+
1489
+
1490
+ def _source_links(
1491
+ item: dict[str, Any], records_by_id: dict[str, dict[str, Any]]
1492
+ ) -> str:
1493
+ ids = item.get("source_ids") or item.get("related_source_ids") or []
1494
+ links: list[str] = []
1495
+ known_urls = {record.get("url") for record in records_by_id.values()}
1496
+ for source_id in ids:
1497
+ record = records_by_id.get(str(source_id))
1498
+ url = record.get("url") if record else None
1499
+ if url:
1500
+ links.append(f"[{source_id}]({url})")
1501
+ else:
1502
+ links.append(str(source_id))
1503
+ for url in item.get("source_urls") or []:
1504
+ if url and url not in known_urls:
1505
+ links.append(f"[source]({url})")
1506
+ return ", ".join(links) if links else "No source cited"
1507
+
1508
+
1509
+ def _score_text(item: dict[str, Any]) -> str:
1510
+ bits = []
1511
+ if item.get("impact_score") is not None:
1512
+ bits.append(f"impact {item.get('impact_score')}/5")
1513
+ if item.get("effort_score") is not None:
1514
+ bits.append(f"effort {item.get('effort_score')}/5")
1515
+ if item.get("confidence") is not None:
1516
+ bits.append(f"confidence {item.get('confidence')}")
1517
+ return ", ".join(bits)
1518
+
1519
+
1520
+ def _local_can_be_closed(records: list[dict[str, Any]]) -> list[dict[str, Any]]:
1521
+ items: list[dict[str, Any]] = []
1522
+ for record in records:
1523
+ resolution = record.get("resolution") or {}
1524
+ if not resolution.get("can_close"):
1525
+ continue
1526
+ source_id = record.get("id")
1527
+ if not source_id:
1528
+ continue
1529
+ checked_ref = resolution.get("checked_ref") or DEFAULT_RESOLUTION_REF
1530
+ checked_sha = str(resolution.get("checked_sha") or "")[:12]
1531
+ source = str(record.get("source") or "item").replace("_", " ")
1532
+ if record.get("source") == "github_pr":
1533
+ action = (
1534
+ f"Close the PR as already present on {checked_ref}"
1535
+ + (f" ({checked_sha})" if checked_sha else "")
1536
+ + " after maintainer confirmation."
1537
+ )
1538
+ else:
1539
+ action = (
1540
+ f"Close the {source} as resolved on {checked_ref}"
1541
+ + (f" ({checked_sha})" if checked_sha else "")
1542
+ + " after maintainer confirmation."
1543
+ )
1544
+ items.append(
1545
+ {
1546
+ "title": record.get("title") or str(source_id),
1547
+ "source_ids": [source_id],
1548
+ "source_urls": [record.get("url")] if record.get("url") else [],
1549
+ "reason": "; ".join(resolution.get("reasons") or [])
1550
+ or "Local main contains a high-confidence resolution signal.",
1551
+ "confidence": resolution.get("confidence", 0),
1552
+ "close_action": action,
1553
+ }
1554
+ )
1555
+ return items
1556
+
1557
+
1558
+ def merge_can_be_closed(
1559
+ ranking: dict[str, Any],
1560
+ records: list[dict[str, Any]],
1561
+ ) -> dict[str, Any]:
1562
+ merged = dict(ranking)
1563
+ existing = [
1564
+ item for item in merged.get("can_be_closed") or [] if isinstance(item, dict)
1565
+ ]
1566
+ seen = {
1567
+ tuple(sorted(str(source_id) for source_id in item.get("source_ids") or []))
1568
+ for item in existing
1569
+ }
1570
+ for item in _local_can_be_closed(records):
1571
+ key = tuple(
1572
+ sorted(str(source_id) for source_id in item.get("source_ids") or [])
1573
+ )
1574
+ if key in seen:
1575
+ continue
1576
+ existing.append(item)
1577
+ seen.add(key)
1578
+ existing.sort(key=lambda item: float(item.get("confidence") or 0), reverse=True)
1579
+ merged["can_be_closed"] = existing
1580
+ return merged
1581
+
1582
+
1583
+ def _render_can_be_closed(
1584
+ items: list[dict[str, Any]],
1585
+ records_by_id: dict[str, dict[str, Any]],
1586
+ ) -> list[str]:
1587
+ lines = ["## Can Be Closed"]
1588
+ if not items:
1589
+ lines.append("")
1590
+ lines.append("No high-confidence resolved-in-main candidates found.")
1591
+ return lines
1592
+
1593
+ for index, item in enumerate(items, start=1):
1594
+ title = item.get("title") or "Untitled"
1595
+ confidence = item.get("confidence")
1596
+ suffix = f" (confidence {confidence})" if confidence is not None else ""
1597
+ lines.append("")
1598
+ lines.append(f"{index}. **{title}**{suffix}")
1599
+ if item.get("reason"):
1600
+ lines.append(f" - Reason: {item['reason']}")
1601
+ if item.get("close_action"):
1602
+ lines.append(f" - Close action: {item['close_action']}")
1603
+ lines.append(f" - Sources: {_source_links(item, records_by_id)}")
1604
+ return lines
1605
+
1606
+
1607
+ def _render_recommendations(
1608
+ title: str,
1609
+ items: list[dict[str, Any]],
1610
+ records_by_id: dict[str, dict[str, Any]],
1611
+ ) -> list[str]:
1612
+ lines = [f"## {title}"]
1613
+ if not items:
1614
+ lines.append("")
1615
+ lines.append("No items.")
1616
+ return lines
1617
+
1618
+ for index, item in enumerate(items, start=1):
1619
+ heading = item.get("title") or item.get("recommendation") or "Untitled"
1620
+ score = _score_text(item)
1621
+ suffix = f" ({score})" if score else ""
1622
+ lines.append("")
1623
+ lines.append(f"{index}. **{heading}**{suffix}")
1624
+ if item.get("recommendation"):
1625
+ lines.append(f" - Recommendation: {item['recommendation']}")
1626
+ if item.get("rationale"):
1627
+ lines.append(f" - Rationale: {item['rationale']}")
1628
+ if item.get("next_action"):
1629
+ lines.append(f" - Next action: {item['next_action']}")
1630
+ lines.append(f" - Sources: {_source_links(item, records_by_id)}")
1631
+ return lines
1632
+
1633
+
1634
+ def render_markdown_report(
1635
+ ranking: dict[str, Any],
1636
+ records: list[dict[str, Any]],
1637
+ *,
1638
+ generated_at: str | None = None,
1639
+ model: str | None = None,
1640
+ ) -> str:
1641
+ records_by_id = _source_lookup(records)
1642
+ source_counts: dict[str, int] = {}
1643
+ for record in records:
1644
+ source = str(record.get("source") or "unknown")
1645
+ source_counts[source] = source_counts.get(source, 0) + 1
1646
+
1647
+ lines = ["# ML Intern Backlog Prioritization", ""]
1648
+ if generated_at:
1649
+ lines.append(f"Generated: {generated_at}")
1650
+ if model:
1651
+ lines.append(f"Model: `{model}`")
1652
+ if generated_at or model:
1653
+ lines.append("")
1654
+ lines.append(
1655
+ "Sources: "
1656
+ + ", ".join(f"{name}={count}" for name, count in sorted(source_counts.items()))
1657
+ )
1658
+ lines.append("")
1659
+ lines.append("## Summary")
1660
+ lines.append("")
1661
+ lines.append(ranking.get("summary") or "No summary returned.")
1662
+ lines.append("")
1663
+
1664
+ lines.extend(
1665
+ _render_can_be_closed(ranking.get("can_be_closed") or [], records_by_id)
1666
+ )
1667
+ lines.append("")
1668
+
1669
+ lines.extend(
1670
+ _render_recommendations(
1671
+ "Highest Impact Next",
1672
+ ranking.get("highest_impact_next") or [],
1673
+ records_by_id,
1674
+ )
1675
+ )
1676
+ lines.append("")
1677
+ lines.extend(
1678
+ _render_recommendations(
1679
+ "Features", ranking.get("features") or [], records_by_id
1680
+ )
1681
+ )
1682
+ lines.append("")
1683
+ lines.extend(
1684
+ _render_recommendations("Fixes", ranking.get("fixes") or [], records_by_id)
1685
+ )
1686
+
1687
+ other = ranking.get("other") or []
1688
+ if other:
1689
+ lines.append("")
1690
+ lines.extend(_render_recommendations("Other / Watchlist", other, records_by_id))
1691
+
1692
+ clusters = ranking.get("clusters") or []
1693
+ if clusters:
1694
+ lines.append("")
1695
+ lines.append("## Clusters")
1696
+ for cluster in clusters:
1697
+ lines.append("")
1698
+ lines.append(f"- **{cluster.get('title', 'Untitled')}**")
1699
+ if cluster.get("summary"):
1700
+ lines.append(f" - Summary: {cluster['summary']}")
1701
+ lines.append(f" - Sources: {_source_links(cluster, records_by_id)}")
1702
+
1703
+ return "\n".join(lines).rstrip() + "\n"
1704
+
1705
+
1706
+ def write_outputs(
1707
+ output_dir: Path,
1708
+ *,
1709
+ sources: list[dict[str, Any]],
1710
+ ranking: dict[str, Any],
1711
+ report: str,
1712
+ ) -> None:
1713
+ output_dir.mkdir(parents=True, exist_ok=True)
1714
+ (output_dir / "sources.json").write_text(
1715
+ json.dumps(sources, ensure_ascii=False, indent=2), encoding="utf-8"
1716
+ )
1717
+ (output_dir / "ranking.json").write_text(
1718
+ json.dumps(ranking, ensure_ascii=False, indent=2), encoding="utf-8"
1719
+ )
1720
+ (output_dir / "report.md").write_text(report, encoding="utf-8")
1721
+
1722
+
1723
+ def default_github_issue_title(generated_at: str) -> str:
1724
+ try:
1725
+ date_text = datetime.fromisoformat(generated_at).date().isoformat()
1726
+ except ValueError:
1727
+ date_text = generated_at[:10] or "latest"
1728
+ return f"ML Intern backlog prioritization report - {date_text}"
1729
+
1730
+
1731
+ def _github_issue_labels(raw_labels: list[str]) -> list[str]:
1732
+ labels: list[str] = []
1733
+ for raw in raw_labels:
1734
+ for label in raw.split(","):
1735
+ cleaned = label.strip()
1736
+ if cleaned and cleaned not in labels:
1737
+ labels.append(cleaned)
1738
+ return labels
1739
+
1740
+
1741
+ def _github_issue_body(report: str, *, max_chars: int) -> str:
1742
+ footer = "\n\n---\n_Generated by `uv run python scripts/prioritize_backlog.py`._\n"
1743
+ body = report.rstrip() + footer
1744
+ if max_chars <= 0 or len(body) <= max_chars:
1745
+ return body
1746
+
1747
+ truncation = (
1748
+ "\n\n---\n"
1749
+ "_Report truncated to fit the configured GitHub issue body limit. "
1750
+ "See the local `report.md` output for the complete version._\n"
1751
+ )
1752
+ if len(truncation) >= max_chars:
1753
+ return truncation[:max_chars]
1754
+ return body[: max(0, max_chars - len(truncation))].rstrip() + truncation
1755
+
1756
+
1757
+ def create_github_report_issue(
1758
+ repo: str,
1759
+ *,
1760
+ title: str,
1761
+ report: str,
1762
+ token: str | None,
1763
+ labels: list[str] | None = None,
1764
+ max_body_chars: int = DEFAULT_GITHUB_ISSUE_BODY_CHARS,
1765
+ client: Any | None = None,
1766
+ ) -> dict[str, Any]:
1767
+ if not token:
1768
+ raise ValueError(
1769
+ "Creating a GitHub issue requires --github-token or GITHUB_TOKEN."
1770
+ )
1771
+
1772
+ close_client = client is None
1773
+ if client is None:
1774
+ client = httpx.Client(timeout=30.0, follow_redirects=True)
1775
+
1776
+ payload: dict[str, Any] = {
1777
+ "title": title,
1778
+ "body": _github_issue_body(report, max_chars=max_body_chars),
1779
+ }
1780
+ cleaned_labels = _github_issue_labels(labels or [])
1781
+ if cleaned_labels:
1782
+ payload["labels"] = cleaned_labels
1783
+
1784
+ try:
1785
+ response = client.post(
1786
+ f"{GITHUB_API}/repos/{repo}/issues",
1787
+ headers=_github_headers(token),
1788
+ json=payload,
1789
+ )
1790
+ _raise_for_status(response)
1791
+ data = response.json()
1792
+ finally:
1793
+ if close_client and hasattr(client, "close"):
1794
+ client.close()
1795
+
1796
+ return {
1797
+ "number": data.get("number"),
1798
+ "url": data.get("html_url"),
1799
+ "api_url": data.get("url"),
1800
+ "title": data.get("title") or title,
1801
+ }
1802
+
1803
+
1804
+ def append_published_issue_section(report: str, issue: dict[str, Any]) -> str:
1805
+ number = issue.get("number")
1806
+ title = f"#{number}" if number else "GitHub issue"
1807
+ url = issue.get("url") or issue.get("api_url") or ""
1808
+ if not url:
1809
+ return report
1810
+ return report.rstrip() + f"\n\n## Published GitHub Issue\n\n- [{title}]({url})\n"
1811
+
1812
+
1813
+ async def async_main(argv: list[str] | None = None) -> int:
1814
+ args = parse_args(argv)
1815
+ logging.basicConfig(
1816
+ level=getattr(logging, args.log_level),
1817
+ format="%(levelname)s %(message)s",
1818
+ )
1819
+
1820
+ model = resolve_model(args.model, args.config)
1821
+ output_dir = resolve_output_dir(args.output_dir)
1822
+ github_token = args.github_token or os.environ.get("GITHUB_TOKEN")
1823
+ hf_token = resolve_hf_token(args.hf_token)
1824
+ github_report_labels = _github_issue_labels([args.github_report_label])
1825
+ if args.create_github_issue and not github_token:
1826
+ logger.error("--create-github-issue requires --github-token or GITHUB_TOKEN.")
1827
+ return 1
1828
+
1829
+ logger.info("Collecting GitHub and Hugging Face backlog sources")
1830
+ sources = collect_sources(
1831
+ args.github_repo,
1832
+ args.hf_space,
1833
+ github_token=github_token,
1834
+ hf_token=hf_token,
1835
+ max_comments=args.max_comments,
1836
+ max_review_comments=args.max_review_comments,
1837
+ max_body_chars=args.max_body_chars,
1838
+ max_comment_chars=args.max_comment_chars,
1839
+ github_exclude_labels=github_report_labels,
1840
+ )
1841
+ logger.info("Collected %d backlog items", len(sources))
1842
+ if not args.skip_resolution_check:
1843
+ logger.info(
1844
+ "Checking whether open items are already resolved on %s",
1845
+ args.resolution_ref,
1846
+ )
1847
+ sources = add_resolution_checks(
1848
+ sources,
1849
+ checked_ref=args.resolution_ref,
1850
+ github_repo=args.github_repo,
1851
+ github_token=github_token,
1852
+ max_commits=args.resolution_log_commits,
1853
+ include_patch_check=not args.skip_pr_patch_check,
1854
+ )
1855
+ can_close = sum(
1856
+ 1 for record in sources if (record.get("resolution") or {}).get("can_close")
1857
+ )
1858
+ logger.info("Found %d resolved-in-main closure candidates", can_close)
1859
+
1860
+ generated_at = utc_now().isoformat()
1861
+ ranking = await prioritize_records(
1862
+ sources,
1863
+ model,
1864
+ reasoning_effort=args.reasoning_effort,
1865
+ batch_size=args.batch_size,
1866
+ max_completion_tokens=args.max_output_tokens,
1867
+ )
1868
+ ranking = merge_can_be_closed(ranking, sources)
1869
+ ranking["generated_at"] = generated_at
1870
+ ranking["model"] = model
1871
+ ranking["source_counts"] = {
1872
+ source: sum(
1873
+ 1 for record in sources if str(record.get("source") or "unknown") == source
1874
+ )
1875
+ for source in sorted(
1876
+ {str(record.get("source") or "unknown") for record in sources}
1877
+ )
1878
+ }
1879
+
1880
+ report = render_markdown_report(
1881
+ ranking,
1882
+ sources,
1883
+ generated_at=generated_at,
1884
+ model=model,
1885
+ )
1886
+ write_outputs(output_dir, sources=sources, ranking=ranking, report=report)
1887
+ if args.create_github_issue:
1888
+ title = args.github_issue_title or default_github_issue_title(generated_at)
1889
+ issue = create_github_report_issue(
1890
+ args.github_repo,
1891
+ title=title,
1892
+ report=report,
1893
+ token=github_token,
1894
+ labels=[*args.github_issue_label, *github_report_labels],
1895
+ max_body_chars=args.github_issue_body_chars,
1896
+ )
1897
+ ranking["github_issue"] = issue
1898
+ report = append_published_issue_section(report, issue)
1899
+ write_outputs(output_dir, sources=sources, ranking=ranking, report=report)
1900
+ print(f"Created GitHub issue #{issue.get('number')}: {issue.get('url')}")
1901
+ print(f"Wrote backlog prioritization to {output_dir}")
1902
+ return 0
1903
+
1904
+
1905
+ def main(argv: list[str] | None = None) -> int:
1906
+ return asyncio.run(async_main(argv))
1907
+
1908
+
1909
+ if __name__ == "__main__":
1910
+ raise SystemExit(main())
tests/unit/test_prioritize_backlog.py ADDED
@@ -0,0 +1,721 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import importlib.util
2
+ import sys
3
+ from datetime import datetime, timezone
4
+ from pathlib import Path
5
+ from types import SimpleNamespace
6
+
7
+ import httpx
8
+ import pytest
9
+
10
+
11
+ def _load():
12
+ path = Path(__file__).parent.parent.parent / "scripts" / "prioritize_backlog.py"
13
+ spec = importlib.util.spec_from_file_location("prioritize_backlog", path)
14
+ mod = importlib.util.module_from_spec(spec)
15
+ sys.modules["prioritize_backlog"] = mod
16
+ spec.loader.exec_module(mod) # type: ignore
17
+ return mod
18
+
19
+
20
+ class FakeResponse:
21
+ def __init__(self, data, headers=None, text=None):
22
+ self._data = data
23
+ self.headers = headers or {}
24
+ self.text = text if text is not None else ""
25
+
26
+ def json(self):
27
+ return self._data
28
+
29
+ def raise_for_status(self):
30
+ return None
31
+
32
+
33
+ class RateLimitResponse(FakeResponse):
34
+ def __init__(self, status_code=403):
35
+ super().__init__({})
36
+ self.status_code = status_code
37
+ self.request = httpx.Request("GET", "https://api.github.test/rate")
38
+ self.response = httpx.Response(
39
+ status_code,
40
+ headers={"x-ratelimit-reset": "123"},
41
+ request=self.request,
42
+ )
43
+
44
+ def raise_for_status(self):
45
+ raise httpx.HTTPStatusError(
46
+ "rate limited", request=self.request, response=self.response
47
+ )
48
+
49
+
50
+ class FakeIssueClient:
51
+ def __init__(self):
52
+ self.posts = []
53
+ self.closed = False
54
+
55
+ def post(self, url, headers=None, json=None):
56
+ self.posts.append({"url": url, "headers": headers or {}, "json": json or {}})
57
+ return FakeResponse(
58
+ {
59
+ "number": 42,
60
+ "html_url": "https://github.com/owner/repo/issues/42",
61
+ "url": "https://api.github.com/repos/owner/repo/issues/42",
62
+ "title": json["title"],
63
+ }
64
+ )
65
+
66
+ def close(self):
67
+ self.closed = True
68
+
69
+
70
+ class FakeGitHubClient:
71
+ def __init__(self):
72
+ self.requests = []
73
+
74
+ def get(self, url, headers=None, params=None):
75
+ self.requests.append((url, params or {}))
76
+ page = (params or {}).get("page")
77
+
78
+ if url == "https://api.github.com/repos/owner/repo/issues":
79
+ if page == 1:
80
+ return FakeResponse(
81
+ [
82
+ {
83
+ "number": 1,
84
+ "html_url": "https://github.com/owner/repo/issues/1",
85
+ "title": "Issue one",
86
+ "body": "broken",
87
+ "labels": [{"name": "bug"}],
88
+ "user": {"login": "alice"},
89
+ "state": "open",
90
+ "created_at": "2026-05-01T00:00:00Z",
91
+ "updated_at": "2026-05-02T00:00:00Z",
92
+ "comments": 1,
93
+ "comments_url": "https://api.github.test/issues/1/comments",
94
+ },
95
+ {
96
+ "number": 2,
97
+ "html_url": "https://github.com/owner/repo/pull/2",
98
+ "title": "PR two",
99
+ "body": "adds feature",
100
+ "labels": [{"name": "enhancement"}],
101
+ "user": {"login": "bob"},
102
+ "state": "open",
103
+ "created_at": "2026-05-01T00:00:00Z",
104
+ "updated_at": "2026-05-02T00:00:00Z",
105
+ "comments": 0,
106
+ "comments_url": "https://api.github.test/issues/2/comments",
107
+ "pull_request": {"url": "https://api.github.test/pulls/2"},
108
+ },
109
+ ],
110
+ headers={"link": '<https://api.github.test?page=2>; rel="next"'},
111
+ )
112
+ return FakeResponse(
113
+ [
114
+ {
115
+ "number": 3,
116
+ "html_url": "https://github.com/owner/repo/issues/3",
117
+ "title": "Issue three",
118
+ "body": "request",
119
+ "labels": [],
120
+ "user": {"login": "carol"},
121
+ "state": "open",
122
+ "created_at": "2026-05-03T00:00:00Z",
123
+ "updated_at": "2026-05-03T00:00:00Z",
124
+ "comments": 0,
125
+ "comments_url": "https://api.github.test/issues/3/comments",
126
+ }
127
+ ]
128
+ )
129
+
130
+ if url.endswith("/comments") and "/pulls/" not in url:
131
+ return FakeResponse(
132
+ [
133
+ {
134
+ "body": "comment",
135
+ "user": {"login": "dana"},
136
+ "created_at": "2026-05-02T00:00:00Z",
137
+ "html_url": "https://github.com/comment",
138
+ }
139
+ ]
140
+ )
141
+
142
+ if url == "https://api.github.com/repos/owner/repo/pulls/2":
143
+ return FakeResponse(
144
+ {
145
+ "number": 2,
146
+ "html_url": "https://github.com/owner/repo/pull/2",
147
+ "title": "PR two",
148
+ "body": "adds feature",
149
+ "user": {"login": "bob"},
150
+ "state": "open",
151
+ "draft": False,
152
+ "base": {"ref": "main"},
153
+ "head": {"ref": "feature"},
154
+ "commits": 2,
155
+ "additions": 10,
156
+ "deletions": 3,
157
+ "changed_files": 2,
158
+ "review_comments": 0,
159
+ }
160
+ )
161
+
162
+ if url in {
163
+ "https://api.github.com/repos/owner/repo/pulls/2/comments",
164
+ "https://api.github.com/repos/owner/repo/pulls/2/reviews",
165
+ }:
166
+ return FakeResponse([])
167
+
168
+ raise AssertionError(f"unexpected URL: {url}")
169
+
170
+
171
+ def test_github_pagination_and_issue_pr_splitting():
172
+ mod = _load()
173
+ records = mod.collect_github_sources("owner/repo", client=FakeGitHubClient())
174
+
175
+ assert [record["id"] for record in records] == [
176
+ "github_issue#1",
177
+ "github_pr#2",
178
+ "github_issue#3",
179
+ ]
180
+ assert records[0]["source"] == "github_issue"
181
+ assert records[1]["source"] == "github_pr"
182
+ assert records[1]["metadata"]["base"] == "main"
183
+
184
+
185
+ def test_collect_github_sources_excludes_generated_report_label():
186
+ mod = _load()
187
+
188
+ class ReportIssueClient:
189
+ def close(self):
190
+ return None
191
+
192
+ def get(self, url, headers=None, params=None):
193
+ if url == "https://api.github.com/repos/owner/repo/issues":
194
+ return FakeResponse(
195
+ [
196
+ {
197
+ "number": 1,
198
+ "html_url": "https://github.com/owner/repo/issues/1",
199
+ "title": "Generated report",
200
+ "body": "report",
201
+ "labels": [
202
+ {"name": mod.DEFAULT_GITHUB_REPORT_LABEL.upper()}
203
+ ],
204
+ "user": {"login": "bot"},
205
+ "state": "open",
206
+ "comments": 0,
207
+ "comments_url": "https://api.github.test/issues/1/comments",
208
+ },
209
+ {
210
+ "number": 2,
211
+ "html_url": "https://github.com/owner/repo/issues/2",
212
+ "title": "Real issue",
213
+ "body": "broken",
214
+ "labels": [{"name": "bug"}],
215
+ "user": {"login": "alice"},
216
+ "state": "open",
217
+ "comments": 0,
218
+ "comments_url": "https://api.github.test/issues/2/comments",
219
+ },
220
+ ]
221
+ )
222
+ if url == "https://api.github.test/issues/2/comments":
223
+ return FakeResponse([])
224
+ raise AssertionError(f"unexpected URL: {url}")
225
+
226
+ records = mod.collect_github_sources(
227
+ "owner/repo",
228
+ exclude_labels=[mod.DEFAULT_GITHUB_REPORT_LABEL],
229
+ client=ReportIssueClient(),
230
+ )
231
+
232
+ assert [record["id"] for record in records] == ["github_issue#2"]
233
+
234
+
235
+ def test_collect_github_sources_returns_partial_results_on_rate_limit(caplog):
236
+ mod = _load()
237
+
238
+ class RateLimitedClient:
239
+ def close(self):
240
+ return None
241
+
242
+ def get(self, url, headers=None, params=None):
243
+ if url == "https://api.github.com/repos/owner/repo/issues":
244
+ return FakeResponse(
245
+ [
246
+ {
247
+ "number": 1,
248
+ "html_url": "https://github.com/owner/repo/issues/1",
249
+ "title": "Issue one",
250
+ "body": "broken",
251
+ "labels": [],
252
+ "user": {"login": "alice"},
253
+ "state": "open",
254
+ "comments": 0,
255
+ "comments_url": "https://api.github.test/issues/1/comments",
256
+ },
257
+ {
258
+ "number": 2,
259
+ "html_url": "https://github.com/owner/repo/issues/2",
260
+ "title": "Issue two",
261
+ "body": "rate limited",
262
+ "labels": [],
263
+ "user": {"login": "bob"},
264
+ "state": "open",
265
+ "comments": 0,
266
+ "comments_url": "https://api.github.test/issues/2/comments",
267
+ },
268
+ ]
269
+ )
270
+ if url == "https://api.github.test/issues/1/comments":
271
+ return FakeResponse([])
272
+ if url == "https://api.github.test/issues/2/comments":
273
+ return RateLimitResponse()
274
+ raise AssertionError(f"unexpected URL: {url}")
275
+
276
+ with caplog.at_level("WARNING"):
277
+ records = mod.collect_github_sources("owner/repo", client=RateLimitedClient())
278
+
279
+ assert [record["id"] for record in records] == ["github_issue#1"]
280
+ assert "GitHub rate limit" in caplog.text
281
+
282
+
283
+ def test_github_comment_cap_and_truncation():
284
+ mod = _load()
285
+
286
+ class CommentClient:
287
+ def get(self, url, headers=None, params=None):
288
+ assert url == "https://api.github.test/comments"
289
+ return FakeResponse(
290
+ [
291
+ {"body": "abcdef", "user": {"login": "one"}},
292
+ {"body": "second", "user": {"login": "two"}},
293
+ ],
294
+ headers={
295
+ "link": '<https://api.github.test/comments?page=2>; rel="next"'
296
+ },
297
+ )
298
+
299
+ comments = mod._fetch_github_comments(
300
+ CommentClient(),
301
+ "https://api.github.test/comments",
302
+ {},
303
+ max_comments=1,
304
+ max_comment_chars=5,
305
+ )
306
+
307
+ assert len(comments) == 1
308
+ assert comments[0]["author"] == "one"
309
+ assert comments[0]["body"].endswith("[truncated]")
310
+
311
+
312
+ def test_hf_discussion_event_normalization():
313
+ mod = _load()
314
+ discussion = SimpleNamespace(
315
+ num=7,
316
+ repo_id="smolagents/ml-intern",
317
+ repo_type="space",
318
+ title="Space fails",
319
+ status="open",
320
+ author="alice",
321
+ created_at=datetime(2026, 5, 1, tzinfo=timezone.utc),
322
+ )
323
+ details = SimpleNamespace(
324
+ title="Space fails",
325
+ status="open",
326
+ events=[
327
+ SimpleNamespace(
328
+ type="comment",
329
+ content="Initial report",
330
+ hidden=False,
331
+ author="alice",
332
+ created_at=datetime(2026, 5, 1, tzinfo=timezone.utc),
333
+ ),
334
+ SimpleNamespace(
335
+ type="comment",
336
+ content="Hidden moderation",
337
+ hidden=True,
338
+ author="mod",
339
+ created_at=datetime(2026, 5, 1, tzinfo=timezone.utc),
340
+ ),
341
+ SimpleNamespace(
342
+ type="comment",
343
+ content="Maintainer reply",
344
+ hidden=False,
345
+ author="bob",
346
+ created_at=datetime(2026, 5, 2, tzinfo=timezone.utc),
347
+ ),
348
+ SimpleNamespace(type="status-change", new_status="open"),
349
+ ],
350
+ )
351
+
352
+ record = mod.normalize_hf_discussion(discussion, details)
353
+
354
+ assert record["id"] == "hf_discussion#7"
355
+ assert record["url"] == (
356
+ "https://huggingface.co/spaces/smolagents/ml-intern/discussions/7"
357
+ )
358
+ assert record["body"] == "Initial report"
359
+ assert len(record["comments"]) == 1
360
+ assert record["comments"][0]["body"] == "Maintainer reply"
361
+ assert record["engagement"]["comments_count"] == 2
362
+
363
+
364
+ def test_resolution_check_marks_pr_and_linked_issue_as_closable():
365
+ mod = _load()
366
+ records = [
367
+ {
368
+ "id": "github_pr#2",
369
+ "source": "github_pr",
370
+ "number": 2,
371
+ "url": "https://github.com/owner/repo/pull/2",
372
+ "title": "Fix login",
373
+ "body": "Fixes the login flow.",
374
+ "comments": [],
375
+ },
376
+ {
377
+ "id": "github_issue#1",
378
+ "source": "github_issue",
379
+ "number": 1,
380
+ "url": "https://github.com/owner/repo/issues/1",
381
+ "title": "Login broken",
382
+ "body": "Fixed by PR #2.",
383
+ "comments": [],
384
+ },
385
+ {
386
+ "id": "github_issue#3",
387
+ "source": "github_issue",
388
+ "number": 3,
389
+ "url": "https://github.com/owner/repo/issues/3",
390
+ "title": "Direct issue",
391
+ "body": "",
392
+ "comments": [],
393
+ },
394
+ ]
395
+ commits = [
396
+ {
397
+ "commit": "abcdef1234567890",
398
+ "subject": "Fix login flow (#2)",
399
+ "body": "Also fixes #3",
400
+ }
401
+ ]
402
+
403
+ checked = mod.apply_resolution_checks(
404
+ records,
405
+ checked_ref="main",
406
+ checked_sha="abcdef1234567890",
407
+ commits=commits,
408
+ github_repo="owner/repo",
409
+ )
410
+
411
+ by_id = {record["id"]: record for record in checked}
412
+ assert by_id["github_pr#2"]["resolution"]["can_close"] is True
413
+ assert by_id["github_pr#2"]["resolution"]["status"] == "resolved"
414
+ assert by_id["github_issue#1"]["resolution"]["can_close"] is True
415
+ assert by_id["github_issue#1"]["resolution"]["status"] == "likely_resolved"
416
+ assert by_id["github_issue#3"]["resolution"]["can_close"] is True
417
+
418
+
419
+ def test_linked_pr_numbers_require_resolution_language():
420
+ mod = _load()
421
+
422
+ assert (
423
+ mod._linked_pr_numbers(
424
+ "Related to PR #12, but that PR does not address this.",
425
+ github_repo="owner/repo",
426
+ )
427
+ == set()
428
+ )
429
+ assert mod._linked_pr_numbers("Fixed by PR #12.", github_repo="owner/repo") == {12}
430
+
431
+
432
+ def test_merge_can_be_closed_adds_local_resolution_candidates():
433
+ mod = _load()
434
+ records = [
435
+ {
436
+ "id": "github_pr#2",
437
+ "source": "github_pr",
438
+ "url": "https://github.com/owner/repo/pull/2",
439
+ "title": "Fix login",
440
+ "resolution": {
441
+ "checked_ref": "main",
442
+ "checked_sha": "abcdef1234567890",
443
+ "status": "resolved",
444
+ "can_close": True,
445
+ "confidence": 0.95,
446
+ "reasons": ["PR #2 appears to already be present on main."],
447
+ "evidence": [],
448
+ },
449
+ }
450
+ ]
451
+
452
+ ranking = mod.merge_can_be_closed({"summary": "x"}, records)
453
+
454
+ assert ranking["can_be_closed"][0]["source_ids"] == ["github_pr#2"]
455
+ assert "already be present" in ranking["can_be_closed"][0]["reason"]
456
+
457
+
458
+ def test_fetch_pr_patch_matches_uses_patch_id(monkeypatch):
459
+ mod = _load()
460
+ records = [
461
+ {
462
+ "id": "github_pr#2",
463
+ "source": "github_pr",
464
+ "number": 2,
465
+ "metadata": {"patch_url": "https://api.github.test/pr/2.patch"},
466
+ }
467
+ ]
468
+
469
+ class PatchClient:
470
+ def close(self):
471
+ return None
472
+
473
+ def get(self, url, headers=None):
474
+ assert url == "https://api.github.test/pr/2.patch"
475
+ assert headers["Accept"] == "application/vnd.github.patch"
476
+ return FakeResponse({}, text="diff --git a/a b/a")
477
+
478
+ monkeypatch.setattr(mod, "_patch_id_for_text", lambda _text: "patch-id")
479
+
480
+ matches = mod._fetch_pr_patch_matches(
481
+ records,
482
+ github_token=None,
483
+ main_patch_ids={"patch-id": "abcdef1234567890"},
484
+ client=PatchClient(),
485
+ )
486
+
487
+ assert matches[2]["kind"] == "patch_id"
488
+ assert matches[2]["commit"] == "abcdef123456"
489
+
490
+
491
+ def test_fetch_pr_patch_matches_stops_on_rate_limit(caplog, monkeypatch):
492
+ mod = _load()
493
+ records = [
494
+ {
495
+ "id": "github_pr#2",
496
+ "source": "github_pr",
497
+ "number": 2,
498
+ "metadata": {"patch_url": "https://api.github.test/pr/2.patch"},
499
+ },
500
+ {
501
+ "id": "github_pr#3",
502
+ "source": "github_pr",
503
+ "number": 3,
504
+ "metadata": {"patch_url": "https://api.github.test/pr/3.patch"},
505
+ },
506
+ ]
507
+ calls = []
508
+
509
+ class RateLimitedPatchClient:
510
+ def close(self):
511
+ return None
512
+
513
+ def get(self, url, headers=None):
514
+ calls.append(url)
515
+ return RateLimitResponse(status_code=429)
516
+
517
+ monkeypatch.setattr(mod, "_patch_id_for_text", lambda _text: "patch-id")
518
+
519
+ with caplog.at_level("WARNING"):
520
+ matches = mod._fetch_pr_patch_matches(
521
+ records,
522
+ github_token=None,
523
+ main_patch_ids={"patch-id": "abcdef1234567890"},
524
+ client=RateLimitedPatchClient(),
525
+ )
526
+
527
+ assert matches == {}
528
+ assert calls == ["https://api.github.test/pr/2.patch"]
529
+ assert "GitHub rate limit" in caplog.text
530
+
531
+
532
+ def test_create_github_report_issue_posts_markdown_report():
533
+ mod = _load()
534
+ client = FakeIssueClient()
535
+
536
+ issue = mod.create_github_report_issue(
537
+ "owner/repo",
538
+ title="Backlog report",
539
+ report="# Report\n\nBody",
540
+ token="gh-token",
541
+ labels=["pm-report, backlog", "triage"],
542
+ client=client,
543
+ )
544
+
545
+ assert issue["number"] == 42
546
+ assert issue["url"] == "https://github.com/owner/repo/issues/42"
547
+ assert client.closed is False
548
+ post = client.posts[0]
549
+ assert post["url"] == "https://api.github.com/repos/owner/repo/issues"
550
+ assert post["headers"]["Authorization"] == "Bearer gh-token"
551
+ assert post["json"]["title"] == "Backlog report"
552
+ assert post["json"]["body"].startswith("# Report")
553
+ assert "Generated by" in post["json"]["body"]
554
+ assert post["json"]["labels"] == ["pm-report", "backlog", "triage"]
555
+
556
+
557
+ def test_create_github_report_issue_requires_token():
558
+ mod = _load()
559
+
560
+ with pytest.raises(ValueError, match="GITHUB_TOKEN"):
561
+ mod.create_github_report_issue(
562
+ "owner/repo",
563
+ title="Backlog report",
564
+ report="# Report",
565
+ token=None,
566
+ client=FakeIssueClient(),
567
+ )
568
+
569
+
570
+ def test_github_issue_body_truncates_with_footer():
571
+ mod = _load()
572
+ body = mod._github_issue_body("abcdef" * 100, max_chars=120)
573
+
574
+ assert len(body) <= 120
575
+ assert "Report truncated" in body
576
+
577
+
578
+ def test_append_published_issue_section_adds_local_link():
579
+ mod = _load()
580
+ report = mod.append_published_issue_section(
581
+ "# Report\n",
582
+ {"number": 42, "url": "https://github.com/owner/repo/issues/42"},
583
+ )
584
+
585
+ assert "## Published GitHub Issue" in report
586
+ assert "[#42](https://github.com/owner/repo/issues/42)" in report
587
+
588
+
589
+ @pytest.mark.asyncio
590
+ async def test_async_main_fails_early_when_issue_publish_token_missing(monkeypatch):
591
+ mod = _load()
592
+ monkeypatch.delenv("GITHUB_TOKEN", raising=False)
593
+
594
+ def fail_collect(*_args, **_kwargs):
595
+ raise AssertionError("collection should not run without a GitHub token")
596
+
597
+ monkeypatch.setattr(mod, "collect_sources", fail_collect)
598
+
599
+ result = await mod.async_main(["--create-github-issue"])
600
+
601
+ assert result == 1
602
+
603
+
604
+ @pytest.mark.asyncio
605
+ async def test_call_json_llm_retries_after_invalid_json():
606
+ mod = _load()
607
+ calls = []
608
+
609
+ async def fake_completion(**kwargs):
610
+ calls.append(kwargs)
611
+ content = "not json" if len(calls) == 1 else '{"ok": true}'
612
+ return {"choices": [{"message": {"content": content}}]}
613
+
614
+ result = await mod._call_json_llm(
615
+ [{"role": "user", "content": "return json"}],
616
+ {},
617
+ completion_func=fake_completion,
618
+ retries=1,
619
+ )
620
+
621
+ assert result == {"ok": True}
622
+ assert len(calls) == 2
623
+ assert "previous response was not valid JSON" in calls[1]["messages"][-1]["content"]
624
+
625
+
626
+ @pytest.mark.asyncio
627
+ async def test_call_json_llm_uses_temperature_one_for_thinking_params():
628
+ mod = _load()
629
+ calls = []
630
+
631
+ async def fake_completion(**kwargs):
632
+ calls.append(kwargs)
633
+ return {"choices": [{"message": {"content": '{"ok": true}'}}]}
634
+
635
+ result = await mod._call_json_llm(
636
+ [{"role": "user", "content": "return json"}],
637
+ {"thinking": {"type": "adaptive"}, "output_config": {"effort": "high"}},
638
+ completion_func=fake_completion,
639
+ retries=0,
640
+ )
641
+
642
+ assert result == {"ok": True}
643
+ assert calls[0]["temperature"] == 1.0
644
+
645
+
646
+ def test_render_markdown_report_from_sample_ranking():
647
+ mod = _load()
648
+ records = [
649
+ {
650
+ "id": "github_issue#1",
651
+ "source": "github_issue",
652
+ "url": "https://github.com/owner/repo/issues/1",
653
+ "title": "Broken login",
654
+ },
655
+ {
656
+ "id": "github_pr#2",
657
+ "source": "github_pr",
658
+ "url": "https://github.com/owner/repo/pull/2",
659
+ "title": "Fix login",
660
+ },
661
+ ]
662
+ ranking = {
663
+ "summary": "Fix login first.",
664
+ "can_be_closed": [
665
+ {
666
+ "title": "Fix login",
667
+ "source_ids": ["github_pr#2"],
668
+ "reason": "PR already landed on main.",
669
+ "confidence": 0.95,
670
+ "close_action": "Close duplicate PR.",
671
+ }
672
+ ],
673
+ "highest_impact_next": [
674
+ {
675
+ "title": "Unblock login",
676
+ "category": "fix",
677
+ "recommendation": "Review and merge the existing PR.",
678
+ "impact_score": 5,
679
+ "effort_score": 1,
680
+ "confidence": 0.9,
681
+ "source_ids": ["github_issue#1", "github_pr#2"],
682
+ "rationale": "It blocks onboarding.",
683
+ "next_action": "Review PR #2.",
684
+ }
685
+ ],
686
+ "features": [],
687
+ "fixes": [],
688
+ }
689
+
690
+ report = mod.render_markdown_report(
691
+ ranking,
692
+ records,
693
+ generated_at="2026-05-04T10:00:00+00:00",
694
+ model="openai/gpt-5.5",
695
+ )
696
+
697
+ assert "# ML Intern Backlog Prioritization" in report
698
+ assert "## Can Be Closed" in report
699
+ assert "PR already landed on main." in report
700
+ assert "## Highest Impact Next" in report
701
+ assert "[github_issue#1](https://github.com/owner/repo/issues/1)" in report
702
+ assert "Review and merge the existing PR." in report
703
+
704
+
705
+ def test_cli_defaults_without_live_network_or_llm():
706
+ mod = _load()
707
+ args = mod.parse_args([])
708
+ out = mod.resolve_output_dir(
709
+ None, now=datetime(2026, 5, 4, 12, 30, tzinfo=timezone.utc)
710
+ )
711
+
712
+ assert args.github_repo == "huggingface/ml-intern"
713
+ assert args.hf_space == "smolagents/ml-intern"
714
+ assert args.config == "configs/cli_agent_config.json"
715
+ assert args.resolution_ref == "main"
716
+ assert args.create_github_issue is False
717
+ assert args.github_issue_label == []
718
+ assert args.github_report_label == mod.DEFAULT_GITHUB_REPORT_LABEL
719
+ assert args.output_dir is None
720
+ assert out.name == "20260504T123000Z"
721
+ assert "scratch/backlog-prioritization" in str(out)