devex-cli 0.24.0__py3-none-any.whl

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.
Files changed (115) hide show
  1. agent_experience/__init__.py +24 -0
  2. agent_experience/__main__.py +4 -0
  3. agent_experience/backends/__init__.py +0 -0
  4. agent_experience/backends/acp/__init__.py +0 -0
  5. agent_experience/backends/acp/probe.py +9 -0
  6. agent_experience/backends/capabilities/acp.yaml +7 -0
  7. agent_experience/backends/capabilities/claude-code.yaml +4 -0
  8. agent_experience/backends/capabilities/codex.yaml +7 -0
  9. agent_experience/backends/capabilities/copilot.yaml +7 -0
  10. agent_experience/backends/claude_code/__init__.py +0 -0
  11. agent_experience/backends/claude_code/probe.py +97 -0
  12. agent_experience/backends/codex/__init__.py +0 -0
  13. agent_experience/backends/codex/probe.py +16 -0
  14. agent_experience/backends/copilot/__init__.py +0 -0
  15. agent_experience/backends/copilot/probe.py +9 -0
  16. agent_experience/cli.py +485 -0
  17. agent_experience/commands/__init__.py +0 -0
  18. agent_experience/commands/doctor/SKILL.md +41 -0
  19. agent_experience/commands/doctor/__init__.py +0 -0
  20. agent_experience/commands/doctor/assets/report.md.j2 +39 -0
  21. agent_experience/commands/doctor/references/design.md +36 -0
  22. agent_experience/commands/doctor/scripts/__init__.py +0 -0
  23. agent_experience/commands/doctor/scripts/doctor.py +394 -0
  24. agent_experience/commands/explain/SKILL.md +26 -0
  25. agent_experience/commands/explain/__init__.py +0 -0
  26. agent_experience/commands/explain/assets/topics/agex.md +37 -0
  27. agent_experience/commands/explain/references/.gitkeep +0 -0
  28. agent_experience/commands/explain/scripts/__init__.py +0 -0
  29. agent_experience/commands/explain/scripts/explain.py +64 -0
  30. agent_experience/commands/gamify/SKILL.md +31 -0
  31. agent_experience/commands/gamify/__init__.py +0 -0
  32. agent_experience/commands/gamify/assets/hooks/claude-code.json +28 -0
  33. agent_experience/commands/gamify/references/.gitkeep +0 -0
  34. agent_experience/commands/gamify/scripts/__init__.py +0 -0
  35. agent_experience/commands/gamify/scripts/install.py +203 -0
  36. agent_experience/commands/hook/SKILL.md +31 -0
  37. agent_experience/commands/hook/__init__.py +0 -0
  38. agent_experience/commands/hook/assets/table.md.j2 +17 -0
  39. agent_experience/commands/hook/references/.gitkeep +0 -0
  40. agent_experience/commands/hook/scripts/__init__.py +0 -0
  41. agent_experience/commands/hook/scripts/read.py +53 -0
  42. agent_experience/commands/hook/scripts/write.py +25 -0
  43. agent_experience/commands/learn/SKILL.md +21 -0
  44. agent_experience/commands/learn/__init__.py +0 -0
  45. agent_experience/commands/learn/assets/menu.md.j2 +7 -0
  46. agent_experience/commands/learn/assets/topics/cicd/SKILL.md +103 -0
  47. agent_experience/commands/learn/assets/topics/gamify/SKILL.md +35 -0
  48. agent_experience/commands/learn/assets/topics/gamify/assets/skill-template/claude-code/SKILL.md +22 -0
  49. agent_experience/commands/learn/assets/topics/introspect/SKILL.md +41 -0
  50. agent_experience/commands/learn/assets/topics/introspect/assets/skill-template/claude-code/SKILL.md +22 -0
  51. agent_experience/commands/learn/assets/topics/levelup/SKILL.md +31 -0
  52. agent_experience/commands/learn/assets/topics/levelup/assets/skill-template/claude-code/SKILL.md +22 -0
  53. agent_experience/commands/learn/assets/topics/visualize/SKILL.md +27 -0
  54. agent_experience/commands/learn/assets/topics/visualize/assets/skill-template/claude-code/SKILL.md +19 -0
  55. agent_experience/commands/learn/references/.gitkeep +0 -0
  56. agent_experience/commands/learn/scripts/__init__.py +0 -0
  57. agent_experience/commands/learn/scripts/learn.py +73 -0
  58. agent_experience/commands/overview/SKILL.md +31 -0
  59. agent_experience/commands/overview/__init__.py +0 -0
  60. agent_experience/commands/overview/assets/backends/acp.yaml +7 -0
  61. agent_experience/commands/overview/assets/backends/claude-code.yaml +7 -0
  62. agent_experience/commands/overview/assets/backends/codex.yaml +7 -0
  63. agent_experience/commands/overview/assets/backends/copilot.yaml +7 -0
  64. agent_experience/commands/overview/assets/sections.md.j2 +52 -0
  65. agent_experience/commands/overview/references/.gitkeep +0 -0
  66. agent_experience/commands/overview/scripts/__init__.py +0 -0
  67. agent_experience/commands/overview/scripts/overview.py +40 -0
  68. agent_experience/commands/pr/SKILL.md +90 -0
  69. agent_experience/commands/pr/__init__.py +0 -0
  70. agent_experience/commands/pr/assets/__init__.py +0 -0
  71. agent_experience/commands/pr/assets/backends/__init__.py +0 -0
  72. agent_experience/commands/pr/assets/backends/acp.yaml +21 -0
  73. agent_experience/commands/pr/assets/backends/claude-code.yaml +21 -0
  74. agent_experience/commands/pr/assets/backends/codex.yaml +21 -0
  75. agent_experience/commands/pr/assets/backends/copilot.yaml +21 -0
  76. agent_experience/commands/pr/assets/rules/__init__.py +0 -0
  77. agent_experience/commands/pr/assets/rules/lint_rules.py +79 -0
  78. agent_experience/commands/pr/assets/rules/next_step_rules.py +78 -0
  79. agent_experience/commands/pr/assets/templates/__init__.py +0 -0
  80. agent_experience/commands/pr/assets/templates/delta.md.j2 +32 -0
  81. agent_experience/commands/pr/assets/templates/footer.md.j2 +2 -0
  82. agent_experience/commands/pr/assets/templates/lint_result.md.j2 +19 -0
  83. agent_experience/commands/pr/assets/templates/pr_briefing.md.j2 +69 -0
  84. agent_experience/commands/pr/assets/templates/pr_open_result.md.j2 +17 -0
  85. agent_experience/commands/pr/assets/templates/pr_reply_result.md.j2 +15 -0
  86. agent_experience/commands/pr/assets/templates/pr_review_result.md.j2 +5 -0
  87. agent_experience/commands/pr/scripts/__init__.py +0 -0
  88. agent_experience/commands/pr/scripts/_footer.py +32 -0
  89. agent_experience/commands/pr/scripts/_journal.py +21 -0
  90. agent_experience/commands/pr/scripts/_qodo.py +147 -0
  91. agent_experience/commands/pr/scripts/_readiness.py +76 -0
  92. agent_experience/commands/pr/scripts/_sonar.py +29 -0
  93. agent_experience/commands/pr/scripts/await_.py +156 -0
  94. agent_experience/commands/pr/scripts/delta.py +84 -0
  95. agent_experience/commands/pr/scripts/lint.py +72 -0
  96. agent_experience/commands/pr/scripts/open_.py +104 -0
  97. agent_experience/commands/pr/scripts/read.py +151 -0
  98. agent_experience/commands/pr/scripts/reply.py +160 -0
  99. agent_experience/commands/pr/scripts/review.py +59 -0
  100. agent_experience/core/__init__.py +0 -0
  101. agent_experience/core/backend.py +80 -0
  102. agent_experience/core/capabilities.py +44 -0
  103. agent_experience/core/config.py +46 -0
  104. agent_experience/core/github.py +355 -0
  105. agent_experience/core/hook_io.py +95 -0
  106. agent_experience/core/journal.py +90 -0
  107. agent_experience/core/paths.py +26 -0
  108. agent_experience/core/prog.py +44 -0
  109. agent_experience/core/render.py +42 -0
  110. agent_experience/core/skill_loader.py +36 -0
  111. devex_cli-0.24.0.dist-info/METADATA +55 -0
  112. devex_cli-0.24.0.dist-info/RECORD +115 -0
  113. devex_cli-0.24.0.dist-info/WHEEL +4 -0
  114. devex_cli-0.24.0.dist-info/entry_points.txt +3 -0
  115. devex_cli-0.24.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,84 @@
1
+ """`agex pr delta` — sibling project alignment dump."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.resources import files
6
+ from pathlib import Path
7
+
8
+ import yaml
9
+
10
+ from agent_experience.commands.pr.assets.rules.next_step_rules import delta_next_step
11
+ from agent_experience.commands.pr.scripts._footer import render_footer
12
+ from agent_experience.core import config as cfg_mod
13
+ from agent_experience.core.backend import resolve_backend
14
+ from agent_experience.core.prog import prog_name
15
+ from agent_experience.core.render import render_string
16
+
17
+ _TEMPLATES_PKG = "agent_experience.commands.pr.assets.templates"
18
+ _DEFAULT_CLAUDE_MD_LINES = 50
19
+
20
+
21
+ def _load_siblings(project_dir: Path) -> list[Path] | None:
22
+ skills_local = project_dir / ".claude" / "skills.local.yaml"
23
+ if not skills_local.exists():
24
+ return None
25
+ data = yaml.safe_load(skills_local.read_text(encoding="utf-8")) or {}
26
+ raw = data.get("sibling_projects") or []
27
+ return [Path(p) for p in raw]
28
+
29
+
30
+ def _claude_md_lines() -> int:
31
+ try:
32
+ cfg = cfg_mod.load()
33
+ except Exception:
34
+ return _DEFAULT_CLAUDE_MD_LINES
35
+ return int(cfg.pr.get("delta_claude_md_lines", _DEFAULT_CLAUDE_MD_LINES))
36
+
37
+
38
+ def _gather(sibling: Path, claude_md_lines: int) -> dict:
39
+ claude_md = sibling / "CLAUDE.md"
40
+ culture = sibling / "culture.yaml"
41
+ head = None
42
+ if claude_md.exists():
43
+ lines = claude_md.read_text(encoding="utf-8").splitlines()
44
+ head = "\n".join(lines[:claude_md_lines])
45
+ culture_text = culture.read_text(encoding="utf-8") if culture.exists() else None
46
+ return {
47
+ "name": sibling.name,
48
+ "path": str(sibling),
49
+ "claude_md_head": head,
50
+ "culture_yaml": culture_text,
51
+ }
52
+
53
+
54
+ def run(agent: str | None, project_dir: Path) -> tuple[str, int, str]:
55
+ backend = resolve_backend(agent, project_dir)
56
+ siblings = _load_siblings(project_dir)
57
+ claude_md_lines = _claude_md_lines()
58
+
59
+ if siblings is None:
60
+ prog = prog_name()
61
+ stdout = (
62
+ f"# `{prog} pr delta`\n\n"
63
+ "No `.claude/skills.local.yaml` found. Copy "
64
+ "`.claude/skills.local.yaml.example` and fill `sibling_projects`.\n"
65
+ )
66
+ stderr = (
67
+ f"{prog}: copy .claude/skills.local.yaml.example to "
68
+ ".claude/skills.local.yaml and fill sibling_projects\n"
69
+ )
70
+ return stdout, 0, stderr
71
+
72
+ template = files(_TEMPLATES_PKG).joinpath("delta.md.j2").read_text(encoding="utf-8")
73
+ rendered_siblings = [_gather(s, claude_md_lines) for s in siblings]
74
+ footer_key, footer_ctx = delta_next_step()
75
+ footer = render_footer(footer_key, backend, footer_ctx)
76
+ stdout = render_string(
77
+ template,
78
+ {
79
+ "siblings": rendered_siblings,
80
+ "claude_md_lines": claude_md_lines,
81
+ "footer": footer,
82
+ },
83
+ )
84
+ return stdout, 0, ""
@@ -0,0 +1,72 @@
1
+ """`agex pr lint` — portability + alignment-trigger lint on the working diff."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from importlib.resources import files
7
+ from pathlib import Path
8
+
9
+ from agent_experience.commands.pr.assets.rules.lint_rules import (
10
+ Violation,
11
+ check_alignment_trigger,
12
+ check_files,
13
+ )
14
+ from agent_experience.commands.pr.assets.rules.next_step_rules import lint_next_step
15
+ from agent_experience.commands.pr.scripts._footer import render_footer
16
+ from agent_experience.core.backend import resolve_backend
17
+ from agent_experience.core.render import render_string
18
+
19
+ _TEMPLATES_PKG = "agent_experience.commands.pr.assets.templates"
20
+
21
+
22
+ def _collect_diff() -> list[tuple[str, str]]:
23
+ """Return [(path, post-change content)] for staged + unstaged files.
24
+
25
+ For deleted files, returns an empty content string — rules treat that as
26
+ nothing to lint.
27
+ """
28
+ paths_staged = subprocess.run(
29
+ ["git", "diff", "--staged", "--name-only"],
30
+ capture_output=True,
31
+ text=True,
32
+ check=False,
33
+ ).stdout.splitlines()
34
+ paths_unstaged = subprocess.run(
35
+ ["git", "diff", "--name-only"],
36
+ capture_output=True,
37
+ text=True,
38
+ check=False,
39
+ ).stdout.splitlines()
40
+ paths = sorted(set(paths_staged) | set(paths_unstaged))
41
+ out: list[tuple[str, str]] = []
42
+ for p in paths:
43
+ try:
44
+ content = Path(p).read_text(encoding="utf-8", errors="replace")
45
+ except (FileNotFoundError, IsADirectoryError):
46
+ content = ""
47
+ out.append((p, content))
48
+ return out
49
+
50
+
51
+ def run(agent: str | None, project_dir: Path, exit_on_violation: bool) -> tuple[str, int, str]:
52
+ backend = resolve_backend(agent, project_dir)
53
+
54
+ file_pairs = _collect_diff()
55
+ violations: list[Violation] = check_files(file_pairs)
56
+ alignment_triggered = check_alignment_trigger([p for p, _ in file_pairs])
57
+
58
+ footer_key, footer_ctx = lint_next_step(violations, alignment_triggered)
59
+ footer = render_footer(footer_key, backend, footer_ctx)
60
+
61
+ template = files(_TEMPLATES_PKG).joinpath("lint_result.md.j2").read_text(encoding="utf-8")
62
+ stdout = render_string(
63
+ template,
64
+ {
65
+ "violations": [v.__dict__ for v in violations],
66
+ "alignment_triggered": alignment_triggered,
67
+ "footer": footer,
68
+ },
69
+ )
70
+
71
+ exit_code = 1 if (exit_on_violation and violations) else 0
72
+ return stdout, exit_code, ""
@@ -0,0 +1,104 @@
1
+ """`agex pr open` — gh pr create with auto-signed body and idempotency."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from importlib.resources import files
7
+ from pathlib import Path
8
+
9
+ from agent_experience.commands.pr.assets.rules.next_step_rules import open_next_step
10
+ from agent_experience.commands.pr.scripts import _journal, review
11
+ from agent_experience.commands.pr.scripts._footer import render_footer
12
+ from agent_experience.core import github
13
+ from agent_experience.core.backend import resolve_backend
14
+ from agent_experience.core.render import render_string
15
+
16
+ _TEMPLATES_PKG = "agent_experience.commands.pr.assets.templates"
17
+
18
+
19
+ def _read_body(body_file: Path | None) -> str:
20
+ if body_file is None:
21
+ return sys.stdin.read()
22
+ return body_file.read_text(encoding="utf-8")
23
+
24
+
25
+ def _signed(body: str, nick: str) -> tuple[str, bool]:
26
+ sig = f"- {nick} (Claude)"
27
+ if sig in body:
28
+ return body, True
29
+ sep = "" if body.endswith("\n") else "\n"
30
+ return f"{body}{sep}\n{sig}\n", True
31
+
32
+
33
+ def run(
34
+ agent: str | None,
35
+ project_dir: Path,
36
+ title: str,
37
+ body_file: Path | None,
38
+ draft: bool,
39
+ delayed_read: bool = False,
40
+ ) -> tuple[str, int, str]:
41
+ backend = resolve_backend(agent, project_dir)
42
+ nick = github.resolve_nick(project_dir)
43
+
44
+ existing = github.pr_view(None)
45
+ if existing is not None and existing.get("state") == "OPEN":
46
+ pr = int(existing["number"])
47
+ url = existing.get("url", "")
48
+ was_already_open = True
49
+ signed = False
50
+ else:
51
+ body = _read_body(body_file)
52
+ body, signed = _signed(body, nick)
53
+ pr = github.pr_create(title=title, body=body, draft=draft)
54
+ url = ""
55
+ was_already_open = False
56
+ _journal.append({"type": "pr_opened", "pr": pr, "title": title})
57
+
58
+ # Auto-post the Qodo agentic-review trigger out of the box for a freshly
59
+ # created, non-draft PR. Skipped for drafts (not review-ready yet — use
60
+ # `pr review` to trigger later) and for already-open PRs (idempotent
61
+ # re-opens shouldn't spam the thread).
62
+ #
63
+ # The trigger post is best-effort: PR creation is the primary side effect
64
+ # and has already succeeded, so a transient `gh` failure here must NOT abort
65
+ # the command (which would tell the user to rerun `pr open`, only to skip
66
+ # the trigger forever as an already-open PR). On failure we keep exit 0 and
67
+ # point the user at `pr review` to retry just the trigger.
68
+ review_posted = False
69
+ review_failed = False
70
+ if not was_already_open and not draft:
71
+ try:
72
+ review.post_trigger(pr)
73
+ review_posted = True
74
+ except RuntimeError:
75
+ review_failed = True
76
+
77
+ footer_key, footer_ctx = open_next_step(pr, was_already_open)
78
+ footer = render_footer(footer_key, backend, footer_ctx)
79
+
80
+ template = files(_TEMPLATES_PKG).joinpath("pr_open_result.md.j2").read_text(encoding="utf-8")
81
+ stdout = render_string(
82
+ template,
83
+ {
84
+ "pr": pr,
85
+ "url": url,
86
+ "title": title,
87
+ "signed": signed,
88
+ "draft": draft,
89
+ "was_already_open": was_already_open,
90
+ "review_posted": review_posted,
91
+ "review_failed": review_failed,
92
+ "review_command": review.QODO_REVIEW_TRIGGER,
93
+ "footer": footer,
94
+ },
95
+ )
96
+
97
+ if delayed_read and not was_already_open:
98
+ from agent_experience.commands.pr.scripts import read as read_script
99
+
100
+ read_stdout, read_exit, _ = read_script.run(
101
+ agent=agent, project_dir=project_dir, pr=pr, wait=180
102
+ )
103
+ return stdout + "\n" + read_stdout, read_exit, ""
104
+ return stdout, 0, ""
@@ -0,0 +1,151 @@
1
+ """`agex pr read` — unified PR briefing.
2
+
3
+ v0.1: one-shot read. Task 17 adds --wait + readiness loop.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import subprocess
9
+ import sys
10
+ import time
11
+ from importlib.resources import files
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from agent_experience.commands.pr.assets.rules.next_step_rules import (
16
+ read_next_step,
17
+ read_wait_timeout_step,
18
+ )
19
+ from agent_experience.commands.pr.scripts import _journal, _qodo, _readiness, _sonar
20
+ from agent_experience.commands.pr.scripts._footer import render_footer
21
+ from agent_experience.core import github
22
+ from agent_experience.core.backend import resolve_backend
23
+ from agent_experience.core.render import render_string
24
+
25
+ _TEMPLATES_PKG = "agent_experience.commands.pr.assets.templates"
26
+
27
+
28
+ def _has_recent_local_commits(journal_events: list[dict[str, Any]], pr: int) -> bool:
29
+ """True if `git log` shows commits authored after the most recent
30
+ `pr_read` event for this PR. No event yet → False (first read)."""
31
+ last_read = next(
32
+ (e for e in reversed(journal_events) if e.get("type") == "pr_read" and e.get("pr") == pr),
33
+ None,
34
+ )
35
+ if last_read is None:
36
+ return False
37
+ ts = last_read["ts"]
38
+ out = subprocess.run(
39
+ ["git", "log", f"--since={ts}", "--pretty=%H"],
40
+ capture_output=True,
41
+ text=True,
42
+ check=False,
43
+ ).stdout.strip()
44
+ return bool(out)
45
+
46
+
47
+ def run(
48
+ agent: str | None,
49
+ project_dir: Path,
50
+ pr: int | None,
51
+ wait: int | None,
52
+ ) -> tuple[str, int, str]:
53
+ backend = resolve_backend(agent, project_dir)
54
+ pr_number = github.resolve_pr_number(pr)
55
+
56
+ waited_secs = 0
57
+ waiting_for: list[str] = []
58
+ if wait is not None and wait > 0:
59
+ required = _readiness.required_reviewers()
60
+ deadline = wait
61
+ ready = False
62
+ while waited_secs < deadline:
63
+ comments = github.pr_comments(pr_number)
64
+ ready, waiting_for = _readiness.is_ready(comments, required)
65
+ sys.stderr.write(
66
+ _readiness.heartbeat("pr_read --wait", pr_number, waited_secs, ready, waiting_for)
67
+ )
68
+ sys.stderr.flush()
69
+ if ready:
70
+ _journal.append(
71
+ {"type": "readiness_arrived", "pr": pr_number, "waited_secs": waited_secs}
72
+ )
73
+ break
74
+ interval = min(_readiness.POLL_INTERVAL_SEC, max(1, deadline - waited_secs))
75
+ time.sleep(interval)
76
+ waited_secs += interval
77
+ if not ready:
78
+ # Timeout — render still-waiting briefing.
79
+ pr_meta = github.pr_view(str(pr_number))
80
+ checks = github.pr_checks(pr_number)
81
+ comments = github.pr_comments(pr_number)
82
+ qodo = _qodo.parse(comments)
83
+ footer_key, footer_ctx = read_wait_timeout_step(pr_number, waiting_for)
84
+ footer = render_footer(footer_key, backend, footer_ctx)
85
+ template = (
86
+ files(_TEMPLATES_PKG).joinpath("pr_briefing.md.j2").read_text(encoding="utf-8")
87
+ )
88
+ stdout = render_string(
89
+ template,
90
+ {
91
+ "pr": pr_number,
92
+ "pr_meta": pr_meta,
93
+ "checks": checks,
94
+ "comments": comments,
95
+ "qodo": qodo,
96
+ "sonar_gate": None,
97
+ "sonar_issues": [],
98
+ "waiting_for": waiting_for,
99
+ "footer": footer,
100
+ },
101
+ )
102
+ return stdout, 0, ""
103
+
104
+ # Either no --wait, or readiness arrived: full briefing path.
105
+ pr_meta = github.pr_view(str(pr_number))
106
+ checks = github.pr_checks(pr_number)
107
+ comments = github.pr_comments(pr_number)
108
+ qodo = _qodo.parse(comments)
109
+ project_key = _sonar.project_key()
110
+ sonar_gate = github.sonar_quality_gate(project_key, pr_number)
111
+ sonar_issues = github.sonar_new_issues(project_key, pr_number)
112
+
113
+ threads_unresolved = _readiness.threads_unresolved(pr_number)
114
+ journal_events = _journal.load()
115
+ has_recent_commits = _has_recent_local_commits(journal_events, pr_number)
116
+ ci_red = any(c.get("conclusion") == "failure" for c in checks)
117
+
118
+ _journal.append(
119
+ {
120
+ "type": "pr_read",
121
+ "pr": pr_number,
122
+ "comment_count": len(comments),
123
+ "threads_unresolved": threads_unresolved,
124
+ "ci_state": "failure" if ci_red else "ok",
125
+ }
126
+ )
127
+
128
+ footer_key, footer_ctx = read_next_step(
129
+ pr=pr_number,
130
+ threads_unresolved=threads_unresolved,
131
+ has_recent_local_commits=has_recent_commits,
132
+ ci_red=ci_red,
133
+ )
134
+ footer = render_footer(footer_key, backend, footer_ctx)
135
+
136
+ template = files(_TEMPLATES_PKG).joinpath("pr_briefing.md.j2").read_text(encoding="utf-8")
137
+ stdout = render_string(
138
+ template,
139
+ {
140
+ "pr": pr_number,
141
+ "pr_meta": pr_meta,
142
+ "checks": checks,
143
+ "comments": comments,
144
+ "qodo": qodo,
145
+ "sonar_gate": sonar_gate,
146
+ "sonar_issues": sonar_issues,
147
+ "waiting_for": [],
148
+ "footer": footer,
149
+ },
150
+ )
151
+ return stdout, 0, ""
@@ -0,0 +1,160 @@
1
+ """`agex pr reply` — batch JSONL replies + thread resolution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from dataclasses import dataclass
8
+ from importlib.resources import files
9
+ from pathlib import Path
10
+
11
+ from agent_experience.commands.pr.assets.rules.next_step_rules import reply_next_step
12
+ from agent_experience.commands.pr.scripts import _journal
13
+ from agent_experience.commands.pr.scripts._footer import render_footer
14
+ from agent_experience.core import github
15
+ from agent_experience.core.backend import resolve_backend
16
+ from agent_experience.core.prog import prog_name
17
+ from agent_experience.core.render import render_string
18
+
19
+ _TEMPLATES_PKG = "agent_experience.commands.pr.assets.templates"
20
+
21
+
22
+ @dataclass
23
+ class _Failure:
24
+ line: int
25
+ reason: str
26
+ entry: str
27
+
28
+
29
+ def _signed(body: str, nick: str) -> str:
30
+ sig = f"- {nick} (Claude)"
31
+ if sig in body:
32
+ return body
33
+ sep = "" if body.endswith("\n") else "\n"
34
+ return f"{body}{sep}\n{sig}\n"
35
+
36
+
37
+ def _validate_entry(raw_line: str, lineno: int) -> tuple[dict | None, _Failure | None, bool]:
38
+ """Parse and validate one JSONL line.
39
+
40
+ Returns (entry, None, False) on success, or (None, failure, is_parse_error)
41
+ on error. The caller should break and record parse_error_line when
42
+ is_parse_error is True.
43
+ """
44
+ try:
45
+ entry = json.loads(raw_line)
46
+ except json.JSONDecodeError as exc:
47
+ return None, _Failure(line=lineno, reason=f"JSONL parse error: {exc}", entry=raw_line), True
48
+ if not isinstance(entry, dict) or not isinstance(entry.get("body"), str):
49
+ return (
50
+ None,
51
+ _Failure(
52
+ line=lineno,
53
+ reason="missing or invalid 'body' field (must be string)",
54
+ entry=raw_line,
55
+ ),
56
+ False,
57
+ )
58
+ return entry, None, False
59
+
60
+
61
+ def _post_entry(entry: dict, nick: str, pr: int) -> bool:
62
+ """Post one reply + optional thread resolution; append journal events.
63
+
64
+ Returns True if a thread was resolved (caller increments resolved count).
65
+ Raises RuntimeError on gh failure — caller records the failure and stops.
66
+ """
67
+ body = _signed(entry["body"], nick)
68
+ github.pr_post_comment(pr=pr, body=body, in_reply_to=entry.get("in_reply_to"))
69
+ _journal.append(
70
+ {
71
+ "type": "pr_reply",
72
+ "pr": pr,
73
+ "thread_id": entry.get("thread_id"),
74
+ "in_reply_to": entry.get("in_reply_to"),
75
+ }
76
+ )
77
+ thread_id = entry.get("thread_id")
78
+ if thread_id:
79
+ github.pr_resolve_thread(thread_id)
80
+ return True
81
+ return False
82
+
83
+
84
+ def _process_line(
85
+ raw_line: str, lineno: int, nick: str, pr: int
86
+ ) -> tuple[int, int, _Failure | None, bool]:
87
+ """Process one JSONL line. Returns (posted_delta, resolved_delta, failure, is_parse_error).
88
+
89
+ Caller breaks the loop when failure is not None.
90
+ """
91
+ entry, failure, is_parse_error = _validate_entry(raw_line, lineno)
92
+ if failure is not None:
93
+ return 0, 0, failure, is_parse_error
94
+ try:
95
+ did_resolve = _post_entry(entry, nick, pr) # type: ignore[arg-type]
96
+ except RuntimeError as exc:
97
+ return 0, 0, _Failure(line=lineno, reason=str(exc), entry=raw_line), False
98
+ return 1, (1 if did_resolve else 0), None, False
99
+
100
+
101
+ def _stderr_for_failures(failures: list[_Failure], parse_error_line: int | None, pr: int) -> str:
102
+ """Build the instructive stderr line for the failure case."""
103
+ prog = prog_name()
104
+ if parse_error_line is not None:
105
+ return (
106
+ f"{prog}: fix line {parse_error_line} (see stdout) and resubmit "
107
+ f"lines {parse_error_line}..end to '{prog} pr reply {pr}'\n"
108
+ )
109
+ first_failed = failures[0].line
110
+ return (
111
+ f"{prog}: resubmit lines {first_failed}..end from the table above "
112
+ f"to '{prog} pr reply {pr}'\n"
113
+ )
114
+
115
+
116
+ def run(
117
+ agent: str | None,
118
+ project_dir: Path,
119
+ pr: int,
120
+ ) -> tuple[str, int, str]:
121
+ backend = resolve_backend(agent, project_dir)
122
+ nick = github.resolve_nick(project_dir)
123
+ raw = sys.stdin.read()
124
+
125
+ posted = 0
126
+ resolved = 0
127
+ failures: list[_Failure] = []
128
+ parse_error_line: int | None = None
129
+
130
+ for lineno, raw_line in enumerate(raw.splitlines(), start=1):
131
+ if not raw_line.strip():
132
+ continue
133
+ dp, dr, failure, is_parse_error = _process_line(raw_line, lineno, nick, pr)
134
+ posted += dp
135
+ resolved += dr
136
+ if failure is not None:
137
+ if is_parse_error:
138
+ parse_error_line = lineno
139
+ failures.append(failure)
140
+ break
141
+
142
+ _journal.append({"type": "pr_batch_replied", "pr": pr, "count": posted, "resolved": resolved})
143
+
144
+ footer_key, footer_ctx = reply_next_step(pr=pr, failure_count=len(failures))
145
+ footer = render_footer(footer_key, backend, footer_ctx)
146
+
147
+ template = files(_TEMPLATES_PKG).joinpath("pr_reply_result.md.j2").read_text(encoding="utf-8")
148
+ stdout = render_string(
149
+ template,
150
+ {
151
+ "pr": pr,
152
+ "count": posted,
153
+ "resolved": resolved,
154
+ "failures": [f.__dict__ for f in failures],
155
+ "footer": footer,
156
+ },
157
+ )
158
+ if not failures:
159
+ return stdout, 0, ""
160
+ return stdout, 1, _stderr_for_failures(failures, parse_error_line, pr)
@@ -0,0 +1,59 @@
1
+ """`agex pr review` — post the Qodo agentic-review trigger comment.
2
+
3
+ Qodo's current command to start an agentic PR review is ``/agentic_review``.
4
+ The legacy ``/improve`` command is deprecated (Qodo emits a deprecation banner
5
+ when it is used), so agex never posts ``/improve``. ``QODO_REVIEW_TRIGGER`` is
6
+ the single source of truth for the command string — any future Qodo rename is a
7
+ one-line change here, picked up by both this verb and the auto-post on
8
+ ``pr open``.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from importlib.resources import files
14
+ from pathlib import Path
15
+
16
+ from agent_experience.commands.pr.assets.rules.next_step_rules import review_next_step
17
+ from agent_experience.commands.pr.scripts import _journal
18
+ from agent_experience.commands.pr.scripts._footer import render_footer
19
+ from agent_experience.core import github
20
+ from agent_experience.core.backend import resolve_backend
21
+ from agent_experience.core.render import render_string
22
+
23
+ _TEMPLATES_PKG = "agent_experience.commands.pr.assets.templates"
24
+
25
+ # The non-deprecated Qodo command to start an agentic code review. Single
26
+ # source of truth — referenced here and by the `pr open` auto-post.
27
+ QODO_REVIEW_TRIGGER = "/agentic_review"
28
+
29
+
30
+ def post_trigger(pr: int) -> int:
31
+ """Post the Qodo review-trigger comment on ``pr``; return the comment ID.
32
+
33
+ Also appends a ``pr_review_triggered`` journal event. Shared by the
34
+ ``pr review`` verb and the ``pr open`` auto-post.
35
+ """
36
+ comment_id = github.pr_post_comment(pr, QODO_REVIEW_TRIGGER, None)
37
+ _journal.append({"type": "pr_review_triggered", "pr": pr, "command": QODO_REVIEW_TRIGGER})
38
+ return comment_id
39
+
40
+
41
+ def run(agent: str | None, project_dir: Path, pr: int | None) -> tuple[str, int, str]:
42
+ backend = resolve_backend(agent, project_dir)
43
+ pr_number = github.resolve_pr_number(pr)
44
+
45
+ post_trigger(pr_number)
46
+
47
+ footer_key, footer_ctx = review_next_step(pr_number)
48
+ footer = render_footer(footer_key, backend, footer_ctx)
49
+
50
+ template = files(_TEMPLATES_PKG).joinpath("pr_review_result.md.j2").read_text(encoding="utf-8")
51
+ stdout = render_string(
52
+ template,
53
+ {
54
+ "pr": pr_number,
55
+ "command": QODO_REVIEW_TRIGGER,
56
+ "footer": footer,
57
+ },
58
+ )
59
+ return stdout, 0, ""
File without changes