whiletrue 0.1.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.
- whiletrue/__init__.py +0 -0
- whiletrue/__main__.py +3 -0
- whiletrue/abort.py +79 -0
- whiletrue/basics/__init__.py +99 -0
- whiletrue/basics/agent.py +249 -0
- whiletrue/basics/comments.py +113 -0
- whiletrue/basics/diff_size.py +80 -0
- whiletrue/basics/exec_.py +84 -0
- whiletrue/basics/history.py +117 -0
- whiletrue/basics/human.py +201 -0
- whiletrue/basics/json_source.py +35 -0
- whiletrue/basics/markdown_source.py +55 -0
- whiletrue/basics/process.py +99 -0
- whiletrue/basics/sessions.py +62 -0
- whiletrue/basics/set_.py +29 -0
- whiletrue/basics/shell.py +98 -0
- whiletrue/basics/wake.py +27 -0
- whiletrue/claude_code/__init__.py +58 -0
- whiletrue/claude_code/endpoint.py +143 -0
- whiletrue/claude_code/headless.py +181 -0
- whiletrue/claude_code/interactive.py +344 -0
- whiletrue/claude_code/learnings.py +32 -0
- whiletrue/claude_code/panes.py +164 -0
- whiletrue/claude_code/plugin/.claude-plugin/plugin.json +5 -0
- whiletrue/claude_code/plugin/hook.py +44 -0
- whiletrue/claude_code/plugin/hooks/hooks.json +81 -0
- whiletrue/claude_code/plugin/mcp_server.py +98 -0
- whiletrue/claude_code/session.py +345 -0
- whiletrue/claude_code/transcript.py +123 -0
- whiletrue/cli/__init__.py +15 -0
- whiletrue/cli/__main__.py +3 -0
- whiletrue/cli/app.py +550 -0
- whiletrue/cli/common.py +36 -0
- whiletrue/cli/gates.py +156 -0
- whiletrue/cli/ops.py +339 -0
- whiletrue/cli/panel.py +198 -0
- whiletrue/cli/registry.py +143 -0
- whiletrue/cli/templates.py +24 -0
- whiletrue/codex/__init__.py +14 -0
- whiletrue/codex/runner.py +243 -0
- whiletrue/control/__init__.py +38 -0
- whiletrue/control/chain.py +61 -0
- whiletrue/control/loop.py +139 -0
- whiletrue/control/map_.py +101 -0
- whiletrue/control/parallel.py +32 -0
- whiletrue/control/retry.py +100 -0
- whiletrue/control/sequence.py +40 -0
- whiletrue/control/switch.py +67 -0
- whiletrue/core/__init__.py +260 -0
- whiletrue/core/caps.py +139 -0
- whiletrue/core/comments.py +80 -0
- whiletrue/core/composite.py +140 -0
- whiletrue/core/config.py +461 -0
- whiletrue/core/context.py +177 -0
- whiletrue/core/duration.py +19 -0
- whiletrue/core/events.py +94 -0
- whiletrue/core/expr.py +85 -0
- whiletrue/core/history.py +83 -0
- whiletrue/core/lock.py +86 -0
- whiletrue/core/memory_store.py +352 -0
- whiletrue/core/module.py +146 -0
- whiletrue/core/outcomes.py +82 -0
- whiletrue/core/registry.py +129 -0
- whiletrue/core/replay.py +58 -0
- whiletrue/core/runner.py +98 -0
- whiletrue/core/runtime.py +538 -0
- whiletrue/core/source.py +45 -0
- whiletrue/core/store.py +207 -0
- whiletrue/core/template.py +60 -0
- whiletrue/core/validate.py +142 -0
- whiletrue/core/wake.py +117 -0
- whiletrue/core/workspace.py +55 -0
- whiletrue/git/__init__.py +31 -0
- whiletrue/git/modules.py +128 -0
- whiletrue/git/workspaces.py +77 -0
- whiletrue/github/__init__.py +30 -0
- whiletrue/github/client.py +269 -0
- whiletrue/github/filebacked.py +87 -0
- whiletrue/github/memory.py +96 -0
- whiletrue/github/plugin.py +343 -0
- whiletrue/github/specs.py +39 -0
- whiletrue/opencode/__init__.py +25 -0
- whiletrue/opencode/runner.py +186 -0
- whiletrue/py.typed +0 -0
- whiletrue/recipes/issue-loop.yaml +59 -0
- whiletrue/recipes/ralph.yaml +14 -0
- whiletrue/recipes/review-pass.yaml +27 -0
- whiletrue/recipes/review-respond.yaml +22 -0
- whiletrue/store_sqlite/__init__.py +6 -0
- whiletrue/store_sqlite/migrations.py +132 -0
- whiletrue/store_sqlite/sqlite_store.py +537 -0
- whiletrue/tmux/__init__.py +11 -0
- whiletrue/tmux/controller.py +197 -0
- whiletrue-0.1.0.dist-info/METADATA +48 -0
- whiletrue-0.1.0.dist-info/RECORD +98 -0
- whiletrue-0.1.0.dist-info/WHEEL +4 -0
- whiletrue-0.1.0.dist-info/entry_points.txt +2 -0
- whiletrue-0.1.0.dist-info/licenses/LICENSE +21 -0
whiletrue/git/modules.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
from whiletrue.basics.process import run_command, tail
|
|
8
|
+
from whiletrue.core import (
|
|
9
|
+
ActionModule,
|
|
10
|
+
Emit,
|
|
11
|
+
Ports,
|
|
12
|
+
PortSpec,
|
|
13
|
+
StepContext,
|
|
14
|
+
define_action,
|
|
15
|
+
)
|
|
16
|
+
from whiletrue.core.module import PortValues
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CommitConfig(BaseModel):
|
|
20
|
+
message: str = Field(min_length=1)
|
|
21
|
+
#: Paths to stage (`.` stages everything, including deletions).
|
|
22
|
+
add: list[str] = Field(default_factory=lambda: ["."])
|
|
23
|
+
#: Commit even when nothing changed.
|
|
24
|
+
allow_empty: bool = False
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class CommitPushConfig(CommitConfig):
|
|
28
|
+
remote: str = "origin"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class DiffConfig(BaseModel):
|
|
32
|
+
base: str = "HEAD"
|
|
33
|
+
#: Include the unified diff text in the output.
|
|
34
|
+
patch: bool = False
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def _git(ctx: StepContext[Any], *args: str) -> tuple[int | None, str, str]:
|
|
38
|
+
result = await run_command("git", list(args), cwd=ctx.cwd, signal=ctx.signal)
|
|
39
|
+
return result.code, result.stdout, result.stderr
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def _commit(ctx: StepContext[Any], cfg: CommitConfig) -> Emit:
|
|
43
|
+
code, _, err = await _git(ctx, "add", "-A", "--", *cfg.add)
|
|
44
|
+
if code != 0:
|
|
45
|
+
return Emit("fail", {"error": f"git add failed: {tail(err)}"})
|
|
46
|
+
staged, _, _ = await _git(ctx, "diff", "--cached", "--quiet")
|
|
47
|
+
if staged == 0 and not cfg.allow_empty:
|
|
48
|
+
return Emit(
|
|
49
|
+
"ok", {"committed": False, "sha": (await _git(ctx, "rev-parse", "HEAD"))[1].strip()}
|
|
50
|
+
)
|
|
51
|
+
args = ["commit", "-m", cfg.message]
|
|
52
|
+
if cfg.allow_empty:
|
|
53
|
+
args.append("--allow-empty")
|
|
54
|
+
code, out, err = await _git(ctx, *args)
|
|
55
|
+
if code != 0:
|
|
56
|
+
return Emit("fail", {"error": f"git commit failed: {tail(err or out)}"})
|
|
57
|
+
sha = (await _git(ctx, "rev-parse", "HEAD"))[1].strip()
|
|
58
|
+
return Emit("ok", {"committed": True, "sha": sha})
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def _commit_run(input: PortValues, ctx: StepContext[CommitConfig]) -> Emit:
|
|
62
|
+
return await _commit(ctx, ctx.config)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
async def _commit_push_run(input: PortValues, ctx: StepContext[CommitPushConfig]) -> Emit:
|
|
66
|
+
made = await _commit(ctx, ctx.config)
|
|
67
|
+
if made.port != "ok":
|
|
68
|
+
return made
|
|
69
|
+
code, out, err = await _git(ctx, "push", "-u", ctx.config.remote, "HEAD")
|
|
70
|
+
if code != 0:
|
|
71
|
+
return Emit("fail", {"error": f"git push failed: {tail(err or out)}"})
|
|
72
|
+
return Emit("ok", {**made.value, "pushed": True})
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def _diff_run(input: PortValues, ctx: StepContext[DiffConfig]) -> Emit:
|
|
76
|
+
code, out, err = await _git(ctx, "diff", "--numstat", ctx.config.base)
|
|
77
|
+
if code != 0:
|
|
78
|
+
return Emit("fail", {"error": f"git diff failed: {tail(err)}"})
|
|
79
|
+
files: list[dict[str, Any]] = []
|
|
80
|
+
for line in out.splitlines():
|
|
81
|
+
added, removed, path = line.split("\t", 2)
|
|
82
|
+
files.append(
|
|
83
|
+
{
|
|
84
|
+
"path": path,
|
|
85
|
+
"additions": 0 if added == "-" else int(added),
|
|
86
|
+
"deletions": 0 if removed == "-" else int(removed),
|
|
87
|
+
}
|
|
88
|
+
)
|
|
89
|
+
value: dict[str, Any] = {"files": files}
|
|
90
|
+
if ctx.config.patch:
|
|
91
|
+
value["patch"] = (await _git(ctx, "diff", ctx.config.base))[1]
|
|
92
|
+
return Emit("ok", value)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
_ANY = Ports(inputs=[PortSpec("in", "Any")], outputs=[PortSpec("out", "Any")])
|
|
96
|
+
|
|
97
|
+
git_commit_module: ActionModule[CommitConfig] = define_action(
|
|
98
|
+
ActionModule(
|
|
99
|
+
id="git.commit",
|
|
100
|
+
config=CommitConfig,
|
|
101
|
+
ports=_ANY,
|
|
102
|
+
effects="side-effect",
|
|
103
|
+
cacheable=False,
|
|
104
|
+
idempotency_key=lambda _i, ctx: f"git.commit:{ctx.step_key}",
|
|
105
|
+
run=_commit_run,
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
git_commit_push_module: ActionModule[CommitPushConfig] = define_action(
|
|
109
|
+
ActionModule(
|
|
110
|
+
id="git.commit_push",
|
|
111
|
+
config=CommitPushConfig,
|
|
112
|
+
ports=_ANY,
|
|
113
|
+
effects="side-effect",
|
|
114
|
+
cacheable=False,
|
|
115
|
+
idempotency_key=lambda _i, ctx: f"git.commit_push:{ctx.step_key}",
|
|
116
|
+
run=_commit_push_run,
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
git_diff_module: ActionModule[DiffConfig] = define_action(
|
|
120
|
+
ActionModule(
|
|
121
|
+
id="git.diff",
|
|
122
|
+
config=DiffConfig,
|
|
123
|
+
ports=Ports(inputs=[PortSpec("in", "Any")], outputs=[PortSpec("out", "Diff")]),
|
|
124
|
+
effects="deterministic",
|
|
125
|
+
cacheable=False,
|
|
126
|
+
run=_diff_run,
|
|
127
|
+
)
|
|
128
|
+
)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import re
|
|
5
|
+
import shutil
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from whiletrue.basics.process import run_command, tail
|
|
10
|
+
from whiletrue.core import ItemState, Workspace, WorkspaceError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _safe(name: str) -> str:
|
|
14
|
+
return re.sub(r"[^A-Za-z0-9_.-]+", "_", name) or "item"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def _git(cwd: str, *args: str) -> str:
|
|
18
|
+
result = await run_command("git", list(args), cwd=cwd)
|
|
19
|
+
if result.code != 0:
|
|
20
|
+
raise WorkspaceError(f"git {args[0]} failed: {tail(result.stderr or result.stdout)}")
|
|
21
|
+
return result.stdout
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class GitWorktree:
|
|
25
|
+
"""One git worktree per item, on its own branch, under `.whiletrue/worktrees/`.
|
|
26
|
+
|
|
27
|
+
An existing worktree is reused, so a resumed run continues where the parked one left off.
|
|
28
|
+
A finished (`done`) item's worktree is removed; its branch stays.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
async def create(self, config: dict[str, Any], item: dict[str, Any], cwd: str) -> Workspace:
|
|
32
|
+
item_id = str(item.get("id", "item"))
|
|
33
|
+
branch = str(config.get("branch") or f"wt/{item_id}")
|
|
34
|
+
base = str(config.get("base") or "HEAD")
|
|
35
|
+
root = Path(cwd) / str(config.get("dir") or ".whiletrue/worktrees")
|
|
36
|
+
path = root / _safe(item_id)
|
|
37
|
+
if not (path / ".git").exists():
|
|
38
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
await _git(cwd, "worktree", "prune")
|
|
40
|
+
await _git(cwd, "worktree", "add", "-B", branch, str(path), base)
|
|
41
|
+
|
|
42
|
+
async def release(state: ItemState) -> None:
|
|
43
|
+
if state == "done":
|
|
44
|
+
await run_command("git", ["worktree", "remove", "--force", str(path)], cwd=cwd)
|
|
45
|
+
|
|
46
|
+
return Workspace(str(path), release)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class DirCopy:
|
|
50
|
+
"""A plain copy of the project directory per item (for projects that are not git repos)."""
|
|
51
|
+
|
|
52
|
+
async def create(self, config: dict[str, Any], item: dict[str, Any], cwd: str) -> Workspace:
|
|
53
|
+
root = Path(cwd) / str(config.get("dir") or ".whiletrue/copies")
|
|
54
|
+
path = root / _safe(str(item.get("id", "item")))
|
|
55
|
+
if not path.exists():
|
|
56
|
+
ignore = (
|
|
57
|
+
shutil.ignore_patterns(".whiletrue", ".git")
|
|
58
|
+
if config.get("skip_git")
|
|
59
|
+
else (shutil.ignore_patterns(".whiletrue"))
|
|
60
|
+
)
|
|
61
|
+
await asyncio.to_thread(shutil.copytree, cwd, path, ignore=ignore)
|
|
62
|
+
|
|
63
|
+
async def release(state: ItemState) -> None:
|
|
64
|
+
if state == "done":
|
|
65
|
+
await asyncio.to_thread(shutil.rmtree, path, ignore_errors=True)
|
|
66
|
+
|
|
67
|
+
return Workspace(str(path), release)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class NoWorkspace:
|
|
71
|
+
"""Items share the project directory."""
|
|
72
|
+
|
|
73
|
+
async def create(self, config: dict[str, Any], item: dict[str, Any], cwd: str) -> Workspace:
|
|
74
|
+
async def release(state: ItemState) -> None:
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
return Workspace(cwd, release)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""GitHub integration: issues as items, PRs, review comments, merge, and wake conditions."""
|
|
2
|
+
|
|
3
|
+
from whiletrue.github.client import (
|
|
4
|
+
GhCliClient,
|
|
5
|
+
GhComment,
|
|
6
|
+
GhIssue,
|
|
7
|
+
GhPullRequest,
|
|
8
|
+
GitHubClient,
|
|
9
|
+
GitHubError,
|
|
10
|
+
repo_from_remote,
|
|
11
|
+
)
|
|
12
|
+
from whiletrue.github.filebacked import FileBackedGitHub
|
|
13
|
+
from whiletrue.github.memory import InMemoryGitHub
|
|
14
|
+
from whiletrue.github.plugin import github_plugin
|
|
15
|
+
from whiletrue.github.specs import blocked_by_in_body, parse_spec_refs
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"FileBackedGitHub",
|
|
19
|
+
"GhCliClient",
|
|
20
|
+
"GhComment",
|
|
21
|
+
"GhIssue",
|
|
22
|
+
"GhPullRequest",
|
|
23
|
+
"GitHubClient",
|
|
24
|
+
"GitHubError",
|
|
25
|
+
"InMemoryGitHub",
|
|
26
|
+
"blocked_by_in_body",
|
|
27
|
+
"github_plugin",
|
|
28
|
+
"parse_spec_refs",
|
|
29
|
+
"repo_from_remote",
|
|
30
|
+
]
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import Any, Literal, Protocol, cast
|
|
9
|
+
|
|
10
|
+
from whiletrue.github.specs import blocked_by_in_body
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class GitHubError(Exception):
|
|
14
|
+
"""A GitHub call failed."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class GhIssue:
|
|
19
|
+
number: int
|
|
20
|
+
title: str
|
|
21
|
+
body: str
|
|
22
|
+
url: str
|
|
23
|
+
labels: list[str] = field(default_factory=lambda: [])
|
|
24
|
+
blocked_by: list[int] = field(default_factory=lambda: [])
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class GhPullRequest:
|
|
29
|
+
number: int
|
|
30
|
+
url: str
|
|
31
|
+
branch: str
|
|
32
|
+
base: str
|
|
33
|
+
title: str
|
|
34
|
+
state: Literal["open", "closed", "merged"] = "open"
|
|
35
|
+
#: The overall review state: approved, changes_requested, or None (no decisive review yet).
|
|
36
|
+
review: Literal["approved", "changes_requested"] | None = None
|
|
37
|
+
#: Bodies of the reviews that requested changes, for a rejection note.
|
|
38
|
+
review_notes: list[str] = field(default_factory=lambda: [])
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class GhComment:
|
|
43
|
+
#: Unique within its kind: a review (inline) comment id or an issue comment id.
|
|
44
|
+
id: str
|
|
45
|
+
kind: Literal["inline", "issue"]
|
|
46
|
+
author: str
|
|
47
|
+
body: str
|
|
48
|
+
created_at: int
|
|
49
|
+
url: str | None = None
|
|
50
|
+
#: For inline comments, the id of the first comment in the thread (its own id if first).
|
|
51
|
+
thread_root: str | None = None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class GitHubClient(Protocol):
|
|
55
|
+
async def list_issues(
|
|
56
|
+
self, repo: str, *, milestone: str | None, state: str, labels: list[str]
|
|
57
|
+
) -> list[GhIssue]: ...
|
|
58
|
+
|
|
59
|
+
async def find_pr(self, repo: str, head: str) -> GhPullRequest | None: ...
|
|
60
|
+
|
|
61
|
+
async def create_pr(
|
|
62
|
+
self, repo: str, *, head: str, base: str, title: str, body: str
|
|
63
|
+
) -> GhPullRequest: ...
|
|
64
|
+
|
|
65
|
+
async def get_pr(self, repo: str, number: int) -> GhPullRequest: ...
|
|
66
|
+
|
|
67
|
+
async def list_comments(self, repo: str, number: int) -> list[GhComment]: ...
|
|
68
|
+
|
|
69
|
+
async def reply(self, repo: str, number: int, comment: GhComment, body: str) -> str: ...
|
|
70
|
+
|
|
71
|
+
async def merge_pr(self, repo: str, number: int, method: str) -> None: ...
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _login(raw: Any) -> str:
|
|
75
|
+
"""The `login` of a possibly-null `user` object."""
|
|
76
|
+
fields = cast(dict[str, Any], raw) if isinstance(raw, dict) else {}
|
|
77
|
+
user = fields.get("user")
|
|
78
|
+
return str(cast(dict[str, Any], user).get("login", "?")) if isinstance(user, dict) else "?"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _ms(stamp: str | None) -> int:
|
|
82
|
+
if not stamp:
|
|
83
|
+
return 0
|
|
84
|
+
return int(datetime.fromisoformat(stamp.replace("Z", "+00:00")).timestamp() * 1000)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def repo_from_remote(url: str) -> str | None:
|
|
88
|
+
"""`owner/repo` from a git remote URL (ssh or https)."""
|
|
89
|
+
match = re.search(r"github\.com[:/]([^/\s]+/[^/\s]+?)(?:\.git)?/?$", url.strip())
|
|
90
|
+
return match.group(1) if match else None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class GhCliClient:
|
|
94
|
+
"""Talks to GitHub through the `gh` CLI, which owns authentication."""
|
|
95
|
+
|
|
96
|
+
def __init__(self, command: str = "gh") -> None:
|
|
97
|
+
self._command = command
|
|
98
|
+
|
|
99
|
+
async def _run(self, *args: str, stdin: str | None = None) -> str:
|
|
100
|
+
try:
|
|
101
|
+
proc = await asyncio.create_subprocess_exec(
|
|
102
|
+
self._command,
|
|
103
|
+
*args,
|
|
104
|
+
stdin=asyncio.subprocess.PIPE if stdin is not None else asyncio.subprocess.DEVNULL,
|
|
105
|
+
stdout=asyncio.subprocess.PIPE,
|
|
106
|
+
stderr=asyncio.subprocess.PIPE,
|
|
107
|
+
)
|
|
108
|
+
except OSError as error:
|
|
109
|
+
raise GitHubError(f"Cannot run {self._command}: {error}") from error
|
|
110
|
+
out, err = await proc.communicate(stdin.encode() if stdin is not None else None)
|
|
111
|
+
if proc.returncode != 0:
|
|
112
|
+
detail = err.decode(errors="replace").strip() or out.decode(errors="replace").strip()
|
|
113
|
+
raise GitHubError(f"`gh {' '.join(args[:3])}` failed: {detail[-400:]}")
|
|
114
|
+
return out.decode()
|
|
115
|
+
|
|
116
|
+
async def _one(self, path: str) -> Any:
|
|
117
|
+
"""A GET that returns a single object (`--slurp` wraps it in a one-element list)."""
|
|
118
|
+
found: Any = await self._api(path)
|
|
119
|
+
if isinstance(found, list) and len(cast(list[Any], found)) > 0:
|
|
120
|
+
first: Any = cast(list[Any], found)[0]
|
|
121
|
+
return first
|
|
122
|
+
return found # pyright: ignore[reportUnknownVariableType]
|
|
123
|
+
|
|
124
|
+
async def _api(
|
|
125
|
+
self, path: str, *, method: str = "GET", body: dict[str, Any] | None = None
|
|
126
|
+
) -> Any:
|
|
127
|
+
args = ["api", path, "--method", method]
|
|
128
|
+
if method == "GET":
|
|
129
|
+
args += ["--paginate", "--slurp"]
|
|
130
|
+
text = (
|
|
131
|
+
await self._run(*args, "--input", "-", stdin=json.dumps(body))
|
|
132
|
+
if body
|
|
133
|
+
else (await self._run(*args))
|
|
134
|
+
)
|
|
135
|
+
data = json.loads(text) if text.strip() else None
|
|
136
|
+
if method == "GET" and isinstance(data, list):
|
|
137
|
+
# --slurp wraps each page in a list; flatten the pages.
|
|
138
|
+
flat: list[Any] = []
|
|
139
|
+
for page in cast(list[Any], data):
|
|
140
|
+
flat.extend(cast(list[Any], page) if isinstance(page, list) else [page])
|
|
141
|
+
return flat
|
|
142
|
+
return data
|
|
143
|
+
|
|
144
|
+
async def _milestone_number(self, repo: str, title: str) -> int:
|
|
145
|
+
for m in await self._api(f"repos/{repo}/milestones?state=all"):
|
|
146
|
+
if m["title"] == title or str(m["number"]) == title:
|
|
147
|
+
return int(m["number"])
|
|
148
|
+
raise GitHubError(f'No milestone "{title}" in {repo}')
|
|
149
|
+
|
|
150
|
+
async def list_issues(
|
|
151
|
+
self, repo: str, *, milestone: str | None, state: str, labels: list[str]
|
|
152
|
+
) -> list[GhIssue]:
|
|
153
|
+
query = f"state={state}&per_page=100"
|
|
154
|
+
if milestone:
|
|
155
|
+
query += f"&milestone={await self._milestone_number(repo, milestone)}"
|
|
156
|
+
if labels:
|
|
157
|
+
query += "&labels=" + ",".join(labels)
|
|
158
|
+
issues: list[GhIssue] = []
|
|
159
|
+
for raw in await self._api(f"repos/{repo}/issues?{query}"):
|
|
160
|
+
if "pull_request" in raw:
|
|
161
|
+
continue
|
|
162
|
+
blocked = await self._blocked_by(repo, int(raw["number"]), raw.get("body") or "")
|
|
163
|
+
issues.append(
|
|
164
|
+
GhIssue(
|
|
165
|
+
number=int(raw["number"]),
|
|
166
|
+
title=raw["title"],
|
|
167
|
+
body=raw.get("body") or "",
|
|
168
|
+
url=raw["html_url"],
|
|
169
|
+
labels=[lb["name"] for lb in raw.get("labels", [])],
|
|
170
|
+
blocked_by=blocked,
|
|
171
|
+
)
|
|
172
|
+
)
|
|
173
|
+
return issues
|
|
174
|
+
|
|
175
|
+
async def _blocked_by(self, repo: str, number: int, body: str) -> list[int]:
|
|
176
|
+
try:
|
|
177
|
+
deps = await self._api(f"repos/{repo}/issues/{number}/dependencies/blocked_by")
|
|
178
|
+
except (GitHubError, ValueError):
|
|
179
|
+
return blocked_by_in_body(body)
|
|
180
|
+
return [int(d["number"]) for d in deps if d.get("state", "open") == "open"]
|
|
181
|
+
|
|
182
|
+
def _pr(self, raw: dict[str, Any]) -> GhPullRequest:
|
|
183
|
+
merged = bool(raw.get("merged") or raw.get("merged_at"))
|
|
184
|
+
return GhPullRequest(
|
|
185
|
+
number=int(raw["number"]),
|
|
186
|
+
url=raw["html_url"],
|
|
187
|
+
branch=raw["head"]["ref"],
|
|
188
|
+
base=raw["base"]["ref"],
|
|
189
|
+
title=raw.get("title", ""),
|
|
190
|
+
state="merged" if merged else raw.get("state", "open"),
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
async def find_pr(self, repo: str, head: str) -> GhPullRequest | None:
|
|
194
|
+
owner = repo.split("/", maxsplit=1)[0]
|
|
195
|
+
found = await self._api(f"repos/{repo}/pulls?state=all&head={owner}:{head}")
|
|
196
|
+
return self._pr(found[0]) if found else None
|
|
197
|
+
|
|
198
|
+
async def create_pr(
|
|
199
|
+
self, repo: str, *, head: str, base: str, title: str, body: str
|
|
200
|
+
) -> GhPullRequest:
|
|
201
|
+
raw = await self._api(
|
|
202
|
+
f"repos/{repo}/pulls",
|
|
203
|
+
method="POST",
|
|
204
|
+
body={"head": head, "base": base, "title": title, "body": body},
|
|
205
|
+
)
|
|
206
|
+
return self._pr(raw)
|
|
207
|
+
|
|
208
|
+
async def get_pr(self, repo: str, number: int) -> GhPullRequest:
|
|
209
|
+
pr = self._pr(await self._one(f"repos/{repo}/pulls/{number}"))
|
|
210
|
+
latest: dict[str, tuple[int, str, str]] = {}
|
|
211
|
+
for review in await self._api(f"repos/{repo}/pulls/{number}/reviews"):
|
|
212
|
+
state = review.get("state", "")
|
|
213
|
+
if state in ("APPROVED", "CHANGES_REQUESTED", "DISMISSED"):
|
|
214
|
+
user = _login(review)
|
|
215
|
+
latest[user] = (int(review["id"]), state, review.get("body") or "")
|
|
216
|
+
states = {state for _, state, _ in latest.values()}
|
|
217
|
+
if "CHANGES_REQUESTED" in states:
|
|
218
|
+
pr.review = "changes_requested"
|
|
219
|
+
pr.review_notes = [b for _, s, b in latest.values() if s == "CHANGES_REQUESTED" and b]
|
|
220
|
+
elif "APPROVED" in states:
|
|
221
|
+
pr.review = "approved"
|
|
222
|
+
return pr
|
|
223
|
+
|
|
224
|
+
async def list_comments(self, repo: str, number: int) -> list[GhComment]:
|
|
225
|
+
comments: list[GhComment] = []
|
|
226
|
+
for raw in await self._api(f"repos/{repo}/pulls/{number}/comments"):
|
|
227
|
+
root = raw.get("in_reply_to_id") or raw["id"]
|
|
228
|
+
comments.append(
|
|
229
|
+
GhComment(
|
|
230
|
+
id=f"inline:{raw['id']}",
|
|
231
|
+
kind="inline",
|
|
232
|
+
author=_login(raw),
|
|
233
|
+
body=raw.get("body") or "",
|
|
234
|
+
created_at=_ms(raw.get("created_at")),
|
|
235
|
+
url=raw.get("html_url"),
|
|
236
|
+
thread_root=str(root),
|
|
237
|
+
)
|
|
238
|
+
)
|
|
239
|
+
for raw in await self._api(f"repos/{repo}/issues/{number}/comments"):
|
|
240
|
+
comments.append(
|
|
241
|
+
GhComment(
|
|
242
|
+
id=f"issue:{raw['id']}",
|
|
243
|
+
kind="issue",
|
|
244
|
+
author=_login(raw),
|
|
245
|
+
body=raw.get("body") or "",
|
|
246
|
+
created_at=_ms(raw.get("created_at")),
|
|
247
|
+
url=raw.get("html_url"),
|
|
248
|
+
)
|
|
249
|
+
)
|
|
250
|
+
return comments
|
|
251
|
+
|
|
252
|
+
async def reply(self, repo: str, number: int, comment: GhComment, body: str) -> str:
|
|
253
|
+
if comment.kind == "inline":
|
|
254
|
+
root = comment.thread_root or comment.id.split(":", 1)[1]
|
|
255
|
+
raw = await self._api(
|
|
256
|
+
f"repos/{repo}/pulls/{number}/comments/{root}/replies",
|
|
257
|
+
method="POST",
|
|
258
|
+
body={"body": body},
|
|
259
|
+
)
|
|
260
|
+
return f"inline:{raw['id']}"
|
|
261
|
+
raw = await self._api(
|
|
262
|
+
f"repos/{repo}/issues/{number}/comments", method="POST", body={"body": body}
|
|
263
|
+
)
|
|
264
|
+
return f"issue:{raw['id']}"
|
|
265
|
+
|
|
266
|
+
async def merge_pr(self, repo: str, number: int, method: str) -> None:
|
|
267
|
+
await self._api(
|
|
268
|
+
f"repos/{repo}/pulls/{number}/merge", method="PUT", body={"merge_method": method}
|
|
269
|
+
)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import asdict
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from whiletrue.github.client import GhComment, GhIssue, GhPullRequest
|
|
9
|
+
from whiletrue.github.memory import InMemoryGitHub
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FileBackedGitHub(InMemoryGitHub):
|
|
13
|
+
"""An in-memory GitHub whose state lives in a JSON file.
|
|
14
|
+
|
|
15
|
+
For dry runs of a real pipeline without a repository: issues, PRs, comments, replies and
|
|
16
|
+
merges survive across processes, so crash/resume and review loops can be tried for real.
|
|
17
|
+
Edit the file (or call the helpers) to play the reviewer.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, path: str | Path) -> None:
|
|
21
|
+
super().__init__()
|
|
22
|
+
self._path = Path(path)
|
|
23
|
+
self._load()
|
|
24
|
+
|
|
25
|
+
def _load(self) -> None:
|
|
26
|
+
if not self._path.exists():
|
|
27
|
+
return
|
|
28
|
+
data: dict[str, Any] = json.loads(self._path.read_text(encoding="utf-8"))
|
|
29
|
+
self.issues = [GhIssue(**i) for i in data.get("issues", [])]
|
|
30
|
+
self.prs = {int(k): GhPullRequest(**v) for k, v in data.get("prs", {}).items()}
|
|
31
|
+
self.comments = {
|
|
32
|
+
int(k): [GhComment(**c) for c in v] for k, v in data.get("comments", {}).items()
|
|
33
|
+
}
|
|
34
|
+
self.replies = [tuple(r) for r in data.get("replies", [])] # type: ignore[misc]
|
|
35
|
+
self.merged = [tuple(m) for m in data.get("merged", [])] # type: ignore[misc]
|
|
36
|
+
self.created = list(data.get("created", []))
|
|
37
|
+
self._next_pr = int(data.get("next_pr", 1))
|
|
38
|
+
self._next_comment = int(data.get("next_comment", 1000))
|
|
39
|
+
|
|
40
|
+
def save(self) -> None:
|
|
41
|
+
data = {
|
|
42
|
+
"issues": [asdict(i) for i in self.issues],
|
|
43
|
+
"prs": {str(k): asdict(v) for k, v in self.prs.items()},
|
|
44
|
+
"comments": {str(k): [asdict(c) for c in v] for k, v in self.comments.items()},
|
|
45
|
+
"replies": [list(r) for r in self.replies],
|
|
46
|
+
"merged": [list(m) for m in self.merged],
|
|
47
|
+
"created": self.created,
|
|
48
|
+
"next_pr": self._next_pr,
|
|
49
|
+
"next_comment": self._next_comment,
|
|
50
|
+
}
|
|
51
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
self._path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
53
|
+
|
|
54
|
+
# Every call re-reads the file first (another process may have changed it) and saves after.
|
|
55
|
+
|
|
56
|
+
async def list_issues(self, *args: Any, **kwargs: Any) -> list[GhIssue]:
|
|
57
|
+
self._load()
|
|
58
|
+
return await super().list_issues(*args, **kwargs)
|
|
59
|
+
|
|
60
|
+
async def find_pr(self, *args: Any, **kwargs: Any) -> GhPullRequest | None:
|
|
61
|
+
self._load()
|
|
62
|
+
return await super().find_pr(*args, **kwargs)
|
|
63
|
+
|
|
64
|
+
async def create_pr(self, *args: Any, **kwargs: Any) -> GhPullRequest:
|
|
65
|
+
self._load()
|
|
66
|
+
pr = await super().create_pr(*args, **kwargs)
|
|
67
|
+
self.save()
|
|
68
|
+
return pr
|
|
69
|
+
|
|
70
|
+
async def get_pr(self, *args: Any, **kwargs: Any) -> GhPullRequest:
|
|
71
|
+
self._load()
|
|
72
|
+
return await super().get_pr(*args, **kwargs)
|
|
73
|
+
|
|
74
|
+
async def list_comments(self, *args: Any, **kwargs: Any) -> list[GhComment]:
|
|
75
|
+
self._load()
|
|
76
|
+
return await super().list_comments(*args, **kwargs)
|
|
77
|
+
|
|
78
|
+
async def reply(self, *args: Any, **kwargs: Any) -> str:
|
|
79
|
+
self._load()
|
|
80
|
+
ref = await super().reply(*args, **kwargs)
|
|
81
|
+
self.save()
|
|
82
|
+
return ref
|
|
83
|
+
|
|
84
|
+
async def merge_pr(self, *args: Any, **kwargs: Any) -> None:
|
|
85
|
+
self._load()
|
|
86
|
+
await super().merge_pr(*args, **kwargs)
|
|
87
|
+
self.save()
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from dataclasses import replace
|
|
5
|
+
|
|
6
|
+
from whiletrue.github.client import GhComment, GhIssue, GhPullRequest, GitHubError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class InMemoryGitHub:
|
|
10
|
+
"""A GitHub stand-in for tests and dry runs: a few issues, PRs and comment threads."""
|
|
11
|
+
|
|
12
|
+
def __init__(self) -> None:
|
|
13
|
+
self.issues: list[GhIssue] = []
|
|
14
|
+
self.prs: dict[int, GhPullRequest] = {}
|
|
15
|
+
self.comments: dict[int, list[GhComment]] = {}
|
|
16
|
+
self.replies: list[tuple[int, str, str]] = []
|
|
17
|
+
self.merged: list[tuple[int, str]] = []
|
|
18
|
+
self.created: list[int] = []
|
|
19
|
+
self._next_pr = 1
|
|
20
|
+
self._next_comment = 1000
|
|
21
|
+
|
|
22
|
+
# ---- helpers for tests --------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
def add_comment(
|
|
25
|
+
self,
|
|
26
|
+
number: int,
|
|
27
|
+
body: str,
|
|
28
|
+
*,
|
|
29
|
+
author: str = "alice",
|
|
30
|
+
kind: str = "inline",
|
|
31
|
+
root: str | None = None,
|
|
32
|
+
) -> GhComment:
|
|
33
|
+
self._next_comment += 1
|
|
34
|
+
comment_id = str(self._next_comment)
|
|
35
|
+
comment = GhComment(
|
|
36
|
+
id=f"{kind}:{comment_id}",
|
|
37
|
+
kind="inline" if kind == "inline" else "issue",
|
|
38
|
+
author=author,
|
|
39
|
+
body=body,
|
|
40
|
+
created_at=int(time.time() * 1000),
|
|
41
|
+
url=f"https://example.test/pr/{number}#c{comment_id}",
|
|
42
|
+
thread_root=(root or comment_id) if kind == "inline" else None,
|
|
43
|
+
)
|
|
44
|
+
self.comments.setdefault(number, []).append(comment)
|
|
45
|
+
return comment
|
|
46
|
+
|
|
47
|
+
def review(self, number: int, state: str | None, notes: list[str] | None = None) -> None:
|
|
48
|
+
pr = self.prs[number]
|
|
49
|
+
pr.review = state # type: ignore[assignment]
|
|
50
|
+
pr.review_notes = list(notes or [])
|
|
51
|
+
|
|
52
|
+
# ---- GitHubClient --------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
async def list_issues(
|
|
55
|
+
self, repo: str, *, milestone: str | None, state: str, labels: list[str]
|
|
56
|
+
) -> list[GhIssue]:
|
|
57
|
+
return [replace(i) for i in self.issues if not labels or set(labels) <= set(i.labels)]
|
|
58
|
+
|
|
59
|
+
async def find_pr(self, repo: str, head: str) -> GhPullRequest | None:
|
|
60
|
+
found = next((p for p in self.prs.values() if p.branch == head), None)
|
|
61
|
+
return replace(found) if found else None
|
|
62
|
+
|
|
63
|
+
async def create_pr(
|
|
64
|
+
self, repo: str, *, head: str, base: str, title: str, body: str
|
|
65
|
+
) -> GhPullRequest:
|
|
66
|
+
number = self._next_pr
|
|
67
|
+
self._next_pr += 1
|
|
68
|
+
pr = GhPullRequest(number, f"https://example.test/{repo}/pull/{number}", head, base, title)
|
|
69
|
+
self.prs[number] = pr
|
|
70
|
+
self.created.append(number)
|
|
71
|
+
return replace(pr)
|
|
72
|
+
|
|
73
|
+
async def get_pr(self, repo: str, number: int) -> GhPullRequest:
|
|
74
|
+
if number not in self.prs:
|
|
75
|
+
raise GitHubError(f"No PR #{number}")
|
|
76
|
+
return replace(self.prs[number])
|
|
77
|
+
|
|
78
|
+
async def list_comments(self, repo: str, number: int) -> list[GhComment]:
|
|
79
|
+
return [replace(c) for c in self.comments.get(number, [])]
|
|
80
|
+
|
|
81
|
+
async def reply(self, repo: str, number: int, comment: GhComment, body: str) -> str:
|
|
82
|
+
self.replies.append((number, comment.id, body))
|
|
83
|
+
mine = self.add_comment(
|
|
84
|
+
number,
|
|
85
|
+
body,
|
|
86
|
+
author="whiletrue",
|
|
87
|
+
kind=comment.kind,
|
|
88
|
+
root=comment.thread_root,
|
|
89
|
+
)
|
|
90
|
+
return mine.id
|
|
91
|
+
|
|
92
|
+
async def merge_pr(self, repo: str, number: int, method: str) -> None:
|
|
93
|
+
if number not in self.prs:
|
|
94
|
+
raise GitHubError(f"No PR #{number}")
|
|
95
|
+
self.prs[number].state = "merged"
|
|
96
|
+
self.merged.append((number, method))
|