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.
@@ -0,0 +1,6 @@
1
+ """Official Curupira trigger extensions (GitHub issues, pull requests, and Projects)."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ # Read from the installed distribution, whose version comes from the release tag.
6
+ __version__ = version("curupira-plugins")
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ curupira-plugins = curupira_plugins.__main__:main