Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3549f37ebf | |||
| 492c27df98 | |||
| 6c6e0149ac | |||
| e9ddc999f7 |
@@ -4,3 +4,6 @@
|
|||||||
tmp/
|
tmp/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
|
||||||
|
# agents-sync regenerates these symlinks next to every AGENTS.md
|
||||||
|
CLAUDE.md
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
## Project goals
|
||||||
|
|
||||||
|
1. **Unify and systematize issue workflow** for the development team with minimal context usage. Issue operations (create, fetch, format) are wrapped in scripts so agents spend tokens on the task, not on re-deriving commands and formats.
|
||||||
|
2. **Route all Gitea interaction through the `tea` CLI via scripts** instead of direct ad-hoc calls wherever possible. Scripts give deterministic, reviewable behavior; the `tea-guard` hook enforces that every `tea` invocation runs under the operator-pinned login.
|
||||||
|
|
||||||
|
## Repo layout
|
||||||
|
|
||||||
|
- `skills/auth` — pin the Gitea login used by `tea` (`/tea:auth`)
|
||||||
|
- `skills/use` — `tea` CLI reference, loaded on demand (`/tea:use`); `scripts/` holds helper scripts (e.g. `fetch_issue.py`), `references/` holds command docs and the canonical issue format
|
||||||
|
- `skills/issue` — create issues in the canonical format (`/tea:issue`)
|
||||||
|
- `hooks/` — PreToolUse hooks: `tea-guard` blocks or rewrites `tea` invocations that don't use the pinned login; `agents-sync` keeps every directory canonical (`AGENTS.md` real file, `CLAUDE.md` symlink to it)
|
||||||
Executable
+141
@@ -0,0 +1,141 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
agents-sync — PreToolUse(Bash) hook.
|
||||||
|
|
||||||
|
Before any Bash command runs, walks the project tree and enforces one
|
||||||
|
filesystem invariant in every directory:
|
||||||
|
|
||||||
|
AGENTS.md is the real file; CLAUDE.md is a symlink pointing at it.
|
||||||
|
|
||||||
|
Per directory:
|
||||||
|
- AGENTS.md real, no CLAUDE.md ........ create symlink CLAUDE.md -> AGENTS.md
|
||||||
|
- CLAUDE.md real, no AGENTS.md ........ rename to AGENTS.md, symlink back
|
||||||
|
- CLAUDE.md symlink -> AGENTS.md ...... already canonical, nothing to do
|
||||||
|
- CLAUDE.md symlink elsewhere ......... re-point at AGENTS.md
|
||||||
|
- AGENTS.md symlink -> real CLAUDE.md . reversed layout: swap to canonical
|
||||||
|
- both real, identical content ........ replace CLAUDE.md with the symlink
|
||||||
|
- both real, different content ........ DON'T touch; report the conflict
|
||||||
|
|
||||||
|
The hook never blocks the tool call and never deletes content: every branch
|
||||||
|
either performs a lossless fix or reports. Fixes/conflicts are surfaced via
|
||||||
|
hookSpecificOutput.additionalContext; silence means the tree was already
|
||||||
|
canonical. Any unexpected error fails open (exit 0).
|
||||||
|
"""
|
||||||
|
import sys, os, json, filecmp
|
||||||
|
|
||||||
|
SKIP_DIRS = {"node_modules", "__pycache__", "venv", "vendor"}
|
||||||
|
|
||||||
|
|
||||||
|
def same_file(a, b):
|
||||||
|
try:
|
||||||
|
return os.path.realpath(a) == os.path.realpath(b)
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def fix_dir(d, root, fixes, conflicts):
|
||||||
|
agents = os.path.join(d, "AGENTS.md")
|
||||||
|
claude = os.path.join(d, "CLAUDE.md")
|
||||||
|
a = os.path.lexists(agents)
|
||||||
|
c = os.path.lexists(claude)
|
||||||
|
if not a and not c:
|
||||||
|
return
|
||||||
|
|
||||||
|
rel = lambda p: os.path.relpath(p, root)
|
||||||
|
a_link = a and os.path.islink(agents)
|
||||||
|
c_link = c and os.path.islink(claude)
|
||||||
|
|
||||||
|
if a and not c:
|
||||||
|
if a_link and not os.path.exists(agents):
|
||||||
|
conflicts.append("%s: broken symlink and no CLAUDE.md" % rel(agents))
|
||||||
|
return
|
||||||
|
os.symlink("AGENTS.md", claude)
|
||||||
|
fixes.append("%s: created symlink -> AGENTS.md" % rel(claude))
|
||||||
|
return
|
||||||
|
|
||||||
|
if c and not a:
|
||||||
|
if c_link:
|
||||||
|
conflicts.append("%s: symlink to missing target (%s)"
|
||||||
|
% (rel(claude), os.readlink(claude)))
|
||||||
|
return
|
||||||
|
os.rename(claude, agents)
|
||||||
|
os.symlink("AGENTS.md", claude)
|
||||||
|
fixes.append("%s: renamed to AGENTS.md, symlink left in place" % rel(claude))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Both exist.
|
||||||
|
if c_link:
|
||||||
|
if same_file(claude, agents):
|
||||||
|
return # canonical
|
||||||
|
old = os.readlink(claude)
|
||||||
|
os.remove(claude)
|
||||||
|
os.symlink("AGENTS.md", claude)
|
||||||
|
fixes.append("%s: re-pointed symlink (%s -> AGENTS.md)" % (rel(claude), old))
|
||||||
|
return
|
||||||
|
|
||||||
|
if a_link:
|
||||||
|
# Reversed layout: AGENTS.md is the symlink, CLAUDE.md the real file.
|
||||||
|
if same_file(agents, claude):
|
||||||
|
os.remove(agents)
|
||||||
|
os.rename(claude, agents)
|
||||||
|
os.symlink("AGENTS.md", claude)
|
||||||
|
fixes.append("%s: swapped — AGENTS.md is now the real file" % rel(agents))
|
||||||
|
else:
|
||||||
|
conflicts.append("%s: symlink elsewhere while CLAUDE.md is a real file"
|
||||||
|
% rel(agents))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Both are real files.
|
||||||
|
try:
|
||||||
|
identical = filecmp.cmp(agents, claude, shallow=False)
|
||||||
|
except OSError:
|
||||||
|
identical = False
|
||||||
|
if identical:
|
||||||
|
os.remove(claude)
|
||||||
|
os.symlink("AGENTS.md", claude)
|
||||||
|
fixes.append("%s: identical to AGENTS.md, replaced with symlink" % rel(claude))
|
||||||
|
else:
|
||||||
|
conflicts.append("%s: AGENTS.md and CLAUDE.md are different real files — "
|
||||||
|
"merge manually" % (rel(d) if rel(d) != "." else "<root>"))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
try:
|
||||||
|
payload = json.load(sys.stdin)
|
||||||
|
except Exception:
|
||||||
|
payload = {}
|
||||||
|
|
||||||
|
root = os.environ.get("CLAUDE_PROJECT_DIR") or payload.get("cwd") or os.getcwd()
|
||||||
|
if not os.path.isdir(root):
|
||||||
|
return
|
||||||
|
|
||||||
|
fixes, conflicts = [], []
|
||||||
|
for dirpath, dirnames, _ in os.walk(root):
|
||||||
|
dirnames[:] = [n for n in dirnames
|
||||||
|
if n not in SKIP_DIRS and not n.startswith(".")]
|
||||||
|
try:
|
||||||
|
fix_dir(dirpath, root, fixes, conflicts)
|
||||||
|
except OSError:
|
||||||
|
pass # unwritable dir etc. — skip, never block the command
|
||||||
|
|
||||||
|
if fixes or conflicts:
|
||||||
|
parts = []
|
||||||
|
if fixes:
|
||||||
|
parts.append("agents-sync fixed:\n " + "\n ".join(fixes))
|
||||||
|
if conflicts:
|
||||||
|
parts.append("agents-sync needs manual resolution:\n "
|
||||||
|
+ "\n ".join(conflicts))
|
||||||
|
print(json.dumps({
|
||||||
|
"hookSpecificOutput": {
|
||||||
|
"hookEventName": "PreToolUse",
|
||||||
|
"additionalContext": "\n".join(parts),
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except Exception:
|
||||||
|
pass # fail open — this hook must never break Bash
|
||||||
|
sys.exit(0)
|
||||||
@@ -4,6 +4,10 @@
|
|||||||
{
|
{
|
||||||
"matcher": "Bash",
|
"matcher": "Bash",
|
||||||
"hooks": [
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/agents-sync.sh"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/tea-guard.sh"
|
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/tea-guard.sh"
|
||||||
|
|||||||
+20
-11
@@ -14,17 +14,22 @@ always `--login "$GITEA_LOGIN"`, never a literal name (see `/tea:use`).
|
|||||||
## Steps
|
## Steps
|
||||||
|
|
||||||
1. **Read the format**: load `../use/references/issue-format.md`.
|
1. **Read the format**: load `../use/references/issue-format.md`.
|
||||||
2. **Pick the type** — `bug`, `feature`, `refactor`, or `draft` (for ideas
|
2. **Pick the type** — `bug`, `task`, `refactor`, `test`, `feature` (a
|
||||||
not ready for work). If it is not obvious from the request, ask the user
|
container for several issues with one business value), or `draft` (for
|
||||||
(one question).
|
ideas not ready for work). If it is not obvious from the request, ask the
|
||||||
|
user (one question).
|
||||||
3. **Ensure labels exist**: `tea labels list --login "$GITEA_LOGIN" -o json`.
|
3. **Ensure labels exist**: `tea labels list --login "$GITEA_LOGIN" -o json`.
|
||||||
For each missing `type/*` label, create it via `tea api` with
|
For each missing **exclusive** label (`type/*`, and `severity/*` when
|
||||||
`"exclusive": true` exactly as shown in the format doc. Do NOT use
|
used), create it via `tea api` with `"exclusive": true` exactly as shown
|
||||||
`tea labels create` for these — it cannot set exclusivity.
|
in the format doc. Do NOT use `tea labels create` for these — it cannot
|
||||||
|
set exclusivity. Non-exclusive `tech/*` and `comp/*` labels may be created
|
||||||
|
either way; apply them when the technology or component is evident.
|
||||||
4. **Compose title and body** per the format: English imperative title without
|
4. **Compose title and body** per the format: English imperative title without
|
||||||
a type prefix; the type's template with all sections present, in order,
|
a type prefix; the type's template with all sections present, in order,
|
||||||
headers in English, prose in Russian; `## Spec` filled with a repo path,
|
headers in English, prose in Russian; `## Spec` filled with a repo path,
|
||||||
a URL, or the literal `none` — ask the user if you cannot determine which.
|
a URL, or the literal `none` — ask the user if you cannot determine which.
|
||||||
|
If the issue depends on others, add a `## Depends on` section right after
|
||||||
|
`## Spec` (one `#N` per line); omit it otherwise.
|
||||||
5. **Post via tmp/ + tea api** (the body is always multi-line, so entity
|
5. **Post via tmp/ + tea api** (the body is always multi-line, so entity
|
||||||
commands are off the table — see "Rich payloads" in `/tea:use`):
|
commands are off the table — see "Rich payloads" in `/tea:use`):
|
||||||
```bash
|
```bash
|
||||||
@@ -35,14 +40,18 @@ always `--login "$GITEA_LOGIN"`, never a literal name (see `/tea:use`).
|
|||||||
```
|
```
|
||||||
The create endpoint takes label **IDs** (integers), not names — take them
|
The create endpoint takes label **IDs** (integers), not names — take them
|
||||||
from the `tea labels list` output of step 3 (or from the create response).
|
from the `tea labels list` output of step 3 (or from the create response).
|
||||||
If labels fail to attach on create, fall back to
|
The `labels` array holds every applied label: the `type/*` ID plus any
|
||||||
`PUT repos/{owner}/{repo}/issues/{n}/labels` with `{"labels": [<id>]}`.
|
`severity/*`, `tech/*`, `comp/*` IDs. If labels fail to attach on create,
|
||||||
6. **Report**: show the issue URL and the applied `type/*` label.
|
fall back to `PUT repos/{owner}/{repo}/issues/{n}/labels` with
|
||||||
|
`{"labels": [<id>]}`.
|
||||||
|
6. **Report**: show the issue URL and the applied labels.
|
||||||
|
|
||||||
## Editing an existing issue
|
## Editing an existing issue
|
||||||
|
|
||||||
When asked to bring an existing issue to the format: fetch it
|
When asked to bring an existing issue to the format: fetch it with the use
|
||||||
(`tea issues <n> --login "$GITEA_LOGIN" -o json`), restructure the body into
|
skill's script (`python3 ../use/scripts/fetch_issue.py <n>` relative to this
|
||||||
|
skill's base dir — writes `tmp/issue/<n>/data` + comments, prints a compact
|
||||||
|
index; no `--login`, it resolves the pin itself), restructure the body into
|
||||||
the type's template without losing information, then
|
the type's template without losing information, then
|
||||||
`PATCH repos/{owner}/{repo}/issues/{n}` with the new title/body and ensure
|
`PATCH repos/{owner}/{repo}/issues/{n}` with the new title/body and ensure
|
||||||
exactly one `type/*` label is set.
|
exactly one `type/*` label is set.
|
||||||
|
|||||||
+38
-4
@@ -46,6 +46,39 @@ The pin takes effect immediately — no restart. Only `tea logins list` and
|
|||||||
per-project by the operator (see `/tea:auth`) and injected by the guard.
|
per-project by the operator (see `/tea:auth`) and injected by the guard.
|
||||||
Config lives in `$XDG_CONFIG_HOME/tea`.
|
Config lives in `$XDG_CONFIG_HOME/tea`.
|
||||||
|
|
||||||
|
## Reading an issue: use the fetch script, not raw tea calls
|
||||||
|
|
||||||
|
To read an existing issue (its body, its discussion), do NOT run
|
||||||
|
`tea issues <n> -o json` or `tea api .../issues/<n>` directly — the full JSON
|
||||||
|
payload (avatars, nested user objects, every comment body) lands in your
|
||||||
|
context whether you need it or not. Instead run the bundled script; the only
|
||||||
|
input it needs is the issue key:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 <skill-base-dir>/scripts/fetch_issue.py 42
|
||||||
|
```
|
||||||
|
|
||||||
|
Key forms: `42`, `#42`, `owner/repo#42`, or a full issue URL. Repo defaults
|
||||||
|
to the current directory's git remote (add `--repo owner/repo` outside one).
|
||||||
|
|
||||||
|
It writes trimmed markdown files locally and prints only a compact index:
|
||||||
|
|
||||||
|
```
|
||||||
|
tmp/issue/42/data issue: metadata header + body
|
||||||
|
tmp/issue/42/comments/ one file per comment: NNN-<comment-id>.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Then Read just the files the task needs — often the `data` file alone, or a
|
||||||
|
single comment picked from the index (author + date per line). Each comment
|
||||||
|
file carries its `comment-id`, ready for a `PATCH` via `tea api`.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- No `--login` on the script call: the script resolves the operator's pinned
|
||||||
|
login itself from `.claude/settings.local.json` — same source as the
|
||||||
|
tea-guard hook. No pin → it exits with a pointer to `/tea:auth`.
|
||||||
|
- Every run refetches fresh and wipes the issue's `comments/` dir, so stale
|
||||||
|
files never survive.
|
||||||
|
|
||||||
## Index
|
## Index
|
||||||
|
|
||||||
- [tea CLI overview](references/tea/index.md) — global flags, common options, output formats
|
- [tea CLI overview](references/tea/index.md) — global flags, common options, output formats
|
||||||
@@ -53,10 +86,11 @@ Config lives in `$XDG_CONFIG_HOME/tea`.
|
|||||||
- [HELPERS](references/tea/helpers.md) — open, notifications, clone, api
|
- [HELPERS](references/tea/helpers.md) — open, notifications, clone, api
|
||||||
- [MISC](references/tea/misc.md) — whoami, admin
|
- [MISC](references/tea/misc.md) — whoami, admin
|
||||||
- [SETUP](references/tea/setup.md) — logins, logout, ssh-keys
|
- [SETUP](references/tea/setup.md) — logins, logout, ssh-keys
|
||||||
- [ISSUE FORMAT](references/issue-format.md) — canonical issue format: types
|
- [ISSUE FORMAT](references/issue-format.md) — canonical issue format: label
|
||||||
(`type/bug|feature|refactor` exclusive labels), templates, title and
|
namespaces (`type/*`, `severity/*` exclusive; `tech/*`, `comp/*` free),
|
||||||
language rules. MANDATORY whenever creating or editing an issue; the
|
types `bug|task|refactor|test|feature|draft`, templates, dependencies,
|
||||||
`/tea:issue` skill is the guided procedure for it.
|
title and language rules. MANDATORY whenever creating or editing an issue;
|
||||||
|
the `/tea:issue` skill is the guided procedure for it.
|
||||||
|
|
||||||
## Rich payloads — write to `$PWD/tmp/` first, then `tea api`
|
## Rich payloads — write to `$PWD/tmp/` first, then `tea api`
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
Canonical format for every issue created or edited via `tea`. Designed to be
|
Canonical format for every issue created or edited via `tea`. Designed to be
|
||||||
unambiguous for both humans and LLMs: fixed English section headers in a fixed
|
unambiguous for both humans and LLMs: fixed English section headers in a fixed
|
||||||
order, verifiable acceptance criteria, one issue = one deliverable.
|
order, verifiable acceptance criteria, one issue = one deliverable. Source
|
||||||
|
spec: the project wiki ([Issues-Workflow](https://git.noodles.cam/claude-skills/tea/wiki/Issues-Workflow)).
|
||||||
|
|
||||||
## Language rules
|
## Language rules
|
||||||
|
|
||||||
@@ -13,30 +14,79 @@ order, verifiable acceptance criteria, one issue = one deliverable.
|
|||||||
the given order. Do not translate, rename, or reorder them.
|
the given order. Do not translate, rename, or reorder them.
|
||||||
- **Body prose** (text inside sections): Russian.
|
- **Body prose** (text inside sections): Russian.
|
||||||
|
|
||||||
## Types and labels
|
## Label namespaces
|
||||||
|
|
||||||
Every issue carries exactly one `type/*` label:
|
Four namespaces classify an issue. Two are exclusive (Gitea enforces at most
|
||||||
|
one label from the scope), two are free-form:
|
||||||
|
|
||||||
| Label | Meaning |
|
| Namespace | Exclusive | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `type/*` | yes | What kind of work; primarily its business value. Mandatory, exactly one. |
|
||||||
|
| `severity/*` | yes | Business impact. At most one; apply when the impact is known. |
|
||||||
|
| `tech/*` | no | Technology the issue is bound to. Any number. |
|
||||||
|
| `comp/*` | no | System component of this repo. Any number; no preset — project-specific. |
|
||||||
|
|
||||||
|
### `type/*` — mandatory, exactly one
|
||||||
|
|
||||||
|
| Label | Color | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `type/bug` | `#ee0701` | Something behaves incorrectly in existing code |
|
||||||
|
| `type/task` | `#0e8a16` | Implementation of new functionality |
|
||||||
|
| `type/refactor` | `#1d76db` | Internal restructuring: file moves, architecture; behavior must not change |
|
||||||
|
| `type/test` | `#fbca04` | Writing or fixing tests |
|
||||||
|
| `type/feature` | `#5319e7` | Container: several issues delivering one unit of business value |
|
||||||
|
| `type/draft` | `#cccccc` | Idea captured for later; not ready for work |
|
||||||
|
|
||||||
|
### `severity/*` — at most one
|
||||||
|
|
||||||
|
| Label | Color |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `type/bug` | Something behaves incorrectly |
|
| `severity/low` | `#c2e0c6` |
|
||||||
| `type/feature` | New capability or change in behavior |
|
| `severity/medium` | `#fbca04` |
|
||||||
| `type/refactor` | Internal restructuring; behavior must not change |
|
| `severity/high` | `#eb6420` |
|
||||||
| `type/draft` | Idea captured for later; not ready for work |
|
| `severity/showstopper` | `#ee0701` |
|
||||||
|
| `severity/critical` | `#b60205` |
|
||||||
|
|
||||||
`type` is an **exclusive scope**: Gitea enforces at most one `type/*` label per
|
### `tech/*` — any number
|
||||||
issue, but only if the labels were created with `exclusive: true`. The `tea
|
|
||||||
labels create` command (as of tea 0.14.2) cannot set that field, so missing
|
Technology-bound labels, e.g. `tech/sql` (pgx, sqlc, sql-migrate — persistent
|
||||||
`type/*` labels MUST be created via `tea api`:
|
storage), `tech/obs` (grafana, loki, prometheus, alloy — observability),
|
||||||
|
`tech/postgres`.
|
||||||
|
|
||||||
|
### `comp/*` — any number
|
||||||
|
|
||||||
|
Components of this repo's system, e.g. `comp/appclick`. No preset list —
|
||||||
|
derive from the project.
|
||||||
|
|
||||||
|
### Creating exclusive labels
|
||||||
|
|
||||||
|
Gitea enforces exclusivity only if the label was created with
|
||||||
|
`exclusive: true`. The `tea labels create` command (as of tea 0.14.2) cannot
|
||||||
|
set that field, so missing `type/*` and `severity/*` labels MUST be created
|
||||||
|
via `tea api`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
tea api --login "$GITEA_LOGIN" -X POST \
|
tea api --login "$GITEA_LOGIN" -X POST \
|
||||||
-d '{"name":"type/bug","color":"#ee0701","exclusive":true,"description":"Something behaves incorrectly"}' \
|
-d '{"name":"type/bug","color":"#ee0701","exclusive":true,"description":"Something behaves incorrectly in existing code"}' \
|
||||||
repos/{owner}/{repo}/labels
|
repos/{owner}/{repo}/labels
|
||||||
```
|
```
|
||||||
|
|
||||||
Suggested colors: `type/bug` `#ee0701`, `type/feature` `#0e8a16`,
|
`tech/*` and `comp/*` are non-exclusive; either `tea labels create` or
|
||||||
`type/refactor` `#1d76db`, `type/draft` `#cccccc`.
|
`tea api` works for them.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
An issue may explicitly depend on others. Declare that in an optional
|
||||||
|
`## Depends on` section placed right after `## Spec`, one `#N` reference per
|
||||||
|
line:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Depends on
|
||||||
|
- #12 — нужна схема БД из этого issue
|
||||||
|
- #15
|
||||||
|
```
|
||||||
|
|
||||||
|
Omit the section when there are no dependencies — never write an empty one.
|
||||||
|
|
||||||
## Shared rules
|
## Shared rules
|
||||||
|
|
||||||
@@ -52,7 +102,8 @@ Suggested colors: `type/bug` `#ee0701`, `type/feature` `#0e8a16`,
|
|||||||
- Code references use the `path/file.ext:line` form; related issues as `#N`.
|
- Code references use the `path/file.ext:line` form; related issues as `#N`.
|
||||||
- Screenshots are allowed but their content must be duplicated as text — an
|
- Screenshots are allowed but their content must be duplicated as text — an
|
||||||
LLM posting through `tea api` cannot read images.
|
LLM posting through `tea api` cannot read images.
|
||||||
- If acceptance criteria grow past ~5 unrelated items, split the issue.
|
- If acceptance criteria grow past ~5 unrelated items, split the issue (or
|
||||||
|
promote it to a `type/feature` container with child issues).
|
||||||
|
|
||||||
## Template: `type/bug`
|
## Template: `type/bug`
|
||||||
|
|
||||||
@@ -81,7 +132,7 @@ Suggested colors: `type/bug` `#ee0701`, `type/feature` `#0e8a16`,
|
|||||||
- [ ] добавлена проверка на регрессию (если применимо)
|
- [ ] добавлена проверка на регрессию (если применимо)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Template: `type/feature`
|
## Template: `type/task`
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
## Summary
|
## Summary
|
||||||
@@ -120,6 +171,53 @@ Suggested colors: `type/bug` `#ee0701`, `type/feature` `#0e8a16`,
|
|||||||
- [ ] проверяемое условие (тесты зелёные, старый путь удалён, …)
|
- [ ] проверяемое условие (тесты зелёные, старый путь удалён, …)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Template: `type/test`
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Summary
|
||||||
|
Что покрываем тестами и где (`path/file:line`).
|
||||||
|
|
||||||
|
## Spec
|
||||||
|
Ссылка или `none`.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
Зачем: регрессия после бага, пробел в покрытии, флаки-тест.
|
||||||
|
|
||||||
|
## Test cases
|
||||||
|
- сценарий → ожидаемый результат
|
||||||
|
- …
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
- [ ] перечисленные кейсы покрыты и зелёные
|
||||||
|
- [ ] тесты проходят в CI
|
||||||
|
```
|
||||||
|
|
||||||
|
## Template: `type/feature`
|
||||||
|
|
||||||
|
A container: one unit of business value delivered by several child issues.
|
||||||
|
Child issues carry their own `type/*` (`task`, `bug`, `test`, …) and link
|
||||||
|
back via `## Depends on` or the `## Issues` list here. Keep implementation
|
||||||
|
detail in the children; the feature body stays at business level.
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Summary
|
||||||
|
Бизнес-ценность одним-двумя предложениями.
|
||||||
|
|
||||||
|
## Spec
|
||||||
|
Ссылка или `none`.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
Какую проблему пользователя/системы это решает.
|
||||||
|
|
||||||
|
## Issues
|
||||||
|
- [ ] #N — краткое описание части
|
||||||
|
- [ ] …
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
- [ ] все дочерние issues закрыты
|
||||||
|
- [ ] проверяемое условие уровня фичи (например, e2e-сценарий работает)
|
||||||
|
```
|
||||||
|
|
||||||
## Template: `type/draft`
|
## Template: `type/draft`
|
||||||
|
|
||||||
A parking spot for ideas that are not fleshed out yet. Minimal structure, no
|
A parking spot for ideas that are not fleshed out yet. Minimal structure, no
|
||||||
@@ -137,3 +235,11 @@ template.
|
|||||||
## Notes
|
## Notes
|
||||||
Свободные заметки: что известно, открытые вопросы, варианты.
|
Свободные заметки: что известно, открытые вопросы, варианты.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Containers beyond `type/feature`
|
||||||
|
|
||||||
|
- **Milestone** — a set of issues with an optional time bound. Manage via
|
||||||
|
`tea milestones` / `tea milestones issues`.
|
||||||
|
- **Project** — a set of issues describing one project, tracked by status
|
||||||
|
columns. Standard statuses: Backlog, ToDo, InProgress, Ready, Done. The
|
||||||
|
Gitea projects API is not exposed via `tea` subcommands — use the web UI.
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
fetch_issue.py — pull one Gitea issue (+ all comments) to local files.
|
||||||
|
|
||||||
|
Token-saving fetcher for Claude sessions: instead of dumping raw API JSON
|
||||||
|
into the conversation, it writes trimmed markdown files under tmp/issue/
|
||||||
|
and prints only a compact index. Read the files you actually need.
|
||||||
|
|
||||||
|
tmp/issue/<n>/data issue itself (metadata header + body)
|
||||||
|
tmp/issue/<n>/comments/ one file per comment: NNN-<comment-id>.md
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
fetch_issue.py <key> [--repo owner/repo] [--out DIR]
|
||||||
|
|
||||||
|
<key> 42 | #42 | owner/repo#42 | https://host/owner/repo/issues/42
|
||||||
|
|
||||||
|
Login: resolved from .claude/settings.local.json (env.GITEA_LOGIN), walking
|
||||||
|
up from CWD — the same file /tea:auth writes and the tea-guard hook reads.
|
||||||
|
The script never accepts a login argument: the operator's pin is the only
|
||||||
|
identity it will use. No pin -> exit with a pointer to /tea:auth.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def die(msg, code=1):
|
||||||
|
sys.stderr.write("fetch_issue: " + msg + "\n")
|
||||||
|
sys.exit(code)
|
||||||
|
|
||||||
|
|
||||||
|
def find_pin(start_dir):
|
||||||
|
"""Walk up from start_dir; return login from the first
|
||||||
|
.claude/settings.local.json carrying a non-empty env.GITEA_LOGIN."""
|
||||||
|
d = os.path.abspath(start_dir or ".")
|
||||||
|
while True:
|
||||||
|
p = os.path.join(d, ".claude", "settings.local.json")
|
||||||
|
if os.path.isfile(p):
|
||||||
|
try:
|
||||||
|
with open(p) as f:
|
||||||
|
v = (json.load(f).get("env") or {}).get("GITEA_LOGIN")
|
||||||
|
if isinstance(v, str) and v.strip():
|
||||||
|
return v.strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
parent = os.path.dirname(d)
|
||||||
|
if parent == d:
|
||||||
|
return None
|
||||||
|
d = parent
|
||||||
|
|
||||||
|
|
||||||
|
def parse_key(key):
|
||||||
|
"""Return (number, repo-or-None) from 42 / #42 / owner/repo#42 / URL."""
|
||||||
|
key = key.strip()
|
||||||
|
m = re.match(r'^https?://[^/]+/([^/]+)/([^/]+)/issues/(\d+)/?$', key)
|
||||||
|
if m:
|
||||||
|
return int(m.group(3)), "%s/%s" % (m.group(1), m.group(2))
|
||||||
|
m = re.match(r'^([\w.-]+/[\w.-]+)#(\d+)$', key)
|
||||||
|
if m:
|
||||||
|
return int(m.group(2)), m.group(1)
|
||||||
|
m = re.match(r'^#?(\d+)$', key)
|
||||||
|
if m:
|
||||||
|
return int(m.group(1)), None
|
||||||
|
die("cannot parse issue key %r (want 42, #42, owner/repo#42, or an issue URL)" % key)
|
||||||
|
|
||||||
|
|
||||||
|
def tea_api(login, endpoint):
|
||||||
|
"""GET via `tea api`, return parsed JSON."""
|
||||||
|
cmd = ["tea", "api", "--login", login, endpoint]
|
||||||
|
r = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
if r.returncode != 0:
|
||||||
|
die("`tea api %s` failed:\n%s" % (endpoint, (r.stderr or r.stdout).strip()))
|
||||||
|
try:
|
||||||
|
return json.loads(r.stdout)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
die("`tea api %s` returned non-JSON:\n%s" % (endpoint, r.stdout[:500]))
|
||||||
|
|
||||||
|
|
||||||
|
def day(iso):
|
||||||
|
return (iso or "")[:10]
|
||||||
|
|
||||||
|
|
||||||
|
def issue_markdown(iss):
|
||||||
|
labels = ", ".join(l.get("name", "") for l in iss.get("labels") or []) or "none"
|
||||||
|
assignees = ", ".join(a.get("login", "") for a in iss.get("assignees") or []) or "none"
|
||||||
|
milestone = (iss.get("milestone") or {}).get("title") or "none"
|
||||||
|
lines = [
|
||||||
|
"#%d %s" % (iss["number"], iss.get("title", "")),
|
||||||
|
"state: %s" % iss.get("state", ""),
|
||||||
|
"labels: %s" % labels,
|
||||||
|
"author: %s" % (iss.get("user") or {}).get("login", ""),
|
||||||
|
"assignees: %s" % assignees,
|
||||||
|
"milestone: %s" % milestone,
|
||||||
|
"created: %s" % iss.get("created_at", ""),
|
||||||
|
"updated: %s" % iss.get("updated_at", ""),
|
||||||
|
"url: %s" % iss.get("html_url", ""),
|
||||||
|
"comments: %d" % iss.get("comments", 0),
|
||||||
|
"",
|
||||||
|
"---",
|
||||||
|
"",
|
||||||
|
iss.get("body") or "(no body)",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def comment_markdown(c):
|
||||||
|
lines = [
|
||||||
|
"comment-id: %d" % c["id"],
|
||||||
|
"author: %s" % (c.get("user") or {}).get("login", ""),
|
||||||
|
"created: %s" % c.get("created_at", ""),
|
||||||
|
"updated: %s" % c.get("updated_at", ""),
|
||||||
|
"",
|
||||||
|
"---",
|
||||||
|
"",
|
||||||
|
c.get("body") or "(empty)",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(description="Fetch a Gitea issue + comments to tmp/issue/<n>/")
|
||||||
|
ap.add_argument("key", help="issue key: 42, #42, owner/repo#42, or issue URL")
|
||||||
|
ap.add_argument("--repo", help="owner/repo (default: auto-detect from CWD git remote)")
|
||||||
|
ap.add_argument("--out", default=os.path.join("tmp", "issue"),
|
||||||
|
help="output root (default: tmp/issue)")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
number, key_repo = parse_key(args.key)
|
||||||
|
repo = args.repo or key_repo # None -> let tea fill {owner}/{repo} from CWD
|
||||||
|
base = "repos/%s" % repo if repo else "repos/{owner}/{repo}"
|
||||||
|
|
||||||
|
login = find_pin(os.getcwd())
|
||||||
|
if not login:
|
||||||
|
die("no login pinned (.claude/settings.local.json env.GITEA_LOGIN). Run /tea:auth.")
|
||||||
|
|
||||||
|
iss = tea_api(login, "%s/issues/%d" % (base, number))
|
||||||
|
|
||||||
|
comments = []
|
||||||
|
page = 1
|
||||||
|
while page <= 40:
|
||||||
|
batch = tea_api(login, "%s/issues/%d/comments?page=%d&limit=50" % (base, number, page))
|
||||||
|
if not isinstance(batch, list) or not batch:
|
||||||
|
break
|
||||||
|
comments.extend(batch)
|
||||||
|
if len(batch) < 50:
|
||||||
|
break
|
||||||
|
page += 1
|
||||||
|
|
||||||
|
root = os.path.join(args.out, str(number))
|
||||||
|
cdir = os.path.join(root, "comments")
|
||||||
|
shutil.rmtree(cdir, ignore_errors=True) # drop stale comments from earlier fetches
|
||||||
|
os.makedirs(cdir, exist_ok=True)
|
||||||
|
|
||||||
|
data_path = os.path.join(root, "data")
|
||||||
|
with open(data_path, "w") as f:
|
||||||
|
f.write(issue_markdown(iss))
|
||||||
|
|
||||||
|
index = []
|
||||||
|
for i, c in enumerate(comments, 1):
|
||||||
|
name = "%03d-%d.md" % (i, c["id"])
|
||||||
|
with open(os.path.join(cdir, name), "w") as f:
|
||||||
|
f.write(comment_markdown(c))
|
||||||
|
index.append((name, (c.get("user") or {}).get("login", ""), day(c.get("created_at"))))
|
||||||
|
|
||||||
|
# Compact index — the only thing that lands in the model's context.
|
||||||
|
labels = ", ".join(l.get("name", "") for l in iss.get("labels") or []) or "no labels"
|
||||||
|
print("#%d %s [%s] %s — %s, updated %s" % (
|
||||||
|
iss["number"], iss.get("title", ""), iss.get("state", ""), labels,
|
||||||
|
(iss.get("user") or {}).get("login", ""), day(iss.get("updated_at"))))
|
||||||
|
print(data_path)
|
||||||
|
print("comments: %d" % len(comments))
|
||||||
|
for name, author, created in index:
|
||||||
|
print("%s %s %s" % (os.path.join(cdir, name), author, created))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user