curupira-plugins 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.
- curupira_plugins/__init__.py +6 -0
- curupira_plugins/__main__.py +54 -0
- curupira_plugins/github/__init__.py +1 -0
- curupira_plugins/github/client.py +298 -0
- curupira_plugins/github/filters.py +233 -0
- curupira_plugins/github/models.py +247 -0
- curupira_plugins/github/triggers.py +336 -0
- curupira_plugins-0.1.0.dist-info/METADATA +9 -0
- curupira_plugins-0.1.0.dist-info/RECORD +11 -0
- curupira_plugins-0.1.0.dist-info/WHEEL +4 -0
- curupira_plugins-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Official Curupira trigger extension process (all providers).
|
|
2
|
+
|
|
3
|
+
Console script ``curupira-plugins`` (also ``python -m curupira_plugins``) speaks
|
|
4
|
+
the extension protocol on stdin/stdout. End-user settings typically launch with::
|
|
5
|
+
|
|
6
|
+
uvx --from curupira-plugins curupira-plugins
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
from curupira_sdk import Curupira
|
|
14
|
+
|
|
15
|
+
from curupira_plugins import __version__
|
|
16
|
+
from curupira_plugins.github.triggers import register as register_github
|
|
17
|
+
|
|
18
|
+
app = Curupira(name="curupira-plugins", version=__version__)
|
|
19
|
+
register_github(app)
|
|
20
|
+
|
|
21
|
+
MANIFEST = """
|
|
22
|
+
manifest_version = 1
|
|
23
|
+
name = "curupira-plugins"
|
|
24
|
+
version = "{version}"
|
|
25
|
+
protocol_version = 2
|
|
26
|
+
runtime = "python"
|
|
27
|
+
kind = "trigger"
|
|
28
|
+
trigger_types = [
|
|
29
|
+
"github-issues",
|
|
30
|
+
"github-pull-requests",
|
|
31
|
+
"github-project-items",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[launch]
|
|
35
|
+
type = "uvx"
|
|
36
|
+
package = "curupira-plugins"
|
|
37
|
+
executable = "curupira-plugins"
|
|
38
|
+
|
|
39
|
+
[package]
|
|
40
|
+
source = "pip"
|
|
41
|
+
name = "curupira-plugins"
|
|
42
|
+
version = "{version}"
|
|
43
|
+
""".strip()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def main() -> None:
|
|
47
|
+
if "--curupira-print-manifest" in sys.argv:
|
|
48
|
+
app.print_manifest(MANIFEST)
|
|
49
|
+
return
|
|
50
|
+
app.run()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
if __name__ == "__main__":
|
|
54
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""GitHub issues and pull-request discovery."""
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""GitHub access through the authenticated gh CLI: bearer token, GraphQL search, and paging."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import subprocess
|
|
7
|
+
import time
|
|
8
|
+
import urllib.error
|
|
9
|
+
import urllib.request
|
|
10
|
+
from collections import deque
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from typing import Any, Generic, TypeVar
|
|
13
|
+
|
|
14
|
+
from curupira_sdk import RpcError
|
|
15
|
+
from pydantic import ValidationError
|
|
16
|
+
|
|
17
|
+
from curupira_plugins.github.models import (
|
|
18
|
+
Node,
|
|
19
|
+
ProjectItems,
|
|
20
|
+
ProjectResponse,
|
|
21
|
+
ProjectV2,
|
|
22
|
+
SearchConnection,
|
|
23
|
+
SearchResponse,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
GRAPHQL_URL = "https://api.github.com/graphql"
|
|
27
|
+
TOKEN_CACHE_SECONDS = 300.0
|
|
28
|
+
PAGE_SIZE = 100
|
|
29
|
+
_RETRY_STATUSES = frozenset({429, 500, 502, 503, 504})
|
|
30
|
+
_ATTEMPTS = 3
|
|
31
|
+
_GH_TIMEOUT_SECONDS = 30
|
|
32
|
+
_HTTP_TIMEOUT_SECONDS = 30
|
|
33
|
+
|
|
34
|
+
COMMON_FIELDS = """
|
|
35
|
+
id number title body url state createdAt
|
|
36
|
+
author { login }
|
|
37
|
+
labels(first: 50) { nodes { name } }
|
|
38
|
+
assignees(first: 20) { nodes { login } }
|
|
39
|
+
milestone { title }
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
ISSUE_FIELDS = (
|
|
43
|
+
COMMON_FIELDS
|
|
44
|
+
+ """
|
|
45
|
+
stateReason
|
|
46
|
+
"""
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
PULL_REQUEST_FIELDS = (
|
|
50
|
+
COMMON_FIELDS
|
|
51
|
+
+ """
|
|
52
|
+
headRefName baseRefName headRefOid isDraft mergeable mergeStateStatus
|
|
53
|
+
changedFiles additions deletions
|
|
54
|
+
closingIssuesReferences(first: 10) { nodes { number url } }
|
|
55
|
+
"""
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
CONTENT_FRAGMENTS = f"""
|
|
59
|
+
... on Issue {{ {ISSUE_FIELDS} repository {{ nameWithOwner }} }}
|
|
60
|
+
... on PullRequest {{ {PULL_REQUEST_FIELDS} repository {{ nameWithOwner }} }}
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
SEARCH_QUERY = f"""
|
|
64
|
+
query CuripiraSearch($query: String!, $first: Int!, $after: String) {{
|
|
65
|
+
search(query: $query, type: ISSUE, first: $first, after: $after) {{
|
|
66
|
+
pageInfo {{ hasNextPage endCursor }}
|
|
67
|
+
nodes {{
|
|
68
|
+
... on Issue {{ {ISSUE_FIELDS} }}
|
|
69
|
+
... on PullRequest {{ {PULL_REQUEST_FIELDS} }}
|
|
70
|
+
}}
|
|
71
|
+
}}
|
|
72
|
+
}}
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
# `repositoryOwner` resolves users and organizations alike. `query` is the Projects view filter
|
|
76
|
+
# syntax (for example `status:"In Progress",Review`) and is applied by GitHub.
|
|
77
|
+
PROJECT_ITEMS_QUERY = f"""
|
|
78
|
+
query CuripiraProjectItems($owner: String!, $number: Int!, $first: Int!, $after: String,
|
|
79
|
+
$query: String) {{
|
|
80
|
+
repositoryOwner(login: $owner) {{
|
|
81
|
+
... on ProjectV2Owner {{
|
|
82
|
+
projectV2(number: $number) {{
|
|
83
|
+
title url
|
|
84
|
+
items(first: $first, after: $after, query: $query) {{
|
|
85
|
+
pageInfo {{ hasNextPage endCursor }}
|
|
86
|
+
nodes {{
|
|
87
|
+
id isArchived
|
|
88
|
+
fieldValues(first: 50) {{
|
|
89
|
+
nodes {{
|
|
90
|
+
... on ProjectV2ItemFieldSingleSelectValue {{ name field {{ ... on ProjectV2FieldCommon {{ name }} }} }}
|
|
91
|
+
... on ProjectV2ItemFieldTextValue {{ text field {{ ... on ProjectV2FieldCommon {{ name }} }} }}
|
|
92
|
+
... on ProjectV2ItemFieldNumberValue {{ number field {{ ... on ProjectV2FieldCommon {{ name }} }} }}
|
|
93
|
+
... on ProjectV2ItemFieldDateValue {{ date field {{ ... on ProjectV2FieldCommon {{ name }} }} }}
|
|
94
|
+
... on ProjectV2ItemFieldIterationValue {{ title field {{ ... on ProjectV2FieldCommon {{ name }} }} }}
|
|
95
|
+
}}
|
|
96
|
+
}}
|
|
97
|
+
content {{ __typename {CONTENT_FRAGMENTS} }}
|
|
98
|
+
}}
|
|
99
|
+
}}
|
|
100
|
+
}}
|
|
101
|
+
}}
|
|
102
|
+
}}
|
|
103
|
+
}}
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
NodeT = TypeVar("NodeT", bound=Node)
|
|
107
|
+
ItemT = TypeVar("ItemT")
|
|
108
|
+
ResponseT = TypeVar("ResponseT", SearchResponse, ProjectResponse)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class GitHubCliToken:
|
|
112
|
+
"""Read the bearer token from `gh auth token`, caching it for five minutes."""
|
|
113
|
+
|
|
114
|
+
def __init__(
|
|
115
|
+
self,
|
|
116
|
+
*,
|
|
117
|
+
clock: Callable[[], float] = time.monotonic,
|
|
118
|
+
run: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
|
|
119
|
+
) -> None:
|
|
120
|
+
self._clock = clock
|
|
121
|
+
self._run = run
|
|
122
|
+
self._token: str | None = None
|
|
123
|
+
self._expires_at = 0.0
|
|
124
|
+
|
|
125
|
+
def token(self) -> str:
|
|
126
|
+
now = self._clock()
|
|
127
|
+
if self._token is not None and now < self._expires_at:
|
|
128
|
+
return self._token
|
|
129
|
+
try:
|
|
130
|
+
completed = self._run(
|
|
131
|
+
["gh", "auth", "token"],
|
|
132
|
+
capture_output=True,
|
|
133
|
+
text=True,
|
|
134
|
+
timeout=_GH_TIMEOUT_SECONDS,
|
|
135
|
+
check=False,
|
|
136
|
+
)
|
|
137
|
+
except FileNotFoundError as exc:
|
|
138
|
+
raise RpcError(-32002, "Required executable not found: gh") from exc
|
|
139
|
+
except subprocess.TimeoutExpired as exc:
|
|
140
|
+
raise RpcError(-32002, "gh auth token timed out") from exc
|
|
141
|
+
if completed.returncode != 0:
|
|
142
|
+
detail = completed.stderr.strip() or f"exit status {completed.returncode}"
|
|
143
|
+
raise RpcError(-32002, f"gh auth token failed: {detail}")
|
|
144
|
+
token = completed.stdout.strip()
|
|
145
|
+
if not token:
|
|
146
|
+
raise RpcError(-32002, "gh auth token returned an empty token")
|
|
147
|
+
self._token = token
|
|
148
|
+
self._expires_at = now + TOKEN_CACHE_SECONDS
|
|
149
|
+
return token
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class GitHubClient:
|
|
153
|
+
"""Run GraphQL search requests with the gh CLI token and bounded retries."""
|
|
154
|
+
|
|
155
|
+
def __init__(
|
|
156
|
+
self,
|
|
157
|
+
tokens: GitHubCliToken,
|
|
158
|
+
*,
|
|
159
|
+
endpoint: str = GRAPHQL_URL,
|
|
160
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
161
|
+
) -> None:
|
|
162
|
+
self._tokens = tokens
|
|
163
|
+
self._endpoint = endpoint
|
|
164
|
+
self._sleep = sleep
|
|
165
|
+
|
|
166
|
+
def search(self, query: str, *, after: str | None) -> SearchConnection:
|
|
167
|
+
"""Return one page of issue/pull-request search results for `query`."""
|
|
168
|
+
payload = {
|
|
169
|
+
"query": SEARCH_QUERY,
|
|
170
|
+
"variables": {"query": query, "first": PAGE_SIZE, "after": after},
|
|
171
|
+
}
|
|
172
|
+
response = self._parse(self._post(payload), SearchResponse)
|
|
173
|
+
if response.data is None:
|
|
174
|
+
raise RpcError(-32002, "GitHub GraphQL response has no data")
|
|
175
|
+
return response.data.search
|
|
176
|
+
|
|
177
|
+
def project_items(
|
|
178
|
+
self, owner: str, number: int, *, view_filter: str | None, after: str | None
|
|
179
|
+
) -> tuple[ProjectV2, ProjectItems]:
|
|
180
|
+
"""Return the project and one page of its items, filtered server-side by `view_filter`."""
|
|
181
|
+
payload = {
|
|
182
|
+
"query": PROJECT_ITEMS_QUERY,
|
|
183
|
+
"variables": {
|
|
184
|
+
"owner": owner,
|
|
185
|
+
"number": number,
|
|
186
|
+
"first": PAGE_SIZE,
|
|
187
|
+
"after": after,
|
|
188
|
+
"query": view_filter,
|
|
189
|
+
},
|
|
190
|
+
}
|
|
191
|
+
response = self._parse(self._post(payload), ProjectResponse)
|
|
192
|
+
owner_node = response.data.repository_owner if response.data else None
|
|
193
|
+
project = owner_node.project_v2 if owner_node else None
|
|
194
|
+
if project is None:
|
|
195
|
+
raise RpcError(
|
|
196
|
+
-32002,
|
|
197
|
+
f"GitHub project {number} not found for {owner!r}; check the owner, the project "
|
|
198
|
+
"number, and that your token has the read:project scope "
|
|
199
|
+
"(gh auth refresh -s read:project)",
|
|
200
|
+
)
|
|
201
|
+
return project, project.items
|
|
202
|
+
|
|
203
|
+
@staticmethod
|
|
204
|
+
def _parse(body: bytes, model: type[ResponseT]) -> ResponseT:
|
|
205
|
+
try:
|
|
206
|
+
response = model.model_validate_json(body)
|
|
207
|
+
except ValidationError as exc:
|
|
208
|
+
raise RpcError(-32002, f"unexpected GitHub GraphQL response: {exc}") from exc
|
|
209
|
+
errors = getattr(response, "errors", [])
|
|
210
|
+
data = getattr(response, "data", None)
|
|
211
|
+
# A missing project is reported as a NOT_FOUND error next to partial data; let the
|
|
212
|
+
# caller explain that one, and surface everything else.
|
|
213
|
+
if errors and not (
|
|
214
|
+
data is not None and all("Could not resolve" in e.message for e in errors)
|
|
215
|
+
):
|
|
216
|
+
messages = "; ".join(error.message for error in errors)
|
|
217
|
+
raise RpcError(-32002, f"GitHub GraphQL error: {messages}")
|
|
218
|
+
return response
|
|
219
|
+
|
|
220
|
+
def _post(self, payload: dict[str, Any]) -> bytes:
|
|
221
|
+
data = json.dumps(payload).encode("utf-8")
|
|
222
|
+
for attempt in range(1, _ATTEMPTS + 1):
|
|
223
|
+
request = urllib.request.Request(
|
|
224
|
+
self._endpoint,
|
|
225
|
+
data=data,
|
|
226
|
+
method="POST",
|
|
227
|
+
headers={
|
|
228
|
+
"Authorization": f"Bearer {self._tokens.token()}",
|
|
229
|
+
"Content-Type": "application/json",
|
|
230
|
+
"Accept": "application/json",
|
|
231
|
+
"User-Agent": "curupira-plugins",
|
|
232
|
+
},
|
|
233
|
+
)
|
|
234
|
+
try:
|
|
235
|
+
with urllib.request.urlopen(request, timeout=_HTTP_TIMEOUT_SECONDS) as response:
|
|
236
|
+
return bytes(response.read())
|
|
237
|
+
except urllib.error.HTTPError as exc:
|
|
238
|
+
# Close the response so the connection is released before retrying or raising.
|
|
239
|
+
status = exc.code
|
|
240
|
+
exc.close()
|
|
241
|
+
if status not in _RETRY_STATUSES or attempt == _ATTEMPTS:
|
|
242
|
+
raise RpcError(
|
|
243
|
+
-32002, f"GitHub GraphQL request failed with status {status}"
|
|
244
|
+
) from exc
|
|
245
|
+
except urllib.error.URLError as exc:
|
|
246
|
+
if attempt == _ATTEMPTS:
|
|
247
|
+
raise RpcError(-32002, f"GitHub GraphQL request failed: {exc.reason}") from exc
|
|
248
|
+
self._sleep(2.0 ** (attempt - 1))
|
|
249
|
+
raise RpcError(-32002, "GitHub GraphQL request failed")
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def parse_nodes(connection: SearchConnection, model: type[NodeT]) -> list[NodeT]:
|
|
253
|
+
"""Validate raw search nodes into `model` (IssueNode or PullRequestNode)."""
|
|
254
|
+
try:
|
|
255
|
+
return [model.model_validate(node) for node in connection.nodes]
|
|
256
|
+
except ValidationError as exc:
|
|
257
|
+
raise RpcError(-32002, f"unexpected GitHub search node: {exc}") from exc
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class Discovery(Generic[NodeT, ItemT]):
|
|
261
|
+
"""Yield each matching item once per process, fetching result pages lazily.
|
|
262
|
+
|
|
263
|
+
The host starts a new extension process for every poll cycle, so the seen set only has
|
|
264
|
+
to hold for one cycle. Items already completed are filtered by the host.
|
|
265
|
+
"""
|
|
266
|
+
|
|
267
|
+
def __init__(
|
|
268
|
+
self,
|
|
269
|
+
*,
|
|
270
|
+
fetch_page: Callable[[str | None], SearchConnection],
|
|
271
|
+
parse_page: Callable[[SearchConnection], list[NodeT]],
|
|
272
|
+
keep: Callable[[NodeT], bool],
|
|
273
|
+
to_item: Callable[[NodeT], ItemT],
|
|
274
|
+
) -> None:
|
|
275
|
+
self._fetch_page = fetch_page
|
|
276
|
+
self._parse_page = parse_page
|
|
277
|
+
self._keep = keep
|
|
278
|
+
self._to_item = to_item
|
|
279
|
+
self._pending: deque[NodeT] = deque()
|
|
280
|
+
self._cursor: str | None = None
|
|
281
|
+
self._has_more = True
|
|
282
|
+
self._seen: set[str] = set()
|
|
283
|
+
|
|
284
|
+
def next_item(self) -> ItemT | None:
|
|
285
|
+
while True:
|
|
286
|
+
while self._pending:
|
|
287
|
+
node = self._pending.popleft()
|
|
288
|
+
if node.id in self._seen:
|
|
289
|
+
continue
|
|
290
|
+
self._seen.add(node.id)
|
|
291
|
+
if self._keep(node):
|
|
292
|
+
return self._to_item(node)
|
|
293
|
+
if not self._has_more:
|
|
294
|
+
return None
|
|
295
|
+
connection = self._fetch_page(self._cursor)
|
|
296
|
+
self._pending.extend(self._parse_page(connection))
|
|
297
|
+
self._has_more = connection.page_info.has_next_page
|
|
298
|
+
self._cursor = connection.page_info.end_cursor
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Typed automation filters and their compilation into GitHub Search query strings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
8
|
+
|
|
9
|
+
REPO_PATTERN = r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$"
|
|
10
|
+
|
|
11
|
+
SearchState = Literal["open", "closed", "all"]
|
|
12
|
+
SortOrder = Literal[
|
|
13
|
+
"created-asc",
|
|
14
|
+
"created-desc",
|
|
15
|
+
"updated-asc",
|
|
16
|
+
"updated-desc",
|
|
17
|
+
"comments-asc",
|
|
18
|
+
"comments-desc",
|
|
19
|
+
]
|
|
20
|
+
MergeState = Literal[
|
|
21
|
+
"BEHIND", "BLOCKED", "CLEAN", "DIRTY", "DRAFT", "HAS_HOOKS", "UNKNOWN", "UNSTABLE"
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SearchFilters(BaseModel):
|
|
26
|
+
"""Filters shared by issue and pull-request automations."""
|
|
27
|
+
|
|
28
|
+
model_config = ConfigDict(extra="forbid")
|
|
29
|
+
|
|
30
|
+
repo: str = Field(pattern=REPO_PATTERN)
|
|
31
|
+
state: SearchState = "open"
|
|
32
|
+
labels: list[str] | None = None
|
|
33
|
+
exclude_labels: list[str] | None = None
|
|
34
|
+
assignee: str | None = None
|
|
35
|
+
author: str | None = None
|
|
36
|
+
milestone: str | None = None
|
|
37
|
+
project: str | None = None
|
|
38
|
+
sort: SortOrder | None = None
|
|
39
|
+
|
|
40
|
+
@field_validator("labels", "exclude_labels", mode="before")
|
|
41
|
+
@classmethod
|
|
42
|
+
def _as_list(cls, value: object) -> object:
|
|
43
|
+
return [value] if isinstance(value, str) else value
|
|
44
|
+
|
|
45
|
+
@field_validator("repo")
|
|
46
|
+
@classmethod
|
|
47
|
+
def _reject_traversal(cls, value: str) -> str:
|
|
48
|
+
if any(part in {".", ".."} for part in value.split("/")):
|
|
49
|
+
raise ValueError("repo must not contain traversal segments")
|
|
50
|
+
return value
|
|
51
|
+
|
|
52
|
+
def shared_qualifiers(self) -> list[str]:
|
|
53
|
+
"""Return the Search qualifiers that both issues and pull requests accept."""
|
|
54
|
+
qualifiers = [f"repo:{self.repo}", _STATE_QUALIFIERS[self.state]]
|
|
55
|
+
qualifiers += [_qualifier("label", label) for label in self.labels or []]
|
|
56
|
+
qualifiers += [f"-{_qualifier('label', label)}" for label in self.exclude_labels or []]
|
|
57
|
+
qualifiers += _assignee_qualifiers(self.assignee)
|
|
58
|
+
if self.author:
|
|
59
|
+
qualifiers.append(_qualifier("author", self.author))
|
|
60
|
+
if self.milestone:
|
|
61
|
+
qualifiers.append(_qualifier("milestone", self.milestone))
|
|
62
|
+
if self.project:
|
|
63
|
+
qualifiers.append(_qualifier("project", self.project))
|
|
64
|
+
return qualifiers
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class IssueFilters(SearchFilters):
|
|
68
|
+
"""Filters for `github-issues` automations."""
|
|
69
|
+
|
|
70
|
+
linked_pull_request: bool | None = None
|
|
71
|
+
|
|
72
|
+
def search_query(self) -> str:
|
|
73
|
+
qualifiers = ["is:issue", *self.shared_qualifiers()]
|
|
74
|
+
if self.linked_pull_request is not None:
|
|
75
|
+
qualifiers.append("linked:pr" if self.linked_pull_request else "-linked:pr")
|
|
76
|
+
return _join(qualifiers, self.sort)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class PullRequestFilters(SearchFilters):
|
|
80
|
+
"""Filters for `github-pull-requests` automations."""
|
|
81
|
+
|
|
82
|
+
draft: bool | None = None
|
|
83
|
+
base: str | None = None
|
|
84
|
+
head: str | None = None
|
|
85
|
+
review: Literal["none", "required", "approved", "changes_requested"] | None = None
|
|
86
|
+
ci_status: Literal["success", "failure", "pending"] | None = None
|
|
87
|
+
linked_issue: bool | None = None
|
|
88
|
+
mergeable: bool | None = None
|
|
89
|
+
merge_state: list[MergeState] | None = None
|
|
90
|
+
|
|
91
|
+
@field_validator("merge_state", mode="before")
|
|
92
|
+
@classmethod
|
|
93
|
+
def _merge_state_as_list(cls, value: object) -> object:
|
|
94
|
+
return [value] if isinstance(value, str) else value
|
|
95
|
+
|
|
96
|
+
def search_query(self) -> str:
|
|
97
|
+
qualifiers = ["is:pr", *self.shared_qualifiers()]
|
|
98
|
+
if self.draft is not None:
|
|
99
|
+
qualifiers.append(f"draft:{str(self.draft).lower()}")
|
|
100
|
+
if self.base:
|
|
101
|
+
qualifiers.append(_qualifier("base", self.base))
|
|
102
|
+
if self.head:
|
|
103
|
+
qualifiers.append(_qualifier("head", self.head))
|
|
104
|
+
if self.review:
|
|
105
|
+
qualifiers.append(f"review:{self.review}")
|
|
106
|
+
if self.ci_status:
|
|
107
|
+
qualifiers.append(f"status:{self.ci_status}")
|
|
108
|
+
if self.linked_issue is not None:
|
|
109
|
+
qualifiers.append("linked:issue" if self.linked_issue else "-linked:issue")
|
|
110
|
+
return _join(qualifiers, self.sort)
|
|
111
|
+
|
|
112
|
+
def keeps(self, *, mergeable_state: str, merge_state_status: str) -> bool:
|
|
113
|
+
"""Apply the GraphQL post-filters that Search cannot express."""
|
|
114
|
+
if self.mergeable is not None:
|
|
115
|
+
expected = "MERGEABLE" if self.mergeable else "CONFLICTING"
|
|
116
|
+
if mergeable_state != expected:
|
|
117
|
+
return False
|
|
118
|
+
return not self.merge_state or merge_state_status in self.merge_state
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
_STATE_QUALIFIERS: dict[str, str] = {"open": "is:open", "closed": "is:closed", "all": ""}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _join(qualifiers: list[str], sort: str | None) -> str:
|
|
125
|
+
"""Join qualifiers in a stable order, appending the sort qualifier last."""
|
|
126
|
+
parts = [part for part in qualifiers if part]
|
|
127
|
+
parts.append(f"sort:{sort or 'created-asc'}")
|
|
128
|
+
return " ".join(parts)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _assignee_qualifiers(assignee: str | None) -> list[str]:
|
|
132
|
+
if assignee is None:
|
|
133
|
+
return []
|
|
134
|
+
if assignee == "none":
|
|
135
|
+
return ["no:assignee"]
|
|
136
|
+
if assignee == "any":
|
|
137
|
+
return ["assignee:*"]
|
|
138
|
+
return [_qualifier("assignee", assignee)]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _qualifier(name: str, value: str) -> str:
|
|
142
|
+
"""Render `name:value`, quoting values that contain whitespace or `"`, `:`, `,`."""
|
|
143
|
+
if any(char in value for char in ' \t\n":,'):
|
|
144
|
+
escaped = value.replace('"', '\\"')
|
|
145
|
+
return f'{name}:"{escaped}"'
|
|
146
|
+
return f"{name}:{value}"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
ProjectContent = Literal["all", "issue", "pull_request"]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class ProjectFilters(BaseModel):
|
|
153
|
+
"""Filters for `github-project-items`: one board, one or more Status columns."""
|
|
154
|
+
|
|
155
|
+
model_config = ConfigDict(extra="forbid")
|
|
156
|
+
|
|
157
|
+
owner: str = Field(min_length=1, pattern=r"^[A-Za-z0-9_.-]+$")
|
|
158
|
+
project: int = Field(gt=0)
|
|
159
|
+
column: list[str] = Field(min_length=1)
|
|
160
|
+
status_field: str = "Status"
|
|
161
|
+
content: ProjectContent = "all"
|
|
162
|
+
repo: str | None = Field(default=None, pattern=REPO_PATTERN)
|
|
163
|
+
state: SearchState = "open"
|
|
164
|
+
labels: list[str] | None = None
|
|
165
|
+
exclude_labels: list[str] | None = None
|
|
166
|
+
assignee: str | None = None
|
|
167
|
+
author: str | None = None
|
|
168
|
+
|
|
169
|
+
@field_validator("column", "labels", "exclude_labels", mode="before")
|
|
170
|
+
@classmethod
|
|
171
|
+
def _as_list(cls, value: object) -> object:
|
|
172
|
+
return [value] if isinstance(value, str) else value
|
|
173
|
+
|
|
174
|
+
@field_validator("column")
|
|
175
|
+
@classmethod
|
|
176
|
+
def _column_not_blank(cls, value: list[str]) -> list[str]:
|
|
177
|
+
if any(not name.strip() for name in value):
|
|
178
|
+
raise ValueError("column names must not be blank")
|
|
179
|
+
return value
|
|
180
|
+
|
|
181
|
+
def view_filter(self) -> str | None:
|
|
182
|
+
"""Projects view-filter text that makes GitHub return only the chosen columns.
|
|
183
|
+
|
|
184
|
+
Field names with whitespace are not safe in that syntax, so those fall back to the
|
|
185
|
+
client-side check in `keeps_column`.
|
|
186
|
+
"""
|
|
187
|
+
if any(char.isspace() for char in self.status_field):
|
|
188
|
+
return None
|
|
189
|
+
values = ",".join(_view_value(name) for name in self.column)
|
|
190
|
+
return f"{self.status_field.lower()}:{values}"
|
|
191
|
+
|
|
192
|
+
def keeps_column(self, fields: dict[str, str]) -> bool:
|
|
193
|
+
wanted = {name.strip().casefold() for name in self.column}
|
|
194
|
+
by_field = {name.casefold(): value for name, value in fields.items()}
|
|
195
|
+
return by_field.get(self.status_field.casefold(), "").strip().casefold() in wanted
|
|
196
|
+
|
|
197
|
+
def keeps_content(
|
|
198
|
+
self,
|
|
199
|
+
*,
|
|
200
|
+
kind: str,
|
|
201
|
+
repo: str,
|
|
202
|
+
state: str,
|
|
203
|
+
labels: list[str],
|
|
204
|
+
assignees: list[str],
|
|
205
|
+
author: str,
|
|
206
|
+
) -> bool:
|
|
207
|
+
"""Apply the issue/pull-request filters that the view filter cannot express."""
|
|
208
|
+
if self.content != "all" and kind != self.content:
|
|
209
|
+
return False
|
|
210
|
+
if self.repo and repo.casefold() != self.repo.casefold():
|
|
211
|
+
return False
|
|
212
|
+
if self.state != "all" and state.casefold() != self.state:
|
|
213
|
+
return False
|
|
214
|
+
have = {label.casefold() for label in labels}
|
|
215
|
+
if any(label.casefold() not in have for label in self.labels or []):
|
|
216
|
+
return False
|
|
217
|
+
if any(label.casefold() in have for label in self.exclude_labels or []):
|
|
218
|
+
return False
|
|
219
|
+
if self.assignee is not None and not _assignee_matches(self.assignee, assignees):
|
|
220
|
+
return False
|
|
221
|
+
return self.author is None or author.casefold() == self.author.casefold()
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _view_value(name: str) -> str:
|
|
225
|
+
return f'"{name}"' if any(char in name for char in ' ,"') else name
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _assignee_matches(wanted: str, assignees: list[str]) -> bool:
|
|
229
|
+
if wanted == "none":
|
|
230
|
+
return not assignees
|
|
231
|
+
if wanted == "any":
|
|
232
|
+
return bool(assignees)
|
|
233
|
+
return wanted.casefold() in {login.casefold() for login in assignees}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""Typed models for GitHub GraphQL responses and the task items returned to the host."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
8
|
+
from pydantic.alias_generators import to_camel
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class _GraphQLModel(BaseModel):
|
|
12
|
+
"""Parse GitHub's camelCase payloads and ignore fields the plugin does not use."""
|
|
13
|
+
|
|
14
|
+
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True, extra="ignore")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Actor(_GraphQLModel):
|
|
18
|
+
login: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class LabelName(_GraphQLModel):
|
|
22
|
+
name: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class LabelConnection(_GraphQLModel):
|
|
26
|
+
nodes: list[LabelName] = Field(default_factory=list)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Milestone(_GraphQLModel):
|
|
30
|
+
title: str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ActorConnection(_GraphQLModel):
|
|
34
|
+
nodes: list[Actor] = Field(default_factory=list)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class LinkedReference(_GraphQLModel):
|
|
38
|
+
number: int
|
|
39
|
+
url: str
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ReferenceConnection(_GraphQLModel):
|
|
43
|
+
nodes: list[LinkedReference] = Field(default_factory=list)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Node(_GraphQLModel):
|
|
47
|
+
id: str
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class IssueNode(Node):
|
|
51
|
+
number: int
|
|
52
|
+
title: str
|
|
53
|
+
body: str | None = None
|
|
54
|
+
url: str
|
|
55
|
+
state: str
|
|
56
|
+
author: Actor | None = None
|
|
57
|
+
labels: LabelConnection = Field(default_factory=LabelConnection)
|
|
58
|
+
assignees: ActorConnection = Field(default_factory=ActorConnection)
|
|
59
|
+
milestone: Milestone | None = None
|
|
60
|
+
created_at: str = ""
|
|
61
|
+
# Issue-only; absent from pull-request nodes.
|
|
62
|
+
state_reason: str | None = None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class PullRequestNode(IssueNode):
|
|
66
|
+
head_ref_name: str
|
|
67
|
+
base_ref_name: str
|
|
68
|
+
head_ref_oid: str
|
|
69
|
+
is_draft: bool
|
|
70
|
+
mergeable: str
|
|
71
|
+
merge_state_status: str
|
|
72
|
+
changed_files: int = 0
|
|
73
|
+
additions: int = 0
|
|
74
|
+
deletions: int = 0
|
|
75
|
+
closing_issues_references: ReferenceConnection = Field(default_factory=ReferenceConnection)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class PageInfo(_GraphQLModel):
|
|
79
|
+
has_next_page: bool
|
|
80
|
+
end_cursor: str | None = None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class SearchConnection(_GraphQLModel):
|
|
84
|
+
page_info: PageInfo
|
|
85
|
+
nodes: list[Any] = Field(default_factory=list)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class SearchData(_GraphQLModel):
|
|
89
|
+
search: SearchConnection
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class GraphQLErrorEntry(_GraphQLModel):
|
|
93
|
+
message: str
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class SearchResponse(_GraphQLModel):
|
|
97
|
+
data: SearchData | None = None
|
|
98
|
+
errors: list[GraphQLErrorEntry] = Field(default_factory=list)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class IssueItem(BaseModel):
|
|
102
|
+
"""Issue task item; field names match the `${issue_*}` prompt placeholders."""
|
|
103
|
+
|
|
104
|
+
model_config = ConfigDict(frozen=True)
|
|
105
|
+
|
|
106
|
+
id: str
|
|
107
|
+
title: str
|
|
108
|
+
url: str
|
|
109
|
+
repo: str
|
|
110
|
+
issue_number: int
|
|
111
|
+
issue_title: str
|
|
112
|
+
issue_body: str
|
|
113
|
+
issue_url: str
|
|
114
|
+
issue_state: str
|
|
115
|
+
issue_author: str
|
|
116
|
+
issue_labels: list[str]
|
|
117
|
+
issue_assignees: list[str]
|
|
118
|
+
issue_milestone: str
|
|
119
|
+
issue_created_at: str
|
|
120
|
+
issue_state_reason: str
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class PullRequestItem(BaseModel):
|
|
124
|
+
"""Pull-request task item; field names match the `${pull_request_*}` prompt placeholders."""
|
|
125
|
+
|
|
126
|
+
model_config = ConfigDict(frozen=True)
|
|
127
|
+
|
|
128
|
+
id: str
|
|
129
|
+
title: str
|
|
130
|
+
url: str
|
|
131
|
+
repo: str
|
|
132
|
+
pull_request_number: int
|
|
133
|
+
pull_request_title: str
|
|
134
|
+
pull_request_body: str
|
|
135
|
+
pull_request_url: str
|
|
136
|
+
pull_request_is_draft: bool
|
|
137
|
+
pull_request_head_ref: str
|
|
138
|
+
pull_request_base_ref: str
|
|
139
|
+
pull_request_head_sha: str
|
|
140
|
+
pull_request_author: str
|
|
141
|
+
pull_request_labels: list[str]
|
|
142
|
+
pull_request_mergeable: str
|
|
143
|
+
pull_request_merge_state_status: str
|
|
144
|
+
pull_request_assignees: list[str]
|
|
145
|
+
pull_request_milestone: str
|
|
146
|
+
pull_request_created_at: str
|
|
147
|
+
pull_request_changed_files: int
|
|
148
|
+
pull_request_additions: int
|
|
149
|
+
pull_request_deletions: int
|
|
150
|
+
pull_request_closing_issues: list[str]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class ProjectItemNode(Node):
|
|
154
|
+
"""One Projects v2 item: its field values plus the issue or pull request it wraps."""
|
|
155
|
+
|
|
156
|
+
is_archived: bool = False
|
|
157
|
+
field_values: FieldValueConnection
|
|
158
|
+
content: dict[str, Any] | None = None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class FieldRef(_GraphQLModel):
|
|
162
|
+
name: str = ""
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class FieldValue(_GraphQLModel):
|
|
166
|
+
"""Any field value; only single-select, text, number, date and iteration carry data."""
|
|
167
|
+
|
|
168
|
+
field: FieldRef | None = None
|
|
169
|
+
name: str | None = None
|
|
170
|
+
text: str | None = None
|
|
171
|
+
number: float | None = None
|
|
172
|
+
date: str | None = None
|
|
173
|
+
title: str | None = None
|
|
174
|
+
|
|
175
|
+
def rendered(self) -> str | None:
|
|
176
|
+
for candidate in (self.name, self.text, self.date, self.title):
|
|
177
|
+
if candidate is not None:
|
|
178
|
+
return candidate
|
|
179
|
+
return None if self.number is None else f"{self.number:g}"
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class FieldValueConnection(_GraphQLModel):
|
|
183
|
+
nodes: list[FieldValue] = Field(default_factory=list)
|
|
184
|
+
|
|
185
|
+
def by_name(self) -> dict[str, str]:
|
|
186
|
+
"""Map field name to rendered value, skipping untyped values (repository, PR links)."""
|
|
187
|
+
values: dict[str, str] = {}
|
|
188
|
+
for value in self.nodes:
|
|
189
|
+
rendered = value.rendered()
|
|
190
|
+
if value.field is not None and value.field.name and rendered is not None:
|
|
191
|
+
values[value.field.name] = rendered
|
|
192
|
+
return values
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
ProjectItemNode.model_rebuild()
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
ProjectItems = SearchConnection
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class ProjectV2(_GraphQLModel):
|
|
202
|
+
title: str
|
|
203
|
+
url: str
|
|
204
|
+
items: ProjectItems
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class ProjectOwner(_GraphQLModel):
|
|
208
|
+
project_v2: ProjectV2 | None = None
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class ProjectData(_GraphQLModel):
|
|
212
|
+
repository_owner: ProjectOwner | None = None
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class ProjectResponse(_GraphQLModel):
|
|
216
|
+
data: ProjectData | None = None
|
|
217
|
+
errors: list[GraphQLErrorEntry] = Field(default_factory=list)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class ProjectItem(BaseModel):
|
|
221
|
+
"""Project item task; field names match the `${item_*}` / `${project_*}` placeholders."""
|
|
222
|
+
|
|
223
|
+
model_config = ConfigDict(frozen=True)
|
|
224
|
+
|
|
225
|
+
id: str
|
|
226
|
+
title: str
|
|
227
|
+
url: str
|
|
228
|
+
repo: str
|
|
229
|
+
project_title: str
|
|
230
|
+
project_url: str
|
|
231
|
+
project_column: str
|
|
232
|
+
project_fields: str
|
|
233
|
+
item_type: str
|
|
234
|
+
item_number: int
|
|
235
|
+
item_title: str
|
|
236
|
+
item_body: str
|
|
237
|
+
item_url: str
|
|
238
|
+
item_state: str
|
|
239
|
+
item_author: str
|
|
240
|
+
item_labels: list[str]
|
|
241
|
+
item_assignees: list[str]
|
|
242
|
+
item_milestone: str
|
|
243
|
+
item_created_at: str
|
|
244
|
+
pull_request_head_ref: str
|
|
245
|
+
pull_request_base_ref: str
|
|
246
|
+
pull_request_head_sha: str
|
|
247
|
+
pull_request_is_draft: bool
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
"""Official GitHub issues / pull-requests triggers.
|
|
2
|
+
|
|
3
|
+
Discovery authenticates through `gh auth token`; no GitHub credentials are stored here.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from curupira_sdk import Curupira, RpcError
|
|
12
|
+
from pydantic import ValidationError
|
|
13
|
+
|
|
14
|
+
from curupira_plugins.github.client import Discovery, GitHubClient, GitHubCliToken, parse_nodes
|
|
15
|
+
from curupira_plugins.github.filters import IssueFilters, ProjectFilters, PullRequestFilters
|
|
16
|
+
from curupira_plugins.github.models import (
|
|
17
|
+
IssueItem,
|
|
18
|
+
IssueNode,
|
|
19
|
+
ProjectItem,
|
|
20
|
+
ProjectItemNode,
|
|
21
|
+
PullRequestItem,
|
|
22
|
+
PullRequestNode,
|
|
23
|
+
ReferenceConnection,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
_CLIENT = GitHubClient(GitHubCliToken())
|
|
27
|
+
# One discovery feed per distinct filter set, kept for the lifetime of this process.
|
|
28
|
+
_FEEDS: dict[str, Discovery[Any, Any]] = {}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _validation_message(exc: ValidationError) -> str:
|
|
32
|
+
return "; ".join(
|
|
33
|
+
f"{'.'.join(str(part) for part in error['loc'])}: {error['msg']}" for error in exc.errors()
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _check_issue_filters(kwargs: dict[str, object]) -> None:
|
|
38
|
+
try:
|
|
39
|
+
IssueFilters(**kwargs) # type: ignore[arg-type]
|
|
40
|
+
except ValidationError as exc:
|
|
41
|
+
raise RpcError(-32003, _validation_message(exc)) from exc
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _check_pull_request_filters(kwargs: dict[str, object]) -> None:
|
|
45
|
+
try:
|
|
46
|
+
PullRequestFilters(**kwargs) # type: ignore[arg-type]
|
|
47
|
+
except ValidationError as exc:
|
|
48
|
+
raise RpcError(-32003, _validation_message(exc)) from exc
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _reference_urls(references: ReferenceConnection) -> list[str]:
|
|
52
|
+
return [reference.url for reference in references.nodes]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _check_project_filters(kwargs: dict[str, object]) -> None:
|
|
56
|
+
try:
|
|
57
|
+
ProjectFilters(**kwargs) # type: ignore[arg-type]
|
|
58
|
+
except ValidationError as exc:
|
|
59
|
+
raise RpcError(-32003, _validation_message(exc)) from exc
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _issue_item(repo: str, node: IssueNode) -> IssueItem:
|
|
63
|
+
return IssueItem(
|
|
64
|
+
id=str(node.number),
|
|
65
|
+
title=node.title,
|
|
66
|
+
url=node.url,
|
|
67
|
+
repo=repo,
|
|
68
|
+
issue_number=node.number,
|
|
69
|
+
issue_title=node.title,
|
|
70
|
+
issue_body=node.body or "",
|
|
71
|
+
issue_url=node.url,
|
|
72
|
+
issue_state=node.state,
|
|
73
|
+
issue_author=node.author.login if node.author else "",
|
|
74
|
+
issue_labels=[label.name for label in node.labels.nodes],
|
|
75
|
+
issue_assignees=[actor.login for actor in node.assignees.nodes],
|
|
76
|
+
issue_milestone=node.milestone.title if node.milestone else "",
|
|
77
|
+
issue_created_at=node.created_at,
|
|
78
|
+
issue_state_reason=node.state_reason or "",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _pull_request_item(repo: str, node: PullRequestNode) -> PullRequestItem:
|
|
83
|
+
return PullRequestItem(
|
|
84
|
+
id=str(node.number),
|
|
85
|
+
title=node.title,
|
|
86
|
+
url=node.url,
|
|
87
|
+
repo=repo,
|
|
88
|
+
pull_request_number=node.number,
|
|
89
|
+
pull_request_title=node.title,
|
|
90
|
+
pull_request_body=node.body or "",
|
|
91
|
+
pull_request_url=node.url,
|
|
92
|
+
pull_request_is_draft=node.is_draft,
|
|
93
|
+
pull_request_head_ref=node.head_ref_name,
|
|
94
|
+
pull_request_base_ref=node.base_ref_name,
|
|
95
|
+
pull_request_head_sha=node.head_ref_oid,
|
|
96
|
+
pull_request_author=node.author.login if node.author else "",
|
|
97
|
+
pull_request_labels=[label.name for label in node.labels.nodes],
|
|
98
|
+
pull_request_mergeable=node.mergeable,
|
|
99
|
+
pull_request_merge_state_status=node.merge_state_status,
|
|
100
|
+
pull_request_assignees=[actor.login for actor in node.assignees.nodes],
|
|
101
|
+
pull_request_milestone=node.milestone.title if node.milestone else "",
|
|
102
|
+
pull_request_created_at=node.created_at,
|
|
103
|
+
pull_request_changed_files=node.changed_files,
|
|
104
|
+
pull_request_additions=node.additions,
|
|
105
|
+
pull_request_deletions=node.deletions,
|
|
106
|
+
pull_request_closing_issues=_reference_urls(node.closing_issues_references),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _issue_feed(filters: IssueFilters) -> Discovery[IssueNode, IssueItem]:
|
|
111
|
+
key = f"issue:{filters.model_dump_json()}"
|
|
112
|
+
feed = _FEEDS.get(key)
|
|
113
|
+
if feed is None:
|
|
114
|
+
query = filters.search_query()
|
|
115
|
+
feed = Discovery(
|
|
116
|
+
fetch_page=lambda after: _CLIENT.search(query, after=after),
|
|
117
|
+
parse_page=lambda connection: parse_nodes(connection, IssueNode),
|
|
118
|
+
keep=lambda node: True,
|
|
119
|
+
to_item=lambda node: _issue_item(filters.repo, node),
|
|
120
|
+
)
|
|
121
|
+
_FEEDS[key] = feed
|
|
122
|
+
return feed
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _pull_request_feed(
|
|
126
|
+
filters: PullRequestFilters,
|
|
127
|
+
) -> Discovery[PullRequestNode, PullRequestItem]:
|
|
128
|
+
key = f"pull_request:{filters.model_dump_json()}"
|
|
129
|
+
feed = _FEEDS.get(key)
|
|
130
|
+
if feed is None:
|
|
131
|
+
query = filters.search_query()
|
|
132
|
+
feed = Discovery(
|
|
133
|
+
fetch_page=lambda after: _CLIENT.search(query, after=after),
|
|
134
|
+
parse_page=lambda connection: parse_nodes(connection, PullRequestNode),
|
|
135
|
+
keep=lambda node: filters.keeps(
|
|
136
|
+
mergeable_state=node.mergeable,
|
|
137
|
+
merge_state_status=node.merge_state_status,
|
|
138
|
+
),
|
|
139
|
+
to_item=lambda node: _pull_request_item(filters.repo, node),
|
|
140
|
+
)
|
|
141
|
+
_FEEDS[key] = feed
|
|
142
|
+
return feed
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _project_item(
|
|
146
|
+
filters: ProjectFilters,
|
|
147
|
+
project_title: str,
|
|
148
|
+
project_url: str,
|
|
149
|
+
node: ProjectItemNode,
|
|
150
|
+
) -> ProjectItem | None:
|
|
151
|
+
"""Map one project item to a task, or None when it is not an eligible issue/PR."""
|
|
152
|
+
content = node.content or {}
|
|
153
|
+
kind = content.get("__typename")
|
|
154
|
+
if node.is_archived or kind not in {"Issue", "PullRequest"}:
|
|
155
|
+
return None # draft items, redacted content, and archived cards carry no repository
|
|
156
|
+
fields = node.field_values.by_name()
|
|
157
|
+
if not filters.keeps_column(fields):
|
|
158
|
+
return None
|
|
159
|
+
repo = str(content.get("repository", {}).get("nameWithOwner", ""))
|
|
160
|
+
pull_request = PullRequestNode.model_validate(content) if kind == "PullRequest" else None
|
|
161
|
+
base = pull_request or IssueNode.model_validate(content)
|
|
162
|
+
labels = [label.name for label in base.labels.nodes]
|
|
163
|
+
assignees = [actor.login for actor in base.assignees.nodes]
|
|
164
|
+
author = base.author.login if base.author else ""
|
|
165
|
+
item_type = "pull_request" if pull_request else "issue"
|
|
166
|
+
if not filters.keeps_content(
|
|
167
|
+
kind=item_type,
|
|
168
|
+
repo=repo,
|
|
169
|
+
state=base.state,
|
|
170
|
+
labels=labels,
|
|
171
|
+
assignees=assignees,
|
|
172
|
+
author=author,
|
|
173
|
+
):
|
|
174
|
+
return None
|
|
175
|
+
return ProjectItem(
|
|
176
|
+
id=f"{repo}#{base.number}",
|
|
177
|
+
title=base.title,
|
|
178
|
+
url=base.url,
|
|
179
|
+
repo=repo,
|
|
180
|
+
project_title=project_title,
|
|
181
|
+
project_url=project_url,
|
|
182
|
+
project_column=fields.get(filters.status_field, ""),
|
|
183
|
+
project_fields=json.dumps(fields, ensure_ascii=False, sort_keys=True),
|
|
184
|
+
item_type=item_type,
|
|
185
|
+
item_number=base.number,
|
|
186
|
+
item_title=base.title,
|
|
187
|
+
item_body=base.body or "",
|
|
188
|
+
item_url=base.url,
|
|
189
|
+
item_state=base.state,
|
|
190
|
+
item_author=author,
|
|
191
|
+
item_labels=labels,
|
|
192
|
+
item_assignees=assignees,
|
|
193
|
+
item_milestone=base.milestone.title if base.milestone else "",
|
|
194
|
+
item_created_at=base.created_at,
|
|
195
|
+
pull_request_head_ref=pull_request.head_ref_name if pull_request else "",
|
|
196
|
+
pull_request_base_ref=pull_request.base_ref_name if pull_request else "",
|
|
197
|
+
pull_request_head_sha=pull_request.head_ref_oid if pull_request else "",
|
|
198
|
+
pull_request_is_draft=pull_request.is_draft if pull_request else False,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _project_feed(filters: ProjectFilters) -> Discovery[ProjectItemNode, ProjectItem]:
|
|
203
|
+
key = f"project:{filters.model_dump_json()}"
|
|
204
|
+
feed = _FEEDS.get(key)
|
|
205
|
+
if feed is None:
|
|
206
|
+
view_filter = filters.view_filter()
|
|
207
|
+
# The project title and URL arrive with every page; keep the latest for `to_item`.
|
|
208
|
+
meta: dict[str, str] = {"title": "", "url": ""}
|
|
209
|
+
|
|
210
|
+
def fetch(after: str | None) -> Any:
|
|
211
|
+
project, items = _CLIENT.project_items(
|
|
212
|
+
filters.owner, filters.project, view_filter=view_filter, after=after
|
|
213
|
+
)
|
|
214
|
+
meta["title"], meta["url"] = project.title, project.url
|
|
215
|
+
return items
|
|
216
|
+
|
|
217
|
+
feed = Discovery(
|
|
218
|
+
fetch_page=fetch,
|
|
219
|
+
parse_page=lambda connection: parse_nodes(connection, ProjectItemNode),
|
|
220
|
+
keep=lambda node: _project_item(filters, meta["title"], meta["url"], node) is not None,
|
|
221
|
+
to_item=lambda node: _project_item( # type: ignore[arg-type, return-value]
|
|
222
|
+
filters, meta["title"], meta["url"], node
|
|
223
|
+
),
|
|
224
|
+
)
|
|
225
|
+
_FEEDS[key] = feed
|
|
226
|
+
return feed
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def github_issues(
|
|
230
|
+
repo: str,
|
|
231
|
+
labels: str | list[str] | None = None,
|
|
232
|
+
exclude_labels: str | list[str] | None = None,
|
|
233
|
+
state: str = "open",
|
|
234
|
+
assignee: str | None = None,
|
|
235
|
+
author: str | None = None,
|
|
236
|
+
milestone: str | None = None,
|
|
237
|
+
project: str | None = None,
|
|
238
|
+
linked_pull_request: bool | None = None,
|
|
239
|
+
sort: str | None = None,
|
|
240
|
+
) -> IssueItem | None:
|
|
241
|
+
"""Return the next eligible GitHub issue, or None when none are available."""
|
|
242
|
+
filters = IssueFilters(
|
|
243
|
+
repo=repo,
|
|
244
|
+
labels=labels,
|
|
245
|
+
exclude_labels=exclude_labels,
|
|
246
|
+
state=state, # type: ignore[arg-type]
|
|
247
|
+
assignee=assignee,
|
|
248
|
+
author=author,
|
|
249
|
+
milestone=milestone,
|
|
250
|
+
project=project,
|
|
251
|
+
linked_pull_request=linked_pull_request,
|
|
252
|
+
sort=sort, # type: ignore[arg-type]
|
|
253
|
+
)
|
|
254
|
+
return _issue_feed(filters).next_item()
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def github_pull_requests(
|
|
258
|
+
repo: str,
|
|
259
|
+
labels: str | list[str] | None = None,
|
|
260
|
+
exclude_labels: str | list[str] | None = None,
|
|
261
|
+
state: str = "open",
|
|
262
|
+
assignee: str | None = None,
|
|
263
|
+
author: str | None = None,
|
|
264
|
+
milestone: str | None = None,
|
|
265
|
+
project: str | None = None,
|
|
266
|
+
draft: bool | None = None,
|
|
267
|
+
base: str | None = None,
|
|
268
|
+
head: str | None = None,
|
|
269
|
+
review: str | None = None,
|
|
270
|
+
ci_status: str | None = None,
|
|
271
|
+
linked_issue: bool | None = None,
|
|
272
|
+
mergeable: bool | None = None,
|
|
273
|
+
merge_state: str | list[str] | None = None,
|
|
274
|
+
sort: str | None = None,
|
|
275
|
+
) -> PullRequestItem | None:
|
|
276
|
+
"""Return the next eligible GitHub pull request, or None when none are available."""
|
|
277
|
+
filters = PullRequestFilters(
|
|
278
|
+
repo=repo,
|
|
279
|
+
labels=labels,
|
|
280
|
+
exclude_labels=exclude_labels,
|
|
281
|
+
state=state, # type: ignore[arg-type]
|
|
282
|
+
assignee=assignee,
|
|
283
|
+
author=author,
|
|
284
|
+
milestone=milestone,
|
|
285
|
+
project=project,
|
|
286
|
+
draft=draft,
|
|
287
|
+
base=base,
|
|
288
|
+
head=head,
|
|
289
|
+
review=review, # type: ignore[arg-type]
|
|
290
|
+
ci_status=ci_status, # type: ignore[arg-type]
|
|
291
|
+
linked_issue=linked_issue,
|
|
292
|
+
mergeable=mergeable,
|
|
293
|
+
merge_state=merge_state, # type: ignore[arg-type]
|
|
294
|
+
sort=sort, # type: ignore[arg-type]
|
|
295
|
+
)
|
|
296
|
+
return _pull_request_feed(filters).next_item()
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def github_project_items(
|
|
300
|
+
owner: str,
|
|
301
|
+
project: int,
|
|
302
|
+
column: str | list[str],
|
|
303
|
+
status_field: str = "Status",
|
|
304
|
+
content: str = "all",
|
|
305
|
+
repo: str | None = None,
|
|
306
|
+
state: str = "open",
|
|
307
|
+
labels: str | list[str] | None = None,
|
|
308
|
+
exclude_labels: str | list[str] | None = None,
|
|
309
|
+
assignee: str | None = None,
|
|
310
|
+
author: str | None = None,
|
|
311
|
+
) -> ProjectItem | None:
|
|
312
|
+
"""Return the next issue or pull request sitting in a GitHub Project column."""
|
|
313
|
+
filters = ProjectFilters(
|
|
314
|
+
owner=owner,
|
|
315
|
+
project=project,
|
|
316
|
+
column=column,
|
|
317
|
+
status_field=status_field,
|
|
318
|
+
content=content, # type: ignore[arg-type]
|
|
319
|
+
repo=repo,
|
|
320
|
+
state=state, # type: ignore[arg-type]
|
|
321
|
+
labels=labels,
|
|
322
|
+
exclude_labels=exclude_labels,
|
|
323
|
+
assignee=assignee,
|
|
324
|
+
author=author,
|
|
325
|
+
)
|
|
326
|
+
return _project_feed(filters).next_item()
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def register(app: Curupira) -> None:
|
|
330
|
+
"""Register GitHub triggers on ``app``."""
|
|
331
|
+
app.trigger(github_issues)
|
|
332
|
+
app.trigger(github_pull_requests)
|
|
333
|
+
app.trigger(github_project_items)
|
|
334
|
+
app.param_check("github-project-items", _check_project_filters)
|
|
335
|
+
app.param_check("github-issues", _check_issue_filters)
|
|
336
|
+
app.param_check("github-pull-requests", _check_pull_request_filters)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: curupira-plugins
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Curupira trigger extensions (GitHub issues, pull requests, and Projects)
|
|
5
|
+
Project-URL: Homepage, https://github.com/caipora-labs/curupira
|
|
6
|
+
Project-URL: Repository, https://github.com/caipora-labs/curupira
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Requires-Dist: curupira-sdk[pydantic]>=0.1.0.dev0
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
curupira_plugins/__init__.py,sha256=Umr4i7TagRBDdIOtob7ZvGLulwU15_ycQ3kefmvwktU,254
|
|
2
|
+
curupira_plugins/__main__.py,sha256=Wt8FGMlzaGaAksZhbQzkIGdMzT72lO9nHSOImqX6mTg,1142
|
|
3
|
+
curupira_plugins/github/__init__.py,sha256=ryXNkmrBCwwigyt0qzpwdkzwYdPlabvTimlkuPFxaL0,48
|
|
4
|
+
curupira_plugins/github/client.py,sha256=6uKfO9Q68LWqAPWUXpl8tllTtyB6G14lRae9WghwcP0,10917
|
|
5
|
+
curupira_plugins/github/filters.py,sha256=ajS_-UNGl6LYAX3nZVlq94O0HaCVy95v76pdszPWLjU,8616
|
|
6
|
+
curupira_plugins/github/models.py,sha256=F2LYUmXpO6gnZyvK9A3sQyP_tZTOxsLYElHKyEZbSmY,6077
|
|
7
|
+
curupira_plugins/github/triggers.py,sha256=Tk3k0fJUqIX434bFbW5s1o1SFdGbZRTViolaHRQIqmc,12030
|
|
8
|
+
curupira_plugins-0.1.0.dist-info/METADATA,sha256=iC46A5QNioLSPBA1SHViDqmw3xhH5dr4Juc7k3W9xEM,379
|
|
9
|
+
curupira_plugins-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
10
|
+
curupira_plugins-0.1.0.dist-info/entry_points.txt,sha256=65ycZitgY2NoI1K8vepqnHghBab4F4X-TX-9pJoYlZw,68
|
|
11
|
+
curupira_plugins-0.1.0.dist-info/RECORD,,
|