alissa-tools-github-revloop 0.12.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.
- alissa/tools/github/revloop/__init__.py +8 -0
- alissa/tools/github/revloop/__main__.py +181 -0
- alissa/tools/github/revloop/alissa.py +315 -0
- alissa/tools/github/revloop/config.py +212 -0
- alissa/tools/github/revloop/ghclient.py +263 -0
- alissa/tools/github/revloop/loop.py +826 -0
- alissa/tools/github/revloop/proc.py +56 -0
- alissa/tools/github/revloop/prreview.py +228 -0
- alissa/tools/github/revloop/state.py +218 -0
- alissa/tools/github/revloop/version +1 -0
- alissa/tools/github/revloop/version.py +46 -0
- alissa_tools_github_revloop-0.12.0.dist-info/METADATA +61 -0
- alissa_tools_github_revloop-0.12.0.dist-info/RECORD +16 -0
- alissa_tools_github_revloop-0.12.0.dist-info/WHEEL +5 -0
- alissa_tools_github_revloop-0.12.0.dist-info/entry_points.txt +3 -0
- alissa_tools_github_revloop-0.12.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Daemon configuration.
|
|
2
|
+
|
|
3
|
+
Settings come from three layers, later winning over earlier:
|
|
4
|
+
|
|
5
|
+
1. the defaults on `Config`
|
|
6
|
+
2. a JSON config file (see `resolve_config_path`)
|
|
7
|
+
3. CLI arguments
|
|
8
|
+
|
|
9
|
+
`workspace_root` is deliberately **not** a config key — it is a property of the
|
|
10
|
+
running process, not of the settings. That lets one config file drive several
|
|
11
|
+
daemons over different workspaces on the same machine, each pointed with
|
|
12
|
+
`--workspace-root` and narrowed with `--repo`.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, Mapping
|
|
21
|
+
|
|
22
|
+
# What to do when a PR has a pending review request but no matching Alissa
|
|
23
|
+
# review task (CR2 is implementer-side, so a third-party PR may not have one).
|
|
24
|
+
ON_MISSING_SPAWN = "spawn_anyway" # review anyway, PR URL carries the context
|
|
25
|
+
ON_MISSING_SKIP = "skip" # ignore the PR until a review task appears
|
|
26
|
+
ON_MISSING_CREATE = "warn_and_spawn" # spawn, but log loudly
|
|
27
|
+
|
|
28
|
+
_MISSING_MODES = {ON_MISSING_SPAWN, ON_MISSING_SKIP, ON_MISSING_CREATE}
|
|
29
|
+
|
|
30
|
+
# What to do when a review arrives for a repo that has no worktree hub yet.
|
|
31
|
+
HUB_SKIP = "skip"
|
|
32
|
+
HUB_ADD = "add" # `alissa code workspace add <org>/<repo>`
|
|
33
|
+
|
|
34
|
+
_HUB_MODES = {HUB_SKIP, HUB_ADD}
|
|
35
|
+
|
|
36
|
+
CONFIG_FILENAME = "revloop.config.json"
|
|
37
|
+
|
|
38
|
+
# Keys accepted in the config file. workspace_root is excluded on purpose.
|
|
39
|
+
CONFIG_KEYS = (
|
|
40
|
+
"hub_template",
|
|
41
|
+
"poll_interval",
|
|
42
|
+
"round_cap",
|
|
43
|
+
"repos",
|
|
44
|
+
"agent_profile",
|
|
45
|
+
"reviewer_login",
|
|
46
|
+
"state_path",
|
|
47
|
+
"on_missing_review_task",
|
|
48
|
+
"on_missing_hub",
|
|
49
|
+
"dry_run",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
MIN_POLL_INTERVAL = 10 # the search API allows 30 req/min
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def default_state_path(workspace_root: Path) -> Path:
|
|
56
|
+
return Path(workspace_root) / ".revloop" / "state.db"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class Config:
|
|
61
|
+
# A property of the process, supplied by --workspace-root (default: cwd).
|
|
62
|
+
workspace_root: Path
|
|
63
|
+
|
|
64
|
+
hub_template: str = "{root}/{repo}/main"
|
|
65
|
+
poll_interval: int = 60
|
|
66
|
+
round_cap: int = 10 # CR9 default
|
|
67
|
+
|
|
68
|
+
# Empty tuple means "every repo that requests a review from me".
|
|
69
|
+
repos: tuple[str, ...] = ()
|
|
70
|
+
|
|
71
|
+
agent_profile: str = "claude"
|
|
72
|
+
reviewer_login: str | None = None # None -> resolve once via `gh api user`
|
|
73
|
+
|
|
74
|
+
# None means "derive from the workspace" -- read `state_db` for the
|
|
75
|
+
# resolved location, never this field.
|
|
76
|
+
state_path: Path | None = None
|
|
77
|
+
|
|
78
|
+
on_missing_review_task: str = ON_MISSING_SPAWN
|
|
79
|
+
on_missing_hub: str = HUB_SKIP
|
|
80
|
+
dry_run: bool = False
|
|
81
|
+
|
|
82
|
+
def __post_init__(self) -> None:
|
|
83
|
+
object.__setattr__(
|
|
84
|
+
self, "workspace_root", Path(self.workspace_root).expanduser().resolve()
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def state_db(self) -> Path:
|
|
89
|
+
"""Where the spawn ledger lives. Defaults inside the workspace so two
|
|
90
|
+
daemons watching different workspaces never share one."""
|
|
91
|
+
if self.state_path is None:
|
|
92
|
+
return default_state_path(self.workspace_root)
|
|
93
|
+
return Path(self.state_path).expanduser()
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def manifest_path(self) -> Path:
|
|
97
|
+
return self.workspace_root / "alissa-workspace.yaml"
|
|
98
|
+
|
|
99
|
+
def hub_for(self, owner: str, repo: str) -> Path:
|
|
100
|
+
return Path(
|
|
101
|
+
self.hub_template.format(
|
|
102
|
+
root=str(self.workspace_root), owner=owner, repo=repo
|
|
103
|
+
)
|
|
104
|
+
).expanduser()
|
|
105
|
+
|
|
106
|
+
def watches(self, full_name: str) -> bool:
|
|
107
|
+
return not self.repos or full_name in self.repos
|
|
108
|
+
|
|
109
|
+
@classmethod
|
|
110
|
+
def build(
|
|
111
|
+
cls,
|
|
112
|
+
workspace_root: Path,
|
|
113
|
+
file_data: Mapping[str, Any] | None = None,
|
|
114
|
+
overrides: Mapping[str, Any] | None = None,
|
|
115
|
+
) -> "Config":
|
|
116
|
+
"""Merge the layers and validate. `overrides` entries that are None mean
|
|
117
|
+
"not specified on the CLI" and fall through to the file / defaults."""
|
|
118
|
+
raw: dict[str, Any] = dict(file_data or {})
|
|
119
|
+
|
|
120
|
+
if "workspace_root" in raw:
|
|
121
|
+
raise ValueError(
|
|
122
|
+
"workspace_root is not a config key — it is a property of the "
|
|
123
|
+
"running process. Pass --workspace-root (or run the daemon from "
|
|
124
|
+
"the workspace), and remove it from the config file."
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# Allow "_"-prefixed keys as inline comments, since JSON has none.
|
|
128
|
+
unknown = {k for k in set(raw) - set(CONFIG_KEYS) if not k.startswith("_")}
|
|
129
|
+
if unknown:
|
|
130
|
+
raise ValueError(
|
|
131
|
+
f"unknown config key(s): {', '.join(sorted(unknown))}. "
|
|
132
|
+
f"Valid keys: {', '.join(CONFIG_KEYS)}"
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
for key, value in (overrides or {}).items():
|
|
136
|
+
if value is not None:
|
|
137
|
+
raw[key] = value
|
|
138
|
+
|
|
139
|
+
mode = raw.get("on_missing_review_task", ON_MISSING_SPAWN)
|
|
140
|
+
if mode not in _MISSING_MODES:
|
|
141
|
+
raise ValueError(
|
|
142
|
+
f"on_missing_review_task must be one of {sorted(_MISSING_MODES)}, got {mode!r}"
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
hub_mode = raw.get("on_missing_hub", HUB_SKIP)
|
|
146
|
+
if hub_mode not in _HUB_MODES:
|
|
147
|
+
raise ValueError(
|
|
148
|
+
f"on_missing_hub must be one of {sorted(_HUB_MODES)}, got {hub_mode!r}"
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
repos = tuple(raw.get("repos", ()))
|
|
152
|
+
if hub_mode == HUB_ADD and not repos:
|
|
153
|
+
# Anyone who can request a review could otherwise cause an arbitrary
|
|
154
|
+
# repo to be cloned onto this machine and opened as an agent's cwd.
|
|
155
|
+
raise ValueError(
|
|
156
|
+
"on_missing_hub='add' requires a non-empty repos allowlist "
|
|
157
|
+
"(config `repos`, or one or more --repo flags) — auto-cloning "
|
|
158
|
+
"whatever repo requests a review is unbounded"
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
cap = int(raw.get("round_cap", cls.round_cap))
|
|
162
|
+
if cap < 1:
|
|
163
|
+
raise ValueError(f"round_cap must be >= 1, got {cap}")
|
|
164
|
+
|
|
165
|
+
interval = int(raw.get("poll_interval", 60))
|
|
166
|
+
if interval < MIN_POLL_INTERVAL:
|
|
167
|
+
raise ValueError(
|
|
168
|
+
f"poll_interval must be >= {MIN_POLL_INTERVAL} seconds, got {interval}"
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
state_path = raw.get("state_path")
|
|
172
|
+
return cls(
|
|
173
|
+
workspace_root=Path(workspace_root),
|
|
174
|
+
hub_template=raw.get("hub_template", cls.hub_template),
|
|
175
|
+
poll_interval=interval,
|
|
176
|
+
round_cap=cap,
|
|
177
|
+
repos=repos,
|
|
178
|
+
agent_profile=raw.get("agent_profile", "claude"),
|
|
179
|
+
reviewer_login=raw.get("reviewer_login"),
|
|
180
|
+
state_path=Path(state_path).expanduser() if state_path else None,
|
|
181
|
+
on_missing_review_task=mode,
|
|
182
|
+
on_missing_hub=hub_mode,
|
|
183
|
+
dry_run=bool(raw.get("dry_run", False)),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def load_config_file(path: Path) -> dict[str, Any]:
|
|
188
|
+
data = json.loads(Path(path).expanduser().read_text())
|
|
189
|
+
if not isinstance(data, dict):
|
|
190
|
+
raise ValueError(f"{path}: expected a JSON object, got {type(data).__name__}")
|
|
191
|
+
return data
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def resolve_config_path(
|
|
195
|
+
explicit: Path | None, workspace_root: Path, cwd: Path | None = None
|
|
196
|
+
) -> Path | None:
|
|
197
|
+
"""Find the config file: explicit path, then cwd, then the workspace root.
|
|
198
|
+
|
|
199
|
+
Returns None when no config file exists — CLI arguments and defaults alone
|
|
200
|
+
are a valid way to run. An explicit path that does not exist is an error.
|
|
201
|
+
"""
|
|
202
|
+
if explicit is not None:
|
|
203
|
+
path = Path(explicit).expanduser()
|
|
204
|
+
if not path.is_file():
|
|
205
|
+
raise FileNotFoundError(f"config file not found: {path}")
|
|
206
|
+
return path
|
|
207
|
+
|
|
208
|
+
cwd = Path.cwd() if cwd is None else Path(cwd)
|
|
209
|
+
for candidate in (cwd / CONFIG_FILENAME, Path(workspace_root) / CONFIG_FILENAME):
|
|
210
|
+
if candidate.is_file():
|
|
211
|
+
return candidate
|
|
212
|
+
return None
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""GitHub access via `gh api`.
|
|
2
|
+
|
|
3
|
+
Note: this targets gh 2.4.0, which predates `gh search`. Every query goes
|
|
4
|
+
through `gh api` against the REST v3 endpoints instead.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
from .proc import CommandError, run, run_json
|
|
13
|
+
|
|
14
|
+
log = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
# States that count as "I have reviewed this". PENDING is a draft review that
|
|
17
|
+
# was never submitted, so it does not close a round.
|
|
18
|
+
SUBMITTED_STATES = {"APPROVED", "CHANGES_REQUESTED", "COMMENTED", "DISMISSED"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class PullRequest:
|
|
23
|
+
owner: str
|
|
24
|
+
repo: str
|
|
25
|
+
number: int
|
|
26
|
+
title: str
|
|
27
|
+
author: str
|
|
28
|
+
head_sha: str
|
|
29
|
+
draft: bool
|
|
30
|
+
url: str
|
|
31
|
+
# "open" or "closed"; merged PRs report state "closed" AND merged True.
|
|
32
|
+
state: str = "open"
|
|
33
|
+
merged: bool = False
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def full_name(self) -> str:
|
|
37
|
+
return f"{self.owner}/{self.repo}"
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def slug(self) -> str:
|
|
41
|
+
return f"{self.owner}/{self.repo}#{self.number}"
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def is_terminal(self) -> bool:
|
|
45
|
+
"""Closed or merged: no round can ever be owed on this PR again."""
|
|
46
|
+
return self.merged or self.state != "open"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class IssueComment:
|
|
51
|
+
"""An issue comment on a PR (a PR is an issue). Distinct from Review:
|
|
52
|
+
issue comments live on the issues endpoints and never create review
|
|
53
|
+
records, so posting one can never disturb round counting."""
|
|
54
|
+
|
|
55
|
+
id: int
|
|
56
|
+
author: str
|
|
57
|
+
body: str
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class Review:
|
|
62
|
+
author: str
|
|
63
|
+
state: str
|
|
64
|
+
commit_id: str
|
|
65
|
+
submitted_at: str
|
|
66
|
+
url: str
|
|
67
|
+
body: str = ""
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def is_substantive(self) -> bool:
|
|
71
|
+
"""A real review round, not a side effect of an inline comment.
|
|
72
|
+
|
|
73
|
+
Posting a standalone inline comment on a PR creates its own review
|
|
74
|
+
record with an empty body, so review records outnumber rounds. The
|
|
75
|
+
round-closing review always carries the verdict write-up in its body.
|
|
76
|
+
"""
|
|
77
|
+
return bool(self.body.strip())
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class RateLimited(RuntimeError):
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class IdentityMismatch(RuntimeError):
|
|
85
|
+
"""Configured reviewer identity disagrees with the gh token."""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class GitHub:
|
|
89
|
+
def __init__(self, login: str | None = None):
|
|
90
|
+
self._login = login
|
|
91
|
+
|
|
92
|
+
def token_login(self) -> str:
|
|
93
|
+
"""Who the gh token actually belongs to. `gh api --jq` prints scalars
|
|
94
|
+
raw (unquoted), so this is deliberately not parsed as JSON."""
|
|
95
|
+
return run(["gh", "api", "user", "--jq", ".login"]).strip()
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def login(self) -> str:
|
|
99
|
+
if self._login is None:
|
|
100
|
+
self._login = self.token_login()
|
|
101
|
+
return self._login
|
|
102
|
+
|
|
103
|
+
def verify_identity(self) -> str:
|
|
104
|
+
"""`review-requested:@me` resolves server-side from the gh token, but
|
|
105
|
+
round counting filters reviews by `self.login`. If a configured
|
|
106
|
+
reviewer_login disagrees with the token, the daemon would search one
|
|
107
|
+
account's queue and count another's reviews — every round would look
|
|
108
|
+
like round 1 and respawn forever. Fail loudly instead."""
|
|
109
|
+
actual = self.token_login()
|
|
110
|
+
if self._login is not None and self._login != actual:
|
|
111
|
+
raise IdentityMismatch(
|
|
112
|
+
f"configured reviewer_login={self._login!r} but the gh token "
|
|
113
|
+
f"belongs to {actual!r}. `@me` follows the token, so round "
|
|
114
|
+
f"counting would break. Fix reviewer_login (or set it to null "
|
|
115
|
+
f"to auto-detect), or re-authenticate gh."
|
|
116
|
+
)
|
|
117
|
+
self._login = actual
|
|
118
|
+
return actual
|
|
119
|
+
|
|
120
|
+
def _api(self, *args: str, timeout: int = 60):
|
|
121
|
+
try:
|
|
122
|
+
return run_json(["gh", "api", *args], timeout=timeout)
|
|
123
|
+
except CommandError as exc:
|
|
124
|
+
blob = exc.stderr.lower()
|
|
125
|
+
if "rate limit" in blob or "403" in blob:
|
|
126
|
+
raise RateLimited(exc.stderr.strip()[:300]) from exc
|
|
127
|
+
raise
|
|
128
|
+
|
|
129
|
+
def review_requests(self, repos: tuple[str, ...] = ()) -> list[tuple[str, str, int]]:
|
|
130
|
+
"""PRs with a review pending from me.
|
|
131
|
+
|
|
132
|
+
`draft:false` enforces CR1 -- draft PRs are never reviewed. GitHub
|
|
133
|
+
clears the request once a review is submitted and re-adds it when the
|
|
134
|
+
implementer re-requests, so this doubles as the CR9 round edge-trigger.
|
|
135
|
+
"""
|
|
136
|
+
query = "is:open is:pr draft:false review-requested:@me"
|
|
137
|
+
for full_name in repos:
|
|
138
|
+
query += f" repo:{full_name}"
|
|
139
|
+
|
|
140
|
+
payload = self._api(
|
|
141
|
+
"-X",
|
|
142
|
+
"GET",
|
|
143
|
+
"search/issues",
|
|
144
|
+
"-f",
|
|
145
|
+
f"q={query}",
|
|
146
|
+
"-f",
|
|
147
|
+
"per_page=100",
|
|
148
|
+
)
|
|
149
|
+
items = (payload or {}).get("items", [])
|
|
150
|
+
|
|
151
|
+
out: list[tuple[str, str, int]] = []
|
|
152
|
+
for item in items:
|
|
153
|
+
# repository_url looks like https://api.github.com/repos/<owner>/<repo>
|
|
154
|
+
parts = item.get("repository_url", "").rstrip("/").split("/")
|
|
155
|
+
if len(parts) < 2:
|
|
156
|
+
log.warning("could not parse repo from %s", item.get("repository_url"))
|
|
157
|
+
continue
|
|
158
|
+
out.append((parts[-2], parts[-1], int(item["number"])))
|
|
159
|
+
return out
|
|
160
|
+
|
|
161
|
+
def pull_request(self, owner: str, repo: str, number: int) -> PullRequest:
|
|
162
|
+
data = self._api(f"repos/{owner}/{repo}/pulls/{number}")
|
|
163
|
+
return PullRequest(
|
|
164
|
+
owner=owner,
|
|
165
|
+
repo=repo,
|
|
166
|
+
number=number,
|
|
167
|
+
title=data.get("title", ""),
|
|
168
|
+
author=(data.get("user") or {}).get("login", ""),
|
|
169
|
+
head_sha=(data.get("head") or {}).get("sha", ""),
|
|
170
|
+
draft=bool(data.get("draft")),
|
|
171
|
+
url=data.get("html_url", ""),
|
|
172
|
+
state=data.get("state") or "open",
|
|
173
|
+
merged=bool(data.get("merged")),
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
def reviews(self, owner: str, repo: str, number: int) -> list[Review]:
|
|
177
|
+
data = (
|
|
178
|
+
self._api(
|
|
179
|
+
"-X",
|
|
180
|
+
"GET",
|
|
181
|
+
f"repos/{owner}/{repo}/pulls/{number}/reviews",
|
|
182
|
+
"-f",
|
|
183
|
+
"per_page=100",
|
|
184
|
+
)
|
|
185
|
+
or []
|
|
186
|
+
)
|
|
187
|
+
return [
|
|
188
|
+
Review(
|
|
189
|
+
author=(r.get("user") or {}).get("login", ""),
|
|
190
|
+
state=r.get("state", ""),
|
|
191
|
+
commit_id=r.get("commit_id") or "",
|
|
192
|
+
submitted_at=r.get("submitted_at") or "",
|
|
193
|
+
url=r.get("html_url", ""),
|
|
194
|
+
body=r.get("body") or "",
|
|
195
|
+
)
|
|
196
|
+
for r in data
|
|
197
|
+
]
|
|
198
|
+
|
|
199
|
+
def my_reviews(self, owner: str, repo: str, number: int) -> list[Review]:
|
|
200
|
+
"""My substantive submitted reviews, oldest first -- one per round.
|
|
201
|
+
|
|
202
|
+
Empty-bodied records are dropped: a standalone inline comment creates
|
|
203
|
+
its own zero-body review record, so counting raw records overcounts
|
|
204
|
+
rounds badly. On fahera-mx/studio.alissa.app#210 three real rounds
|
|
205
|
+
produced six records (round 1 plus three inline-comment artifacts, then
|
|
206
|
+
rounds 2 and 3), and round 3's reviewer was told it was "round 6 of
|
|
207
|
+
cap 10".
|
|
208
|
+
|
|
209
|
+
Do NOT dedupe by `commit_id` instead -- it looks like the natural
|
|
210
|
+
grouping key but it UNDERCOUNTS. A round reviews whatever head is
|
|
211
|
+
current, and consecutive rounds routinely land on the same commit when
|
|
212
|
+
the implementer triages findings without pushing: on #210 rounds 2 and
|
|
213
|
+
3 both carry head 805398a and would collapse into one. Body presence
|
|
214
|
+
tracks "a reviewer wrote a verdict"; commit identity does not.
|
|
215
|
+
"""
|
|
216
|
+
mine = [
|
|
217
|
+
r
|
|
218
|
+
for r in self.reviews(owner, repo, number)
|
|
219
|
+
if r.author == self.login
|
|
220
|
+
and r.state in SUBMITTED_STATES
|
|
221
|
+
and r.is_substantive
|
|
222
|
+
]
|
|
223
|
+
return sorted(mine, key=lambda r: r.submitted_at)
|
|
224
|
+
|
|
225
|
+
def comment(self, owner: str, repo: str, number: int, body: str) -> None:
|
|
226
|
+
run_json(
|
|
227
|
+
[
|
|
228
|
+
"gh",
|
|
229
|
+
"api",
|
|
230
|
+
f"repos/{owner}/{repo}/issues/{number}/comments",
|
|
231
|
+
"-f",
|
|
232
|
+
f"body={body}",
|
|
233
|
+
]
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
def issue_comments(self, owner: str, repo: str, number: int) -> list[IssueComment]:
|
|
237
|
+
data = (
|
|
238
|
+
self._api(
|
|
239
|
+
"-X",
|
|
240
|
+
"GET",
|
|
241
|
+
f"repos/{owner}/{repo}/issues/{number}/comments",
|
|
242
|
+
"-f",
|
|
243
|
+
"per_page=100",
|
|
244
|
+
)
|
|
245
|
+
or []
|
|
246
|
+
)
|
|
247
|
+
return [
|
|
248
|
+
IssueComment(
|
|
249
|
+
id=int(c.get("id") or 0),
|
|
250
|
+
author=(c.get("user") or {}).get("login", ""),
|
|
251
|
+
body=c.get("body") or "",
|
|
252
|
+
)
|
|
253
|
+
for c in data
|
|
254
|
+
]
|
|
255
|
+
|
|
256
|
+
def update_comment(self, owner: str, repo: str, comment_id: int, body: str) -> None:
|
|
257
|
+
self._api(
|
|
258
|
+
"-X",
|
|
259
|
+
"PATCH",
|
|
260
|
+
f"repos/{owner}/{repo}/issues/comments/{comment_id}",
|
|
261
|
+
"-f",
|
|
262
|
+
f"body={body}",
|
|
263
|
+
)
|