curupira-plugins 0.1.0.dev0__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 +62 -0
- curupira_plugins/azure/__init__.py +1 -0
- curupira_plugins/azure/triggers.py +56 -0
- curupira_plugins/github/__init__.py +1 -0
- curupira_plugins/github/client.py +203 -0
- curupira_plugins/github/filters.py +136 -0
- curupira_plugins/github/models.py +110 -0
- curupira_plugins/github/triggers.py +189 -0
- curupira_plugins/monday/__init__.py +1 -0
- curupira_plugins/monday/triggers.py +30 -0
- curupira_plugins/trello/__init__.py +1 -0
- curupira_plugins/trello/triggers.py +29 -0
- curupira_plugins-0.1.0.dev0.dist-info/METADATA +9 -0
- curupira_plugins-0.1.0.dev0.dist-info/RECORD +17 -0
- curupira_plugins-0.1.0.dev0.dist-info/WHEEL +4 -0
- curupira_plugins-0.1.0.dev0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,62 @@
|
|
|
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.azure.triggers import register as register_azure
|
|
17
|
+
from curupira_plugins.github.triggers import register as register_github
|
|
18
|
+
from curupira_plugins.monday.triggers import register as register_monday
|
|
19
|
+
from curupira_plugins.trello.triggers import register as register_trello
|
|
20
|
+
|
|
21
|
+
app = Curupira(name="curupira-plugins", version=__version__)
|
|
22
|
+
register_github(app)
|
|
23
|
+
register_azure(app)
|
|
24
|
+
register_trello(app)
|
|
25
|
+
register_monday(app)
|
|
26
|
+
|
|
27
|
+
MANIFEST = """
|
|
28
|
+
manifest_version = 1
|
|
29
|
+
name = "curupira-plugins"
|
|
30
|
+
version = "{version}"
|
|
31
|
+
protocol_version = 1
|
|
32
|
+
runtime = "python"
|
|
33
|
+
kind = "trigger"
|
|
34
|
+
trigger_types = [
|
|
35
|
+
"github-issues",
|
|
36
|
+
"github-pull-requests",
|
|
37
|
+
"azure-cli-pull-requests",
|
|
38
|
+
"trello-cli-cards",
|
|
39
|
+
"monday-items",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
[launch]
|
|
43
|
+
type = "uvx"
|
|
44
|
+
package = "curupira-plugins"
|
|
45
|
+
executable = "curupira-plugins"
|
|
46
|
+
|
|
47
|
+
[package]
|
|
48
|
+
source = "pip"
|
|
49
|
+
name = "curupira-plugins"
|
|
50
|
+
version = "{version}"
|
|
51
|
+
""".strip()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def main() -> None:
|
|
55
|
+
if "--curupira-print-manifest" in sys.argv:
|
|
56
|
+
app.print_manifest(MANIFEST)
|
|
57
|
+
return
|
|
58
|
+
app.run()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
if __name__ == "__main__":
|
|
62
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Azure DevOps pull-request discovery."""
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Official Azure DevOps pull-request trigger."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from curupira_sdk import Curupira, RpcError
|
|
9
|
+
|
|
10
|
+
_REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class AzurePullRequest:
|
|
15
|
+
pull_request_number: int
|
|
16
|
+
pull_request_title: str
|
|
17
|
+
pull_request_url: str
|
|
18
|
+
pull_request_body: str = ""
|
|
19
|
+
pull_request_head_ref: str = ""
|
|
20
|
+
pull_request_base_ref: str = ""
|
|
21
|
+
id: str = ""
|
|
22
|
+
|
|
23
|
+
def __post_init__(self) -> None:
|
|
24
|
+
if not self.id:
|
|
25
|
+
self.id = str(self.pull_request_number)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _validate_repo(repo: str) -> None:
|
|
29
|
+
if _REPO_RE.fullmatch(repo) is None:
|
|
30
|
+
raise RpcError(-32003, "repo must use the organization/project/repository format")
|
|
31
|
+
if any(part in {".", ".."} for part in repo.split("/")):
|
|
32
|
+
raise RpcError(-32003, "repo must not contain traversal segments")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _check_repo(kwargs: dict[str, object]) -> None:
|
|
36
|
+
repo = kwargs.get("repo")
|
|
37
|
+
if not isinstance(repo, str):
|
|
38
|
+
raise RpcError(-32003, "repo must use the organization/project/repository format")
|
|
39
|
+
_validate_repo(repo)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def register(app: Curupira) -> None:
|
|
43
|
+
"""Register Azure DevOps triggers on ``app``."""
|
|
44
|
+
|
|
45
|
+
@app.trigger
|
|
46
|
+
def azure_cli_pull_requests(
|
|
47
|
+
repo: str,
|
|
48
|
+
status: str | None = None,
|
|
49
|
+
source_branch: str | None = None,
|
|
50
|
+
target_branch: str | None = None,
|
|
51
|
+
) -> AzurePullRequest | None:
|
|
52
|
+
"""Return the next eligible Azure DevOps pull request, or None."""
|
|
53
|
+
del repo, status, source_branch, target_branch
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
app.param_check("azure-cli-pull-requests", _check_repo)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""GitHub issues and pull-request discovery."""
|
|
@@ -0,0 +1,203 @@
|
|
|
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 IssueNode, SearchConnection, SearchResponse
|
|
18
|
+
|
|
19
|
+
GRAPHQL_URL = "https://api.github.com/graphql"
|
|
20
|
+
TOKEN_CACHE_SECONDS = 300.0
|
|
21
|
+
PAGE_SIZE = 100
|
|
22
|
+
_RETRY_STATUSES = frozenset({429, 500, 502, 503, 504})
|
|
23
|
+
_ATTEMPTS = 3
|
|
24
|
+
_GH_TIMEOUT_SECONDS = 30
|
|
25
|
+
_HTTP_TIMEOUT_SECONDS = 30
|
|
26
|
+
|
|
27
|
+
SEARCH_QUERY = """
|
|
28
|
+
query CuripiraSearch($query: String!, $first: Int!, $after: String) {
|
|
29
|
+
search(query: $query, type: ISSUE, first: $first, after: $after) {
|
|
30
|
+
pageInfo { hasNextPage endCursor }
|
|
31
|
+
nodes {
|
|
32
|
+
... on Issue {
|
|
33
|
+
id number title body url state
|
|
34
|
+
author { login }
|
|
35
|
+
labels(first: 100) { nodes { name } }
|
|
36
|
+
}
|
|
37
|
+
... on PullRequest {
|
|
38
|
+
id number title body url state
|
|
39
|
+
author { login }
|
|
40
|
+
labels(first: 100) { nodes { name } }
|
|
41
|
+
headRefName baseRefName headRefOid isDraft mergeable mergeStateStatus
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
NodeT = TypeVar("NodeT", bound=IssueNode)
|
|
49
|
+
ItemT = TypeVar("ItemT")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class GitHubCliToken:
|
|
53
|
+
"""Read the bearer token from `gh auth token`, caching it for five minutes."""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
*,
|
|
58
|
+
clock: Callable[[], float] = time.monotonic,
|
|
59
|
+
run: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
|
|
60
|
+
) -> None:
|
|
61
|
+
self._clock = clock
|
|
62
|
+
self._run = run
|
|
63
|
+
self._token: str | None = None
|
|
64
|
+
self._expires_at = 0.0
|
|
65
|
+
|
|
66
|
+
def token(self) -> str:
|
|
67
|
+
now = self._clock()
|
|
68
|
+
if self._token is not None and now < self._expires_at:
|
|
69
|
+
return self._token
|
|
70
|
+
try:
|
|
71
|
+
completed = self._run(
|
|
72
|
+
["gh", "auth", "token"],
|
|
73
|
+
capture_output=True,
|
|
74
|
+
text=True,
|
|
75
|
+
timeout=_GH_TIMEOUT_SECONDS,
|
|
76
|
+
check=False,
|
|
77
|
+
)
|
|
78
|
+
except FileNotFoundError as exc:
|
|
79
|
+
raise RpcError(-32002, "Required executable not found: gh") from exc
|
|
80
|
+
except subprocess.TimeoutExpired as exc:
|
|
81
|
+
raise RpcError(-32002, "gh auth token timed out") from exc
|
|
82
|
+
if completed.returncode != 0:
|
|
83
|
+
detail = completed.stderr.strip() or f"exit status {completed.returncode}"
|
|
84
|
+
raise RpcError(-32002, f"gh auth token failed: {detail}")
|
|
85
|
+
token = completed.stdout.strip()
|
|
86
|
+
if not token:
|
|
87
|
+
raise RpcError(-32002, "gh auth token returned an empty token")
|
|
88
|
+
self._token = token
|
|
89
|
+
self._expires_at = now + TOKEN_CACHE_SECONDS
|
|
90
|
+
return token
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class GitHubClient:
|
|
94
|
+
"""Run GraphQL search requests with the gh CLI token and bounded retries."""
|
|
95
|
+
|
|
96
|
+
def __init__(
|
|
97
|
+
self,
|
|
98
|
+
tokens: GitHubCliToken,
|
|
99
|
+
*,
|
|
100
|
+
endpoint: str = GRAPHQL_URL,
|
|
101
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
102
|
+
) -> None:
|
|
103
|
+
self._tokens = tokens
|
|
104
|
+
self._endpoint = endpoint
|
|
105
|
+
self._sleep = sleep
|
|
106
|
+
|
|
107
|
+
def search(self, query: str, *, after: str | None) -> SearchConnection:
|
|
108
|
+
"""Return one page of issue/pull-request search results for `query`."""
|
|
109
|
+
payload = {
|
|
110
|
+
"query": SEARCH_QUERY,
|
|
111
|
+
"variables": {"query": query, "first": PAGE_SIZE, "after": after},
|
|
112
|
+
}
|
|
113
|
+
body = self._post(payload)
|
|
114
|
+
try:
|
|
115
|
+
response = SearchResponse.model_validate_json(body)
|
|
116
|
+
except ValidationError as exc:
|
|
117
|
+
raise RpcError(-32002, f"unexpected GitHub GraphQL response: {exc}") from exc
|
|
118
|
+
if response.errors:
|
|
119
|
+
messages = "; ".join(error.message for error in response.errors)
|
|
120
|
+
raise RpcError(-32002, f"GitHub GraphQL error: {messages}")
|
|
121
|
+
if response.data is None:
|
|
122
|
+
raise RpcError(-32002, "GitHub GraphQL response has no data")
|
|
123
|
+
return response.data.search
|
|
124
|
+
|
|
125
|
+
def _post(self, payload: dict[str, Any]) -> bytes:
|
|
126
|
+
data = json.dumps(payload).encode("utf-8")
|
|
127
|
+
for attempt in range(1, _ATTEMPTS + 1):
|
|
128
|
+
request = urllib.request.Request(
|
|
129
|
+
self._endpoint,
|
|
130
|
+
data=data,
|
|
131
|
+
method="POST",
|
|
132
|
+
headers={
|
|
133
|
+
"Authorization": f"Bearer {self._tokens.token()}",
|
|
134
|
+
"Content-Type": "application/json",
|
|
135
|
+
"Accept": "application/json",
|
|
136
|
+
"User-Agent": "curupira-plugins",
|
|
137
|
+
},
|
|
138
|
+
)
|
|
139
|
+
try:
|
|
140
|
+
with urllib.request.urlopen(request, timeout=_HTTP_TIMEOUT_SECONDS) as response:
|
|
141
|
+
return bytes(response.read())
|
|
142
|
+
except urllib.error.HTTPError as exc:
|
|
143
|
+
# Close the response so the connection is released before retrying or raising.
|
|
144
|
+
status = exc.code
|
|
145
|
+
exc.close()
|
|
146
|
+
if status not in _RETRY_STATUSES or attempt == _ATTEMPTS:
|
|
147
|
+
raise RpcError(
|
|
148
|
+
-32002, f"GitHub GraphQL request failed with status {status}"
|
|
149
|
+
) from exc
|
|
150
|
+
except urllib.error.URLError as exc:
|
|
151
|
+
if attempt == _ATTEMPTS:
|
|
152
|
+
raise RpcError(-32002, f"GitHub GraphQL request failed: {exc.reason}") from exc
|
|
153
|
+
self._sleep(2.0 ** (attempt - 1))
|
|
154
|
+
raise RpcError(-32002, "GitHub GraphQL request failed")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def parse_nodes(connection: SearchConnection, model: type[NodeT]) -> list[NodeT]:
|
|
158
|
+
"""Validate raw search nodes into `model` (IssueNode or PullRequestNode)."""
|
|
159
|
+
try:
|
|
160
|
+
return [model.model_validate(node) for node in connection.nodes]
|
|
161
|
+
except ValidationError as exc:
|
|
162
|
+
raise RpcError(-32002, f"unexpected GitHub search node: {exc}") from exc
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class Discovery(Generic[NodeT, ItemT]):
|
|
166
|
+
"""Yield each matching item once per process, fetching result pages lazily.
|
|
167
|
+
|
|
168
|
+
The host starts a new extension process for every poll cycle, so the seen set only has
|
|
169
|
+
to hold for one cycle. Items already completed are filtered by the host.
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
def __init__(
|
|
173
|
+
self,
|
|
174
|
+
*,
|
|
175
|
+
fetch_page: Callable[[str | None], SearchConnection],
|
|
176
|
+
parse_page: Callable[[SearchConnection], list[NodeT]],
|
|
177
|
+
keep: Callable[[NodeT], bool],
|
|
178
|
+
to_item: Callable[[NodeT], ItemT],
|
|
179
|
+
) -> None:
|
|
180
|
+
self._fetch_page = fetch_page
|
|
181
|
+
self._parse_page = parse_page
|
|
182
|
+
self._keep = keep
|
|
183
|
+
self._to_item = to_item
|
|
184
|
+
self._pending: deque[NodeT] = deque()
|
|
185
|
+
self._cursor: str | None = None
|
|
186
|
+
self._has_more = True
|
|
187
|
+
self._seen: set[str] = set()
|
|
188
|
+
|
|
189
|
+
def next_item(self) -> ItemT | None:
|
|
190
|
+
while True:
|
|
191
|
+
while self._pending:
|
|
192
|
+
node = self._pending.popleft()
|
|
193
|
+
if node.id in self._seen:
|
|
194
|
+
continue
|
|
195
|
+
self._seen.add(node.id)
|
|
196
|
+
if self._keep(node):
|
|
197
|
+
return self._to_item(node)
|
|
198
|
+
if not self._has_more:
|
|
199
|
+
return None
|
|
200
|
+
connection = self._fetch_page(self._cursor)
|
|
201
|
+
self._pending.extend(self._parse_page(connection))
|
|
202
|
+
self._has_more = connection.page_info.has_next_page
|
|
203
|
+
self._cursor = connection.page_info.end_cursor
|
|
@@ -0,0 +1,136 @@
|
|
|
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("repo")
|
|
41
|
+
@classmethod
|
|
42
|
+
def _reject_traversal(cls, value: str) -> str:
|
|
43
|
+
if any(part in {".", ".."} for part in value.split("/")):
|
|
44
|
+
raise ValueError("repo must not contain traversal segments")
|
|
45
|
+
return value
|
|
46
|
+
|
|
47
|
+
def shared_qualifiers(self) -> list[str]:
|
|
48
|
+
"""Return the Search qualifiers that both issues and pull requests accept."""
|
|
49
|
+
qualifiers = [f"repo:{self.repo}", _STATE_QUALIFIERS[self.state]]
|
|
50
|
+
qualifiers += [_qualifier("label", label) for label in self.labels or []]
|
|
51
|
+
qualifiers += [f"-{_qualifier('label', label)}" for label in self.exclude_labels or []]
|
|
52
|
+
qualifiers += _assignee_qualifiers(self.assignee)
|
|
53
|
+
if self.author:
|
|
54
|
+
qualifiers.append(_qualifier("author", self.author))
|
|
55
|
+
if self.milestone:
|
|
56
|
+
qualifiers.append(_qualifier("milestone", self.milestone))
|
|
57
|
+
if self.project:
|
|
58
|
+
qualifiers.append(_qualifier("project", self.project))
|
|
59
|
+
return qualifiers
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class IssueFilters(SearchFilters):
|
|
63
|
+
"""Filters for `github-issues` automations."""
|
|
64
|
+
|
|
65
|
+
linked_pull_request: bool | None = None
|
|
66
|
+
|
|
67
|
+
def search_query(self) -> str:
|
|
68
|
+
qualifiers = ["is:issue", *self.shared_qualifiers()]
|
|
69
|
+
if self.linked_pull_request is not None:
|
|
70
|
+
qualifiers.append("linked:pr" if self.linked_pull_request else "-linked:pr")
|
|
71
|
+
return _join(qualifiers, self.sort)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class PullRequestFilters(SearchFilters):
|
|
75
|
+
"""Filters for `github-pull-requests` automations."""
|
|
76
|
+
|
|
77
|
+
draft: bool | None = None
|
|
78
|
+
base: str | None = None
|
|
79
|
+
head: str | None = None
|
|
80
|
+
review: Literal["none", "required", "approved", "changes_requested"] | None = None
|
|
81
|
+
ci_status: Literal["success", "failure", "pending"] | None = None
|
|
82
|
+
linked_issue: bool | None = None
|
|
83
|
+
mergeable: bool | None = None
|
|
84
|
+
merge_state: list[MergeState] | None = None
|
|
85
|
+
|
|
86
|
+
def search_query(self) -> str:
|
|
87
|
+
qualifiers = ["is:pr", *self.shared_qualifiers()]
|
|
88
|
+
if self.draft is not None:
|
|
89
|
+
qualifiers.append(f"draft:{str(self.draft).lower()}")
|
|
90
|
+
if self.base:
|
|
91
|
+
qualifiers.append(_qualifier("base", self.base))
|
|
92
|
+
if self.head:
|
|
93
|
+
qualifiers.append(_qualifier("head", self.head))
|
|
94
|
+
if self.review:
|
|
95
|
+
qualifiers.append(f"review:{self.review}")
|
|
96
|
+
if self.ci_status:
|
|
97
|
+
qualifiers.append(f"status:{self.ci_status}")
|
|
98
|
+
if self.linked_issue is not None:
|
|
99
|
+
qualifiers.append("linked:issue" if self.linked_issue else "-linked:issue")
|
|
100
|
+
return _join(qualifiers, self.sort)
|
|
101
|
+
|
|
102
|
+
def keeps(self, *, mergeable_state: str, merge_state_status: str) -> bool:
|
|
103
|
+
"""Apply the GraphQL post-filters that Search cannot express."""
|
|
104
|
+
if self.mergeable is not None:
|
|
105
|
+
expected = "MERGEABLE" if self.mergeable else "CONFLICTING"
|
|
106
|
+
if mergeable_state != expected:
|
|
107
|
+
return False
|
|
108
|
+
return not self.merge_state or merge_state_status in self.merge_state
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
_STATE_QUALIFIERS: dict[str, str] = {"open": "is:open", "closed": "is:closed", "all": ""}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _join(qualifiers: list[str], sort: str | None) -> str:
|
|
115
|
+
"""Join qualifiers in a stable order, appending the sort qualifier last."""
|
|
116
|
+
parts = [part for part in qualifiers if part]
|
|
117
|
+
parts.append(f"sort:{sort or 'created-asc'}")
|
|
118
|
+
return " ".join(parts)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _assignee_qualifiers(assignee: str | None) -> list[str]:
|
|
122
|
+
if assignee is None:
|
|
123
|
+
return []
|
|
124
|
+
if assignee == "none":
|
|
125
|
+
return ["no:assignee"]
|
|
126
|
+
if assignee == "any":
|
|
127
|
+
return ["assignee:*"]
|
|
128
|
+
return [_qualifier("assignee", assignee)]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _qualifier(name: str, value: str) -> str:
|
|
132
|
+
"""Render `name:value`, quoting values that contain whitespace or `"`, `:`, `,`."""
|
|
133
|
+
if any(char in value for char in ' \t\n":,'):
|
|
134
|
+
escaped = value.replace('"', '\\"')
|
|
135
|
+
return f'{name}:"{escaped}"'
|
|
136
|
+
return f"{name}:{value}"
|
|
@@ -0,0 +1,110 @@
|
|
|
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 IssueNode(_GraphQLModel):
|
|
30
|
+
id: str
|
|
31
|
+
number: int
|
|
32
|
+
title: str
|
|
33
|
+
body: str | None = None
|
|
34
|
+
url: str
|
|
35
|
+
state: str
|
|
36
|
+
author: Actor | None = None
|
|
37
|
+
labels: LabelConnection = Field(default_factory=LabelConnection)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class PullRequestNode(IssueNode):
|
|
41
|
+
head_ref_name: str
|
|
42
|
+
base_ref_name: str
|
|
43
|
+
head_ref_oid: str
|
|
44
|
+
is_draft: bool
|
|
45
|
+
mergeable: str
|
|
46
|
+
merge_state_status: str
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class PageInfo(_GraphQLModel):
|
|
50
|
+
has_next_page: bool
|
|
51
|
+
end_cursor: str | None = None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class SearchConnection(_GraphQLModel):
|
|
55
|
+
page_info: PageInfo
|
|
56
|
+
nodes: list[Any] = Field(default_factory=list)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class SearchData(_GraphQLModel):
|
|
60
|
+
search: SearchConnection
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class GraphQLErrorEntry(_GraphQLModel):
|
|
64
|
+
message: str
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class SearchResponse(_GraphQLModel):
|
|
68
|
+
data: SearchData | None = None
|
|
69
|
+
errors: list[GraphQLErrorEntry] = Field(default_factory=list)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class IssueItem(BaseModel):
|
|
73
|
+
"""Issue task item; field names match the `${issue_*}` prompt placeholders."""
|
|
74
|
+
|
|
75
|
+
model_config = ConfigDict(frozen=True)
|
|
76
|
+
|
|
77
|
+
id: str
|
|
78
|
+
title: str
|
|
79
|
+
url: str
|
|
80
|
+
repo: str
|
|
81
|
+
issue_number: int
|
|
82
|
+
issue_title: str
|
|
83
|
+
issue_body: str
|
|
84
|
+
issue_url: str
|
|
85
|
+
issue_state: str
|
|
86
|
+
issue_author: str
|
|
87
|
+
issue_labels: list[str]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class PullRequestItem(BaseModel):
|
|
91
|
+
"""Pull-request task item; field names match the `${pull_request_*}` prompt placeholders."""
|
|
92
|
+
|
|
93
|
+
model_config = ConfigDict(frozen=True)
|
|
94
|
+
|
|
95
|
+
id: str
|
|
96
|
+
title: str
|
|
97
|
+
url: str
|
|
98
|
+
repo: str
|
|
99
|
+
pull_request_number: int
|
|
100
|
+
pull_request_title: str
|
|
101
|
+
pull_request_body: str
|
|
102
|
+
pull_request_url: str
|
|
103
|
+
pull_request_is_draft: bool
|
|
104
|
+
pull_request_head_ref: str
|
|
105
|
+
pull_request_base_ref: str
|
|
106
|
+
pull_request_head_sha: str
|
|
107
|
+
pull_request_author: str
|
|
108
|
+
pull_request_labels: list[str]
|
|
109
|
+
pull_request_mergeable: str
|
|
110
|
+
pull_request_merge_state_status: str
|
|
@@ -0,0 +1,189 @@
|
|
|
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
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from curupira_sdk import Curupira, RpcError
|
|
11
|
+
from pydantic import ValidationError
|
|
12
|
+
|
|
13
|
+
from curupira_plugins.github.client import Discovery, GitHubClient, GitHubCliToken, parse_nodes
|
|
14
|
+
from curupira_plugins.github.filters import IssueFilters, PullRequestFilters
|
|
15
|
+
from curupira_plugins.github.models import IssueItem, IssueNode, PullRequestItem, PullRequestNode
|
|
16
|
+
|
|
17
|
+
_CLIENT = GitHubClient(GitHubCliToken())
|
|
18
|
+
# One discovery feed per distinct filter set, kept for the lifetime of this process.
|
|
19
|
+
_FEEDS: dict[str, Discovery[Any, Any]] = {}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _validation_message(exc: ValidationError) -> str:
|
|
23
|
+
return "; ".join(
|
|
24
|
+
f"{'.'.join(str(part) for part in error['loc'])}: {error['msg']}" for error in exc.errors()
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _check_issue_filters(kwargs: dict[str, object]) -> None:
|
|
29
|
+
try:
|
|
30
|
+
IssueFilters(**kwargs) # type: ignore[arg-type]
|
|
31
|
+
except ValidationError as exc:
|
|
32
|
+
raise RpcError(-32003, _validation_message(exc)) from exc
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _check_pull_request_filters(kwargs: dict[str, object]) -> None:
|
|
36
|
+
try:
|
|
37
|
+
PullRequestFilters(**kwargs) # type: ignore[arg-type]
|
|
38
|
+
except ValidationError as exc:
|
|
39
|
+
raise RpcError(-32003, _validation_message(exc)) from exc
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _issue_item(repo: str, node: IssueNode) -> IssueItem:
|
|
43
|
+
return IssueItem(
|
|
44
|
+
id=str(node.number),
|
|
45
|
+
title=node.title,
|
|
46
|
+
url=node.url,
|
|
47
|
+
repo=repo,
|
|
48
|
+
issue_number=node.number,
|
|
49
|
+
issue_title=node.title,
|
|
50
|
+
issue_body=node.body or "",
|
|
51
|
+
issue_url=node.url,
|
|
52
|
+
issue_state=node.state,
|
|
53
|
+
issue_author=node.author.login if node.author else "",
|
|
54
|
+
issue_labels=[label.name for label in node.labels.nodes],
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _pull_request_item(repo: str, node: PullRequestNode) -> PullRequestItem:
|
|
59
|
+
return PullRequestItem(
|
|
60
|
+
id=str(node.number),
|
|
61
|
+
title=node.title,
|
|
62
|
+
url=node.url,
|
|
63
|
+
repo=repo,
|
|
64
|
+
pull_request_number=node.number,
|
|
65
|
+
pull_request_title=node.title,
|
|
66
|
+
pull_request_body=node.body or "",
|
|
67
|
+
pull_request_url=node.url,
|
|
68
|
+
pull_request_is_draft=node.is_draft,
|
|
69
|
+
pull_request_head_ref=node.head_ref_name,
|
|
70
|
+
pull_request_base_ref=node.base_ref_name,
|
|
71
|
+
pull_request_head_sha=node.head_ref_oid,
|
|
72
|
+
pull_request_author=node.author.login if node.author else "",
|
|
73
|
+
pull_request_labels=[label.name for label in node.labels.nodes],
|
|
74
|
+
pull_request_mergeable=node.mergeable,
|
|
75
|
+
pull_request_merge_state_status=node.merge_state_status,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _issue_feed(filters: IssueFilters) -> Discovery[IssueNode, IssueItem]:
|
|
80
|
+
key = f"issue:{filters.model_dump_json()}"
|
|
81
|
+
feed = _FEEDS.get(key)
|
|
82
|
+
if feed is None:
|
|
83
|
+
query = filters.search_query()
|
|
84
|
+
feed = Discovery(
|
|
85
|
+
fetch_page=lambda after: _CLIENT.search(query, after=after),
|
|
86
|
+
parse_page=lambda connection: parse_nodes(connection, IssueNode),
|
|
87
|
+
keep=lambda node: True,
|
|
88
|
+
to_item=lambda node: _issue_item(filters.repo, node),
|
|
89
|
+
)
|
|
90
|
+
_FEEDS[key] = feed
|
|
91
|
+
return feed
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _pull_request_feed(
|
|
95
|
+
filters: PullRequestFilters,
|
|
96
|
+
) -> Discovery[PullRequestNode, PullRequestItem]:
|
|
97
|
+
key = f"pull_request:{filters.model_dump_json()}"
|
|
98
|
+
feed = _FEEDS.get(key)
|
|
99
|
+
if feed is None:
|
|
100
|
+
query = filters.search_query()
|
|
101
|
+
feed = Discovery(
|
|
102
|
+
fetch_page=lambda after: _CLIENT.search(query, after=after),
|
|
103
|
+
parse_page=lambda connection: parse_nodes(connection, PullRequestNode),
|
|
104
|
+
keep=lambda node: filters.keeps(
|
|
105
|
+
mergeable_state=node.mergeable,
|
|
106
|
+
merge_state_status=node.merge_state_status,
|
|
107
|
+
),
|
|
108
|
+
to_item=lambda node: _pull_request_item(filters.repo, node),
|
|
109
|
+
)
|
|
110
|
+
_FEEDS[key] = feed
|
|
111
|
+
return feed
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def github_issues(
|
|
115
|
+
repo: str,
|
|
116
|
+
labels: list[str] | None = None,
|
|
117
|
+
exclude_labels: list[str] | None = None,
|
|
118
|
+
state: str = "open",
|
|
119
|
+
assignee: str | None = None,
|
|
120
|
+
author: str | None = None,
|
|
121
|
+
milestone: str | None = None,
|
|
122
|
+
project: str | None = None,
|
|
123
|
+
linked_pull_request: bool | None = None,
|
|
124
|
+
sort: str | None = None,
|
|
125
|
+
) -> IssueItem | None:
|
|
126
|
+
"""Return the next eligible GitHub issue, or None when none are available."""
|
|
127
|
+
filters = IssueFilters(
|
|
128
|
+
repo=repo,
|
|
129
|
+
labels=labels,
|
|
130
|
+
exclude_labels=exclude_labels,
|
|
131
|
+
state=state, # type: ignore[arg-type]
|
|
132
|
+
assignee=assignee,
|
|
133
|
+
author=author,
|
|
134
|
+
milestone=milestone,
|
|
135
|
+
project=project,
|
|
136
|
+
linked_pull_request=linked_pull_request,
|
|
137
|
+
sort=sort, # type: ignore[arg-type]
|
|
138
|
+
)
|
|
139
|
+
return _issue_feed(filters).next_item()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def github_pull_requests(
|
|
143
|
+
repo: str,
|
|
144
|
+
labels: list[str] | None = None,
|
|
145
|
+
exclude_labels: list[str] | None = None,
|
|
146
|
+
state: str = "open",
|
|
147
|
+
assignee: str | None = None,
|
|
148
|
+
author: str | None = None,
|
|
149
|
+
milestone: str | None = None,
|
|
150
|
+
project: str | None = None,
|
|
151
|
+
draft: bool | None = None,
|
|
152
|
+
base: str | None = None,
|
|
153
|
+
head: str | None = None,
|
|
154
|
+
review: str | None = None,
|
|
155
|
+
ci_status: str | None = None,
|
|
156
|
+
linked_issue: bool | None = None,
|
|
157
|
+
mergeable: bool | None = None,
|
|
158
|
+
merge_state: list[str] | None = None,
|
|
159
|
+
sort: str | None = None,
|
|
160
|
+
) -> PullRequestItem | None:
|
|
161
|
+
"""Return the next eligible GitHub pull request, or None when none are available."""
|
|
162
|
+
filters = PullRequestFilters(
|
|
163
|
+
repo=repo,
|
|
164
|
+
labels=labels,
|
|
165
|
+
exclude_labels=exclude_labels,
|
|
166
|
+
state=state, # type: ignore[arg-type]
|
|
167
|
+
assignee=assignee,
|
|
168
|
+
author=author,
|
|
169
|
+
milestone=milestone,
|
|
170
|
+
project=project,
|
|
171
|
+
draft=draft,
|
|
172
|
+
base=base,
|
|
173
|
+
head=head,
|
|
174
|
+
review=review, # type: ignore[arg-type]
|
|
175
|
+
ci_status=ci_status, # type: ignore[arg-type]
|
|
176
|
+
linked_issue=linked_issue,
|
|
177
|
+
mergeable=mergeable,
|
|
178
|
+
merge_state=merge_state, # type: ignore[arg-type]
|
|
179
|
+
sort=sort, # type: ignore[arg-type]
|
|
180
|
+
)
|
|
181
|
+
return _pull_request_feed(filters).next_item()
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def register(app: Curupira) -> None:
|
|
185
|
+
"""Register GitHub triggers on ``app``."""
|
|
186
|
+
app.trigger(github_issues)
|
|
187
|
+
app.trigger(github_pull_requests)
|
|
188
|
+
app.param_check("github-issues", _check_issue_filters)
|
|
189
|
+
app.param_check("github-pull-requests", _check_pull_request_filters)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""monday.com item discovery."""
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Official monday.com items trigger."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from curupira_sdk import Curupira
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class MondayItem:
|
|
12
|
+
id: str
|
|
13
|
+
name: str
|
|
14
|
+
url: str
|
|
15
|
+
board_id: str = ""
|
|
16
|
+
group_title: str = ""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def register(app: Curupira) -> None:
|
|
20
|
+
"""Register monday.com triggers on ``app``."""
|
|
21
|
+
|
|
22
|
+
@app.trigger
|
|
23
|
+
async def monday_items(
|
|
24
|
+
board_id: str,
|
|
25
|
+
group_ids: list[str] | None = None,
|
|
26
|
+
token_env: str | None = None,
|
|
27
|
+
) -> MondayItem | None:
|
|
28
|
+
"""Return the next eligible monday.com item, or None when none are available."""
|
|
29
|
+
del board_id, group_ids, token_env
|
|
30
|
+
return None
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Trello card discovery."""
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Official Trello card trigger."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from curupira_sdk import Curupira
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class Card:
|
|
12
|
+
id: str
|
|
13
|
+
name: str
|
|
14
|
+
url: str
|
|
15
|
+
desc: str = ""
|
|
16
|
+
list_name: str = ""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def register(app: Curupira) -> None:
|
|
20
|
+
"""Register Trello triggers on ``app``."""
|
|
21
|
+
|
|
22
|
+
@app.trigger
|
|
23
|
+
def trello_cli_cards(
|
|
24
|
+
board_id: str,
|
|
25
|
+
list_ids: list[str] | None = None,
|
|
26
|
+
) -> Card | None:
|
|
27
|
+
"""Return the next eligible Trello card, or None when none are available."""
|
|
28
|
+
del board_id, list_ids
|
|
29
|
+
return None
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: curupira-plugins
|
|
3
|
+
Version: 0.1.0.dev0
|
|
4
|
+
Summary: Official Curupira trigger extensions (GitHub, Azure, Trello, monday.com)
|
|
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,17 @@
|
|
|
1
|
+
curupira_plugins/__init__.py,sha256=q4tVccbhzGovBhKCP5mwz1jhfzhMlr8t3ROKXm_NT6U,245
|
|
2
|
+
curupira_plugins/__main__.py,sha256=QPOXjh-dVCCHaJSOJ8ifVbADkXkwPBMTBa58KVHqOrM,1464
|
|
3
|
+
curupira_plugins/azure/__init__.py,sha256=2w6cJekJjOdQb09Yw1-g1DwoshG22XcQFoiMrvjf1so,43
|
|
4
|
+
curupira_plugins/azure/triggers.py,sha256=3okzpy8OXkTvkirJxK_sIMjHbbI6pMtORi-tB0Tawe0,1677
|
|
5
|
+
curupira_plugins/github/__init__.py,sha256=ryXNkmrBCwwigyt0qzpwdkzwYdPlabvTimlkuPFxaL0,48
|
|
6
|
+
curupira_plugins/github/client.py,sha256=RMuI0PIAqo2SNzKQgyACBgduN0TFyaMgoaTSGhy_QOI,7419
|
|
7
|
+
curupira_plugins/github/filters.py,sha256=FyDb2Db6UcESxX61sxuHQduexnghwB8DT83IguLEIaA,4953
|
|
8
|
+
curupira_plugins/github/models.py,sha256=Hk4_XVBxuV9sDjI-7DrBC7KWrLzf6IWniu3wcR04Yms,2497
|
|
9
|
+
curupira_plugins/github/triggers.py,sha256=0eLmUH1kLz48c32raO4FDZ_D1wBFtzRfGCG9t-c2Arg,6441
|
|
10
|
+
curupira_plugins/monday/__init__.py,sha256=Rv10WVHt8xoy49aBqu305__VkEnzaL7ZjyIVYJXXjQg,32
|
|
11
|
+
curupira_plugins/monday/triggers.py,sha256=-BICinZBgy9emPcxCRz5KQSblkRKriIeWfQq5l85gFY,687
|
|
12
|
+
curupira_plugins/trello/__init__.py,sha256=KeRfcHvQFI1fqko_GXMowI3UHmqQzDuwlRN2Ol8M-V4,29
|
|
13
|
+
curupira_plugins/trello/triggers.py,sha256=9W6PPbBIMlLKi0FyU-Dx5k6IgOvezatOEFMrYFbDo4g,603
|
|
14
|
+
curupira_plugins-0.1.0.dev0.dist-info/METADATA,sha256=nmMaQiLShcNuznwt93dTDeF0QA6ZUa03oOTIgxgBbQA,375
|
|
15
|
+
curupira_plugins-0.1.0.dev0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
16
|
+
curupira_plugins-0.1.0.dev0.dist-info/entry_points.txt,sha256=65ycZitgY2NoI1K8vepqnHghBab4F4X-TX-9pJoYlZw,68
|
|
17
|
+
curupira_plugins-0.1.0.dev0.dist-info/RECORD,,
|