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,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
|
+
)
|