-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcomment.py
More file actions
139 lines (112 loc) · 4.95 KB
/
Copy pathcomment.py
File metadata and controls
139 lines (112 loc) · 4.95 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
"""Post or update a single PR comment with the performance diff.
Uses the GitHub REST API over :mod:`urllib.request`. The network layer is
injectable via the :class:`Transport` protocol so the create-vs-update logic can
be unit-tested with a fake transport and no real HTTP.
The module always looks for a previous comment carrying our hidden
:data:`profile_diff.MARKER`; if found it *updates* that comment (PATCH),
otherwise it *creates* a new one (POST). This keeps exactly one profile-diff
comment on a PR no matter how many times CI runs.
"""
from __future__ import annotations
import json
import urllib.error
import urllib.request
from dataclasses import dataclass
from typing import Callable, Dict, List, Optional, Tuple
from . import MARKER
API_ROOT = "https://api.github.com"
#: A transport is ``fn(method, url, headers, body) -> (status_code, response_text)``.
Transport = Callable[[str, str, Dict[str, str], Optional[bytes]], Tuple[int, str]]
class CommentError(RuntimeError):
"""Raised when the GitHub API returns an unexpected status."""
def urllib_transport(
method: str, url: str, headers: Dict[str, str], body: Optional[bytes]
) -> Tuple[int, str]:
"""Default :data:`Transport` implementation backed by :mod:`urllib.request`."""
request = urllib.request.Request(url, data=body, headers=headers, method=method)
try:
with urllib.request.urlopen(request) as response: # nosec B310 - fixed https host
return response.status, response.read().decode("utf-8")
except urllib.error.HTTPError as exc: # pragma: no cover - network path
return exc.code, exc.read().decode("utf-8", "replace")
@dataclass
class GitHubClient:
"""Thin GitHub REST client for issue (PR) comments.
Parameters
----------
repo:
``owner/name`` slug.
token:
A GitHub token with ``pull-requests: write`` permission.
transport:
Injectable network callable (defaults to :func:`urllib_transport`).
"""
repo: str
token: str
transport: Transport = urllib_transport
def _headers(self) -> Dict[str, str]:
return {
"Authorization": f"Bearer {self.token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": "profile-diff-action",
"Content-Type": "application/json",
}
def _request(self, method: str, url: str, payload: Optional[Dict] = None) -> Dict:
body = json.dumps(payload).encode("utf-8") if payload is not None else None
status, text = self.transport(method, url, self._headers(), body)
if not 200 <= status < 300:
raise CommentError(f"{method} {url} -> HTTP {status}: {text}")
return json.loads(text) if text else {}
def list_comments(self, pr_number: int) -> List[Dict]:
"""Return all issue comments on *pr_number*, following pagination."""
comments: List[Dict] = []
page = 1
while True:
url = (
f"{API_ROOT}/repos/{self.repo}/issues/{pr_number}/comments"
f"?per_page=100&page={page}"
)
batch = self._request("GET", url)
if not isinstance(batch, list) or not batch:
break
comments.extend(batch)
if len(batch) < 100:
break
page += 1
return comments
def create_comment(self, pr_number: int, body: str) -> Dict:
url = f"{API_ROOT}/repos/{self.repo}/issues/{pr_number}/comments"
return self._request("POST", url, {"body": body})
def update_comment(self, comment_id: int, body: str) -> Dict:
url = f"{API_ROOT}/repos/{self.repo}/issues/comments/{comment_id}"
return self._request("PATCH", url, {"body": body})
def find_existing_comment(comments: List[Dict], marker: str = MARKER) -> Optional[int]:
"""Return the id of the first comment containing *marker*, else ``None``."""
for comment in comments:
if marker in (comment.get("body") or ""):
return comment.get("id")
return None
def post_or_update(
client: GitHubClient,
pr_number: int,
body: str,
*,
marker: str = MARKER,
) -> Dict:
"""Create a new comment, or update our previous one if it already exists.
The decision is made purely from the comment list returned by the (possibly
fake) transport, so this function is fully unit-testable offline.
Returns the created/updated comment payload; the result additionally carries
an ``"_action"`` key of ``"created"`` or ``"updated"`` for convenience.
"""
if marker not in body:
body = f"{marker}\n{body}"
existing_id = find_existing_comment(client.list_comments(pr_number), marker)
if existing_id is not None:
result = client.update_comment(existing_id, body)
result.setdefault("_action", "updated")
return result
result = client.create_comment(pr_number, body)
result.setdefault("_action", "created")
return result