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