phab-feedback 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.
- phab_feedback/__init__.py +3 -0
- phab_feedback/__main__.py +3 -0
- phab_feedback/api.py +107 -0
- phab_feedback/cli.py +241 -0
- phab_feedback/config.py +276 -0
- phab_feedback/errors.py +21 -0
- phab_feedback/py.typed +0 -0
- phab_feedback/service.py +407 -0
- phab_feedback/transport.py +62 -0
- phab_feedback-0.1.0.dist-info/METADATA +184 -0
- phab_feedback-0.1.0.dist-info/RECORD +15 -0
- phab_feedback-0.1.0.dist-info/WHEEL +5 -0
- phab_feedback-0.1.0.dist-info/entry_points.txt +2 -0
- phab_feedback-0.1.0.dist-info/licenses/LICENSE +21 -0
- phab_feedback-0.1.0.dist-info/top_level.txt +1 -0
phab_feedback/api.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Conduit and internal web API clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import html
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from typing import Any, Mapping
|
|
9
|
+
from urllib.parse import urlencode
|
|
10
|
+
|
|
11
|
+
from .errors import APIError
|
|
12
|
+
from .transport import Transport
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ConduitClient:
|
|
16
|
+
def __init__(self, host: str, token: str, transport: Transport) -> None:
|
|
17
|
+
self.host = host
|
|
18
|
+
self._token = token
|
|
19
|
+
self._transport = transport
|
|
20
|
+
|
|
21
|
+
def call(self, method: str, params: Mapping[str, Any]) -> Any:
|
|
22
|
+
conduit_params = dict(params)
|
|
23
|
+
conduit_params["__conduit__"] = {"token": self._token}
|
|
24
|
+
data = urlencode(
|
|
25
|
+
{
|
|
26
|
+
"params": json.dumps(conduit_params, separators=(",", ":")),
|
|
27
|
+
"output": "json",
|
|
28
|
+
"__conduit__": "1",
|
|
29
|
+
}
|
|
30
|
+
).encode()
|
|
31
|
+
response = self._transport.request(
|
|
32
|
+
"POST",
|
|
33
|
+
f"{self.host}/api/{method}",
|
|
34
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
35
|
+
data=data,
|
|
36
|
+
)
|
|
37
|
+
payload = _json_object(response.body, f"Conduit method {method}")
|
|
38
|
+
if payload.get("error_code"):
|
|
39
|
+
code = payload["error_code"]
|
|
40
|
+
info = str(payload.get("error_info") or "request rejected")
|
|
41
|
+
info = info.replace(self._token, "[redacted]")
|
|
42
|
+
raise APIError(f"Conduit {method} failed: {code}: {info}")
|
|
43
|
+
if "result" not in payload:
|
|
44
|
+
raise APIError(f"Conduit {method} returned no result")
|
|
45
|
+
return payload["result"]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class WebClient:
|
|
49
|
+
def __init__(self, host: str, cookie_header: str, transport: Transport) -> None:
|
|
50
|
+
self.host = host
|
|
51
|
+
self._cookie_header = cookie_header
|
|
52
|
+
self._transport = transport
|
|
53
|
+
self._csrf: str | None = None
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def csrf(self) -> str:
|
|
57
|
+
if self._csrf is None:
|
|
58
|
+
response = self._transport.request(
|
|
59
|
+
"GET",
|
|
60
|
+
self.host,
|
|
61
|
+
headers={"Cookie": self._cookie_header},
|
|
62
|
+
)
|
|
63
|
+
decoded = html.unescape(
|
|
64
|
+
response.body.decode("utf-8", errors="replace")
|
|
65
|
+
)
|
|
66
|
+
patterns = (
|
|
67
|
+
r'name="__csrf__"\s+value="(B@[A-Za-z0-9]+)"',
|
|
68
|
+
r'"current":"(B@[A-Za-z0-9]+)"',
|
|
69
|
+
r'"token":"(B@[A-Za-z0-9]+)"',
|
|
70
|
+
)
|
|
71
|
+
for pattern in patterns:
|
|
72
|
+
match = re.search(pattern, decoded)
|
|
73
|
+
if match:
|
|
74
|
+
self._csrf = match.group(1)
|
|
75
|
+
break
|
|
76
|
+
if self._csrf is None:
|
|
77
|
+
raise APIError("Could not extract a CSRF token from the host")
|
|
78
|
+
return self._csrf
|
|
79
|
+
|
|
80
|
+
def post(self, path: str, data: Mapping[str, Any]) -> dict[str, Any]:
|
|
81
|
+
csrf = self.csrf
|
|
82
|
+
response = self._transport.request(
|
|
83
|
+
"POST",
|
|
84
|
+
f"{self.host}{path}",
|
|
85
|
+
headers={
|
|
86
|
+
"Cookie": self._cookie_header,
|
|
87
|
+
"X-Phabricator-Csrf": csrf,
|
|
88
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
89
|
+
},
|
|
90
|
+
data=urlencode(data).encode(),
|
|
91
|
+
)
|
|
92
|
+
body = re.sub(rb"^for \(;;\);", b"", response.body)
|
|
93
|
+
payload = _json_object(body, f"Web endpoint {path}")
|
|
94
|
+
error = payload.get("error")
|
|
95
|
+
if error:
|
|
96
|
+
raise APIError(f"Web endpoint {path} failed: {error}")
|
|
97
|
+
return payload
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _json_object(body: bytes, operation: str) -> dict[str, Any]:
|
|
101
|
+
try:
|
|
102
|
+
payload = json.loads(body.decode("utf-8"))
|
|
103
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
|
104
|
+
raise APIError(f"{operation} returned an invalid JSON response") from error
|
|
105
|
+
if not isinstance(payload, dict):
|
|
106
|
+
raise APIError(f"{operation} returned an unexpected response")
|
|
107
|
+
return payload
|
phab_feedback/cli.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Command-line interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Sequence, TextIO
|
|
10
|
+
|
|
11
|
+
from .api import ConduitClient, WebClient
|
|
12
|
+
from .config import ConfigResolver
|
|
13
|
+
from .errors import PhabFeedbackError, ValidationError
|
|
14
|
+
from .service import FeedbackService
|
|
15
|
+
from .transport import Transport, UrllibTransport
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
CONDUIT_COMMANDS = {
|
|
19
|
+
"timeline",
|
|
20
|
+
"comment",
|
|
21
|
+
"reply-inline",
|
|
22
|
+
"remove-comment",
|
|
23
|
+
"mark-done",
|
|
24
|
+
"mark-helpful",
|
|
25
|
+
"mark-unhelpful",
|
|
26
|
+
}
|
|
27
|
+
WEB_COMMANDS = {
|
|
28
|
+
"reply-inline",
|
|
29
|
+
"remove-comment",
|
|
30
|
+
"mark-done",
|
|
31
|
+
"mark-helpful",
|
|
32
|
+
"mark-unhelpful",
|
|
33
|
+
"submit",
|
|
34
|
+
"request-ai-review",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
39
|
+
parser = argparse.ArgumentParser(
|
|
40
|
+
prog="phab-feedback",
|
|
41
|
+
description="Manage Phabricator and Phorge review feedback",
|
|
42
|
+
)
|
|
43
|
+
parser.add_argument("--host", help="Phabricator/Phorge base URL")
|
|
44
|
+
parser.add_argument(
|
|
45
|
+
"--config",
|
|
46
|
+
type=Path,
|
|
47
|
+
help="Path to config JSON (default: XDG config directory)",
|
|
48
|
+
)
|
|
49
|
+
parser.add_argument(
|
|
50
|
+
"--firefox-cookies",
|
|
51
|
+
action="store_true",
|
|
52
|
+
help="Read the web session from a local Firefox profile",
|
|
53
|
+
)
|
|
54
|
+
parser.add_argument(
|
|
55
|
+
"--firefox-profile",
|
|
56
|
+
type=Path,
|
|
57
|
+
help="Firefox profile directory (implies --firefox-cookies)",
|
|
58
|
+
)
|
|
59
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
60
|
+
|
|
61
|
+
timeline = subparsers.add_parser(
|
|
62
|
+
"timeline", help="Show structured general and inline feedback"
|
|
63
|
+
)
|
|
64
|
+
timeline.add_argument("revision")
|
|
65
|
+
|
|
66
|
+
comment = subparsers.add_parser(
|
|
67
|
+
"comment", help="Post an immediate top-level revision comment"
|
|
68
|
+
)
|
|
69
|
+
comment.add_argument("revision")
|
|
70
|
+
_add_message_options(comment)
|
|
71
|
+
|
|
72
|
+
reply = subparsers.add_parser(
|
|
73
|
+
"reply-inline", help="Draft a true reply to an inline comment"
|
|
74
|
+
)
|
|
75
|
+
reply.add_argument("revision")
|
|
76
|
+
reply.add_argument("comment_id")
|
|
77
|
+
_add_message_options(reply)
|
|
78
|
+
reply.add_argument(
|
|
79
|
+
"--submit",
|
|
80
|
+
action="store_true",
|
|
81
|
+
help="Explicitly publish the new reply draft immediately",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
remove = subparsers.add_parser(
|
|
85
|
+
"remove-comment", help="Remove an accidental top-level comment"
|
|
86
|
+
)
|
|
87
|
+
remove.add_argument("revision")
|
|
88
|
+
remove.add_argument("comment_id")
|
|
89
|
+
|
|
90
|
+
done = subparsers.add_parser(
|
|
91
|
+
"mark-done", help="Mark inline comments Done as drafts"
|
|
92
|
+
)
|
|
93
|
+
done.add_argument("revision")
|
|
94
|
+
done.add_argument("comment_ids", nargs="+")
|
|
95
|
+
|
|
96
|
+
submit = subparsers.add_parser(
|
|
97
|
+
"submit", help="Submit pending draft actions and comments"
|
|
98
|
+
)
|
|
99
|
+
submit.add_argument("revision")
|
|
100
|
+
|
|
101
|
+
helpful = subparsers.add_parser(
|
|
102
|
+
"mark-helpful",
|
|
103
|
+
help="Rate Review Helper feedback helpful (Mozilla only)",
|
|
104
|
+
)
|
|
105
|
+
helpful.add_argument("revision")
|
|
106
|
+
helpful.add_argument("comment_ids", nargs="+")
|
|
107
|
+
|
|
108
|
+
unhelpful = subparsers.add_parser(
|
|
109
|
+
"mark-unhelpful",
|
|
110
|
+
help="Rate Review Helper feedback unhelpful (Mozilla only)",
|
|
111
|
+
)
|
|
112
|
+
unhelpful.add_argument("revision")
|
|
113
|
+
unhelpful.add_argument("comment_ids", nargs="+")
|
|
114
|
+
|
|
115
|
+
request = subparsers.add_parser(
|
|
116
|
+
"request-ai-review",
|
|
117
|
+
help="Request a Review Helper AI review (Mozilla only)",
|
|
118
|
+
)
|
|
119
|
+
request.add_argument("revision")
|
|
120
|
+
|
|
121
|
+
return parser
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _add_message_options(parser: argparse.ArgumentParser) -> None:
|
|
125
|
+
group = parser.add_mutually_exclusive_group()
|
|
126
|
+
group.add_argument("--message", help="Message text")
|
|
127
|
+
group.add_argument(
|
|
128
|
+
"--message-file",
|
|
129
|
+
type=Path,
|
|
130
|
+
help="Read message from a file, or use - for stdin",
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def read_message(args: argparse.Namespace, stdin: TextIO) -> str:
|
|
135
|
+
if args.message is not None:
|
|
136
|
+
message = args.message
|
|
137
|
+
elif args.message_file is not None:
|
|
138
|
+
if str(args.message_file) == "-":
|
|
139
|
+
message = stdin.read()
|
|
140
|
+
else:
|
|
141
|
+
try:
|
|
142
|
+
message = args.message_file.read_text(encoding="utf-8")
|
|
143
|
+
except OSError as error:
|
|
144
|
+
raise ValidationError(
|
|
145
|
+
f"Could not read message file: {args.message_file}"
|
|
146
|
+
) from error
|
|
147
|
+
elif not stdin.isatty():
|
|
148
|
+
message = stdin.read()
|
|
149
|
+
else:
|
|
150
|
+
raise ValidationError(
|
|
151
|
+
"Provide --message, --message-file, or redirected stdin"
|
|
152
|
+
)
|
|
153
|
+
if not message.strip():
|
|
154
|
+
raise ValidationError("Message must not be empty")
|
|
155
|
+
return message
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def run(
|
|
159
|
+
argv: Sequence[str] | None = None,
|
|
160
|
+
*,
|
|
161
|
+
stdin: TextIO = sys.stdin,
|
|
162
|
+
stdout: TextIO = sys.stdout,
|
|
163
|
+
stderr: TextIO = sys.stderr,
|
|
164
|
+
resolver: ConfigResolver | None = None,
|
|
165
|
+
transport: Transport | None = None,
|
|
166
|
+
) -> int:
|
|
167
|
+
parser = build_parser()
|
|
168
|
+
args = parser.parse_args(argv)
|
|
169
|
+
command = args.command
|
|
170
|
+
resolver = resolver or ConfigResolver()
|
|
171
|
+
transport = transport or UrllibTransport()
|
|
172
|
+
try:
|
|
173
|
+
credentials = resolver.resolve(
|
|
174
|
+
cli_host=args.host,
|
|
175
|
+
config_path=args.config,
|
|
176
|
+
require_token=command in CONDUIT_COMMANDS,
|
|
177
|
+
require_cookie=command in WEB_COMMANDS,
|
|
178
|
+
firefox_cookies=args.firefox_cookies
|
|
179
|
+
or args.firefox_profile is not None,
|
|
180
|
+
firefox_profile=args.firefox_profile,
|
|
181
|
+
)
|
|
182
|
+
conduit = (
|
|
183
|
+
ConduitClient(
|
|
184
|
+
credentials.host,
|
|
185
|
+
credentials.conduit_token or "",
|
|
186
|
+
transport,
|
|
187
|
+
)
|
|
188
|
+
if command in CONDUIT_COMMANDS
|
|
189
|
+
else None
|
|
190
|
+
)
|
|
191
|
+
web = (
|
|
192
|
+
WebClient(
|
|
193
|
+
credentials.host,
|
|
194
|
+
credentials.cookie_header or "",
|
|
195
|
+
transport,
|
|
196
|
+
)
|
|
197
|
+
if command in WEB_COMMANDS
|
|
198
|
+
else None
|
|
199
|
+
)
|
|
200
|
+
service = FeedbackService(conduit=conduit, web=web)
|
|
201
|
+
|
|
202
|
+
if command == "timeline":
|
|
203
|
+
result = service.timeline(args.revision)
|
|
204
|
+
elif command == "comment":
|
|
205
|
+
result = service.post_comment(
|
|
206
|
+
args.revision, read_message(args, stdin)
|
|
207
|
+
)
|
|
208
|
+
elif command == "reply-inline":
|
|
209
|
+
result = service.draft_inline_reply(
|
|
210
|
+
args.revision, args.comment_id, read_message(args, stdin)
|
|
211
|
+
)
|
|
212
|
+
if args.submit:
|
|
213
|
+
result["submission"] = service.submit(args.revision)
|
|
214
|
+
elif command == "remove-comment":
|
|
215
|
+
result = service.remove_comment(args.revision, args.comment_id)
|
|
216
|
+
elif command == "mark-done":
|
|
217
|
+
result = service.mark_done(args.revision, args.comment_ids)
|
|
218
|
+
elif command == "submit":
|
|
219
|
+
result = service.submit(args.revision)
|
|
220
|
+
elif command == "mark-helpful":
|
|
221
|
+
result = service.rate(
|
|
222
|
+
args.revision, args.comment_ids, helpful=True
|
|
223
|
+
)
|
|
224
|
+
elif command == "mark-unhelpful":
|
|
225
|
+
result = service.rate(
|
|
226
|
+
args.revision, args.comment_ids, helpful=False
|
|
227
|
+
)
|
|
228
|
+
elif command == "request-ai-review":
|
|
229
|
+
result = service.request_ai_review(args.revision)
|
|
230
|
+
else:
|
|
231
|
+
raise AssertionError(f"Unhandled command: {command}")
|
|
232
|
+
except PhabFeedbackError as error:
|
|
233
|
+
print(f"error: {error}", file=stderr)
|
|
234
|
+
return 1
|
|
235
|
+
json.dump(result, stdout, indent=2, sort_keys=True)
|
|
236
|
+
stdout.write("\n")
|
|
237
|
+
return 0
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
241
|
+
return run(argv)
|
phab_feedback/config.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""Configuration and credential discovery."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import configparser
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import sqlite3
|
|
10
|
+
import tempfile
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Mapping
|
|
14
|
+
from urllib.parse import urlsplit
|
|
15
|
+
|
|
16
|
+
from .errors import ConfigurationError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Credentials:
|
|
21
|
+
host: str
|
|
22
|
+
conduit_token: str | None = None
|
|
23
|
+
cookie_header: str | None = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ConfigResolver:
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
*,
|
|
30
|
+
env: Mapping[str, str] | None = None,
|
|
31
|
+
home: Path | None = None,
|
|
32
|
+
) -> None:
|
|
33
|
+
self.env = dict(os.environ if env is None else env)
|
|
34
|
+
self.home = Path.home() if home is None else home
|
|
35
|
+
|
|
36
|
+
def resolve(
|
|
37
|
+
self,
|
|
38
|
+
*,
|
|
39
|
+
cli_host: str | None,
|
|
40
|
+
config_path: Path | None,
|
|
41
|
+
require_token: bool,
|
|
42
|
+
require_cookie: bool,
|
|
43
|
+
firefox_cookies: bool,
|
|
44
|
+
firefox_profile: Path | None,
|
|
45
|
+
) -> Credentials:
|
|
46
|
+
config = self._read_config(config_path)
|
|
47
|
+
arcrc = self._read_arcrc()
|
|
48
|
+
host = self._resolve_host(cli_host, config, arcrc)
|
|
49
|
+
token = self._resolve_token(host, arcrc) if require_token else None
|
|
50
|
+
cookie = (
|
|
51
|
+
self._resolve_cookie(
|
|
52
|
+
host,
|
|
53
|
+
config,
|
|
54
|
+
firefox_cookies=firefox_cookies,
|
|
55
|
+
firefox_profile=firefox_profile,
|
|
56
|
+
)
|
|
57
|
+
if require_cookie
|
|
58
|
+
else None
|
|
59
|
+
)
|
|
60
|
+
return Credentials(host=host, conduit_token=token, cookie_header=cookie)
|
|
61
|
+
|
|
62
|
+
def _default_config_path(self) -> Path:
|
|
63
|
+
root = Path(
|
|
64
|
+
self.env.get("XDG_CONFIG_HOME", self.home / ".config")
|
|
65
|
+
).expanduser()
|
|
66
|
+
return root / "phab-feedback" / "config.json"
|
|
67
|
+
|
|
68
|
+
def _read_config(self, path: Path | None) -> dict[str, object]:
|
|
69
|
+
selected = self._default_config_path() if path is None else path.expanduser()
|
|
70
|
+
if not selected.exists():
|
|
71
|
+
if path is not None:
|
|
72
|
+
raise ConfigurationError(f"Config file not found: {selected}")
|
|
73
|
+
return {}
|
|
74
|
+
try:
|
|
75
|
+
payload = json.loads(selected.read_text(encoding="utf-8"))
|
|
76
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
77
|
+
raise ConfigurationError(f"Could not read config file: {selected}") from error
|
|
78
|
+
if not isinstance(payload, dict):
|
|
79
|
+
raise ConfigurationError("Config file must contain a JSON object")
|
|
80
|
+
return payload
|
|
81
|
+
|
|
82
|
+
def _arcrc_path(self) -> Path:
|
|
83
|
+
return Path(
|
|
84
|
+
self.env.get("PHAB_FEEDBACK_ARCRC", self.home / ".arcrc")
|
|
85
|
+
).expanduser()
|
|
86
|
+
|
|
87
|
+
def _read_arcrc(self) -> dict[str, object]:
|
|
88
|
+
path = self._arcrc_path()
|
|
89
|
+
if not path.exists():
|
|
90
|
+
return {}
|
|
91
|
+
try:
|
|
92
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
93
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
94
|
+
raise ConfigurationError(f"Could not read .arcrc: {path}") from error
|
|
95
|
+
if not isinstance(payload, dict):
|
|
96
|
+
raise ConfigurationError(".arcrc must contain a JSON object")
|
|
97
|
+
return payload
|
|
98
|
+
|
|
99
|
+
def _resolve_host(
|
|
100
|
+
self,
|
|
101
|
+
cli_host: str | None,
|
|
102
|
+
config: Mapping[str, object],
|
|
103
|
+
arcrc: Mapping[str, object],
|
|
104
|
+
) -> str:
|
|
105
|
+
configured = cli_host or self.env.get("PHAB_FEEDBACK_HOST")
|
|
106
|
+
if configured is None:
|
|
107
|
+
value = config.get("host")
|
|
108
|
+
configured = value if isinstance(value, str) else None
|
|
109
|
+
if configured:
|
|
110
|
+
return normalize_host(configured)
|
|
111
|
+
|
|
112
|
+
hosts = arcrc.get("hosts")
|
|
113
|
+
available = list(hosts) if isinstance(hosts, dict) else []
|
|
114
|
+
if len(available) == 1:
|
|
115
|
+
return normalize_host(available[0])
|
|
116
|
+
if not available:
|
|
117
|
+
raise ConfigurationError(
|
|
118
|
+
"No Phabricator host configured; use --host, "
|
|
119
|
+
"PHAB_FEEDBACK_HOST, or the config file"
|
|
120
|
+
)
|
|
121
|
+
raise ConfigurationError(
|
|
122
|
+
"Multiple .arcrc hosts found; select one with --host or "
|
|
123
|
+
"PHAB_FEEDBACK_HOST"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def _resolve_token(
|
|
127
|
+
self, host: str, arcrc: Mapping[str, object]
|
|
128
|
+
) -> str:
|
|
129
|
+
token = self.env.get("PHAB_FEEDBACK_TOKEN")
|
|
130
|
+
if token:
|
|
131
|
+
return token
|
|
132
|
+
hosts = arcrc.get("hosts")
|
|
133
|
+
if isinstance(hosts, dict):
|
|
134
|
+
for candidate, raw_settings in hosts.items():
|
|
135
|
+
if normalize_host(candidate) != host or not isinstance(
|
|
136
|
+
raw_settings, dict
|
|
137
|
+
):
|
|
138
|
+
continue
|
|
139
|
+
value = raw_settings.get("token")
|
|
140
|
+
if isinstance(value, str) and value:
|
|
141
|
+
return value
|
|
142
|
+
raise ConfigurationError(
|
|
143
|
+
f"No Conduit token found for {host}; configure .arcrc or "
|
|
144
|
+
"PHAB_FEEDBACK_TOKEN"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
def _resolve_cookie(
|
|
148
|
+
self,
|
|
149
|
+
host: str,
|
|
150
|
+
config: Mapping[str, object],
|
|
151
|
+
*,
|
|
152
|
+
firefox_cookies: bool,
|
|
153
|
+
firefox_profile: Path | None,
|
|
154
|
+
) -> str:
|
|
155
|
+
cookie_name = config.get("cookie_name", "phsid")
|
|
156
|
+
if not isinstance(cookie_name, str) or not cookie_name:
|
|
157
|
+
raise ConfigurationError("cookie_name must be a non-empty string")
|
|
158
|
+
raw = self.env.get("PHAB_FEEDBACK_SESSION_COOKIE")
|
|
159
|
+
if raw:
|
|
160
|
+
stripped = raw.strip()
|
|
161
|
+
if stripped.startswith(f"{cookie_name}=") or ";" in stripped:
|
|
162
|
+
return stripped
|
|
163
|
+
return f"{cookie_name}={stripped}"
|
|
164
|
+
|
|
165
|
+
use_firefox = firefox_cookies or config.get("firefox_cookies") is True
|
|
166
|
+
configured_profile = config.get("firefox_profile")
|
|
167
|
+
profile = firefox_profile
|
|
168
|
+
if profile is None and isinstance(configured_profile, str):
|
|
169
|
+
profile = Path(configured_profile).expanduser()
|
|
170
|
+
if use_firefox:
|
|
171
|
+
return discover_firefox_cookie(
|
|
172
|
+
hostname=urlsplit(host).hostname or "",
|
|
173
|
+
cookie_name=cookie_name,
|
|
174
|
+
profile=profile,
|
|
175
|
+
home=self.home,
|
|
176
|
+
)
|
|
177
|
+
raise ConfigurationError(
|
|
178
|
+
"This command needs a web session; set "
|
|
179
|
+
"PHAB_FEEDBACK_SESSION_COOKIE or pass --firefox-cookies"
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def normalize_host(host: str) -> str:
|
|
184
|
+
value = host.strip().rstrip("/")
|
|
185
|
+
if value.endswith("/api"):
|
|
186
|
+
value = value[:-4]
|
|
187
|
+
parsed = urlsplit(value)
|
|
188
|
+
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
|
189
|
+
raise ConfigurationError(
|
|
190
|
+
"Phabricator host must be an absolute http(s) URL"
|
|
191
|
+
)
|
|
192
|
+
if parsed.username or parsed.password or parsed.query or parsed.fragment:
|
|
193
|
+
raise ConfigurationError(
|
|
194
|
+
"Phabricator host must not contain credentials, a query, or a fragment"
|
|
195
|
+
)
|
|
196
|
+
return f"{parsed.scheme}://{parsed.netloc}{parsed.path.rstrip('/')}"
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def discover_firefox_cookie(
|
|
200
|
+
*,
|
|
201
|
+
hostname: str,
|
|
202
|
+
cookie_name: str,
|
|
203
|
+
profile: Path | None,
|
|
204
|
+
home: Path,
|
|
205
|
+
) -> str:
|
|
206
|
+
selected = profile or find_firefox_profile(home)
|
|
207
|
+
if selected is None:
|
|
208
|
+
raise ConfigurationError("No Firefox profile found")
|
|
209
|
+
database = selected / "cookies.sqlite"
|
|
210
|
+
if not database.exists():
|
|
211
|
+
raise ConfigurationError(f"Firefox cookie database not found: {database}")
|
|
212
|
+
|
|
213
|
+
temporary = tempfile.NamedTemporaryFile(suffix=".sqlite", delete=False)
|
|
214
|
+
temporary.close()
|
|
215
|
+
copy = Path(temporary.name)
|
|
216
|
+
try:
|
|
217
|
+
shutil.copy2(database, copy)
|
|
218
|
+
with sqlite3.connect(copy) as connection:
|
|
219
|
+
rows = connection.execute(
|
|
220
|
+
"""
|
|
221
|
+
SELECT name, value FROM moz_cookies
|
|
222
|
+
WHERE name IN (?, 'phusr') AND (host = ? OR host = ?)
|
|
223
|
+
""",
|
|
224
|
+
(cookie_name, hostname, f".{hostname}"),
|
|
225
|
+
).fetchall()
|
|
226
|
+
except (OSError, sqlite3.Error) as error:
|
|
227
|
+
raise ConfigurationError("Could not read Firefox cookie database") from error
|
|
228
|
+
finally:
|
|
229
|
+
copy.unlink(missing_ok=True)
|
|
230
|
+
|
|
231
|
+
values = {str(name): str(value) for name, value in rows}
|
|
232
|
+
session = values.get(cookie_name)
|
|
233
|
+
if not session:
|
|
234
|
+
raise ConfigurationError(
|
|
235
|
+
f"No {cookie_name} Firefox cookie found for {hostname}"
|
|
236
|
+
)
|
|
237
|
+
pairs = [f"{cookie_name}={session}"]
|
|
238
|
+
if values.get("phusr"):
|
|
239
|
+
pairs.append(f"phusr={values['phusr']}")
|
|
240
|
+
return "; ".join(pairs)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def find_firefox_profile(home: Path) -> Path | None:
|
|
244
|
+
roots = [
|
|
245
|
+
home / "Library" / "Application Support" / "Firefox",
|
|
246
|
+
home / ".mozilla" / "firefox",
|
|
247
|
+
]
|
|
248
|
+
for root in roots:
|
|
249
|
+
profiles_ini = root / "profiles.ini"
|
|
250
|
+
if profiles_ini.exists():
|
|
251
|
+
parser = configparser.ConfigParser()
|
|
252
|
+
parser.read(profiles_ini, encoding="utf-8")
|
|
253
|
+
candidates: list[tuple[bool, Path]] = []
|
|
254
|
+
for section in parser.sections():
|
|
255
|
+
if not section.startswith("Profile"):
|
|
256
|
+
continue
|
|
257
|
+
raw_path = parser.get(section, "Path", fallback="")
|
|
258
|
+
if not raw_path:
|
|
259
|
+
continue
|
|
260
|
+
candidate = Path(raw_path)
|
|
261
|
+
if parser.getboolean(section, "IsRelative", fallback=True):
|
|
262
|
+
candidate = root / candidate
|
|
263
|
+
candidates.append(
|
|
264
|
+
(parser.getboolean(section, "Default", fallback=False), candidate)
|
|
265
|
+
)
|
|
266
|
+
for _, candidate in sorted(candidates, reverse=True):
|
|
267
|
+
if candidate.exists():
|
|
268
|
+
return candidate
|
|
269
|
+
profiles = root / "Profiles"
|
|
270
|
+
if profiles.exists():
|
|
271
|
+
matches = sorted(profiles.glob("*.default-release"))
|
|
272
|
+
matches.extend(sorted(profiles.glob("*.default")))
|
|
273
|
+
matches.extend(sorted(path for path in profiles.iterdir() if path.is_dir()))
|
|
274
|
+
if matches:
|
|
275
|
+
return matches[0]
|
|
276
|
+
return None
|
phab_feedback/errors.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Public error types."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class PhabFeedbackError(Exception):
|
|
5
|
+
"""Base error suitable for display to CLI users."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ConfigurationError(PhabFeedbackError):
|
|
9
|
+
"""Configuration or credential resolution failed."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class NetworkError(PhabFeedbackError):
|
|
13
|
+
"""A remote request failed."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class APIError(PhabFeedbackError):
|
|
17
|
+
"""A remote API rejected an operation."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ValidationError(PhabFeedbackError):
|
|
21
|
+
"""A user-supplied identifier or operation was invalid."""
|
phab_feedback/py.typed
ADDED
|
File without changes
|
phab_feedback/service.py
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
"""Feedback timeline and mutation workflows."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from typing import Any, Iterable
|
|
7
|
+
|
|
8
|
+
from .api import ConduitClient, WebClient
|
|
9
|
+
from .errors import APIError, ValidationError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def revision_number(revision: str) -> int:
|
|
13
|
+
value = revision.strip()
|
|
14
|
+
if value[:1].lower() == "d":
|
|
15
|
+
value = value[1:]
|
|
16
|
+
if not value.isdigit() or int(value) < 1:
|
|
17
|
+
raise ValidationError(f"Invalid revision identifier: {revision}")
|
|
18
|
+
return int(value)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def comment_id(value: str | int) -> int:
|
|
22
|
+
text = str(value)
|
|
23
|
+
if not text.isdigit() or int(text) < 1:
|
|
24
|
+
raise ValidationError(f"Invalid comment ID: {value}")
|
|
25
|
+
return int(text)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def active_comment(transaction: dict[str, Any]) -> dict[str, Any] | None:
|
|
29
|
+
versions = transaction.get("comments") or []
|
|
30
|
+
if not isinstance(versions, list) or not versions:
|
|
31
|
+
return None
|
|
32
|
+
latest = max(versions, key=lambda item: item.get("version", 0))
|
|
33
|
+
return None if latest.get("removed") else latest
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class FeedbackService:
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
*,
|
|
40
|
+
conduit: ConduitClient | None = None,
|
|
41
|
+
web: WebClient | None = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
self.conduit = conduit
|
|
44
|
+
self.web = web
|
|
45
|
+
|
|
46
|
+
def revision_transactions(self, revision: str) -> list[dict[str, Any]]:
|
|
47
|
+
conduit = self._conduit()
|
|
48
|
+
identifier = f"D{revision_number(revision)}"
|
|
49
|
+
transactions: list[dict[str, Any]] = []
|
|
50
|
+
after: str | None = None
|
|
51
|
+
while True:
|
|
52
|
+
params: dict[str, Any] = {
|
|
53
|
+
"objectIdentifier": identifier,
|
|
54
|
+
"limit": 100,
|
|
55
|
+
}
|
|
56
|
+
if after:
|
|
57
|
+
params["after"] = after
|
|
58
|
+
result = conduit.call("transaction.search", params)
|
|
59
|
+
page = result.get("data", [])
|
|
60
|
+
if not isinstance(page, list):
|
|
61
|
+
raise APIError("transaction.search returned invalid data")
|
|
62
|
+
transactions.extend(page)
|
|
63
|
+
cursor = result.get("cursor") or {}
|
|
64
|
+
after = cursor.get("after")
|
|
65
|
+
if not after:
|
|
66
|
+
return transactions
|
|
67
|
+
|
|
68
|
+
def timeline(self, revision: str) -> dict[str, Any]:
|
|
69
|
+
conduit = self._conduit()
|
|
70
|
+
revision_id = revision_number(revision)
|
|
71
|
+
revisions = conduit.call(
|
|
72
|
+
"differential.revision.search",
|
|
73
|
+
{"constraints": {"ids": [revision_id]}},
|
|
74
|
+
).get("data", [])
|
|
75
|
+
if not revisions:
|
|
76
|
+
raise ValidationError(f"D{revision_id} was not found")
|
|
77
|
+
current_diff_phid = revisions[0].get("fields", {}).get("diffPHID")
|
|
78
|
+
if not current_diff_phid:
|
|
79
|
+
raise APIError(f"D{revision_id} returned no current diff")
|
|
80
|
+
diffs = conduit.call(
|
|
81
|
+
"differential.diff.search",
|
|
82
|
+
{"constraints": {"phids": [current_diff_phid]}},
|
|
83
|
+
).get("data", [])
|
|
84
|
+
if not diffs:
|
|
85
|
+
raise APIError(f"Current diff for D{revision_id} was not found")
|
|
86
|
+
current_diff = diffs[0]
|
|
87
|
+
transactions = self.revision_transactions(revision)
|
|
88
|
+
by_phid = {
|
|
89
|
+
version["phid"]: version["id"]
|
|
90
|
+
for transaction in transactions
|
|
91
|
+
for version in transaction.get("comments", [])
|
|
92
|
+
if version.get("phid")
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
general: list[dict[str, Any]] = []
|
|
96
|
+
inline: list[dict[str, Any]] = []
|
|
97
|
+
for transaction in transactions:
|
|
98
|
+
kind = transaction.get("type")
|
|
99
|
+
if kind not in {"comment", "inline"}:
|
|
100
|
+
continue
|
|
101
|
+
comment = active_comment(transaction)
|
|
102
|
+
if comment is None:
|
|
103
|
+
continue
|
|
104
|
+
base = {
|
|
105
|
+
"kind": "general" if kind == "comment" else "inline",
|
|
106
|
+
"id": comment.get("id"),
|
|
107
|
+
"phid": comment.get("phid"),
|
|
108
|
+
"transaction_id": transaction.get("id"),
|
|
109
|
+
"transaction_phid": transaction.get("phid"),
|
|
110
|
+
"created": _timestamp(comment.get("dateCreated")),
|
|
111
|
+
"content": (comment.get("content") or {}).get("raw"),
|
|
112
|
+
}
|
|
113
|
+
if kind == "comment":
|
|
114
|
+
general.append(base)
|
|
115
|
+
continue
|
|
116
|
+
fields = transaction.get("fields") or {}
|
|
117
|
+
diff = fields.get("diff") or {}
|
|
118
|
+
parent_phid = fields.get("replyToCommentPHID")
|
|
119
|
+
inline.append(
|
|
120
|
+
{
|
|
121
|
+
**base,
|
|
122
|
+
"diff_id": diff.get("id"),
|
|
123
|
+
"diff_phid": diff.get("phid"),
|
|
124
|
+
"on_current_diff": diff.get("phid") == current_diff_phid,
|
|
125
|
+
"path": fields.get("path"),
|
|
126
|
+
"line": fields.get("line"),
|
|
127
|
+
"is_done": fields.get("isDone"),
|
|
128
|
+
"reply_to_comment_id": by_phid.get(parent_phid),
|
|
129
|
+
"reply_to_comment_phid": parent_phid,
|
|
130
|
+
}
|
|
131
|
+
)
|
|
132
|
+
general.sort(key=lambda item: item["created"] or "")
|
|
133
|
+
inline.sort(key=lambda item: item["created"] or "")
|
|
134
|
+
events = sorted(
|
|
135
|
+
[*general, *inline], key=lambda item: item["created"] or ""
|
|
136
|
+
)
|
|
137
|
+
return {
|
|
138
|
+
"revision_id": revision_id,
|
|
139
|
+
"current_diff": {
|
|
140
|
+
"id": current_diff.get("id"),
|
|
141
|
+
"phid": current_diff_phid,
|
|
142
|
+
"created": _timestamp(
|
|
143
|
+
(current_diff.get("fields") or {}).get("dateCreated")
|
|
144
|
+
),
|
|
145
|
+
},
|
|
146
|
+
"events": events,
|
|
147
|
+
"general_comments": general,
|
|
148
|
+
"inline_comments": inline,
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
def post_comment(self, revision: str, message: str) -> dict[str, Any]:
|
|
152
|
+
revision_id = revision_number(revision)
|
|
153
|
+
result = self._conduit().call(
|
|
154
|
+
"differential.revision.edit",
|
|
155
|
+
{
|
|
156
|
+
"objectIdentifier": f"D{revision_id}",
|
|
157
|
+
"transactions": [{"type": "comment", "value": message}],
|
|
158
|
+
},
|
|
159
|
+
)
|
|
160
|
+
return {"revision_id": revision_id, "posted": True, "result": result}
|
|
161
|
+
|
|
162
|
+
def reply_inline(
|
|
163
|
+
self, revision: str, parent_id: str | int
|
|
164
|
+
) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
165
|
+
return self._find_comment(revision, parent_id, "inline")
|
|
166
|
+
|
|
167
|
+
def draft_inline_reply(
|
|
168
|
+
self, revision: str, parent_id: str | int, message: str
|
|
169
|
+
) -> dict[str, Any]:
|
|
170
|
+
revision_id = revision_number(revision)
|
|
171
|
+
_, parent = self.reply_inline(revision, parent_id)
|
|
172
|
+
path = f"/differential/comment/inline/edit/{revision_id}/"
|
|
173
|
+
content = {
|
|
174
|
+
"hasContentState": "1",
|
|
175
|
+
"text": message,
|
|
176
|
+
"suggestionText": "",
|
|
177
|
+
"hasSuggestion": "0",
|
|
178
|
+
}
|
|
179
|
+
common = {"on_right": "1", "renderer": "2up", "__wflow__": "true", "__ajax__": "true"}
|
|
180
|
+
created = self._web().post(
|
|
181
|
+
path,
|
|
182
|
+
{
|
|
183
|
+
**common,
|
|
184
|
+
**content,
|
|
185
|
+
"op": "reply",
|
|
186
|
+
"replyToCommentPHID": parent["phid"],
|
|
187
|
+
},
|
|
188
|
+
)
|
|
189
|
+
reply_id = (
|
|
190
|
+
((created.get("payload") or {}).get("inline") or {}).get("id")
|
|
191
|
+
)
|
|
192
|
+
if not reply_id:
|
|
193
|
+
raise APIError("Inline reply creation returned no comment ID")
|
|
194
|
+
self._web().post(
|
|
195
|
+
path,
|
|
196
|
+
{**common, **content, "op": "save", "id": str(reply_id)},
|
|
197
|
+
)
|
|
198
|
+
return {
|
|
199
|
+
"revision_id": revision_id,
|
|
200
|
+
"parent_comment_id": comment_id(parent_id),
|
|
201
|
+
"parent_comment_phid": parent["phid"],
|
|
202
|
+
"draft_comment_id": int(reply_id),
|
|
203
|
+
"draft": True,
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
def remove_comment(
|
|
207
|
+
self, revision: str, target_id: str | int
|
|
208
|
+
) -> dict[str, Any]:
|
|
209
|
+
revision_id = revision_number(revision)
|
|
210
|
+
transaction, _ = self._find_comment(revision, target_id, "comment")
|
|
211
|
+
self._web().post(
|
|
212
|
+
f"/transactions/edit/{transaction['phid']}/",
|
|
213
|
+
{"text": "", "__form__": "1", "__ajax__": "true"},
|
|
214
|
+
)
|
|
215
|
+
updated = next(
|
|
216
|
+
(
|
|
217
|
+
item
|
|
218
|
+
for item in self.revision_transactions(revision)
|
|
219
|
+
if item.get("id") == transaction.get("id")
|
|
220
|
+
),
|
|
221
|
+
None,
|
|
222
|
+
)
|
|
223
|
+
versions = updated.get("comments", []) if updated else []
|
|
224
|
+
latest = max(
|
|
225
|
+
versions,
|
|
226
|
+
key=lambda item: item.get("version", 0),
|
|
227
|
+
default=None,
|
|
228
|
+
)
|
|
229
|
+
if latest is None or not latest.get("removed"):
|
|
230
|
+
raise APIError(
|
|
231
|
+
f"Server did not confirm removal of comment {target_id}"
|
|
232
|
+
)
|
|
233
|
+
return {
|
|
234
|
+
"revision_id": revision_id,
|
|
235
|
+
"comment_id": comment_id(target_id),
|
|
236
|
+
"removed": True,
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
def mark_done(
|
|
240
|
+
self, revision: str, comment_ids: Iterable[str | int]
|
|
241
|
+
) -> dict[str, Any]:
|
|
242
|
+
revision_id = revision_number(revision)
|
|
243
|
+
ids = self._validate_comments(revision, comment_ids, "inline")
|
|
244
|
+
path = f"/differential/comment/inline/edit/{revision_id}/"
|
|
245
|
+
results = []
|
|
246
|
+
for identifier in ids:
|
|
247
|
+
response = self._web().post(
|
|
248
|
+
path,
|
|
249
|
+
{
|
|
250
|
+
"op": "done",
|
|
251
|
+
"id": str(identifier),
|
|
252
|
+
"__wflow__": "true",
|
|
253
|
+
"__ajax__": "true",
|
|
254
|
+
},
|
|
255
|
+
)
|
|
256
|
+
payload = response.get("payload") or {}
|
|
257
|
+
results.append(
|
|
258
|
+
{
|
|
259
|
+
"comment_id": identifier,
|
|
260
|
+
"is_done": bool(payload.get("isChecked")),
|
|
261
|
+
"draft": bool(payload.get("draftState")),
|
|
262
|
+
}
|
|
263
|
+
)
|
|
264
|
+
return {"revision_id": revision_id, "comments": results}
|
|
265
|
+
|
|
266
|
+
def rate(
|
|
267
|
+
self,
|
|
268
|
+
revision: str,
|
|
269
|
+
comment_ids: Iterable[str | int],
|
|
270
|
+
*,
|
|
271
|
+
helpful: bool,
|
|
272
|
+
) -> dict[str, Any]:
|
|
273
|
+
revision_id = revision_number(revision)
|
|
274
|
+
ids = self._validate_comments(revision, comment_ids, "inline")
|
|
275
|
+
results = []
|
|
276
|
+
for identifier in ids:
|
|
277
|
+
response = self._web().post(
|
|
278
|
+
"/reviewhelper/feedback/",
|
|
279
|
+
{
|
|
280
|
+
"commentID": str(identifier),
|
|
281
|
+
"feedbackType": "up" if helpful else "down",
|
|
282
|
+
"__ajax__": "true",
|
|
283
|
+
},
|
|
284
|
+
)
|
|
285
|
+
payload = response.get("payload") or {}
|
|
286
|
+
results.append(
|
|
287
|
+
{
|
|
288
|
+
"comment_id": identifier,
|
|
289
|
+
"helpful": helpful,
|
|
290
|
+
"message": payload.get("message"),
|
|
291
|
+
}
|
|
292
|
+
)
|
|
293
|
+
return {
|
|
294
|
+
"revision_id": revision_id,
|
|
295
|
+
"mozilla_review_helper": True,
|
|
296
|
+
"comments": results,
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
def submit(self, revision: str) -> dict[str, Any]:
|
|
300
|
+
revision_id = revision_number(revision)
|
|
301
|
+
web = self._web()
|
|
302
|
+
response = web.post(
|
|
303
|
+
f"/differential/revision/edit/{revision_id}/comment/",
|
|
304
|
+
{
|
|
305
|
+
"__csrf__": web.csrf,
|
|
306
|
+
"__form__": "1",
|
|
307
|
+
"editengine.actions": "[]",
|
|
308
|
+
"comment": "",
|
|
309
|
+
"comment_metadata": "{}",
|
|
310
|
+
"__ajax__": "true",
|
|
311
|
+
},
|
|
312
|
+
)
|
|
313
|
+
return {
|
|
314
|
+
"revision_id": revision_id,
|
|
315
|
+
"submitted": True,
|
|
316
|
+
"redirect": (response.get("payload") or {}).get("redirect"),
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
def request_ai_review(self, revision: str) -> dict[str, Any]:
|
|
320
|
+
revision_id = revision_number(revision)
|
|
321
|
+
response = self._web().post(
|
|
322
|
+
f"/reviewhelper/request/{revision_id}/",
|
|
323
|
+
{"__wflow__": "true", "__ajax__": "true", "__metablock__": "6"},
|
|
324
|
+
)
|
|
325
|
+
dialog = str((response.get("payload") or {}).get("dialog", ""))
|
|
326
|
+
if "successfully" in dialog:
|
|
327
|
+
status = "requested"
|
|
328
|
+
elif "being processed" in dialog:
|
|
329
|
+
status = "already-in-progress"
|
|
330
|
+
else:
|
|
331
|
+
status = "response-received"
|
|
332
|
+
return {
|
|
333
|
+
"revision_id": revision_id,
|
|
334
|
+
"mozilla_review_helper": True,
|
|
335
|
+
"status": status,
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
def _validate_comments(
|
|
339
|
+
self,
|
|
340
|
+
revision: str,
|
|
341
|
+
values: Iterable[str | int],
|
|
342
|
+
expected_type: str,
|
|
343
|
+
) -> list[int]:
|
|
344
|
+
ids = [comment_id(value) for value in values]
|
|
345
|
+
if not ids:
|
|
346
|
+
raise ValidationError("At least one comment ID is required")
|
|
347
|
+
transactions = self.revision_transactions(revision)
|
|
348
|
+
by_id: dict[int, dict[str, Any]] = {}
|
|
349
|
+
for transaction in transactions:
|
|
350
|
+
for version in transaction.get("comments", []):
|
|
351
|
+
raw_id = version.get("id")
|
|
352
|
+
if raw_id is not None:
|
|
353
|
+
by_id[int(raw_id)] = transaction
|
|
354
|
+
for identifier in ids:
|
|
355
|
+
transaction = by_id.get(identifier)
|
|
356
|
+
if transaction is None:
|
|
357
|
+
raise ValidationError(
|
|
358
|
+
f"Comment {identifier} was not found on "
|
|
359
|
+
f"D{revision_number(revision)}"
|
|
360
|
+
)
|
|
361
|
+
actual = transaction.get("type") or "non-comment"
|
|
362
|
+
if actual != expected_type:
|
|
363
|
+
raise ValidationError(
|
|
364
|
+
f"Comment {identifier} is a {actual} transaction, "
|
|
365
|
+
f"not {expected_type}"
|
|
366
|
+
)
|
|
367
|
+
if active_comment(transaction) is None:
|
|
368
|
+
raise ValidationError(f"Comment {identifier} has been removed")
|
|
369
|
+
return ids
|
|
370
|
+
|
|
371
|
+
def _find_comment(
|
|
372
|
+
self, revision: str, target_id: str | int, expected_type: str
|
|
373
|
+
) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
374
|
+
identifier = comment_id(target_id)
|
|
375
|
+
for transaction in self.revision_transactions(revision):
|
|
376
|
+
versions = transaction.get("comments", [])
|
|
377
|
+
if not any(int(version.get("id", 0)) == identifier for version in versions):
|
|
378
|
+
continue
|
|
379
|
+
actual = transaction.get("type") or "non-comment"
|
|
380
|
+
if actual != expected_type:
|
|
381
|
+
raise ValidationError(
|
|
382
|
+
f"Comment {identifier} is a {actual} transaction, "
|
|
383
|
+
f"not {expected_type}"
|
|
384
|
+
)
|
|
385
|
+
comment = active_comment(transaction)
|
|
386
|
+
if comment is None:
|
|
387
|
+
raise ValidationError(f"Comment {identifier} has been removed")
|
|
388
|
+
return transaction, comment
|
|
389
|
+
raise ValidationError(
|
|
390
|
+
f"Comment {identifier} was not found on D{revision_number(revision)}"
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
def _conduit(self) -> ConduitClient:
|
|
394
|
+
if self.conduit is None:
|
|
395
|
+
raise RuntimeError("Conduit client not configured")
|
|
396
|
+
return self.conduit
|
|
397
|
+
|
|
398
|
+
def _web(self) -> WebClient:
|
|
399
|
+
if self.web is None:
|
|
400
|
+
raise RuntimeError("Web client not configured")
|
|
401
|
+
return self.web
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _timestamp(value: Any) -> str | None:
|
|
405
|
+
if value is None:
|
|
406
|
+
return None
|
|
407
|
+
return datetime.fromtimestamp(int(value), timezone.utc).isoformat()
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Minimal HTTP transport with a mockable boundary."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Mapping, Protocol
|
|
7
|
+
from urllib.error import HTTPError, URLError
|
|
8
|
+
from urllib.parse import urlsplit
|
|
9
|
+
from urllib.request import Request, urlopen
|
|
10
|
+
|
|
11
|
+
from .errors import NetworkError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class HttpResponse:
|
|
16
|
+
status: int
|
|
17
|
+
body: bytes
|
|
18
|
+
headers: Mapping[str, str]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Transport(Protocol):
|
|
22
|
+
def request(
|
|
23
|
+
self,
|
|
24
|
+
method: str,
|
|
25
|
+
url: str,
|
|
26
|
+
*,
|
|
27
|
+
headers: Mapping[str, str] | None = None,
|
|
28
|
+
data: bytes | None = None,
|
|
29
|
+
) -> HttpResponse: ...
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class UrllibTransport:
|
|
33
|
+
def request(
|
|
34
|
+
self,
|
|
35
|
+
method: str,
|
|
36
|
+
url: str,
|
|
37
|
+
*,
|
|
38
|
+
headers: Mapping[str, str] | None = None,
|
|
39
|
+
data: bytes | None = None,
|
|
40
|
+
) -> HttpResponse:
|
|
41
|
+
request = Request(url, data=data, method=method, headers=dict(headers or {}))
|
|
42
|
+
try:
|
|
43
|
+
with urlopen(request) as response:
|
|
44
|
+
return HttpResponse(
|
|
45
|
+
status=response.status,
|
|
46
|
+
body=response.read(),
|
|
47
|
+
headers=dict(response.headers.items()),
|
|
48
|
+
)
|
|
49
|
+
except HTTPError as error:
|
|
50
|
+
raise NetworkError(
|
|
51
|
+
f"HTTP {error.code} from {_safe_location(url)}"
|
|
52
|
+
) from error
|
|
53
|
+
except URLError as error:
|
|
54
|
+
reason = getattr(error, "reason", "connection failed")
|
|
55
|
+
raise NetworkError(
|
|
56
|
+
f"Request to {_safe_location(url)} failed: {reason}"
|
|
57
|
+
) from error
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _safe_location(url: str) -> str:
|
|
61
|
+
parsed = urlsplit(url)
|
|
62
|
+
return f"{parsed.scheme}://{parsed.netloc}{parsed.path}"
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: phab-feedback
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI for Phabricator and Phorge review feedback workflows
|
|
5
|
+
Author: Logan Rosen
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/loganrosen/phab-feedback
|
|
8
|
+
Project-URL: Repository, https://github.com/loganrosen/phab-feedback
|
|
9
|
+
Project-URL: Issues, https://github.com/loganrosen/phab-feedback/issues
|
|
10
|
+
Keywords: phabricator,phorge,code-review,cli
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: MacOS
|
|
15
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
23
|
+
Classifier: Topic :: Software Development :: Version Control
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Requires-Python: >=3.10
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
|
|
30
|
+
# phab-feedback
|
|
31
|
+
|
|
32
|
+
`phab-feedback` is a small command-line client for reviewing and acting on
|
|
33
|
+
Phabricator and Phorge feedback. It keeps inline replies as real inline-thread
|
|
34
|
+
replies, exposes draft actions explicitly, and produces structured JSON suitable
|
|
35
|
+
for both people and automation.
|
|
36
|
+
|
|
37
|
+
## How this differs
|
|
38
|
+
|
|
39
|
+
`arc` and `moz-phab` handle author-side Differential workflows such as creating
|
|
40
|
+
or updating revisions from local commits, with `arc` also providing landing
|
|
41
|
+
workflows. `phabfive` provides broader Conduit-based access to Phabricator and
|
|
42
|
+
Phorge applications such as Maniphest, Paste, Diffusion, Passphrase, and User.
|
|
43
|
+
|
|
44
|
+
`phab-feedback` complements those tools by focusing on structured Differential
|
|
45
|
+
feedback, inline threads, explicit draft actions, and browser-only mutations
|
|
46
|
+
that Conduit does not expose. Its optional Mozilla Review Helper rating and
|
|
47
|
+
AI-review commands remain isolated from the generic Phabricator and Phorge
|
|
48
|
+
behavior.
|
|
49
|
+
|
|
50
|
+
## Install
|
|
51
|
+
|
|
52
|
+
Python 3.10 or newer is required.
|
|
53
|
+
|
|
54
|
+
After the first PyPI release is published, run the CLI without installing it:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
uvx phab-feedback --help
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
For a persistent installation, `uv` is recommended:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
uv tool install phab-feedback
|
|
64
|
+
phab-feedback --help
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`pipx` is an alternative:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
pipx install phab-feedback
|
|
71
|
+
phab-feedback --help
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
To try unreleased development from GitHub:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
uv tool install git+https://github.com/loganrosen/phab-feedback.git
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
For local source development:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
git clone https://github.com/loganrosen/phab-feedback.git
|
|
84
|
+
cd phab-feedback
|
|
85
|
+
python3 -m pip install -e .
|
|
86
|
+
python3 -m unittest discover -s tests
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Configuration
|
|
90
|
+
|
|
91
|
+
Choose a host with `--host`, `PHAB_FEEDBACK_HOST`, or
|
|
92
|
+
`~/.config/phab-feedback/config.json`, in that order:
|
|
93
|
+
|
|
94
|
+
```json
|
|
95
|
+
{
|
|
96
|
+
"host": "https://phabricator.example.com",
|
|
97
|
+
"cookie_name": "phsid"
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
If no host is configured and `~/.arcrc` contains exactly one host,
|
|
102
|
+
`phab-feedback` uses it. Conduit tokens come from `PHAB_FEEDBACK_TOKEN` or the
|
|
103
|
+
matching `~/.arcrc` entry. Tokens are never accepted as command-line arguments.
|
|
104
|
+
|
|
105
|
+
Internal web actions need a logged-in browser session. Set
|
|
106
|
+
`PHAB_FEEDBACK_SESSION_COOKIE` to a complete `Cookie` header value, or to the
|
|
107
|
+
value of the configured session cookie. The value is never printed. Mozilla
|
|
108
|
+
Phabricator users can instead pass `--firefox-cookies` to discover the session
|
|
109
|
+
from a local Firefox profile; `--firefox-profile` selects a specific profile.
|
|
110
|
+
|
|
111
|
+
`XDG_CONFIG_HOME` and `PHAB_FEEDBACK_ARCRC` are respected. The config file is
|
|
112
|
+
for non-secret settings; keep tokens in `.arcrc` or the environment and session
|
|
113
|
+
cookies in the environment or browser store.
|
|
114
|
+
|
|
115
|
+
## Commands
|
|
116
|
+
|
|
117
|
+
All successful commands write JSON to stdout. Message-taking commands accept
|
|
118
|
+
exactly one of `--message`, `--message-file PATH`, or `--message-file -`.
|
|
119
|
+
When stdin is redirected, it is also accepted without an option.
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
# Read the complete chronological feedback timeline.
|
|
123
|
+
phab-feedback timeline D123
|
|
124
|
+
|
|
125
|
+
# Post an immediate top-level revision comment through Conduit.
|
|
126
|
+
phab-feedback comment D123 --message-file reply.txt
|
|
127
|
+
|
|
128
|
+
# Create a true inline-thread reply draft, then publish it separately.
|
|
129
|
+
printf '%s\n' 'Handled in the latest update.' |
|
|
130
|
+
phab-feedback reply-inline D123 456
|
|
131
|
+
phab-feedback submit D123
|
|
132
|
+
|
|
133
|
+
# Explicitly create and publish an inline reply in one invocation.
|
|
134
|
+
phab-feedback reply-inline D123 456 --message 'Done.' --submit
|
|
135
|
+
|
|
136
|
+
# Remove an accidental top-level comment after validating its type.
|
|
137
|
+
phab-feedback remove-comment D123 789
|
|
138
|
+
|
|
139
|
+
# Create Done drafts, then submit them.
|
|
140
|
+
phab-feedback mark-done D123 456 457
|
|
141
|
+
phab-feedback submit D123
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
`timeline`, `comment`, and the metadata validation used by mutations rely on
|
|
145
|
+
standard Conduit APIs. Inline reply drafting, top-level comment removal, Done
|
|
146
|
+
drafting, and draft submission use internal web endpoints available in upstream
|
|
147
|
+
Phabricator and Phorge. Those endpoints are less stable than Conduit and may
|
|
148
|
+
change between server releases.
|
|
149
|
+
|
|
150
|
+
These commands are **Mozilla-only** because they use the Review Helper
|
|
151
|
+
extension, not upstream Phabricator:
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
phab-feedback mark-helpful D123 456
|
|
155
|
+
phab-feedback mark-unhelpful D123 457
|
|
156
|
+
phab-feedback request-ai-review D123
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Helpful and unhelpful ratings take effect immediately. Inline replies and Done
|
|
160
|
+
states remain drafts until `submit`; `reply-inline --submit` is the only
|
|
161
|
+
intentional combined workflow. Rating, Done, and reply actions are never
|
|
162
|
+
combined implicitly.
|
|
163
|
+
|
|
164
|
+
The repository also includes an optional thin agent skill. It contains workflow
|
|
165
|
+
and approval guidance only; the CLI remains the single implementation of all
|
|
166
|
+
deterministic behavior. Install it through the open Skills CLI:
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
npx skills add loganrosen/phab-feedback@phab-feedback -g
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
The Skills CLI handles the supported agent-specific installation paths. The
|
|
173
|
+
Python package does not modify agent configuration or install the skill
|
|
174
|
+
automatically.
|
|
175
|
+
|
|
176
|
+
## Security
|
|
177
|
+
|
|
178
|
+
Credentials are sent only in request headers or bodies to the configured host.
|
|
179
|
+
Errors omit request bodies, tokens, and cookies. Avoid enabling shell tracing
|
|
180
|
+
while setting credential environment variables.
|
|
181
|
+
|
|
182
|
+
## License
|
|
183
|
+
|
|
184
|
+
MIT
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
phab_feedback/__init__.py,sha256=0TygQdigXXRf_sSl4G0fjbJeRxE8x8VjJyGHIxRnBWw,72
|
|
2
|
+
phab_feedback/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
|
|
3
|
+
phab_feedback/api.py,sha256=Cjid6QlBb19a3ZJMMJ5HlqFtTVM0OrKFEJu1pvn3GCs,3775
|
|
4
|
+
phab_feedback/cli.py,sha256=6PH6C9ZR5bChFzDy-MVHIe1Xj1XAMHQ1I-63YYBi790,7415
|
|
5
|
+
phab_feedback/config.py,sha256=QwwSx8-3EzvZJg7usG5PcxGYb1ndjKj-VVzm1N9EoNk,9826
|
|
6
|
+
phab_feedback/errors.py,sha256=eekOfwfXZaGe0XkcFlW-7M49b1qXYoe_EAxHbvOcK2k,490
|
|
7
|
+
phab_feedback/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
phab_feedback/service.py,sha256=TD-Nnm4QDinszaKD95FY8yrQncO87fEkbdl6zwc9SfI,14876
|
|
9
|
+
phab_feedback/transport.py,sha256=5BLeoFKkWVWIcdNBE1VQryA44oct8Zwj8yaVTvsPUoY,1722
|
|
10
|
+
phab_feedback-0.1.0.dist-info/licenses/LICENSE,sha256=jBVfH2xxurzCGKzRu4_XThCqw1gUwAV1EMfsLNoNAGg,1068
|
|
11
|
+
phab_feedback-0.1.0.dist-info/METADATA,sha256=LOFqyV_wVcGjNMW2QKqeosPMRwOQBMa3cZqnrx7MrGg,6302
|
|
12
|
+
phab_feedback-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
13
|
+
phab_feedback-0.1.0.dist-info/entry_points.txt,sha256=6ne9fezYvTVdwflxC6FbkqwdSx5mbPA17WkhkVE03_8,57
|
|
14
|
+
phab_feedback-0.1.0.dist-info/top_level.txt,sha256=D9rAP31Iv_e-2CsBFamVTzEWK4G_OY0y6LbxRqKpHuM,14
|
|
15
|
+
phab_feedback-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Logan Rosen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
phab_feedback
|