Files
marketplace/plugins/tea/skills/sync/scripts/comment.py
T
naudachu fb5445915f fix: resolve the issue store from the project, not the plugin
`issue.store_root` and `_gitea.PAYLOAD_ROOT` were anchored on `__file__`, on
the reasoning that where an installation keeps its files is a fact about the
installation. That holds for an installation and not for a store.

Installed, the plugin therefore resolved every project's issues inside its own
directory — and a plugin cache is versioned, so the store moved on each
update:

    ~/.claude/plugins/cache/tea/tea/2.0.0/tmp/issues   5 files, 2 origin: local
    ~/.claude/plugins/cache/tea/tea/2.1.0/tmp/issues   12 files
    ~/.claude/plugins/cache/claude-skills/tea/2.2.0/   empty, the current one

Issues written from one project were invisible from the next, and an `origin:
local` file — which IS the issue, the only copy — was stranded a version bump
at a time. Two of them were.

The store is a fact about the project, exactly as the login pin is. So the
anchor is now an explicit marker an operator creates, `.tea/`, searched for up
from $CLAUDE_PROJECT_DIR and then cwd — the pin's order, so the two cannot
disagree about which project this is. Inferred markers were tried and are worse
than useless here: `.git` is in every clone including this plugin's own, and
the agents-sync hook writes an AGENTS.md next to every AGENTS.md, so the plugin
root always carried one and cwd never got a turn.

With no marker anywhere, `store_root()` is None and every entry point reports
which directories it searched. A store in a plausible-looking directory is the
failure this replaces, so nothing falls back to one.

- `.tea/` holds the store and the transport's scratchpad: `.tea/issues`,
  `.tea/payload`. One marker, one walk, one gitignore line.
- `issue_init.py` creates it, moves an old `tmp/issues` store in rather than
  copying, adds `.tea/` to `.gitignore`, and refuses to pick a winner when both
  sides hold the same file name.
- A linked worktree has no marker — it is gitignored — and reaches the main
  checkout's store by the hop the pin already took.
- `parents`, `gitdir_of` and `main_worktree` move from `pin.py` into the domain
  and `pin.py` imports them. The domain depends on nothing, so it is the layer
  all three callers can borrow from, and the walk stays written once: the
  guard, the transport and the store cannot disagree about a directory.

The suite stopped copying the script layers into its fixtures. That is what hid
this: with the scripts inside the fixture, the installation and the project
were the same directory. They are now deliberately far apart, and a regression
test asserts the plugin tree gains no files when commands run against a project
somewhere else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 13:38:39 +05:00

100 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: <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()