@seanyao/roll 2026.529.5 → 2026.601.2

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 (59) hide show
  1. package/CHANGELOG.md +57 -25
  2. package/README.md +10 -7
  3. package/bin/roll +3952 -317
  4. package/conventions/config.yaml +7 -0
  5. package/lib/__pycache__/github_sync.cpython-314.pyc +0 -0
  6. package/lib/__pycache__/loop_result_eval.cpython-314.pyc +0 -0
  7. package/lib/__pycache__/model_prices.cpython-314.pyc +0 -0
  8. package/lib/__pycache__/roll-home.cpython-314.pyc +0 -0
  9. package/lib/__pycache__/roll-loop-status.cpython-314.pyc +0 -0
  10. package/lib/__pycache__/roll_git.cpython-314.pyc +0 -0
  11. package/lib/__pycache__/slides-render.cpython-314.pyc +0 -0
  12. package/lib/agent_usage/__init__.py +4 -0
  13. package/lib/agent_usage/__pycache__/__init__.cpython-314.pyc +0 -0
  14. package/lib/agent_usage/__pycache__/gemini.cpython-314.pyc +0 -0
  15. package/lib/agent_usage/__pycache__/kimi.cpython-314.pyc +0 -0
  16. package/lib/agent_usage/__pycache__/openai.cpython-314.pyc +0 -0
  17. package/lib/agent_usage/__pycache__/qwen.cpython-314.pyc +0 -0
  18. package/lib/agent_usage/gemini.py +127 -0
  19. package/lib/agent_usage/kimi.py +127 -0
  20. package/lib/agent_usage/openai.py +126 -0
  21. package/lib/agent_usage/qwen.py +128 -0
  22. package/lib/context_feed_budget.sh +194 -0
  23. package/lib/github_sync.py +876 -0
  24. package/lib/i18n/agent.sh +54 -0
  25. package/lib/i18n/init.sh +22 -0
  26. package/lib/i18n/peer.sh +7 -0
  27. package/lib/i18n/peer_help.sh +4 -0
  28. package/lib/i18n/skills_catalog.sh +30 -0
  29. package/lib/loop-exit-summary.py +393 -0
  30. package/lib/loop-fmt.py +93 -75
  31. package/lib/loop_pick_agent.py +241 -170
  32. package/lib/loop_result_eval.py +469 -0
  33. package/lib/model_prices.py +0 -10
  34. package/lib/roll-home.py +1 -28
  35. package/lib/roll-loop-status.py +330 -40
  36. package/lib/roll-onboard-render.py +378 -0
  37. package/lib/roll-peer.py +1 -1
  38. package/lib/roll-plan-validate.py +165 -0
  39. package/lib/roll_git.py +41 -0
  40. package/lib/slides/components/README.md +8 -2
  41. package/lib/slides/templates/introduction-v3.html +1 -6
  42. package/lib/slides-render.py +305 -15
  43. package/lib/slides-validate.py +195 -7
  44. package/package.json +1 -1
  45. package/skills/roll-.changelog/SKILL.md +67 -56
  46. package/skills/roll-brief/SKILL.md +1 -1
  47. package/skills/roll-build/SKILL.md +14 -12
  48. package/skills/roll-deck/SKILL.md +152 -0
  49. package/skills/roll-design/SKILL.md +13 -6
  50. package/skills/roll-doc/SKILL.md +269 -6
  51. package/skills/roll-fix/SKILL.md +15 -9
  52. package/skills/roll-loop/SKILL.md +9 -7
  53. package/skills/roll-notes/SKILL.md +1 -1
  54. package/skills/roll-onboard/SKILL.md +85 -0
  55. package/skills/roll-peer/SKILL.md +6 -5
  56. package/lib/agent_routes_lint.py +0 -203
  57. package/skills/roll-research/SKILL.md +0 -316
  58. package/skills/roll-research/references/schema.json +0 -166
  59. package/skills/roll-research/scripts/md_to_pdf.py +0 -289
@@ -0,0 +1,876 @@
1
+ """
2
+ github_sync — GitHub Issues REST API client + auth (US-SYNC-001).
3
+
4
+ This is the pure API layer for the ``roll backlog sync`` feature: it fetches
5
+ issues from a GitHub repository, follows pagination via the ``Link`` header,
6
+ resolves auth from ``$GITHUB_TOKEN`` then ``gh auth token``, and surfaces a
7
+ friendly hint when the rate-limit budget runs low. It deliberately does NOT
8
+ touch ``.roll/backlog.md`` — downstream stories (US-SYNC-002+) consume the
9
+ issues this module returns.
10
+
11
+ Design (mirrors lib/prices_fetcher.py):
12
+ * ``resolve_token(...)`` — pure-ish: env first, ``gh auth token`` fallback,
13
+ raises ``AuthError`` when neither is available.
14
+ * ``fetch_issues(owner, repo, ...)`` — orchestrator; follows Link-header
15
+ pagination, honours rate-limit headers.
16
+ * The HTTP layer is injectable via the ``opener`` parameter so tests can
17
+ mock pagination / auth / rate-limit responses without network access.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import os
24
+ import re
25
+ import subprocess
26
+ import sys
27
+ import time
28
+ from typing import Any, Callable, Dict, List, Optional
29
+ from urllib.error import HTTPError, URLError
30
+ from urllib.request import Request, urlopen
31
+
32
+ API_ROOT = "https://api.github.com"
33
+ DEFAULT_TIMEOUT = 15
34
+ DEFAULT_PER_PAGE = 100
35
+ # When fewer than this many requests remain in the rate-limit window we warn
36
+ # and back off rather than hammering the API into a hard 429.
37
+ RATE_LIMIT_FLOOR = 5
38
+
39
+
40
+ class AuthError(RuntimeError):
41
+ """Raised when no GitHub credential can be resolved."""
42
+
43
+
44
+ class RateLimitError(RuntimeError):
45
+ """Raised when GitHub returns HTTP 429 / the rate-limit budget is exhausted."""
46
+
47
+
48
+ class GitHubAPIError(RuntimeError):
49
+ """Raised for non-success HTTP responses other than rate-limiting."""
50
+
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # Auth
54
+ # ---------------------------------------------------------------------------
55
+ def resolve_token(env: Optional[Dict[str, str]] = None,
56
+ gh_token_fn: Optional[Callable[[], Optional[str]]] = None
57
+ ) -> str:
58
+ """Resolve a GitHub token.
59
+
60
+ Order: ``$GITHUB_TOKEN`` → ``gh auth token`` (fallback) → ``AuthError``
61
+ with a hint on how to configure credentials.
62
+
63
+ ``env`` and ``gh_token_fn`` are injectable for tests.
64
+ """
65
+ env = os.environ if env is None else env
66
+ token = (env.get("GITHUB_TOKEN") or "").strip()
67
+ if token:
68
+ return token
69
+
70
+ fn = gh_token_fn if gh_token_fn is not None else _gh_auth_token
71
+ gh_token = (fn() or "").strip()
72
+ if gh_token:
73
+ return gh_token
74
+
75
+ raise AuthError(
76
+ "no GitHub credential found.\n"
77
+ " set GITHUB_TOKEN, or run `gh auth login` so `gh auth token` works.\n"
78
+ " 未找到 GitHub 凭据:请设置 GITHUB_TOKEN,或运行 `gh auth login`。"
79
+ )
80
+
81
+
82
+ def _gh_auth_token() -> Optional[str]:
83
+ """Return the token from `gh auth token`, or None if gh is absent/unauthed."""
84
+ try:
85
+ out = subprocess.check_output(
86
+ ["gh", "auth", "token"],
87
+ text=True, stderr=subprocess.DEVNULL,
88
+ )
89
+ return out.strip()
90
+ except (OSError, subprocess.CalledProcessError):
91
+ return None
92
+
93
+
94
+ # ---------------------------------------------------------------------------
95
+ # HTTP layer (injectable)
96
+ # ---------------------------------------------------------------------------
97
+ class _Response:
98
+ """Normalized response shape returned by the default opener."""
99
+
100
+ def __init__(self, status: int, headers: Dict[str, str], body: str) -> None:
101
+ self.status = status
102
+ self.headers = headers
103
+ self.body = body
104
+
105
+
106
+ def _default_opener(req: Request, timeout: float) -> _Response:
107
+ """Perform a real HTTP request and normalize it into a ``_Response``."""
108
+ try:
109
+ with urlopen(req, timeout=timeout) as resp:
110
+ data = resp.read().decode("utf-8", errors="replace")
111
+ headers = {k.lower(): v for k, v in resp.headers.items()}
112
+ status = getattr(resp, "status", None) or resp.getcode()
113
+ return _Response(status, headers, data)
114
+ except HTTPError as exc:
115
+ body = ""
116
+ try:
117
+ body = exc.read().decode("utf-8", errors="replace")
118
+ except Exception: # pragma: no cover - defensive
119
+ pass
120
+ headers = {k.lower(): v for k, v in (exc.headers or {}).items()}
121
+ return _Response(exc.code, headers, body)
122
+ except (URLError, OSError, TimeoutError) as exc:
123
+ raise GitHubAPIError(f"request failed: {exc}") from exc
124
+
125
+
126
+ def _parse_link_header(value: Optional[str]) -> Dict[str, str]:
127
+ """Parse a GitHub ``Link`` header into a {rel: url} map."""
128
+ rels: Dict[str, str] = {}
129
+ if not value:
130
+ return rels
131
+ for part in value.split(","):
132
+ segs = part.split(";")
133
+ if len(segs) < 2:
134
+ continue
135
+ url = segs[0].strip().lstrip("<").rstrip(">")
136
+ for seg in segs[1:]:
137
+ seg = seg.strip()
138
+ if seg.startswith("rel="):
139
+ rel = seg[len("rel="):].strip().strip('"')
140
+ rels[rel] = url
141
+ return rels
142
+
143
+
144
+ def _check_rate_limit(resp: _Response,
145
+ warn: Callable[[str], None]) -> None:
146
+ """Inspect rate-limit headers / 429 status; warn + raise when exhausted."""
147
+ if resp.status == 429:
148
+ raise RateLimitError(
149
+ "GitHub rate limit hit (HTTP 429); retry later or authenticate.\n"
150
+ " 触发 GitHub 限流 (HTTP 429):请稍后重试或配置鉴权。"
151
+ )
152
+ remaining_raw = resp.headers.get("x-ratelimit-remaining")
153
+ if remaining_raw is None:
154
+ return
155
+ try:
156
+ remaining = int(remaining_raw)
157
+ except ValueError:
158
+ return
159
+ if remaining < RATE_LIMIT_FLOOR:
160
+ reset = resp.headers.get("x-ratelimit-reset", "")
161
+ warn(
162
+ f"GitHub rate-limit low: {remaining} requests left "
163
+ f"(resets at epoch {reset}); backing off.\n"
164
+ f" GitHub 配额不足:剩余 {remaining} 次,正在退避。"
165
+ )
166
+ if remaining <= 0:
167
+ raise RateLimitError(
168
+ "GitHub rate-limit budget exhausted; aborting.\n"
169
+ " GitHub 配额已耗尽:已中止。"
170
+ )
171
+
172
+
173
+ # ---------------------------------------------------------------------------
174
+ # Issues
175
+ # ---------------------------------------------------------------------------
176
+ def fetch_issues(owner: str,
177
+ repo: str,
178
+ *,
179
+ state: str = "all",
180
+ token: Optional[str] = None,
181
+ per_page: int = DEFAULT_PER_PAGE,
182
+ timeout: float = DEFAULT_TIMEOUT,
183
+ opener: Optional[Callable[[Request, float], _Response]] = None,
184
+ warn: Optional[Callable[[str], None]] = None,
185
+ sleep: Callable[[float], None] = time.sleep,
186
+ ) -> List[Dict[str, Any]]:
187
+ """Fetch all issues for ``owner/repo`` (default ``state=all``).
188
+
189
+ Follows ``Link``-header pagination, applies the resolved bearer token, and
190
+ honours rate-limit headers (backing off below ``RATE_LIMIT_FLOOR``). The
191
+ ``opener``/``warn``/``sleep`` hooks are injectable for tests.
192
+
193
+ Pull requests (which the issues endpoint includes) are filtered out — only
194
+ true issues are returned, matching what downstream backlog sync expects.
195
+ """
196
+ if token is None:
197
+ token = resolve_token()
198
+ if opener is None:
199
+ opener = _default_opener
200
+ if warn is None:
201
+ warn = lambda msg: print(msg, file=sys.stderr) # noqa: E731
202
+
203
+ url: Optional[str] = (
204
+ f"{API_ROOT}/repos/{owner}/{repo}/issues"
205
+ f"?state={state}&per_page={per_page}"
206
+ )
207
+ issues: List[Dict[str, Any]] = []
208
+ while url:
209
+ req = Request(url, headers={
210
+ "Authorization": f"Bearer {token}",
211
+ "Accept": "application/vnd.github+json",
212
+ "User-Agent": "roll/github_sync",
213
+ "X-GitHub-Api-Version": "2022-11-28",
214
+ })
215
+ resp = opener(req, timeout)
216
+ _check_rate_limit(resp, warn)
217
+ if resp.status == 401 or resp.status == 403:
218
+ # 403 without a 429 is most often a bad/expired token.
219
+ raise AuthError(
220
+ f"GitHub returned HTTP {resp.status}; check your token scopes.\n"
221
+ f" GitHub 返回 HTTP {resp.status}:请检查 token 权限。"
222
+ )
223
+ if resp.status < 200 or resp.status >= 300:
224
+ raise GitHubAPIError(
225
+ f"GitHub returned HTTP {resp.status} for {url}"
226
+ )
227
+ page = json.loads(resp.body) if resp.body.strip() else []
228
+ for item in page:
229
+ # The issues endpoint also returns PRs; skip them.
230
+ if "pull_request" in item:
231
+ continue
232
+ issues.append(item)
233
+ links = _parse_link_header(resp.headers.get("link"))
234
+ next_url = links.get("next")
235
+ url = next_url
236
+ if url:
237
+ # Be polite between pages when the budget is getting tight.
238
+ remaining_raw = resp.headers.get("x-ratelimit-remaining")
239
+ if remaining_raw is not None:
240
+ try:
241
+ if int(remaining_raw) < RATE_LIMIT_FLOOR:
242
+ sleep(1.0)
243
+ except ValueError:
244
+ pass
245
+ return issues
246
+
247
+
248
+ # ---------------------------------------------------------------------------
249
+ # Backlog write (US-SYNC-002)
250
+ #
251
+ # Single-direction mapping: GitHub issues → .roll/backlog.md rows. label→type
252
+ # mapping decides the backlog prefix (FIX / US / REFACTOR), title becomes the
253
+ # Description, and state (open/closed) becomes the status emoji. New rows are
254
+ # appended to the bottom of the target Markdown table.
255
+ # ---------------------------------------------------------------------------
256
+
257
+ # label → backlog type. First matching label (case-insensitive) wins; an issue
258
+ # with no recognised label defaults to US.
259
+ _LABEL_TYPE_MAP = {
260
+ "bug": "FIX",
261
+ "enhancement": "US",
262
+ "feature": "US",
263
+ "us": "US",
264
+ "refactor": "REFACTOR",
265
+ }
266
+ DEFAULT_TYPE = "US"
267
+
268
+ # state → backlog status column.
269
+ _STATE_STATUS_MAP = {
270
+ "open": "📋 Todo",
271
+ "closed": "✅ Done",
272
+ }
273
+ DEFAULT_STATUS = "📋 Todo"
274
+
275
+
276
+ def map_label_to_type(labels: List[Any]) -> str:
277
+ """Map a GitHub issue's labels to a backlog type prefix.
278
+
279
+ ``labels`` is the raw ``issue["labels"]`` list (each entry a dict with a
280
+ ``name`` key, as GitHub returns them, or a plain string). The first label
281
+ that matches a known mapping wins; with no match we fall back to
282
+ ``DEFAULT_TYPE`` (US).
283
+ """
284
+ for label in labels or []:
285
+ name = label.get("name", "") if isinstance(label, dict) else str(label)
286
+ key = name.strip().lower()
287
+ if key in _LABEL_TYPE_MAP:
288
+ return _LABEL_TYPE_MAP[key]
289
+ return DEFAULT_TYPE
290
+
291
+
292
+ def map_state_to_status(state: Optional[str]) -> str:
293
+ """Map a GitHub issue state (``open``/``closed``) to a backlog status."""
294
+ return _STATE_STATUS_MAP.get((state or "").strip().lower(), DEFAULT_STATUS)
295
+
296
+
297
+ def gh_id(issue: Dict[str, Any]) -> str:
298
+ """Return the canonical GitHub id token for an issue, e.g. ``GH-13``.
299
+
300
+ This is stable across syncs and independent of the label→type prefix, so
301
+ it is what idempotency detection keys on (US-SYNC-003).
302
+ """
303
+ return f"GH-{issue.get('number')}"
304
+
305
+
306
+ def issue_to_row(issue: Dict[str, Any]) -> str:
307
+ """Render a single GitHub issue as a backlog Markdown table row.
308
+
309
+ The id is ``<TYPE>-GH-<number>`` (e.g. ``US-GH-13`` / ``FIX-GH-13``): the
310
+ label→type mapping supplies the prefix and ``GH-<number>`` is the stable
311
+ GitHub id (US-SYNC-003). The issue title becomes the Description and the
312
+ state becomes the status column.
313
+ """
314
+ title = (issue.get("title") or "").strip()
315
+ type_prefix = map_label_to_type(issue.get("labels", []))
316
+ status = map_state_to_status(issue.get("state"))
317
+ row_id = f"{type_prefix}-{gh_id(issue)}"
318
+ return f"| {row_id} | {title} | {status} |"
319
+
320
+
321
+ def _append_rows_to_table(content: str, rows: List[str]) -> str:
322
+ """Append ``rows`` to the bottom of the first Markdown table in ``content``.
323
+
324
+ A "table" here is the contiguous run of lines starting with ``|`` that
325
+ follows the ``|---|`` separator. New rows are inserted directly after the
326
+ last existing body row of that table, so subsequent (non-table) content is
327
+ preserved.
328
+ """
329
+ if not rows:
330
+ return content
331
+ lines = content.split("\n")
332
+ sep_idx = None
333
+ for idx, line in enumerate(lines):
334
+ stripped = line.strip()
335
+ if stripped.startswith("|") and set(stripped) <= set("|-: "):
336
+ sep_idx = idx
337
+ break
338
+ if sep_idx is None:
339
+ # No table found — append the rows at the end as a fallback.
340
+ tail = "\n".join(rows)
341
+ if content and not content.endswith("\n"):
342
+ return content + "\n" + tail + "\n"
343
+ return content + tail + "\n"
344
+ # Find the last contiguous body row after the separator.
345
+ insert_at = sep_idx + 1
346
+ while insert_at < len(lines) and lines[insert_at].strip().startswith("|"):
347
+ insert_at += 1
348
+ new_lines = lines[:insert_at] + rows + lines[insert_at:]
349
+ return "\n".join(new_lines)
350
+
351
+
352
+ def _gh_id_present(content: str, ident: str) -> bool:
353
+ """Return True if backlog ``content`` already contains the GitHub id token.
354
+
355
+ Matches the ``GH-<number>`` token so ``GH-1`` does not spuriously match
356
+ ``GH-13``. The token always appears inside a row id of the form
357
+ ``<TYPE>-GH-<number>`` (the char before ``GH`` is the prefix hyphen), so the
358
+ leading boundary must reject only an alphanumeric — never the hyphen. A
359
+ label/type change between syncs still counts as "already exists" (we skip
360
+ rather than duplicate).
361
+ """
362
+ import re
363
+ return re.search(r'(?<![0-9A-Za-z])' + re.escape(ident) + r'(?![0-9A-Za-z-])',
364
+ content) is not None
365
+
366
+
367
+ def parse_labels_filter(value: Optional[str]) -> List[str]:
368
+ """Parse a ``--label`` flag value into a normalized list of label names.
369
+
370
+ The flag is comma-separated and may be passed multiple times (the caller
371
+ joins repeats with commas before calling this); ``"P1, bug"`` → ``["p1",
372
+ "bug"]``. Names are lower-cased and stripped so matching is
373
+ case-insensitive (US-SYNC-005). Empty / whitespace-only tokens are dropped.
374
+ """
375
+ if not value:
376
+ return []
377
+ out: List[str] = []
378
+ for tok in value.split(","):
379
+ key = tok.strip().lower()
380
+ if key and key not in out:
381
+ out.append(key)
382
+ return out
383
+
384
+
385
+ def issue_has_label(issue: Dict[str, Any], wanted: List[str]) -> bool:
386
+ """Return True if ``issue`` carries any of the ``wanted`` labels (OR).
387
+
388
+ ``wanted`` is the normalized list from :func:`parse_labels_filter`. An
389
+ empty ``wanted`` matches every issue (no filter). Matching is
390
+ case-insensitive and uses OR semantics — a single overlapping label is
391
+ enough (US-SYNC-005).
392
+ """
393
+ if not wanted:
394
+ return True
395
+ have = set()
396
+ for label in issue.get("labels", []) or []:
397
+ name = label.get("name", "") if isinstance(label, dict) else str(label)
398
+ key = name.strip().lower()
399
+ if key:
400
+ have.add(key)
401
+ return any(w in have for w in wanted)
402
+
403
+
404
+ def filter_issues_by_label(issues: List[Dict[str, Any]],
405
+ wanted: List[str]) -> List[Dict[str, Any]]:
406
+ """Filter ``issues`` to those matching any ``wanted`` label (US-SYNC-005)."""
407
+ if not wanted:
408
+ return list(issues)
409
+ return [i for i in issues if issue_has_label(i, wanted)]
410
+
411
+
412
+ # A top-level GitHub task-list item: ``- [ ] text`` or ``- [x] text`` with NO
413
+ # leading indentation. Nested items (indented) are intentionally ignored so we
414
+ # only capture the issue's primary acceptance criteria, not sub-points.
415
+ import re as _re # noqa: E402
416
+
417
+ _TOP_LEVEL_CHECKBOX = _re.compile(r'^[-*] \[([ xX])\] (.+?)\s*$')
418
+
419
+
420
+ def extract_ac_items(body: Optional[str]) -> List[str]:
421
+ """Extract top-level ``- [ ]`` / ``- [x]`` checkbox items from an issue body.
422
+
423
+ Only checkbox items with no leading indentation are returned — nested
424
+ (indented) list items are ignored so we capture the issue's primary
425
+ acceptance criteria, not sub-bullets (US-SYNC-005). Returns the raw item
426
+ text (the label after the checkbox), in document order. ``\\r\\n`` line
427
+ endings are tolerated.
428
+ """
429
+ if not body:
430
+ return []
431
+ items: List[str] = []
432
+ for raw in body.replace("\r\n", "\n").replace("\r", "\n").split("\n"):
433
+ # A leading space means the item is nested under another bullet — skip.
434
+ if raw[:1] == " " or raw[:1] == "\t":
435
+ continue
436
+ m = _TOP_LEVEL_CHECKBOX.match(raw)
437
+ if m:
438
+ items.append(m.group(2).strip())
439
+ return items
440
+
441
+
442
+ def render_ac_section(issue: Dict[str, Any]) -> str:
443
+ """Render the AC section body for a feature stub from an issue (US-SYNC-005).
444
+
445
+ Each top-level checkbox in the issue body becomes a Markdown ``- [ ]`` AC
446
+ line (state normalized to unchecked — the backlog tracks completion, not
447
+ the upstream issue). When the issue has no checkboxes the section is empty.
448
+ """
449
+ items = extract_ac_items(issue.get("body"))
450
+ return "\n".join(f"- [ ] {it}" for it in items)
451
+
452
+
453
+ def write_feature_stub(issue: Dict[str, Any],
454
+ features_dir: str,
455
+ *,
456
+ epic: str = "backlog-lifecycle") -> str:
457
+ """Create or append a feature file stub for ``issue`` (US-SYNC-005).
458
+
459
+ Writes ``<features_dir>/<epic>/GH-<number>.md``. If the file does not exist
460
+ a stub is created with a heading + AC section; if it exists the AC items are
461
+ appended (idempotency at the row level is handled upstream, but appending
462
+ here is non-destructive to any human-authored prose). Returns the path
463
+ written.
464
+ """
465
+ ident = gh_id(issue)
466
+ epic_dir = os.path.join(features_dir, epic)
467
+ os.makedirs(epic_dir, exist_ok=True)
468
+ path = os.path.join(epic_dir, f"{ident}.md")
469
+ ac_body = render_ac_section(issue)
470
+ if os.path.exists(path):
471
+ with open(path, "r", encoding="utf-8") as fh:
472
+ existing = fh.read()
473
+ block = ac_body + "\n" if ac_body else ""
474
+ sep = "" if existing.endswith("\n") or not existing else "\n"
475
+ with open(path, "a", encoding="utf-8") as fh:
476
+ if block:
477
+ fh.write(sep + block)
478
+ return path
479
+ title = (issue.get("title") or "").strip()
480
+ type_prefix = map_label_to_type(issue.get("labels", []))
481
+ parts = [
482
+ f"# {ident} {title}".rstrip(),
483
+ "",
484
+ f"> Synced from GitHub issue #{issue.get('number')} "
485
+ f"({type_prefix}).",
486
+ "",
487
+ "## AC",
488
+ "",
489
+ ]
490
+ stub = "\n".join(parts)
491
+ if ac_body:
492
+ stub += ac_body + "\n"
493
+ with open(path, "w", encoding="utf-8") as fh:
494
+ fh.write(stub)
495
+ return path
496
+
497
+
498
+ def dry_run_line(issue: Dict[str, Any], *, skipped: bool) -> str:
499
+ """Render the ``--dry-run`` preview line for a single issue (US-SYNC-004).
500
+
501
+ Format:
502
+ ``+ GH-13 [US] 需求:roll backlog 支持从 GitHub Issues 同步`` (would add)
503
+ ``= GH-12 [FIX] (skipped, already exists)`` (would skip)
504
+
505
+ The ``GH-<number>`` token is the stable id; the bracketed token is the
506
+ label→type prefix; the leading ``+``/``=`` marks add vs skip.
507
+ """
508
+ ident = gh_id(issue)
509
+ type_prefix = map_label_to_type(issue.get("labels", []))
510
+ if skipped:
511
+ return f"= {ident} [{type_prefix}] (skipped, already exists)"
512
+ title = (issue.get("title") or "").strip()
513
+ return f"+ {ident} [{type_prefix}] {title}"
514
+
515
+
516
+ def dry_run_preview(issues: List[Dict[str, Any]],
517
+ backlog_path: str) -> Dict[str, Any]:
518
+ """Compute the sync diff for ``issues`` WITHOUT writing ``backlog_path``.
519
+
520
+ Mirrors :func:`sync_to_backlog`'s idempotency logic (an issue whose
521
+ ``GH-<number>`` id already appears in the backlog is a skip) but performs
522
+ no file write — the backlog file is read-only here (US-SYNC-004 dry-run).
523
+ Returns ``{"added": N, "skipped": M, "total": K, "lines": [...]}`` where
524
+ ``lines`` are the formatted preview lines in issue order.
525
+ """
526
+ with open(backlog_path, "r", encoding="utf-8") as fh:
527
+ content = fh.read()
528
+ lines: List[str] = []
529
+ added = 0
530
+ skipped = 0
531
+ for issue in issues:
532
+ ident = gh_id(issue)
533
+ is_skip = _gh_id_present(content, ident)
534
+ if is_skip:
535
+ skipped += 1
536
+ else:
537
+ added += 1
538
+ lines.append(dry_run_line(issue, skipped=is_skip))
539
+ return {
540
+ "added": added,
541
+ "skipped": skipped,
542
+ "total": len(issues),
543
+ "lines": lines,
544
+ }
545
+
546
+
547
+ def sync_to_backlog(issues: List[Dict[str, Any]],
548
+ backlog_path: str) -> Dict[str, Any]:
549
+ """Append backlog rows for new ``issues`` to the table in ``backlog_path``.
550
+
551
+ Idempotent (US-SYNC-003): an issue whose ``GH-<number>`` id already appears
552
+ in the backlog is skipped (status/description left untouched) and reported
553
+ in ``skipped``. Returns a summary dict
554
+ ``{"added": N, "skipped": M, "total": K, "rows": [...], "skipped_ids": [...]}``.
555
+ """
556
+ with open(backlog_path, "r", encoding="utf-8") as fh:
557
+ content = fh.read()
558
+ rows: List[str] = []
559
+ skipped_ids: List[str] = []
560
+ for issue in issues:
561
+ ident = gh_id(issue)
562
+ if _gh_id_present(content, ident):
563
+ skipped_ids.append(ident)
564
+ continue
565
+ rows.append(issue_to_row(issue))
566
+ updated = _append_rows_to_table(content, rows)
567
+ with open(backlog_path, "w", encoding="utf-8") as fh:
568
+ fh.write(updated)
569
+ return {
570
+ "added": len(rows),
571
+ "skipped": len(skipped_ids),
572
+ "total": len(issues),
573
+ "rows": rows,
574
+ "skipped_ids": skipped_ids,
575
+ }
576
+
577
+
578
+ # ---------------------------------------------------------------------------
579
+ # Config persistence (US-SYNC-006)
580
+ #
581
+ # After a successful `roll backlog sync --repo owner/repo`, the resolved repo /
582
+ # labels / timestamp are persisted to `.roll/local.yaml` under a `backlog_sync:`
583
+ # block so subsequent `roll backlog sync` (no flags) can reuse them. We parse /
584
+ # rewrite YAML with the same regex-on-text approach the rest of the codebase
585
+ # uses (lib/roll-home.py, lib/roll-loop-status.py) — no PyYAML dependency, and
586
+ # the block is replaced surgically so unrelated keys (`agent:`, `loop_schedule:`)
587
+ # survive untouched.
588
+ # ---------------------------------------------------------------------------
589
+ SYNC_CONFIG_KEY = "backlog_sync"
590
+ DEFAULT_SYNC_DIRECTION = "issues-to-backlog"
591
+
592
+
593
+ def read_sync_config(local_yaml_path: str) -> Dict[str, Any]:
594
+ """Read the ``backlog_sync:`` block from ``local_yaml_path``.
595
+
596
+ Returns a dict with whatever keys are present (``repo``, ``direction``,
597
+ ``labels``, ``last_sync_at``); an empty dict when the file is missing or
598
+ has no ``backlog_sync:`` block (US-SYNC-006). ``labels`` is normalized to a
599
+ list. Parsing is line-based so it tolerates the rest of the YAML without a
600
+ full parser.
601
+ """
602
+ if not os.path.exists(local_yaml_path):
603
+ return {}
604
+ with open(local_yaml_path, "r", encoding="utf-8") as fh:
605
+ lines = fh.read().replace("\r\n", "\n").replace("\r", "\n").split("\n")
606
+ # Locate the top-level `backlog_sync:` key.
607
+ start = None
608
+ for idx, line in enumerate(lines):
609
+ if line.rstrip() == f"{SYNC_CONFIG_KEY}:" or \
610
+ line.startswith(f"{SYNC_CONFIG_KEY}:"):
611
+ # Must be a top-level key (no leading indentation).
612
+ if line[:1] not in (" ", "\t"):
613
+ start = idx
614
+ break
615
+ if start is None:
616
+ return {}
617
+ cfg: Dict[str, Any] = {}
618
+ for line in lines[start + 1:]:
619
+ if line.strip() == "":
620
+ continue
621
+ # A new top-level key (no indentation) ends the block.
622
+ if line[:1] not in (" ", "\t"):
623
+ break
624
+ m = re.match(r'^\s+([A-Za-z_][A-Za-z0-9_]*):\s*(.*)$', line)
625
+ if not m:
626
+ continue
627
+ key, raw = m.group(1), m.group(2).strip()
628
+ if key == "labels":
629
+ cfg["labels"] = _parse_yaml_inline_list(raw)
630
+ else:
631
+ # Strip surrounding quotes if present.
632
+ if len(raw) >= 2 and raw[0] in "'\"" and raw[-1] == raw[0]:
633
+ raw = raw[1:-1]
634
+ cfg[key] = raw
635
+ return cfg
636
+
637
+
638
+ def _parse_yaml_inline_list(raw: str) -> List[str]:
639
+ """Parse a YAML inline list literal (``[]`` / ``[a, b]``) into a list."""
640
+ raw = raw.strip()
641
+ if not raw or raw == "[]":
642
+ return []
643
+ if raw.startswith("[") and raw.endswith("]"):
644
+ inner = raw[1:-1]
645
+ return [tok.strip().strip("'\"") for tok in inner.split(",")
646
+ if tok.strip()]
647
+ # A bare scalar (single label without brackets).
648
+ return [raw.strip("'\"")]
649
+
650
+
651
+ def _render_sync_block(repo: str,
652
+ labels: List[str],
653
+ last_sync_at: str,
654
+ direction: str = DEFAULT_SYNC_DIRECTION) -> str:
655
+ """Render the ``backlog_sync:`` YAML block (no trailing newline)."""
656
+ labels_lit = "[" + ", ".join(labels) + "]" if labels else "[]"
657
+ return (
658
+ f"{SYNC_CONFIG_KEY}:\n"
659
+ f" repo: {repo}\n"
660
+ f" direction: {direction}\n"
661
+ f" labels: {labels_lit}\n"
662
+ f" last_sync_at: {last_sync_at}"
663
+ )
664
+
665
+
666
+ def write_sync_config(local_yaml_path: str,
667
+ repo: str,
668
+ *,
669
+ labels: Optional[List[str]] = None,
670
+ last_sync_at: Optional[str] = None,
671
+ direction: str = DEFAULT_SYNC_DIRECTION) -> None:
672
+ """Persist the ``backlog_sync:`` block to ``local_yaml_path`` (US-SYNC-006).
673
+
674
+ Replaces an existing top-level ``backlog_sync:`` block in place (preserving
675
+ every other key) or appends a new one when absent. Creates the file if it
676
+ does not exist. ``last_sync_at`` defaults to the current UTC time in RFC3339
677
+ form (``2026-05-28T10:00:00Z``).
678
+ """
679
+ labels = list(labels or [])
680
+ if last_sync_at is None:
681
+ last_sync_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
682
+ block = _render_sync_block(repo, labels, last_sync_at, direction)
683
+
684
+ if not os.path.exists(local_yaml_path):
685
+ os.makedirs(os.path.dirname(local_yaml_path) or ".", exist_ok=True)
686
+ with open(local_yaml_path, "w", encoding="utf-8") as fh:
687
+ fh.write(block + "\n")
688
+ return
689
+
690
+ with open(local_yaml_path, "r", encoding="utf-8") as fh:
691
+ original = fh.read()
692
+ text = original.replace("\r\n", "\n").replace("\r", "\n")
693
+ lines = text.split("\n")
694
+
695
+ start = None
696
+ for idx, line in enumerate(lines):
697
+ if (line.rstrip() == f"{SYNC_CONFIG_KEY}:" or
698
+ line.startswith(f"{SYNC_CONFIG_KEY}:")) and \
699
+ line[:1] not in (" ", "\t"):
700
+ start = idx
701
+ break
702
+
703
+ if start is None:
704
+ # Append the block, keeping a single blank-line separator.
705
+ sep = "" if text.endswith("\n\n") or text == "" else (
706
+ "\n" if text.endswith("\n") else "\n\n")
707
+ new_text = text + sep + block + "\n"
708
+ else:
709
+ # Find the end of the existing block (next top-level key or EOF).
710
+ end = start + 1
711
+ while end < len(lines):
712
+ line = lines[end]
713
+ if line.strip() != "" and line[:1] not in (" ", "\t"):
714
+ break
715
+ end += 1
716
+ new_lines = lines[:start] + block.split("\n") + lines[end:]
717
+ new_text = "\n".join(new_lines)
718
+ if not new_text.endswith("\n"):
719
+ new_text += "\n"
720
+
721
+ with open(local_yaml_path, "w", encoding="utf-8") as fh:
722
+ fh.write(new_text)
723
+
724
+
725
+ # ---------------------------------------------------------------------------
726
+ # CLI entry — `python3 lib/github_sync.py issues owner/repo` for ad-hoc use /
727
+ # direct testing when bin/roll is unavailable.
728
+ # ---------------------------------------------------------------------------
729
+ def _load_issues_for_sync(owner: str, repo: str) -> List[Dict[str, Any]]:
730
+ """Fetch open issues for sync, honouring a test fixture override.
731
+
732
+ When ``ROLL_SYNC_FIXTURE`` points at a JSON file, its contents are used
733
+ instead of a live API call. This lets the ``roll backlog sync`` integration
734
+ test exercise the full bin/roll → python write path with mocked GitHub
735
+ responses and zero network access.
736
+ """
737
+ fixture = os.environ.get("ROLL_SYNC_FIXTURE")
738
+ if fixture:
739
+ with open(fixture, "r", encoding="utf-8") as fh:
740
+ return json.load(fh)
741
+ return fetch_issues(owner, repo, state="open")
742
+
743
+
744
+ def _cmd_sync(argv: List[str]) -> int: # pragma: no cover - thin CLI wrapper
745
+ backlog = ".roll/backlog.md"
746
+ if "--backlog" in argv:
747
+ backlog = argv[argv.index("--backlog") + 1]
748
+ features_dir = ".roll/features"
749
+ if "--features" in argv:
750
+ features_dir = argv[argv.index("--features") + 1]
751
+ local_yaml = ".roll/local.yaml"
752
+ if "--local-yaml" in argv:
753
+ local_yaml = argv[argv.index("--local-yaml") + 1]
754
+
755
+ # US-SYNC-006: resolve --repo from the flag first; otherwise fall back to
756
+ # the persisted backlog_sync.repo in .roll/local.yaml. With neither, the
757
+ # first sync must be explicit.
758
+ cfg = read_sync_config(local_yaml)
759
+ if "--repo" in argv:
760
+ repo_arg = argv[argv.index("--repo") + 1]
761
+ else:
762
+ repo_arg = cfg.get("repo") or ""
763
+ if not repo_arg:
764
+ print("usage: github_sync.py sync --repo <owner/repo> "
765
+ "[--backlog <path>] [--features <dir>] [--label <a,b>] [--dry-run]\n"
766
+ " no --repo and no backlog_sync.repo in .roll/local.yaml: "
767
+ "first sync must pass --repo.\n"
768
+ " 首次 sync 必须显式 --repo(local.yaml 中尚无 backlog_sync.repo)。",
769
+ file=sys.stderr)
770
+ return 1
771
+ if "/" not in repo_arg:
772
+ print(f"invalid --repo {repo_arg!r}: expected owner/repo",
773
+ file=sys.stderr)
774
+ return 1
775
+ owner, repo = repo_arg.split("/", 1)
776
+ # --label may be repeated; each value is comma-separated. Join repeats with
777
+ # commas so parse_labels_filter sees one flat list (OR semantics). With no
778
+ # --label flag, fall back to persisted config labels (US-SYNC-006).
779
+ label_parts: List[str] = []
780
+ for i, tok in enumerate(argv):
781
+ if tok == "--label" and i + 1 < len(argv):
782
+ label_parts.append(argv[i + 1])
783
+ if label_parts:
784
+ wanted = parse_labels_filter(",".join(label_parts))
785
+ else:
786
+ wanted = parse_labels_filter(",".join(cfg.get("labels") or []))
787
+ dry_run = "--dry-run" in argv
788
+ try:
789
+ issues = _load_issues_for_sync(owner, repo)
790
+ except AuthError as exc:
791
+ print(f"auth error: {exc}", file=sys.stderr)
792
+ return 2
793
+ except RateLimitError as exc:
794
+ print(f"rate limit: {exc}", file=sys.stderr)
795
+ return 3
796
+ except GitHubAPIError as exc:
797
+ print(f"api error: {exc}", file=sys.stderr)
798
+ return 4
799
+ issues = filter_issues_by_label(issues, wanted)
800
+ if dry_run:
801
+ # US-SYNC-004: preview only — compute the diff, leave backlog.md
802
+ # untouched, exit 0 on a successful dry run.
803
+ preview = dry_run_preview(issues, backlog)
804
+ for line in preview["lines"]:
805
+ print(line)
806
+ print(f"added: {preview['added']}, skipped: {preview['skipped']}, "
807
+ f"total issues: {preview['total']} (dry-run, no changes written)")
808
+ return 0
809
+ summary = sync_to_backlog(issues, backlog)
810
+ # US-SYNC-005: for each newly-added issue, materialize a feature stub whose
811
+ # AC section is its top-level issue-body checkboxes.
812
+ skipped_set = set(summary["skipped_ids"])
813
+ for issue in issues:
814
+ if gh_id(issue) in skipped_set:
815
+ continue
816
+ write_feature_stub(issue, features_dir)
817
+ for row in summary["rows"]:
818
+ print(f"+ {row}")
819
+ for ident in summary["skipped_ids"]:
820
+ print(f"skipped (already exists): {ident}")
821
+ print(f"added: {summary['added']}, skipped: {summary['skipped']}, "
822
+ f"total issues: {summary['total']}")
823
+ # US-SYNC-006: persist the resolved repo/labels/timestamp so subsequent
824
+ # `roll backlog sync` (no flags) can reuse them.
825
+ write_sync_config(local_yaml, repo_arg, labels=wanted)
826
+ return 0
827
+
828
+
829
+ def _main(argv: List[str]) -> int: # pragma: no cover - thin CLI wrapper
830
+ if not argv or argv[0] in ("-h", "--help", "help"):
831
+ print("usage: github_sync.py issues <owner/repo> [--state all|open|closed]")
832
+ print(" github_sync.py sync --repo <owner/repo> [--backlog <path>] [--dry-run]")
833
+ return 0
834
+ cmd = argv[0]
835
+ if cmd == "sync":
836
+ return _cmd_sync(argv[1:])
837
+ if cmd == "on-loop-cycle":
838
+ # US-SYNC-008: print "true"/"false" for backlog_sync.on_loop_cycle so the
839
+ # roll-loop preflight hook can read the switch without a YAML parser.
840
+ # Default false when the file or the key is absent.
841
+ rest = argv[1:]
842
+ local_yaml = ".roll/local.yaml"
843
+ if "--local-yaml" in rest:
844
+ local_yaml = rest[rest.index("--local-yaml") + 1]
845
+ cfg = read_sync_config(local_yaml)
846
+ raw = str(cfg.get("on_loop_cycle", "")).strip().lower()
847
+ print("true" if raw in ("true", "1", "yes", "on") else "false")
848
+ return 0
849
+ if cmd != "issues" or len(argv) < 2 or "/" not in argv[1]:
850
+ print("usage: github_sync.py issues <owner/repo>", file=sys.stderr)
851
+ return 1
852
+ owner, repo = argv[1].split("/", 1)
853
+ state = "all"
854
+ if "--state" in argv:
855
+ state = argv[argv.index("--state") + 1]
856
+ try:
857
+ issues = fetch_issues(owner, repo, state=state)
858
+ except AuthError as exc:
859
+ print(f"auth error: {exc}", file=sys.stderr)
860
+ return 2
861
+ except RateLimitError as exc:
862
+ print(f"rate limit: {exc}", file=sys.stderr)
863
+ return 3
864
+ except GitHubAPIError as exc:
865
+ print(f"api error: {exc}", file=sys.stderr)
866
+ return 4
867
+ print(json.dumps(
868
+ [{"number": i.get("number"), "title": i.get("title"),
869
+ "state": i.get("state")} for i in issues],
870
+ ensure_ascii=False, indent=2,
871
+ ))
872
+ return 0
873
+
874
+
875
+ if __name__ == "__main__": # pragma: no cover
876
+ sys.exit(_main(sys.argv[1:]))