curupira-plugins 0.1.0.dev0__tar.gz

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,190 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Local curupira configuration and runtime state
40
+ /*-dispatch.toml
41
+ *.sqlite3
42
+ *.sqlite3-journal
43
+ workspaces/
44
+
45
+ # Unit test / coverage reports
46
+ htmlcov/
47
+ .tox/
48
+ .nox/
49
+ .coverage
50
+ .coverage.*
51
+ .cache
52
+ nosetests.xml
53
+ coverage.xml
54
+ *.cover
55
+ *.py,cover
56
+ .hypothesis/
57
+ .pytest_cache/
58
+ cover/
59
+
60
+ # Translations
61
+ *.mo
62
+ *.pot
63
+
64
+ # Django stuff:
65
+ *.log
66
+ local_settings.py
67
+ db.sqlite3
68
+ db.sqlite3-journal
69
+
70
+ # Flask stuff:
71
+ instance/
72
+ .webassets-cache
73
+
74
+ # Scrapy stuff:
75
+ .scrapy
76
+
77
+ # Sphinx documentation
78
+ docs/_build/
79
+
80
+ # PyBuilder
81
+ .pybuilder/
82
+ target/
83
+
84
+ # Jupyter Notebook
85
+ .ipynb_checkpoints
86
+
87
+ # IPython
88
+ profile_default/
89
+ ipython_config.py
90
+
91
+ # pyenv
92
+ # For a library or package, you might want to ignore these files since the code is
93
+ # intended to run in multiple environments; otherwise, check them in:
94
+ # .python-version
95
+
96
+ # pipenv
97
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
98
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
99
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
100
+ # install all needed dependencies.
101
+ #Pipfile.lock
102
+
103
+ # UV
104
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
105
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
106
+ # commonly ignored for libraries.
107
+ #uv.lock
108
+
109
+ # poetry
110
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
111
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
112
+ # commonly ignored for libraries.
113
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
114
+ #poetry.lock
115
+
116
+ # pdm
117
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
118
+ #pdm.lock
119
+ # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
120
+ # in version control.
121
+ # https://pdm.fming.dev/latest/usage/project/#working-with-version-control
122
+ .pdm.toml
123
+ .pdm-python
124
+ .pdm-build/
125
+
126
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
127
+ __pypackages__/
128
+
129
+ # Celery stuff
130
+ celerybeat-schedule
131
+ celerybeat.pid
132
+
133
+ # SageMath parsed files
134
+ *.sage.py
135
+
136
+ # Environments
137
+ .env
138
+ .venv
139
+ env/
140
+ venv/
141
+ ENV/
142
+ env.bak/
143
+ venv.bak/
144
+
145
+ # Spyder project settings
146
+ .spyderproject
147
+ .spyproject
148
+
149
+ # Rope project settings
150
+ .ropeproject
151
+
152
+ # mkdocs documentation
153
+ /site
154
+
155
+ # mypy
156
+ .mypy_cache/
157
+ .dmypy.json
158
+ dmypy.json
159
+
160
+ # Pyre type checker
161
+ .pyre/
162
+
163
+ # pytype static type analyzer
164
+ .pytype/
165
+
166
+ # Cython debug symbols
167
+ cython_debug/
168
+
169
+ # PyCharm
170
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
171
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
172
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
173
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
174
+ #.idea/
175
+
176
+ # Ruff stuff:
177
+ .ruff_cache/
178
+
179
+ # Pyrefly stuff:
180
+ .pyrefly/
181
+
182
+ # PyPI configuration file
183
+ .pypirc
184
+
185
+ # Cursor
186
+ # Cursor is an AI-powered code editor.`.cursorignore` specifies files/directories to
187
+ # exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
188
+ # refer to https://docs.cursor.com/context/ignore-files
189
+ .cursorignore
190
+ .cursorindexingignore
@@ -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,23 @@
1
+ manifest_version = 1
2
+ name = "curupira-plugins"
3
+ version = "0.1.0.dev0"
4
+ protocol_version = 1
5
+ runtime = "python"
6
+ kind = "trigger"
7
+ trigger_types = [
8
+ "github-issues",
9
+ "github-pull-requests",
10
+ "azure-cli-pull-requests",
11
+ "trello-cli-cards",
12
+ "monday-items",
13
+ ]
14
+
15
+ [launch]
16
+ type = "executable"
17
+ program = "python3"
18
+ args = ["-m", "curupira_plugins"]
19
+
20
+ [package]
21
+ source = "bundled"
22
+ name = "curupira-plugins"
23
+ version = "0.1.0.dev0"
@@ -0,0 +1,30 @@
1
+ [project]
2
+ name = "curupira-plugins"
3
+ dynamic = ["version"]
4
+ description = "Official Curupira trigger extensions (GitHub, Azure, Trello, monday.com)"
5
+ requires-python = ">=3.11"
6
+ license = "MIT"
7
+ # Pre-releases need an explicit pre-release specifier, or pip ignores them.
8
+ dependencies = ["curupira-sdk[pydantic]>=0.1.0.dev0"]
9
+
10
+ [project.scripts]
11
+ curupira-plugins = "curupira_plugins.__main__:main"
12
+
13
+ [project.urls]
14
+ Homepage = "https://github.com/caipora-labs/curupira"
15
+ Repository = "https://github.com/caipora-labs/curupira"
16
+
17
+ [build-system]
18
+ requires = ["hatchling>=1.32.4,<2", "hatch-vcs>=0.5,<1"]
19
+ build-backend = "hatchling.build"
20
+
21
+ # The version is the publish/curupira-plugins/<version> git tag (see curupira-sdk).
22
+ [tool.hatch.version]
23
+ source = "vcs"
24
+ raw-options = { root = "../..", tag_regex = "^publish/curupira-plugins/(?P<version>.+)$", git_describe_command = "git describe --tags --long --match publish/curupira-plugins/*", fallback_version = "0.1.0.dev0" }
25
+
26
+ [tool.uv.sources]
27
+ curupira-sdk = { workspace = true }
28
+
29
+ [tool.hatch.build.targets.wheel]
30
+ packages = ["src/curupira_plugins"]
@@ -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