@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.
- package/CHANGELOG.md +57 -25
- package/README.md +10 -7
- package/bin/roll +3952 -317
- package/conventions/config.yaml +7 -0
- package/lib/__pycache__/github_sync.cpython-314.pyc +0 -0
- package/lib/__pycache__/loop_result_eval.cpython-314.pyc +0 -0
- package/lib/__pycache__/model_prices.cpython-314.pyc +0 -0
- package/lib/__pycache__/roll-home.cpython-314.pyc +0 -0
- package/lib/__pycache__/roll-loop-status.cpython-314.pyc +0 -0
- package/lib/__pycache__/roll_git.cpython-314.pyc +0 -0
- package/lib/__pycache__/slides-render.cpython-314.pyc +0 -0
- package/lib/agent_usage/__init__.py +4 -0
- package/lib/agent_usage/__pycache__/__init__.cpython-314.pyc +0 -0
- package/lib/agent_usage/__pycache__/gemini.cpython-314.pyc +0 -0
- package/lib/agent_usage/__pycache__/kimi.cpython-314.pyc +0 -0
- package/lib/agent_usage/__pycache__/openai.cpython-314.pyc +0 -0
- package/lib/agent_usage/__pycache__/qwen.cpython-314.pyc +0 -0
- package/lib/agent_usage/gemini.py +127 -0
- package/lib/agent_usage/kimi.py +127 -0
- package/lib/agent_usage/openai.py +126 -0
- package/lib/agent_usage/qwen.py +128 -0
- package/lib/context_feed_budget.sh +194 -0
- package/lib/github_sync.py +876 -0
- package/lib/i18n/agent.sh +54 -0
- package/lib/i18n/init.sh +22 -0
- package/lib/i18n/peer.sh +7 -0
- package/lib/i18n/peer_help.sh +4 -0
- package/lib/i18n/skills_catalog.sh +30 -0
- package/lib/loop-exit-summary.py +393 -0
- package/lib/loop-fmt.py +93 -75
- package/lib/loop_pick_agent.py +241 -170
- package/lib/loop_result_eval.py +469 -0
- package/lib/model_prices.py +0 -10
- package/lib/roll-home.py +1 -28
- package/lib/roll-loop-status.py +330 -40
- package/lib/roll-onboard-render.py +378 -0
- package/lib/roll-peer.py +1 -1
- package/lib/roll-plan-validate.py +165 -0
- package/lib/roll_git.py +41 -0
- package/lib/slides/components/README.md +8 -2
- package/lib/slides/templates/introduction-v3.html +1 -6
- package/lib/slides-render.py +305 -15
- package/lib/slides-validate.py +195 -7
- package/package.json +1 -1
- package/skills/roll-.changelog/SKILL.md +67 -56
- package/skills/roll-brief/SKILL.md +1 -1
- package/skills/roll-build/SKILL.md +14 -12
- package/skills/roll-deck/SKILL.md +152 -0
- package/skills/roll-design/SKILL.md +13 -6
- package/skills/roll-doc/SKILL.md +269 -6
- package/skills/roll-fix/SKILL.md +15 -9
- package/skills/roll-loop/SKILL.md +9 -7
- package/skills/roll-notes/SKILL.md +1 -1
- package/skills/roll-onboard/SKILL.md +85 -0
- package/skills/roll-peer/SKILL.md +6 -5
- package/lib/agent_routes_lint.py +0 -203
- package/skills/roll-research/SKILL.md +0 -316
- package/skills/roll-research/references/schema.json +0 -166
- 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:]))
|