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.
@@ -0,0 +1,3 @@
1
+ """Phabricator and Phorge feedback workflows."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
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)
@@ -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
@@ -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
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ phab-feedback = phab_feedback.cli:main
@@ -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