6d01ead245
ISSUE_ROOT was the relative `tmp/issues`, so "the store" was whatever
directory the shell happened to be standing in. It is the --out default
in all eight scripts of both layers, which made one `cd` — and a `cd`
outlives the command that ran it — enough for readers to report an empty
store on a full one and for writers to quietly build a second store
beside the first. `issue_index.py` run from inside tmp/issues left
tmp/issues/tmp/issues/ behind and exited 0.
The anchor is issue.py's own __file__, not cwd. A script's location is a
fact about the installation; cwd is a fact about the last `cd`, and the
scripts are invoked by path from wherever the agent happens to be. From
there `store_root()` walks up to the nearest repo marker — `.git`
(exists(), not isdir(): a worktree's .git is a file) or AGENTS.md for a
copy taken out of git — and joins tmp/issues. Markers rather than a
fixed number of `..` hops, because the layout is not a promise. cwd is
tried only if the scripts are not inside a repository at all.
The function lives in the domain layer and skills/sync imports it, so
both layers agree by construction — the direction the layering rule
allows. skills/issue stays stdlib-only.
An explicit --out still wins and is used exactly as typed: a relative
--out stays relative to cwd, because that is what the operator asked
for. No new environment surface.
Two consequences the issue also asked for:
- Missing is no longer reported as empty. `store_error()` returns one
message for a path that is not there and another for a store with no
issues in it.
- Nothing conjures a store as a side effect of a write. save() and
issue_index.build() require it instead of os.makedirs'ing it; only
issue_new.py and pull.py create one, and both say so on stderr.
Establishes tests/ — plain stdlib unittest, no pytest, no dependencies.
The store tests build a throwaway repo in a TemporaryDirectory (a .git
marker, a copy of both script layers, fixture issues) and run the real
scripts inside it as subprocesses from five different working
directories; tmp/issues/ is never touched. Against the pre-fix scripts
15 of the 21 fail, reproducing the report exactly — five stray stores,
including tmp/issues/tmp/issues.
python3 -m unittest discover -s tests -v
Closes claude-skills/tea#15
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
102 lines
3.9 KiB
Python
102 lines
3.9 KiB
Python
#!/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: <repo>/tmp/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,
|
|
out_root=root)
|
|
verb = "edited"
|
|
else:
|
|
got = _gitea.api(login, "%s/issues/%d/comments" % (base, number), "POST",
|
|
{"body": body}, payload_name="comment-%s" % args.id,
|
|
out_root=root)
|
|
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()
|