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.
@@ -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,6 @@
1
+ """Run the optional configured local host only when explicitly executed."""
2
+
3
+ from .host import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -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
+ )