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.
- {dkitchen-0.5.2/src/dkitchen.egg-info → dkitchen-0.5.4}/PKG-INFO +1 -1
- {dkitchen-0.5.2 → dkitchen-0.5.4}/pyproject.toml +1 -1
- {dkitchen-0.5.2 → dkitchen-0.5.4}/src/dkitchen/config.py +25 -3
- {dkitchen-0.5.2 → dkitchen-0.5.4}/src/dkitchen/ding.py +49 -1
- {dkitchen-0.5.2 → dkitchen-0.5.4}/src/dkitchen/refs.py +24 -9
- dkitchen-0.5.4/src/dkitchen/rule.py +189 -0
- {dkitchen-0.5.2 → dkitchen-0.5.4}/src/dkitchen/watch.py +3 -2
- {dkitchen-0.5.2 → dkitchen-0.5.4/src/dkitchen.egg-info}/PKG-INFO +1 -1
- {dkitchen-0.5.2 → dkitchen-0.5.4}/src/dkitchen.egg-info/SOURCES.txt +1 -0
- {dkitchen-0.5.2 → dkitchen-0.5.4}/tests/test_kitchen.py +251 -1
- {dkitchen-0.5.2 → dkitchen-0.5.4}/LICENSE +0 -0
- {dkitchen-0.5.2 → dkitchen-0.5.4}/README.md +0 -0
- {dkitchen-0.5.2 → dkitchen-0.5.4}/setup.cfg +0 -0
- {dkitchen-0.5.2 → dkitchen-0.5.4}/src/dkitchen/__init__.py +0 -0
- {dkitchen-0.5.2 → dkitchen-0.5.4}/src/dkitchen/orchestrate.py +0 -0
- {dkitchen-0.5.2 → dkitchen-0.5.4}/src/dkitchen.egg-info/dependency_links.txt +0 -0
- {dkitchen-0.5.2 → dkitchen-0.5.4}/src/dkitchen.egg-info/entry_points.txt +0 -0
- {dkitchen-0.5.2 → dkitchen-0.5.4}/src/dkitchen.egg-info/requires.txt +0 -0
- {dkitchen-0.5.2 → dkitchen-0.5.4}/src/dkitchen.egg-info/top_level.txt +0 -0
- {dkitchen-0.5.2 → dkitchen-0.5.4}/tests/test_codex_session.py +0 -0
- {dkitchen-0.5.2 → dkitchen-0.5.4}/tests/test_records.py +0 -0
- {dkitchen-0.5.2 → dkitchen-0.5.4}/tests/test_reference_scripts.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "dkitchen"
|
|
7
|
-
version = "0.5.
|
|
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
|
|
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}]
|
|
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
|
|
225
|
-
|
|
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(
|
|
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
|
-
|
|
240
|
-
|
|
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
|
|
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]:
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|