dkitchen 0.5.2__tar.gz → 0.5.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dkitchen
3
- Version: 0.5.2
3
+ Version: 0.5.4
4
4
  Summary: A multi-agent workflow for research repos, with the ding CLI for messaging and waking agent sessions
5
5
  Author: 917Dhj
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "dkitchen"
7
- version = "0.5.2"
7
+ version = "0.5.4"
8
8
  description = "A multi-agent workflow for research repos, with the ding CLI for messaging and waking agent sessions"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -35,6 +35,11 @@ Every key is optional. The defaults match the English templates in kitchen/:
35
35
 
36
36
  [relay]
37
37
  superseding = "New decision; it replaces earlier relays not yet carried out." # first line of a default relay
38
+
39
+ [rule] # `ding rule`: the designer's reply opens with the designer's signature,
40
+ type_label = "Type" # then a line `<type_label><separator><one of types>`
41
+ types = ["actionable", "proposal, awaiting user", "record"]
42
+ notify = "#{ticket} answered, see the ticket {url}" # message `--notify` sends; also {first_line}
38
43
  """
39
44
  from __future__ import annotations
40
45
 
@@ -105,6 +110,13 @@ class Relay:
105
110
  superseding: str = "New decision; it replaces earlier relays not yet carried out."
106
111
 
107
112
 
113
+ @dataclass(frozen=True)
114
+ class Rule:
115
+ type_label: str = "Type"
116
+ types: tuple[str, ...] = ("actionable", "proposal, awaiting user", "record")
117
+ notify: str = "#{ticket} answered, see the ticket {url}"
118
+
119
+
108
120
  SESSION_FIELDS = ("issue", "name", "repo")
109
121
 
110
122
 
@@ -139,6 +151,7 @@ class Config:
139
151
  log: Log = Log()
140
152
  sessions: Sessions = Sessions()
141
153
  relay: Relay = Relay()
154
+ rule: Rule = Rule()
142
155
 
143
156
  def repository(self) -> str:
144
157
  """The configured repository, or the one gh sees from the repository root."""
@@ -162,10 +175,18 @@ def section(cls, values: object, name: str):
162
175
  result = cls()
163
176
  for key, value in values.items():
164
177
  default = getattr(result, key)
165
- if key == "fields":
178
+ if key in {"fields", "types"}:
166
179
  if not isinstance(value, list) or not value or not all(isinstance(item, str) and item.strip() for item in value):
167
- raise ValueError(f"{FILE}: [{name}] fields must be a nonempty list of field names")
180
+ raise ValueError(f"{FILE}: [{name}] {key} must be a nonempty list of "
181
+ f"{'field names' if key == 'fields' else 'words'}")
168
182
  value = tuple(item.strip() for item in value)
183
+ elif key == "notify":
184
+ if not isinstance(value, str) or "{url}" not in value:
185
+ raise ValueError(f"{FILE}: [rule] notify must be a string holding {{url}}")
186
+ try:
187
+ value.format(ticket=1, url="u", first_line="l")
188
+ except (ValueError, KeyError, IndexError) as exc:
189
+ raise ValueError(f"{FILE}: [rule] notify is not a valid template: {exc}") from exc
169
190
  elif key == "name" and cls is Sessions:
170
191
  value = session_template(value)
171
192
  elif key == "parent" and cls is Sessions:
@@ -200,7 +221,8 @@ def load(cwd: Path | None = None) -> Config:
200
221
  data = tomllib.loads(path.read_text(encoding="utf-8"))
201
222
  except tomllib.TOMLDecodeError as exc:
202
223
  raise ValueError(f"{path}: {exc}") from exc
203
- sections = {"stations": Stations, "status": Status, "labels": Labels, "log": Log, "sessions": Sessions, "relay": Relay}
224
+ sections = {"stations": Stations, "status": Status, "labels": Labels, "log": Log, "sessions": Sessions, "relay": Relay,
225
+ "rule": Rule}
204
226
  unknown = sorted(set(data) - {"repo", *sections})
205
227
  if unknown:
206
228
  raise ValueError(f"{FILE}: unknown keys: {', '.join(unknown)}")
@@ -1705,9 +1705,45 @@ def main(argv=None) -> int:
1705
1705
  sub.add_argument("--stale-hours", type=float, default=6, help="Executor silence that counts as stale (default: 6)")
1706
1706
  sub.add_argument("--stale-every", type=float, default=3600, help="Seconds between stale checks (default: 3600)")
1707
1707
  sub.add_argument("--max-errors", type=int, default=10, help="Consecutive failed polls before exiting (default: 10)")
1708
+ sub = commands.add_parser(
1709
+ "rule", help="Post the designer's ruling on a ticket after checking it, and tell a session",
1710
+ formatter_class=argparse.RawDescriptionHelpFormatter,
1711
+ description="Post a designer ruling as a ticket comment (or, with --edit-body, as the ticket's new body).\n"
1712
+ "Before anything is sent it checks that:\n"
1713
+ " - the draft's line 1 starts with the designer's signature and line 2 with the type label\n"
1714
+ " and one of the allowed types ([stations] and [rule] in .dkitchen.toml; comments only);\n"
1715
+ " - `ding check-refs` finds no wrong reference in it;\n"
1716
+ " - with --reply-to, no comment came after the one answered (--force posts anyway); without\n"
1717
+ " it, the time and first line of the latest comment are shown as a hint;\n"
1718
+ " - with --edit-body, the ticket body still equals the --base file.\n"
1719
+ "Then it posts with gh. stdout is only the URL gh returns; everything else goes to stderr.\n"
1720
+ "With --notify it then sends the [rule] notify message (default: '#N answered, see the ticket "
1721
+ "URL')\nplus the draft's first line. A failed notification is reported, and the comment stays.",
1722
+ epilog="examples:\n"
1723
+ " ding rule 42 reply.md --reply-to https://github.com/o/r/issues/42#issuecomment-123 --notify '#42'\n"
1724
+ " gh api repos/o/r/issues/42 --jq .body > base.md # take the baseline, edit a copy, then:\n"
1725
+ " ding rule 42 new-body.md --edit-body --base base.md\n"
1726
+ "--notify reaches Codex sessions (via `ding send`: UUID, name, '#N', ticket URL). ding cannot "
1727
+ "message\nClaude Code sessions; tell them with SendMessage after the URL is printed.")
1728
+ sub.add_argument("ticket", type=int, help="Ticket number")
1729
+ sub.add_argument("draft", help="Draft file (UTF-8): the comment, or with --edit-body the new ticket body")
1730
+ sub.add_argument("--reply-to", help="Comment answered, as its link or id; stops when later comments exist")
1731
+ sub.add_argument("--force", action="store_true", help="With --reply-to: post although later comments exist")
1732
+ sub.add_argument("--notify", help="Codex session to tell, as for `ding send` (UUID, name, '#N', ticket URL)")
1733
+ sub.add_argument("--edit-body", action="store_true", help="Replace the ticket body, not add a comment; needs --base")
1734
+ sub.add_argument("--base", help="With --edit-body: file holding the body you started from; stops if the ticket's "
1735
+ "body differs from it")
1736
+ where = sub.add_mutually_exclusive_group()
1737
+ where.add_argument("--ref", default="origin/main", help="check-refs: Git revision the references point into "
1738
+ "(default: origin/main)")
1739
+ where.add_argument("--root", type=Path, help="check-refs: directory to read files from instead")
1740
+ sub.add_argument("--tolerance", type=int, default=0, help="check-refs: lines around a reference that may hold its name")
1741
+ sub.add_argument("--repo", help="Repository (default: repo in .dkitchen.toml, then the one gh sees)")
1742
+ sub.add_argument("--offline", action="store_true", help="check-refs: do not ask GitHub")
1708
1743
  sub = commands.add_parser("check-refs", help="Check `file:line`, sha256, ticket, PR, and comment references "
1709
1744
  "in a draft comment")
1710
- sub.add_argument("draft", help="Draft comment file")
1745
+ sub.add_argument("draft", nargs="+", help="Draft comment file; several files are checked one by one and "
1746
+ "the exit status is nonzero when any has a wrong reference")
1711
1747
  where = sub.add_mutually_exclusive_group()
1712
1748
  where.add_argument("--ref", default="origin/main", help="Git revision the references point into (default: origin/main)")
1713
1749
  where.add_argument("--root", type=Path, help="Directory to read files from instead, such as a worktree or output folder")
@@ -1731,6 +1767,15 @@ def main(argv=None) -> int:
1731
1767
  parser.error("--parent must be a positive issue number")
1732
1768
  if args.action == "merge" and args.ticket is not None and args.ticket <= 0:
1733
1769
  parser.error("--ticket must be a positive issue number")
1770
+ if args.action == "rule":
1771
+ if args.ticket <= 0:
1772
+ parser.error("ticket must be a positive issue number")
1773
+ if args.edit_body and (args.reply_to or args.force):
1774
+ parser.error("--edit-body edits the ticket body; --reply-to and --force belong to comments")
1775
+ if args.edit_body != (args.base is not None):
1776
+ parser.error("--edit-body and --base go together")
1777
+ if args.force and not args.reply_to:
1778
+ parser.error("--force goes with --reply-to")
1734
1779
  if args.action in {"run", "launch"} and not command:
1735
1780
  parser.error(f"{args.action} requires a task command after --")
1736
1781
  try:
@@ -1751,6 +1796,9 @@ def main(argv=None) -> int:
1751
1796
  settings.sessions.parent.format(parent=args.parent))
1752
1797
  args.name = settings.sessions.session_name(issue=args.issue, name=args.name, repo=args.repo)
1753
1798
  return new_task(args)
1799
+ if args.action == "rule":
1800
+ from dkitchen import config, rule
1801
+ return rule.rule(args, config.load())
1754
1802
  if args.action == "check-refs":
1755
1803
  from dkitchen import refs
1756
1804
  return refs.check_refs(args)
@@ -221,21 +221,36 @@ def default_repository() -> str | None:
221
221
  return None
222
222
 
223
223
 
224
- def check_refs(args) -> int:
225
- github = None if getattr(args, "offline", False) else GitHub(getattr(args, "repo", None), default=default_repository)
224
+ def report(path: str, args, source: Source, github: GitHub | None, many: bool, emit_json: bool = True) -> int:
225
+ """Check one draft and report it on stderr (and as a JSON line on stdout); 1 when something is wrong, 2 when
226
+ the draft cannot be read. With several drafts every line names its file."""
227
+ label = f"{path}: " if many else ""
226
228
  counts: dict[str, int] = {}
227
229
  try:
228
- findings, notes, checked = check(Path(args.draft).read_text(encoding="utf-8"), Source(args.ref, args.root),
229
- args.tolerance, github, counts)
230
+ findings, notes, checked = check(Path(path).read_text(encoding="utf-8"), source, args.tolerance, github, counts)
230
231
  except (OSError, subprocess.CalledProcessError) as error:
231
232
  print(f"ding: {error}", file=sys.stderr)
232
233
  return 2
233
234
  for line in findings:
234
- print(f"wrong: {line}", file=sys.stderr)
235
+ print(f"wrong: {label}{line}", file=sys.stderr)
235
236
  for line in notes:
236
- print(f"not checked: {line}", file=sys.stderr)
237
+ print(f"not checked: {label}{line}", file=sys.stderr)
237
238
  kinds = ", ".join(f"{counts[kind]} {kind}" for kind in KINDS if counts.get(kind)) or "none"
238
- print(f"checked {checked} references ({kinds}): {len(findings)} wrong, {len(notes)} not checked", file=sys.stderr)
239
- print(json.dumps({"checked": checked, "findings": len(findings), "unchecked": len(notes),
240
- "by_kind": counts, "wrong": findings, "not_checked": notes}, ensure_ascii=False))
239
+ print(f"{label}checked {checked} references ({kinds}): {len(findings)} wrong, {len(notes)} not checked", file=sys.stderr)
240
+ if emit_json:
241
+ print(json.dumps({**({"draft": path} if many else {}), "checked": checked, "findings": len(findings),
242
+ "unchecked": len(notes), "by_kind": counts, "wrong": findings, "not_checked": notes},
243
+ ensure_ascii=False))
241
244
  return int(bool(findings))
245
+
246
+
247
+ def check_refs(args, emit_json: bool = True) -> int:
248
+ """Report each draft in turn; 0 when none has a wrong reference, 1 when one does, 2 when one cannot be read."""
249
+ drafts = [args.draft] if isinstance(args.draft, (str, Path)) else list(args.draft)
250
+ github = None if getattr(args, "offline", False) else GitHub(getattr(args, "repo", None), default=default_repository)
251
+ try:
252
+ source = Source(args.ref, args.root)
253
+ except OSError as error:
254
+ print(f"ding: {error}", file=sys.stderr)
255
+ return 2
256
+ return max(report(path, args, source, github, len(drafts) > 1, emit_json) for path in drafts)
@@ -0,0 +1,189 @@
1
+ """Post the designer's ruling on a ticket: check the draft, guard against overwriting, post, and notify.
2
+
3
+ Steps, each stopping the command before anything is sent when it fails:
4
+ - the draft opens with the designer's signature and a type line (`[rule]` in .dkitchen.toml);
5
+ - `ding check-refs` finds nothing wrong in it;
6
+ - with --reply-to, no comment came after the one answered (--force posts anyway); without it, the latest
7
+ comment is shown as a hint;
8
+ - with --edit-body, the ticket body still equals the baseline file (--base).
9
+ Then it posts with gh and prints only the URL gh returns. A failed notification is reported but never undoes
10
+ the comment.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import contextlib
15
+ import difflib
16
+ import json
17
+ import re
18
+ import sys
19
+ from types import SimpleNamespace
20
+
21
+ from dkitchen import ding, refs
22
+ from dkitchen.config import Config
23
+ from dkitchen.orchestrate import call, fail
24
+
25
+ CLAUDE_ADDRESS = re.compile(r"^(?:uds|bridge|did):|^local_")
26
+ COMMENT_URL = re.compile(r"^https://\S+#issuecomment-\d+$")
27
+ COMMENT_ID = re.compile(r"(?:#issuecomment-)?(\d+)$")
28
+
29
+
30
+ def first_line(text: str) -> str:
31
+ return next((line.strip() for line in text.splitlines() if line.strip()), "")[:100]
32
+
33
+
34
+ def format_problem(draft: str, config: Config) -> str | None:
35
+ """Why the draft's first two lines are wrong, or None when they are right."""
36
+ stations, rule = config.stations, config.rule
37
+ lines = draft.split("\n")
38
+ signature = stations.signature(stations.designer)
39
+ if not lines[0].startswith(signature):
40
+ return f"line 1 must start with {signature!r}, not {lines[0][:40]!r}"
41
+ label = f"{rule.type_label}{stations.separator}"
42
+ if len(lines) < 2 or not lines[1].startswith(label):
43
+ return f"line 2 must start with {label!r}, not {(lines[1] if len(lines) > 1 else '')[:40]!r}"
44
+ kind = lines[1][len(label):].strip()
45
+ if kind not in rule.types:
46
+ return f"line 2 type {kind!r} must be one of: {', '.join(rule.types)}"
47
+ return None
48
+
49
+
50
+ def comments_of(config: Config, repo: str, ticket: int) -> list[dict]:
51
+ result = call(config, "gh", "api", "--paginate", "--slurp", f"repos/{repo}/issues/{ticket}/comments?per_page=100")
52
+ if result.returncode:
53
+ raise RuntimeError(result.stderr.strip() or "cannot read the ticket's comments")
54
+ return [comment for page in json.loads(result.stdout) for comment in page]
55
+
56
+
57
+ def comment_id(reply_to: str, repo: str, ticket: int) -> int:
58
+ """The id in a comment URL or a bare id; refuses a link to another ticket."""
59
+ if reply_to.startswith("http"):
60
+ link = refs.LINK.fullmatch(reply_to.strip())
61
+ if link is None or not link.group(4) or not link.group(4).startswith("issuecomment-"):
62
+ raise ValueError(f"--reply-to {reply_to!r} is not an issue comment link")
63
+ if (link.group(1), int(link.group(3))) != (repo, ticket):
64
+ raise ValueError(f"--reply-to {reply_to!r} is not on {repo}#{ticket}")
65
+ found = COMMENT_ID.search(reply_to.strip())
66
+ if found is None:
67
+ raise ValueError(f"--reply-to {reply_to!r} is neither a comment link nor a comment id")
68
+ return int(found.group(1))
69
+
70
+
71
+ def guard_reply(config: Config, repo: str, ticket: int, args) -> int | None:
72
+ """Stop (1) when a comment came after the one answered; otherwise show what the ticket's last comment is."""
73
+ comments = comments_of(config, repo, ticket)
74
+ if args.reply_to is None:
75
+ if comments:
76
+ latest = comments[-1]
77
+ print(f"latest comment on #{ticket}: {latest['created_at']} {first_line(latest['body'])!r} "
78
+ f"({latest['html_url']})", file=sys.stderr)
79
+ else:
80
+ print(f"#{ticket} has no comments yet", file=sys.stderr)
81
+ return None
82
+ target = comment_id(args.reply_to, repo, ticket)
83
+ ids = [comment["id"] for comment in comments]
84
+ if target not in ids:
85
+ raise ValueError(f"comment {target} is not among the comments of {repo}#{ticket}")
86
+ newer = comments[ids.index(target) + 1:]
87
+ if not newer:
88
+ return None
89
+ for comment in newer:
90
+ print(f"newer: {comment['html_url']} {first_line(comment['body'])!r}", file=sys.stderr)
91
+ if getattr(args, "force", False):
92
+ print(f"{len(newer)} newer comment(s) after the one answered; posting anyway (--force)", file=sys.stderr)
93
+ return None
94
+ return fail(f"{len(newer)} comment(s) came after the one you answer; read them, then rerun (--force posts anyway). "
95
+ "Nothing posted")
96
+
97
+
98
+ def notify(config: Config, args, ticket: int, url: str, draft: str) -> int:
99
+ message = config.rule.notify.format(ticket=ticket, url=url, first_line=first_line(draft))
100
+ if "{first_line}" not in config.rule.notify:
101
+ message += "\n" + first_line(draft)
102
+ send = ["send", args.notify, "--from", config.stations.designer, "--message", message]
103
+ try:
104
+ with contextlib.redirect_stdout(sys.stderr):
105
+ code = ding.main(send)
106
+ except SystemExit as error:
107
+ code = error.code or 1
108
+ if code:
109
+ return fail(f"posted {url}, but the notification to {args.notify} failed (ding send exit {code}); "
110
+ "the comment stays. Send the message by hand")
111
+ return 0
112
+
113
+
114
+ def normal(text: str) -> str:
115
+ return text.replace("\r\n", "\n").rstrip("\r\n")
116
+
117
+
118
+ def edit_body(config: Config, repo: str, ticket: int, args, draft: str) -> int:
119
+ """Replace the ticket body when it still equals the --base file, then check what was written back."""
120
+ path = f"repos/{repo}/issues/{ticket}"
121
+
122
+ def current() -> str:
123
+ result = call(config, "gh", "api", path, "--jq", ".body")
124
+ if result.returncode:
125
+ raise RuntimeError(result.stderr.strip() or f"cannot read {repo}#{ticket}")
126
+ return result.stdout[:-1] if result.stdout.endswith("\n") else result.stdout
127
+
128
+ body = current()
129
+ with open(args.base, encoding="utf-8") as handle:
130
+ base = handle.read()
131
+ if normal(body) != normal(base):
132
+ changes = [line for line in difflib.unified_diff(normal(base).split("\n"), normal(body).split("\n"),
133
+ "--base", "ticket now", lineterm="", n=0)
134
+ if line[:1] in "+-" and line[:3] not in {"+++", "---"}][:6]
135
+ print("\n".join(changes), file=sys.stderr)
136
+ return fail(f"the body of {repo}#{ticket} changed since {args.base}; merge the change into the draft and "
137
+ "take a new baseline. Nothing edited")
138
+ result = call(config, "gh", "issue", "edit", str(ticket), "--repo", repo, "--body-file", args.draft)
139
+ if result.returncode:
140
+ return fail(f"edit failed: {result.stderr.strip()}")
141
+ url = result.stdout.strip()
142
+ if current().rstrip("\n").encode("utf-8") != draft.rstrip("\n").encode("utf-8"):
143
+ return fail(f"edited {url}, but the body read back differs from the draft; check the ticket before relying on it")
144
+ print(url)
145
+ return 0 if args.notify is None else notify(config, args, ticket, url, draft)
146
+
147
+
148
+ def rule(args, config: Config) -> int:
149
+ ticket = args.ticket
150
+ if args.notify and CLAUDE_ADDRESS.search(args.notify):
151
+ return fail(f"--notify {args.notify}: ding cannot message a Claude Code session; use SendMessage for it. "
152
+ "Nothing posted")
153
+ try:
154
+ with open(args.draft, encoding="utf-8") as handle:
155
+ draft = handle.read()
156
+ except (OSError, UnicodeDecodeError) as error:
157
+ return fail(str(error))
158
+ if not draft.strip():
159
+ return fail(f"{args.draft} is empty")
160
+ if not args.edit_body:
161
+ problem = format_problem(draft, config)
162
+ if problem:
163
+ return fail(f"{args.draft}: {problem}. Nothing posted")
164
+ try:
165
+ repo = args.repo or config.repository()
166
+ args.repo = repo
167
+ if refs.check_refs(_refs_args(args), emit_json=False):
168
+ return fail("check-refs found wrong or unreadable references; fix the draft. Nothing posted")
169
+ if args.edit_body:
170
+ return edit_body(config, repo, ticket, args, draft)
171
+ stop = guard_reply(config, repo, ticket, args)
172
+ if stop is not None:
173
+ return stop
174
+ result = call(config, "gh", "issue", "comment", str(ticket), "--repo", repo, "--body-file", args.draft)
175
+ except (RuntimeError, ValueError, OSError, KeyError, TypeError) as error:
176
+ return fail(f"{error}. Nothing posted")
177
+ url = result.stdout.strip()
178
+ if result.returncode:
179
+ return fail(f"comment failed: {result.stderr.strip()}")
180
+ if not COMMENT_URL.match(url):
181
+ return fail(f"gh did not print a comment link (got {url!r}); look at the ticket before posting again")
182
+ print(url)
183
+ return 0 if args.notify is None else notify(config, args, ticket, url, draft)
184
+
185
+
186
+ def _refs_args(args):
187
+ """What `check_refs` reads from the options `rule` shares with `check-refs`."""
188
+ return SimpleNamespace(draft=[args.draft], ref=args.ref, root=args.root, tolerance=args.tolerance,
189
+ repo=args.repo, offline=args.offline)
@@ -63,7 +63,8 @@ def issue_number(url: str) -> int:
63
63
 
64
64
  def role(body: str, config: Config) -> str:
65
65
  """The station named at the start of the first line; otherwise the executor by a line starting with its
66
- session signature; else the user. A session signature quoted inside a line signs nothing."""
66
+ session signature (`Codex session:`, or `Claude session:` for an executor run in Claude Code); else the user.
67
+ A session signature quoted inside a line signs nothing."""
67
68
  stations = config.stations
68
69
  first = next((line for line in body.splitlines() if line.strip()), "")
69
70
  first = re.sub(r"^[\s#*>]+", "", first)
@@ -71,7 +72,7 @@ def role(body: str, config: Config) -> str:
71
72
  if first.startswith(name)), None)
72
73
  if signed:
73
74
  return signed
74
- return stations.executor if re.search(r"(?m)^[ \t]*Codex session:", body) else stations.user
75
+ return stations.executor if re.search(r"(?m)^[ \t]*(?:Codex|Claude) session:", body) else stations.user
75
76
 
76
77
 
77
78
  def fields(body: str, config: Config) -> dict[str, str]:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dkitchen
3
- Version: 0.5.2
3
+ Version: 0.5.4
4
4
  Summary: A multi-agent workflow for research repos, with the ding CLI for messaging and waking agent sessions
5
5
  Author: 917Dhj
6
6
  License-Expression: MIT
@@ -6,6 +6,7 @@ src/dkitchen/config.py
6
6
  src/dkitchen/ding.py
7
7
  src/dkitchen/orchestrate.py
8
8
  src/dkitchen/refs.py
9
+ src/dkitchen/rule.py
9
10
  src/dkitchen/watch.py
10
11
  src/dkitchen.egg-info/PKG-INFO
11
12
  src/dkitchen.egg-info/SOURCES.txt
@@ -7,7 +7,7 @@ from types import SimpleNamespace
7
7
 
8
8
  import pytest
9
9
 
10
- from dkitchen import config, ding, orchestrate, refs, watch
10
+ from dkitchen import config, ding, orchestrate, refs, rule, watch
11
11
 
12
12
 
13
13
  CHINESE = """
@@ -246,6 +246,22 @@ def test_merge_finds_the_one_ticket_with_executor_comments(tmp_path, monkeypatch
246
246
  f"repos/o/r/issues/{number}/comments?per_page=100" for number in tickets]
247
247
 
248
248
 
249
+ CLAUDE = "Claude session: ToM agent person experience record implementation"
250
+
251
+
252
+ @pytest.mark.parametrize("bodies, code", [
253
+ ((f"状态:进行中\n在等:总编排审合PR7\n关键数字:1\n下一步:合入后冒烟\n{CLAUDE}",), 0),
254
+ ((WAITING, f"状态:进行中\n在等:无\n关键数字:1\n下一步:还在改PR7\n{CLAUDE}"), 1),
255
+ ((f"状态:完成\n在等:无\n关键数字:1\n下一步:交给执行者\n{CLAUDE}", WAITING), 0),
256
+ ])
257
+ def test_merge_reads_claude_and_codex_executors_by_the_latest_comment(tmp_path, monkeypatch, capsys, bodies, code):
258
+ loaded = settings(tmp_path, CHINESE)
259
+ responses = [view(), thread_comments("总设计:答复", *bodies)] + ([(0, ""), view("MERGED")] if code == 0 else [])
260
+ monkeypatch.setattr(orchestrate, "call", fake_call(responses, []))
261
+ assert orchestrate.merge(SimpleNamespace(pr=7, sha="abc", ticket=1020, no_ticket=False), loaded) == code
262
+ assert f"#1020 latest executor status (https://c/{len(bodies)}):" in capsys.readouterr().out
263
+
264
+
249
265
  def test_merge_refuses_a_ticket_without_executor_comments(tmp_path, monkeypatch, capsys):
250
266
  loaded = settings(tmp_path, CHINESE)
251
267
  monkeypatch.setattr(orchestrate, "call", fake_call([view(), thread_comments("总设计:答复")], []))
@@ -468,4 +484,238 @@ def test_a_quoted_session_signature_signs_nothing(tmp_path, text):
468
484
  "| request body | ok: it starts with the status block and contains `Codex session:` |\n")
469
485
  assert watch.role(verifier, loaded) == stations.verifier
470
486
  assert watch.role("I checked it; the body has `Codex session:` in it.", loaded) == stations.user
487
+ assert watch.role("Quoting `Claude session:` signs nothing.", loaded) == stations.user
488
+ assert watch.role("Status: done\nWaiting on: none\n\nClaude session: impl", loaded) == stations.executor
471
489
  assert watch.role(executor("Status: done\nWaiting on: none\nKey numbers: 1\nNext: merge"), loaded) == stations.executor
490
+
491
+
492
+ RULING = CHINESE + """
493
+ [rule]
494
+ type_label = "类型"
495
+ types = ["可执行", "方案,等用户", "记录"]
496
+ notify = "#{ticket} 已答复,见票 {url}"
497
+ """
498
+ REPLY = "总设计:结论\n类型:可执行\n\n正文 见 `pkg/work.py:9` holds `Ledger`\n"
499
+ URL = "https://github.com/o/r/issues/42#issuecomment-900"
500
+
501
+
502
+ def comment_pages(*bodies):
503
+ return 0, json.dumps([[{"id": 100 + index, "body": body, "created_at": f"2026-10-28T0{index}:00:00Z",
504
+ "html_url": f"https://github.com/o/r/issues/42#issuecomment-{100 + index}"}
505
+ for index, body in enumerate(bodies)]])
506
+
507
+
508
+ @pytest.fixture
509
+ def ruling(tree, tmp_path, monkeypatch):
510
+ """`ding rule` run offline in a Chinese-vocabulary repo whose gh and ding send are doubles."""
511
+ (tmp_path / config.FILE).write_text(RULING, encoding="utf-8")
512
+ monkeypatch.chdir(tmp_path)
513
+ state = SimpleNamespace(calls=[], sends=[], send_code=0, draft=tmp_path / "draft.md")
514
+ state.draft.write_text(REPLY, encoding="utf-8")
515
+
516
+ def run(responses, *options, text=None):
517
+ if text is not None:
518
+ state.draft.write_text(text, encoding="utf-8")
519
+ monkeypatch.setattr(rule, "call", fake_call(list(responses), state.calls))
520
+ return real_main(["rule", "42", str(state.draft), "--root", str(tree), "--offline", *options])
521
+
522
+ real_main = ding.main
523
+
524
+ def main(argv):
525
+ if argv[0] != "send":
526
+ return real_main(argv)
527
+ state.sends.append(argv)
528
+ print("started")
529
+ return state.send_code
530
+
531
+ monkeypatch.setattr(ding, "main", main)
532
+
533
+ state.run = run
534
+ return state
535
+
536
+
537
+ def test_rule_posts_and_prints_only_the_url(ruling, capsys):
538
+ assert ruling.run([comment_pages("用户:请裁定", "核查:通过"), (0, URL + "\n")], "--reply-to", "101") == 0
539
+ captured = capsys.readouterr()
540
+ assert captured.out == URL + "\n"
541
+ assert "latest comment" not in captured.err
542
+ assert ruling.calls[0][:4] == ("gh", "api", "--paginate", "--slurp")
543
+ assert ruling.calls[1] == ("gh", "issue", "comment", "42", "--repo", "o/r", "--body-file", str(ruling.draft))
544
+ assert ruling.sends == []
545
+
546
+
547
+ def test_rule_without_reply_to_shows_the_latest_comment(ruling, capsys):
548
+ assert ruling.run([comment_pages("用户:请裁定", "核查:通过\n第二行"), (0, URL)]) == 0
549
+ captured = capsys.readouterr()
550
+ assert captured.out == URL + "\n"
551
+ assert "latest comment on #42: 2026-10-28T01:00:00Z '核查:通过'" in captured.err
552
+
553
+
554
+ @pytest.mark.parametrize("text, message", [
555
+ ("核查:结论\n类型:可执行\n", "line 1 must start with '总设计:'"),
556
+ ("总设计:结论\n", "line 2 must start with '类型:'"),
557
+ ("总设计:结论\n正文\n类型:可执行\n", "line 2 must start with '类型:'"),
558
+ ("总设计:结论\n类型:大概可以\n", "must be one of: 可执行, 方案,等用户, 记录"),
559
+ ])
560
+ def test_rule_stops_when_the_first_two_lines_are_wrong(ruling, capsys, text, message):
561
+ assert ruling.run([], text=text) == 1
562
+ assert message in capsys.readouterr().err
563
+ assert ruling.calls == [] and ruling.sends == []
564
+
565
+
566
+ def test_rule_accepts_each_type(ruling, capsys):
567
+ for kind in ("方案,等用户", "记录"):
568
+ assert ruling.run([comment_pages("用户:问"), (0, URL)], text=f"总设计:结论\n类型:{kind}\n") == 0
569
+
570
+
571
+ def test_rule_stops_on_a_wrong_reference_and_posts_nothing(ruling, capsys):
572
+ assert ruling.run([], text="总设计:结论\n类型:可执行\n`pkg/work.py:6` holds `Ledger`\n") == 1
573
+ captured = capsys.readouterr()
574
+ assert "wrong: line 3 `pkg/work.py:6`" in captured.err and "Nothing posted" in captured.err
575
+ assert captured.out == "" and ruling.calls == [] and ruling.sends == []
576
+
577
+
578
+ def test_rule_stops_when_a_comment_came_after_the_one_answered(ruling, capsys):
579
+ pages = comment_pages("用户:请裁定", "用户:补充一点", "核查:新数字")
580
+ link = "https://github.com/o/r/issues/42#issuecomment-100"
581
+ assert ruling.run([pages], "--reply-to", link, "--notify", "#42") == 1
582
+ captured = capsys.readouterr()
583
+ assert captured.out == "" and len(ruling.calls) == 1 and ruling.sends == []
584
+ assert "newer: https://github.com/o/r/issues/42#issuecomment-101 '用户:补充一点'" in captured.err
585
+ assert "newer: https://github.com/o/r/issues/42#issuecomment-102 '核查:新数字'" in captured.err
586
+ assert "--force" in captured.err
587
+
588
+
589
+ def test_rule_force_posts_despite_newer_comments(ruling, capsys):
590
+ assert ruling.run([comment_pages("用户:请裁定", "用户:补充一点"), (0, URL)], "--reply-to", "100", "--force") == 0
591
+ captured = capsys.readouterr()
592
+ assert captured.out == URL + "\n" and "posting anyway" in captured.err
593
+
594
+
595
+ @pytest.mark.parametrize("reply_to, message", [
596
+ ("999", "comment 999 is not among"),
597
+ ("https://github.com/o/r/issues/43#issuecomment-100", "is not on o/r#42"),
598
+ ("https://github.com/o/r/issues/42", "not an issue comment link"),
599
+ ])
600
+ def test_rule_refuses_a_reply_target_that_is_not_on_the_ticket(ruling, capsys, reply_to, message):
601
+ assert ruling.run([comment_pages("用户:请裁定")], "--reply-to", reply_to) == 1
602
+ assert message in capsys.readouterr().err and len(ruling.calls) <= 1
603
+
604
+
605
+ def test_rule_reports_a_failed_notification_but_keeps_the_comment(ruling, capsys):
606
+ ruling.send_code = 1
607
+ assert ruling.run([comment_pages("用户:请裁定"), (0, URL)], "--reply-to", "100", "--notify", "#42") == 1
608
+ captured = capsys.readouterr()
609
+ assert captured.out == URL + "\n"
610
+ assert "the comment stays" in captured.err and "failed" in captured.err
611
+ assert ruling.sends == [["send", "#42", "--from", "总设计", "--message", f"#42 已答复,见票 {URL}\n总设计:结论"]]
612
+ assert [call[:3] for call in ruling.calls] == [("gh", "api", "--paginate"), ("gh", "issue", "comment")]
613
+
614
+
615
+ def test_rule_notifies_a_codex_session_and_keeps_stdout_clean(ruling, capsys):
616
+ assert ruling.run([comment_pages("用户:请裁定"), (0, URL)], "--notify", "00000000-0000-4000-8000-000000000001") == 0
617
+ captured = capsys.readouterr()
618
+ assert captured.out == URL + "\n" and "started" in captured.err
619
+ assert ruling.sends[0][:2] == ["send", "00000000-0000-4000-8000-000000000001"]
620
+
621
+
622
+ def test_rule_refuses_a_claude_session_before_posting(ruling, capsys):
623
+ assert ruling.run([], "--notify", "uds:/tmp/cc-socks/1.sock") == 1
624
+ assert "cannot message a Claude Code session" in capsys.readouterr().err
625
+ assert ruling.calls == [] and ruling.sends == []
626
+
627
+
628
+ def test_rule_reports_a_failed_post_and_a_non_url_answer(ruling, capsys):
629
+ assert ruling.run([comment_pages("用户:问"), (1, "")]) == 1
630
+ assert "comment failed: boom" in capsys.readouterr().err
631
+ assert ruling.run([comment_pages("用户:问"), (0, "created\n")], "--notify", "#42") == 1
632
+ assert "did not print a comment link" in capsys.readouterr().err and ruling.sends == []
633
+
634
+
635
+ BODY = "票面\n第二行\n"
636
+
637
+
638
+ def test_edit_body_writes_only_over_the_baseline_and_verifies(ruling, tmp_path, capsys):
639
+ base = tmp_path / "base.md"
640
+ base.write_text(BODY, encoding="utf-8")
641
+ new = "新票面 `pkg/work.py:9` holds `Ledger`\n"
642
+ responses = [(0, BODY.replace("\n", "\r\n") + "\n"), (0, "https://github.com/o/r/issues/42\n"), (0, new)]
643
+ assert ruling.run(responses, "--edit-body", "--base", str(base), text=new) == 0
644
+ assert capsys.readouterr().out == "https://github.com/o/r/issues/42\n"
645
+ assert ruling.calls[0] == ("gh", "api", "repos/o/r/issues/42", "--jq", ".body")
646
+ assert ruling.calls[1] == ("gh", "issue", "edit", "42", "--repo", "o/r", "--body-file", str(ruling.draft))
647
+
648
+
649
+ def test_edit_body_stops_when_the_body_changed(ruling, tmp_path, capsys):
650
+ base = tmp_path / "base.md"
651
+ base.write_text(BODY, encoding="utf-8")
652
+ assert ruling.run([(0, "票面\n别人改了\n")], "--edit-body", "--base", str(base), text="新票面\n") == 1
653
+ captured = capsys.readouterr()
654
+ assert "+别人改了" in captured.err and "Nothing edited" in captured.err and len(ruling.calls) == 1
655
+
656
+
657
+ def test_edit_body_reports_a_body_that_reads_back_differently(ruling, tmp_path, capsys):
658
+ base = tmp_path / "base.md"
659
+ base.write_text(BODY, encoding="utf-8")
660
+ responses = [(0, BODY), (0, "https://github.com/o/r/issues/42\n"), (0, "新票面 被改了\n")]
661
+ assert ruling.run(responses, "--edit-body", "--base", str(base), text="新票面\n") == 1
662
+ captured = capsys.readouterr()
663
+ assert captured.out == "" and "read back differs" in captured.err
664
+
665
+
666
+ @pytest.mark.parametrize("options, error", [
667
+ (["--edit-body"], "--edit-body and --base go together"),
668
+ (["--base", "x"], "--edit-body and --base go together"),
669
+ (["--edit-body", "--base", "x", "--reply-to", "1"], "belong to comments"),
670
+ (["--force"], "--force goes with --reply-to"),
671
+ ])
672
+ def test_rule_option_combinations(capsys, options, error):
673
+ with pytest.raises(SystemExit) as exc:
674
+ ding.main(["rule", "42", "draft.md", *options])
675
+ assert exc.value.code == 2 and error in capsys.readouterr().err
676
+
677
+
678
+ def test_rule_help_explains_the_steps(capsys):
679
+ with pytest.raises(SystemExit):
680
+ ding.main(["rule", "--help"])
681
+ out = capsys.readouterr().out
682
+ for word in ("--reply-to", "--notify", "--edit-body", "--base", "--force", "check-refs", "SendMessage"):
683
+ assert word in out
684
+
685
+
686
+ def test_check_refs_reports_each_draft_and_fails_when_any_is_wrong(tree, capsys):
687
+ good, bad = tree / "good.md", tree / "bad.md"
688
+ good.write_text("`pkg/work.py:9` holds `Ledger`\n")
689
+ bad.write_text("`pkg/work.py:6` holds `Ledger`\n")
690
+ assert ding.main(["check-refs", str(good), "--root", str(tree)]) == 0
691
+ assert "draft" not in json.loads(capsys.readouterr().out)
692
+ assert ding.main(["check-refs", str(good), str(bad), str(good), "--root", str(tree)]) == 1
693
+ captured = capsys.readouterr()
694
+ reports = [json.loads(line) for line in captured.out.splitlines()]
695
+ assert [(r["draft"], r["findings"]) for r in reports] == [(str(good), 0), (str(bad), 1), (str(good), 0)]
696
+ assert f"wrong: {bad}: line 1 `pkg/work.py:6`" in captured.err
697
+ assert f"{good}: checked 1 references (1 file:line): 0 wrong" in captured.err
698
+
699
+
700
+ def test_check_refs_keeps_going_past_an_unreadable_draft(tree, capsys):
701
+ good = tree / "good.md"
702
+ good.write_text("`pkg/work.py:9` holds `Ledger`\n")
703
+ assert ding.main(["check-refs", str(tree / "missing.md"), str(good), "--root", str(tree)]) == 2
704
+ captured = capsys.readouterr()
705
+ assert "missing.md" in captured.err and json.loads(captured.out)["draft"] == str(good)
706
+
707
+
708
+ @pytest.mark.parametrize("text, error", [
709
+ ("[rule]\ntypes = []", "types must be a nonempty list"),
710
+ ("[rule]\nnotify = 'no link'", "holding {url}"),
711
+ ("[rule]\nnotify = '{url} {oops}'", "not a valid template"),
712
+ ])
713
+ def test_rule_config_is_checked(tmp_path, text, error):
714
+ with pytest.raises(ValueError, match=error):
715
+ settings(tmp_path, text)
716
+
717
+
718
+ def test_rule_defaults_are_english(tmp_path):
719
+ loaded = settings(tmp_path)
720
+ assert loaded.rule.type_label == "Type" and "actionable" in loaded.rule.types
721
+ assert rule.format_problem("Designer: x\nType: record\n", loaded) is None
File without changes
File without changes
File without changes
File without changes