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,130 @@
1
+ """Concrete, transport-neutral records for bounded Projects reads."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Literal
5
+
6
+ OwnerKind = Literal["user", "organization"]
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class PageInfo:
11
+ has_next_page: bool
12
+ end_cursor: str | None
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class Diagnostic:
17
+ code: str
18
+ node_id: str | None = None
19
+ graphql_type: str | None = None
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Project:
24
+ id: str
25
+ number: int
26
+ title: str
27
+ url: str
28
+ owner_login: str
29
+ owner_kind: OwnerKind
30
+ description: str | None
31
+ public: bool
32
+ closed: bool
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class ProjectPage:
37
+ projects: list[Project]
38
+ page_info: PageInfo
39
+ diagnostics: list[Diagnostic] = field(default_factory=list)
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class SelectOption:
44
+ id: str
45
+ name: str
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class Iteration:
50
+ id: str
51
+ title: str
52
+ start_date: str
53
+ duration: int
54
+ completed: bool = False
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class ProjectField:
59
+ id: str | None
60
+ name: str | None
61
+ data_type: str | None
62
+ graphql_type: str
63
+ options: list[SelectOption] = field(default_factory=list)
64
+ iterations: list[Iteration] = field(default_factory=list)
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class FieldPage:
69
+ project_id: str
70
+ fields: list[ProjectField]
71
+ page_info: PageInfo
72
+ diagnostics: list[Diagnostic] = field(default_factory=list)
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class Content:
77
+ kind: str
78
+ id: str | None = None
79
+ title: str | None = None
80
+ url: str | None = None
81
+ number: int | None = None
82
+ repository: str | None = None
83
+ state: str | None = None
84
+ body: str | None = None
85
+ graphql_type: str | None = None
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class FieldValue:
90
+ graphql_type: str
91
+ id: str | None = None
92
+ field_id: str | None = None
93
+ field_name: str | None = None
94
+ text: str | None = None
95
+ number: float | None = None
96
+ date: str | None = None
97
+ option_id: str | None = None
98
+ option_name: str | None = None
99
+ iteration: Iteration | None = None
100
+
101
+
102
+ @dataclass(frozen=True)
103
+ class FieldValuePage:
104
+ item_id: str
105
+ values: list[FieldValue]
106
+ page_info: PageInfo
107
+ diagnostics: list[Diagnostic] = field(default_factory=list)
108
+
109
+
110
+ @dataclass(frozen=True)
111
+ class ProjectItem:
112
+ id: str
113
+ project_id: str
114
+ item_type: str
115
+ archived: bool
116
+ created_at: str
117
+ updated_at: str
118
+ content: Content
119
+ field_values: list[FieldValue]
120
+ field_values_page: PageInfo
121
+ field_values_complete: bool
122
+ diagnostics: list[Diagnostic] = field(default_factory=list)
123
+
124
+
125
+ @dataclass(frozen=True)
126
+ class ItemPage:
127
+ project_id: str
128
+ items: list[ProjectItem]
129
+ page_info: PageInfo
130
+ diagnostics: list[Diagnostic] = field(default_factory=list)
@@ -0,0 +1,122 @@
1
+ """Optional explicit Projects-to-Backpack snapshots; no synchronization or writes."""
2
+
3
+ from collections.abc import Iterable
4
+ from dataclasses import dataclass
5
+
6
+ from backpack_store import Backpack, DataclassCodec, Provenance, WriteResult
7
+ from backpack_store.records import utc_now, validate_timestamp
8
+
9
+ from .client import ProjectsClient
10
+ from .errors import NotFoundError, ProjectsError
11
+ from .models import OwnerKind, Project, ProjectItem
12
+
13
+ SNAPSHOT_TYPE_KEY = "github.project_item_snapshot"
14
+ SNAPSHOT_SCHEMA_VERSION = 1
15
+
16
+
17
+ class SnapshotError(ProjectsError):
18
+ """A supplied snapshot does not match its source Project identity."""
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class ProjectItemSnapshot:
23
+ project: Project
24
+ item: ProjectItem
25
+ captured_at: str
26
+
27
+ def __post_init__(self) -> None:
28
+ if type(self.project) is not Project or type(self.item) is not ProjectItem:
29
+ raise SnapshotError(
30
+ "snapshots require normalized Project and ProjectItem DTOs"
31
+ )
32
+ if self.item.project_id != self.project.id:
33
+ raise SnapshotError("the item must belong to the supplied Project")
34
+ validate_timestamp(self.captured_at)
35
+
36
+
37
+ def register_snapshot_codec(store: Backpack) -> None:
38
+ """Register the trusted current DTO codec; choose the database separately."""
39
+ store.register(
40
+ DataclassCodec(
41
+ ProjectItemSnapshot,
42
+ type_key=SNAPSHOT_TYPE_KEY,
43
+ schema_version=SNAPSHOT_SCHEMA_VERSION,
44
+ )
45
+ )
46
+
47
+
48
+ def fetch_project_item(
49
+ client: ProjectsClient,
50
+ owner: str,
51
+ owner_kind: OwnerKind,
52
+ number: int,
53
+ item_id: str,
54
+ *,
55
+ page_size: int = 50,
56
+ field_page_size: int = 50,
57
+ max_pages: int = 10,
58
+ ) -> tuple[Project, ProjectItem]:
59
+ """Find one explicit item through bounded read pages, preserving diagnostics."""
60
+ if not isinstance(item_id, str) or not item_id or len(item_id) > 256:
61
+ raise ValueError("item_id must be an explicit opaque Project item ID")
62
+ project = client.get(owner, owner_kind, number)
63
+ for page in client.iter_item_pages(
64
+ project.id,
65
+ page_size=page_size,
66
+ field_page_size=field_page_size,
67
+ max_pages=max_pages,
68
+ ):
69
+ for item in page.items:
70
+ if item.id == item_id:
71
+ return project, item
72
+ raise NotFoundError(
73
+ "the requested item is not available in the bounded Project connection"
74
+ )
75
+
76
+
77
+ def store_snapshot(
78
+ store: Backpack,
79
+ project: Project,
80
+ item: ProjectItem,
81
+ *,
82
+ captured_at: str | None = None,
83
+ tags: Iterable[str] = (),
84
+ ) -> WriteResult:
85
+ """Persist one dated DTO after the application registers its snapshot codec."""
86
+ snapshot = ProjectItemSnapshot(
87
+ project, item, utc_now() if captured_at is None else captured_at
88
+ )
89
+ provenance = Provenance(
90
+ source_url=project.url,
91
+ source_system="github-projects",
92
+ source_id=item.id,
93
+ captured_at=snapshot.captured_at,
94
+ )
95
+ return store.put(snapshot, tags=tags, provenance=provenance)
96
+
97
+
98
+ def capture_project_item(
99
+ client: ProjectsClient,
100
+ store: Backpack,
101
+ owner: str,
102
+ owner_kind: OwnerKind,
103
+ number: int,
104
+ item_id: str,
105
+ *,
106
+ page_size: int = 50,
107
+ field_page_size: int = 50,
108
+ max_pages: int = 10,
109
+ tags: Iterable[str] = (),
110
+ ) -> WriteResult:
111
+ """Fetch first, then atomically persist; a fetch failure creates no record."""
112
+ project, item = fetch_project_item(
113
+ client,
114
+ owner,
115
+ owner_kind,
116
+ number,
117
+ item_id,
118
+ page_size=page_size,
119
+ field_page_size=field_page_size,
120
+ max_pages=max_pages,
121
+ )
122
+ return store_snapshot(store, project, item, tags=tags)
@@ -0,0 +1,32 @@
1
+ """Bounded HTTP POST transport with credential-bearing redirects disabled."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Protocol
5
+ from urllib.error import HTTPError
6
+ from urllib.request import HTTPRedirectHandler, Request, build_opener
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class HTTPResponse:
11
+ status: int
12
+ body: bytes = field(repr=False)
13
+
14
+
15
+ class Transport(Protocol):
16
+ def __call__(self, request: Request, *, timeout: float, max_bytes: int) -> HTTPResponse: ...
17
+
18
+
19
+ class _NoRedirect(HTTPRedirectHandler):
20
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
21
+ return None
22
+
23
+
24
+ def http_transport(request: Request, *, timeout: float, max_bytes: int) -> HTTPResponse:
25
+ """Make one explicit request, reading at most the budget plus one sentinel byte."""
26
+ try:
27
+ with build_opener(_NoRedirect()).open(request, timeout=timeout) as response:
28
+ return HTTPResponse(response.status, response.read(max_bytes + 1))
29
+ except HTTPError as error:
30
+ status = error.code
31
+ error.close()
32
+ return HTTPResponse(status, b"")
@@ -0,0 +1,69 @@
1
+ """Explicit local write host, separate from the default read-only host."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ from collections.abc import Iterable
8
+ from typing import TYPE_CHECKING
9
+
10
+ from .client import ProjectsClient
11
+ from .host import resolve_gh_token
12
+ from .writes import ProjectsWriter
13
+
14
+ if TYPE_CHECKING:
15
+ from hidden_moves.adapters import CapabilityCatalog
16
+
17
+
18
+ def build_write_catalog(
19
+ writer: ProjectsWriter, names: Iterable[str]
20
+ ) -> CapabilityCatalog:
21
+ from hidden_moves import Moves, discover_providers, load_provider
22
+ from hidden_moves.adapters import CapabilityCatalog
23
+
24
+ names = tuple(names)
25
+ if not names:
26
+ raise ValueError("Select at least one explicit Project write capability.")
27
+ entries = [
28
+ entry for entry in discover_providers() if entry.name == "github-projects-write"
29
+ ]
30
+ if len(entries) != 1:
31
+ raise ValueError("Install exactly one github-projects-write provider.")
32
+ moves = Moves(target=writer)
33
+ load_provider(entries[0], moves.registry)
34
+ return CapabilityCatalog(moves, names)
35
+
36
+
37
+ def main() -> None:
38
+ parser = argparse.ArgumentParser(
39
+ description="Serve explicitly authorized bounded Project writes over local MCP stdio."
40
+ )
41
+ parser.add_argument(
42
+ "--allow-project",
43
+ action="append",
44
+ required=True,
45
+ help="Authorized Project node ID; repeat for each allowed Project.",
46
+ )
47
+ parser.add_argument(
48
+ "--move",
49
+ action="append",
50
+ required=True,
51
+ help="Qualified write capability; repeat to select operations.",
52
+ )
53
+ args = parser.parse_args()
54
+ try:
55
+ from hidden_moves_mcp import MCPAdapter, serve_stdio
56
+
57
+ from hidden_moves import MoveError
58
+
59
+ writer = ProjectsWriter(
60
+ ProjectsClient(resolve_gh_token), allowed_projects=args.allow_project
61
+ )
62
+ server = MCPAdapter(build_write_catalog(writer, args.move)).server(
63
+ "github-projects-write"
64
+ )
65
+ except ImportError:
66
+ parser.error("Install the Projects package with its [host] extra.")
67
+ except (MoveError, ValueError) as error:
68
+ parser.error(str(error))
69
+ asyncio.run(serve_stdio(server))
@@ -0,0 +1,53 @@
1
+ """A separate explicitly bound write provider; no model-supplied approval flag."""
2
+
3
+ from hidden_moves import MoveAnnotations, MoveSpec
4
+
5
+ from .writes import ProjectsWriter, WriteOutcomeUnknownError, WriteReceipt
6
+
7
+ WRITE_CAPABILITIES = ("github.projects.add_item", "github.projects.set_single_select")
8
+
9
+
10
+ def add_item(writer: ProjectsWriter, project_id: str, content_id: str) -> WriteReceipt:
11
+ """Add an existing Issue/PR to an authorized Project and verify its identity."""
12
+ try:
13
+ return writer.add_item(project_id, content_id)
14
+ except WriteOutcomeUnknownError as error:
15
+ return error.receipt
16
+
17
+
18
+ def set_single_select(
19
+ writer: ProjectsWriter,
20
+ project_id: str,
21
+ item_id: str,
22
+ field_name: str,
23
+ option_name: str,
24
+ expected_option_id: str | None,
25
+ ) -> WriteReceipt:
26
+ """Set a resolved choice after an expected-value check and verify the write."""
27
+ try:
28
+ return writer.set_single_select(
29
+ project_id, item_id, field_name, option_name, expected_option_id
30
+ )
31
+ except WriteOutcomeUnknownError as error:
32
+ return error.receipt
33
+
34
+
35
+ def provide_write_moves() -> tuple[MoveSpec, ...]:
36
+ """Advertise only two writes; the application must configure the bound writer."""
37
+ annotations = MoveAnnotations(
38
+ read_only=False, destructive=True, idempotent=False, external=True
39
+ )
40
+ return tuple(
41
+ MoveSpec(
42
+ name=name,
43
+ namespace="github.projects",
44
+ func=operation,
45
+ bind_target=True,
46
+ target_types=(ProjectsWriter,),
47
+ annotations=annotations,
48
+ )
49
+ for name, operation in (
50
+ ("add_item", add_item),
51
+ ("set_single_select", set_single_select),
52
+ )
53
+ )