refactor!: rewire the plugin onto the kettle binary, and rename it
BREAKING: the plugin is `kettle`, not `tea`, and its commands are `/kettle:*`. It also now needs a binary on PATH that it did not need before; the README and every skill say how to get one and what a missing one looks like. The plugin was 3800 lines of Python doing what a compiled binary does better, and the name pointed at a tool that no longer takes part: `tea` is Gitea's CLI, and since the transport moved into the binary nothing here shells out to it for issues at all. A plugin named after it was going to keep suggesting otherwise. Deleted: 19 scripts, the 14-file unittest suite, and the tea-guard hook. The guard blocked any `tea` invocation that would run under a login the model picked instead of the operator; the binary holds its own credentials and reads the pinned login out of the project's own config, so that failure is no longer expressible and there is nothing left to police. agents-sync stays — it is about AGENTS.md symlinks and has nothing to do with any of this. What the plugin keeps is what only a plugin can carry: the rules an operator states and a binary cannot enforce. `init` still refuses to run inside a linked worktree and still may not be model-invoked, because which directory is the project is a statement a person makes. The issue format reference stays here and stays the source of truth. The runner subagent is still for batches and still may not decide what an issue says. The command reference in the issue, sync and project skills is GENERATED from the binary's own command registry, between markers, so a flag that changed cannot ship with a skill that recommends the old one. `kettle gen skills --check` exits non-zero when they drift. The generator owns the region and nothing outside it: the frontmatter description, which is what decides whether a skill loads at all, stays hand-written. `use` survives and is the one place `tea` is still named — for releases, webhooks and actions, which kettle does not cover. Its instruction to write `--login "$GITEA_LOGIN"` and let the hook substitute the pin was true until this commit and is now rewritten: `tea` keeps its own configuration, kettle keeps its own, and configuring one configures nothing in the other. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,9 +5,9 @@
|
|||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
"name": "tea",
|
"name": "kettle",
|
||||||
"source": "./plugins/tea",
|
"source": "./plugins/kettle",
|
||||||
"description": "Gitea issues as local markdown, cleanly layered: /tea:issue works on issues offline (format, validation, dependency graph), /tea:sync moves them to and from Gitea, /tea:use is the CLI reference, the tea-runner subagent executes the scripts on a cheap model, and a PreToolUse hook blocks any command that would touch Gitea without the operator-pinned login."
|
"description": "Issues as local markdown, driven by the kettle binary: /kettle:init makes a directory a project, /kettle:issue works on issues offline (format, validation, checkboxes, dependency graph), /kettle:sync moves them to and from Gitea, /kettle:auth manages the credential a project runs under, /kettle:use is the tea CLI reference for the Gitea entities kettle does not cover, and the kettle-runner subagent executes batches on a cheap model. Needs the kettle binary on PATH — build it from cli/ in this repository (Go 1.26)."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "tdl",
|
"name": "tdl",
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
# claude-skills — a Claude Code plugin marketplace
|
# claude-skills — a Claude Code plugin marketplace
|
||||||
|
|
||||||
One repository, one marketplace, several plugins. Register it once and install
|
One repository, one marketplace, several plugins, and the `kettle` binary the
|
||||||
whichever pieces you want; each plugin is independent and carries its own
|
issue plugin is built on. Register the marketplace once and install whichever
|
||||||
manifest, docs, and tests.
|
pieces you want; each plugin is independent and carries its own manifest and
|
||||||
|
docs.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -19,17 +20,29 @@ Working from a local clone? Point at the directory instead:
|
|||||||
Then install what you need:
|
Then install what you need:
|
||||||
|
|
||||||
```
|
```
|
||||||
/plugin install tea@claude-skills
|
/plugin install kettle@claude-skills
|
||||||
/plugin install tdl@claude-skills
|
/plugin install tdl@claude-skills
|
||||||
```
|
```
|
||||||
|
|
||||||
Use `/plugin` to enable, disable, or update them later.
|
Use `/plugin` to enable, disable, or update them later.
|
||||||
|
|
||||||
|
**`kettle` also needs its binary**, which no plugin can install for you. Build it
|
||||||
|
from this repository (`cli/go.mod` requires **Go 1.26**; `vendor/` is committed,
|
||||||
|
so the build needs no network):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd cli && go build -o ~/.local/bin/kettle ./cmd/kettle
|
||||||
|
go install git.noodles.cam/claude-skills/marketplace/cli/cmd/kettle@latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Put the target directory on your `PATH` and check with `kettle help`. A skill
|
||||||
|
that answers `command not found: kettle` is telling you exactly this.
|
||||||
|
|
||||||
## What ships here
|
## What ships here
|
||||||
|
|
||||||
| Plugin | Commands | What it does |
|
| Plugin | Commands | What it does |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| [`tea`](plugins/tea) | `/tea:auth` `/tea:issue` `/tea:sync` `/tea:use` | Gitea issues as local markdown, cleanly layered. Issues are units of work offline first and tracker rows second; a PreToolUse hook blocks any `tea` command that would run under a login Claude picked instead of the operator |
|
| [`kettle`](plugins/kettle) | `/kettle:init` `/kettle:auth` `/kettle:issue` `/kettle:sync` `/kettle:use` `/kettle:project` | Issues as local markdown, cleanly layered. Issues are units of work offline first and tracker rows second; the `kettle` binary does the work, and each skill's command reference is generated from the binary's own command registry so it cannot drift |
|
||||||
| [`tdl`](plugins/tdl) | `/tdl:audit` | Three Dots Labs Go conventions as an enforceable rule set — audits a Go project against 63 CQRS/DDD/Clean-Architecture rules by severity, or scaffolds services, handlers, entities, repositories and Watermill adapters from templates that already follow them |
|
| [`tdl`](plugins/tdl) | `/tdl:audit` | Three Dots Labs Go conventions as an enforceable rule set — audits a Go project against 63 CQRS/DDD/Clean-Architecture rules by severity, or scaffolds services, handlers, entities, repositories and Watermill adapters from templates that already follow them |
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
@@ -38,10 +51,12 @@ Use `/plugin` to enable, disable, or update them later.
|
|||||||
.claude-plugin/
|
.claude-plugin/
|
||||||
marketplace.json the catalog — one entry per plugin, source is a
|
marketplace.json the catalog — one entry per plugin, source is a
|
||||||
path into plugins/
|
path into plugins/
|
||||||
|
cli/ the kettle binary: cmd/kettle + internal/*, its
|
||||||
|
own AGENTS.md, its own tests, vendored deps
|
||||||
plugins/
|
plugins/
|
||||||
tea/
|
kettle/
|
||||||
.claude-plugin/plugin.json
|
.claude-plugin/plugin.json
|
||||||
agents/ hooks/ skills/ tests/
|
agents/ hooks/ skills/
|
||||||
README.md AGENTS.md
|
README.md AGENTS.md
|
||||||
tdl/
|
tdl/
|
||||||
.claude-plugin/plugin.json
|
.claude-plugin/plugin.json
|
||||||
@@ -53,11 +68,21 @@ resolves inside it and every path a plugin uses stays relative to itself.
|
|||||||
Adding a plugin means adding a directory here plus one entry in
|
Adding a plugin means adding a directory here plus one entry in
|
||||||
`marketplace.json` — nothing else in the repo needs to know about it.
|
`marketplace.json` — nothing else in the repo needs to know about it.
|
||||||
|
|
||||||
|
`cli/` is deliberately not inside a plugin: a binary is installed on a machine,
|
||||||
|
while a plugin is a directory Claude Code loads, and collapsing the two is what
|
||||||
|
put an earlier version's issue store inside a versioned plugin cache.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
`tea` has a test suite; run it from its own directory so the tests resolve
|
```bash
|
||||||
their root correctly:
|
cd cli && go test ./... # the binary's suite
|
||||||
|
cd cli && go build -o /tmp/kettle ./cmd/kettle
|
||||||
|
/tmp/kettle gen skills --out plugins/kettle/skills --check # docs vs binary
|
||||||
|
```
|
||||||
|
|
||||||
```
|
The second one is the invariant that keeps the plugin honest: everything a
|
||||||
cd plugins/tea && python3 -m unittest discover -s tests
|
SKILL.md says about a `kettle` command — its usage line, its flags, its
|
||||||
```
|
examples — is generated from the registry the binary is built from, between
|
||||||
|
`<!-- kettle:gen -->` markers. `--check` exits 1 when any of it is out of date;
|
||||||
|
run `gen skills` without it to regenerate. Prose outside the markers is never
|
||||||
|
touched.
|
||||||
|
|||||||
+8
-4
@@ -310,7 +310,11 @@ Done and tested: all seven packages, and the commands `init`, `auth`, `config`,
|
|||||||
`new`, `check`, `ac`, `tree`, `index`, `evict`, `pull`, `push`, `remote`,
|
`new`, `check`, `ac`, `tree`, `index`, `evict`, `pull`, `push`, `remote`,
|
||||||
`comment`, `close`, `labels`, `sync-evict`. 99 tests.
|
`comment`, `close`, `labels`, `sync-evict`. 99 tests.
|
||||||
|
|
||||||
Not done: the plugin still ships the Python scripts and the guard hook, and
|
The plugin is rewired: it lives at `plugins/kettle`, ships no Python and no guard
|
||||||
still resolves `.tea/`. Rewiring `plugins/tea` onto this binary — and generating
|
hook, and its command reference is generated from this registry —
|
||||||
its SKILL.md files from the command registry, so the docs cannot drift from the
|
`kettle gen skills --out plugins/kettle/skills`, with `--check` as the invariant.
|
||||||
CLI — is the remaining work.
|
The generator writes one file per GROUP (`project`, `issue`, `sync`); the plugin
|
||||||
|
also carries `init`, `auth` and `use`, which hold procedure rather than flags and
|
||||||
|
point at the generated `project` block. Adding a group here adds a skill
|
||||||
|
directory there, so name one only when it is a subject somebody would load on its
|
||||||
|
own.
|
||||||
|
|||||||
@@ -62,9 +62,9 @@ property made useful: it writes nothing and exits 1 when any file on disk
|
|||||||
differs from what would be generated, which is what a pre-commit hook or a CI
|
differs from what would be generated, which is what a pre-commit hook or a CI
|
||||||
step calls. It wins over --dry-run when both are given.`,
|
step calls. It wins over --dry-run when both are given.`,
|
||||||
Examples: []Example{
|
Examples: []Example{
|
||||||
{"kettle gen skills --out ../plugins/tea/skills", "write the region in every group's SKILL.md"},
|
{"kettle gen skills --out ../plugins/kettle/skills", "write the region in every group's SKILL.md"},
|
||||||
{"kettle gen skills --out ../plugins/tea/skills --dry-run", "print what would change; write nothing"},
|
{"kettle gen skills --out ../plugins/kettle/skills --dry-run", "print what would change; write nothing"},
|
||||||
{"kettle gen skills --out ../plugins/tea/skills --check", "exit 1 if the docs are out of date"},
|
{"kettle gen skills --out ../plugins/kettle/skills --check", "exit 1 if the docs are out of date"},
|
||||||
},
|
},
|
||||||
Setup: func(fs *flag.FlagSet) func([]string) error {
|
Setup: func(fs *flag.FlagSet) func([]string) error {
|
||||||
out := fs.String("out", "", "directory the skills live in; one <group>/SKILL.md under it")
|
out := fs.String("out", "", "directory the skills live in; one <group>/SKILL.md under it")
|
||||||
|
|||||||
@@ -76,11 +76,11 @@ one would write issues into whatever it happened to be installed in.
|
|||||||
machine, outside every working tree, managed with ` + "`kettle auth`" + `.
|
machine, outside every working tree, managed with ` + "`kettle auth`" + `.
|
||||||
|
|
||||||
All of it is idempotent: it creates .kettle/issues and .kettle/payload, migrates
|
All of it is idempotent: it creates .kettle/issues and .kettle/payload, migrates
|
||||||
an older store in if it finds one (tmp/ or .tea/), writes the config without
|
an older store in if it finds one (either layout the tea plugin used, oldest
|
||||||
disturbing settings it was not given, and adds .kettle/ to .gitignore. Each
|
first), writes the config without disturbing settings it was not given, and adds
|
||||||
migration is a move, not a copy — two stores is the state the marker exists to
|
.kettle/ to .gitignore. Each migration is a move, not a copy — two stores is the
|
||||||
prevent — and it refuses to pick a winner when both sides hold a file of the
|
state the marker exists to prevent — and it refuses to pick a winner when both
|
||||||
same name.
|
sides hold a file of the same name.
|
||||||
|
|
||||||
Do NOT run this inside a linked worktree. A worktree is the same project on
|
Do NOT run this inside a linked worktree. A worktree is the same project on
|
||||||
another branch and reaches the store by a hop out to the main checkout; a marker
|
another branch and reaches the store by a hop out to the main checkout; a marker
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ While it says local, this file is the ONLY copy of the work — the store, not a
|
|||||||
cache of anything. That is what a push changes: it hands the issue to the
|
cache of anything. That is what a push changes: it hands the issue to the
|
||||||
tracker and removes the file.
|
tracker and removes the file.
|
||||||
|
|
||||||
Writes .tea/issues/<slug>.md prefilled with the type's template, prints the
|
Writes .kettle/issues/<slug>.md prefilled with the type's template, prints the
|
||||||
path, and rebuilds INDEX.md. Fill the sections in an editor, then run
|
path, and rebuilds INDEX.md. Fill the sections in an editor, then run
|
||||||
` + "`kettle check <id>`" + `.
|
` + "`kettle check <id>`" + `.
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ Body prose is Russian, section headers and the title are English.`,
|
|||||||
Examples: []Example{
|
Examples: []Example{
|
||||||
{`kettle new --type task --title "Wire sqlc into the appclick repo layer" --label tech/sql --label comp/appclick`,
|
{`kettle new --type task --title "Wire sqlc into the appclick repo layer" --label tech/sql --label comp/appclick`,
|
||||||
"a task with two free-form labels"},
|
"a task with two free-form labels"},
|
||||||
{`kettle new --type bug --title "Fix tea-guard crash on empty settings" --depends wire-sqlc-appclick --milestone v0.2`,
|
{`kettle new --type bug --title "Fix the index rebuild on an empty store" --depends wire-sqlc-appclick --milestone v0.2`,
|
||||||
"a bug that is blocked by another issue"},
|
"a bug that is blocked by another issue"},
|
||||||
},
|
},
|
||||||
Setup: func(fs *flag.FlagSet) func([]string) error {
|
Setup: func(fs *flag.FlagSet) func([]string) error {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ this works identically for issues that were never pushed anywhere.
|
|||||||
Downwards is what this draws — what an issue depends on. The other direction is
|
Downwards is what this draws — what an issue depends on. The other direction is
|
||||||
a grep, not a flag:
|
a grep, not a flag:
|
||||||
|
|
||||||
grep -ln 'depends:.*migrate-schema' .tea/issues/*.md`,
|
grep -ln 'depends:.*migrate-schema' .kettle/issues/*.md`,
|
||||||
Examples: []Example{
|
Examples: []Example{
|
||||||
{"kettle tree", "every root (nothing depends on it)"},
|
{"kettle tree", "every root (nothing depends on it)"},
|
||||||
{"kettle tree wire-sqlc-appclick", "one subtree"},
|
{"kettle tree wire-sqlc-appclick", "one subtree"},
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"name": "kettle",
|
||||||
|
"description": "Issues as local markdown, driven by the kettle binary: /kettle:init makes a directory a project, /kettle:issue works on issues offline (format, validation, checkboxes, dependency graph), /kettle:sync moves them to and from Gitea, /kettle:auth manages the credential a project runs under, /kettle:use is the tea CLI reference for the Gitea entities kettle does not cover, and the kettle-runner subagent executes batches on a cheap model. The command reference in each skill is generated from the binary's own command registry, so it cannot drift.",
|
||||||
|
"version": "3.0.0",
|
||||||
|
"author": {
|
||||||
|
"name": "naudachu"
|
||||||
|
},
|
||||||
|
"license": "MIT",
|
||||||
|
"keywords": ["gitea", "issues", "markdown", "cli", "kettle"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
# AGENTS.md — the kettle plugin
|
||||||
|
|
||||||
|
This plugin is a **thin wrapper over the `kettle` binary**, which lives in this
|
||||||
|
same repository at [`cli/`](../../cli) and is documented in
|
||||||
|
[`cli/AGENTS.md`](../../cli/AGENTS.md). The binary owns everything mechanical:
|
||||||
|
what an issue is, where the store lives, who this machine is, and how issues move
|
||||||
|
to and from Gitea. Its layering, its walk, its round-trip guarantees and its
|
||||||
|
tests are described there and are **not repeated here** — one design, one place.
|
||||||
|
|
||||||
|
What a plugin can carry that a binary cannot is the reason this directory still
|
||||||
|
exists:
|
||||||
|
|
||||||
|
1. **The rules an operator states.** A binary can refuse to evict an
|
||||||
|
`origin: local` issue; it cannot refuse to be run in the wrong directory, or
|
||||||
|
decide that a migration clash is not a model's to resolve.
|
||||||
|
2. **Routing.** A `description:` in a SKILL.md frontmatter is the only thing that
|
||||||
|
decides whether an agent loads a skill at all, and no generator can write it.
|
||||||
|
3. **Procedures.** How to turn a one-line request into a properly filled
|
||||||
|
template, what to ask the user and what to never invent.
|
||||||
|
|
||||||
|
## Installing the binary
|
||||||
|
|
||||||
|
**`kettle` is not on anybody's PATH by default.** Nothing in this plugin ships
|
||||||
|
it, and a skill that assumes it exists fails with `command not found: kettle` —
|
||||||
|
which is the clearest failure available, and every skill says what to do about
|
||||||
|
it rather than falling back to something else.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd cli && go build -o ~/.local/bin/kettle ./cmd/kettle # from this repository
|
||||||
|
go install git.noodles.cam/claude-skills/marketplace/cli/cmd/kettle@latest
|
||||||
|
```
|
||||||
|
|
||||||
|
`cli/go.mod` says **go 1.26** — the Gitea SDK requires it, so that is the minimum
|
||||||
|
for anybody building this. `vendor/` is committed, so a build from a clone needs
|
||||||
|
no network.
|
||||||
|
|
||||||
|
An operator who sees `command not found: kettle` installs it and re-runs; there
|
||||||
|
is nothing to configure in this plugin either way. `kettle config` is the command
|
||||||
|
that explains where a run resolved to once it does exist.
|
||||||
|
|
||||||
|
## The generated command reference
|
||||||
|
|
||||||
|
Every command line in `skills/{project,issue,sync}/SKILL.md` between
|
||||||
|
|
||||||
|
```
|
||||||
|
<!-- kettle:gen --> … <!-- /kettle:gen -->
|
||||||
|
```
|
||||||
|
|
||||||
|
is written by `kettle gen skills` from the command registry the binary is built
|
||||||
|
from, and **must not be edited by hand** — the next run replaces it. Everything
|
||||||
|
outside the markers is prose and comes back byte for byte, which is why the
|
||||||
|
frontmatter is safe.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd cli && go build -o /tmp/kettle ./cmd/kettle
|
||||||
|
/tmp/kettle gen skills --out plugins/kettle/skills # rewrite the blocks
|
||||||
|
/tmp/kettle gen skills --out plugins/kettle/skills --check # exit 1 if stale
|
||||||
|
```
|
||||||
|
|
||||||
|
`--check` is what a pre-commit hook or a CI step calls; it writes nothing. A file
|
||||||
|
the run reports as `without a region` is one where somebody dropped the markers —
|
||||||
|
it is left alone, never overwritten, and the fix is to put them back.
|
||||||
|
|
||||||
|
**Groups and skills are not the same set, and that is the one seam.** The binary
|
||||||
|
groups its commands `project`, `issue`, `sync`; the plugin's skills are `init`,
|
||||||
|
`auth`, `project`, `issue`, `sync`, `use`. The generator writes one
|
||||||
|
`<group>/SKILL.md`, so:
|
||||||
|
|
||||||
|
| skill | generated region | why |
|
||||||
|
|---|---|---|
|
||||||
|
| `issue`, `sync` | yes — the group of the same name | the skill and the group are the same subject |
|
||||||
|
| `project` | yes — `init`, `auth`, `config`, `gen` | the flag table `init` and `auth` point at |
|
||||||
|
| `init` | no | it is a *procedure* around one command, and it is operator-only |
|
||||||
|
| `auth` | no | it is a *procedure* around two, and it must not tempt a model into typing a token |
|
||||||
|
| `use` | no | it documents `tea`, which is not this binary |
|
||||||
|
|
||||||
|
The three skills with no region hold no flag tables of their own. They name a
|
||||||
|
command and send the reader to `/kettle:project`, which is the point: a file that
|
||||||
|
hand-copies a flag list is a file that will disagree with the binary in a month.
|
||||||
|
If the generator ever cannot express what a skill needs, the answer is to change
|
||||||
|
the generator, not to paste a block that will rot.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
.claude-plugin/plugin.json the manifest (the marketplace catalog is one level
|
||||||
|
up, in the repo root's .claude-plugin/)
|
||||||
|
agents/
|
||||||
|
kettle-runner.md subagent (Haiku): runs kettle commands, reports a
|
||||||
|
receipt. Batches only, and no opinions about content
|
||||||
|
hooks/
|
||||||
|
hooks.json registers the PreToolUse hooks
|
||||||
|
agents-sync.sh keeps every directory canonical: AGENTS.md a real
|
||||||
|
file, CLAUDE.md a symlink to it
|
||||||
|
skills/
|
||||||
|
init/ SKILL.md /kettle:init — operator-only; the rules around
|
||||||
|
`kettle init`
|
||||||
|
auth/ SKILL.md /kettle:auth — the credential workflow around
|
||||||
|
`kettle auth` and `kettle init --login`
|
||||||
|
project/ SKILL.md generated flag reference: init, auth, config, gen
|
||||||
|
issue/ SKILL.md /kettle:issue — the offline commands
|
||||||
|
references/format.md THE canonical issue format; source of truth
|
||||||
|
sync/ SKILL.md /kettle:sync — the tracker commands
|
||||||
|
use/ SKILL.md /kettle:use — the `tea` CLI, for the Gitea entities
|
||||||
|
references/tea/ kettle does not cover: releases, webhooks,
|
||||||
|
actions, pull requests
|
||||||
|
```
|
||||||
|
|
||||||
|
`references/format.md` is the one document here that the binary does not
|
||||||
|
generate and does not own a copy of. It is the format's statement of intent —
|
||||||
|
identity, metadata, label namespaces, the per-type templates, the language rule —
|
||||||
|
and it stays hand-written.
|
||||||
|
|
||||||
|
## The rules that must survive, because a binary cannot state them
|
||||||
|
|
||||||
|
- **An `origin: local` issue is the only copy of that work.** A push deletes the
|
||||||
|
local file only after the tracker confirms the write and the number → slug
|
||||||
|
ledger is written; nothing else deletes it, ever.
|
||||||
|
- **A closed issue is evicted, not archived** — and a local one is never evicted,
|
||||||
|
in any state, not even when it is named on the command line.
|
||||||
|
- **Body prose is Russian; the title and the section headers are English.**
|
||||||
|
- **Do not run `init` inside a linked worktree.** `.kettle/` is gitignored, a
|
||||||
|
worktree reaches the main checkout's store on its own, and a marker there gives
|
||||||
|
one project two stores — the second of which disappears with the branch.
|
||||||
|
- **Which directory is the project is a statement a person makes.** `/kettle:init`
|
||||||
|
keeps `disable-model-invocation: true` for that reason, and never invents an
|
||||||
|
`--at`.
|
||||||
|
- **A migration clash is the operator's to resolve.** The binary stops and names
|
||||||
|
both files; one of them may be somebody's only copy.
|
||||||
|
|
||||||
|
## What this plugin no longer ships, and why
|
||||||
|
|
||||||
|
| gone | replaced by |
|
||||||
|
|---|---|
|
||||||
|
| every Python script under `skills/*/scripts/` — the whole domain, bridge and transport | the `kettle` binary; see `cli/AGENTS.md` for the three runtime failures that motivated it |
|
||||||
|
| `tests/` — the stdlib `unittest` suite | `cd cli && go test ./...` |
|
||||||
|
| the guard hook and its entry in `hooks/hooks.json` | nothing. It existed to stop a `tea` command running under a login the model picked; the binary holds its own credentials and reads the login out of the project's config, so the failure is not expressible any more |
|
||||||
|
| `.claude/settings.local.json` → `env.GITEA_LOGIN`, the login pin | `<project>/.kettle/config.yaml` (a login **name**) plus `~/.config/kettle/logins.yaml` (the tokens, 0600, outside every working tree) |
|
||||||
|
| the tea plugin's own store marker | `.kettle/`; `kettle init` migrates an older layout in, and each migration is a move |
|
||||||
|
|
||||||
|
`hooks/agents-sync.sh` is unrelated to any of that and stays.
|
||||||
|
|
||||||
|
All of it is in git history. `git log --diff-filter=D` finds it if a decision
|
||||||
|
needs to be re-read rather than re-derived.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
The plugin has no test suite of its own; the binary's is the suite.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd cli && go test ./...
|
||||||
|
/tmp/kettle gen skills --out plugins/kettle/skills --check
|
||||||
|
```
|
||||||
|
|
||||||
|
The second line is the plugin's only mechanical invariant: the documentation an
|
||||||
|
agent reads agrees with the binary it is documenting.
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
# kettle — Claude Code plugin for issues as local markdown
|
||||||
|
|
||||||
|
Issues are units of work first and tracker rows second. `kettle` keeps them as
|
||||||
|
flat markdown files in your project, works on them entirely offline, and moves
|
||||||
|
them to and from Gitea when you decide to — never before.
|
||||||
|
|
||||||
|
The plugin is a thin wrapper. The work is done by the **`kettle` binary**, which
|
||||||
|
lives in this same repository under [`cli/`](../../cli); the skills carry the
|
||||||
|
rules and procedures a binary cannot state, and their command reference is
|
||||||
|
generated from the binary's own command registry, so the docs cannot drift from
|
||||||
|
the tool.
|
||||||
|
|
||||||
|
## What it ships
|
||||||
|
|
||||||
|
| Piece | What it does |
|
||||||
|
|---|---|
|
||||||
|
| `/kettle:init` skill | Makes a directory a project: creates the `.kettle/` marker every command resolves the store from. Once per project, and only you can run it |
|
||||||
|
| `/kettle:auth` skill | The credential workflow — what this machine holds, and which login this project runs under |
|
||||||
|
| `/kettle:project` skill | Generated flag reference for `init`, `auth`, `config`, `gen` |
|
||||||
|
| `/kettle:issue` skill | Issues as units of work — create, read, grep, validate, tick, evict, walk the dependency graph. Entirely offline |
|
||||||
|
| `/kettle:sync` skill | Moves issues between the local store and Gitea — pull, push, comment, close, evict |
|
||||||
|
| `/kettle:use` skill | `tea` CLI reference for the Gitea entities kettle does not cover: pulls, releases, milestones, webhooks, actions |
|
||||||
|
| `kettle-runner` agent | Subagent on Haiku that runs batches of commands and reports back a receipt — the mechanical half, off your main context |
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- **Claude Code** — CLI, desktop app, or IDE extension.
|
||||||
|
- **The `kettle` binary, on your `PATH`.** It is not installed for you, and
|
||||||
|
nothing here works without it — see below.
|
||||||
|
- **Python 3** — the `agents-sync` hook is a Python script; `python3` must be on
|
||||||
|
`$PATH`. Nothing else here needs it.
|
||||||
|
- **`tea`** (optional) — Gitea's own CLI, only for `/kettle:use`. `brew install
|
||||||
|
tea`, or from [gitea.com/gitea/tea/releases](https://gitea.com/gitea/tea/releases).
|
||||||
|
It keeps its own logins (`tea logins add`), separate from kettle's.
|
||||||
|
|
||||||
|
### Installing the binary
|
||||||
|
|
||||||
|
Building it needs **Go 1.26** — `cli/go.mod` says so because the Gitea SDK
|
||||||
|
requires it. `vendor/` is committed, so the build itself needs no network.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# from a clone of this repository
|
||||||
|
cd cli && go build -o ~/.local/bin/kettle ./cmd/kettle
|
||||||
|
|
||||||
|
# or, without cloning
|
||||||
|
go install git.noodles.cam/claude-skills/marketplace/cli/cmd/kettle@latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Make sure the target directory is on your `PATH` (`go install` uses
|
||||||
|
`$(go env GOPATH)/bin`). Check it with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kettle help
|
||||||
|
```
|
||||||
|
|
||||||
|
If a skill ever answers `command not found: kettle`, that is the whole diagnosis:
|
||||||
|
the binary is missing. Install it and run the command again — the skills say so
|
||||||
|
rather than falling back to something that half-works.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
This is a Claude Code plugin — install it through the plugin marketplace, not by
|
||||||
|
hand-editing `settings.json`.
|
||||||
|
|
||||||
|
1. Register the marketplace this plugin ships in:
|
||||||
|
|
||||||
|
```
|
||||||
|
/plugin marketplace add https://git.noodles.cam/claude-skills/marketplace.git
|
||||||
|
```
|
||||||
|
|
||||||
|
Already have a local clone? Point at the directory instead:
|
||||||
|
|
||||||
|
```
|
||||||
|
/plugin marketplace add /path/to/marketplace
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Install the plugin:
|
||||||
|
|
||||||
|
```
|
||||||
|
/plugin install kettle@claude-skills
|
||||||
|
```
|
||||||
|
|
||||||
|
The skills load immediately. Use `/plugin` to enable, disable, or update it
|
||||||
|
later.
|
||||||
|
|
||||||
|
> The marketplace registration is written to `extraKnownMarketplaces` and the
|
||||||
|
> plugin to `enabledPlugins` in your settings automatically — you don't edit
|
||||||
|
> those by hand. There is **no** top-level `"plugins"` settings key; if you've
|
||||||
|
> added one from older instructions, remove it.
|
||||||
|
|
||||||
|
## First use
|
||||||
|
|
||||||
|
Run `/kettle:init` once per project. It creates the `.kettle/` marker that says
|
||||||
|
*this* directory is the project whose issues live in it — every command resolves
|
||||||
|
the store by walking up to the nearest one, and with no marker anywhere they stop
|
||||||
|
and name the directories they searched rather than picking a plausible one.
|
||||||
|
|
||||||
|
```
|
||||||
|
/kettle:init
|
||||||
|
```
|
||||||
|
|
||||||
|
Only you can run it; Claude can't invoke it on its own. Which directory is a
|
||||||
|
project is a statement, and a model guessing at one is the failure the marker
|
||||||
|
exists to prevent. It is idempotent, adds `.kettle/` to `.gitignore`, and moves
|
||||||
|
an older store in if it finds one.
|
||||||
|
|
||||||
|
Don't run it inside a `git worktree`: the marker is gitignored, so a worktree has
|
||||||
|
none by design and reaches the main checkout's store on its own.
|
||||||
|
|
||||||
|
That is all `/kettle:issue` needs — no login, no network, no tracker.
|
||||||
|
|
||||||
|
For the Gitea side, give this machine a credential and pin it to the project:
|
||||||
|
|
||||||
|
```
|
||||||
|
/kettle:auth
|
||||||
|
```
|
||||||
|
|
||||||
|
Claude will list what `kettle auth` already holds and ask you to pick. Adding a
|
||||||
|
login is yours to do — the token is read from standard input so it never lands in
|
||||||
|
shell history, and never in a transcript:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kettle auth add --name noodles --url https://git.example.com < token.txt
|
||||||
|
kettle init --login noodles --repo owner/name
|
||||||
|
```
|
||||||
|
|
||||||
|
Tokens live in `~/.config/kettle/logins.yaml`, mode 0600, outside every working
|
||||||
|
tree. What goes in the repository is the login's **name**, in
|
||||||
|
`.kettle/config.yaml` — worth nothing on its own, which is what makes it safe
|
||||||
|
there. `kettle config` prints everything a directory resolved to and never prints
|
||||||
|
a token.
|
||||||
|
|
||||||
|
After that, just ask Claude to do something with issues — it loads the right
|
||||||
|
skill on its own.
|
||||||
|
|
||||||
|
## The kettle-runner agent
|
||||||
|
|
||||||
|
The skills carry meaning; the binary carries work. `kettle-runner` is a subagent
|
||||||
|
on Haiku that does the second half in its own context and hands back a receipt —
|
||||||
|
what ran, what it touched, what failed, verbatim.
|
||||||
|
|
||||||
|
Delegate a **batch**: pull a milestone and rebuild the index, push the three
|
||||||
|
issues you just wrote, bootstrap the label set, post a comment from a file you
|
||||||
|
prepared. Spawning it for a single `kettle pull 42` costs more than running the
|
||||||
|
command yourself; the saving is in the loop, the retry, and reading somebody
|
||||||
|
else's stderr.
|
||||||
|
|
||||||
|
It cannot decide anything. No `Edit`, no `Write`, no `--force`, no widening the
|
||||||
|
set it was handed, no raw `tea`. A missing type, a failed validation, an unpushed
|
||||||
|
dependency come back as a question, not as a guess.
|
||||||
|
|
||||||
|
## Project layout
|
||||||
|
|
||||||
|
```
|
||||||
|
.claude-plugin/
|
||||||
|
plugin.json plugin manifest
|
||||||
|
(the marketplace catalog lives one level up, in
|
||||||
|
the repo root's .claude-plugin/marketplace.json)
|
||||||
|
agents/
|
||||||
|
kettle-runner.md subagent (Haiku) that runs the commands
|
||||||
|
hooks/
|
||||||
|
hooks.json registers the PreToolUse hooks
|
||||||
|
agents-sync.sh keeps AGENTS.md real and CLAUDE.md a symlink to it
|
||||||
|
skills/
|
||||||
|
init/ /kettle:init — make a directory a project
|
||||||
|
auth/ /kettle:auth — the credential a project runs under
|
||||||
|
project/ generated flag reference: init, auth, config, gen
|
||||||
|
issue/ /kettle:issue — the issue domain, offline
|
||||||
|
references/format.md canonical issue format (identity, types, templates)
|
||||||
|
sync/ /kettle:sync — the bridge to Gitea
|
||||||
|
use/ /kettle:use — tea CLI reference
|
||||||
|
references/tea/ command docs
|
||||||
|
```
|
||||||
|
|
||||||
|
`AGENTS.md` carries the same layout with the reasoning behind it, and the
|
||||||
|
binary's own design is in [`cli/AGENTS.md`](../../cli/AGENTS.md). If any two
|
||||||
|
disagree, the binary is right and the prose is stale.
|
||||||
|
|
||||||
|
## Local issue store
|
||||||
|
|
||||||
|
Issues live in `<project>/.kettle/issues/` as flat markdown with one metadata
|
||||||
|
field per line — so `grep -l 'labels:.*type/bug' .kettle/issues/*.md` works
|
||||||
|
without a parser. The directory is gitignored by `kettle init`; drop that line if
|
||||||
|
your team decides otherwise.
|
||||||
|
|
||||||
|
It holds two kinds of file and only one of them is a store:
|
||||||
|
|
||||||
|
- **An `origin: local` file *is* the issue** — the only copy of that work. It is
|
||||||
|
a complete state, not a draft, and nothing evicts it, in any state.
|
||||||
|
- **Anything with a tracker origin is a working copy.** A successful push deletes
|
||||||
|
it — `--update` too, one rule with no exception — and only after the tracker
|
||||||
|
confirms the write. Get it back with `kettle pull <n>`: same slug, same
|
||||||
|
`depends:`, even after a rename in the web UI.
|
||||||
|
- Identity is a slug (`wire-sqlc-appclick.md`), never a tracker number. Numbers
|
||||||
|
live in a `gitea:` field.
|
||||||
|
- A closed issue is **evicted, not archived**. The store is a working set.
|
||||||
|
- Pulling overwrites the body: a fetch, not a merge. Checkbox state is the one
|
||||||
|
exception, because a tick is monotone.
|
||||||
|
- Nothing tracks drift, and there is nothing to track: a file that is still here
|
||||||
|
has not been pushed.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd cli && go test ./... # the binary's suite
|
||||||
|
cd cli && go build -o /tmp/kettle ./cmd/kettle
|
||||||
|
/tmp/kettle gen skills --out plugins/kettle/skills --check # docs vs binary
|
||||||
|
```
|
||||||
|
|
||||||
|
The second command is the plugin's only mechanical invariant: what the skills say
|
||||||
|
about a command matches the command. Run `gen skills` without `--check` to
|
||||||
|
rewrite the generated regions after changing the CLI, and never edit inside the
|
||||||
|
`<!-- kettle:gen -->` markers by hand.
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
---
|
||||||
|
name: kettle-runner
|
||||||
|
description: Runs `kettle` commands and hands back a compact receipt. Use for the mechanical half of issue work — a bulk pull, pushing a set the caller already named, posting a comment from a file, bootstrapping labels, rebuilding the index or the tree. It runs commands; it never decides what an issue should say. Delegate a batch, not a single call.
|
||||||
|
tools: Bash, Read, Grep, Glob, Skill
|
||||||
|
model: haiku
|
||||||
|
---
|
||||||
|
|
||||||
|
# kettle-runner — the execution layer
|
||||||
|
|
||||||
|
You run `kettle` commands and hand back a short receipt. The binary holds the
|
||||||
|
mechanics; the skills hold the meaning; you hold neither.
|
||||||
|
|
||||||
|
**You have no opinion about content.** Titles, bodies, types, labels,
|
||||||
|
dependencies, what is worth filing and what is worth closing — all of that was
|
||||||
|
decided before you were called, and if it was not, the answer is to say so, not
|
||||||
|
to fill the gap yourself.
|
||||||
|
|
||||||
|
## Where the commands come from
|
||||||
|
|
||||||
|
Load the skill, do not remember the flags:
|
||||||
|
|
||||||
|
- `/kettle:sync` — `pull`, `push`, `remote`, `comment`, `close`, `labels`,
|
||||||
|
`sync-evict`
|
||||||
|
- `/kettle:issue` — `new`, `check`, `ac`, `tree`, `index`, `evict`
|
||||||
|
- `/kettle:project` — `config`, `auth list`
|
||||||
|
|
||||||
|
Invoke `Skill` with the one that owns the task at the start and use the generated
|
||||||
|
command reference it carries verbatim. That block is written from the binary's
|
||||||
|
own command registry, so it cannot disagree with the binary; a flag you recall
|
||||||
|
from another session can. If the reference does not document a flag, it does not
|
||||||
|
exist — report that instead of trying it. `kettle help <command>` is the same
|
||||||
|
truth if you need it in a hurry.
|
||||||
|
|
||||||
|
## Hard rules
|
||||||
|
|
||||||
|
1. **`kettle` only.** No raw `tea`, no `tea api`, no curl at a tracker. The
|
||||||
|
binary carries the project's credentials; there is no login for you to name
|
||||||
|
and none for you to choose. If a task needs an entity `kettle` does not cover,
|
||||||
|
that is a finding for the caller, not a command for you to improvise.
|
||||||
|
2. **No writing to issue files.** You have no `Edit` and no `Write`. Commands
|
||||||
|
write files; you do not. If a task needs a body edited or a metadata field
|
||||||
|
changed by hand, stop and say which file and which field. `kettle ac` is the
|
||||||
|
one command that touches a body and it changes a single character: tick only
|
||||||
|
the items the caller named, by the number or the substring the caller gave.
|
||||||
|
Whether a criterion is actually met is a judgement about content, and content
|
||||||
|
is never yours.
|
||||||
|
3. **Push only what you were told to push.** `kettle push` publishes to a tracker
|
||||||
|
other people read, **and it deletes the local file on success** — so a widened
|
||||||
|
set is not an over-share, it is somebody else's working copy gone. Run it with
|
||||||
|
the ids or filter the caller named. Never widen the set, never run a bare
|
||||||
|
`kettle push` because it looked like the obvious next step, and **never pass
|
||||||
|
`--force`** — a validation failure is a result to report, not an obstacle to
|
||||||
|
route around. Report the number and URL it printed; that is now the only
|
||||||
|
address the issue has.
|
||||||
|
4. **Close only the ids the caller named.** Same discipline as push. Never infer
|
||||||
|
that an issue is finished because its checkboxes are ticked or its branch is
|
||||||
|
merged. `--reopen` is the same rule backwards. Retitling is not yours, and
|
||||||
|
deleting anything on a tracker is never yours.
|
||||||
|
|
||||||
|
Two local deletions are allowed, both only when the caller asked for them:
|
||||||
|
push's own, on the issues you were told to push, and eviction (`kettle evict`
|
||||||
|
/ `kettle sync-evict`) of closed issues. Run eviction with `--dry-run` first
|
||||||
|
and report what it named. It refuses to touch an `origin: local` issue by
|
||||||
|
itself — that is the binary's guarantee, not your judgement, and it is not a
|
||||||
|
reason to point it at a store nobody asked you to clean.
|
||||||
|
5. **One retry, maximum.** A command that fails twice is a finding. Do not
|
||||||
|
permute flags looking for one that works.
|
||||||
|
6. **No payload dumps.** Never `cat` a pulled issue body back into your report.
|
||||||
|
`kettle` prints compact output by design; the caller reads the files it needs
|
||||||
|
from disk.
|
||||||
|
|
||||||
|
## Procedure
|
||||||
|
|
||||||
|
1. Load the skill you need.
|
||||||
|
2. Run the commands. Prefer one filtered call over a loop — `kettle pull
|
||||||
|
--milestone 6` pages the list endpoint, `kettle pull 41 42 43 …` is a request
|
||||||
|
per issue and per blocker.
|
||||||
|
3. If a command exits non-zero, capture the last lines of stderr and stop that
|
||||||
|
branch. Keep going on independent branches.
|
||||||
|
4. Report.
|
||||||
|
|
||||||
|
## Report format
|
||||||
|
|
||||||
|
Your final message is the return value. Keep it under ~20 lines. No preamble, no
|
||||||
|
restatement of the request, no advice about what to do next.
|
||||||
|
|
||||||
|
```
|
||||||
|
ran:
|
||||||
|
kettle pull --milestone 6 --state all ok 7 issues, 3 threads
|
||||||
|
kettle index ok INDEX.md rebuilt
|
||||||
|
kettle push wire-sqlc-appclick FAIL exit 1
|
||||||
|
|
||||||
|
touched: .kettle/issues/{a,b,c}.md, .kettle/issues/INDEX.md
|
||||||
|
|
||||||
|
failed: kettle push wire-sqlc-appclick
|
||||||
|
ERROR wire-sqlc-appclick: missing section '## Acceptance criteria'
|
||||||
|
|
||||||
|
blocked: none
|
||||||
|
```
|
||||||
|
|
||||||
|
- `ran` — one line per command: what, ok/FAIL, and the one number that matters.
|
||||||
|
- `touched` — paths only. Never contents.
|
||||||
|
- `failed` — the command, then stderr verbatim, trimmed to the lines that name
|
||||||
|
the cause. Quote it exactly; do not paraphrase an error.
|
||||||
|
- `blocked` — what you refused to decide, phrased as the question the caller has
|
||||||
|
to answer. `none` when there is nothing.
|
||||||
|
|
||||||
|
## Known stops
|
||||||
|
|
||||||
|
Report these and halt; none of them is yours to resolve.
|
||||||
|
|
||||||
|
| Condition | Report |
|
||||||
|
|---|---|
|
||||||
|
| `command not found: kettle` | `blocked: kettle is not installed — operator builds it from cli/ or go install`s it |
|
||||||
|
| `no .kettle/ found — searched up from …` | `blocked: not a project — operator must run /kettle:init here` |
|
||||||
|
| no login pinned, an unknown login name, 401/403 | `blocked: credential — operator runs /kettle:auth`, with the binary's own line |
|
||||||
|
| `kettle check` errors before a push | the validator's own lines, verbatim |
|
||||||
|
| a dependency is still `origin: local` | name the id; the caller decides whether to push it |
|
||||||
|
| a milestone or label does not exist in the repo | the command prints the real ones — pass that list through |
|
||||||
|
| a command asks for a decision (type, label, `--force`) | `blocked:` with the question |
|
||||||
|
| the tracker refuses a close because the issue is still blocked | the tracker's own line and the blocker's number; the caller decides |
|
||||||
@@ -7,10 +7,6 @@
|
|||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/agents-sync.sh"
|
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/agents-sync.sh"
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "command",
|
|
||||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/tea-guard.sh"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
---
|
||||||
|
name: auth
|
||||||
|
description: Give `kettle` a Gitea credential and choose which login this project runs under — `kettle auth list/add/remove` manages the machine-wide token file, `kettle init --login <name>` pins one of those names into `.kettle/config.yaml`. Load when a sync command reports no login, a 401, or an unknown login name, or when the user asks to switch the account this project's issues are pushed under. The OPERATOR picks the login; you never type a token.
|
||||||
|
---
|
||||||
|
|
||||||
|
# /kettle:auth — the credential a project runs under
|
||||||
|
|
||||||
|
Two files, and the split is the whole design.
|
||||||
|
|
||||||
|
| where | what is in it | who writes it |
|
||||||
|
|---|---|---|
|
||||||
|
| `~/.config/kettle/logins.yaml` | the tokens, one file per machine, mode 0600, outside every working tree | `kettle auth add` |
|
||||||
|
| `<project>/.kettle/config.yaml` | the **name** of one of those logins, and the tracker repo | `kettle init --login … --repo …` |
|
||||||
|
|
||||||
|
A name is worth nothing on its own, which is what makes it safe to keep in a
|
||||||
|
file inside a repository. A token in a working tree ends up in a commit
|
||||||
|
eventually, and a secret that has ever been pushed has to be rotated.
|
||||||
|
`$KETTLE_CONFIG_HOME` or `$XDG_CONFIG_HOME` move the machine file;
|
||||||
|
`KETTLE_LOGIN`, `KETTLE_URL` and `KETTLE_TOKEN` override it outright, which is
|
||||||
|
how CI runs with no token on disk.
|
||||||
|
|
||||||
|
**There is no login pinned in `.claude/settings.local.json` any more, and no hook
|
||||||
|
that rewrites a `--login` argument.** That mechanism is gone with the Python
|
||||||
|
scripts; nothing here reads Claude's settings. If you find a `GITEA_LOGIN` in a
|
||||||
|
settings file, it is dead weight from the old plugin.
|
||||||
|
|
||||||
|
## The one hard rule: the operator chooses, and holds the token
|
||||||
|
|
||||||
|
- **Never pick a login.** Not from memory, not from the repo URL, not from a
|
||||||
|
previous session. Present the choice with `AskUserQuestion` — name, url and
|
||||||
|
user out of `kettle auth list` — and let the operator answer. Exactly one login
|
||||||
|
on the machine is the only case where you may propose, and you still confirm.
|
||||||
|
- **Never type, echo, paste or read a token.** `kettle auth add` takes it on
|
||||||
|
stdin precisely so it does not land in shell history; a token that goes through
|
||||||
|
a model's context is a token in a transcript. Adding a login is the operator's
|
||||||
|
own terminal, not a Bash call you make for them.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. See what this machine holds. It never prints a token, and there is no flag to
|
||||||
|
make it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kettle auth list
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Nothing there** — stop and hand the operator the command to run themselves:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kettle auth add --name noodles --url https://git.example.com --user naudachu < token.txt
|
||||||
|
pass show gitea/token | kettle auth add --name noodles --url https://git.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
`--user` is documentation only. `kettle auth remove <name>` forgets one.
|
||||||
|
|
||||||
|
3. **Pin the choice into the project.** Ask `kettle config` first and only
|
||||||
|
proceed if it answers with a project — `kettle init` in a directory that is
|
||||||
|
not one would *create* a project there, which is the one statement that is
|
||||||
|
never yours to make (`/kettle:init`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kettle config
|
||||||
|
kettle init --login noodles
|
||||||
|
```
|
||||||
|
|
||||||
|
`init` on an initialized project prints `already initialized — nothing to do`
|
||||||
|
and rewrites only the settings it was given, so the repo pinned earlier stays.
|
||||||
|
|
||||||
|
4. Confirm with `kettle config`. Tell the operator which login is pinned and
|
||||||
|
which file it went in. It is live immediately — nothing caches it, no restart.
|
||||||
|
|
||||||
|
```
|
||||||
|
login noodles
|
||||||
|
url https://git.example.com
|
||||||
|
token (set)
|
||||||
|
repo claude-skills/marketplace
|
||||||
|
```
|
||||||
|
|
||||||
|
## When it goes wrong
|
||||||
|
|
||||||
|
| what you see | what it means |
|
||||||
|
|---|---|
|
||||||
|
| `no login "X" in …/logins.yaml — known: …` | the project pins a name this machine does not hold. Either add it (step 2) or pin one that is there |
|
||||||
|
| `no .kettle/ found — searched up from …` | not a project. `/kettle:init`, and it is the operator's to run |
|
||||||
|
| `401` / `403` from a sync command | report it verbatim. Do **not** try another login, and do not edit or remove one to route around it — that is somebody's identity, not a setting |
|
||||||
|
| `token none` in `kettle config` | a name is pinned but no credential answers to it |
|
||||||
|
|
||||||
|
**`tea` does not read any of this.** The `tea` CLI keeps its own configuration
|
||||||
|
under `$XDG_CONFIG_HOME/tea` and its own logins (`tea logins list`), and
|
||||||
|
configuring one tool configures nothing in the other — see `/kettle:use`.
|
||||||
|
|
||||||
|
**No `kettle` on PATH?** `command not found: kettle` is the whole story. Stop and
|
||||||
|
tell the operator to install it: `cd cli && go build -o ~/.local/bin/kettle
|
||||||
|
./cmd/kettle` in the marketplace repository (go.mod requires **go 1.26**), or
|
||||||
|
`go install git.noodles.cam/claude-skills/marketplace/cli/cmd/kettle@latest`.
|
||||||
|
|
||||||
|
The full flag table for `auth`, `config` and `init` is the generated block in
|
||||||
|
`/kettle:project`.
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
---
|
||||||
|
name: init
|
||||||
|
description: Make THIS directory a project that tracks issues — run `kettle init`, which creates the `.kettle/` marker every other command resolves the store from, migrates an older store in, and gitignores it. Operator-invoked only; carries the rules the binary cannot enforce — never inside a linked worktree, never an `--at` nobody named, never a migration clash resolved for them.
|
||||||
|
argument-hint: "[--at DIR] [--login NAME] [--repo owner/name] [--dry-run]"
|
||||||
|
disable-model-invocation: true
|
||||||
|
allowed-tools: Bash(kettle init:*), Bash(kettle config:*), Bash(git rev-parse:*)
|
||||||
|
---
|
||||||
|
|
||||||
|
# /kettle:init — make this directory a project
|
||||||
|
|
||||||
|
Initializing is a statement, and the operator makes it: *this* directory is the
|
||||||
|
project whose issues live in it. Nothing infers it — `.git` is in every clone,
|
||||||
|
and a tool that inferred its root from one wrote other projects' issues into its
|
||||||
|
own versioned cache. It is answered once, by a person, and every command
|
||||||
|
downstream reads the answer instead of guessing.
|
||||||
|
|
||||||
|
The binary does the work and is idempotent. What this skill carries is the three
|
||||||
|
things it cannot decide for itself.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. **Refuse inside a linked worktree.** Two different paths mean one:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git rev-parse --path-format=absolute --git-dir --git-common-dir
|
||||||
|
```
|
||||||
|
|
||||||
|
Stop and say so. `.kettle/` is gitignored, so a worktree has no marker by
|
||||||
|
design and reaches the main checkout's store on its own — the walk crosses to
|
||||||
|
it through the `gitdir:` in the `.git` *file*. A marker here gives one project
|
||||||
|
two stores, and the second is deleted with the branch. If anything needs
|
||||||
|
initializing it is the main checkout, which is the second path's parent.
|
||||||
|
|
||||||
|
2. Run it, passing the operator's arguments through unchanged:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kettle init $ARGUMENTS
|
||||||
|
```
|
||||||
|
|
||||||
|
With no `--at` it initializes the current directory. **Never supply an `--at`
|
||||||
|
the operator did not name.** Which directory is the project is the one
|
||||||
|
question this command exists to have a person answer; picking a plausible one
|
||||||
|
is the failure the marker replaces.
|
||||||
|
|
||||||
|
3. Report what it printed, verbatim. `already initialized — nothing to do` is a
|
||||||
|
success, not something to work around.
|
||||||
|
|
||||||
|
`--login` pins a login by name and `--repo` names the tracker repository; both
|
||||||
|
are optional and both can be added later by running `init` again — it writes the
|
||||||
|
config without disturbing settings it was not given. Neither is a credential:
|
||||||
|
the tokens live in one file per machine, `/kettle:auth`.
|
||||||
|
|
||||||
|
## When it stops
|
||||||
|
|
||||||
|
- **A name clash on the migration** — the same file name on both sides. It exits
|
||||||
|
having changed nothing and names the files. Report that. Do **not** move,
|
||||||
|
delete, or merge either side: one of them may be an `origin: local` issue,
|
||||||
|
which *is* the issue and the only copy of that work. The operator decides
|
||||||
|
which survives.
|
||||||
|
- **A marker already exists above this directory.** A second one gives that
|
||||||
|
project a second store and the nearer one wins. Confirm with the operator
|
||||||
|
before going ahead; usually they are standing in a subdirectory and there is
|
||||||
|
nothing to do.
|
||||||
|
|
||||||
|
**No `kettle` on PATH?** `command not found: kettle` is the whole story. Stop and
|
||||||
|
tell the operator to install it: `cd cli && go build -o ~/.local/bin/kettle
|
||||||
|
./cmd/kettle` in the marketplace repository (go.mod requires **go 1.26**), or
|
||||||
|
`go install git.noodles.cam/claude-skills/marketplace/cli/cmd/kettle@latest`.
|
||||||
|
|
||||||
|
## After
|
||||||
|
|
||||||
|
- `/kettle:issue` works now — offline, no login, no network.
|
||||||
|
- `/kettle:auth` puts a token on this machine and pins the login this project
|
||||||
|
runs under; needed only for the tracker side, `/kettle:sync`.
|
||||||
|
- `kettle config` prints every path and setting this directory resolved to, and
|
||||||
|
is the first thing to run when something looks like it landed in the wrong
|
||||||
|
place.
|
||||||
|
|
||||||
|
The full flag table for `init` is the generated block in `/kettle:project`.
|
||||||
@@ -0,0 +1,432 @@
|
|||||||
|
---
|
||||||
|
name: issue
|
||||||
|
description: Work with this project's issues as units of work — create, read, grep, validate, tick checkboxes, evict closed ones, and walk their dependency graph, with the `kettle` binary's offline commands (new, check, ac, tree, index, evict). Entirely offline; issues are local markdown files in `.kettle/issues/` and need no tracker, no login and no network. Load when the user asks to file or create an issue, read or find issues, check one against the format, or see what depends on what. Pushing to or pulling from Gitea is /kettle:sync.
|
||||||
|
---
|
||||||
|
|
||||||
|
# /kettle:issue — issues as units of work
|
||||||
|
|
||||||
|
An issue is a markdown file in `<project>/.kettle/issues/`. This skill covers
|
||||||
|
everything you do **with** an issue: writing one, reading one, checking it
|
||||||
|
against the canonical format, ticking its boxes, and walking the dependency
|
||||||
|
graph.
|
||||||
|
|
||||||
|
**Nothing here touches the network.** No tracker, no login, no token. An issue
|
||||||
|
that lives only on this machine is a first-class issue, not a draft waiting to
|
||||||
|
be uploaded. Synchronizing with a tracker is a separate, optional layer —
|
||||||
|
`/kettle:sync`.
|
||||||
|
|
||||||
|
Read [`references/format.md`](references/format.md) before creating or editing
|
||||||
|
an issue. It is the single source of truth for identity, metadata, types,
|
||||||
|
labels, templates, and language rules.
|
||||||
|
|
||||||
|
**No `kettle` on PATH?** `command not found: kettle` is the whole story — the
|
||||||
|
Python scripts this plugin used to ship are gone and `tea` is not a substitute.
|
||||||
|
Stop and tell the operator to install it: `cd cli && go build -o
|
||||||
|
~/.local/bin/kettle ./cmd/kettle` in the marketplace repository (go.mod requires
|
||||||
|
**go 1.26**), or `go install
|
||||||
|
git.noodles.cam/claude-skills/marketplace/cli/cmd/kettle@latest`.
|
||||||
|
|
||||||
|
## Identity: the slug
|
||||||
|
|
||||||
|
The file name is the id and the id is a slug —
|
||||||
|
`.kettle/issues/wire-sqlc-appclick.md`. It never changes: not when the title
|
||||||
|
changes, not when the issue is pushed somewhere. Tracker numbers live in a
|
||||||
|
metadata field (`gitea: owner/repo#42`), never in a file name and never in
|
||||||
|
`depends:`.
|
||||||
|
|
||||||
|
Consequence worth internalizing: **`#42` means nothing in this layer.** Refer to
|
||||||
|
issues by id.
|
||||||
|
|
||||||
|
```
|
||||||
|
.kettle/issues/INDEX.md table of every issue — read this first
|
||||||
|
.kettle/issues/wire-sqlc-appclick.md metadata block + `# Title` + body
|
||||||
|
.kettle/issues/wire-sqlc.comments.md comment thread (written by /kettle:sync only)
|
||||||
|
.kettle/issues/tree-<id>.md saved graph (kettle tree --write)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Where the store is
|
||||||
|
|
||||||
|
`<project root>/.kettle/issues` — **not** `.kettle/issues` relative to wherever
|
||||||
|
you are standing. The project root is the nearest directory up from where you
|
||||||
|
are that holds a `.kettle/` marker: the binary walks up from
|
||||||
|
`$CLAUDE_PROJECT_DIR`, then from the working directory, and out of a linked
|
||||||
|
worktree to its main checkout. Every command sees one store no matter which
|
||||||
|
subdirectory it runs in, and a `cd` into a *different* project correctly answers
|
||||||
|
with that project's issues.
|
||||||
|
|
||||||
|
**A project has a store because an operator ran `/kettle:init` in it.** The
|
||||||
|
marker is never inferred from the tree — `.git` is in every clone. **With no
|
||||||
|
marker anywhere, every command stops and names the directories it searched.** It
|
||||||
|
does not fall back to a plausible directory. If you see that, either you are not
|
||||||
|
in the project you think you are, or nobody has initialized it: tell the operator
|
||||||
|
to run `/kettle:init`. It is theirs to run, and it carries the worktree and
|
||||||
|
migration-clash rules a bare `kettle init` does not.
|
||||||
|
|
||||||
|
`--out` overrides all of it and is taken **literally**: an absolute path as
|
||||||
|
given, a relative one relative to the working directory. `kettle config` prints
|
||||||
|
every path this directory resolved to and is the fastest way to explain a run
|
||||||
|
that went somewhere unexpected.
|
||||||
|
|
||||||
|
Two things follow, both deliberate: a store that is not there reports `does not
|
||||||
|
exist` while a store with nothing in it reports `is empty` — different problems —
|
||||||
|
and nothing conjures a store as a side effect of a write.
|
||||||
|
|
||||||
|
## Reading: grep, don't parse
|
||||||
|
|
||||||
|
Metadata is one field per line with inline lists precisely so plain `grep`
|
||||||
|
works. `INDEX.md` first, then the files:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -l 'labels:.*type/bug' .kettle/issues/*.md # all bugs
|
||||||
|
grep -l 'origin: local' .kettle/issues/*.md # never pushed anywhere
|
||||||
|
grep -ln 'depends:.*migrate-schema' .kettle/issues/*.md # who depends on it
|
||||||
|
grep -A3 '## Acceptance criteria' .kettle/issues/wire-*.md
|
||||||
|
grep -c '^- \[ \]' .kettle/issues/wire-sqlc-appclick.md # open checkboxes
|
||||||
|
```
|
||||||
|
|
||||||
|
Read whole files only for the issues the task actually needs.
|
||||||
|
|
||||||
|
## Creating an issue
|
||||||
|
|
||||||
|
1. **Read the format**: [`references/format.md`](references/format.md).
|
||||||
|
2. **Pick the type** — `bug`, `task`, `refactor`, `test`, `feature` (a container
|
||||||
|
for several issues with one business value), or `draft` (an idea not ready
|
||||||
|
for work). If it is not obvious from the request, ask the user; one question.
|
||||||
|
3. **Scaffold it** with `kettle new` — English imperative title, no type prefix,
|
||||||
|
`--depends` takes ids.
|
||||||
|
4. **Fill the sections** with Edit — every section of the template present and
|
||||||
|
in order, **headers English, prose Russian**. `## Spec` gets a repo path, a
|
||||||
|
URL, or the literal `none`; ask the user if you cannot determine which.
|
||||||
|
5. **Check it** with `kettle check <id>`.
|
||||||
|
|
||||||
|
One file = one issue. Several related issues = several files, linked through
|
||||||
|
`depends:`.
|
||||||
|
|
||||||
|
The issue is real and complete the moment the file exists. `origin: local` is a
|
||||||
|
finished state, not a draft — and while it says local, **that file is the only
|
||||||
|
copy of the work.** Publishing it to Gitea is a separate decision
|
||||||
|
(`/kettle:sync`) and it ends that state: a push hands the issue over and deletes
|
||||||
|
the file.
|
||||||
|
|
||||||
|
## Editing an issue
|
||||||
|
|
||||||
|
Edit the file. Change `state:` to close it, edit `labels:`, add ids to
|
||||||
|
`depends:`. Re-run `kettle check` afterwards, and `kettle index` to refresh the
|
||||||
|
table. Checkboxes are the exception — use `kettle ac`.
|
||||||
|
|
||||||
|
If the issue is synced (`origin:` names a tracker), the file is a working copy:
|
||||||
|
your edit is local until `kettle push --update`, and that push **deletes the
|
||||||
|
file** once the tracker has it. Closing one of those is `kettle close` — it moves
|
||||||
|
the state on both sides in one run, where editing `state:` here alone would only
|
||||||
|
ever tell this machine. Get the file back with `kettle pull <n>`; the slug does
|
||||||
|
not change.
|
||||||
|
|
||||||
|
## Ticking checkboxes
|
||||||
|
|
||||||
|
A checkbox is the one part of a body that is **state** and not prose, so it has
|
||||||
|
a command of its own. Never rewrite a body to tick a box: the rewrite re-flows
|
||||||
|
lines and re-words sentences, and the issue's diff swells around a change that
|
||||||
|
means one character. `kettle ac <id>` lists them numbered with their state,
|
||||||
|
`--check` / `--uncheck` take a number or a substring.
|
||||||
|
|
||||||
|
- **Every checkbox in the body counts, not just `## Acceptance criteria`.** A
|
||||||
|
`type/feature` keeps its children as checkboxes under `## Issues` and they are
|
||||||
|
in the same numbering.
|
||||||
|
- **A substring must match exactly one item.** Two matches is an error listing
|
||||||
|
both; pick by number. It never guesses.
|
||||||
|
- **Exactly one character of the file changes.** Wording, wrapping and trailing
|
||||||
|
whitespace come back byte for byte, so both `git diff` and the tracker's diff
|
||||||
|
show the tick and nothing else.
|
||||||
|
- Examples inside a ``` fence are markup, not state — they are skipped.
|
||||||
|
- Whether a criterion is actually *met* is a judgement about content. Tick what
|
||||||
|
the caller named, never what looks done.
|
||||||
|
|
||||||
|
Getting the tick to the tracker is a separate step — `kettle push --update`.
|
||||||
|
|
||||||
|
## Writing a proper description
|
||||||
|
|
||||||
|
Issues get filed on the run — "comments aren't pulled", "the guard broke". That
|
||||||
|
is a request, not a statement of work: no reproduction steps, no
|
||||||
|
`path/file:line`, acceptance criteria nobody can check. Rewriting one into the
|
||||||
|
canonical format is a procedure, not improvisation.
|
||||||
|
|
||||||
|
1. **Read the issue whole**, and everything it points at — the ids in
|
||||||
|
`depends:`, the `## Spec` target, the files it names.
|
||||||
|
2. **Determine the type and its template.** The `type/*` label selects one of
|
||||||
|
the templates in [`references/format.md`](references/format.md), and that
|
||||||
|
template's section list is the shape you are aiming at. If the label is
|
||||||
|
missing or wrong, decide it now and fix `labels:`; promoting a `type/draft`
|
||||||
|
to a concrete type is this same step.
|
||||||
|
3. **Locate the anchor points in the code.** Grep the repo for every file,
|
||||||
|
symbol, command and error string the issue mentions, until you can name
|
||||||
|
lines. Work that does not exist yet still has anchor points — the files the
|
||||||
|
change will land in, and the ones that will call it.
|
||||||
|
4. **Gather the missing context.** What has to be there when you are done:
|
||||||
|
- code references in the `path/file.ext:line` form, for every place the
|
||||||
|
change lands;
|
||||||
|
- reproduction steps — exact commands and their real output (`type/bug`
|
||||||
|
splits them across `## Steps to reproduce` / `## Expected` / `## Actual`);
|
||||||
|
- acceptance criteria that are objectively checkable: a command that exits 0,
|
||||||
|
a file that exists, a section that is present — not aspirations;
|
||||||
|
- a real value for `## Spec` — a repo path, a URL, or the literal `none`.
|
||||||
|
|
||||||
|
**A missing fact is either found in the repository or becomes a question to
|
||||||
|
the user. Inventing one is forbidden.** Ask in one batch, and keep `none` in
|
||||||
|
`## Spec` as the legitimate answer it is — never a plausible-looking link.
|
||||||
|
5. **Rewrite the sections** with Edit: every section of the template, in the
|
||||||
|
template's order, English headers and Russian prose. Replace the body; do not
|
||||||
|
append a second telling of the same issue below the old one.
|
||||||
|
6. **Check it** with `kettle check <id>`. Errors mean malformed, warnings mean
|
||||||
|
the type's template is not fully filled in. Re-run `kettle index` if the
|
||||||
|
labels changed.
|
||||||
|
|
||||||
|
The procedure is identical for a local issue and a synced one — it works on
|
||||||
|
`.kettle/issues/<id>.md` and this layer does not know the difference. Getting the
|
||||||
|
rewritten body into the tracker is `kettle push --update` and is no part of this.
|
||||||
|
|
||||||
|
## Evicting closed issues
|
||||||
|
|
||||||
|
The store is a working set, not an archive. A closed issue is not a unit of work
|
||||||
|
any more, and `kettle evict` takes it out — no `rm`, no rebuilding `INDEX.md` by
|
||||||
|
hand. Two conditions, both read off the file, and the second is the whole safety
|
||||||
|
argument:
|
||||||
|
|
||||||
|
| `state:` | `origin:` | what eviction does |
|
||||||
|
|---|---|---|
|
||||||
|
| `closed` | a tracker | removes `<id>.md` and every sidecar under that slug |
|
||||||
|
| `closed` | `local` | **keeps it, always**, and says why |
|
||||||
|
| `open` | anything | keeps it |
|
||||||
|
|
||||||
|
**`origin: local` is never evicted, in any state, not even when you name it on
|
||||||
|
the command line.** That file *is* the issue; there is no copy to fetch back.
|
||||||
|
Only a file whose own metadata says the work lives somewhere else may go — the
|
||||||
|
same trade a push makes when it drops a file the tracker just confirmed.
|
||||||
|
|
||||||
|
`.remote.json` is deliberately **not** pruned: it is the number → slug ledger and
|
||||||
|
its entries are supposed to outlive the files they name, which is what makes a
|
||||||
|
later `kettle pull <n>` land on the same slug. And eviction is **not a one-off
|
||||||
|
migration** — a pull by number fetches an issue in any state, so a closed issue
|
||||||
|
pulled after an eviction lands on disk again. Evict it again when you are done.
|
||||||
|
|
||||||
|
This command decides from `state:` in the file, which is only as fresh as the
|
||||||
|
last pull. To have the tracker's answer instead — an issue closed in the web UI
|
||||||
|
five minutes ago — use `kettle sync-evict` from `/kettle:sync`, which refreshes
|
||||||
|
`state:` first and then makes exactly this decision.
|
||||||
|
|
||||||
|
## Dependency graph
|
||||||
|
|
||||||
|
`depends:` is the authoritative edge list; the body's `## Depends on` section is
|
||||||
|
prose for humans, and `kettle check` warns when they disagree. `kettle tree`
|
||||||
|
draws downwards — what an issue depends on. The other direction is a grep, not a
|
||||||
|
flag:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -ln 'depends:.*migrate-schema' .kettle/issues/*.md
|
||||||
|
```
|
||||||
|
|
||||||
|
A `type/feature` plus its children read as one document: draw the tree once for
|
||||||
|
the shape, then grep the files.
|
||||||
|
|
||||||
|
## Layering rule
|
||||||
|
|
||||||
|
Everything below is offline. No command in this skill opens a socket, reads a
|
||||||
|
token, or knows what an issue number is — that is `/kettle:sync`, and the domain
|
||||||
|
would not notice if the tracker did not exist. If you find yourself wanting a
|
||||||
|
tracker concept here — a number, a login, an HTTP call, a label colour — it
|
||||||
|
belongs on the other side of that line.
|
||||||
|
|
||||||
|
The commands themselves follow. Their usage lines, flags, defaults and examples
|
||||||
|
are generated from the binary's own command registry, so they cannot disagree
|
||||||
|
with the binary; `kettle help <command>` prints the same text. Editing them here
|
||||||
|
changes nothing.
|
||||||
|
|
||||||
|
<!-- kettle:gen -->
|
||||||
|
**Generated from the kettle command registry by `kettle gen skills`.** Everything between the two markers is replaced on the next run — hand-written prose belongs outside them.
|
||||||
|
|
||||||
|
## `kettle ac <id>`
|
||||||
|
|
||||||
|
list and tick an issue's checkboxes
|
||||||
|
|
||||||
|
A checkbox is the one part of a body that is *state* and not prose. Everything
|
||||||
|
else is written once; boxes get ticked as the work goes, and the only other ways
|
||||||
|
to tick one are a human with an editor or a model rewriting the whole body — the
|
||||||
|
second worse than the first, because the rewrite re-flows the text and the
|
||||||
|
issue's diff swells around a change of one character. This changes that one
|
||||||
|
character and nothing else.
|
||||||
|
|
||||||
|
Named after `## Acceptance criteria`, where most boxes live, but every checkbox in
|
||||||
|
the body is listed and tickable: a type/feature keeps its children under
|
||||||
|
`## Issues`, and binding this to one heading would silently lose half of them.
|
||||||
|
|
||||||
|
A substring picks an item only when it picks exactly one. Two matches is an
|
||||||
|
error listing both — a coin flip would tick the wrong box and look like it
|
||||||
|
worked.
|
||||||
|
|
||||||
|
Delivering the changed body to a tracker is not part of this; that is
|
||||||
|
`kettle push --update`.
|
||||||
|
|
||||||
|
| flag | default | what it does |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `--check` | — | tick one item: number or substring |
|
||||||
|
| `--out` | — | store root (default: <project>/.kettle/issues) |
|
||||||
|
| `--uncheck` | — | untick one item: number or substring |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kettle ac wire-sqlc-appclick # numbered list with state
|
||||||
|
kettle ac wire-sqlc-appclick --check 3 # tick by number
|
||||||
|
kettle ac wire-sqlc-appclick --check регресс # tick by substring
|
||||||
|
kettle ac wire-sqlc-appclick --uncheck 3 # untick it again
|
||||||
|
```
|
||||||
|
|
||||||
|
## `kettle check [<id>…]`
|
||||||
|
|
||||||
|
validate issues against the canonical format
|
||||||
|
|
||||||
|
The same check the sync layer runs before it pushes anything, available on its
|
||||||
|
own so a local-only issue can be held to the format without a tracker being
|
||||||
|
involved.
|
||||||
|
|
||||||
|
Errors mean malformed; warnings mean it deviates from its type's template or its
|
||||||
|
graph looks suspect. An unticked checkbox is neither: work not done yet is the
|
||||||
|
normal state of a perfectly well-formed issue.
|
||||||
|
|
||||||
|
Exit status is 1 when anything has errors, which is what makes this usable in a
|
||||||
|
hook or a CI step.
|
||||||
|
|
||||||
|
| flag | default | what it does |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `--out` | — | store root (default: <project>/.kettle/issues) |
|
||||||
|
| `--quiet` | `false` | exit status only, print nothing |
|
||||||
|
| `--strict` | `false` | treat warnings as errors |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kettle check # every issue in the store
|
||||||
|
kettle check wire-sqlc-appclick # one issue
|
||||||
|
kettle check --quiet # exit status only
|
||||||
|
kettle check --strict # treat warnings as errors
|
||||||
|
```
|
||||||
|
|
||||||
|
## `kettle evict [<id>…]`
|
||||||
|
|
||||||
|
remove closed issues from the local store
|
||||||
|
|
||||||
|
The store is a working set, not an archive. What is evicted is two conditions,
|
||||||
|
both read off the file:
|
||||||
|
|
||||||
|
state: closed the work is done
|
||||||
|
origin: <tracker> the work is somewhere else too
|
||||||
|
|
||||||
|
THE SECOND CONDITION IS THE WHOLE SAFETY ARGUMENT. `origin: local` means this
|
||||||
|
file IS the issue — there is no other copy and deleting it deletes the work. It
|
||||||
|
is never evicted, in any state, not even when named explicitly on the command
|
||||||
|
line: a closed local issue is reported and kept.
|
||||||
|
|
||||||
|
Eviction asks the file rather than the tracker, because state and origin are
|
||||||
|
domain fields and the answer is already in the store — which is why this needs
|
||||||
|
no network and no login. `kettle sync-evict` is the variant that refreshes state
|
||||||
|
from the tracker first and then makes the same decision.
|
||||||
|
|
||||||
|
Not a one-off migration: a pull by number fetches an issue in any state, so a
|
||||||
|
closed issue pulled after an eviction lands on disk again. Evict it again when
|
||||||
|
you are done with it.
|
||||||
|
|
||||||
|
INDEX.md is rebuilt, because it IS a view of the directory. The number -> slug
|
||||||
|
ledger is deliberately not pruned: its entries outlive the files they name, and
|
||||||
|
that is what makes a pull land on the same slug afterwards.
|
||||||
|
|
||||||
|
| flag | default | what it does |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `--dry-run` | `false` | print what would be removed; touch nothing |
|
||||||
|
| `--out` | — | store root (default: <project>/.kettle/issues) |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kettle evict # every closed issue that is not origin: local
|
||||||
|
kettle evict old-thing another-thing # only these
|
||||||
|
kettle evict --dry-run # print what would go; touch nothing
|
||||||
|
```
|
||||||
|
|
||||||
|
## `kettle index`
|
||||||
|
|
||||||
|
rebuild INDEX.md from what is on disk
|
||||||
|
|
||||||
|
A map of the local store, nothing else. The `origin` column is the only place
|
||||||
|
the index acknowledges that a tracker exists: `local` means the issue has never
|
||||||
|
left this machine, anything else names the tracker it also lives in. Both are
|
||||||
|
ordinary issues here.
|
||||||
|
|
||||||
|
`progress` counts the body's checkboxes, ticked over total, and is read off the
|
||||||
|
body at build time rather than stored — a second copy of that state in a
|
||||||
|
metadata field would be wrong by the next edit.
|
||||||
|
|
||||||
|
An existing store with nothing in it is a legitimate thing to index and gets an
|
||||||
|
"_empty_" table. A store that is not there is an error, not a directory to
|
||||||
|
create.
|
||||||
|
|
||||||
|
| flag | default | what it does |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `--out` | — | store root (default: <project>/.kettle/issues) |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kettle index # rebuild the index for this project
|
||||||
|
```
|
||||||
|
|
||||||
|
## `kettle new`
|
||||||
|
|
||||||
|
create a local issue from its type template
|
||||||
|
|
||||||
|
The issue is real the moment this writes the file. Nothing is pending, nothing
|
||||||
|
is a draft awaiting a tracker: `origin: local` is a complete state and pushing it
|
||||||
|
later is optional.
|
||||||
|
|
||||||
|
While it says local, this file is the ONLY copy of the work — the store, not a
|
||||||
|
cache of anything. That is what a push changes: it hands the issue to the
|
||||||
|
tracker and removes the file.
|
||||||
|
|
||||||
|
Writes .kettle/issues/<slug>.md prefilled with the type's template, prints the
|
||||||
|
path, and rebuilds INDEX.md. Fill the sections in an editor, then run
|
||||||
|
`kettle check <id>`.
|
||||||
|
|
||||||
|
Body prose is Russian, section headers and the title are English.
|
||||||
|
|
||||||
|
| flag | default | what it does |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `--assignee` | — | assignee login; repeat |
|
||||||
|
| `--depends` | — | id this issue depends on; repeat |
|
||||||
|
| `--id` | — | slug (default: derived from the title) |
|
||||||
|
| `--label` | — | extra label, e.g. tech/sql; repeat |
|
||||||
|
| `--milestone` | — | milestone title |
|
||||||
|
| `--out` | — | store root (default: <project>/.kettle/issues) |
|
||||||
|
| `--severity` | — | severity/* label, one of: low, medium, high, showstopper, critical |
|
||||||
|
| `--title` | — | English, imperative, no type prefix |
|
||||||
|
| `--type` | — | issue type, one of: bug, task, refactor, test, feature, draft (becomes the exclusive type/* label) |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kettle new --type task --title "Wire sqlc into the appclick repo layer" --label tech/sql --label comp/appclick # a task with two free-form labels
|
||||||
|
kettle new --type bug --title "Fix the index rebuild on an empty store" --depends wire-sqlc-appclick --milestone v0.2 # a bug that is blocked by another issue
|
||||||
|
```
|
||||||
|
|
||||||
|
## `kettle tree [<id>…]`
|
||||||
|
|
||||||
|
draw the dependency graph of the local store
|
||||||
|
|
||||||
|
Edges come from the `depends:` metadata, which is the authoritative edge list;
|
||||||
|
prose in the body is never walked. Because the graph is slugs all the way down,
|
||||||
|
this works identically for issues that were never pushed anywhere.
|
||||||
|
|
||||||
|
Downwards is what this draws — what an issue depends on. The other direction is
|
||||||
|
a grep, not a flag:
|
||||||
|
|
||||||
|
grep -ln 'depends:.*migrate-schema' .kettle/issues/*.md
|
||||||
|
|
||||||
|
| flag | default | what it does |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `--depth` | `6` | maximum depth |
|
||||||
|
| `--out` | — | store root (default: <project>/.kettle/issues) |
|
||||||
|
| `--write` | `false` | also write <store>/tree-<slug>.md |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kettle tree # every root (nothing depends on it)
|
||||||
|
kettle tree wire-sqlc-appclick # one subtree
|
||||||
|
kettle tree --depth 2 --write # shallow, and saved beside the issues
|
||||||
|
```
|
||||||
|
<!-- /kettle:gen -->
|
||||||
+17
-17
@@ -6,11 +6,11 @@ English section headers in a fixed order, verifiable acceptance criteria, one
|
|||||||
issue = one deliverable.
|
issue = one deliverable.
|
||||||
|
|
||||||
Nothing here depends on Gitea. How these files are mapped onto a tracker is the
|
Nothing here depends on Gitea. How these files are mapped onto a tracker is the
|
||||||
sync layer's business — see `/tea:sync`.
|
sync layer's business — see `/kettle:sync`.
|
||||||
|
|
||||||
## Identity
|
## Identity
|
||||||
|
|
||||||
An issue is one file, `.tea/issues/<id>.md`, and `id` is a slug: lowercase
|
An issue is one file, `.kettle/issues/<id>.md`, and `id` is a slug: lowercase
|
||||||
ASCII, digits, single dashes, derived from the title. **The slug is the
|
ASCII, digits, single dashes, derived from the title. **The slug is the
|
||||||
identity.** It is stable for the life of the issue — a retitled issue keeps its
|
identity.** It is stable for the life of the issue — a retitled issue keeps its
|
||||||
slug; an issue pushed to a tracker, deleted locally and fetched back a month
|
slug; an issue pushed to a tracker, deleted locally and fetched back a month
|
||||||
@@ -18,7 +18,7 @@ later keeps it too. Tracker numbers are a foreign key stored in a field, never
|
|||||||
the name of anything.
|
the name of anything.
|
||||||
|
|
||||||
```
|
```
|
||||||
.tea/issues/wire-sqlc-appclick.md
|
.kettle/issues/wire-sqlc-appclick.md
|
||||||
```
|
```
|
||||||
|
|
||||||
A slug never contains a dot, which is how the store tells an issue from the
|
A slug never contains a dot, which is how the store tells an issue from the
|
||||||
@@ -26,7 +26,7 @@ files parked beside it (`<id>.comments.md`).
|
|||||||
|
|
||||||
Stability is a promise the format makes, so something has to keep it once the
|
Stability is a promise the format makes, so something has to keep it once the
|
||||||
file is gone. That is the sync layer's problem and its answer is a marker in the
|
file is gone. That is the sync layer's problem and its answer is a marker in the
|
||||||
body — see `/tea:sync`; the domain neither writes nor reads it, and it never
|
body — see `/kettle:sync`; the domain neither writes nor reads it, and it never
|
||||||
appears in the file on disk.
|
appears in the file on disk.
|
||||||
|
|
||||||
## Metadata block
|
## Metadata block
|
||||||
@@ -86,15 +86,15 @@ It is not a *permanent* state, and it is what the file's fate depends on:
|
|||||||
| `local` | the issue itself — the only copy there is | creates it in the tracker, then deletes the file | **nothing, ever** — in any state, named or not |
|
| `local` | the issue itself — the only copy there is | creates it in the tracker, then deletes the file | **nothing, ever** — in any state, named or not |
|
||||||
| a tracker | a working copy of something the tracker already has | updates the tracker, then deletes the file | removes it once `state: closed` |
|
| a tracker | a working copy of something the tracker already has | updates the tracker, then deletes the file | removes it once `state: closed` |
|
||||||
|
|
||||||
**A successful push deletes `.tea/issues/<id>.md`** (and `<id>.comments.md`), on
|
**A successful push deletes `.kettle/issues/<id>.md`** (and `<id>.comments.md`), on
|
||||||
create and on `--update` alike. What is in the store is what has not left this
|
create and on `--update` alike. What is in the store is what has not left this
|
||||||
machine; everything else is fetched again when it is needed. The rule, its
|
machine; everything else is fetched again when it is needed. The rule, its
|
||||||
safety conditions, and how the slug survives are `/tea:sync`'s to state.
|
safety conditions, and how the slug survives are `/kettle:sync`'s to state.
|
||||||
|
|
||||||
**A closed issue is evicted from the store** by `issue_evict.py` — same trade,
|
**A closed issue is evicted from the store** by `kettle evict` — same trade,
|
||||||
one condition more: the work is done *and* it exists somewhere else. An
|
one condition more: the work is done *and* it exists somewhere else. An
|
||||||
`origin: local` issue is never evicted, because there is nowhere to fetch it
|
`origin: local` issue is never evicted, because there is nowhere to fetch it
|
||||||
back from. The store is a working set, not an archive; `pull.py <n>` fetches a
|
back from. The store is a working set, not an archive; `kettle pull <n>` fetches a
|
||||||
closed issue again whenever it is wanted.
|
closed issue again whenever it is wanted.
|
||||||
|
|
||||||
The `id` never changes across that round trip, which is why `depends:` in other
|
The `id` never changes across that round trip, which is why `depends:` in other
|
||||||
@@ -103,7 +103,7 @@ issues keeps working. That is the format's promise; the mechanism is not.
|
|||||||
## Language rules
|
## Language rules
|
||||||
|
|
||||||
- **Issue title**: English, imperative mood, no type prefix — the type lives in
|
- **Issue title**: English, imperative mood, no type prefix — the type lives in
|
||||||
the label, not the title. Good: `Fix tea-guard crash on empty settings file`.
|
the label, not the title. Good: `Fix the index rebuild on an empty store`.
|
||||||
Bad: `fix: crash`, `[bug] crash`, `Крашится гвард`.
|
Bad: `fix: crash`, `[bug] crash`, `Крашится гвард`.
|
||||||
- **Section headers**: the exact English literals below, as `##` headings, in
|
- **Section headers**: the exact English literals below, as `##` headings, in
|
||||||
the given order. Do not translate, rename, or reorder them.
|
the given order. Do not translate, rename, or reorder them.
|
||||||
@@ -149,8 +149,8 @@ Components of this repo's system, e.g. `comp/appclick`. No preset list —
|
|||||||
derive from the project.
|
derive from the project.
|
||||||
|
|
||||||
> Label **colors** are not part of the format: a hex code is how a tracker
|
> Label **colors** are not part of the format: a hex code is how a tracker
|
||||||
> paints a chip, not what an issue is. They live in `skills/sync/scripts/map.py`
|
> paints a chip, not what an issue is. They live in the binary's mapping layer
|
||||||
> and are applied on push.
|
> (`cli/internal/mapping`) and are applied on push.
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
@@ -171,7 +171,7 @@ human explanation — one reference per line, with a reason where it helps:
|
|||||||
|
|
||||||
The section is prose and is passed to and from a tracker unchanged; only
|
The section is prose and is passed to and from a tracker unchanged; only
|
||||||
`depends:` is walked when the graph is computed. Keeping them consistent is on
|
`depends:` is walked when the graph is computed. Keeping them consistent is on
|
||||||
you — `issue_check.py` warns when the section names an id that `depends:` does
|
you — `kettle check` warns when the section names an id that `depends:` does
|
||||||
not list. Omit the section when there are no dependencies; never write an empty
|
not list. Omit the section when there are no dependencies; never write an empty
|
||||||
one.
|
one.
|
||||||
|
|
||||||
@@ -180,10 +180,10 @@ A `type/feature` container writes the same relation under `## Issues` instead
|
|||||||
belongs in that issue's `depends:`. The warning names whichever of the two
|
belongs in that issue's `depends:`. The warning names whichever of the two
|
||||||
sections the reference actually came from.
|
sections the reference actually came from.
|
||||||
|
|
||||||
Draw the graph with `issue_tree.py`. The reverse direction is a grep:
|
Draw the graph with `kettle tree`. The reverse direction is a grep:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
grep -ln 'depends:.*migrate-schema' .tea/issues/*.md
|
grep -ln 'depends:.*migrate-schema' .kettle/issues/*.md
|
||||||
```
|
```
|
||||||
|
|
||||||
## Shared rules
|
## Shared rules
|
||||||
@@ -201,7 +201,7 @@ grep -ln 'depends:.*migrate-schema' .tea/issues/*.md
|
|||||||
unticked, `- [x]` ticked, and it means the same under `## Issues` as under
|
unticked, `- [x]` ticked, and it means the same under `## Issues` as under
|
||||||
`## Acceptance criteria`. An item that wraps continues on an indented line
|
`## Acceptance criteria`. An item that wraps continues on an indented line
|
||||||
and is still one item. A `- [ ]` inside a ``` code fence is an example of the
|
and is still one item. A `- [ ]` inside a ``` code fence is an example of the
|
||||||
markup, not state. Tick them with `issue_ac.py`, which reads the whole body
|
markup, not state. Tick them with `kettle ac`, which reads the whole body
|
||||||
on exactly these rules and rewrites one character; progress (`3/7`) is
|
on exactly these rules and rewrites one character; progress (`3/7`) is
|
||||||
counted off the body and is never a metadata field.
|
counted off the body and is never a metadata field.
|
||||||
- Code references use the `path/file.ext:line` form; related issues by id.
|
- Code references use the `path/file.ext:line` form; related issues by id.
|
||||||
@@ -314,8 +314,8 @@ when its children are closed" *is* a dependency relation. "This child belongs
|
|||||||
to that feature" is a membership relation, and membership has no place in a
|
to that feature" is a membership relation, and membership has no place in a
|
||||||
dependency graph. Pointed the other way the two rules contradict each other:
|
dependency graph. Pointed the other way the two rules contradict each other:
|
||||||
the moment the container listed a child that already depended on it,
|
the moment the container listed a child that already depended on it,
|
||||||
`issue_check.py` would report `ERROR cycle`. With the edge going down, the
|
`kettle check` would report `ERROR cycle`. With the edge going down, the
|
||||||
graph reads as nesting — `issue_tree.py` draws the container as the root with
|
graph reads as nesting — `kettle tree` draws the container as the root with
|
||||||
its children beneath it — and the check is green.
|
its children beneath it — and the check is green.
|
||||||
|
|
||||||
So the container's metadata block carries the children:
|
So the container's metadata block carries the children:
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
---
|
||||||
|
name: project
|
||||||
|
description: Generated flag reference for the project-level `kettle` commands — `kettle init`, `kettle auth`, `kettle config`, `kettle gen`. Load it to look up the exact flags and defaults of one of those four, or when a command answers "no project" / "no login" and you need `kettle config` to say what this directory resolved to. The rules around initializing are /kettle:init and the credential workflow is /kettle:auth; this file is the flag table both of them point at.
|
||||||
|
---
|
||||||
|
|
||||||
|
# kettle project — the project itself
|
||||||
|
|
||||||
|
`kettle` resolves everything from one marker. `<project>/.kettle/` is created by
|
||||||
|
`kettle init` and never inferred: `.git` is in every clone, so a tool that
|
||||||
|
guessed a root from one would write issues into whatever tree it happened to be
|
||||||
|
standing in. With no marker anywhere the command stops and names the directories
|
||||||
|
it searched — that is an answer, not a fallback.
|
||||||
|
|
||||||
|
Two configuration files, and the split is the point. `<project>/.kettle/config.yaml`
|
||||||
|
holds the tracker repository and the **name** of a login; the name is worth
|
||||||
|
nothing on its own, which is what makes it safe inside a working tree.
|
||||||
|
`~/.config/kettle/logins.yaml` (0600, one per machine, `$KETTLE_CONFIG_HOME` or
|
||||||
|
`$XDG_CONFIG_HOME` move it) holds the tokens. `KETTLE_LOGIN`, `KETTLE_REPO`,
|
||||||
|
`KETTLE_URL` and `KETTLE_TOKEN` each override the file they shadow.
|
||||||
|
|
||||||
|
**No `kettle` on PATH?** `command not found: kettle` is the whole story — no
|
||||||
|
script and no `tea` invocation substitutes for it. Stop and tell the operator to
|
||||||
|
install it: `cd cli && go build -o ~/.local/bin/kettle ./cmd/kettle` in the
|
||||||
|
marketplace repository (go.mod requires **go 1.26**), or
|
||||||
|
`go install git.noodles.cam/claude-skills/marketplace/cli/cmd/kettle@latest`.
|
||||||
|
|
||||||
|
`kettle gen` is a maintainer command: it rewrites the generated region of these
|
||||||
|
SKILL.md files from the command registry the binary was built from. Run it after
|
||||||
|
changing the CLI, never to "fix" documentation by hand.
|
||||||
|
|
||||||
|
<!-- kettle:gen -->
|
||||||
|
**Generated from the kettle command registry by `kettle gen skills`.** Everything between the two markers is replaced on the next run — hand-written prose belongs outside them.
|
||||||
|
|
||||||
|
## `kettle auth list | add | remove <name>`
|
||||||
|
|
||||||
|
manage the tokens this machine holds
|
||||||
|
|
||||||
|
Credentials live in one file per machine, outside every working tree, mode
|
||||||
|
0600. A project pins a login by NAME; the name is worth nothing on its own,
|
||||||
|
which is what makes it safe to keep in a file inside the repository.
|
||||||
|
|
||||||
|
The token is read from standard input unless --token is given, because an
|
||||||
|
argument is in the shell history the moment it is typed:
|
||||||
|
|
||||||
|
kettle auth add --name noodles --url https://git.example.com < token.txt
|
||||||
|
pass show gitea/token | kettle auth add --name noodles --url https://git.example.com
|
||||||
|
|
||||||
|
`list` never prints a token. There is no flag to make it.
|
||||||
|
|
||||||
|
| flag | default | what it does |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `--name` | — | login name (add) |
|
||||||
|
| `--token` | — | token, if you would rather not use stdin (add) |
|
||||||
|
| `--url` | — | instance URL, e.g. https://git.example.com (add) |
|
||||||
|
| `--user` | — | account this token belongs to; documentation only (add) |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kettle auth list # what this machine holds
|
||||||
|
pass show gitea | kettle auth add --name noodles --url https://git.example.com # add one, token on stdin
|
||||||
|
kettle auth remove noodles # forget it
|
||||||
|
```
|
||||||
|
|
||||||
|
## `kettle config`
|
||||||
|
|
||||||
|
show what this project resolved to
|
||||||
|
|
||||||
|
Every path and every setting, with the overrides already applied, so a run that
|
||||||
|
went somewhere unexpected can be explained without guessing.
|
||||||
|
|
||||||
|
The token is never printed — only whether one was found.
|
||||||
|
|
||||||
|
This is the command to reach for when the store looks empty, when a push says
|
||||||
|
401, or when two directories disagree about which project they are in.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kettle config # resolved paths and settings
|
||||||
|
```
|
||||||
|
|
||||||
|
## `kettle gen skills`
|
||||||
|
|
||||||
|
write the plugin's SKILL.md files from the command registry
|
||||||
|
|
||||||
|
A SKILL.md tells an agent how to invoke this binary. Hand-written, it drifts: a
|
||||||
|
flag is renamed here and the documentation goes on recommending the old one,
|
||||||
|
and the agent that reads it fails in a way nobody traces back to a stale
|
||||||
|
sentence. Everything those files say about a command — its usage line, its
|
||||||
|
flags with their defaults, its worked examples — is already in the registry
|
||||||
|
this binary is built from, so it is written from there and cannot disagree.
|
||||||
|
|
||||||
|
THE GENERATOR OWNS A REGION, NOT A FILE. Each SKILL.md carries a pair of HTML
|
||||||
|
comment markers — `kettle:gen` to open and `/kettle:gen` to close, both written in
|
||||||
|
the `<!-- … -->` form and visible at the top and bottom of the block below.
|
||||||
|
Everything between them is replaced on every run; every byte outside them comes
|
||||||
|
back exactly as it was, which matters most for `description:`, the prose that
|
||||||
|
decides whether an agent loads the skill at all, and the one thing here that no
|
||||||
|
generator can write.
|
||||||
|
|
||||||
|
A file with no markers is REPORTED AND LEFT ALONE, never overwritten: clobbering
|
||||||
|
somebody's prose because they forgot a marker is the failure this design exists
|
||||||
|
to prevent. A file that does not exist yet is created with a frontmatter stub
|
||||||
|
around a generated block, for a human to fill in.
|
||||||
|
|
||||||
|
The output is deterministic to the byte — no timestamps, no map iteration — so
|
||||||
|
regenerating something that has not changed produces no diff. --check is that
|
||||||
|
property made useful: it writes nothing and exits 1 when any file on disk
|
||||||
|
differs from what would be generated, which is what a pre-commit hook or a CI
|
||||||
|
step calls. It wins over --dry-run when both are given.
|
||||||
|
|
||||||
|
| flag | default | what it does |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `--check` | `false` | write nothing, exit 1 if anything is out of date |
|
||||||
|
| `--dry-run` | `false` | print what would change; write nothing |
|
||||||
|
| `--out` | — | directory the skills live in; one <group>/SKILL.md under it |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kettle gen skills --out ../plugins/kettle/skills # write the region in every group's SKILL.md
|
||||||
|
kettle gen skills --out ../plugins/kettle/skills --dry-run # print what would change; write nothing
|
||||||
|
kettle gen skills --out ../plugins/kettle/skills --check # exit 1 if the docs are out of date
|
||||||
|
```
|
||||||
|
|
||||||
|
## `kettle init`
|
||||||
|
|
||||||
|
make this directory a project that tracks issues
|
||||||
|
|
||||||
|
Creates `.kettle/` — the marker every other command resolves the store from,
|
||||||
|
and `.kettle/config.yaml`, which says which tracker repository these issues
|
||||||
|
belong to and which login to reach it under.
|
||||||
|
|
||||||
|
The marker is deliberately something an operator makes, not something inferred
|
||||||
|
from the tree: `.git` is in every clone, so anything that inferred a root from
|
||||||
|
one would write issues into whatever it happened to be installed in.
|
||||||
|
|
||||||
|
--login pins a name, never a credential. The tokens live in one file per
|
||||||
|
machine, outside every working tree, managed with `kettle auth`.
|
||||||
|
|
||||||
|
All of it is idempotent: it creates .kettle/issues and .kettle/payload, migrates
|
||||||
|
an older store in if it finds one (either layout the tea plugin used, oldest
|
||||||
|
first), writes the config without disturbing settings it was not given, and adds
|
||||||
|
.kettle/ to .gitignore. Each migration is a move, not a copy — two stores is the
|
||||||
|
state the marker exists to prevent — and it refuses to pick a winner when both
|
||||||
|
sides hold a file of the same name.
|
||||||
|
|
||||||
|
Do NOT run this inside a linked worktree. A worktree is the same project on
|
||||||
|
another branch and reaches the store by a hop out to the main checkout; a marker
|
||||||
|
here would give one project two stores, and the directory holding the second one
|
||||||
|
disappears with the branch.
|
||||||
|
|
||||||
|
| flag | default | what it does |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `--at` | — | directory to initialize (default: the working directory) |
|
||||||
|
| `--dry-run` | `false` | report what would happen; change nothing |
|
||||||
|
| `--login` | — | name of a login in the machine-wide file (see `kettle auth`) |
|
||||||
|
| `--repo` | — | tracker repository, as owner/name |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kettle init # initialize the current directory
|
||||||
|
kettle init --login noodles --repo claude-skills/marketplace # and point it at a tracker
|
||||||
|
kettle init --at ~/code/x # initialize somewhere else
|
||||||
|
kettle init --dry-run # say what it would do, touch nothing
|
||||||
|
```
|
||||||
|
<!-- /kettle:gen -->
|
||||||
@@ -0,0 +1,569 @@
|
|||||||
|
---
|
||||||
|
name: sync
|
||||||
|
description: Move issues between this project's local store and its Gitea tracker with the `kettle` binary — pull issues into `.kettle/issues/`, push local ones up (which deletes the local file), list what the tracker holds, post comments, close and reopen, and evict what the tracker says is closed. Load when the user asks to fetch or publish an issue, see what exists in the tracker, comment on one, or close one. Writing, grepping, validating and graphing an issue's content is /kettle:issue and needs no network.
|
||||||
|
---
|
||||||
|
|
||||||
|
# /kettle:sync — the bridge between the local store and the tracker
|
||||||
|
|
||||||
|
One job: carry issues between `<project>/.kettle/issues/<id>.md` and Gitea.
|
||||||
|
Everything about **what an issue is** — format, types, validation, the dependency
|
||||||
|
graph — belongs to `/kettle:issue`, and this layer neither redefines nor
|
||||||
|
second-guesses it. Knowledge flows one way: delete the tracker from the world and
|
||||||
|
the issue domain does not notice.
|
||||||
|
|
||||||
|
**No `kettle` on PATH?** `command not found: kettle` is the whole story — the
|
||||||
|
Python scripts this plugin used to ship are gone and raw `tea` is not a
|
||||||
|
substitute. Stop and tell the operator to install it: `cd cli && go build -o
|
||||||
|
~/.local/bin/kettle ./cmd/kettle` in the marketplace repository (go.mod requires
|
||||||
|
**go 1.26**), or `go install
|
||||||
|
git.noodles.cam/claude-skills/marketplace/cli/cmd/kettle@latest`.
|
||||||
|
|
||||||
|
## No `--login`, no `--repo`, no guard
|
||||||
|
|
||||||
|
Which login this project runs under and which repository its issues belong to
|
||||||
|
are facts about the project, stated once by `kettle init` and kept in
|
||||||
|
`.kettle/config.yaml`; the token lives in one file per machine that no working
|
||||||
|
tree can see. There is nothing to pass and nothing to police — the old PreToolUse
|
||||||
|
guard hook and its `--login "$GITEA_LOGIN"` placeholder are gone, along with the
|
||||||
|
failure they existed to catch. `kettle labels --repo owner/name` is the
|
||||||
|
single exception, because bootstrapping a repository's label set is the one
|
||||||
|
operation whose target is not this project.
|
||||||
|
|
||||||
|
A cross-repository *address* is still an address: `kettle pull owner/repo#42`
|
||||||
|
re-points the client for that one call and comes back with the same credentials
|
||||||
|
and the same scratchpad. `42`, `#42`, `owner/repo#42` and a full issue URL are
|
||||||
|
four spellings of one key.
|
||||||
|
|
||||||
|
No login, an unknown login name, a 401: report it and stop — `/kettle:auth`.
|
||||||
|
|
||||||
|
## Never read an issue through a raw API dump
|
||||||
|
|
||||||
|
`tea issues 42 -o json` and `tea api …/issues/42` put the whole payload —
|
||||||
|
avatars, nested user objects, every comment body — into your context whether you
|
||||||
|
need it or not. `kettle pull` writes flat markdown and prints a compact line per
|
||||||
|
issue; `kettle remote` lists the tracker without writing anything at all. Use
|
||||||
|
those.
|
||||||
|
|
||||||
|
## The round trip is one rule
|
||||||
|
|
||||||
|
**The store holds what has not left this machine.**
|
||||||
|
|
||||||
|
A successful `kettle push` deletes `<id>.md` and every sidecar under that slug —
|
||||||
|
on create and on `--update` alike, one rule with no exception — and prints the
|
||||||
|
number and URL the issue now lives at. Once the tracker has the issue, the
|
||||||
|
tracker *is* the issue.
|
||||||
|
|
||||||
|
The deletion is the last thing that happens, and only after all three of: the
|
||||||
|
call came back 2xx, the answer carries the number that was written, and the
|
||||||
|
number → slug ledger has been written. Network down, a 422, an answer about
|
||||||
|
another issue — the file stays exactly where it is and the run stops.
|
||||||
|
|
||||||
|
**An `origin: local` issue that has never been pushed is never touched by any of
|
||||||
|
this.** That file is the only copy of that work.
|
||||||
|
|
||||||
|
The slug survives the round trip two ways over, which is why the file can be
|
||||||
|
deleted at all:
|
||||||
|
|
||||||
|
| where | survives |
|
||||||
|
|---|---|
|
||||||
|
| `<!-- kettle:id wire-sqlc-appclick -->`, first line of the **tracker-side** body | a rename in the web UI, a lost ledger, a fresh clone, another machine |
|
||||||
|
| `.kettle/issues/.remote.json`, number → slug | the local file being deleted |
|
||||||
|
|
||||||
|
The marker never appears in the local file: one is put at the top on the way up
|
||||||
|
and every one is stripped on the way down. A pull consults the ledger first (it
|
||||||
|
is the one that knows what is on disk *now*), then the marker, then slugifies the
|
||||||
|
title for an issue filed in the web UI that never had a local name — and a marker
|
||||||
|
is taken at its word only when that slug is free, because a name already in use
|
||||||
|
is a collision and not an identity.
|
||||||
|
|
||||||
|
Nothing prunes the ledger — not a push, not an eviction. Its entries are meant to
|
||||||
|
outlive the files they name.
|
||||||
|
|
||||||
|
## Pulling is a fetch, not a merge
|
||||||
|
|
||||||
|
A pull overwrites the body. Unpushed local edits are lost, with exactly one
|
||||||
|
exception: **checkbox state**. A tick is monotone, so for a checkbox line whose
|
||||||
|
text matches on both sides `[x]` wins from either — tick it in the web UI, tick
|
||||||
|
it locally, tick it in both, the tick survives. The price is real and stated:
|
||||||
|
**a box unticked in the web UI comes back on the next pull.** Untick locally, then
|
||||||
|
`kettle push --update`.
|
||||||
|
|
||||||
|
Two spellings, and they are different operations:
|
||||||
|
|
||||||
|
- **by key** — an address. It fetches the issue in **any** state, because a
|
||||||
|
number is not a question about state.
|
||||||
|
- **by filter** (`--milestone`, `--label`, `-q`) — a query. Closed issues are
|
||||||
|
enumerated and left out, and `--limit` bounds what is **stored**, never what is
|
||||||
|
read.
|
||||||
|
|
||||||
|
Do not loop over numbers to fetch a group; pass the filter. And a pull returns
|
||||||
|
the **unit of work**, not one row of it: blockers come down with it recursively
|
||||||
|
to `--depth`, which costs a request per issue and per outside blocker. That is
|
||||||
|
what `--no-deps` buys back. A blocker the filter did not select still lands on
|
||||||
|
disk, deliberately — it is there because a stored issue named it.
|
||||||
|
|
||||||
|
Comments ride along into `<id>.comments.md` with no flag, and cost nothing when
|
||||||
|
the payload says the thread is empty. **They are pull-only in the store**: editing
|
||||||
|
that file changes nothing in the tracker. `kettle comment` is the way, and it
|
||||||
|
refetches the thread after writing so the local copy is not stale by the comment
|
||||||
|
it just made.
|
||||||
|
|
||||||
|
## Closing, and evicting what the tracker says is closed
|
||||||
|
|
||||||
|
`kettle close` sends `{"state": …}` and nothing else — no title, no body, no
|
||||||
|
labels. **Closing is not an edit**; editing is pull → change → `push --update`.
|
||||||
|
Explicit ids only: there is no `--milestone` and no `--label`, because which
|
||||||
|
issues are finished is a judgement about content and this command only carries
|
||||||
|
one out. The local file is rewritten only after the tracker confirms that very
|
||||||
|
write. A tracker that refuses to close an issue its own dependency graph still
|
||||||
|
blocks says so in its own words — close the blockers first, or unlink them.
|
||||||
|
|
||||||
|
`kettle sync-evict` is `kettle evict` with one thing in front of it: a `state:`
|
||||||
|
that is not stale. Every candidate is asked about **before anything is removed**,
|
||||||
|
and one bad answer evicts nothing at all — not even the issues whose answers had
|
||||||
|
already arrived. `origin: local` is never asked about and never evicted; a
|
||||||
|
tracked issue whose handle is unreadable is reported and kept.
|
||||||
|
|
||||||
|
## Labels
|
||||||
|
|
||||||
|
Push creates the labels its issues happen to use, which means a repository grows
|
||||||
|
the set in pieces and nobody can filter by `type/bug` in the web UI until
|
||||||
|
somebody pushes a bug. `kettle labels` lays the canonical `type/*` and
|
||||||
|
`severity/*` set down in one run instead. An exact name is left alone; a
|
||||||
|
**lookalike** (`bug`, `Bug`, `kind/bug`, `type: bug`) is reported with its id and
|
||||||
|
never touched, because renaming somebody else's label is a decision and not a
|
||||||
|
step; colour or `exclusive` drift is corrected only under `--fix`. `tech/*` and
|
||||||
|
`comp/*` stay open-ended and push-created. A milestone must already exist — push
|
||||||
|
attaches, it never creates.
|
||||||
|
|
||||||
|
## What crosses the boundary, and what does not
|
||||||
|
|
||||||
|
| domain | tracker | note |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` (slug) | `<!-- kettle:id … -->` | first line of the tracker-side body; stripped out of the local copy |
|
||||||
|
| title, body | `title`, `body` | verbatim, both ways, except the marker and the checkbox union |
|
||||||
|
| `state` | `state` | same vocabulary |
|
||||||
|
| `labels` | `labels[]` | names both ways |
|
||||||
|
| `assignees` | `assignees[]` | logins |
|
||||||
|
| `milestone` | `milestone.title` | resolved to an id on write |
|
||||||
|
| `depends` | native issue links | slugs here, `{index, owner, repo}` there; push writes them, a pull reads them back |
|
||||||
|
| — | `ref` | lands in `branch:`; push fills an empty one with the current git branch |
|
||||||
|
| — | `number`, `html_url` | land in `gitea:` / `url:` |
|
||||||
|
|
||||||
|
`depends:` is always slugs, and the body's `## Depends on` prose is passed
|
||||||
|
through **unchanged** in both directions — a translator that edits prose churns
|
||||||
|
the body on every round trip. The edge the tracker acts on is the native link,
|
||||||
|
not the text, which is exactly why the text can be left alone. Push only ever
|
||||||
|
**adds** links: a dependency deleted from `depends:` leaves its tracker link
|
||||||
|
standing, and unlinking is a web UI job.
|
||||||
|
|
||||||
|
Dependencies go up in topological order so a blocker has its number before the
|
||||||
|
issue that names it. One that is still local-only gets no link and is reported,
|
||||||
|
never silently dropped.
|
||||||
|
|
||||||
|
## Drift
|
||||||
|
|
||||||
|
There is none tracked, and there is very little left to track: a published issue
|
||||||
|
has **one** copy — the tracker's — except while somebody is working on it, and
|
||||||
|
that window closes at the next push. Nothing watches the tracker, nothing
|
||||||
|
reconciles, nothing warns that a synced issue changed upstream. `synced:` says how
|
||||||
|
old your working copy is, `remote-updated:` what the server said at that moment.
|
||||||
|
The old question — "I edited this locally, does the server have it, whose text is
|
||||||
|
newer?" — is answered by the store's contents rather than by a mechanism: **a file
|
||||||
|
that is here has not been pushed.**
|
||||||
|
|
||||||
|
The checkbox union is not an exception. It reads only the two bodies in front of
|
||||||
|
it; there is no base version and no way for it to report that anything diverged.
|
||||||
|
|
||||||
|
## Payloads
|
||||||
|
|
||||||
|
Every request body goes to `<project>/.kettle/payload/` first and is kept there
|
||||||
|
for a retry or a post-mortem. It is a **sibling** of the store and never a child:
|
||||||
|
request bodies are debris of the transport, and a scratchpad inside a store makes
|
||||||
|
`ls .kettle/issues` lie about what exists. Nothing in it is anybody's only copy —
|
||||||
|
deleting it costs nothing. A run that sends nothing leaves no directory behind.
|
||||||
|
|
||||||
|
For Gitea entities `kettle` does not cover — releases, webhooks, actions, pull
|
||||||
|
requests — the tool is `tea`, and it keeps its own configuration and its own
|
||||||
|
logins. `/kettle:use`.
|
||||||
|
|
||||||
|
The commands themselves follow. Their usage lines, flags, defaults and examples
|
||||||
|
are generated from the binary's own command registry, so they cannot disagree
|
||||||
|
with the binary; `kettle help <command>` prints the same text. Editing them here
|
||||||
|
changes nothing.
|
||||||
|
|
||||||
|
<!-- kettle:gen -->
|
||||||
|
**Generated from the kettle command registry by `kettle gen skills`.** Everything between the two markers is replaced on the next run — hand-written prose belongs outside them.
|
||||||
|
|
||||||
|
## `kettle close <id|number> [<id|number>…]`
|
||||||
|
|
||||||
|
close or reopen issues in the tracker, and on disk with them
|
||||||
|
|
||||||
|
STATE ONLY. This sends `{"state": …}` and nothing else: no title, no body, no
|
||||||
|
labels, no milestone. Editing an issue is `kettle pull` -> edit ->
|
||||||
|
`kettle push --update`; closing it is not an edit.
|
||||||
|
|
||||||
|
EXPLICIT IDS ONLY. No --milestone, no --label, no "close everything that looks
|
||||||
|
done". Which issues are finished is a judgement about content; this carries that
|
||||||
|
judgement out, one named id at a time. Nothing here deletes an issue either —
|
||||||
|
the tracker can, and it is not an operation of this workflow.
|
||||||
|
|
||||||
|
WHAT MAY BE NAMED: a local slug, or a tracker key (42, #42, owner/repo#42, an
|
||||||
|
issue URL). Both, and for the same reason: a push deletes the local file, so
|
||||||
|
most issues in the tracker have no slug on disk to name them by. A slug is
|
||||||
|
resolved through the file's `gitea:` handle when the file is there, and through
|
||||||
|
the ledger (`.remote.json`) when push has already dropped it. A bare number is
|
||||||
|
this project's repository; a qualified key names its own, so a foreign #42 can
|
||||||
|
never be closed against the repository that happens to be configured here.
|
||||||
|
|
||||||
|
An `origin: local` issue cannot be closed. It is not in the tracker, so there is
|
||||||
|
no state there to change, and the run stops naming the id rather than quietly
|
||||||
|
editing one field of a local file. Push it first, or delete it.
|
||||||
|
|
||||||
|
THE LOCAL FILE IS WRITTEN ONLY AFTER THE TRACKER CONFIRMS: the answer has to be
|
||||||
|
the very issue that was patched, in the state that was asked for. Anything else
|
||||||
|
and the file is left exactly as it was. An issue whose local copy is gone
|
||||||
|
(pushed and dropped) is closed in the tracker and nothing is written; the state
|
||||||
|
comes down with the next pull.
|
||||||
|
|
||||||
|
A tracker that refuses to close an issue its own dependency graph still blocks
|
||||||
|
says so in the answer, and the run stops with its words: close the blockers
|
||||||
|
first, or unlink them.
|
||||||
|
|
||||||
|
| flag | default | what it does |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `--dry-run` | `false` | print what would change; makes no request |
|
||||||
|
| `--out` | — | store root (default: <project>/.kettle/issues) |
|
||||||
|
| `--reopen` | `false` | set the state back to open instead of closed |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kettle close wire-sqlc-appclick # one issue, by slug
|
||||||
|
kettle close wire-sqlc-appclick 42 #43 # several, by slug or number
|
||||||
|
kettle close --reopen 42 # the same thing backwards
|
||||||
|
kettle close --dry-run 42 43 # what would change; no request at all
|
||||||
|
```
|
||||||
|
|
||||||
|
## `kettle comment <id>`
|
||||||
|
|
||||||
|
post or edit a comment on a synced issue
|
||||||
|
|
||||||
|
The target is a LOCAL ID, not a number. Which issue this is, is a fact about the
|
||||||
|
work; where it lives in the tracker is bookkeeping, and the `gitea:` handle on the
|
||||||
|
file is what turns one into the other. An `origin: local` issue cannot be
|
||||||
|
commented on at all — it is not in the tracker, so there is nothing there to
|
||||||
|
comment on; push it first.
|
||||||
|
|
||||||
|
The body comes from a file or from --body, and multi-line prose is what --file
|
||||||
|
is for. This is why comments go through the API rather than through a tracker
|
||||||
|
CLI: an entity command with an empty-looking positional opens $EDITOR, and on a
|
||||||
|
TTY that does not exist it hangs forever.
|
||||||
|
|
||||||
|
After the write the whole thread is refetched into `<id>.comments.md`, so the
|
||||||
|
local copy is not stale by one comment — the one this run just made.
|
||||||
|
|
||||||
|
COMMENTS ARE PULL-ONLY IN THE STORE. Nothing round-trips them back: editing
|
||||||
|
`<id>.comments.md` by hand changes nothing in the tracker. Use --edit with a
|
||||||
|
comment id for that.
|
||||||
|
|
||||||
|
| flag | default | what it does |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `--body` | — | comment body inline (short, single-line) |
|
||||||
|
| `--edit` | `0` | comment id to rewrite, instead of posting a new one |
|
||||||
|
| `--file` | — | markdown file holding the comment body |
|
||||||
|
| `--out` | — | store root (default: <project>/.kettle/issues) |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kettle comment wire-sqlc-appclick --file notes.md # post the contents of a file
|
||||||
|
kettle comment wire-sqlc-appclick --body "готово, задеплоено" # post one line
|
||||||
|
kettle comment wire-sqlc-appclick --file fix.md --edit 1234 # rewrite comment 1234 instead
|
||||||
|
```
|
||||||
|
|
||||||
|
## `kettle labels`
|
||||||
|
|
||||||
|
put the canonical type/* and severity/* labels into a repository
|
||||||
|
|
||||||
|
Every `type/*` and every `severity/*` the domain taxonomy defines, created up
|
||||||
|
front instead of trickling in as a side effect of whichever push first happens
|
||||||
|
to use one. Until a name exists in the repository nobody can filter by it in the
|
||||||
|
web UI, so somebody makes their own — foreign colour, no `exclusive` — and the
|
||||||
|
set arrives in pieces over months.
|
||||||
|
|
||||||
|
NO LABEL NAME IS SPELLED OUT HERE. The names come from the domain taxonomy and
|
||||||
|
are painted by the mapping layer, because a hex code is how a tracker paints a
|
||||||
|
chip and not what an issue is. Add a type over in the domain and the next run
|
||||||
|
creates it.
|
||||||
|
|
||||||
|
THE REPOSITORY'S OWN LABELS ARE READ BEFORE ANYTHING IS WRITTEN, and read from
|
||||||
|
the repository, never from a cache — a cache answers "what did we create last
|
||||||
|
time" and the question here is "what does this repository have right now". A
|
||||||
|
name that matches exactly is left alone; a colour or `exclusive` that disagrees
|
||||||
|
with the spec is reported, and corrected only under --fix. A name that merely
|
||||||
|
RESEMBLES a canonical one (the same tail, up to case, separator and whatever
|
||||||
|
namespace is in front: `x`, `X`, `kind/x`, `type: x` against `type/x`) is
|
||||||
|
reported with its id and never touched — renaming somebody else's label is a
|
||||||
|
decision, not a step.
|
||||||
|
|
||||||
|
Out of scope by design: `tech/*` and `comp/*`, which are open-ended and are
|
||||||
|
created by push as they come up, and deleting or renaming anything at all. Only
|
||||||
|
repository labels are read; an organization's own labels sit behind a different
|
||||||
|
endpoint and are neither read nor written.
|
||||||
|
|
||||||
|
The issue store is out of scope too, and not incidentally: a label belongs to
|
||||||
|
the repository and not to any issue, so this neither reads the store nor creates
|
||||||
|
it. Request bodies go to the transport's own scratchpad, which is a sibling of
|
||||||
|
the store and never a child.
|
||||||
|
|
||||||
|
| flag | default | what it does |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `--dry-run` | `false` | print the plan; not one writing request |
|
||||||
|
| `--fix` | `false` | also patch colour/exclusive on labels that already exist |
|
||||||
|
| `--repo` | — | repository to bootstrap, as owner/name (default: this project's) |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kettle labels --dry-run # print the plan; not one writing request
|
||||||
|
kettle labels # create whatever is missing
|
||||||
|
kettle labels --fix # also patch colour / exclusive drift
|
||||||
|
kettle labels --repo owner/name # bootstrap another repository
|
||||||
|
```
|
||||||
|
|
||||||
|
## `kettle pull [<key>…]`
|
||||||
|
|
||||||
|
fetch issues from the tracker into the local store
|
||||||
|
|
||||||
|
THIS IS HOW A PUSHED ISSUE COMES BACK. `kettle push` deletes the local file the
|
||||||
|
moment the tracker confirms the write, so a pull is not a refresh of a copy you
|
||||||
|
kept — it is how the copy comes to exist at all.
|
||||||
|
|
||||||
|
It lands under the SAME slug it had before, after a rename in the web UI and on
|
||||||
|
a machine that has never seen the issue. Three sources answer "what is this
|
||||||
|
issue called here", in this order:
|
||||||
|
|
||||||
|
.remote.json the number -> slug ledger; the only one that knows what
|
||||||
|
is on disk right now, so it wins
|
||||||
|
<!-- kettle:id … --> the marker in the tracker-side body; it survives a lost
|
||||||
|
ledger, a fresh clone, another machine, and a retitling
|
||||||
|
the title slugified — where an issue filed in the web UI gets its
|
||||||
|
first local name
|
||||||
|
|
||||||
|
A marker is taken at its word only when the slug is free; a name already in use
|
||||||
|
is a collision, not an identity, and is uniquified rather than allowed to
|
||||||
|
overwrite somebody else's issue. The marker itself is stripped out of what lands
|
||||||
|
on disk.
|
||||||
|
|
||||||
|
TWO WAYS TO NAME WHAT TO PULL, and they are not the same operation:
|
||||||
|
|
||||||
|
kettle pull 42 #43 owner/repo#44 by key — an ADDRESS
|
||||||
|
kettle pull --milestone v0.2 by filter — a QUERY
|
||||||
|
|
||||||
|
A key fetches an issue in ANY state, because a number is an address and not a
|
||||||
|
question about state. Only filter mode leaves closed issues out — a closed issue
|
||||||
|
is not a unit of work — and only `--state closed` puts one in the store. An issue
|
||||||
|
already on disk is refreshed either way, so a local copy learns it was closed
|
||||||
|
instead of staying open forever, and the count that stayed out goes to stderr.
|
||||||
|
|
||||||
|
`--limit` IS ON THE WRITE, NOT ON THE SELECTION. It counts the issues this run puts
|
||||||
|
in the store and never the closed ones it enumerated and threw away, so pages
|
||||||
|
keep coming until the budget is full — and stop the moment it is. A filter that
|
||||||
|
matches almost only closed issues ends in a warning and a short answer rather
|
||||||
|
than a walk of the whole tracker.
|
||||||
|
|
||||||
|
A PULL RETURNS THE UNIT OF WORK, NOT ONE ROW OF IT. `depends:` is filled from the
|
||||||
|
tracker's own dependency graph and every blocker comes down with it, recursively,
|
||||||
|
to --depth. What that costs, stated rather than hidden: one request per issue
|
||||||
|
that lands in the store, plus one per blocker the selection did not already
|
||||||
|
carry. `--no-deps` is the way back to one request, and narrows the answer to the
|
||||||
|
one issue you asked for. Dependencies are outside --limit: a blocker is followed
|
||||||
|
because a stored issue named it, not because the filter selected it, so a
|
||||||
|
filtered pull can leave more files behind than its limit — including one from
|
||||||
|
another milestone. The one blocker that does not land is a closed one.
|
||||||
|
|
||||||
|
PULLING OVERWRITES THE BODY: a fetch, not a merge. Local edits you have not
|
||||||
|
pushed are lost, with exactly one exception — checkbox state. A tick is monotone,
|
||||||
|
so a `[x]` on either side wins for any item whose text matches; unticking is not,
|
||||||
|
so untick locally and push. `--cached` skips an issue before any of that.
|
||||||
|
|
||||||
|
Comments ride along: the thread lands beside the issue in <id>.comments.md. It
|
||||||
|
costs no request when the payload says there are none, and a file left over from
|
||||||
|
an earlier pull is deleted — so no file means "no comments", never "not asked
|
||||||
|
for". The thread is pull-only; post with `kettle comment`.
|
||||||
|
|
||||||
|
| flag | default | what it does |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `--cached` | `false` | skip issues already on disk instead of refetching |
|
||||||
|
| `--depth` | `3` | how deep to follow blockers |
|
||||||
|
| `--label` | — | filter by label; repeat for AND |
|
||||||
|
| `--limit` | `100` | filter mode: how many issues to STORE, not to enumerate |
|
||||||
|
| `--milestone` | — | pull a whole milestone (id or title) |
|
||||||
|
| `--no-deps` | `false` | do not fill depends: and do not follow blockers |
|
||||||
|
| `--out` | — | store root (default: <project>/.kettle/issues) |
|
||||||
|
| `--q` | — | search text in title and body |
|
||||||
|
| `--query` | — | the long spelling of -q |
|
||||||
|
| `--state` | `open` | filter mode only: open, closed or all |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kettle pull 42 # the issue and everything blocking it, in any state
|
||||||
|
kettle pull 42 --no-deps # just that one issue — one request
|
||||||
|
kettle pull owner/repo#42 # an issue in another repository
|
||||||
|
kettle pull --milestone v0.2 --limit 20 # 20 open issues from a milestone, blockers included
|
||||||
|
kettle pull --label type/bug --state all # every bug; the closed ones are enumerated, not stored
|
||||||
|
```
|
||||||
|
|
||||||
|
## `kettle push [<id>…]`
|
||||||
|
|
||||||
|
send local issues to the tracker; the local copy goes with them
|
||||||
|
|
||||||
|
A SUCCESSFUL PUSH DELETES THE LOCAL FILE — <id>.md and every sidecar under that
|
||||||
|
slug — and prints the number and the URL the issue now lives at. Once the tracker
|
||||||
|
has the issue, the tracker IS the issue: what is left in the store is what has
|
||||||
|
not left this machine. Get it back with `kettle pull <n>`, which returns it under
|
||||||
|
the same slug, because the slug travelled up in the body as <!-- kettle:id … -->
|
||||||
|
and was recorded in the number -> slug ledger.
|
||||||
|
|
||||||
|
ONE RULE, NO EXCEPTION: --update deletes as well. A PATCH is a push, and an issue
|
||||||
|
that has just been sent is no more local than one that was just created. Two
|
||||||
|
rules would put back exactly the question this removes — "is my copy the fresh
|
||||||
|
one?".
|
||||||
|
|
||||||
|
THE DELETION IS THE LAST THING THAT HAPPENS TO AN ISSUE, and only after all
|
||||||
|
three of:
|
||||||
|
|
||||||
|
1. the call came back without an error and with a 2xx,
|
||||||
|
2. the answer carries a plausible number — on --update the very number that
|
||||||
|
was PATCHed, and
|
||||||
|
3. the ledger has been written with number -> slug.
|
||||||
|
|
||||||
|
Network down, non-2xx, an answer that does not confirm the write: the file stays
|
||||||
|
and the run stops. Nothing removes a file it has not just watched the tracker
|
||||||
|
accept, and nothing removes a file for an issue it did not send — `origin: local`
|
||||||
|
work that has never been pushed is never touched by any of this. Get the ordering
|
||||||
|
wrong and a slug is lost at exactly the moment the local copy stops being the
|
||||||
|
record, which is why the ledger is written before anything is deleted and not
|
||||||
|
after.
|
||||||
|
|
||||||
|
Every issue is validated against the canonical format first, offline and before
|
||||||
|
a socket is opened. --force posts anyway; say why when you use it.
|
||||||
|
|
||||||
|
DEPENDENCIES GO FIRST, in topological order, so a blocker has its number before
|
||||||
|
the issue that names it. Every `depends:` entry that has a number becomes a NATIVE
|
||||||
|
tracker link — the same /dependencies a pull reads back, so the tracker shows the
|
||||||
|
blocking panel and refuses to close a blocked issue first. A link that is already
|
||||||
|
there is skipped, not re-POSTed, which is what makes a repeat push a no-op. A
|
||||||
|
dependency that is still local-only has no number and becomes no link: it is
|
||||||
|
reported, never silently dropped.
|
||||||
|
|
||||||
|
REMOVING a link is out of scope — push only ever adds. A dependency deleted from
|
||||||
|
`depends:` leaves its tracker link standing; unlink it in the web UI.
|
||||||
|
|
||||||
|
The `## Depends on` prose is never touched: slugs stay slugs and are not rewritten
|
||||||
|
to #N, so a pull -> push round trip is byte for byte.
|
||||||
|
|
||||||
|
Labels the repository is missing are created with the canonical colour and, for
|
||||||
|
type/* and severity/*, exclusive: true. `branch:` carries the tracker's `ref`: an
|
||||||
|
empty one is filled with the current git branch and an already-set one is sent as
|
||||||
|
written. Detached HEAD or no repository at all is not an error — no ref is sent
|
||||||
|
and a warning says so.
|
||||||
|
|
||||||
|
| flag | default | what it does |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `--dry-run` | `false` | validate and print the plan; no network, nothing deleted |
|
||||||
|
| `--force` | `false` | push despite format violations |
|
||||||
|
| `--out` | — | store root (default: <project>/.kettle/issues) |
|
||||||
|
| `--update` | `false` | PATCH issues that already carry a gitea: field |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kettle push # every issue the tracker does not have yet, blockers first
|
||||||
|
kettle push wire-sqlc-appclick # one issue
|
||||||
|
kettle push --update wire-sqlc-appclick # PATCH one that is already there — the file still goes
|
||||||
|
kettle push --dry-run # validate and print the plan; no network, nothing deleted
|
||||||
|
```
|
||||||
|
|
||||||
|
## `kettle remote`
|
||||||
|
|
||||||
|
list what exists in the tracker, one line each
|
||||||
|
|
||||||
|
Discovery only: this prints and WRITES NOTHING. The local store is a store, not a
|
||||||
|
search-results folder, and a listing that landed in it would leave files nobody
|
||||||
|
asked for beside the issues somebody did. Pick the numbers here, then pull them.
|
||||||
|
|
||||||
|
#42 open type/task, tech/sql Wire sqlc into the repo layer
|
||||||
|
└─ local: wire-sqlc-appclick
|
||||||
|
|
||||||
|
The second line appears when the number is already in the local ledger, so it is
|
||||||
|
obvious what a pull would refresh and what it would add.
|
||||||
|
|
||||||
|
--limit here caps the LISTING: N lines out, closed ones among them. That is not
|
||||||
|
what the same flag means to `kettle pull`, and the difference is not an oversight —
|
||||||
|
pull bounds what it WRITES, this command writes nothing, and enumeration is the
|
||||||
|
whole job.
|
||||||
|
|
||||||
|
Projects are not filterable: the projects API is not exposed by Gitea. Use
|
||||||
|
milestones or labels, or the web UI.
|
||||||
|
|
||||||
|
| flag | default | what it does |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `--label` | — | filter by label; repeat for AND |
|
||||||
|
| `--limit` | `30` | how many lines to print |
|
||||||
|
| `--milestone` | — | milestone id or title |
|
||||||
|
| `--out` | — | store root (default: <project>/.kettle/issues) |
|
||||||
|
| `--q` | — | search text in title and body |
|
||||||
|
| `--query` | — | the long spelling of -q |
|
||||||
|
| `--state` | `open` | open, closed or all |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kettle remote # the open issues, 30 of them
|
||||||
|
kettle remote --state all --label type/bug --limit 50 # every bug, open and closed
|
||||||
|
kettle remote --milestone v0.2 # what is in a milestone
|
||||||
|
kettle remote -q sqlc # keyword search over title and body
|
||||||
|
```
|
||||||
|
|
||||||
|
## `kettle sync-evict [<id>…]`
|
||||||
|
|
||||||
|
refresh state from the tracker, then evict what is closed
|
||||||
|
|
||||||
|
`kettle evict` is the command that decides and deletes. This adds exactly one
|
||||||
|
thing in front of it: a `state:` that is not stale. A local `state:` is only as
|
||||||
|
fresh as the last pull, so an issue closed in the web UI an hour ago still reads
|
||||||
|
`open` here and the offline command will — correctly — leave it alone. That is
|
||||||
|
the gap this closes, and before it existed the operator had to pull the five
|
||||||
|
closed issues back onto disk before anything could remove them.
|
||||||
|
|
||||||
|
ORDER OF OPERATIONS, AND IT IS THE WHOLE SAFETY ARGUMENT:
|
||||||
|
|
||||||
|
1. every candidate's state is fetched — ALL of them, before anything is
|
||||||
|
removed;
|
||||||
|
2. each answer must be the issue that was asked about, in a state the domain
|
||||||
|
recognizes;
|
||||||
|
3. only then is the eviction run, by handing the refreshed issues to the
|
||||||
|
domain — the same decision, the same deletion, the same protection of
|
||||||
|
`origin: local`, in one place.
|
||||||
|
|
||||||
|
A dead connection, a non-2xx, an answer about another issue, a state nobody
|
||||||
|
recognizes: the run stops at step 2 and NOTHING is deleted, not even the issues
|
||||||
|
whose answers had already arrived. That is stricter than push, which deletes as
|
||||||
|
it goes, and it costs nothing here — there is no ordering constraint between
|
||||||
|
evictions, so there is no reason to start before every answer is in.
|
||||||
|
|
||||||
|
A candidate is an issue carrying a `gitea:` handle. `origin: local` work has
|
||||||
|
none, is never asked about, and is never evicted — it is not in the tracker to
|
||||||
|
be closed. A tracked issue whose handle is missing or unreadable cannot be
|
||||||
|
verified, so it is reported and kept rather than guessed at.
|
||||||
|
|
||||||
|
Cost: one request per candidate. The store is a working set that push keeps
|
||||||
|
small, and a wrong answer here deletes a file, so each issue is asked about by
|
||||||
|
its own address rather than inferred from a list a limit could have truncated.
|
||||||
|
|
||||||
|
The refreshed state is written back even for the issues that stay: the answer is
|
||||||
|
already paid for, and a store that keeps a state the tracker has disowned is the
|
||||||
|
thing this command exists to fix.
|
||||||
|
|
||||||
|
| flag | default | what it does |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `--dry-run` | `false` | ask the tracker and report; write and delete nothing |
|
||||||
|
| `--out` | — | store root (default: <project>/.kettle/issues) |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kettle sync-evict # ask about every synced issue; evict the closed ones
|
||||||
|
kettle sync-evict old-thing another-thing # only these
|
||||||
|
kettle sync-evict --dry-run # ask, report, write and delete nothing
|
||||||
|
```
|
||||||
|
<!-- /kettle:gen -->
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
---
|
||||||
|
name: use
|
||||||
|
description: Reference docs for the `tea` CLI — Gitea's own command-line client, and the way to reach every Gitea entity the `kettle` binary does not cover. Load when the user asks about pull requests, releases, milestones, labels, repos, branches, actions, webhooks, notifications or times, to look up the right `tea` command and flags. `tea` keeps its own configuration and its own logins, entirely separate from kettle's. Issues are NOT handled here: /kettle:issue works on them offline and /kettle:sync moves them to and from the tracker.
|
||||||
|
---
|
||||||
|
|
||||||
|
# /kettle:use — tea CLI reference
|
||||||
|
|
||||||
|
Reference material for `tea`, Gitea's official command-line client. Use these
|
||||||
|
docs to look up commands, flags, filters and output fields before running `tea`
|
||||||
|
via Bash.
|
||||||
|
|
||||||
|
`kettle` covers issues and nothing else. Everything else Gitea has — pulls,
|
||||||
|
releases, milestones, labels, repos, branches, actions, webhooks, notifications,
|
||||||
|
times — is reached through `tea`, and this skill is how.
|
||||||
|
|
||||||
|
## Issues are somewhere else
|
||||||
|
|
||||||
|
Do **not** reach for `tea issues` or `tea api …/issues/…` to read or create an
|
||||||
|
issue. Two skills own that, and they keep the payload out of your context:
|
||||||
|
|
||||||
|
| Skill | Scope |
|
||||||
|
|---|---|
|
||||||
|
| `/kettle:issue` | issues as units of work — create, read, grep, validate, tick, dependency graph. Offline. |
|
||||||
|
| `/kettle:sync` | moving issues between the local store and the tracker — pull, push, comment, close, evict. |
|
||||||
|
|
||||||
|
## Login: `tea` has its own configuration, and it is not kettle's
|
||||||
|
|
||||||
|
Two tools, two credential stores, no connection between them:
|
||||||
|
|
||||||
|
| tool | where its logins live | how they are managed |
|
||||||
|
|---|---|---|
|
||||||
|
| `tea` | `$XDG_CONFIG_HOME/tea` | `tea logins list`, `tea logins add` (interactive), `tea logins default` |
|
||||||
|
| `kettle` | `~/.config/kettle/logins.yaml` + `<project>/.kettle/config.yaml` | `kettle auth`, `kettle init --login` (`/kettle:auth`) |
|
||||||
|
|
||||||
|
**Configuring one configures nothing in the other.** `/kettle:auth` does not give
|
||||||
|
`tea` a credential, and `tea logins add` does not give `kettle` one. A project
|
||||||
|
whose `kettle` commands work fine can still have no `tea` login at all, and the
|
||||||
|
error you get will be about the login `tea` chose for itself.
|
||||||
|
|
||||||
|
**There is no `$GITEA_LOGIN` placeholder and no hook that substitutes one.** The
|
||||||
|
PreToolUse guard that used to rewrite it was deleted along with the Python
|
||||||
|
scripts; writing `--login "$GITEA_LOGIN"` now passes an empty variable to `tea`
|
||||||
|
and fails in a way that reads like a `tea` bug. If you find that spelling
|
||||||
|
anywhere, it is stale.
|
||||||
|
|
||||||
|
How to name a login honestly:
|
||||||
|
|
||||||
|
- Inside a checkout, `tea` auto-detects owner, repo and login from the git
|
||||||
|
remote. That is usually right and usually enough — run the command without
|
||||||
|
`--login`.
|
||||||
|
- When the machine holds more than one login, or you are outside a checkout,
|
||||||
|
pass `--login <name>` with a name out of `tea logins list`. **Which one is the
|
||||||
|
operator's call**: ask with `AskUserQuestion` rather than picking the one that
|
||||||
|
looks likely. A wrong identity writes to a real tracker under somebody else's
|
||||||
|
account.
|
||||||
|
- `no gitea login detected, falling back to login '…'` is a **hard failure**, not
|
||||||
|
a warning. Stop, do not act on the result, surface the line.
|
||||||
|
- **Never mutate login state**: no `tea logins add/edit/delete/default`, no
|
||||||
|
`tea logout`. `tea logins list` is the only login command that is yours to run,
|
||||||
|
and adding a login is interactive — the operator does it in their own terminal.
|
||||||
|
|
||||||
|
## How to use
|
||||||
|
|
||||||
|
1. Identify the entity in the request: pulls, labels, milestones, releases,
|
||||||
|
times, repos, branches, actions, webhooks, notifications, etc.
|
||||||
|
2. Find the matching command in the index below.
|
||||||
|
3. Run it via Bash, e.g. `tea pulls list --repo owner/repo --state open`.
|
||||||
|
|
||||||
|
`tea` auto-detects owner/repo from `$PWD` inside a git repo; otherwise pass
|
||||||
|
`--repo owner/repo` (or `-r`).
|
||||||
|
|
||||||
|
### `--repo` takes a slug — except where a checkout is required
|
||||||
|
|
||||||
|
A few commands touch local git, not just the API, and for those `--repo` **must
|
||||||
|
be a path to a checkout**; a slug is rejected:
|
||||||
|
|
||||||
|
```
|
||||||
|
Error: local repository required: execute from a repo dir, or specify a path with --repo
|
||||||
|
```
|
||||||
|
|
||||||
|
The message reads like the flag is missing even when it was passed. Confirmed
|
||||||
|
for `pulls create`, `pulls checkout` and `pulls clean` (tea 0.14.x). Everything
|
||||||
|
that is only an API call — `pulls list`, `milestones`, `releases`, `times`,
|
||||||
|
`labels`, `issues` — takes the slug from any directory.
|
||||||
|
|
||||||
|
Three working forms for `pulls create`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. cwd inside the checkout, no --repo at all
|
||||||
|
tea pulls create --head feat/x --base main --title "…" --description "…"
|
||||||
|
|
||||||
|
# 2. from anywhere, --repo as a PATH (this is also the git-worktree answer:
|
||||||
|
# point it at the main checkout)
|
||||||
|
tea pulls create --repo /path/to/checkout \
|
||||||
|
--head feat/x --base main --title "…" --description "…"
|
||||||
|
|
||||||
|
# 3. no checkout in reach — POST it, where owner/repo is a slug again
|
||||||
|
tea api -X POST -d @tmp/pull/x.json repos/{owner}/{repo}/pulls
|
||||||
|
```
|
||||||
|
|
||||||
|
## Index
|
||||||
|
|
||||||
|
- [tea CLI overview](references/tea/index.md) — global flags, common options, output formats
|
||||||
|
- [ENTITIES](references/tea/entities.md) — issues, pulls, labels, milestones, releases, times, repos, branches, actions, webhooks, comment
|
||||||
|
- [HELPERS](references/tea/helpers.md) — open, notifications, clone, api
|
||||||
|
- [MISC](references/tea/misc.md) — whoami, admin
|
||||||
|
- [SETUP](references/tea/setup.md) — logins, logout, ssh-keys
|
||||||
|
|
||||||
|
The canonical issue format lives in
|
||||||
|
[`../issue/references/format.md`](../issue/references/format.md) — it describes
|
||||||
|
local files, not `tea` commands.
|
||||||
|
|
||||||
|
## Rich payloads — write to `$PWD/tmp/` first, then `tea api`
|
||||||
|
|
||||||
|
Entity subcommands (`tea comment`, `tea pulls create`, `tea releases create`, …)
|
||||||
|
are built for humans at a TTY. With a large or formatted body they can hang
|
||||||
|
silently — an empty-looking positional arg triggers the `$EDITOR` fallback, or a
|
||||||
|
scope/confirm prompt waits on a TTY that doesn't exist. The harness eventually
|
||||||
|
kills the process (e.g. exit 144 = 128 + SIGURG on macOS).
|
||||||
|
|
||||||
|
**Rule:** for any non-trivial body (multi-line, or containing markdown / code
|
||||||
|
fences / backticks / pipes / tables), bypass entity commands. Save the full
|
||||||
|
request payload to `$PWD/tmp/` first, then POST via `tea api`.
|
||||||
|
|
||||||
|
Issues and issue comments are already wrapped — use `/kettle:sync` rather than
|
||||||
|
hand-rolling their JSON. `.kettle/payload/` is kettle's own scratchpad and is
|
||||||
|
written by kettle only; do not put hand-made bodies there. The procedure below
|
||||||
|
covers everything else.
|
||||||
|
|
||||||
|
### Procedure
|
||||||
|
|
||||||
|
1. Ensure the target dir exists: `mkdir -p tmp/{kind}` where `{kind}` is
|
||||||
|
`pull`, `release`, etc.
|
||||||
|
2. Write the **complete request body as JSON** to `$PWD/tmp/{kind}/<slug>.json`.
|
||||||
|
One file = one request. Use a quoted heredoc to avoid shell expansion:
|
||||||
|
```bash
|
||||||
|
mkdir -p tmp/release
|
||||||
|
cat > tmp/release/v0-2-0.json <<'EOF'
|
||||||
|
{"tag_name": "v0.2.0", "name": "v0.2.0", "body": "## Changes\n\nMulti-line markdown with `code`."}
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
Newlines inside the body must be encoded as `\n` in the JSON string. If
|
||||||
|
composing programmatically, pipe through
|
||||||
|
`jq -Rs '{body: .}' < body.md > tmp/release/v0-2-0.json`.
|
||||||
|
3. POST with `tea api`, passing the file with `-d @<path>`:
|
||||||
|
```bash
|
||||||
|
tea api -X POST -d @tmp/release/v0-2-0.json repos/{owner}/{repo}/releases
|
||||||
|
```
|
||||||
|
4. Keep the file. `tmp/` should be gitignored; the saved payload is useful for
|
||||||
|
retries, edits (`PATCH`), and debugging failed posts.
|
||||||
|
|
||||||
|
### Common endpoints
|
||||||
|
|
||||||
|
| Action | Method + endpoint |
|
||||||
|
|---|---|
|
||||||
|
| Create PR | `POST repos/{owner}/{repo}/pulls` |
|
||||||
|
| Edit PR body or title | `PATCH repos/{owner}/{repo}/issues/{n}` |
|
||||||
|
| Comment on a PR | `POST repos/{owner}/{repo}/issues/{n}/comments` |
|
||||||
|
| Edit comment | `PATCH repos/{owner}/{repo}/issues/comments/{id}` |
|
||||||
|
| Create release | `POST repos/{owner}/{repo}/releases` |
|
||||||
|
| Create milestone | `POST repos/{owner}/{repo}/milestones` |
|
||||||
|
|
||||||
|
Short single-line bodies (e.g. `tea comment 42 "lgtm"`) are still fine via
|
||||||
|
entity commands.
|
||||||
|
|
||||||
|
## Tips
|
||||||
|
|
||||||
|
- Pass `-o json` for structured output when parsing programmatically — on
|
||||||
|
**entity commands only**. On `tea api`, `-o` is a *file name*: `-o json`
|
||||||
|
writes the response body to a file called `json` and leaves stdout empty.
|
||||||
|
The response is already JSON, so there is nothing to format; use `-` for
|
||||||
|
stdout, or leave the flag off.
|
||||||
|
- Use `--fields, -f` to narrow columns.
|
||||||
|
- Pagination: `--page, -p <n>` and `--limit, --lm <n>` (defaults 1 / 30).
|
||||||
|
- A `tea` command that fails on identity is a login problem in **tea's** own
|
||||||
|
config, never in kettle's — `tea logins list`, and the operator decides.
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "tea",
|
|
||||||
"description": "Gitea issues as local markdown, cleanly layered: /tea:issue works on issues offline (format, validation, dependency graph), /tea:sync moves them to and from Gitea, /tea:use is the CLI reference, the tea-runner subagent executes the scripts on a cheap model, and a PreToolUse hook blocks any command that would touch Gitea without the operator-pinned login.",
|
|
||||||
"version": "2.3.0",
|
|
||||||
"author": {
|
|
||||||
"name": "naudachu"
|
|
||||||
},
|
|
||||||
"license": "MIT",
|
|
||||||
"keywords": ["gitea", "cli", "git", "issues", "login-guard"]
|
|
||||||
}
|
|
||||||
@@ -1,262 +0,0 @@
|
|||||||
# AGENTS.md
|
|
||||||
|
|
||||||
## Project goals
|
|
||||||
|
|
||||||
1. **Unify and systematize issue workflow** for the development team with
|
|
||||||
minimal context usage. Issue operations are wrapped in scripts so agents
|
|
||||||
spend tokens on the task, not on re-deriving commands and formats.
|
|
||||||
2. **Keep the tracker out of the work.** An issue is a unit of work first and a
|
|
||||||
Gitea row second. The two are separate layers, and the first one does not
|
|
||||||
know the second exists.
|
|
||||||
3. **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.
|
|
||||||
|
|
||||||
## Layers
|
|
||||||
|
|
||||||
The hard rule of this repo. One domain, one bridge, one transport, and
|
|
||||||
knowledge flows one way only:
|
|
||||||
|
|
||||||
```
|
|
||||||
skills/issue DOMAIN what an issue is: format, validation, dependency graph
|
|
||||||
▲ offline — no tracker, no network, stdlib imports only
|
|
||||||
│ imports
|
|
||||||
skills/sync BRIDGE map.py md <-> Gitea issue JSON, pure, no I/O
|
|
||||||
_gitea.py tea api, pagination, filters, payloads
|
|
||||||
│ imports
|
|
||||||
▼
|
|
||||||
skills/auth IDENTITY pin the login the whole tracker side runs under
|
|
||||||
▲ pin.py where the pin is and how it is found —
|
|
||||||
│ imports imported by _gitea.py AND by hooks/tea-guard.sh
|
|
||||||
hooks/tea-guard so `tea` and the scripts cannot disagree
|
|
||||||
|
|
||||||
skills/use REFERENCE tea CLI docs for everything that is not an issue
|
|
||||||
▲
|
|
||||||
│ calls
|
|
||||||
agents/ EXECUTION tea-runner: runs the scripts, reports a receipt
|
|
||||||
```
|
|
||||||
|
|
||||||
The domain never imports its bridge: delete `skills/sync` and issues still
|
|
||||||
work. The check is mechanical — every import under the domain's `scripts/` is
|
|
||||||
stdlib, and `subprocess` is not among them:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
grep -rh '^import \|^from ' skills/issue/scripts/ | sort -u
|
|
||||||
```
|
|
||||||
|
|
||||||
If a tracker concept (issue number, login, HTTP call, label color) shows up in
|
|
||||||
the domain layer, it is in the wrong place.
|
|
||||||
|
|
||||||
## Repo layout
|
|
||||||
|
|
||||||
- `skills/auth` — pin the Gitea login used by `tea` (`/tea:auth`)
|
|
||||||
- `scripts/pin.py` — the one written copy of the pin's location and search
|
|
||||||
order (see "The login pin" below); stdlib, no subprocess, no network
|
|
||||||
- `skills/issue` — issues as units of work (`/tea:issue`), entirely offline
|
|
||||||
- `references/format.md` — canonical issue format; single source of truth
|
|
||||||
- `scripts/issue.py` — domain module: slug identity, parse/render, validation,
|
|
||||||
taxonomy, dependency graph, body checkboxes
|
|
||||||
- `scripts/issue_new.py` — create a local issue from its type template
|
|
||||||
- `scripts/issue_check.py` — validate against the format
|
|
||||||
- `scripts/issue_ac.py` — list the body's checkboxes; tick one by number or
|
|
||||||
substring, changing exactly one character of the file
|
|
||||||
- `scripts/issue_tree.py` — draw the dependency graph
|
|
||||||
- `scripts/issue_evict.py` — remove closed issues from the store; never an
|
|
||||||
`origin: local` one
|
|
||||||
- `scripts/issue_index.py` — rebuild `.tea/issues/INDEX.md`
|
|
||||||
- `scripts/issue_init.py` — create the `.tea/` marker that makes a directory
|
|
||||||
a project; migrates an old `tmp/issues` store in, adds `.tea/` to
|
|
||||||
`.gitignore`. Idempotent, and refuses to pick a winner on a name clash
|
|
||||||
- `skills/sync` — move issues between the local store and Gitea (`/tea:sync`)
|
|
||||||
- `scripts/map.py` — md ↔ Gitea JSON, pure, no I/O; label colors live here
|
|
||||||
- `scripts/_gitea.py` — transport: `tea api`, pagination, filters, label ids,
|
|
||||||
the remote-id map, `.tea/payload/`; the login comes from `auth/pin.py`
|
|
||||||
- `scripts/pull.py`, `push.py`, `remote.py`, `comment.py`
|
|
||||||
- `scripts/close.py` — the state field, both ways; explicit ids only
|
|
||||||
- `scripts/evict.py` — refresh `state:` from Gitea, then hand the decision to
|
|
||||||
the domain's `issue_evict.run`
|
|
||||||
- `scripts/labels.py` — put the canonical `type/*` and `severity/*` set into a
|
|
||||||
repository; reads the domain taxonomy, never the store
|
|
||||||
- `skills/use` — `tea` CLI reference for everything that is not an issue
|
|
||||||
(`/tea:use`); `references/tea/` holds the command docs
|
|
||||||
- `agents/tea-runner.md` — subagent on Haiku that executes the scripts and
|
|
||||||
returns a compact receipt. Delegate batches (bulk pull, push a named set,
|
|
||||||
bootstrap labels, rebuild the index), never the thinking: it has no `Edit`
|
|
||||||
and no `Write`, may not `--force`, and may not decide what an issue says.
|
|
||||||
Delegating a single call costs more than running it inline — the win is the
|
|
||||||
loop, the retry, and the error triage.
|
|
||||||
- `hooks/` — PreToolUse hooks: `tea-guard` blocks or rewrites `tea` invocations
|
|
||||||
that don't use the pinned login (resolving it through `auth/pin.py`);
|
|
||||||
`agents-sync` keeps every directory canonical (`AGENTS.md` real file,
|
|
||||||
`CLAUDE.md` symlink to it)
|
|
||||||
- `tests/` — stdlib `unittest`, no third-party anything
|
|
||||||
|
|
||||||
## The login pin
|
|
||||||
|
|
||||||
`<project root>/.claude/settings.local.json` → `env.GITEA_LOGIN`, written by
|
|
||||||
`/tea:auth` and read at call time. **The search order is written once, in
|
|
||||||
`skills/auth/scripts/pin.py`**, and both callers import it: the transport
|
|
||||||
(`_gitea.require_login`) and the `tea-guard` hook. Neither spells the path or
|
|
||||||
the walk itself, and a test asserts they don't.
|
|
||||||
|
|
||||||
Start directories, first hit wins: `$CLAUDE_PROJECT_DIR`, then a hint the
|
|
||||||
caller supplies (the hook passes the Bash payload's `cwd`; a script passes
|
|
||||||
nothing), then the current directory. Each one is searched up its parent chain,
|
|
||||||
and then — only if that found nothing — up the parent chain of the **main
|
|
||||||
working tree of any linked worktree** met on the way, reached by reading
|
|
||||||
`gitdir:` out of a `.git` *file* and following `commondir`.
|
|
||||||
|
|
||||||
**Nothing here resolves from `__file__`, and the walk is written once.**
|
|
||||||
`issue.parents`, `issue.gitdir_of` and `issue.main_worktree` live in the domain
|
|
||||||
— the layer that depends on nothing, and therefore the only one all three
|
|
||||||
callers may borrow from — and `pin.py` imports them. The guard, the transport
|
|
||||||
and the store cannot disagree about a directory, because there is one walk.
|
|
||||||
|
|
||||||
Where an installation keeps its files is a fact about the installation. Whose
|
|
||||||
login a project runs under is a fact about the project — **and so is which
|
|
||||||
issues it has.** A plugin installed outside any repository and pointed at
|
|
||||||
somebody else's tree must answer both from the tree it was pointed at.
|
|
||||||
|
|
||||||
Three failures this replaces, all worth remembering:
|
|
||||||
|
|
||||||
- a git worktree is a *sibling* of the main checkout, so the untracked pin is
|
|
||||||
not on its parent chain, and the whole sync layer died there while `tea` in
|
|
||||||
the same directory worked;
|
|
||||||
- the cure that invited — `/tea:auth` inside the worktree — writes a second
|
|
||||||
settings file into a directory that is deleted with the worktree;
|
|
||||||
- and the store and the payload root, which *were* anchored on `__file__`,
|
|
||||||
resolved inside the installed plugin: `~/.claude/plugins/cache/tea/tea/2.0.0/
|
|
||||||
tmp/issues`, a **versioned** directory. Issues written from one project were
|
|
||||||
invisible from the next and stranded by every plugin update. Two `origin:
|
|
||||||
local` files — the only copy of that work, by definition — were found there.
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 -m unittest discover -s tests -v
|
|
||||||
```
|
|
||||||
|
|
||||||
Plain `unittest`; no pytest, no dependencies — the scripts under test are
|
|
||||||
stdlib-only and the tests hold the same line. `skills/*/scripts/` are not
|
|
||||||
packages, so a test that needs the domain module imports it with
|
|
||||||
`sys.path.insert`.
|
|
||||||
|
|
||||||
**A test never touches `.tea/issues/` or `.tea/payload/`.** Anything that needs
|
|
||||||
a store builds a throwaway project in a `tempfile.TemporaryDirectory()` — a
|
|
||||||
`.tea/` marker and fixture issues — and runs the real scripts inside it as
|
|
||||||
subprocesses. That is the only way to test behavior that depends on where a
|
|
||||||
script is run from, and it keeps the developer's own store out of the blast
|
|
||||||
radius.
|
|
||||||
|
|
||||||
**The scripts are not copied into the fixture.** They stay where they really
|
|
||||||
live, and the fixture is somewhere else entirely — that separation *is* the
|
|
||||||
contract: a plugin is installed in one place and used on projects in another.
|
|
||||||
The suite used to copy both layers in, which made the two the same directory
|
|
||||||
and hid the `__file__` bug completely.
|
|
||||||
|
|
||||||
**A subprocess fixture strips `CLAUDE_PROJECT_DIR`** unless the test is about
|
|
||||||
it. It is the first anchor of the walk, so the harness's own value would point
|
|
||||||
every fixture at this repository.
|
|
||||||
|
|
||||||
## Local issue store
|
|
||||||
|
|
||||||
`.tea/issues/` (gitignored) holds **two kinds of file, and only one of them is a
|
|
||||||
store.** An `origin: local` issue lives here and nowhere else — this file *is*
|
|
||||||
the issue, and losing it loses the work. Anything with `origin: gitea` is a
|
|
||||||
**cache**: the tracker has it, this copy is a working copy, and it is deleted
|
|
||||||
the moment a push confirms the tracker is up to date.
|
|
||||||
|
|
||||||
One flat markdown file per issue, named by its slug, with one metadata field per
|
|
||||||
line so plain grep works without a parser.
|
|
||||||
|
|
||||||
- **The path is `<project root>/.tea/issues`, where the project root is the
|
|
||||||
nearest ancestor of the WORKING DIRECTORY holding a `.tea/` marker.**
|
|
||||||
`issue.project_root()` walks up from `$CLAUDE_PROJECT_DIR`, then cwd — the
|
|
||||||
same order and the same walk as the login pin, since both answer "which
|
|
||||||
project is this". Every script in both layers sees one store from anywhere
|
|
||||||
inside the project; a `cd` into a *different* project answers with that
|
|
||||||
project's store, which is the point. An explicit `--out` overrides it and is
|
|
||||||
used exactly as typed; a relative `--out` stays relative to cwd.
|
|
||||||
- **The marker is created by `issue_init.py`, never inferred.** An operator
|
|
||||||
states that a directory is a project; nothing guesses it. `.git` was tried as
|
|
||||||
the marker and is in every clone including this plugin's own — see below.
|
|
||||||
- **No marker anywhere is an answer, not a fallback.** `store_root()` returns
|
|
||||||
None and every entry point reports which directories it searched. A store
|
|
||||||
placed in a plausible-looking directory is the failure this replaces.
|
|
||||||
- **A linked worktree resolves to the main checkout.** The marker is gitignored,
|
|
||||||
so a worktree never has one; it is the same project on another branch, and it
|
|
||||||
reaches the store by the same hop the pin takes. Do not run `issue_init.py`
|
|
||||||
in a worktree — one project would get two stores, and the second disappears
|
|
||||||
with the branch.
|
|
||||||
- Nothing creates the store as a side effect of a write. Readers distinguish
|
|
||||||
"does not exist" from "is empty"; only `issue_new.py` and `pull.py` create it,
|
|
||||||
and they say so on stderr.
|
|
||||||
- Identity is the slug (`wire-sqlc-appclick.md`), never a tracker number.
|
|
||||||
Numbers live in the `gitea:` field.
|
|
||||||
- `origin: local` is a complete state, not a draft: an issue that never leaves
|
|
||||||
this machine is valid and finished. It is not a *durable* state, though —
|
|
||||||
pushing ends it, and the local file goes with it.
|
|
||||||
- **A successful push deletes the local file** (`<id>.md` and
|
|
||||||
`<id>.comments.md`), and prints the number and URL the issue now lives at.
|
|
||||||
`--update` too: one rule, no exception. What is in the store is what has not
|
|
||||||
left. Get it back with `pull.py <n>` — which brings its blockers back with it:
|
|
||||||
a pull returns the unit of work, not one row of it. `--no-deps` narrows it to
|
|
||||||
the one issue, and the cost of the default is in `pull.py`'s docstring.
|
|
||||||
- Deletion happens only after a confirmed tracker response and only after
|
|
||||||
`.remote.json` has been written. Network down, non-2xx, an answer that does
|
|
||||||
not carry the right number: the file stays and the run stops. A never-pushed
|
|
||||||
`origin: local` issue is never touched by any of this.
|
|
||||||
- The slug survives the round trip because it goes up in the body as
|
|
||||||
`<!-- tea:id … -->` (`map.with_id_marker`) and is indexed by number in
|
|
||||||
`.tea/issues/.remote.json`. A rename in the web UI, a lost `.remote.json`, a
|
|
||||||
fresh clone, another machine — the file comes back under the same name and
|
|
||||||
every `depends:` that points at it still resolves.
|
|
||||||
- `.remote.json` is therefore no longer "an index over the files": it is the
|
|
||||||
local number → slug ledger, its entries outlive the files they name, and
|
|
||||||
nothing prunes them. It is still recoverable — from the markers in Gitea, not
|
|
||||||
from the files. **Eviction does not prune it either**, for the same reason a
|
|
||||||
push does not: an evicted issue is in exactly the state a pushed one is.
|
|
||||||
- **A closed issue is evicted, not archived.** `issue_evict.py` removes
|
|
||||||
`<id>.md` and every sidecar under that slug for anything that is `state:
|
|
||||||
closed` **and** carries an `origin:` naming a tracker, then rebuilds
|
|
||||||
`INDEX.md`. `--dry-run` prints and writes nothing. **`origin: local` is never
|
|
||||||
evicted, in any state, not even when named on the command line** — that file
|
|
||||||
*is* the issue and nothing can fetch it back.
|
|
||||||
- **Eviction lives in the domain** (`skills/issue/scripts/issue_evict.py`),
|
|
||||||
because its two inputs — `state:` and `origin:` — are domain fields and the
|
|
||||||
answer is already on disk. No network, no login, no `tea`.
|
|
||||||
`skills/sync/scripts/evict.py` is the bridge form: it refreshes `state:` from
|
|
||||||
the tracker first (a local `state:` is only as fresh as the last pull) and then
|
|
||||||
calls `issue_evict.run`. One implementation of "what may be evicted", in the
|
|
||||||
layer that owns the fields it reads. Same gate as push, one step earlier: a
|
|
||||||
failed or unconfirmed tracker answer evicts nothing at all.
|
|
||||||
- **Pull by number fetches an issue in any state — a number is a number.** An
|
|
||||||
address is not a query: `pull.py 42` puts a closed issue on disk exactly as it
|
|
||||||
always has, and so does `#42`, `owner/repo#42`, or its URL. Only filter mode
|
|
||||||
(`--milestone`, `--label`, `-q`) leaves closed issues out. Eviction does not
|
|
||||||
revoke this: a closed issue pulled after a cleanup lands on disk again, and
|
|
||||||
that is the tracker answering what it was asked, not a regression. Evict it
|
|
||||||
again when you are done with it.
|
|
||||||
- Pulling overwrites the body — a fetch, not a merge. It is also how a pushed
|
|
||||||
issue comes back at all.
|
|
||||||
- No drift tracking, and now nothing to track: there is no second copy to
|
|
||||||
diverge from. `synced:` tells you how old your working copy is.
|
|
||||||
|
|
||||||
## Request payloads
|
|
||||||
|
|
||||||
`.tea/payload/` (gitignored) holds the JSON bodies `tea api -d @file` was given,
|
|
||||||
one file per named request, kept after the call for a retry or a post-mortem.
|
|
||||||
It is **not a store and holds nobody's only copy** — deleting it costs nothing.
|
|
||||||
|
|
||||||
- One directory for every caller, a sibling of the store under the same marker
|
|
||||||
and resolved by the same walk, so which command wrote a body does not change
|
|
||||||
where it landed — and the scratchpad and the store can never end up in two
|
|
||||||
different projects. `_gitea.api` takes no directory argument; that it once
|
|
||||||
did is exactly how a label bootstrap came to create the issue store.
|
|
||||||
- It is created lazily, by the first write of a run, and only then: a `--dry-run`
|
|
||||||
or a run with nothing to send leaves no directory behind.
|
|
||||||
- **A scratchpad may never sit inside a store.** Store contents are the thing
|
|
||||||
being tracked; request bodies are debris of the transport. When the two share
|
|
||||||
a path, an operation that touches no issue at all still materializes the issue
|
|
||||||
store, and the operator's `ls .tea/issues` starts lying about what exists.
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
# tea — Claude Code plugin for the Gitea CLI
|
|
||||||
|
|
||||||
A Claude Code plugin that gives Claude a reference for the `tea` CLI and enforces a hard rule: every `tea` command runs under the login **the operator chose**, never one Claude picked.
|
|
||||||
|
|
||||||
## What it ships
|
|
||||||
|
|
||||||
| Piece | What it does |
|
|
||||||
|---|---|
|
|
||||||
| `/tea:auth` skill | Prompts you to pick a Gitea login and pins it to the project |
|
|
||||||
| `/tea:issue` skill | Issues as units of work — create, read, grep, validate, walk the dependency graph. Entirely offline |
|
|
||||||
| `/tea:sync` skill | Moves issues between the local store and Gitea — pull, push, comment, close, evict |
|
|
||||||
| `/tea:use` skill | Tea CLI reference for everything that is not an issue — loads command docs on demand |
|
|
||||||
| `tea-runner` agent | Subagent on Haiku that runs the scripts and reports back a receipt — the mechanical half, off your main context |
|
|
||||||
| `tea-guard` hook | PreToolUse hook that blocks or rewrites every `tea` invocation |
|
|
||||||
|
|
||||||
## The layering
|
|
||||||
|
|
||||||
An issue is a unit of work first and a Gitea row second. That is two layers,
|
|
||||||
and knowledge flows one way:
|
|
||||||
|
|
||||||
```
|
|
||||||
skills/issue DOMAIN what an issue is: format, validation, dependency graph
|
|
||||||
▲ offline — no tracker, no network, stdlib only
|
|
||||||
│ imports
|
|
||||||
skills/sync BRIDGE md <-> Gitea issue JSON, then over the wire
|
|
||||||
▲
|
|
||||||
│ calls
|
|
||||||
tea-runner EXECUTION runs the scripts, reports a receipt — no opinions
|
|
||||||
```
|
|
||||||
|
|
||||||
Delete `skills/sync` and the issue domain keeps working. Work that lives only
|
|
||||||
on your machine is first-class, not a draft waiting to be uploaded. That is the
|
|
||||||
point of the split: you can plan, write, and validate without a tracker, and
|
|
||||||
publish only what you choose to.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- **Claude Code** — CLI, desktop app, or IDE extension
|
|
||||||
- **Python 3** — required by the `tea-guard` hook (`python3` must be on `$PATH`)
|
|
||||||
- **`tea`** — Gitea's official CLI. Install with `brew install tea` (macOS) or from [gitea.com/gitea/tea/releases](https://gitea.com/gitea/tea/releases)
|
|
||||||
- At least one login configured: `tea logins add` (interactive — run it in a terminal, not via Claude)
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
This is a Claude Code plugin — install it through the plugin marketplace, not by hand-editing `settings.json`.
|
|
||||||
|
|
||||||
1. Register the marketplace this plugin ships in:
|
|
||||||
|
|
||||||
```
|
|
||||||
/plugin marketplace add https://git.noodles.cam/claude-skills/marketplace.git
|
|
||||||
```
|
|
||||||
|
|
||||||
Already have a local clone? Point at the directory instead:
|
|
||||||
|
|
||||||
```
|
|
||||||
/plugin marketplace add /path/to/marketplace
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Install the plugin:
|
|
||||||
|
|
||||||
```
|
|
||||||
/plugin install tea@claude-skills
|
|
||||||
```
|
|
||||||
|
|
||||||
The skills (`/tea:auth`, `/tea:issue`, `/tea:sync`, `/tea:use`) and the `tea-guard` hook load immediately. Use `/plugin` to enable, disable, or update it later.
|
|
||||||
|
|
||||||
> The marketplace registration is written to `extraKnownMarketplaces` and the plugin to `enabledPlugins` in your settings automatically — you don't edit those by hand. There is **no** top-level `"plugins"` settings key; if you've added one from older instructions, remove it.
|
|
||||||
|
|
||||||
## First use
|
|
||||||
|
|
||||||
Run `/tea:auth` once per project. Claude will list your available Gitea logins and ask you to pick one. The choice is written to the project root's `.claude/settings.local.json` and takes effect immediately — no restart needed.
|
|
||||||
|
|
||||||
Once per *project*, not once per checkout: a `git worktree` shares its main checkout's pin. Both the hook and the scripts find it from inside a worktree, so don't run `/tea:auth` there — it would leave a second pin in a directory that disappears with the branch.
|
|
||||||
|
|
||||||
```
|
|
||||||
/tea:auth
|
|
||||||
```
|
|
||||||
|
|
||||||
After that, just ask Claude to do something with issues or Gitea — it loads the
|
|
||||||
right skill automatically. `/tea:auth` is only needed for the tracker side;
|
|
||||||
`/tea:issue` works without any login at all.
|
|
||||||
|
|
||||||
## How the login guard works
|
|
||||||
|
|
||||||
Every `tea` invocation Claude writes must carry the literal placeholder `--login "$GITEA_LOGIN"`. The `tea-guard` hook intercepts the Bash call before it runs, looks up the pinned login from `.claude/settings.local.json`, and rewrites the command to use it. The hook and the scripts look it up the same way — one search order, in `skills/auth/scripts/pin.py`.
|
|
||||||
|
|
||||||
Claude is **blocked** from:
|
|
||||||
- running `tea` without `--login` at all
|
|
||||||
- naming a login itself (e.g. `--login myaccount`)
|
|
||||||
- using any variable other than `$GITEA_LOGIN`
|
|
||||||
|
|
||||||
This prevents silent fallback to the machine's default login (often a personal account) when working in a project that belongs to a different identity.
|
|
||||||
|
|
||||||
`tea logins list` and `tea --version / --help` are exempt — they don't touch Gitea data.
|
|
||||||
|
|
||||||
## The tea-runner agent
|
|
||||||
|
|
||||||
The skills carry meaning; the scripts carry work. `tea-runner` is a subagent on
|
|
||||||
Haiku that does the second half in its own context and hands back a receipt —
|
|
||||||
what ran, what it touched, what failed, verbatim.
|
|
||||||
|
|
||||||
Delegate a **batch**: pull a milestone and rebuild the index, push the three
|
|
||||||
issues you just wrote, bootstrap the label set, post a comment from a file you
|
|
||||||
prepared. Spawning it for a single `pull.py 42` costs more than running the
|
|
||||||
command yourself; the saving is in the loop, the retry, and reading somebody
|
|
||||||
else's stderr.
|
|
||||||
|
|
||||||
It cannot decide anything. No `Edit`, no `Write`, no `--force`, no closing or
|
|
||||||
retitling, no raw `tea`, no pushing beyond the set it was handed. A missing
|
|
||||||
type, a failed validation, an unpushed dependency come back as a question, not
|
|
||||||
as a guess. The `tea-guard` hook applies to it exactly as it does to the main
|
|
||||||
session — the pinned login is enforced on every call it makes.
|
|
||||||
|
|
||||||
## Project layout
|
|
||||||
|
|
||||||
```
|
|
||||||
.claude-plugin/
|
|
||||||
plugin.json plugin manifest
|
|
||||||
(the marketplace catalog lives one level up, in
|
|
||||||
the repo root's .claude-plugin/marketplace.json)
|
|
||||||
agents/
|
|
||||||
tea-runner.md subagent (Haiku) that executes the scripts
|
|
||||||
hooks/
|
|
||||||
hooks.json registers the PreToolUse hooks
|
|
||||||
tea-guard.sh the guard (Python 3, no deps)
|
|
||||||
agents-sync.sh keeps AGENTS.md real and CLAUDE.md a symlink to it
|
|
||||||
skills/
|
|
||||||
auth/ /tea:auth — the identity layer
|
|
||||||
SKILL.md
|
|
||||||
scripts/pin.py where the login pin is and how it is found —
|
|
||||||
imported by _gitea.py AND by tea-guard.sh
|
|
||||||
issue/ /tea:issue — the issue domain, offline
|
|
||||||
SKILL.md
|
|
||||||
references/format.md canonical issue format (identity, types, templates)
|
|
||||||
scripts/ Python 3, stdlib only, no network:
|
|
||||||
issue.py domain module: slug identity, parse/render,
|
|
||||||
validation, taxonomy, dependency graph,
|
|
||||||
body checkboxes
|
|
||||||
issue_new.py create a local issue from its type template
|
|
||||||
issue_check.py validate against the format
|
|
||||||
issue_ac.py list the body's checkboxes; tick one
|
|
||||||
issue_tree.py draw the dependency graph
|
|
||||||
issue_evict.py drop closed issues the tracker also has
|
|
||||||
issue_index.py rebuild .tea/issues/INDEX.md
|
|
||||||
issue_init.py create the .tea/ marker that makes a
|
|
||||||
directory a project; migrates tmp/issues in
|
|
||||||
sync/ /tea:sync — the bridge to Gitea
|
|
||||||
SKILL.md
|
|
||||||
scripts/
|
|
||||||
map.py md <-> Gitea JSON, pure functions, no I/O
|
|
||||||
_gitea.py transport: login pin, tea api, pagination, filters
|
|
||||||
pull.py Gitea -> .tea/issues/
|
|
||||||
push.py .tea/issues/ -> Gitea, then drops the local file
|
|
||||||
remote.py discovery listing to stdout
|
|
||||||
comment.py post or edit a comment
|
|
||||||
close.py the state field, both ways
|
|
||||||
evict.py refresh state: from Gitea, then evict
|
|
||||||
labels.py put the canonical label set into a repository
|
|
||||||
use/ /tea:use — tea CLI reference (non-issue entities)
|
|
||||||
SKILL.md
|
|
||||||
references/tea/ command docs
|
|
||||||
```
|
|
||||||
|
|
||||||
`AGENTS.md` carries the same layout with the reasoning behind it; if the two
|
|
||||||
ever disagree, `AGENTS.md` is the one being worked from.
|
|
||||||
|
|
||||||
## Local issue store
|
|
||||||
|
|
||||||
Issues live in `.tea/issues/` as flat markdown with one metadata field per line
|
|
||||||
— so `grep -l 'labels:.*type/bug' .tea/issues/*.md` works without a parser.
|
|
||||||
|
|
||||||
**Run `issue_init.py` once per project.** It creates the `.tea/` marker, which
|
|
||||||
is what every script resolves the store from: they walk up from the working
|
|
||||||
directory to the nearest one. The marker is never inferred from the tree, and
|
|
||||||
with none anywhere the commands stop and name the directories they searched
|
|
||||||
rather than picking a plausible one.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 <plugin>/skills/issue/scripts/issue_init.py
|
|
||||||
```
|
|
||||||
|
|
||||||
It is idempotent, adds `.tea/` to `.gitignore`, and moves an older
|
|
||||||
`tmp/issues` store in if it finds one. Don't run it inside a git worktree: the
|
|
||||||
marker is gitignored, so a worktree has none by design and reaches the main
|
|
||||||
checkout's store on its own — exactly like the login pin.
|
|
||||||
|
|
||||||
An `origin: local` file **is** the issue — the store, and the only copy.
|
|
||||||
Anything with `origin: gitea` is a working copy of something the tracker
|
|
||||||
already has, and it is deleted as soon as a push confirms the tracker is up to
|
|
||||||
date:
|
|
||||||
|
|
||||||
- Identity is a slug (`wire-sqlc-appclick.md`), never a tracker number. Numbers
|
|
||||||
live in a `gitea:` field.
|
|
||||||
- `origin: local` is a complete state. An issue that never leaves your machine
|
|
||||||
is valid and finished — but it is not permanent: pushing ends it.
|
|
||||||
- **A successful push deletes the local file** (`--update` too) and prints the
|
|
||||||
number and URL it now lives at. Only after a confirmed response: a failed
|
|
||||||
call leaves the file exactly where it was. Get it back with `pull.py <n>` —
|
|
||||||
same slug, same `depends:`, even after a rename in Gitea.
|
|
||||||
- Pulling overwrites the body: a fetch, not a merge. It is also how a pushed
|
|
||||||
issue comes back.
|
|
||||||
- Nothing tracks drift, and there is no second copy to drift. A file that is
|
|
||||||
still here has not been pushed.
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
---
|
|
||||||
name: tea-runner
|
|
||||||
description: Executes the tea plugin's scripts and reports back a compact receipt. Use for the mechanical half of tracker work — bulk pulls, pushing issues the caller already named, posting a comment from a file, bootstrapping labels, rebuilding the index or the tree. It runs commands; it never decides what an issue should say. Delegate a batch, not a single call.
|
|
||||||
tools: Bash, Read, Grep, Glob, Skill
|
|
||||||
model: haiku
|
|
||||||
---
|
|
||||||
|
|
||||||
# tea-runner — the execution layer
|
|
||||||
|
|
||||||
You run this project's issue scripts and hand back a short receipt. You are the
|
|
||||||
fourth layer of the plugin, below the three that carry meaning:
|
|
||||||
|
|
||||||
```
|
|
||||||
skills/issue DOMAIN what an issue is
|
|
||||||
skills/sync BRIDGE md <-> Gitea, over the wire
|
|
||||||
skills/use REFERENCE tea CLI docs
|
|
||||||
▲
|
|
||||||
│ calls
|
|
||||||
tea-runner EXECUTION runs the scripts, reports the result
|
|
||||||
```
|
|
||||||
|
|
||||||
Knowledge still flows one way. You call those layers; nothing in them knows you
|
|
||||||
exist. **You hold no opinion about content.** Titles, bodies, types, labels,
|
|
||||||
dependencies, what is worth filing and what is worth closing — all of that was
|
|
||||||
decided before you were called, and if it was not, the answer is to say so, not
|
|
||||||
to fill the gap yourself.
|
|
||||||
|
|
||||||
## Where the commands come from
|
|
||||||
|
|
||||||
Load the skill, do not remember the flags:
|
|
||||||
|
|
||||||
- `/tea:sync` — `pull.py`, `push.py`, `comment.py`, `close.py`, `remote.py`,
|
|
||||||
`labels.py`, `evict.py`
|
|
||||||
- `/tea:issue` — `issue_check.py`, `issue_tree.py`, `issue_index.py`,
|
|
||||||
`issue_new.py`, `issue_ac.py`, `issue_evict.py`
|
|
||||||
|
|
||||||
Invoke `Skill` with the one that owns the task at the start, and use the command
|
|
||||||
table it gives you verbatim. The skill is the single source of
|
|
||||||
truth for the script surface; a flag you recall from another session is a
|
|
||||||
guess. If the skill does not document a flag, it does not exist — report that
|
|
||||||
instead of trying it.
|
|
||||||
|
|
||||||
## Hard rules
|
|
||||||
|
|
||||||
1. **No raw `tea`.** Every tracker call goes through a script in
|
|
||||||
`skills/sync/scripts/`. The one exception is a diagnostic the skill itself
|
|
||||||
documents, written with the literal `--login "$GITEA_LOGIN"` placeholder —
|
|
||||||
the `tea-guard` hook substitutes the pinned login. Never name a login.
|
|
||||||
2. **No writing to issue files.** You have no `Edit` and no `Write`. Scripts
|
|
||||||
write files; you do not. If a task needs a body edited or a metadata field
|
|
||||||
changed by hand, stop and say which file and which field. `issue_ac.py` is
|
|
||||||
the one script that touches a body, and it changes a single character: tick
|
|
||||||
only the items the caller named, by the number or the substring the caller
|
|
||||||
gave. Whether a criterion is actually met is a judgement about content, and
|
|
||||||
content is never yours.
|
|
||||||
3. **Push only what you were told to push.** `push.py` publishes to a tracker
|
|
||||||
other people read, **and it deletes the local file on success** — so a
|
|
||||||
widened set is not an over-share, it is somebody else's working copy gone.
|
|
||||||
Run it with the ids, titles, or filter the caller named. Never widen the
|
|
||||||
set, never run a bare `push.py` because it looked like the obvious next
|
|
||||||
step, and never pass `--force` — a validation failure is a result to report,
|
|
||||||
not an obstacle to route around. Report the number and URL `push.py`
|
|
||||||
printed; that is now the only address the issue has.
|
|
||||||
4. **Close only the ids the caller named.** Closing is a script now
|
|
||||||
(`close.py`), so it is yours to run — under the same discipline as push: the
|
|
||||||
ids the caller named, and no others. Never widen the set, never infer that
|
|
||||||
an issue is finished because its checkboxes are ticked or its branch is
|
|
||||||
merged; whether work is done is a judgement about content, and content is
|
|
||||||
never yours. `--reopen` is the same rule backwards. **Retitling stays
|
|
||||||
forbidden**, and deleting anything on a tracker is never yours either.
|
|
||||||
|
|
||||||
Two local deletions are allowed, both only when the caller asked for them:
|
|
||||||
push's own, on the issue you were told to push, and eviction
|
|
||||||
(`issue_evict.py` / `evict.py`) of closed issues. Run eviction with
|
|
||||||
`--dry-run` first and report what it named; never widen the set past what
|
|
||||||
the caller said. It refuses to touch an `origin: local` issue by itself —
|
|
||||||
that is the script's guarantee, not your judgement, and it is not a reason
|
|
||||||
to point it at a store nobody asked you to clean.
|
|
||||||
5. **One retry, maximum.** A command that fails twice is a finding. Do not
|
|
||||||
permute flags looking for one that works.
|
|
||||||
6. **No payload dumps.** Never run `tea issues -o json`, never `cat` a pulled
|
|
||||||
issue body back into your report. The scripts print compact output by
|
|
||||||
design; the caller reads the files it needs from disk.
|
|
||||||
|
|
||||||
## Procedure
|
|
||||||
|
|
||||||
1. Load the skill you need.
|
|
||||||
2. Run the commands. Prefer one filtered call over a loop —
|
|
||||||
`pull.py --milestone 6` is one request per 50 issues, `pull.py 41 42 43…`
|
|
||||||
is one per issue.
|
|
||||||
3. If a command exits non-zero, capture the last lines of stderr and stop that
|
|
||||||
branch. Keep going on independent branches.
|
|
||||||
4. Report.
|
|
||||||
|
|
||||||
## Report format
|
|
||||||
|
|
||||||
Your final message is the return value. Keep it under ~20 lines. No preamble,
|
|
||||||
no restatement of the request, no advice about what to do next.
|
|
||||||
|
|
||||||
```
|
|
||||||
ran:
|
|
||||||
pull.py --milestone 6 --state all ok 7 issues, 3 threads
|
|
||||||
issue_index.py ok INDEX.md rebuilt
|
|
||||||
push.py wire-sqlc-appclick FAIL exit 1
|
|
||||||
|
|
||||||
touched: .tea/issues/{a,b,c}.md, .tea/issues/INDEX.md
|
|
||||||
|
|
||||||
failed: push.py wire-sqlc-appclick
|
|
||||||
ERROR wire-sqlc-appclick: missing section '## Acceptance criteria'
|
|
||||||
|
|
||||||
blocked: none
|
|
||||||
```
|
|
||||||
|
|
||||||
- `ran` — one line per command: what, ok/FAIL, and the one number that matters.
|
|
||||||
- `touched` — paths only. Never contents.
|
|
||||||
- `failed` — the command, then stderr verbatim, trimmed to the lines that name
|
|
||||||
the cause. Quote it exactly; do not paraphrase an error.
|
|
||||||
- `blocked` — what you refused to decide, phrased as the question the caller
|
|
||||||
has to answer. `none` when there is nothing.
|
|
||||||
|
|
||||||
## Known stops
|
|
||||||
|
|
||||||
Report these and halt; none of them is yours to resolve.
|
|
||||||
|
|
||||||
| Condition | Report |
|
|
||||||
|---|---|
|
|
||||||
| no login pinned (`tea-guard` blocks, or a script points at `/tea:auth`) | `blocked: no pinned login — operator must run /tea:auth` |
|
|
||||||
| `issue_check.py` errors before a push | the validator's own lines, verbatim |
|
|
||||||
| a dependency is still `origin: local` | name the id; the caller decides whether to push it |
|
|
||||||
| a milestone or label does not exist in the repo | the script prints the real ones — pass that list through |
|
|
||||||
| a script asks for a decision (type, label, `--force`) | `blocked:` with the question |
|
|
||||||
| `close.py` is refused by Gitea because the issue is still blocked | the tracker's own line, and the blocker's number; the caller decides |
|
|
||||||
@@ -1,280 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
tea-guard — PreToolUse(Bash) hook for the `tea` plugin.
|
|
||||||
|
|
||||||
Enforces, deterministically, the one rule prose cannot: every `tea` command
|
|
||||||
that touches Gitea runs under the login the OPERATOR pinned — never one Claude
|
|
||||||
chose. It does this by *resolving and rewriting* the command rather than just
|
|
||||||
checking it:
|
|
||||||
|
|
||||||
Claude must write: tea ... --login "$GITEA_LOGIN" ...
|
|
||||||
The guard rewrites: tea ... --login <operator-pinned-login> ...
|
|
||||||
|
|
||||||
The pin is read from .claude/settings.local.json (env.GITEA_LOGIN) at call
|
|
||||||
time — from the FILE, not the environment — so a freshly pinned login works in
|
|
||||||
the same session with no restart. WHERE that file is looked for is not decided
|
|
||||||
here: skills/auth/scripts/pin.py holds the search order, and the sync
|
|
||||||
scripts resolve the pin through the same module. One order, one copy of it. The
|
|
||||||
guard and the scripts disagreeing about a directory is a bug by construction,
|
|
||||||
and was one: in a git worktree `tea` worked and every script said "no login
|
|
||||||
pinned".
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
- not a `tea` command ............................. allow (passthrough)
|
|
||||||
- tea logins list/ls, tea --version/--help ........ allow (no identity used)
|
|
||||||
- no --login / -l ................................. BLOCK
|
|
||||||
- --login <literal> or --login "$OTHER_VAR" ....... BLOCK (Claude may not pick)
|
|
||||||
- --login "$GITEA_LOGIN", pin found ............... REWRITE to the pin, allow
|
|
||||||
- --login "$GITEA_LOGIN", no pin .................. BLOCK (run /tea:auth)
|
|
||||||
|
|
||||||
"A `tea` command" means the shell would RUN `tea`, not that the string contains
|
|
||||||
the word. The guard used to ask the second question — a substring search over
|
|
||||||
the whole command line — and in a repository whose subject *is* the CLI that is
|
|
||||||
a different question with the same answer far too often: an issue title, a
|
|
||||||
commit message, `grep -rn " tea " docs/` and `echo tea` were all blocked, with
|
|
||||||
a message telling the operator to add `--login` to `git commit`. Worse, the
|
|
||||||
advice was unfollowable: the only way past the guard was to reword the prose.
|
|
||||||
|
|
||||||
So the command is tokenized (heredoc bodies dropped, line continuations
|
|
||||||
folded, backticks and newlines treated as boundaries) and only words in
|
|
||||||
*command position* count — the first word, and the first word after `;`, `&&`,
|
|
||||||
`||`, `|`, `&`, `(`, `)`, `{`, `}`, past any VAR=value assignments and prefix
|
|
||||||
words like `env`/`sudo`/`xargs`. Quoting is what saves the prose: a title or a
|
|
||||||
`-m` message is one token, and one token is never a command. Compound commands
|
|
||||||
stay guarded segment by segment, substitutions included, and every `tea` in the
|
|
||||||
line is checked — not just the first.
|
|
||||||
|
|
||||||
If the line cannot be tokenized at all (unbalanced quotes), the old substring
|
|
||||||
test decides. That direction fails closed: it over-matches, and over-matching
|
|
||||||
blocks.
|
|
||||||
|
|
||||||
Output protocol: exit 0 + JSON {hookSpecificOutput:{updatedInput,...}} to
|
|
||||||
rewrite; exit 2 + stderr to block.
|
|
||||||
"""
|
|
||||||
import sys, os, re, json, shlex
|
|
||||||
|
|
||||||
# The identity layer, reached by the plugin's own layout — the one thing a hook
|
|
||||||
# may assume about where it lives. Import failure is not fatal on its own: a
|
|
||||||
# command that is not `tea` still passes through untouched (see main), and only
|
|
||||||
# a command that needs a login is blocked.
|
|
||||||
sys.path.append(os.path.abspath(os.path.join(
|
|
||||||
os.path.dirname(os.path.abspath(__file__)),
|
|
||||||
os.pardir, "skills", "auth", "scripts")))
|
|
||||||
try:
|
|
||||||
import pin
|
|
||||||
except Exception:
|
|
||||||
pin = None
|
|
||||||
|
|
||||||
PLACEHOLDERS = {"$GITEA_LOGIN", "${GITEA_LOGIN}"}
|
|
||||||
|
|
||||||
# Operators after which the next word is a command again.
|
|
||||||
SEPARATORS = {";", ";;", "&", "&&", "|", "|&", "||", "(", ")", "{", "}"}
|
|
||||||
# Words that stand in front of a command without being one.
|
|
||||||
TRANSPARENT = {"env", "command", "exec", "nohup", "time", "sudo", "xargs",
|
|
||||||
"if", "then", "else", "elif", "while", "until", "do", "!"}
|
|
||||||
|
|
||||||
ASSIGNMENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
|
|
||||||
REDIRECT = re.compile(r"^\d*[<>]+&?\d*-?$")
|
|
||||||
HEREDOC = re.compile(r"<<-?\s*(['\"]?)([A-Za-z_][A-Za-z0-9_]*)\1")
|
|
||||||
# A login flag and its value, in the ORIGINAL text — this is what gets
|
|
||||||
# rewritten, so it works on the raw string rather than on tokens.
|
|
||||||
LOGIN_FLAG = re.compile(r"(--login|(?<![\w-])-l)(\s+|=)(\S+)")
|
|
||||||
# The pre-tokenizer test, kept for the one case tokenizing cannot serve.
|
|
||||||
LOOKS_LIKE_TEA = re.compile(r"(^|[;&|(]|\s)tea(\s|$)")
|
|
||||||
|
|
||||||
NO_LOGIN = ('every `tea` command must include --login "$GITEA_LOGIN" '
|
|
||||||
'(the guard substitutes the operator-pinned login). '
|
|
||||||
'Run /tea:auth if no login is pinned.')
|
|
||||||
|
|
||||||
|
|
||||||
def named_login(raw):
|
|
||||||
return ('do not name the login yourself (got `%s`). Write exactly '
|
|
||||||
'--login "$GITEA_LOGIN"; the guard replaces it with the login '
|
|
||||||
'the operator pinned via /tea:auth. This prevents acting under '
|
|
||||||
'the wrong identity.' % raw)
|
|
||||||
|
|
||||||
|
|
||||||
def unquote(value):
|
|
||||||
for q in ('"', "'"):
|
|
||||||
if len(value) >= 2 and value[0] == q and value[-1] == q:
|
|
||||||
return value[1:-1]
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def strip_heredocs(cmd):
|
|
||||||
"""Drop heredoc bodies. They are data the shell feeds to a command, not
|
|
||||||
commands — and a commit message quoting a raw `tea api` call is exactly the
|
|
||||||
thing that used to be unwritable."""
|
|
||||||
lines, kept, i = cmd.split("\n"), [], 0
|
|
||||||
while i < len(lines):
|
|
||||||
line = lines[i]
|
|
||||||
kept.append(line)
|
|
||||||
i += 1
|
|
||||||
for m in HEREDOC.finditer(line):
|
|
||||||
delim, dash = m.group(2), m.group(0).startswith("<<-")
|
|
||||||
while i < len(lines):
|
|
||||||
probe = lines[i].strip() if dash else lines[i].rstrip()
|
|
||||||
i += 1
|
|
||||||
if probe == delim:
|
|
||||||
break
|
|
||||||
return "\n".join(kept)
|
|
||||||
|
|
||||||
|
|
||||||
def shell_words(cmd):
|
|
||||||
"""Tokens, with operators as tokens of their own and quotes honored.
|
|
||||||
|
|
||||||
Backticks and newlines become separators before tokenizing: shlex knows
|
|
||||||
neither, and both start a command. Inside quotes that substitution is
|
|
||||||
harmless — the token still spans the quotes, and a token is never a
|
|
||||||
command."""
|
|
||||||
text = strip_heredocs(cmd)
|
|
||||||
text = re.sub(r"\\\n", " ", text)
|
|
||||||
text = text.replace("`", " ; ").replace("\n", " ; ")
|
|
||||||
lex = shlex.shlex(text, posix=True, punctuation_chars=True)
|
|
||||||
lex.whitespace_split = True
|
|
||||||
return list(lex)
|
|
||||||
|
|
||||||
|
|
||||||
def tea_invocations(words):
|
|
||||||
"""The argument list of every `tea` the shell would actually run."""
|
|
||||||
found, current, expect, skip = [], None, True, False
|
|
||||||
for w in words:
|
|
||||||
if skip:
|
|
||||||
skip = False
|
|
||||||
continue
|
|
||||||
if REDIRECT.match(w):
|
|
||||||
skip = True # the target of a redirection is not a command
|
|
||||||
continue
|
|
||||||
if w in SEPARATORS:
|
|
||||||
current, expect = None, True
|
|
||||||
continue
|
|
||||||
if expect:
|
|
||||||
if ASSIGNMENT.match(w) or w in TRANSPARENT:
|
|
||||||
continue
|
|
||||||
expect = False
|
|
||||||
if w.rsplit("/", 1)[-1] == "tea":
|
|
||||||
current = []
|
|
||||||
found.append(current)
|
|
||||||
continue
|
|
||||||
if current is not None:
|
|
||||||
current.append(w)
|
|
||||||
return found
|
|
||||||
|
|
||||||
|
|
||||||
def is_meta(args):
|
|
||||||
"""Login enumeration and `--version`/`--help`: no identity is used, and
|
|
||||||
/tea:auth needs `tea logins list` while no pin exists yet."""
|
|
||||||
if not args:
|
|
||||||
return False
|
|
||||||
if args[0] in ("--version", "-v", "--help", "-h", "help"):
|
|
||||||
return True
|
|
||||||
return args[0] in ("logins", "login") and len(args) > 1 \
|
|
||||||
and args[1] in ("list", "ls")
|
|
||||||
|
|
||||||
|
|
||||||
def login_value(args):
|
|
||||||
"""The login as written, or None if the flag is absent."""
|
|
||||||
for i, a in enumerate(args):
|
|
||||||
if a in ("--login", "-l"):
|
|
||||||
return args[i + 1] if i + 1 < len(args) else ""
|
|
||||||
if a.startswith("--login=") or a.startswith("-l="):
|
|
||||||
return a.split("=", 1)[1]
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def substitute(cmd, login):
|
|
||||||
"""Every placeholder login in the line, replaced by the pin. Every one:
|
|
||||||
a command may run `tea` twice, and half a rewrite leaves the second call
|
|
||||||
with an unset variable and no login at all."""
|
|
||||||
def repl(m):
|
|
||||||
if unquote(m.group(3)) in PLACEHOLDERS:
|
|
||||||
return m.group(1) + m.group(2) + shlex.quote(login)
|
|
||||||
return m.group(0)
|
|
||||||
return LOGIN_FLAG.sub(repl, cmd)
|
|
||||||
|
|
||||||
|
|
||||||
def block(msg):
|
|
||||||
sys.stderr.write("tea-guard: BLOCKED — " + msg + "\n")
|
|
||||||
sys.exit(2)
|
|
||||||
|
|
||||||
|
|
||||||
def allow_passthrough():
|
|
||||||
# exit 0 with no stdout → tool runs unchanged
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
|
|
||||||
def rewrite(tool_input, new_cmd, note):
|
|
||||||
updated = dict(tool_input)
|
|
||||||
updated["command"] = new_cmd
|
|
||||||
print(json.dumps({
|
|
||||||
"hookSpecificOutput": {
|
|
||||||
"hookEventName": "PreToolUse",
|
|
||||||
"updatedInput": updated,
|
|
||||||
"additionalContext": note,
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
try:
|
|
||||||
payload = json.load(sys.stdin)
|
|
||||||
except Exception:
|
|
||||||
# Can't parse the hook payload — fail open for non-tea safety, but we
|
|
||||||
# can't even read the command, so don't block arbitrary Bash.
|
|
||||||
allow_passthrough()
|
|
||||||
|
|
||||||
tool_input = payload.get("tool_input") or {}
|
|
||||||
cmd = tool_input.get("command") or ""
|
|
||||||
|
|
||||||
try:
|
|
||||||
runs = tea_invocations(shell_words(cmd))
|
|
||||||
except ValueError:
|
|
||||||
# Unbalanced quotes: what the shell would run is not knowable here.
|
|
||||||
# Fall back to the substring test — it over-matches, and over-matching
|
|
||||||
# blocks rather than lets an unpinned call through.
|
|
||||||
runs = None
|
|
||||||
|
|
||||||
if runs is None:
|
|
||||||
if not LOOKS_LIKE_TEA.search(cmd):
|
|
||||||
allow_passthrough()
|
|
||||||
m = LOGIN_FLAG.search(cmd)
|
|
||||||
if not m:
|
|
||||||
block(NO_LOGIN)
|
|
||||||
if unquote(m.group(3)) not in PLACEHOLDERS:
|
|
||||||
block(named_login(m.group(3)))
|
|
||||||
else:
|
|
||||||
# The word appears but nothing runs it → not our concern. This is the
|
|
||||||
# branch that lets prose about the CLI be written at all.
|
|
||||||
if not runs:
|
|
||||||
allow_passthrough()
|
|
||||||
for args in runs:
|
|
||||||
if is_meta(args):
|
|
||||||
continue
|
|
||||||
raw = login_value(args)
|
|
||||||
if raw is None:
|
|
||||||
block(NO_LOGIN)
|
|
||||||
if unquote(raw) not in PLACEHOLDERS:
|
|
||||||
block(named_login(raw))
|
|
||||||
if all(is_meta(args) for args in runs):
|
|
||||||
allow_passthrough()
|
|
||||||
|
|
||||||
if pin is None:
|
|
||||||
block('cannot import skills/auth/scripts/pin.py, so the pinned login '
|
|
||||||
'cannot be resolved. The plugin tree is incomplete; reinstall it.')
|
|
||||||
|
|
||||||
# The hint is the directory the Bash command will run in; the rest of the
|
|
||||||
# order (CLAUDE_PROJECT_DIR first, cwd last, and the worktree branch of the
|
|
||||||
# search) is pin.py's, and is the same order the scripts get.
|
|
||||||
login, src = pin.find_pin(payload.get("cwd"))
|
|
||||||
if not login:
|
|
||||||
block('no login is pinned. Run /tea:auth to choose one (writes '
|
|
||||||
'.claude/settings.local.json env.GITEA_LOGIN). The guard reads '
|
|
||||||
'the file at call time, so it takes effect with no restart.')
|
|
||||||
|
|
||||||
rewrite(tool_input, substitute(cmd, login),
|
|
||||||
'tea-guard: resolved --login -> %s (pinned in %s)' % (login, src))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
---
|
|
||||||
name: auth
|
|
||||||
description: Pin the Gitea login used by the tea CLI in this project. Run when the tea-guard hook reports no login is pinned, or when the user types /tea:auth. Enumerates available logins, makes the OPERATOR pick one, and persists it to .claude/settings.local.json. The pin takes effect immediately — no restart.
|
|
||||||
---
|
|
||||||
|
|
||||||
# /tea:auth — pin the project Gitea login
|
|
||||||
|
|
||||||
Goal: have the **operator** select exactly one `tea` login for this project and
|
|
||||||
persist it to `.claude/settings.local.json` under `env.GITEA_LOGIN`. The
|
|
||||||
`tea-guard` hook reads this file at call time and rewrites every
|
|
||||||
`--login "$GITEA_LOGIN"` to the pinned value, so the choice takes effect
|
|
||||||
**immediately, with no session restart**.
|
|
||||||
|
|
||||||
## The one hard rule: the operator chooses, never you
|
|
||||||
|
|
||||||
Picking the wrong identity is the exact failure this command exists to prevent.
|
|
||||||
So:
|
|
||||||
|
|
||||||
- **ALWAYS** present the choice with `AskUserQuestion` and let the operator
|
|
||||||
pick — even if memory, context, the repo URL, or a previous session suggests
|
|
||||||
a "likely" login. Do **not** auto-select from memory or infer it. A wrong
|
|
||||||
guess writes under the wrong account.
|
|
||||||
- The only exception: exactly **one** login exists on the machine — then
|
|
||||||
propose it and still confirm before writing.
|
|
||||||
|
|
||||||
## Steps
|
|
||||||
|
|
||||||
1. Enumerate logins (allowed by the guard even with no pin):
|
|
||||||
`tea logins list -o json`
|
|
||||||
2. **No logins:** stop and ask the operator to run `tea logins add` themselves
|
|
||||||
— it is interactive (prompts for URL/token). Do not run it for them.
|
|
||||||
3. **One login:** propose it; confirm before writing.
|
|
||||||
4. **Several logins:** `AskUserQuestion` with each login's `name`, `user`, and
|
|
||||||
`url` so the operator's choice is unambiguous. Never decide for them.
|
|
||||||
5. Merge the chosen name into the **project root's**
|
|
||||||
`.claude/settings.local.json` under `env` (do not clobber other keys):
|
|
||||||
```json
|
|
||||||
{ "env": { "GITEA_LOGIN": "<chosen-name>" } }
|
|
||||||
```
|
|
||||||
**In a git worktree, write it to the main checkout, never to the worktree.**
|
|
||||||
A worktree is deleted when the branch is done, taking a pin written into it
|
|
||||||
with it, and one repository with two pins is one repository with two
|
|
||||||
identities. Both the guard and the scripts already reach the main checkout's
|
|
||||||
pin from inside any worktree — so there is nothing to pin a second time.
|
|
||||||
`git rev-parse --path-format=absolute --git-common-dir` names the `.git` to
|
|
||||||
write beside.
|
|
||||||
6. Done — it is live. The guard resolves the pin from the file on the next
|
|
||||||
`tea` call; no restart needed. Tell the operator which login is now pinned,
|
|
||||||
and which file it went in.
|
|
||||||
|
|
||||||
## Where the pin is looked for
|
|
||||||
|
|
||||||
One search order, written once in `scripts/pin.py` and imported by both the
|
|
||||||
`tea-guard` hook and the sync transport — they cannot disagree about a
|
|
||||||
directory, and a test asserts neither keeps a copy of the walk.
|
|
||||||
|
|
||||||
`$CLAUDE_PROJECT_DIR`, then the caller's hint (the hook passes the Bash call's
|
|
||||||
`cwd`), then the current directory. Each is searched up its parent chain; only
|
|
||||||
if that finds nothing does the search cross into the main working tree of a
|
|
||||||
linked worktree, via `gitdir:` in the `.git` file. The plugin's own directory
|
|
||||||
is never a source — a plugin pointed at somebody else's project must take the
|
|
||||||
identity from that project, not from where it happens to be installed.
|
|
||||||
|
|
||||||
If a script reports "no login pinned", that is the honest answer: nothing was
|
|
||||||
found anywhere on that order. Pin one — at the project root.
|
|
||||||
|
|
||||||
## Identity-safety rules
|
|
||||||
|
|
||||||
- NEVER run commands that mutate logins or global login state:
|
|
||||||
`tea logins add/edit/delete/default`, `tea logout`. Read-only
|
|
||||||
`tea logins list` is the only allowed login command.
|
|
||||||
- If a `tea` call fails with a permission/scope error, report it. Do NOT try to
|
|
||||||
fix it by switching to, or editing, a different login.
|
|
||||||
- If you ever see `no gitea login detected, falling back to login '...'`, treat
|
|
||||||
it as a hard failure: stop, do not act on the result, surface it.
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
pin.py — where the operator's Gitea login pin is, and how it is found.
|
|
||||||
|
|
||||||
**The search order lives here and nowhere else.** The `tea-guard` hook imports
|
|
||||||
this module; so does the transport every sync script runs on. Two
|
|
||||||
copies of the order is exactly how a git worktree came to have a working hook
|
|
||||||
and a dead transport in the same directory: `tea` resolved the login, the
|
|
||||||
scripts said "no login pinned", and the error told the operator to pin what was
|
|
||||||
already pinned.
|
|
||||||
|
|
||||||
Not a command — a lookup. Stdlib only, no subprocess, no network: a PreToolUse
|
|
||||||
hook runs before every Bash call and must not fork a process to answer this.
|
|
||||||
|
|
||||||
The pin is a file the OPERATOR owns and `/tea:auth` writes:
|
|
||||||
|
|
||||||
<project root>/.claude/settings.local.json -> env.GITEA_LOGIN
|
|
||||||
|
|
||||||
## Search order
|
|
||||||
|
|
||||||
Start directories, in order, first hit wins:
|
|
||||||
|
|
||||||
1. $CLAUDE_PROJECT_DIR the project Claude Code was started on, when set
|
|
||||||
2. an explicit hint the hook passes the Bash tool's cwd; scripts pass
|
|
||||||
nothing and go straight to 3
|
|
||||||
3. the current directory
|
|
||||||
|
|
||||||
Each start directory is searched the same way:
|
|
||||||
|
|
||||||
a. up the parent chain, from the directory itself to the filesystem root
|
|
||||||
b. then, for each LINKED WORKTREE seen on that chain, up the parent chain
|
|
||||||
of that repository's main working tree
|
|
||||||
|
|
||||||
(b) is the whole point of this module. A worktree is a *sibling* of the main
|
|
||||||
checkout, not a descendant, so `.claude/settings.local.json` — untracked, and
|
|
||||||
therefore only ever in the main checkout — is not on the parent chain of (a).
|
|
||||||
Git knows the two trees are one repository: a worktree's `.git` is a FILE
|
|
||||||
holding `gitdir: <path>`, and `<path>/commondir` points back at the shared
|
|
||||||
`.git`. `git rev-parse --git-common-dir` answers the same question by forking;
|
|
||||||
this reads the files.
|
|
||||||
|
|
||||||
## Why the search does not start at __file__
|
|
||||||
|
|
||||||
where does this installation keep its files a fact about the plugin
|
|
||||||
whose login does this project run under a fact about the project
|
|
||||||
which issues does it have a fact about the project
|
|
||||||
|
|
||||||
A plugin installed outside any repository and pointed at somebody else's tree
|
|
||||||
must answer the second one from the tree it was pointed at. Anchoring the pin
|
|
||||||
on `__file__` would make the plugin's own directory an identity source, which
|
|
||||||
is how a checkout ends up acting under a login nobody chose for it. So the
|
|
||||||
search runs from the working directory upward — and reaches a worktree's main
|
|
||||||
checkout by asking git, not by walking somewhere else.
|
|
||||||
|
|
||||||
This module answered that way first and alone; `issue.store_root` and
|
|
||||||
`_gitea.PAYLOAD_ROOT` were anchored on `__file__` until an installed plugin was
|
|
||||||
found keeping other projects' issues inside its own versioned cache directory.
|
|
||||||
They resolve from the working directory now too, and the walk they share is the
|
|
||||||
one below — `issue.parents`, `gitdir_of`, `main_worktree` moved down into the
|
|
||||||
domain, which is the layer that depends on nothing and so is the only one all
|
|
||||||
three can borrow from. One written copy: the guard, the transport and the store
|
|
||||||
cannot disagree about a directory.
|
|
||||||
|
|
||||||
Finding nothing is a real answer: `(None, None)` means there is no pin, and the
|
|
||||||
caller says so. This module never guesses a login.
|
|
||||||
"""
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
# The domain owns the walk (see above). It is stdlib-only and imports nothing,
|
|
||||||
# so the tea-guard hook inherits no new weight by reaching it through here.
|
|
||||||
sys.path.append(os.path.abspath(os.path.join(
|
|
||||||
os.path.dirname(os.path.abspath(__file__)),
|
|
||||||
os.pardir, os.pardir, "issue", "scripts")))
|
|
||||||
from issue import parents, gitdir_of, main_worktree # noqa: E402,F401
|
|
||||||
|
|
||||||
SETTINGS_PARTS = (".claude", "settings.local.json")
|
|
||||||
ENV_KEY = "GITEA_LOGIN"
|
|
||||||
PROJECT_DIR_ENV = "CLAUDE_PROJECT_DIR"
|
|
||||||
|
|
||||||
|
|
||||||
def settings_path(root):
|
|
||||||
"""The pin file for a project root. The one place this path is spelled."""
|
|
||||||
return os.path.join(root, *SETTINGS_PARTS)
|
|
||||||
|
|
||||||
|
|
||||||
def read_pin(path):
|
|
||||||
"""The login in a settings file, or None.
|
|
||||||
|
|
||||||
Unreadable, not JSON, no `env`, empty string — all the same answer. A
|
|
||||||
broken file is not a login and is not worth a traceback in a hook."""
|
|
||||||
try:
|
|
||||||
with open(path) as f:
|
|
||||||
value = (json.load(f).get("env") or {}).get(ENV_KEY)
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
if isinstance(value, str) and value.strip():
|
|
||||||
return value.strip()
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def search(start):
|
|
||||||
"""(login, path) for one start directory: the parent chain, then the main
|
|
||||||
checkout of any worktree met on it. (None, None) when there is no pin.
|
|
||||||
|
|
||||||
The chain comes first and always wins, so the worktree branch can only
|
|
||||||
ever find a pin that walking up would not have found at all."""
|
|
||||||
hops = []
|
|
||||||
for d in parents(start):
|
|
||||||
login = read_pin(settings_path(d))
|
|
||||||
if login:
|
|
||||||
return login, settings_path(d)
|
|
||||||
root = main_worktree(d)
|
|
||||||
if root and root not in hops:
|
|
||||||
hops.append(root)
|
|
||||||
for root in hops:
|
|
||||||
# One level of indirection, never two: a main checkout is not itself a
|
|
||||||
# linked worktree, so this loop cannot chain and cannot cycle.
|
|
||||||
for d in parents(root):
|
|
||||||
login = read_pin(settings_path(d))
|
|
||||||
if login:
|
|
||||||
return login, settings_path(d)
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
|
|
||||||
def start_dirs(hint=None):
|
|
||||||
"""The ordered, deduplicated start directories.
|
|
||||||
|
|
||||||
`hint` is the caller's own idea of where the work is happening — the hook
|
|
||||||
passes the `cwd` from its payload, which is the directory the Bash command
|
|
||||||
will actually run in. A script has no payload and passes nothing."""
|
|
||||||
try:
|
|
||||||
cwd = os.getcwd()
|
|
||||||
except OSError: # cwd deleted out from under us
|
|
||||||
cwd = None
|
|
||||||
out = []
|
|
||||||
for d in (os.environ.get(PROJECT_DIR_ENV), hint, cwd):
|
|
||||||
if not d:
|
|
||||||
continue
|
|
||||||
d = os.path.abspath(d)
|
|
||||||
if d not in out:
|
|
||||||
out.append(d)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def find_pin(hint=None):
|
|
||||||
"""(login, path) for the first start directory that has a pin, else
|
|
||||||
(None, None). The entry point; everything above is its parts."""
|
|
||||||
for start in start_dirs(hint):
|
|
||||||
login, path = search(start)
|
|
||||||
if login:
|
|
||||||
return login, path
|
|
||||||
return None, None
|
|
||||||
@@ -1,301 +0,0 @@
|
|||||||
---
|
|
||||||
name: issue
|
|
||||||
description: Work with this project's issues as units of work — create, read, grep, validate, and walk their dependency graph. Entirely offline; issues are local markdown files and need no tracker. Load when the user asks to file/create an issue, read or find issues, check an issue against the format, or see what depends on what. For pushing to or pulling from Gitea, load /tea:sync instead.
|
|
||||||
---
|
|
||||||
|
|
||||||
# /tea:issue — issues as units of work
|
|
||||||
|
|
||||||
An issue is a markdown file in `.tea/issues/`. This skill covers everything you
|
|
||||||
do **with** an issue: writing one, reading one, checking it against the
|
|
||||||
canonical format, and walking the dependency graph.
|
|
||||||
|
|
||||||
**Nothing here touches the network.** No `tea`, no Gitea, no login. An issue
|
|
||||||
that lives only on this machine is a first-class issue, not a draft waiting to
|
|
||||||
be uploaded. Synchronizing with a tracker is a separate, optional layer —
|
|
||||||
`/tea:sync`.
|
|
||||||
|
|
||||||
Read [`references/format.md`](references/format.md) before creating or editing
|
|
||||||
an issue. It is the single source of truth for identity, metadata, types,
|
|
||||||
labels, templates, and language rules.
|
|
||||||
|
|
||||||
## Identity: the slug
|
|
||||||
|
|
||||||
The file name is the id and the id is a slug — `.tea/issues/wire-sqlc-appclick.md`.
|
|
||||||
It never changes, not when the title changes and not when the issue is pushed
|
|
||||||
somewhere. Tracker numbers live in a metadata field (`gitea: owner/repo#42`),
|
|
||||||
never in a file name and never in `depends:`.
|
|
||||||
|
|
||||||
Consequence worth internalizing: **`#42` means nothing in this layer.** Refer to
|
|
||||||
issues by id.
|
|
||||||
|
|
||||||
## Scripts
|
|
||||||
|
|
||||||
All offline, all in `<skill-base-dir>/scripts/`.
|
|
||||||
|
|
||||||
| Script | What it does |
|
|
||||||
|---|---|
|
|
||||||
| `issue_init.py [--at DIR] [--dry-run]` | make a directory a project: create `.tea/`, migrate an old `tmp/issues` store in, add `.tea/` to `.gitignore`. Idempotent |
|
|
||||||
| `issue_new.py --type T --title "…"` | create `.tea/issues/<slug>.md` from the type's template |
|
|
||||||
| `issue_check.py [id…]` | validate against the canonical format; exit 1 on errors |
|
|
||||||
| `issue_ac.py <id> [--check N\|TEXT]` | list the body's checkboxes; tick or untick one |
|
|
||||||
| `issue_tree.py [id…]` | draw the dependency graph from `depends:` |
|
|
||||||
| `issue_evict.py [id…] [--dry-run]` | remove closed issues from the store; **never** an `origin: local` one |
|
|
||||||
| `issue_index.py` | rebuild `.tea/issues/INDEX.md` |
|
|
||||||
| `issue.py` | the domain module the others import — not a command |
|
|
||||||
|
|
||||||
```
|
|
||||||
.tea/issues/INDEX.md table of every issue — read this first
|
|
||||||
.tea/issues/wire-sqlc-appclick.md metadata block + `# Title` + body
|
|
||||||
.tea/issues/wire-sqlc.comments.md comment thread (written by /tea:sync only)
|
|
||||||
.tea/issues/tree-<id>.md saved graph (issue_tree.py --write)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Where the store is
|
|
||||||
|
|
||||||
`<project root>/.tea/issues` — **not** `.tea/issues` relative to wherever you
|
|
||||||
are standing. The project root is the nearest directory up from where you are
|
|
||||||
that holds a `.tea/` marker: the scripts walk up from `$CLAUDE_PROJECT_DIR`,
|
|
||||||
then from the current directory. So they all see one store no matter which
|
|
||||||
subdirectory you run them from, and a `cd` earlier in the session changes
|
|
||||||
nothing — while a `cd` into a *different* project correctly gets that project's
|
|
||||||
issues.
|
|
||||||
|
|
||||||
**A project has a store because somebody ran `issue_init.py` in it.** The
|
|
||||||
marker is never inferred from the tree: `.git` is in every clone including this
|
|
||||||
plugin's own, and inferring from one is how an installed plugin came to keep
|
|
||||||
other projects' issues inside its own cache directory.
|
|
||||||
|
|
||||||
**With no marker anywhere, every command stops and says so**, naming the
|
|
||||||
directories it searched. It does not fall back to a plausible directory. If you
|
|
||||||
see that message, either you are not in the project you think you are, or the
|
|
||||||
project has not been initialized — run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 <skill-base-dir>/scripts/issue_init.py
|
|
||||||
```
|
|
||||||
|
|
||||||
**In a git worktree, do not initialize.** `.tea/` is gitignored, so a worktree
|
|
||||||
never has one; the scripts reach the main checkout's store on their own, the
|
|
||||||
same way the login pin does. Initializing there gives one project two stores,
|
|
||||||
and the second one is deleted with the branch.
|
|
||||||
|
|
||||||
`--out` overrides all of it and is taken **literally**: an absolute path is
|
|
||||||
used as given, a relative one stays relative to the current directory. Nothing
|
|
||||||
rewrites what you typed.
|
|
||||||
|
|
||||||
Two things follow, and both are deliberate:
|
|
||||||
|
|
||||||
- A store that is not there reports `does not exist`; a store with no issues in
|
|
||||||
it reports `is empty`. They are different problems.
|
|
||||||
- No script conjures a store as a side effect of writing. Only `issue_new.py`
|
|
||||||
creates one — the first issue in a fresh checkout — and it says so on stderr.
|
|
||||||
|
|
||||||
## Reading: grep, don't parse
|
|
||||||
|
|
||||||
Metadata is one field per line with inline lists precisely so plain `grep`
|
|
||||||
works. `INDEX.md` first, then the files:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
grep -l 'labels:.*type/bug' .tea/issues/*.md # all bugs
|
|
||||||
grep -l 'origin: local' .tea/issues/*.md # never pushed anywhere
|
|
||||||
grep -ln 'depends:.*migrate-schema' .tea/issues/*.md # who depends on it
|
|
||||||
grep -A3 '## Acceptance criteria' .tea/issues/wire-*.md
|
|
||||||
grep -c '^- \[ \]' .tea/issues/wire-sqlc-appclick.md # open checkboxes
|
|
||||||
```
|
|
||||||
|
|
||||||
Read whole files only for the issues the task actually needs.
|
|
||||||
|
|
||||||
## Creating an issue
|
|
||||||
|
|
||||||
1. **Read the format**: [`references/format.md`](references/format.md).
|
|
||||||
2. **Pick the type** — `bug`, `task`, `refactor`, `test`, `feature` (a
|
|
||||||
container for several issues with one business value), or `draft` (an idea
|
|
||||||
not ready for work). If it is not obvious from the request, ask the user
|
|
||||||
(one question).
|
|
||||||
3. **Scaffold it:**
|
|
||||||
```bash
|
|
||||||
python3 <skill-base-dir>/scripts/issue_new.py \
|
|
||||||
--type task --title "Wire sqlc into the appclick repo layer" \
|
|
||||||
--label tech/sql --label comp/appclick --depends migrate-schema
|
|
||||||
```
|
|
||||||
English imperative title with no type prefix; `--depends` takes ids.
|
|
||||||
4. **Fill the sections** with Edit — every section of the template present and
|
|
||||||
in order, headers English, prose Russian. `## Spec` gets a repo path, a URL,
|
|
||||||
or the literal `none`; ask the user if you cannot determine which.
|
|
||||||
5. **Check it:**
|
|
||||||
```bash
|
|
||||||
python3 <skill-base-dir>/scripts/issue_check.py wire-sqlc-appclick
|
|
||||||
```
|
|
||||||
|
|
||||||
One file = one issue. Several related issues = several files, linked through
|
|
||||||
`depends:`.
|
|
||||||
|
|
||||||
The issue is now real and complete. Publishing it to Gitea is a separate
|
|
||||||
decision — `/tea:sync` — and does not change the file's status here.
|
|
||||||
|
|
||||||
## Editing an issue
|
|
||||||
|
|
||||||
Edit the file. Change `state:` to close it, edit `labels:`, add ids to
|
|
||||||
`depends:`. Re-run `issue_check.py` afterwards, and `issue_index.py` to refresh
|
|
||||||
the table. Checkboxes are the exception — use `issue_ac.py`, below.
|
|
||||||
|
|
||||||
If the issue is synced (`origin: gitea`), the file is a working copy: your edit
|
|
||||||
is local until you run `push.py --update` from `/tea:sync`, and that push
|
|
||||||
**deletes the file** once Gitea has it. Closing one of those is `close.py` from
|
|
||||||
`/tea:sync` — it moves the state on both sides in a single run; editing
|
|
||||||
`state:` here alone would only ever tell this machine. Nothing tracks drift, and with one copy
|
|
||||||
at a time there is little to track — a file that is still here has not been
|
|
||||||
pushed. Get it back with `pull.py <n>`; the slug does not change.
|
|
||||||
|
|
||||||
## Ticking checkboxes
|
|
||||||
|
|
||||||
A checkbox is the one part of a body that is **state** and not prose, so it has
|
|
||||||
a command of its own. Never rewrite a body just to tick a box: the rewrite
|
|
||||||
re-flows lines and re-words sentences, and the issue's diff swells around a
|
|
||||||
change that means one character.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 <skill-base-dir>/scripts/issue_ac.py wire-sqlc-appclick
|
|
||||||
python3 <skill-base-dir>/scripts/issue_ac.py wire-sqlc-appclick --check 3
|
|
||||||
python3 <skill-base-dir>/scripts/issue_ac.py wire-sqlc-appclick --check "регресс"
|
|
||||||
python3 <skill-base-dir>/scripts/issue_ac.py wire-sqlc-appclick --uncheck 3
|
|
||||||
```
|
|
||||||
|
|
||||||
With no flag it prints the numbered list with each item's state, grouped by the
|
|
||||||
heading the item sits under. `--check` / `--uncheck` take that number or a
|
|
||||||
substring of the item's text (case-insensitive).
|
|
||||||
|
|
||||||
- **Every checkbox in the body counts, not just `## Acceptance criteria`.** A
|
|
||||||
`type/feature` keeps its children as checkboxes under `## Issues`, and they
|
|
||||||
are numbered in the same list. The script is named after the section most
|
|
||||||
boxes live in, nothing more.
|
|
||||||
- **A substring must match exactly one item.** Two matches is an error that
|
|
||||||
lists them; pick by number instead. It never guesses.
|
|
||||||
- **Exactly one character of the file changes.** Metadata, wording, wrapping
|
|
||||||
and trailing whitespace all come back byte for byte, so `git diff` and the
|
|
||||||
tracker's diff show the tick and nothing else.
|
|
||||||
- Examples inside a ``` fence are markup, not state — they are skipped.
|
|
||||||
- `INDEX.md` gains a `progress` column (`3/7`, blank when the issue has no
|
|
||||||
boxes), recomputed from the body on every build and stored in no field.
|
|
||||||
`issue_ac.py` rebuilds the index after a successful tick.
|
|
||||||
|
|
||||||
Getting the tick to the tracker is a separate step — `push.py --update` in
|
|
||||||
`/tea:sync`.
|
|
||||||
|
|
||||||
## Writing a proper description
|
|
||||||
|
|
||||||
Issues get filed on the run — "comments aren't pulled", "the guard broke".
|
|
||||||
That is a request, not a statement of work: no reproduction steps, no
|
|
||||||
`path/file:line`, acceptance criteria nobody can check. Rewriting one into the
|
|
||||||
canonical format is a procedure, not improvisation.
|
|
||||||
|
|
||||||
1. **Read the issue whole**, and everything it points at — the ids in
|
|
||||||
`depends:`, the `## Spec` target, the files it names.
|
|
||||||
2. **Determine the type and its template.** The `type/*` label selects one of
|
|
||||||
the templates in [`references/format.md`](references/format.md), and that
|
|
||||||
template's section list is the shape you are aiming at. If the label is
|
|
||||||
missing or wrong, decide it now and fix `labels:`; promoting a `type/draft`
|
|
||||||
to a concrete type is this same step.
|
|
||||||
3. **Locate the anchor points in the code.** Grep the repo for every file,
|
|
||||||
symbol, command, and error string the issue mentions, until you can name
|
|
||||||
lines:
|
|
||||||
```bash
|
|
||||||
grep -rn 'GITEA_LOGIN' hooks/ skills/
|
|
||||||
```
|
|
||||||
Work that does not exist yet still has anchor points — the files the change
|
|
||||||
will land in, and the ones that will call it.
|
|
||||||
4. **Gather the missing context.** What has to be there when you are done:
|
|
||||||
- code references in the `path/file.ext:line` form, for every place the
|
|
||||||
change lands;
|
|
||||||
- reproduction steps — exact commands and their real output (`type/bug`
|
|
||||||
splits them across `## Steps to reproduce` / `## Expected` / `## Actual`);
|
|
||||||
- acceptance criteria that are objectively checkable: a command that exits
|
|
||||||
0, a file that exists, a section that is present — not aspirations;
|
|
||||||
- a real value for `## Spec` — a repo path, a URL, or the literal `none`.
|
|
||||||
|
|
||||||
**A missing fact is either found in the repository or becomes a question to
|
|
||||||
the user. Inventing one is forbidden.** Ask in one batch, and keep `none` in
|
|
||||||
`## Spec` as the legitimate answer it is — never a plausible-looking link.
|
|
||||||
5. **Rewrite the sections** with Edit: every section of the template, in the
|
|
||||||
template's order, English headers and Russian prose. Replace the body; do
|
|
||||||
not append a second telling of the same issue below the old one.
|
|
||||||
6. **Check it:**
|
|
||||||
```bash
|
|
||||||
python3 <skill-base-dir>/scripts/issue_check.py wire-sqlc-appclick
|
|
||||||
```
|
|
||||||
Errors mean malformed, warnings mean the type's template is not fully
|
|
||||||
filled in. Re-run `issue_index.py` if the labels changed.
|
|
||||||
|
|
||||||
The procedure is identical for `origin: local` and `origin: gitea` — it works
|
|
||||||
on `.tea/issues/<id>.md`, and this layer does not know the difference. Getting
|
|
||||||
the rewritten body into the tracker is a separate decision — `push.py --update`
|
|
||||||
in `/tea:sync` — and is no part of this.
|
|
||||||
|
|
||||||
## Evicting closed issues
|
|
||||||
|
|
||||||
The store is a working set, not an archive. A closed issue is not a unit of
|
|
||||||
work any more, and one command takes it out — no `rm`, no rebuilding `INDEX.md`
|
|
||||||
by hand:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 <skill-base-dir>/scripts/issue_evict.py --dry-run # what would go
|
|
||||||
python3 <skill-base-dir>/scripts/issue_evict.py # every closed one
|
|
||||||
python3 <skill-base-dir>/scripts/issue_evict.py old-thing # just this one
|
|
||||||
```
|
|
||||||
|
|
||||||
Two conditions, both read off the file, and the second one is the whole safety
|
|
||||||
argument:
|
|
||||||
|
|
||||||
| `state:` | `origin:` | what eviction does |
|
|
||||||
|---|---|---|
|
|
||||||
| `closed` | a tracker | removes `<id>.md` and every sidecar under that slug |
|
|
||||||
| `closed` | `local` | **keeps it, always**, and says why |
|
|
||||||
| `open` | anything | keeps it |
|
|
||||||
|
|
||||||
**`origin: local` is never evicted, in any state, not even when you name it on
|
|
||||||
the command line.** That file *is* the issue; there is no copy to fetch back.
|
|
||||||
Only a file whose own metadata says the work lives somewhere else may go — the
|
|
||||||
same trade `push.py` makes when it drops a file the tracker just confirmed.
|
|
||||||
|
|
||||||
- `--dry-run` prints what would go and writes nothing at all, `INDEX.md`
|
|
||||||
included.
|
|
||||||
- `INDEX.md` is rebuilt afterwards, so the table and the directory agree. It is
|
|
||||||
rebuilt only when something was actually removed.
|
|
||||||
- `.remote.json` is **not** pruned, deliberately: it is the number → slug
|
|
||||||
ledger, and its entries are supposed to outlive the files they name (that is
|
|
||||||
what makes `pull.py <n>` land on the same slug after a push). An evicted issue
|
|
||||||
is in exactly the state a pushed one is.
|
|
||||||
- **This is not a one-off migration.** `pull.py <n>` fetches an issue in any
|
|
||||||
state — a number is an address, not a query — so a closed issue pulled after
|
|
||||||
an eviction lands on disk again. Not a regression: evict it again when you are
|
|
||||||
done reading it.
|
|
||||||
|
|
||||||
This command is offline and decides from `state:` in the file, which is only as
|
|
||||||
fresh as the last pull. To have the tracker's answer instead — an issue closed
|
|
||||||
in the web UI five minutes ago — use `/tea:sync`'s `evict.py`, which refreshes
|
|
||||||
`state:` first and then calls exactly this decision.
|
|
||||||
|
|
||||||
## Dependency graph
|
|
||||||
|
|
||||||
`depends:` is the authoritative edge list; the body's `## Depends on` section
|
|
||||||
is prose for humans. `issue_check.py` warns when they disagree.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 <skill-base-dir>/scripts/issue_tree.py # all roots
|
|
||||||
python3 <skill-base-dir>/scripts/issue_tree.py wire-sqlc-appclick --write
|
|
||||||
```
|
|
||||||
|
|
||||||
A `type/feature` plus its children read as one document: draw the tree once for
|
|
||||||
the shape, then grep the files.
|
|
||||||
|
|
||||||
## Layering rule
|
|
||||||
|
|
||||||
This skill must keep working with `skills/sync/` deleted. Every import under
|
|
||||||
`scripts/` is stdlib, and `subprocess` is not among them:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
grep -rhn '^import\|^from' skills/issue/scripts/ | sort -u
|
|
||||||
```
|
|
||||||
|
|
||||||
If you find yourself wanting a tracker concept here — an issue number, a login,
|
|
||||||
an HTTP call — it belongs in `/tea:sync`.
|
|
||||||
@@ -1,876 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
r"""
|
|
||||||
issue.py — what an issue IS. The domain layer.
|
|
||||||
|
|
||||||
Not a command; the module every other issue script builds on. It knows the
|
|
||||||
canonical markdown format, the label taxonomy, validation, and the dependency
|
|
||||||
graph. It knows NOTHING about any tracker: no Gitea, no `tea`, no logins, no HTTP, no
|
|
||||||
issue numbers. The layering rule is mechanically checkable — every import in
|
|
||||||
this directory is stdlib, and `subprocess` is not among them:
|
|
||||||
|
|
||||||
grep -rhn '^import\|^from' skills/issue/scripts/ | sort -u
|
|
||||||
|
|
||||||
Delete skills/sync/ entirely and this layer keeps working: issues that live
|
|
||||||
only on this machine are first-class, not drafts on their way somewhere.
|
|
||||||
|
|
||||||
Identity is a slug derived from the title, and it is the only identity the
|
|
||||||
domain has. The file name is the id:
|
|
||||||
|
|
||||||
.tea/issues/wire-sqlc-appclick.md
|
|
||||||
|
|
||||||
---
|
|
||||||
id: wire-sqlc-appclick
|
|
||||||
state: open
|
|
||||||
labels: [type/task, tech/sql]
|
|
||||||
assignees: [naudachu]
|
|
||||||
milestone: v0.2
|
|
||||||
depends: [migrate-schema]
|
|
||||||
origin: gitea
|
|
||||||
gitea: owner/repo#42
|
|
||||||
synced: 2026-08-07T18:40:00Z
|
|
||||||
---
|
|
||||||
# Wire sqlc into the appclick repo layer
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
...
|
|
||||||
|
|
||||||
Keys above `origin:` are owned here. Everything below is written by the sync
|
|
||||||
layer; this module carries those keys through load/save verbatim and never
|
|
||||||
reads them. That passthrough is what lets one file represent both a local
|
|
||||||
issue and a synced one without the domain learning a second vocabulary.
|
|
||||||
|
|
||||||
Every metadata field is one line and lists are inline, so plain grep works
|
|
||||||
without a parser:
|
|
||||||
|
|
||||||
grep -l 'labels:.*type/bug' .tea/issues/*.md
|
|
||||||
grep -ln 'depends:.*migrate-schema' .tea/issues/*.md # who depends on it
|
|
||||||
"""
|
|
||||||
import collections
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# where the store lives
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# `<project root>/.tea/issues`, absolute, resolved once at import — where the
|
|
||||||
# project root is the nearest directory up from the WORKING DIRECTORY that an
|
|
||||||
# operator has run `issue_init.py` in.
|
|
||||||
#
|
|
||||||
# Two anchors have been wrong here, in this order. First the relative
|
|
||||||
# `tmp/issues`, which made "the store" whatever directory the shell happened to
|
|
||||||
# be standing in: one `cd` — and a `cd` outlives the command that ran it — and
|
|
||||||
# readers reported an empty store on a full one while writers built a second
|
|
||||||
# store beside the first. Then `__file__`, on the reasoning that a script's own
|
|
||||||
# location is a fact about the installation while cwd is a fact about the last
|
|
||||||
# `cd`. That reasoning holds for an installation; it does not hold for a STORE.
|
|
||||||
#
|
|
||||||
# Anchored on `__file__`, an installed plugin resolves the store inside its own
|
|
||||||
# directory — and a plugin cache is versioned, so `~/.claude/plugins/cache/tea/
|
|
||||||
# tea/2.0.0/tmp/issues` stopped being found the moment the plugin became 2.1.0.
|
|
||||||
# Issues written from one project landed in the plugin and were invisible from
|
|
||||||
# the next. `origin: local` files — which ARE the issue, the only copy — were
|
|
||||||
# stranded a version bump at a time.
|
|
||||||
#
|
|
||||||
# So: the store is a fact about the PROJECT, exactly as the login pin is (see
|
|
||||||
# auth/scripts/pin.py, which has always resolved this way and says why). The
|
|
||||||
# anchor is an explicit marker an operator created, not a marker inferred from
|
|
||||||
# the tree: `.git` is present in every clone including this plugin's own, and
|
|
||||||
# AGENTS.md was worse still — the agents-sync hook writes one next to every
|
|
||||||
# AGENTS.md, so the plugin root always carried one and cwd never got a turn.
|
|
||||||
#
|
|
||||||
# Nothing is guessed when the marker is absent. `store_root()` returns None and
|
|
||||||
# the callers report which directories were searched; a wrong directory that
|
|
||||||
# looks like it worked is the failure this replaces.
|
|
||||||
#
|
|
||||||
# An explicit --out still wins over all of this, and is used exactly as typed: a
|
|
||||||
# relative --out stays relative to cwd, because that is what the operator asked
|
|
||||||
# for.
|
|
||||||
|
|
||||||
MARKER = ".tea"
|
|
||||||
STORE_PARTS = (MARKER, "issues")
|
|
||||||
|
|
||||||
|
|
||||||
def anchors(start=None):
|
|
||||||
"""The directories a root search starts from, in order, first hit wins.
|
|
||||||
|
|
||||||
`start` overrides them and exists so the resolution can be exercised
|
|
||||||
against a scratch tree. Otherwise: the project Claude Code was opened on,
|
|
||||||
then the working directory. The same order as `pin.search_dirs`, for the
|
|
||||||
same reason — both answer "which project is this", and a project that
|
|
||||||
disagrees with itself about that has two identities."""
|
|
||||||
if start is not None:
|
|
||||||
return [os.path.abspath(start)]
|
|
||||||
out = []
|
|
||||||
for d in (os.environ.get("CLAUDE_PROJECT_DIR"), os.getcwd()):
|
|
||||||
if d and os.path.isdir(d):
|
|
||||||
d = os.path.abspath(d)
|
|
||||||
if d not in out:
|
|
||||||
out.append(d)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
# The walk itself — the parent chain and the hop out of a linked worktree —
|
|
||||||
# lives here rather than in the identity layer that first needed it, because
|
|
||||||
# the domain is the layer everything else may depend on and it depends on
|
|
||||||
# nothing. `pin.py` imports these three; one written copy of the walk means the
|
|
||||||
# guard, the transport and the store cannot disagree about a directory. They
|
|
||||||
# did once: in a worktree, `tea` worked and every script said "no login
|
|
||||||
# pinned".
|
|
||||||
|
|
||||||
def parents(start):
|
|
||||||
"""`start` and every ancestor of it, up to the filesystem root."""
|
|
||||||
d = os.path.abspath(start)
|
|
||||||
while True:
|
|
||||||
yield d
|
|
||||||
parent = os.path.dirname(d)
|
|
||||||
if parent == d:
|
|
||||||
return
|
|
||||||
d = parent
|
|
||||||
|
|
||||||
|
|
||||||
def gitdir_of(d):
|
|
||||||
"""The private git directory `d/.git` points at, or None.
|
|
||||||
|
|
||||||
Only a `.git` FILE is a pointer; in an ordinary clone `.git` is a
|
|
||||||
directory and there is nothing to follow."""
|
|
||||||
p = os.path.join(d, ".git")
|
|
||||||
if not os.path.isfile(p):
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
with open(p) as f:
|
|
||||||
head = f.read(4096)
|
|
||||||
except OSError:
|
|
||||||
return None
|
|
||||||
for line in head.splitlines():
|
|
||||||
line = line.strip()
|
|
||||||
if line.startswith("gitdir:"):
|
|
||||||
target = line[len("gitdir:"):].strip()
|
|
||||||
if not target:
|
|
||||||
return None
|
|
||||||
if not os.path.isabs(target):
|
|
||||||
target = os.path.join(d, target)
|
|
||||||
return os.path.abspath(target)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def main_worktree(d):
|
|
||||||
"""If `d` is a linked worktree, the main working tree of its repository.
|
|
||||||
|
|
||||||
`<worktree>/.git` -> `<main>/.git/worktrees/<name>`, whose `commondir`
|
|
||||||
file holds a path to `<main>/.git`; the main working tree is its parent.
|
|
||||||
The `.git` basename check keeps this to worktrees: a submodule's `.git`
|
|
||||||
is a pointer too, but it points into `<super>/.git/modules/…`, and the
|
|
||||||
tree it belongs to is already on the parent chain."""
|
|
||||||
gitdir = gitdir_of(d)
|
|
||||||
if not gitdir or not os.path.isdir(gitdir):
|
|
||||||
return None
|
|
||||||
common = gitdir
|
|
||||||
marker = os.path.join(gitdir, "commondir")
|
|
||||||
if os.path.isfile(marker):
|
|
||||||
try:
|
|
||||||
with open(marker) as f:
|
|
||||||
rel = f.read().strip()
|
|
||||||
except OSError:
|
|
||||||
rel = ""
|
|
||||||
if rel:
|
|
||||||
common = os.path.abspath(os.path.join(gitdir, rel))
|
|
||||||
if os.path.basename(common) != ".git":
|
|
||||||
return None
|
|
||||||
root = os.path.dirname(common)
|
|
||||||
if root and os.path.isdir(root) and root != os.path.abspath(d):
|
|
||||||
return root
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def project_root(start=None):
|
|
||||||
"""Nearest ancestor of an anchor (inclusive) holding `.tea/`, or None.
|
|
||||||
|
|
||||||
A marker, not a fixed number of `..` hops: how deep a caller sits below the
|
|
||||||
root is an implementation detail of the project layout, and the layout is
|
|
||||||
not a promise. Walking up means every script sees one store from anywhere
|
|
||||||
inside the project — including from inside the store itself — while a `cd`
|
|
||||||
into a DIFFERENT project correctly answers with that project's store.
|
|
||||||
|
|
||||||
A linked worktree is the same project on another branch, and the marker is
|
|
||||||
gitignored, so it is only ever in the main checkout: the chain is searched
|
|
||||||
first and always wins, then the main working tree of any worktree met on
|
|
||||||
it. Initializing inside a worktree would give one project two stores, and
|
|
||||||
the directory holding the second one disappears with the branch."""
|
|
||||||
for anchor in anchors(start):
|
|
||||||
hops = []
|
|
||||||
for d in parents(anchor):
|
|
||||||
if os.path.isdir(os.path.join(d, MARKER)):
|
|
||||||
return d
|
|
||||||
main = main_worktree(d)
|
|
||||||
if main and main not in hops:
|
|
||||||
hops.append(main)
|
|
||||||
for root in hops:
|
|
||||||
# One level of indirection, never two: a main checkout is not
|
|
||||||
# itself a linked worktree, so this cannot chain and cannot cycle.
|
|
||||||
for d in parents(root):
|
|
||||||
if os.path.isdir(os.path.join(d, MARKER)):
|
|
||||||
return d
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def store_root(start=None):
|
|
||||||
"""Absolute path of the issue store, or None when no project was found."""
|
|
||||||
root = project_root(start)
|
|
||||||
return os.path.join(root, *STORE_PARTS) if root else None
|
|
||||||
|
|
||||||
|
|
||||||
def no_project_error(start=None):
|
|
||||||
"""Why no store could be resolved, naming every directory searched.
|
|
||||||
|
|
||||||
The searched directories are the anchors, not the whole chain above them:
|
|
||||||
an operator who sees the two places the search began knows immediately
|
|
||||||
whether it began where they meant it to."""
|
|
||||||
return ("no %s/ found — searched up from %s. Run issue_init.py in the "
|
|
||||||
"project you mean to track issues in."
|
|
||||||
% (MARKER, " and ".join(anchors(start)) or "nowhere"))
|
|
||||||
|
|
||||||
|
|
||||||
ISSUE_ROOT = store_root()
|
|
||||||
|
|
||||||
# Domain-owned metadata, in render order. Foreign keys render after these,
|
|
||||||
# sorted, so the sync layer can add fields without touching this list.
|
|
||||||
DOMAIN_KEYS = ["id", "state", "labels", "assignees", "milestone", "depends",
|
|
||||||
"origin"]
|
|
||||||
LIST_KEYS = {"labels", "assignees", "depends"}
|
|
||||||
STATES = ("open", "closed")
|
|
||||||
|
|
||||||
# `origin` is "does this issue exist anywhere but here" — a fact about the
|
|
||||||
# work, so it is owned here. Its value is `local` or a tracker's name; what
|
|
||||||
# that name means, and the handle that goes with it (`gitea: owner/repo#42`),
|
|
||||||
# stay foreign keys this layer carries but never reads.
|
|
||||||
LOCAL = "local"
|
|
||||||
|
|
||||||
# type/* is mandatory and exclusive; severity/* is optional and exclusive;
|
|
||||||
# tech/* and comp/* are free-form. Colors are NOT here — a hex code is how
|
|
||||||
# Gitea paints a chip, which makes it the sync layer's business.
|
|
||||||
TYPES = {
|
|
||||||
"bug": "Something behaves incorrectly in existing code",
|
|
||||||
"task": "Implementation of new functionality",
|
|
||||||
"refactor": "Internal restructuring; behavior must not change",
|
|
||||||
"test": "Writing or fixing tests",
|
|
||||||
"feature": "Container: several issues delivering one unit of business value",
|
|
||||||
"draft": "Idea captured for later; not ready for work",
|
|
||||||
}
|
|
||||||
SEVERITIES = ("low", "medium", "high", "showstopper", "critical")
|
|
||||||
EXCLUSIVE_NS = ("type/", "severity/")
|
|
||||||
|
|
||||||
# Sections every type must carry. type/draft is exempt from acceptance criteria.
|
|
||||||
REQUIRED_SECTIONS = ["## Summary", "## Spec"]
|
|
||||||
AC_SECTION = "## Acceptance criteria"
|
|
||||||
DEPENDS_SECTION = "## Depends on"
|
|
||||||
ISSUES_SECTION = "## Issues"
|
|
||||||
# Both sections name what an issue depends on, so both are edge sources and
|
|
||||||
# both point the same way. In a `type/feature` that reads container -> child:
|
|
||||||
# "the container is closed when its children are closed" IS a dependency.
|
|
||||||
# "a child belongs to a feature" is membership, and membership has no place in
|
|
||||||
# a dependency graph — which is why a child never names its container back.
|
|
||||||
DEP_SECTIONS = (DEPENDS_SECTION, ISSUES_SECTION)
|
|
||||||
# Per-type sections from the templates — absence is a warning, not a stop.
|
|
||||||
EXPECTED_SECTIONS = {
|
|
||||||
"bug": ["## Steps to reproduce", "## Expected", "## Actual", "## Environment"],
|
|
||||||
"task": ["## Motivation"],
|
|
||||||
"refactor": ["## Motivation", "## Invariants"],
|
|
||||||
"test": ["## Motivation", "## Test cases"],
|
|
||||||
"feature": ["## Motivation", ISSUES_SECTION],
|
|
||||||
"draft": ["## Notes"],
|
|
||||||
}
|
|
||||||
|
|
||||||
TITLE_PREFIX = re.compile(
|
|
||||||
r'^\s*(\[[^\]]+\]|(fix|feat|feature|bug|task|test|chore|refactor)\s*:)', re.I)
|
|
||||||
CYRILLIC = re.compile(r'[а-яё]', re.I)
|
|
||||||
SLUG_OK = re.compile(r'^[a-z0-9]+(-[a-z0-9]+)*$')
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# identity
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def slugify(text, maxlen=48):
|
|
||||||
"""Title -> id. Titles are English by format rule, so ASCII is enough;
|
|
||||||
anything else is dropped rather than transliterated."""
|
|
||||||
s = re.sub(r'[^a-z0-9]+', '-', (text or "").lower()).strip("-")
|
|
||||||
if len(s) > maxlen:
|
|
||||||
s = s[:maxlen].rsplit("-", 1)[0] or s[:maxlen]
|
|
||||||
return s.strip("-") or "issue"
|
|
||||||
|
|
||||||
|
|
||||||
def unique_id(root, base, taken=()):
|
|
||||||
"""`base`, or base-2, base-3… when the slug is already used."""
|
|
||||||
used = set(taken) | set(all_ids(root))
|
|
||||||
if base not in used:
|
|
||||||
return base
|
|
||||||
for i in range(2, 1000):
|
|
||||||
cand = "%s-%d" % (base, i)
|
|
||||||
if cand not in used:
|
|
||||||
return cand
|
|
||||||
raise ValueError("cannot allocate an id for %r" % base)
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# metadata block
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def parse_meta(text):
|
|
||||||
"""Split a file into (meta, title, body).
|
|
||||||
|
|
||||||
meta values are strings, or lists for the inline `[a, b]` form. title is
|
|
||||||
the first `# ` heading below the block and is stripped out of body."""
|
|
||||||
meta, rest = {}, text
|
|
||||||
if text.startswith("---"):
|
|
||||||
end = text.find("\n---", 3)
|
|
||||||
if end != -1:
|
|
||||||
for line in text[3:end].strip().splitlines():
|
|
||||||
if ":" not in line:
|
|
||||||
continue
|
|
||||||
k, v = line.split(":", 1)
|
|
||||||
k, v = k.strip(), v.strip()
|
|
||||||
if v.startswith("[") and v.endswith("]"):
|
|
||||||
v = [x.strip() for x in v[1:-1].split(",") if x.strip()]
|
|
||||||
elif k in LIST_KEYS:
|
|
||||||
v = [x.strip() for x in v.split(",") if x.strip()]
|
|
||||||
meta[k] = v
|
|
||||||
rest = text[end + 4:]
|
|
||||||
rest = rest.lstrip("\n")
|
|
||||||
|
|
||||||
title = ""
|
|
||||||
m = re.match(r'^#\s+(.+?)\s*\n', rest)
|
|
||||||
if m:
|
|
||||||
title = m.group(1).strip()
|
|
||||||
rest = rest[m.end():].lstrip("\n")
|
|
||||||
return meta, title, rest
|
|
||||||
|
|
||||||
|
|
||||||
def render_meta(meta):
|
|
||||||
"""Domain keys in DOMAIN_KEYS order, foreign keys after them, sorted.
|
|
||||||
Lists stay on one line so grep sees them whole."""
|
|
||||||
lines = ["---"]
|
|
||||||
foreign = sorted(k for k in meta if k not in DOMAIN_KEYS)
|
|
||||||
for k in DOMAIN_KEYS + foreign:
|
|
||||||
if k not in meta:
|
|
||||||
continue
|
|
||||||
v = meta[k]
|
|
||||||
if isinstance(v, (list, tuple)):
|
|
||||||
v = "[%s]" % ", ".join(str(x) for x in v)
|
|
||||||
lines.append("%s: %s" % (k, v))
|
|
||||||
lines.append("---")
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# the issue
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class Issue(object):
|
|
||||||
"""One unit of work. `extra` holds metadata this layer does not own."""
|
|
||||||
|
|
||||||
def __init__(self, id="", title="", body="", state="open", labels=None,
|
|
||||||
assignees=None, milestone="", depends=None,
|
|
||||||
origin=LOCAL, extra=None):
|
|
||||||
self.id = id
|
|
||||||
self.title = title
|
|
||||||
self.body = body
|
|
||||||
self.state = state or "open"
|
|
||||||
self.labels = list(labels or [])
|
|
||||||
self.assignees = list(assignees or [])
|
|
||||||
self.milestone = milestone or ""
|
|
||||||
self.depends = list(depends or [])
|
|
||||||
self.origin = origin or LOCAL
|
|
||||||
self.extra = dict(extra or {})
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_local(self):
|
|
||||||
"""True while this issue exists nowhere but here.
|
|
||||||
|
|
||||||
A complete state, not a pending one — and the state in which this file
|
|
||||||
is the only copy of the work. An issue whose `origin` names somewhere
|
|
||||||
else can be fetched from there again; this one cannot."""
|
|
||||||
return self.origin == LOCAL
|
|
||||||
|
|
||||||
# -- taxonomy views ----------------------------------------------------
|
|
||||||
|
|
||||||
@property
|
|
||||||
def type(self):
|
|
||||||
for l in self.labels:
|
|
||||||
if l.startswith("type/"):
|
|
||||||
return l.split("/", 1)[1]
|
|
||||||
return ""
|
|
||||||
|
|
||||||
@property
|
|
||||||
def severity(self):
|
|
||||||
for l in self.labels:
|
|
||||||
if l.startswith("severity/"):
|
|
||||||
return l.split("/", 1)[1]
|
|
||||||
return ""
|
|
||||||
|
|
||||||
# -- serialization -----------------------------------------------------
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_text(cls, text, id=None):
|
|
||||||
meta, title, body = parse_meta(text)
|
|
||||||
extra = {k: v for k, v in meta.items() if k not in DOMAIN_KEYS}
|
|
||||||
|
|
||||||
def lst(key):
|
|
||||||
v = meta.get(key) or []
|
|
||||||
return [v] if isinstance(v, str) else list(v)
|
|
||||||
|
|
||||||
ms = meta.get("milestone") or ""
|
|
||||||
return cls(id=id or meta.get("id") or "",
|
|
||||||
title=title, body=body.strip(),
|
|
||||||
state=meta.get("state") or "open",
|
|
||||||
labels=lst("labels"), assignees=lst("assignees"),
|
|
||||||
milestone="" if ms == "none" else ms,
|
|
||||||
depends=lst("depends"),
|
|
||||||
origin=meta.get("origin") or LOCAL, extra=extra)
|
|
||||||
|
|
||||||
def to_text(self):
|
|
||||||
meta = dict(self.extra)
|
|
||||||
meta.update({
|
|
||||||
"id": self.id,
|
|
||||||
"state": self.state,
|
|
||||||
"labels": self.labels,
|
|
||||||
"assignees": self.assignees,
|
|
||||||
"milestone": self.milestone or "none",
|
|
||||||
"depends": self.depends,
|
|
||||||
"origin": self.origin,
|
|
||||||
})
|
|
||||||
body = self.body.strip() or "(no body)"
|
|
||||||
return "%s\n# %s\n\n%s\n" % (render_meta(meta), self.title, body)
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# body sections
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def section_body(body, header):
|
|
||||||
"""Text under `header`, up to the next `## ` heading."""
|
|
||||||
out, active = [], False
|
|
||||||
for line in (body or "").splitlines():
|
|
||||||
if line.startswith("## "):
|
|
||||||
if active:
|
|
||||||
break
|
|
||||||
active = line.strip() == header
|
|
||||||
continue
|
|
||||||
if active:
|
|
||||||
out.append(line)
|
|
||||||
return "\n".join(out).strip()
|
|
||||||
|
|
||||||
|
|
||||||
def body_dep_ref_sections(body):
|
|
||||||
"""[(section, ref)] for every reference under one of DEP_SECTIONS — never
|
|
||||||
from prose, or a graph walk would drag in half the backlog. Refs are
|
|
||||||
whatever was written there (slugs, and `#N` on issues that came from a
|
|
||||||
tracker), deduplicated on first sight.
|
|
||||||
|
|
||||||
The section is carried out with the ref so a caller can name the one the
|
|
||||||
reader actually has in front of them: a container's children come from
|
|
||||||
`## Issues`, and pointing at `## Depends on` would name a section that is
|
|
||||||
not in the file."""
|
|
||||||
out, seen, section = [], set(), ""
|
|
||||||
for line in (body or "").splitlines():
|
|
||||||
if line.startswith("## "):
|
|
||||||
head = line.strip()
|
|
||||||
section = head if head in DEP_SECTIONS else ""
|
|
||||||
continue
|
|
||||||
if not section:
|
|
||||||
continue
|
|
||||||
for tok in re.findall(r'#(\d+)|\b([a-z0-9]+(?:-[a-z0-9]+)+)\b', line):
|
|
||||||
ref = ("#" + tok[0]) if tok[0] else tok[1]
|
|
||||||
if ref not in seen:
|
|
||||||
seen.add(ref)
|
|
||||||
out.append((section, ref))
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def body_dep_refs(body):
|
|
||||||
"""Just the refs, in order of first appearance."""
|
|
||||||
return [ref for _, ref in body_dep_ref_sections(body)]
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# checkboxes
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
# A checkbox is the one part of a body that is *state* and not prose, so the
|
|
||||||
# format gives it markup of its own (references/format.md:163-164). It is item
|
|
||||||
# markup, not a property of one section: `## Acceptance criteria` is the usual
|
|
||||||
# home, but a type/feature keeps its children as checkboxes under `## Issues`
|
|
||||||
# (format.md:275-277). The scan is therefore over the whole text and the
|
|
||||||
# heading is only recorded, never required.
|
|
||||||
CHECKBOX_RE = re.compile(
|
|
||||||
r'^(?P<indent>[ \t]*)(?P<marker>[-*+]|\d+[.)])[ \t]+'
|
|
||||||
r'\[(?P<box>[ xX])\](?=[ \t]|$)(?P<text>.*)$')
|
|
||||||
# Any list item — a sibling ends the item above it, checkbox or not.
|
|
||||||
LIST_ITEM_RE = re.compile(r'^[ \t]*([-*+]|\d+[.)])([ \t]|$)')
|
|
||||||
FENCE_RE = re.compile(r'^[ \t]{0,3}(`{3,}|~{3,})')
|
|
||||||
|
|
||||||
Checkbox = collections.namedtuple(
|
|
||||||
"Checkbox", "index line end_line checked text section")
|
|
||||||
|
|
||||||
|
|
||||||
def checkboxes(text):
|
|
||||||
"""Every checkbox item in `text`, in document order.
|
|
||||||
|
|
||||||
A pure function of the string it is given — no I/O, no store, no tracker.
|
|
||||||
Pass an issue body (`Issue.body`) to get body-relative line numbers, or a
|
|
||||||
whole file to get file-relative ones; nothing else changes.
|
|
||||||
|
|
||||||
Returns a list of `Checkbox` namedtuples:
|
|
||||||
|
|
||||||
index 1-based position in this list — what a user types to pick it
|
|
||||||
line 1-based line of the `- [ ]` marker, in the text given
|
|
||||||
end_line 1-based last line of the item, continuation lines included
|
|
||||||
checked True for `[x]` / `[X]`, False for `[ ]`
|
|
||||||
text the item's text; continuation lines joined with one space
|
|
||||||
section nearest preceding `## ` heading, "" above the first one
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
|
|
||||||
- Only a line matching CHECKBOX_RE opens an item. A wrapped ("continuation")
|
|
||||||
line is part of the item above it, never an item of its own; the item
|
|
||||||
runs to the next blank line, heading, code fence, or list marker.
|
|
||||||
- Fenced code blocks are skipped whole: `- [ ]` inside a ``` fence is an
|
|
||||||
example of the markup, not a box anybody may tick.
|
|
||||||
- `-`, `*`, `+` and `1.` markers all count, at any indentation, so nested
|
|
||||||
lists are seen too.
|
|
||||||
"""
|
|
||||||
lines = (text or "").splitlines()
|
|
||||||
items, section, fence = [], "", ""
|
|
||||||
for n, line in enumerate(lines, 1):
|
|
||||||
m = FENCE_RE.match(line)
|
|
||||||
if m:
|
|
||||||
tok = m.group(1)
|
|
||||||
if not fence:
|
|
||||||
fence = tok
|
|
||||||
elif tok[0] == fence[0] and len(tok) >= len(fence):
|
|
||||||
fence = ""
|
|
||||||
continue
|
|
||||||
if fence:
|
|
||||||
continue
|
|
||||||
if line.startswith("## "):
|
|
||||||
section = line.strip()
|
|
||||||
continue
|
|
||||||
if line.startswith("# "):
|
|
||||||
section = ""
|
|
||||||
continue
|
|
||||||
m = CHECKBOX_RE.match(line)
|
|
||||||
if not m:
|
|
||||||
continue
|
|
||||||
end, parts = n, [m.group("text").strip()]
|
|
||||||
for k in range(n, len(lines)): # lines[k] is line number k + 1
|
|
||||||
nxt = lines[k]
|
|
||||||
if (not nxt.strip() or nxt.startswith("#")
|
|
||||||
or FENCE_RE.match(nxt) or LIST_ITEM_RE.match(nxt)):
|
|
||||||
break
|
|
||||||
end = k + 1
|
|
||||||
parts.append(nxt.strip())
|
|
||||||
items.append(Checkbox(len(items) + 1, n, end,
|
|
||||||
m.group("box") != " ",
|
|
||||||
" ".join(p for p in parts if p), section))
|
|
||||||
return items
|
|
||||||
|
|
||||||
|
|
||||||
def set_checkbox(text, item, checked=True):
|
|
||||||
"""Return `text` with one checkbox set to `checked`.
|
|
||||||
|
|
||||||
Pure, and deliberately surgical: exactly one character of the input
|
|
||||||
changes — the one between the brackets. Everything else, including
|
|
||||||
trailing whitespace and the item's own wording, comes back byte for byte.
|
|
||||||
That is the whole point of the function: ticking a box must not produce a
|
|
||||||
diff wider than the state that changed.
|
|
||||||
|
|
||||||
`item` is a `Checkbox` from `checkboxes(text)` — the same text, or the
|
|
||||||
line number will point at the wrong line — or a 1-based line number.
|
|
||||||
Already in the requested state is a no-op: `text` is returned unchanged,
|
|
||||||
and an existing `[X]` keeps its capital.
|
|
||||||
"""
|
|
||||||
line_no = item.line if isinstance(item, Checkbox) else int(item)
|
|
||||||
off = 0
|
|
||||||
for n, raw in enumerate(text.splitlines(True), 1):
|
|
||||||
if n == line_no:
|
|
||||||
m = CHECKBOX_RE.match(raw.rstrip("\r\n"))
|
|
||||||
if not m:
|
|
||||||
raise ValueError("line %d is not a checkbox item" % line_no)
|
|
||||||
if (m.group("box") != " ") == bool(checked):
|
|
||||||
return text
|
|
||||||
box = off + m.start("box")
|
|
||||||
return text[:box] + ("x" if checked else " ") + text[box + 1:]
|
|
||||||
off += len(raw)
|
|
||||||
raise ValueError("line %d is past the end of the text" % line_no)
|
|
||||||
|
|
||||||
|
|
||||||
def checkbox_progress(text):
|
|
||||||
"""(done, total) over every checkbox in `text`; (0, 0) when it has none.
|
|
||||||
|
|
||||||
Computed on the fly, on purpose. Progress is not a metadata field: it is
|
|
||||||
the body read back, and the body is the only place the state lives."""
|
|
||||||
items = checkboxes(text)
|
|
||||||
return sum(1 for c in items if c.checked), len(items)
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# validation
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def validate(issue, known_ids=None):
|
|
||||||
"""Return (errors, warnings). Errors mean the issue is not well-formed in
|
|
||||||
the canonical format; warnings mean it deviates from its type template."""
|
|
||||||
err, warn = [], []
|
|
||||||
|
|
||||||
if not issue.id:
|
|
||||||
err.append("no `id:` — the slug is the issue's identity")
|
|
||||||
elif not SLUG_OK.match(issue.id):
|
|
||||||
err.append("id %r is not a slug (lowercase, digits, single dashes)" % issue.id)
|
|
||||||
|
|
||||||
if issue.state not in STATES:
|
|
||||||
err.append("state %r must be one of: %s" % (issue.state, ", ".join(STATES)))
|
|
||||||
|
|
||||||
types = [l for l in issue.labels if l.startswith("type/")]
|
|
||||||
if len(types) != 1:
|
|
||||||
err.append("need exactly one type/* label, found %d: %s"
|
|
||||||
% (len(types), ", ".join(types) or "none"))
|
|
||||||
elif issue.type not in TYPES:
|
|
||||||
err.append("unknown type %r — known: %s" % (issue.type, ", ".join(sorted(TYPES))))
|
|
||||||
if len([l for l in issue.labels if l.startswith("severity/")]) > 1:
|
|
||||||
err.append("at most one severity/* label")
|
|
||||||
if issue.severity and issue.severity not in SEVERITIES:
|
|
||||||
warn.append("unknown severity %r" % issue.severity)
|
|
||||||
|
|
||||||
if not issue.title:
|
|
||||||
err.append("no `# Title` heading below the metadata block")
|
|
||||||
else:
|
|
||||||
if TITLE_PREFIX.match(issue.title):
|
|
||||||
err.append("title carries a type prefix (%r) — the type lives in the label"
|
|
||||||
% issue.title[:24])
|
|
||||||
if CYRILLIC.search(issue.title):
|
|
||||||
err.append("title must be English, imperative mood (prose stays Russian)")
|
|
||||||
|
|
||||||
for h in REQUIRED_SECTIONS:
|
|
||||||
if h not in issue.body:
|
|
||||||
err.append("missing section %s" % h)
|
|
||||||
if issue.type != "draft" and AC_SECTION not in issue.body:
|
|
||||||
err.append("missing section %s" % AC_SECTION)
|
|
||||||
if "## Spec" in issue.body and not section_body(issue.body, "## Spec"):
|
|
||||||
err.append("## Spec is empty — put a repo path, a URL, or the literal `none`")
|
|
||||||
|
|
||||||
for h in EXPECTED_SECTIONS.get(issue.type, []):
|
|
||||||
if h not in issue.body:
|
|
||||||
warn.append("type/%s template usually has %s" % (issue.type, h))
|
|
||||||
|
|
||||||
if issue.id in issue.depends:
|
|
||||||
err.append("depends on itself")
|
|
||||||
if known_ids is not None:
|
|
||||||
for d in issue.depends:
|
|
||||||
if d not in known_ids:
|
|
||||||
warn.append("depends on %r, which is not in the store" % d)
|
|
||||||
|
|
||||||
# `depends:` is the machine-readable graph; the body section is prose for
|
|
||||||
# humans. They drift silently unless something says so. Name the section
|
|
||||||
# the reference actually came from — for a container that is `## Issues`.
|
|
||||||
listed = set(issue.depends)
|
|
||||||
for section, ref in body_dep_ref_sections(issue.body):
|
|
||||||
if not ref.startswith("#") and ref not in listed:
|
|
||||||
warn.append("%s mentions %r but `depends:` does not list it"
|
|
||||||
% (section, ref))
|
|
||||||
|
|
||||||
# An unticked checkbox is never a finding — neither an error nor a
|
|
||||||
# warning. `- [ ]` is work not done yet, which is the normal state of a
|
|
||||||
# perfectly well-formed issue. Reading that state is issue_ac.py's job.
|
|
||||||
|
|
||||||
return err, warn
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# store
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class StoreMissing(Exception):
|
|
||||||
"""The store directory is not there.
|
|
||||||
|
|
||||||
Deliberately a different answer from "the store is empty". One is a path
|
|
||||||
that does not exist, the other is a repository with no issues filed yet, and
|
|
||||||
conflating the two is exactly what made a missed directory look like an
|
|
||||||
empty backlog."""
|
|
||||||
|
|
||||||
def __init__(self, root):
|
|
||||||
self.root = root
|
|
||||||
Exception.__init__(self, no_project_error() if root is None
|
|
||||||
else "store %s does not exist" % root)
|
|
||||||
|
|
||||||
|
|
||||||
def store_exists(root):
|
|
||||||
return root is not None and os.path.isdir(root)
|
|
||||||
|
|
||||||
|
|
||||||
def require_store(root):
|
|
||||||
"""Assert the store is there before reading or writing it.
|
|
||||||
|
|
||||||
`root` is None when no project was found at all — a different failure from
|
|
||||||
a project whose store has not been created yet, and StoreMissing says so."""
|
|
||||||
if not store_exists(root):
|
|
||||||
raise StoreMissing(root)
|
|
||||||
return root
|
|
||||||
|
|
||||||
|
|
||||||
def create_store(root):
|
|
||||||
"""Create the store; True when it actually made the directory.
|
|
||||||
|
|
||||||
Only the commands that legitimately bootstrap a store call this — issue_new
|
|
||||||
and pull — and both announce it. Nothing creates a store as a side effect of
|
|
||||||
a write any more: a missing directory is something to report, not something
|
|
||||||
to conjure. An unresolved root is never conjured either: without a marker
|
|
||||||
there is no project to create a store IN, and guessing one is how a store
|
|
||||||
ended up inside the plugin."""
|
|
||||||
if root is None:
|
|
||||||
raise StoreMissing(None)
|
|
||||||
if os.path.isdir(root):
|
|
||||||
return False
|
|
||||||
os.makedirs(root)
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def store_error(root):
|
|
||||||
"""Why `root` cannot be read as a store, or None when it holds issues.
|
|
||||||
|
|
||||||
The three messages are distinct on purpose — no project at all, a project
|
|
||||||
with no store, and a store with nothing in it are three different things to
|
|
||||||
do next."""
|
|
||||||
if root is None:
|
|
||||||
return no_project_error()
|
|
||||||
if not os.path.isdir(root):
|
|
||||||
return ("store %s does not exist — nothing was created; pass --out to "
|
|
||||||
"point elsewhere" % root)
|
|
||||||
if not all_ids(root):
|
|
||||||
return "store %s exists but is empty" % root
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def path_of(root, id):
|
|
||||||
return os.path.join(root, "%s.md" % id)
|
|
||||||
|
|
||||||
|
|
||||||
def all_ids(root):
|
|
||||||
"""Every issue in the store, by slug.
|
|
||||||
|
|
||||||
An issue file is named by its slug and a slug has no dot in it (SLUG_OK),
|
|
||||||
so `<id>.comments.md` — the thread the sync layer parks beside an issue —
|
|
||||||
is not one, and neither is anything else that grew a second extension.
|
|
||||||
Without that rule `wire-sqlc.comments` reads as an issue called
|
|
||||||
`wire-sqlc.comments`, and a bare `push.py` tries to file the comment thread
|
|
||||||
as a unit of work."""
|
|
||||||
if not store_exists(root):
|
|
||||||
return []
|
|
||||||
return sorted(f[:-3] for f in os.listdir(root)
|
|
||||||
if f.endswith(".md") and not f.startswith((".", "INDEX", "tree-"))
|
|
||||||
and "." not in f[:-3])
|
|
||||||
|
|
||||||
|
|
||||||
def slug_files(root, id):
|
|
||||||
"""Every file the store holds under one slug — the issue and its sidecars.
|
|
||||||
|
|
||||||
`<id>.md` is the issue. Anything named `<id>.<something>` beside it is a
|
|
||||||
companion another layer parked there (`<id>.comments.md` is the one that
|
|
||||||
exists today). `all_ids` already refuses to read those as issues because a
|
|
||||||
slug has no dot in it; this is the same rule read the other way round.
|
|
||||||
|
|
||||||
Which is how the domain can remove an issue *completely* without learning
|
|
||||||
what any of those companions are: it does not need to know that a comment
|
|
||||||
thread exists to know that a file named after this issue belongs to it and
|
|
||||||
goes when it goes. The issue's own file comes first — it is the headline of
|
|
||||||
any receipt printed from this list.
|
|
||||||
|
|
||||||
A missing store is an empty list, not an error: nothing is there to remove.
|
|
||||||
"""
|
|
||||||
if not os.path.isdir(root):
|
|
||||||
return []
|
|
||||||
own, sidecars = [], []
|
|
||||||
for name in sorted(os.listdir(root)):
|
|
||||||
if not name.startswith("%s." % id):
|
|
||||||
continue
|
|
||||||
p = os.path.join(root, name)
|
|
||||||
if not os.path.isfile(p):
|
|
||||||
continue
|
|
||||||
(own if name == "%s.md" % id else sidecars).append(p)
|
|
||||||
return own + sidecars
|
|
||||||
|
|
||||||
|
|
||||||
def load(root, id):
|
|
||||||
with open(path_of(root, id)) as f:
|
|
||||||
return Issue.from_text(f.read(), id=id)
|
|
||||||
|
|
||||||
|
|
||||||
def load_all(root):
|
|
||||||
return {i: load(root, i) for i in all_ids(root)}
|
|
||||||
|
|
||||||
|
|
||||||
def save(root, issue):
|
|
||||||
require_store(root)
|
|
||||||
p = path_of(root, issue.id)
|
|
||||||
with open(p, "w") as f:
|
|
||||||
f.write(issue.to_text())
|
|
||||||
return p
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# dependency graph
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def graph(issues):
|
|
||||||
"""{id: [dep ids]} from the `depends:` metadata — the authoritative edge
|
|
||||||
list. Body prose is never walked."""
|
|
||||||
return {i: list(iss.depends) for i, iss in issues.items()}
|
|
||||||
|
|
||||||
|
|
||||||
def dependents(issues, id):
|
|
||||||
"""Who depends on `id` (the upward direction)."""
|
|
||||||
return sorted(i for i, iss in issues.items() if id in iss.depends)
|
|
||||||
|
|
||||||
|
|
||||||
def topo_order(ids, edges):
|
|
||||||
"""Dependencies first. Cycles are broken deterministically rather than
|
|
||||||
raising: a cycle is a data problem for the caller to report, not a reason
|
|
||||||
to refuse to order the rest."""
|
|
||||||
order, state = [], {}
|
|
||||||
|
|
||||||
def visit(n):
|
|
||||||
if state.get(n) == "done":
|
|
||||||
return
|
|
||||||
if state.get(n) == "open":
|
|
||||||
return # cycle — leave the back edge unresolved
|
|
||||||
state[n] = "open"
|
|
||||||
for d in edges.get(n, []):
|
|
||||||
if d in edges:
|
|
||||||
visit(d)
|
|
||||||
state[n] = "done"
|
|
||||||
order.append(n)
|
|
||||||
|
|
||||||
for n in ids:
|
|
||||||
visit(n)
|
|
||||||
return order
|
|
||||||
|
|
||||||
|
|
||||||
def find_cycles(edges):
|
|
||||||
"""List of id lists, one per cycle found. Empty when the graph is a DAG."""
|
|
||||||
cycles, state, stack = [], {}, []
|
|
||||||
|
|
||||||
def visit(n):
|
|
||||||
state[n] = "open"
|
|
||||||
stack.append(n)
|
|
||||||
for d in edges.get(n, []):
|
|
||||||
if d not in edges:
|
|
||||||
continue
|
|
||||||
if state.get(d) == "open":
|
|
||||||
cycles.append(stack[stack.index(d):] + [d])
|
|
||||||
elif d not in state:
|
|
||||||
visit(d)
|
|
||||||
stack.pop()
|
|
||||||
state[n] = "done"
|
|
||||||
|
|
||||||
for n in edges:
|
|
||||||
if n not in state:
|
|
||||||
visit(n)
|
|
||||||
return cycles
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
issue_ac.py — list and tick the checkboxes in an issue's body. Offline.
|
|
||||||
|
|
||||||
issue_ac.py wire-sqlc-appclick numbered list with state
|
|
||||||
issue_ac.py wire-sqlc-appclick --check 3 by number
|
|
||||||
issue_ac.py wire-sqlc-appclick --check регресс by substring
|
|
||||||
issue_ac.py wire-sqlc-appclick --uncheck 3
|
|
||||||
|
|
||||||
A checkbox is the one part of a body that is *state* and not prose. Everything
|
|
||||||
else is written once; boxes get ticked as the work goes, and until now the only
|
|
||||||
ways to tick one were a human with an editor or a model rewriting the whole
|
|
||||||
body — the second worse than the first, because the rewrite re-flows the text
|
|
||||||
and the issue's diff swells around a change of one character. This changes that
|
|
||||||
one character and nothing else.
|
|
||||||
|
|
||||||
Named after `## Acceptance criteria`, where most boxes live, but every checkbox
|
|
||||||
in the body is listed and tickable: a type/feature keeps its children under
|
|
||||||
`## Issues`, and binding this to one heading would silently lose half of them.
|
|
||||||
|
|
||||||
A substring picks an item only when it picks exactly one. Two matches is an
|
|
||||||
error listing both — a coin flip would tick the wrong box and look like it
|
|
||||||
worked.
|
|
||||||
|
|
||||||
Delivering the changed body to a tracker is not part of this: that is
|
|
||||||
`push.py --update` in /tea:sync.
|
|
||||||
"""
|
|
||||||
import argparse
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
||||||
import issue # noqa: E402
|
|
||||||
import issue_index # noqa: E402
|
|
||||||
|
|
||||||
NUMBER = re.compile(r'^\d+$')
|
|
||||||
|
|
||||||
|
|
||||||
def box(c):
|
|
||||||
return "[x]" if c.checked else "[ ]"
|
|
||||||
|
|
||||||
|
|
||||||
def listing(items):
|
|
||||||
"""The numbered list, grouped by the heading each item sits under."""
|
|
||||||
out, section = [], None
|
|
||||||
for c in items:
|
|
||||||
if c.section != section:
|
|
||||||
section = c.section
|
|
||||||
out.append("")
|
|
||||||
out.append(section or "(above the first heading)")
|
|
||||||
out.append(" %2d %s %s" % (c.index, box(c), c.text))
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def select(items, needle):
|
|
||||||
"""Resolve a --check/--uncheck argument to exactly one item, or exit."""
|
|
||||||
needle = (needle or "").strip()
|
|
||||||
if not needle:
|
|
||||||
sys.exit("issue_ac.py: empty selector — give an item number or a substring")
|
|
||||||
if NUMBER.match(needle):
|
|
||||||
n = int(needle)
|
|
||||||
if not 1 <= n <= len(items):
|
|
||||||
sys.exit("issue_ac.py: no item %d — the issue has %d" % (n, len(items)))
|
|
||||||
return items[n - 1]
|
|
||||||
hits = [c for c in items if needle.lower() in c.text.lower()]
|
|
||||||
if not hits:
|
|
||||||
sys.exit("issue_ac.py: nothing matches %r" % needle)
|
|
||||||
if len(hits) > 1:
|
|
||||||
sys.exit("\n".join(
|
|
||||||
["issue_ac.py: %r matches %d items — narrow it down, or use a number:"
|
|
||||||
% (needle, len(hits))]
|
|
||||||
+ [" %2d %s %s" % (c.index, box(c), c.text) for c in hits]))
|
|
||||||
return hits[0]
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv=None):
|
|
||||||
ap = argparse.ArgumentParser(
|
|
||||||
description="List and tick an issue's checkboxes (offline)")
|
|
||||||
ap.add_argument("id", help="issue id (the slug, without .md)")
|
|
||||||
g = ap.add_mutually_exclusive_group()
|
|
||||||
g.add_argument("--check", metavar="N|TEXT", help="tick one item: number or substring")
|
|
||||||
g.add_argument("--uncheck", metavar="N|TEXT", help="untick one item: number or substring")
|
|
||||||
ap.add_argument("--out", default=issue.ISSUE_ROOT, help="store root (default: .tea/issues)")
|
|
||||||
args = ap.parse_args(argv)
|
|
||||||
|
|
||||||
if args.out is None:
|
|
||||||
sys.exit("issue_ac.py: %s" % issue.no_project_error())
|
|
||||||
|
|
||||||
path = issue.path_of(args.out, args.id)
|
|
||||||
if not os.path.exists(path):
|
|
||||||
sys.exit("issue_ac.py: no issue %r in %s" % (args.id, args.out))
|
|
||||||
# newline="": no translation in either direction. Byte-for-byte means the
|
|
||||||
# line endings too — reading a CRLF file in text mode and writing it back
|
|
||||||
# would rewrite every line while claiming to have changed one character.
|
|
||||||
with open(path, newline="") as f:
|
|
||||||
text = f.read()
|
|
||||||
|
|
||||||
# The whole file, not just the body: line numbers then point at the file,
|
|
||||||
# and the metadata block is rewritten by nobody. Round-tripping through
|
|
||||||
# Issue.to_text() would re-render metadata and re-strip the body, which is
|
|
||||||
# exactly the byte-level churn this script exists to avoid.
|
|
||||||
items = issue.checkboxes(text)
|
|
||||||
needle = args.check if args.check is not None else args.uncheck
|
|
||||||
|
|
||||||
if not items:
|
|
||||||
if needle is not None:
|
|
||||||
sys.exit("issue_ac.py: %s has no checkboxes" % args.id)
|
|
||||||
print("%s — no checkboxes" % args.id)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
if needle is None:
|
|
||||||
done = sum(1 for c in items if c.checked)
|
|
||||||
print("%s — %d/%d %s" % (args.id, done, len(items), path))
|
|
||||||
print("\n".join(listing(items)))
|
|
||||||
return 0
|
|
||||||
|
|
||||||
checked = args.check is not None
|
|
||||||
item = select(items, needle)
|
|
||||||
new = issue.set_checkbox(text, item, checked)
|
|
||||||
verb = "checked" if checked else "unchecked"
|
|
||||||
if new == text:
|
|
||||||
print("unchanged %2d %s %s" % (item.index, box(item), item.text))
|
|
||||||
return 0
|
|
||||||
|
|
||||||
with open(path, "w", newline="") as f:
|
|
||||||
f.write(new)
|
|
||||||
issue_index.build(args.out)
|
|
||||||
|
|
||||||
done, total = issue.checkbox_progress(new)
|
|
||||||
print("%s %2d %s %s" % (verb, item.index, "[x]" if checked else "[ ]", item.text))
|
|
||||||
print("%s — %d/%d %s:%d" % (args.id, done, total, path, item.line))
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
issue_check.py — validate issues against the canonical format. Offline.
|
|
||||||
|
|
||||||
The same check the sync layer runs before it pushes anything, available on its
|
|
||||||
own so a local-only issue can be held to the format without a tracker being
|
|
||||||
involved. Errors mean malformed; warnings mean it deviates from its type's
|
|
||||||
template or its graph looks suspect.
|
|
||||||
|
|
||||||
issue_check.py every issue in the store
|
|
||||||
issue_check.py wire-sqlc one issue
|
|
||||||
issue_check.py --quiet exit code only (0 clean, 1 errors)
|
|
||||||
|
|
||||||
Format reference: ../references/format.md
|
|
||||||
"""
|
|
||||||
import argparse
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
||||||
import issue # noqa: E402
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
ap = argparse.ArgumentParser(description="Validate local issues (offline)")
|
|
||||||
ap.add_argument("ids", nargs="*", help="ids to check (default: all)")
|
|
||||||
ap.add_argument("--quiet", action="store_true", help="exit code only")
|
|
||||||
ap.add_argument("--strict", action="store_true", help="treat warnings as errors")
|
|
||||||
ap.add_argument("--out", default=issue.ISSUE_ROOT,
|
|
||||||
help="store root (default: <project>/.tea/issues)")
|
|
||||||
args = ap.parse_args()
|
|
||||||
|
|
||||||
problem = issue.store_error(args.out)
|
|
||||||
if problem:
|
|
||||||
sys.exit("issue_check.py: %s" % problem)
|
|
||||||
|
|
||||||
issues = issue.load_all(args.out)
|
|
||||||
ids = args.ids or sorted(issues)
|
|
||||||
for i in ids:
|
|
||||||
if i not in issues:
|
|
||||||
sys.exit("issue_check.py: no issue %r in %s" % (i, args.out))
|
|
||||||
|
|
||||||
known = set(issues)
|
|
||||||
bad = 0
|
|
||||||
for i in ids:
|
|
||||||
err, warn = issue.validate(issues[i], known_ids=known)
|
|
||||||
if args.strict:
|
|
||||||
err, warn = err + warn, []
|
|
||||||
if err:
|
|
||||||
bad += 1
|
|
||||||
if args.quiet:
|
|
||||||
continue
|
|
||||||
if not err and not warn:
|
|
||||||
print("ok %s" % i)
|
|
||||||
continue
|
|
||||||
for e in err:
|
|
||||||
print("ERROR %s: %s" % (i, e))
|
|
||||||
for w in warn:
|
|
||||||
print("warn %s: %s" % (i, w))
|
|
||||||
|
|
||||||
for c in issue.find_cycles(issue.graph(issues)):
|
|
||||||
bad += 1
|
|
||||||
if not args.quiet:
|
|
||||||
print("ERROR cycle: %s" % " -> ".join(c))
|
|
||||||
|
|
||||||
if not args.quiet:
|
|
||||||
print("%d issue(s) checked, %d with errors" % (len(ids), bad))
|
|
||||||
return 1 if bad else 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
issue_evict.py — closed issues leave the store. Offline.
|
|
||||||
|
|
||||||
issue_evict.py every closed issue that is not origin: local
|
|
||||||
issue_evict.py old-thing … only these
|
|
||||||
issue_evict.py --dry-run print what would go; touch nothing
|
|
||||||
|
|
||||||
The store is a working set, not an archive. A closed issue is not a unit of
|
|
||||||
work any more, and `pull.py` has kept new ones out of filter mode for a while —
|
|
||||||
but the files already on disk were nobody's job, so the only way to remove one
|
|
||||||
was `rm` past every script, followed by rebuilding `INDEX.md` by hand. This is
|
|
||||||
that job.
|
|
||||||
|
|
||||||
WHAT IS EVICTED, and it is two conditions, both read off the file:
|
|
||||||
|
|
||||||
state: closed the work is done
|
|
||||||
origin: <tracker> the work is somewhere else too
|
|
||||||
|
|
||||||
TWO CONDITIONS, AND THE SECOND ONE IS THE WHOLE SAFETY ARGUMENT. `origin:
|
|
||||||
local` means this file IS the issue — there is no other copy and deleting it
|
|
||||||
deletes the work. It is therefore never evicted, in any state, not even when
|
|
||||||
named explicitly on the command line: a closed local issue is reported and
|
|
||||||
kept. The only files that go are ones whose own metadata says the work can be
|
|
||||||
fetched back (`pull.py <n>`), which is the same trade `push.py` makes when it
|
|
||||||
drops a file the tracker has just confirmed.
|
|
||||||
|
|
||||||
That parallel is exact except for where the confirmation comes from. Push has
|
|
||||||
to ask Gitea, because it is Gitea that just changed. Eviction asks the file,
|
|
||||||
because `state:` and `origin:` are domain fields and the answer is already in
|
|
||||||
the store — which is why this command lives in the domain layer and needs no
|
|
||||||
network, no login, and no `tea`. See `skills/sync/scripts/evict.py` for the
|
|
||||||
variant that refreshes `state:` from the tracker first; it makes the deletion
|
|
||||||
decision by calling `run()` below, so there is exactly one implementation of
|
|
||||||
"what may be evicted" and it is this one.
|
|
||||||
|
|
||||||
NOT A ONE-OFF MIGRATION. `pull.py <n>` fetches an issue in any state — a number
|
|
||||||
is an address, not a query — so a closed issue pulled after an eviction lands on
|
|
||||||
disk again. That is the tracker being asked a direct question, not a regression,
|
|
||||||
and the answer is to evict again when you are done with it.
|
|
||||||
|
|
||||||
`.remote.json` is deliberately NOT pruned. It is the local number -> slug
|
|
||||||
ledger, its entries outlive the files they name (that is what makes `pull.py
|
|
||||||
<n>` land on the same slug after a push deleted the file), and an evicted issue
|
|
||||||
is in exactly that state. `INDEX.md` is rebuilt, because it *is* a view of the
|
|
||||||
directory.
|
|
||||||
"""
|
|
||||||
import argparse
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
||||||
import issue # noqa: E402
|
|
||||||
import issue_index # noqa: E402
|
|
||||||
|
|
||||||
CLOSED = "closed"
|
|
||||||
|
|
||||||
# Why an issue was kept, in the receipt. `LOCAL_REASON` is the one that matters:
|
|
||||||
# it is printed whether or not the issue was named, because "this closed thing
|
|
||||||
# is still here" needs an answer every time.
|
|
||||||
LOCAL_REASON = "origin: %s — this file IS the issue" % issue.LOCAL
|
|
||||||
|
|
||||||
|
|
||||||
def classify(issues, ids=None):
|
|
||||||
"""Split the store into (evict, protected, still_open).
|
|
||||||
|
|
||||||
Pure — it reads the loaded issues and decides; nothing here touches disk.
|
|
||||||
|
|
||||||
evict closed, and lives in a tracker too: safe to remove
|
|
||||||
protected closed, but `origin: local`: the only copy of the work
|
|
||||||
still_open not closed
|
|
||||||
|
|
||||||
`ids` restricts the question to those issues; without it the whole store is
|
|
||||||
considered. A protected issue is returned as such even when it was named
|
|
||||||
explicitly — naming a file does not make deleting it safe.
|
|
||||||
"""
|
|
||||||
chosen = list(ids) if ids else sorted(issues)
|
|
||||||
evict, protected, still_open = [], [], []
|
|
||||||
for id in chosen:
|
|
||||||
iss = issues[id]
|
|
||||||
if iss.state != CLOSED:
|
|
||||||
still_open.append(id)
|
|
||||||
elif iss.is_local:
|
|
||||||
protected.append(id)
|
|
||||||
else:
|
|
||||||
evict.append(id)
|
|
||||||
return evict, protected, still_open
|
|
||||||
|
|
||||||
|
|
||||||
def remove(root, id):
|
|
||||||
"""Delete everything the store holds under one slug; return the paths.
|
|
||||||
|
|
||||||
Deliberately dumb, and for the same reason `push.drop_local` is: it takes an
|
|
||||||
id, not a decision. Whether an issue may go is settled by `classify` before
|
|
||||||
this is reached, so the dangerous half of the operation has no branches in
|
|
||||||
it at all. There is exactly one call site.
|
|
||||||
"""
|
|
||||||
gone = []
|
|
||||||
for p in issue.slug_files(root, id):
|
|
||||||
os.remove(p)
|
|
||||||
gone.append(p)
|
|
||||||
return gone
|
|
||||||
|
|
||||||
|
|
||||||
def run(root, issues, ids=None, dry_run=False, out=None):
|
|
||||||
"""Classify, report, remove, rebuild the index. Returns (gone, kept).
|
|
||||||
|
|
||||||
The one implementation of eviction, called both by `main` below and by the
|
|
||||||
sync layer's `evict.py` — which does nothing to this decision except hand
|
|
||||||
over issues whose `state:` it has just refreshed from the tracker.
|
|
||||||
|
|
||||||
`gone` is {id: [paths]} and is empty on a dry run; `kept` is
|
|
||||||
[(id, why)] for everything considered and not removed.
|
|
||||||
"""
|
|
||||||
out = out or sys.stdout
|
|
||||||
evict, protected, still_open = classify(issues, ids)
|
|
||||||
|
|
||||||
gone, kept = {}, []
|
|
||||||
for id in evict:
|
|
||||||
paths = issue.slug_files(root, id) if dry_run else remove(root, id)
|
|
||||||
if not dry_run:
|
|
||||||
gone[id] = paths
|
|
||||||
out.write("%-11s %s\n" % ("would evict" if dry_run else "evicted", id))
|
|
||||||
for p in paths:
|
|
||||||
out.write(" %s\n" % p)
|
|
||||||
for id in protected:
|
|
||||||
kept.append((id, LOCAL_REASON))
|
|
||||||
out.write("%-11s %s closed, %s\n" % ("kept", id, LOCAL_REASON))
|
|
||||||
# An open issue is the normal case and says nothing worth a line — unless
|
|
||||||
# the operator named it, in which case they are owed the reason.
|
|
||||||
for id in still_open:
|
|
||||||
kept.append((id, "state: %s" % issues[id].state))
|
|
||||||
if ids:
|
|
||||||
out.write("%-11s %s state: %s\n" % ("kept", id, issues[id].state))
|
|
||||||
|
|
||||||
if dry_run:
|
|
||||||
out.write("%d issue(s) would be evicted, %d kept — nothing was touched\n"
|
|
||||||
% (len(evict), len(kept)))
|
|
||||||
return gone, kept
|
|
||||||
|
|
||||||
out.write("%d issue(s) evicted, %d kept\n" % (len(gone), len(kept)))
|
|
||||||
# Only when something actually went: the index is a view of the directory,
|
|
||||||
# and rewriting it after a run that changed nothing is a write nobody asked
|
|
||||||
# for.
|
|
||||||
if gone:
|
|
||||||
path, n = issue_index.build(root)
|
|
||||||
out.write("index: %s — %d issue(s)\n" % (path, n))
|
|
||||||
return gone, kept
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv=None):
|
|
||||||
ap = argparse.ArgumentParser(
|
|
||||||
description="Evict closed issues from the local store (offline)")
|
|
||||||
ap.add_argument("ids", nargs="*",
|
|
||||||
help="issue ids (default: every closed issue in the store)")
|
|
||||||
ap.add_argument("--dry-run", action="store_true",
|
|
||||||
help="print what would be removed; touch nothing")
|
|
||||||
ap.add_argument("--out", default=issue.ISSUE_ROOT,
|
|
||||||
help="store root (default: <project>/.tea/issues)")
|
|
||||||
args = ap.parse_args(argv)
|
|
||||||
|
|
||||||
root = args.out
|
|
||||||
if root is None:
|
|
||||||
sys.exit("issue_evict.py: %s" % issue.no_project_error())
|
|
||||||
if not issue.store_exists(root):
|
|
||||||
sys.exit("issue_evict.py: store %s does not exist — nothing to evict" % root)
|
|
||||||
|
|
||||||
issues = issue.load_all(root)
|
|
||||||
missing = [i for i in args.ids if i not in issues]
|
|
||||||
if missing:
|
|
||||||
sys.exit("issue_evict.py: no such issue(s) in the store: %s"
|
|
||||||
% ", ".join(missing))
|
|
||||||
|
|
||||||
run(root, issues, args.ids, args.dry_run)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
issue_index.py — rebuild .tea/issues/INDEX.md from what is on disk. Offline.
|
|
||||||
|
|
||||||
A map of the local store, nothing else. The `origin` column is the only place
|
|
||||||
the index acknowledges that a tracker exists: `local` means the issue has never
|
|
||||||
left this machine, `gitea` means the sync layer has pushed or pulled it. Both
|
|
||||||
are ordinary issues here.
|
|
||||||
|
|
||||||
The store is <project root>/.tea/issues unless --out says otherwise; an existing
|
|
||||||
store with nothing in it gets an "_empty_" table, a store that is not there is
|
|
||||||
an error rather than a directory to create.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
issue_index.py [--out DIR]
|
|
||||||
"""
|
|
||||||
import argparse
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
||||||
import issue # noqa: E402
|
|
||||||
|
|
||||||
|
|
||||||
def cell(v):
|
|
||||||
if isinstance(v, (list, tuple)):
|
|
||||||
return ", ".join(str(x) for x in v) or "—"
|
|
||||||
v = str(v or "").strip()
|
|
||||||
return v.replace("|", "\\|") or "—"
|
|
||||||
|
|
||||||
|
|
||||||
def progress(body):
|
|
||||||
"""`3/7` for a body with checkboxes, "" for one without.
|
|
||||||
|
|
||||||
Counted from the body every time the index is built and stored nowhere —
|
|
||||||
the boxes are the state, and a second copy of it in a metadata field would
|
|
||||||
be wrong by the next edit."""
|
|
||||||
done, total = issue.checkbox_progress(body)
|
|
||||||
return "%d/%d" % (done, total) if total else ""
|
|
||||||
|
|
||||||
|
|
||||||
def build(root):
|
|
||||||
# An index of a store that is not there is not an empty index, it is a bad
|
|
||||||
# path. Raising beats writing INDEX.md into a directory nobody asked for.
|
|
||||||
issue.require_store(root)
|
|
||||||
issues = issue.load_all(root)
|
|
||||||
rows = []
|
|
||||||
for i in sorted(issues):
|
|
||||||
iss = issues[i]
|
|
||||||
rest = [l for l in iss.labels if not l.startswith("type/")]
|
|
||||||
rows.append({
|
|
||||||
"id": i,
|
|
||||||
"state": cell(iss.state),
|
|
||||||
"progress": progress(iss.body),
|
|
||||||
"type": cell(iss.type),
|
|
||||||
"labels": cell(rest),
|
|
||||||
"title": cell(iss.title),
|
|
||||||
"milestone": cell(iss.milestone),
|
|
||||||
"depends": cell(iss.depends),
|
|
||||||
"origin": cell(iss.origin),
|
|
||||||
})
|
|
||||||
|
|
||||||
listing = os.listdir(root) if os.path.isdir(root) else []
|
|
||||||
trees = sorted(f for f in listing if re.match(r'^tree-.+\.md$', f))
|
|
||||||
|
|
||||||
out = ["# Issue store", "",
|
|
||||||
"Every issue this project knows about. `origin: local` means it "
|
|
||||||
"exists nowhere else — a complete state, not a pending one. Any "
|
|
||||||
"other value names the tracker it also lives in; the handle is in "
|
|
||||||
"the file. `progress` counts the body's checkboxes, ticked over "
|
|
||||||
"total, and is blank for an issue that has none — read off the "
|
|
||||||
"body at build time, stored nowhere. Rebuild with `issue_index.py`; "
|
|
||||||
"tick a box with `issue_ac.py`.", ""]
|
|
||||||
if rows:
|
|
||||||
out += ["| id | state | progress | type | labels | title | milestone | depends | origin |",
|
|
||||||
"|---|---|---|---|---|---|---|---|---|"]
|
|
||||||
out += ["| [%s](%s.md) | %s | %s | %s | %s | %s | %s | %s | %s |" % (
|
|
||||||
r["id"], r["id"], r["state"], r["progress"], r["type"], r["labels"],
|
|
||||||
r["title"], r["milestone"], r["depends"], r["origin"]) for r in rows]
|
|
||||||
else:
|
|
||||||
out.append("_empty_")
|
|
||||||
|
|
||||||
if trees:
|
|
||||||
out += ["", "## Dependency trees", ""]
|
|
||||||
out += ["- [%s](%s)" % (t, t) for t in trees]
|
|
||||||
|
|
||||||
cycles = issue.find_cycles(issue.graph(issues))
|
|
||||||
if cycles:
|
|
||||||
out += ["", "## Dependency cycles", ""]
|
|
||||||
out += ["- %s" % " -> ".join(c) for c in cycles]
|
|
||||||
|
|
||||||
out.append("")
|
|
||||||
path = os.path.join(root, "INDEX.md")
|
|
||||||
with open(path, "w") as f:
|
|
||||||
f.write("\n".join(out))
|
|
||||||
return path, len(rows)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
ap = argparse.ArgumentParser(description="Rebuild the local issue index (offline)")
|
|
||||||
ap.add_argument("--out", default=issue.ISSUE_ROOT,
|
|
||||||
help="store root (default: <project>/.tea/issues)")
|
|
||||||
args = ap.parse_args()
|
|
||||||
# An existing store with nothing in it is a legitimate thing to index — it
|
|
||||||
# gets an "_empty_" table. A store that is not there is not.
|
|
||||||
try:
|
|
||||||
path, n = build(args.out)
|
|
||||||
except issue.StoreMissing as e:
|
|
||||||
sys.exit("issue_index.py: %s — nothing was created; create an issue with "
|
|
||||||
"issue_new.py, or pass --out" % e)
|
|
||||||
print("%s — %d issue(s)" % (path, n))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
issue_init.py — make this project one that tracks issues. Offline.
|
|
||||||
|
|
||||||
issue_init.py initialize the current directory
|
|
||||||
issue_init.py --at ~/code/x initialize somewhere else
|
|
||||||
issue_init.py --dry-run say what it would do, touch nothing
|
|
||||||
|
|
||||||
Creates `.tea/` — the marker every other script resolves the store from. The
|
|
||||||
marker is deliberately something an operator makes, not something inferred from
|
|
||||||
the tree: `.git` is in every clone including this plugin's own, so a plugin that
|
|
||||||
inferred its root from one wrote issues into itself. See issue.py's docstring.
|
|
||||||
|
|
||||||
Initializing is therefore a statement, and the only one that matters here:
|
|
||||||
*this* directory is the project whose issues live in it. It is answered once,
|
|
||||||
by a person, and every script downstream reads the answer instead of guessing.
|
|
||||||
|
|
||||||
What it does, all of it idempotent:
|
|
||||||
|
|
||||||
- creates `.tea/issues/` and `.tea/payload/`
|
|
||||||
- moves an existing `tmp/issues/` and `tmp/payload/` in, if it finds them
|
|
||||||
- adds `.tea/` to `.gitignore`
|
|
||||||
|
|
||||||
The move is the migration off the old layout and it is a move, not a copy: two
|
|
||||||
stores is the state this whole change exists to prevent, and a store left
|
|
||||||
behind at the old path is a store somebody will edit by accident. It refuses to
|
|
||||||
overwrite — if both locations hold a file of the same name, it stops and says
|
|
||||||
so rather than picking a winner.
|
|
||||||
|
|
||||||
`.tea/` is gitignored because an `origin: local` issue is the only copy of that
|
|
||||||
work and the operator, not this script, decides what goes in a shared history.
|
|
||||||
Committing the store is a legitimate choice — drop the line if you make it.
|
|
||||||
"""
|
|
||||||
import argparse
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
import sys
|
|
||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
||||||
import issue # noqa: E402
|
|
||||||
|
|
||||||
LEGACY = {"issues": os.path.join("tmp", "issues"),
|
|
||||||
"payload": os.path.join("tmp", "payload")}
|
|
||||||
|
|
||||||
|
|
||||||
def gitignore_lines(path):
|
|
||||||
if not os.path.isfile(path):
|
|
||||||
return []
|
|
||||||
with open(path) as f:
|
|
||||||
return [line.rstrip("\n") for line in f]
|
|
||||||
|
|
||||||
|
|
||||||
def add_to_gitignore(path, entry, dry_run=False):
|
|
||||||
"""Append `entry` unless some line already ignores it. True when written."""
|
|
||||||
lines = gitignore_lines(path)
|
|
||||||
if any(line.strip().rstrip("/") == entry.rstrip("/") for line in lines):
|
|
||||||
return False
|
|
||||||
if dry_run:
|
|
||||||
return True
|
|
||||||
trailer = "" if not lines or lines[-1] == "" else "\n"
|
|
||||||
with open(path, "a") as f:
|
|
||||||
f.write("%s%s\n" % (trailer, entry))
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def migrate(src, dst, dry_run=False):
|
|
||||||
"""Move the contents of `src` into `dst`. Returns what it moved, or None.
|
|
||||||
|
|
||||||
Contents, not the directory, so an already-created destination is not a
|
|
||||||
reason to refuse. A name that exists on both sides is: that is two versions
|
|
||||||
of one issue, and which one survives is not a decision a migration gets to
|
|
||||||
make quietly."""
|
|
||||||
if not os.path.isdir(src):
|
|
||||||
return None
|
|
||||||
names = sorted(os.listdir(src))
|
|
||||||
if not names:
|
|
||||||
return []
|
|
||||||
clashes = [n for n in names if os.path.exists(os.path.join(dst, n))]
|
|
||||||
if clashes:
|
|
||||||
sys.exit("issue_init.py: %s and %s both hold %s — move or delete one "
|
|
||||||
"side first; nothing was changed"
|
|
||||||
% (src, dst, ", ".join(clashes[:5])
|
|
||||||
+ (" (+%d more)" % (len(clashes) - 5) if len(clashes) > 5 else "")))
|
|
||||||
if dry_run:
|
|
||||||
return names
|
|
||||||
os.makedirs(dst, exist_ok=True)
|
|
||||||
for n in names:
|
|
||||||
shutil.move(os.path.join(src, n), os.path.join(dst, n))
|
|
||||||
try:
|
|
||||||
os.rmdir(src) # only when we emptied it
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
return names
|
|
||||||
|
|
||||||
|
|
||||||
def run(root, dry_run=False):
|
|
||||||
"""Initialize `root`. Returns a list of lines describing what happened."""
|
|
||||||
done = []
|
|
||||||
marker = os.path.join(root, issue.MARKER)
|
|
||||||
fresh = not os.path.isdir(marker)
|
|
||||||
|
|
||||||
for name in ("issues", "payload"):
|
|
||||||
d = os.path.join(marker, name)
|
|
||||||
if not os.path.isdir(d):
|
|
||||||
if not dry_run:
|
|
||||||
os.makedirs(d)
|
|
||||||
done.append("created %s" % os.path.join(issue.MARKER, name))
|
|
||||||
|
|
||||||
for name, legacy in LEGACY.items():
|
|
||||||
src = os.path.join(root, legacy)
|
|
||||||
moved = migrate(src, os.path.join(marker, name), dry_run)
|
|
||||||
if moved:
|
|
||||||
done.append("moved %d file(s) from %s to %s"
|
|
||||||
% (len(moved), legacy, os.path.join(issue.MARKER, name)))
|
|
||||||
elif moved == []:
|
|
||||||
done.append("%s was empty — nothing to move" % legacy)
|
|
||||||
|
|
||||||
if add_to_gitignore(os.path.join(root, ".gitignore"),
|
|
||||||
issue.MARKER + "/", dry_run):
|
|
||||||
done.append("added %s/ to .gitignore" % issue.MARKER)
|
|
||||||
|
|
||||||
if not done:
|
|
||||||
done.append("already initialized — nothing to do")
|
|
||||||
elif fresh:
|
|
||||||
done.append("%s now tracks issues in %s/issues"
|
|
||||||
% (root, issue.MARKER))
|
|
||||||
return done
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
ap = argparse.ArgumentParser(
|
|
||||||
description="Create the .tea/ marker that makes a directory a project")
|
|
||||||
ap.add_argument("--at", default=os.getcwd(),
|
|
||||||
help="directory to initialize (default: cwd)")
|
|
||||||
ap.add_argument("--dry-run", action="store_true",
|
|
||||||
help="report what would happen; change nothing")
|
|
||||||
args = ap.parse_args()
|
|
||||||
|
|
||||||
root = os.path.abspath(args.at)
|
|
||||||
if not os.path.isdir(root):
|
|
||||||
sys.exit("issue_init.py: %s is not a directory" % root)
|
|
||||||
|
|
||||||
existing = issue.project_root(root)
|
|
||||||
if existing and existing != root:
|
|
||||||
sys.stderr.write(
|
|
||||||
"warning: %s already sits inside the project at %s — a second "
|
|
||||||
"marker here gives it a second store, and the nearer one wins.\n"
|
|
||||||
% (root, existing))
|
|
||||||
|
|
||||||
for line in run(root, args.dry_run):
|
|
||||||
print(("would: " if args.dry_run else "") + line)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -1,207 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
issue_new.py — create an issue in the local store. Offline, always.
|
|
||||||
|
|
||||||
The issue is real the moment this writes the file. Nothing is pending, nothing
|
|
||||||
is a draft awaiting a tracker: `origin: local` is a complete state and pushing
|
|
||||||
it to Gitea later (see /tea:sync) is optional.
|
|
||||||
|
|
||||||
While it says `local`, this file is the ONLY copy of the work — the store, not
|
|
||||||
a cache of anything. That is what a push changes: it hands the issue to the
|
|
||||||
tracker and removes the file.
|
|
||||||
|
|
||||||
issue_new.py --type task --title "Wire sqlc into the appclick repo layer" \
|
|
||||||
--label tech/sql --label comp/appclick
|
|
||||||
|
|
||||||
issue_new.py --type bug --title "Fix tea-guard crash on empty settings" \
|
|
||||||
--depends wire-sqlc-appclick --milestone v0.2
|
|
||||||
|
|
||||||
Writes .tea/issues/<slug>.md prefilled with the type's template, prints the
|
|
||||||
path, and rebuilds INDEX.md. Fill the sections in an editor or with Edit; run
|
|
||||||
issue_check.py when done.
|
|
||||||
|
|
||||||
Body prose is Russian, section headers and the title are English — see
|
|
||||||
../references/format.md.
|
|
||||||
"""
|
|
||||||
import argparse
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
||||||
import issue # noqa: E402
|
|
||||||
import issue_index # noqa: E402
|
|
||||||
|
|
||||||
SPEC = """## Spec
|
|
||||||
none
|
|
||||||
"""
|
|
||||||
|
|
||||||
TEMPLATES = {
|
|
||||||
"bug": """## Summary
|
|
||||||
Что сломано и где проявляется, одно-два предложения.
|
|
||||||
|
|
||||||
""" + SPEC + """
|
|
||||||
## Steps to reproduce
|
|
||||||
1. …
|
|
||||||
2. …
|
|
||||||
|
|
||||||
## Expected
|
|
||||||
Что должно было произойти.
|
|
||||||
|
|
||||||
## Actual
|
|
||||||
Что происходит на самом деле: вывод команды, лог.
|
|
||||||
|
|
||||||
## Environment
|
|
||||||
Только релевантное: версии, ОС, конфигурация.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
- [ ] баг не воспроизводится по шагам выше
|
|
||||||
- [ ] добавлена проверка на регрессию (если применимо)
|
|
||||||
""",
|
|
||||||
"task": """## Summary
|
|
||||||
Что нужно сделать, одно-два предложения.
|
|
||||||
|
|
||||||
""" + SPEC + """
|
|
||||||
## Motivation
|
|
||||||
Какую проблему пользователя/системы это решает.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
- [ ] проверяемое условие
|
|
||||||
- [ ] …
|
|
||||||
""",
|
|
||||||
"refactor": """## Summary
|
|
||||||
Что перестраиваем и в каких файлах (`path/file:line`).
|
|
||||||
|
|
||||||
""" + SPEC + """
|
|
||||||
## Motivation
|
|
||||||
Чем плохо текущее состояние: дублирование, связность, читаемость.
|
|
||||||
|
|
||||||
## Invariants
|
|
||||||
Что НЕ должно измениться: поведение, публичные API, форматы данных.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
- [ ] проверяемое условие (тесты зелёные, старый путь удалён, …)
|
|
||||||
""",
|
|
||||||
"test": """## Summary
|
|
||||||
Что покрываем тестами и где (`path/file:line`).
|
|
||||||
|
|
||||||
""" + SPEC + """
|
|
||||||
## Motivation
|
|
||||||
Зачем: регрессия после бага, пробел в покрытии, флаки-тест.
|
|
||||||
|
|
||||||
## Test cases
|
|
||||||
- сценарий → ожидаемый результат
|
|
||||||
- …
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
- [ ] перечисленные кейсы покрыты и зелёные
|
|
||||||
- [ ] тесты проходят в CI
|
|
||||||
""",
|
|
||||||
"feature": """## Summary
|
|
||||||
Бизнес-ценность одним-двумя предложениями.
|
|
||||||
|
|
||||||
""" + SPEC + """
|
|
||||||
## Motivation
|
|
||||||
Какую проблему пользователя/системы это решает.
|
|
||||||
|
|
||||||
## Issues
|
|
||||||
- [ ] slug-дочернего-issue — краткое описание части
|
|
||||||
- [ ] …
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
- [ ] все дочерние issues закрыты
|
|
||||||
- [ ] проверяемое условие уровня фичи
|
|
||||||
""",
|
|
||||||
"draft": """## Summary
|
|
||||||
Идея одним-двумя предложениями.
|
|
||||||
|
|
||||||
""" + SPEC + """
|
|
||||||
## Notes
|
|
||||||
Свободные заметки: что известно, открытые вопросы, варианты.
|
|
||||||
""",
|
|
||||||
}
|
|
||||||
|
|
||||||
DEPENDS_BLOCK = """## Depends on
|
|
||||||
%s
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def with_depends(body, depends):
|
|
||||||
"""Insert `## Depends on` right after `## Spec`, per the format."""
|
|
||||||
if not depends:
|
|
||||||
return body
|
|
||||||
block = DEPENDS_BLOCK % "\n".join("- %s" % d for d in depends)
|
|
||||||
lines, out, placed = body.splitlines(True), [], False
|
|
||||||
for line in lines:
|
|
||||||
if not placed and line.startswith("## ") and not line.startswith("## Summary") \
|
|
||||||
and not line.startswith("## Spec") and out:
|
|
||||||
out.append(block + "\n")
|
|
||||||
placed = True
|
|
||||||
out.append(line)
|
|
||||||
if not placed:
|
|
||||||
out.append("\n" + block)
|
|
||||||
return "".join(out)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
ap = argparse.ArgumentParser(description="Create a local issue from its type template")
|
|
||||||
ap.add_argument("--type", required=True, choices=sorted(issue.TYPES),
|
|
||||||
help="issue type (becomes the exclusive type/* label)")
|
|
||||||
ap.add_argument("--title", required=True, help="English, imperative, no type prefix")
|
|
||||||
ap.add_argument("--id", help="slug (default: derived from the title)")
|
|
||||||
ap.add_argument("--label", action="append", default=[],
|
|
||||||
help="extra label, e.g. tech/sql; repeat")
|
|
||||||
ap.add_argument("--severity", choices=list(issue.SEVERITIES), help="severity/* label")
|
|
||||||
ap.add_argument("--milestone", default="", help="milestone title")
|
|
||||||
ap.add_argument("--assignee", action="append", default=[], help="assignee; repeat")
|
|
||||||
ap.add_argument("--depends", action="append", default=[],
|
|
||||||
help="id this issue depends on; repeat")
|
|
||||||
ap.add_argument("--out", default=issue.ISSUE_ROOT,
|
|
||||||
help="store root (default: <project>/.tea/issues)")
|
|
||||||
args = ap.parse_args()
|
|
||||||
|
|
||||||
# Before anything reads the store path — slug collision, dependency check.
|
|
||||||
# There is no store to be second-guessed about when there is no project.
|
|
||||||
if args.out is None:
|
|
||||||
sys.exit("issue_new.py: %s" % issue.no_project_error())
|
|
||||||
|
|
||||||
labels = ["type/%s" % args.type]
|
|
||||||
if args.severity:
|
|
||||||
labels.append("severity/%s" % args.severity)
|
|
||||||
labels += [l for l in args.label if l not in labels]
|
|
||||||
|
|
||||||
id = args.id or issue.unique_id(args.out, issue.slugify(args.title))
|
|
||||||
if args.id and not issue.SLUG_OK.match(args.id):
|
|
||||||
sys.exit("issue_new.py: --id %r is not a slug (lowercase, digits, single dashes)"
|
|
||||||
% args.id)
|
|
||||||
if os.path.exists(issue.path_of(args.out, id)):
|
|
||||||
sys.exit("issue_new.py: %s already exists" % issue.path_of(args.out, id))
|
|
||||||
|
|
||||||
known = set(issue.all_ids(args.out))
|
|
||||||
for d in args.depends:
|
|
||||||
if d not in known:
|
|
||||||
sys.stderr.write("warning: depends on %r, which is not in the store yet\n" % d)
|
|
||||||
|
|
||||||
iss = issue.Issue(
|
|
||||||
id=id, title=args.title,
|
|
||||||
body=with_depends(TEMPLATES[args.type], args.depends),
|
|
||||||
labels=labels, assignees=args.assignee, milestone=args.milestone,
|
|
||||||
depends=args.depends)
|
|
||||||
|
|
||||||
# The first issue in a fresh checkout has to create the store, but it says
|
|
||||||
# so — and it says where, because the path is absolute. A store it cannot
|
|
||||||
# place at all is a different answer: creating one is only ever allowed
|
|
||||||
# inside a project somebody initialized.
|
|
||||||
try:
|
|
||||||
if issue.create_store(args.out):
|
|
||||||
sys.stderr.write("created store %s\n" % os.path.abspath(args.out))
|
|
||||||
except issue.StoreMissing as e:
|
|
||||||
sys.exit("issue_new.py: %s" % e)
|
|
||||||
|
|
||||||
path = issue.save(args.out, iss)
|
|
||||||
issue_index.build(args.out)
|
|
||||||
print("%s [type/%s] %s" % (path, args.type, args.title))
|
|
||||||
print("fill the sections, then: issue_check.py %s" % id)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
issue_tree.py — draw the dependency graph of the local store. Offline.
|
|
||||||
|
|
||||||
Edges come from the `depends:` metadata, which is the authoritative edge list;
|
|
||||||
prose in the body is never walked. Because the graph is slugs all the way down,
|
|
||||||
this works identically for issues that were never pushed anywhere.
|
|
||||||
|
|
||||||
issue_tree.py every root (nothing depends on it)
|
|
||||||
issue_tree.py wire-sqlc-appclick one subtree
|
|
||||||
issue_tree.py --depth 2 --write
|
|
||||||
|
|
||||||
Downwards is what this draws (what an issue depends on). The other direction is
|
|
||||||
a grep, not a flag:
|
|
||||||
|
|
||||||
grep -ln 'depends:.*migrate-schema' .tea/issues/*.md
|
|
||||||
"""
|
|
||||||
import argparse
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
||||||
import issue # noqa: E402
|
|
||||||
|
|
||||||
|
|
||||||
def label(id, issues, seen, edges):
|
|
||||||
iss = issues.get(id)
|
|
||||||
if not iss:
|
|
||||||
return "%s (not in the store)" % id
|
|
||||||
tail = " (see above)" if id in seen and edges.get(id) else ""
|
|
||||||
return "%s [%s] %s — %s %s.md%s" % (
|
|
||||||
id, iss.type or "-", iss.title, iss.state, id, tail)
|
|
||||||
|
|
||||||
|
|
||||||
def render(roots, issues, edges, depth):
|
|
||||||
lines, seen = [], set()
|
|
||||||
|
|
||||||
def walk(id, prefix, is_last, is_root, level):
|
|
||||||
connector = "" if is_root else ("└── " if is_last else "├── ")
|
|
||||||
lines.append(prefix + connector + label(id, issues, seen, edges))
|
|
||||||
if id in seen or level >= depth:
|
|
||||||
return
|
|
||||||
seen.add(id)
|
|
||||||
kids = edges.get(id) or []
|
|
||||||
child_prefix = prefix if is_root else prefix + (" " if is_last else "│ ")
|
|
||||||
for i, k in enumerate(kids):
|
|
||||||
walk(k, child_prefix, i == len(kids) - 1, False, level + 1)
|
|
||||||
|
|
||||||
for r in roots:
|
|
||||||
if r in seen:
|
|
||||||
continue # already drawn as somebody's child — one tree, not two
|
|
||||||
walk(r, "", True, True, 0)
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
head = roots[0] if len(roots) == 1 else "%d root(s)" % len(roots)
|
|
||||||
out = "# Dependency tree — %s\n\n```\n%s```\n" % (head, "\n".join(lines))
|
|
||||||
cycles = issue.find_cycles(edges)
|
|
||||||
if cycles:
|
|
||||||
out += "\n## Cycles\n\n" + "\n".join("- %s" % " -> ".join(c) for c in cycles) + "\n"
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
ap = argparse.ArgumentParser(description="Draw the local dependency graph (offline)")
|
|
||||||
ap.add_argument("ids", nargs="*", help="roots (default: issues nothing depends on)")
|
|
||||||
ap.add_argument("--depth", type=int, default=6, help="max depth (default: 6)")
|
|
||||||
ap.add_argument("--write", action="store_true",
|
|
||||||
help="also write .tea/issues/tree-<slug>.md")
|
|
||||||
ap.add_argument("--out", default=issue.ISSUE_ROOT,
|
|
||||||
help="store root (default: <project>/.tea/issues)")
|
|
||||||
args = ap.parse_args()
|
|
||||||
|
|
||||||
problem = issue.store_error(args.out)
|
|
||||||
if problem:
|
|
||||||
sys.exit("issue_tree.py: %s" % problem)
|
|
||||||
|
|
||||||
issues = issue.load_all(args.out)
|
|
||||||
edges = issue.graph(issues)
|
|
||||||
|
|
||||||
roots = args.ids
|
|
||||||
for r in roots:
|
|
||||||
if r not in issues:
|
|
||||||
sys.exit("issue_tree.py: no issue %r in %s" % (r, args.out))
|
|
||||||
if not roots:
|
|
||||||
depended_on = {d for deps in edges.values() for d in deps}
|
|
||||||
roots = sorted(i for i in issues if i not in depended_on) or sorted(issues)
|
|
||||||
|
|
||||||
text = render(roots, issues, edges, args.depth)
|
|
||||||
sys.stdout.write(text)
|
|
||||||
if args.write:
|
|
||||||
slug = roots[0] if len(roots) == 1 else "all"
|
|
||||||
path = os.path.join(args.out, "tree-%s.md" % slug)
|
|
||||||
with open(path, "w") as f:
|
|
||||||
f.write(text)
|
|
||||||
print("written: %s" % path)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,542 +0,0 @@
|
|||||||
---
|
|
||||||
name: sync
|
|
||||||
description: Move issues between the local store and Gitea — pull issues into .tea/issues/, push local issues up, post comments, close and reopen them. Load when the user asks to fetch/read a Gitea issue, publish an issue, list what exists in the tracker, comment on one, or close/reopen one. Working with an issue's content (writing, grepping, validating, dependency graph) is /tea:issue and needs no network.
|
|
||||||
---
|
|
||||||
|
|
||||||
# /tea:sync — the bridge between the local store and Gitea
|
|
||||||
|
|
||||||
One job: translate between `.tea/issues/<id>.md` and Gitea's JSON, and carry the
|
|
||||||
result over the wire. Everything about **what an issue is** — format, types,
|
|
||||||
validation, the dependency graph — belongs to `/tea:issue` and is imported from
|
|
||||||
there, never redefined here.
|
|
||||||
|
|
||||||
Direction of knowledge, and it is one-way:
|
|
||||||
|
|
||||||
```
|
|
||||||
skills/issue domain what an issue is offline, no tracker
|
|
||||||
▲
|
|
||||||
│ imports
|
|
||||||
skills/sync bridge map.py md <-> Gitea JSON, pure, no I/O
|
|
||||||
_gitea.py login, tea api, pagination, filters
|
|
||||||
```
|
|
||||||
|
|
||||||
`skills/issue` never imports anything from here.
|
|
||||||
|
|
||||||
## Never read an issue through raw `tea`
|
|
||||||
|
|
||||||
`tea issues <n> -o json` and `tea api .../issues/<n>` dump the full payload —
|
|
||||||
avatars, nested user objects, every comment body — into your context whether
|
|
||||||
you need it or not. Use `pull.py`: it writes flat markdown and prints a compact
|
|
||||||
index.
|
|
||||||
|
|
||||||
## Scripts
|
|
||||||
|
|
||||||
In `<skill-base-dir>/scripts/`. None of them take `--login`: they resolve the
|
|
||||||
operator's pin from `.claude/settings.local.json` through
|
|
||||||
`skills/auth/scripts/pin.py` — the same *function* the `tea-guard` hook calls,
|
|
||||||
not merely the same file, so a directory where `tea` works is a directory where
|
|
||||||
these work. That includes a **git worktree**, whose untracked pin sits in the
|
|
||||||
main checkout: the search crosses to it through the `gitdir:` in `.git`, and
|
|
||||||
there is nothing to pin a second time. No pin anywhere → exit with a pointer to
|
|
||||||
`/tea:auth`.
|
|
||||||
|
|
||||||
| Script | What it does |
|
|
||||||
|---|---|
|
|
||||||
| `remote.py [--state] [--label] [--milestone] [-q TEXT] [--limit N]` | discovery: one line per Gitea issue to stdout, writes nothing; `--limit` caps the **listing** (default 30) |
|
|
||||||
| `pull.py <key…>` or `pull.py --milestone M \| --label L \| -q TEXT [--limit N]` | Gitea → `.tea/issues/<id>.md`, plus `<id>.comments.md` when the thread is not empty; follows dependencies by default (`--no-deps` to stop); `--limit` caps what is **stored** (default 100) |
|
|
||||||
| `push.py [id…] [--update] [--dry-run]` | local → Gitea; validates first, **deletes the local file on success** and prints where it lives now |
|
|
||||||
| `evict.py [id…] [--dry-run]` | refresh `state:` from Gitea, then evict the issues it reports closed; `origin: local` is never asked about and never removed |
|
|
||||||
| `comment.py <id> --file F \| --body TEXT [--edit N]` | post or edit a comment, then refetch the thread |
|
|
||||||
| `close.py <id…> [--reopen] [--dry-run]` | set `state` in Gitea and in the local copy with it; explicit ids only, no bulk filter |
|
|
||||||
| `labels.py [--dry-run] [--fix]` | bootstrap the canonical `type/*` + `severity/*` set in a repo; exact names left alone, lookalikes reported, drift fixed only with `--fix` |
|
|
||||||
| `map.py`, `_gitea.py` | the two layers the commands import — not commands |
|
|
||||||
|
|
||||||
Key forms for `<key>`: `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.
|
|
||||||
|
|
||||||
`--out` defaults to `issue.ISSUE_ROOT` on every one of them — the domain
|
|
||||||
layer's `<project root>/.tea/issues`, found by walking up from the working
|
|
||||||
directory to the nearest `.tea/` marker. Both layers therefore address the same
|
|
||||||
store by construction, from any directory. Pass `--out` to override; a relative
|
|
||||||
one stays relative to cwd. Only `pull.py` will create a missing store, and it
|
|
||||||
says so on stderr.
|
|
||||||
|
|
||||||
A project with no marker is not a project these scripts will write into: they
|
|
||||||
stop and name the directories they searched. Run `/tea:issue`'s
|
|
||||||
`issue_init.py` in it first.
|
|
||||||
|
|
||||||
## Identity mapping
|
|
||||||
|
|
||||||
The local id is a slug; Gitea's is a number. While a working copy exists, the
|
|
||||||
pair is in the file:
|
|
||||||
|
|
||||||
```
|
|
||||||
origin: gitea
|
|
||||||
gitea: claude-skills/tea#42
|
|
||||||
url: https://git.noodles.cam/claude-skills/tea/issues/42
|
|
||||||
synced: 2026-08-09T18:40:00Z
|
|
||||||
```
|
|
||||||
|
|
||||||
But the file is deleted on push, so the pair also lives in two places that
|
|
||||||
outlast it: `.tea/issues/.remote.json` (number → slug) and the `<!-- tea:id … -->`
|
|
||||||
marker in the issue body on the Gitea side. See [How the slug comes
|
|
||||||
back](#how-the-slug-comes-back).
|
|
||||||
|
|
||||||
`.remote.json` used to be described as an index over the files. It is not one
|
|
||||||
any more — the files are a subset of what it knows, and its entries deliberately
|
|
||||||
outlive them. It is the local **ledger**, and `_gitea.rebuild_map` merges into it
|
|
||||||
rather than reconstructing it, so a rebuild can never drop a pushed issue.
|
|
||||||
Nothing prunes it: "no file" no longer means "no such issue". Delete it anyway
|
|
||||||
and nothing is lost — the next pull reads the slug off the marker and writes the
|
|
||||||
entry back.
|
|
||||||
|
|
||||||
A retitled issue keeps its slug: neither record is keyed by the title.
|
|
||||||
|
|
||||||
## Pulling
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 <skill-base-dir>/scripts/pull.py 42
|
|
||||||
python3 <skill-base-dir>/scripts/pull.py --milestone 6 # id or title
|
|
||||||
python3 <skill-base-dir>/scripts/pull.py --label type/bug --state all
|
|
||||||
python3 <skill-base-dir>/scripts/pull.py -q sqlc --limit 20
|
|
||||||
python3 <skill-base-dir>/scripts/pull.py 40 --no-deps # this issue only
|
|
||||||
```
|
|
||||||
|
|
||||||
Do not loop over numbers to pull a group — pass the filter. The list endpoint
|
|
||||||
carries the issue bodies, so a milestone costs **one request per 50 issues**,
|
|
||||||
not one per issue. Filters AND together; `--state` defaults to `open`;
|
|
||||||
`--limit` to 100. Keys and filters are mutually exclusive.
|
|
||||||
|
|
||||||
**A pull is how a pushed issue comes back.** Push deleted the file, so this is
|
|
||||||
not refreshing a copy you kept — it is how the copy comes to exist. It lands
|
|
||||||
under the same slug it had before, even after a rename in Gitea and even on a
|
|
||||||
machine that has never seen the issue; see [How the slug comes
|
|
||||||
back](#how-the-slug-comes-back).
|
|
||||||
|
|
||||||
**A pull overwrites the local body.** It is a fetch, not a merge — unpushed
|
|
||||||
local edits are lost, with one exception: [checkbox
|
|
||||||
state](#checkboxes-are-the-one-exception). `--cached` skips issues already on
|
|
||||||
disk.
|
|
||||||
|
|
||||||
**Closed issues stay out of the store.** In filter mode they are enumerated
|
|
||||||
but not written: `--state all` still shows the whole picture, only `--state
|
|
||||||
closed` puts one on disk, and the number left out goes to stderr. An issue
|
|
||||||
already on disk is refreshed either way — the local copy learns it was closed
|
|
||||||
instead of staying open forever. Key mode is exempt: `pull.py 1` fetches a
|
|
||||||
closed issue as always, because an address is not a bulk read.
|
|
||||||
|
|
||||||
**`--limit N` bounds the write, not the selection.** N is how many issues this
|
|
||||||
run leaves in the store — written, or left in place by `--cached`. Closed ones
|
|
||||||
that were enumerated and thrown away do not spend it, so `--limit 20` over a
|
|
||||||
milestone whose first 30 issues are closed still writes 20, as long as 20 open
|
|
||||||
ones are there to write. Pagination follows the budget rather than the other way
|
|
||||||
round:
|
|
||||||
|
|
||||||
| | |
|
|
||||||
|---|---|
|
|
||||||
| budget full | the next page is never requested |
|
|
||||||
| pages run out | fewer than N, and that is the honest answer |
|
|
||||||
| filter matches almost only closed issues | at most 4× the pages N would need if nothing were dropped, then a warning on stderr and a short answer — raising `--limit` raises that ceiling too |
|
|
||||||
| dependencies | outside the count: a blocker is followed because a stored issue named it, not because the filter selected it — so `--limit 20` can leave more than 20 files behind |
|
|
||||||
|
|
||||||
`remote.py --limit` means something else, deliberately: it caps the **listing**,
|
|
||||||
closed issues included. It writes nothing, so there is no write for a limit to
|
|
||||||
bound — enumeration is its whole job.
|
|
||||||
|
|
||||||
**Comments come with every pull** — there is no flag. An issue that has a
|
|
||||||
thread gets `.tea/issues/<id>.comments.md` beside it, in key mode and in filter
|
|
||||||
mode alike, and the issue's output line says how many. An issue with none
|
|
||||||
costs nothing: the count arrives in the list payload, so no request is made
|
|
||||||
and no file is written — and a file left over from a thread that has since
|
|
||||||
been emptied is deleted. `--cached` skips the thread along with the body, so a
|
|
||||||
skipped issue makes one request for its links and no other.
|
|
||||||
|
|
||||||
**Dependencies come with every pull too, and this one costs.** A pull answers
|
|
||||||
with the unit of work — the issue and what blocks it — so `depends:` is filled
|
|
||||||
from Gitea's native graph and every blocker is pulled as well, recursively, down
|
|
||||||
to `--depth` (default 3). It has to come from the native graph: the body's
|
|
||||||
`## Depends on` section holds slugs, never `#N`, so there is no edge to recover
|
|
||||||
from the text. `--no-deps` turns off both halves. `--deps` is still accepted and
|
|
||||||
does nothing — it names the default.
|
|
||||||
|
|
||||||
| | requests |
|
|
||||||
|---|---|
|
|
||||||
| every issue that lands in the store | **+1** — `GET …/issues/{n}/dependencies`, fetched once and used twice (fills `depends:`, steers the walk) |
|
|
||||||
| every blocker the selection did not already carry | **+1** to fetch it, then its own links, until `--depth` |
|
|
||||||
| a closed issue filter mode drops | 0 — nothing was stored, so there is no unit of work to complete |
|
|
||||||
| `--milestone X` over 50 open issues | 1 list request + 50, plus a pair per outside blocker — it used to be 1 |
|
|
||||||
| the same with `--no-deps` | 1 |
|
|
||||||
|
|
||||||
**A blocker the filter did not select still lands in the store, deliberately.**
|
|
||||||
`--milestone X` can leave an issue from milestone Y on disk; `--label` can leave
|
|
||||||
an unlabelled one. It is there because a stored issue names it, not because it
|
|
||||||
matched. The exception is a closed blocker: closed is not a unit of work, filter
|
|
||||||
mode drops it like any other closed issue, and the `depends:` edge to it goes
|
|
||||||
with it — nothing is left pointing at a file that is not there. Key mode
|
|
||||||
(`pull.py 42`) has no such rule and stores it.
|
|
||||||
|
|
||||||
Two traps this handles for you:
|
|
||||||
|
|
||||||
- **Gitea silently ignores an unresolvable milestone filter** and returns the
|
|
||||||
whole backlog. `pull.py` resolves the milestone first (exiting with the real
|
|
||||||
ones if it does not exist) and re-checks every returned issue locally. Never
|
|
||||||
trust a raw `tea api ...issues?milestones=X` for this.
|
|
||||||
- **Projects are not fetchable.** The projects API is not exposed (404 on
|
|
||||||
Gitea 1.26 for `repos/…/projects`, `orgs/…/projects`, `projects/{id}`). Use
|
|
||||||
milestones or labels; project columns live in the web UI only.
|
|
||||||
|
|
||||||
After a pull, draw the graph with `/tea:issue`'s `issue_tree.py` — offline, no
|
|
||||||
extra requests.
|
|
||||||
|
|
||||||
### Checkboxes are the one exception
|
|
||||||
|
|
||||||
A checkbox is state, not prose, and it is the one thing a pull does **not**
|
|
||||||
overwrite. For a checkbox line whose **text** matches a line in the local copy,
|
|
||||||
`[x]` wins from whichever side has it — tick it in the web UI, tick it locally,
|
|
||||||
tick it in both, the tick survives.
|
|
||||||
|
|
||||||
| part of the body | what a pull does to it |
|
|
||||||
|---|---|
|
|
||||||
| prose, headings, everything not a checkbox | overwritten from the server, whole, as before |
|
|
||||||
| a checkbox whose text is in the local copy | `[x]` from **either** side wins |
|
|
||||||
| a checkbox whose text is not in the local copy | taken from the server as it stands, ticked or not |
|
|
||||||
| any issue the store has never seen | written exactly as the server sent it |
|
|
||||||
|
|
||||||
This is not drift tracking — [Drift](#drift) stands. A tick is **monotone**: an
|
|
||||||
item only travels `[ ]` → `[x]`, so joining the two sides is a set union, not a
|
|
||||||
conflict to resolve. No base version is kept and nothing is compared against
|
|
||||||
one; one rule for one line type replaces the whole mechanism.
|
|
||||||
|
|
||||||
**The price, and it is real: a box unticked in the web UI comes back on the next
|
|
||||||
pull.** Unticking is not monotone, so the union cannot see it. Untick locally,
|
|
||||||
then `push.py --update` — the body goes up whole and the server follows.
|
|
||||||
|
|
||||||
Matching is on the item's text after the domain parser has stripped it and
|
|
||||||
rejoined wrapped lines with single spaces, so rewrapping a long item keeps its
|
|
||||||
tick. Rewording one does not: different text is a different item. The same text
|
|
||||||
twice in a body is read as a set — one ticked local copy ticks every server line
|
|
||||||
with that text.
|
|
||||||
|
|
||||||
The parsing is `/tea:issue`'s (`issue.checkboxes` / `issue.set_checkbox`),
|
|
||||||
imported, never reimplemented here. The rule itself is
|
|
||||||
`map.merge_checkbox_state`: pure, and testable without a Gitea anywhere.
|
|
||||||
|
|
||||||
## Pushing
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 <skill-base-dir>/scripts/push.py --dry-run # validate, no network
|
|
||||||
python3 <skill-base-dir>/scripts/push.py # every local-only issue
|
|
||||||
python3 <skill-base-dir>/scripts/push.py wire-sqlc-appclick
|
|
||||||
python3 <skill-base-dir>/scripts/push.py --update wire-sqlc-appclick # PATCH
|
|
||||||
```
|
|
||||||
|
|
||||||
**A successful push DELETES the local file** — `.tea/issues/<id>.md` and
|
|
||||||
`<id>.comments.md` — and prints the number and URL the issue now lives at:
|
|
||||||
|
|
||||||
```
|
|
||||||
created wire-sqlc-appclick #42 https://git.noodles.cam/claude-skills/tea/issues/42
|
|
||||||
dropped /repo/.tea/issues/wire-sqlc-appclick.md
|
|
||||||
pull.py 42 to work on it again
|
|
||||||
```
|
|
||||||
|
|
||||||
Once the tracker has the issue, the tracker *is* the issue. What is left in the
|
|
||||||
store is what has not left this machine. There is no second copy, so there is
|
|
||||||
nothing to reconcile and no "is mine the fresh one?" to answer — see
|
|
||||||
[Drift](#drift).
|
|
||||||
|
|
||||||
**`--update` deletes too. One rule, no exception.** A PATCH is a push; an issue
|
|
||||||
that has just been sent is no more local than one that was just created. Edit an
|
|
||||||
issue by pulling it, changing it, pushing it — the copy is gone again after.
|
|
||||||
|
|
||||||
### What has to be true before anything is deleted
|
|
||||||
|
|
||||||
In order, and the delete is last:
|
|
||||||
|
|
||||||
1. the transport returned — `tea` ran and exited 0 (a non-2xx exits the run), and
|
|
||||||
2. the answer is an object carrying a positive integer `number`, and on
|
|
||||||
`--update` **the same number that was PATCHed** (`push.confirmed_number`), and
|
|
||||||
3. `.remote.json` has been written with number → slug.
|
|
||||||
|
|
||||||
Network down, a 422, an empty body, an answer for a different issue: the file is
|
|
||||||
still there and the run stops with the path in the error. An `origin: local`
|
|
||||||
issue that was not sent — including a local-only dependency that push only read
|
|
||||||
to warn about — is never touched. `--dry-run` deletes nothing and sends nothing.
|
|
||||||
|
|
||||||
### How the slug comes back
|
|
||||||
|
|
||||||
The slug is the issue's identity and the format promises it is stable for life,
|
|
||||||
so it cannot live only in a file that push is about to delete. Two records, and
|
|
||||||
the durable one is not local:
|
|
||||||
|
|
||||||
| where | survives | how |
|
|
||||||
|---|---|---|
|
|
||||||
| `<!-- tea:id wire-sqlc-appclick -->` | a rename in the web UI, a lost `.remote.json`, a fresh clone, another machine | first line of the **tracker-side** body; an HTML comment, so Gitea renders nothing |
|
|
||||||
| `.tea/issues/.remote.json` | the file being deleted | number → slug, written before the delete |
|
|
||||||
|
|
||||||
`pull.py` consults the ledger first (it is the one that knows about files on
|
|
||||||
disk right now), then the marker, then falls back to slugifying the title for an
|
|
||||||
issue filed in the web UI that has never had a local name. A marker is only
|
|
||||||
taken at its word when that slug is free — it never overwrites an issue already
|
|
||||||
in the store.
|
|
||||||
|
|
||||||
**The marker never appears in the local file.** `map.to_payload` puts exactly
|
|
||||||
one at the top on the way up, `map.from_api` strips every one on the way down.
|
|
||||||
Strip-all-then-prepend-one is the whole mechanism, which is why a body cannot
|
|
||||||
accumulate them however many round trips it makes, and why a body that somehow
|
|
||||||
gained two is cleaned on the next pull.
|
|
||||||
|
|
||||||
`depends:` survives the same round trip through Gitea's native links (below):
|
|
||||||
push writes them, every `pull.py` reads them back, and the ledger turns the
|
|
||||||
numbers into the slugs they had here.
|
|
||||||
|
|
||||||
Before anything is sent, `/tea:issue`'s validator runs (exactly one `type/*`,
|
|
||||||
at most one `severity/*`, English title with no type prefix, `## Summary` /
|
|
||||||
`## Spec` / `## Acceptance criteria` present). `--force` posts anyway — say why
|
|
||||||
when you use it.
|
|
||||||
|
|
||||||
### Dependencies
|
|
||||||
|
|
||||||
Issues go up in topological order, dependencies first, and **the graph goes up
|
|
||||||
with them**. Once an issue has its number, every `depends:` entry that also has
|
|
||||||
one becomes a native Gitea link, so the tracker shows the blocking panel and
|
|
||||||
refuses to close a blocked issue before its blocker.
|
|
||||||
|
|
||||||
The two directions are symmetric, and they use the same endpoint:
|
|
||||||
|
|
||||||
| | direction | endpoint |
|
|
||||||
|---|---|---|
|
|
||||||
| `push.py` | `depends:` → native links | `POST …/issues/{n}/dependencies` |
|
|
||||||
| `pull.py` (default; `--no-deps` off) | native links → `depends:` | `GET …/issues/{n}/dependencies` |
|
|
||||||
|
|
||||||
The POST body is Gitea's `IssueMeta` — `{"index", "owner", "repo"}` naming the
|
|
||||||
**blocker**, posted to the **blocked** issue's endpoint ("make the issue in the
|
|
||||||
url depend on the issue in the form"). `owner`/`repo` travel with it, so a
|
|
||||||
dependency in another repo links correctly.
|
|
||||||
|
|
||||||
- Topological order means the blocker already has its number — no second pass.
|
|
||||||
- A link the tracker already has is skipped: push GETs the existing ones first,
|
|
||||||
so a repeat push is a no-op and a 409 never happens. Should a link fail
|
|
||||||
anyway, it is a warning, not a dead run — the issues are already created.
|
|
||||||
- `--update` carries links that appeared in `depends:` after the first push.
|
|
||||||
- `--dry-run` prints every link it would make (`#?` for a number this run has
|
|
||||||
not handed out yet) and makes no request at all.
|
|
||||||
- **Removing a link is out of scope.** Push only adds. A dependency deleted
|
|
||||||
from `depends:` leaves its Gitea link standing; drop it in the web UI or with
|
|
||||||
`tea api -X DELETE …/issues/N/dependencies`.
|
|
||||||
|
|
||||||
A dependency that is still local-only is reported, not silently dropped: it has
|
|
||||||
no number, so it gets no link. The body's `## Depends on` prose is sent verbatim
|
|
||||||
either way — nothing is lost, but the tracker shows no edge until that issue is
|
|
||||||
pushed too.
|
|
||||||
|
|
||||||
Missing labels are created with the canonical color and, for `type/*` and
|
|
||||||
`severity/*`, `exclusive: true` — `tea labels create` cannot set that field
|
|
||||||
(tea 0.14.2), so it goes through `tea api`. Colors live in `map.py`; the names
|
|
||||||
and their meaning come from the domain taxonomy.
|
|
||||||
|
|
||||||
That is per-push and piecemeal: a repo only ever grows the labels its issues
|
|
||||||
happened to use, so filtering by `type/bug` in the web UI stays impossible
|
|
||||||
until someone pushes a bug. `labels.py` lays down the whole set — the 11
|
|
||||||
`type/*` and `severity/*` names — in one run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 <skill-base-dir>/scripts/labels.py --dry-run # the plan, no writes
|
|
||||||
python3 <skill-base-dir>/scripts/labels.py # create what is missing
|
|
||||||
```
|
|
||||||
|
|
||||||
It reads the repo's labels first. An exactly-matching name is never re-created
|
|
||||||
and never patched. A **lookalike** — `bug`, `Bug`, `type: bug`, `kind/bug` —
|
|
||||||
is reported with its id and left alone: renaming somebody else's label is a
|
|
||||||
decision, not a migration. A color or `exclusive` that drifted is printed, and
|
|
||||||
changed only under `--fix`. Running it twice creates nothing. `tech/*` and
|
|
||||||
`comp/*` are open-ended by design and stay push-created.
|
|
||||||
|
|
||||||
Labels belong to the repository, not to any issue, so this one runs on a
|
|
||||||
checkout with no store and leaves it that way — nothing here reads `.tea/issues/`
|
|
||||||
and nothing creates it. The request bodies go to `.tea/payload/` (below).
|
|
||||||
|
|
||||||
A milestone must already exist in the repo — push attaches, it does not create.
|
|
||||||
|
|
||||||
`branch:` is Gitea's `ref`, the branch the work actually lives on. Push fills
|
|
||||||
an empty one with the current git branch (`git rev-parse --abbrev-ref HEAD`)
|
|
||||||
and sends it up as `ref`; a value already there is never
|
|
||||||
overwritten, neither on create nor on `--update`. Nothing is written back to
|
|
||||||
the issue file — there is no file left to write to, because a successful push
|
|
||||||
deletes it. The branch comes back on disk with the next `pull.py <n>`, from
|
|
||||||
the tracker. On a detached HEAD or outside
|
|
||||||
a git repo no `ref` is sent and a warning names the issues that went up without
|
|
||||||
one. Reading the branch is the only thing these scripts ask git for — they
|
|
||||||
never check out, create, or write anything.
|
|
||||||
|
|
||||||
The branch comes from the **current directory**, so run `push.py` from the tree
|
|
||||||
the work is on. In a git worktree that is the worktree, and it is now also
|
|
||||||
where the pin resolves from: the old workaround for the pin — run the scripts
|
|
||||||
with cwd in the main checkout — sent the main checkout's branch as `ref`, which
|
|
||||||
is the one thing `branch:` exists to record.
|
|
||||||
|
|
||||||
## Closing and reopening
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 <skill-base-dir>/scripts/close.py wire-sqlc-appclick # by slug
|
|
||||||
python3 <skill-base-dir>/scripts/close.py 42 '#43' # by number
|
|
||||||
python3 <skill-base-dir>/scripts/close.py --reopen 42
|
|
||||||
python3 <skill-base-dir>/scripts/close.py --dry-run 42 43 # no request at all
|
|
||||||
```
|
|
||||||
|
|
||||||
`close.py` is the only supported way to move `state:`. Never hand-roll
|
|
||||||
`tea api -X PATCH -d '{"state":"closed"}' repos/OWNER/REPO/issues/N`: it spells
|
|
||||||
out the owner, the repo and the request body — the three things this layer
|
|
||||||
exists to hide — and it needs a `Bash(tea api *)` permission that also covers
|
|
||||||
`-X DELETE` on the repository.
|
|
||||||
|
|
||||||
**State only.** The payload is `{"state": …}` and nothing else — no title, no
|
|
||||||
body, no labels, no milestone. Closing is not an edit; editing is `pull.py` →
|
|
||||||
change → `push.py --update`.
|
|
||||||
|
|
||||||
**Explicit ids only.** There is no `--milestone` and no `--label`: which issues
|
|
||||||
are finished is a judgement about content, and this script only carries one
|
|
||||||
out, one named id at a time. Deleting an issue is out of scope too — Gitea can,
|
|
||||||
and it is not an operation of this workflow.
|
|
||||||
|
|
||||||
What may be named, and what happens to the local copy:
|
|
||||||
|
|
||||||
| named | resolved through | local file |
|
|
||||||
|---|---|---|
|
|
||||||
| a slug with a file on disk | its `gitea:` field | `state:` rewritten, `synced:` refreshed |
|
|
||||||
| a slug whose file push dropped | `.remote.json` | none to write — say so and move on |
|
|
||||||
| `42`, `#42`, `owner/repo#42`, a URL | the key itself; the ledger supplies the slug | rewritten when a file of that slug is there |
|
|
||||||
| a slug with `origin: local` | — | **refused**: it is not in the tracker, and the error names the id |
|
|
||||||
|
|
||||||
The local file is written only after the tracker has confirmed *this* write: an
|
|
||||||
object carrying the very number that was PATCHed, in the state that was asked
|
|
||||||
for. A non-2xx, a `tea` that would not run, an answer for another issue, a 200
|
|
||||||
that still says `open` — the run stops and the file is byte for byte what it
|
|
||||||
was. `--dry-run` prints the same lines and makes no request at all, so it needs
|
|
||||||
no pinned login.
|
|
||||||
|
|
||||||
Gitea refuses to close an issue that its own dependency graph still blocks. The
|
|
||||||
refusal arrives as a non-2xx with the tracker's own words: close the blockers
|
|
||||||
first, or unlink them in the web UI.
|
|
||||||
|
|
||||||
The index is rebuilt when at least one local file changed, so `INDEX.md` never
|
|
||||||
outlives the state it reports. Nothing is deleted here — unlike a push, a close
|
|
||||||
leaves the working copy where it is.
|
|
||||||
|
|
||||||
## Evicting what the tracker says is closed
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 <skill-base-dir>/scripts/evict.py --dry-run # ask, report, change nothing
|
|
||||||
python3 <skill-base-dir>/scripts/evict.py # and remove them
|
|
||||||
python3 <skill-base-dir>/scripts/evict.py old-thing # just this one
|
|
||||||
```
|
|
||||||
|
|
||||||
Eviction itself belongs to `/tea:issue` (`issue_evict.py`) and is offline: the
|
|
||||||
decision is `state: closed` plus an `origin:` that names a tracker, both read
|
|
||||||
off the file. This script adds one thing in front of it — a `state:` that is not
|
|
||||||
stale — and then calls that same decision. There is one implementation of "what
|
|
||||||
may be evicted" and it is in the domain.
|
|
||||||
|
|
||||||
Why it exists: a local `state:` is only as fresh as the last pull, so an issue
|
|
||||||
closed in the web UI still reads `open` here and the offline command correctly
|
|
||||||
leaves it alone. The workaround was `pull.py 11 12 13 14 15` — which writes the
|
|
||||||
five closed files back to disk before anything can remove them.
|
|
||||||
|
|
||||||
Order of operations, and it is the safety argument:
|
|
||||||
|
|
||||||
1. every candidate's state is fetched — **all** of them, before anything is
|
|
||||||
removed;
|
|
||||||
2. each answer must be an object carrying the number that was asked about and a
|
|
||||||
state the domain recognizes (`evict.confirmed_state`, the counterpart of
|
|
||||||
`push.confirmed_number`);
|
|
||||||
3. only then does the eviction run.
|
|
||||||
|
|
||||||
**A failed call evicts nothing** — not even the candidates whose answers had
|
|
||||||
already arrived, and no refreshed `state:` is written back either. Stricter than
|
|
||||||
push, which deletes as it goes, and free: evictions have no order between them,
|
|
||||||
so there is no reason to start before every answer is in.
|
|
||||||
|
|
||||||
- A **candidate** is an issue carrying a `gitea:` handle. `origin: local` has
|
|
||||||
none, is never asked about, and is never removed. An `origin: gitea` issue
|
|
||||||
whose handle is missing or unparseable cannot be verified — it is reported on
|
|
||||||
stderr and kept.
|
|
||||||
- No `--repo`: the repo comes from each issue's own handle, so a store holding
|
|
||||||
issues from two repos is checked against both.
|
|
||||||
- One GET per candidate. The store is a working set that push keeps small, and a
|
|
||||||
wrong answer here deletes a file — so each issue is asked about by its own
|
|
||||||
address rather than inferred from a list a `--limit` could have truncated.
|
|
||||||
- A state that disagrees with the file is written back, so the store stops lying
|
|
||||||
about the issues that stay too. `--dry-run` makes no writes at all.
|
|
||||||
- `.remote.json` is not pruned; see [How the slug comes
|
|
||||||
back](#how-the-slug-comes-back) — an evicted issue is exactly as findable as a
|
|
||||||
pushed one.
|
|
||||||
- **`pull.py <n>` still fetches a closed issue.** A number is an address, not a
|
|
||||||
query. A closed issue pulled after an eviction is back on disk, and that is
|
|
||||||
the tracker answering the question it was asked, not a regression.
|
|
||||||
|
|
||||||
## What crosses the boundary, and what does not
|
|
||||||
|
|
||||||
| domain | Gitea | note |
|
|
||||||
|---|---|---|
|
|
||||||
| `id` (slug) | `<!-- tea:id … -->` | first line of the tracker-side body; stripped out of the local copy |
|
|
||||||
| title | `title` | verbatim, both directions |
|
|
||||||
| body | `body` | verbatim up except the marker; verbatim down except the marker and checkbox state, which is unioned |
|
|
||||||
| `state` | `state` | same vocabulary |
|
|
||||||
| `labels` | `labels[]` | names both ways; ids only on write |
|
|
||||||
| `assignees` | `assignees[]` | logins |
|
|
||||||
| `milestone` | `milestone.title` | resolved to an id on write |
|
|
||||||
| `depends` | native links | slugs here, `IssueMeta` there; push writes them, every pull reads them (`--no-deps` opts out) |
|
|
||||||
| — | `ref` | lands in `branch:`; sent only when non-empty |
|
|
||||||
| — | `number`, `html_url` | lands in `gitea:` / `url:` |
|
|
||||||
|
|
||||||
`depends:` is always slugs. The body's `## Depends on` section is human prose
|
|
||||||
and is passed through **unchanged** in both directions: a pull seeds `depends:`
|
|
||||||
from the `#N` it finds there, a push never rewrites what the author wrote. A
|
|
||||||
translator that edits prose churns the body on every round trip. The edge the
|
|
||||||
tracker acts on is the native link, not the text — which is exactly why the
|
|
||||||
text can be left alone.
|
|
||||||
|
|
||||||
Comments are **pull-only** in the store: `<id>.comments.md` is written by
|
|
||||||
`pull.py` and `comment.py`, and editing it by hand changes nothing in Gitea.
|
|
||||||
|
|
||||||
## Drift
|
|
||||||
|
|
||||||
There is none tracked, and since push started deleting what it sends there is
|
|
||||||
very little left to track. A published issue has **one** copy — Gitea's —
|
|
||||||
except while somebody is working on it, and that window closes at the next
|
|
||||||
push. Nothing watches Gitea, nothing reconciles, nothing warns that a synced
|
|
||||||
issue changed upstream. `synced:` tells you how old your working copy is;
|
|
||||||
`remote-updated:` what the server said at that moment. Re-pull when it matters,
|
|
||||||
and push when you are done so there is nothing to be stale.
|
|
||||||
|
|
||||||
The old question — "I edited this locally, does the server have it, whose text
|
|
||||||
is newer?" — is answered by the store's contents rather than by a mechanism: a
|
|
||||||
file that is here has not been pushed.
|
|
||||||
|
|
||||||
Checkbox state is not an exception to this. The union a pull applies reads only
|
|
||||||
the two bodies in front of it — there is no base version, no history, and no
|
|
||||||
way for it to report that anything diverged. One rule for one line type,
|
|
||||||
precisely so the mechanism this section rules out is not needed.
|
|
||||||
|
|
||||||
## Rich payloads for everything else
|
|
||||||
|
|
||||||
Every body these scripts send is written to `<project>/.tea/payload/<name>.json`
|
|
||||||
first and passed as `-d @file`, then kept for a retry or a look at what actually
|
|
||||||
went up. One gitignored directory for all of them, chosen by the transport and
|
|
||||||
not by the caller. **It is not a store**: nothing in it is anybody's only copy,
|
|
||||||
and it is never `.tea/issues/` — a command that touches no issue must not leave
|
|
||||||
an issue store behind.
|
|
||||||
|
|
||||||
Comments and issues are wrapped by the scripts above. For **other** entities
|
|
||||||
(pulls, releases, PATCHing something these scripts do not cover), entity
|
|
||||||
subcommands like `tea pulls create` hang on a large or formatted body — an
|
|
||||||
empty-looking positional triggers the `$EDITOR` fallback on a TTY that does not
|
|
||||||
exist, and the harness eventually kills the process (exit 144 = 128 + SIGURG on
|
|
||||||
macOS). Write the JSON payload to `$PWD/.tea/payload/` first and POST it with
|
|
||||||
`tea api -d @file`. Procedure and endpoint table: `/tea:use`.
|
|
||||||
|
|
||||||
## Login
|
|
||||||
|
|
||||||
Every `tea` call made by hand must carry the literal placeholder
|
|
||||||
`--login "$GITEA_LOGIN"`; the `tea-guard` hook substitutes the operator's pin.
|
|
||||||
Set it with `/tea:auth`. Details in `/tea:use`.
|
|
||||||
@@ -1,505 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
_gitea.py — transport. Everything that talks to Gitea, and nothing else.
|
|
||||||
|
|
||||||
Not a command. This module knows logins, HTTP verbs, pagination, and Gitea's
|
|
||||||
query quirks. It does NOT know what an issue is: no sections, no acceptance
|
|
||||||
criteria, no type taxonomy. Payload shapes come from map.py; the domain model
|
|
||||||
lives one layer further out in skills/issue/scripts/issue.py.
|
|
||||||
|
|
||||||
Login: the operator's pin from .claude/settings.local.json (env.GITEA_LOGIN).
|
|
||||||
Where that file is searched for is NOT written here — skills/auth/scripts/pin.py
|
|
||||||
owns the search order, and the tea-guard hook imports the same module, so `tea`
|
|
||||||
and the scripts can never disagree about which login a directory runs under. No
|
|
||||||
script here accepts a login argument: the operator's pin is the only identity
|
|
||||||
they will use. No pin -> exit with a pointer to /tea:auth.
|
|
||||||
|
|
||||||
Also holds the id map (.tea/issues/.remote.json), which pairs a remote key with
|
|
||||||
a local slug, and the paths of the store-side files this layer writes. All of
|
|
||||||
it is transport bookkeeping, not domain data — the domain never reads any of
|
|
||||||
it, and losing the map still costs a re-pull and not information: the slug it
|
|
||||||
records also travels in the issue body as `<!-- tea:id … -->` (map.py), so a
|
|
||||||
pull rebuilds the entry from the tracker. See `rebuild_map`.
|
|
||||||
|
|
||||||
Request bodies go to .tea/payload/, which is this module's own scratchpad and
|
|
||||||
NOT a store: nothing in it is anybody's only copy, and writing one must never
|
|
||||||
materialize .tea/issues/ on a project that has none. Bootstrapping labels
|
|
||||||
touches no issue at all — it used to leave a store behind anyway, because the
|
|
||||||
request file had nowhere else to live. One directory, every caller, resolved by
|
|
||||||
the domain's project walk so the scratchpad and the store cannot land in
|
|
||||||
different projects.
|
|
||||||
"""
|
|
||||||
import datetime
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
import urllib.parse
|
|
||||||
|
|
||||||
REMOTE_MAP = ".remote.json"
|
|
||||||
|
|
||||||
# How far past the ideal page count a `keep`-bounded listing may scan before it
|
|
||||||
# gives up (see list_issues). The ideal is what `limit` would need if every
|
|
||||||
# payload counted; the slack pays for the ones that do not. It is a bound on
|
|
||||||
# requests, deliberately small: "fetch until N are kept" without one is "fetch
|
|
||||||
# the whole tracker" on any repo whose filter matches mostly closed issues.
|
|
||||||
PAGE_SLACK = 4
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# where request bodies land
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# A sibling of the store under `.tea/`, never a directory inside it: a
|
|
||||||
# scratchpad that looks like store contents is how this went wrong the first
|
|
||||||
# time, when a label bootstrap that touches no issue at all materialized
|
|
||||||
# `tmp/issues/` on a fresh checkout. Same marker, same walk, one directory for
|
|
||||||
# every caller — see payload_root below.
|
|
||||||
|
|
||||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
|
|
||||||
# The domain owns "which project is this" and this layer imports it rather than
|
|
||||||
# walking the tree a second time. Two copies of the walk is how the guard and
|
|
||||||
# the transport once disagreed about a worktree; the same trap, one layer over.
|
|
||||||
_ISSUE_SCRIPTS = os.path.abspath(
|
|
||||||
os.path.join(_HERE, os.pardir, os.pardir, "issue", "scripts"))
|
|
||||||
if _ISSUE_SCRIPTS not in sys.path:
|
|
||||||
sys.path.append(_ISSUE_SCRIPTS)
|
|
||||||
import issue as _issue # noqa: E402
|
|
||||||
|
|
||||||
PAYLOAD_PARTS = (_issue.MARKER, "payload")
|
|
||||||
|
|
||||||
|
|
||||||
def die(msg, code=1):
|
|
||||||
sys.stderr.write("%s: %s\n" % (os.path.basename(sys.argv[0]), msg))
|
|
||||||
sys.exit(code)
|
|
||||||
|
|
||||||
|
|
||||||
def warn(msg):
|
|
||||||
sys.stderr.write("warning: %s\n" % msg)
|
|
||||||
|
|
||||||
|
|
||||||
def now_iso():
|
|
||||||
return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
||||||
|
|
||||||
|
|
||||||
def payload_root(start=None):
|
|
||||||
"""Absolute path of the request-body scratchpad, or None with no project.
|
|
||||||
|
|
||||||
`start` overrides the anchor so the resolution can be exercised against a
|
|
||||||
scratch tree. Sibling of the store, under the same marker and resolved by
|
|
||||||
the same walk: which command wrote a body does not change where it landed,
|
|
||||||
and neither does which directory it was run from."""
|
|
||||||
root = _issue.project_root(start)
|
|
||||||
return os.path.join(root, *PAYLOAD_PARTS) if root else None
|
|
||||||
|
|
||||||
|
|
||||||
PAYLOAD_ROOT = payload_root()
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# login
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# Borrowed from the identity layer, not reimplemented: `pin.find_pin` is the
|
|
||||||
# single written copy of the search order, and the tea-guard hook calls the
|
|
||||||
# same function. When the two had a copy each, a git worktree got a hook that
|
|
||||||
# resolved the pin and a transport that did not — in the same directory.
|
|
||||||
#
|
|
||||||
# The pin resolves from the working directory upward, and PAYLOAD_ROOT and
|
|
||||||
# issue.store_root now do the same. They did not always: those two were
|
|
||||||
# anchored on their own file, on the reasoning that where an installation keeps
|
|
||||||
# its files is a fact about the installation. Whose login a project runs under
|
|
||||||
# is a fact about the project — and so is which issues it has. The identity
|
|
||||||
# layer was right first; the other two followed it. See pin.py's docstring for
|
|
||||||
# the walk, and issue.py's for what the old anchor cost.
|
|
||||||
|
|
||||||
_AUTH_SCRIPTS = os.path.abspath(
|
|
||||||
os.path.join(_HERE, os.pardir, os.pardir, "auth", "scripts"))
|
|
||||||
if _AUTH_SCRIPTS not in sys.path:
|
|
||||||
sys.path.append(_AUTH_SCRIPTS)
|
|
||||||
import pin # noqa: E402
|
|
||||||
|
|
||||||
|
|
||||||
def require_login():
|
|
||||||
"""The operator's pinned login, or exit pointing at /tea:auth.
|
|
||||||
|
|
||||||
No pin found is reported as exactly that. It stays a truthful message: the
|
|
||||||
fix for "the pin is somewhere this search does not reach" belongs in
|
|
||||||
pin.py, never in a hint here that sends the operator to pin it twice."""
|
|
||||||
login, _ = pin.find_pin()
|
|
||||||
if not login:
|
|
||||||
die("no login pinned (.claude/settings.local.json env.GITEA_LOGIN). Run /tea:auth.")
|
|
||||||
return login
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# api
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def api(login, endpoint, method="GET", payload=None, payload_name=None,
|
|
||||||
allow_fail=False):
|
|
||||||
"""Call `tea api`; return parsed JSON (None on an empty body).
|
|
||||||
|
|
||||||
payload (a dict) is written to PAYLOAD_ROOT/<name>.json and passed as
|
|
||||||
-d @file — the file survives the call for retries and debugging. Where
|
|
||||||
that is, is not the caller's business and never was: the directory is
|
|
||||||
this layer's scratchpad, and the one time it was a caller's decision it
|
|
||||||
got pointed at the issue store. allow_fail returns None instead of
|
|
||||||
exiting when the call fails."""
|
|
||||||
cmd = ["tea", "api", "--login", login]
|
|
||||||
if method != "GET":
|
|
||||||
cmd += ["-X", method]
|
|
||||||
if payload is not None:
|
|
||||||
if PAYLOAD_ROOT is None:
|
|
||||||
die(_issue.no_project_error())
|
|
||||||
os.makedirs(PAYLOAD_ROOT, exist_ok=True)
|
|
||||||
path = os.path.join(PAYLOAD_ROOT, "%s.json" % (payload_name or "request"))
|
|
||||||
with open(path, "w") as f:
|
|
||||||
json.dump(payload, f, ensure_ascii=False, indent=2)
|
|
||||||
cmd += ["-d", "@" + path]
|
|
||||||
cmd.append(endpoint)
|
|
||||||
|
|
||||||
r = subprocess.run(cmd, capture_output=True, text=True)
|
|
||||||
if r.returncode != 0:
|
|
||||||
if allow_fail:
|
|
||||||
return None
|
|
||||||
die("`tea api %s %s` failed:\n%s" % (method, endpoint, (r.stderr or r.stdout).strip()))
|
|
||||||
body = r.stdout.strip()
|
|
||||||
if not body:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return json.loads(body)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
if allow_fail:
|
|
||||||
return None
|
|
||||||
die("`tea api %s` returned non-JSON:\n%s" % (endpoint, body[:500]))
|
|
||||||
|
|
||||||
|
|
||||||
def pages(login, endpoint, limit=50, max_pages=40, **kw):
|
|
||||||
"""GET a list endpoint page by page, yielding each page as it arrives.
|
|
||||||
|
|
||||||
A generator, because a caller whose budget is spent on what it *keeps*
|
|
||||||
cannot be served by a function that fetches everything first: the page after
|
|
||||||
the one that completed the budget must never be requested. Stop consuming
|
|
||||||
and no further request is made."""
|
|
||||||
sep = "&" if "?" in endpoint else "?"
|
|
||||||
for page in range(1, max_pages + 1):
|
|
||||||
batch = api(login, "%s%spage=%d&limit=%d" % (endpoint, sep, page, limit), **kw)
|
|
||||||
if not isinstance(batch, list) or not batch:
|
|
||||||
return
|
|
||||||
yield batch
|
|
||||||
if len(batch) < limit:
|
|
||||||
return # a short page is the last one
|
|
||||||
|
|
||||||
|
|
||||||
def paginate(login, endpoint, limit=50, max_pages=40, **kw):
|
|
||||||
"""GET a list endpoint page by page; return the concatenated list."""
|
|
||||||
out = []
|
|
||||||
for batch in pages(login, endpoint, limit=limit, max_pages=max_pages, **kw):
|
|
||||||
out.extend(batch)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def repo_base(repo=None):
|
|
||||||
"""API prefix. Without --repo, let tea fill {owner}/{repo} from CWD."""
|
|
||||||
return "repos/%s" % repo if repo else "repos/{owner}/{repo}"
|
|
||||||
|
|
||||||
|
|
||||||
def repo_slug(login, repo=None):
|
|
||||||
"""owner/repo as a literal string — needed for remote keys, which must not
|
|
||||||
contain tea's {owner}/{repo} placeholder."""
|
|
||||||
if repo:
|
|
||||||
return repo
|
|
||||||
got = api(login, "repos/{owner}/{repo}", allow_fail=True)
|
|
||||||
if isinstance(got, dict) and got.get("full_name"):
|
|
||||||
return got["full_name"]
|
|
||||||
die("cannot determine owner/repo from the CWD — pass --repo owner/repo")
|
|
||||||
|
|
||||||
|
|
||||||
def parse_key(key):
|
|
||||||
"""Return (number, repo-or-None) from 42 / #42 / owner/repo#42 / a 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)
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# filters
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def resolve_milestone(login, base, value):
|
|
||||||
"""(id, title) for a milestone given by id or title. Exits if unknown.
|
|
||||||
|
|
||||||
Gitea silently IGNORES an unresolvable `milestones=` filter and returns the
|
|
||||||
whole backlog, so the milestone must be resolved before it is trusted."""
|
|
||||||
got = paginate(login, "%s/milestones?state=all" % base, limit=100)
|
|
||||||
for m in got or []:
|
|
||||||
if str(m.get("id")) == str(value) or m.get("title") == str(value):
|
|
||||||
return m["id"], m.get("title", "")
|
|
||||||
have = ", ".join("%s (id %d)" % (m.get("title", ""), m["id"]) for m in got or [])
|
|
||||||
die("no milestone %r in this repo — have: %s" % (value, have or "none"))
|
|
||||||
|
|
||||||
|
|
||||||
def matches(payload, milestone_id=None, labels=()):
|
|
||||||
"""Client-side re-check of a server-side filter — see resolve_milestone."""
|
|
||||||
if payload.get("pull_request"):
|
|
||||||
return False
|
|
||||||
if milestone_id is not None and (payload.get("milestone") or {}).get("id") != milestone_id:
|
|
||||||
return False
|
|
||||||
names = {l.get("name", "") for l in payload.get("labels") or []}
|
|
||||||
return all(l in names for l in labels)
|
|
||||||
|
|
||||||
|
|
||||||
def list_issues(login, base, state="open", labels=(), query=None,
|
|
||||||
milestone=None, limit=100, keep=None):
|
|
||||||
"""Filtered issue payloads. Returns (payloads, milestone_title).
|
|
||||||
|
|
||||||
One request per page, and the payload already carries the issue bodies — a
|
|
||||||
whole milestone costs one call per 50 issues, not one per issue.
|
|
||||||
|
|
||||||
`limit` counts the payloads the CALLER cares about, not the ones the server
|
|
||||||
returned. Without `keep` those are the same thing and this behaves as it
|
|
||||||
always did. With it, `keep(payload)` says whether a payload counts, pages
|
|
||||||
keep coming until `limit` of them have, and the returned list carries the
|
|
||||||
ones that did not count too — they were enumerated, and a caller that has
|
|
||||||
something to say about them (pull.py: "N closed, not stored") still can.
|
|
||||||
|
|
||||||
What `keep` means is the caller's business; this module only counts. Two
|
|
||||||
boundaries hold whatever it decides:
|
|
||||||
|
|
||||||
- **Stop at the limit.** The page after the one that completed the budget
|
|
||||||
is not requested — `pages` is a generator and this loop returns out of it.
|
|
||||||
- **Stop at the page budget.** A predicate that rejects everything must not
|
|
||||||
turn a bounded read into a walk of the whole tracker, so a filtered read
|
|
||||||
may scan at most `PAGE_SLACK` times the pages `limit` would need if every
|
|
||||||
payload counted. Hitting that with an unfilled budget is a warning, not a
|
|
||||||
silent short answer: the caller asked for N and is told it got fewer."""
|
|
||||||
if limit < 1:
|
|
||||||
die("--limit must be 1 or more, got %d" % limit)
|
|
||||||
ms_id, ms_title = (None, None)
|
|
||||||
if milestone is not None:
|
|
||||||
ms_id, ms_title = resolve_milestone(login, base, milestone)
|
|
||||||
|
|
||||||
params = {"state": state, "type": "issues"}
|
|
||||||
if labels:
|
|
||||||
params["labels"] = ",".join(labels)
|
|
||||||
if query:
|
|
||||||
params["q"] = query
|
|
||||||
if ms_title:
|
|
||||||
params["milestones"] = ms_title
|
|
||||||
endpoint = "%s/issues?%s" % (base, urllib.parse.urlencode(params))
|
|
||||||
|
|
||||||
per_page = min(limit, 50)
|
|
||||||
ideal = max(1, -(-limit // per_page))
|
|
||||||
budget = ideal if keep is None else ideal * PAGE_SLACK
|
|
||||||
|
|
||||||
got, kept, seen_pages, last_full = [], 0, 0, False
|
|
||||||
for batch in pages(login, endpoint, limit=per_page, max_pages=budget):
|
|
||||||
seen_pages += 1
|
|
||||||
last_full = len(batch) == per_page
|
|
||||||
for p in batch:
|
|
||||||
if not matches(p, ms_id, labels):
|
|
||||||
continue
|
|
||||||
got.append(p)
|
|
||||||
if keep is None or keep(p):
|
|
||||||
kept += 1
|
|
||||||
if kept >= limit:
|
|
||||||
return got, ms_title
|
|
||||||
if keep is not None and seen_pages >= budget and last_full:
|
|
||||||
warn("scanned %d page(s) and stopped %d short of --limit %d — there may"
|
|
||||||
" be more; narrow the filter or raise --limit" % (budget, limit - kept, limit))
|
|
||||||
return got, ms_title
|
|
||||||
|
|
||||||
|
|
||||||
def get_issue(login, base, number):
|
|
||||||
payload = api(login, "%s/issues/%d" % (base, number))
|
|
||||||
if not isinstance(payload, dict) or "number" not in payload:
|
|
||||||
die("issue #%d not found" % number)
|
|
||||||
return payload
|
|
||||||
|
|
||||||
|
|
||||||
def get_comments(login, base, number):
|
|
||||||
return paginate(login, "%s/issues/%d/comments" % (base, number))
|
|
||||||
|
|
||||||
|
|
||||||
def native_deps(login, base, number):
|
|
||||||
"""Gitea's own issue-dependency links; empty when unsupported."""
|
|
||||||
got = api(login, "%s/issues/%d/dependencies" % (base, number), allow_fail=True)
|
|
||||||
return [i["number"] for i in got] if isinstance(got, list) else []
|
|
||||||
|
|
||||||
|
|
||||||
def native_dep_pairs(login, base, number):
|
|
||||||
"""The same links as {(owner/repo, number)} — what a repeat push compares
|
|
||||||
against so it does not POST a link the tracker already has.
|
|
||||||
|
|
||||||
A bare number is ambiguous the moment a dependency lives in another repo,
|
|
||||||
and IssueMeta lets it, so the repo travels with it. The pair is a transport
|
|
||||||
fact; formatting it as `owner/repo#42` is map.py's job, not this module's."""
|
|
||||||
got = api(login, "%s/issues/%d/dependencies" % (base, number), allow_fail=True)
|
|
||||||
out = set()
|
|
||||||
for i in got if isinstance(got, list) else []:
|
|
||||||
repo = (i.get("repository") or {}).get("full_name") or ""
|
|
||||||
if "number" in i:
|
|
||||||
out.add((repo, int(i["number"])))
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def add_dependency(login, base, number, dep_repo, dep_number):
|
|
||||||
"""Make issue `number` depend on `dep_repo#dep_number`. True on success.
|
|
||||||
|
|
||||||
Confirmed against the instance's own swagger.v1.json (Gitea 1.26.1):
|
|
||||||
|
|
||||||
POST /repos/{owner}/{repo}/issues/{index}/dependencies
|
|
||||||
body: IssueMeta — {"index": <int>, "owner": "<owner>", "repo": "<name>"}
|
|
||||||
"Make the issue in the url depend on the issue in the form."
|
|
||||||
|
|
||||||
The URL names the blocked issue and the body the blocker, which is the same
|
|
||||||
direction native_deps reads back ("all issues that block this issue"). A
|
|
||||||
link that already exists answers 409, so a failure here is reported and not
|
|
||||||
fatal: one missing cross-link must not abort a push that has already
|
|
||||||
created issues. Callers pre-filter with native_dep_pairs."""
|
|
||||||
owner, _, name = (dep_repo or "").partition("/")
|
|
||||||
if not owner or not name:
|
|
||||||
return False
|
|
||||||
payload = {"index": int(dep_number), "owner": owner, "repo": name}
|
|
||||||
got = api(login, "%s/issues/%d/dependencies" % (base, number), "POST", payload,
|
|
||||||
payload_name="dep-%d-%d" % (number, dep_number), allow_fail=True)
|
|
||||||
return got is not None
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# labels
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def ensure_labels(login, base, specs, root):
|
|
||||||
"""Map label name -> id, creating what the repo is missing.
|
|
||||||
|
|
||||||
`specs` is {name: {"color", "description", "exclusive"}} handed in by the
|
|
||||||
caller — this module does not know which namespaces are exclusive or what
|
|
||||||
they mean. Cached in <root>/.labels.json; the cache is refreshed from the
|
|
||||||
API before anything is created."""
|
|
||||||
cache_path = os.path.join(root, ".labels.json")
|
|
||||||
cache = {}
|
|
||||||
if os.path.isfile(cache_path):
|
|
||||||
try:
|
|
||||||
with open(cache_path) as f:
|
|
||||||
cache = json.load(f)
|
|
||||||
except Exception:
|
|
||||||
cache = {}
|
|
||||||
|
|
||||||
if any(n not in cache for n in specs):
|
|
||||||
cache = {l["name"]: l["id"] for l in paginate(login, "%s/labels" % base, limit=100)}
|
|
||||||
|
|
||||||
for name, spec in specs.items():
|
|
||||||
if name in cache:
|
|
||||||
continue
|
|
||||||
payload = dict(spec, name=name)
|
|
||||||
created = api(login, "%s/labels" % base, "POST", payload,
|
|
||||||
payload_name="label-%s" % name.replace("/", "-"))
|
|
||||||
if not created or "id" not in created:
|
|
||||||
die("could not create label %r" % name)
|
|
||||||
cache[name] = created["id"]
|
|
||||||
sys.stderr.write("created label %s%s\n"
|
|
||||||
% (name, " (exclusive)" if spec.get("exclusive") else ""))
|
|
||||||
|
|
||||||
os.makedirs(root, exist_ok=True)
|
|
||||||
with open(cache_path, "w") as f:
|
|
||||||
json.dump(cache, f, indent=2, sort_keys=True)
|
|
||||||
return {n: cache[n] for n in specs}
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_milestone_id(login, base, title):
|
|
||||||
"""Milestone id for a title, or None when the repo has no such milestone."""
|
|
||||||
if not title or title == "none":
|
|
||||||
return None
|
|
||||||
for m in paginate(login, "%s/milestones?state=all" % base, limit=100) or []:
|
|
||||||
if m.get("title") == title:
|
|
||||||
return m["id"]
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# store-side files this layer owns
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# The issue file itself is the domain's (`issue.path_of`). The one file the sync
|
|
||||||
# layer puts beside it is named here, in one place, because three commands have
|
|
||||||
# to agree on it: pull.py writes the thread, comment.py refetches it, push.py
|
|
||||||
# deletes it along with the issue it just sent.
|
|
||||||
|
|
||||||
def comments_path(root, id):
|
|
||||||
"""An issue's comment thread — beside it, under the same slug.
|
|
||||||
|
|
||||||
A path, not a concept the domain needs: a thread is pulled from Gitea and
|
|
||||||
never pushed back, so the domain has no reason to know the file exists."""
|
|
||||||
return os.path.join(root, "%s.comments.md" % id)
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# id map: remote key <-> local slug
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def map_path(root):
|
|
||||||
return os.path.join(root, REMOTE_MAP)
|
|
||||||
|
|
||||||
|
|
||||||
def load_map(root):
|
|
||||||
"""{"owner/repo#42": "wire-sqlc-appclick"} — the local slug ledger.
|
|
||||||
|
|
||||||
Entries outlive the files they name, and that is now the normal case rather
|
|
||||||
than a leak: `push.py` deletes an issue's file the moment Gitea confirms it,
|
|
||||||
and the entry it leaves behind is what lets the next `pull.py 42` land on
|
|
||||||
the same slug. Nothing prunes them, because "no file" no longer means "no
|
|
||||||
such issue". A stale entry costs one json line and is corrected the next
|
|
||||||
time that number is pulled."""
|
|
||||||
p = map_path(root)
|
|
||||||
if not os.path.isfile(p):
|
|
||||||
return {}
|
|
||||||
try:
|
|
||||||
with open(p) as f:
|
|
||||||
got = json.load(f)
|
|
||||||
return got if isinstance(got, dict) else {}
|
|
||||||
except Exception:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
def save_map(root, m):
|
|
||||||
os.makedirs(root, exist_ok=True)
|
|
||||||
with open(map_path(root), "w") as f:
|
|
||||||
json.dump(m, f, indent=2, sort_keys=True)
|
|
||||||
|
|
||||||
|
|
||||||
def rebuild_map(root, issues):
|
|
||||||
"""Fold the `gitea:` fields still on disk into the id map. Returns it.
|
|
||||||
|
|
||||||
This used to say "the files are the source of truth; .remote.json is only an
|
|
||||||
index over them", and that stopped being true the day push started deleting
|
|
||||||
the file it had just sent. A pushed issue leaves no `gitea:` field behind to
|
|
||||||
read, so the files are now a SUBSET of what the map knows, and a rebuild
|
|
||||||
from them alone would throw away every entry it cannot see.
|
|
||||||
|
|
||||||
So the contradiction is resolved by moving the source of truth, not by
|
|
||||||
keeping this function honest about files:
|
|
||||||
|
|
||||||
Gitea the issue, and — in `<!-- tea:id … -->` — its slug
|
|
||||||
.remote.json a local number -> slug ledger, a cache of that marker
|
|
||||||
.tea/issues/*.md whatever happens to be checked out right now
|
|
||||||
|
|
||||||
Which makes this a MERGE and never a replacement: it starts from what is
|
|
||||||
already recorded and adds what the remaining files say. What it cannot
|
|
||||||
recover — a pushed-and-dropped issue whose ledger entry was also lost — is
|
|
||||||
not lost either; the next `pull.py <n>` reads the slug off the marker and
|
|
||||||
writes the entry back."""
|
|
||||||
m = load_map(root)
|
|
||||||
for id, iss in issues.items():
|
|
||||||
key = iss.extra.get("gitea")
|
|
||||||
if key:
|
|
||||||
m[key] = id
|
|
||||||
save_map(root, m)
|
|
||||||
return m
|
|
||||||
@@ -1,253 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
close.py — change an issue's state in Gitea, and in the local copy with it.
|
|
||||||
|
|
||||||
The one regular tracker operation that used to have no script: closing. Without
|
|
||||||
it the only way to move `state:` was a raw `tea api -X PATCH -d '{"state":
|
|
||||||
"closed"}' repos/OWNER/REPO/issues/N`, which spells out the owner, the repo and
|
|
||||||
the request body — the three things `_gitea.py` exists to hide — and which needs
|
|
||||||
`Bash(tea api *)`, a permission that also covers `-X DELETE` on the repository.
|
|
||||||
|
|
||||||
close.py wire-sqlc-appclick one issue, by slug
|
|
||||||
close.py wire-sqlc-appclick 42 #43 several, by slug or number
|
|
||||||
close.py --reopen 42 the same thing backwards
|
|
||||||
close.py --dry-run 42 43 what would happen, no request at all
|
|
||||||
|
|
||||||
STATE ONLY. This script sends `{"state": …}` and nothing else: no title, no
|
|
||||||
body, no labels, no milestone. Editing an issue is `pull.py` -> edit ->
|
|
||||||
`push.py --update`; closing it is not an edit.
|
|
||||||
|
|
||||||
**What may be named.** A local slug, or a Gitea key (`42`, `#42`,
|
|
||||||
`owner/repo#42`, an issue URL) — the same forms `pull.py` takes. Both are
|
|
||||||
needed, and for the same reason: a push deletes the local file, so most issues
|
|
||||||
in the tracker have no slug on disk to name them by. A slug is resolved through
|
|
||||||
the file's `gitea:` field when the file is there, and through the ledger
|
|
||||||
(`.remote.json`) when push has already dropped it.
|
|
||||||
|
|
||||||
**An `origin: local` issue cannot be closed.** It is not in the tracker, so
|
|
||||||
there is nothing to close there, and the run stops naming the id rather than
|
|
||||||
quietly editing one field of a local file. Delete it, or push it first.
|
|
||||||
|
|
||||||
**Explicit ids only.** No `--milestone`, no `--label`, no "close everything
|
|
||||||
that looks done". Which issues are finished is a judgement about content; this
|
|
||||||
script only carries it out, one named id at a time. Nothing here deletes an
|
|
||||||
issue either — Gitea can, and it is not an operation of this workflow.
|
|
||||||
|
|
||||||
The local file is written only after the tracker has confirmed the write:
|
|
||||||
|
|
||||||
1. `tea` ran and exited 0 (a non-2xx exits the run inside `_gitea.api`), and
|
|
||||||
2. the answer is an object carrying the very number that was PATCHed, and
|
|
||||||
3. its `state` is the state we asked for.
|
|
||||||
|
|
||||||
Anything else and the file is left exactly as it was — see `confirmed`. An
|
|
||||||
issue whose local copy is gone (pushed and dropped) is closed in Gitea and
|
|
||||||
nothing is written; the state comes down with the next `pull.py`.
|
|
||||||
|
|
||||||
Gitea refuses to close an issue that its own dependency graph still blocks. That
|
|
||||||
refusal arrives as a non-2xx and stops the run with the tracker's own words:
|
|
||||||
close the blockers first, or unlink them in the web UI.
|
|
||||||
|
|
||||||
Login: the operator's pin from .claude/settings.local.json (see /tea:auth).
|
|
||||||
"""
|
|
||||||
import argparse
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
|
|
||||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
sys.path[:0] = [_HERE, os.path.normpath(os.path.join(_HERE, "..", "..", "issue", "scripts"))]
|
|
||||||
|
|
||||||
import _gitea # noqa: E402
|
|
||||||
import issue # noqa: E402
|
|
||||||
import issue_index # noqa: E402
|
|
||||||
import map as gmap # noqa: E402
|
|
||||||
|
|
||||||
# What `_gitea.parse_key` accepts, asked as a question instead of an assertion:
|
|
||||||
# parse_key exits on anything it cannot read, and here "not a key" is the normal
|
|
||||||
# case — it means the argument is a slug. A slug never contains `#`, `/` or `:`,
|
|
||||||
# so the two vocabularies cannot collide.
|
|
||||||
KEY_RE = re.compile(r'^(#?\d+|[\w.-]+/[\w.-]+#\d+|https?://\S+)$')
|
|
||||||
|
|
||||||
|
|
||||||
def looks_like_key(arg):
|
|
||||||
return bool(KEY_RE.match((arg or "").strip()))
|
|
||||||
|
|
||||||
|
|
||||||
def ledger_pairs(remote_map, repo=None):
|
|
||||||
"""[(repo, number, slug)] from `.remote.json`, filtered to `repo`.
|
|
||||||
|
|
||||||
A `--repo` that was not given means "whatever the ledger holds": resolving
|
|
||||||
the repo's real name costs a request, and a dry run is required to make
|
|
||||||
none. The ambiguity that opens — one number under two repos — is caught at
|
|
||||||
lookup time rather than papered over."""
|
|
||||||
out = []
|
|
||||||
for key, slug in sorted(remote_map.items()):
|
|
||||||
r, n = gmap.parse_remote_key(key)
|
|
||||||
if n:
|
|
||||||
if repo is None or r == repo:
|
|
||||||
out.append((r, n, slug))
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def one(candidates, what, arg):
|
|
||||||
"""The single `(repo, value)` in `candidates`, None when empty, or exit.
|
|
||||||
|
|
||||||
Two answers mean the ledger knows this number (or this slug) under more than
|
|
||||||
one repository, and only `--repo` can settle that."""
|
|
||||||
got = sorted(set(candidates))
|
|
||||||
if len(got) > 1:
|
|
||||||
_gitea.die("%r matches %s under more than one repo (%s) — pass "
|
|
||||||
"--repo owner/repo" % (arg, what, ", ".join(r for r, _v in got)))
|
|
||||||
return got[0] if got else None
|
|
||||||
|
|
||||||
|
|
||||||
def resolve(arg, issues, pairs):
|
|
||||||
"""(id, number, repo) for one argument. Either of `id` and `repo` is None
|
|
||||||
when nothing this machine holds names it.
|
|
||||||
|
|
||||||
Order, and it is the order of what is most authoritative about this machine:
|
|
||||||
a file on disk, then the ledger, then nothing. A key skips straight to the
|
|
||||||
ledger — its number is already the tracker's answer, and the slug is only
|
|
||||||
wanted so the local copy, if there is one, can be kept honest.
|
|
||||||
|
|
||||||
`repo` travels out with the number because a key may name one
|
|
||||||
(`owner/repo#42`) and a `gitea:` field always does. Sending a foreign key to
|
|
||||||
whatever repo the CWD happens to be in would close somebody else's issue of
|
|
||||||
the same number, so the caller reconciles them before anything goes out."""
|
|
||||||
if looks_like_key(arg):
|
|
||||||
number, repo = _gitea.parse_key(arg)
|
|
||||||
hit = one([(r, s) for r, n, s in pairs
|
|
||||||
if n == number and (repo is None or r == repo)], "a slug", arg)
|
|
||||||
return (hit[1] if hit else None), number, repo or (hit[0] if hit else None)
|
|
||||||
|
|
||||||
iss = issues.get(arg)
|
|
||||||
if iss is not None:
|
|
||||||
repo, number = gmap.parse_remote_key(iss.extra.get("gitea", ""))
|
|
||||||
if not number:
|
|
||||||
_gitea.die("%s is not in the tracker (origin: %s, no gitea: field) — "
|
|
||||||
"there is no state there to change; push.py %s first"
|
|
||||||
% (arg, iss.origin, arg))
|
|
||||||
return arg, number, repo
|
|
||||||
|
|
||||||
hit = one([(r, n) for r, n, s in pairs if s == arg], "a number", arg)
|
|
||||||
if hit:
|
|
||||||
return arg, hit[1], hit[0] # pushed, and its file went with the push
|
|
||||||
_gitea.die("no issue %r in the store or the ledger — pass a Gitea number "
|
|
||||||
"(42, #42, owner/repo#42, a URL) to close one this machine has "
|
|
||||||
"never seen" % arg)
|
|
||||||
|
|
||||||
|
|
||||||
def confirmed(got, number, state):
|
|
||||||
"""True when the tracker's answer confirms THIS write, and nothing else.
|
|
||||||
|
|
||||||
The gate in front of the local write, and deliberately boring: an answer
|
|
||||||
counts only when it is an object carrying the very number that was PATCHed
|
|
||||||
(`bool` rejected explicitly — `True` is an `int`) and the state that was
|
|
||||||
asked for. A non-2xx and a `tea` that would not run never reach here at all;
|
|
||||||
`_gitea.api` exits on both, so the file survives those by never being
|
|
||||||
written."""
|
|
||||||
if not isinstance(got, dict):
|
|
||||||
return False
|
|
||||||
n = got.get("number")
|
|
||||||
if isinstance(n, bool) or not isinstance(n, int) or n != number:
|
|
||||||
return False
|
|
||||||
return got.get("state") == state
|
|
||||||
|
|
||||||
|
|
||||||
def apply_state(root, iss, state, got):
|
|
||||||
"""Write the confirmed state onto the local file; return its path.
|
|
||||||
|
|
||||||
`state:` is the domain's own field, so it is set on the issue and written
|
|
||||||
out by the domain's own writer. The sync-owned freshness fields travel with
|
|
||||||
it: the answer that authorized this write is also the newest thing the
|
|
||||||
tracker has said about the issue, so `synced:` and `remote-updated:` are
|
|
||||||
stamped from it rather than left describing an older read."""
|
|
||||||
iss.state = state
|
|
||||||
iss.extra["synced"] = _gitea.now_iso()
|
|
||||||
if got.get("updated_at"):
|
|
||||||
iss.extra["remote-updated"] = got["updated_at"]
|
|
||||||
return issue.save(root, iss)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
ap = argparse.ArgumentParser(description="Close (or reopen) issues in Gitea")
|
|
||||||
ap.add_argument("ids", nargs="+",
|
|
||||||
help="local ids, or Gitea keys: 42, #42, owner/repo#42, URL")
|
|
||||||
ap.add_argument("--reopen", action="store_true",
|
|
||||||
help="set the state back to open instead of closed")
|
|
||||||
ap.add_argument("--dry-run", action="store_true",
|
|
||||||
help="print what would change; makes no request at all")
|
|
||||||
ap.add_argument("--repo", help="owner/repo (default: auto-detect from CWD git remote)")
|
|
||||||
ap.add_argument("--out", default=issue.ISSUE_ROOT,
|
|
||||||
help="store root (default: <project>/.tea/issues)")
|
|
||||||
args = ap.parse_args()
|
|
||||||
|
|
||||||
root = args.out
|
|
||||||
state = "open" if args.reopen else "closed"
|
|
||||||
verb = "reopen" if args.reopen else "close"
|
|
||||||
past = "reopened" if args.reopen else "closed"
|
|
||||||
|
|
||||||
# A store that is not there is not an error here: a number needs no local
|
|
||||||
# file, and closing an issue whose copy was dropped by push is the normal
|
|
||||||
# case. `load_all` reads an absent directory as an empty one.
|
|
||||||
issues = issue.load_all(root)
|
|
||||||
pairs = ledger_pairs(_gitea.load_map(root), args.repo)
|
|
||||||
|
|
||||||
# Every argument is resolved before anything is sent, so a typo in the third
|
|
||||||
# id does not leave the first two closed.
|
|
||||||
targets = []
|
|
||||||
for arg in args.ids:
|
|
||||||
got = resolve(arg, issues, pairs)
|
|
||||||
if got not in targets:
|
|
||||||
targets.append(got)
|
|
||||||
|
|
||||||
# One run, one repo. An explicit --repo is the operator's word and wins;
|
|
||||||
# without one, the repo comes from what the ids themselves said, and two
|
|
||||||
# answers are a question rather than a guess — `repo_base` would otherwise
|
|
||||||
# let `tea` fill the blank from the CWD and close the wrong #42.
|
|
||||||
named = {r for _i, _n, r in targets if r}
|
|
||||||
if not args.repo and len(named) > 1:
|
|
||||||
_gitea.die("all ids must belong to one repo, got: %s" % ", ".join(sorted(named)))
|
|
||||||
repo_arg = args.repo or (sorted(named)[0] if named else None)
|
|
||||||
|
|
||||||
if args.dry_run:
|
|
||||||
for id, number, _repo in targets:
|
|
||||||
iss = issues.get(id)
|
|
||||||
where = ("%s (state: %s)" % (issue.path_of(root, id), iss.state)
|
|
||||||
if iss is not None else "no local copy")
|
|
||||||
print("would %s %s #%d — %s" % (verb, id or "?", number, where))
|
|
||||||
print("%d issue(s) would be %s; no request was made"
|
|
||||||
% (len(targets), past))
|
|
||||||
return
|
|
||||||
|
|
||||||
login = _gitea.require_login()
|
|
||||||
base = _gitea.repo_base(repo_arg)
|
|
||||||
|
|
||||||
touched = 0
|
|
||||||
for id, number, _repo in targets:
|
|
||||||
got = _gitea.api(login, "%s/issues/%d" % (base, number), "PATCH",
|
|
||||||
{"state": state}, payload_name="state-%d" % number)
|
|
||||||
# The gate. Above it nothing local has been written; below it the file
|
|
||||||
# is about to say something the tracker had better agree with.
|
|
||||||
if not confirmed(got, number, state):
|
|
||||||
_gitea.die("#%d: %s failed — the tracker's answer does not confirm the "
|
|
||||||
"write (%.200r). Nothing local was changed."
|
|
||||||
% (number, verb, got))
|
|
||||||
|
|
||||||
print("%s %s #%d %s" % (past, id or "?", number,
|
|
||||||
got.get("html_url", "")))
|
|
||||||
|
|
||||||
iss = issues.get(id)
|
|
||||||
if iss is None:
|
|
||||||
print(" no local copy — pull.py %d to get one" % number)
|
|
||||||
continue
|
|
||||||
print(" state: %s %s" % (state, apply_state(root, iss, state, got)))
|
|
||||||
touched += 1
|
|
||||||
|
|
||||||
if touched:
|
|
||||||
path, n = issue_index.build(root)
|
|
||||||
print("index: %s — %d issue(s)" % (path, n))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
comment.py — post or edit a comment on a synced issue.
|
|
||||||
|
|
||||||
The last issue operation that used to be hand-rolled (`mkdir tmp/comment`,
|
|
||||||
`jq -Rs`, `tea api -X POST`). Entity commands like `tea comment` hang on a
|
|
||||||
multi-line body — an empty-looking positional triggers the $EDITOR fallback on
|
|
||||||
a TTY that does not exist — so everything goes through `tea api` with the
|
|
||||||
payload written to a file first.
|
|
||||||
|
|
||||||
comment.py wire-sqlc-appclick --file notes.md
|
|
||||||
comment.py wire-sqlc-appclick --body "готово, задеплоено"
|
|
||||||
comment.py wire-sqlc-appclick --file fix.md --edit 1234
|
|
||||||
|
|
||||||
The target is a local id, not a number: this layer resolves it through the
|
|
||||||
`gitea:` field. A local-only issue cannot be commented on — there is nothing to
|
|
||||||
comment on yet. After a successful write the comment thread is refetched into
|
|
||||||
<id>.comments.md so the local copy is not stale.
|
|
||||||
|
|
||||||
Comments are pull-only in the store: nothing round-trips them back, and editing
|
|
||||||
<id>.comments.md by hand changes nothing in Gitea.
|
|
||||||
|
|
||||||
Login: the operator's pin from .claude/settings.local.json (see /tea:auth).
|
|
||||||
"""
|
|
||||||
import argparse
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
sys.path[:0] = [_HERE, os.path.normpath(os.path.join(_HERE, "..", "..", "issue", "scripts"))]
|
|
||||||
|
|
||||||
import _gitea # noqa: E402
|
|
||||||
import issue # noqa: E402
|
|
||||||
import map as gmap # noqa: E402
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
ap = argparse.ArgumentParser(description="Comment on a synced issue")
|
|
||||||
ap.add_argument("id", help="local issue id (must already be in Gitea)")
|
|
||||||
src = ap.add_mutually_exclusive_group(required=True)
|
|
||||||
src.add_argument("--file", help="markdown file holding the comment body")
|
|
||||||
src.add_argument("--body", help="comment body inline (short, single-line)")
|
|
||||||
ap.add_argument("--edit", type=int, metavar="COMMENT_ID",
|
|
||||||
help="PATCH an existing comment instead of posting a new one")
|
|
||||||
ap.add_argument("--repo", help="owner/repo (default: auto-detect from CWD git remote)")
|
|
||||||
ap.add_argument("--out", default=issue.ISSUE_ROOT,
|
|
||||||
help="store root (default: <project>/.tea/issues)")
|
|
||||||
args = ap.parse_args()
|
|
||||||
|
|
||||||
root = args.out
|
|
||||||
if not issue.store_exists(root):
|
|
||||||
_gitea.die("store %s does not exist — nothing was created" % root)
|
|
||||||
if not os.path.isfile(issue.path_of(root, args.id)):
|
|
||||||
_gitea.die("no issue %r in %s" % (args.id, root))
|
|
||||||
iss = issue.load(root, args.id)
|
|
||||||
|
|
||||||
number = gmap.number_of(iss)
|
|
||||||
if not number:
|
|
||||||
_gitea.die("%s is local-only (no gitea: field) — push it first" % args.id)
|
|
||||||
|
|
||||||
if args.file:
|
|
||||||
if not os.path.isfile(args.file):
|
|
||||||
_gitea.die("no such file: %s" % args.file)
|
|
||||||
with open(args.file) as f:
|
|
||||||
body = f.read().strip()
|
|
||||||
else:
|
|
||||||
body = args.body.strip()
|
|
||||||
if not body:
|
|
||||||
_gitea.die("empty comment body")
|
|
||||||
|
|
||||||
login = _gitea.require_login()
|
|
||||||
base = _gitea.repo_base(args.repo)
|
|
||||||
|
|
||||||
if args.edit:
|
|
||||||
got = _gitea.api(login, "%s/issues/comments/%d" % (base, args.edit), "PATCH",
|
|
||||||
{"body": body}, payload_name="comment-%d" % args.edit)
|
|
||||||
verb = "edited"
|
|
||||||
else:
|
|
||||||
got = _gitea.api(login, "%s/issues/%d/comments" % (base, number), "POST",
|
|
||||||
{"body": body}, payload_name="comment-%s" % args.id)
|
|
||||||
verb = "posted"
|
|
||||||
if not isinstance(got, dict) or "id" not in got:
|
|
||||||
_gitea.die("%s failed, unexpected response" % verb)
|
|
||||||
|
|
||||||
comments = _gitea.get_comments(login, base, number)
|
|
||||||
cpath = os.path.join(root, "%s.comments.md" % args.id)
|
|
||||||
if comments:
|
|
||||||
with open(cpath, "w") as f:
|
|
||||||
f.write(gmap.render_comments(comments))
|
|
||||||
elif os.path.isfile(cpath):
|
|
||||||
os.remove(cpath)
|
|
||||||
|
|
||||||
print("%s comment %s on %s (#%d) %s"
|
|
||||||
% (verb, got["id"], args.id, number, got.get("html_url", "")))
|
|
||||||
print("thread: %s (%d comment(s))" % (cpath, len(comments)))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
evict.py — ask Gitea which stored issues are closed, then evict those.
|
|
||||||
|
|
||||||
evict.py check every synced issue in the store, evict the
|
|
||||||
ones Gitea says are closed
|
|
||||||
evict.py old-thing … only these
|
|
||||||
evict.py --dry-run ask, report, change nothing
|
|
||||||
|
|
||||||
The offline command is `/tea:issue`'s `issue_evict.py`, and it is the one that
|
|
||||||
decides and deletes — this script adds exactly one thing in front of it: a
|
|
||||||
`state:` that is not stale. A local `state:` is only as fresh as the last pull,
|
|
||||||
so an issue closed in the web UI an hour ago still reads `open` here and the
|
|
||||||
offline command will (correctly) leave it alone. That is the gap this closes,
|
|
||||||
and it is the observed workflow: before this existed the operator had to
|
|
||||||
`pull.py 11 12 13 14 15` first, which re-wrote the five closed files onto disk
|
|
||||||
before anything could remove them.
|
|
||||||
|
|
||||||
Order of operations, and it is the whole safety argument:
|
|
||||||
|
|
||||||
1. every candidate's state is fetched — ALL of them, before anything is
|
|
||||||
removed;
|
|
||||||
2. each answer must be an object carrying the number we asked about and a
|
|
||||||
state from the domain's own vocabulary (`confirmed_state`);
|
|
||||||
3. only then is the eviction run, by handing the refreshed issues to
|
|
||||||
`issue_evict.run` — the same decision, the same deletion, the same
|
|
||||||
protection of `origin: local`, in one place.
|
|
||||||
|
|
||||||
A `tea` that will not run, a non-2xx, an answer for another issue, a state
|
|
||||||
nobody recognizes: the run stops at step 2 and NOTHING is deleted, not even the
|
|
||||||
issues whose answers had already arrived. That is stricter than `push.py`, which
|
|
||||||
deletes as it goes, and it costs nothing here — there is no ordering constraint
|
|
||||||
between evictions, so there is no reason to start before every answer is in.
|
|
||||||
|
|
||||||
A candidate is an issue carrying a `gitea:` handle. `origin: local` work has
|
|
||||||
none, is never asked about, and is never evicted — it is not in the tracker to
|
|
||||||
be closed. An `origin: gitea` issue whose handle is missing or unparseable
|
|
||||||
cannot be verified, so it is reported and kept rather than guessed at.
|
|
||||||
|
|
||||||
Cost: one GET per candidate. The store is a working set that push keeps small,
|
|
||||||
and a wrong answer here deletes a file, so each issue is asked about by its own
|
|
||||||
address rather than inferred from a list that a `--limit` could have truncated.
|
|
||||||
|
|
||||||
Login: the operator's pin from .claude/settings.local.json (see /tea:auth).
|
|
||||||
"""
|
|
||||||
import argparse
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
sys.path[:0] = [_HERE, os.path.normpath(os.path.join(_HERE, "..", "..", "issue", "scripts"))]
|
|
||||||
|
|
||||||
import _gitea # noqa: E402
|
|
||||||
import issue # noqa: E402
|
|
||||||
import issue_evict # noqa: E402
|
|
||||||
import map as gmap # noqa: E402
|
|
||||||
|
|
||||||
|
|
||||||
def candidates(issues, ids=None):
|
|
||||||
"""(checkable, unverifiable) — which issues the tracker can be asked about.
|
|
||||||
|
|
||||||
checkable is [(id, repo, number)] read off the `gitea:` handle, so an issue
|
|
||||||
that lives in another repo is asked about there. unverifiable is
|
|
||||||
[(id, why)]: it names a tracker but carries no handle to reach it by, which
|
|
||||||
is a file to report, never one to delete on a guess.
|
|
||||||
|
|
||||||
An `origin: local` issue is in neither list. It has no handle because it has
|
|
||||||
never left this machine, and asking Gitea about it is not a question that
|
|
||||||
has an answer.
|
|
||||||
"""
|
|
||||||
checkable, unverifiable = [], []
|
|
||||||
for id in (list(ids) if ids else sorted(issues)):
|
|
||||||
iss = issues[id]
|
|
||||||
if iss.is_local:
|
|
||||||
continue
|
|
||||||
repo, number = gmap.parse_remote_key(iss.extra.get("gitea", ""))
|
|
||||||
if not repo or not number:
|
|
||||||
unverifiable.append((id, "origin: %s but no usable `gitea:` handle"
|
|
||||||
% iss.origin))
|
|
||||||
continue
|
|
||||||
checkable.append((id, repo, number))
|
|
||||||
return checkable, unverifiable
|
|
||||||
|
|
||||||
|
|
||||||
def confirmed_state(got, number):
|
|
||||||
"""The state Gitea confirmed for `number`, or None — the deletion gate.
|
|
||||||
|
|
||||||
The counterpart of `push.confirmed_number`, and written the same way: boring,
|
|
||||||
and saying no by default, because everything downstream of a `str` return
|
|
||||||
here may delete a file. An answer counts only when it is a dict, carries the
|
|
||||||
very number we asked about, and names a state the domain recognizes.
|
|
||||||
|
|
||||||
`bool` is rejected explicitly: `True` is an `int` in Python, and an answer
|
|
||||||
about issue `true` is not an answer about issue 42.
|
|
||||||
|
|
||||||
What it does not have to catch, because it never gets here: a non-2xx or a
|
|
||||||
`tea` that would not run at all — `_gitea.api` exits on both.
|
|
||||||
"""
|
|
||||||
if not isinstance(got, dict):
|
|
||||||
return None
|
|
||||||
n = got.get("number")
|
|
||||||
if isinstance(n, bool) or not isinstance(n, int) or n != number:
|
|
||||||
return None
|
|
||||||
state = got.get("state")
|
|
||||||
return state if state in issue.STATES else None
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv=None):
|
|
||||||
ap = argparse.ArgumentParser(
|
|
||||||
description="Evict issues Gitea reports as closed from the local store")
|
|
||||||
ap.add_argument("ids", nargs="*",
|
|
||||||
help="issue ids (default: every synced issue in the store)")
|
|
||||||
ap.add_argument("--dry-run", action="store_true",
|
|
||||||
help="ask the tracker and report; write and delete nothing")
|
|
||||||
ap.add_argument("--out", default=issue.ISSUE_ROOT,
|
|
||||||
help="store root (default: <project>/.tea/issues)")
|
|
||||||
args = ap.parse_args(argv)
|
|
||||||
|
|
||||||
root = args.out
|
|
||||||
if not issue.store_exists(root):
|
|
||||||
_gitea.die("store %s does not exist — nothing to evict" % root)
|
|
||||||
issues = issue.load_all(root)
|
|
||||||
missing = [i for i in args.ids if i not in issues]
|
|
||||||
if missing:
|
|
||||||
_gitea.die("no such issue(s) in the store: %s" % ", ".join(missing))
|
|
||||||
|
|
||||||
checkable, unverifiable = candidates(issues, args.ids)
|
|
||||||
for id, why in unverifiable:
|
|
||||||
_gitea.warn("%s: %s — kept, and not asked about" % (id, why))
|
|
||||||
if not checkable:
|
|
||||||
print("nothing to check: no issue in the store carries a `gitea:` handle")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
login = _gitea.require_login()
|
|
||||||
|
|
||||||
# ---- every answer first, deletions after -----------------------------
|
|
||||||
fresh = {}
|
|
||||||
for id, repo, number in checkable:
|
|
||||||
got = _gitea.api(login, "%s/issues/%d" % (_gitea.repo_base(repo), number))
|
|
||||||
state = confirmed_state(got, number)
|
|
||||||
if state is None:
|
|
||||||
_gitea.die("%s: the tracker's answer for %s#%d does not confirm a state "
|
|
||||||
"(%.200r). Nothing was evicted."
|
|
||||||
% (id, repo, number, got))
|
|
||||||
fresh[id] = state
|
|
||||||
|
|
||||||
# The store stops lying even about the issues that stay: an answer already
|
|
||||||
# paid for is written back when it disagrees with the file. This is the only
|
|
||||||
# write this script makes, and a dry run makes none.
|
|
||||||
for id, state in sorted(fresh.items()):
|
|
||||||
was = issues[id].state
|
|
||||||
if was == state:
|
|
||||||
continue
|
|
||||||
print("state %s %s -> %s" % (id, was, state))
|
|
||||||
issues[id].state = state
|
|
||||||
if not args.dry_run:
|
|
||||||
issue.save(root, issues[id])
|
|
||||||
|
|
||||||
issue_evict.run(root, issues, [id for id, _, _ in checkable], args.dry_run)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -1,239 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
labels.py — put the canonical label set into a repository, in one run.
|
|
||||||
|
|
||||||
Every `type/*` and every `severity/*` the domain taxonomy defines, created up
|
|
||||||
front instead of trickling in as a side effect of whichever push first happens
|
|
||||||
to use one. Until a name exists in the repository nobody can filter by it in
|
|
||||||
the web UI, so somebody makes their own with a foreign color and without
|
|
||||||
`exclusive`, and the set arrives in pieces over months.
|
|
||||||
|
|
||||||
labels.py --dry-run print the plan, write nothing
|
|
||||||
labels.py create whatever is missing
|
|
||||||
labels.py --fix also patch color / `exclusive` drift
|
|
||||||
labels.py --repo owner/repo outside the repository's own checkout
|
|
||||||
|
|
||||||
No label name is spelled out in this file. The names are assembled from the
|
|
||||||
domain — issue.TYPES, issue.SEVERITIES, issue.EXCLUSIVE_NS — and painted by
|
|
||||||
map.label_specs; add a type over in skills/issue and the next run creates it.
|
|
||||||
`tea labels create` cannot set `exclusive` (tea 0.14.2), so creation goes
|
|
||||||
through `tea api`.
|
|
||||||
|
|
||||||
The repository's own labels are read before anything is written. A name that
|
|
||||||
matches exactly is left alone — never re-created, never patched; a color or
|
|
||||||
`exclusive` that disagrees with the spec is reported, and corrected only under
|
|
||||||
--fix. A name that merely RESEMBLES a canonical one (the same tail, up to
|
|
||||||
case, separator and whatever namespace is in front: `X`, `x`, `kind/x`,
|
|
||||||
`type: x` against `type/x`) is reported with its id and never touched —
|
|
||||||
renaming somebody else's label is a decision, not a step.
|
|
||||||
|
|
||||||
Out of scope by design: `tech/*` and `comp/*`, which are open-ended and get
|
|
||||||
created by push as they come up, and deleting or renaming anything at all.
|
|
||||||
Only repository labels are read; an organization's own labels sit behind a
|
|
||||||
different endpoint and are neither read nor written.
|
|
||||||
|
|
||||||
The issue store is out of scope too, and not incidentally. A label belongs to
|
|
||||||
the repository, not to any issue, so this command neither reads .tea/issues/ nor
|
|
||||||
creates it — the taxonomy it paints comes from the domain MODULE, and the
|
|
||||||
request bodies it sends go to the transport's own .tea/payload/.
|
|
||||||
|
|
||||||
Login: the operator's pin from .claude/settings.local.json (see /tea:auth).
|
|
||||||
"""
|
|
||||||
import argparse
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
|
|
||||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
sys.path[:0] = [_HERE, os.path.normpath(os.path.join(_HERE, "..", "..", "issue", "scripts"))]
|
|
||||||
|
|
||||||
import _gitea # noqa: E402
|
|
||||||
import issue # noqa: E402
|
|
||||||
import map as gmap # noqa: E402
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# the canonical set
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
# Which taxonomy collection fills which exclusive namespace. Both sides are the
|
|
||||||
# domain's — this dict is only the join between them, and it is the whole
|
|
||||||
# reason no name has to be repeated here.
|
|
||||||
MEMBERS = {"type/": issue.TYPES, "severity/": issue.SEVERITIES}
|
|
||||||
|
|
||||||
|
|
||||||
def canonical_names():
|
|
||||||
"""Every name in the canonical set, in taxonomy order.
|
|
||||||
|
|
||||||
Which namespaces are exclusive is issue.EXCLUSIVE_NS; what lives in each
|
|
||||||
is MEMBERS, i.e. the domain again. A namespace the domain declares but
|
|
||||||
MEMBERS does not know about is handed back separately — better reported
|
|
||||||
than quietly missing from the set."""
|
|
||||||
names, orphan = [], []
|
|
||||||
for ns in issue.EXCLUSIVE_NS:
|
|
||||||
if ns in MEMBERS:
|
|
||||||
names += [ns + m for m in MEMBERS[ns]]
|
|
||||||
else:
|
|
||||||
orphan.append(ns)
|
|
||||||
return names, orphan
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# lookalikes
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
WORDS = re.compile(r'[^a-z0-9]+')
|
|
||||||
|
|
||||||
|
|
||||||
def akin(name):
|
|
||||||
"""Comparison keys for a label name: its tail, and the whole name squashed.
|
|
||||||
|
|
||||||
Case, separators and the namespace in front are noise — what a person
|
|
||||||
meant is the tail. `x`, `X`, `kind/x` all reduce to the same tail as
|
|
||||||
`type/x`, and `severity: x y` to the same squashed form as `severity/xy`.
|
|
||||||
Two names resemble each other when these sets intersect."""
|
|
||||||
parts = [p for p in WORDS.split(name.lower()) if p]
|
|
||||||
return {parts[-1], "".join(parts)} if parts else set()
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# plan
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def color_of(value):
|
|
||||||
"""Gitea reports colors bare, map.py writes them with a `#`. Same color."""
|
|
||||||
return (value or "").lstrip("#").lower()
|
|
||||||
|
|
||||||
|
|
||||||
def drift_of(spec, got):
|
|
||||||
"""Where an existing label disagrees with the spec, as (field, is, want).
|
|
||||||
|
|
||||||
Only color and `exclusive` — a description somebody rewrote is theirs, and
|
|
||||||
the name matched exactly or we would not be here."""
|
|
||||||
out = []
|
|
||||||
if color_of(got.get("color")) != color_of(spec.get("color")):
|
|
||||||
out.append(("color", color_of(got.get("color")), color_of(spec.get("color"))))
|
|
||||||
if bool(got.get("exclusive")) != bool(spec.get("exclusive")):
|
|
||||||
out.append(("exclusive", str(bool(got.get("exclusive"))).lower(),
|
|
||||||
str(bool(spec.get("exclusive"))).lower()))
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def plan(specs, existing):
|
|
||||||
"""(rows, similar) for one repository, decided before anything is written.
|
|
||||||
|
|
||||||
A row is (name, spec, got, drift), one per canonical label in taxonomy
|
|
||||||
order: `got` is the repository's own payload when that exact name is
|
|
||||||
already there (None when it is not), `drift` what disagrees with the spec.
|
|
||||||
|
|
||||||
`similar` is (name, id, [canonical it resembles]) for the repository's
|
|
||||||
other labels. They are reported and left alone: this script owns the
|
|
||||||
canonical names, not everything that looks like one."""
|
|
||||||
by_name = dict((l.get("name", ""), l) for l in existing or [])
|
|
||||||
|
|
||||||
rows = []
|
|
||||||
for name in specs:
|
|
||||||
got = by_name.get(name)
|
|
||||||
rows.append((name, specs[name], got, drift_of(specs[name], got) if got else []))
|
|
||||||
|
|
||||||
keys = dict((name, akin(name)) for name in specs)
|
|
||||||
similar = []
|
|
||||||
for l in existing or []:
|
|
||||||
name = l.get("name", "")
|
|
||||||
if name in specs:
|
|
||||||
continue
|
|
||||||
mine = akin(name)
|
|
||||||
hits = [n for n in specs if keys[n] & mine]
|
|
||||||
if hits:
|
|
||||||
similar.append((name, l.get("id"), hits))
|
|
||||||
return rows, similar
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# run
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def main():
|
|
||||||
ap = argparse.ArgumentParser(
|
|
||||||
description="Create the canonical type/* and severity/* labels in a repository")
|
|
||||||
ap.add_argument("--dry-run", action="store_true",
|
|
||||||
help="print the plan; not one writing request")
|
|
||||||
ap.add_argument("--fix", action="store_true",
|
|
||||||
help="also patch color/exclusive on labels that already exist")
|
|
||||||
ap.add_argument("--repo", help="owner/repo (default: auto-detect from CWD git remote)")
|
|
||||||
args = ap.parse_args()
|
|
||||||
|
|
||||||
names, orphan = canonical_names()
|
|
||||||
for ns in orphan:
|
|
||||||
_gitea.warn("namespace %r is exclusive in the domain but has no members here "
|
|
||||||
"— nothing created for it" % ns)
|
|
||||||
specs = gmap.label_specs(names)
|
|
||||||
|
|
||||||
login = _gitea.require_login()
|
|
||||||
base = _gitea.repo_base(args.repo)
|
|
||||||
|
|
||||||
# Read first, always: the plan is decided against the repository itself,
|
|
||||||
# never against .tea/issues/.labels.json. That cache is what makes
|
|
||||||
# _gitea.ensure_labels cheap for push.py and wrong for a bootstrap — it
|
|
||||||
# answers "what did we create last time", and the answer here has to be
|
|
||||||
# "what does the repository have right now".
|
|
||||||
existing = _gitea.paginate(login, "%s/labels" % base, limit=100)
|
|
||||||
rows, similar = plan(specs, existing)
|
|
||||||
|
|
||||||
fixed, drifted = 0, 0
|
|
||||||
for name, spec, got, drift in rows:
|
|
||||||
mark = " exclusive" if spec.get("exclusive") else ""
|
|
||||||
|
|
||||||
if got is None:
|
|
||||||
if args.dry_run:
|
|
||||||
print("create %-20s %s%s" % (name, spec["color"], mark))
|
|
||||||
continue
|
|
||||||
payload = dict(spec, name=name)
|
|
||||||
new = _gitea.api(login, "%s/labels" % base, "POST", payload,
|
|
||||||
payload_name="label-%s" % name.replace("/", "-"))
|
|
||||||
if not new or "id" not in new:
|
|
||||||
_gitea.die("could not create label %r" % name)
|
|
||||||
print("created %-20s id %-5s %s%s" % (name, new["id"], spec["color"], mark))
|
|
||||||
continue
|
|
||||||
|
|
||||||
if not drift:
|
|
||||||
print("present %-20s id %s" % (name, got.get("id")))
|
|
||||||
continue
|
|
||||||
|
|
||||||
drifted += 1
|
|
||||||
shown = ", ".join("%s %s -> %s" % d for d in drift)
|
|
||||||
if not args.fix:
|
|
||||||
print("present %-20s id %-5s drift: %s" % (name, got.get("id"), shown))
|
|
||||||
continue
|
|
||||||
if args.dry_run:
|
|
||||||
print("fix %-20s id %-5s %s" % (name, got.get("id"), shown))
|
|
||||||
continue
|
|
||||||
# Gitea 1.26 patches only the fields it is given, but the unchanged
|
|
||||||
# name and description ride along anyway: they cost nothing and an
|
|
||||||
# older server that reads an absent field as empty would blank them.
|
|
||||||
patch = {"name": name, "description": got.get("description") or ""}
|
|
||||||
for field, _is, _want in drift:
|
|
||||||
patch[field] = spec[field]
|
|
||||||
_gitea.api(login, "%s/labels/%s" % (base, got.get("id")), "PATCH", patch,
|
|
||||||
payload_name="label-%s" % name.replace("/", "-"))
|
|
||||||
fixed += 1
|
|
||||||
print("fixed %-20s id %-5s %s" % (name, got.get("id"), shown))
|
|
||||||
|
|
||||||
for name, id, hits in similar:
|
|
||||||
_gitea.warn("%r (id %s) resembles %s — left alone; rename it by hand or ignore it"
|
|
||||||
% (name, id, ", ".join(hits)))
|
|
||||||
|
|
||||||
missing = sum(1 for r in rows if r[2] is None)
|
|
||||||
print("%d canonical label(s): %d %s, %d present%s%s"
|
|
||||||
% (len(rows), missing, "to create" if args.dry_run else "created",
|
|
||||||
len(rows) - missing,
|
|
||||||
" (%d drifted, %d fixed)" % (drifted, fixed) if drifted else "",
|
|
||||||
", %d similar" % len(similar) if similar else ""))
|
|
||||||
if drifted and not args.fix:
|
|
||||||
print("drift is shown, not applied — re-run with --fix to patch color/exclusive")
|
|
||||||
if args.dry_run:
|
|
||||||
print("dry-run — nothing was written")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,354 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
map.py — md <-> Gitea JSON. The whole translation, and only the translation.
|
|
||||||
|
|
||||||
Pure functions: no network, no filesystem, no argparse. Give it a payload and
|
|
||||||
it hands back a domain Issue; give it an Issue and it hands back a request
|
|
||||||
body. That purity is the point — it can be reasoned about and tested without a
|
|
||||||
Gitea anywhere, and it is the single file to open when the two representations
|
|
||||||
disagree.
|
|
||||||
|
|
||||||
Direction of knowledge: this module imports the domain (issue.py) and is
|
|
||||||
imported by the transport's callers. The domain never imports this.
|
|
||||||
|
|
||||||
What crosses the boundary, and what does not:
|
|
||||||
|
|
||||||
domain Gitea note
|
|
||||||
----------------------------------------------------------------------
|
|
||||||
id (slug) body marker `<!-- tea:id … -->`, first line of
|
|
||||||
the tracker-side body; stripped out
|
|
||||||
of the local copy — see below
|
|
||||||
title title verbatim, both ways
|
|
||||||
body body verbatim up, verbatim down except
|
|
||||||
the marker and checkbox state — see
|
|
||||||
with_id_marker / merge_checkbox_state
|
|
||||||
state state open/closed, same vocabulary
|
|
||||||
labels labels[] names both ways; ids only on write
|
|
||||||
assignees assignees[] logins
|
|
||||||
milestone milestone.title resolved to an id on write
|
|
||||||
depends — slugs; #N is translated at the edge
|
|
||||||
— number, html_url lands in extra as gitea:/url:
|
|
||||||
— ref extra as branch:; push fills it from git
|
|
||||||
|
|
||||||
`depends:` is the authoritative graph and is always slugs. The body's
|
|
||||||
`## Depends on` section is human prose and is passed through UNCHANGED in both
|
|
||||||
directions: a pull seeds `depends:` from the `#N` it finds there, and a push
|
|
||||||
never rewrites what the author wrote. Deliberate — a translator that edits
|
|
||||||
prose churns the body on every round trip.
|
|
||||||
|
|
||||||
The ONE thing this module does add to a body is the id marker, and it does so
|
|
||||||
because the slug now has to survive a push: `push.py` deletes the local file,
|
|
||||||
so the tracker has to remember what the issue was called here. See
|
|
||||||
`with_id_marker`.
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
|
|
||||||
sys.path.insert(0, os.path.normpath(os.path.join(
|
|
||||||
os.path.dirname(os.path.abspath(__file__)), "..", "..", "issue", "scripts")))
|
|
||||||
import issue # noqa: E402
|
|
||||||
|
|
||||||
# How the taxonomy is painted in Gitea's UI. A hex code says nothing about what
|
|
||||||
# an issue IS, which is exactly why it lives here and not in the domain.
|
|
||||||
LABEL_COLORS = {
|
|
||||||
"type/bug": "#ee0701",
|
|
||||||
"type/task": "#0e8a16",
|
|
||||||
"type/refactor": "#1d76db",
|
|
||||||
"type/test": "#fbca04",
|
|
||||||
"type/feature": "#5319e7",
|
|
||||||
"type/draft": "#cccccc",
|
|
||||||
"severity/low": "#c2e0c6",
|
|
||||||
"severity/medium": "#fbca04",
|
|
||||||
"severity/high": "#eb6420",
|
|
||||||
"severity/showstopper": "#ee0701",
|
|
||||||
"severity/critical": "#b60205",
|
|
||||||
}
|
|
||||||
DEFAULT_COLOR = "#ededed"
|
|
||||||
|
|
||||||
# What this bridge writes into the domain's `origin:` field. The domain records
|
|
||||||
# that an issue exists somewhere else; only this module knows where.
|
|
||||||
ORIGIN = "gitea"
|
|
||||||
|
|
||||||
# Metadata key for Gitea's `ref` — the branch an issue is pinned to. A sync
|
|
||||||
# field: its value is a git branch name and means exactly `ref`, so the domain
|
|
||||||
# carries it in `extra` and never reads it.
|
|
||||||
BRANCH_KEY = "branch"
|
|
||||||
|
|
||||||
|
|
||||||
def label_specs(names):
|
|
||||||
"""{name: {color, description, exclusive}} for the transport to create.
|
|
||||||
|
|
||||||
Exclusivity and meaning come from the domain taxonomy; only the color is
|
|
||||||
decided here. `tea labels create` cannot set `exclusive` (as of 0.14.2),
|
|
||||||
which is why these go through the API."""
|
|
||||||
out = {}
|
|
||||||
for name in names:
|
|
||||||
desc = ""
|
|
||||||
if name.startswith("type/"):
|
|
||||||
desc = issue.TYPES.get(name.split("/", 1)[1], "")
|
|
||||||
out[name] = {
|
|
||||||
"color": LABEL_COLORS.get(name, DEFAULT_COLOR),
|
|
||||||
"description": desc,
|
|
||||||
"exclusive": name.startswith(issue.EXCLUSIVE_NS),
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def remote_key(repo, number):
|
|
||||||
"""Stable cross-repo handle: owner/repo#42."""
|
|
||||||
return "%s#%d" % (repo, int(number))
|
|
||||||
|
|
||||||
|
|
||||||
def parse_remote_key(key):
|
|
||||||
repo, _, num = (key or "").rpartition("#")
|
|
||||||
return (repo, int(num)) if repo and num.isdigit() else (None, None)
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# the id marker: the slug, kept tracker-side
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# `push.py` deletes the local file once the tracker has confirmed the write, so
|
|
||||||
# the slug — the issue's ONLY identity in the domain — cannot live only on this
|
|
||||||
# machine any more. It rides up in the body as an HTML comment:
|
|
||||||
#
|
|
||||||
# <!-- tea:id wire-sqlc-appclick -->
|
|
||||||
#
|
|
||||||
# Why the body and not `.remote.json`: the map is a local file, and "the local
|
|
||||||
# copy is not the record" is the whole point of deleting it. A marker in the
|
|
||||||
# body survives a rename in the web UI, a lost `.remote.json`, a fresh clone,
|
|
||||||
# and a second machine — none of which the map does. Why an HTML comment: Gitea
|
|
||||||
# renders markdown, so it is invisible to a human reader, and it comes back
|
|
||||||
# verbatim on every API read.
|
|
||||||
#
|
|
||||||
# WHERE: the first line of the tracker-side body, followed by one blank line.
|
|
||||||
# First because it is the one position that does not depend on what sections the
|
|
||||||
# issue happens to have, and because a human who does look at the raw markdown
|
|
||||||
# finds it before the prose rather than buried in it.
|
|
||||||
#
|
|
||||||
# WHAT THE LOCAL FILE SEES: nothing. `from_api` strips every marker before the
|
|
||||||
# body is written to disk, so `.tea/issues/<id>.md` holds exactly what the author
|
|
||||||
# wrote — checkbox line numbers, `issue_check.py`, and diffs are all unaffected,
|
|
||||||
# and the slug is already the file's name, so a copy of it in the body would be
|
|
||||||
# duplicated state.
|
|
||||||
#
|
|
||||||
# WHY IT CANNOT ACCUMULATE: the two operations are strip-all and
|
|
||||||
# strip-all-then-prepend-one. `with_id_marker` never appends to what is there,
|
|
||||||
# and `strip_id_marker` removes EVERY marker line, not the first. So a body that
|
|
||||||
# somehow gained two (a hand-edit in the web UI, a copy-paste) is cleaned on the
|
|
||||||
# next pull and goes back up with exactly one. There is no code path that adds
|
|
||||||
# a marker to a body that has not just been stripped.
|
|
||||||
|
|
||||||
_MARKER_LINE = re.compile(r'^[ \t]*<!--[ \t]*tea:id[ \t]+(\S+)[ \t]*-->[ \t]*$')
|
|
||||||
|
|
||||||
|
|
||||||
def id_marker(id):
|
|
||||||
"""The marker line for a slug. One place formats it, one regex reads it."""
|
|
||||||
return "<!-- tea:id %s -->" % id
|
|
||||||
|
|
||||||
|
|
||||||
def id_in_body(body):
|
|
||||||
"""The slug a tracker-side body claims, or None.
|
|
||||||
|
|
||||||
The FIRST valid marker wins; a second one is ignored here and removed by
|
|
||||||
`strip_id_marker` on the way in. The captured text must be a slug by the
|
|
||||||
domain's own rule — a marker holding anything else is not a slug and is
|
|
||||||
treated as if it were not there, so a mangled comment falls back to the
|
|
||||||
title instead of naming a file after garbage."""
|
|
||||||
for line in (body or "").splitlines():
|
|
||||||
m = _MARKER_LINE.match(line)
|
|
||||||
if m and issue.SLUG_OK.match(m.group(1)):
|
|
||||||
return m.group(1)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def strip_id_marker(body):
|
|
||||||
"""`body` with every marker line removed. Idempotent.
|
|
||||||
|
|
||||||
A body that carries no marker is returned byte for byte — the common case
|
|
||||||
(an issue filed in the web UI) costs nothing and is not reformatted. When a
|
|
||||||
marker is removed from the top, the blank line it was written with goes with
|
|
||||||
it, so the round trip is exact: strip(with_id_marker(b, id)) == b."""
|
|
||||||
text = body or ""
|
|
||||||
if not any(_MARKER_LINE.match(l) for l in text.splitlines()):
|
|
||||||
return text
|
|
||||||
kept = [l for l in text.splitlines() if not _MARKER_LINE.match(l)]
|
|
||||||
return "\n".join(kept).lstrip("\n")
|
|
||||||
|
|
||||||
|
|
||||||
def with_id_marker(body, id):
|
|
||||||
"""`body` with exactly one marker, as its first line.
|
|
||||||
|
|
||||||
Strip-then-prepend, always — that is the guarantee that a body can never end
|
|
||||||
up with two, however many it arrived with."""
|
|
||||||
return "%s\n\n%s" % (id_marker(id), strip_id_marker(body))
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# Gitea -> domain
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def numbers_in_body(body):
|
|
||||||
"""`#N` referenced from the body's dependency sections, as ints. Used only
|
|
||||||
to seed `depends:` on the first pull."""
|
|
||||||
return [int(r[1:]) for r in issue.body_dep_refs(body) if r.startswith("#")]
|
|
||||||
|
|
||||||
|
|
||||||
def merge_checkbox_state(remote_body, local_body):
|
|
||||||
"""The remote body with every tick the local copy already had put back.
|
|
||||||
|
|
||||||
The one exception to "a pull overwrites the body", and it is deliberately
|
|
||||||
the narrowest one that works. A tick is **monotone** — an item only ever
|
|
||||||
travels `[ ]` -> `[x]` — so the two sides are joined by a set union, not
|
|
||||||
reconciled: no base version, no drift tracking, no conflict to resolve. The
|
|
||||||
set is a set of item TEXTS, and an item comes out ticked when either side
|
|
||||||
has it ticked. Everything else in the body is still the remote's word.
|
|
||||||
|
|
||||||
Matching is on `Checkbox.text`, which the domain parser has already
|
|
||||||
stripped and rejoined with single spaces, so rewrapping a long item does
|
|
||||||
not cost it its tick. It is otherwise literal: reword an item and it is a
|
|
||||||
different item — the tick stays with the wording it was put on.
|
|
||||||
|
|
||||||
**The same text more than once** is read as the rule says, as a set: one
|
|
||||||
ticked local item ticks every remote item with that text. The alternative —
|
|
||||||
pairing duplicates up by order — is the reading that can still drop a tick
|
|
||||||
(local `[ ]` then `[x]`, remote a single line: the ticked one pairs with
|
|
||||||
nothing), and dropping a tick is the bug this exists to fix. Two items
|
|
||||||
whose text is identical are the same item to whoever reads them.
|
|
||||||
|
|
||||||
Pure: no store, no tracker, no I/O. A `local_body` of None or "" — a first
|
|
||||||
pull, an empty store — returns the remote body untouched.
|
|
||||||
|
|
||||||
The price, accepted explicitly: UNticking is not monotone, so a box
|
|
||||||
unticked in the web UI comes back on the next pull. Untick locally, push.
|
|
||||||
"""
|
|
||||||
ticked = {c.text for c in issue.checkboxes(local_body) if c.checked}
|
|
||||||
if not ticked:
|
|
||||||
return remote_body
|
|
||||||
body = remote_body
|
|
||||||
# set_checkbox trades one character for one character, so line numbers read
|
|
||||||
# off `remote_body` stay valid against the partially rewritten `body`.
|
|
||||||
for c in issue.checkboxes(remote_body):
|
|
||||||
if not c.checked and c.text in ticked:
|
|
||||||
body = issue.set_checkbox(body, c.line, True)
|
|
||||||
return body
|
|
||||||
|
|
||||||
|
|
||||||
def from_api(payload, id, repo, id_for_number=None, extra_numbers=(), synced=None,
|
|
||||||
local_body=None):
|
|
||||||
"""Build a domain Issue from a Gitea issue payload.
|
|
||||||
|
|
||||||
id_for_number maps a Gitea number to a local slug — dependencies whose
|
|
||||||
target has not been pulled yet are dropped from `depends:` (the body still
|
|
||||||
names them, so nothing is lost) rather than invented.
|
|
||||||
|
|
||||||
`local_body` is the body of the copy already in the store, when there is
|
|
||||||
one. It contributes exactly one thing: its ticked checkboxes survive the
|
|
||||||
overwrite (merge_checkbox_state). Pass None and the remote body is taken
|
|
||||||
whole, which is what a first pull does.
|
|
||||||
|
|
||||||
The id marker is stripped before anything else looks at the body: it is
|
|
||||||
transport bookkeeping, and the caller has already read the slug off it
|
|
||||||
(`pull.id_for`). Everything downstream — checkboxes, `#N` references, what
|
|
||||||
lands on disk — sees the body the author wrote."""
|
|
||||||
body = merge_checkbox_state(
|
|
||||||
strip_id_marker((payload.get("body") or "").strip()), local_body)
|
|
||||||
id_for_number = id_for_number or {}
|
|
||||||
|
|
||||||
numbers = list(numbers_in_body(body))
|
|
||||||
for n in extra_numbers:
|
|
||||||
if n not in numbers:
|
|
||||||
numbers.append(n)
|
|
||||||
depends, unresolved = [], []
|
|
||||||
for n in numbers:
|
|
||||||
slug = id_for_number.get(n)
|
|
||||||
if slug and slug != id and slug not in depends:
|
|
||||||
depends.append(slug)
|
|
||||||
elif not slug:
|
|
||||||
unresolved.append(n)
|
|
||||||
|
|
||||||
extra = {
|
|
||||||
"gitea": remote_key(repo, payload["number"]),
|
|
||||||
"url": payload.get("html_url", ""),
|
|
||||||
"synced": synced or "",
|
|
||||||
}
|
|
||||||
if payload.get("ref"):
|
|
||||||
extra[BRANCH_KEY] = payload["ref"]
|
|
||||||
if payload.get("updated_at"):
|
|
||||||
extra["remote-updated"] = payload["updated_at"]
|
|
||||||
if payload.get("comments"):
|
|
||||||
extra["comments"] = payload["comments"]
|
|
||||||
|
|
||||||
iss = issue.Issue(
|
|
||||||
id=id,
|
|
||||||
title=payload.get("title", ""),
|
|
||||||
body=body,
|
|
||||||
state=payload.get("state") or "open",
|
|
||||||
labels=[l.get("name", "") for l in payload.get("labels") or []],
|
|
||||||
assignees=[a.get("login", "") for a in payload.get("assignees") or []],
|
|
||||||
milestone=(payload.get("milestone") or {}).get("title") or "",
|
|
||||||
depends=depends,
|
|
||||||
origin=ORIGIN,
|
|
||||||
extra=extra)
|
|
||||||
return iss, unresolved
|
|
||||||
|
|
||||||
|
|
||||||
def render_comments(comments):
|
|
||||||
"""Comment thread as flat markdown. Read-only: nothing writes it back."""
|
|
||||||
out = []
|
|
||||||
for c in comments:
|
|
||||||
out.append("## comment %s — %s — %s" % (
|
|
||||||
c.get("id"), (c.get("user") or {}).get("login", ""),
|
|
||||||
(c.get("created_at") or "")[:10]))
|
|
||||||
out.append("")
|
|
||||||
out.append((c.get("body") or "(empty)").strip())
|
|
||||||
out.append("")
|
|
||||||
return "\n".join(out)
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# domain -> Gitea
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def to_payload(iss, label_ids=None, milestone_id=None, include_state=False):
|
|
||||||
"""Request body for POST /issues or PATCH /issues/{n}.
|
|
||||||
|
|
||||||
The prose is sent verbatim — see the module docstring on why slugs in
|
|
||||||
`## Depends on` are not rewritten to `#N`. The one addition is the id
|
|
||||||
marker, prepended (never appended) so the tracker remembers the slug after
|
|
||||||
push has deleted the local file. `from_api` takes it straight back off, so
|
|
||||||
the body still round-trips byte for byte."""
|
|
||||||
payload = {"title": iss.title,
|
|
||||||
"body": with_id_marker(iss.body.strip(), iss.id)}
|
|
||||||
if label_ids is not None:
|
|
||||||
payload["labels"] = [label_ids[l] for l in iss.labels if l in label_ids]
|
|
||||||
if iss.assignees:
|
|
||||||
payload["assignees"] = list(iss.assignees)
|
|
||||||
if milestone_id is not None:
|
|
||||||
payload["milestone"] = milestone_id
|
|
||||||
if include_state:
|
|
||||||
payload["state"] = iss.state
|
|
||||||
# An empty `branch:` is "no opinion", not "no branch": sending ref="" would
|
|
||||||
# clear whatever is set on the Gitea side, so the key is left out instead.
|
|
||||||
branch = (iss.extra.get(BRANCH_KEY) or "").strip()
|
|
||||||
if branch:
|
|
||||||
payload["ref"] = branch
|
|
||||||
return payload
|
|
||||||
|
|
||||||
|
|
||||||
def apply_remote(iss, payload, repo, synced):
|
|
||||||
"""Stamp the sync-owned fields onto an issue after a successful write.
|
|
||||||
Mutates and returns it; `origin` is the one domain field this touches."""
|
|
||||||
iss.origin = ORIGIN
|
|
||||||
iss.extra["gitea"] = remote_key(repo, payload["number"])
|
|
||||||
iss.extra["url"] = payload.get("html_url", "")
|
|
||||||
iss.extra["synced"] = synced
|
|
||||||
if payload.get("updated_at"):
|
|
||||||
iss.extra["remote-updated"] = payload["updated_at"]
|
|
||||||
return iss
|
|
||||||
|
|
||||||
|
|
||||||
def number_of(iss):
|
|
||||||
"""Gitea number for an already-synced issue, or None."""
|
|
||||||
_repo, n = parse_remote_key(iss.extra.get("gitea", ""))
|
|
||||||
return n
|
|
||||||
@@ -1,384 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
pull.py — Gitea issues -> the local store.
|
|
||||||
|
|
||||||
Writes flat markdown the domain layer owns and prints a compact index; the raw
|
|
||||||
API payload never reaches the conversation.
|
|
||||||
|
|
||||||
**This is how you get a pushed issue back.** `push.py` deletes the local file
|
|
||||||
once Gitea has confirmed it, so pulling is not a refresh of a copy you kept —
|
|
||||||
it is how the copy comes to exist. It lands under the SAME slug it had before,
|
|
||||||
even after a rename in the web UI and even on a machine that has never seen the
|
|
||||||
issue: the slug travels in the body as `<!-- tea:id … -->`, and
|
|
||||||
.tea/issues/.remote.json indexes it by number. See `id_for` for the order those
|
|
||||||
are consulted in. The marker itself is stripped out of what is written to disk.
|
|
||||||
|
|
||||||
Two ways to name what to pull:
|
|
||||||
|
|
||||||
pull.py 42 [17 …] by key: 42 | #42 | owner/repo#42 | URL
|
|
||||||
pull.py --milestone 6 by filter: whole milestone in ONE request
|
|
||||||
pull.py --label type/bug --state all
|
|
||||||
pull.py -q sqlc --limit 20
|
|
||||||
|
|
||||||
Filter mode costs one request per 50 issues — the list payload already carries
|
|
||||||
the bodies. Gitea silently ignores an unresolvable `milestones=` filter and
|
|
||||||
returns the whole backlog, so the milestone is resolved up front and every
|
|
||||||
issue is re-checked locally. Projects are NOT filterable: the projects API is
|
|
||||||
not exposed (404 on Gitea 1.26) — use milestones or labels, or the web UI.
|
|
||||||
|
|
||||||
A closed issue is not a unit of work, so filter mode enumerates it but leaves
|
|
||||||
it out of the store: `--state all` still shows the whole picture, and only
|
|
||||||
`--state closed` writes one. An issue already on disk is refreshed either way,
|
|
||||||
so the local copy learns it was closed instead of staying open forever, and the
|
|
||||||
count of the ones left out goes to stderr. Key mode is exempt: an address is not
|
|
||||||
a bulk read, and `pull.py 1` fetches a closed issue as it always did.
|
|
||||||
|
|
||||||
**`--limit` is on the write, not on the selection.** It counts the issues this
|
|
||||||
run puts in the store — written, or left in place by `--cached` — and never the
|
|
||||||
closed ones it enumerated and threw away. `--limit 20` over a milestone whose
|
|
||||||
first 30 issues are closed still writes 20, if 20 open ones are there to write:
|
|
||||||
pages keep coming until the budget is full. Two boundaries keep that honest:
|
|
||||||
|
|
||||||
- Pages stop the moment the budget is full. Never one page more.
|
|
||||||
- A filtered read may scan at most `_gitea.PAGE_SLACK` times the pages the limit
|
|
||||||
would need if nothing were dropped. A filter that matches almost only closed
|
|
||||||
issues therefore ends in a warning and a short answer, not in a walk of the
|
|
||||||
whole tracker. Narrow the filter, or raise `--limit`, which raises the budget
|
|
||||||
with it.
|
|
||||||
- Dependencies are outside the count: a blocker is followed because a stored
|
|
||||||
issue named it, not because the filter selected it. `--limit 20` can
|
|
||||||
therefore leave more than 20 files behind — the budget counts the selection's
|
|
||||||
writes, and the graph is not part of the selection.
|
|
||||||
|
|
||||||
`remote.py` is the deliberate exception, and it is not the same flag twice: it
|
|
||||||
writes nothing at all, so there is no write to bound and its `--limit` means
|
|
||||||
what it says — how many lines to print.
|
|
||||||
|
|
||||||
Comments ride along by default, in both modes and for every issue written:
|
|
||||||
the thread lands in .tea/issues/<id>.comments.md, beside the issue. It costs
|
|
||||||
nothing when there is nothing to fetch — the payload already carries the
|
|
||||||
comment count, so an issue with none makes no request, and a file left over
|
|
||||||
from an earlier pull is deleted. An absent file therefore means "no comments",
|
|
||||||
never "not asked for". The thread is pull-only: editing it changes nothing in
|
|
||||||
Gitea (post with comment.py).
|
|
||||||
|
|
||||||
**Dependencies come with every pull.** A pull answers with the whole unit of
|
|
||||||
work — the issue and what blocks it — so `depends:` is filled from Gitea's
|
|
||||||
native dependency graph and every blocker is pulled too, recursively, down to
|
|
||||||
`--depth` (default 3). That graph is the only source there is: `map.from_api`
|
|
||||||
writes slugs into the `## Depends on` prose and never `#N`, so an edge cannot be
|
|
||||||
recovered from the body. `--no-deps` turns off both halves — no `depends:`, no
|
|
||||||
recursion, and no request spent on either. `--deps` is still accepted and now
|
|
||||||
does nothing; it names what already happens.
|
|
||||||
|
|
||||||
What it costs, stated rather than hidden:
|
|
||||||
|
|
||||||
- **One request per issue that lands in the store** — `GET …/issues/{n}/dependencies`,
|
|
||||||
fetched once and used twice, since the same links both fill `depends:` and
|
|
||||||
tell the walk where to go next. A closed issue that filter mode drops costs
|
|
||||||
nothing: nothing was stored, so there is no unit of work to complete.
|
|
||||||
- **One request per blocker the selection did not already carry** — a `GET` for
|
|
||||||
the issue itself, then its own links, and so on until `--depth`.
|
|
||||||
- So `--milestone X` over 50 open issues is one list request + 50 link requests
|
|
||||||
+ one pair for every blocker outside the milestone, where it used to be one
|
|
||||||
request flat. `--no-deps` is the way back to one.
|
|
||||||
|
|
||||||
**In filter mode a blocker the filter did not select still lands in the store,
|
|
||||||
and that is deliberate.** `--milestone X` can leave an issue from milestone Y on
|
|
||||||
disk and `--label` an unlabelled one: a blocker is followed because a stored
|
|
||||||
issue names it, not because it matched. The one blocker that does not land is a
|
|
||||||
closed one — closed is not a unit of work, filter mode drops it the way it drops
|
|
||||||
any other closed issue, and the `depends:` edge to it goes with it, so nothing
|
|
||||||
points at a file that is not there. Key mode has no such rule and stores it.
|
|
||||||
|
|
||||||
Other flags:
|
|
||||||
--no-deps do not fill depends:, do not follow blockers
|
|
||||||
--deps accepted, does nothing: it is the default now
|
|
||||||
--depth N how deep to follow blockers (default 3)
|
|
||||||
--cached skip issues already on disk (body AND comments)
|
|
||||||
--repo owner/repo default: auto-detect from the CWD git remote
|
|
||||||
|
|
||||||
Pulling overwrites the local body: it is a fetch, not a merge. Local edits you
|
|
||||||
have not pushed are lost — with exactly one exception, checkbox state. A `[x]`
|
|
||||||
on either side wins for any item whose text matches, because a tick is monotone
|
|
||||||
and unioning the two sides is not conflict resolution (gmap.merge_checkbox_state
|
|
||||||
has the rule and its price). `--cached` skips an issue before any of that: it is
|
|
||||||
not read and not merged — it still costs its one link request, because a cached
|
|
||||||
issue's blockers can be missing from disk even when it is not (`--cached
|
|
||||||
--no-deps` is the free one). Draw the graph afterwards with the domain's own
|
|
||||||
issue_tree.py — it needs no network.
|
|
||||||
|
|
||||||
Login: the operator's pin from .claude/settings.local.json (see /tea:auth).
|
|
||||||
"""
|
|
||||||
import argparse
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
sys.path[:0] = [_HERE, os.path.normpath(os.path.join(_HERE, "..", "..", "issue", "scripts"))]
|
|
||||||
|
|
||||||
import _gitea # noqa: E402
|
|
||||||
import issue # noqa: E402
|
|
||||||
import issue_index # noqa: E402
|
|
||||||
import map as gmap # noqa: E402
|
|
||||||
|
|
||||||
|
|
||||||
def id_for(payload, store_ids, remote_map, repo, root):
|
|
||||||
"""The slug this remote issue belongs under. Three sources, in order.
|
|
||||||
|
|
||||||
1. **`.remote.json`, keyed by number.** The local ledger, and the only one
|
|
||||||
that knows about a file sitting on disk right now, so it wins. A
|
|
||||||
retitled issue keeps the slug it was first pulled under.
|
|
||||||
2. **The `<!-- tea:id … -->` marker in the body** (`gmap.id_in_body`). What
|
|
||||||
makes push -> delete -> pull a round trip rather than a rename: the
|
|
||||||
ledger can be lost (a fresh clone, another machine, a deleted
|
|
||||||
`.remote.json`) and the tracker still remembers what this issue is called
|
|
||||||
here — even after the title was changed in the web UI.
|
|
||||||
3. **The title, slugified.** Issues filed in the web UI have no marker and
|
|
||||||
have never had a local name; this is where they get one.
|
|
||||||
|
|
||||||
A marker is only taken at its word when the slug is free. If a file of that
|
|
||||||
name is already in the store, or the ledger has it under another number, the
|
|
||||||
marker is a collision and not an identity — the name is uniquified
|
|
||||||
(`marked-2`) rather than allowed to overwrite somebody else's issue."""
|
|
||||||
got = remote_map.get(gmap.remote_key(repo, payload["number"]))
|
|
||||||
if got:
|
|
||||||
return got
|
|
||||||
marked = gmap.id_in_body(payload.get("body") or "")
|
|
||||||
if marked and marked not in store_ids and marked not in set(remote_map.values()):
|
|
||||||
return marked
|
|
||||||
return issue.unique_id(root, marked or issue.slugify(payload.get("title", "")),
|
|
||||||
taken=store_ids)
|
|
||||||
|
|
||||||
|
|
||||||
def lands_in_store(payload, drop_closed, store_ids, remote_map, repo, root):
|
|
||||||
"""Would this payload leave a file in the store? The `--limit` predicate.
|
|
||||||
|
|
||||||
It has to be the same test the walk below applies, or the budget is spent on
|
|
||||||
issues that never land — which is the bug this exists to prevent. So: a
|
|
||||||
closed issue counts only when the store already has it (it is refreshed, and
|
|
||||||
that is a write); anything else counts, including one `--cached` will skip,
|
|
||||||
because a skipped issue is still an issue the store holds when the run ends.
|
|
||||||
|
|
||||||
Cheap in the common case: only a closed payload costs an `id_for`, and that
|
|
||||||
is a lookup plus, at worst, a stat."""
|
|
||||||
if not (drop_closed and payload.get("state") == "closed"):
|
|
||||||
return True
|
|
||||||
id = id_for(payload, store_ids, remote_map, repo, root)
|
|
||||||
return os.path.isfile(issue.path_of(root, id))
|
|
||||||
|
|
||||||
|
|
||||||
def comments_path(root, id):
|
|
||||||
"""Where an issue's comment thread lives — beside it, under the same slug.
|
|
||||||
Named in `_gitea` because push.py has to delete the same file."""
|
|
||||||
return _gitea.comments_path(root, id)
|
|
||||||
|
|
||||||
|
|
||||||
def sync_comments(login, base, root, id, number, count):
|
|
||||||
"""Bring <id>.comments.md in line with the server; return it, or None when
|
|
||||||
the issue has no thread.
|
|
||||||
|
|
||||||
`count` is the payload's own comment count, so an issue with none costs no
|
|
||||||
request. A file from an earlier pull is removed when the thread is empty:
|
|
||||||
the absence of the file is the answer, not a gap in what was asked for."""
|
|
||||||
path = comments_path(root, id)
|
|
||||||
comments = _gitea.get_comments(login, base, number) if count else []
|
|
||||||
if comments:
|
|
||||||
with open(path, "w") as f:
|
|
||||||
f.write(gmap.render_comments(comments))
|
|
||||||
return path
|
|
||||||
if os.path.isfile(path):
|
|
||||||
os.remove(path) # stale thread from an earlier pull
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
ap = argparse.ArgumentParser(description="Pull Gitea issues into the local store")
|
|
||||||
ap.add_argument("keys", nargs="*", help="issue keys: 42, #42, owner/repo#42, URL")
|
|
||||||
ap.add_argument("--milestone", help="pull a whole milestone (id or title)")
|
|
||||||
ap.add_argument("--label", action="append", default=[],
|
|
||||||
help="filter by label; repeat for AND")
|
|
||||||
ap.add_argument("-q", "--query", help="search text in title/body")
|
|
||||||
ap.add_argument("--state", default="open", choices=["open", "closed", "all"],
|
|
||||||
help="filter mode only (default: open)")
|
|
||||||
ap.add_argument("--limit", type=int, default=100,
|
|
||||||
help="filter mode: how many issues to STORE, not to enumerate"
|
|
||||||
" (default: 100)")
|
|
||||||
# Dependencies are the default: a pull answers with the unit of work, not
|
|
||||||
# one row of it. `--deps` stays accepted so the calls and command tables
|
|
||||||
# written against the old default keep working — it now sets what is
|
|
||||||
# already set.
|
|
||||||
ap.add_argument("--no-deps", dest="deps", action="store_false",
|
|
||||||
help="do not fill depends: and do not follow blockers")
|
|
||||||
ap.add_argument("--deps", dest="deps", action="store_true",
|
|
||||||
help="accepted, does nothing: dependencies are followed by default")
|
|
||||||
ap.set_defaults(deps=True)
|
|
||||||
ap.add_argument("--depth", type=int, default=3, help="max dependency depth (default: 3)")
|
|
||||||
ap.add_argument("--cached", action="store_true",
|
|
||||||
help="skip issues already on disk instead of refetching")
|
|
||||||
ap.add_argument("--repo", help="owner/repo (default: auto-detect from CWD git remote)")
|
|
||||||
ap.add_argument("--out", default=issue.ISSUE_ROOT,
|
|
||||||
help="store root (default: <project>/.tea/issues)")
|
|
||||||
args = ap.parse_args()
|
|
||||||
|
|
||||||
filtered = bool(args.milestone or args.label or args.query)
|
|
||||||
if args.keys and filtered:
|
|
||||||
_gitea.die("pass issue keys OR filters, not both")
|
|
||||||
if not args.keys and not filtered:
|
|
||||||
_gitea.die("nothing to pull: pass issue keys, or --milestone / --label / -q")
|
|
||||||
|
|
||||||
root = args.out
|
|
||||||
# A first pull into a fresh checkout has to create the store; it says so,
|
|
||||||
# and the path is absolute, so it cannot be a stray cwd. With no project
|
|
||||||
# marker anywhere there is nowhere legitimate to put one — pulling into a
|
|
||||||
# guessed directory is what stranded issues inside the plugin.
|
|
||||||
try:
|
|
||||||
if issue.create_store(root):
|
|
||||||
sys.stderr.write("created store %s\n" % os.path.abspath(root))
|
|
||||||
except issue.StoreMissing as e:
|
|
||||||
_gitea.die(str(e))
|
|
||||||
|
|
||||||
login = _gitea.require_login()
|
|
||||||
|
|
||||||
# ---- which repo ------------------------------------------------------
|
|
||||||
repo_arg = args.repo
|
|
||||||
if not repo_arg and args.keys:
|
|
||||||
repos = {_gitea.parse_key(k)[1] for k in args.keys} - {None}
|
|
||||||
if len(repos) > 1:
|
|
||||||
_gitea.die("all keys must belong to one repo, got: %s" % ", ".join(sorted(repos)))
|
|
||||||
repo_arg = repos.pop() if repos else None
|
|
||||||
base = _gitea.repo_base(repo_arg)
|
|
||||||
repo = _gitea.repo_slug(login, repo_arg)
|
|
||||||
|
|
||||||
issues = issue.load_all(root)
|
|
||||||
remote_map = _gitea.load_map(root) or _gitea.rebuild_map(root, issues)
|
|
||||||
store_ids = set(issues)
|
|
||||||
number_of_id = {gmap.parse_remote_key(k)[1]: v for k, v in remote_map.items()
|
|
||||||
if gmap.parse_remote_key(k)[0] == repo}
|
|
||||||
|
|
||||||
# A closed issue is not a unit of work: filter mode enumerates it but keeps
|
|
||||||
# it out of the store unless the operator named the state. A key is an
|
|
||||||
# address, not a bulk read, so key mode is exempt.
|
|
||||||
drop_closed = filtered and args.state != "closed"
|
|
||||||
|
|
||||||
written, skipped, dropped, pending = [], [], [], []
|
|
||||||
|
|
||||||
# ---- seeds -----------------------------------------------------------
|
|
||||||
if filtered:
|
|
||||||
# The limit bounds the write, so the transport is told what a write is
|
|
||||||
# and counts those; the closed ones it enumerated on the way come back
|
|
||||||
# in the list anyway, to be reported and dropped below.
|
|
||||||
payloads, ms_title = _gitea.list_issues(
|
|
||||||
login, base, state=args.state, labels=args.label, query=args.query,
|
|
||||||
milestone=args.milestone, limit=args.limit,
|
|
||||||
keep=lambda p: lands_in_store(p, drop_closed, store_ids, remote_map,
|
|
||||||
repo, root))
|
|
||||||
if not payloads:
|
|
||||||
_gitea.die("no issues match that filter")
|
|
||||||
what = []
|
|
||||||
if args.milestone:
|
|
||||||
what.append("milestone %s" % ms_title)
|
|
||||||
what += ["label %s" % l for l in args.label]
|
|
||||||
if args.query:
|
|
||||||
what.append("q=%r" % args.query)
|
|
||||||
sys.stderr.write("%d issue(s) match %s (%s)\n"
|
|
||||||
% (len(payloads), " + ".join(what), args.state))
|
|
||||||
queue = [(p, 0) for p in payloads]
|
|
||||||
seen_numbers = {p["number"] for p in payloads}
|
|
||||||
else:
|
|
||||||
numbers = [_gitea.parse_key(k)[0] for k in args.keys]
|
|
||||||
queue = [(_gitea.get_issue(login, base, n), 0) for n in numbers]
|
|
||||||
seen_numbers = set(numbers)
|
|
||||||
|
|
||||||
# ---- walk ------------------------------------------------------------
|
|
||||||
while queue:
|
|
||||||
payload, depth = queue.pop(0)
|
|
||||||
number = payload["number"]
|
|
||||||
id = id_for(payload, store_ids, remote_map, repo, root)
|
|
||||||
stored = os.path.isfile(issue.path_of(root, id))
|
|
||||||
|
|
||||||
# Closed and not already ours: nothing is written and nothing is asked
|
|
||||||
# of the server for it — not its comments, not its links, and its own
|
|
||||||
# blockers are not followed. The slug stays unclaimed too, so no other
|
|
||||||
# issue ends up pointing `depends:` at a missing file.
|
|
||||||
if drop_closed and payload.get("state") == "closed" and not stored:
|
|
||||||
dropped.append(number)
|
|
||||||
continue # not stored: no unit of work here, so no links are fetched
|
|
||||||
|
|
||||||
store_ids.add(id)
|
|
||||||
number_of_id[number] = id
|
|
||||||
|
|
||||||
# The native links, fetched ONCE for the two things they are for:
|
|
||||||
# filling this issue's `depends:` and telling the walk where to go next.
|
|
||||||
# One request per issue that lands in the store, and only one — the cost
|
|
||||||
# the docstring quotes is this line.
|
|
||||||
deps = _gitea.native_deps(login, base, number) if args.deps else []
|
|
||||||
|
|
||||||
if args.cached and stored:
|
|
||||||
skipped.append(id) # body and thread unread; only the links cost
|
|
||||||
else:
|
|
||||||
# The copy already on disk, as it was when this run started. It
|
|
||||||
# contributes its ticked checkboxes and nothing else; None when
|
|
||||||
# the store has never seen this issue.
|
|
||||||
prev = issues.get(id)
|
|
||||||
iss, unresolved = gmap.from_api(payload, id, repo,
|
|
||||||
id_for_number=number_of_id,
|
|
||||||
extra_numbers=deps,
|
|
||||||
synced=_gitea.now_iso(),
|
|
||||||
local_body=prev.body if prev else None)
|
|
||||||
issue.save(root, iss)
|
|
||||||
sync_comments(login, base, root, id, number, payload.get("comments") or 0)
|
|
||||||
remote_map[gmap.remote_key(repo, number)] = id
|
|
||||||
written.append(id)
|
|
||||||
pending.append((id, unresolved))
|
|
||||||
|
|
||||||
if args.deps and depth < args.depth:
|
|
||||||
child_numbers = gmap.numbers_in_body(payload.get("body") or "") + deps
|
|
||||||
for n in child_numbers:
|
|
||||||
if n in seen_numbers:
|
|
||||||
continue
|
|
||||||
seen_numbers.add(n)
|
|
||||||
queue.append((_gitea.get_issue(login, base, n), depth + 1))
|
|
||||||
|
|
||||||
# Nothing is dropped in silence — say how many closed ones stayed out.
|
|
||||||
if dropped:
|
|
||||||
sys.stderr.write("%d closed issue(s) enumerated, not stored"
|
|
||||||
" (--state closed to pull them)\n" % len(dropped))
|
|
||||||
|
|
||||||
# ---- second pass: dependencies that were not yet known on first write --
|
|
||||||
for id, unresolved in pending:
|
|
||||||
newly = [number_of_id[n] for n in unresolved
|
|
||||||
if n in number_of_id and number_of_id[n] != id]
|
|
||||||
if not newly:
|
|
||||||
continue
|
|
||||||
iss = issue.load(root, id)
|
|
||||||
for slug in newly:
|
|
||||||
if slug not in iss.depends:
|
|
||||||
iss.depends.append(slug)
|
|
||||||
issue.save(root, iss)
|
|
||||||
|
|
||||||
_gitea.save_map(root, remote_map)
|
|
||||||
index_path, _ = issue_index.build(root)
|
|
||||||
|
|
||||||
# Compact output — the only thing that lands in the model's context. The
|
|
||||||
# thread rides on the issue's own line; no file means no comments.
|
|
||||||
graph = False
|
|
||||||
for id in sorted(set(written) | set(skipped)):
|
|
||||||
iss = issue.load(root, id)
|
|
||||||
graph = graph or bool(iss.depends)
|
|
||||||
note = " (cached)" if id in skipped else ""
|
|
||||||
cpath = comments_path(root, id)
|
|
||||||
if os.path.isfile(cpath):
|
|
||||||
note += " +%s comments: %s" % (iss.extra.get("comments") or "?", cpath)
|
|
||||||
print("%s [%s] %s — %s %s%s" % (
|
|
||||||
id, ", ".join(iss.labels) or "no labels", iss.title, iss.state,
|
|
||||||
issue.path_of(root, id), note))
|
|
||||||
print("index: %s" % index_path)
|
|
||||||
# Now that dependencies are the default, the hint is worth printing when
|
|
||||||
# there is something to draw, not on every run that could have drawn it.
|
|
||||||
if graph:
|
|
||||||
print("graph: run issue_tree.py (offline) to draw it")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,417 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
push.py — local store -> Gitea, and the local copy goes away.
|
|
||||||
|
|
||||||
**A successful push deletes `.tea/issues/<id>.md` and `<id>.comments.md`.** Once
|
|
||||||
the tracker has the issue, the tracker IS the issue: what is left in the store
|
|
||||||
is only what has not left this machine. Get it back with `pull.py <n>` — it
|
|
||||||
comes back under the same slug, because the slug travelled up in the body as
|
|
||||||
`<!-- tea:id … -->` (map.with_id_marker) and is also recorded in
|
|
||||||
`.remote.json`. That is the reversal of "pushing is additive, the file is never
|
|
||||||
deleted"; it is deliberate, and AGENTS.md and references/format.md say so too.
|
|
||||||
|
|
||||||
ONE RULE, NO EXCEPTION: `--update` deletes as well. A PATCH is a push, and an
|
|
||||||
issue that has just been sent is no more local than one that was just created.
|
|
||||||
Two rules would put back exactly the question this removes — "is my copy the
|
|
||||||
fresh one?".
|
|
||||||
|
|
||||||
The deletion is the LAST thing that happens to an issue, and only after:
|
|
||||||
|
|
||||||
1. the api call returned (it did not raise, and `tea` exited 0), and
|
|
||||||
2. the answer is a dict carrying a plausible `number`, and on `--update`
|
|
||||||
the very number that was PATCHed (`confirmed_number`), and
|
|
||||||
3. `.remote.json` has been written with number -> slug.
|
|
||||||
|
|
||||||
Network down, non-2xx, a body that does not confirm the write, a mismatched
|
|
||||||
number: the file stays and the run stops. Nothing here removes a file it has not
|
|
||||||
just watched Gitea accept, and nothing removes a file for an issue it did not
|
|
||||||
send — `origin: local` work that has never been pushed is never touched.
|
|
||||||
|
|
||||||
push.py every local-only issue, dependencies first
|
|
||||||
push.py wire-sqlc-appclick one issue
|
|
||||||
push.py --update <id …> PATCH issues that are already in Gitea
|
|
||||||
push.py --dry-run validate only, no network, nothing deleted
|
|
||||||
|
|
||||||
Before anything is sent, each issue is validated against the canonical format
|
|
||||||
by the domain layer (exactly one type/*, English title with no type prefix,
|
|
||||||
`## Summary` / `## Spec` / `## Acceptance criteria` present). `--force` posts
|
|
||||||
anyway; say why when you use it.
|
|
||||||
|
|
||||||
Dependencies are pushed in topological order so a parent is created after the
|
|
||||||
issues it depends on. A dependency that is still local-only is reported, not
|
|
||||||
silently dropped — the body's `## Depends on` prose is sent verbatim either
|
|
||||||
way, so nothing is lost, but the tracker shows no edge for it.
|
|
||||||
|
|
||||||
The graph goes up with them. Once an issue has its number, every `depends:`
|
|
||||||
entry that also has one becomes a **native Gitea link** — the same
|
|
||||||
`/dependencies` that every `pull.py` reads back, so the tracker shows the
|
|
||||||
blocking panel and refuses to close a blocked issue first. Topological order
|
|
||||||
means the blocker already has its number by then; no second pass is needed.
|
|
||||||
`--update` links whatever appeared in `depends:` since the last push. A link
|
|
||||||
the tracker already has is skipped, not re-POSTed. A dependency that stayed
|
|
||||||
local has no number and becomes no link — only the warning above.
|
|
||||||
|
|
||||||
REMOVING a link is OUT OF SCOPE. Push only ever adds: a dependency deleted
|
|
||||||
from `depends:` leaves its Gitea link standing, and nothing here will notice.
|
|
||||||
Unlink it in the web UI, or by hand with
|
|
||||||
`tea api -X DELETE --login "$GITEA_LOGIN" repos/OWNER/REPO/issues/N/dependencies`.
|
|
||||||
|
|
||||||
The `## Depends on` prose itself is never touched — slugs stay slugs and are
|
|
||||||
not rewritten to `#N`, so the body survives a pull -> push round trip byte for
|
|
||||||
byte. The link lives in Gitea's own graph, not in the text.
|
|
||||||
|
|
||||||
Missing labels are created with the canonical color and, for type/* and
|
|
||||||
severity/*, `exclusive: true` — `tea labels create` cannot set that field.
|
|
||||||
|
|
||||||
`branch:` carries Gitea's `ref`, the branch the work lives on. An empty one is
|
|
||||||
filled with the current git branch and goes up with the issue; one that is
|
|
||||||
already set is sent as written and never overwritten. Detached HEAD, or no repo
|
|
||||||
at all: no `ref` is sent and a warning says so. It is not written back to the
|
|
||||||
file any more — there is no file to write it back to; it comes down with the
|
|
||||||
next pull.
|
|
||||||
|
|
||||||
Login: the operator's pin from .claude/settings.local.json (see /tea:auth).
|
|
||||||
"""
|
|
||||||
import argparse
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
|
|
||||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
sys.path[:0] = [_HERE, os.path.normpath(os.path.join(_HERE, "..", "..", "issue", "scripts"))]
|
|
||||||
|
|
||||||
import _gitea # noqa: E402
|
|
||||||
import issue # noqa: E402
|
|
||||||
import issue_index # noqa: E402
|
|
||||||
import map as gmap # noqa: E402
|
|
||||||
|
|
||||||
|
|
||||||
def select(issues, ids, update):
|
|
||||||
"""Which issues to send, and refuse the ambiguous combinations."""
|
|
||||||
if ids:
|
|
||||||
missing = [i for i in ids if i not in issues]
|
|
||||||
if missing:
|
|
||||||
_gitea.die("no such issue(s) in the store: %s" % ", ".join(missing))
|
|
||||||
chosen = list(ids)
|
|
||||||
else:
|
|
||||||
chosen = sorted(i for i in issues
|
|
||||||
if update or not issues[i].extra.get("gitea"))
|
|
||||||
if not chosen:
|
|
||||||
_gitea.die("nothing to push: every issue in the store is already in Gitea "
|
|
||||||
"(use --update to PATCH them, or issue_new.py to make one)")
|
|
||||||
if not update:
|
|
||||||
already = [i for i in chosen if issues[i].extra.get("gitea")]
|
|
||||||
if already:
|
|
||||||
_gitea.die("already in Gitea: %s — pass --update to PATCH them"
|
|
||||||
% ", ".join(already))
|
|
||||||
return chosen
|
|
||||||
|
|
||||||
|
|
||||||
def ledger_keys(remote_map, repo=None):
|
|
||||||
"""slug -> remote key, the reverse of `.remote.json`.
|
|
||||||
|
|
||||||
Where a dependency's number comes from once push has deleted its file. The
|
|
||||||
forward map is keyed by number because that is what a pull has in hand; a
|
|
||||||
push has a slug, so it needs the other direction. Same-repo entries win if a
|
|
||||||
slug somehow appears under two keys."""
|
|
||||||
out = {}
|
|
||||||
for key, slug in sorted(remote_map.items()):
|
|
||||||
if slug not in out or gmap.parse_remote_key(key)[0] == repo:
|
|
||||||
out[slug] = key
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def dep_state(iss, issues, pushing, key_of_id=None):
|
|
||||||
"""What each `depends:` entry is, as far as linking is concerned.
|
|
||||||
|
|
||||||
Yields (slug, remote_key, in_run) per dependency this run can say anything
|
|
||||||
about:
|
|
||||||
|
|
||||||
remote_key where the dependency lives in Gitea, or None while it is
|
|
||||||
local-only
|
|
||||||
in_run this push is about to give it one
|
|
||||||
|
|
||||||
A dependency's key is read from its `gitea:` field when the file is still
|
|
||||||
on disk, and from the ledger (`key_of_id`) when it is not — which, since
|
|
||||||
push deletes what it sends, is the normal state of an already-published
|
|
||||||
blocker. Without that fallback the graph would quietly lose an edge every
|
|
||||||
time a blocker was pushed before its dependent: the file is gone, the field
|
|
||||||
goes with it, and the link is never made.
|
|
||||||
|
|
||||||
A slug that is neither in the store nor in the ledger is dropped; it names
|
|
||||||
nothing this machine has ever seen, and validate() has already warned.
|
|
||||||
|
|
||||||
In the real run remote_key is all that matters — topological order means an
|
|
||||||
in-run blocker has already been stamped by the time its dependent is sent.
|
|
||||||
`--dry-run` has no numbers to stamp, so it leans on in_run to say which
|
|
||||||
links are coming and which cannot exist at all."""
|
|
||||||
key_of_id = key_of_id or {}
|
|
||||||
out = []
|
|
||||||
for d in iss.depends:
|
|
||||||
dep = issues.get(d)
|
|
||||||
key = (dep.extra.get("gitea") if dep is not None else None) or key_of_id.get(d)
|
|
||||||
if dep is None and not key:
|
|
||||||
continue
|
|
||||||
out.append((d, key or None, d in pushing))
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def confirmed_number(got, sent_number=None):
|
|
||||||
"""The number Gitea confirmed for a write, or None — the deletion gate.
|
|
||||||
|
|
||||||
Every local file this script removes is removed because this function
|
|
||||||
returned an int, so it is written to be boring and to say no by default.
|
|
||||||
An answer counts only when it is a dict carrying a positive integer
|
|
||||||
`number`, and, when `sent_number` is given (a PATCH, where we already know
|
|
||||||
which issue we addressed), the same number we sent.
|
|
||||||
|
|
||||||
`bool` is rejected explicitly: `True` is an `int` in Python and `number:
|
|
||||||
true` is not a confirmation of anything.
|
|
||||||
|
|
||||||
What this does NOT have to catch, because it never gets here: a non-2xx
|
|
||||||
answer or a `tea` that failed to run at all — `_gitea.api` exits on both,
|
|
||||||
and an exception in the transport propagates. The file survives all three
|
|
||||||
by never reaching the delete."""
|
|
||||||
if not isinstance(got, dict):
|
|
||||||
return None
|
|
||||||
n = got.get("number")
|
|
||||||
if isinstance(n, bool) or not isinstance(n, int) or n <= 0:
|
|
||||||
return None
|
|
||||||
if sent_number is not None and n != sent_number:
|
|
||||||
return None
|
|
||||||
return n
|
|
||||||
|
|
||||||
|
|
||||||
def drop_local(root, id):
|
|
||||||
"""Delete the local copy of an issue and its thread; return what went.
|
|
||||||
|
|
||||||
Deliberately dumb: it takes an id, not a decision. Whether an issue may be
|
|
||||||
dropped is decided by the caller, before this is reached, so the dangerous
|
|
||||||
half of the operation has no branches in it at all. There is exactly one
|
|
||||||
call site.
|
|
||||||
|
|
||||||
A missing file is not an error — an issue with no comments has no thread."""
|
|
||||||
gone = []
|
|
||||||
for p in (issue.path_of(root, id), _gitea.comments_path(root, id)):
|
|
||||||
if os.path.isfile(p):
|
|
||||||
os.remove(p)
|
|
||||||
gone.append(p)
|
|
||||||
return gone
|
|
||||||
|
|
||||||
|
|
||||||
def git_branch():
|
|
||||||
"""The branch HEAD is on, or None. The only git call these scripts make —
|
|
||||||
read, never write. A detached HEAD prints `HEAD` and outside a repo git
|
|
||||||
exits non-zero; both mean "no branch to name", which is not an error."""
|
|
||||||
try:
|
|
||||||
r = subprocess.run(["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
||||||
capture_output=True, text=True)
|
|
||||||
except OSError:
|
|
||||||
return None
|
|
||||||
name = r.stdout.strip()
|
|
||||||
if r.returncode != 0 or not name or name == "HEAD":
|
|
||||||
return None
|
|
||||||
return name
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
ap = argparse.ArgumentParser(description="Push local issues to Gitea")
|
|
||||||
ap.add_argument("ids", nargs="*", help="issue ids (default: every local-only issue)")
|
|
||||||
ap.add_argument("--update", action="store_true",
|
|
||||||
help="PATCH issues that already carry a gitea: field")
|
|
||||||
ap.add_argument("--dry-run", action="store_true", help="validate only, no network")
|
|
||||||
ap.add_argument("--force", action="store_true", help="push despite format violations")
|
|
||||||
ap.add_argument("--repo", help="owner/repo (default: auto-detect from CWD git remote)")
|
|
||||||
ap.add_argument("--out", default=issue.ISSUE_ROOT,
|
|
||||||
help="store root (default: <project>/.tea/issues)")
|
|
||||||
args = ap.parse_args()
|
|
||||||
|
|
||||||
root = args.out
|
|
||||||
problem = issue.store_error(root)
|
|
||||||
if problem:
|
|
||||||
_gitea.die("%s — create an issue with issue_new.py first" % problem)
|
|
||||||
issues = issue.load_all(root)
|
|
||||||
|
|
||||||
chosen = select(issues, args.ids, args.update)
|
|
||||||
|
|
||||||
# ---- validate (domain layer, no network) -----------------------------
|
|
||||||
known = set(issues)
|
|
||||||
blocked = False
|
|
||||||
for id in chosen:
|
|
||||||
err, warn = issue.validate(issues[id], known_ids=known)
|
|
||||||
for w in warn:
|
|
||||||
_gitea.warn("%s: %s" % (id, w))
|
|
||||||
for e in err:
|
|
||||||
sys.stderr.write("%s: %s\n" % (id, e))
|
|
||||||
if err:
|
|
||||||
blocked = True
|
|
||||||
if blocked and not args.force:
|
|
||||||
_gitea.die("format violations (see above); --force overrides")
|
|
||||||
|
|
||||||
# ---- dependencies first ----------------------------------------------
|
|
||||||
edges = {i: [d for d in issues[i].depends if d in issues] for i in chosen}
|
|
||||||
order = [i for i in issue.topo_order(chosen, edges) if i in set(chosen)]
|
|
||||||
for c in issue.find_cycles(edges):
|
|
||||||
_gitea.warn("dependency cycle: %s" % " -> ".join(c))
|
|
||||||
|
|
||||||
# ---- branch: -> Gitea `ref` ------------------------------------------
|
|
||||||
# Only an empty field is filled: a branch written by hand is the author's
|
|
||||||
# decision and push does not argue with it. Nothing to read (detached HEAD,
|
|
||||||
# no repo) is not an error — the issue goes up without a `ref`. The value is
|
|
||||||
# set on the in-memory issue only; the file it came from is about to be
|
|
||||||
# deleted, and the branch comes back with the next pull.
|
|
||||||
blank = [id for id in order if not issues[id].extra.get(gmap.BRANCH_KEY)]
|
|
||||||
branch = git_branch() if blank else None
|
|
||||||
if branch:
|
|
||||||
for id in blank:
|
|
||||||
issues[id].extra[gmap.BRANCH_KEY] = branch
|
|
||||||
elif blank:
|
|
||||||
_gitea.warn("no current git branch (detached HEAD, or outside a git repo) "
|
|
||||||
"— no `ref` on: %s" % ", ".join(blank))
|
|
||||||
|
|
||||||
pushing = set(order)
|
|
||||||
|
|
||||||
if args.dry_run:
|
|
||||||
links = 0
|
|
||||||
# The ledger costs no request, so a dry run resolves an already-pushed
|
|
||||||
# blocker the same way the real run does.
|
|
||||||
key_of_id = ledger_keys(_gitea.load_map(root), args.repo)
|
|
||||||
for id in order:
|
|
||||||
iss = issues[id]
|
|
||||||
print("ok %s [type/%s] %s (%s)"
|
|
||||||
% (id, iss.type or "?", iss.title, ", ".join(iss.labels) or "no labels"))
|
|
||||||
# Not one request is made here: everything below is read off the
|
|
||||||
# store. `#?` is a number this run has not handed out yet.
|
|
||||||
for slug, key, in_run in dep_state(iss, issues, pushing, key_of_id):
|
|
||||||
if key:
|
|
||||||
print(" link -> %s (%s)" % (key, slug))
|
|
||||||
links += 1
|
|
||||||
elif in_run:
|
|
||||||
print(" link -> #? (%s, created by this run)" % slug)
|
|
||||||
links += 1
|
|
||||||
else:
|
|
||||||
print(" no link: %s is local-only" % slug)
|
|
||||||
print("%d issue(s) would be %s, %d dependency link(s) would be created"
|
|
||||||
% (len(order), "updated" if args.update else "created", links))
|
|
||||||
return
|
|
||||||
|
|
||||||
login = _gitea.require_login()
|
|
||||||
base = _gitea.repo_base(args.repo)
|
|
||||||
repo = _gitea.repo_slug(login, args.repo)
|
|
||||||
|
|
||||||
wanted = sorted({l for id in order for l in issues[id].labels})
|
|
||||||
label_ids = _gitea.ensure_labels(login, base, gmap.label_specs(wanted), root) \
|
|
||||||
if wanted else {}
|
|
||||||
|
|
||||||
milestone_ids = {}
|
|
||||||
remote_map = _gitea.load_map(root) or _gitea.rebuild_map(root, issues)
|
|
||||||
key_of_id = ledger_keys(remote_map, repo)
|
|
||||||
|
|
||||||
for id in order:
|
|
||||||
iss = issues[id]
|
|
||||||
|
|
||||||
# Local-only means "this machine has never sent it": no `gitea:` on the
|
|
||||||
# file AND no entry in the ledger. A blocker whose file push already
|
|
||||||
# dropped is in the ledger and is not one of these.
|
|
||||||
unsynced = [d for d in iss.depends
|
|
||||||
if d in issues and not issues[d].extra.get("gitea")
|
|
||||||
and d not in key_of_id and d not in pushing]
|
|
||||||
if unsynced:
|
|
||||||
_gitea.warn("%s: depends on local-only issue(s) %s — no #N cross-link in Gitea"
|
|
||||||
% (id, ", ".join(unsynced)))
|
|
||||||
|
|
||||||
ms_id = None
|
|
||||||
if iss.milestone:
|
|
||||||
if iss.milestone not in milestone_ids:
|
|
||||||
milestone_ids[iss.milestone] = _gitea.resolve_milestone_id(
|
|
||||||
login, base, iss.milestone)
|
|
||||||
ms_id = milestone_ids[iss.milestone]
|
|
||||||
if ms_id is None:
|
|
||||||
_gitea.warn("%s: milestone %r does not exist in %s — not set"
|
|
||||||
% (id, iss.milestone, repo))
|
|
||||||
|
|
||||||
sent_number = gmap.number_of(iss)
|
|
||||||
if sent_number:
|
|
||||||
payload = gmap.to_payload(iss, label_ids, ms_id, include_state=True)
|
|
||||||
got = _gitea.api(login, "%s/issues/%d" % (base, sent_number), "PATCH",
|
|
||||||
payload, payload_name="issue-%s" % id)
|
|
||||||
verb = "updated"
|
|
||||||
else:
|
|
||||||
payload = gmap.to_payload(iss, label_ids, ms_id)
|
|
||||||
got = _gitea.api(login, "%s/issues" % base, "POST", payload,
|
|
||||||
payload_name="issue-%s" % id)
|
|
||||||
verb = "created"
|
|
||||||
# The gate. Below this line the local file is going to be deleted, so
|
|
||||||
# anything short of a confirmed write has to stop the run here.
|
|
||||||
number = confirmed_number(got, sent_number)
|
|
||||||
if number is None:
|
|
||||||
_gitea.die("%s: %s failed — the tracker's answer does not confirm the "
|
|
||||||
"write (%.200r). %s is untouched."
|
|
||||||
% (id, verb, got, issue.path_of(root, id)))
|
|
||||||
|
|
||||||
# The number is confirmed, so the ledger learns it now — before the
|
|
||||||
# label fix-up below, which can still fail, and well before the file is
|
|
||||||
# removed. `.remote.json` is what a later `pull.py N` uses to land on
|
|
||||||
# this slug again; an interrupted run must cost a re-pull, not a slug.
|
|
||||||
remote_map[gmap.remote_key(repo, number)] = id
|
|
||||||
key_of_id[id] = gmap.remote_key(repo, number)
|
|
||||||
_gitea.save_map(root, remote_map)
|
|
||||||
|
|
||||||
# Gitea occasionally drops labels on create — re-apply rather than
|
|
||||||
# trust the echo.
|
|
||||||
applied = {l.get("name", "") for l in got.get("labels") or []}
|
|
||||||
missing = [l for l in iss.labels if l in label_ids and l not in applied]
|
|
||||||
if missing:
|
|
||||||
_gitea.api(login, "%s/issues/%d/labels" % (base, number), "PUT",
|
|
||||||
{"labels": [label_ids[l] for l in iss.labels if l in label_ids]},
|
|
||||||
payload_name="labels-%s" % id)
|
|
||||||
_gitea.warn("%s: labels re-applied via PUT (%s)" % (id, ", ".join(missing)))
|
|
||||||
|
|
||||||
# The in-memory issue is stamped even though its file is going: the rest
|
|
||||||
# of this loop reads `gitea:` off it to link dependencies, and a later
|
|
||||||
# issue in topological order asks the same of this one.
|
|
||||||
gmap.apply_remote(iss, got, repo, _gitea.now_iso())
|
|
||||||
|
|
||||||
# Where the issue lives now. The number and the URL lead because this
|
|
||||||
# is the receipt: in a moment the local path is gone and this is the
|
|
||||||
# only address the issue has.
|
|
||||||
print("%s %s #%d %s" % (verb, id, number, got.get("html_url", "")))
|
|
||||||
|
|
||||||
# ---- the graph, as Gitea's own links ------------------------------
|
|
||||||
# Blockers came first in topological order, so each one that is going
|
|
||||||
# to have a number has one already — stamped on the in-memory issue
|
|
||||||
# above, or read out of the ledger for one whose file an earlier push
|
|
||||||
# already dropped. The GET is the idempotence check: it costs one
|
|
||||||
# request per issue that has dependencies at all, and it is what makes
|
|
||||||
# a repeat push a no-op.
|
|
||||||
wanted_links = [(slug, gmap.parse_remote_key(key))
|
|
||||||
for slug, key, _ in dep_state(iss, issues, pushing, key_of_id)
|
|
||||||
if key]
|
|
||||||
if wanted_links:
|
|
||||||
have = _gitea.native_dep_pairs(login, base, number)
|
|
||||||
for slug, (drepo, dnum) in wanted_links:
|
|
||||||
if not dnum or (drepo, dnum) in have:
|
|
||||||
continue
|
|
||||||
if _gitea.add_dependency(login, base, number, drepo, dnum):
|
|
||||||
print(" depends on %s#%d (%s)" % (drepo, dnum, slug))
|
|
||||||
else:
|
|
||||||
_gitea.warn("%s: could not link #%d -> %s#%d (%s) — link it by "
|
|
||||||
"hand, or `pull.py %d` and push it again"
|
|
||||||
% (id, number, drepo, dnum, slug, number))
|
|
||||||
|
|
||||||
# ---- and now the local copy goes ----------------------------------
|
|
||||||
# The last thing that happens to this issue, after the write, the
|
|
||||||
# ledger, and the links. A failure above is a warning and lands here
|
|
||||||
# anyway: the issue IS in Gitea, so keeping a stale file beside it
|
|
||||||
# would put back exactly the two-copies question this removes.
|
|
||||||
for p in drop_local(root, id):
|
|
||||||
print(" dropped %s" % p)
|
|
||||||
print(" pull.py %d to work on it again" % number)
|
|
||||||
|
|
||||||
_gitea.save_map(root, remote_map)
|
|
||||||
path, n = issue_index.build(root)
|
|
||||||
print("index: %s — %d issue(s)" % (path, n))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
remote.py — what exists in Gitea, one line each.
|
|
||||||
|
|
||||||
Discovery only: prints to stdout and writes nothing. The local store is a
|
|
||||||
store, not a search-results folder, so a listing never lands in it. Pick the
|
|
||||||
numbers here, then pull them.
|
|
||||||
|
|
||||||
#42 open type/task, tech/sql Wire sqlc into the repo layer
|
|
||||||
└─ local: wire-sqlc-appclick
|
|
||||||
|
|
||||||
The second line appears when the issue is already in the local store, so it is
|
|
||||||
obvious what a pull would refresh versus what it would add.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
remote.py [--state open|closed|all] [--label L]… [-q TEXT]
|
|
||||||
[--milestone M] [--limit N] [--repo owner/repo]
|
|
||||||
|
|
||||||
`--limit` here caps the LISTING: N lines out, closed ones among them. That is
|
|
||||||
not what the same flag means to `pull.py`, and the difference is not an
|
|
||||||
oversight — pull.py bounds what it writes, and this command writes nothing, so
|
|
||||||
there is nothing else for a limit to bound. Enumeration is the whole job.
|
|
||||||
|
|
||||||
Login: the operator's pin from .claude/settings.local.json (see /tea:auth).
|
|
||||||
"""
|
|
||||||
import argparse
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
sys.path[:0] = [_HERE, os.path.normpath(os.path.join(_HERE, "..", "..", "issue", "scripts"))]
|
|
||||||
|
|
||||||
import _gitea # noqa: E402
|
|
||||||
import issue # noqa: E402
|
|
||||||
import map as gmap # noqa: E402
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
ap = argparse.ArgumentParser(description="List Gitea issues (stdout only, no files)")
|
|
||||||
ap.add_argument("--state", default="open", choices=["open", "closed", "all"])
|
|
||||||
ap.add_argument("--label", action="append", default=[],
|
|
||||||
help="filter by label; repeat for AND")
|
|
||||||
ap.add_argument("-q", "--query", help="search text in title/body")
|
|
||||||
ap.add_argument("--milestone", help="milestone id or title")
|
|
||||||
ap.add_argument("--limit", type=int, default=30)
|
|
||||||
ap.add_argument("--repo", help="owner/repo (default: auto-detect from CWD git remote)")
|
|
||||||
ap.add_argument("--out", default=issue.ISSUE_ROOT,
|
|
||||||
help="store root (default: <project>/.tea/issues)")
|
|
||||||
args = ap.parse_args()
|
|
||||||
|
|
||||||
login = _gitea.require_login()
|
|
||||||
base = _gitea.repo_base(args.repo)
|
|
||||||
payloads, ms_title = _gitea.list_issues(
|
|
||||||
login, base, state=args.state, labels=args.label, query=args.query,
|
|
||||||
milestone=args.milestone, limit=args.limit)
|
|
||||||
|
|
||||||
remote_map = _gitea.load_map(args.out)
|
|
||||||
repo = _gitea.repo_slug(login, args.repo) if remote_map else None
|
|
||||||
|
|
||||||
for p in payloads:
|
|
||||||
labels = ", ".join(l.get("name", "") for l in p.get("labels") or []) or "-"
|
|
||||||
print("#%-5d %-7s %-38s %s" % (p["number"], p.get("state", ""),
|
|
||||||
labels[:38], p.get("title", "")))
|
|
||||||
local = remote_map.get(gmap.remote_key(repo, p["number"])) if repo else None
|
|
||||||
if local:
|
|
||||||
print("%13s└─ local: %s" % ("", local))
|
|
||||||
|
|
||||||
scope = " in milestone %s" % ms_title if ms_title else ""
|
|
||||||
hint = ("--milestone %s" % args.milestone) if args.milestone else "<n>"
|
|
||||||
print("%d issue(s)%s — pull them with: pull.py %s" % (len(payloads), scope, hint))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
---
|
|
||||||
name: use
|
|
||||||
description: Reference docs for the `tea` CLI — Gitea's command-line client. Load when the user asks about Gitea repos, pulls, releases, milestones, labels, actions, webhooks, or other Gitea entities, to look up the right `tea` command and flags. Always write the login as the literal placeholder --login "$GITEA_LOGIN" — the tea-guard hook substitutes the operator-pinned login; set it with /tea:auth. Issues are NOT handled here: use /tea:issue to work on them and /tea:sync to move them to and from Gitea.
|
|
||||||
---
|
|
||||||
|
|
||||||
# /tea:use — tea CLI reference
|
|
||||||
|
|
||||||
Reference material for the `tea` CLI (Gitea's official command-line client).
|
|
||||||
Use these docs to look up commands, flags, filters, and output fields before
|
|
||||||
running `tea` via Bash.
|
|
||||||
|
|
||||||
## Issues are somewhere else
|
|
||||||
|
|
||||||
Do **not** reach for `tea issues` or `tea api .../issues/...` to read or create
|
|
||||||
an issue. Two skills own that, and they keep the payload out of your context:
|
|
||||||
|
|
||||||
| Skill | Scope |
|
|
||||||
|---|---|
|
|
||||||
| `/tea:issue` | issues as units of work — create, read, grep, validate, dependency graph. Offline. |
|
|
||||||
| `/tea:sync` | moving issues between the local store and Gitea — pull, push, comment. |
|
|
||||||
|
|
||||||
This skill covers everything else Gitea has: pulls, releases, milestones,
|
|
||||||
labels, repos, branches, actions, webhooks, notifications, times.
|
|
||||||
|
|
||||||
## Login: always write the placeholder, never a name (enforced)
|
|
||||||
|
|
||||||
Every `tea` invocation that touches Gitea MUST carry the login as the **literal
|
|
||||||
placeholder** `--login "$GITEA_LOGIN"` (or `-l "$GITEA_LOGIN"`). Do **not**
|
|
||||||
substitute an actual login name yourself.
|
|
||||||
|
|
||||||
The **`tea-guard`** PreToolUse hook enforces this and resolves it:
|
|
||||||
|
|
||||||
- no `--login` → blocked.
|
|
||||||
- `--login "$GITEA_LOGIN"` → the hook reads the operator's pinned login from
|
|
||||||
`.claude/settings.local.json` (`env.GITEA_LOGIN`) **at call time** and
|
|
||||||
rewrites the command to use that literal before it runs.
|
|
||||||
- `--login <some-name>` or any other variable → blocked. You may not choose the
|
|
||||||
login; only the operator does (via `/tea:auth`).
|
|
||||||
- no login pinned → blocked with a pointer to run `/tea:auth`.
|
|
||||||
|
|
||||||
Why: without an explicit login `tea` silently falls back to the machine's
|
|
||||||
default (possibly the user's personal account), and a login *you* pick may be
|
|
||||||
the wrong identity. Pinning is the operator's decision; the hook guarantees it.
|
|
||||||
The pin takes effect immediately — no restart. Only `tea logins list` and
|
|
||||||
`tea --version/--help` are exempt from the guard.
|
|
||||||
|
|
||||||
## How to use
|
|
||||||
|
|
||||||
1. Identify the entity in the request: pulls, labels, milestones, releases,
|
|
||||||
times, repos, branches, actions, webhooks, notifications, etc.
|
|
||||||
2. Find the matching command in the index below.
|
|
||||||
3. Run it via Bash with the placeholder login, e.g.
|
|
||||||
`tea pulls list --login "$GITEA_LOGIN" --repo owner/repo --state open`.
|
|
||||||
(The hook rewrites `"$GITEA_LOGIN"` to the operator-pinned login.)
|
|
||||||
|
|
||||||
`tea` auto-detects owner/repo from `$PWD` inside a git repo; otherwise pass
|
|
||||||
`--repo owner/repo` (or `-r`). Login is **not** auto-detected — it is pinned
|
|
||||||
per-project by the operator (see `/tea:auth`) and injected by the guard.
|
|
||||||
Config lives in `$XDG_CONFIG_HOME/tea`.
|
|
||||||
|
|
||||||
### `--repo` takes a slug — except where a checkout is required
|
|
||||||
|
|
||||||
A few commands touch local git, not just the API, and for those `--repo`
|
|
||||||
**must be a path to a checkout**; a slug is rejected:
|
|
||||||
|
|
||||||
```
|
|
||||||
Error: local repository required: execute from a repo dir, or specify a path with --repo
|
|
||||||
```
|
|
||||||
|
|
||||||
The message reads like the flag is missing even when it was passed. Confirmed
|
|
||||||
for `pulls create`, `pulls checkout` and `pulls clean` (tea 0.14.x). Everything
|
|
||||||
that is only an API call — `pulls list`, `milestones`, `releases`, `times`,
|
|
||||||
`labels`, `issues` — takes the slug from any directory.
|
|
||||||
|
|
||||||
Three working forms for `pulls create`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. cwd inside the checkout, no --repo at all
|
|
||||||
tea pulls create --login "$GITEA_LOGIN" --head feat/x --base main \
|
|
||||||
--title "…" --description "…"
|
|
||||||
|
|
||||||
# 2. from anywhere, --repo as a PATH (this is also the git-worktree answer:
|
|
||||||
# point it at the main checkout)
|
|
||||||
tea pulls create --login "$GITEA_LOGIN" --repo /path/to/checkout \
|
|
||||||
--head feat/x --base main --title "…" --description "…"
|
|
||||||
|
|
||||||
# 3. no checkout in reach — POST it, where owner/repo is a slug again
|
|
||||||
tea api --login "$GITEA_LOGIN" -X POST -d @tmp/pull/x.json \
|
|
||||||
repos/{owner}/{repo}/pulls
|
|
||||||
```
|
|
||||||
|
|
||||||
## Index
|
|
||||||
|
|
||||||
- [tea CLI overview](references/tea/index.md) — global flags, common options, output formats
|
|
||||||
- [ENTITIES](references/tea/entities.md) — issues, pulls, labels, milestones, releases, times, repos, branches, actions, webhooks, comment
|
|
||||||
- [HELPERS](references/tea/helpers.md) — open, notifications, clone, api
|
|
||||||
- [MISC](references/tea/misc.md) — whoami, admin
|
|
||||||
- [SETUP](references/tea/setup.md) — logins, logout, ssh-keys
|
|
||||||
|
|
||||||
The canonical issue format moved to
|
|
||||||
[`../issue/references/format.md`](../issue/references/format.md) — it describes
|
|
||||||
local files, not `tea` commands.
|
|
||||||
|
|
||||||
## Rich payloads — write to `$PWD/tmp/` first, then `tea api`
|
|
||||||
|
|
||||||
Entity subcommands (`tea comment`, `tea pulls create`, `tea releases create`, …)
|
|
||||||
are built for humans at a TTY. With a large or formatted body they can hang
|
|
||||||
silently — an empty-looking positional arg triggers `$EDITOR` fallback, or a
|
|
||||||
scope/confirm prompt waits on a TTY that doesn't exist. The harness eventually
|
|
||||||
kills the process (e.g. exit 144 = 128 + SIGURG on macOS).
|
|
||||||
|
|
||||||
**Rule:** for any non-trivial body (multi-line, or containing markdown / code
|
|
||||||
fences / backticks / pipes / tables), bypass entity commands. Save the full
|
|
||||||
request payload to `$PWD/tmp/` first, then POST via `tea api`.
|
|
||||||
|
|
||||||
Issues and issue comments are already wrapped — use `/tea:sync` rather than
|
|
||||||
hand-rolling their JSON. The procedure below covers everything else.
|
|
||||||
|
|
||||||
### Procedure
|
|
||||||
|
|
||||||
1. Ensure the target dir exists: `mkdir -p tmp/{kind}` where `{kind}` is
|
|
||||||
`pull`, `release`, etc.
|
|
||||||
2. Write the **complete request body as JSON** to `$PWD/tmp/{kind}/<slug>.json`.
|
|
||||||
One file = one request. Use a quoted heredoc to avoid shell expansion:
|
|
||||||
```bash
|
|
||||||
mkdir -p tmp/release
|
|
||||||
cat > tmp/release/v0-2-0.json <<'EOF'
|
|
||||||
{"tag_name": "v0.2.0", "name": "v0.2.0", "body": "## Changes\n\nMulti-line markdown with `code`."}
|
|
||||||
EOF
|
|
||||||
```
|
|
||||||
Newlines inside the body must be encoded as `\n` in the JSON string. If
|
|
||||||
composing programmatically, pipe through
|
|
||||||
`jq -Rs '{body: .}' < body.md > tmp/release/v0-2-0.json`.
|
|
||||||
3. POST with `tea api`, passing the file with `-d @<path>`:
|
|
||||||
```bash
|
|
||||||
tea api --login "$GITEA_LOGIN" \
|
|
||||||
-X POST -d @tmp/release/v0-2-0.json \
|
|
||||||
repos/{owner}/{repo}/releases
|
|
||||||
```
|
|
||||||
4. Keep the file. `tmp/` should be gitignored; the saved payload is useful for
|
|
||||||
retries, edits (`PATCH`), and debugging failed posts.
|
|
||||||
|
|
||||||
### Common endpoints
|
|
||||||
|
|
||||||
| Action | Method + endpoint |
|
|
||||||
|---|---|
|
|
||||||
| Create PR | `POST repos/{owner}/{repo}/pulls` |
|
|
||||||
| Edit PR body or title | `PATCH repos/{owner}/{repo}/issues/{n}` |
|
|
||||||
| Comment on a PR | `POST repos/{owner}/{repo}/issues/{n}/comments` |
|
|
||||||
| Edit comment | `PATCH repos/{owner}/{repo}/issues/comments/{id}` |
|
|
||||||
| Create release | `POST repos/{owner}/{repo}/releases` |
|
|
||||||
| Create milestone | `POST repos/{owner}/{repo}/milestones` |
|
|
||||||
|
|
||||||
Short single-line bodies (e.g. `tea comment 42 "lgtm" --login "$GITEA_LOGIN"`)
|
|
||||||
are still fine via entity commands. Always the placeholder, never a login name.
|
|
||||||
|
|
||||||
## Tips
|
|
||||||
|
|
||||||
- Pass `-o json` for structured output when parsing programmatically — on
|
|
||||||
**entity commands only**. On `tea api`, `-o` is a *file name*: `-o json`
|
|
||||||
writes the response body to a file called `json` and leaves stdout empty.
|
|
||||||
The response is already JSON, so there is nothing to format; use `-` for
|
|
||||||
stdout, or leave the flag off.
|
|
||||||
- Use `--fields, -f` to narrow columns.
|
|
||||||
- Pagination: `--page, -p <n>` and `--limit, --lm <n>` (defaults 1 / 30).
|
|
||||||
- If a `tea` command is blocked by `tea-guard`: either you forgot
|
|
||||||
`--login "$GITEA_LOGIN"`, you wrote a literal login name instead of the
|
|
||||||
placeholder (not allowed — let the guard substitute), or no login is pinned
|
|
||||||
(run `/tea:auth`).
|
|
||||||
@@ -1,412 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Checkbox state survives a pull; everything else in the body does not.
|
|
||||||
|
|
||||||
Two levels, on purpose. `map.merge_checkbox_state` is pure, so most of the rule
|
|
||||||
is pinned down with plain strings and no store anywhere. The pull tests then
|
|
||||||
prove the rule is actually wired into the write path, with the transport
|
|
||||||
stubbed at the one seam `test_push_dependencies.py` uses — `_gitea.api`, the
|
|
||||||
single function that shells out to `tea`. Nothing here touches a network, and
|
|
||||||
no test may ever be made to.
|
|
||||||
|
|
||||||
`skills/*/scripts/` are not packages; they go on sys.path by hand.
|
|
||||||
"""
|
|
||||||
import contextlib
|
|
||||||
import io
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import shutil
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
import unittest
|
|
||||||
import urllib.parse
|
|
||||||
from unittest import mock
|
|
||||||
|
|
||||||
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
||||||
for _p in (os.path.join(_ROOT, "skills", "sync", "scripts"),
|
|
||||||
os.path.join(_ROOT, "skills", "issue", "scripts")):
|
|
||||||
if _p not in sys.path:
|
|
||||||
sys.path.insert(0, _p)
|
|
||||||
|
|
||||||
import _gitea # noqa: E402
|
|
||||||
import issue # noqa: E402
|
|
||||||
import map as gmap # noqa: E402
|
|
||||||
import pull # noqa: E402
|
|
||||||
|
|
||||||
REPO = "claude-skills/tea"
|
|
||||||
BASE = "repos/%s" % REPO
|
|
||||||
|
|
||||||
|
|
||||||
def body(*criteria, **kw):
|
|
||||||
"""A body in the canonical shape, with the given `## Acceptance criteria`."""
|
|
||||||
summary = kw.get("summary", "Прозаическое описание.")
|
|
||||||
return ("## Summary\n%s\n\n## Spec\nnone\n\n## Acceptance criteria\n%s\n"
|
|
||||||
% (summary, "\n".join(criteria))).strip()
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# the rule itself — pure, no store, no tracker
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class MergeCheckboxStateTest(unittest.TestCase):
|
|
||||||
"""`[x]` wins from whichever side has it, for a matching item text."""
|
|
||||||
|
|
||||||
def test_a_local_tick_survives_the_overwrite(self):
|
|
||||||
got = gmap.merge_checkbox_state(body("- [ ] первое", "- [ ] второе"),
|
|
||||||
body("- [x] первое", "- [ ] второе"))
|
|
||||||
self.assertEqual(got, body("- [x] первое", "- [ ] второе"))
|
|
||||||
|
|
||||||
def test_a_remote_tick_is_kept(self):
|
|
||||||
got = gmap.merge_checkbox_state(body("- [x] первое", "- [ ] второе"),
|
|
||||||
body("- [ ] первое", "- [ ] второе"))
|
|
||||||
self.assertEqual(got, body("- [x] первое", "- [ ] второе"))
|
|
||||||
|
|
||||||
def test_both_sides_ticked_is_still_ticked(self):
|
|
||||||
one = body("- [x] первое")
|
|
||||||
self.assertEqual(gmap.merge_checkbox_state(one, one), one)
|
|
||||||
|
|
||||||
def test_the_union_is_taken_item_by_item(self):
|
|
||||||
got = gmap.merge_checkbox_state(
|
|
||||||
body("- [x] первое", "- [ ] второе", "- [ ] третье"),
|
|
||||||
body("- [ ] первое", "- [x] второе", "- [ ] третье"))
|
|
||||||
self.assertEqual(got, body("- [x] первое", "- [x] второе", "- [ ] третье"))
|
|
||||||
|
|
||||||
def test_an_item_the_local_copy_does_not_have_comes_from_the_server(self):
|
|
||||||
"""Including its state — both states, in both directions."""
|
|
||||||
got = gmap.merge_checkbox_state(
|
|
||||||
body("- [x] новое сверху", "- [ ] новое снизу"),
|
|
||||||
body("- [x] что-то совсем другое"))
|
|
||||||
self.assertEqual(got, body("- [x] новое сверху", "- [ ] новое снизу"))
|
|
||||||
|
|
||||||
def test_prose_is_not_merged(self):
|
|
||||||
got = gmap.merge_checkbox_state(
|
|
||||||
body("- [ ] пункт", summary="Новый текст с сервера."),
|
|
||||||
body("- [x] пункт", summary="Старый локальный текст."))
|
|
||||||
self.assertIn("Новый текст с сервера.", got)
|
|
||||||
self.assertNotIn("Старый локальный текст.", got)
|
|
||||||
self.assertIn("- [x] пункт", got)
|
|
||||||
|
|
||||||
def test_a_heading_the_local_copy_added_is_gone(self):
|
|
||||||
remote = body("- [x] пункт")
|
|
||||||
got = gmap.merge_checkbox_state(remote, remote + "\n\n## Notes\nмои заметки\n")
|
|
||||||
self.assertEqual(got, remote)
|
|
||||||
|
|
||||||
def test_no_local_copy_returns_the_server_body_untouched(self):
|
|
||||||
remote = body("- [ ] пункт")
|
|
||||||
for local in (None, "", " \n"):
|
|
||||||
self.assertIs(gmap.merge_checkbox_state(remote, local), remote,
|
|
||||||
"local_body=%r rewrote the body" % local)
|
|
||||||
|
|
||||||
def test_nothing_ticked_locally_returns_the_same_object(self):
|
|
||||||
remote = body("- [ ] пункт")
|
|
||||||
self.assertIs(gmap.merge_checkbox_state(remote, body("- [ ] пункт")), remote)
|
|
||||||
|
|
||||||
def test_no_matching_item_returns_the_same_object(self):
|
|
||||||
remote = body("- [ ] пункт")
|
|
||||||
self.assertIs(gmap.merge_checkbox_state(remote, body("- [x] другой")), remote)
|
|
||||||
|
|
||||||
def test_a_body_with_no_checkboxes_at_all(self):
|
|
||||||
remote = "## Summary\nодна проза\n"
|
|
||||||
self.assertIs(gmap.merge_checkbox_state(remote, "- [x] пункт"), remote)
|
|
||||||
self.assertEqual(gmap.merge_checkbox_state("- [ ] пункт", remote), "- [ ] пункт")
|
|
||||||
|
|
||||||
def test_exactly_one_character_changes(self):
|
|
||||||
"""Ticking a box must not produce a diff wider than the state."""
|
|
||||||
remote = body("- [ ] пункт", "- [ ] второй")
|
|
||||||
got = gmap.merge_checkbox_state(remote, body("- [x] пункт", "- [ ] второй"))
|
|
||||||
diff = [i for i, (a, b) in enumerate(zip(remote, got)) if a != b]
|
|
||||||
self.assertEqual(len(remote), len(got))
|
|
||||||
self.assertEqual(len(diff), 1)
|
|
||||||
self.assertEqual((remote[diff[0]], got[diff[0]]), (" ", "x"))
|
|
||||||
|
|
||||||
def test_a_rewrapped_item_keeps_its_tick(self):
|
|
||||||
"""`Checkbox.text` joins continuation lines with one space, which is
|
|
||||||
the whole reason matching survives a reflow."""
|
|
||||||
remote = body("- [ ] длинный пункт, который сервер\n"
|
|
||||||
" перенёс на две строки")
|
|
||||||
got = gmap.merge_checkbox_state(
|
|
||||||
remote, body("- [x] длинный пункт, который сервер перенёс на две строки"))
|
|
||||||
self.assertIn("- [x] длинный пункт", got)
|
|
||||||
|
|
||||||
def test_a_reworded_item_does_not_keep_its_tick(self):
|
|
||||||
"""Different text is a different item. The tick stays with the wording
|
|
||||||
it was put on — this is a match, not a guess."""
|
|
||||||
got = gmap.merge_checkbox_state(body("- [ ] пункт про sqlc"),
|
|
||||||
body("- [x] пункт про SQLC"))
|
|
||||||
self.assertEqual(got, body("- [ ] пункт про sqlc"))
|
|
||||||
|
|
||||||
def test_the_marker_style_does_not_have_to_match(self):
|
|
||||||
"""`-`, `*` and `1.` are all checkbox markers to the domain parser, so
|
|
||||||
the item is the same item however the two sides chose to render it."""
|
|
||||||
got = gmap.merge_checkbox_state(body("1. [ ] пункт"), body("* [x] пункт"))
|
|
||||||
self.assertEqual(got, body("1. [x] пункт"))
|
|
||||||
|
|
||||||
def test_a_moved_item_keeps_its_tick(self):
|
|
||||||
"""Matching is on text alone; the section is not part of the key. An
|
|
||||||
item promoted out of `## Acceptance criteria` is the same item."""
|
|
||||||
remote = "## Issues\n- [ ] пункт\n"
|
|
||||||
got = gmap.merge_checkbox_state(remote, body("- [x] пункт"))
|
|
||||||
self.assertEqual(got, "## Issues\n- [x] пункт\n")
|
|
||||||
|
|
||||||
def test_duplicate_text_is_read_as_a_set(self):
|
|
||||||
"""The documented reading: one ticked local item ticks every remote
|
|
||||||
line with that text. Pairing duplicates by order is the alternative,
|
|
||||||
and it is the one that can still drop a tick."""
|
|
||||||
got = gmap.merge_checkbox_state(body("- [ ] пункт", "- [ ] пункт"),
|
|
||||||
body("- [ ] пункт", "- [x] пункт"))
|
|
||||||
self.assertEqual(got, body("- [x] пункт", "- [x] пункт"))
|
|
||||||
|
|
||||||
def test_duplicate_text_never_loses_the_second_tick(self):
|
|
||||||
"""Two local lines, one remote: order-pairing would drop this tick."""
|
|
||||||
got = gmap.merge_checkbox_state(body("- [ ] пункт"),
|
|
||||||
body("- [ ] пункт", "- [x] пункт"))
|
|
||||||
self.assertEqual(got, body("- [x] пункт"))
|
|
||||||
|
|
||||||
def test_an_example_inside_a_code_fence_is_not_ticked(self):
|
|
||||||
"""The domain parser skips fences whole, and so does the merge: a
|
|
||||||
`- [ ]` in a fence is markup being shown, not a box anyone may tick."""
|
|
||||||
remote = "## Spec\n```md\n- [ ] пункт\n```\n\n## Acceptance criteria\n- [ ] пункт\n"
|
|
||||||
got = gmap.merge_checkbox_state(remote, body("- [x] пункт"))
|
|
||||||
self.assertEqual(got, remote.replace("## Acceptance criteria\n- [ ] пункт",
|
|
||||||
"## Acceptance criteria\n- [x] пункт"))
|
|
||||||
self.assertIn("```md\n- [ ] пункт\n```", got)
|
|
||||||
|
|
||||||
def test_an_existing_capital_X_is_left_alone(self):
|
|
||||||
remote = body("- [X] пункт")
|
|
||||||
self.assertIs(gmap.merge_checkbox_state(remote, body("- [x] пункт")), remote)
|
|
||||||
|
|
||||||
def test_a_capital_X_locally_still_counts_as_ticked(self):
|
|
||||||
got = gmap.merge_checkbox_state(body("- [ ] пункт"), body("- [X] пункт"))
|
|
||||||
self.assertEqual(got, body("- [x] пункт"))
|
|
||||||
|
|
||||||
def test_unticking_in_the_web_does_not_survive(self):
|
|
||||||
"""The accepted price, pinned so nobody 'fixes' it by accident:
|
|
||||||
unticking is not monotone, so a box unticked upstream comes back.
|
|
||||||
Untick locally and push."""
|
|
||||||
got = gmap.merge_checkbox_state(body("- [ ] пункт"), body("- [x] пункт"))
|
|
||||||
self.assertEqual(got, body("- [x] пункт"))
|
|
||||||
|
|
||||||
def test_parsing_is_the_domain_layer_s(self):
|
|
||||||
"""The acceptance criterion, asserted rather than eyeballed: the rule
|
|
||||||
calls into skills/issue and defines no checkbox syntax of its own."""
|
|
||||||
with mock.patch.object(issue, "checkboxes", wraps=issue.checkboxes) as cb, \
|
|
||||||
mock.patch.object(issue, "set_checkbox", wraps=issue.set_checkbox) as sc:
|
|
||||||
gmap.merge_checkbox_state(body("- [ ] пункт"), body("- [x] пункт"))
|
|
||||||
self.assertTrue(cb.called)
|
|
||||||
self.assertTrue(sc.called)
|
|
||||||
|
|
||||||
def test_no_checkbox_markup_is_spelled_out_in_the_sync_layer(self):
|
|
||||||
"""The same criterion from the other side: the bracket markup itself
|
|
||||||
appears nowhere under skills/sync/scripts. Knowing what `[ ]` looks
|
|
||||||
like is the domain's job, and there is only one copy of it."""
|
|
||||||
sync = os.path.join(_ROOT, "skills", "sync", "scripts")
|
|
||||||
for name in sorted(f for f in os.listdir(sync) if f.endswith(".py")):
|
|
||||||
with open(os.path.join(sync, name)) as f:
|
|
||||||
code = f.read().split('"""')[0::2] # docstrings dropped
|
|
||||||
for chunk in code:
|
|
||||||
for markup in ("[ xX]", "[xX]", "- [ ]", "- [x]"):
|
|
||||||
self.assertNotIn(markup, chunk,
|
|
||||||
"%s spells out %r" % (name, markup))
|
|
||||||
|
|
||||||
|
|
||||||
class FromApiTest(unittest.TestCase):
|
|
||||||
"""The seam between the rule and the translation."""
|
|
||||||
|
|
||||||
PAYLOAD = {"number": 42, "title": "T", "html_url": "u",
|
|
||||||
"body": body("- [ ] пункт")}
|
|
||||||
|
|
||||||
def test_local_body_is_optional_and_defaults_to_no_merge(self):
|
|
||||||
iss, _ = gmap.from_api(dict(self.PAYLOAD), "an-issue", REPO)
|
|
||||||
self.assertEqual(iss.body, body("- [ ] пункт"))
|
|
||||||
|
|
||||||
def test_local_body_contributes_its_ticks(self):
|
|
||||||
iss, _ = gmap.from_api(dict(self.PAYLOAD), "an-issue", REPO,
|
|
||||||
local_body=body("- [x] пункт"))
|
|
||||||
self.assertEqual(iss.body, body("- [x] пункт"))
|
|
||||||
|
|
||||||
def test_an_empty_remote_body_does_not_crash(self):
|
|
||||||
iss, _ = gmap.from_api({"number": 42, "title": "T", "body": None},
|
|
||||||
"an-issue", REPO, local_body=body("- [x] пункт"))
|
|
||||||
self.assertEqual(iss.body, "")
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# pull.py — the rule wired into the write path
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class FakeGitea(object):
|
|
||||||
"""A `tea api` that answers issues from memory and remembers the calls."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.calls = []
|
|
||||||
self.issues = {}
|
|
||||||
|
|
||||||
def add(self, number, title, text, **kw):
|
|
||||||
p = {"number": number, "title": title, "body": text, "state": "open",
|
|
||||||
"comments": 0, "html_url": "https://git.example/%s/issues/%d" % (REPO, number),
|
|
||||||
"labels": [{"name": "type/task"}], "assignees": [], "milestone": None,
|
|
||||||
"updated_at": "2026-08-10T00:00:00Z"}
|
|
||||||
p.update(kw)
|
|
||||||
self.issues[number] = p
|
|
||||||
return p
|
|
||||||
|
|
||||||
def api(self, login, endpoint, method="GET", payload=None, **kw):
|
|
||||||
self.calls.append((method, endpoint))
|
|
||||||
path, _, query = endpoint.partition("?")
|
|
||||||
params = dict(urllib.parse.parse_qsl(query))
|
|
||||||
|
|
||||||
# Every pull asks for an issue's native links now (dependencies are the
|
|
||||||
# default). Nothing here has any; the answer just has to exist.
|
|
||||||
if path.endswith("/dependencies"):
|
|
||||||
return []
|
|
||||||
|
|
||||||
m = re.match(r"^%s/issues/(\d+)$" % re.escape(BASE), path)
|
|
||||||
if m and method == "GET":
|
|
||||||
return self.issues.get(int(m.group(1)))
|
|
||||||
if path == "%s/issues" % BASE and method == "GET":
|
|
||||||
if int(params.get("page", 1)) > 1:
|
|
||||||
return []
|
|
||||||
return [self.issues[n] for n in sorted(self.issues)]
|
|
||||||
raise AssertionError("unstubbed call: %s %s" % (method, endpoint))
|
|
||||||
|
|
||||||
|
|
||||||
class PullTestCase(unittest.TestCase):
|
|
||||||
"""A temp store and a fake transport."""
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
self.root = tempfile.mkdtemp(prefix="tea-store-")
|
|
||||||
self.fake = FakeGitea()
|
|
||||||
for p in (mock.patch.object(_gitea, "api", self.fake.api),
|
|
||||||
mock.patch.object(_gitea, "require_login", lambda: "test-login")):
|
|
||||||
p.start()
|
|
||||||
self.addCleanup(p.stop)
|
|
||||||
self.addCleanup(shutil.rmtree, self.root, True)
|
|
||||||
|
|
||||||
def write_local(self, id, text, number=42):
|
|
||||||
issue.save(self.root, issue.Issue(
|
|
||||||
id=id, title="An issue", body=text, labels=["type/task"],
|
|
||||||
origin="gitea", extra={"gitea": "%s#%d" % (REPO, number),
|
|
||||||
"url": "https://git.example/x", "synced": "old"}))
|
|
||||||
|
|
||||||
def run_pull(self, *argv):
|
|
||||||
out, err = io.StringIO(), io.StringIO()
|
|
||||||
args = ["pull.py", "--repo", REPO, "--out", self.root] + list(argv)
|
|
||||||
with mock.patch.object(sys, "argv", args), \
|
|
||||||
contextlib.redirect_stdout(out), \
|
|
||||||
contextlib.redirect_stderr(err):
|
|
||||||
pull.main()
|
|
||||||
return out.getvalue(), err.getvalue()
|
|
||||||
|
|
||||||
def stored_body(self, id):
|
|
||||||
return issue.load(self.root, id).body
|
|
||||||
|
|
||||||
def raw(self, id):
|
|
||||||
"""The file on disk, byte for byte — metadata included."""
|
|
||||||
with open(issue.path_of(self.root, id)) as f:
|
|
||||||
return f.read()
|
|
||||||
|
|
||||||
|
|
||||||
class PullMergesTicksTest(PullTestCase):
|
|
||||||
|
|
||||||
def test_a_tick_made_locally_survives_the_pull(self):
|
|
||||||
self.write_local("an-issue", body("- [x] первое", "- [ ] второе"))
|
|
||||||
self.fake.add(42, "An issue", body("- [ ] первое", "- [ ] второе"))
|
|
||||||
self.run_pull("42")
|
|
||||||
self.assertEqual(self.stored_body("an-issue"),
|
|
||||||
body("- [x] первое", "- [ ] второе"))
|
|
||||||
|
|
||||||
def test_a_tick_made_in_the_web_lands_locally(self):
|
|
||||||
self.write_local("an-issue", body("- [ ] первое", "- [ ] второе"))
|
|
||||||
self.fake.add(42, "An issue", body("- [x] первое", "- [ ] второе"))
|
|
||||||
self.run_pull("42")
|
|
||||||
self.assertEqual(self.stored_body("an-issue"),
|
|
||||||
body("- [x] первое", "- [ ] второе"))
|
|
||||||
|
|
||||||
def test_the_rest_of_the_body_is_still_overwritten(self):
|
|
||||||
self.write_local("an-issue", body("- [x] первое",
|
|
||||||
summary="Локальная правка прозы."))
|
|
||||||
self.fake.add(42, "An issue", body("- [ ] первое", "- [ ] новое с сервера",
|
|
||||||
summary="Серверная проза."))
|
|
||||||
self.run_pull("42")
|
|
||||||
got = self.stored_body("an-issue")
|
|
||||||
self.assertIn("Серверная проза.", got)
|
|
||||||
self.assertNotIn("Локальная правка прозы.", got)
|
|
||||||
self.assertIn("- [x] первое", got)
|
|
||||||
self.assertIn("- [ ] новое с сервера", got)
|
|
||||||
|
|
||||||
def test_an_item_the_server_added_arrives_ticked_if_the_server_ticked_it(self):
|
|
||||||
self.write_local("an-issue", body("- [ ] первое"))
|
|
||||||
self.fake.add(42, "An issue", body("- [ ] первое", "- [x] новое с сервера"))
|
|
||||||
self.run_pull("42")
|
|
||||||
self.assertEqual(self.stored_body("an-issue"),
|
|
||||||
body("- [ ] первое", "- [x] новое с сервера"))
|
|
||||||
|
|
||||||
def test_filter_mode_merges_too(self):
|
|
||||||
self.write_local("an-issue", body("- [x] первое"))
|
|
||||||
self.fake.add(42, "An issue", body("- [ ] первое"))
|
|
||||||
self.run_pull("--label", "type/task")
|
|
||||||
self.assertEqual(self.stored_body("an-issue"), body("- [x] первое"))
|
|
||||||
|
|
||||||
def test_a_retitled_issue_keeps_its_slug_and_its_ticks(self):
|
|
||||||
"""The merge hangs off the local id, which is resolved from the number
|
|
||||||
— a title change must not orphan the ticks."""
|
|
||||||
self.write_local("an-issue", body("- [x] первое"))
|
|
||||||
self.fake.add(42, "Completely different title", body("- [ ] первое"))
|
|
||||||
self.run_pull("42")
|
|
||||||
self.assertEqual(self.stored_body("an-issue"), body("- [x] первое"))
|
|
||||||
self.assertEqual(issue.load(self.root, "an-issue").title,
|
|
||||||
"Completely different title")
|
|
||||||
|
|
||||||
|
|
||||||
class PullIntoAnEmptyStoreTest(PullTestCase):
|
|
||||||
|
|
||||||
def test_no_local_file_writes_the_server_body_unchanged(self):
|
|
||||||
self.fake.add(42, "An issue", body("- [x] первое", "- [ ] второе"))
|
|
||||||
self.run_pull("42")
|
|
||||||
self.assertEqual(self.stored_body("an-issue"),
|
|
||||||
body("- [x] первое", "- [ ] второе"))
|
|
||||||
|
|
||||||
def test_a_store_that_does_not_exist_yet_is_created_and_not_merged(self):
|
|
||||||
shutil.rmtree(self.root)
|
|
||||||
self.fake.add(42, "An issue", body("- [ ] первое"))
|
|
||||||
_out, err = self.run_pull("42")
|
|
||||||
self.assertIn("created store", err)
|
|
||||||
self.assertEqual(self.stored_body("an-issue"), body("- [ ] первое"))
|
|
||||||
|
|
||||||
|
|
||||||
class CachedIsUnchangedTest(PullTestCase):
|
|
||||||
|
|
||||||
def test_a_skipped_issue_is_neither_read_nor_merged(self):
|
|
||||||
self.write_local("an-issue", body("- [x] первое"))
|
|
||||||
self.fake.add(42, "An issue", body("- [ ] первое", "- [ ] второе"))
|
|
||||||
before = self.raw("an-issue")
|
|
||||||
|
|
||||||
with mock.patch.object(gmap, "merge_checkbox_state") as merge:
|
|
||||||
out, _ = self.run_pull("42", "--cached")
|
|
||||||
|
|
||||||
merge.assert_not_called()
|
|
||||||
self.assertIn("(cached)", out)
|
|
||||||
self.assertEqual(self.raw("an-issue"), before)
|
|
||||||
|
|
||||||
def test_without_cached_the_same_issue_is_merged(self):
|
|
||||||
self.write_local("an-issue", body("- [x] первое"))
|
|
||||||
self.fake.add(42, "An issue", body("- [ ] первое", "- [ ] второе"))
|
|
||||||
self.run_pull("42")
|
|
||||||
self.assertEqual(self.stored_body("an-issue"),
|
|
||||||
body("- [x] первое", "- [ ] второе"))
|
|
||||||
|
|
||||||
|
|
||||||
class RoundTripTest(PullTestCase):
|
|
||||||
"""Pull twice with no change in between: the second is a no-op."""
|
|
||||||
|
|
||||||
def test_a_repeat_pull_does_not_churn_the_file(self):
|
|
||||||
self.write_local("an-issue", body("- [x] первое", "- [ ] второе"))
|
|
||||||
self.fake.add(42, "An issue", body("- [ ] первое", "- [ ] второе"))
|
|
||||||
self.run_pull("42")
|
|
||||||
first = self.raw("an-issue")
|
|
||||||
self.run_pull("42")
|
|
||||||
self.assertEqual(self.raw("an-issue"), first)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,423 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Checkbox parsing, ticking, and the INDEX progress column.
|
|
||||||
|
|
||||||
Plain stdlib unittest — the scripts under test are stdlib-only by the layering
|
|
||||||
rule, and their tests have no business dragging in a dependency the code they
|
|
||||||
cover is forbidden to have. `skills/*/scripts/` are directories of scripts, not
|
|
||||||
packages, so they go on sys.path the same way the scripts do it to each other.
|
|
||||||
|
|
||||||
python3 -m unittest discover -s tests -v
|
|
||||||
|
|
||||||
Nothing here touches tmp/, the network, or the real store: every case builds
|
|
||||||
its own store in a TemporaryDirectory.
|
|
||||||
"""
|
|
||||||
import contextlib
|
|
||||||
import io
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
import unittest
|
|
||||||
|
|
||||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
||||||
sys.path.insert(0, os.path.join(ROOT, "skills", "issue", "scripts"))
|
|
||||||
|
|
||||||
import issue # noqa: E402
|
|
||||||
import issue_ac # noqa: E402
|
|
||||||
import issue_check # noqa: E402
|
|
||||||
import issue_index # noqa: E402
|
|
||||||
|
|
||||||
# Boxes in two different sections, a wrapped item, a fenced example, and a
|
|
||||||
# plain list item that is not a checkbox at all. Line numbers are 1-based:
|
|
||||||
# the items sit on lines 8, 9, 12, 14 and 15.
|
|
||||||
BODY = """## Summary
|
|
||||||
Что-то про задачу.
|
|
||||||
|
|
||||||
## Spec
|
|
||||||
none
|
|
||||||
|
|
||||||
## Issues
|
|
||||||
- [x] wire-sqlc-appclick — первая часть
|
|
||||||
- [ ] add-pool-cfg — вторая часть
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
- [ ] в `issue.py` есть функция разбора чекбоксов тела:
|
|
||||||
возвращает пункты с номером строки, состоянием и текстом
|
|
||||||
- [X] чекбоксы ищутся по всему телу
|
|
||||||
- [ ] пример в блоке кода не считается пунктом:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
- [ ] это разметка из шаблона, а не галочка
|
|
||||||
- [x] и эта тоже
|
|
||||||
```
|
|
||||||
|
|
||||||
## Constraints
|
|
||||||
- не входит в объём: доставка тела в трекер
|
|
||||||
"""
|
|
||||||
|
|
||||||
NO_BOXES = """## Summary
|
|
||||||
Тело без единой галочки.
|
|
||||||
|
|
||||||
## Spec
|
|
||||||
none
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
- обычный пункт списка
|
|
||||||
- ещё один
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Metadata deliberately out of canonical order and missing optional keys, one
|
|
||||||
# item with trailing whitespace: a round-trip through Issue.to_text() would
|
|
||||||
# rewrite all of that, so this fixture catches a ticking path that re-renders
|
|
||||||
# the file instead of patching one character of it.
|
|
||||||
MESSY = """---
|
|
||||||
origin: local
|
|
||||||
labels: [type/task]
|
|
||||||
id: messy-issue
|
|
||||||
state: open
|
|
||||||
---
|
|
||||||
# Messy but valid
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
Тело, которое нельзя перерисовывать.
|
|
||||||
|
|
||||||
## Spec
|
|
||||||
none
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
- [ ] первый пункт
|
|
||||||
- [ ] второй пункт
|
|
||||||
- [ ] третий пункт
|
|
||||||
"""
|
|
||||||
|
|
||||||
TASK_BODY = """## Summary
|
|
||||||
Что нужно сделать.
|
|
||||||
|
|
||||||
## Spec
|
|
||||||
none
|
|
||||||
|
|
||||||
## Motivation
|
|
||||||
Зачем это нужно.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
- [ ] ничего ещё не сделано
|
|
||||||
- [ ] и это тоже не сделано
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def sole_difference(before, after):
|
|
||||||
"""The single character position at which the two strings differ.
|
|
||||||
|
|
||||||
Raises AssertionError when they differ in length or in more than one
|
|
||||||
place — the whole claim of `set_checkbox` is that this never happens."""
|
|
||||||
assert len(before) == len(after), (
|
|
||||||
"length changed: %d -> %d" % (len(before), len(after)))
|
|
||||||
diff = [i for i, (a, b) in enumerate(zip(before, after)) if a != b]
|
|
||||||
assert len(diff) == 1, "expected 1 differing character, got %d" % len(diff)
|
|
||||||
return diff[0]
|
|
||||||
|
|
||||||
|
|
||||||
@contextlib.contextmanager
|
|
||||||
def store(**files):
|
|
||||||
"""A throwaway issue store: {id: file text}."""
|
|
||||||
with tempfile.TemporaryDirectory() as root:
|
|
||||||
for id, text in files.items():
|
|
||||||
with open(os.path.join(root, "%s.md" % id), "w", newline="") as f:
|
|
||||||
f.write(text)
|
|
||||||
yield root
|
|
||||||
|
|
||||||
|
|
||||||
def run(fn, *argv):
|
|
||||||
"""Call a script entry point, returning (exit code or None, stdout)."""
|
|
||||||
buf = io.StringIO()
|
|
||||||
with contextlib.redirect_stdout(buf):
|
|
||||||
rc = fn(list(argv))
|
|
||||||
return rc, buf.getvalue()
|
|
||||||
|
|
||||||
|
|
||||||
class TestParse(unittest.TestCase):
|
|
||||||
|
|
||||||
def test_finds_every_box_in_every_section(self):
|
|
||||||
items = issue.checkboxes(BODY)
|
|
||||||
self.assertEqual([c.index for c in items], [1, 2, 3, 4, 5])
|
|
||||||
self.assertEqual([c.line for c in items], [8, 9, 12, 14, 15])
|
|
||||||
self.assertEqual([c.checked for c in items],
|
|
||||||
[True, False, False, True, False])
|
|
||||||
self.assertEqual([c.section for c in items],
|
|
||||||
["## Issues", "## Issues"] + ["## Acceptance criteria"] * 3)
|
|
||||||
|
|
||||||
def test_boxes_outside_acceptance_criteria_are_items_too(self):
|
|
||||||
# A type/feature keeps its children under `## Issues`; binding the
|
|
||||||
# parser to one heading would lose them.
|
|
||||||
under_issues = [c for c in issue.checkboxes(BODY) if c.section == "## Issues"]
|
|
||||||
self.assertEqual(len(under_issues), 2)
|
|
||||||
self.assertTrue(under_issues[0].text.startswith("wire-sqlc-appclick"))
|
|
||||||
|
|
||||||
def test_continuation_line_is_part_of_the_item(self):
|
|
||||||
item = issue.checkboxes(BODY)[2]
|
|
||||||
self.assertEqual((item.line, item.end_line), (12, 13))
|
|
||||||
self.assertEqual(
|
|
||||||
item.text,
|
|
||||||
"в `issue.py` есть функция разбора чекбоксов тела: "
|
|
||||||
"возвращает пункты с номером строки, состоянием и текстом")
|
|
||||||
|
|
||||||
def test_fenced_example_is_not_an_item(self):
|
|
||||||
texts = [c.text for c in issue.checkboxes(BODY)]
|
|
||||||
self.assertNotIn("это разметка из шаблона, а не галочка", texts)
|
|
||||||
self.assertEqual(len(texts), 5)
|
|
||||||
|
|
||||||
def test_plain_list_item_is_not_a_checkbox(self):
|
|
||||||
self.assertNotIn("не входит в объём: доставка тела в трекер",
|
|
||||||
[c.text for c in issue.checkboxes(BODY)])
|
|
||||||
|
|
||||||
def test_markers_and_nesting(self):
|
|
||||||
text = ("* [ ] star\n"
|
|
||||||
"+ [x] plus\n"
|
|
||||||
"1. [ ] ordered\n"
|
|
||||||
"2) [x] ordered too\n"
|
|
||||||
" - [ ] nested\n"
|
|
||||||
"- [x]no space, not an item\n")
|
|
||||||
items = issue.checkboxes(text)
|
|
||||||
self.assertEqual([c.text for c in items],
|
|
||||||
["star", "plus", "ordered", "ordered too", "nested"])
|
|
||||||
self.assertEqual([c.checked for c in items],
|
|
||||||
[False, True, False, True, False])
|
|
||||||
|
|
||||||
def test_empty_text(self):
|
|
||||||
self.assertEqual(issue.checkboxes(""), [])
|
|
||||||
self.assertEqual(issue.checkboxes(None), [])
|
|
||||||
|
|
||||||
def test_line_numbers_are_relative_to_the_text_given(self):
|
|
||||||
# Same body, prefixed with a metadata block: the offsets move with it,
|
|
||||||
# which is what lets issue_ac.py work on a whole file.
|
|
||||||
head = "---\nid: x\nstate: open\n---\n# Title\n\n"
|
|
||||||
shift = head.count("\n")
|
|
||||||
self.assertEqual([c.line for c in issue.checkboxes(head + BODY)],
|
|
||||||
[c.line + shift for c in issue.checkboxes(BODY)])
|
|
||||||
|
|
||||||
def test_progress(self):
|
|
||||||
self.assertEqual(issue.checkbox_progress(BODY), (2, 5))
|
|
||||||
self.assertEqual(issue.checkbox_progress(NO_BOXES), (0, 0))
|
|
||||||
|
|
||||||
|
|
||||||
class TestToggle(unittest.TestCase):
|
|
||||||
|
|
||||||
def test_ticking_changes_exactly_one_character(self):
|
|
||||||
item = issue.checkboxes(BODY)[1] # line 9, unchecked
|
|
||||||
after = issue.set_checkbox(BODY, item, True)
|
|
||||||
at = sole_difference(BODY, after)
|
|
||||||
self.assertEqual(BODY[at], " ")
|
|
||||||
self.assertEqual(after[at], "x")
|
|
||||||
self.assertEqual(issue.checkbox_progress(after), (3, 5))
|
|
||||||
|
|
||||||
def test_unticking_changes_exactly_one_character(self):
|
|
||||||
item = issue.checkboxes(BODY)[0] # line 8, checked
|
|
||||||
after = issue.set_checkbox(BODY, item, False)
|
|
||||||
at = sole_difference(BODY, after)
|
|
||||||
self.assertEqual((BODY[at], after[at]), ("x", " "))
|
|
||||||
|
|
||||||
def test_every_item_toggles_in_isolation(self):
|
|
||||||
for item in issue.checkboxes(BODY):
|
|
||||||
after = issue.set_checkbox(BODY, item, not item.checked)
|
|
||||||
at = sole_difference(BODY, after)
|
|
||||||
self.assertEqual(after.splitlines()[item.line - 1].count("["), 1)
|
|
||||||
self.assertLess(at, len(BODY))
|
|
||||||
|
|
||||||
def test_no_op_when_already_in_that_state(self):
|
|
||||||
items = issue.checkboxes(BODY)
|
|
||||||
self.assertIs(issue.set_checkbox(BODY, items[0], True), BODY)
|
|
||||||
self.assertIs(issue.set_checkbox(BODY, items[1], False), BODY)
|
|
||||||
|
|
||||||
def test_capital_x_is_left_alone(self):
|
|
||||||
item = issue.checkboxes(BODY)[3] # `- [X]`
|
|
||||||
self.assertEqual(issue.set_checkbox(BODY, item, True), BODY)
|
|
||||||
|
|
||||||
def test_accepts_a_line_number(self):
|
|
||||||
after = issue.set_checkbox(BODY, 9, True)
|
|
||||||
self.assertEqual(after, issue.set_checkbox(BODY, issue.checkboxes(BODY)[1], True))
|
|
||||||
|
|
||||||
def test_refuses_a_line_that_is_not_a_checkbox(self):
|
|
||||||
with self.assertRaises(ValueError):
|
|
||||||
issue.set_checkbox(BODY, 1, True)
|
|
||||||
with self.assertRaises(ValueError):
|
|
||||||
issue.set_checkbox(BODY, 9999, True)
|
|
||||||
|
|
||||||
|
|
||||||
class TestScript(unittest.TestCase):
|
|
||||||
|
|
||||||
def test_lists_items_numbered_with_state(self):
|
|
||||||
with store(**{"messy-issue": MESSY}) as root:
|
|
||||||
rc, out = run(issue_ac.main, "messy-issue", "--out", root)
|
|
||||||
self.assertEqual(rc, 0)
|
|
||||||
self.assertIn("messy-issue — 0/3", out)
|
|
||||||
self.assertIn("## Acceptance criteria", out)
|
|
||||||
self.assertIn(" 1 [ ] первый пункт", out)
|
|
||||||
self.assertIn(" 3 [ ] третий пункт", out)
|
|
||||||
|
|
||||||
def test_check_by_number(self):
|
|
||||||
with store(**{"messy-issue": MESSY}) as root:
|
|
||||||
rc, out = run(issue_ac.main, "messy-issue", "--check", "2", "--out", root)
|
|
||||||
with open(os.path.join(root, "messy-issue.md")) as f:
|
|
||||||
after = f.read()
|
|
||||||
self.assertEqual(rc, 0)
|
|
||||||
self.assertIn("checked", out)
|
|
||||||
self.assertIn("1/3", out)
|
|
||||||
self.assertEqual(issue.checkbox_progress(after), (1, 3))
|
|
||||||
|
|
||||||
def test_check_by_substring(self):
|
|
||||||
with store(**{"messy-issue": MESSY}) as root:
|
|
||||||
run(issue_ac.main, "messy-issue", "--check", "ТРЕТИЙ", "--out", root)
|
|
||||||
with open(os.path.join(root, "messy-issue.md")) as f:
|
|
||||||
after = f.read()
|
|
||||||
self.assertTrue(issue.checkboxes(after)[2].checked)
|
|
||||||
self.assertEqual(issue.checkbox_progress(after), (1, 3))
|
|
||||||
|
|
||||||
def test_uncheck(self):
|
|
||||||
with store(**{"messy-issue": MESSY}) as root:
|
|
||||||
run(issue_ac.main, "messy-issue", "--check", "1", "--out", root)
|
|
||||||
rc, out = run(issue_ac.main, "messy-issue", "--uncheck", "1", "--out", root)
|
|
||||||
with open(os.path.join(root, "messy-issue.md")) as f:
|
|
||||||
after = f.read()
|
|
||||||
self.assertEqual(rc, 0)
|
|
||||||
self.assertIn("unchecked", out)
|
|
||||||
self.assertEqual(after, MESSY)
|
|
||||||
|
|
||||||
def test_toggling_through_the_script_changes_one_character_of_the_file(self):
|
|
||||||
with store(**{"messy-issue": MESSY}) as root:
|
|
||||||
path = os.path.join(root, "messy-issue.md")
|
|
||||||
with open(path) as f:
|
|
||||||
before = f.read()
|
|
||||||
run(issue_ac.main, "messy-issue", "--check", "второй", "--out", root)
|
|
||||||
with open(path) as f:
|
|
||||||
after = f.read()
|
|
||||||
at = sole_difference(before, after)
|
|
||||||
self.assertEqual((before[at], after[at]), (" ", "x"))
|
|
||||||
# The metadata block was neither reordered nor completed, and the
|
|
||||||
# trailing whitespace on the third item survived.
|
|
||||||
self.assertTrue(after.startswith("---\norigin: local\n"))
|
|
||||||
self.assertIn("- [ ] третий пункт \n", after)
|
|
||||||
|
|
||||||
def test_crlf_line_endings_survive(self):
|
|
||||||
crlf = MESSY.replace("\n", "\r\n")
|
|
||||||
with store(**{"messy-issue": crlf}) as root:
|
|
||||||
path = os.path.join(root, "messy-issue.md")
|
|
||||||
run(issue_ac.main, "messy-issue", "--check", "1", "--out", root)
|
|
||||||
with open(path, newline="") as f:
|
|
||||||
after = f.read()
|
|
||||||
at = sole_difference(crlf, after)
|
|
||||||
self.assertEqual((crlf[at], after[at]), (" ", "x"))
|
|
||||||
self.assertEqual(after.count("\r\n"), crlf.count("\r\n"))
|
|
||||||
|
|
||||||
def test_ambiguous_substring_is_an_error_listing_the_matches(self):
|
|
||||||
with store(**{"messy-issue": MESSY}) as root:
|
|
||||||
with self.assertRaises(SystemExit) as cm:
|
|
||||||
run(issue_ac.main, "messy-issue", "--check", "пункт", "--out", root)
|
|
||||||
with open(os.path.join(root, "messy-issue.md")) as f:
|
|
||||||
self.assertEqual(f.read(), MESSY) # nothing was picked
|
|
||||||
msg = str(cm.exception)
|
|
||||||
self.assertIn("matches 3 items", msg)
|
|
||||||
for want in ("1 [ ] первый пункт", "2 [ ] второй пункт", "3 [ ] третий пункт"):
|
|
||||||
self.assertIn(want, msg)
|
|
||||||
|
|
||||||
def test_substring_that_matches_nothing(self):
|
|
||||||
with store(**{"messy-issue": MESSY}) as root:
|
|
||||||
with self.assertRaises(SystemExit) as cm:
|
|
||||||
run(issue_ac.main, "messy-issue", "--check", "нетакого", "--out", root)
|
|
||||||
self.assertIn("nothing matches", str(cm.exception))
|
|
||||||
|
|
||||||
def test_number_out_of_range(self):
|
|
||||||
with store(**{"messy-issue": MESSY}) as root:
|
|
||||||
with self.assertRaises(SystemExit) as cm:
|
|
||||||
run(issue_ac.main, "messy-issue", "--check", "9", "--out", root)
|
|
||||||
self.assertIn("no item 9 — the issue has 3", str(cm.exception))
|
|
||||||
|
|
||||||
def test_issue_without_checkboxes(self):
|
|
||||||
with store(**{"plain": "---\nid: plain\n---\n# Plain\n\n" + NO_BOXES}) as root:
|
|
||||||
rc, out = run(issue_ac.main, "plain", "--out", root)
|
|
||||||
self.assertEqual((rc, out.strip()), (0, "plain — no checkboxes"))
|
|
||||||
with self.assertRaises(SystemExit) as cm:
|
|
||||||
run(issue_ac.main, "plain", "--check", "1", "--out", root)
|
|
||||||
self.assertIn("has no checkboxes", str(cm.exception))
|
|
||||||
|
|
||||||
def test_unknown_id(self):
|
|
||||||
with store() as root:
|
|
||||||
with self.assertRaises(SystemExit) as cm:
|
|
||||||
run(issue_ac.main, "nope", "--out", root)
|
|
||||||
self.assertIn("no issue 'nope'", str(cm.exception))
|
|
||||||
|
|
||||||
|
|
||||||
class TestIndexProgress(unittest.TestCase):
|
|
||||||
|
|
||||||
def files(self):
|
|
||||||
boxed = ("---\nid: boxed\nstate: open\nlabels: [type/task]\n"
|
|
||||||
"origin: local\n---\n# Boxed\n\n" + BODY)
|
|
||||||
plain = ("---\nid: plain\nstate: open\nlabels: [type/task]\n"
|
|
||||||
"origin: local\n---\n# Plain\n\n" + NO_BOXES)
|
|
||||||
return {"boxed": boxed, "plain": plain}
|
|
||||||
|
|
||||||
def index(self, root):
|
|
||||||
issue_index.build(root)
|
|
||||||
with open(os.path.join(root, "INDEX.md")) as f:
|
|
||||||
return f.read()
|
|
||||||
|
|
||||||
def row(self, text, id):
|
|
||||||
for line in text.splitlines():
|
|
||||||
if line.startswith("| [%s]" % id):
|
|
||||||
return [c.strip() for c in line.split("|")]
|
|
||||||
self.fail("no row for %r in INDEX.md" % id)
|
|
||||||
|
|
||||||
def test_column_exists_and_counts_the_body(self):
|
|
||||||
with store(**self.files()) as root:
|
|
||||||
text = self.index(root)
|
|
||||||
self.assertIn("| id | state | progress | type |", text)
|
|
||||||
self.assertEqual(self.row(text, "boxed")[3], "2/5")
|
|
||||||
|
|
||||||
def test_blank_for_an_issue_without_checkboxes(self):
|
|
||||||
with store(**self.files()) as root:
|
|
||||||
text = self.index(root)
|
|
||||||
self.assertEqual(self.row(text, "plain")[3], "")
|
|
||||||
|
|
||||||
def test_recomputed_on_the_fly_not_stored(self):
|
|
||||||
with store(**self.files()) as root:
|
|
||||||
self.assertEqual(self.row(self.index(root), "boxed")[3], "2/5")
|
|
||||||
run(issue_ac.main, "boxed", "--check", "add-pool-cfg", "--out", root)
|
|
||||||
self.assertEqual(self.row(self.index(root), "boxed")[3], "3/5")
|
|
||||||
# No metadata field anywhere holds it.
|
|
||||||
with open(os.path.join(root, "boxed.md")) as f:
|
|
||||||
head = f.read().split("---")[1]
|
|
||||||
self.assertNotIn("3/5", head)
|
|
||||||
self.assertNotIn("progress", head)
|
|
||||||
|
|
||||||
|
|
||||||
class TestCheckIgnoresUntickedBoxes(unittest.TestCase):
|
|
||||||
"""An unticked box is work not done yet, not a malformed issue."""
|
|
||||||
|
|
||||||
def test_validate_reports_nothing(self):
|
|
||||||
iss = issue.Issue(id="unticked-issue", title="Do the thing",
|
|
||||||
labels=["type/task"], body=TASK_BODY)
|
|
||||||
err, warn = issue.validate(iss, known_ids={"unticked-issue"})
|
|
||||||
self.assertEqual(err, [])
|
|
||||||
self.assertEqual(warn, [])
|
|
||||||
|
|
||||||
def test_issue_check_exits_clean(self):
|
|
||||||
text = ("---\nid: unticked-issue\nstate: open\nlabels: [type/task]\n"
|
|
||||||
"assignees: []\nmilestone: none\ndepends: []\norigin: local\n"
|
|
||||||
"---\n# Do the thing\n\n" + TASK_BODY)
|
|
||||||
argv = sys.argv
|
|
||||||
with store(**{"unticked-issue": text}) as root:
|
|
||||||
sys.argv = ["issue_check.py", "--out", root]
|
|
||||||
try:
|
|
||||||
buf = io.StringIO()
|
|
||||||
with contextlib.redirect_stdout(buf):
|
|
||||||
rc = issue_check.main()
|
|
||||||
finally:
|
|
||||||
sys.argv = argv
|
|
||||||
out = buf.getvalue()
|
|
||||||
self.assertEqual(rc, 0, out)
|
|
||||||
self.assertIn("ok unticked-issue", out)
|
|
||||||
self.assertNotIn("ERROR", out)
|
|
||||||
self.assertNotIn("warn ", out)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,641 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
close.py — the state changes in Gitea, and the local file follows it or nothing
|
|
||||||
happens at all.
|
|
||||||
|
|
||||||
Two halves, and the second is the one that matters:
|
|
||||||
|
|
||||||
1. **It closes.** A slug, a number, several of either in one run, and
|
|
||||||
`--reopen` going the other way. What goes out is a PATCH carrying `state`
|
|
||||||
and nothing else; what comes back is written into `state:` on the local
|
|
||||||
file, and the index is rebuilt so the store's own table agrees.
|
|
||||||
|
|
||||||
2. **It changes nothing local unless the tracker confirmed it.** A `tea` that
|
|
||||||
exited non-zero, an answer with no number, an answer for another issue, an
|
|
||||||
answer that still says `open`, an `origin: local` issue, a `--dry-run`: in
|
|
||||||
every one of those the file on disk is byte for byte what it was. A bug here
|
|
||||||
makes the store lie about the tracker, so each path is asserted on its own.
|
|
||||||
|
|
||||||
The transport is stubbed at `_gitea.api`, as `test_drop_after_push.py` does,
|
|
||||||
with the same deliberate exception: the non-2xx test stubs `_gitea.subprocess`
|
|
||||||
and lets the real `_gitea.api` run, so "tea exited 1" is proved end to end.
|
|
||||||
|
|
||||||
Nothing here touches a network, and nothing here touches the developer's store:
|
|
||||||
every test builds its own in a `tempfile.TemporaryDirectory()`.
|
|
||||||
"""
|
|
||||||
import contextlib
|
|
||||||
import io
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
import types
|
|
||||||
import unittest
|
|
||||||
from unittest import mock
|
|
||||||
|
|
||||||
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
||||||
for _p in (os.path.join(_ROOT, "skills", "sync", "scripts"),
|
|
||||||
os.path.join(_ROOT, "skills", "issue", "scripts")):
|
|
||||||
if _p not in sys.path:
|
|
||||||
sys.path.insert(0, _p)
|
|
||||||
|
|
||||||
import _gitea # noqa: E402
|
|
||||||
import close # noqa: E402
|
|
||||||
import issue # noqa: E402
|
|
||||||
import map as gmap # noqa: E402
|
|
||||||
|
|
||||||
# Captured before any test patches it — the non-2xx test needs the real thing.
|
|
||||||
REAL_API = _gitea.api
|
|
||||||
|
|
||||||
REPO = "claude-skills/tea"
|
|
||||||
BASE = "repos/%s" % REPO
|
|
||||||
|
|
||||||
BODY = """## Summary
|
|
||||||
Прозаическое описание задачи.
|
|
||||||
|
|
||||||
## Spec
|
|
||||||
skills/issue/references/format.md
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
- [x] что-нибудь работает
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class FakeTracker(object):
|
|
||||||
"""`tea api` answered from memory, for state writes only.
|
|
||||||
|
|
||||||
It keeps a `state` per number and flips it on a PATCH, which is the whole
|
|
||||||
contract close.py has with the far side."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.calls = []
|
|
||||||
self.states = {} # number -> "open" / "closed"
|
|
||||||
self.raise_on_write = None # an exception instance to raise
|
|
||||||
self.answer_override = None # what a write answers instead
|
|
||||||
|
|
||||||
def payload_of(self, number):
|
|
||||||
return {"number": number, "state": self.states[number],
|
|
||||||
"title": "A thing", "updated_at": "2026-08-11T00:00:00Z",
|
|
||||||
"html_url": "https://git.example/%s/issues/%d" % (REPO, number)}
|
|
||||||
|
|
||||||
def writes(self):
|
|
||||||
return [c for c in self.calls if c[0] != "GET"]
|
|
||||||
|
|
||||||
def api(self, login, endpoint, method="GET", payload=None,
|
|
||||||
payload_name=None, out_root=None, allow_fail=False):
|
|
||||||
self.calls.append((method, endpoint, payload))
|
|
||||||
path = endpoint.split("?")[0]
|
|
||||||
|
|
||||||
if "/issues/" in path and method == "PATCH":
|
|
||||||
number = int(path.rsplit("/", 1)[1])
|
|
||||||
if self.raise_on_write is not None:
|
|
||||||
raise self.raise_on_write
|
|
||||||
self.states.setdefault(number, "open")
|
|
||||||
if "state" in (payload or {}):
|
|
||||||
self.states[number] = payload["state"]
|
|
||||||
if self.answer_override is not None:
|
|
||||||
return self.answer_override
|
|
||||||
return self.payload_of(number)
|
|
||||||
|
|
||||||
if "/issues/" in path and method == "GET":
|
|
||||||
n = int(path.rsplit("/", 1)[1])
|
|
||||||
return self.payload_of(n) if n in self.states else None
|
|
||||||
|
|
||||||
raise AssertionError("unstubbed call: %s %s" % (method, endpoint))
|
|
||||||
|
|
||||||
|
|
||||||
class StoreTestCase(unittest.TestCase):
|
|
||||||
"""A temp store and a fake tracker."""
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
tmp = tempfile.TemporaryDirectory(prefix="tea-close-")
|
|
||||||
self.addCleanup(tmp.cleanup)
|
|
||||||
self.root = tmp.name
|
|
||||||
self.fake = FakeTracker()
|
|
||||||
for p in (mock.patch.object(_gitea, "api", self.fake.api),
|
|
||||||
mock.patch.object(_gitea, "require_login", lambda: "test-login")):
|
|
||||||
p.start()
|
|
||||||
self.addCleanup(p.stop)
|
|
||||||
|
|
||||||
# -- fixtures ----------------------------------------------------------
|
|
||||||
|
|
||||||
def synced(self, id="a-thing", number=101, state="open"):
|
|
||||||
"""An issue that is in the tracker and on disk, the way a pull leaves
|
|
||||||
it: `origin: gitea`, a `gitea:` field, and a ledger entry."""
|
|
||||||
key = gmap.remote_key(REPO, number)
|
|
||||||
iss = issue.Issue(id=id, title="A thing", body=BODY, state=state,
|
|
||||||
labels=["type/task"], origin=gmap.ORIGIN,
|
|
||||||
extra={"gitea": key, "url": "https://git.example/x",
|
|
||||||
"synced": "2026-08-10T00:00:00Z"})
|
|
||||||
issue.save(self.root, iss)
|
|
||||||
m = _gitea.load_map(self.root)
|
|
||||||
m[key] = id
|
|
||||||
_gitea.save_map(self.root, m)
|
|
||||||
self.fake.states[number] = state
|
|
||||||
return iss
|
|
||||||
|
|
||||||
def local_only(self, id="local-thing"):
|
|
||||||
"""An issue that has never left this machine."""
|
|
||||||
iss = issue.Issue(id=id, title="Local thing", body=BODY,
|
|
||||||
labels=["type/task"])
|
|
||||||
issue.save(self.root, iss)
|
|
||||||
return iss
|
|
||||||
|
|
||||||
def dropped(self, id="gone-thing", number=205, state="open"):
|
|
||||||
"""Pushed, and its file went with the push: ledger only."""
|
|
||||||
m = _gitea.load_map(self.root)
|
|
||||||
m[gmap.remote_key(REPO, number)] = id
|
|
||||||
_gitea.save_map(self.root, m)
|
|
||||||
self.fake.states[number] = state
|
|
||||||
return number
|
|
||||||
|
|
||||||
# -- runner ------------------------------------------------------------
|
|
||||||
|
|
||||||
def run_close(self, *argv):
|
|
||||||
self.out, self.err = io.StringIO(), io.StringIO()
|
|
||||||
args = ["close.py", "--repo", REPO, "--out", self.root] + list(argv)
|
|
||||||
with mock.patch.object(sys, "argv", args), \
|
|
||||||
contextlib.redirect_stdout(self.out), \
|
|
||||||
contextlib.redirect_stderr(self.err):
|
|
||||||
close.main()
|
|
||||||
return self.out.getvalue(), self.err.getvalue()
|
|
||||||
|
|
||||||
# -- assertions --------------------------------------------------------
|
|
||||||
|
|
||||||
def state_on_disk(self, id):
|
|
||||||
return issue.load(self.root, id).state
|
|
||||||
|
|
||||||
def raw(self, id):
|
|
||||||
with open(issue.path_of(self.root, id)) as f:
|
|
||||||
return f.read()
|
|
||||||
|
|
||||||
def assertUnchanged(self, id, before, why=""):
|
|
||||||
self.assertEqual(self.raw(id), before,
|
|
||||||
"%s.md was rewritten%s" % (id, why and " — " + why))
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# it closes
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class ClosesTest(StoreTestCase):
|
|
||||||
|
|
||||||
def test_a_slug_closes_the_issue_it_names(self):
|
|
||||||
self.synced("a-thing", 101)
|
|
||||||
out, _ = self.run_close("a-thing")
|
|
||||||
self.assertEqual(self.fake.states[101], "closed")
|
|
||||||
self.assertIn("closed a-thing #101", out)
|
|
||||||
|
|
||||||
def test_the_local_state_follows(self):
|
|
||||||
self.synced("a-thing", 101)
|
|
||||||
self.run_close("a-thing")
|
|
||||||
self.assertEqual(self.state_on_disk("a-thing"), "closed")
|
|
||||||
|
|
||||||
def test_only_the_state_is_sent(self):
|
|
||||||
"""Closing is not an edit: no title, no body, no labels ride along."""
|
|
||||||
self.synced("a-thing", 101)
|
|
||||||
self.run_close("a-thing")
|
|
||||||
writes = self.fake.writes()
|
|
||||||
self.assertEqual(len(writes), 1)
|
|
||||||
method, endpoint, payload = writes[0]
|
|
||||||
self.assertEqual((method, endpoint), ("PATCH", "%s/issues/101" % BASE))
|
|
||||||
self.assertEqual(payload, {"state": "closed"})
|
|
||||||
|
|
||||||
def test_a_number_closes_it_too(self):
|
|
||||||
"""The normal case for a pushed issue — the file is long gone."""
|
|
||||||
self.synced("a-thing", 101)
|
|
||||||
self.run_close("101")
|
|
||||||
self.assertEqual(self.fake.states[101], "closed")
|
|
||||||
self.assertEqual(self.state_on_disk("a-thing"), "closed")
|
|
||||||
|
|
||||||
def test_every_key_form_is_accepted(self):
|
|
||||||
forms = {110: "110", 111: "#111", 112: "%s#112" % REPO,
|
|
||||||
113: "https://git.example/%s/issues/113" % REPO}
|
|
||||||
for n in forms:
|
|
||||||
self.fake.states[n] = "open"
|
|
||||||
for n, arg in forms.items():
|
|
||||||
with self.subTest(arg=arg):
|
|
||||||
self.run_close(arg)
|
|
||||||
self.assertEqual(self.fake.states[n], "closed")
|
|
||||||
|
|
||||||
def test_several_ids_in_one_run(self):
|
|
||||||
self.synced("a-thing", 101)
|
|
||||||
self.synced("b-thing", 102)
|
|
||||||
self.run_close("a-thing", "102")
|
|
||||||
self.assertEqual(self.fake.states, {101: "closed", 102: "closed"})
|
|
||||||
self.assertEqual(self.state_on_disk("a-thing"), "closed")
|
|
||||||
self.assertEqual(self.state_on_disk("b-thing"), "closed")
|
|
||||||
|
|
||||||
def test_the_same_issue_named_twice_is_written_once(self):
|
|
||||||
self.synced("a-thing", 101)
|
|
||||||
self.run_close("a-thing", "#101")
|
|
||||||
self.assertEqual(len(self.fake.writes()), 1)
|
|
||||||
|
|
||||||
def test_the_index_is_rebuilt(self):
|
|
||||||
self.synced("a-thing", 101)
|
|
||||||
out, _ = self.run_close("a-thing")
|
|
||||||
self.assertIn("index:", out)
|
|
||||||
with open(os.path.join(self.root, "INDEX.md")) as f:
|
|
||||||
self.assertIn("closed", f.read())
|
|
||||||
|
|
||||||
def test_the_body_survives_untouched(self):
|
|
||||||
"""One metadata field changes; the prose and the ticks do not."""
|
|
||||||
self.synced("a-thing", 101)
|
|
||||||
before = issue.load(self.root, "a-thing").body
|
|
||||||
self.run_close("a-thing")
|
|
||||||
self.assertEqual(issue.load(self.root, "a-thing").body, before)
|
|
||||||
|
|
||||||
def test_synced_is_refreshed(self):
|
|
||||||
self.synced("a-thing", 101)
|
|
||||||
self.run_close("a-thing")
|
|
||||||
iss = issue.load(self.root, "a-thing")
|
|
||||||
self.assertNotEqual(iss.extra.get("synced"), "2026-08-10T00:00:00Z")
|
|
||||||
self.assertEqual(iss.extra.get("remote-updated"), "2026-08-11T00:00:00Z")
|
|
||||||
|
|
||||||
def test_an_issue_whose_file_was_dropped_still_closes(self):
|
|
||||||
"""No local copy at all: the ledger names it, the tracker takes it, and
|
|
||||||
nothing is written locally."""
|
|
||||||
self.dropped("gone-thing", 205)
|
|
||||||
out, _ = self.run_close("gone-thing")
|
|
||||||
self.assertEqual(self.fake.states[205], "closed")
|
|
||||||
self.assertIn("no local copy", out)
|
|
||||||
self.assertNotIn("index:", out)
|
|
||||||
|
|
||||||
def test_a_number_nobody_here_knows_closes_without_a_slug(self):
|
|
||||||
self.fake.states[777] = "open"
|
|
||||||
out, _ = self.run_close("777")
|
|
||||||
self.assertEqual(self.fake.states[777], "closed")
|
|
||||||
self.assertIn("#777", out)
|
|
||||||
|
|
||||||
|
|
||||||
class ReopensTest(StoreTestCase):
|
|
||||||
|
|
||||||
def test_reopen_sends_open(self):
|
|
||||||
self.synced("a-thing", 101, state="closed")
|
|
||||||
out, _ = self.run_close("--reopen", "a-thing")
|
|
||||||
self.assertEqual(self.fake.writes()[0][2], {"state": "open"})
|
|
||||||
self.assertIn("reopened a-thing #101", out)
|
|
||||||
|
|
||||||
def test_reopen_writes_the_local_state_back(self):
|
|
||||||
self.synced("a-thing", 101, state="closed")
|
|
||||||
self.run_close("--reopen", "a-thing")
|
|
||||||
self.assertEqual(self.state_on_disk("a-thing"), "open")
|
|
||||||
|
|
||||||
def test_close_then_reopen_is_a_round_trip(self):
|
|
||||||
self.synced("a-thing", 101)
|
|
||||||
self.run_close("a-thing")
|
|
||||||
self.run_close("--reopen", "a-thing")
|
|
||||||
self.assertEqual(self.fake.states[101], "open")
|
|
||||||
self.assertEqual(self.state_on_disk("a-thing"), "open")
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# it refuses
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class LocalOnlyTest(StoreTestCase):
|
|
||||||
"""An `origin: local` issue is not in the tracker, so it cannot be closed
|
|
||||||
there — and the local field is not quietly edited instead."""
|
|
||||||
|
|
||||||
def test_it_exits(self):
|
|
||||||
self.local_only("local-thing")
|
|
||||||
with self.assertRaises(SystemExit):
|
|
||||||
self.run_close("local-thing")
|
|
||||||
|
|
||||||
def test_the_error_names_the_id_and_says_it_is_not_in_the_tracker(self):
|
|
||||||
self.local_only("local-thing")
|
|
||||||
with self.assertRaises(SystemExit):
|
|
||||||
self.run_close("local-thing")
|
|
||||||
err = self.err.getvalue()
|
|
||||||
self.assertIn("local-thing", err)
|
|
||||||
self.assertIn("not in the tracker", err)
|
|
||||||
|
|
||||||
def test_nothing_is_sent(self):
|
|
||||||
self.local_only("local-thing")
|
|
||||||
with self.assertRaises(SystemExit):
|
|
||||||
self.run_close("local-thing")
|
|
||||||
self.assertEqual(self.fake.calls, [])
|
|
||||||
|
|
||||||
def test_the_file_is_untouched(self):
|
|
||||||
self.local_only("local-thing")
|
|
||||||
before = self.raw("local-thing")
|
|
||||||
with self.assertRaises(SystemExit):
|
|
||||||
self.run_close("local-thing")
|
|
||||||
self.assertUnchanged("local-thing", before)
|
|
||||||
|
|
||||||
def test_a_bad_id_stops_the_whole_run_before_anything_is_sent(self):
|
|
||||||
"""Resolution happens up front, so a typo in the second id does not
|
|
||||||
leave the first one closed."""
|
|
||||||
self.synced("a-thing", 101)
|
|
||||||
with self.assertRaises(SystemExit):
|
|
||||||
self.run_close("a-thing", "local-thing")
|
|
||||||
self.assertEqual(self.fake.states[101], "open")
|
|
||||||
self.assertEqual(self.fake.calls, [])
|
|
||||||
|
|
||||||
def test_an_unknown_slug_exits(self):
|
|
||||||
with self.assertRaises(SystemExit):
|
|
||||||
self.run_close("no-such-thing")
|
|
||||||
self.assertIn("no-such-thing", self.err.getvalue())
|
|
||||||
|
|
||||||
|
|
||||||
class DryRunTest(StoreTestCase):
|
|
||||||
|
|
||||||
def test_not_one_request_is_made(self):
|
|
||||||
self.synced("a-thing", 101)
|
|
||||||
self.run_close("--dry-run", "a-thing")
|
|
||||||
self.assertEqual(self.fake.calls, [])
|
|
||||||
|
|
||||||
def test_the_file_is_untouched(self):
|
|
||||||
self.synced("a-thing", 101)
|
|
||||||
before = self.raw("a-thing")
|
|
||||||
self.run_close("--dry-run", "a-thing")
|
|
||||||
self.assertUnchanged("a-thing", before, "--dry-run must write nothing")
|
|
||||||
|
|
||||||
def test_it_says_what_would_be_closed(self):
|
|
||||||
self.synced("a-thing", 101)
|
|
||||||
self.synced("b-thing", 102)
|
|
||||||
out, _ = self.run_close("--dry-run", "a-thing", "102")
|
|
||||||
self.assertIn("would close a-thing #101", out)
|
|
||||||
self.assertIn("would close b-thing #102", out)
|
|
||||||
self.assertIn("2 issue(s) would be closed", out)
|
|
||||||
|
|
||||||
def test_it_says_reopen_under_reopen(self):
|
|
||||||
self.synced("a-thing", 101, state="closed")
|
|
||||||
out, _ = self.run_close("--dry-run", "--reopen", "a-thing")
|
|
||||||
self.assertIn("would reopen a-thing #101", out)
|
|
||||||
self.assertIn("would be reopened", out)
|
|
||||||
|
|
||||||
def test_it_needs_no_login(self):
|
|
||||||
"""A dry run must work before /tea:auth has ever been run."""
|
|
||||||
self.synced("a-thing", 101)
|
|
||||||
with mock.patch.object(_gitea, "require_login",
|
|
||||||
lambda: self.fail("dry run asked for a login")):
|
|
||||||
self.run_close("--dry-run", "a-thing")
|
|
||||||
|
|
||||||
def test_a_local_only_issue_is_still_refused(self):
|
|
||||||
self.local_only("local-thing")
|
|
||||||
with self.assertRaises(SystemExit):
|
|
||||||
self.run_close("--dry-run", "local-thing")
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# the tracker said no
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TrackerFailureTest(StoreTestCase):
|
|
||||||
"""The criterion that matters most: a write that was not confirmed leaves
|
|
||||||
the local file exactly as it was."""
|
|
||||||
|
|
||||||
def test_a_non_2xx_answer_leaves_the_file(self):
|
|
||||||
"""The real `_gitea.api` against a `tea` that exits 1 — the path a 422
|
|
||||||
or a 500 actually takes, and it ends in `die()`."""
|
|
||||||
self.synced("a-thing", 101)
|
|
||||||
before = self.raw("a-thing")
|
|
||||||
|
|
||||||
def fake_run(cmd, capture_output=False, text=False):
|
|
||||||
return types.SimpleNamespace(
|
|
||||||
returncode=1, stdout="",
|
|
||||||
stderr="422 Unprocessable Entity: issue is blocked")
|
|
||||||
|
|
||||||
with mock.patch.object(_gitea, "api", REAL_API), \
|
|
||||||
mock.patch.object(_gitea, "subprocess",
|
|
||||||
types.SimpleNamespace(run=fake_run)), \
|
|
||||||
self.assertRaises(SystemExit):
|
|
||||||
self.run_close("a-thing")
|
|
||||||
|
|
||||||
self.assertUnchanged("a-thing", before, "tea exited non-zero")
|
|
||||||
self.assertEqual(self.state_on_disk("a-thing"), "open")
|
|
||||||
|
|
||||||
def test_a_transport_exception_leaves_the_file(self):
|
|
||||||
self.synced("a-thing", 101)
|
|
||||||
before = self.raw("a-thing")
|
|
||||||
self.fake.raise_on_write = OSError("tea: command not found")
|
|
||||||
with self.assertRaises(OSError):
|
|
||||||
self.run_close("a-thing")
|
|
||||||
self.assertUnchanged("a-thing", before, "the transport raised")
|
|
||||||
|
|
||||||
def test_an_answer_without_a_number_leaves_the_file(self):
|
|
||||||
self.synced("a-thing", 101)
|
|
||||||
before = self.raw("a-thing")
|
|
||||||
self.fake.answer_override = {"ok": True, "state": "closed"}
|
|
||||||
with self.assertRaises(SystemExit):
|
|
||||||
self.run_close("a-thing")
|
|
||||||
self.assertUnchanged("a-thing", before)
|
|
||||||
|
|
||||||
def test_an_answer_for_another_issue_leaves_the_file(self):
|
|
||||||
self.synced("a-thing", 101)
|
|
||||||
before = self.raw("a-thing")
|
|
||||||
self.fake.answer_override = {"number": 999, "state": "closed"}
|
|
||||||
with self.assertRaises(SystemExit):
|
|
||||||
self.run_close("a-thing")
|
|
||||||
self.assertUnchanged("a-thing", before)
|
|
||||||
|
|
||||||
def test_an_answer_that_did_not_change_the_state_leaves_the_file(self):
|
|
||||||
"""A 200 that still says `open` is not a close."""
|
|
||||||
self.synced("a-thing", 101)
|
|
||||||
before = self.raw("a-thing")
|
|
||||||
self.fake.answer_override = {"number": 101, "state": "open"}
|
|
||||||
with self.assertRaises(SystemExit):
|
|
||||||
self.run_close("a-thing")
|
|
||||||
self.assertUnchanged("a-thing", before)
|
|
||||||
|
|
||||||
def test_an_empty_answer_leaves_the_file(self):
|
|
||||||
self.synced("a-thing", 101)
|
|
||||||
before = self.raw("a-thing")
|
|
||||||
self.fake.answer_override = None
|
|
||||||
real_api = self.fake.api
|
|
||||||
self.fake.api = lambda *a, **kw: (real_api(*a, **kw), None)[1]
|
|
||||||
with mock.patch.object(_gitea, "api", self.fake.api), \
|
|
||||||
self.assertRaises(SystemExit):
|
|
||||||
self.run_close("a-thing")
|
|
||||||
self.assertUnchanged("a-thing", before)
|
|
||||||
|
|
||||||
def test_the_error_says_nothing_local_changed(self):
|
|
||||||
self.synced("a-thing", 101)
|
|
||||||
self.fake.answer_override = {"ok": True}
|
|
||||||
with self.assertRaises(SystemExit):
|
|
||||||
self.run_close("a-thing")
|
|
||||||
self.assertIn("Nothing local was changed", self.err.getvalue())
|
|
||||||
|
|
||||||
def test_a_failure_partway_through_keeps_the_rest(self):
|
|
||||||
"""Two issues, the second one is not confirmed. The first is
|
|
||||||
legitimately closed; the second's file still says open."""
|
|
||||||
self.synced("aaa-thing", 101)
|
|
||||||
self.synced("zzz-thing", 102)
|
|
||||||
before = self.raw("zzz-thing")
|
|
||||||
|
|
||||||
real = self.fake.api
|
|
||||||
seen = []
|
|
||||||
|
|
||||||
def once(login, endpoint, method="GET", payload=None, **kw):
|
|
||||||
got = real(login, endpoint, method, payload, **kw)
|
|
||||||
if method != "GET":
|
|
||||||
seen.append(endpoint)
|
|
||||||
return {"nope": True} if len(seen) > 1 else got
|
|
||||||
|
|
||||||
with mock.patch.object(_gitea, "api", once), \
|
|
||||||
self.assertRaises(SystemExit):
|
|
||||||
self.run_close("aaa-thing", "zzz-thing")
|
|
||||||
|
|
||||||
self.assertEqual(self.state_on_disk("aaa-thing"), "closed")
|
|
||||||
self.assertUnchanged("zzz-thing", before, "its write was not confirmed")
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# the pure parts
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class ConfirmedTest(unittest.TestCase):
|
|
||||||
"""The gate itself. Everything below it rewrites a file."""
|
|
||||||
|
|
||||||
def test_a_matching_close_is_confirmed(self):
|
|
||||||
self.assertTrue(close.confirmed({"number": 42, "state": "closed"}, 42, "closed"))
|
|
||||||
|
|
||||||
def test_a_mismatched_number_is_not(self):
|
|
||||||
self.assertFalse(close.confirmed({"number": 43, "state": "closed"}, 42, "closed"))
|
|
||||||
|
|
||||||
def test_the_wrong_state_is_not(self):
|
|
||||||
self.assertFalse(close.confirmed({"number": 42, "state": "open"}, 42, "closed"))
|
|
||||||
|
|
||||||
def test_a_missing_state_is_not(self):
|
|
||||||
self.assertFalse(close.confirmed({"number": 42}, 42, "closed"))
|
|
||||||
|
|
||||||
def test_none_and_lists_are_not(self):
|
|
||||||
self.assertFalse(close.confirmed(None, 42, "closed"))
|
|
||||||
self.assertFalse(close.confirmed([{"number": 42, "state": "closed"}], 42, "closed"))
|
|
||||||
|
|
||||||
def test_true_is_not_a_number(self):
|
|
||||||
self.assertFalse(close.confirmed({"number": True, "state": "closed"}, 1, "closed"))
|
|
||||||
|
|
||||||
def test_a_string_number_is_not(self):
|
|
||||||
self.assertFalse(close.confirmed({"number": "42", "state": "closed"}, 42, "closed"))
|
|
||||||
|
|
||||||
|
|
||||||
class KeyFormTest(unittest.TestCase):
|
|
||||||
"""A slug and a key are two vocabularies that must not collide."""
|
|
||||||
|
|
||||||
def test_keys_are_keys(self):
|
|
||||||
for k in ("42", "#42", "owner/repo#42",
|
|
||||||
"https://git.example/owner/repo/issues/42"):
|
|
||||||
self.assertTrue(close.looks_like_key(k), k)
|
|
||||||
|
|
||||||
def test_slugs_are_not_keys(self):
|
|
||||||
for s in ("a-thing", "wire-sqlc-appclick", "close-issues-through-a-script"):
|
|
||||||
self.assertFalse(close.looks_like_key(s), s)
|
|
||||||
|
|
||||||
|
|
||||||
class LedgerPairsTest(unittest.TestCase):
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
self.m = {"%s#7" % REPO: "a-thing", "other/repo#7": "b-thing",
|
|
||||||
"not-a-key": "c-thing"}
|
|
||||||
|
|
||||||
def test_it_filters_by_repo(self):
|
|
||||||
self.assertEqual(close.ledger_pairs(self.m, REPO), [(REPO, 7, "a-thing")])
|
|
||||||
|
|
||||||
def test_without_a_repo_it_keeps_everything_parseable(self):
|
|
||||||
got = close.ledger_pairs(self.m)
|
|
||||||
self.assertEqual(sorted(s for _r, _n, s in got), ["a-thing", "b-thing"])
|
|
||||||
|
|
||||||
def test_an_ambiguous_number_exits(self):
|
|
||||||
pairs = close.ledger_pairs(self.m)
|
|
||||||
with self.assertRaises(SystemExit):
|
|
||||||
with contextlib.redirect_stderr(io.StringIO()):
|
|
||||||
close.resolve("7", {}, pairs)
|
|
||||||
|
|
||||||
|
|
||||||
class AmbiguityTest(StoreTestCase):
|
|
||||||
"""Two repos, one number, no --repo: settle it rather than guess."""
|
|
||||||
|
|
||||||
def test_the_error_points_at_repo(self):
|
|
||||||
_gitea.save_map(self.root, {"%s#7" % REPO: "a-thing",
|
|
||||||
"other/repo#7": "b-thing"})
|
|
||||||
err = io.StringIO()
|
|
||||||
args = ["close.py", "--out", self.root, "7"]
|
|
||||||
with mock.patch.object(sys, "argv", args), \
|
|
||||||
contextlib.redirect_stdout(io.StringIO()), \
|
|
||||||
contextlib.redirect_stderr(err), \
|
|
||||||
self.assertRaises(SystemExit):
|
|
||||||
close.main()
|
|
||||||
self.assertIn("--repo", err.getvalue())
|
|
||||||
|
|
||||||
|
|
||||||
class RepoOfTheKeyTest(StoreTestCase):
|
|
||||||
"""A key that names its own repo is sent there, not to whatever repo the
|
|
||||||
CWD happens to be — otherwise `#42` closes somebody else's issue."""
|
|
||||||
|
|
||||||
def run_bare(self, *argv):
|
|
||||||
"""No `--repo`, so the ids have to say where they live."""
|
|
||||||
self.out, self.err = io.StringIO(), io.StringIO()
|
|
||||||
args = ["close.py", "--out", self.root] + list(argv)
|
|
||||||
with mock.patch.object(sys, "argv", args), \
|
|
||||||
contextlib.redirect_stdout(self.out), \
|
|
||||||
contextlib.redirect_stderr(self.err):
|
|
||||||
close.main()
|
|
||||||
return self.out.getvalue(), self.err.getvalue()
|
|
||||||
|
|
||||||
def test_a_foreign_key_goes_to_its_own_repo(self):
|
|
||||||
self.run_bare("other/repo#42")
|
|
||||||
self.assertEqual(self.fake.writes()[0][1], "repos/other/repo/issues/42")
|
|
||||||
|
|
||||||
def test_a_slug_goes_to_the_repo_its_gitea_field_names(self):
|
|
||||||
self.synced("a-thing", 101)
|
|
||||||
self.run_bare("a-thing")
|
|
||||||
self.assertEqual(self.fake.writes()[0][1], "%s/issues/101" % BASE)
|
|
||||||
|
|
||||||
def test_two_repos_in_one_run_is_a_question_not_a_guess(self):
|
|
||||||
self.synced("a-thing", 101)
|
|
||||||
with self.assertRaises(SystemExit):
|
|
||||||
self.run_bare("a-thing", "other/repo#42")
|
|
||||||
self.assertIn("one repo", self.err.getvalue())
|
|
||||||
self.assertEqual(self.fake.calls, [])
|
|
||||||
|
|
||||||
def test_an_explicit_repo_settles_it(self):
|
|
||||||
self.synced("a-thing", 101)
|
|
||||||
self.run_close("a-thing", "other/repo#42")
|
|
||||||
self.assertEqual({c[1] for c in self.fake.writes()},
|
|
||||||
{"%s/issues/101" % BASE, "%s/issues/42" % BASE})
|
|
||||||
|
|
||||||
|
|
||||||
class NoStoreTest(StoreTestCase):
|
|
||||||
"""A number needs no local file, and a store that is not there is not an
|
|
||||||
error — closing an issue whose copy push dropped is the normal case."""
|
|
||||||
|
|
||||||
def test_a_number_closes_with_no_store_at_all(self):
|
|
||||||
missing = os.path.join(self.root, "nowhere")
|
|
||||||
self.fake.states[303] = "open"
|
|
||||||
args = ["close.py", "--repo", REPO, "--out", missing, "303"]
|
|
||||||
with mock.patch.object(sys, "argv", args), \
|
|
||||||
contextlib.redirect_stdout(io.StringIO()), \
|
|
||||||
contextlib.redirect_stderr(io.StringIO()):
|
|
||||||
close.main()
|
|
||||||
self.assertEqual(self.fake.states[303], "closed")
|
|
||||||
self.assertFalse(os.path.isdir(missing), "no store was conjured")
|
|
||||||
|
|
||||||
|
|
||||||
class PayloadFileTest(StoreTestCase):
|
|
||||||
"""The request body goes to the transport's own scratchpad.
|
|
||||||
|
|
||||||
Not to a directory this script picks: `close.py` names the payload and
|
|
||||||
nothing else, the way every other caller does. Where PAYLOAD_ROOT lands is
|
|
||||||
_gitea's business, and test_payload_root.py is where that is tested."""
|
|
||||||
|
|
||||||
def test_the_payload_lands_in_the_transports_scratchpad(self):
|
|
||||||
self.synced("a-thing", 101)
|
|
||||||
payloads = os.path.join(self.root, "payload")
|
|
||||||
with mock.patch.object(_gitea, "PAYLOAD_ROOT", payloads), \
|
|
||||||
mock.patch.object(_gitea, "api", REAL_API), \
|
|
||||||
mock.patch.object(
|
|
||||||
_gitea, "subprocess",
|
|
||||||
types.SimpleNamespace(run=lambda cmd, **kw: types.SimpleNamespace(
|
|
||||||
returncode=0, stderr="",
|
|
||||||
stdout=json.dumps({"number": 101, "state": "closed"})))):
|
|
||||||
self.run_close("a-thing")
|
|
||||||
p = os.path.join(payloads, "state-101.json")
|
|
||||||
self.assertTrue(os.path.isfile(p))
|
|
||||||
with open(p) as f:
|
|
||||||
self.assertEqual(json.load(f), {"state": "closed"})
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,234 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
The container <-> child edge points ONE way: container -> child.
|
|
||||||
|
|
||||||
A `type/feature` is closed when its children are closed, and that is a
|
|
||||||
dependency relation, so the container lists its children in `depends:`. A child
|
|
||||||
belongs to a feature, which is a membership relation, and membership has no
|
|
||||||
place in a dependency graph — so a child never names its container back. These
|
|
||||||
tests pin that direction down in all three places it shows up: the validator,
|
|
||||||
the desync warning, and the drawn tree.
|
|
||||||
|
|
||||||
python3 -m unittest discover -s tests -v
|
|
||||||
|
|
||||||
Stdlib only, like the scripts under test. `skills/*/scripts/` are directories,
|
|
||||||
not packages, so they go on sys.path by hand.
|
|
||||||
"""
|
|
||||||
import contextlib
|
|
||||||
import io
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
import unittest
|
|
||||||
|
|
||||||
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
||||||
SCRIPTS = os.path.join(REPO, "skills", "issue", "scripts")
|
|
||||||
if SCRIPTS not in sys.path:
|
|
||||||
sys.path.insert(0, SCRIPTS)
|
|
||||||
|
|
||||||
import issue # noqa: E402
|
|
||||||
import issue_check # noqa: E402
|
|
||||||
import issue_new # noqa: E402
|
|
||||||
import issue_tree # noqa: E402
|
|
||||||
|
|
||||||
|
|
||||||
def run(module, argv):
|
|
||||||
"""Call a script's main() with argv, returning (exit_code, stdout).
|
|
||||||
|
|
||||||
stderr is swallowed: issue_new.py notes on it when a `--depends` id is not
|
|
||||||
in the store yet, which is fine and not what these tests are about."""
|
|
||||||
buf = io.StringIO()
|
|
||||||
old = sys.argv
|
|
||||||
sys.argv = [module.__name__ + ".py"] + argv
|
|
||||||
try:
|
|
||||||
with contextlib.redirect_stdout(buf), contextlib.redirect_stderr(io.StringIO()):
|
|
||||||
code = module.main()
|
|
||||||
except SystemExit as e: # argparse / sys.exit("msg")
|
|
||||||
code = e.code if isinstance(e.code, int) else 1
|
|
||||||
finally:
|
|
||||||
sys.argv = old
|
|
||||||
return (code or 0), buf.getvalue()
|
|
||||||
|
|
||||||
|
|
||||||
def drawn(tree_output):
|
|
||||||
"""The rows inside the tree's code fence, header and blanks dropped."""
|
|
||||||
return [l for l in tree_output.splitlines() if l.rstrip().endswith(".md")]
|
|
||||||
|
|
||||||
|
|
||||||
class StoreCase(unittest.TestCase):
|
|
||||||
"""A scratch store per test. Never touches tmp/issues/."""
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
self._tmp = tempfile.TemporaryDirectory()
|
|
||||||
self.root = self._tmp.name
|
|
||||||
self.addCleanup(self._tmp.cleanup)
|
|
||||||
|
|
||||||
def new(self, type, id, title, depends=()):
|
|
||||||
argv = ["--type", type, "--id", id, "--title", title, "--out", self.root]
|
|
||||||
for d in depends:
|
|
||||||
argv += ["--depends", d]
|
|
||||||
code, _ = run(issue_new, argv)
|
|
||||||
self.assertEqual(code, 0, "issue_new.py failed for %s" % id)
|
|
||||||
|
|
||||||
def edit(self, id, old, new):
|
|
||||||
p = issue.path_of(self.root, id)
|
|
||||||
with open(p) as f:
|
|
||||||
text = f.read()
|
|
||||||
self.assertIn(old, text, "%s.md does not contain %r" % (id, old))
|
|
||||||
with open(p, "w") as f:
|
|
||||||
f.write(text.replace(old, new))
|
|
||||||
|
|
||||||
def fill_issues_section(self, id, *children):
|
|
||||||
"""Replace the type/feature template's `## Issues` placeholder."""
|
|
||||||
self.edit(id,
|
|
||||||
"- [ ] slug-дочернего-issue — краткое описание части\n- [ ] …\n",
|
|
||||||
"".join("- [ ] %s — часть\n" % c for c in children))
|
|
||||||
|
|
||||||
def container_and_child(self):
|
|
||||||
"""The canonical shape from references/format.md: the container names
|
|
||||||
the child in `depends:` AND in `## Issues`; the child names nobody."""
|
|
||||||
self.new("task", "child-y", "Child y")
|
|
||||||
self.new("feature", "feat-x", "Container x", depends=["child-y"])
|
|
||||||
self.fill_issues_section("feat-x", "child-y")
|
|
||||||
|
|
||||||
|
|
||||||
class CanonicalContainerIsClean(StoreCase):
|
|
||||||
"""A container from the template plus a child per format.md: green."""
|
|
||||||
|
|
||||||
def test_check_is_silent_and_exits_zero(self):
|
|
||||||
self.container_and_child()
|
|
||||||
code, out = run(issue_check, ["--out", self.root])
|
|
||||||
self.assertEqual(code, 0, out)
|
|
||||||
self.assertNotIn("ERROR", out)
|
|
||||||
self.assertNotIn("warn", out)
|
|
||||||
self.assertIn("ok feat-x", out)
|
|
||||||
self.assertIn("ok child-y", out)
|
|
||||||
|
|
||||||
def test_validate_reports_nothing_for_either_issue(self):
|
|
||||||
self.container_and_child()
|
|
||||||
issues = issue.load_all(self.root)
|
|
||||||
for id in ("feat-x", "child-y"):
|
|
||||||
err, warn = issue.validate(issues[id], known_ids=set(issues))
|
|
||||||
self.assertEqual((err, warn), ([], []), id)
|
|
||||||
|
|
||||||
def test_the_child_does_not_depend_on_its_container(self):
|
|
||||||
self.container_and_child()
|
|
||||||
issues = issue.load_all(self.root)
|
|
||||||
self.assertEqual(issues["feat-x"].depends, ["child-y"])
|
|
||||||
self.assertEqual(issues["child-y"].depends, [])
|
|
||||||
|
|
||||||
|
|
||||||
class OnlyOneDirectionIsLegal(StoreCase):
|
|
||||||
"""format.md and the validator agree on container -> child, and the
|
|
||||||
reverse edge is an error rather than a matter of taste."""
|
|
||||||
|
|
||||||
def test_the_reverse_edge_is_a_cycle(self):
|
|
||||||
self.container_and_child()
|
|
||||||
self.edit("child-y", "depends: []", "depends: [feat-x]")
|
|
||||||
code, out = run(issue_check, ["--out", self.root])
|
|
||||||
self.assertEqual(code, 1, out)
|
|
||||||
self.assertIn("ERROR cycle:", out)
|
|
||||||
self.assertIn("feat-x", out)
|
|
||||||
self.assertIn("child-y", out)
|
|
||||||
|
|
||||||
def test_a_child_pointing_at_its_container_alone_is_not_the_graph(self):
|
|
||||||
"""The shape format.md used to document: the child depends on the
|
|
||||||
container and the container's depends: is empty. It no longer matches
|
|
||||||
what the container's own `## Issues` says, so the check complains."""
|
|
||||||
self.new("feature", "feat-x", "Container x")
|
|
||||||
self.new("task", "child-y", "Child y", depends=["feat-x"])
|
|
||||||
self.fill_issues_section("feat-x", "child-y")
|
|
||||||
_, out = run(issue_check, ["--out", self.root])
|
|
||||||
self.assertIn("warn feat-x:", out)
|
|
||||||
|
|
||||||
def test_issues_section_is_an_edge_source_pointing_down(self):
|
|
||||||
body = "## Issues\n- [ ] child-y — часть\n"
|
|
||||||
self.assertEqual(issue.body_dep_refs(body), ["child-y"])
|
|
||||||
|
|
||||||
|
|
||||||
class WarningNamesItsOwnSection(StoreCase):
|
|
||||||
"""The desync warning quotes the section the reference came from, not
|
|
||||||
`## Depends on` unconditionally — a container has no such section."""
|
|
||||||
|
|
||||||
def test_container_warning_says_issues(self):
|
|
||||||
self.new("feature", "feat-x", "Container x")
|
|
||||||
self.new("task", "child-y", "Child y")
|
|
||||||
self.fill_issues_section("feat-x", "child-y") # but not depends:
|
|
||||||
issues = issue.load_all(self.root)
|
|
||||||
err, warn = issue.validate(issues["feat-x"], known_ids=set(issues))
|
|
||||||
self.assertEqual(err, [])
|
|
||||||
self.assertEqual(
|
|
||||||
warn, ["## Issues mentions 'child-y' but `depends:` does not list it"])
|
|
||||||
self.assertNotIn("## Depends on", "\n".join(warn))
|
|
||||||
body = issue.load(self.root, "feat-x").body
|
|
||||||
self.assertNotIn("## Depends on", body,
|
|
||||||
"the warning must not name a section that is not in the file")
|
|
||||||
|
|
||||||
def test_plain_issue_warning_still_says_depends_on(self):
|
|
||||||
self.new("task", "child-y", "Child y", depends=["migrate-schema"])
|
|
||||||
self.edit("child-y", "depends: [migrate-schema]", "depends: []")
|
|
||||||
issues = issue.load_all(self.root)
|
|
||||||
_, warn = issue.validate(issues["child-y"])
|
|
||||||
self.assertEqual(
|
|
||||||
warn,
|
|
||||||
["## Depends on mentions 'migrate-schema' but `depends:` does not list it"])
|
|
||||||
|
|
||||||
def test_each_reference_is_named_with_its_own_section(self):
|
|
||||||
body = ("## Depends on\n- migrate-schema\n\n"
|
|
||||||
"## Issues\n- [ ] child-y — часть\n")
|
|
||||||
self.assertEqual(
|
|
||||||
issue.body_dep_ref_sections(body),
|
|
||||||
[("## Depends on", "migrate-schema"), ("## Issues", "child-y")])
|
|
||||||
|
|
||||||
def test_body_dep_refs_still_returns_bare_strings(self):
|
|
||||||
"""skills/sync/scripts/map.py filters this list for `#N` refs."""
|
|
||||||
body = "## Depends on\n- migrate-schema\n- #42\n"
|
|
||||||
refs = issue.body_dep_refs(body)
|
|
||||||
self.assertEqual(refs, ["migrate-schema", "#42"])
|
|
||||||
self.assertTrue(all(isinstance(r, str) for r in refs))
|
|
||||||
|
|
||||||
def test_tracker_numbers_never_warn(self):
|
|
||||||
"""`#42` is a tracker handle, not a slug; `depends:` holds ids only."""
|
|
||||||
self.new("task", "child-y", "Child y")
|
|
||||||
self.edit("child-y", "## Motivation", "## Depends on\n- #42\n\n## Motivation")
|
|
||||||
issues = issue.load_all(self.root)
|
|
||||||
_, warn = issue.validate(issues["child-y"])
|
|
||||||
self.assertEqual(warn, [])
|
|
||||||
|
|
||||||
|
|
||||||
class TreePutsTheContainerOnTop(StoreCase):
|
|
||||||
|
|
||||||
def test_container_is_the_root_and_children_hang_below(self):
|
|
||||||
self.container_and_child()
|
|
||||||
code, out = run(issue_tree, ["--out", self.root])
|
|
||||||
self.assertEqual(code, 0, out)
|
|
||||||
rows = drawn(out)
|
|
||||||
self.assertEqual(len(rows), 2, out)
|
|
||||||
self.assertTrue(rows[0].startswith("feat-x "), out)
|
|
||||||
self.assertTrue(rows[1].startswith("└── child-y "), out)
|
|
||||||
self.assertEqual(out.count("child-y ["), 1, "child drawn more than once")
|
|
||||||
|
|
||||||
def test_the_container_is_the_only_root(self):
|
|
||||||
self.container_and_child()
|
|
||||||
_, out = run(issue_tree, ["--out", self.root])
|
|
||||||
self.assertIn("# Dependency tree — feat-x", out)
|
|
||||||
|
|
||||||
def test_two_children_hang_off_one_container(self):
|
|
||||||
self.new("task", "child-y", "Child y")
|
|
||||||
self.new("task", "child-z", "Child z")
|
|
||||||
self.new("feature", "feat-x", "Container x",
|
|
||||||
depends=["child-y", "child-z"])
|
|
||||||
self.fill_issues_section("feat-x", "child-y", "child-z")
|
|
||||||
code, out = run(issue_check, ["--out", self.root])
|
|
||||||
self.assertEqual(code, 0, out)
|
|
||||||
_, tree = run(issue_tree, ["--out", self.root])
|
|
||||||
self.assertEqual(tree.count("feat-x ["), 1,
|
|
||||||
"the container must not repeat once per child")
|
|
||||||
self.assertEqual([r.split(" ")[0] for r in drawn(tree)],
|
|
||||||
["feat-x", "├──", "└──"], tree)
|
|
||||||
self.assertIn("├── child-y ", tree)
|
|
||||||
self.assertIn("└── child-z ", tree)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,811 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
The local copy is dropped after a successful push, and pulled back on demand.
|
|
||||||
|
|
||||||
Two halves, and the second one is the one that matters:
|
|
||||||
|
|
||||||
1. **It deletes.** A confirmed create or PATCH removes `tmp/issues/<id>.md` and
|
|
||||||
`<id>.comments.md`, prints where the issue lives now, and leaves the ledger
|
|
||||||
behind so the slug can be found again. A pull puts the same file back —
|
|
||||||
same slug, same `depends:`, same body — including after a rename in Gitea
|
|
||||||
and on a machine that never had the file.
|
|
||||||
|
|
||||||
2. **It does not delete anything else, ever.** A transport that raised, a `tea`
|
|
||||||
that exited non-zero, an answer without a number, an answer for the wrong
|
|
||||||
issue, an `origin: local` issue nobody pushed: the file is still on disk.
|
|
||||||
A bug here destroys work, so every one of those paths is asserted
|
|
||||||
separately, and the assertion is always the same — `os.path.isfile`.
|
|
||||||
|
|
||||||
The transport is stubbed at `_gitea.api`, as `test_push_dependencies.py` does,
|
|
||||||
with one deliberate exception: the non-2xx test stubs `_gitea.subprocess`
|
|
||||||
instead and lets the REAL `_gitea.api` run, so "tea exited 1" is proved end to
|
|
||||||
end rather than assumed.
|
|
||||||
|
|
||||||
Nothing here touches a network, and nothing here touches the developer's store:
|
|
||||||
every test builds its own in a `tempfile.mkdtemp()`.
|
|
||||||
"""
|
|
||||||
import contextlib
|
|
||||||
import io
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
import types
|
|
||||||
import unittest
|
|
||||||
from unittest import mock
|
|
||||||
|
|
||||||
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
||||||
for _p in (os.path.join(_ROOT, "skills", "sync", "scripts"),
|
|
||||||
os.path.join(_ROOT, "skills", "issue", "scripts")):
|
|
||||||
if _p not in sys.path:
|
|
||||||
sys.path.insert(0, _p)
|
|
||||||
|
|
||||||
import _gitea # noqa: E402
|
|
||||||
import issue # noqa: E402
|
|
||||||
import map as gmap # noqa: E402
|
|
||||||
import pull # noqa: E402
|
|
||||||
import push # noqa: E402
|
|
||||||
|
|
||||||
# Captured before any test patches it — the non-2xx test needs the real thing.
|
|
||||||
REAL_API = _gitea.api
|
|
||||||
|
|
||||||
REPO = "claude-skills/tea"
|
|
||||||
BASE = "repos/%s" % REPO
|
|
||||||
LABELS = {"type/task": 901, "type/bug": 902}
|
|
||||||
LABEL_NAMES = {v: k for k, v in LABELS.items()}
|
|
||||||
|
|
||||||
BODY = """## Summary
|
|
||||||
Прозаическое описание задачи.
|
|
||||||
|
|
||||||
## Spec
|
|
||||||
skills/issue/references/format.md
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
- [ ] что-нибудь работает
|
|
||||||
"""
|
|
||||||
|
|
||||||
BODY_WITH_DEPS = """## Summary
|
|
||||||
Прозаическое описание задачи.
|
|
||||||
|
|
||||||
## Spec
|
|
||||||
skills/issue/references/format.md
|
|
||||||
|
|
||||||
## Depends on
|
|
||||||
- first-thing — ставит фундамент, без него второй не собрать
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
- [ ] что-нибудь работает
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# a tracker that can be both pushed to and pulled from
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class FakeTracker(object):
|
|
||||||
"""`tea api` answered from memory, for push AND pull.
|
|
||||||
|
|
||||||
It keeps bodies the way Gitea does — verbatim, marker and all — which is
|
|
||||||
what makes the round-trip tests real: the slug that comes back is the one
|
|
||||||
that was actually stored on the far side, not one the test handed over."""
|
|
||||||
|
|
||||||
def __init__(self, next_number=101):
|
|
||||||
self.calls = []
|
|
||||||
self.next_number = next_number
|
|
||||||
self.issues = {} # number -> payload
|
|
||||||
self.deps = {} # number -> {(repo, number)}
|
|
||||||
# Failure injection, one write at a time.
|
|
||||||
self.raise_on_write = None # an exception instance to raise
|
|
||||||
self.answer_override = None # what a write answers instead
|
|
||||||
|
|
||||||
# -- state -------------------------------------------------------------
|
|
||||||
|
|
||||||
def store(self, number, title, body, **kw):
|
|
||||||
p = {"number": number, "title": title, "body": body, "state": "open",
|
|
||||||
"comments": 0, "labels": [{"name": "type/task"}], "assignees": [],
|
|
||||||
"milestone": None, "ref": "test-branch",
|
|
||||||
"html_url": "https://git.example/%s/issues/%d" % (REPO, number),
|
|
||||||
"updated_at": "2026-08-10T00:00:00Z",
|
|
||||||
"repository": {"full_name": REPO}}
|
|
||||||
p.update(kw)
|
|
||||||
self.issues[number] = p
|
|
||||||
return p
|
|
||||||
|
|
||||||
def body_of(self, number):
|
|
||||||
return self.issues[number]["body"]
|
|
||||||
|
|
||||||
def rename(self, number, title):
|
|
||||||
self.issues[number]["title"] = title
|
|
||||||
|
|
||||||
def writes(self):
|
|
||||||
return [c for c in self.calls if c[0] != "GET"]
|
|
||||||
|
|
||||||
# -- the seam ----------------------------------------------------------
|
|
||||||
|
|
||||||
def api(self, login, endpoint, method="GET", payload=None,
|
|
||||||
payload_name=None, allow_fail=False):
|
|
||||||
self.calls.append((method, endpoint, payload))
|
|
||||||
path = endpoint.split("?")[0]
|
|
||||||
|
|
||||||
if path == "%s/labels" % BASE and method == "GET":
|
|
||||||
return [{"name": n, "id": i} for n, i in LABELS.items()]
|
|
||||||
|
|
||||||
if path.endswith("/comments"):
|
|
||||||
return []
|
|
||||||
|
|
||||||
if path.endswith("/dependencies"):
|
|
||||||
number = int(path.split("/issues/")[1].split("/")[0])
|
|
||||||
if method == "GET":
|
|
||||||
return [dict(self.issues[n], repository={"full_name": r})
|
|
||||||
for r, n in sorted(self.deps.get(number, set()))
|
|
||||||
if n in self.issues]
|
|
||||||
if method == "POST":
|
|
||||||
self.deps.setdefault(number, set()).add(
|
|
||||||
("%s/%s" % (payload["owner"], payload["repo"]),
|
|
||||||
int(payload["index"])))
|
|
||||||
return {"number": number}
|
|
||||||
|
|
||||||
if path == "%s/issues" % BASE and method == "POST":
|
|
||||||
return self._write(
|
|
||||||
lambda: self.store(self._next(), payload.get("title", ""),
|
|
||||||
payload.get("body", ""),
|
|
||||||
labels=self._labels(payload),
|
|
||||||
ref=payload.get("ref", "")))
|
|
||||||
|
|
||||||
if "/issues/" in path and method == "PATCH":
|
|
||||||
number = int(path.rsplit("/", 1)[1])
|
|
||||||
return self._write(
|
|
||||||
lambda: self.store(number, payload.get("title", ""),
|
|
||||||
payload.get("body", ""),
|
|
||||||
labels=self._labels(payload),
|
|
||||||
ref=payload.get("ref", "")))
|
|
||||||
|
|
||||||
if "/issues/" in path and method == "GET":
|
|
||||||
return self.issues.get(int(path.rsplit("/", 1)[1]))
|
|
||||||
|
|
||||||
raise AssertionError("unstubbed call: %s %s" % (method, endpoint))
|
|
||||||
|
|
||||||
# -- helpers -----------------------------------------------------------
|
|
||||||
|
|
||||||
def _next(self):
|
|
||||||
n = self.next_number
|
|
||||||
self.next_number += 1
|
|
||||||
return n
|
|
||||||
|
|
||||||
def _labels(self, payload):
|
|
||||||
return [{"name": LABEL_NAMES[i]} for i in (payload or {}).get("labels") or []
|
|
||||||
if i in LABEL_NAMES]
|
|
||||||
|
|
||||||
def _write(self, do):
|
|
||||||
"""Every create and update goes through here, so a test can make one
|
|
||||||
fail without knowing which verb it was."""
|
|
||||||
if self.raise_on_write is not None:
|
|
||||||
raise self.raise_on_write
|
|
||||||
got = do()
|
|
||||||
if self.answer_override is not None:
|
|
||||||
return self.answer_override
|
|
||||||
return got
|
|
||||||
|
|
||||||
|
|
||||||
class StoreTestCase(unittest.TestCase):
|
|
||||||
"""A temp store, a fake tracker, and no git."""
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
self.root = tempfile.mkdtemp(prefix="tea-drop-")
|
|
||||||
self.fake = FakeTracker()
|
|
||||||
# PAYLOAD_ROOT is the repo's own tmp/payload, and a test that stubs the
|
|
||||||
# transport one layer down (see the non-2xx case) reaches the real
|
|
||||||
# write. Point it at the fixture: a test writes in its temp directory
|
|
||||||
# and nowhere else.
|
|
||||||
for p in (mock.patch.object(_gitea, "api", self.fake.api),
|
|
||||||
mock.patch.object(_gitea, "PAYLOAD_ROOT",
|
|
||||||
os.path.join(self.root, "payload")),
|
|
||||||
mock.patch.object(_gitea, "require_login", lambda: "test-login"),
|
|
||||||
mock.patch.object(push, "git_branch", lambda: "test-branch")):
|
|
||||||
p.start()
|
|
||||||
self.addCleanup(p.stop)
|
|
||||||
self.addCleanup(shutil.rmtree, self.root, True)
|
|
||||||
|
|
||||||
# -- fixtures ----------------------------------------------------------
|
|
||||||
|
|
||||||
def write_issue(self, id, title, body=BODY, depends=(), origin=issue.LOCAL,
|
|
||||||
extra=None):
|
|
||||||
iss = issue.Issue(id=id, title=title, body=body, labels=["type/task"],
|
|
||||||
depends=list(depends), origin=origin,
|
|
||||||
extra=dict(extra or {}))
|
|
||||||
issue.save(self.root, iss)
|
|
||||||
return iss
|
|
||||||
|
|
||||||
def write_comments(self, id, text="## comment 1 — someone — 2026-08-10\n\nтекст\n"):
|
|
||||||
p = _gitea.comments_path(self.root, id)
|
|
||||||
with open(p, "w") as f:
|
|
||||||
f.write(text)
|
|
||||||
return p
|
|
||||||
|
|
||||||
# -- runners -----------------------------------------------------------
|
|
||||||
|
|
||||||
def run_push(self, *argv):
|
|
||||||
return self._run(push, "push.py", argv)
|
|
||||||
|
|
||||||
def run_pull(self, *argv):
|
|
||||||
return self._run(pull, "pull.py", argv)
|
|
||||||
|
|
||||||
def _run(self, mod, name, argv):
|
|
||||||
# Kept on self so a test that expects SystemExit can still read what
|
|
||||||
# went to stderr — the run never returns in that case.
|
|
||||||
self.out, self.err = io.StringIO(), io.StringIO()
|
|
||||||
args = [name, "--repo", REPO, "--out", self.root] + list(argv)
|
|
||||||
with mock.patch.object(sys, "argv", args), \
|
|
||||||
contextlib.redirect_stdout(self.out), \
|
|
||||||
contextlib.redirect_stderr(self.err):
|
|
||||||
mod.main()
|
|
||||||
return self.out.getvalue(), self.err.getvalue()
|
|
||||||
|
|
||||||
# -- assertions --------------------------------------------------------
|
|
||||||
|
|
||||||
def assertOnDisk(self, id, why=""):
|
|
||||||
self.assertTrue(os.path.isfile(issue.path_of(self.root, id)),
|
|
||||||
"%s.md was deleted%s" % (id, why and " — " + why))
|
|
||||||
|
|
||||||
def assertGone(self, id):
|
|
||||||
self.assertFalse(os.path.isfile(issue.path_of(self.root, id)),
|
|
||||||
"%s.md is still on disk" % id)
|
|
||||||
|
|
||||||
def ledger(self):
|
|
||||||
return _gitea.load_map(self.root)
|
|
||||||
|
|
||||||
def number_of(self, id):
|
|
||||||
for key, slug in self.ledger().items():
|
|
||||||
if slug == id:
|
|
||||||
return gmap.parse_remote_key(key)[1]
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# it deletes
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class DropsAfterCreateTest(StoreTestCase):
|
|
||||||
|
|
||||||
def test_the_issue_file_is_gone(self):
|
|
||||||
self.write_issue("a-thing", "A thing")
|
|
||||||
self.run_push()
|
|
||||||
self.assertGone("a-thing")
|
|
||||||
|
|
||||||
def test_the_comment_thread_goes_with_it(self):
|
|
||||||
self.write_issue("a-thing", "A thing")
|
|
||||||
cpath = self.write_comments("a-thing")
|
|
||||||
self.run_push()
|
|
||||||
self.assertFalse(os.path.isfile(cpath), "the thread outlived the issue")
|
|
||||||
|
|
||||||
def test_a_missing_thread_is_not_an_error(self):
|
|
||||||
"""Most issues have no comments file. Dropping must not care."""
|
|
||||||
self.write_issue("a-thing", "A thing")
|
|
||||||
out, _ = self.run_push()
|
|
||||||
self.assertIn("dropped", out)
|
|
||||||
|
|
||||||
def test_the_output_names_the_number_and_the_url(self):
|
|
||||||
"""The local path is gone, so this line is the only address left."""
|
|
||||||
self.write_issue("a-thing", "A thing")
|
|
||||||
out, _ = self.run_push()
|
|
||||||
n = self.number_of("a-thing")
|
|
||||||
self.assertIn("#%d" % n, out)
|
|
||||||
self.assertIn("https://git.example/%s/issues/%d" % (REPO, n), out)
|
|
||||||
self.assertIn("pull.py %d" % n, out)
|
|
||||||
|
|
||||||
def test_the_ledger_outlives_the_file(self):
|
|
||||||
"""`.remote.json` does not become garbage when the files go — it
|
|
||||||
becomes the only local record of which slug this number is."""
|
|
||||||
self.write_issue("a-thing", "A thing")
|
|
||||||
self.run_push()
|
|
||||||
n = self.number_of("a-thing")
|
|
||||||
self.assertIsNotNone(n)
|
|
||||||
self.assertEqual(self.ledger(), {gmap.remote_key(REPO, n): "a-thing"})
|
|
||||||
|
|
||||||
def test_the_ledger_is_written_before_the_file_is_removed(self):
|
|
||||||
"""Ordering, asserted rather than trusted: if the two were swapped, an
|
|
||||||
interrupted run would cost the slug and not just a re-pull."""
|
|
||||||
seen = {}
|
|
||||||
real_drop = push.drop_local
|
|
||||||
|
|
||||||
def spy(root, id):
|
|
||||||
seen["ledger"] = json.load(open(_gitea.map_path(root)))
|
|
||||||
return real_drop(root, id)
|
|
||||||
|
|
||||||
self.write_issue("a-thing", "A thing")
|
|
||||||
with mock.patch.object(push, "drop_local", spy):
|
|
||||||
self.run_push()
|
|
||||||
self.assertIn("a-thing", (seen.get("ledger") or {}).values())
|
|
||||||
|
|
||||||
|
|
||||||
class DropsAfterUpdateTest(StoreTestCase):
|
|
||||||
"""One rule, no exception: `--update` deletes too."""
|
|
||||||
|
|
||||||
def pushed_then_pulled(self, id="a-thing", body=BODY):
|
|
||||||
self.write_issue(id, "A thing", body=body)
|
|
||||||
self.run_push()
|
|
||||||
self.run_pull(str(self.number_of(id)))
|
|
||||||
self.assertOnDisk(id, "the pull should have put it back")
|
|
||||||
return id
|
|
||||||
|
|
||||||
def test_patch_deletes_the_file_too(self):
|
|
||||||
id = self.pushed_then_pulled()
|
|
||||||
out, _ = self.run_push("--update", id)
|
|
||||||
self.assertIn("updated", out)
|
|
||||||
self.assertGone(id)
|
|
||||||
|
|
||||||
def test_patch_deletes_the_thread_too(self):
|
|
||||||
id = self.pushed_then_pulled()
|
|
||||||
cpath = self.write_comments(id)
|
|
||||||
self.run_push("--update", id)
|
|
||||||
self.assertFalse(os.path.isfile(cpath))
|
|
||||||
|
|
||||||
def test_the_patch_really_went_out(self):
|
|
||||||
id = self.pushed_then_pulled()
|
|
||||||
self.run_push("--update", id)
|
|
||||||
self.assertTrue([c for c in self.fake.calls if c[0] == "PATCH"])
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# it deletes nothing else
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class NeverPushedIsNeverDroppedTest(StoreTestCase):
|
|
||||||
|
|
||||||
def test_a_local_issue_nobody_selected_stays(self):
|
|
||||||
self.write_issue("pushed-thing", "Pushed thing")
|
|
||||||
self.write_issue("kept-thing", "Kept thing")
|
|
||||||
self.run_push("pushed-thing")
|
|
||||||
self.assertGone("pushed-thing")
|
|
||||||
self.assertOnDisk("kept-thing", "it was never pushed")
|
|
||||||
|
|
||||||
def test_a_local_only_dependency_stays(self):
|
|
||||||
"""It is read (for the warning) but never sent, so never dropped."""
|
|
||||||
self.write_issue("first-thing", "First thing")
|
|
||||||
self.write_issue("second-thing", "Second thing", body=BODY_WITH_DEPS,
|
|
||||||
depends=["first-thing"])
|
|
||||||
_, err = self.run_push("second-thing")
|
|
||||||
self.assertIn("depends on local-only issue(s) first-thing", err)
|
|
||||||
self.assertOnDisk("first-thing", "it was never sent")
|
|
||||||
|
|
||||||
def test_dry_run_deletes_nothing(self):
|
|
||||||
self.write_issue("a-thing", "A thing")
|
|
||||||
self.run_push("--dry-run")
|
|
||||||
self.assertOnDisk("a-thing", "--dry-run must not write or delete")
|
|
||||||
self.assertEqual(self.fake.calls, [])
|
|
||||||
|
|
||||||
def test_a_format_violation_stops_before_anything_is_sent(self):
|
|
||||||
"""No type/* label: validation fails, nothing is sent, nothing goes."""
|
|
||||||
issue.save(self.root, issue.Issue(id="bad-thing", title="Bad thing",
|
|
||||||
body=BODY, labels=[]))
|
|
||||||
with self.assertRaises(SystemExit):
|
|
||||||
self.run_push("bad-thing")
|
|
||||||
self.assertOnDisk("bad-thing")
|
|
||||||
self.assertEqual(self.fake.writes(), [])
|
|
||||||
|
|
||||||
|
|
||||||
class SurvivesEveryFailureTest(StoreTestCase):
|
|
||||||
"""The criterion that matters most. Each path is asserted on its own."""
|
|
||||||
|
|
||||||
def test_a_transport_exception_leaves_the_file(self):
|
|
||||||
"""`tea` could not be run at all — the exception propagates out of the
|
|
||||||
push and the delete is never reached."""
|
|
||||||
self.write_issue("a-thing", "A thing")
|
|
||||||
self.fake.raise_on_write = OSError("tea: command not found")
|
|
||||||
with self.assertRaises(OSError):
|
|
||||||
self.run_push()
|
|
||||||
self.assertOnDisk("a-thing", "the transport raised")
|
|
||||||
self.assertEqual(self.ledger(), {})
|
|
||||||
|
|
||||||
def test_a_non_2xx_answer_leaves_the_file(self):
|
|
||||||
"""The real `_gitea.api` against a `tea` that exits 1.
|
|
||||||
|
|
||||||
Stubbed one layer lower than every other test here on purpose: this is
|
|
||||||
the path a 422 or a 500 actually takes, and it ends in `die()`."""
|
|
||||||
self.write_issue("a-thing", "A thing")
|
|
||||||
|
|
||||||
def fake_run(cmd, capture_output=False, text=False):
|
|
||||||
creating = "-X" in cmd and cmd[cmd.index("-X") + 1] == "POST"
|
|
||||||
if creating:
|
|
||||||
return types.SimpleNamespace(
|
|
||||||
returncode=1, stdout="",
|
|
||||||
stderr="422 Unprocessable Entity: validation failed")
|
|
||||||
if cmd[-1].split("?")[0].endswith("/labels"):
|
|
||||||
return types.SimpleNamespace(
|
|
||||||
returncode=0, stderr="",
|
|
||||||
stdout=json.dumps([{"name": n, "id": i}
|
|
||||||
for n, i in LABELS.items()]))
|
|
||||||
return types.SimpleNamespace(returncode=0, stdout="", stderr="")
|
|
||||||
|
|
||||||
with mock.patch.object(_gitea, "api", REAL_API), \
|
|
||||||
mock.patch.object(_gitea, "subprocess",
|
|
||||||
types.SimpleNamespace(run=fake_run)), \
|
|
||||||
self.assertRaises(SystemExit):
|
|
||||||
self.run_push()
|
|
||||||
|
|
||||||
self.assertOnDisk("a-thing", "tea exited non-zero")
|
|
||||||
|
|
||||||
def test_an_answer_without_a_number_leaves_the_file(self):
|
|
||||||
self.write_issue("a-thing", "A thing")
|
|
||||||
self.fake.answer_override = {"ok": True, "message": "created"}
|
|
||||||
with self.assertRaises(SystemExit):
|
|
||||||
self.run_push()
|
|
||||||
self.assertOnDisk("a-thing", "the answer carried no number")
|
|
||||||
|
|
||||||
def test_an_answer_that_is_not_an_object_leaves_the_file(self):
|
|
||||||
self.write_issue("a-thing", "A thing")
|
|
||||||
self.fake.answer_override = ["something", "else"]
|
|
||||||
with self.assertRaises(SystemExit):
|
|
||||||
self.run_push()
|
|
||||||
self.assertOnDisk("a-thing")
|
|
||||||
|
|
||||||
def test_an_empty_answer_leaves_the_file(self):
|
|
||||||
"""`tea` exited 0 and printed nothing — api returns None."""
|
|
||||||
self.write_issue("a-thing", "A thing")
|
|
||||||
self.fake.answer_override = None
|
|
||||||
real_write = self.fake._write
|
|
||||||
self.fake._write = lambda do: (real_write(do), None)[1]
|
|
||||||
with self.assertRaises(SystemExit):
|
|
||||||
self.run_push()
|
|
||||||
self.assertOnDisk("a-thing")
|
|
||||||
|
|
||||||
def test_a_patch_answering_for_another_issue_leaves_the_file(self):
|
|
||||||
"""The mismatched-body case: we PATCHed #101 and #999 answered."""
|
|
||||||
self.write_issue("a-thing", "A thing")
|
|
||||||
self.run_push()
|
|
||||||
n = self.number_of("a-thing")
|
|
||||||
self.run_pull(str(n))
|
|
||||||
self.assertOnDisk("a-thing")
|
|
||||||
|
|
||||||
self.fake.answer_override = {"number": 999, "html_url": "https://x"}
|
|
||||||
with self.assertRaises(SystemExit):
|
|
||||||
self.run_push("--update", "a-thing")
|
|
||||||
self.assertOnDisk("a-thing", "the tracker answered for a different issue")
|
|
||||||
|
|
||||||
def test_the_error_says_the_file_is_untouched(self):
|
|
||||||
self.write_issue("a-thing", "A thing")
|
|
||||||
self.fake.answer_override = {"ok": True}
|
|
||||||
with self.assertRaises(SystemExit):
|
|
||||||
self.run_push()
|
|
||||||
self.assertIn("untouched", self.err.getvalue())
|
|
||||||
|
|
||||||
def test_a_failure_partway_through_keeps_what_has_not_been_sent(self):
|
|
||||||
"""Two issues, the second one fails. The first is legitimately gone —
|
|
||||||
Gitea confirmed it — and the second is still here."""
|
|
||||||
self.write_issue("aaa-thing", "Aaa thing")
|
|
||||||
self.write_issue("zzz-thing", "Zzz thing")
|
|
||||||
|
|
||||||
real_write = self.fake._write
|
|
||||||
seen = []
|
|
||||||
|
|
||||||
def once(do):
|
|
||||||
seen.append(1)
|
|
||||||
if len(seen) > 1:
|
|
||||||
return {"nope": True}
|
|
||||||
return real_write(do)
|
|
||||||
|
|
||||||
self.fake._write = once
|
|
||||||
with self.assertRaises(SystemExit):
|
|
||||||
self.run_push()
|
|
||||||
|
|
||||||
self.assertGone("aaa-thing")
|
|
||||||
self.assertOnDisk("zzz-thing", "its write never succeeded")
|
|
||||||
# And the one that did go up is in the ledger, so it is findable.
|
|
||||||
self.assertEqual(list(self.ledger().values()), ["aaa-thing"])
|
|
||||||
|
|
||||||
|
|
||||||
class ConfirmedNumberTest(unittest.TestCase):
|
|
||||||
"""The gate itself. Everything below it deletes a file."""
|
|
||||||
|
|
||||||
def test_a_plain_create_is_confirmed(self):
|
|
||||||
self.assertEqual(push.confirmed_number({"number": 42}), 42)
|
|
||||||
|
|
||||||
def test_a_matching_patch_is_confirmed(self):
|
|
||||||
self.assertEqual(push.confirmed_number({"number": 42}, 42), 42)
|
|
||||||
|
|
||||||
def test_a_mismatched_patch_is_not(self):
|
|
||||||
self.assertIsNone(push.confirmed_number({"number": 43}, 42))
|
|
||||||
|
|
||||||
def test_none_is_not(self):
|
|
||||||
self.assertIsNone(push.confirmed_number(None))
|
|
||||||
|
|
||||||
def test_a_list_is_not(self):
|
|
||||||
self.assertIsNone(push.confirmed_number([{"number": 42}]))
|
|
||||||
|
|
||||||
def test_a_missing_number_is_not(self):
|
|
||||||
self.assertIsNone(push.confirmed_number({"html_url": "https://x"}))
|
|
||||||
|
|
||||||
def test_a_string_number_is_not(self):
|
|
||||||
self.assertIsNone(push.confirmed_number({"number": "42"}))
|
|
||||||
|
|
||||||
def test_true_is_not_a_number(self):
|
|
||||||
"""`True` is an `int` in Python; `number: true` confirms nothing."""
|
|
||||||
self.assertIsNone(push.confirmed_number({"number": True}))
|
|
||||||
|
|
||||||
def test_zero_and_negatives_are_not(self):
|
|
||||||
self.assertIsNone(push.confirmed_number({"number": 0}))
|
|
||||||
self.assertIsNone(push.confirmed_number({"number": -1}))
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# the id marker
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class IdMarkerTest(unittest.TestCase):
|
|
||||||
"""map.py, pure — no store, no tracker."""
|
|
||||||
|
|
||||||
def test_the_marker_is_the_first_line(self):
|
|
||||||
got = gmap.with_id_marker("## Summary\nтекст", "a-thing")
|
|
||||||
self.assertEqual(got.splitlines()[0], "<!-- tea:id a-thing -->")
|
|
||||||
self.assertEqual(got.splitlines()[1], "")
|
|
||||||
|
|
||||||
def test_strip_is_the_exact_inverse(self):
|
|
||||||
for body in ("## Summary\nтекст", "", "one line",
|
|
||||||
"## Summary\n\n- [ ] пункт\n\n## Spec\nnone"):
|
|
||||||
self.assertEqual(gmap.strip_id_marker(gmap.with_id_marker(body, "x")),
|
|
||||||
body)
|
|
||||||
|
|
||||||
def test_a_body_with_no_marker_comes_back_byte_for_byte(self):
|
|
||||||
body = "## Summary\n\n весь текст \n\n\n"
|
|
||||||
self.assertEqual(gmap.strip_id_marker(body), body)
|
|
||||||
|
|
||||||
def test_marking_twice_still_leaves_one(self):
|
|
||||||
once = gmap.with_id_marker("текст", "a-thing")
|
|
||||||
twice = gmap.with_id_marker(once, "a-thing")
|
|
||||||
self.assertEqual(once, twice)
|
|
||||||
self.assertEqual(twice.count("tea:id"), 1)
|
|
||||||
|
|
||||||
def test_remarking_under_a_new_slug_replaces_rather_than_adds(self):
|
|
||||||
got = gmap.with_id_marker(gmap.with_id_marker("текст", "old"), "new")
|
|
||||||
self.assertEqual(got.count("tea:id"), 1)
|
|
||||||
self.assertEqual(gmap.id_in_body(got), "new")
|
|
||||||
|
|
||||||
def test_every_marker_is_removed_not_just_the_first(self):
|
|
||||||
"""A body hand-edited in the web UI could hold two. It comes back with
|
|
||||||
none, and the next push writes exactly one."""
|
|
||||||
mangled = ("<!-- tea:id one -->\n\nтекст\n\n<!-- tea:id two -->\nещё")
|
|
||||||
self.assertEqual(gmap.strip_id_marker(mangled), "текст\n\nещё")
|
|
||||||
self.assertEqual(gmap.with_id_marker(mangled, "one").count("tea:id"), 1)
|
|
||||||
|
|
||||||
def test_id_in_body_reads_the_first_marker(self):
|
|
||||||
self.assertEqual(gmap.id_in_body("<!-- tea:id one -->\n\nx"), "one")
|
|
||||||
self.assertIsNone(gmap.id_in_body("## Summary\nтекст"))
|
|
||||||
self.assertIsNone(gmap.id_in_body(""))
|
|
||||||
|
|
||||||
def test_a_marker_that_is_not_a_slug_is_ignored(self):
|
|
||||||
"""Better to fall back to the title than to name a file after junk."""
|
|
||||||
for junk in ("Not A Slug", "../etc/passwd", "-leading", "два-слова"):
|
|
||||||
self.assertIsNone(gmap.id_in_body("<!-- tea:id %s -->\n\nx" % junk))
|
|
||||||
|
|
||||||
def test_the_marker_tolerates_spacing(self):
|
|
||||||
self.assertEqual(gmap.id_in_body("<!--tea:id a-thing-->"), "a-thing")
|
|
||||||
self.assertEqual(gmap.id_in_body(" <!-- tea:id a-thing --> "),
|
|
||||||
"a-thing")
|
|
||||||
|
|
||||||
def test_a_marker_inside_prose_is_not_one(self):
|
|
||||||
"""Only a line that is nothing but the marker counts."""
|
|
||||||
self.assertIsNone(gmap.id_in_body("см. <!-- tea:id a-thing --> выше"))
|
|
||||||
|
|
||||||
def test_to_payload_marks_and_from_api_unmarks(self):
|
|
||||||
iss = issue.Issue(id="a-thing", title="A thing", body="## Summary\nтекст")
|
|
||||||
sent = gmap.to_payload(iss)["body"]
|
|
||||||
self.assertTrue(sent.startswith("<!-- tea:id a-thing -->"))
|
|
||||||
back, _ = gmap.from_api({"number": 1, "title": "A thing", "body": sent},
|
|
||||||
"a-thing", REPO)
|
|
||||||
self.assertEqual(back.body, "## Summary\nтекст")
|
|
||||||
|
|
||||||
|
|
||||||
class MarkerStaysOffDiskTest(StoreTestCase):
|
|
||||||
|
|
||||||
def test_the_local_file_never_holds_a_marker(self):
|
|
||||||
self.write_issue("a-thing", "A thing")
|
|
||||||
self.run_push()
|
|
||||||
n = self.number_of("a-thing")
|
|
||||||
self.assertIn("tea:id a-thing", self.fake.body_of(n))
|
|
||||||
|
|
||||||
self.run_pull(str(n))
|
|
||||||
with open(issue.path_of(self.root, "a-thing")) as f:
|
|
||||||
self.assertNotIn("tea:id", f.read())
|
|
||||||
|
|
||||||
def test_repeated_round_trips_do_not_accumulate_markers(self):
|
|
||||||
self.write_issue("a-thing", "A thing")
|
|
||||||
self.run_push()
|
|
||||||
n = self.number_of("a-thing")
|
|
||||||
for _ in range(3):
|
|
||||||
self.run_pull(str(n))
|
|
||||||
self.run_push("--update", "a-thing")
|
|
||||||
self.assertEqual(self.fake.body_of(n).count("tea:id"), 1)
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# the round trip
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class RoundTripTest(StoreTestCase):
|
|
||||||
"""push -> the file is gone -> pull -> the same file is back."""
|
|
||||||
|
|
||||||
def two_issues(self):
|
|
||||||
self.write_issue("first-thing", "First thing")
|
|
||||||
self.write_issue("second-thing", "Second thing", body=BODY_WITH_DEPS,
|
|
||||||
depends=["first-thing"])
|
|
||||||
|
|
||||||
def snapshot(self, id):
|
|
||||||
iss = issue.load(self.root, id)
|
|
||||||
return (iss.id, iss.title, iss.body, sorted(iss.depends),
|
|
||||||
sorted(iss.labels), iss.state)
|
|
||||||
|
|
||||||
def test_the_file_comes_back_identical(self):
|
|
||||||
self.two_issues()
|
|
||||||
before = self.snapshot("second-thing")
|
|
||||||
self.run_push()
|
|
||||||
self.assertGone("second-thing")
|
|
||||||
|
|
||||||
self.run_pull(str(self.number_of("second-thing")), "--deps")
|
|
||||||
self.assertEqual(self.snapshot("second-thing"), before)
|
|
||||||
|
|
||||||
def test_depends_survives_the_round_trip(self):
|
|
||||||
"""The edge lives in Gitea's own graph while the files do not exist —
|
|
||||||
push wrote it, `pull --deps` reads it back, and the ledger turns the
|
|
||||||
number back into the slug it had here."""
|
|
||||||
self.two_issues()
|
|
||||||
self.run_push()
|
|
||||||
self.assertGone("first-thing")
|
|
||||||
self.assertGone("second-thing")
|
|
||||||
|
|
||||||
self.run_pull(str(self.number_of("second-thing")), "--deps")
|
|
||||||
self.assertEqual(issue.load(self.root, "second-thing").depends,
|
|
||||||
["first-thing"])
|
|
||||||
|
|
||||||
def test_the_prose_dependency_is_still_the_authors_words(self):
|
|
||||||
self.two_issues()
|
|
||||||
self.run_push()
|
|
||||||
self.run_pull(str(self.number_of("second-thing")), "--deps")
|
|
||||||
self.assertIn("- first-thing — ставит фундамент",
|
|
||||||
issue.load(self.root, "second-thing").body)
|
|
||||||
|
|
||||||
def test_a_rename_in_gitea_does_not_change_the_slug(self):
|
|
||||||
self.write_issue("a-thing", "A thing")
|
|
||||||
self.run_push()
|
|
||||||
n = self.number_of("a-thing")
|
|
||||||
|
|
||||||
self.fake.rename(n, "Completely different title now")
|
|
||||||
self.run_pull(str(n))
|
|
||||||
|
|
||||||
self.assertOnDisk("a-thing")
|
|
||||||
self.assertFalse(os.path.isfile(
|
|
||||||
issue.path_of(self.root, "completely-different-title-now")))
|
|
||||||
self.assertEqual(issue.load(self.root, "a-thing").title,
|
|
||||||
"Completely different title now")
|
|
||||||
|
|
||||||
def test_the_slug_survives_a_rename_with_the_ledger_thrown_away(self):
|
|
||||||
"""The case `.remote.json` cannot cover: a fresh clone, or another
|
|
||||||
machine. The marker is the only thing left, and it is enough."""
|
|
||||||
self.write_issue("a-thing", "A thing")
|
|
||||||
self.run_push()
|
|
||||||
n = self.number_of("a-thing")
|
|
||||||
|
|
||||||
self.fake.rename(n, "Completely different title now")
|
|
||||||
os.remove(_gitea.map_path(self.root))
|
|
||||||
|
|
||||||
self.run_pull(str(n))
|
|
||||||
self.assertOnDisk("a-thing")
|
|
||||||
self.assertEqual(self.ledger(), {gmap.remote_key(REPO, n): "a-thing"})
|
|
||||||
|
|
||||||
def test_depends_survives_a_lost_ledger_when_both_come_back(self):
|
|
||||||
self.two_issues()
|
|
||||||
self.run_push()
|
|
||||||
first, second = self.number_of("first-thing"), self.number_of("second-thing")
|
|
||||||
os.remove(_gitea.map_path(self.root))
|
|
||||||
|
|
||||||
self.run_pull(str(first), str(second), "--deps")
|
|
||||||
self.assertEqual(issue.load(self.root, "second-thing").depends,
|
|
||||||
["first-thing"])
|
|
||||||
|
|
||||||
def test_an_issue_filed_in_the_web_ui_still_gets_a_slug(self):
|
|
||||||
"""No marker, no ledger entry — the title is the fallback, as before."""
|
|
||||||
self.fake.store(500, "Filed in the web ui", "## Summary\nтекст")
|
|
||||||
self.run_pull("500")
|
|
||||||
self.assertOnDisk("filed-in-the-web-ui")
|
|
||||||
|
|
||||||
def test_a_marker_colliding_with_a_local_issue_does_not_overwrite_it(self):
|
|
||||||
"""A slug is only taken at its word when it is free."""
|
|
||||||
self.write_issue("a-thing", "A thing", body="## Summary\nмоя локальная")
|
|
||||||
self.fake.store(500, "Something else",
|
|
||||||
gmap.with_id_marker("## Summary\nчужая", "a-thing"))
|
|
||||||
self.run_pull("500")
|
|
||||||
|
|
||||||
self.assertIn("моя локальная", issue.load(self.root, "a-thing").body)
|
|
||||||
self.assertIn("чужая", issue.load(self.root, "a-thing-2").body)
|
|
||||||
|
|
||||||
def test_the_branch_ref_comes_back_with_the_issue(self):
|
|
||||||
"""`branch:` is not written back to a file that is being deleted; it
|
|
||||||
goes up in the payload and comes down again on the next pull."""
|
|
||||||
self.write_issue("a-thing", "A thing")
|
|
||||||
self.run_push()
|
|
||||||
n = self.number_of("a-thing")
|
|
||||||
self.run_pull(str(n))
|
|
||||||
self.assertEqual(issue.load(self.root, "a-thing").extra.get("branch"),
|
|
||||||
"test-branch")
|
|
||||||
|
|
||||||
def test_pushing_the_pulled_copy_back_is_a_no_op_on_the_body(self):
|
|
||||||
self.write_issue("a-thing", "A thing")
|
|
||||||
self.run_push()
|
|
||||||
n = self.number_of("a-thing")
|
|
||||||
self.run_pull(str(n))
|
|
||||||
before = self.fake.body_of(n)
|
|
||||||
|
|
||||||
self.run_push("--update", "a-thing")
|
|
||||||
self.assertEqual(self.fake.body_of(n), before)
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# the ledger
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class StoreListingTest(StoreTestCase):
|
|
||||||
"""The store layout the drop depends on."""
|
|
||||||
|
|
||||||
def test_a_comment_thread_is_not_an_issue(self):
|
|
||||||
"""`<id>.comments.md` sits in the store beside the issue. A slug has no
|
|
||||||
dot in it, so it is not a slug and not a unit of work — otherwise a bare
|
|
||||||
`push.py` files the comment thread as an issue of its own."""
|
|
||||||
self.write_issue("a-thing", "A thing")
|
|
||||||
self.write_comments("a-thing")
|
|
||||||
self.assertEqual(issue.all_ids(self.root), ["a-thing"])
|
|
||||||
|
|
||||||
def test_a_bare_push_with_threads_in_the_store_still_works(self):
|
|
||||||
self.write_issue("a-thing", "A thing")
|
|
||||||
self.write_comments("a-thing")
|
|
||||||
self.run_push()
|
|
||||||
self.assertGone("a-thing")
|
|
||||||
|
|
||||||
|
|
||||||
class LedgerTest(StoreTestCase):
|
|
||||||
"""`.remote.json` after the files it used to index are gone."""
|
|
||||||
|
|
||||||
def test_rebuild_keeps_entries_whose_files_no_longer_exist(self):
|
|
||||||
"""It used to reconstruct the map from the files and save the result,
|
|
||||||
which would now silently drop every pushed issue."""
|
|
||||||
_gitea.save_map(self.root, {gmap.remote_key(REPO, 7): "gone-thing"})
|
|
||||||
self.write_issue("here-thing", "Here thing", origin="gitea",
|
|
||||||
extra={"gitea": gmap.remote_key(REPO, 8)})
|
|
||||||
|
|
||||||
got = _gitea.rebuild_map(self.root, issue.load_all(self.root))
|
|
||||||
self.assertEqual(got, {gmap.remote_key(REPO, 7): "gone-thing",
|
|
||||||
gmap.remote_key(REPO, 8): "here-thing"})
|
|
||||||
self.assertEqual(_gitea.load_map(self.root), got)
|
|
||||||
|
|
||||||
def test_a_second_push_reuses_the_ledger_not_the_files(self):
|
|
||||||
"""Two pushes, no pull in between for the blocker: its file is gone, so
|
|
||||||
its number can only come from the ledger — and the link is still made."""
|
|
||||||
self.write_issue("first-thing", "First thing")
|
|
||||||
self.run_push("first-thing")
|
|
||||||
self.assertGone("first-thing")
|
|
||||||
|
|
||||||
self.write_issue("second-thing", "Second thing", body=BODY_WITH_DEPS,
|
|
||||||
depends=["first-thing"])
|
|
||||||
out, err = self.run_push("second-thing")
|
|
||||||
|
|
||||||
first, second = self.number_of("first-thing"), self.number_of("second-thing")
|
|
||||||
self.assertEqual(self.fake.deps.get(second), {(REPO, first)})
|
|
||||||
self.assertIn("depends on %s#%d (first-thing)" % (REPO, first), out)
|
|
||||||
self.assertNotIn("local-only", err)
|
|
||||||
|
|
||||||
def test_the_dry_run_resolves_a_dropped_blocker_from_the_ledger(self):
|
|
||||||
self.write_issue("first-thing", "First thing")
|
|
||||||
self.run_push("first-thing")
|
|
||||||
first = self.number_of("first-thing")
|
|
||||||
|
|
||||||
self.write_issue("second-thing", "Second thing", body=BODY_WITH_DEPS,
|
|
||||||
depends=["first-thing"])
|
|
||||||
out, _ = self.run_push("--dry-run", "second-thing")
|
|
||||||
self.assertIn("link -> %s#%d (first-thing)" % (REPO, first), out)
|
|
||||||
|
|
||||||
def test_ledger_keys_prefers_the_current_repo(self):
|
|
||||||
m = {"other/repo#7": "a-thing", "%s#9" % REPO: "a-thing"}
|
|
||||||
self.assertEqual(push.ledger_keys(m, REPO), {"a-thing": "%s#9" % REPO})
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,570 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Closed issues leave the store, and nothing else does.
|
|
||||||
|
|
||||||
Two halves, and the second one is the one that matters:
|
|
||||||
|
|
||||||
1. **It evicts.** A closed issue whose `origin:` names a tracker is removed from
|
|
||||||
`tmp/issues/` — the issue file and every sidecar under its slug — by one
|
|
||||||
command, and `INDEX.md` is rebuilt so the directory and its table agree.
|
|
||||||
`skills/sync/scripts/evict.py` does the same after refreshing `state:` from
|
|
||||||
Gitea, so an issue closed in the web UI goes without a pull first.
|
|
||||||
|
|
||||||
2. **It evicts nothing else, ever.** `origin: local` is the only copy of the
|
|
||||||
work there is: it stays in every state, including when it is closed and
|
|
||||||
including when it is named on the command line. An open issue stays. A dry
|
|
||||||
run stays. And a tracker call that fails leaves the whole store on disk —
|
|
||||||
every candidate, not just the ones whose answers had not arrived yet.
|
|
||||||
|
|
||||||
A bug in the second half destroys work, so each path is asserted separately and
|
|
||||||
the assertion is always the same — `os.path.isfile`.
|
|
||||||
|
|
||||||
Nothing here touches a network (the sync half stubs `_gitea.api`, and one test
|
|
||||||
stubs `_gitea.subprocess` so a non-zero `tea` is proved end to end) and nothing
|
|
||||||
here touches the developer's store: every test builds its own under
|
|
||||||
`tempfile.TemporaryDirectory()`.
|
|
||||||
"""
|
|
||||||
import contextlib
|
|
||||||
import io
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
import types
|
|
||||||
import unittest
|
|
||||||
from unittest import mock
|
|
||||||
|
|
||||||
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
||||||
for _p in (os.path.join(_ROOT, "skills", "sync", "scripts"),
|
|
||||||
os.path.join(_ROOT, "skills", "issue", "scripts")):
|
|
||||||
if _p not in sys.path:
|
|
||||||
sys.path.insert(0, _p)
|
|
||||||
|
|
||||||
import _gitea # noqa: E402
|
|
||||||
import evict # noqa: E402
|
|
||||||
import issue # noqa: E402
|
|
||||||
import issue_evict # noqa: E402
|
|
||||||
import map as gmap # noqa: E402
|
|
||||||
|
|
||||||
REAL_API = _gitea.api
|
|
||||||
|
|
||||||
REPO = "claude-skills/tea"
|
|
||||||
|
|
||||||
BODY = """## Summary
|
|
||||||
Прозаическое описание задачи.
|
|
||||||
|
|
||||||
## Spec
|
|
||||||
skills/issue/references/format.md
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
- [x] сделано
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class StoreTestCase(unittest.TestCase):
|
|
||||||
"""A temp store, and fixtures for the three kinds of file that live in it."""
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
self.root = tempfile.mkdtemp(prefix="tea-evict-")
|
|
||||||
self.addCleanup(shutil.rmtree, self.root, True)
|
|
||||||
self.numbers = {}
|
|
||||||
|
|
||||||
# -- fixtures ----------------------------------------------------------
|
|
||||||
|
|
||||||
def local(self, id, state="open"):
|
|
||||||
"""An issue that exists nowhere but here."""
|
|
||||||
return self._write(id, state=state, origin=issue.LOCAL)
|
|
||||||
|
|
||||||
def synced(self, id, state="open", number=None):
|
|
||||||
"""A working copy of something the tracker already has."""
|
|
||||||
n = number if number is not None else 100 + len(self.numbers)
|
|
||||||
self.numbers[id] = n
|
|
||||||
return self._write(id, state=state, origin=gmap.ORIGIN,
|
|
||||||
extra={"gitea": gmap.remote_key(REPO, n),
|
|
||||||
"url": "https://git.example/%s/issues/%d" % (REPO, n),
|
|
||||||
"synced": "2026-08-10T00:00:00Z"})
|
|
||||||
|
|
||||||
def _write(self, id, state, origin, extra=None):
|
|
||||||
iss = issue.Issue(id=id, title=id.replace("-", " ").capitalize(),
|
|
||||||
body=BODY, labels=["type/task"], state=state,
|
|
||||||
origin=origin, extra=dict(extra or {}))
|
|
||||||
issue.save(self.root, iss)
|
|
||||||
return iss
|
|
||||||
|
|
||||||
def comments(self, id):
|
|
||||||
p = _gitea.comments_path(self.root, id)
|
|
||||||
with open(p, "w") as f:
|
|
||||||
f.write("## comment 1 — someone — 2026-08-10\n\nтекст\n")
|
|
||||||
return p
|
|
||||||
|
|
||||||
# -- runners -----------------------------------------------------------
|
|
||||||
|
|
||||||
def run_evict(self, *argv):
|
|
||||||
return self._run(issue_evict, "issue_evict.py", argv)
|
|
||||||
|
|
||||||
def run_sync_evict(self, *argv):
|
|
||||||
return self._run(evict, "evict.py", argv)
|
|
||||||
|
|
||||||
def _run(self, mod, name, argv):
|
|
||||||
self.out, self.err = io.StringIO(), io.StringIO()
|
|
||||||
args = [name, "--out", self.root] + list(argv)
|
|
||||||
with mock.patch.object(sys, "argv", args), \
|
|
||||||
contextlib.redirect_stdout(self.out), \
|
|
||||||
contextlib.redirect_stderr(self.err):
|
|
||||||
mod.main()
|
|
||||||
return self.out.getvalue(), self.err.getvalue()
|
|
||||||
|
|
||||||
# -- assertions --------------------------------------------------------
|
|
||||||
|
|
||||||
def assertOnDisk(self, id, why=""):
|
|
||||||
self.assertTrue(os.path.isfile(issue.path_of(self.root, id)),
|
|
||||||
"%s.md was deleted%s" % (id, why and " — " + why))
|
|
||||||
|
|
||||||
def assertGone(self, id):
|
|
||||||
self.assertFalse(os.path.isfile(issue.path_of(self.root, id)),
|
|
||||||
"%s.md is still on disk" % id)
|
|
||||||
|
|
||||||
def index(self):
|
|
||||||
with open(os.path.join(self.root, "INDEX.md")) as f:
|
|
||||||
return f.read()
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# the domain: what belongs to a slug
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class SlugFilesTest(StoreTestCase):
|
|
||||||
"""`issue.slug_files` — how the domain removes an issue completely without
|
|
||||||
knowing what a comment thread is."""
|
|
||||||
|
|
||||||
def test_the_issue_file_comes_first(self):
|
|
||||||
self.synced("a-thing")
|
|
||||||
p = self.comments("a-thing")
|
|
||||||
self.assertEqual(issue.slug_files(self.root, "a-thing"),
|
|
||||||
[issue.path_of(self.root, "a-thing"), p])
|
|
||||||
|
|
||||||
def test_an_issue_with_no_sidecars_is_one_file(self):
|
|
||||||
self.synced("a-thing")
|
|
||||||
self.assertEqual(issue.slug_files(self.root, "a-thing"),
|
|
||||||
[issue.path_of(self.root, "a-thing")])
|
|
||||||
|
|
||||||
def test_a_longer_slug_is_not_a_sidecar(self):
|
|
||||||
"""`a-thing-2` is another issue, not a companion of `a-thing`."""
|
|
||||||
self.synced("a-thing")
|
|
||||||
self.synced("a-thing-2")
|
|
||||||
self.assertEqual(issue.slug_files(self.root, "a-thing"),
|
|
||||||
[issue.path_of(self.root, "a-thing")])
|
|
||||||
|
|
||||||
def test_a_missing_store_is_empty_not_an_error(self):
|
|
||||||
self.assertEqual(issue.slug_files(os.path.join(self.root, "nope"), "x"), [])
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# the domain: it evicts
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class EvictsClosedTest(StoreTestCase):
|
|
||||||
|
|
||||||
def test_a_closed_synced_issue_goes(self):
|
|
||||||
self.synced("old-thing", state="closed")
|
|
||||||
self.run_evict()
|
|
||||||
self.assertGone("old-thing")
|
|
||||||
|
|
||||||
def test_the_comment_thread_goes_with_it(self):
|
|
||||||
self.synced("old-thing", state="closed")
|
|
||||||
p = self.comments("old-thing")
|
|
||||||
self.run_evict()
|
|
||||||
self.assertFalse(os.path.isfile(p), "the thread outlived the issue")
|
|
||||||
|
|
||||||
def test_the_store_of_open_and_closed_keeps_exactly_the_open_and_the_local(self):
|
|
||||||
"""The acceptance criterion, whole: a store of both kinds, one run, and
|
|
||||||
what is left is the open issues and the local ones."""
|
|
||||||
self.synced("open-synced")
|
|
||||||
self.synced("closed-synced", state="closed")
|
|
||||||
self.local("open-local")
|
|
||||||
self.local("closed-local", state="closed")
|
|
||||||
|
|
||||||
self.run_evict()
|
|
||||||
|
|
||||||
self.assertEqual(issue.all_ids(self.root),
|
|
||||||
["closed-local", "open-local", "open-synced"])
|
|
||||||
|
|
||||||
def test_the_output_names_every_file_removed(self):
|
|
||||||
self.synced("old-thing", state="closed")
|
|
||||||
p = self.comments("old-thing")
|
|
||||||
out, _ = self.run_evict()
|
|
||||||
self.assertIn("evicted", out)
|
|
||||||
self.assertIn(issue.path_of(self.root, "old-thing"), out)
|
|
||||||
self.assertIn(p, out)
|
|
||||||
|
|
||||||
def test_the_index_is_rebuilt_to_match_the_directory(self):
|
|
||||||
"""`INDEX.md` and the directory agree afterwards — nothing to fix up."""
|
|
||||||
self.synced("old-thing", state="closed")
|
|
||||||
self.synced("live-thing")
|
|
||||||
self.run_evict()
|
|
||||||
index = self.index()
|
|
||||||
self.assertIn("live-thing", index)
|
|
||||||
self.assertNotIn("old-thing", index)
|
|
||||||
|
|
||||||
def test_only_the_named_issue_is_evicted(self):
|
|
||||||
self.synced("first-old", state="closed")
|
|
||||||
self.synced("second-old", state="closed")
|
|
||||||
self.run_evict("first-old")
|
|
||||||
self.assertGone("first-old")
|
|
||||||
self.assertOnDisk("second-old", "it was not named")
|
|
||||||
|
|
||||||
def test_the_ledger_is_not_pruned(self):
|
|
||||||
"""`.remote.json` is the number -> slug ledger, not an index over the
|
|
||||||
files: an evicted issue is exactly as findable as a pushed one."""
|
|
||||||
self.synced("old-thing", state="closed")
|
|
||||||
key = gmap.remote_key(REPO, self.numbers["old-thing"])
|
|
||||||
_gitea.save_map(self.root, {key: "old-thing"})
|
|
||||||
self.run_evict()
|
|
||||||
self.assertEqual(_gitea.load_map(self.root), {key: "old-thing"})
|
|
||||||
|
|
||||||
|
|
||||||
class ClassifyTest(unittest.TestCase):
|
|
||||||
"""The decision itself, pure. Everything below it deletes a file."""
|
|
||||||
|
|
||||||
def issues(self, **kinds):
|
|
||||||
return {id: issue.Issue(id=id, state=state, origin=origin)
|
|
||||||
for id, (state, origin) in kinds.items()}
|
|
||||||
|
|
||||||
def test_closed_and_synced_is_evicted(self):
|
|
||||||
got = issue_evict.classify(self.issues(a=("closed", "gitea")))
|
|
||||||
self.assertEqual(got, (["a"], [], []))
|
|
||||||
|
|
||||||
def test_closed_and_local_is_protected(self):
|
|
||||||
got = issue_evict.classify(self.issues(a=("closed", issue.LOCAL)))
|
|
||||||
self.assertEqual(got, ([], ["a"], []))
|
|
||||||
|
|
||||||
def test_open_is_left_alone_whatever_its_origin(self):
|
|
||||||
got = issue_evict.classify(self.issues(a=("open", "gitea"),
|
|
||||||
b=("open", issue.LOCAL)))
|
|
||||||
self.assertEqual(got, ([], [], ["a", "b"]))
|
|
||||||
|
|
||||||
def test_naming_a_local_issue_does_not_make_it_evictable(self):
|
|
||||||
got = issue_evict.classify(self.issues(a=("closed", issue.LOCAL)), ["a"])
|
|
||||||
self.assertEqual(got, ([], ["a"], []))
|
|
||||||
|
|
||||||
def test_ids_restrict_the_question(self):
|
|
||||||
got = issue_evict.classify(self.issues(a=("closed", "gitea"),
|
|
||||||
b=("closed", "gitea")), ["b"])
|
|
||||||
self.assertEqual(got, (["b"], [], []))
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# the domain: it evicts nothing else
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class LocalIsNeverEvictedTest(StoreTestCase):
|
|
||||||
"""The criterion that matters most: `origin: local` IS the work."""
|
|
||||||
|
|
||||||
def test_a_closed_local_issue_stays(self):
|
|
||||||
self.local("closed-local", state="closed")
|
|
||||||
self.run_evict()
|
|
||||||
self.assertOnDisk("closed-local", "origin: local is the only copy")
|
|
||||||
|
|
||||||
def test_a_closed_local_issue_named_explicitly_still_stays(self):
|
|
||||||
self.local("closed-local", state="closed")
|
|
||||||
out, _ = self.run_evict("closed-local")
|
|
||||||
self.assertOnDisk("closed-local", "naming it does not make deleting it safe")
|
|
||||||
self.assertIn("kept", out)
|
|
||||||
|
|
||||||
def test_the_receipt_says_why_it_was_kept(self):
|
|
||||||
self.local("closed-local", state="closed")
|
|
||||||
out, _ = self.run_evict()
|
|
||||||
self.assertIn("origin: local", out)
|
|
||||||
self.assertIn("this file IS the issue", out)
|
|
||||||
|
|
||||||
def test_its_sidecars_stay_too(self):
|
|
||||||
self.local("closed-local", state="closed")
|
|
||||||
p = self.comments("closed-local")
|
|
||||||
self.run_evict()
|
|
||||||
self.assertTrue(os.path.isfile(p))
|
|
||||||
|
|
||||||
|
|
||||||
class DryRunTouchesNothingTest(StoreTestCase):
|
|
||||||
|
|
||||||
def test_nothing_is_deleted(self):
|
|
||||||
self.synced("old-thing", state="closed")
|
|
||||||
p = self.comments("old-thing")
|
|
||||||
self.run_evict("--dry-run")
|
|
||||||
self.assertOnDisk("old-thing", "--dry-run must not delete")
|
|
||||||
self.assertTrue(os.path.isfile(p))
|
|
||||||
|
|
||||||
def test_it_prints_what_would_go(self):
|
|
||||||
self.synced("old-thing", state="closed")
|
|
||||||
p = self.comments("old-thing")
|
|
||||||
out, _ = self.run_evict("--dry-run")
|
|
||||||
self.assertIn("would evict", out)
|
|
||||||
self.assertIn(issue.path_of(self.root, "old-thing"), out)
|
|
||||||
self.assertIn(p, out)
|
|
||||||
self.assertIn("nothing was touched", out)
|
|
||||||
|
|
||||||
def test_the_index_is_not_written(self):
|
|
||||||
"""`INDEX.md` is a write like any other — a dry run makes none."""
|
|
||||||
self.synced("old-thing", state="closed")
|
|
||||||
self.run_evict("--dry-run")
|
|
||||||
self.assertFalse(os.path.isfile(os.path.join(self.root, "INDEX.md")))
|
|
||||||
|
|
||||||
|
|
||||||
class NoOpRunsWriteNothingTest(StoreTestCase):
|
|
||||||
|
|
||||||
def test_a_store_with_nothing_to_evict_is_not_rewritten(self):
|
|
||||||
self.synced("live-thing")
|
|
||||||
out, _ = self.run_evict()
|
|
||||||
self.assertIn("0 issue(s) evicted", out)
|
|
||||||
self.assertFalse(os.path.isfile(os.path.join(self.root, "INDEX.md")))
|
|
||||||
|
|
||||||
def test_an_unknown_id_stops_the_run(self):
|
|
||||||
self.synced("old-thing", state="closed")
|
|
||||||
with self.assertRaises(SystemExit):
|
|
||||||
self.run_evict("no-such-thing")
|
|
||||||
self.assertOnDisk("old-thing", "the run stopped before anything went")
|
|
||||||
|
|
||||||
def test_a_missing_store_is_an_error_and_not_a_directory_to_create(self):
|
|
||||||
missing = os.path.join(self.root, "nope")
|
|
||||||
self.out, self.err = io.StringIO(), io.StringIO()
|
|
||||||
argv = ["issue_evict.py", "--out", missing]
|
|
||||||
with mock.patch.object(sys, "argv", argv), \
|
|
||||||
contextlib.redirect_stdout(self.out), \
|
|
||||||
contextlib.redirect_stderr(self.err), \
|
|
||||||
self.assertRaises(SystemExit):
|
|
||||||
issue_evict.main()
|
|
||||||
self.assertFalse(os.path.isdir(missing))
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# the bridge: the state comes from the tracker
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class FakeTracker(object):
|
|
||||||
"""`tea api` answered from memory. GET on an issue, and nothing else."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.calls = []
|
|
||||||
self.states = {} # number -> "open" | "closed"
|
|
||||||
self.answer_override = {} # number -> whatever it should answer instead
|
|
||||||
self.raise_on = None # number -> exception to raise instead
|
|
||||||
|
|
||||||
def api(self, login, endpoint, method="GET", payload=None, payload_name=None,
|
|
||||||
out_root=None, allow_fail=False):
|
|
||||||
self.calls.append((method, endpoint))
|
|
||||||
number = int(endpoint.rstrip("/").rsplit("/", 1)[1])
|
|
||||||
if self.raise_on == number:
|
|
||||||
raise OSError("tea: command not found")
|
|
||||||
if number in self.answer_override:
|
|
||||||
return self.answer_override[number]
|
|
||||||
return {"number": number, "state": self.states.get(number, "open"),
|
|
||||||
"title": "Whatever", "body": "текст"}
|
|
||||||
|
|
||||||
|
|
||||||
class SyncEvictTestCase(StoreTestCase):
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
StoreTestCase.setUp(self)
|
|
||||||
self.fake = FakeTracker()
|
|
||||||
for p in (mock.patch.object(_gitea, "api", self.fake.api),
|
|
||||||
mock.patch.object(_gitea, "require_login", lambda: "test-login")):
|
|
||||||
p.start()
|
|
||||||
self.addCleanup(p.stop)
|
|
||||||
|
|
||||||
def close_in_gitea(self, id):
|
|
||||||
self.fake.states[self.numbers[id]] = "closed"
|
|
||||||
|
|
||||||
def state_on_disk(self, id):
|
|
||||||
return issue.load(self.root, id).state
|
|
||||||
|
|
||||||
|
|
||||||
class TrackerStateWinsTest(SyncEvictTestCase):
|
|
||||||
|
|
||||||
def test_an_issue_closed_upstream_is_evicted_without_a_pull_first(self):
|
|
||||||
"""The observed workflow, in one command: the file still says `open`."""
|
|
||||||
self.synced("old-thing", state="open")
|
|
||||||
self.close_in_gitea("old-thing")
|
|
||||||
self.run_sync_evict()
|
|
||||||
self.assertGone("old-thing")
|
|
||||||
|
|
||||||
def test_an_issue_still_open_upstream_stays(self):
|
|
||||||
self.synced("live-thing", state="open")
|
|
||||||
self.run_sync_evict()
|
|
||||||
self.assertOnDisk("live-thing", "Gitea says it is open")
|
|
||||||
|
|
||||||
def test_a_stale_closed_file_is_corrected_and_kept(self):
|
|
||||||
"""Reopened in the web UI: the local `state:` stops lying, and the file
|
|
||||||
is not evicted on the strength of what it used to say."""
|
|
||||||
self.synced("back-thing", state="closed")
|
|
||||||
self.run_sync_evict()
|
|
||||||
self.assertOnDisk("back-thing", "Gitea says it is open again")
|
|
||||||
self.assertEqual(self.state_on_disk("back-thing"), "open")
|
|
||||||
|
|
||||||
def test_a_local_issue_is_never_asked_about(self):
|
|
||||||
self.local("closed-local", state="closed")
|
|
||||||
out, _ = self.run_sync_evict()
|
|
||||||
self.assertEqual(self.fake.calls, [])
|
|
||||||
self.assertOnDisk("closed-local")
|
|
||||||
|
|
||||||
def test_an_issue_with_no_handle_is_reported_and_kept(self):
|
|
||||||
"""`origin: gitea` and nothing to reach it by: a guess would delete a
|
|
||||||
file nobody can get back."""
|
|
||||||
issue.save(self.root, issue.Issue(id="orphan-thing", title="Orphan thing",
|
|
||||||
body=BODY, labels=["type/task"],
|
|
||||||
state="closed", origin=gmap.ORIGIN))
|
|
||||||
_, err = self.run_sync_evict()
|
|
||||||
self.assertIn("orphan-thing", err)
|
|
||||||
self.assertOnDisk("orphan-thing", "it could not be verified")
|
|
||||||
|
|
||||||
def test_the_index_matches_the_directory_afterwards(self):
|
|
||||||
self.synced("old-thing", state="open")
|
|
||||||
self.synced("live-thing", state="open")
|
|
||||||
self.close_in_gitea("old-thing")
|
|
||||||
self.run_sync_evict()
|
|
||||||
self.assertNotIn("old-thing", self.index())
|
|
||||||
self.assertIn("live-thing", self.index())
|
|
||||||
|
|
||||||
def test_dry_run_asks_but_neither_writes_nor_deletes(self):
|
|
||||||
self.synced("old-thing", state="open")
|
|
||||||
self.close_in_gitea("old-thing")
|
|
||||||
out, _ = self.run_sync_evict("--dry-run")
|
|
||||||
self.assertTrue(self.fake.calls, "it should still have asked")
|
|
||||||
self.assertOnDisk("old-thing", "--dry-run must not delete")
|
|
||||||
self.assertEqual(self.state_on_disk("old-thing"), "open",
|
|
||||||
"--dry-run must not write the refreshed state either")
|
|
||||||
self.assertIn("would evict", out)
|
|
||||||
|
|
||||||
|
|
||||||
class SurvivesEveryTrackerFailureTest(SyncEvictTestCase):
|
|
||||||
"""A failed call evicts nothing — including the candidates whose answers had
|
|
||||||
already arrived."""
|
|
||||||
|
|
||||||
def two_closed(self):
|
|
||||||
self.synced("aaa-thing", state="closed", number=11)
|
|
||||||
self.synced("zzz-thing", state="closed", number=12)
|
|
||||||
self.close_in_gitea("aaa-thing")
|
|
||||||
self.close_in_gitea("zzz-thing")
|
|
||||||
|
|
||||||
def test_a_transport_exception_evicts_nothing(self):
|
|
||||||
self.two_closed()
|
|
||||||
self.fake.raise_on = 12
|
|
||||||
with self.assertRaises(OSError):
|
|
||||||
self.run_sync_evict()
|
|
||||||
self.assertOnDisk("aaa-thing", "its answer arrived, but the run failed")
|
|
||||||
self.assertOnDisk("zzz-thing")
|
|
||||||
|
|
||||||
def test_a_non_2xx_answer_evicts_nothing(self):
|
|
||||||
"""The real `_gitea.api` against a `tea` that exits 1 — the path a 422
|
|
||||||
or a 500 actually takes, and it ends in `die()`."""
|
|
||||||
self.two_closed()
|
|
||||||
|
|
||||||
def fake_run(cmd, capture_output=False, text=False):
|
|
||||||
return types.SimpleNamespace(returncode=1, stdout="",
|
|
||||||
stderr="500 Internal Server Error")
|
|
||||||
|
|
||||||
with mock.patch.object(_gitea, "api", REAL_API), \
|
|
||||||
mock.patch.object(_gitea, "subprocess",
|
|
||||||
types.SimpleNamespace(run=fake_run)), \
|
|
||||||
self.assertRaises(SystemExit):
|
|
||||||
self.run_sync_evict()
|
|
||||||
|
|
||||||
self.assertOnDisk("aaa-thing", "tea exited non-zero")
|
|
||||||
self.assertOnDisk("zzz-thing", "tea exited non-zero")
|
|
||||||
|
|
||||||
def test_an_answer_for_another_issue_evicts_nothing(self):
|
|
||||||
self.two_closed()
|
|
||||||
self.fake.answer_override[12] = {"number": 999, "state": "closed"}
|
|
||||||
with self.assertRaises(SystemExit):
|
|
||||||
self.run_sync_evict()
|
|
||||||
self.assertOnDisk("aaa-thing")
|
|
||||||
self.assertOnDisk("zzz-thing", "the tracker answered for a different issue")
|
|
||||||
|
|
||||||
def test_an_answer_without_a_state_evicts_nothing(self):
|
|
||||||
self.two_closed()
|
|
||||||
self.fake.answer_override[12] = {"number": 12}
|
|
||||||
with self.assertRaises(SystemExit):
|
|
||||||
self.run_sync_evict()
|
|
||||||
self.assertOnDisk("aaa-thing")
|
|
||||||
self.assertOnDisk("zzz-thing")
|
|
||||||
|
|
||||||
def test_an_empty_answer_evicts_nothing(self):
|
|
||||||
"""`tea` exited 0 and printed nothing — api returns None."""
|
|
||||||
self.two_closed()
|
|
||||||
self.fake.answer_override[12] = None
|
|
||||||
with self.assertRaises(SystemExit):
|
|
||||||
self.run_sync_evict()
|
|
||||||
self.assertOnDisk("aaa-thing")
|
|
||||||
self.assertOnDisk("zzz-thing")
|
|
||||||
|
|
||||||
def test_the_error_says_nothing_was_evicted(self):
|
|
||||||
self.two_closed()
|
|
||||||
self.fake.answer_override[12] = {"ok": True}
|
|
||||||
with self.assertRaises(SystemExit):
|
|
||||||
self.run_sync_evict()
|
|
||||||
self.assertIn("Nothing was evicted", self.err.getvalue())
|
|
||||||
|
|
||||||
def test_no_state_is_written_back_before_the_failure_either(self):
|
|
||||||
"""The write-back happens after every answer is in, so a run that dies
|
|
||||||
leaves the files exactly as it found them."""
|
|
||||||
self.synced("aaa-thing", state="closed", number=11)
|
|
||||||
self.synced("zzz-thing", state="closed", number=12)
|
|
||||||
self.fake.states[11] = "open" # would be corrected on a good run
|
|
||||||
self.fake.answer_override[12] = {"nope": True}
|
|
||||||
with self.assertRaises(SystemExit):
|
|
||||||
self.run_sync_evict()
|
|
||||||
self.assertEqual(self.state_on_disk("aaa-thing"), "closed")
|
|
||||||
|
|
||||||
|
|
||||||
class ConfirmedStateTest(unittest.TestCase):
|
|
||||||
"""The gate itself, in the shape of `push.confirmed_number`."""
|
|
||||||
|
|
||||||
def test_a_matching_answer_is_confirmed(self):
|
|
||||||
self.assertEqual(evict.confirmed_state({"number": 42, "state": "closed"}, 42),
|
|
||||||
"closed")
|
|
||||||
self.assertEqual(evict.confirmed_state({"number": 42, "state": "open"}, 42),
|
|
||||||
"open")
|
|
||||||
|
|
||||||
def test_another_issue_is_not(self):
|
|
||||||
self.assertIsNone(evict.confirmed_state({"number": 43, "state": "closed"}, 42))
|
|
||||||
|
|
||||||
def test_none_is_not(self):
|
|
||||||
self.assertIsNone(evict.confirmed_state(None, 42))
|
|
||||||
|
|
||||||
def test_a_list_is_not(self):
|
|
||||||
self.assertIsNone(evict.confirmed_state([{"number": 42, "state": "closed"}], 42))
|
|
||||||
|
|
||||||
def test_a_missing_state_is_not(self):
|
|
||||||
self.assertIsNone(evict.confirmed_state({"number": 42}, 42))
|
|
||||||
|
|
||||||
def test_an_unknown_state_is_not(self):
|
|
||||||
self.assertIsNone(evict.confirmed_state({"number": 42, "state": "merged"}, 42))
|
|
||||||
|
|
||||||
def test_a_string_number_is_not(self):
|
|
||||||
self.assertIsNone(evict.confirmed_state({"number": "42", "state": "closed"}, 42))
|
|
||||||
|
|
||||||
def test_true_is_not_a_number(self):
|
|
||||||
self.assertIsNone(evict.confirmed_state({"number": True, "state": "closed"}, 1))
|
|
||||||
|
|
||||||
|
|
||||||
class CandidatesTest(StoreTestCase):
|
|
||||||
"""Who the tracker is asked about at all."""
|
|
||||||
|
|
||||||
def test_a_synced_issue_is_asked_about_in_its_own_repo(self):
|
|
||||||
self.synced("a-thing", number=7)
|
|
||||||
checkable, unverifiable = evict.candidates(issue.load_all(self.root))
|
|
||||||
self.assertEqual(checkable, [("a-thing", REPO, 7)])
|
|
||||||
self.assertEqual(unverifiable, [])
|
|
||||||
|
|
||||||
def test_a_local_issue_is_in_neither_list(self):
|
|
||||||
self.local("local-thing", state="closed")
|
|
||||||
self.assertEqual(evict.candidates(issue.load_all(self.root)), ([], []))
|
|
||||||
|
|
||||||
def test_a_handle_that_cannot_be_parsed_is_unverifiable(self):
|
|
||||||
issue.save(self.root, issue.Issue(id="bad-thing", origin=gmap.ORIGIN,
|
|
||||||
extra={"gitea": "not-a-key"}))
|
|
||||||
checkable, unverifiable = evict.candidates(issue.load_all(self.root))
|
|
||||||
self.assertEqual(checkable, [])
|
|
||||||
self.assertEqual([id for id, _ in unverifiable], ["bad-thing"])
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,211 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
What the guard guards: `tea` the command, not `tea` the word.
|
|
||||||
|
|
||||||
python3 -m unittest discover -s tests -v
|
|
||||||
|
|
||||||
The bug these tests hold down: the guard asked whether the string contained
|
|
||||||
`tea` surrounded by whitespace, so in a repository *about* the CLI it blocked
|
|
||||||
prose. An issue title, a commit message quoting a raw call, `grep -rn " tea "`
|
|
||||||
and `echo tea` were all refused, with a message telling the operator to add
|
|
||||||
`--login` to `git commit`. The advice could not be followed — the only way
|
|
||||||
past was to reword the sentence.
|
|
||||||
|
|
||||||
Two lines are held at once here, and neither may move without the other: the
|
|
||||||
four false positives pass, and every shape that really runs the CLI — after
|
|
||||||
`&&`, after a pipe, in a subshell, in a substitution, twice in one line — is
|
|
||||||
still blocked or still rewritten. A test that only proved the first would be
|
|
||||||
satisfied by deleting the guard.
|
|
||||||
|
|
||||||
No network and no `tea` binary: the hook is pure decision-making, so the
|
|
||||||
fixture is a directory with a pin in it and a JSON payload on stdin.
|
|
||||||
"""
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
import unittest
|
|
||||||
|
|
||||||
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
||||||
GUARD = os.path.join(REPO, "hooks", "tea-guard.sh")
|
|
||||||
|
|
||||||
sys.path.insert(0, os.path.join(REPO, "skills", "auth", "scripts"))
|
|
||||||
import pin # noqa: E402
|
|
||||||
|
|
||||||
LOGIN = "fixture/user"
|
|
||||||
|
|
||||||
ALLOW, BLOCK, REWRITE = "allow", "block", "rewrite"
|
|
||||||
|
|
||||||
|
|
||||||
class GuardCase(unittest.TestCase):
|
|
||||||
"""One temp project with one pinned login; the hook run as the harness
|
|
||||||
runs it."""
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
self._tmp = tempfile.TemporaryDirectory(prefix="tea-guard-")
|
|
||||||
self.root = os.path.realpath(self._tmp.name)
|
|
||||||
self.addCleanup(self._tmp.cleanup)
|
|
||||||
path = pin.settings_path(self.root)
|
|
||||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
||||||
with open(path, "w") as f:
|
|
||||||
f.write(json.dumps({"env": {pin.ENV_KEY: LOGIN}}))
|
|
||||||
|
|
||||||
def run_guard(self, cmd):
|
|
||||||
env = dict(os.environ)
|
|
||||||
env.pop("PYTHONPATH", None)
|
|
||||||
env[pin.PROJECT_DIR_ENV] = self.root
|
|
||||||
p = subprocess.run([sys.executable, GUARD],
|
|
||||||
input=json.dumps({"tool_input": {"command": cmd},
|
|
||||||
"cwd": self.root}),
|
|
||||||
cwd=self.root, env=env,
|
|
||||||
capture_output=True, text=True)
|
|
||||||
return p
|
|
||||||
|
|
||||||
def verdict(self, cmd):
|
|
||||||
p = self.run_guard(cmd)
|
|
||||||
if p.returncode == 2:
|
|
||||||
return BLOCK, p.stderr
|
|
||||||
self.assertEqual(p.returncode, 0, p.stderr)
|
|
||||||
if not p.stdout.strip():
|
|
||||||
return ALLOW, ""
|
|
||||||
got = json.loads(p.stdout)["hookSpecificOutput"]["updatedInput"]["command"]
|
|
||||||
return REWRITE, got
|
|
||||||
|
|
||||||
def assertVerdict(self, cmd, expected):
|
|
||||||
kind, detail = self.verdict(cmd)
|
|
||||||
self.assertEqual(kind, expected,
|
|
||||||
"%r → %s (%s)" % (cmd, kind, detail.strip()))
|
|
||||||
return detail
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# the four false positives, verbatim from the report
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestProseAboutTheCliRuns(GuardCase):
|
|
||||||
|
|
||||||
def test_an_issue_title_may_name_the_command(self):
|
|
||||||
self.assertVerdict(
|
|
||||||
'python3 skills/issue/scripts/issue_new.py --type bug '
|
|
||||||
'--title "Warn that tea pulls create needs the repo checkout" '
|
|
||||||
'--label comp/use --severity low', ALLOW)
|
|
||||||
|
|
||||||
def test_a_commit_message_may_quote_a_raw_call(self):
|
|
||||||
self.assertVerdict(
|
|
||||||
"git add -A && git commit -F- <<'EOF'\n"
|
|
||||||
"feat: close issues through a script\n"
|
|
||||||
"\n"
|
|
||||||
"Единственным способом сменить state был сырой вызов\n"
|
|
||||||
"tea api -X PATCH ... repos/OWNER/REPO/issues/N\n"
|
|
||||||
"EOF", ALLOW)
|
|
||||||
|
|
||||||
def test_a_one_line_commit_message_may_too(self):
|
|
||||||
self.assertVerdict('git commit -m "route it through tea api"', ALLOW)
|
|
||||||
|
|
||||||
def test_searching_the_repository_for_the_word(self):
|
|
||||||
for cmd in ('grep -rn " tea " docs/',
|
|
||||||
'grep -rn "tea api" skills/',
|
|
||||||
'echo tea'):
|
|
||||||
self.assertVerdict(cmd, ALLOW)
|
|
||||||
|
|
||||||
def test_the_word_as_a_bare_argument_is_still_an_argument(self):
|
|
||||||
"""`echo tea` was the smallest case in the report; these are the same
|
|
||||||
shape with the word in other argument positions."""
|
|
||||||
for cmd in ('ls tea', 'cat notes/tea', 'python3 x.py tea api'):
|
|
||||||
self.assertVerdict(cmd, ALLOW)
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# and the real thing is still guarded
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestRealInvocationsStayGuarded(GuardCase):
|
|
||||||
|
|
||||||
def test_a_bare_call_without_a_login_is_blocked(self):
|
|
||||||
detail = self.assertVerdict("tea issues list", BLOCK)
|
|
||||||
self.assertIn("--login", detail)
|
|
||||||
|
|
||||||
def test_the_placeholder_is_rewritten_to_the_pin(self):
|
|
||||||
got = self.assertVerdict(
|
|
||||||
'tea issues list --login "$GITEA_LOGIN" --state open', REWRITE)
|
|
||||||
self.assertIn(LOGIN, got)
|
|
||||||
self.assertNotIn("GITEA_LOGIN", got)
|
|
||||||
|
|
||||||
def test_a_login_named_by_hand_is_blocked(self):
|
|
||||||
detail = self.assertVerdict("tea issues list --login somebody", BLOCK)
|
|
||||||
self.assertIn("do not name the login", detail)
|
|
||||||
|
|
||||||
def test_another_variable_is_not_the_placeholder(self):
|
|
||||||
self.assertVerdict('tea issues list --login "$OTHER"', BLOCK)
|
|
||||||
|
|
||||||
def test_compound_commands_are_read_segment_by_segment(self):
|
|
||||||
for cmd in ('cd /tmp && tea issues list',
|
|
||||||
'echo x | tea api -X GET repos/x/y',
|
|
||||||
'( tea issues list )',
|
|
||||||
'cd /tmp; tea issues list',
|
|
||||||
'FOO=1 tea issues list',
|
|
||||||
'sudo tea issues list',
|
|
||||||
'xargs tea issues list'):
|
|
||||||
self.assertVerdict(cmd, BLOCK)
|
|
||||||
|
|
||||||
def test_substitutions_are_read_too(self):
|
|
||||||
for cmd in ('echo $(tea whoami)',
|
|
||||||
'x=$(tea whoami)',
|
|
||||||
'echo `tea whoami`'):
|
|
||||||
self.assertVerdict(cmd, BLOCK)
|
|
||||||
|
|
||||||
def test_a_guarded_call_beside_prose_that_mentions_the_word(self):
|
|
||||||
"""The two halves of the bug in one line: the guard must ignore the
|
|
||||||
argument and still catch the call."""
|
|
||||||
self.assertVerdict(
|
|
||||||
'git commit -m "route it through tea api" && tea issues list',
|
|
||||||
BLOCK)
|
|
||||||
|
|
||||||
def test_an_absolute_path_to_the_binary_is_the_binary(self):
|
|
||||||
self.assertVerdict("/usr/local/bin/tea issues list", BLOCK)
|
|
||||||
|
|
||||||
def test_every_call_in_the_line_is_rewritten(self):
|
|
||||||
"""A half-rewritten line leaves the second call with an unset variable
|
|
||||||
and therefore no login at all."""
|
|
||||||
got = self.assertVerdict(
|
|
||||||
'tea issues list --login "$GITEA_LOGIN" && '
|
|
||||||
'tea pulls list --login "$GITEA_LOGIN"', REWRITE)
|
|
||||||
self.assertEqual(got.count(LOGIN), 2)
|
|
||||||
self.assertNotIn("GITEA_LOGIN", got)
|
|
||||||
|
|
||||||
def test_a_second_unguarded_call_is_not_covered_by_the_first(self):
|
|
||||||
self.assertVerdict(
|
|
||||||
'tea issues list --login "$GITEA_LOGIN" && tea pulls list', BLOCK)
|
|
||||||
|
|
||||||
def test_prose_naming_the_whitelisted_form_does_not_launder_a_call(self):
|
|
||||||
"""`tea logins list` is allowed because it uses no identity. Quoting
|
|
||||||
that phrase must not turn the call beside it into a whitelisted one."""
|
|
||||||
self.assertVerdict(
|
|
||||||
'echo "run tea logins list first" && tea issues list', BLOCK)
|
|
||||||
|
|
||||||
|
|
||||||
class TestTheWhitelistStillApplies(GuardCase):
|
|
||||||
|
|
||||||
def test_login_enumeration_needs_no_pin(self):
|
|
||||||
for cmd in ("tea logins list", "tea logins ls",
|
|
||||||
"tea --version", "tea --help"):
|
|
||||||
self.assertVerdict(cmd, ALLOW)
|
|
||||||
|
|
||||||
def test_a_whitelisted_call_next_to_a_guarded_one_does_not_excuse_it(self):
|
|
||||||
self.assertVerdict("tea logins list && tea issues list", BLOCK)
|
|
||||||
|
|
||||||
|
|
||||||
class TestUnparseableLinesFailClosed(GuardCase):
|
|
||||||
"""An unbalanced quote means the shell's reading and ours may differ. The
|
|
||||||
old substring test decides — it over-matches, and over-matching blocks."""
|
|
||||||
|
|
||||||
def test_an_unterminated_quote_around_a_call_still_blocks(self):
|
|
||||||
self.assertVerdict('tea issues list --state "open', BLOCK)
|
|
||||||
|
|
||||||
def test_an_unterminated_quote_with_no_call_is_still_allowed(self):
|
|
||||||
self.assertVerdict('echo "unterminated', ALLOW)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
`issue_init.py` — the statement that makes a directory a project.
|
|
||||||
|
|
||||||
python3 -m unittest discover -s tests -v
|
|
||||||
|
|
||||||
The marker is the anchor every other script resolves from, so the command that
|
|
||||||
creates it carries the whole contract: it is idempotent, it never picks a
|
|
||||||
winner between two versions of one issue, and it migrates the old `tmp/` layout
|
|
||||||
by MOVING — a store left behind at the old path is a store somebody edits by
|
|
||||||
accident.
|
|
||||||
|
|
||||||
Like the rest of the suite, these run the real script against throwaway
|
|
||||||
directories: the script stays where it is installed, the project is somewhere
|
|
||||||
else entirely.
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
import unittest
|
|
||||||
|
|
||||||
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
||||||
ISSUE_SCRIPTS = os.path.join(REPO, "skills", "issue", "scripts")
|
|
||||||
INIT = os.path.join(ISSUE_SCRIPTS, "issue_init.py")
|
|
||||||
|
|
||||||
sys.path.insert(0, ISSUE_SCRIPTS)
|
|
||||||
import issue # noqa: E402
|
|
||||||
|
|
||||||
|
|
||||||
def run(*args, **kw):
|
|
||||||
env = dict(os.environ)
|
|
||||||
env.pop("PYTHONPATH", None)
|
|
||||||
env.pop("CLAUDE_PROJECT_DIR", None)
|
|
||||||
p = subprocess.run([sys.executable, INIT] + list(args),
|
|
||||||
cwd=kw.pop("cwd"), env=env, capture_output=True, text=True)
|
|
||||||
return p.returncode, p.stdout, p.stderr
|
|
||||||
|
|
||||||
|
|
||||||
def write(path, text):
|
|
||||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
||||||
with open(path, "w") as f:
|
|
||||||
f.write(text)
|
|
||||||
|
|
||||||
|
|
||||||
class Dir(object):
|
|
||||||
def __init__(self):
|
|
||||||
self._tmp = tempfile.TemporaryDirectory()
|
|
||||||
self.root = os.path.realpath(self._tmp.name)
|
|
||||||
|
|
||||||
def cleanup(self):
|
|
||||||
self._tmp.cleanup()
|
|
||||||
|
|
||||||
def path(self, *parts):
|
|
||||||
return os.path.join(self.root, *parts)
|
|
||||||
|
|
||||||
|
|
||||||
class TestInit(unittest.TestCase):
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
self.d = Dir()
|
|
||||||
self.addCleanup(self.d.cleanup)
|
|
||||||
|
|
||||||
def test_it_creates_the_marker_and_both_directories(self):
|
|
||||||
rc, out, err = run(cwd=self.d.root)
|
|
||||||
self.assertEqual(rc, 0, err)
|
|
||||||
self.assertTrue(os.path.isdir(self.d.path(issue.MARKER, "issues")))
|
|
||||||
self.assertTrue(os.path.isdir(self.d.path(issue.MARKER, "payload")))
|
|
||||||
self.assertEqual(issue.project_root(self.d.root), self.d.root)
|
|
||||||
|
|
||||||
def test_the_store_resolves_from_a_subdirectory_afterwards(self):
|
|
||||||
run(cwd=self.d.root)
|
|
||||||
deep = self.d.path("a", "b", "c")
|
|
||||||
os.makedirs(deep)
|
|
||||||
self.assertEqual(issue.store_root(deep),
|
|
||||||
self.d.path(*issue.STORE_PARTS))
|
|
||||||
|
|
||||||
def test_running_it_twice_changes_nothing(self):
|
|
||||||
run(cwd=self.d.root)
|
|
||||||
before = sorted(os.walk(self.d.root))
|
|
||||||
rc, out, err = run(cwd=self.d.root)
|
|
||||||
self.assertEqual(rc, 0, err)
|
|
||||||
self.assertIn("already initialized", out)
|
|
||||||
self.assertEqual(sorted(os.walk(self.d.root)), before)
|
|
||||||
|
|
||||||
def test_a_dry_run_touches_nothing(self):
|
|
||||||
rc, out, err = run("--dry-run", cwd=self.d.root)
|
|
||||||
self.assertEqual(rc, 0, err)
|
|
||||||
self.assertIn("would:", out)
|
|
||||||
self.assertFalse(os.path.exists(self.d.path(issue.MARKER)))
|
|
||||||
|
|
||||||
def test_at_initializes_somewhere_else(self):
|
|
||||||
other = Dir()
|
|
||||||
self.addCleanup(other.cleanup)
|
|
||||||
rc, out, err = run("--at", other.root, cwd=self.d.root)
|
|
||||||
self.assertEqual(rc, 0, err)
|
|
||||||
self.assertTrue(os.path.isdir(other.path(issue.MARKER)))
|
|
||||||
self.assertFalse(os.path.exists(self.d.path(issue.MARKER)))
|
|
||||||
|
|
||||||
|
|
||||||
class TestGitignore(unittest.TestCase):
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
self.d = Dir()
|
|
||||||
self.addCleanup(self.d.cleanup)
|
|
||||||
|
|
||||||
def test_the_marker_is_added(self):
|
|
||||||
run(cwd=self.d.root)
|
|
||||||
with open(self.d.path(".gitignore")) as f:
|
|
||||||
self.assertIn(issue.MARKER + "/", f.read().split())
|
|
||||||
|
|
||||||
def test_an_existing_gitignore_keeps_its_contents(self):
|
|
||||||
write(self.d.path(".gitignore"), "node_modules/\n*.log\n")
|
|
||||||
run(cwd=self.d.root)
|
|
||||||
with open(self.d.path(".gitignore")) as f:
|
|
||||||
lines = f.read().split()
|
|
||||||
self.assertIn("node_modules/", lines)
|
|
||||||
self.assertIn("*.log", lines)
|
|
||||||
self.assertIn(issue.MARKER + "/", lines)
|
|
||||||
|
|
||||||
def test_it_is_not_added_twice(self):
|
|
||||||
write(self.d.path(".gitignore"), issue.MARKER + "\n")
|
|
||||||
run(cwd=self.d.root)
|
|
||||||
with open(self.d.path(".gitignore")) as f:
|
|
||||||
body = f.read()
|
|
||||||
self.assertEqual(body.count(issue.MARKER), 1, body)
|
|
||||||
|
|
||||||
|
|
||||||
class TestMigration(unittest.TestCase):
|
|
||||||
"""The old layout moves in. Moves, not copies: two stores is the state this
|
|
||||||
whole change exists to prevent."""
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
self.d = Dir()
|
|
||||||
self.addCleanup(self.d.cleanup)
|
|
||||||
|
|
||||||
def test_an_old_store_is_moved_in(self):
|
|
||||||
write(self.d.path("tmp", "issues", "old-work.md"), "id: old-work\n")
|
|
||||||
write(self.d.path("tmp", "payload", "request.json"), "{}")
|
|
||||||
rc, out, err = run(cwd=self.d.root)
|
|
||||||
self.assertEqual(rc, 0, err)
|
|
||||||
|
|
||||||
self.assertTrue(os.path.isfile(
|
|
||||||
self.d.path(issue.MARKER, "issues", "old-work.md")))
|
|
||||||
self.assertTrue(os.path.isfile(
|
|
||||||
self.d.path(issue.MARKER, "payload", "request.json")))
|
|
||||||
self.assertFalse(os.path.exists(self.d.path("tmp", "issues")),
|
|
||||||
"the old store was left behind for somebody to edit")
|
|
||||||
self.assertIn("moved 1 file(s)", out)
|
|
||||||
|
|
||||||
def test_a_clash_stops_everything_and_moves_nothing(self):
|
|
||||||
write(self.d.path("tmp", "issues", "same.md"), "old version\n")
|
|
||||||
write(self.d.path(issue.MARKER, "issues", "same.md"), "new version\n")
|
|
||||||
rc, out, err = run(cwd=self.d.root)
|
|
||||||
self.assertNotEqual(rc, 0)
|
|
||||||
self.assertIn("same.md", out + err)
|
|
||||||
with open(self.d.path("tmp", "issues", "same.md")) as f:
|
|
||||||
self.assertEqual(f.read(), "old version\n")
|
|
||||||
with open(self.d.path(issue.MARKER, "issues", "same.md")) as f:
|
|
||||||
self.assertEqual(f.read(), "new version\n")
|
|
||||||
|
|
||||||
def test_a_dry_run_reports_the_move_without_making_it(self):
|
|
||||||
write(self.d.path("tmp", "issues", "old-work.md"), "id: old-work\n")
|
|
||||||
rc, out, err = run("--dry-run", cwd=self.d.root)
|
|
||||||
self.assertEqual(rc, 0, err)
|
|
||||||
self.assertIn("moved 1 file(s)", out)
|
|
||||||
self.assertTrue(os.path.isfile(self.d.path("tmp", "issues", "old-work.md")))
|
|
||||||
self.assertFalse(os.path.exists(self.d.path(issue.MARKER)))
|
|
||||||
|
|
||||||
def test_no_old_layout_is_not_an_error(self):
|
|
||||||
rc, out, err = run(cwd=self.d.root)
|
|
||||||
self.assertEqual(rc, 0, err)
|
|
||||||
self.assertNotIn("moved", out)
|
|
||||||
|
|
||||||
|
|
||||||
class TestNesting(unittest.TestCase):
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
self.d = Dir()
|
|
||||||
self.addCleanup(self.d.cleanup)
|
|
||||||
run(cwd=self.d.root)
|
|
||||||
|
|
||||||
def test_initializing_inside_a_project_warns(self):
|
|
||||||
inner = self.d.path("packages", "api")
|
|
||||||
os.makedirs(inner)
|
|
||||||
rc, out, err = run(cwd=inner)
|
|
||||||
self.assertEqual(rc, 0, err)
|
|
||||||
self.assertIn("already sits inside the project", err)
|
|
||||||
self.assertIn(self.d.root, err)
|
|
||||||
|
|
||||||
def test_the_warning_does_not_stop_it(self):
|
|
||||||
"""A monorepo package that genuinely wants its own issues is allowed to
|
|
||||||
say so. The warning is that the nearer marker wins from then on, which
|
|
||||||
is a consequence worth reading, not an error."""
|
|
||||||
inner = self.d.path("packages", "api")
|
|
||||||
os.makedirs(inner)
|
|
||||||
run(cwd=inner)
|
|
||||||
self.assertEqual(issue.project_root(inner), inner)
|
|
||||||
self.assertEqual(issue.project_root(self.d.root), self.d.root)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,444 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Where the login pin is found, and that a git worktree is not a dead zone.
|
|
||||||
|
|
||||||
python3 -m unittest discover -s tests -v
|
|
||||||
|
|
||||||
Stdlib unittest, no third-party anything, and not one real network call: every
|
|
||||||
run here is against a throwaway repository with a FAKE `tea` first on PATH.
|
|
||||||
|
|
||||||
The bug: the pin was searched for by walking up from CWD only. A worktree is a
|
|
||||||
*sibling* of the main checkout, and `.claude/settings.local.json` is untracked,
|
|
||||||
so it lives in the main checkout and nowhere else — the whole sync layer died
|
|
||||||
inside any worktree with "no login pinned", while `tea` in the same directory
|
|
||||||
worked, because the tea-guard hook had a second, different copy of the search.
|
|
||||||
|
|
||||||
So these tests hold two lines at once: the pin is reachable from a worktree,
|
|
||||||
and the hook and the scripts get their answer from the same function.
|
|
||||||
"""
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
import stat
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
import unittest
|
|
||||||
from unittest import mock
|
|
||||||
|
|
||||||
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
||||||
AUTH_SCRIPTS = os.path.join(REPO, "skills", "auth", "scripts")
|
|
||||||
SYNC_SCRIPTS = os.path.join(REPO, "skills", "sync", "scripts")
|
|
||||||
HOOKS = os.path.join(REPO, "hooks")
|
|
||||||
|
|
||||||
sys.path.insert(0, AUTH_SCRIPTS)
|
|
||||||
import pin # noqa: E402
|
|
||||||
|
|
||||||
HAVE_GIT = shutil.which("git") is not None
|
|
||||||
|
|
||||||
LOGIN = "fixture/user"
|
|
||||||
ENV_KEY = pin.ENV_KEY
|
|
||||||
|
|
||||||
# A `tea` that answers without a network: an empty list for every GET, a
|
|
||||||
# created object for every write. It records its own argv, which is how a test
|
|
||||||
# reads back the login the call actually ran under.
|
|
||||||
FAKE_TEA = '''#!%s
|
|
||||||
import json, os, sys
|
|
||||||
argv = sys.argv[1:]
|
|
||||||
with open(os.environ["TEA_CALL_LOG"], "a") as f:
|
|
||||||
f.write("\\t".join(argv) + "\\n")
|
|
||||||
sys.stdout.write(json.dumps({"id": 1, "number": 101, "name": "created",
|
|
||||||
"html_url": "https://example.invalid/issues/101",
|
|
||||||
"labels": []})
|
|
||||||
if "-X" in argv else "[]")
|
|
||||||
'''
|
|
||||||
|
|
||||||
ISSUE = """\
|
|
||||||
---
|
|
||||||
id: pinned-work
|
|
||||||
state: open
|
|
||||||
labels: [type/task]
|
|
||||||
assignees: []
|
|
||||||
milestone: none
|
|
||||||
depends: []
|
|
||||||
origin: local
|
|
||||||
---
|
|
||||||
# Pinned work
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
Issue фикстуры, живёт в сторе worktree.
|
|
||||||
|
|
||||||
## Spec
|
|
||||||
none
|
|
||||||
|
|
||||||
## Motivation
|
|
||||||
Нужен, чтобы push.py было что отправить.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
- [ ] проверяемое условие
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def write(path, text):
|
|
||||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
||||||
with open(path, "w") as f:
|
|
||||||
f.write(text)
|
|
||||||
|
|
||||||
|
|
||||||
class Worktree(object):
|
|
||||||
"""A repository with a pin, and a linked worktree beside it.
|
|
||||||
|
|
||||||
Beside, not below: `main/` and `worktrees/feature/` are siblings, which is
|
|
||||||
the entire shape of the bug. The pin is written after the clone is
|
|
||||||
committed and is covered by .gitignore, so it exists in the main checkout
|
|
||||||
only — exactly as `/tea:auth` leaves it."""
|
|
||||||
|
|
||||||
def __init__(self, pinned=LOGIN):
|
|
||||||
self._tmp = tempfile.TemporaryDirectory(prefix="tea-pin-")
|
|
||||||
# realpath: on macOS $TMPDIR is a symlink, and a child reporting its
|
|
||||||
# own cwd would otherwise disagree with the path we handed it.
|
|
||||||
self.root = os.path.realpath(self._tmp.name)
|
|
||||||
self.main = os.path.join(self.root, "main")
|
|
||||||
self.tree = os.path.join(self.root, "worktrees", "feature")
|
|
||||||
self.calls = os.path.join(self.root, "calls.txt")
|
|
||||||
|
|
||||||
skip = shutil.ignore_patterns("__pycache__")
|
|
||||||
for layer in ("auth", "issue", "sync"):
|
|
||||||
shutil.copytree(os.path.join(REPO, "skills", layer, "scripts"),
|
|
||||||
os.path.join(self.main, "skills", layer, "scripts"),
|
|
||||||
ignore=skip)
|
|
||||||
shutil.copytree(HOOKS, os.path.join(self.main, "hooks"), ignore=skip)
|
|
||||||
write(os.path.join(self.main, ".gitignore"), ".tea/\n.claude/\n")
|
|
||||||
|
|
||||||
# The project marker, in the MAIN checkout only — it is gitignored, so
|
|
||||||
# a linked worktree never has one, exactly like the pin. One project,
|
|
||||||
# one store, reached from the worktree by the same hop.
|
|
||||||
os.makedirs(os.path.join(self.main, ".tea", "issues"))
|
|
||||||
|
|
||||||
self.bin = os.path.join(self.root, "fakebin")
|
|
||||||
os.makedirs(self.bin)
|
|
||||||
tea = os.path.join(self.bin, "tea")
|
|
||||||
write(tea, FAKE_TEA % sys.executable)
|
|
||||||
os.chmod(tea, os.stat(tea).st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
|
||||||
|
|
||||||
self.git("init", cwd=self.main)
|
|
||||||
self.git("add", "-A", cwd=self.main)
|
|
||||||
self.git("commit", "-m", "fixture", cwd=self.main)
|
|
||||||
self.git("worktree", "add", "-b", "feature", self.tree, cwd=self.main)
|
|
||||||
|
|
||||||
if pinned:
|
|
||||||
write(os.path.join(self.main, ".claude", "settings.local.json"),
|
|
||||||
json.dumps({"env": {ENV_KEY: pinned}}))
|
|
||||||
|
|
||||||
def cleanup(self):
|
|
||||||
self._tmp.cleanup()
|
|
||||||
|
|
||||||
def env(self):
|
|
||||||
env = dict(os.environ)
|
|
||||||
env.pop("PYTHONPATH", None) # no leakage from the harness into the child
|
|
||||||
# The start of the search order, cleared: this fixture is about the
|
|
||||||
# steps *after* it, and the developer's own project must not answer.
|
|
||||||
env.pop(pin.PROJECT_DIR_ENV, None)
|
|
||||||
env["PATH"] = self.bin + os.pathsep + env["PATH"]
|
|
||||||
env["TEA_CALL_LOG"] = self.calls
|
|
||||||
env["HOME"] = self.root # keep the developer's git config out
|
|
||||||
env["GIT_CONFIG_NOSYSTEM"] = "1"
|
|
||||||
env["GIT_CONFIG_GLOBAL"] = os.devnull
|
|
||||||
return env
|
|
||||||
|
|
||||||
def git(self, *args, **kw):
|
|
||||||
cmd = ["git", "-c", "user.email=fixture@example.invalid",
|
|
||||||
"-c", "user.name=fixture", "-c", "commit.gpgsign=false"] + list(args)
|
|
||||||
p = subprocess.run(cmd, cwd=kw.pop("cwd", self.tree), env=self.env(),
|
|
||||||
capture_output=True, text=True)
|
|
||||||
if p.returncode != 0:
|
|
||||||
raise AssertionError("%s failed:\n%s%s" % (" ".join(cmd), p.stdout, p.stderr))
|
|
||||||
return p.stdout.strip()
|
|
||||||
|
|
||||||
def script(self, layer, name):
|
|
||||||
"""A script as the WORKTREE sees it — the copy the operator would run."""
|
|
||||||
return os.path.join(self.tree, "skills", layer, "scripts", name)
|
|
||||||
|
|
||||||
def run(self, script, *args, **kw):
|
|
||||||
p = subprocess.run([sys.executable, script] + list(args),
|
|
||||||
cwd=kw.pop("cwd", self.tree), env=self.env(),
|
|
||||||
capture_output=True, text=True)
|
|
||||||
return p.returncode, p.stdout, p.stderr
|
|
||||||
|
|
||||||
def tea_calls(self):
|
|
||||||
if not os.path.isfile(self.calls):
|
|
||||||
return []
|
|
||||||
with open(self.calls) as f:
|
|
||||||
return [line.rstrip("\n").split("\t") for line in f if line.strip()]
|
|
||||||
|
|
||||||
def logins_used(self):
|
|
||||||
return [a[a.index("--login") + 1] for a in self.tea_calls() if "--login" in a]
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# the search itself
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestSearch(unittest.TestCase):
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
self._tmp = tempfile.TemporaryDirectory(prefix="tea-pin-unit-")
|
|
||||||
self.root = os.path.realpath(self._tmp.name)
|
|
||||||
self.addCleanup(self._tmp.cleanup)
|
|
||||||
|
|
||||||
def path(self, *parts):
|
|
||||||
return os.path.join(self.root, *parts)
|
|
||||||
|
|
||||||
def pin_at(self, root, login=LOGIN):
|
|
||||||
write(os.path.join(root, ".claude", "settings.local.json"),
|
|
||||||
json.dumps({"env": {ENV_KEY: login}}))
|
|
||||||
|
|
||||||
def test_the_parent_chain_is_searched(self):
|
|
||||||
self.pin_at(self.root)
|
|
||||||
os.makedirs(self.path("a", "b"))
|
|
||||||
self.assertEqual(pin.search(self.path("a", "b"))[0], LOGIN)
|
|
||||||
|
|
||||||
def test_no_pin_is_no_pin(self):
|
|
||||||
os.makedirs(self.path("a"))
|
|
||||||
self.assertEqual(pin.search(self.path("a")), (None, None))
|
|
||||||
|
|
||||||
def test_an_unreadable_pin_is_not_a_login(self):
|
|
||||||
write(self.path(".claude", "settings.local.json"), "{ not json")
|
|
||||||
self.assertEqual(pin.search(self.root), (None, None))
|
|
||||||
|
|
||||||
def test_an_empty_pin_is_not_a_login(self):
|
|
||||||
write(self.path(".claude", "settings.local.json"),
|
|
||||||
json.dumps({"env": {ENV_KEY: " "}}))
|
|
||||||
self.assertEqual(pin.search(self.root), (None, None))
|
|
||||||
|
|
||||||
def test_a_git_file_pointing_at_a_worktree_reaches_the_main_checkout(self):
|
|
||||||
"""The hop, built by hand from the two files git writes — no git
|
|
||||||
needed to state what the layout means."""
|
|
||||||
main, tree = self.path("main"), self.path("elsewhere", "feature")
|
|
||||||
gitdir = os.path.join(main, ".git", "worktrees", "feature")
|
|
||||||
os.makedirs(gitdir)
|
|
||||||
os.makedirs(tree)
|
|
||||||
write(os.path.join(gitdir, "commondir"), "../..\n")
|
|
||||||
write(os.path.join(tree, ".git"), "gitdir: %s\n" % gitdir)
|
|
||||||
self.pin_at(main)
|
|
||||||
|
|
||||||
self.assertEqual(pin.main_worktree(tree), main)
|
|
||||||
login, src = pin.search(tree)
|
|
||||||
self.assertEqual(login, LOGIN)
|
|
||||||
self.assertEqual(src, pin.settings_path(main))
|
|
||||||
|
|
||||||
def test_an_ordinary_clone_is_not_a_worktree(self):
|
|
||||||
os.makedirs(self.path("clone", ".git"))
|
|
||||||
self.assertIsNone(pin.main_worktree(self.path("clone")))
|
|
||||||
|
|
||||||
def test_a_submodule_pointer_is_not_a_worktree(self):
|
|
||||||
"""`.git` is a file there too, but it points into .git/modules/… and
|
|
||||||
the tree it belongs to is already on the parent chain."""
|
|
||||||
sub = self.path("super", "lib")
|
|
||||||
gitdir = self.path("super", ".git", "modules", "lib")
|
|
||||||
os.makedirs(gitdir)
|
|
||||||
os.makedirs(sub)
|
|
||||||
write(os.path.join(sub, ".git"), "gitdir: %s\n" % gitdir)
|
|
||||||
self.assertIsNone(pin.main_worktree(sub))
|
|
||||||
|
|
||||||
def test_the_chain_wins_over_the_hop(self):
|
|
||||||
"""The worktree branch may only find a pin the walk up would have
|
|
||||||
missed entirely — it never overrides a nearer one."""
|
|
||||||
main, tree = self.path("main"), self.path("elsewhere", "feature")
|
|
||||||
gitdir = os.path.join(main, ".git", "worktrees", "feature")
|
|
||||||
os.makedirs(gitdir)
|
|
||||||
os.makedirs(tree)
|
|
||||||
write(os.path.join(gitdir, "commondir"), "../..\n")
|
|
||||||
write(os.path.join(tree, ".git"), "gitdir: %s\n" % gitdir)
|
|
||||||
self.pin_at(main, "main/login")
|
|
||||||
self.pin_at(tree, "worktree/login")
|
|
||||||
self.assertEqual(pin.search(tree)[0], "worktree/login")
|
|
||||||
|
|
||||||
def test_start_dirs_are_ordered_and_deduplicated(self):
|
|
||||||
with mock.patch.dict(os.environ, {pin.PROJECT_DIR_ENV: self.path("p")}):
|
|
||||||
self.assertEqual(pin.start_dirs(self.path("h")),
|
|
||||||
[self.path("p"), self.path("h"),
|
|
||||||
os.path.abspath(os.getcwd())])
|
|
||||||
with mock.patch.dict(os.environ, {}, clear=True):
|
|
||||||
self.assertEqual(pin.start_dirs(), [os.path.abspath(os.getcwd())])
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# a script run from a worktree
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@unittest.skipUnless(HAVE_GIT, "git is not installed")
|
|
||||||
class TestScriptsInAWorktree(unittest.TestCase):
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
self.wt = Worktree()
|
|
||||||
self.addCleanup(self.wt.cleanup)
|
|
||||||
|
|
||||||
def test_a_sync_script_run_from_the_worktree_finds_the_login(self):
|
|
||||||
"""The acceptance criterion, run for real: cwd inside the worktree,
|
|
||||||
the pin in the main checkout, and the call goes out under it."""
|
|
||||||
rc, out, err = self.wt.run(self.wt.script("sync", "remote.py"),
|
|
||||||
"--repo", "fixture/repo", "--state", "all")
|
|
||||||
self.assertEqual(rc, 0, "remote.py failed:\n%s%s" % (out, err))
|
|
||||||
self.assertNotIn("no login pinned", err)
|
|
||||||
self.assertEqual(self.wt.logins_used(), [LOGIN])
|
|
||||||
|
|
||||||
def test_it_does_not_pin_a_second_login_in_the_worktree(self):
|
|
||||||
"""Nothing here writes a settings file, and the worktree is the last
|
|
||||||
place one should appear: it is deleted with the worktree."""
|
|
||||||
self.wt.run(self.wt.script("sync", "remote.py"), "--repo", "fixture/repo")
|
|
||||||
self.assertFalse(os.path.exists(pin.settings_path(self.wt.tree)),
|
|
||||||
"a second settings.local.json appeared in the worktree")
|
|
||||||
|
|
||||||
def test_with_no_pin_anywhere_it_still_says_so(self):
|
|
||||||
wt = Worktree(pinned=None)
|
|
||||||
self.addCleanup(wt.cleanup)
|
|
||||||
rc, out, err = wt.run(wt.script("sync", "remote.py"), "--repo", "fixture/repo")
|
|
||||||
self.assertNotEqual(rc, 0)
|
|
||||||
self.assertIn("no login pinned", err)
|
|
||||||
self.assertEqual(wt.logins_used(), [])
|
|
||||||
|
|
||||||
def test_the_scripts_own_directory_is_not_a_pin_source(self):
|
|
||||||
"""Run the worktree's script from a directory that is in no pinned
|
|
||||||
tree. The script sits inside a repository that has a pin — and it must
|
|
||||||
still refuse, because the pin belongs to the project being worked on,
|
|
||||||
not to the installation."""
|
|
||||||
outside = os.path.join(self.wt.root, "outside")
|
|
||||||
os.makedirs(outside)
|
|
||||||
rc, out, err = self.wt.run(self.wt.script("sync", "remote.py"),
|
|
||||||
"--repo", "fixture/repo", cwd=outside)
|
|
||||||
self.assertNotEqual(rc, 0)
|
|
||||||
self.assertIn("no login pinned", err)
|
|
||||||
|
|
||||||
def test_the_store_resolves_to_the_main_checkout_from_a_worktree(self):
|
|
||||||
"""The marker is gitignored, so a linked worktree never has one. It is
|
|
||||||
the same project on another branch and it gets the same store — by the
|
|
||||||
same hop the pin takes. Initializing in the worktree instead would give
|
|
||||||
one project two stores, in a directory deleted with the branch."""
|
|
||||||
code = ("import sys; sys.path.insert(0, %r)\n"
|
|
||||||
"import issue\nprint(issue.project_root() or '')\n"
|
|
||||||
"print(issue.store_root() or '')\n"
|
|
||||||
% os.path.join(self.wt.main, "skills", "issue", "scripts"))
|
|
||||||
env = self.wt.env()
|
|
||||||
env.pop("CLAUDE_PROJECT_DIR", None)
|
|
||||||
p = subprocess.run([sys.executable, "-c", code], cwd=self.wt.tree,
|
|
||||||
env=env, capture_output=True, text=True)
|
|
||||||
self.assertEqual(p.returncode, 0, p.stderr)
|
|
||||||
root, store = p.stdout.strip().splitlines()
|
|
||||||
self.assertEqual(root, self.wt.main)
|
|
||||||
self.assertEqual(store, os.path.join(self.wt.main, ".tea", "issues"))
|
|
||||||
|
|
||||||
def test_push_from_a_worktree_sends_the_worktree_branch(self):
|
|
||||||
"""`branch:` -> Gitea `ref`. The workaround this fix removes — run the
|
|
||||||
worktree's scripts with cwd in the main checkout — sent the main
|
|
||||||
checkout's branch, which is the one field `branch:` exists for.
|
|
||||||
|
|
||||||
The store is the main checkout's, reached by the hop; the branch is the
|
|
||||||
worktree's, read from cwd. Two questions, two answers, one command."""
|
|
||||||
write(os.path.join(self.wt.main, ".tea", "issues", "pinned-work.md"), ISSUE)
|
|
||||||
rc, out, err = self.wt.run(self.wt.script("sync", "push.py"),
|
|
||||||
"pinned-work", "--repo", "fixture/repo")
|
|
||||||
self.assertEqual(rc, 0, "push.py failed:\n%s%s" % (out, err))
|
|
||||||
self.assertIn("created pinned-work #101", out)
|
|
||||||
|
|
||||||
with open(os.path.join(self.wt.main, ".tea", "payload",
|
|
||||||
"issue-pinned-work.json")) as f:
|
|
||||||
payload = json.load(f)
|
|
||||||
self.assertEqual(payload.get("ref"), "feature")
|
|
||||||
self.assertEqual(self.wt.git("rev-parse", "--abbrev-ref", "HEAD"), "feature")
|
|
||||||
self.assertNotEqual(
|
|
||||||
self.wt.git("rev-parse", "--abbrev-ref", "HEAD", cwd=self.wt.main),
|
|
||||||
"feature", "the fixture's two trees are on the same branch")
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# one order, one copy of it
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@unittest.skipUnless(HAVE_GIT, "git is not installed")
|
|
||||||
class TestTheHookAndTheScriptsAgree(unittest.TestCase):
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
self.wt = Worktree()
|
|
||||||
self.addCleanup(self.wt.cleanup)
|
|
||||||
|
|
||||||
def guard(self, cwd):
|
|
||||||
"""The hook, as the harness calls it: payload on stdin, decision on
|
|
||||||
stdout."""
|
|
||||||
payload = {"tool_input": {"command": 'tea api --login "$GITEA_LOGIN" repos/x/y'},
|
|
||||||
"cwd": cwd}
|
|
||||||
p = subprocess.run([sys.executable, os.path.join(self.wt.tree, "hooks",
|
|
||||||
"tea-guard.sh")],
|
|
||||||
input=json.dumps(payload), cwd=cwd, env=self.wt.env(),
|
|
||||||
capture_output=True, text=True)
|
|
||||||
return p
|
|
||||||
|
|
||||||
def test_the_hook_resolves_the_pin_from_the_worktree_too(self):
|
|
||||||
p = self.guard(self.wt.tree)
|
|
||||||
self.assertEqual(p.returncode, 0, p.stderr)
|
|
||||||
got = json.loads(p.stdout)["hookSpecificOutput"]["updatedInput"]["command"]
|
|
||||||
self.assertIn(LOGIN, got)
|
|
||||||
self.assertNotIn("GITEA_LOGIN", got)
|
|
||||||
|
|
||||||
def test_the_hook_and_a_script_answer_the_same_directory_alike(self):
|
|
||||||
"""The regression that started this: in one directory the hook
|
|
||||||
resolved the login and every script said there was none."""
|
|
||||||
rc, out, err = self.wt.run(self.wt.script("sync", "remote.py"),
|
|
||||||
"--repo", "fixture/repo")
|
|
||||||
self.assertEqual(rc, 0, err)
|
|
||||||
script_login = self.wt.logins_used()[0]
|
|
||||||
hook_login = json.loads(self.guard(self.wt.tree).stdout)[
|
|
||||||
"hookSpecificOutput"]["updatedInput"]["command"].split("--login ")[1].split()[0]
|
|
||||||
self.assertEqual(hook_login, script_login)
|
|
||||||
|
|
||||||
def test_the_hook_still_blocks_when_nothing_is_pinned(self):
|
|
||||||
wt = Worktree(pinned=None)
|
|
||||||
self.addCleanup(wt.cleanup)
|
|
||||||
payload = {"tool_input": {"command": 'tea api --login "$GITEA_LOGIN" repos/x/y'},
|
|
||||||
"cwd": wt.tree}
|
|
||||||
p = subprocess.run([sys.executable, os.path.join(wt.tree, "hooks", "tea-guard.sh")],
|
|
||||||
input=json.dumps(payload), cwd=wt.tree, env=wt.env(),
|
|
||||||
capture_output=True, text=True)
|
|
||||||
self.assertEqual(p.returncode, 2)
|
|
||||||
self.assertIn("no login is pinned", p.stderr)
|
|
||||||
|
|
||||||
|
|
||||||
class TestNobodyKeepsASecondCopy(unittest.TestCase):
|
|
||||||
"""Mechanical: the search order is written in pin.py, and the two callers
|
|
||||||
spell neither the path nor the walk."""
|
|
||||||
|
|
||||||
CALLERS = (os.path.join(HOOKS, "tea-guard.sh"),
|
|
||||||
os.path.join(SYNC_SCRIPTS, "_gitea.py"))
|
|
||||||
|
|
||||||
def source(self, path):
|
|
||||||
with open(path) as f:
|
|
||||||
return f.read()
|
|
||||||
|
|
||||||
def test_the_path_is_spelled_once(self):
|
|
||||||
self.assertEqual(pin.SETTINGS_PARTS, (".claude", "settings.local.json"))
|
|
||||||
for path in self.CALLERS:
|
|
||||||
body = self.source(path)
|
|
||||||
for literal in ('".claude"', "'.claude'"):
|
|
||||||
self.assertNotIn(literal, body,
|
|
||||||
"%s builds the settings path itself" % path)
|
|
||||||
|
|
||||||
def test_both_callers_go_through_the_module(self):
|
|
||||||
for path in self.CALLERS:
|
|
||||||
self.assertIn("import pin", self.source(path),
|
|
||||||
"%s does not resolve the pin through pin.py" % path)
|
|
||||||
|
|
||||||
def test_the_domain_layer_never_learns_what_a_login_is(self):
|
|
||||||
"""The layer rule, unchanged by this: the identity module is imported
|
|
||||||
by the bridge and by the hook, never by a domain."""
|
|
||||||
for layer in ("issue", "page"):
|
|
||||||
d = os.path.join(REPO, "skills", layer, "scripts")
|
|
||||||
for name in sorted(os.listdir(d)):
|
|
||||||
if not name.endswith(".py"):
|
|
||||||
continue
|
|
||||||
body = self.source(os.path.join(d, name))
|
|
||||||
for banned in ("import pin", "GITEA_LOGIN", "settings.local.json"):
|
|
||||||
self.assertNotIn(banned, body, "%s/%s: %s" % (layer, name, banned))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,283 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Where request bodies land, and that writing one never conjures a store.
|
|
||||||
|
|
||||||
python3 -m unittest discover -s tests -v
|
|
||||||
|
|
||||||
Stdlib unittest, no third-party anything. The bug these tests pin down:
|
|
||||||
`labels.py --bootstrap` on a fresh checkout left `tmp/issues/.payload/` behind,
|
|
||||||
because the only place `_gitea.api` had to put a request file was whatever root
|
|
||||||
the caller handed it — and the label bootstrap, which touches no issue at all,
|
|
||||||
handed it the issue store. A store materialized as a side effect of an
|
|
||||||
operation that has nothing to do with issues.
|
|
||||||
|
|
||||||
Every run here is against a throwaway repository with a FAKE `tea` first on
|
|
||||||
PATH, so nothing reaches the network and the developer's own store is never in
|
|
||||||
the blast radius.
|
|
||||||
"""
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
import stat
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
import unittest
|
|
||||||
|
|
||||||
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
||||||
ISSUE_SCRIPTS = os.path.join(REPO, "skills", "issue", "scripts")
|
|
||||||
SYNC_SCRIPTS = os.path.join(REPO, "skills", "sync", "scripts")
|
|
||||||
AUTH_SCRIPTS = os.path.join(REPO, "skills", "auth", "scripts")
|
|
||||||
|
|
||||||
sys.path.insert(0, SYNC_SCRIPTS)
|
|
||||||
sys.path.insert(0, ISSUE_SCRIPTS)
|
|
||||||
import _gitea # noqa: E402
|
|
||||||
import issue # noqa: E402
|
|
||||||
|
|
||||||
|
|
||||||
# A `tea` that answers without a network: an empty list for every GET (so the
|
|
||||||
# repository looks like it has no labels yet) and a created object for every
|
|
||||||
# write. It also records its own argv, which is how a test can tell that the
|
|
||||||
# payload file the script wrote is the one the call actually referenced.
|
|
||||||
FAKE_TEA = '''#!%s
|
|
||||||
import json, os, sys
|
|
||||||
with open(os.path.join(os.environ["TEA_CALL_LOG"], "calls.txt"), "a") as f:
|
|
||||||
f.write("\\t".join(sys.argv[1:]) + "\\n")
|
|
||||||
sys.stdout.write(json.dumps({"id": 1, "name": "created"})
|
|
||||||
if "-X" in sys.argv else "[]")
|
|
||||||
'''
|
|
||||||
|
|
||||||
|
|
||||||
class FakeRepo(object):
|
|
||||||
"""An initialized project with no store and nothing under `.tea/` yet.
|
|
||||||
|
|
||||||
The scripts are NOT copied in: they stay at their real installed path, so
|
|
||||||
what these tests exercise is a plugin operating on somebody else's project
|
|
||||||
— which is every use of it but this repository's own."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self._tmp = tempfile.TemporaryDirectory()
|
|
||||||
# realpath: on macOS $TMPDIR is a symlink, and a child reporting its
|
|
||||||
# own cwd would otherwise disagree with the path we handed it.
|
|
||||||
self.root = os.path.realpath(self._tmp.name)
|
|
||||||
|
|
||||||
os.makedirs(os.path.join(self.root, issue.MARKER)) # the project marker
|
|
||||||
os.makedirs(self.path("sub", "deeper"))
|
|
||||||
|
|
||||||
# the login pin the transport insists on, local to this fixture
|
|
||||||
os.makedirs(self.path(".claude"))
|
|
||||||
with open(self.path(".claude", "settings.local.json"), "w") as f:
|
|
||||||
json.dump({"env": {"GITEA_LOGIN": "fixture/user"}}, f)
|
|
||||||
|
|
||||||
self.bin = self.path("fakebin")
|
|
||||||
os.makedirs(self.bin)
|
|
||||||
tea = os.path.join(self.bin, "tea")
|
|
||||||
with open(tea, "w") as f:
|
|
||||||
f.write(FAKE_TEA % sys.executable)
|
|
||||||
os.chmod(tea, os.stat(tea).st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
|
||||||
|
|
||||||
def cleanup(self):
|
|
||||||
self._tmp.cleanup()
|
|
||||||
|
|
||||||
def path(self, *parts):
|
|
||||||
return os.path.join(self.root, *parts)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def store(self):
|
|
||||||
return self.path(*issue.STORE_PARTS)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def payloads(self):
|
|
||||||
return self.path(*_gitea.PAYLOAD_PARTS)
|
|
||||||
|
|
||||||
def script(self, layer, name):
|
|
||||||
"""The real script, several directories away from this fixture."""
|
|
||||||
return os.path.join(REPO, "skills", layer, "scripts", name)
|
|
||||||
|
|
||||||
def run(self, script, *args, **kw):
|
|
||||||
env = dict(os.environ)
|
|
||||||
env.pop("PYTHONPATH", None) # no leakage from the harness into the child
|
|
||||||
# the first anchor of both walks: left in place, every fixture would
|
|
||||||
# resolve to this repository instead of itself
|
|
||||||
env.pop("CLAUDE_PROJECT_DIR", None)
|
|
||||||
env["PATH"] = self.bin + os.pathsep + env["PATH"]
|
|
||||||
env["TEA_CALL_LOG"] = self.root
|
|
||||||
p = subprocess.run([sys.executable, script] + list(args),
|
|
||||||
cwd=kw.pop("cwd", self.root), env=env,
|
|
||||||
capture_output=True, text=True)
|
|
||||||
return p.returncode, p.stdout, p.stderr
|
|
||||||
|
|
||||||
def calls(self):
|
|
||||||
p = os.path.join(self.root, "calls.txt")
|
|
||||||
if not os.path.isfile(p):
|
|
||||||
return []
|
|
||||||
with open(p) as f:
|
|
||||||
return [line.rstrip("\n").split("\t") for line in f if line.strip()]
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# resolution
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestPayloadRoot(unittest.TestCase):
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
self.repo = FakeRepo()
|
|
||||||
self.addCleanup(self.repo.cleanup)
|
|
||||||
|
|
||||||
def test_root_is_absolute_and_project_anchored(self):
|
|
||||||
root = _gitea.payload_root(self.repo.path("sub", "deeper"))
|
|
||||||
self.assertTrue(os.path.isabs(root), root)
|
|
||||||
self.assertEqual(root, self.repo.payloads)
|
|
||||||
|
|
||||||
def test_it_is_not_the_issue_store_and_not_inside_one(self):
|
|
||||||
"""The acceptance criterion, as a path fact: a request body is not
|
|
||||||
store content, so it may not live in a store or under one."""
|
|
||||||
start = self.repo.path("sub", "deeper")
|
|
||||||
payload = _gitea.payload_root(start)
|
|
||||||
store = issue.store_root(start)
|
|
||||||
self.assertNotEqual(payload, store)
|
|
||||||
self.assertFalse(payload.startswith(store + os.sep))
|
|
||||||
self.assertFalse(store.startswith(payload + os.sep))
|
|
||||||
|
|
||||||
def test_the_name_says_what_it_holds(self):
|
|
||||||
"""Named so the distinction is visible: `payload`, a sibling of the
|
|
||||||
store under the marker, not a dotdir hiding among an issue's files."""
|
|
||||||
payload = _gitea.payload_root(self.repo.root)
|
|
||||||
self.assertEqual(os.path.basename(payload), "payload")
|
|
||||||
self.assertEqual(os.path.dirname(payload),
|
|
||||||
self.repo.path(issue.MARKER))
|
|
||||||
|
|
||||||
def test_no_project_means_no_payload_root(self):
|
|
||||||
"""Same answer as the store gives: nothing, rather than a directory
|
|
||||||
picked because it was the only one at hand."""
|
|
||||||
plain = tempfile.TemporaryDirectory()
|
|
||||||
self.addCleanup(plain.cleanup)
|
|
||||||
self.assertIsNone(_gitea.payload_root(os.path.realpath(plain.name)))
|
|
||||||
|
|
||||||
def test_gitignore_covers_it(self):
|
|
||||||
"""The rule is that the marker is ignored, not which file says so: this
|
|
||||||
plugin lives under `plugins/` in a marketplace repo, and git reads every
|
|
||||||
.gitignore on the way up. So walk up the same way git does."""
|
|
||||||
ignored = set()
|
|
||||||
d = REPO
|
|
||||||
while True:
|
|
||||||
p = os.path.join(d, ".gitignore")
|
|
||||||
if os.path.isfile(p):
|
|
||||||
with open(p) as f:
|
|
||||||
ignored |= {line.strip() for line in f}
|
|
||||||
parent = os.path.dirname(d)
|
|
||||||
if parent == d or os.path.isdir(os.path.join(d, ".git")):
|
|
||||||
break
|
|
||||||
d = parent
|
|
||||||
self.assertEqual(_gitea.PAYLOAD_PARTS[0], issue.MARKER)
|
|
||||||
self.assertIn(issue.MARKER + "/", ignored,
|
|
||||||
"the payload directory is not covered by .gitignore")
|
|
||||||
|
|
||||||
def test_resolution_follows_the_project_not_the_module(self):
|
|
||||||
"""The bug, as a path fact: the scripts live somewhere else entirely,
|
|
||||||
and the answer is still this project's directory."""
|
|
||||||
self.assertEqual(_gitea.payload_root(self.repo.path("sub", "deeper")),
|
|
||||||
self.repo.payloads)
|
|
||||||
self.assertFalse(self.repo.payloads.startswith(REPO + os.sep))
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# the bug: a label bootstrap that materialized the store
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestLabelsTouchesNoStore(unittest.TestCase):
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
self.repo = FakeRepo()
|
|
||||||
self.addCleanup(self.repo.cleanup)
|
|
||||||
|
|
||||||
def bootstrap(self, *args, **kw):
|
|
||||||
rc, out, err = self.repo.run(self.repo.script("sync", "labels.py"),
|
|
||||||
"--repo", "fixture/repo", *args, **kw)
|
|
||||||
self.assertEqual(rc, 0, "labels.py failed:\n%s%s" % (out, err))
|
|
||||||
return out, err
|
|
||||||
|
|
||||||
def test_bootstrap_creates_no_store(self):
|
|
||||||
"""The reproduction from the report, run for real: no tmp/issues, and
|
|
||||||
no complaint about one either."""
|
|
||||||
out, _ = self.bootstrap()
|
|
||||||
self.assertIn("created", out)
|
|
||||||
self.assertFalse(os.path.exists(self.repo.store),
|
|
||||||
"labels.py created the issue store")
|
|
||||||
|
|
||||||
def test_bootstrap_writes_its_payloads_to_the_payload_root(self):
|
|
||||||
self.bootstrap()
|
|
||||||
self.assertTrue(os.path.isdir(self.repo.payloads),
|
|
||||||
"no payload directory: %s" % self.repo.payloads)
|
|
||||||
written = os.listdir(self.repo.payloads)
|
|
||||||
self.assertIn("label-type-bug.json", written)
|
|
||||||
for name in written:
|
|
||||||
self.assertTrue(name.startswith("label-"), name)
|
|
||||||
|
|
||||||
# and the file named on the command line is the one that was written
|
|
||||||
sent = [a[a.index("-d") + 1][1:] for a in self.repo.calls() if "-d" in a]
|
|
||||||
self.assertTrue(sent)
|
|
||||||
for path in sent:
|
|
||||||
self.assertEqual(os.path.dirname(path), self.repo.payloads)
|
|
||||||
self.assertTrue(os.path.isfile(path), path)
|
|
||||||
|
|
||||||
def test_the_payload_is_the_request_body(self):
|
|
||||||
self.bootstrap()
|
|
||||||
with open(os.path.join(self.repo.payloads, "label-type-bug.json")) as f:
|
|
||||||
body = json.load(f)
|
|
||||||
self.assertEqual(body.get("name"), "type/bug")
|
|
||||||
self.assertTrue(body.get("color"))
|
|
||||||
|
|
||||||
def test_a_dry_run_writes_nothing_at_all(self):
|
|
||||||
out, _ = self.bootstrap("--dry-run")
|
|
||||||
self.assertIn("nothing was written", out)
|
|
||||||
self.assertEqual(os.listdir(self.repo.path(issue.MARKER)), [],
|
|
||||||
"a dry run left something behind under the marker")
|
|
||||||
|
|
||||||
def test_the_directory_does_not_follow_cwd(self):
|
|
||||||
"""Run from a subdirectory: still one payload root, at the project
|
|
||||||
root. A cwd-relative directory is how the store ended up with a second
|
|
||||||
copy of itself, and this one is resolved the same way to avoid the same
|
|
||||||
class of bug."""
|
|
||||||
self.bootstrap(cwd=self.repo.path("sub", "deeper"))
|
|
||||||
self.assertTrue(os.path.isdir(self.repo.payloads))
|
|
||||||
self.assertFalse(os.path.exists(
|
|
||||||
self.repo.path("sub", "deeper", issue.MARKER)))
|
|
||||||
self.assertFalse(os.path.exists(self.repo.store))
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# one place, every caller
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestOnePlaceForEveryCaller(unittest.TestCase):
|
|
||||||
|
|
||||||
def hits(self, needle, skip_transport=False):
|
|
||||||
"""Every `layer/script.py:line` mentioning `needle`."""
|
|
||||||
out = []
|
|
||||||
d = SYNC_SCRIPTS
|
|
||||||
layer = os.path.basename(os.path.dirname(d))
|
|
||||||
for name in sorted(os.listdir(d)):
|
|
||||||
if not name.endswith(".py") or (skip_transport and name == "_gitea.py"):
|
|
||||||
continue
|
|
||||||
with open(os.path.join(d, name)) as f:
|
|
||||||
for n, line in enumerate(f, 1):
|
|
||||||
if needle in line:
|
|
||||||
out.append("%s/%s:%d" % (layer, name, n))
|
|
||||||
return out
|
|
||||||
|
|
||||||
def test_no_caller_chooses_where_its_payload_goes(self):
|
|
||||||
"""Whatever the answer is, it has to be the same for all of them —
|
|
||||||
payload files scattered across the stores of whichever command wrote
|
|
||||||
them is the state this replaced."""
|
|
||||||
self.assertEqual(self.hits("out_root"), [],
|
|
||||||
"a caller still picks a payload directory of its own")
|
|
||||||
|
|
||||||
def test_only_the_transport_names_the_directory(self):
|
|
||||||
self.assertEqual(self.hits("PAYLOAD", skip_transport=True), [],
|
|
||||||
"the payload directory is named outside the transport")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,354 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
A pull returns the unit of work: the issue AND what blocks it.
|
|
||||||
|
|
||||||
`--deps` used to be opt-in, so `pull.py 42` wrote a file with an empty
|
|
||||||
`depends:` and `issue_tree.py` drew it as a root with no blockers. The edge was
|
|
||||||
not lost — it lives in Gitea's native dependency graph — but it was not asked
|
|
||||||
for, and the body cannot supply it: `map.from_api` writes slugs into the
|
|
||||||
`## Depends on` prose and never `#N`. Following the graph is now the default.
|
|
||||||
|
|
||||||
What is asserted here:
|
|
||||||
|
|
||||||
1. **The default fills the graph.** A bare `pull.py <n>` fills `depends:` and
|
|
||||||
pulls the blocker too, down to `--depth`.
|
|
||||||
2. **`--no-deps` is the way out, and it is free.** No `depends:`, no recursion,
|
|
||||||
and not one request beyond the issue itself.
|
|
||||||
3. **`--deps` still works and means nothing.** Calls written against the old
|
|
||||||
default keep running and get what they always got.
|
|
||||||
4. **The cost is one request per stored issue.** The native links are fetched
|
|
||||||
once and used twice — for `depends:` and for the walk. Never twice.
|
|
||||||
5. **Filter mode follows blockers out of the selection, deliberately.** A
|
|
||||||
blocker no filter selected still lands in the store and does not spend
|
|
||||||
`--limit`; a closed one is dropped like any other closed issue, and so is
|
|
||||||
the edge to it. An issue the filter dropped costs no link request at all.
|
|
||||||
|
|
||||||
The transport is stubbed at `_gitea.api`, as the other suites do it, and the
|
|
||||||
stub records every call so "how many requests" is an observation. No network,
|
|
||||||
and no test touches the developer's store: each builds its own in a
|
|
||||||
`tempfile.TemporaryDirectory()`.
|
|
||||||
"""
|
|
||||||
import contextlib
|
|
||||||
import io
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
import unittest
|
|
||||||
import urllib.parse
|
|
||||||
from unittest import mock
|
|
||||||
|
|
||||||
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
||||||
for _p in (os.path.join(_ROOT, "skills", "sync", "scripts"),
|
|
||||||
os.path.join(_ROOT, "skills", "issue", "scripts")):
|
|
||||||
if _p not in sys.path:
|
|
||||||
sys.path.insert(0, _p)
|
|
||||||
|
|
||||||
import _gitea # noqa: E402
|
|
||||||
import issue # noqa: E402
|
|
||||||
import pull # noqa: E402
|
|
||||||
|
|
||||||
REPO = "claude-skills/tea"
|
|
||||||
BASE = "repos/%s" % REPO
|
|
||||||
|
|
||||||
BODY = """## Summary
|
|
||||||
Прозаическое описание задачи.
|
|
||||||
|
|
||||||
## Spec
|
|
||||||
skills/issue/references/format.md
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
- [ ] что-нибудь работает
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def payload(number, title, state="open"):
|
|
||||||
return {"number": number, "title": title, "body": BODY, "state": state,
|
|
||||||
"comments": 0, "labels": [{"name": "type/task"}], "assignees": [],
|
|
||||||
"milestone": None, "ref": "main", "updated_at": "2026-08-10T00:00:00Z",
|
|
||||||
"html_url": "https://git.example/%s/issues/%d" % (REPO, number),
|
|
||||||
"repository": {"full_name": REPO}}
|
|
||||||
|
|
||||||
|
|
||||||
class FakeTracker(object):
|
|
||||||
"""`tea api` answered from memory, with a native dependency graph.
|
|
||||||
|
|
||||||
`listed` is what the list endpoint serves — the filter's selection. `extra`
|
|
||||||
exists and is fetchable by number but is in no selection, which is how a
|
|
||||||
blocker outside the filter is modelled. `deps` maps a blocked issue's number
|
|
||||||
to the numbers that block it, the direction `GET …/dependencies` reads.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, listed=(), extra=(), deps=None):
|
|
||||||
self.listed = list(listed)
|
|
||||||
self.issues = {p["number"]: p for p in list(listed) + list(extra)}
|
|
||||||
self.deps = {int(k): list(v) for k, v in (deps or {}).items()}
|
|
||||||
self.calls = [] # (method, path), in request order
|
|
||||||
|
|
||||||
# -- what the tests read off it ----------------------------------------
|
|
||||||
|
|
||||||
def paths(self, suffix):
|
|
||||||
return [p for m, p in self.calls if p.endswith(suffix)]
|
|
||||||
|
|
||||||
def issue_gets(self):
|
|
||||||
"""`GET …/issues/<n>` — one issue fetched by number."""
|
|
||||||
return [p for m, p in self.calls
|
|
||||||
if m == "GET" and p.startswith("%s/issues/" % BASE)
|
|
||||||
and p.rsplit("/", 1)[1].isdigit()]
|
|
||||||
|
|
||||||
# -- the seam ----------------------------------------------------------
|
|
||||||
|
|
||||||
def api(self, login, endpoint, method="GET", payload=None,
|
|
||||||
payload_name=None, out_root=None, allow_fail=False):
|
|
||||||
path, _, qs = endpoint.partition("?")
|
|
||||||
q = urllib.parse.parse_qs(qs)
|
|
||||||
self.calls.append((method, path))
|
|
||||||
|
|
||||||
if path == "%s/issues" % BASE and method == "GET":
|
|
||||||
page, per = int(q["page"][0]), int(q["limit"][0])
|
|
||||||
return self.listed[(page - 1) * per:(page - 1) * per + per]
|
|
||||||
|
|
||||||
if path.endswith("/comments"):
|
|
||||||
return []
|
|
||||||
|
|
||||||
if path.endswith("/dependencies") and method == "GET":
|
|
||||||
n = int(path.split("/issues/")[1].split("/")[0])
|
|
||||||
return [self.issues[b] for b in self.deps.get(n, []) if b in self.issues]
|
|
||||||
|
|
||||||
if path.startswith("%s/issues/" % BASE) and method == "GET":
|
|
||||||
return self.issues.get(int(path.rsplit("/", 1)[1]))
|
|
||||||
|
|
||||||
raise AssertionError("unstubbed call: %s %s" % (method, endpoint))
|
|
||||||
|
|
||||||
|
|
||||||
class PullDepsTestCase(unittest.TestCase):
|
|
||||||
"""A temp store, a fake tracker, no git and no network."""
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
self.tmp = tempfile.TemporaryDirectory(prefix="tea-deps-")
|
|
||||||
self.addCleanup(self.tmp.cleanup)
|
|
||||||
self.root = os.path.join(self.tmp.name, "tmp", "issues")
|
|
||||||
os.makedirs(self.root)
|
|
||||||
p = mock.patch.object(_gitea, "require_login", lambda: "test-login")
|
|
||||||
p.start()
|
|
||||||
self.addCleanup(p.stop)
|
|
||||||
|
|
||||||
def serve(self, listed=(), extra=(), deps=None):
|
|
||||||
self.fake = FakeTracker(listed, extra, deps)
|
|
||||||
p = mock.patch.object(_gitea, "api", self.fake.api)
|
|
||||||
p.start()
|
|
||||||
self.addCleanup(p.stop)
|
|
||||||
return self.fake
|
|
||||||
|
|
||||||
def blocked_pair(self):
|
|
||||||
"""#10 "Second thing" is blocked by #7 "First thing"."""
|
|
||||||
return self.serve(listed=[payload(10, "Second thing"),
|
|
||||||
payload(7, "First thing")],
|
|
||||||
deps={10: [7]})
|
|
||||||
|
|
||||||
def run_pull(self, *argv):
|
|
||||||
out, err = io.StringIO(), io.StringIO()
|
|
||||||
args = ["pull.py", "--repo", REPO, "--out", self.root] + list(argv)
|
|
||||||
with mock.patch.object(sys, "argv", args), \
|
|
||||||
contextlib.redirect_stdout(out), \
|
|
||||||
contextlib.redirect_stderr(err):
|
|
||||||
pull.main()
|
|
||||||
return out.getvalue(), err.getvalue()
|
|
||||||
|
|
||||||
def stored(self):
|
|
||||||
return sorted(issue.all_ids(self.root))
|
|
||||||
|
|
||||||
def depends_of(self, id):
|
|
||||||
return issue.load(self.root, id).depends
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# 1. the default fills the graph
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class DepsAreTheDefaultTest(PullDepsTestCase):
|
|
||||||
|
|
||||||
def test_a_bare_pull_fills_depends(self):
|
|
||||||
"""The acceptance criterion, and the whole point: no flag, and the file
|
|
||||||
knows what blocks it."""
|
|
||||||
self.blocked_pair()
|
|
||||||
self.run_pull("10")
|
|
||||||
self.assertEqual(self.depends_of("second-thing"), ["first-thing"])
|
|
||||||
|
|
||||||
def test_a_bare_pull_stores_the_blocker(self):
|
|
||||||
"""`depends:` pointing at a file that is not there would be worse than
|
|
||||||
an empty one — the blocker comes with it."""
|
|
||||||
self.blocked_pair()
|
|
||||||
self.run_pull("10")
|
|
||||||
self.assertIn("first-thing", self.stored())
|
|
||||||
|
|
||||||
def test_the_walk_is_recursive(self):
|
|
||||||
"""A blocker's blocker is context too, down to --depth (default 3)."""
|
|
||||||
self.serve(listed=[payload(n, "Thing %d" % n) for n in range(1, 6)],
|
|
||||||
deps={1: [2], 2: [3], 3: [4], 4: [5]})
|
|
||||||
self.run_pull("1")
|
|
||||||
self.assertEqual(self.stored(), ["thing-1", "thing-2", "thing-3", "thing-4"],
|
|
||||||
"the default depth of 3 was not what was walked")
|
|
||||||
|
|
||||||
def test_depth_bounds_the_walk(self):
|
|
||||||
self.serve(listed=[payload(n, "Thing %d" % n) for n in range(1, 6)],
|
|
||||||
deps={1: [2], 2: [3], 3: [4], 4: [5]})
|
|
||||||
self.run_pull("1", "--depth", "1")
|
|
||||||
self.assertEqual(self.stored(), ["thing-1", "thing-2"])
|
|
||||||
|
|
||||||
def test_the_graph_hint_is_printed_when_there_is_a_graph(self):
|
|
||||||
self.blocked_pair()
|
|
||||||
out, _ = self.run_pull("10")
|
|
||||||
self.assertIn("issue_tree.py", out)
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# 2. --no-deps is the way out, and it is free
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class NoDepsOptsOutTest(PullDepsTestCase):
|
|
||||||
|
|
||||||
def test_no_deps_leaves_depends_empty(self):
|
|
||||||
self.blocked_pair()
|
|
||||||
self.run_pull("10", "--no-deps")
|
|
||||||
self.assertEqual(self.depends_of("second-thing"), [])
|
|
||||||
|
|
||||||
def test_no_deps_does_not_pull_the_blocker(self):
|
|
||||||
self.blocked_pair()
|
|
||||||
self.run_pull("10", "--no-deps")
|
|
||||||
self.assertEqual(self.stored(), ["second-thing"])
|
|
||||||
|
|
||||||
def test_no_deps_spends_no_extra_request(self):
|
|
||||||
"""The other half of the criterion: not the links, not the blocker.
|
|
||||||
One issue asked for, one request made."""
|
|
||||||
self.blocked_pair()
|
|
||||||
self.run_pull("10", "--no-deps")
|
|
||||||
self.assertEqual(self.fake.paths("/dependencies"), [])
|
|
||||||
self.assertEqual(self.fake.issue_gets(), ["%s/issues/10" % BASE])
|
|
||||||
|
|
||||||
def test_no_deps_prints_no_graph_hint(self):
|
|
||||||
self.blocked_pair()
|
|
||||||
out, _ = self.run_pull("10", "--no-deps")
|
|
||||||
self.assertNotIn("issue_tree.py", out)
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# 3. --deps is still accepted, and means nothing
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class DepsFlagIsANoOpTest(PullDepsTestCase):
|
|
||||||
|
|
||||||
def test_the_flag_is_still_accepted(self):
|
|
||||||
"""Existing calls and the /tea:sync command tables must not break."""
|
|
||||||
self.blocked_pair()
|
|
||||||
self.run_pull("10", "--deps")
|
|
||||||
self.assertEqual(self.depends_of("second-thing"), ["first-thing"])
|
|
||||||
|
|
||||||
def test_it_changes_nothing_about_the_run(self):
|
|
||||||
self.blocked_pair()
|
|
||||||
self.run_pull("10", "--deps")
|
|
||||||
with_flag = (self.stored(), self.depends_of("second-thing"),
|
|
||||||
list(self.fake.calls))
|
|
||||||
|
|
||||||
self.setUp()
|
|
||||||
self.blocked_pair()
|
|
||||||
self.run_pull("10")
|
|
||||||
self.assertEqual((self.stored(), self.depends_of("second-thing"),
|
|
||||||
list(self.fake.calls)), with_flag)
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# 4. one request per stored issue
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TheCostIsOneRequestPerIssueTest(PullDepsTestCase):
|
|
||||||
|
|
||||||
def test_the_links_are_fetched_once_per_issue(self):
|
|
||||||
"""They fill `depends:` AND steer the walk; fetching them twice is
|
|
||||||
double the price the docstring quotes."""
|
|
||||||
self.blocked_pair()
|
|
||||||
self.run_pull("10")
|
|
||||||
self.assertEqual(self.fake.paths("/dependencies"),
|
|
||||||
["%s/issues/10/dependencies" % BASE,
|
|
||||||
"%s/issues/7/dependencies" % BASE])
|
|
||||||
|
|
||||||
def test_a_bulk_pull_costs_one_per_issue(self):
|
|
||||||
"""The number the docstring quotes: one list request, then one link
|
|
||||||
request per issue that lands in the store."""
|
|
||||||
self.serve(listed=[payload(n, "Thing %d" % n) for n in range(1, 21)])
|
|
||||||
self.run_pull("-q", "x")
|
|
||||||
self.assertEqual(len(self.fake.paths("/dependencies")), 20)
|
|
||||||
self.assertEqual(len(self.fake.paths("/issues")), 1)
|
|
||||||
|
|
||||||
def test_a_cached_issue_costs_its_links_and_nothing_else(self):
|
|
||||||
"""--cached stops the body and the thread, not the graph: a cached
|
|
||||||
issue's blockers can be missing from disk even when it is not."""
|
|
||||||
self.blocked_pair()
|
|
||||||
self.run_pull("10", "--no-deps") # only #10 on disk
|
|
||||||
self.fake.calls = []
|
|
||||||
self.run_pull("10", "--cached")
|
|
||||||
self.assertEqual(self.fake.paths("/dependencies"),
|
|
||||||
["%s/issues/10/dependencies" % BASE,
|
|
||||||
"%s/issues/7/dependencies" % BASE])
|
|
||||||
self.assertIn("first-thing", self.stored())
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# 5. filter mode follows blockers out of the selection
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class FilterModeFollowsOutwardTest(PullDepsTestCase):
|
|
||||||
|
|
||||||
def test_a_blocker_outside_the_filter_lands_in_the_store(self):
|
|
||||||
"""Documented as deliberate: a blocker is followed because a stored
|
|
||||||
issue named it, not because the filter selected it."""
|
|
||||||
self.serve(listed=[payload(1, "Selected thing")],
|
|
||||||
extra=[payload(99, "Outside thing")],
|
|
||||||
deps={1: [99]})
|
|
||||||
self.run_pull("-q", "x")
|
|
||||||
self.assertEqual(self.stored(), ["outside-thing", "selected-thing"])
|
|
||||||
self.assertEqual(self.depends_of("selected-thing"), ["outside-thing"])
|
|
||||||
|
|
||||||
def test_a_blocker_does_not_spend_the_limit(self):
|
|
||||||
"""--limit counts the selection's writes; the graph is not part of the
|
|
||||||
selection, so the store can legitimately hold more than N."""
|
|
||||||
self.serve(listed=[payload(n, "Thing %d" % n) for n in range(1, 5)],
|
|
||||||
extra=[payload(100 + n, "Blocker %d" % n) for n in range(1, 5)],
|
|
||||||
deps={n: [100 + n] for n in range(1, 5)})
|
|
||||||
self.run_pull("-q", "x", "--limit", "2")
|
|
||||||
self.assertEqual(self.stored(),
|
|
||||||
["blocker-1", "blocker-2", "thing-1", "thing-2"])
|
|
||||||
|
|
||||||
def test_a_closed_blocker_is_dropped_with_the_edge_to_it(self):
|
|
||||||
"""The documented exception. Closed is not a unit of work, so filter
|
|
||||||
mode drops it like any other closed issue — and `depends:` must not be
|
|
||||||
left pointing at a file that is not there."""
|
|
||||||
self.serve(listed=[payload(1, "Selected thing")],
|
|
||||||
extra=[payload(99, "Closed blocker", state="closed")],
|
|
||||||
deps={1: [99]})
|
|
||||||
self.run_pull("-q", "x")
|
|
||||||
self.assertEqual(self.stored(), ["selected-thing"])
|
|
||||||
self.assertEqual(self.depends_of("selected-thing"), [])
|
|
||||||
|
|
||||||
def test_a_closed_blocker_is_stored_in_key_mode(self):
|
|
||||||
"""An address is not a bulk read: `pull.py 1` has no closed rule."""
|
|
||||||
self.serve(listed=[payload(1, "Selected thing")],
|
|
||||||
extra=[payload(99, "Closed blocker", state="closed")],
|
|
||||||
deps={1: [99]})
|
|
||||||
self.run_pull("1")
|
|
||||||
self.assertEqual(self.stored(), ["closed-blocker", "selected-thing"])
|
|
||||||
|
|
||||||
def test_a_dropped_closed_issue_costs_no_link_request(self):
|
|
||||||
"""Nothing was stored for it, so there is no unit of work to complete
|
|
||||||
— and its own blockers are not dragged in behind it."""
|
|
||||||
self.serve(listed=[payload(1, "Closed thing", state="closed"),
|
|
||||||
payload(2, "Open thing")],
|
|
||||||
extra=[payload(50, "Blocker of the closed one")],
|
|
||||||
deps={1: [50]})
|
|
||||||
self.run_pull("-q", "x", "--state", "all")
|
|
||||||
self.assertEqual(self.fake.paths("/dependencies"),
|
|
||||||
["%s/issues/2/dependencies" % BASE])
|
|
||||||
self.assertEqual(self.stored(), ["open-thing"])
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,349 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
`pull.py --limit N` bounds the WRITE, not the selection.
|
|
||||||
|
|
||||||
The bug this file exists to keep dead: the limit used to cut the list of
|
|
||||||
payloads before pull.py dropped the closed ones, so a milestone whose first
|
|
||||||
issues are closed spent the budget on issues that never reached disk —
|
|
||||||
`--limit 20` wrote twelve, and the docstring promised twenty.
|
|
||||||
|
|
||||||
What is asserted, in the order the fix has to hold it:
|
|
||||||
|
|
||||||
1. **The count is of files.** N issues under the filter that would be stored →
|
|
||||||
exactly N files, however many closed ones were enumerated on the way.
|
|
||||||
2. **Pagination serves the budget.** More pages are requested while the budget
|
|
||||||
is unfilled, and the page after the one that fills it is never requested.
|
|
||||||
3. **The scan is bounded.** A filter that matches almost only closed issues
|
|
||||||
stops after `_gitea.PAGE_SLACK` times the ideal page count, says so, and
|
|
||||||
returns short — it does not walk the tracker.
|
|
||||||
4. **`remote.py` is unchanged.** Its `--limit` still caps the listing, closed
|
|
||||||
issues included, because it writes nothing there is a limit for.
|
|
||||||
|
|
||||||
The transport is stubbed at `_gitea.api`, the way the other suites do it, and
|
|
||||||
the stub serves `page=` / `limit=` itself so the request pattern is a real
|
|
||||||
observation and not an assumption. No network, and no test writes to the
|
|
||||||
developer's store: each one builds its own in a `tempfile.TemporaryDirectory()`.
|
|
||||||
"""
|
|
||||||
import contextlib
|
|
||||||
import io
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
import unittest
|
|
||||||
import urllib.parse
|
|
||||||
from unittest import mock
|
|
||||||
|
|
||||||
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
||||||
for _p in (os.path.join(_ROOT, "skills", "sync", "scripts"),
|
|
||||||
os.path.join(_ROOT, "skills", "issue", "scripts")):
|
|
||||||
if _p not in sys.path:
|
|
||||||
sys.path.insert(0, _p)
|
|
||||||
|
|
||||||
import _gitea # noqa: E402
|
|
||||||
import issue # noqa: E402
|
|
||||||
import map as gmap # noqa: E402
|
|
||||||
import pull # noqa: E402
|
|
||||||
import remote # noqa: E402
|
|
||||||
|
|
||||||
REPO = "claude-skills/tea"
|
|
||||||
BASE = "repos/%s" % REPO
|
|
||||||
|
|
||||||
BODY = """## Summary
|
|
||||||
Прозаическое описание задачи.
|
|
||||||
|
|
||||||
## Spec
|
|
||||||
skills/issue/references/format.md
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
- [ ] что-нибудь работает
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def payload(number, state="open", title=None, comments=0):
|
|
||||||
return {"number": number, "title": title or "Issue number %d" % number,
|
|
||||||
"body": BODY, "state": state, "comments": comments,
|
|
||||||
"labels": [{"name": "type/task"}], "assignees": [], "milestone": None,
|
|
||||||
"ref": "main", "updated_at": "2026-08-10T00:00:00Z",
|
|
||||||
"html_url": "https://git.example/%s/issues/%d" % (REPO, number),
|
|
||||||
"repository": {"full_name": REPO}}
|
|
||||||
|
|
||||||
|
|
||||||
def alternating(count, first="closed"):
|
|
||||||
"""`count` issues, every other one closed. The shape of the bug report:
|
|
||||||
closed issues sitting in front of the open ones, in page order."""
|
|
||||||
other = "open" if first == "closed" else "closed"
|
|
||||||
return [payload(n, first if n % 2 else other) for n in range(1, count + 1)]
|
|
||||||
|
|
||||||
|
|
||||||
class FakeTracker(object):
|
|
||||||
"""`tea api` answered from a list, with real pagination.
|
|
||||||
|
|
||||||
It slices on the `page=` and `limit=` it was given rather than ignoring
|
|
||||||
them, so "which pages were requested" is something the test can read off
|
|
||||||
`self.list_pages` instead of inferring."""
|
|
||||||
|
|
||||||
def __init__(self, payloads):
|
|
||||||
self.payloads = list(payloads)
|
|
||||||
self.list_pages = [] # (page, per_page), in request order
|
|
||||||
|
|
||||||
def api(self, login, endpoint, method="GET", payload=None,
|
|
||||||
payload_name=None, out_root=None, allow_fail=False):
|
|
||||||
path, _, qs = endpoint.partition("?")
|
|
||||||
q = urllib.parse.parse_qs(qs)
|
|
||||||
|
|
||||||
if path == "%s/issues" % BASE and method == "GET":
|
|
||||||
page, per = int(q["page"][0]), int(q["limit"][0])
|
|
||||||
self.list_pages.append((page, per))
|
|
||||||
return self.payloads[(page - 1) * per:(page - 1) * per + per]
|
|
||||||
|
|
||||||
if path.endswith("/comments"):
|
|
||||||
return []
|
|
||||||
|
|
||||||
if path.endswith("/dependencies"):
|
|
||||||
return []
|
|
||||||
|
|
||||||
if "/issues/" in path and method == "GET":
|
|
||||||
n = int(path.rsplit("/", 1)[1])
|
|
||||||
for p in self.payloads:
|
|
||||||
if p["number"] == n:
|
|
||||||
return p
|
|
||||||
return None
|
|
||||||
|
|
||||||
raise AssertionError("unstubbed call: %s %s" % (method, endpoint))
|
|
||||||
|
|
||||||
|
|
||||||
class PullLimitTestCase(unittest.TestCase):
|
|
||||||
"""A temp store, a fake tracker, no git and no network."""
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
self.tmp = tempfile.TemporaryDirectory(prefix="tea-limit-")
|
|
||||||
self.addCleanup(self.tmp.cleanup)
|
|
||||||
self.root = os.path.join(self.tmp.name, "tmp", "issues")
|
|
||||||
os.makedirs(self.root)
|
|
||||||
p = mock.patch.object(_gitea, "require_login", lambda: "test-login")
|
|
||||||
p.start()
|
|
||||||
self.addCleanup(p.stop)
|
|
||||||
|
|
||||||
# -- runners -----------------------------------------------------------
|
|
||||||
|
|
||||||
def serve(self, payloads):
|
|
||||||
self.fake = FakeTracker(payloads)
|
|
||||||
p = mock.patch.object(_gitea, "api", self.fake.api)
|
|
||||||
p.start()
|
|
||||||
self.addCleanup(p.stop)
|
|
||||||
return self.fake
|
|
||||||
|
|
||||||
def run_pull(self, *argv):
|
|
||||||
return self._run(pull, "pull.py", argv)
|
|
||||||
|
|
||||||
def run_remote(self, *argv):
|
|
||||||
return self._run(remote, "remote.py", argv)
|
|
||||||
|
|
||||||
def _run(self, mod, name, argv):
|
|
||||||
out, err = io.StringIO(), io.StringIO()
|
|
||||||
args = [name, "--repo", REPO, "--out", self.root] + list(argv)
|
|
||||||
with mock.patch.object(sys, "argv", args), \
|
|
||||||
contextlib.redirect_stdout(out), \
|
|
||||||
contextlib.redirect_stderr(err):
|
|
||||||
mod.main()
|
|
||||||
return out.getvalue(), err.getvalue()
|
|
||||||
|
|
||||||
# -- assertions --------------------------------------------------------
|
|
||||||
|
|
||||||
def stored(self):
|
|
||||||
return sorted(issue.all_ids(self.root))
|
|
||||||
|
|
||||||
def assertStoredCount(self, n, why=""):
|
|
||||||
got = self.stored()
|
|
||||||
self.assertEqual(len(got), n, "%d issue(s) in the store, wanted %d%s: %s"
|
|
||||||
% (len(got), n, why and " — " + why, got))
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# 1. the count is of files
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class LimitCountsWritesTest(PullLimitTestCase):
|
|
||||||
|
|
||||||
def test_closed_issues_do_not_spend_the_budget(self):
|
|
||||||
"""The regression. Half the selection is closed and stands in front of
|
|
||||||
the open ones; the limit still buys ten files."""
|
|
||||||
self.serve(alternating(40))
|
|
||||||
self.run_pull("-q", "x", "--state", "all", "--limit", "10")
|
|
||||||
self.assertStoredCount(10)
|
|
||||||
|
|
||||||
def test_only_open_issues_landed(self):
|
|
||||||
self.serve(alternating(40))
|
|
||||||
self.run_pull("-q", "x", "--state", "all", "--limit", "10")
|
|
||||||
for id in self.stored():
|
|
||||||
self.assertEqual(issue.load(self.root, id).state, "open")
|
|
||||||
|
|
||||||
def test_the_dropped_ones_are_still_reported(self):
|
|
||||||
"""Enumerated-and-dropped is not silence: the closed ones seen on the
|
|
||||||
pages that were fetched are counted on stderr."""
|
|
||||||
self.serve(alternating(40))
|
|
||||||
_, err = self.run_pull("-q", "x", "--state", "all", "--limit", "10")
|
|
||||||
self.assertIn("closed issue(s) enumerated, not stored", err)
|
|
||||||
|
|
||||||
def test_a_closed_issue_already_in_the_store_spends_it(self):
|
|
||||||
"""It is refreshed rather than dropped — that is a write, so it counts.
|
|
||||||
The limit is on what the store holds when the run ends, and this issue
|
|
||||||
is in it."""
|
|
||||||
kept = issue.Issue(id="already-here", title="Already here", body=BODY,
|
|
||||||
labels=["type/task"], origin="gitea",
|
|
||||||
extra={"gitea": gmap.remote_key(REPO, 1)})
|
|
||||||
issue.save(self.root, kept)
|
|
||||||
_gitea.save_map(self.root, {gmap.remote_key(REPO, 1): "already-here"})
|
|
||||||
|
|
||||||
self.serve(alternating(40)) # #1 is closed, and is on disk
|
|
||||||
self.run_pull("-q", "x", "--state", "all", "--limit", "10")
|
|
||||||
self.assertStoredCount(10)
|
|
||||||
self.assertEqual(issue.load(self.root, "already-here").state, "closed",
|
|
||||||
"a stored issue must learn it was closed")
|
|
||||||
|
|
||||||
def test_state_closed_writes_closed_ones(self):
|
|
||||||
"""Nothing above may leak into the mode where closed IS the selection."""
|
|
||||||
self.serve([payload(n, "closed") for n in range(1, 21)])
|
|
||||||
self.run_pull("-q", "x", "--state", "closed", "--limit", "6")
|
|
||||||
self.assertStoredCount(6)
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# 2. pagination serves the budget
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class PaginationFollowsTheBudgetTest(PullLimitTestCase):
|
|
||||||
|
|
||||||
def test_more_pages_are_fetched_until_the_budget_is_full(self):
|
|
||||||
"""One page of ten holds five open issues, so ten files cost two."""
|
|
||||||
self.serve(alternating(40))
|
|
||||||
self.run_pull("-q", "x", "--state", "all", "--limit", "10")
|
|
||||||
self.assertStoredCount(10)
|
|
||||||
self.assertEqual([p for p, _ in self.fake.list_pages], [1, 2])
|
|
||||||
|
|
||||||
def test_the_page_after_the_last_needed_one_is_never_requested(self):
|
|
||||||
"""The budget fills inside page 2; page 3 exists and must not be asked
|
|
||||||
for. Bounding the write must not become fetching the whole repo."""
|
|
||||||
self.serve(alternating(200))
|
|
||||||
self.run_pull("-q", "x", "--state", "all", "--limit", "10")
|
|
||||||
self.assertEqual(len(self.fake.list_pages), 2,
|
|
||||||
"extra pages requested: %r" % (self.fake.list_pages,))
|
|
||||||
|
|
||||||
def test_an_unfiltered_selection_still_costs_one_page(self):
|
|
||||||
"""Nothing is dropped, so nothing changes: the old arithmetic holds."""
|
|
||||||
self.serve([payload(n) for n in range(1, 60)])
|
|
||||||
self.run_pull("-q", "x", "--limit", "10")
|
|
||||||
self.assertStoredCount(10)
|
|
||||||
self.assertEqual(len(self.fake.list_pages), 1)
|
|
||||||
|
|
||||||
def test_running_out_of_pages_gives_a_short_answer(self):
|
|
||||||
"""Six issues, three of them open, `--limit 10`: three files, no crash,
|
|
||||||
and no page beyond the last."""
|
|
||||||
self.serve(alternating(6))
|
|
||||||
self.run_pull("-q", "x", "--state", "all", "--limit", "10")
|
|
||||||
self.assertStoredCount(3)
|
|
||||||
self.assertEqual(len(self.fake.list_pages), 1)
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# 3. the scan is bounded
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class ScanIsBoundedTest(PullLimitTestCase):
|
|
||||||
|
|
||||||
def test_a_selection_of_only_closed_issues_stops_at_the_page_budget(self):
|
|
||||||
self.serve([payload(n, "closed") for n in range(1, 501)])
|
|
||||||
_, err = self.run_pull("-q", "x", "--state", "all", "--limit", "10")
|
|
||||||
self.assertStoredCount(0)
|
|
||||||
self.assertEqual(len(self.fake.list_pages), _gitea.PAGE_SLACK,
|
|
||||||
"the scan walked past its budget: %r" % (self.fake.list_pages,))
|
|
||||||
self.assertIn("short of --limit", err)
|
|
||||||
|
|
||||||
def test_a_full_budget_does_not_warn(self):
|
|
||||||
"""The warning means "there may be more"; it must not fire on a run
|
|
||||||
that got everything it asked for."""
|
|
||||||
self.serve(alternating(40))
|
|
||||||
_, err = self.run_pull("-q", "x", "--state", "all", "--limit", "10")
|
|
||||||
self.assertNotIn("short of --limit", err)
|
|
||||||
|
|
||||||
def test_a_selection_that_ran_out_does_not_warn(self):
|
|
||||||
"""Six issues in the repo and the server said so — that is an answer,
|
|
||||||
not a truncation."""
|
|
||||||
self.serve(alternating(6))
|
|
||||||
_, err = self.run_pull("-q", "x", "--state", "all", "--limit", "10")
|
|
||||||
self.assertNotIn("short of --limit", err)
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# 4. remote.py is the deliberate exception
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class RemoteListingIsUnchangedTest(PullLimitTestCase):
|
|
||||||
|
|
||||||
def test_the_listing_limit_still_counts_lines_not_writes(self):
|
|
||||||
"""remote.py writes nothing, so there is no write to bound: ten lines
|
|
||||||
out, closed ones among them, one request."""
|
|
||||||
self.serve(alternating(40))
|
|
||||||
out, _ = self.run_remote("-q", "x", "--state", "all", "--limit", "10")
|
|
||||||
numbered = [l for l in out.splitlines() if l.startswith("#")]
|
|
||||||
self.assertEqual(len(numbered), 10)
|
|
||||||
self.assertTrue(any("closed" in l for l in numbered),
|
|
||||||
"a listing that hides closed issues is not a listing")
|
|
||||||
self.assertEqual(len(self.fake.list_pages), 1)
|
|
||||||
|
|
||||||
def test_it_leaves_the_store_alone(self):
|
|
||||||
self.serve(alternating(40))
|
|
||||||
self.run_remote("-q", "x", "--state", "all", "--limit", "10")
|
|
||||||
self.assertStoredCount(0, "discovery wrote to the store")
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# the transport on its own
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class ListIssuesKeepTest(PullLimitTestCase):
|
|
||||||
"""`_gitea.list_issues` without a caller in front of it — the counting rule
|
|
||||||
is the transport's, and it is testable without a store."""
|
|
||||||
|
|
||||||
def list(self, payloads, **kw):
|
|
||||||
self.serve(payloads)
|
|
||||||
return _gitea.list_issues("test-login", BASE, state="all", **kw)
|
|
||||||
|
|
||||||
def test_without_keep_the_limit_caps_the_selection(self):
|
|
||||||
got, _ = self.list(alternating(40), limit=10)
|
|
||||||
self.assertEqual(len(got), 10)
|
|
||||||
|
|
||||||
def test_with_keep_the_limit_caps_the_kept(self):
|
|
||||||
got, _ = self.list(alternating(40), limit=10,
|
|
||||||
keep=lambda p: p["state"] == "open")
|
|
||||||
self.assertEqual(len([p for p in got if p["state"] == "open"]), 10)
|
|
||||||
|
|
||||||
def test_the_rejected_ones_come_back_too(self):
|
|
||||||
"""They were enumerated. The caller reports them; the transport does
|
|
||||||
not get to throw away what it did not count."""
|
|
||||||
got, _ = self.list(alternating(40), limit=10,
|
|
||||||
keep=lambda p: p["state"] == "open")
|
|
||||||
self.assertTrue([p for p in got if p["state"] == "closed"])
|
|
||||||
|
|
||||||
def test_a_limit_below_one_is_refused(self):
|
|
||||||
"""The page arithmetic divides by the page size, and a limit of zero
|
|
||||||
used to make that a traceback. It is a usage error, so it reads like
|
|
||||||
one."""
|
|
||||||
with self.assertRaises(SystemExit):
|
|
||||||
self.list(alternating(4), limit=0)
|
|
||||||
|
|
||||||
def test_pull_requests_never_count(self):
|
|
||||||
"""`matches` drops them, so they cannot spend the budget either."""
|
|
||||||
mixed = []
|
|
||||||
for n in range(1, 41):
|
|
||||||
p = payload(n)
|
|
||||||
if n % 2:
|
|
||||||
p["pull_request"] = {"merged": False}
|
|
||||||
mixed.append(p)
|
|
||||||
got, _ = self.list(mixed, limit=10, keep=lambda p: True)
|
|
||||||
self.assertEqual(len(got), 10)
|
|
||||||
self.assertFalse([p for p in got if p.get("pull_request")])
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,433 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Native Gitea dependency links, written by push.py.
|
|
||||||
|
|
||||||
The transport is stubbed at exactly one seam — `_gitea.api`, the single
|
|
||||||
function that shells out to `tea` — so everything above it runs for real:
|
|
||||||
argument parsing, validation, topological order, the id map, map.py's payload
|
|
||||||
shapes and _gitea's own endpoint/body construction. Nothing here touches a
|
|
||||||
network, and no test may ever be made to.
|
|
||||||
|
|
||||||
`skills/*/scripts/` are not packages; they go on sys.path by hand.
|
|
||||||
"""
|
|
||||||
import contextlib
|
|
||||||
import io
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
import unittest
|
|
||||||
from unittest import mock
|
|
||||||
|
|
||||||
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
||||||
for _p in (os.path.join(_ROOT, "skills", "sync", "scripts"),
|
|
||||||
os.path.join(_ROOT, "skills", "issue", "scripts")):
|
|
||||||
if _p not in sys.path:
|
|
||||||
sys.path.insert(0, _p)
|
|
||||||
|
|
||||||
import _gitea # noqa: E402
|
|
||||||
import issue # noqa: E402
|
|
||||||
import map as gmap # noqa: E402
|
|
||||||
import push # noqa: E402
|
|
||||||
|
|
||||||
REPO = "claude-skills/tea"
|
|
||||||
BASE = "repos/%s" % REPO
|
|
||||||
LABELS = {"type/task": 901, "type/bug": 902, "severity/medium": 903,
|
|
||||||
"comp/sync": 904}
|
|
||||||
LABEL_NAMES = {v: k for k, v in LABELS.items()}
|
|
||||||
|
|
||||||
BODY = """## Summary
|
|
||||||
Прозаическое описание.
|
|
||||||
|
|
||||||
## Spec
|
|
||||||
skills/issue/references/format.md
|
|
||||||
|
|
||||||
## Depends on
|
|
||||||
- first-thing — ставит фундамент, без него второй не собрать
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
- [ ] что-нибудь работает
|
|
||||||
"""
|
|
||||||
|
|
||||||
BODY_NO_DEPS = """## Summary
|
|
||||||
Прозаическое описание.
|
|
||||||
|
|
||||||
## Spec
|
|
||||||
skills/issue/references/format.md
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
- [ ] что-нибудь работает
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class FakeGitea(object):
|
|
||||||
"""A `tea api` that answers from memory and remembers what it was asked.
|
|
||||||
|
|
||||||
Dependency links are kept the way Gitea keeps them: per blocked issue, a
|
|
||||||
set of (repo, number) blockers. That is what makes the idempotence test
|
|
||||||
meaningful — the second push sees the link the first one made."""
|
|
||||||
|
|
||||||
def __init__(self, next_number=101):
|
|
||||||
self.calls = [] # (method, endpoint, payload)
|
|
||||||
self.next_number = next_number
|
|
||||||
self.deps = {} # number -> {(repo, number)}
|
|
||||||
self.titles = {} # number -> title
|
|
||||||
self.fail_dependency_post = False
|
|
||||||
|
|
||||||
# -- helpers -----------------------------------------------------------
|
|
||||||
|
|
||||||
@property
|
|
||||||
def writes(self):
|
|
||||||
"""Every non-GET call. `--dry-run` must produce an empty list."""
|
|
||||||
return [c for c in self.calls if c[0] != "GET"]
|
|
||||||
|
|
||||||
def dep_posts(self):
|
|
||||||
return [c for c in self.calls
|
|
||||||
if c[0] == "POST" and c[1].endswith("/dependencies")]
|
|
||||||
|
|
||||||
def issue_payload(self, number, labels=()):
|
|
||||||
return {"number": number,
|
|
||||||
"html_url": "https://git.example/%s/issues/%d" % (REPO, number),
|
|
||||||
"title": self.titles.get(number, ""),
|
|
||||||
"labels": [{"name": LABEL_NAMES[i]} for i in labels
|
|
||||||
if i in LABEL_NAMES],
|
|
||||||
"updated_at": "2026-08-10T00:00:00Z",
|
|
||||||
"repository": {"full_name": REPO}}
|
|
||||||
|
|
||||||
# -- the seam ----------------------------------------------------------
|
|
||||||
|
|
||||||
def api(self, login, endpoint, method="GET", payload=None,
|
|
||||||
payload_name=None, allow_fail=False):
|
|
||||||
self.calls.append((method, endpoint, payload))
|
|
||||||
path = endpoint.split("?")[0]
|
|
||||||
|
|
||||||
if path == "%s/labels" % BASE and method == "GET":
|
|
||||||
# Every label the run could ask for, so nothing is ever created.
|
|
||||||
return [{"name": n, "id": i} for n, i in LABELS.items()]
|
|
||||||
|
|
||||||
if path == "%s/issues" % BASE and method == "POST":
|
|
||||||
number = self.next_number
|
|
||||||
self.next_number += 1
|
|
||||||
self.titles[number] = (payload or {}).get("title", "")
|
|
||||||
# Echo the labels back, or push re-applies them with a PUT.
|
|
||||||
return self.issue_payload(number, (payload or {}).get("labels") or [])
|
|
||||||
|
|
||||||
if path.endswith("/dependencies"):
|
|
||||||
number = int(path.split("/issues/")[1].split("/")[0])
|
|
||||||
if method == "GET":
|
|
||||||
return [dict(self.issue_payload(n), repository={"full_name": r})
|
|
||||||
for r, n in sorted(self.deps.get(number, set()))]
|
|
||||||
if method == "POST":
|
|
||||||
if self.fail_dependency_post:
|
|
||||||
return None
|
|
||||||
key = ("%s/%s" % (payload["owner"], payload["repo"]),
|
|
||||||
int(payload["index"]))
|
|
||||||
self.deps.setdefault(number, set()).add(key)
|
|
||||||
return self.issue_payload(number)
|
|
||||||
|
|
||||||
if "/issues/" in path and method == "PATCH":
|
|
||||||
number = int(path.rsplit("/", 1)[1])
|
|
||||||
self.titles[number] = (payload or {}).get("title", self.titles.get(number, ""))
|
|
||||||
return self.issue_payload(number, (payload or {}).get("labels") or [])
|
|
||||||
|
|
||||||
raise AssertionError("unstubbed call: %s %s" % (method, endpoint))
|
|
||||||
|
|
||||||
|
|
||||||
class PushTestCase(unittest.TestCase):
|
|
||||||
"""A temp store, a fake transport, and no git."""
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
self.root = tempfile.mkdtemp(prefix="tea-store-")
|
|
||||||
self.fake = FakeGitea()
|
|
||||||
patches = [
|
|
||||||
mock.patch.object(_gitea, "api", self.fake.api),
|
|
||||||
mock.patch.object(_gitea, "require_login", lambda: "test-login"),
|
|
||||||
# push reads the current branch from git; a temp store has none and
|
|
||||||
# the runner's branch would leak into the payload.
|
|
||||||
mock.patch.object(push, "git_branch", lambda: "test-branch"),
|
|
||||||
]
|
|
||||||
for p in patches:
|
|
||||||
p.start()
|
|
||||||
self.addCleanup(p.stop)
|
|
||||||
self.addCleanup(shutil.rmtree, self.root, True)
|
|
||||||
|
|
||||||
# -- fixtures ----------------------------------------------------------
|
|
||||||
|
|
||||||
def write_issue(self, id, title, body=BODY_NO_DEPS, depends=(), extra=None,
|
|
||||||
origin=issue.LOCAL):
|
|
||||||
iss = issue.Issue(id=id, title=title, body=body, labels=["type/task"],
|
|
||||||
depends=list(depends), origin=origin,
|
|
||||||
extra=dict(extra or {}))
|
|
||||||
issue.save(self.root, iss)
|
|
||||||
return iss
|
|
||||||
|
|
||||||
def repull(self, id, body=BODY_NO_DEPS, depends=()):
|
|
||||||
"""Put a pushed issue back the way `pull.py` would.
|
|
||||||
|
|
||||||
Push deletes the file, so anything that pushes the same issue twice has
|
|
||||||
to fetch it in between — which is the workflow, not a test artifact.
|
|
||||||
The slug and the number come from the ledger, exactly as `pull.id_for`
|
|
||||||
would resolve them."""
|
|
||||||
number = self.number_of(id)
|
|
||||||
self.assertIsNotNone(number, "%s was never pushed" % id)
|
|
||||||
return self.write_issue(id, self.fake.titles[number], body=body,
|
|
||||||
depends=depends, origin="gitea",
|
|
||||||
extra={"gitea": "%s#%d" % (REPO, number)})
|
|
||||||
|
|
||||||
def two_issues(self):
|
|
||||||
"""first-thing, and second-thing which depends on it."""
|
|
||||||
self.write_issue("first-thing", "First thing")
|
|
||||||
self.write_issue("second-thing", "Second thing", body=BODY,
|
|
||||||
depends=["first-thing"])
|
|
||||||
|
|
||||||
def run_push(self, *argv):
|
|
||||||
out, err = io.StringIO(), io.StringIO()
|
|
||||||
args = ["push.py", "--repo", REPO, "--out", self.root] + list(argv)
|
|
||||||
with mock.patch.object(sys, "argv", args), \
|
|
||||||
contextlib.redirect_stdout(out), \
|
|
||||||
contextlib.redirect_stderr(err):
|
|
||||||
push.main()
|
|
||||||
return out.getvalue(), err.getvalue()
|
|
||||||
|
|
||||||
def number_of(self, id):
|
|
||||||
"""The number an id was pushed under, or None.
|
|
||||||
|
|
||||||
Read off `.remote.json` rather than the issue file: a successful push
|
|
||||||
deletes the file, and the ledger is what is left behind."""
|
|
||||||
for key, got in _gitea.load_map(self.root).items():
|
|
||||||
if got == id:
|
|
||||||
return gmap.parse_remote_key(key)[1]
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# _gitea: the POST body, and the pre-check that reads links back
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class AddDependencyTest(unittest.TestCase):
|
|
||||||
|
|
||||||
def test_post_body_is_issue_meta(self):
|
|
||||||
"""POST /issues/{index}/dependencies with IssueMeta for the BLOCKER.
|
|
||||||
|
|
||||||
Confirmed against the instance's swagger.v1.json (Gitea 1.26.1):
|
|
||||||
"Make the issue in the url depend on the issue in the form." """
|
|
||||||
calls = []
|
|
||||||
|
|
||||||
def fake_api(login, endpoint, method="GET", payload=None, **kw):
|
|
||||||
calls.append((method, endpoint, payload))
|
|
||||||
return {"number": 102}
|
|
||||||
|
|
||||||
with mock.patch.object(_gitea, "api", fake_api):
|
|
||||||
ok = _gitea.add_dependency("l", BASE, 102, REPO, 101)
|
|
||||||
|
|
||||||
self.assertTrue(ok)
|
|
||||||
method, endpoint, payload = calls[0]
|
|
||||||
self.assertEqual(method, "POST")
|
|
||||||
self.assertEqual(endpoint, "%s/issues/102/dependencies" % BASE)
|
|
||||||
self.assertEqual(payload, {"index": 101, "owner": "claude-skills",
|
|
||||||
"repo": "tea"})
|
|
||||||
|
|
||||||
def test_blocker_may_live_in_another_repo(self):
|
|
||||||
"""IssueMeta carries owner/repo precisely so it can."""
|
|
||||||
seen = {}
|
|
||||||
|
|
||||||
def fake_api(login, endpoint, method="GET", payload=None, **kw):
|
|
||||||
seen.update(payload or {})
|
|
||||||
return {"number": 1}
|
|
||||||
|
|
||||||
with mock.patch.object(_gitea, "api", fake_api):
|
|
||||||
_gitea.add_dependency("l", BASE, 102, "other-org/infra", 7)
|
|
||||||
self.assertEqual(seen, {"index": 7, "owner": "other-org", "repo": "infra"})
|
|
||||||
|
|
||||||
def test_failure_is_reported_not_raised(self):
|
|
||||||
"""409 (link already there) and friends come back as False."""
|
|
||||||
with mock.patch.object(_gitea, "api", lambda *a, **k: None):
|
|
||||||
self.assertFalse(_gitea.add_dependency("l", BASE, 102, REPO, 101))
|
|
||||||
|
|
||||||
def test_unparseable_repo_makes_no_request(self):
|
|
||||||
called = []
|
|
||||||
with mock.patch.object(_gitea, "api", lambda *a, **k: called.append(1)):
|
|
||||||
self.assertFalse(_gitea.add_dependency("l", BASE, 102, "tea", 101))
|
|
||||||
self.assertEqual(called, [])
|
|
||||||
|
|
||||||
def test_native_dep_pairs_reads_repo_and_number(self):
|
|
||||||
payload = [{"number": 101, "repository": {"full_name": REPO}},
|
|
||||||
{"number": 7, "repository": {"full_name": "other-org/infra"}}]
|
|
||||||
with mock.patch.object(_gitea, "api", lambda *a, **k: payload):
|
|
||||||
got = _gitea.native_dep_pairs("l", BASE, 102)
|
|
||||||
self.assertEqual(got, {(REPO, 101), ("other-org/infra", 7)})
|
|
||||||
|
|
||||||
def test_native_dep_pairs_empty_when_unsupported(self):
|
|
||||||
with mock.patch.object(_gitea, "api", lambda *a, **k: None):
|
|
||||||
self.assertEqual(_gitea.native_dep_pairs("l", BASE, 102), set())
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# push: the whole run
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class PushCreatesLinksTest(PushTestCase):
|
|
||||||
|
|
||||||
def test_link_created_after_both_have_numbers(self):
|
|
||||||
"""One run, topological order, one native link — no second pass."""
|
|
||||||
self.two_issues()
|
|
||||||
out, _ = self.run_push()
|
|
||||||
|
|
||||||
first, second = self.number_of("first-thing"), self.number_of("second-thing")
|
|
||||||
self.assertLess(first, second, "blocker must be created first")
|
|
||||||
self.assertEqual(self.fake.deps.get(second), {(REPO, first)})
|
|
||||||
self.assertIn("depends on %s#%d (first-thing)" % (REPO, first), out)
|
|
||||||
|
|
||||||
def test_link_direction_matches_what_pull_reads_back(self):
|
|
||||||
"""The link hangs off the BLOCKED issue, which is where native_deps
|
|
||||||
looks — push and `pull.py --deps` must agree or the round trip lies."""
|
|
||||||
self.two_issues()
|
|
||||||
self.run_push()
|
|
||||||
second = self.number_of("second-thing")
|
|
||||||
with mock.patch.object(_gitea, "api", self.fake.api):
|
|
||||||
self.assertEqual(_gitea.native_deps("l", BASE, second),
|
|
||||||
[self.number_of("first-thing")])
|
|
||||||
|
|
||||||
def test_issue_without_dependencies_makes_no_dependency_request(self):
|
|
||||||
"""Not even the idempotence GET — it is skipped when there is nothing
|
|
||||||
to link, so the common case costs no extra round trip."""
|
|
||||||
self.write_issue("lonely-thing", "Lonely thing")
|
|
||||||
self.run_push()
|
|
||||||
self.assertEqual([c for c in self.fake.calls if "dependencies" in c[1]], [])
|
|
||||||
|
|
||||||
|
|
||||||
class LocalOnlyDependencyTest(PushTestCase):
|
|
||||||
|
|
||||||
def test_local_dependency_is_warned_and_not_linked(self):
|
|
||||||
self.two_issues()
|
|
||||||
out, err = self.run_push("second-thing")
|
|
||||||
|
|
||||||
self.assertEqual(self.fake.dep_posts(), [])
|
|
||||||
self.assertIn("depends on local-only issue(s) first-thing", err)
|
|
||||||
self.assertNotIn("depends on ", out)
|
|
||||||
self.assertIsNone(self.number_of("first-thing"))
|
|
||||||
|
|
||||||
|
|
||||||
class IdempotenceTest(PushTestCase):
|
|
||||||
|
|
||||||
def test_repeat_push_does_not_duplicate_the_link(self):
|
|
||||||
self.two_issues()
|
|
||||||
self.run_push()
|
|
||||||
self.assertEqual(len(self.fake.dep_posts()), 1)
|
|
||||||
|
|
||||||
self.repull("first-thing")
|
|
||||||
self.repull("second-thing", body=BODY, depends=["first-thing"])
|
|
||||||
self.run_push("--update")
|
|
||||||
self.assertEqual(len(self.fake.dep_posts()), 1, "link re-POSTed")
|
|
||||||
self.assertEqual(self.fake.deps[self.number_of("second-thing")],
|
|
||||||
{(REPO, self.number_of("first-thing"))})
|
|
||||||
|
|
||||||
def test_a_failing_link_warns_and_the_run_finishes(self):
|
|
||||||
"""A 409 or any other refusal must not abort a push that has already
|
|
||||||
created issues."""
|
|
||||||
self.two_issues()
|
|
||||||
self.fake.fail_dependency_post = True
|
|
||||||
out, err = self.run_push()
|
|
||||||
|
|
||||||
self.assertIn("could not link", err)
|
|
||||||
self.assertIn("index:", out) # the run completed
|
|
||||||
self.assertIsNotNone(self.number_of("second-thing"))
|
|
||||||
|
|
||||||
|
|
||||||
class UpdateCarriesNewLinksTest(PushTestCase):
|
|
||||||
|
|
||||||
def test_dependency_added_after_the_first_push_is_linked_by_update(self):
|
|
||||||
self.write_issue("first-thing", "First thing")
|
|
||||||
self.write_issue("second-thing", "Second thing")
|
|
||||||
self.run_push()
|
|
||||||
self.assertEqual(self.fake.dep_posts(), [])
|
|
||||||
|
|
||||||
# The issue comes back from Gitea, and the dependency is added to the
|
|
||||||
# copy that came back — there is no other copy to add it to.
|
|
||||||
self.repull("second-thing", body=BODY, depends=["first-thing"])
|
|
||||||
|
|
||||||
self.run_push("--update", "second-thing")
|
|
||||||
self.assertEqual(self.fake.deps[self.number_of("second-thing")],
|
|
||||||
{(REPO, self.number_of("first-thing"))})
|
|
||||||
|
|
||||||
|
|
||||||
class DryRunTest(PushTestCase):
|
|
||||||
|
|
||||||
def test_dry_run_names_the_links_and_writes_nothing(self):
|
|
||||||
self.two_issues()
|
|
||||||
out, _ = self.run_push("--dry-run")
|
|
||||||
|
|
||||||
self.assertEqual(self.fake.calls, [], "--dry-run made a request")
|
|
||||||
self.assertIn("link -> #? (first-thing, created by this run)", out)
|
|
||||||
self.assertIn("1 dependency link(s) would be created", out)
|
|
||||||
|
|
||||||
def test_dry_run_shows_a_known_number_when_the_blocker_is_pushed(self):
|
|
||||||
self.write_issue("first-thing", "First thing",
|
|
||||||
extra={"gitea": "%s#101" % REPO})
|
|
||||||
self.write_issue("second-thing", "Second thing", body=BODY,
|
|
||||||
depends=["first-thing"])
|
|
||||||
out, _ = self.run_push("--dry-run")
|
|
||||||
|
|
||||||
self.assertIn("link -> %s#101 (first-thing)" % REPO, out)
|
|
||||||
self.assertEqual(self.fake.writes, [])
|
|
||||||
|
|
||||||
def test_dry_run_says_a_local_dependency_gets_no_link(self):
|
|
||||||
self.two_issues()
|
|
||||||
out, _ = self.run_push("--dry-run", "second-thing")
|
|
||||||
self.assertIn("no link: first-thing is local-only", out)
|
|
||||||
self.assertIn("0 dependency link(s) would be created", out)
|
|
||||||
|
|
||||||
|
|
||||||
class BodyIsVerbatimTest(PushTestCase):
|
|
||||||
"""The prose is untouched. The id marker is the one thing push adds, and it
|
|
||||||
comes straight back off — `strip_id_marker` is the inverse."""
|
|
||||||
|
|
||||||
def test_depends_on_prose_is_not_rewritten_to_numbers(self):
|
|
||||||
"""map.py deliberately never edits the prose. Linking must not start."""
|
|
||||||
self.two_issues()
|
|
||||||
before = issue.load(self.root, "second-thing").body
|
|
||||||
self.run_push()
|
|
||||||
|
|
||||||
created = [c for c in self.fake.calls
|
|
||||||
if c[0] == "POST" and c[1] == "%s/issues" % BASE]
|
|
||||||
sent = [c[2]["body"] for c in created]
|
|
||||||
second_body = [b for b in sent if "Depends on" in b][0]
|
|
||||||
|
|
||||||
self.assertIn("- first-thing — ставит фундамент", second_body)
|
|
||||||
self.assertNotIn("#101", second_body)
|
|
||||||
self.assertEqual(gmap.strip_id_marker(second_body), before)
|
|
||||||
|
|
||||||
def test_body_survives_a_second_push_unchanged(self):
|
|
||||||
self.two_issues()
|
|
||||||
before = issue.load(self.root, "second-thing").body
|
|
||||||
self.run_push()
|
|
||||||
|
|
||||||
self.repull("first-thing")
|
|
||||||
self.repull("second-thing", body=before, depends=["first-thing"])
|
|
||||||
self.assertEqual(issue.load(self.root, "second-thing").body, before)
|
|
||||||
|
|
||||||
self.run_push("--update")
|
|
||||||
patched = [c for c in self.fake.calls if c[0] == "PATCH"]
|
|
||||||
self.assertIn(before, [gmap.strip_id_marker(c[2]["body"]) for c in patched])
|
|
||||||
|
|
||||||
|
|
||||||
class DepStateTest(PushTestCase):
|
|
||||||
"""The classifier both the dry run and the real run read from."""
|
|
||||||
|
|
||||||
def test_classifies_linked_in_run_and_local(self):
|
|
||||||
issues = {
|
|
||||||
"pushed": issue.Issue(id="pushed", extra={"gitea": "%s#101" % REPO}),
|
|
||||||
"coming": issue.Issue(id="coming"),
|
|
||||||
"local": issue.Issue(id="local"),
|
|
||||||
}
|
|
||||||
iss = issue.Issue(id="dependent",
|
|
||||||
depends=["pushed", "coming", "local", "ghost"])
|
|
||||||
got = push.dep_state(iss, issues, {"coming", "dependent"})
|
|
||||||
|
|
||||||
self.assertEqual(got, [("pushed", "%s#101" % REPO, False),
|
|
||||||
("coming", None, True),
|
|
||||||
("local", None, False)])
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,621 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Where the issue store is: the project the operator marked, never the plugin.
|
|
||||||
|
|
||||||
python3 -m unittest discover -s tests -v
|
|
||||||
|
|
||||||
Stdlib unittest, no third-party anything — the same rule the scripts under test
|
|
||||||
live by. `skills/*/scripts/` are not packages, so the domain module is imported
|
|
||||||
by path.
|
|
||||||
|
|
||||||
These tests build a throwaway project in a temp directory — a `.tea/` marker, a
|
|
||||||
store with two issues — and run the real scripts inside it as subprocesses with
|
|
||||||
different working directories.
|
|
||||||
|
|
||||||
**The scripts are deliberately NOT copied into the fixture.** They stay where
|
|
||||||
they really live, several directories away from the project under test, because
|
|
||||||
that separation IS the thing being tested: a plugin is installed in one place
|
|
||||||
and used on projects in another, and the store belongs to the project. The
|
|
||||||
suite used to copy both script layers in, which made the two locations the same
|
|
||||||
directory and hid the bug completely — issues written from a project landed in
|
|
||||||
`~/.claude/plugins/cache/tea/tea/<version>/tmp/issues` and vanished on the next
|
|
||||||
version bump.
|
|
||||||
|
|
||||||
`CLAUDE_PROJECT_DIR` is stripped from the child environment except where a test
|
|
||||||
is about it: it is the first anchor, so leaving the harness's own value in place
|
|
||||||
would point every fixture at this repository.
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
import unittest
|
|
||||||
|
|
||||||
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
||||||
ISSUE_SCRIPTS = os.path.join(REPO, "skills", "issue", "scripts")
|
|
||||||
SYNC_SCRIPTS = os.path.join(REPO, "skills", "sync", "scripts")
|
|
||||||
|
|
||||||
sys.path.insert(0, ISSUE_SCRIPTS)
|
|
||||||
import issue # noqa: E402
|
|
||||||
|
|
||||||
|
|
||||||
ALPHA = """\
|
|
||||||
---
|
|
||||||
id: alpha-issue
|
|
||||||
state: open
|
|
||||||
labels: [type/task]
|
|
||||||
assignees: []
|
|
||||||
milestone: none
|
|
||||||
depends: []
|
|
||||||
origin: local
|
|
||||||
---
|
|
||||||
# Alpha issue
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
Первый issue фикстуры.
|
|
||||||
|
|
||||||
## Spec
|
|
||||||
none
|
|
||||||
|
|
||||||
## Motivation
|
|
||||||
Нужен, чтобы в store что-то лежало.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
- [ ] проверяемое условие
|
|
||||||
"""
|
|
||||||
|
|
||||||
BETA = """\
|
|
||||||
---
|
|
||||||
id: beta-issue
|
|
||||||
state: open
|
|
||||||
labels: [type/task]
|
|
||||||
assignees: []
|
|
||||||
milestone: none
|
|
||||||
depends: [alpha-issue]
|
|
||||||
origin: local
|
|
||||||
---
|
|
||||||
# Beta issue
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
Второй issue фикстуры, зависит от первого.
|
|
||||||
|
|
||||||
## Spec
|
|
||||||
none
|
|
||||||
|
|
||||||
## Depends on
|
|
||||||
- alpha-issue
|
|
||||||
|
|
||||||
## Motivation
|
|
||||||
Нужен, чтобы у графа было ребро.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
- [ ] проверяемое условие
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def run(script, *args, **kw):
|
|
||||||
"""Run one of the plugin's real scripts and return (rc, stdout, stderr).
|
|
||||||
|
|
||||||
`project_dir` sets CLAUDE_PROJECT_DIR for the child; by default the variable
|
|
||||||
is removed, so cwd alone decides which project answers."""
|
|
||||||
cwd = kw.pop("cwd")
|
|
||||||
project_dir = kw.pop("project_dir", None)
|
|
||||||
env = dict(os.environ)
|
|
||||||
env.pop("PYTHONPATH", None) # no leakage from the harness into the child
|
|
||||||
env.pop("CLAUDE_PROJECT_DIR", None)
|
|
||||||
if project_dir:
|
|
||||||
env["CLAUDE_PROJECT_DIR"] = project_dir
|
|
||||||
p = subprocess.run([sys.executable, script] + list(args), cwd=cwd, env=env,
|
|
||||||
capture_output=True, text=True)
|
|
||||||
return p.returncode, p.stdout, p.stderr
|
|
||||||
|
|
||||||
|
|
||||||
def script(layer, name):
|
|
||||||
"""A script at its real installed path — never a copy inside a fixture."""
|
|
||||||
return os.path.join(REPO, "skills", layer, "scripts", name)
|
|
||||||
|
|
||||||
|
|
||||||
class FakeProject(object):
|
|
||||||
"""An initialized project in a temp directory, far from the scripts."""
|
|
||||||
|
|
||||||
def __init__(self, with_store=True, marker=True, issues=(ALPHA, BETA)):
|
|
||||||
self._tmp = tempfile.TemporaryDirectory()
|
|
||||||
# realpath: on macOS $TMPDIR is a symlink, and a child process reporting
|
|
||||||
# its own cwd would otherwise disagree with the path we handed it.
|
|
||||||
self.root = os.path.realpath(self._tmp.name)
|
|
||||||
|
|
||||||
if marker:
|
|
||||||
os.makedirs(os.path.join(self.root, issue.MARKER))
|
|
||||||
os.makedirs(self.path("sub", "deeper"))
|
|
||||||
|
|
||||||
if with_store:
|
|
||||||
os.makedirs(self.store)
|
|
||||||
for text in issues:
|
|
||||||
id = text.split("id: ", 1)[1].split("\n", 1)[0]
|
|
||||||
with open(os.path.join(self.store, "%s.md" % id), "w") as f:
|
|
||||||
f.write(text)
|
|
||||||
|
|
||||||
def cleanup(self):
|
|
||||||
self._tmp.cleanup()
|
|
||||||
|
|
||||||
def path(self, *parts):
|
|
||||||
return os.path.join(self.root, *parts)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def store(self):
|
|
||||||
return self.path(*issue.STORE_PARTS)
|
|
||||||
|
|
||||||
def everywhere(self):
|
|
||||||
"""Working directories that must all produce the same answer: the
|
|
||||||
project root, a plain subdirectory, a deeper one, and — the case from
|
|
||||||
the original bug report — inside the store itself."""
|
|
||||||
return [self.root, self.path("sub"), self.path("sub", "deeper"),
|
|
||||||
self.store]
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# resolution, in isolation
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestResolution(unittest.TestCase):
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
self.project = FakeProject()
|
|
||||||
self.addCleanup(self.project.cleanup)
|
|
||||||
|
|
||||||
def test_the_marker_is_found_from_any_depth(self):
|
|
||||||
for start in self.project.everywhere():
|
|
||||||
self.assertEqual(issue.project_root(start), self.project.root, start)
|
|
||||||
|
|
||||||
def test_git_alone_is_not_a_marker(self):
|
|
||||||
"""The whole point of an explicit marker. `.git` is in every clone,
|
|
||||||
including this plugin's own — inferring the root from one is how the
|
|
||||||
plugin came to answer with itself. An uninitialized repository is not a
|
|
||||||
project this tool knows about, and it says so instead of guessing."""
|
|
||||||
plain = FakeProject(marker=False, with_store=False)
|
|
||||||
self.addCleanup(plain.cleanup)
|
|
||||||
os.makedirs(plain.path(".git"))
|
|
||||||
open(plain.path("AGENTS.md"), "w").close()
|
|
||||||
self.assertIsNone(issue.project_root(plain.path("sub", "deeper")))
|
|
||||||
self.assertIsNone(issue.store_root(plain.path("sub", "deeper")))
|
|
||||||
|
|
||||||
def test_nearest_marker_wins(self):
|
|
||||||
"""A project inside a project (a vendored copy, a nested checkout)
|
|
||||||
resolves to the inner one, not the outer."""
|
|
||||||
inner = self.project.path("sub", "inner")
|
|
||||||
os.makedirs(os.path.join(inner, issue.MARKER))
|
|
||||||
self.assertEqual(issue.project_root(inner), inner)
|
|
||||||
self.assertEqual(issue.project_root(self.project.root), self.project.root)
|
|
||||||
|
|
||||||
def test_store_root_is_project_root_plus_marker(self):
|
|
||||||
self.assertEqual(issue.store_root(self.project.path("sub", "deeper")),
|
|
||||||
self.project.store)
|
|
||||||
self.assertTrue(os.path.isabs(issue.store_root(self.project.root)))
|
|
||||||
|
|
||||||
def test_no_marker_anywhere_resolves_to_nothing(self):
|
|
||||||
"""Not a default, not cwd, not the script's own directory: None. A
|
|
||||||
wrong directory that looks like it worked is the failure this
|
|
||||||
replaces."""
|
|
||||||
plain = FakeProject(marker=False, with_store=False)
|
|
||||||
self.addCleanup(plain.cleanup)
|
|
||||||
self.assertIsNone(issue.store_root(plain.path("sub", "deeper")))
|
|
||||||
|
|
||||||
def test_the_error_names_the_directories_it_searched(self):
|
|
||||||
msg = issue.no_project_error("/nowhere-at-all")
|
|
||||||
self.assertIn(issue.MARKER, msg)
|
|
||||||
self.assertIn("/nowhere-at-all", msg)
|
|
||||||
self.assertIn("issue_init.py", msg)
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# the regression: an installed plugin never answers with itself
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestTheStoreIsNeverThePlugin(unittest.TestCase):
|
|
||||||
"""The bug this contract exists for.
|
|
||||||
|
|
||||||
Anchored on `__file__`, every one of these commands resolved the store
|
|
||||||
inside the plugin — a versioned cache directory — so work written from a
|
|
||||||
project was invisible from it and disappeared on the next plugin update."""
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
self.project = FakeProject()
|
|
||||||
self.addCleanup(self.project.cleanup)
|
|
||||||
self.before = self._plugin_tree()
|
|
||||||
|
|
||||||
def _plugin_tree(self):
|
|
||||||
out = set()
|
|
||||||
for dirpath, dirnames, filenames in os.walk(REPO):
|
|
||||||
dirnames[:] = [d for d in dirnames if d != "__pycache__"]
|
|
||||||
for f in filenames:
|
|
||||||
out.add(os.path.join(dirpath, f))
|
|
||||||
return out
|
|
||||||
|
|
||||||
def test_scripts_run_from_a_project_write_only_into_that_project(self):
|
|
||||||
for d in self.project.everywhere():
|
|
||||||
for name in ("issue_index.py", "issue_check.py", "issue_tree.py"):
|
|
||||||
run(script("issue", name), cwd=d)
|
|
||||||
|
|
||||||
rc, out, err = run(script("issue", "issue_new.py"),
|
|
||||||
"--type", "task", "--title", "Written from a project",
|
|
||||||
cwd=self.project.path("sub", "deeper"))
|
|
||||||
self.assertEqual(rc, 0, err)
|
|
||||||
self.assertTrue(os.path.isfile(
|
|
||||||
os.path.join(self.project.store, "written-from-a-project.md")))
|
|
||||||
|
|
||||||
new = self._plugin_tree() - self.before
|
|
||||||
self.assertEqual(new, set(),
|
|
||||||
"these commands wrote into the plugin: %s"
|
|
||||||
% ", ".join(sorted(new)))
|
|
||||||
|
|
||||||
def test_the_plugins_own_marker_does_not_leak_into_a_project(self):
|
|
||||||
"""Should this repository ever be initialized for its own issues, that
|
|
||||||
marker must not become the answer for a project that has one."""
|
|
||||||
for d in self.project.everywhere():
|
|
||||||
self.assertEqual(issue.project_root(d), self.project.root, d)
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# the acceptance criterion: same answer from any subdirectory
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestSameFromAnywhere(unittest.TestCase):
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
self.project = FakeProject()
|
|
||||||
self.addCleanup(self.project.cleanup)
|
|
||||||
|
|
||||||
def assertSameEverywhere(self, layer, name, *args):
|
|
||||||
"""Run the script from the project root and from every other directory;
|
|
||||||
every result must be byte-identical to the one from the root."""
|
|
||||||
dirs = self.project.everywhere()
|
|
||||||
base = run(script(layer, name), *args, cwd=dirs[0])
|
|
||||||
self.assertEqual(base[0], 0, "%s failed at the project root:\n%s"
|
|
||||||
% (name, base[2]))
|
|
||||||
for d in dirs[1:]:
|
|
||||||
self.assertEqual(run(script(layer, name), *args, cwd=d), base,
|
|
||||||
"%s disagrees when run from %s" % (name, d))
|
|
||||||
return base
|
|
||||||
|
|
||||||
def test_issue_check(self):
|
|
||||||
rc, out, _ = self.assertSameEverywhere("issue", "issue_check.py")
|
|
||||||
self.assertIn("ok alpha-issue", out)
|
|
||||||
self.assertIn("2 issue(s) checked, 0 with errors", out)
|
|
||||||
|
|
||||||
def test_issue_tree(self):
|
|
||||||
_, out, _ = self.assertSameEverywhere("issue", "issue_tree.py")
|
|
||||||
self.assertIn("beta-issue", out)
|
|
||||||
self.assertIn("alpha-issue", out)
|
|
||||||
|
|
||||||
def test_issue_index(self):
|
|
||||||
_, out, _ = self.assertSameEverywhere("issue", "issue_index.py")
|
|
||||||
self.assertIn("2 issue(s)", out)
|
|
||||||
self.assertIn(os.path.join(self.project.store, "INDEX.md"), out)
|
|
||||||
|
|
||||||
def test_no_second_store_is_ever_created(self):
|
|
||||||
"""The old bug's worst symptom: `issue_index.py` run from inside the
|
|
||||||
store used to leave tmp/issues/tmp/issues/ behind, silently."""
|
|
||||||
for d in self.project.everywhere():
|
|
||||||
for name in ("issue_index.py", "issue_check.py", "issue_tree.py"):
|
|
||||||
run(script("issue", name), cwd=d)
|
|
||||||
|
|
||||||
found = []
|
|
||||||
for dirpath, dirnames, filenames in os.walk(self.project.root):
|
|
||||||
if "__pycache__" in dirnames:
|
|
||||||
dirnames.remove("__pycache__")
|
|
||||||
if "INDEX.md" in filenames:
|
|
||||||
found.append(dirpath)
|
|
||||||
self.assertEqual(found, [self.project.store],
|
|
||||||
"a second store appeared: %s" % found)
|
|
||||||
|
|
||||||
def test_a_cd_into_another_project_answers_with_that_project(self):
|
|
||||||
"""Walking up is not cwd-independence for its own sake: two projects
|
|
||||||
are two stores, and the one you are standing in is the one you meant."""
|
|
||||||
other = FakeProject(issues=(ALPHA,))
|
|
||||||
self.addCleanup(other.cleanup)
|
|
||||||
_, mine, _ = run(script("issue", "issue_check.py"), cwd=self.project.root)
|
|
||||||
_, theirs, _ = run(script("issue", "issue_check.py"), cwd=other.root)
|
|
||||||
self.assertIn("2 issue(s) checked", mine)
|
|
||||||
self.assertIn("1 issue(s) checked", theirs)
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# which anchor wins
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestAnchorOrder(unittest.TestCase):
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
self.project = FakeProject()
|
|
||||||
self.other = FakeProject(issues=(ALPHA,))
|
|
||||||
self.addCleanup(self.project.cleanup)
|
|
||||||
self.addCleanup(self.other.cleanup)
|
|
||||||
|
|
||||||
def test_claude_project_dir_is_asked_before_cwd(self):
|
|
||||||
"""The editor's project is the project, even when a command happens to
|
|
||||||
run from somewhere else — the same order the login pin uses, so the two
|
|
||||||
cannot disagree about which project this is."""
|
|
||||||
_, out, err = run(script("issue", "issue_check.py"),
|
|
||||||
cwd=self.other.root, project_dir=self.project.root)
|
|
||||||
self.assertIn("2 issue(s) checked", out, err)
|
|
||||||
|
|
||||||
def test_an_unmarked_claude_project_dir_falls_through_to_cwd(self):
|
|
||||||
"""First hit wins, not first anchor tried: a project dir with no marker
|
|
||||||
above it is no answer at all, and cwd still gets its turn."""
|
|
||||||
plain = FakeProject(marker=False, with_store=False)
|
|
||||||
self.addCleanup(plain.cleanup)
|
|
||||||
_, out, err = run(script("issue", "issue_check.py"),
|
|
||||||
cwd=self.other.root, project_dir=plain.root)
|
|
||||||
self.assertIn("1 issue(s) checked", out, err)
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# no project at all
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestNoProject(unittest.TestCase):
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
self.plain = FakeProject(marker=False, with_store=False)
|
|
||||||
self.addCleanup(self.plain.cleanup)
|
|
||||||
|
|
||||||
def test_readers_report_it_and_name_where_they_looked(self):
|
|
||||||
for name in ("issue_check.py", "issue_tree.py", "issue_index.py"):
|
|
||||||
rc, out, err = run(script("issue", name), cwd=self.plain.root)
|
|
||||||
msg = out + err
|
|
||||||
self.assertNotEqual(rc, 0, "%s should fail with no project" % name)
|
|
||||||
self.assertIn("no %s/ found" % issue.MARKER, msg, name)
|
|
||||||
self.assertIn(self.plain.root, msg, name)
|
|
||||||
|
|
||||||
def test_no_domain_script_ever_shows_a_traceback(self):
|
|
||||||
""""No project" is an ordinary answer, not a crash. A TypeError on a
|
|
||||||
None path is how an unresolved root announced itself while this was
|
|
||||||
being written — every entry point is swept, so a new one cannot
|
|
||||||
quietly reintroduce it."""
|
|
||||||
args = {"issue_ac.py": ["alpha-issue"],
|
|
||||||
"issue_evict.py": ["--dry-run"],
|
|
||||||
"issue_new.py": ["--type", "task", "--title", "Nowhere"]}
|
|
||||||
entries = [n for n in sorted(os.listdir(ISSUE_SCRIPTS))
|
|
||||||
if n.endswith(".py") and n not in ("issue.py", "issue_init.py")]
|
|
||||||
self.assertTrue(entries)
|
|
||||||
for name in entries:
|
|
||||||
rc, out, err = run(script("issue", name), *args.get(name, []),
|
|
||||||
cwd=self.plain.path("sub", "deeper"))
|
|
||||||
self.assertNotIn("Traceback", err, "%s crashed:\n%s" % (name, err))
|
|
||||||
self.assertNotEqual(rc, 0, name)
|
|
||||||
self.assertIn("no %s/ found" % issue.MARKER, out + err, name)
|
|
||||||
|
|
||||||
def test_a_writer_refuses_to_invent_a_project(self):
|
|
||||||
rc, out, err = run(script("issue", "issue_new.py"),
|
|
||||||
"--type", "task", "--title", "Nowhere to put this",
|
|
||||||
cwd=self.plain.path("sub", "deeper"))
|
|
||||||
self.assertNotEqual(rc, 0)
|
|
||||||
self.assertIn("no %s/ found" % issue.MARKER, out + err)
|
|
||||||
self.assertFalse(os.path.exists(self.plain.path(issue.MARKER)))
|
|
||||||
self.assertFalse(os.path.exists(self.plain.path("sub", "deeper",
|
|
||||||
issue.MARKER)))
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# missing is not empty
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestMissingVersusEmpty(unittest.TestCase):
|
|
||||||
|
|
||||||
def test_missing_store_says_missing(self):
|
|
||||||
project = FakeProject(with_store=False)
|
|
||||||
self.addCleanup(project.cleanup)
|
|
||||||
for name in ("issue_check.py", "issue_tree.py", "issue_index.py"):
|
|
||||||
rc, out, err = run(script("issue", name), cwd=project.root)
|
|
||||||
msg = out + err
|
|
||||||
self.assertNotEqual(rc, 0, "%s should fail on a missing store" % name)
|
|
||||||
self.assertIn("does not exist", msg, name)
|
|
||||||
self.assertNotIn("is empty", msg, name)
|
|
||||||
|
|
||||||
def test_empty_store_says_empty(self):
|
|
||||||
project = FakeProject(issues=())
|
|
||||||
self.addCleanup(project.cleanup)
|
|
||||||
for name in ("issue_check.py", "issue_tree.py"):
|
|
||||||
rc, out, err = run(script("issue", name), cwd=project.root)
|
|
||||||
msg = out + err
|
|
||||||
self.assertNotEqual(rc, 0, name)
|
|
||||||
self.assertIn("is empty", msg, name)
|
|
||||||
self.assertNotIn("does not exist", msg, name)
|
|
||||||
|
|
||||||
def test_index_of_an_empty_store_is_legitimate(self):
|
|
||||||
"""An existing store with nothing in it gets an index saying so. Only a
|
|
||||||
missing directory is an error."""
|
|
||||||
project = FakeProject(issues=())
|
|
||||||
self.addCleanup(project.cleanup)
|
|
||||||
rc, out, err = run(script("issue", "issue_index.py"), cwd=project.root)
|
|
||||||
self.assertEqual(rc, 0, err)
|
|
||||||
self.assertIn("0 issue(s)", out)
|
|
||||||
with open(os.path.join(project.store, "INDEX.md")) as f:
|
|
||||||
self.assertIn("_empty_", f.read())
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# nothing conjures a store
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestNoSilentCreation(unittest.TestCase):
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
self.project = FakeProject(with_store=False)
|
|
||||||
self.addCleanup(self.project.cleanup)
|
|
||||||
|
|
||||||
def test_readers_and_the_indexer_create_nothing(self):
|
|
||||||
for d in (self.project.root, self.project.path("sub")):
|
|
||||||
for name in ("issue_check.py", "issue_tree.py", "issue_index.py"):
|
|
||||||
run(script("issue", name), cwd=d)
|
|
||||||
self.assertFalse(os.path.exists(self.project.store),
|
|
||||||
"the store was created by a read")
|
|
||||||
self.assertFalse(os.path.exists(self.project.path("sub", issue.MARKER)),
|
|
||||||
"a store was created relative to cwd")
|
|
||||||
|
|
||||||
def test_explicit_out_pointing_nowhere_is_an_error_not_a_mkdir(self):
|
|
||||||
target = self.project.path("sub", "nowhere")
|
|
||||||
rc, out, err = run(script("issue", "issue_index.py"),
|
|
||||||
"--out", target, cwd=self.project.root)
|
|
||||||
self.assertNotEqual(rc, 0)
|
|
||||||
self.assertIn("does not exist", out + err)
|
|
||||||
self.assertFalse(os.path.exists(target))
|
|
||||||
|
|
||||||
def test_issue_new_creates_the_store_and_says_so(self):
|
|
||||||
"""Creating the first issue in a fresh project must still work — but
|
|
||||||
out loud, and at the project root, not below whatever cwd happens to
|
|
||||||
be."""
|
|
||||||
rc, out, err = run(script("issue", "issue_new.py"),
|
|
||||||
"--type", "task", "--title", "Bootstrap the store",
|
|
||||||
cwd=self.project.path("sub", "deeper"))
|
|
||||||
self.assertEqual(rc, 0, err)
|
|
||||||
self.assertIn("created store", err)
|
|
||||||
self.assertIn(self.project.store, err)
|
|
||||||
self.assertTrue(os.path.isfile(
|
|
||||||
os.path.join(self.project.store, "bootstrap-the-store.md")))
|
|
||||||
self.assertFalse(
|
|
||||||
os.path.exists(self.project.path("sub", "deeper", issue.MARKER)),
|
|
||||||
"a store was created relative to cwd")
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# an explicit --out is the operator's, not ours to rewrite
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestExplicitOutWins(unittest.TestCase):
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
self.project = FakeProject()
|
|
||||||
self.addCleanup(self.project.cleanup)
|
|
||||||
|
|
||||||
def test_absolute_out_is_honored(self):
|
|
||||||
other = self.project.path("sub", "other-store")
|
|
||||||
os.makedirs(other)
|
|
||||||
shutil.copy(os.path.join(self.project.store, "alpha-issue.md"), other)
|
|
||||||
rc, out, err = run(script("issue", "issue_check.py"),
|
|
||||||
"--out", other, cwd=self.project.root)
|
|
||||||
self.assertEqual(rc, 0, err)
|
|
||||||
self.assertIn("1 issue(s) checked", out)
|
|
||||||
|
|
||||||
def test_relative_out_stays_relative_to_cwd(self):
|
|
||||||
"""`--out .tea/issues` typed from a subdirectory means that
|
|
||||||
subdirectory's `.tea/issues` — which is not there. Auto-resolution must
|
|
||||||
not step in and "fix" what the operator typed."""
|
|
||||||
rel = os.path.join(*issue.STORE_PARTS)
|
|
||||||
rc, out, err = run(script("issue", "issue_check.py"),
|
|
||||||
"--out", rel, cwd=self.project.path("sub"))
|
|
||||||
self.assertNotEqual(rc, 0)
|
|
||||||
self.assertIn("does not exist", out + err)
|
|
||||||
|
|
||||||
# the same relative path from the root does resolve, by cwd alone
|
|
||||||
rc, out, err = run(script("issue", "issue_check.py"),
|
|
||||||
"--out", rel, cwd=self.project.root)
|
|
||||||
self.assertEqual(rc, 0, err)
|
|
||||||
self.assertIn("2 issue(s) checked", out)
|
|
||||||
|
|
||||||
def test_relative_out_can_climb(self):
|
|
||||||
rc, out, err = run(script("issue", "issue_check.py"),
|
|
||||||
"--out", os.path.join("..", *issue.STORE_PARTS),
|
|
||||||
cwd=self.project.path("sub"))
|
|
||||||
self.assertEqual(rc, 0, err)
|
|
||||||
self.assertIn("2 issue(s) checked", out)
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# both layers, one root
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestSyncLayerAgrees(unittest.TestCase):
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
self.project = FakeProject()
|
|
||||||
self.addCleanup(self.project.cleanup)
|
|
||||||
|
|
||||||
def _probe(self, layer, cwd):
|
|
||||||
"""Ask one layer, from `cwd`, which module defines the store and where
|
|
||||||
it lands. The sync scripts put the issue scripts on sys.path themselves
|
|
||||||
— `import map` is how they do it — so each layer is asked its own way.
|
|
||||||
"""
|
|
||||||
scripts = SYNC_SCRIPTS if layer == "sync" else ISSUE_SCRIPTS
|
|
||||||
entry = "import map, issue" if layer == "sync" else "import issue"
|
|
||||||
code = ("import sys; sys.path.insert(0, %r)\n%s\n"
|
|
||||||
"print(issue.__file__)\nprint(issue.ISSUE_ROOT)\n") % (scripts, entry)
|
|
||||||
env = dict(os.environ)
|
|
||||||
env.pop("PYTHONPATH", None)
|
|
||||||
env.pop("CLAUDE_PROJECT_DIR", None)
|
|
||||||
p = subprocess.run([sys.executable, "-c", code], cwd=cwd, env=env,
|
|
||||||
capture_output=True, text=True)
|
|
||||||
self.assertEqual(p.returncode, 0, p.stderr)
|
|
||||||
return p.stdout.strip().splitlines()
|
|
||||||
|
|
||||||
def test_both_layers_resolve_the_same_store_from_anywhere(self):
|
|
||||||
for d in self.project.everywhere():
|
|
||||||
mod_i, root_i = self._probe("issue", d)
|
|
||||||
mod_s, root_s = self._probe("sync", d)
|
|
||||||
# sync does not redefine the store; it imports the domain module
|
|
||||||
self.assertEqual(os.path.realpath(mod_i), os.path.realpath(mod_s), d)
|
|
||||||
self.assertEqual(root_i, self.project.store, d)
|
|
||||||
self.assertEqual(root_s, self.project.store, d)
|
|
||||||
|
|
||||||
def test_the_payload_root_is_a_sibling_of_the_store(self):
|
|
||||||
"""One marker, one walk: the transport's scratchpad and the domain's
|
|
||||||
store cannot end up in different projects, and the scratchpad is never
|
|
||||||
inside the store."""
|
|
||||||
code = ("import sys; sys.path.insert(0, %r)\n"
|
|
||||||
"import _gitea\nprint(_gitea.PAYLOAD_ROOT)\n") % SYNC_SCRIPTS
|
|
||||||
env = dict(os.environ)
|
|
||||||
env.pop("PYTHONPATH", None)
|
|
||||||
env.pop("CLAUDE_PROJECT_DIR", None)
|
|
||||||
for d in self.project.everywhere():
|
|
||||||
p = subprocess.run([sys.executable, "-c", code], cwd=d, env=env,
|
|
||||||
capture_output=True, text=True)
|
|
||||||
self.assertEqual(p.returncode, 0, p.stderr)
|
|
||||||
payload = p.stdout.strip()
|
|
||||||
self.assertEqual(payload,
|
|
||||||
self.project.path(issue.MARKER, "payload"), d)
|
|
||||||
self.assertFalse(payload.startswith(self.project.store + os.sep), d)
|
|
||||||
|
|
||||||
def test_every_out_flag_defers_to_the_domain_layer(self):
|
|
||||||
"""Both layers agree by construction, not by coincidence: no script
|
|
||||||
spells the default out for itself."""
|
|
||||||
for layer, names in (("issue", ("issue_new.py", "issue_check.py",
|
|
||||||
"issue_tree.py", "issue_index.py",
|
|
||||||
"issue_evict.py")),
|
|
||||||
("sync", ("pull.py", "push.py", "remote.py",
|
|
||||||
"comment.py", "evict.py"))):
|
|
||||||
for name in names:
|
|
||||||
with open(os.path.join(REPO, "skills", layer, "scripts", name)) as f:
|
|
||||||
src = f.read()
|
|
||||||
self.assertIn('"--out", default=issue.ISSUE_ROOT', src,
|
|
||||||
"%s/%s does not take its --out default from the "
|
|
||||||
"domain layer" % (layer, name))
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# the layering rule, mechanically
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestLayering(unittest.TestCase):
|
|
||||||
|
|
||||||
def test_domain_layer_is_stdlib_only(self):
|
|
||||||
"""skills/issue must keep working with skills/sync deleted — so no
|
|
||||||
transport, and above all no subprocess, in the domain layer."""
|
|
||||||
imported = set()
|
|
||||||
for name in sorted(os.listdir(ISSUE_SCRIPTS)):
|
|
||||||
if not name.endswith(".py"):
|
|
||||||
continue
|
|
||||||
with open(os.path.join(ISSUE_SCRIPTS, name)) as f:
|
|
||||||
for line in f:
|
|
||||||
if line.startswith(("import ", "from ")):
|
|
||||||
imported.add(line.split()[1].split(".")[0])
|
|
||||||
local = {"issue", "issue_ac", "issue_index"}
|
|
||||||
foreign = imported - local - sys.stdlib_module_names
|
|
||||||
self.assertEqual(foreign, set(),
|
|
||||||
"non-stdlib import in the domain layer: %s"
|
|
||||||
% ", ".join(sorted(foreign)))
|
|
||||||
self.assertNotIn("subprocess", imported)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
Reference in New Issue
Block a user