refactor: split issue domain from Gitea transport
An issue was a Gitea row that happened to be cached locally: its identity was the tracker's number (42.md), its dependencies were tracker numbers (depends: [#12]), and a local issue existed only as a draft that push deleted on success. Nothing could be planned or tracked without a tracker. Split into layers, with knowledge flowing one way: skills/issue DOMAIN what an issue is: format, validation, dep graph ^ offline; stdlib imports only, no subprocess | imports skills/sync BRIDGE map.py md <-> Gitea JSON, pure, no I/O _gitea.py login pin, api, pagination, filters skills/use REFERENCE tea CLI docs for non-issue entities skills/issue never imports skills/sync. Delete the sync layer and the domain keeps working. Identity is now a slug derived from the title (wire-sqlc-appclick.md) and is stable across retitles and pushes. Tracker numbers live in a `gitea:` field, never in a file name and never in `depends:`; the pair is indexed in .remote.json, which is a cache over the files, not a second source of truth. Behavior changes: - Pushing is additive. The file is never deleted; it gains gitea:/url:/ synced: and origin: flips from local to gitea. `origin: local` is a durable state, not a pending one. - Pushes go in topological order so dependencies get numbers first. - The dependency graph is computed offline from `depends:` metadata; body prose is passed through unchanged in both directions rather than being rewritten between slugs and #N. - `origin` is domain-owned (whether work exists elsewhere is a fact about the work); the handle and how to reach it stay with sync. Script moves: issue_get.py -> sync/pull.py issue_push.py -> sync/push.py issue_list.py -> sync/remote.py issue_index.py -> issue/issue_index.py _tea.py -> split into issue/issue.py, sync/map.py, sync/_gitea.py New: issue/issue_new.py, issue/issue_check.py, issue/issue_tree.py, and sync/comment.py — comment posting was the last issue operation still hand-rolled through raw `tea api`. references/issue-format.md moves to skills/issue/references/format.md; label hex colors move out of it into map.py, since a color is how a tracker paints a chip, not what an issue is. Verified: offline path end to end (new, check, tree, index, push --dry-run) and read-only against Gitea (remote listing, pull with mapping, comment guard). Write paths of push.py and comment.py are not exercised here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
#!/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: tmp/issues)")
|
||||
args = ap.parse_args()
|
||||
|
||||
root = args.out
|
||||
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()
|
||||
Reference in New Issue
Block a user