feat(buglog): status field, buglog skill, status-driven gitea-sync
The buglog had no status field, so "is this bug open?" meant reading free text, and gitea-sync closed any issue whose `fix` text was non-empty -- including "NOT FIXED"/"PENDING"/"PARTIALLY FIXED" ones (bug-1855). - Backfill `status` (+ status_updated) on all 1,193 entries. The 85 entries whose text said not-fixed/pending/out-of-scope were checked against HEAD and git history; the rest are `fixed`. Result: 36 open, 10 partial, 2 investigating, 5 cannot-reproduce, 4 wontfix, 1 duplicate. Insert-only diff: existing lines are untouched. - .claude/rules/buglog-status.md defines the status values (kept out of openwolf.md, which `openwolf update` overwrites). - scripts/buglog.py + buglog skill: add (next free id), status, note, recur, list, show, triage. Edits only the touched entry's lines; a full json.dumps rewrite churns ~1,200 lines of mixed \u-escaping. - gitea-sync (script + skill): state and a single bug:<status> label come from `status`; reopen supported; issues shared by several entries follow the most-open one; only status labels are swapped; per-entry write-back with gitea_synced_status so unchanged entries cost no API calls; list-typed `fix` no longer crashes; spec issues close on done phases (spec.json has no implementation approval); --since no longer floods Gitea after the backfill. - Disable OpenWolf buglog auto-detection: its hook numbers new entries len(bugs)+1, which collides with existing ids (bug-1856, upstream). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8330997c5a
commit
19104f1940
7 changed files with 3333 additions and 171 deletions
34
.claude/rules/buglog-status.md
Normal file
34
.claude/rules/buglog-status.md
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
# Buglog status field
|
||||||
|
|
||||||
|
Kept separate from `openwolf.md` because `openwolf update` overwrites that file.
|
||||||
|
OpenWolf itself ignores this field (search, pre-write hints and the hook-created
|
||||||
|
entries don't read or set it), so the convention is enforced only by this rule.
|
||||||
|
|
||||||
|
- Every `.wolf/buglog.json` entry carries a `status`, and it is the only source
|
||||||
|
of truth for whether a bug is open. Never infer it from `fix`: many entries
|
||||||
|
have a populated `fix` that says "NOT FIXED", "PENDING" or "PARTIAL".
|
||||||
|
- Values:
|
||||||
|
- `open`: defect confirmed and still in the code. Intermittent test
|
||||||
|
failures that do still recur count as `open`, not `cannot-reproduce`.
|
||||||
|
- `partial`: part fixed; `status_note` says what remains.
|
||||||
|
- `investigating`: real failure observed, root cause not found yet or
|
||||||
|
waiting for a reproduction.
|
||||||
|
- `fixed`: fix committed; cite the commit in `fix` or `status_note`.
|
||||||
|
- `cannot-reproduce`: reproduction was attempted and failed; record what was
|
||||||
|
tried in `repro_attempts` (e.g. "0/40 runs, 2026-08-01").
|
||||||
|
- `wontfix`: not a bug, accepted limitation, or deferred with no plan.
|
||||||
|
- `duplicate`: same defect as another entry; set `duplicate_of`.
|
||||||
|
- Whenever you log a bug or change its state, set `status` and
|
||||||
|
`status_updated` (YYYY-MM-DD), plus `status_note` unless the status is
|
||||||
|
`fixed` with the commit already in `fix`. Put them right after `"id"`.
|
||||||
|
- A `cannot-reproduce` bug that recurs: bump `occurrences`/`last_seen` and set
|
||||||
|
`status` back to `investigating`.
|
||||||
|
- Use the `buglog` skill (`scripts/buglog.py`) to add entries and change
|
||||||
|
status: `add`, `status`, `note`, `recur`, `list`, `triage`. It picks the
|
||||||
|
next free id and edits only the touched entry's lines. Don't re-serialize
|
||||||
|
the whole file with `json.dumps`/`jq`; that rewrites ~1,200 unrelated lines.
|
||||||
|
- Open-bug list:
|
||||||
|
`jq -r '.bugs[] | select(.status | IN("open","partial","investigating")) | "\(.id) [\(.status)] \(.status_note // .error_message[0:90])"' .wolf/buglog.json`
|
||||||
|
- Entries with no `status` (hand-written without it, or created by an OpenWolf
|
||||||
|
hook) need triage:
|
||||||
|
`jq -r '.bugs[] | select(.status == null) | .id' .wolf/buglog.json`
|
||||||
64
.claude/skills/buglog/SKILL.md
Normal file
64
.claude/skills/buglog/SKILL.md
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
---
|
||||||
|
name: buglog
|
||||||
|
description: Log a new bug, change a bug's status (fixed / partial / cannot-reproduce / ...), record a recurrence, or list open bugs in the OpenWolf buglog (.wolf/buglog.json). Use whenever you log or fix a bug, or are asked for the open-bug list.
|
||||||
|
argument-hint: add | status <id> <status> | note <id> | recur <id> | list [--open] | show <id> | triage
|
||||||
|
triggers:
|
||||||
|
- /buglog
|
||||||
|
- log a bug
|
||||||
|
- open bug list
|
||||||
|
- mark bug fixed
|
||||||
|
---
|
||||||
|
|
||||||
|
# Buglog Skill
|
||||||
|
|
||||||
|
Every operation goes through `scripts/buglog.py` (run from the repo root). It
|
||||||
|
edits only the lines of the entry it touches, re-parses the file before
|
||||||
|
saving, and writes atomically. **Never** rewrite `.wolf/buglog.json` with
|
||||||
|
`json.dumps`/`jq`/the Write tool: entries mix raw UTF-8 and `\uXXXX` escapes,
|
||||||
|
so a re-serialize rewrites ~1,200 unrelated lines. Never read the file whole
|
||||||
|
either (~27K lines); use `list`/`show`.
|
||||||
|
|
||||||
|
Status values and when to use each: `.claude/rules/buglog-status.md`.
|
||||||
|
|
||||||
|
## Before fixing a bug
|
||||||
|
|
||||||
|
Search for prior art first (project rule): `openwolf bug search "<error text>"`,
|
||||||
|
or `python3 scripts/buglog.py list --grep '<regex>'`.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
| Task | Command |
|
||||||
|
|------|---------|
|
||||||
|
| Log a new bug | `python3 scripts/buglog.py add --error "<symptom, exact error text>" --root-cause "<cause>" --file <path>[,<path>] --tags scheduler,asan --status open --note "<one line>"` → prints the new id |
|
||||||
|
| Log a bug you already fixed | same, with `--fix "<what changed>, commit <sha>" --status fixed` |
|
||||||
|
| Mark fixed | `python3 scripts/buglog.py status <id> fixed --note "<sha>"` |
|
||||||
|
| Mark partly fixed | `python3 scripts/buglog.py status <id> partial --note "<what remains>"` |
|
||||||
|
| Couldn't reproduce | `python3 scripts/buglog.py status <id> cannot-reproduce --repro "0/40 runs, 2026-09-24" --note "<what was tried>"` |
|
||||||
|
| Real failure, cause unknown | `python3 scripts/buglog.py status <id> investigating --note "<next step>"` |
|
||||||
|
| Not a bug / accepted | `python3 scripts/buglog.py status <id> wontfix --note "<why / who decided>"` |
|
||||||
|
| Duplicate | `python3 scripts/buglog.py status <id> duplicate --duplicate-of <other-id> --note "<same defect as ...>"` |
|
||||||
|
| Add findings to an entry | `python3 scripts/buglog.py note <id> "<text>"` (appends an `UPDATE (date):` paragraph to `fix`) |
|
||||||
|
| Seen again | `python3 scripts/buglog.py recur <id> [--note "<where>"]` (bumps occurrences/last_seen; reopens a `fixed`/`wontfix`/`cannot-reproduce` bug) |
|
||||||
|
| Open-bug list | `python3 scripts/buglog.py list --open` (add `--tag scheduler`, `--grep`, `--since YYYY-MM-DD`) |
|
||||||
|
| One entry | `python3 scripts/buglog.py show <id>` |
|
||||||
|
| Entries missing a status | `python3 scripts/buglog.py triage` |
|
||||||
|
| Any other single-line field | `python3 scripts/buglog.py set <id> key=<json> ...` |
|
||||||
|
|
||||||
|
`status` requires `--note` for every status except `fixed`, and always
|
||||||
|
rewrites the note, so a stale note never outlives its status.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- **Ids**: let `add` pick the id (one past the highest existing). Don't number
|
||||||
|
entries by hand or by entry count. OpenWolf's post-write hook numbers by
|
||||||
|
entry count, which collides with existing ids; its auto-detection is turned
|
||||||
|
off in `.wolf/config.json` for that reason (`openwolf.buglog.auto_detect`).
|
||||||
|
- **Write the symptom first**: `--error` holds the observed error text or
|
||||||
|
behavior, not a status banner ("OPEN, NOT FIXED --" goes in `status`).
|
||||||
|
- **Fixing a bug in code**: after the fix commit, run `status <id> fixed
|
||||||
|
--note <sha>`. If only part is fixed, use `partial` with what remains.
|
||||||
|
- **Arrays** (`tags`, `related_bugs`, `files`) can only be set at `add` time;
|
||||||
|
to change them later, edit that entry's lines by hand and re-check with
|
||||||
|
`python3 -c "import json; json.load(open('.wolf/buglog.json'))"`.
|
||||||
|
- **Gitea**: status changes reach Gitea on the next `/gitea-sync`, which opens,
|
||||||
|
closes or reopens issues from `status`.
|
||||||
|
|
@ -24,6 +24,21 @@ Sync state (issue numbers) is stored in source files for idempotency.
|
||||||
/gitea-sync --dry-run # preview without making changes
|
/gitea-sync --dry-run # preview without making changes
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Preferred: run the script
|
||||||
|
|
||||||
|
`scripts/gitea-sync.py` implements every step below over the Gitea REST API
|
||||||
|
and needs no MCP server (token from `GITEA_TOKEN`, or the gitea MCP entry in
|
||||||
|
`~/.claude.json`). Use it whenever it can reach Gitea; follow the manual MCP
|
||||||
|
procedure only when it can't:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/gitea-sync.py --dry-run --bugs --since 2026-09-01 # preview
|
||||||
|
python3 scripts/gitea-sync.py --bugs --since 2026-09-01
|
||||||
|
python3 scripts/gitea-sync.py --spec <name>
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep the script and this procedure in step when changing either.
|
||||||
|
|
||||||
## Procedure
|
## Procedure
|
||||||
|
|
||||||
### 0. Parse arguments
|
### 0. Parse arguments
|
||||||
|
|
@ -31,7 +46,10 @@ Sync state (issue numbers) is stored in source files for idempotency.
|
||||||
- `--dry-run`: only report what would change, don't call MCP write tools
|
- `--dry-run`: only report what would change, don't call MCP write tools
|
||||||
- `--spec <name>`: sync only one spec directory
|
- `--spec <name>`: sync only one spec directory
|
||||||
- `--bugs`: sync bugs only (skip specs)
|
- `--bugs`: sync bugs only (skip specs)
|
||||||
- `--since <YYYY-MM-DD>`: only sync bugs with `last_seen >=` this date
|
- `--since <YYYY-MM-DD>`: create issues only for bugs with `last_seen >=` this
|
||||||
|
date; for already-synced bugs (`gitea_issue` set), also mirror those whose
|
||||||
|
`status_updated >=` this date, so a status change on an old bug still
|
||||||
|
reaches Gitea without flooding it with old never-synced bugs
|
||||||
|
|
||||||
### 1. Ensure labels exist
|
### 1. Ensure labels exist
|
||||||
|
|
||||||
|
|
@ -49,6 +67,16 @@ for any missing ones:
|
||||||
| `spec` | `3498DB` | Spec-tracked issue |
|
| `spec` | `3498DB` | Spec-tracked issue |
|
||||||
| `bug` | `E74C3C` | Bug-tracked issue |
|
| `bug` | `E74C3C` | Bug-tracked issue |
|
||||||
| `bug:fixed` | `27AE60` | Bug resolved |
|
| `bug:fixed` | `27AE60` | Bug resolved |
|
||||||
|
| `bug:partial` | `F39C12` | Partly fixed, remainder open |
|
||||||
|
| `bug:investigating` | `E67E22` | Real failure, root cause not found yet |
|
||||||
|
| `bug:cannot-reproduce` | `95A5A6` | Repro attempted and failed |
|
||||||
|
| `bug:wontfix` | `7F8C8D` | Not a bug / accepted limitation |
|
||||||
|
| `bug:duplicate` | `BDC3C7` | Duplicate of another bug |
|
||||||
|
| `bug:asan` | `8E44AD` | Sanitizer finding |
|
||||||
|
| `bug:crash` | `C0392B` | Crash (SEGV, double-free, UAF) |
|
||||||
|
| `bug:race` | `D35400` | Race / TOCTOU |
|
||||||
|
| `bug:ha` | `2980B9` | HA / Raft / failover |
|
||||||
|
| `bug:data-loss` | `922B21` | Data loss |
|
||||||
| `blocked` | `E74C3C` | Blocked on dependency |
|
| `blocked` | `E74C3C` | Blocked on dependency |
|
||||||
|
|
||||||
### 2. Sync Specs
|
### 2. Sync Specs
|
||||||
|
|
@ -95,30 +123,72 @@ For each spec directory in `.kiro/specs/` (or just `--spec <name>`):
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
d. **Phase state updates** (for already-synced specs): Check `spec.json`
|
d. **Phase state updates** (for already-synced specs). `spec.json`
|
||||||
approvals. If requirements AND design are approved → close the
|
`approvals` only covers requirements/design/tasks — there is no
|
||||||
requirements_design issue via `mcp__gitea__issue_write` method `update`
|
implementation approval — so use `phase` for the later issues:
|
||||||
with `state: "closed"`. If implementation approved → close
|
|
||||||
implementation and validation issues. If all approved → close milestone
|
"Done" phases: `implementation-complete`, `implemented`, `superseded`
|
||||||
via `mcp__gitea__milestone_write` method `update`.
|
(no tool distinguishes the first two).
|
||||||
|
|
||||||
|
| Condition | Action |
|
||||||
|
|-----------|--------|
|
||||||
|
| `approvals.requirements.approved` AND `approvals.design.approved`, or phase is done | close `requirements_design` |
|
||||||
|
| phase is done | close `implementation`, `validation` and the milestone |
|
||||||
|
| phase is not done | reopen `implementation`/`validation`/milestone if closed |
|
||||||
|
|
||||||
|
The same rules give the initial state when the issues are first created.
|
||||||
|
|
||||||
|
Close issues via `mcp__gitea__issue_write` method `update` with
|
||||||
|
`state: "closed"`, the milestone via `mcp__gitea__milestone_write`
|
||||||
|
method `update`.
|
||||||
|
|
||||||
### 3. Sync Bugs
|
### 3. Sync Bugs
|
||||||
|
|
||||||
Read `.wolf/buglog.json`. For each entry:
|
Read `.wolf/buglog.json`. Each entry's `status` field is the source of truth
|
||||||
|
for open/closed — **never infer it from `fix`**: many entries have a populated
|
||||||
|
`fix` that says "NOT FIXED", "PENDING" or "PARTIALLY FIXED".
|
||||||
|
|
||||||
1. **Skip** if:
|
**Status → Gitea state / label** (`status` values are defined in
|
||||||
- Entry has `gitea_issue` field (already synced) AND bug was already fixed
|
`.claude/rules/buglog-status.md`):
|
||||||
when synced (no state change needed).
|
|
||||||
- Filter: if `--since <date>` provided, skip entries with
|
| `status` | Gitea state | Status label |
|
||||||
`last_seen < <date>`.
|
|----------|-------------|--------------|
|
||||||
|
| `open` | open | — |
|
||||||
|
| `partial` | open | `bug:partial` |
|
||||||
|
| `investigating` | open | `bug:investigating` |
|
||||||
|
| `fixed` | closed | `bug:fixed` |
|
||||||
|
| `cannot-reproduce` | closed | `bug:cannot-reproduce` |
|
||||||
|
| `wontfix` | closed | `bug:wontfix` |
|
||||||
|
| `duplicate` | closed | `bug:duplicate` |
|
||||||
|
| missing (hook-created entry) | open | — ; report it in the summary as "needs status" |
|
||||||
|
|
||||||
|
An issue carries exactly one status label. When changing it, remove only the
|
||||||
|
other `bug:<status>` labels and add the new one; never replace the issue's
|
||||||
|
whole label set, which would wipe labels a person added.
|
||||||
|
|
||||||
|
**Shared issues**: several entries can point at one `gitea_issue` (e.g. a
|
||||||
|
user-reported issue whose cause was split into several bugs, like #46). Such
|
||||||
|
an issue follows its "most open" entry, in this order: `open`,
|
||||||
|
`investigating`, `partial`, (no status), `cannot-reproduce`, `duplicate`,
|
||||||
|
`wontfix`, `fixed`.
|
||||||
|
|
||||||
|
For each entry (after the `--since` filter):
|
||||||
|
|
||||||
|
1. **Skip** if it has `gitea_issue` AND `gitea_synced_status == status`
|
||||||
|
(already mirrored, nothing changed).
|
||||||
|
|
||||||
2. **Create issue** for entries without `gitea_issue`:
|
2. **Create issue** for entries without `gitea_issue`:
|
||||||
Use `mcp__gitea__issue_write` method `create`.
|
Use `mcp__gitea__issue_write` method `create`.
|
||||||
|
|
||||||
**Title**: `[<bug-id>] <first sentence of error_message>` (max 120 chars)
|
**Title**: `[<bug-id>] <first sentence of error_message>` (max 120 chars),
|
||||||
|
after dropping any leading status banner such as "OPEN, NOT FIXED --" or
|
||||||
|
"PRIORITY: LOW --" (status lives in the state and labels).
|
||||||
|
|
||||||
**Body**:
|
**Body**:
|
||||||
```
|
```
|
||||||
|
**Status:** <status> (<status_note>, if present)
|
||||||
|
<"Duplicate of <duplicate_of>" / "Repro attempts: <repro_attempts>", if present>
|
||||||
|
|
||||||
## Error
|
## Error
|
||||||
<error_message>
|
<error_message>
|
||||||
|
|
||||||
|
|
@ -126,10 +196,11 @@ Read `.wolf/buglog.json`. For each entry:
|
||||||
<root_cause>
|
<root_cause>
|
||||||
|
|
||||||
## Fix
|
## Fix
|
||||||
<fix> (or "*Not yet fixed*" if empty)
|
<fix> (if `fix` is an array, join the elements with blank lines;
|
||||||
|
"*Not yet fixed*" if empty)
|
||||||
|
|
||||||
## Affected Files
|
## Affected Files
|
||||||
- <file> (split on commas)
|
- <file> (split on commas), or each element of `files`
|
||||||
|
|
||||||
## Tags
|
## Tags
|
||||||
`tag1` `tag2` ...
|
`tag1` `tag2` ...
|
||||||
|
|
@ -141,41 +212,62 @@ Read `.wolf/buglog.json`. For each entry:
|
||||||
*Synced from `.wolf/buglog.json`*
|
*Synced from `.wolf/buglog.json`*
|
||||||
```
|
```
|
||||||
|
|
||||||
**Labels**: Start with `["bug"]`. If `fix` is populated, add `bug:fixed`.
|
**Labels**: `["bug"]` + the status label from the table above. Map known
|
||||||
Map known tags to labels: `asan`/`ubsan` → create `bug:asan` label,
|
tags to labels: `asan`/`ubsan` → `bug:asan`, `SEGV`/`double-free`/`UAF` →
|
||||||
`SEGV`/`double-free`/`UAF` → `bug:crash`, `race`/`toctou` → `bug:race`,
|
`bug:crash`, `race`/`toctou` → `bug:race`, `ha`/`raft`/`election`/`failover`
|
||||||
`ha`/`raft`/`election`/`failover` → `bug:ha`, `data-loss` → `bug:data-loss`.
|
→ `bug:ha`, `data-loss` → `bug:data-loss`.
|
||||||
|
|
||||||
**State**: `"closed"` if `fix` is populated, `"open"` otherwise.
|
**State**: from the table above. `state: "closed"` may be ignored on
|
||||||
|
`create` — create as open, then `update` to closed if needed.
|
||||||
|
|
||||||
**Milestone**: None (bugs span specs).
|
**Milestone**: None (bugs span specs).
|
||||||
|
|
||||||
3. **Close fixed bugs**: For entries WITH `gitea_issue` where `fix` is now
|
3. **Update changed issues**: for entries WITH `gitea_issue` whose `status`
|
||||||
populated (was empty before — we track by checking if the Gitea issue is
|
differs from `gitea_synced_status` (or that have no `gitea_synced_status`
|
||||||
still open). Use `mcp__gitea__issue_write` method `update` with
|
yet), `update` the issue's state and swap its status label per the table
|
||||||
`state: "closed"`.
|
(using the aggregate status for shared issues).
|
||||||
|
This both closes newly fixed bugs and **reopens** bugs whose status went
|
||||||
|
back to `open`/`investigating` (e.g. a `cannot-reproduce` bug that recurred).
|
||||||
|
Post a one-line comment with the new status and `status_note`.
|
||||||
|
|
||||||
4. **Write `gitea_issue`** back to the buglog entry: add
|
**One-time repair**: issues synced before the `status` field existed were
|
||||||
`"gitea_issue": <issue_number>` to the entry in `.wolf/buglog.json`.
|
closed whenever `fix` was non-empty, so entries with `gitea_issue` and no
|
||||||
Read the full file, modify the matching entry, write back with
|
`gitea_synced_status` whose status is `open`/`partial`/`investigating` are
|
||||||
`json.dumps(data, indent=2)`.
|
probably wrongly closed — step 3 reopens them.
|
||||||
|
|
||||||
|
4. **Write sync state back** to the buglog entry immediately after each
|
||||||
|
issue is created/updated (not once at the end — a failure part-way through
|
||||||
|
must not lose an issue number, or the next run creates a duplicate issue), with the surgical helper —
|
||||||
|
never re-serialize the whole file (`json.dumps`/`jq` rewrite ~1,200
|
||||||
|
unrelated lines because entries mix raw UTF-8 and `\uXXXX` escapes):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/buglog.py set <bug-id> \
|
||||||
|
gitea_issue=<issue_number> 'gitea_synced_status="<status>"'
|
||||||
|
```
|
||||||
|
|
||||||
|
Run it from the repo root. It edits only that entry's lines and refuses to
|
||||||
|
write anything that no longer parses. For entries without a `status`,
|
||||||
|
record `gitea_synced_status="unknown"`.
|
||||||
|
|
||||||
### 4. Report
|
### 4. Report
|
||||||
|
|
||||||
Print summary: "Specs: N synced, M skipped | Bugs: X created, Y closed, Z skipped"
|
Print summary: "Specs: N synced, M skipped | Bugs: X created, Y closed, R reopened, Z skipped"
|
||||||
|
and list any entries with no `status` (hook-created entries) so they can be triaged.
|
||||||
|
|
||||||
## Important Rules
|
## Important Rules
|
||||||
|
|
||||||
- **Files are ground truth**. Never read Gitea state to update local files.
|
- **Files are ground truth**. Never read Gitea state to update local files.
|
||||||
The gitea_issue/milestone_id fields in local files only store sync mappings.
|
The gitea_issue/milestone_id fields in local files only store sync mappings.
|
||||||
- **Idempotent**. Items with existing `gitea_issue` or `gitea` state are skipped
|
- **Idempotent**. Items with existing `gitea_issue` or `gitea` state are skipped
|
||||||
unless their phase/fix state changed.
|
unless their phase changed (specs) or `status != gitea_synced_status` (bugs).
|
||||||
- **Labels**: Check existing labels before creating. Use label IDs (not names)
|
- **Labels**: Check existing labels before creating. Use label IDs (not names)
|
||||||
in issue create calls — fetch with `mcp__gitea__label_read` first.
|
in issue create calls — fetch with `mcp__gitea__label_read` first.
|
||||||
- **Rate limiting**: Batch issue creation — create issues sequentially
|
- **Rate limiting**: Batch issue creation — create issues sequentially
|
||||||
(a few per turn) to stay within API limits.
|
(a few per turn) to stay within API limits.
|
||||||
- **Buglog is large** (~13K lines). Always use `--since` filter for
|
- **Buglog is large** (~1,200 entries, ~27K lines). Always use `--since` filter
|
||||||
practical runs (e.g., `--since 2026-07-18`). Without `--since`, sync ALL bugs.
|
for practical runs (e.g., `--since 2026-07-18`). Without `--since`, sync ALL
|
||||||
|
bugs. Filter with `jq`, don't read the file whole.
|
||||||
- **Private repo**: `nexa/xce` is private. MCP auth must have access.
|
- **Private repo**: `nexa/xce` is private. MCP auth must have access.
|
||||||
|
|
||||||
## Gitea MCP Tool Reference
|
## Gitea MCP Tool Reference
|
||||||
|
|
|
||||||
2519
.wolf/buglog.json
2519
.wolf/buglog.json
File diff suppressed because one or more lines are too long
|
|
@ -2,6 +2,9 @@
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"openwolf": {
|
"openwolf": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"buglog": {
|
||||||
|
"auto_detect": false
|
||||||
|
},
|
||||||
"anatomy": {
|
"anatomy": {
|
||||||
"auto_scan_on_init": true,
|
"auto_scan_on_init": true,
|
||||||
"rescan_interval_hours": 6,
|
"rescan_interval_hours": 6,
|
||||||
|
|
@ -116,4 +119,4 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
335
scripts/buglog.py
Normal file
335
scripts/buglog.py
Normal file
|
|
@ -0,0 +1,335 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Read and edit .wolf/buglog.json without re-serializing it.
|
||||||
|
|
||||||
|
Re-serializing the whole buglog (json.dumps / jq) rewrites ~1,200 unrelated
|
||||||
|
lines because existing entries mix raw UTF-8 and \\uXXXX escapes, so every
|
||||||
|
write here edits only the lines of the entry it touches. Every write is
|
||||||
|
re-parsed before it is saved and saved atomically (tmp file + rename), the
|
||||||
|
same way OpenWolf's own hooks write it.
|
||||||
|
|
||||||
|
The `status` values and when to use each are defined in
|
||||||
|
.claude/rules/buglog-status.md.
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
add --error MSG [--root-cause T] [--fix T] [--file F] [--tags a,b]
|
||||||
|
[--related bug-1,bug-2] [--status S] [--note T]
|
||||||
|
Append a new entry with the next free id; prints the id.
|
||||||
|
status ID STATUS [--note T] [--duplicate-of ID] [--repro T]
|
||||||
|
Set status + status_updated (today) + status_note. --note is
|
||||||
|
required except for `fixed`; duplicate needs --duplicate-of,
|
||||||
|
cannot-reproduce needs --repro.
|
||||||
|
note ID TEXT Append an "UPDATE (date): TEXT" paragraph to `fix`.
|
||||||
|
recur ID [--note T] The bug was seen again: bump occurrences/last_seen;
|
||||||
|
cannot-reproduce -> investigating, fixed/wontfix -> open.
|
||||||
|
set ID key=<json> ... Set arbitrary single-line fields.
|
||||||
|
show ID Print one entry.
|
||||||
|
list [--status S,S | --open] [--tag T] [--grep RE] [--since DATE]
|
||||||
|
One line per matching entry.
|
||||||
|
triage List entries with no (or an unknown) status.
|
||||||
|
next-id Print the id `add` would use.
|
||||||
|
|
||||||
|
Importable: set_fields(path, bug_id, {key: value}), BuglogEditError.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
DEFAULT_PATH = Path(__file__).resolve().parent.parent / ".wolf" / "buglog.json"
|
||||||
|
|
||||||
|
STATUSES = ["open", "partial", "investigating", "fixed",
|
||||||
|
"cannot-reproduce", "wontfix", "duplicate"]
|
||||||
|
OPEN_STATUSES = {"open", "partial", "investigating"}
|
||||||
|
|
||||||
|
|
||||||
|
class BuglogEditError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _today():
|
||||||
|
return datetime.date.today().isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _now():
|
||||||
|
return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
|
||||||
|
|
||||||
|
def _load(path):
|
||||||
|
return json.loads(Path(path).read_text(encoding="utf-8"))["bugs"]
|
||||||
|
|
||||||
|
|
||||||
|
def _save(path, text):
|
||||||
|
"""Refuse to write text that no longer parses; write atomically."""
|
||||||
|
json.loads(text)
|
||||||
|
path = Path(path)
|
||||||
|
tmp = path.with_name(f"{path.name}.{os.getpid()}.tmp")
|
||||||
|
tmp.write_text(text, encoding="utf-8")
|
||||||
|
os.replace(tmp, path)
|
||||||
|
|
||||||
|
|
||||||
|
def _entry_span(lines, bug_id):
|
||||||
|
"""(start, end, indent) of the entry: start = its "id" line, end = its '}'."""
|
||||||
|
id_re = re.compile(r'^(\s+)"id": ' + re.escape(json.dumps(bug_id)) + r",?$")
|
||||||
|
starts = [i for i, ln in enumerate(lines) if id_re.match(ln)]
|
||||||
|
if len(starts) != 1:
|
||||||
|
raise BuglogEditError(f"{bug_id}: expected exactly one entry, found {len(starts)}")
|
||||||
|
start = starts[0]
|
||||||
|
indent = id_re.match(lines[start]).group(1)
|
||||||
|
close_re = re.compile(r"^" + re.escape(indent[:-2]) + r"\},?$")
|
||||||
|
end = next(i for i in range(start, len(lines)) if close_re.match(lines[i]))
|
||||||
|
return start, end, indent
|
||||||
|
|
||||||
|
|
||||||
|
def set_fields(path, bug_id, fields):
|
||||||
|
"""Set each key in `fields` on entry `bug_id`, touching only its lines.
|
||||||
|
|
||||||
|
An existing single-line field is replaced in place; a new one is inserted
|
||||||
|
right after the entry's "id" line. Multi-line (array/object) fields are
|
||||||
|
refused -- edit those by hand.
|
||||||
|
"""
|
||||||
|
path = Path(path)
|
||||||
|
lines = path.read_text(encoding="utf-8").split("\n")
|
||||||
|
start, end, indent = _entry_span(lines, bug_id)
|
||||||
|
|
||||||
|
for key, value in fields.items():
|
||||||
|
rendered = json.dumps(value, ensure_ascii=False)
|
||||||
|
key_re = re.compile(r"^" + re.escape(indent) + re.escape(json.dumps(key)) + r": (.*?)(,?)$")
|
||||||
|
hit = next((i for i in range(start, end) if key_re.match(lines[i])), None)
|
||||||
|
if hit is not None:
|
||||||
|
old, comma = key_re.match(lines[hit]).groups()
|
||||||
|
if old in ("[", "{"):
|
||||||
|
raise BuglogEditError(f"{bug_id}.{key}: multi-line value, edit by hand")
|
||||||
|
lines[hit] = f"{indent}{json.dumps(key)}: {rendered}{comma}"
|
||||||
|
else:
|
||||||
|
lines.insert(start + 1, f"{indent}{json.dumps(key)}: {rendered},")
|
||||||
|
end += 1
|
||||||
|
|
||||||
|
text = "\n".join(lines)
|
||||||
|
entry = next(b for b in json.loads(text)["bugs"] if b.get("id") == bug_id)
|
||||||
|
for key, value in fields.items():
|
||||||
|
if entry[key] != value:
|
||||||
|
raise BuglogEditError(f"{bug_id}.{key}: wrote {entry[key]!r}, wanted {value!r}")
|
||||||
|
_save(path, text)
|
||||||
|
|
||||||
|
|
||||||
|
def next_id(bugs):
|
||||||
|
"""One past the highest numeric id. (OpenWolf's own hook uses len(bugs)+1,
|
||||||
|
which collides with existing ids once any were renumbered or skipped.)"""
|
||||||
|
nums = [int(m.group(1)) for b in bugs
|
||||||
|
if (m := re.fullmatch(r"bug-(\d+)", str(b.get("id", ""))))]
|
||||||
|
return f"bug-{max(nums, default=0) + 1:03d}"
|
||||||
|
|
||||||
|
|
||||||
|
def append_entry(path, entry):
|
||||||
|
"""Append `entry` as the last element of the `bugs` array."""
|
||||||
|
path = Path(path)
|
||||||
|
lines = path.read_text(encoding="utf-8").split("\n")
|
||||||
|
close = max(i for i, ln in enumerate(lines) if ln == " ]")
|
||||||
|
prev = close - 1
|
||||||
|
if lines[prev] == " }":
|
||||||
|
lines[prev] = " },"
|
||||||
|
elif lines[prev] != " \"bugs\": [":
|
||||||
|
raise BuglogEditError(f"unexpected line before bugs array end: {lines[prev]!r}")
|
||||||
|
body = json.dumps(entry, indent=2, ensure_ascii=False).split("\n")
|
||||||
|
lines[close:close] = [" " + ln for ln in body]
|
||||||
|
text = "\n".join(lines)
|
||||||
|
if json.loads(text)["bugs"][-1] != entry:
|
||||||
|
raise BuglogEditError("appended entry did not round-trip")
|
||||||
|
_save(path, text)
|
||||||
|
|
||||||
|
|
||||||
|
def _split(value):
|
||||||
|
return [v.strip() for v in (value or "").split(",") if v.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _text(value):
|
||||||
|
if isinstance(value, list):
|
||||||
|
return " || ".join(str(v) for v in value)
|
||||||
|
return value or ""
|
||||||
|
|
||||||
|
|
||||||
|
def _find(bugs, bug_id):
|
||||||
|
for b in bugs:
|
||||||
|
if b.get("id") == bug_id:
|
||||||
|
return b
|
||||||
|
raise BuglogEditError(f"{bug_id}: no such entry")
|
||||||
|
|
||||||
|
|
||||||
|
def _line(b):
|
||||||
|
note = b.get("status_note") or _text(b.get("error_message"))
|
||||||
|
return f"{b['id']:<10} {b.get('status') or '-':<16} {note[:110]}"
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_add(a, path):
|
||||||
|
if a.status not in STATUSES:
|
||||||
|
sys.exit(f"unknown status {a.status!r}; one of {', '.join(STATUSES)}")
|
||||||
|
bugs = _load(path)
|
||||||
|
bid = next_id(bugs)
|
||||||
|
now = _now()
|
||||||
|
entry = {"id": bid, "status": a.status, "status_updated": _today()}
|
||||||
|
if a.note:
|
||||||
|
entry["status_note"] = a.note
|
||||||
|
entry.update({
|
||||||
|
"timestamp": now,
|
||||||
|
"error_message": a.error,
|
||||||
|
"file": a.file or "",
|
||||||
|
"root_cause": a.root_cause or "",
|
||||||
|
"fix": a.fix or "",
|
||||||
|
"tags": _split(a.tags),
|
||||||
|
"related_bugs": _split(a.related),
|
||||||
|
"occurrences": 1,
|
||||||
|
"last_seen": now,
|
||||||
|
})
|
||||||
|
append_entry(path, entry)
|
||||||
|
print(bid)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_status(a, path):
|
||||||
|
if a.status not in STATUSES:
|
||||||
|
sys.exit(f"unknown status {a.status!r}; one of {', '.join(STATUSES)}")
|
||||||
|
if a.status == "duplicate" and not a.duplicate_of:
|
||||||
|
sys.exit("duplicate needs --duplicate-of")
|
||||||
|
if a.status == "cannot-reproduce" and not a.repro:
|
||||||
|
sys.exit("cannot-reproduce needs --repro (what was tried, e.g. '0/40 runs, 2026-08-01')")
|
||||||
|
if a.note is None and a.status != "fixed":
|
||||||
|
sys.exit(f"{a.status} needs --note (what remains / why / what was seen)")
|
||||||
|
# Always (re)write the note so a stale one never outlives its status.
|
||||||
|
fields = {"status": a.status, "status_updated": _today(),
|
||||||
|
"status_note": a.note or ""}
|
||||||
|
if a.duplicate_of:
|
||||||
|
fields["duplicate_of"] = a.duplicate_of
|
||||||
|
if a.repro:
|
||||||
|
fields["repro_attempts"] = a.repro
|
||||||
|
set_fields(path, a.id, fields)
|
||||||
|
print(_line(_find(_load(path), a.id)))
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_note(a, path):
|
||||||
|
fix = _find(_load(path), a.id).get("fix") or ""
|
||||||
|
if isinstance(fix, list):
|
||||||
|
raise BuglogEditError(f"{a.id}.fix is a list, edit by hand")
|
||||||
|
para = f"UPDATE ({_today()}): {a.text}"
|
||||||
|
set_fields(path, a.id, {"fix": f"{fix}\n\n{para}" if fix else para})
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_recur(a, path):
|
||||||
|
b = _find(_load(path), a.id)
|
||||||
|
fields = {"occurrences": int(b.get("occurrences") or 1) + 1, "last_seen": _now()}
|
||||||
|
reopen = {"cannot-reproduce": "investigating", "fixed": "open", "wontfix": "open"}
|
||||||
|
if b.get("status") in reopen:
|
||||||
|
fields.update(status=reopen[b["status"]], status_updated=_today())
|
||||||
|
fields["status_note"] = a.note or f"recurred {_today()} (was {b['status']})"
|
||||||
|
elif a.note:
|
||||||
|
fields["status_note"] = a.note
|
||||||
|
set_fields(path, a.id, fields)
|
||||||
|
print(_line(_find(_load(path), a.id)))
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_set(a, path):
|
||||||
|
fields = {}
|
||||||
|
for kv in a.pairs:
|
||||||
|
key, sep, raw = kv.partition("=")
|
||||||
|
if not sep or not key:
|
||||||
|
sys.exit(f"bad argument {kv!r}: expected key=value")
|
||||||
|
try:
|
||||||
|
fields[key] = json.loads(raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
fields[key] = raw
|
||||||
|
set_fields(path, a.id, fields)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_show(a, path):
|
||||||
|
print(json.dumps(_find(_load(path), a.id), indent=2, ensure_ascii=False))
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_list(a, path):
|
||||||
|
want = OPEN_STATUSES if a.open else set(_split(a.status)) or None
|
||||||
|
rx = re.compile(a.grep, re.I) if a.grep else None
|
||||||
|
for b in _load(path):
|
||||||
|
if want is not None and b.get("status") not in want:
|
||||||
|
continue
|
||||||
|
if a.tag and a.tag not in (b.get("tags") or []):
|
||||||
|
continue
|
||||||
|
if a.since and max(b.get("last_seen") or "", b.get("status_updated") or "") < a.since:
|
||||||
|
continue
|
||||||
|
if rx and not rx.search(json.dumps(b, ensure_ascii=False)):
|
||||||
|
continue
|
||||||
|
print(_line(b))
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_triage(a, path):
|
||||||
|
for b in _load(path):
|
||||||
|
if b.get("status") not in STATUSES:
|
||||||
|
print(_line(b))
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
p = argparse.ArgumentParser(description=__doc__.split("\n")[0],
|
||||||
|
epilog="Status values: " + ", ".join(STATUSES))
|
||||||
|
p.add_argument("--path", default=str(DEFAULT_PATH), help=argparse.SUPPRESS)
|
||||||
|
sub = p.add_subparsers(dest="cmd", required=True)
|
||||||
|
|
||||||
|
s = sub.add_parser("add")
|
||||||
|
s.add_argument("--error", required=True)
|
||||||
|
s.add_argument("--root-cause")
|
||||||
|
s.add_argument("--fix")
|
||||||
|
s.add_argument("--file")
|
||||||
|
s.add_argument("--tags")
|
||||||
|
s.add_argument("--related")
|
||||||
|
s.add_argument("--status", default="open")
|
||||||
|
s.add_argument("--note")
|
||||||
|
s.set_defaults(fn=cmd_add)
|
||||||
|
|
||||||
|
s = sub.add_parser("status")
|
||||||
|
s.add_argument("id")
|
||||||
|
s.add_argument("status")
|
||||||
|
s.add_argument("--note")
|
||||||
|
s.add_argument("--duplicate-of")
|
||||||
|
s.add_argument("--repro")
|
||||||
|
s.set_defaults(fn=cmd_status)
|
||||||
|
|
||||||
|
s = sub.add_parser("note")
|
||||||
|
s.add_argument("id")
|
||||||
|
s.add_argument("text")
|
||||||
|
s.set_defaults(fn=cmd_note)
|
||||||
|
|
||||||
|
s = sub.add_parser("recur")
|
||||||
|
s.add_argument("id")
|
||||||
|
s.add_argument("--note")
|
||||||
|
s.set_defaults(fn=cmd_recur)
|
||||||
|
|
||||||
|
s = sub.add_parser("set")
|
||||||
|
s.add_argument("id")
|
||||||
|
s.add_argument("pairs", nargs="+")
|
||||||
|
s.set_defaults(fn=cmd_set)
|
||||||
|
|
||||||
|
s = sub.add_parser("show")
|
||||||
|
s.add_argument("id")
|
||||||
|
s.set_defaults(fn=cmd_show)
|
||||||
|
|
||||||
|
s = sub.add_parser("list")
|
||||||
|
s.add_argument("--status")
|
||||||
|
s.add_argument("--open", action="store_true")
|
||||||
|
s.add_argument("--tag")
|
||||||
|
s.add_argument("--grep")
|
||||||
|
s.add_argument("--since")
|
||||||
|
s.set_defaults(fn=cmd_list)
|
||||||
|
|
||||||
|
s = sub.add_parser("triage")
|
||||||
|
s.set_defaults(fn=cmd_triage)
|
||||||
|
|
||||||
|
s = sub.add_parser("next-id")
|
||||||
|
s.set_defaults(fn=lambda a, path: print(next_id(_load(path))))
|
||||||
|
|
||||||
|
a = p.parse_args(argv)
|
||||||
|
try:
|
||||||
|
a.fn(a, a.path)
|
||||||
|
except BuglogEditError as e:
|
||||||
|
sys.exit(str(e))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -34,6 +34,9 @@ import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
from buglog import BuglogEditError, set_fields # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -120,6 +123,27 @@ LABELS = [
|
||||||
"description": "Bug found during development"},
|
"description": "Bug found during development"},
|
||||||
{"name": "bug:fixed", "color": "27AE60",
|
{"name": "bug:fixed", "color": "27AE60",
|
||||||
"description": "Bug resolved and fix committed"},
|
"description": "Bug resolved and fix committed"},
|
||||||
|
{"name": "bug:partial", "color": "F39C12",
|
||||||
|
"description": "Partly fixed, remainder still open"},
|
||||||
|
{"name": "bug:investigating", "color": "E67E22",
|
||||||
|
"description": "Real failure, root cause not found yet"},
|
||||||
|
{"name": "bug:cannot-reproduce", "color": "95A5A6",
|
||||||
|
"description": "Reproduction attempted and failed"},
|
||||||
|
{"name": "bug:wontfix", "color": "7F8C8D",
|
||||||
|
"description": "Not a bug / accepted limitation"},
|
||||||
|
{"name": "bug:duplicate", "color": "BDC3C7",
|
||||||
|
"description": "Duplicate of another bug"},
|
||||||
|
# Bug category labels (from buglog tags, see TAG_TO_LABEL)
|
||||||
|
{"name": "bug:asan", "color": "9B59B6",
|
||||||
|
"description": "ASAN/UBSAN caught bug"},
|
||||||
|
{"name": "bug:crash", "color": "C0392B",
|
||||||
|
"description": "SEGV/UAF/double-free crash"},
|
||||||
|
{"name": "bug:data-loss", "color": "C0392B",
|
||||||
|
"description": "Committed data loss bug"},
|
||||||
|
{"name": "bug:ha", "color": "E67E22",
|
||||||
|
"description": "HA/Raft/consensus bug"},
|
||||||
|
{"name": "bug:race", "color": "F39C12",
|
||||||
|
"description": "Data race / TOCTOU bug"},
|
||||||
# State labels
|
# State labels
|
||||||
{"name": "blocked", "color": "E74C3C",
|
{"name": "blocked", "color": "E74C3C",
|
||||||
"description": "Blocked on external dependency or upstream spec"},
|
"description": "Blocked on external dependency or upstream spec"},
|
||||||
|
|
@ -143,6 +167,45 @@ TAG_TO_LABEL = {
|
||||||
"failover": "bug:ha",
|
"failover": "bug:ha",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# buglog `status` -> (Gitea issue state, status label). The buglog's `status`
|
||||||
|
# field is the only source of truth for open/closed (see
|
||||||
|
# .claude/rules/buglog-status.md); never infer it from `fix`, which often says
|
||||||
|
# "NOT FIXED" / "PENDING" / "PARTIALLY FIXED". An entry with no status (e.g.
|
||||||
|
# created by an OpenWolf hook) is mirrored as open and reported for triage.
|
||||||
|
STATUS_MAP = {
|
||||||
|
"open": ("open", None),
|
||||||
|
"partial": ("open", "bug:partial"),
|
||||||
|
"investigating": ("open", "bug:investigating"),
|
||||||
|
"fixed": ("closed", "bug:fixed"),
|
||||||
|
"cannot-reproduce": ("closed", "bug:cannot-reproduce"),
|
||||||
|
"wontfix": ("closed", "bug:wontfix"),
|
||||||
|
"duplicate": ("closed", "bug:duplicate"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# When several buglog entries share one Gitea issue (e.g. a user-reported
|
||||||
|
# issue whose cause was split into several bugs), the issue takes the status
|
||||||
|
# of its "most open" entry, first in this list.
|
||||||
|
STATUS_PRIORITY = ["open", "investigating", "partial", None,
|
||||||
|
"cannot-reproduce", "duplicate", "wontfix", "fixed"]
|
||||||
|
|
||||||
|
|
||||||
|
def bug_status(bug_entry):
|
||||||
|
"""The entry's status, or None when it has none / an unknown one."""
|
||||||
|
status = bug_entry.get("status")
|
||||||
|
return status if status in STATUS_MAP else None
|
||||||
|
|
||||||
|
|
||||||
|
def bug_state(bug_entry):
|
||||||
|
return STATUS_MAP.get(bug_status(bug_entry), ("open", None))[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _text(value):
|
||||||
|
"""buglog text fields are a string or, in some entries, a list of strings."""
|
||||||
|
if isinstance(value, list):
|
||||||
|
return "\n\n".join(str(v).strip() for v in value if str(v).strip())
|
||||||
|
return (value or "").strip()
|
||||||
|
|
||||||
|
|
||||||
def api_url(path):
|
def api_url(path):
|
||||||
"""Build full API URL. All paths are repo-scoped."""
|
"""Build full API URL. All paths are repo-scoped."""
|
||||||
|
|
@ -245,9 +308,18 @@ class GiteaAPI:
|
||||||
return issue
|
return issue
|
||||||
|
|
||||||
def update_issue(self, issue_number, **fields):
|
def update_issue(self, issue_number, **fields):
|
||||||
"""Update issue fields (state, title, body, milestone, labels)."""
|
"""Update issue fields (state, title, body, milestone)."""
|
||||||
request("PATCH", f"/issues/{issue_number}", fields)
|
request("PATCH", f"/issues/{issue_number}", fields)
|
||||||
|
|
||||||
|
def add_labels(self, issue_number, label_ids):
|
||||||
|
request("POST", f"/issues/{issue_number}/labels", {"labels": label_ids})
|
||||||
|
|
||||||
|
def remove_label(self, issue_number, label_id):
|
||||||
|
request("DELETE", f"/issues/{issue_number}/labels/{label_id}")
|
||||||
|
|
||||||
|
def add_comment(self, issue_number, body):
|
||||||
|
request("POST", f"/issues/{issue_number}/comments", {"body": body})
|
||||||
|
|
||||||
|
|
||||||
# ── Label Sync ───────────────────────────────────────────────────────────────
|
# ── Label Sync ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -259,40 +331,27 @@ def ensure_all_labels(api):
|
||||||
|
|
||||||
# ── Bug Tag → Label Mapping ──────────────────────────────────────────────────
|
# ── Bug Tag → Label Mapping ──────────────────────────────────────────────────
|
||||||
|
|
||||||
def bug_labels(bug_entry):
|
def status_label(status):
|
||||||
"""Derive Gitea labels from buglog tags."""
|
return STATUS_MAP.get(status, (None, None))[1]
|
||||||
labels = ["bug"]
|
|
||||||
tags = [t.lower() for t in bug_entry.get("tags", [])]
|
|
||||||
|
|
||||||
# Fixed bugs get bug:fixed label
|
|
||||||
fix = bug_entry.get("fix", "")
|
STATUS_LABELS = {lbl for _, lbl in STATUS_MAP.values() if lbl}
|
||||||
if fix and fix.strip():
|
|
||||||
labels.append("bug:fixed")
|
|
||||||
|
def bug_labels(bug_entry):
|
||||||
|
"""Derive Gitea labels from the entry's status and tags."""
|
||||||
|
labels = ["bug"]
|
||||||
|
if status_label(bug_status(bug_entry)):
|
||||||
|
labels.append(status_label(bug_status(bug_entry)))
|
||||||
|
|
||||||
# Map known tag patterns to labels
|
# Map known tag patterns to labels
|
||||||
for tag in tags:
|
for tag in (t.lower() for t in bug_entry.get("tags", [])):
|
||||||
for pattern, label in TAG_TO_LABEL.items():
|
for pattern, label in TAG_TO_LABEL.items():
|
||||||
if pattern.lower() in tag:
|
if pattern.lower() in tag and label not in labels:
|
||||||
if label not in labels:
|
labels.append(label)
|
||||||
labels.append(label)
|
return labels
|
||||||
|
|
||||||
# Ensure any needed labels exist in Gitea
|
|
||||||
# (bug:asan, bug:crash, bug:data-loss, bug:ha, bug:race may not exist yet)
|
|
||||||
extra_labels = {
|
|
||||||
"bug:asan": ("9B59B6", "ASAN/UBSAN caught bug"),
|
|
||||||
"bug:crash": ("C0392B", "SEGV/UAF/double-free crash"),
|
|
||||||
"bug:data-loss": ("C0392B", "Committed data loss bug"),
|
|
||||||
"bug:ha": ("E67E22", "HA/Raft/consensus bug"),
|
|
||||||
"bug:race": ("F39C12", "Data race / TOCTOU bug"),
|
|
||||||
}
|
|
||||||
for lbl in labels:
|
|
||||||
if lbl in extra_labels and lbl not in LABEL_NAMES:
|
|
||||||
pass # handled below
|
|
||||||
|
|
||||||
return labels, extra_labels
|
|
||||||
|
|
||||||
|
|
||||||
LABEL_NAMES = {l["name"] for l in LABELS}
|
|
||||||
|
|
||||||
|
|
||||||
# ── Body Formatting ──────────────────────────────────────────────────────────
|
# ── Body Formatting ──────────────────────────────────────────────────────────
|
||||||
|
|
@ -301,35 +360,44 @@ def bug_body(bug_entry):
|
||||||
"""Format a buglog entry as a Gitea issue body."""
|
"""Format a buglog entry as a Gitea issue body."""
|
||||||
parts = []
|
parts = []
|
||||||
|
|
||||||
error = bug_entry.get("error_message", "")
|
status = bug_entry.get("status") or "unknown (needs triage)"
|
||||||
|
note = _text(bug_entry.get("status_note"))
|
||||||
|
parts.append(f"**Status:** {status}" + (f" — {note}" if note else ""))
|
||||||
|
if bug_entry.get("duplicate_of"):
|
||||||
|
parts.append(f"Duplicate of {bug_entry['duplicate_of']}")
|
||||||
|
if bug_entry.get("repro_attempts"):
|
||||||
|
parts.append(f"Repro attempts: {_text(bug_entry['repro_attempts'])}")
|
||||||
|
parts.append("")
|
||||||
|
|
||||||
|
error = _text(bug_entry.get("error_message"))
|
||||||
if error:
|
if error:
|
||||||
parts.append("## Error\n")
|
parts.append("## Error\n")
|
||||||
parts.append(error.strip())
|
parts.append(error)
|
||||||
parts.append("")
|
parts.append("")
|
||||||
|
|
||||||
root_cause = bug_entry.get("root_cause", "")
|
root_cause = _text(bug_entry.get("root_cause"))
|
||||||
if root_cause:
|
if root_cause:
|
||||||
parts.append("## Root Cause\n")
|
parts.append("## Root Cause\n")
|
||||||
parts.append(root_cause.strip())
|
parts.append(root_cause)
|
||||||
parts.append("")
|
parts.append("")
|
||||||
|
|
||||||
fix = bug_entry.get("fix", "")
|
fix = _text(bug_entry.get("fix"))
|
||||||
if fix and fix.strip():
|
if fix:
|
||||||
parts.append("## Fix\n")
|
parts.append("## Fix\n")
|
||||||
parts.append(fix.strip())
|
parts.append(fix)
|
||||||
parts.append("")
|
parts.append("")
|
||||||
else:
|
else:
|
||||||
parts.append("## Fix\n")
|
parts.append("## Fix\n")
|
||||||
parts.append("*Not yet fixed*")
|
parts.append("*Not yet fixed*")
|
||||||
parts.append("")
|
parts.append("")
|
||||||
|
|
||||||
file_ = bug_entry.get("file", "")
|
files = [f.strip() for f in (bug_entry.get("file") or "").split(",")]
|
||||||
if file_:
|
files += [str(f).strip() for f in bug_entry.get("files") or []]
|
||||||
|
files = [f for f in dict.fromkeys(files) if f]
|
||||||
|
if files:
|
||||||
parts.append("## Affected Files\n")
|
parts.append("## Affected Files\n")
|
||||||
for f in file_.split(","):
|
for f in files:
|
||||||
f = f.strip()
|
parts.append(f"- `{f}`")
|
||||||
if f:
|
|
||||||
parts.append(f"- `{f}`")
|
|
||||||
parts.append("")
|
parts.append("")
|
||||||
|
|
||||||
tags = bug_entry.get("tags", [])
|
tags = bug_entry.get("tags", [])
|
||||||
|
|
@ -355,7 +423,13 @@ def bug_body(bug_entry):
|
||||||
def bug_title(bug_entry):
|
def bug_title(bug_entry):
|
||||||
"""Build a short title from a buglog entry."""
|
"""Build a short title from a buglog entry."""
|
||||||
bid = bug_entry.get("id", "bug-?")
|
bid = bug_entry.get("id", "bug-?")
|
||||||
error = bug_entry.get("error_message", "")
|
error = _text(bug_entry.get("error_message"))
|
||||||
|
# Drop a leading status banner ("OPEN, NOT FIXED -- ...", "PRIORITY: LOW --
|
||||||
|
# ...") -- status lives in labels/state, not the title.
|
||||||
|
error = re.sub(r"^(?:\s*(?:OPEN|NOT (?:YET )?FIXED|NOT A CRASH[^,.]*|"
|
||||||
|
r"NOT YET ROOT-CAUSED|DEAD-CODE-ONLY|PRIORITY: \w+|"
|
||||||
|
r"low priority|pre-existing)\b\s*(?:,|--|\.|;)?)+\s*",
|
||||||
|
"", error, flags=re.I) or error
|
||||||
# Take first sentence or first 100 chars
|
# Take first sentence or first 100 chars
|
||||||
first_line = error.split("\n")[0].strip()
|
first_line = error.split("\n")[0].strip()
|
||||||
first_sentence = re.split(r'[.。]\s+', first_line)[0].strip()
|
first_sentence = re.split(r'[.。]\s+', first_line)[0].strip()
|
||||||
|
|
@ -533,6 +607,27 @@ PHASE_DEFS = [
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# spec.json `phase` values that mean the spec's work is finished. There is no
|
||||||
|
# implementation approval in spec.json (approvals cover requirements/design/
|
||||||
|
# tasks only), and no tool distinguishes "implemented" from
|
||||||
|
# "implementation-complete", so both count as done.
|
||||||
|
DONE_PHASES = {"implementation-complete", "implemented", "superseded"}
|
||||||
|
|
||||||
|
|
||||||
|
def _spec_desired_states(spec_data):
|
||||||
|
"""Desired Gitea state per phase-issue key, plus the milestone's."""
|
||||||
|
approvals = spec_data.get("approvals", {})
|
||||||
|
req_ok = approvals.get("requirements", {}).get("approved", False)
|
||||||
|
des_ok = approvals.get("design", {}).get("approved", False)
|
||||||
|
done = spec_data.get("phase") in DONE_PHASES
|
||||||
|
closed = lambda cond: "closed" if cond else "open"
|
||||||
|
return {
|
||||||
|
"requirements_design": closed((req_ok and des_ok) or done),
|
||||||
|
"implementation": closed(done),
|
||||||
|
"validation": closed(done),
|
||||||
|
}, closed(done)
|
||||||
|
|
||||||
|
|
||||||
def sync_spec(api, spec_name, dry_run=False, quiet=False):
|
def sync_spec(api, spec_name, dry_run=False, quiet=False):
|
||||||
"""Sync one spec directory to Gitea milestone + phase issues."""
|
"""Sync one spec directory to Gitea milestone + phase issues."""
|
||||||
spec_dir = SPECS_DIR / spec_name
|
spec_dir = SPECS_DIR / spec_name
|
||||||
|
|
@ -595,14 +690,7 @@ def sync_spec(api, spec_name, dry_run=False, quiet=False):
|
||||||
for phase_def in PHASE_DEFS:
|
for phase_def in PHASE_DEFS:
|
||||||
title = f"[{spec_name}] {phase_def['title_suffix']}"
|
title = f"[{spec_name}] {phase_def['title_suffix']}"
|
||||||
|
|
||||||
# Determine if this phase issue should start closed
|
state = _spec_desired_states(spec_data)[0][phase_def["key"]]
|
||||||
state = "open"
|
|
||||||
if phase_def["key"] == "requirements_design":
|
|
||||||
approvals = spec_data.get("approvals", {})
|
|
||||||
req_ok = approvals.get("requirements", {}).get("approved", False)
|
|
||||||
des_ok = approvals.get("design", {}).get("approved", False)
|
|
||||||
if req_ok and des_ok:
|
|
||||||
state = "closed"
|
|
||||||
|
|
||||||
# Build body
|
# Build body
|
||||||
if phase_def.get("body_fn_extra"):
|
if phase_def.get("body_fn_extra"):
|
||||||
|
|
@ -638,25 +726,9 @@ def _sync_spec_phase_states(api, spec_name, spec_data, gitea_state,
|
||||||
print(f" [DRY-RUN] check phase states for '{spec_name}'")
|
print(f" [DRY-RUN] check phase states for '{spec_name}'")
|
||||||
return
|
return
|
||||||
|
|
||||||
approvals = spec_data.get("approvals", {})
|
|
||||||
req_ok = approvals.get("requirements", {}).get("approved", False)
|
|
||||||
des_ok = approvals.get("design", {}).get("approved", False)
|
|
||||||
tasks_ok = approvals.get("tasks", {}).get("approved", False)
|
|
||||||
impl_ok = approvals.get("implementation", {}).get("approved", False)
|
|
||||||
|
|
||||||
issues = gitea_state.get("issues", {})
|
issues = gitea_state.get("issues", {})
|
||||||
|
by_key, milestone_state = _spec_desired_states(spec_data)
|
||||||
# Build desired states for each phase issue
|
desired = {issues[k]: st for k, st in by_key.items() if issues.get(k)}
|
||||||
desired = {}
|
|
||||||
rd_issue = issues.get("requirements_design")
|
|
||||||
if rd_issue:
|
|
||||||
desired[rd_issue] = "closed" if (req_ok and des_ok) else "open"
|
|
||||||
impl_issue = issues.get("implementation")
|
|
||||||
if impl_issue:
|
|
||||||
desired[impl_issue] = "closed" if impl_ok else "open"
|
|
||||||
val_issue = issues.get("validation")
|
|
||||||
if val_issue:
|
|
||||||
desired[val_issue] = "closed" if impl_ok else "open"
|
|
||||||
|
|
||||||
# Fetch existing issues once
|
# Fetch existing issues once
|
||||||
existing_issues = api.list_issues(
|
existing_issues = api.list_issues(
|
||||||
|
|
@ -676,15 +748,14 @@ def _sync_spec_phase_states(api, spec_name, spec_data, gitea_state,
|
||||||
f"{current['state']} -> {target_state}")
|
f"{current['state']} -> {target_state}")
|
||||||
|
|
||||||
# Update milestone state
|
# Update milestone state
|
||||||
all_approved = req_ok and des_ok and tasks_ok and impl_ok
|
|
||||||
milestone_id = gitea_state.get("milestone_id")
|
milestone_id = gitea_state.get("milestone_id")
|
||||||
if all_approved and milestone_id:
|
if milestone_id:
|
||||||
all_milestones = api.list_milestones(state="all")
|
ms = api.list_milestones(state="all").get(spec_name)
|
||||||
ms = all_milestones.get(spec_name)
|
if ms and ms.get("state") != milestone_state:
|
||||||
if ms and ms.get("state") != "closed":
|
api.update_milestone(milestone_id, state=milestone_state)
|
||||||
api.update_milestone(milestone_id, state="closed")
|
|
||||||
if not quiet:
|
if not quiet:
|
||||||
print(f" closed milestone '{spec_name}'")
|
print(f" milestone '{spec_name}': "
|
||||||
|
f"{ms['state']} -> {milestone_state}")
|
||||||
|
|
||||||
|
|
||||||
def _spec_milestone_desc(spec_name, spec_data):
|
def _spec_milestone_desc(spec_name, spec_data):
|
||||||
|
|
@ -715,93 +786,137 @@ def _resolve_label_ids(api, label_names):
|
||||||
def sync_bugs(api, dry_run=False, quiet=False, since=None):
|
def sync_bugs(api, dry_run=False, quiet=False, since=None):
|
||||||
"""Sync buglog entries to Gitea issues.
|
"""Sync buglog entries to Gitea issues.
|
||||||
|
|
||||||
|
Open/closed and the status label come from each entry's `status` (see
|
||||||
|
STATUS_MAP). `gitea_synced_status` records the status last mirrored, so an
|
||||||
|
entry is only touched when its status changed -- including going back to
|
||||||
|
open (reopen). Sync state is written back per entry, surgically, so a
|
||||||
|
failure part-way through never loses an already-created issue number.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
since: Only sync bugs with last_seen >= this date (ISO format: YYYY-MM-DD).
|
since: ISO date (YYYY-MM-DD). Create issues only for bugs with
|
||||||
Helps avoid flooding Gitea with ancient auto-detected bugs.
|
last_seen >= since; update already-synced ones whose
|
||||||
|
last_seen or status_updated >= since.
|
||||||
"""
|
"""
|
||||||
if not BUGLOG_PATH.exists():
|
if not BUGLOG_PATH.exists():
|
||||||
warn(f"buglog not found: {BUGLOG_PATH}")
|
warn(f"buglog not found: {BUGLOG_PATH}")
|
||||||
return
|
return
|
||||||
|
|
||||||
buglog_wrapper = json.loads(BUGLOG_PATH.read_text())
|
buglog = json.loads(BUGLOG_PATH.read_text())["bugs"]
|
||||||
buglog = buglog_wrapper["bugs"]
|
created = closed = reopened = updated = skipped = 0
|
||||||
created = 0
|
needs_status = []
|
||||||
closed = 0
|
|
||||||
skipped = 0
|
|
||||||
|
|
||||||
for i, bug in enumerate(buglog):
|
# Aggregate status per already-synced issue (see STATUS_PRIORITY).
|
||||||
bid = bug.get("id", f"bug-{i:04d}")
|
issue_status = {}
|
||||||
|
for bug in buglog:
|
||||||
|
num = bug.get("gitea_issue")
|
||||||
|
if num:
|
||||||
|
prev = issue_status.get(num, "fixed")
|
||||||
|
cur = bug_status(bug)
|
||||||
|
issue_status[num] = min(prev, cur, key=STATUS_PRIORITY.index)
|
||||||
|
done_issues = set()
|
||||||
|
|
||||||
# Apply --since filter
|
for bug in buglog:
|
||||||
|
bid = bug.get("id")
|
||||||
|
if not bid:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# --since: new issues only for recently seen bugs; status changes
|
||||||
|
# (status_updated) are still mirrored for already-synced ones.
|
||||||
if since:
|
if since:
|
||||||
last_seen = bug.get("last_seen", "")
|
recent = (bug.get("last_seen") or "") >= since
|
||||||
if last_seen < since:
|
if bug.get("gitea_issue"):
|
||||||
|
recent = recent or (bug.get("status_updated") or "") >= since
|
||||||
|
if not recent:
|
||||||
skipped += 1
|
skipped += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
gitea_issue_num = bug.get("gitea_issue")
|
status = bug_status(bug)
|
||||||
|
if status is None:
|
||||||
|
needs_status.append(bid)
|
||||||
|
synced_status = status or "unknown"
|
||||||
|
state = bug_state(bug)
|
||||||
|
issue_num = bug.get("gitea_issue")
|
||||||
|
|
||||||
if gitea_issue_num:
|
if issue_num and bug.get("gitea_synced_status") == synced_status:
|
||||||
# Already synced — check if state changed
|
skipped += 1
|
||||||
fix_now = bool(bug.get("fix", "").strip())
|
continue
|
||||||
# We don't track previous fix state, so check Gitea
|
|
||||||
|
if issue_num:
|
||||||
|
# Status changed since the last sync (or was never recorded:
|
||||||
|
# issues synced before the status field existed were closed
|
||||||
|
# whenever `fix` was non-empty, so re-assert state for them).
|
||||||
|
# The issue itself follows the aggregate status of every entry
|
||||||
|
# linked to it; only status labels are touched, so labels a
|
||||||
|
# person put on the issue survive.
|
||||||
|
agg = issue_status[issue_num]
|
||||||
|
agg_state = STATUS_MAP.get(agg, ("open", None))[0]
|
||||||
|
if dry_run:
|
||||||
|
if issue_num not in done_issues:
|
||||||
|
print(f" [DRY-RUN] #{issue_num} ({bid}): -> {agg_state}"
|
||||||
|
f" ({agg or 'unknown'})")
|
||||||
|
done_issues.add(issue_num)
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
_, issue = request("GET", f"/issues/{gitea_issue_num}")
|
if issue_num not in done_issues:
|
||||||
if issue and issue.get("state") == "open" and fix_now:
|
_, issue = request("GET", f"/issues/{issue_num}")
|
||||||
if dry_run:
|
if issue is None:
|
||||||
print(f" [DRY-RUN] close bug #{gitea_issue_num}: {bid}")
|
warn(f" {bid}: issue #{issue_num} not found, skipping")
|
||||||
else:
|
skipped += 1
|
||||||
api.update_issue(gitea_issue_num, state="closed")
|
continue
|
||||||
# Add bug:fixed label via API
|
labels = api.list_labels()
|
||||||
request(
|
want = status_label(agg)
|
||||||
"PUT",
|
for lbl in issue.get("labels") or []:
|
||||||
f"/issues/{gitea_issue_num}/labels",
|
if lbl["name"] in STATUS_LABELS and lbl["name"] != want:
|
||||||
{"labels": [api.list_labels().get("bug:fixed", {}).get("id", 0)]}
|
api.remove_label(issue_num, lbl["id"])
|
||||||
)
|
have = {lbl["name"] for lbl in issue.get("labels") or []}
|
||||||
|
if want and want not in have and want in labels:
|
||||||
|
api.add_labels(issue_num, [labels[want]["id"]])
|
||||||
|
if issue.get("state") != agg_state:
|
||||||
|
api.update_issue(issue_num, state=agg_state)
|
||||||
|
note = _text(bug.get("status_note"))
|
||||||
|
api.add_comment(issue_num, f"{bid} status: **{synced_status}**"
|
||||||
|
+ (f" — {note}" if note else ""))
|
||||||
|
if agg_state == "closed":
|
||||||
|
closed += 1
|
||||||
|
else:
|
||||||
|
reopened += 1
|
||||||
if not quiet:
|
if not quiet:
|
||||||
print(f" closed #{gitea_issue_num}: {bid}")
|
print(f" #{issue_num} {bid}: {issue['state']} -> "
|
||||||
closed += 1
|
f"{agg_state} ({agg or 'unknown'})")
|
||||||
else:
|
else:
|
||||||
skipped += 1
|
updated += 1
|
||||||
except Exception as e:
|
done_issues.add(issue_num)
|
||||||
warn(f" {bid}: could not check issue #{gitea_issue_num}: {e}")
|
set_fields(BUGLOG_PATH, bid, {"gitea_synced_status": synced_status})
|
||||||
|
except (RuntimeError, BuglogEditError) as e:
|
||||||
|
warn(f" {bid}: could not update issue #{issue_num}: {e}")
|
||||||
skipped += 1
|
skipped += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# New bug — create issue
|
# New bug -- create issue
|
||||||
title = bug_title(bug)
|
title = bug_title(bug)
|
||||||
body = bug_body(bug)
|
|
||||||
fix = bug.get("fix", "").strip()
|
|
||||||
state = "closed" if fix else "open"
|
|
||||||
|
|
||||||
if dry_run:
|
if dry_run:
|
||||||
print(f" [DRY-RUN] create {'CLOSED' if state == 'closed' else 'OPEN'} "
|
print(f" [DRY-RUN] create {state.upper()} issue: {title}")
|
||||||
f"issue: {title}")
|
|
||||||
created += 1
|
created += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Determine labels
|
label_ids = _resolve_label_ids(api, bug_labels(bug))
|
||||||
labels, extra = bug_labels(bug)
|
issue = api.create_issue(title, bug_body(bug), labels=label_ids)
|
||||||
for ename, (ecolor, edesc) in extra.items():
|
if state == "closed":
|
||||||
if ename in labels:
|
# Gitea may ignore `state` on create; close explicitly.
|
||||||
api.ensure_label(ename, ecolor, edesc)
|
api.update_issue(issue["number"], state="closed")
|
||||||
|
set_fields(BUGLOG_PATH, bid, {"gitea_issue": issue["number"],
|
||||||
label_ids = _resolve_label_ids(api, labels)
|
"gitea_synced_status": synced_status})
|
||||||
|
|
||||||
issue = api.create_issue(title, body, labels=label_ids, state=state)
|
|
||||||
bug["gitea_issue"] = issue["number"]
|
|
||||||
state_mark = " (closed)" if state == "closed" else ""
|
|
||||||
if not quiet:
|
if not quiet:
|
||||||
print(f" created #{issue['number']}: {title}{state_mark}")
|
mark = " (closed)" if state == "closed" else ""
|
||||||
|
print(f" created #{issue['number']}: {title}{mark}")
|
||||||
created += 1
|
created += 1
|
||||||
|
|
||||||
# Write back updated buglog if any new issues were created
|
|
||||||
if created > 0 and not dry_run:
|
|
||||||
buglog_wrapper["bugs"] = buglog
|
|
||||||
_write_json(BUGLOG_PATH, buglog_wrapper)
|
|
||||||
|
|
||||||
if not quiet:
|
if not quiet:
|
||||||
print(f"Bugs: {created} created, {closed} closed, {skipped} skipped")
|
print(f"Bugs: {created} created, {closed} closed, {reopened} reopened, "
|
||||||
|
f"{updated} relabelled, {skipped} skipped")
|
||||||
|
if needs_status:
|
||||||
|
warn(f"{len(needs_status)} bug(s) have no status -- triage per "
|
||||||
|
f".claude/rules/buglog-status.md: {' '.join(needs_status)}")
|
||||||
|
|
||||||
|
|
||||||
# ── JSON Writing ─────────────────────────────────────────────────────────────
|
# ── JSON Writing ─────────────────────────────────────────────────────────────
|
||||||
|
|
@ -828,7 +943,7 @@ def main():
|
||||||
parser.add_argument("--bugs", action="store_true",
|
parser.add_argument("--bugs", action="store_true",
|
||||||
help="Sync bugs only")
|
help="Sync bugs only")
|
||||||
parser.add_argument("--since", metavar="DATE",
|
parser.add_argument("--since", metavar="DATE",
|
||||||
help="Only sync bugs with last_seen >= DATE (YYYY-MM-DD)")
|
help="Create issues only for bugs last seen >= DATE; update synced ones changed since (YYYY-MM-DD)")
|
||||||
parser.add_argument("--quiet", action="store_true",
|
parser.add_argument("--quiet", action="store_true",
|
||||||
help="Suppress progress output, print only errors")
|
help="Suppress progress output, print only errors")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue