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.
@@ -0,0 +1,6 @@
1
+ """Official Curupira trigger extensions (GitHub, Azure, Trello, monday.com)."""
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,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,,
@@ -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