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,38 @@
|
|
|
1
|
+
"""Read typed GitHub Projects records without a registry or protocol dependency."""
|
|
2
|
+
|
|
3
|
+
from .client import ProjectsClient
|
|
4
|
+
from .errors import (
|
|
5
|
+
CredentialError,
|
|
6
|
+
GraphQLError,
|
|
7
|
+
HTTPStatusError,
|
|
8
|
+
NotFoundError,
|
|
9
|
+
PaginationLimitError,
|
|
10
|
+
ProjectsError,
|
|
11
|
+
ProtocolError,
|
|
12
|
+
TransportError,
|
|
13
|
+
)
|
|
14
|
+
from .models import (
|
|
15
|
+
Content,
|
|
16
|
+
Diagnostic,
|
|
17
|
+
FieldPage,
|
|
18
|
+
FieldValue,
|
|
19
|
+
FieldValuePage,
|
|
20
|
+
ItemPage,
|
|
21
|
+
Iteration,
|
|
22
|
+
OwnerKind,
|
|
23
|
+
PageInfo,
|
|
24
|
+
Project,
|
|
25
|
+
ProjectField,
|
|
26
|
+
ProjectItem,
|
|
27
|
+
ProjectPage,
|
|
28
|
+
SelectOption,
|
|
29
|
+
)
|
|
30
|
+
from .transport import HTTPResponse, Transport
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"Content", "CredentialError", "Diagnostic", "FieldPage", "FieldValue", "FieldValuePage",
|
|
34
|
+
"GraphQLError", "HTTPResponse", "HTTPStatusError", "ItemPage", "Iteration", "NotFoundError",
|
|
35
|
+
"OwnerKind", "PageInfo", "PaginationLimitError", "Project", "ProjectField", "ProjectItem",
|
|
36
|
+
"ProjectPage", "ProjectsClient", "ProjectsError", "ProtocolError", "SelectOption", "Transport",
|
|
37
|
+
"TransportError",
|
|
38
|
+
]
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Normalize only queried fields and retain diagnostics for unavailable data."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .errors import NotFoundError, ProtocolError
|
|
7
|
+
from .models import (
|
|
8
|
+
Content,
|
|
9
|
+
Diagnostic,
|
|
10
|
+
FieldValue,
|
|
11
|
+
FieldValuePage,
|
|
12
|
+
Iteration,
|
|
13
|
+
PageInfo,
|
|
14
|
+
Project,
|
|
15
|
+
ProjectField,
|
|
16
|
+
ProjectItem,
|
|
17
|
+
SelectOption,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def obj(value: Any) -> dict:
|
|
22
|
+
if not isinstance(value, dict):
|
|
23
|
+
raise ProtocolError("GitHub returned an invalid object.")
|
|
24
|
+
return value
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def text(value: Any, *, nullable: bool = False) -> str | None:
|
|
28
|
+
if nullable and value is None:
|
|
29
|
+
return None
|
|
30
|
+
if not isinstance(value, str):
|
|
31
|
+
raise ProtocolError("GitHub returned an invalid text field.")
|
|
32
|
+
return value
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def integer(value: Any) -> int:
|
|
36
|
+
if type(value) is not int:
|
|
37
|
+
raise ProtocolError("GitHub returned an invalid integer field.")
|
|
38
|
+
return value
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def boolean(value: Any) -> bool:
|
|
42
|
+
if type(value) is not bool:
|
|
43
|
+
raise ProtocolError("GitHub returned an invalid boolean field.")
|
|
44
|
+
return value
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def identity(value: Any) -> str:
|
|
48
|
+
value = text(value)
|
|
49
|
+
if not value:
|
|
50
|
+
raise ProtocolError("GitHub returned an invalid node identity.")
|
|
51
|
+
return value
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def sequence(value: Any) -> list:
|
|
55
|
+
if not isinstance(value, list):
|
|
56
|
+
raise ProtocolError("GitHub returned an invalid list.")
|
|
57
|
+
return value
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def connection(value: Any) -> tuple[list, PageInfo]:
|
|
61
|
+
value = obj(value)
|
|
62
|
+
page = obj(value.get("pageInfo"))
|
|
63
|
+
info = PageInfo(boolean(page.get("hasNextPage")), text(page.get("endCursor"), nullable=True))
|
|
64
|
+
if info.has_next_page and not info.end_cursor:
|
|
65
|
+
raise ProtocolError("GitHub omitted the next-page cursor.")
|
|
66
|
+
return sequence(value.get("nodes")), info
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def node(data: dict, expected: str) -> dict:
|
|
70
|
+
value = data.get("node")
|
|
71
|
+
if value is None:
|
|
72
|
+
raise NotFoundError("The requested node is absent or inaccessible.")
|
|
73
|
+
value = obj(value)
|
|
74
|
+
if value.get("__typename") != expected:
|
|
75
|
+
raise NotFoundError("The requested node has a different resource type.")
|
|
76
|
+
return value
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def project(value: dict) -> Project:
|
|
80
|
+
value = obj(value)
|
|
81
|
+
owner = obj(value.get("owner"))
|
|
82
|
+
kinds = {"User": "user", "Organization": "organization"}
|
|
83
|
+
kind = kinds.get(owner.get("__typename"))
|
|
84
|
+
if kind is None:
|
|
85
|
+
raise ProtocolError("GitHub returned an unsupported project owner.")
|
|
86
|
+
return Project(
|
|
87
|
+
identity(value.get("id")), integer(value.get("number")), text(value.get("title")),
|
|
88
|
+
text(value.get("url")), identity(owner.get("login")), kind,
|
|
89
|
+
text(value.get("shortDescription"), nullable=True), boolean(value.get("public")),
|
|
90
|
+
boolean(value.get("closed")),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def iteration(value: dict, *, completed: bool = False) -> Iteration:
|
|
95
|
+
value = obj(value)
|
|
96
|
+
return Iteration(
|
|
97
|
+
identity(value.get("id")), text(value.get("title")), text(value.get("startDate")),
|
|
98
|
+
integer(value.get("duration")), completed,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def project_field(value: dict | None) -> tuple[ProjectField, list[Diagnostic]]:
|
|
103
|
+
if value is None:
|
|
104
|
+
return ProjectField(None, None, None, "Unavailable"), [Diagnostic("unavailable_field")]
|
|
105
|
+
value = obj(value)
|
|
106
|
+
typename = text(value.get("__typename"))
|
|
107
|
+
field = ProjectField(
|
|
108
|
+
text(value.get("id"), nullable=True), text(value.get("name"), nullable=True),
|
|
109
|
+
text(value.get("dataType"), nullable=True), typename,
|
|
110
|
+
)
|
|
111
|
+
if typename not in {"ProjectV2Field", "ProjectV2SingleSelectField", "ProjectV2IterationField"}:
|
|
112
|
+
return field, [Diagnostic("unsupported_field_type", field.id, typename)]
|
|
113
|
+
identity(field.id)
|
|
114
|
+
text(field.name)
|
|
115
|
+
text(field.data_type)
|
|
116
|
+
if typename == "ProjectV2SingleSelectField":
|
|
117
|
+
field.options.extend(SelectOption(identity(obj(option).get("id")), text(option.get("name"))) for option in sequence(value.get("options")))
|
|
118
|
+
if typename == "ProjectV2IterationField":
|
|
119
|
+
config = obj(value.get("configuration"))
|
|
120
|
+
field.iterations.extend(iteration(item) for item in sequence(config.get("iterations")))
|
|
121
|
+
field.iterations.extend(iteration(item, completed=True) for item in sequence(config.get("completedIterations")))
|
|
122
|
+
return field, []
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def field_values(item_id: str, value: dict) -> FieldValuePage:
|
|
126
|
+
nodes, page = connection(value)
|
|
127
|
+
values, diagnostics = [], []
|
|
128
|
+
supported = {
|
|
129
|
+
"ProjectV2ItemFieldTextValue", "ProjectV2ItemFieldNumberValue", "ProjectV2ItemFieldDateValue",
|
|
130
|
+
"ProjectV2ItemFieldSingleSelectValue", "ProjectV2ItemFieldIterationValue",
|
|
131
|
+
}
|
|
132
|
+
for entry in nodes:
|
|
133
|
+
if entry is None:
|
|
134
|
+
values.append(FieldValue("Unavailable"))
|
|
135
|
+
diagnostics.append(Diagnostic("unavailable_field_value", item_id))
|
|
136
|
+
continue
|
|
137
|
+
entry = obj(entry)
|
|
138
|
+
typename = text(entry.get("__typename"))
|
|
139
|
+
field = obj(entry.get("field", {}))
|
|
140
|
+
kwargs = {
|
|
141
|
+
"graphql_type": typename,
|
|
142
|
+
"id": text(entry.get("id"), nullable=True),
|
|
143
|
+
"field_id": text(field.get("id"), nullable=True),
|
|
144
|
+
"field_name": text(field.get("name"), nullable=True),
|
|
145
|
+
}
|
|
146
|
+
if typename not in supported:
|
|
147
|
+
diagnostics.append(Diagnostic("unsupported_field_value", item_id, typename))
|
|
148
|
+
else:
|
|
149
|
+
identity(kwargs["id"])
|
|
150
|
+
identity(kwargs["field_id"])
|
|
151
|
+
text(kwargs["field_name"])
|
|
152
|
+
if typename.endswith("TextValue"):
|
|
153
|
+
kwargs["text"] = text(entry.get("text"), nullable=True)
|
|
154
|
+
elif typename.endswith("NumberValue"):
|
|
155
|
+
number = entry.get("number")
|
|
156
|
+
if number is not None:
|
|
157
|
+
if type(number) not in (float, int) or not math.isfinite(number):
|
|
158
|
+
raise ProtocolError("GitHub returned an invalid numeric field value.")
|
|
159
|
+
kwargs["number"] = float(number)
|
|
160
|
+
elif typename.endswith("DateValue"):
|
|
161
|
+
kwargs["date"] = text(entry.get("date"), nullable=True)
|
|
162
|
+
elif typename.endswith("SingleSelectValue"):
|
|
163
|
+
kwargs["option_id"] = text(entry.get("optionId"), nullable=True)
|
|
164
|
+
kwargs["option_name"] = text(entry.get("name"), nullable=True)
|
|
165
|
+
else:
|
|
166
|
+
kwargs["iteration"] = iteration({**entry, "id": entry.get("iterationId")})
|
|
167
|
+
values.append(FieldValue(**kwargs))
|
|
168
|
+
if page.has_next_page:
|
|
169
|
+
diagnostics.append(Diagnostic("field_values_incomplete", item_id))
|
|
170
|
+
return FieldValuePage(item_id, values, page, diagnostics)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def content(value: dict | None, item_type: str, item_id: str) -> tuple[Content, list[Diagnostic]]:
|
|
174
|
+
if value is None:
|
|
175
|
+
kind = "redacted" if item_type == "REDACTED" else "unavailable"
|
|
176
|
+
return Content(kind), [Diagnostic(kind + "_content", item_id)]
|
|
177
|
+
value = obj(value)
|
|
178
|
+
typename = text(value.get("__typename"))
|
|
179
|
+
kinds = {"Issue": "issue", "PullRequest": "pull_request", "DraftIssue": "draft"}
|
|
180
|
+
if typename not in kinds:
|
|
181
|
+
return Content("unknown", graphql_type=typename), [Diagnostic("unsupported_content_type", item_id, typename)]
|
|
182
|
+
result = Content(
|
|
183
|
+
kind=kinds[typename], id=identity(value.get("id")), title=text(value.get("title")),
|
|
184
|
+
url=text(value.get("url"), nullable=True),
|
|
185
|
+
number=None if typename == "DraftIssue" else integer(value.get("number")),
|
|
186
|
+
repository=None if typename == "DraftIssue" else text(obj(value.get("repository")).get("nameWithOwner")),
|
|
187
|
+
state=text(value.get("state"), nullable=True), body=text(value.get("body"), nullable=True),
|
|
188
|
+
graphql_type=typename,
|
|
189
|
+
)
|
|
190
|
+
return result, []
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def item(value: dict, project_id: str) -> ProjectItem:
|
|
194
|
+
value = obj(value)
|
|
195
|
+
item_id = identity(value.get("id"))
|
|
196
|
+
item_type = text(value.get("type"))
|
|
197
|
+
linked, diagnostics = content(value.get("content"), item_type, item_id)
|
|
198
|
+
fields = field_values(item_id, value.get("fieldValues"))
|
|
199
|
+
diagnostics.extend(fields.diagnostics)
|
|
200
|
+
return ProjectItem(
|
|
201
|
+
item_id, project_id, item_type, boolean(value.get("isArchived")),
|
|
202
|
+
text(value.get("createdAt")), text(value.get("updatedAt")), linked,
|
|
203
|
+
fields.values, fields.page_info, not fields.diagnostics, diagnostics,
|
|
204
|
+
)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Fixed read-only queries; account identities and cursors are variables."""
|
|
2
|
+
|
|
3
|
+
PROJECT = """
|
|
4
|
+
id number title url shortDescription public closed
|
|
5
|
+
owner { __typename ... on User { login } ... on Organization { login } }
|
|
6
|
+
"""
|
|
7
|
+
PAGE = "pageInfo { hasNextPage endCursor }"
|
|
8
|
+
FIELDS = """
|
|
9
|
+
__typename
|
|
10
|
+
... on ProjectV2FieldCommon { id name dataType }
|
|
11
|
+
... on ProjectV2SingleSelectField { options { id name } }
|
|
12
|
+
... on ProjectV2IterationField {
|
|
13
|
+
configuration {
|
|
14
|
+
iterations { id title startDate duration }
|
|
15
|
+
completedIterations { id title startDate duration }
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
"""
|
|
19
|
+
VALUES = """
|
|
20
|
+
__typename
|
|
21
|
+
... on ProjectV2ItemFieldValueCommon {
|
|
22
|
+
id field { ... on ProjectV2FieldCommon { id name } }
|
|
23
|
+
}
|
|
24
|
+
... on ProjectV2ItemFieldTextValue { text }
|
|
25
|
+
... on ProjectV2ItemFieldNumberValue { number }
|
|
26
|
+
... on ProjectV2ItemFieldDateValue { date }
|
|
27
|
+
... on ProjectV2ItemFieldSingleSelectValue { optionId name }
|
|
28
|
+
... on ProjectV2ItemFieldIterationValue { iterationId title startDate duration }
|
|
29
|
+
"""
|
|
30
|
+
CONTENT = """
|
|
31
|
+
__typename
|
|
32
|
+
... on Issue { id number title url state repository { nameWithOwner } }
|
|
33
|
+
... on PullRequest { id number title url state repository { nameWithOwner } }
|
|
34
|
+
... on DraftIssue { id title body }
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def owner_query(kind: str, *, listing: bool) -> str:
|
|
39
|
+
if listing:
|
|
40
|
+
return f"""query ListProjects($owner: String!, $first: Int!, $cursor: String) {{
|
|
41
|
+
owner: {kind}(login: $owner) {{ projectsV2(first: $first, after: $cursor) {{
|
|
42
|
+
nodes {{ {PROJECT} }} {PAGE}
|
|
43
|
+
}} }}
|
|
44
|
+
}}"""
|
|
45
|
+
return f"""query GetProject($owner: String!, $number: Int!) {{
|
|
46
|
+
owner: {kind}(login: $owner) {{ projectV2(number: $number) {{ {PROJECT} }} }}
|
|
47
|
+
}}"""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
FIELD_QUERY = f"""query ProjectFields($id: ID!, $first: Int!, $cursor: String) {{
|
|
51
|
+
node(id: $id) {{ __typename ... on ProjectV2 {{
|
|
52
|
+
fields(first: $first, after: $cursor) {{ nodes {{ {FIELDS} }} {PAGE} }}
|
|
53
|
+
}} }}
|
|
54
|
+
}}"""
|
|
55
|
+
ITEM_QUERY = f"""query ProjectItems($id: ID!, $first: Int!, $cursor: String, $fieldFirst: Int!) {{
|
|
56
|
+
node(id: $id) {{ __typename ... on ProjectV2 {{
|
|
57
|
+
items(first: $first, after: $cursor) {{
|
|
58
|
+
nodes {{ id type isArchived createdAt updatedAt
|
|
59
|
+
content {{ {CONTENT} }}
|
|
60
|
+
fieldValues(first: $fieldFirst) {{ nodes {{ {VALUES} }} {PAGE} }}
|
|
61
|
+
}} {PAGE}
|
|
62
|
+
}}
|
|
63
|
+
}} }}
|
|
64
|
+
}}"""
|
|
65
|
+
VALUE_QUERY = f"""query ItemFieldValues($id: ID!, $first: Int!, $cursor: String) {{
|
|
66
|
+
node(id: $id) {{ __typename ... on ProjectV2Item {{
|
|
67
|
+
fieldValues(first: $first, after: $cursor) {{ nodes {{ {VALUES} }} {PAGE} }}
|
|
68
|
+
}} }}
|
|
69
|
+
}}"""
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""An ordinary read-only Projects client, independent of capability adapters."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import math
|
|
5
|
+
from collections.abc import Callable, Iterator
|
|
6
|
+
from urllib.parse import urlsplit
|
|
7
|
+
from urllib.request import Request
|
|
8
|
+
|
|
9
|
+
from . import _normalize as normalize
|
|
10
|
+
from . import _queries
|
|
11
|
+
from .errors import (
|
|
12
|
+
CredentialError,
|
|
13
|
+
GraphQLError,
|
|
14
|
+
HTTPStatusError,
|
|
15
|
+
NotFoundError,
|
|
16
|
+
PaginationLimitError,
|
|
17
|
+
ProtocolError,
|
|
18
|
+
TransportError,
|
|
19
|
+
)
|
|
20
|
+
from .models import (
|
|
21
|
+
Diagnostic,
|
|
22
|
+
FieldPage,
|
|
23
|
+
FieldValuePage,
|
|
24
|
+
ItemPage,
|
|
25
|
+
OwnerKind,
|
|
26
|
+
Project,
|
|
27
|
+
ProjectPage,
|
|
28
|
+
)
|
|
29
|
+
from .transport import HTTPResponse, Transport, http_transport
|
|
30
|
+
|
|
31
|
+
_ERROR_CODES = {
|
|
32
|
+
"FORBIDDEN", "NOT_FOUND", "RATE_LIMITED", "UNAUTHORIZED", "UNPROCESSABLE",
|
|
33
|
+
"MAX_NODE_LIMIT_EXCEEDED", "GRAPHQL_VALIDATION_FAILED", "INSUFFICIENT_SCOPES",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _text(value: str, label: str) -> str:
|
|
38
|
+
if not isinstance(value, str) or not value.strip():
|
|
39
|
+
raise ValueError(f"{label} must be a nonempty string.")
|
|
40
|
+
return value
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _count(value: int, label: str, maximum: int = 100) -> int:
|
|
44
|
+
if type(value) is not int or not 1 <= value <= maximum:
|
|
45
|
+
raise ValueError(f"{label} must be an integer between 1 and {maximum}.")
|
|
46
|
+
return value
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _cursor(value: str | None) -> str | None:
|
|
50
|
+
return None if value is None else _text(value, "cursor")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ProjectsClient:
|
|
54
|
+
"""Resolve explicit credentials and perform one bounded GraphQL read per call."""
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self, token: str | Callable[[], str], *, transport: Transport = http_transport,
|
|
58
|
+
timeout: float = 10.0, max_response_bytes: int = 4_000_000,
|
|
59
|
+
endpoint: str = "https://api.github.com/graphql",
|
|
60
|
+
) -> None:
|
|
61
|
+
if type(timeout) not in (float, int) or not math.isfinite(timeout) or timeout <= 0:
|
|
62
|
+
raise ValueError("timeout must be a finite positive number.")
|
|
63
|
+
_count(max_response_bytes, "max_response_bytes", 16_000_000)
|
|
64
|
+
parsed = urlsplit(endpoint)
|
|
65
|
+
if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password or parsed.query or parsed.fragment:
|
|
66
|
+
raise ValueError("endpoint must be an HTTPS URL without credentials, query, or fragment.")
|
|
67
|
+
if not callable(token) and not isinstance(token, str):
|
|
68
|
+
raise TypeError("token must be a string or an explicit resolver.")
|
|
69
|
+
if not callable(transport):
|
|
70
|
+
raise TypeError("transport must be callable.")
|
|
71
|
+
self._token = token
|
|
72
|
+
self._transport = transport
|
|
73
|
+
self._timeout = float(timeout)
|
|
74
|
+
self._max_response_bytes = max_response_bytes
|
|
75
|
+
self._endpoint = endpoint
|
|
76
|
+
|
|
77
|
+
def __repr__(self) -> str:
|
|
78
|
+
return "ProjectsClient()"
|
|
79
|
+
|
|
80
|
+
def _execute(self, query: str, variables: dict) -> dict:
|
|
81
|
+
try:
|
|
82
|
+
token = self._token() if callable(self._token) else self._token
|
|
83
|
+
except Exception: # noqa: BLE001 - Resolver failures may contain credentials.
|
|
84
|
+
raise CredentialError("The configured credential resolver failed.") from None
|
|
85
|
+
if not isinstance(token, str) or not token.strip() or any(char.isspace() for char in token):
|
|
86
|
+
raise CredentialError("The configured credential is empty or invalid.")
|
|
87
|
+
request = Request(
|
|
88
|
+
self._endpoint, data=json.dumps({"query": query, "variables": variables}, allow_nan=False).encode(),
|
|
89
|
+
headers={"Authorization": "Bearer " + token, "Accept": "application/json", "Content-Type": "application/json", "User-Agent": "hidden-moves-github-projects/0.1.0"},
|
|
90
|
+
method="POST",
|
|
91
|
+
)
|
|
92
|
+
try:
|
|
93
|
+
response = self._transport(request, timeout=self._timeout, max_bytes=self._max_response_bytes)
|
|
94
|
+
except Exception: # noqa: BLE001 - Transport failures may echo request credentials.
|
|
95
|
+
raise TransportError("The configured GitHub transport failed.") from None
|
|
96
|
+
if not isinstance(response, HTTPResponse) or type(response.status) is not int or not 100 <= response.status <= 599 or not isinstance(response.body, bytes):
|
|
97
|
+
raise ProtocolError("The transport returned an invalid response.")
|
|
98
|
+
if response.status != 200:
|
|
99
|
+
raise HTTPStatusError(response.status)
|
|
100
|
+
if len(response.body) > self._max_response_bytes:
|
|
101
|
+
raise ProtocolError("The GitHub response exceeded the configured byte budget.")
|
|
102
|
+
try:
|
|
103
|
+
def reject_constant(value):
|
|
104
|
+
raise ValueError("non-finite JSON")
|
|
105
|
+
result = json.loads(response.body, parse_constant=reject_constant)
|
|
106
|
+
except (ValueError, UnicodeError, RecursionError):
|
|
107
|
+
raise ProtocolError("GitHub returned invalid JSON.") from None
|
|
108
|
+
result = normalize.obj(result)
|
|
109
|
+
errors = result.get("errors")
|
|
110
|
+
if errors is not None and not isinstance(errors, list):
|
|
111
|
+
raise ProtocolError("GitHub returned an invalid GraphQL error response.")
|
|
112
|
+
if errors:
|
|
113
|
+
codes = []
|
|
114
|
+
for error in errors:
|
|
115
|
+
code = error.get("type") if isinstance(error, dict) else None
|
|
116
|
+
codes.append(code if isinstance(code, str) and code in _ERROR_CODES else "GRAPHQL_ERROR")
|
|
117
|
+
raise GraphQLError(tuple(codes), partial_data=result.get("data") is not None)
|
|
118
|
+
return normalize.obj(result.get("data"))
|
|
119
|
+
|
|
120
|
+
def list(self, owner: str, owner_kind: OwnerKind, *, cursor: str | None = None, page_size: int = 50) -> ProjectPage:
|
|
121
|
+
"""Read one page of a user's or organization's Projects, retaining its cursor."""
|
|
122
|
+
if owner_kind not in ("user", "organization"):
|
|
123
|
+
raise ValueError("owner_kind must be user or organization.")
|
|
124
|
+
data = self._execute(_queries.owner_query(owner_kind, listing=True), {
|
|
125
|
+
"owner": _text(owner, "owner"), "first": _count(page_size, "page_size"), "cursor": _cursor(cursor),
|
|
126
|
+
})
|
|
127
|
+
if data.get("owner") is None:
|
|
128
|
+
raise NotFoundError("The requested owner is absent or inaccessible.")
|
|
129
|
+
nodes, page = normalize.connection(normalize.obj(data["owner"]).get("projectsV2"))
|
|
130
|
+
diagnostics = [Diagnostic("unavailable_project") for entry in nodes if entry is None]
|
|
131
|
+
return ProjectPage([normalize.project(entry) for entry in nodes if entry is not None], page, diagnostics)
|
|
132
|
+
|
|
133
|
+
def get(self, owner: str, owner_kind: OwnerKind, number: int) -> Project:
|
|
134
|
+
"""Read a Project by its owner kind, login, and Project number."""
|
|
135
|
+
if owner_kind not in ("user", "organization"):
|
|
136
|
+
raise ValueError("owner_kind must be user or organization.")
|
|
137
|
+
data = self._execute(_queries.owner_query(owner_kind, listing=False), {
|
|
138
|
+
"owner": _text(owner, "owner"), "number": _count(number, "number", 2_147_483_647),
|
|
139
|
+
})
|
|
140
|
+
owner_data = data.get("owner")
|
|
141
|
+
if owner_data is None or normalize.obj(owner_data).get("projectV2") is None:
|
|
142
|
+
raise NotFoundError("The requested Project is absent or inaccessible.")
|
|
143
|
+
return normalize.project(owner_data["projectV2"])
|
|
144
|
+
|
|
145
|
+
def fields(self, project_id: str, *, cursor: str | None = None, page_size: int = 50) -> FieldPage:
|
|
146
|
+
"""Read one field-definition page, including select options and iteration metadata."""
|
|
147
|
+
data = self._execute(_queries.FIELD_QUERY, {"id": _text(project_id, "project_id"), "first": _count(page_size, "page_size"), "cursor": _cursor(cursor)})
|
|
148
|
+
nodes, page = normalize.connection(normalize.node(data, "ProjectV2").get("fields"))
|
|
149
|
+
fields, diagnostics = [], []
|
|
150
|
+
for value in nodes:
|
|
151
|
+
field, hints = normalize.project_field(value)
|
|
152
|
+
fields.append(field)
|
|
153
|
+
diagnostics.extend(hints)
|
|
154
|
+
return FieldPage(project_id, fields, page, diagnostics)
|
|
155
|
+
|
|
156
|
+
def items(self, project_id: str, *, cursor: str | None = None, page_size: int = 50, field_page_size: int = 50) -> ItemPage:
|
|
157
|
+
"""Read a bounded item page; incomplete nested values retain their own cursors."""
|
|
158
|
+
data = self._execute(_queries.ITEM_QUERY, {
|
|
159
|
+
"id": _text(project_id, "project_id"), "first": _count(page_size, "page_size"),
|
|
160
|
+
"cursor": _cursor(cursor), "fieldFirst": _count(field_page_size, "field_page_size"),
|
|
161
|
+
})
|
|
162
|
+
nodes, page = normalize.connection(normalize.node(data, "ProjectV2").get("items"))
|
|
163
|
+
items = [normalize.item(value, project_id) for value in nodes if value is not None]
|
|
164
|
+
diagnostics = [Diagnostic("unavailable_item", project_id) for value in nodes if value is None]
|
|
165
|
+
diagnostics.extend(hint for value in items for hint in value.diagnostics)
|
|
166
|
+
return ItemPage(project_id, items, page, diagnostics)
|
|
167
|
+
|
|
168
|
+
def item_field_values(self, item_id: str, *, cursor: str | None = None, page_size: int = 50) -> FieldValuePage:
|
|
169
|
+
"""Continue a nested field-values connection using the item's distinct node ID."""
|
|
170
|
+
data = self._execute(_queries.VALUE_QUERY, {"id": _text(item_id, "item_id"), "first": _count(page_size, "page_size"), "cursor": _cursor(cursor)})
|
|
171
|
+
return normalize.field_values(item_id, normalize.node(data, "ProjectV2Item").get("fieldValues"))
|
|
172
|
+
|
|
173
|
+
def iter_item_pages(self, project_id: str, *, page_size: int = 50, field_page_size: int = 50, max_pages: int = 10) -> Iterator[ItemPage]:
|
|
174
|
+
"""Yield pages with diagnostics; exceeding the explicit budget reports truncation."""
|
|
175
|
+
_count(max_pages, "max_pages", 1000)
|
|
176
|
+
cursor = None
|
|
177
|
+
seen = set()
|
|
178
|
+
for _ in range(max_pages):
|
|
179
|
+
page = self.items(project_id, cursor=cursor, page_size=page_size, field_page_size=field_page_size)
|
|
180
|
+
yield page
|
|
181
|
+
if not page.page_info.has_next_page:
|
|
182
|
+
return
|
|
183
|
+
cursor = page.page_info.end_cursor
|
|
184
|
+
if cursor in seen:
|
|
185
|
+
raise ProtocolError("GitHub repeated an item-page cursor.")
|
|
186
|
+
seen.add(cursor)
|
|
187
|
+
raise PaginationLimitError("The item-page budget ended before the connection did.")
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Public error categories; upstream messages and credentials are never copied."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ProjectsError(Exception):
|
|
5
|
+
"""A sanitized failure reading GitHub Projects."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CredentialError(ProjectsError):
|
|
9
|
+
"""Explicit credentials could not be resolved."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TransportError(ProjectsError):
|
|
13
|
+
"""The configured HTTP transport failed."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class HTTPStatusError(ProjectsError):
|
|
17
|
+
def __init__(self, status: int) -> None:
|
|
18
|
+
self.status = status
|
|
19
|
+
super().__init__(f"GitHub returned HTTP status {status}.")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class GraphQLError(ProjectsError):
|
|
23
|
+
def __init__(self, codes: tuple[str, ...], *, partial_data: bool) -> None:
|
|
24
|
+
self.codes = codes
|
|
25
|
+
self.partial_data = partial_data
|
|
26
|
+
super().__init__("GitHub rejected the GraphQL read (" + ", ".join(codes) + ").")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ProtocolError(ProjectsError):
|
|
30
|
+
"""An invalid or oversized response cannot be normalized."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class NotFoundError(ProjectsError):
|
|
34
|
+
"""The requested resource is absent or not visible to this credential."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class PaginationLimitError(ProjectsError):
|
|
38
|
+
"""An explicit iterator budget ended before the connection did."""
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Explicit client configuration and selection for a local stdio application."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import subprocess
|
|
8
|
+
from collections.abc import Iterable
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
from .client import ProjectsClient
|
|
12
|
+
from .errors import CredentialError
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from hidden_moves.adapters import CapabilityCatalog
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def resolve_gh_token() -> str:
|
|
19
|
+
"""Explicitly read the existing GitHub CLI login; never print its captured token."""
|
|
20
|
+
try:
|
|
21
|
+
process = subprocess.run(
|
|
22
|
+
["gh", "auth", "token", "--hostname", "github.com"],
|
|
23
|
+
capture_output=True, text=True, check=False, timeout=10,
|
|
24
|
+
)
|
|
25
|
+
except (OSError, subprocess.SubprocessError):
|
|
26
|
+
raise CredentialError("The existing GitHub CLI credential could not be read.") from None
|
|
27
|
+
if process.returncode or not process.stdout.strip():
|
|
28
|
+
raise CredentialError("The existing GitHub CLI credential is unavailable.")
|
|
29
|
+
return process.stdout.strip()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def build_catalog(client: ProjectsClient, names: Iterable[str]) -> CapabilityCatalog:
|
|
33
|
+
"""Bind the configured client before explicitly selecting installed capabilities."""
|
|
34
|
+
from hidden_moves import Moves, discover_providers, load_provider
|
|
35
|
+
from hidden_moves.adapters import CapabilityCatalog
|
|
36
|
+
|
|
37
|
+
entries = [entry for entry in discover_providers() if entry.name == "github-projects"]
|
|
38
|
+
if len(entries) != 1:
|
|
39
|
+
raise ValueError("Install exactly one github-projects provider.")
|
|
40
|
+
moves = Moves(target=client)
|
|
41
|
+
load_provider(entries[0], moves.registry)
|
|
42
|
+
return CapabilityCatalog(moves, names)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def serve_client(client: ProjectsClient, names: Iterable[str]) -> None:
|
|
46
|
+
"""Serve the configured, selected catalog through the existing MCP SDK adapter."""
|
|
47
|
+
from hidden_moves_mcp import MCPAdapter, serve_stdio
|
|
48
|
+
|
|
49
|
+
catalog = build_catalog(client, names)
|
|
50
|
+
await serve_stdio(MCPAdapter(catalog).server("github-projects"))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def main() -> None:
|
|
54
|
+
parser = argparse.ArgumentParser(description="Serve explicitly selected read-only GitHub Projects operations over local MCP stdio.")
|
|
55
|
+
parser.add_argument("--move", action="append", required=True, help="Qualified read capability; repeat to select multiple operations.")
|
|
56
|
+
parser.add_argument("--timeout", type=float, default=10.0, help="Finite GitHub socket timeout in seconds.")
|
|
57
|
+
args = parser.parse_args()
|
|
58
|
+
try:
|
|
59
|
+
from hidden_moves_mcp import MCPAdapter, serve_stdio
|
|
60
|
+
|
|
61
|
+
from hidden_moves import MoveError
|
|
62
|
+
|
|
63
|
+
client = ProjectsClient(resolve_gh_token, timeout=args.timeout)
|
|
64
|
+
catalog = build_catalog(client, args.move)
|
|
65
|
+
server = MCPAdapter(catalog).server("github-projects")
|
|
66
|
+
except ImportError:
|
|
67
|
+
parser.error("Install the Projects package with its [host] extra.")
|
|
68
|
+
except (MoveError, ValueError) as error:
|
|
69
|
+
parser.error(str(error))
|
|
70
|
+
asyncio.run(serve_stdio(server))
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Optional target-bound definitions; the factory never resolves an account."""
|
|
2
|
+
|
|
3
|
+
from hidden_moves import MoveAnnotations, MoveSpec
|
|
4
|
+
|
|
5
|
+
from .client import ProjectsClient
|
|
6
|
+
from .models import FieldPage, ItemPage, OwnerKind, Project, ProjectPage
|
|
7
|
+
|
|
8
|
+
READ_CAPABILITIES = (
|
|
9
|
+
"github.projects.list", "github.projects.get", "github.projects.fields", "github.projects.items",
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def list_projects(client: ProjectsClient, owner: str, owner_kind: OwnerKind, cursor: str | None = None, page_size: int = 50) -> ProjectPage:
|
|
14
|
+
"""Read one bounded Project page for an explicit user or organization owner."""
|
|
15
|
+
return client.list(owner, owner_kind, cursor=cursor, page_size=page_size)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_project(client: ProjectsClient, owner: str, owner_kind: OwnerKind, number: int) -> Project:
|
|
19
|
+
"""Read one Project by owner identity and Project number."""
|
|
20
|
+
return client.get(owner, owner_kind, number)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def project_fields(client: ProjectsClient, project_id: str, cursor: str | None = None, page_size: int = 50) -> FieldPage:
|
|
24
|
+
"""Read one field-definition page with select options and iteration metadata."""
|
|
25
|
+
return client.fields(project_id, cursor=cursor, page_size=page_size)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def project_items(client: ProjectsClient, project_id: str, cursor: str | None = None, page_size: int = 50, field_page_size: int = 50) -> ItemPage:
|
|
29
|
+
"""Read one item page with nested completeness diagnostics and cursor state."""
|
|
30
|
+
return client.items(project_id, cursor=cursor, page_size=page_size, field_page_size=field_page_size)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def provide_moves() -> tuple[MoveSpec, ...]:
|
|
34
|
+
"""Advertise read operations around an application-configured ProjectsClient."""
|
|
35
|
+
annotations = MoveAnnotations(read_only=True, destructive=False, idempotent=True, external=True)
|
|
36
|
+
return tuple(
|
|
37
|
+
MoveSpec(name=name, namespace="github.projects", func=operation,
|
|
38
|
+
bind_target=True, target_types=(ProjectsClient,), annotations=annotations)
|
|
39
|
+
for name, operation in (
|
|
40
|
+
("list", list_projects), ("get", get_project),
|
|
41
|
+
("fields", project_fields), ("items", project_items),
|
|
42
|
+
)
|
|
43
|
+
)
|