hidden-moves-github-projects 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hidden_moves_github_projects/__init__.py +38 -0
- hidden_moves_github_projects/__main__.py +6 -0
- hidden_moves_github_projects/_normalize.py +204 -0
- hidden_moves_github_projects/_queries.py +69 -0
- hidden_moves_github_projects/client.py +187 -0
- hidden_moves_github_projects/errors.py +38 -0
- hidden_moves_github_projects/host.py +70 -0
- hidden_moves_github_projects/integration.py +43 -0
- hidden_moves_github_projects/models.py +130 -0
- hidden_moves_github_projects/snapshots.py +122 -0
- hidden_moves_github_projects/transport.py +32 -0
- hidden_moves_github_projects/write_host.py +69 -0
- hidden_moves_github_projects/write_integration.py +53 -0
- hidden_moves_github_projects/writes.py +327 -0
- hidden_moves_github_projects-0.1.0.dist-info/METADATA +193 -0
- hidden_moves_github_projects-0.1.0.dist-info/RECORD +19 -0
- hidden_moves_github_projects-0.1.0.dist-info/WHEEL +4 -0
- hidden_moves_github_projects-0.1.0.dist-info/entry_points.txt +8 -0
- hidden_moves_github_projects-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
"""Two fixed mutations, guarded by trusted local Project authorization."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
from dataclasses import dataclass, replace
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from . import _normalize as normalize
|
|
8
|
+
from .client import ProjectsClient, _count, _text
|
|
9
|
+
from .errors import CredentialError, PaginationLimitError, ProjectsError, ProtocolError
|
|
10
|
+
from .models import ProjectField, SelectOption
|
|
11
|
+
|
|
12
|
+
CONTENT_QUERY = """query WritableContent($id: ID!) {
|
|
13
|
+
node(id: $id) { __typename id }
|
|
14
|
+
}"""
|
|
15
|
+
MEMBERSHIP_QUERY = """query ProjectItemMembership($id: ID!) {
|
|
16
|
+
node(id: $id) { __typename ... on ProjectV2Item {
|
|
17
|
+
id project { id } content { __typename ... on Issue { id } ... on PullRequest { id } }
|
|
18
|
+
} }
|
|
19
|
+
}"""
|
|
20
|
+
SELECTION_QUERY = """query SingleSelectValue($id: ID!, $name: String!) {
|
|
21
|
+
node(id: $id) { __typename ... on ProjectV2Item {
|
|
22
|
+
id project { id } fieldValueByName(name: $name) {
|
|
23
|
+
__typename ... on ProjectV2ItemFieldSingleSelectValue {
|
|
24
|
+
optionId field { ... on ProjectV2SingleSelectField { id } }
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
} }
|
|
28
|
+
}"""
|
|
29
|
+
ADD_MUTATION = """mutation AddProjectItem($project: ID!, $content: ID!) {
|
|
30
|
+
addProjectV2ItemById(input: {projectId: $project, contentId: $content}) { item { id } }
|
|
31
|
+
}"""
|
|
32
|
+
SET_MUTATION = """mutation SetSingleSelect($project: ID!, $item: ID!, $field: ID!, $option: String!) {
|
|
33
|
+
updateProjectV2ItemFieldValue(input: {projectId: $project, itemId: $item, fieldId: $field,
|
|
34
|
+
value: {singleSelectOptionId: $option}}) { projectV2Item { id } }
|
|
35
|
+
}"""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ProjectAuthorizationError(ProjectsError):
|
|
39
|
+
"""The configured allowlist, content type or item membership denies the write."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class SelectionConflictError(ProjectsError):
|
|
43
|
+
"""The single-select value changed before the preflight check."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class WriteReceipt:
|
|
48
|
+
operation: Literal["add_item", "set_single_select"]
|
|
49
|
+
project_id: str
|
|
50
|
+
item_id: str | None
|
|
51
|
+
content_id: str | None
|
|
52
|
+
field_id: str | None
|
|
53
|
+
option_id: str | None
|
|
54
|
+
verified: bool
|
|
55
|
+
acknowledged: bool = False
|
|
56
|
+
diagnostic: str | None = None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class SingleSelectState:
|
|
61
|
+
project_id: str
|
|
62
|
+
item_id: str
|
|
63
|
+
field_id: str
|
|
64
|
+
current_option_id: str | None
|
|
65
|
+
options: list[SelectOption]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class WriteOutcomeUnknownError(ProjectsError):
|
|
69
|
+
"""A submitted mutation may have taken effect; inspect before any retry."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, receipt: WriteReceipt, *, acknowledged: bool, cause_type: str):
|
|
72
|
+
self.receipt = replace(
|
|
73
|
+
receipt, acknowledged=acknowledged, diagnostic=cause_type
|
|
74
|
+
)
|
|
75
|
+
self.acknowledged = acknowledged
|
|
76
|
+
self.cause_type = cause_type
|
|
77
|
+
super().__init__(
|
|
78
|
+
"mutation outcome is unverified; inspect the Project before retrying"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class PostWriteVerificationError(WriteOutcomeUnknownError):
|
|
83
|
+
"""GitHub acknowledged the mutation but its post-write read did not verify it."""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _opaque(value: str, label: str) -> str:
|
|
87
|
+
value = _text(value, label)
|
|
88
|
+
if len(value) > 1024 or any(char.isspace() for char in value):
|
|
89
|
+
raise ValueError(f"{label} must be a bounded opaque ID")
|
|
90
|
+
return value
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class ProjectsWriter:
|
|
94
|
+
"""Trusted configuration authorizes Projects; tool input cannot add permission."""
|
|
95
|
+
|
|
96
|
+
def __init__(
|
|
97
|
+
self,
|
|
98
|
+
reader: ProjectsClient,
|
|
99
|
+
*,
|
|
100
|
+
allowed_projects: Iterable[str],
|
|
101
|
+
max_field_pages: int = 5,
|
|
102
|
+
):
|
|
103
|
+
if not isinstance(reader, ProjectsClient):
|
|
104
|
+
raise TypeError("reader must be an explicitly configured ProjectsClient")
|
|
105
|
+
if isinstance(allowed_projects, (str, bytes)):
|
|
106
|
+
raise ValueError("allowed_projects must be an explicit collection of IDs")
|
|
107
|
+
projects = set()
|
|
108
|
+
for index, project_id in enumerate(allowed_projects):
|
|
109
|
+
if index >= 64:
|
|
110
|
+
raise ValueError("authorize at most 64 Projects per local writer")
|
|
111
|
+
projects.add(_opaque(project_id, "project_id"))
|
|
112
|
+
if not projects:
|
|
113
|
+
raise ValueError("authorize at least one explicit Project")
|
|
114
|
+
self._reader = reader
|
|
115
|
+
self._allowed_projects = frozenset(projects)
|
|
116
|
+
self._max_field_pages = _count(max_field_pages, "max_field_pages", 100)
|
|
117
|
+
|
|
118
|
+
def __repr__(self) -> str:
|
|
119
|
+
return "ProjectsWriter()"
|
|
120
|
+
|
|
121
|
+
def _authorize(self, project_id: str) -> None:
|
|
122
|
+
_opaque(project_id, "project_id")
|
|
123
|
+
if project_id not in self._allowed_projects:
|
|
124
|
+
raise ProjectAuthorizationError(
|
|
125
|
+
"Project is outside the configured write allowlist"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
def _member(self, node: dict, project_id: str, item_id: str) -> None:
|
|
129
|
+
if (
|
|
130
|
+
normalize.text(node.get("id")) != item_id
|
|
131
|
+
or normalize.text(normalize.obj(node.get("project")).get("id"))
|
|
132
|
+
!= project_id
|
|
133
|
+
):
|
|
134
|
+
raise ProjectAuthorizationError(
|
|
135
|
+
"item does not belong to the authorized Project"
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
def _field(self, project_id: str, name: str) -> ProjectField:
|
|
139
|
+
_text(name, "field_name")
|
|
140
|
+
found, cursor, seen = [], None, set()
|
|
141
|
+
for _ in range(self._max_field_pages):
|
|
142
|
+
page = self._reader.fields(project_id, cursor=cursor, page_size=100)
|
|
143
|
+
found.extend(field for field in page.fields if field.name == name)
|
|
144
|
+
if not page.page_info.has_next_page:
|
|
145
|
+
break
|
|
146
|
+
cursor = page.page_info.end_cursor
|
|
147
|
+
if cursor in seen:
|
|
148
|
+
raise ProtocolError("GitHub repeated a field-definition cursor")
|
|
149
|
+
seen.add(cursor)
|
|
150
|
+
else:
|
|
151
|
+
raise PaginationLimitError(
|
|
152
|
+
"field-definition budget ended before the connection"
|
|
153
|
+
)
|
|
154
|
+
if (
|
|
155
|
+
len(found) != 1
|
|
156
|
+
or found[0].graphql_type != "ProjectV2SingleSelectField"
|
|
157
|
+
or found[0].id is None
|
|
158
|
+
):
|
|
159
|
+
raise ValueError(
|
|
160
|
+
"field_name must identify one accessible single-select field"
|
|
161
|
+
)
|
|
162
|
+
_opaque(found[0].id, "field_id")
|
|
163
|
+
return found[0]
|
|
164
|
+
|
|
165
|
+
def _selection(
|
|
166
|
+
self, project_id: str, item_id: str, field: ProjectField
|
|
167
|
+
) -> str | None:
|
|
168
|
+
data = self._reader._execute(
|
|
169
|
+
SELECTION_QUERY, {"id": item_id, "name": field.name}
|
|
170
|
+
)
|
|
171
|
+
node = normalize.node(data, "ProjectV2Item")
|
|
172
|
+
self._member(node, project_id, item_id)
|
|
173
|
+
value = node.get("fieldValueByName")
|
|
174
|
+
if value is None:
|
|
175
|
+
return None
|
|
176
|
+
value = normalize.obj(value)
|
|
177
|
+
if (
|
|
178
|
+
value.get("__typename") != "ProjectV2ItemFieldSingleSelectValue"
|
|
179
|
+
or normalize.obj(value.get("field")).get("id") != field.id
|
|
180
|
+
):
|
|
181
|
+
raise ProtocolError(
|
|
182
|
+
"single-select lookup returned a different field or value type"
|
|
183
|
+
)
|
|
184
|
+
return _opaque(normalize.text(value.get("optionId")), "option_id")
|
|
185
|
+
|
|
186
|
+
def inspect_single_select(
|
|
187
|
+
self, project_id: str, item_id: str, field_name: str
|
|
188
|
+
) -> SingleSelectState:
|
|
189
|
+
"""Read the authorized item value and dynamically resolved choices."""
|
|
190
|
+
self._authorize(project_id)
|
|
191
|
+
_opaque(item_id, "item_id")
|
|
192
|
+
field = self._field(project_id, field_name)
|
|
193
|
+
return SingleSelectState(
|
|
194
|
+
project_id,
|
|
195
|
+
item_id,
|
|
196
|
+
field.id,
|
|
197
|
+
self._selection(project_id, item_id, field),
|
|
198
|
+
field.options,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
def _mutate(self, query: str, variables: dict, receipt: WriteReceipt) -> dict:
|
|
202
|
+
try:
|
|
203
|
+
return self._reader._execute(query, variables)
|
|
204
|
+
except CredentialError:
|
|
205
|
+
raise
|
|
206
|
+
except ProjectsError as error:
|
|
207
|
+
# Never retry automatically: transport/GraphQL errors can follow a completed effect.
|
|
208
|
+
raise WriteOutcomeUnknownError(
|
|
209
|
+
receipt, acknowledged=False, cause_type=type(error).__name__
|
|
210
|
+
) from None
|
|
211
|
+
|
|
212
|
+
def add_item(self, project_id: str, content_id: str) -> WriteReceipt:
|
|
213
|
+
"""Add one existing Issue/PR and verify returned identity and membership."""
|
|
214
|
+
self._authorize(project_id)
|
|
215
|
+
_opaque(content_id, "content_id")
|
|
216
|
+
self._reader.fields(
|
|
217
|
+
project_id, page_size=1
|
|
218
|
+
) # also verifies Project read access/type
|
|
219
|
+
content = normalize.obj(
|
|
220
|
+
self._reader._execute(CONTENT_QUERY, {"id": content_id}).get("node")
|
|
221
|
+
)
|
|
222
|
+
if (
|
|
223
|
+
content.get("__typename") not in ("Issue", "PullRequest")
|
|
224
|
+
or content.get("id") != content_id
|
|
225
|
+
):
|
|
226
|
+
raise ProjectAuthorizationError(
|
|
227
|
+
"content must be an accessible existing Issue or PullRequest"
|
|
228
|
+
)
|
|
229
|
+
receipt = WriteReceipt(
|
|
230
|
+
"add_item", project_id, None, content_id, None, None, False
|
|
231
|
+
)
|
|
232
|
+
data = self._mutate(
|
|
233
|
+
ADD_MUTATION, {"project": project_id, "content": content_id}, receipt
|
|
234
|
+
)
|
|
235
|
+
try:
|
|
236
|
+
item_id = _opaque(
|
|
237
|
+
normalize.text(
|
|
238
|
+
normalize.obj(
|
|
239
|
+
normalize.obj(data.get("addProjectV2ItemById")).get("item")
|
|
240
|
+
).get("id")
|
|
241
|
+
),
|
|
242
|
+
"item_id",
|
|
243
|
+
)
|
|
244
|
+
except (ProjectsError, ValueError) as error:
|
|
245
|
+
raise WriteOutcomeUnknownError(
|
|
246
|
+
receipt, acknowledged=False, cause_type=type(error).__name__
|
|
247
|
+
) from None
|
|
248
|
+
receipt = replace(receipt, item_id=item_id, acknowledged=True)
|
|
249
|
+
try:
|
|
250
|
+
node = normalize.node(
|
|
251
|
+
self._reader._execute(MEMBERSHIP_QUERY, {"id": item_id}),
|
|
252
|
+
"ProjectV2Item",
|
|
253
|
+
)
|
|
254
|
+
self._member(node, project_id, item_id)
|
|
255
|
+
if normalize.obj(node.get("content")).get("id") != content_id:
|
|
256
|
+
raise ProtocolError(
|
|
257
|
+
"added item content did not match the supplied identity"
|
|
258
|
+
)
|
|
259
|
+
except (ProjectsError, ValueError) as error:
|
|
260
|
+
raise PostWriteVerificationError(
|
|
261
|
+
receipt, acknowledged=True, cause_type=type(error).__name__
|
|
262
|
+
) from None
|
|
263
|
+
return replace(receipt, verified=True, acknowledged=True)
|
|
264
|
+
|
|
265
|
+
def set_single_select(
|
|
266
|
+
self,
|
|
267
|
+
project_id: str,
|
|
268
|
+
item_id: str,
|
|
269
|
+
field_name: str,
|
|
270
|
+
option_name: str,
|
|
271
|
+
expected_option_id: str | None,
|
|
272
|
+
) -> WriteReceipt:
|
|
273
|
+
"""Check the expected value, set a dynamically resolved option, then verify."""
|
|
274
|
+
self._authorize(project_id)
|
|
275
|
+
_opaque(item_id, "item_id")
|
|
276
|
+
_text(option_name, "option_name")
|
|
277
|
+
if expected_option_id is not None:
|
|
278
|
+
_opaque(expected_option_id, "expected_option_id")
|
|
279
|
+
field = self._field(project_id, field_name)
|
|
280
|
+
options = [option for option in field.options if option.name == option_name]
|
|
281
|
+
if len(options) != 1:
|
|
282
|
+
raise ValueError(
|
|
283
|
+
"option_name must identify one choice on the resolved field"
|
|
284
|
+
)
|
|
285
|
+
option_id = _opaque(options[0].id, "option_id")
|
|
286
|
+
if self._selection(project_id, item_id, field) != expected_option_id:
|
|
287
|
+
raise SelectionConflictError(
|
|
288
|
+
"single-select value differs from the caller's expected option"
|
|
289
|
+
)
|
|
290
|
+
receipt = WriteReceipt(
|
|
291
|
+
"set_single_select", project_id, item_id, None, field.id, option_id, False
|
|
292
|
+
)
|
|
293
|
+
data = self._mutate(
|
|
294
|
+
SET_MUTATION,
|
|
295
|
+
{
|
|
296
|
+
"project": project_id,
|
|
297
|
+
"item": item_id,
|
|
298
|
+
"field": field.id,
|
|
299
|
+
"option": option_id,
|
|
300
|
+
},
|
|
301
|
+
receipt,
|
|
302
|
+
)
|
|
303
|
+
try:
|
|
304
|
+
returned = normalize.obj(
|
|
305
|
+
normalize.obj(data.get("updateProjectV2ItemFieldValue")).get(
|
|
306
|
+
"projectV2Item"
|
|
307
|
+
)
|
|
308
|
+
)
|
|
309
|
+
if returned.get("id") != item_id:
|
|
310
|
+
raise ProtocolError(
|
|
311
|
+
"mutation response did not identify the requested item"
|
|
312
|
+
)
|
|
313
|
+
except (ProjectsError, ValueError) as error:
|
|
314
|
+
raise WriteOutcomeUnknownError(
|
|
315
|
+
receipt, acknowledged=False, cause_type=type(error).__name__
|
|
316
|
+
) from None
|
|
317
|
+
receipt = replace(receipt, acknowledged=True)
|
|
318
|
+
try:
|
|
319
|
+
if self._selection(project_id, item_id, field) != option_id:
|
|
320
|
+
raise ProtocolError(
|
|
321
|
+
"single-select post-write value did not match the requested choice"
|
|
322
|
+
)
|
|
323
|
+
except (ProjectsError, ValueError) as error:
|
|
324
|
+
raise PostWriteVerificationError(
|
|
325
|
+
receipt, acknowledged=True, cause_type=type(error).__name__
|
|
326
|
+
) from None
|
|
327
|
+
return replace(receipt, verified=True, acknowledged=True)
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hidden-moves-github-projects
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Read and explicitly edit GitHub Projects through typed Python operations.
|
|
5
|
+
Keywords: github,projects,graphql,capabilities
|
|
6
|
+
Author: Ella Inng
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
15
|
+
Requires-Dist: backpack-store>=0.1,<0.2 ; extra == 'backpack'
|
|
16
|
+
Requires-Dist: hidden-moves-mcp>=0.1,<0.2 ; extra == 'host'
|
|
17
|
+
Requires-Dist: hidden-moves>=0.1,<0.2 ; extra == 'moves'
|
|
18
|
+
Maintainer: Outside Labs
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Project-URL: Homepage, https://github.com/outside-labs/hidden_moves
|
|
21
|
+
Project-URL: Source, https://github.com/outside-labs/hidden_moves
|
|
22
|
+
Project-URL: Issues, https://github.com/outside-labs/hidden_moves/issues
|
|
23
|
+
Project-URL: Documentation, https://github.com/outside-labs/hidden_moves/blob/main/packages/hidden-moves-github-projects/README.md
|
|
24
|
+
Provides-Extra: backpack
|
|
25
|
+
Provides-Extra: host
|
|
26
|
+
Provides-Extra: moves
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# GitHub Projects client
|
|
30
|
+
|
|
31
|
+
An experimental GraphQL client with concrete typed records and explicitly
|
|
32
|
+
selected write operations. The ordinary Python library uses only the standard
|
|
33
|
+
library and does not import Hidden Moves, MCP, or an API SDK. Capability integration
|
|
34
|
+
is an optional extra.
|
|
35
|
+
|
|
36
|
+
The PyPI distribution is `hidden-moves-github-projects` and the Python import is
|
|
37
|
+
`hidden_moves_github_projects`. The optional `backpack` extra requires
|
|
38
|
+
`backpack-store>=0.1,<0.2`.
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
python -m pip install hidden-moves-github-projects==0.1.0
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Its release route uses `v0.1.0-github-projects`, `release.yaml`, and environment
|
|
45
|
+
`pypi-github-projects`; see the
|
|
46
|
+
[release guide](https://github.com/outside-labs/hidden_moves/blob/main/docs/PYPI_RELEASES.md).
|
|
47
|
+
|
|
48
|
+
## Ordinary Python
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from hidden_moves_github_projects import ProjectsClient
|
|
52
|
+
|
|
53
|
+
# The application supplies its credential resolver; construction performs no I/O.
|
|
54
|
+
client = ProjectsClient(resolve_token, timeout=10)
|
|
55
|
+
project = client.get("example", "organization", 2)
|
|
56
|
+
fields = client.fields(project.id)
|
|
57
|
+
page = client.items(project.id, page_size=20, field_page_size=50)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`resolve_token` is an application-owned callable returning an authorized token.
|
|
61
|
+
Keep credentials outside source control and tool arguments. A successful Project
|
|
62
|
+
query verifies access; repository access alone does not establish Projects access.
|
|
63
|
+
Classic-token scopes and GitHub App permissions differ. See the
|
|
64
|
+
[GitHub Projects API guide](https://docs.github.com/en/issues/planning-and-tracking-with-projects/automating-your-project/using-the-api-to-manage-projects).
|
|
65
|
+
|
|
66
|
+
## Operations and identities
|
|
67
|
+
|
|
68
|
+
- `list(owner, owner_kind, cursor=None, page_size=50)` returns a Project page.
|
|
69
|
+
- `get(owner, owner_kind, number)` returns a Project record.
|
|
70
|
+
- `fields(project_id, cursor=None, page_size=50)` returns a field-definition page.
|
|
71
|
+
- `items(project_id, cursor=None, page_size=50, field_page_size=50)` returns an item page.
|
|
72
|
+
- `item_field_values(item_id, cursor=None, page_size=50)` continues nested values.
|
|
73
|
+
- `iter_item_pages(project_id, ..., max_pages=10)` explicitly iterates bounded pages.
|
|
74
|
+
|
|
75
|
+
Owner kind is `user` or `organization`. Node IDs are opaque strings. Project number,
|
|
76
|
+
Project node ID, item node ID, content node ID, and issue/PR number are separate
|
|
77
|
+
identities. Item content distinguishes issues, PRs, drafts, redaction, and unknown
|
|
78
|
+
types. Select options retain IDs/names; iteration definitions retain active and
|
|
79
|
+
completed iteration metadata.
|
|
80
|
+
|
|
81
|
+
## Paging and completeness
|
|
82
|
+
|
|
83
|
+
Each requested connection returns `page_info.has_next_page` and `end_cursor`.
|
|
84
|
+
Every page contains at most the requested 1–100 entries. A last page with an input
|
|
85
|
+
cursor still represents only that page. Nested item field values have independent
|
|
86
|
+
page information and `field_values_complete`; continue them with the item ID and
|
|
87
|
+
their own cursor. An iterator that reaches its page budget raises
|
|
88
|
+
`PaginationLimitError` instead of silently claiming to have read the board.
|
|
89
|
+
|
|
90
|
+
Text, number, date, single-select, and iteration values are normalized. Unsupported
|
|
91
|
+
value types remain visible as typed placeholders and diagnostics; their omitted
|
|
92
|
+
payloads make `field_values_complete=False`. Unknown field/content types and null
|
|
93
|
+
nodes also produce diagnostics. Issue assignees, labels, linked PRs, and repository
|
|
94
|
+
connections are not queried. No unbounded nested connection is silently returned.
|
|
95
|
+
|
|
96
|
+
## Transport and errors
|
|
97
|
+
|
|
98
|
+
The default transport makes one HTTPS POST per explicit read, uses a finite socket
|
|
99
|
+
timeout, reads at most a configured byte budget, closes the response, and refuses
|
|
100
|
+
redirects carrying credentials. An explicit HTTPS endpoint supports application-
|
|
101
|
+
configured enterprise API URLs. Inject a `Transport` for synthetic or custom HTTP
|
|
102
|
+
behavior; it receives a `urllib.request.Request` and the timeout/byte budget and
|
|
103
|
+
returns `HTTPResponse(status, body)`.
|
|
104
|
+
|
|
105
|
+
HTTP failures, GraphQL errors (including HTTP 200 with partial data), malformed or
|
|
106
|
+
oversized responses, unavailable resources, and iterator limits have separate error
|
|
107
|
+
types. Upstream error text and resolver/transport exception messages are sanitized.
|
|
108
|
+
Requests are not retried automatically. Clients do not resolve local login state
|
|
109
|
+
unless the application explicitly supplies such a resolver.
|
|
110
|
+
|
|
111
|
+
## Verification
|
|
112
|
+
|
|
113
|
+
```sh
|
|
114
|
+
PYTHONPATH=packages/hidden-moves-github-projects/src \
|
|
115
|
+
.venv/bin/python -m unittest discover -s packages/hidden-moves-github-projects/tests -p test_client.py
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Tests use synthetic response fixtures, an injected transport, and blocked side
|
|
119
|
+
effects. CI builds and installs the actual wheel across the family platform matrix.
|
|
120
|
+
Live read access and capability-host interoperability are separate proofs.
|
|
121
|
+
|
|
122
|
+
## Optional provider and local host
|
|
123
|
+
|
|
124
|
+
Install `[moves]` for the optional provider or `[host]` for the MCP executable.
|
|
125
|
+
For local development install the family packages together:
|
|
126
|
+
|
|
127
|
+
```sh
|
|
128
|
+
uv pip install --python .venv/bin/python . ./packages/hidden-moves-mcp \
|
|
129
|
+
./packages/hidden-moves-openai ./packages/hidden-moves-github-projects
|
|
130
|
+
hidden-moves --plugin github-projects moves show github.projects.items
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
The zero-argument `github-projects` entry-point factory never resolves credentials
|
|
134
|
+
or makes a request. It advertises target-bound `github.projects.list/get/fields/items`
|
|
135
|
+
with read-only, non-destructive, external annotations. Generic framework inspection
|
|
136
|
+
needs no token; account invocation needs an application-bound `ProjectsClient`.
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
from hidden_moves_github_projects import ProjectsClient
|
|
140
|
+
from hidden_moves_github_projects.host import build_catalog, resolve_gh_token
|
|
141
|
+
from hidden_moves_mcp import MCPAdapter
|
|
142
|
+
from hidden_moves_openai import FunctionToolAdapter
|
|
143
|
+
|
|
144
|
+
client = ProjectsClient(resolve_gh_token, timeout=10)
|
|
145
|
+
catalog = build_catalog(client, ["github.projects.items"])
|
|
146
|
+
mcp = MCPAdapter(catalog)
|
|
147
|
+
functions = FunctionToolAdapter(catalog)
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
`resolve_gh_token` explicitly captures the existing `github.com` login from `gh`
|
|
151
|
+
when a read executes. It neither prints the token nor changes login/scopes. A
|
|
152
|
+
local MCP host can launch the installed executable:
|
|
153
|
+
|
|
154
|
+
```json
|
|
155
|
+
{
|
|
156
|
+
"command": "/absolute/path/to/environment/bin/hidden-moves-github-projects",
|
|
157
|
+
"args": ["--move", "github.projects.items", "--timeout", "10"]
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Only selected capabilities list or dispatch. The host supplies no arbitrary query,
|
|
162
|
+
shell, or Project-editing tool. The SDK owns stdio protocol and process cleanup.
|
|
163
|
+
This configured local application uses one local identity; a remote host requires
|
|
164
|
+
request-scoped authorization and credentials. Sync HTTP executes inline here;
|
|
165
|
+
concurrent remote hosting must provide its own offloading and resource strategy.
|
|
166
|
+
|
|
167
|
+
The consumer tests use a synthetic transport and compare ordinary Python, the
|
|
168
|
+
selected catalog, actual stdio MCP initialize/list/call, and offline function-tool
|
|
169
|
+
dispatch. Install the local family and run:
|
|
170
|
+
|
|
171
|
+
```sh
|
|
172
|
+
python -m unittest discover -s packages/hidden-moves-github-projects/tests
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Inspection, fixture tests, and offline function tools require no model API call.
|
|
176
|
+
# Optional local snapshots
|
|
177
|
+
|
|
178
|
+
The `backpack` extra enables an explicit Projects-to-Backpack bridge. Store dated
|
|
179
|
+
normalized Project/item DTOs with a local UUID and source provenance, then reopen
|
|
180
|
+
them without GitHub access. Fetch and persistence can be invoked separately; no
|
|
181
|
+
snapshot read updates a board or claims to be live. See the
|
|
182
|
+
[snapshot guide](../../docs/PROJECT_SNAPSHOTS.md) and run
|
|
183
|
+
`python examples/projects_snapshot_demo.py` from the repository for the offline
|
|
184
|
+
proof. Backpack remains an independent package with no mandatory runtime
|
|
185
|
+
dependencies.
|
|
186
|
+
# Optional bounded writes
|
|
187
|
+
|
|
188
|
+
The separate `ProjectsWriter` and `github-projects-write` provider support adding
|
|
189
|
+
an existing Issue/PR and setting a dynamically resolved single-select choice.
|
|
190
|
+
The host authorizes explicit Project IDs and selects writes separately from the
|
|
191
|
+
default read profile. See the [write guide](../../docs/PROJECT_WRITES.md) for
|
|
192
|
+
expected-value checks, verified/unverified receipts and the remaining disposable
|
|
193
|
+
fixture gate. There are no bulk, deletion or archive tools.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
hidden_moves_github_projects/__init__.py,sha256=981WudjuNVSGl0Db7ok9AUTnUV6ABO7RdXwG6RVR9c0,940
|
|
2
|
+
hidden_moves_github_projects/__main__.py,sha256=lJ-aBQgaPtD3giAFpUTx60YNQ67fj_lwL35Ue-ATB3M,136
|
|
3
|
+
hidden_moves_github_projects/_normalize.py,sha256=eJ_QeFajuxdYW73eRqVYhr8f6qLryNQlnWq6ZvY1U3o,7398
|
|
4
|
+
hidden_moves_github_projects/_queries.py,sha256=ynGDpWhPyE9Nmv19CsIudvvDTW40fsvYIntlQKeNjRE,2450
|
|
5
|
+
hidden_moves_github_projects/client.py,sha256=-Sm1katVEVppiVGeCmPHpxe6rQ21O8ObaAfd-prcw88,8965
|
|
6
|
+
hidden_moves_github_projects/errors.py,sha256=JAc4BFBj88BLXALxpTp3EdfbvrJ6X6PJ3kEEF2FBtQw,1088
|
|
7
|
+
hidden_moves_github_projects/host.py,sha256=eOKmzB1ra4grnBkbtc_IGsBK7e-aHZdB59QcODOIuy4,2758
|
|
8
|
+
hidden_moves_github_projects/integration.py,sha256=SmFZMZcoyJ0lmXhvSSzVceYh4YnS4AdfhghpvWS187M,2010
|
|
9
|
+
hidden_moves_github_projects/models.py,sha256=i-g0UJG_kRNsx5uNDIXpUVA6DgZLTNuh1lq8XzDNtSE,2548
|
|
10
|
+
hidden_moves_github_projects/snapshots.py,sha256=uy0PJz2_f76148LAELzSOd56WDrjaXREAauA8eQgfks,3697
|
|
11
|
+
hidden_moves_github_projects/transport.py,sha256=bONC5j0yJTR4WfxBVLIGzfOKcZDU_wAjX2glC_vsg_o,1022
|
|
12
|
+
hidden_moves_github_projects/write_host.py,sha256=5CjPt8T-CM3NN4LVLERfNJQLEnhxHkwiCUCXYkKj3B4,2208
|
|
13
|
+
hidden_moves_github_projects/write_integration.py,sha256=Fk4rtBipT3FjLpPDR51KFGBWnQGGlzX4ADr1fFtn6-w,1742
|
|
14
|
+
hidden_moves_github_projects/writes.py,sha256=G94xxifZY_vh0HzzvF5IhCNmpSXGisIK3NlxcxF2nQQ,12652
|
|
15
|
+
hidden_moves_github_projects-0.1.0.dist-info/licenses/LICENSE,sha256=MJiaArfnOql8NJRA2TI9JIYDOoiis3Rh8xQruzBRXoU,1066
|
|
16
|
+
hidden_moves_github_projects-0.1.0.dist-info/WHEEL,sha256=e4_1dyBeezi8ZjfxrZ3bnVOxFDa3ksqVqH0jTHkUZ3k,81
|
|
17
|
+
hidden_moves_github_projects-0.1.0.dist-info/entry_points.txt,sha256=WNeCIjgtRJZ8sK1OesWutOoqyX8e-aAMCidKaaPOPm8,357
|
|
18
|
+
hidden_moves_github_projects-0.1.0.dist-info/METADATA,sha256=5KyJPaNRbBo2hXzjslEIMtW2xu6FSIBCMc1mHducHhw,9283
|
|
19
|
+
hidden_moves_github_projects-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
[console_scripts]
|
|
2
|
+
hidden-moves-github-projects = hidden_moves_github_projects.host:main
|
|
3
|
+
hidden-moves-github-projects-write = hidden_moves_github_projects.write_host:main
|
|
4
|
+
|
|
5
|
+
[hidden_moves.moves]
|
|
6
|
+
github-projects = hidden_moves_github_projects.integration:provide_moves
|
|
7
|
+
github-projects-write = hidden_moves_github_projects.write_integration:provide_write_moves
|
|
8
|
+
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ella Inng
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|