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.
- agent_experience/__init__.py +24 -0
- agent_experience/__main__.py +4 -0
- agent_experience/backends/__init__.py +0 -0
- agent_experience/backends/acp/__init__.py +0 -0
- agent_experience/backends/acp/probe.py +9 -0
- agent_experience/backends/capabilities/acp.yaml +7 -0
- agent_experience/backends/capabilities/claude-code.yaml +4 -0
- agent_experience/backends/capabilities/codex.yaml +7 -0
- agent_experience/backends/capabilities/copilot.yaml +7 -0
- agent_experience/backends/claude_code/__init__.py +0 -0
- agent_experience/backends/claude_code/probe.py +97 -0
- agent_experience/backends/codex/__init__.py +0 -0
- agent_experience/backends/codex/probe.py +16 -0
- agent_experience/backends/copilot/__init__.py +0 -0
- agent_experience/backends/copilot/probe.py +9 -0
- agent_experience/cli.py +485 -0
- agent_experience/commands/__init__.py +0 -0
- agent_experience/commands/doctor/SKILL.md +41 -0
- agent_experience/commands/doctor/__init__.py +0 -0
- agent_experience/commands/doctor/assets/report.md.j2 +39 -0
- agent_experience/commands/doctor/references/design.md +36 -0
- agent_experience/commands/doctor/scripts/__init__.py +0 -0
- agent_experience/commands/doctor/scripts/doctor.py +394 -0
- agent_experience/commands/explain/SKILL.md +26 -0
- agent_experience/commands/explain/__init__.py +0 -0
- agent_experience/commands/explain/assets/topics/agex.md +37 -0
- agent_experience/commands/explain/references/.gitkeep +0 -0
- agent_experience/commands/explain/scripts/__init__.py +0 -0
- agent_experience/commands/explain/scripts/explain.py +64 -0
- agent_experience/commands/gamify/SKILL.md +31 -0
- agent_experience/commands/gamify/__init__.py +0 -0
- agent_experience/commands/gamify/assets/hooks/claude-code.json +28 -0
- agent_experience/commands/gamify/references/.gitkeep +0 -0
- agent_experience/commands/gamify/scripts/__init__.py +0 -0
- agent_experience/commands/gamify/scripts/install.py +203 -0
- agent_experience/commands/hook/SKILL.md +31 -0
- agent_experience/commands/hook/__init__.py +0 -0
- agent_experience/commands/hook/assets/table.md.j2 +17 -0
- agent_experience/commands/hook/references/.gitkeep +0 -0
- agent_experience/commands/hook/scripts/__init__.py +0 -0
- agent_experience/commands/hook/scripts/read.py +53 -0
- agent_experience/commands/hook/scripts/write.py +25 -0
- agent_experience/commands/learn/SKILL.md +21 -0
- agent_experience/commands/learn/__init__.py +0 -0
- agent_experience/commands/learn/assets/menu.md.j2 +7 -0
- agent_experience/commands/learn/assets/topics/cicd/SKILL.md +103 -0
- agent_experience/commands/learn/assets/topics/gamify/SKILL.md +35 -0
- agent_experience/commands/learn/assets/topics/gamify/assets/skill-template/claude-code/SKILL.md +22 -0
- agent_experience/commands/learn/assets/topics/introspect/SKILL.md +41 -0
- agent_experience/commands/learn/assets/topics/introspect/assets/skill-template/claude-code/SKILL.md +22 -0
- agent_experience/commands/learn/assets/topics/levelup/SKILL.md +31 -0
- agent_experience/commands/learn/assets/topics/levelup/assets/skill-template/claude-code/SKILL.md +22 -0
- agent_experience/commands/learn/assets/topics/visualize/SKILL.md +27 -0
- agent_experience/commands/learn/assets/topics/visualize/assets/skill-template/claude-code/SKILL.md +19 -0
- agent_experience/commands/learn/references/.gitkeep +0 -0
- agent_experience/commands/learn/scripts/__init__.py +0 -0
- agent_experience/commands/learn/scripts/learn.py +73 -0
- agent_experience/commands/overview/SKILL.md +31 -0
- agent_experience/commands/overview/__init__.py +0 -0
- agent_experience/commands/overview/assets/backends/acp.yaml +7 -0
- agent_experience/commands/overview/assets/backends/claude-code.yaml +7 -0
- agent_experience/commands/overview/assets/backends/codex.yaml +7 -0
- agent_experience/commands/overview/assets/backends/copilot.yaml +7 -0
- agent_experience/commands/overview/assets/sections.md.j2 +52 -0
- agent_experience/commands/overview/references/.gitkeep +0 -0
- agent_experience/commands/overview/scripts/__init__.py +0 -0
- agent_experience/commands/overview/scripts/overview.py +40 -0
- agent_experience/commands/pr/SKILL.md +90 -0
- agent_experience/commands/pr/__init__.py +0 -0
- agent_experience/commands/pr/assets/__init__.py +0 -0
- agent_experience/commands/pr/assets/backends/__init__.py +0 -0
- agent_experience/commands/pr/assets/backends/acp.yaml +21 -0
- agent_experience/commands/pr/assets/backends/claude-code.yaml +21 -0
- agent_experience/commands/pr/assets/backends/codex.yaml +21 -0
- agent_experience/commands/pr/assets/backends/copilot.yaml +21 -0
- agent_experience/commands/pr/assets/rules/__init__.py +0 -0
- agent_experience/commands/pr/assets/rules/lint_rules.py +79 -0
- agent_experience/commands/pr/assets/rules/next_step_rules.py +78 -0
- agent_experience/commands/pr/assets/templates/__init__.py +0 -0
- agent_experience/commands/pr/assets/templates/delta.md.j2 +32 -0
- agent_experience/commands/pr/assets/templates/footer.md.j2 +2 -0
- agent_experience/commands/pr/assets/templates/lint_result.md.j2 +19 -0
- agent_experience/commands/pr/assets/templates/pr_briefing.md.j2 +69 -0
- agent_experience/commands/pr/assets/templates/pr_open_result.md.j2 +17 -0
- agent_experience/commands/pr/assets/templates/pr_reply_result.md.j2 +15 -0
- agent_experience/commands/pr/assets/templates/pr_review_result.md.j2 +5 -0
- agent_experience/commands/pr/scripts/__init__.py +0 -0
- agent_experience/commands/pr/scripts/_footer.py +32 -0
- agent_experience/commands/pr/scripts/_journal.py +21 -0
- agent_experience/commands/pr/scripts/_qodo.py +147 -0
- agent_experience/commands/pr/scripts/_readiness.py +76 -0
- agent_experience/commands/pr/scripts/_sonar.py +29 -0
- agent_experience/commands/pr/scripts/await_.py +156 -0
- agent_experience/commands/pr/scripts/delta.py +84 -0
- agent_experience/commands/pr/scripts/lint.py +72 -0
- agent_experience/commands/pr/scripts/open_.py +104 -0
- agent_experience/commands/pr/scripts/read.py +151 -0
- agent_experience/commands/pr/scripts/reply.py +160 -0
- agent_experience/commands/pr/scripts/review.py +59 -0
- agent_experience/core/__init__.py +0 -0
- agent_experience/core/backend.py +80 -0
- agent_experience/core/capabilities.py +44 -0
- agent_experience/core/config.py +46 -0
- agent_experience/core/github.py +355 -0
- agent_experience/core/hook_io.py +95 -0
- agent_experience/core/journal.py +90 -0
- agent_experience/core/paths.py +26 -0
- agent_experience/core/prog.py +44 -0
- agent_experience/core/render.py +42 -0
- agent_experience/core/skill_loader.py +36 -0
- devex_cli-0.24.0.dist-info/METADATA +55 -0
- devex_cli-0.24.0.dist-info/RECORD +115 -0
- devex_cli-0.24.0.dist-info/WHEEL +4 -0
- devex_cli-0.24.0.dist-info/entry_points.txt +3 -0
- 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
|