compforge-repokit 0.17.1__tar.gz → 0.17.2__tar.gz
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.
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/PKG-INFO +1 -1
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/pyproject.toml +1 -1
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/forge/connection.py +4 -1
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/forge/github.py +10 -8
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/forge/gitlab.py +15 -9
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/validation/report.py +57 -1
- compforge_repokit-0.17.2/tests/test_forge_connection.py +99 -0
- compforge_repokit-0.17.2/tests/test_validation_report.py +176 -0
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/uv.lock +1 -1
- compforge_repokit-0.17.1/tests/test_forge_connection.py +0 -44
- compforge_repokit-0.17.1/tests/test_validation_report.py +0 -88
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/.gitignore +0 -0
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/AGENTS.md +0 -0
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/Makefile +0 -0
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/README.md +0 -0
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/__init__.py +0 -0
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/_inspect.py +0 -0
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/_language.py +0 -0
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/_layout.py +0 -0
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/_process.py +0 -0
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/budget.py +0 -0
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/dependencies.py +0 -0
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/forge/__init__.py +0 -0
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/forge/_http_state.py +0 -0
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/forge/_page.py +0 -0
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/forge/_rest.py +0 -0
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/forge/creation.py +0 -0
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/forge/model.py +0 -0
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/forge/observation/__init__.py +0 -0
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/forge/observation/context.py +0 -0
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/forge/observation/model.py +0 -0
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/forge/observation/service.py +0 -0
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/forge/observation/store.py +0 -0
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/git/__init__.py +0 -0
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/git/_catalog.py +0 -0
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/git/_staging.py +0 -0
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/git/index.py +0 -0
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/git/operations.py +0 -0
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/git/remote.py +0 -0
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/git/stash.py +0 -0
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/git/state.py +0 -0
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/languages.json +0 -0
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/model.py +0 -0
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/py.typed +0 -0
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/repository.py +0 -0
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/snapshot.py +0 -0
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/tree.py +0 -0
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/validation/__init__.py +0 -0
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/validation/engine.py +0 -0
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/tests/conftest.py +0 -0
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/tests/test_budget.py +0 -0
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/tests/test_checkout_contract.py +0 -0
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/tests/test_dependencies.py +0 -0
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/tests/test_forge.py +0 -0
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/tests/test_forge_http.py +0 -0
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/tests/test_forge_repositories.py +0 -0
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/tests/test_fork.py +0 -0
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/tests/test_git_facts.py +0 -0
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/tests/test_git_helpers.py +0 -0
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/tests/test_git_index.py +0 -0
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/tests/test_inspect.py +0 -0
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/tests/test_metadata_contract.py +0 -0
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/tests/test_operations.py +0 -0
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/tests/test_pr_creation.py +0 -0
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/tests/test_pr_observation.py +0 -0
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/tests/test_process.py +0 -0
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/tests/test_rebase.py +0 -0
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/tests/test_repository_metadata.py +0 -0
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/tests/test_repository_queries.py +0 -0
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/tests/test_scalar_contract.py +0 -0
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/tests/test_snapshot_scopes.py +0 -0
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/tests/test_staging.py +0 -0
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/tests/test_stash.py +0 -0
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/tests/test_tree.py +0 -0
- {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/tests/test_validation.py +0 -0
|
@@ -41,12 +41,13 @@ class ForgeConnection:
|
|
|
41
41
|
timeout: int = 10
|
|
42
42
|
|
|
43
43
|
def __post_init__(self) -> None:
|
|
44
|
+
object.__setattr__(self, "host", self.host.lower())
|
|
44
45
|
if not self.host or self.timeout <= 0:
|
|
45
46
|
raise ValueError("Forge host and positive timeout are required")
|
|
46
47
|
if not self.token.strip():
|
|
47
48
|
raise ForgeAuthError(f"PR query unavailable: no credential for {self.host}")
|
|
48
49
|
object.__setattr__(self, "provider", detect_provider(self.host, self.provider))
|
|
49
|
-
object.__setattr__(self, "api_host", self.api_host or self.host)
|
|
50
|
+
object.__setattr__(self, "api_host", (self.api_host or self.host).lower())
|
|
50
51
|
object.__setattr__(self, "state_dir", Path(self.state_dir))
|
|
51
52
|
|
|
52
53
|
@property
|
|
@@ -76,6 +77,7 @@ class ForgeConnection:
|
|
|
76
77
|
owner,
|
|
77
78
|
name,
|
|
78
79
|
self.token,
|
|
80
|
+
forge=self.name,
|
|
79
81
|
timeout=self.timeout,
|
|
80
82
|
state_dir=self.state_dir / "forge-http",
|
|
81
83
|
)
|
|
@@ -83,6 +85,7 @@ class ForgeConnection:
|
|
|
83
85
|
self.api_host or self.host,
|
|
84
86
|
path,
|
|
85
87
|
self.token,
|
|
88
|
+
forge=self.name,
|
|
86
89
|
timeout=self.timeout,
|
|
87
90
|
state_dir=self.state_dir / "forge-http",
|
|
88
91
|
)
|
|
@@ -40,9 +40,14 @@ class GitHubForge(Forge):
|
|
|
40
40
|
name: str,
|
|
41
41
|
token: str,
|
|
42
42
|
*,
|
|
43
|
+
forge: str | None = None,
|
|
43
44
|
timeout: int = 10,
|
|
44
45
|
state_dir: str | Path | None = None,
|
|
45
46
|
):
|
|
47
|
+
host = host.lower()
|
|
48
|
+
self.forge = ForgeIdentity(
|
|
49
|
+
forge if forge is not None else ("github" if host == "github.com" else host)
|
|
50
|
+
)
|
|
46
51
|
# github.com → api.github.com; GitHub Enterprise → https://<host>/api/v3
|
|
47
52
|
api = "https://api.github.com" if host == "github.com" else f"https://{host}/api/v3"
|
|
48
53
|
graphql_api = (
|
|
@@ -68,7 +73,7 @@ class GitHubForge(Forge):
|
|
|
68
73
|
def list_repositories(
|
|
69
74
|
self, *, page_size: int | None = None, cursor: str | None = None
|
|
70
75
|
) -> Page[Repository]:
|
|
71
|
-
scope = f"github:{self.host}:repositories"
|
|
76
|
+
scope = f"github:{self.forge.name}:{self.host}:repositories"
|
|
72
77
|
page, size = page_position(scope, page_size, cursor)
|
|
73
78
|
rows = self._instance.get(
|
|
74
79
|
"user/repos", sort="full_name", direction="asc", per_page=size, page=page
|
|
@@ -86,18 +91,14 @@ class GitHubForge(Forge):
|
|
|
86
91
|
or any(p in ("", ".", "..") for p in path.split("/"))
|
|
87
92
|
):
|
|
88
93
|
raise ForgeError("Invalid GitHub repository full_name")
|
|
89
|
-
items.append(
|
|
90
|
-
Repository(
|
|
91
|
-
ForgeIdentity("github" if self.host == "github.com" else self.host), path
|
|
92
|
-
)
|
|
93
|
-
)
|
|
94
|
+
items.append(Repository(self.forge, path))
|
|
94
95
|
return Page(tuple(items), page_continuation(scope, page, size, len(rows) >= size))
|
|
95
96
|
|
|
96
97
|
def repository(self) -> Repository:
|
|
97
98
|
data = self.c.get("")
|
|
98
99
|
parent = (data.get("parent") or {}).get("full_name") or None
|
|
99
100
|
return Repository(
|
|
100
|
-
|
|
101
|
+
self.forge,
|
|
101
102
|
data["full_name"],
|
|
102
103
|
forked_from=parent,
|
|
103
104
|
)
|
|
@@ -111,12 +112,13 @@ class GitHubForge(Forge):
|
|
|
111
112
|
owner,
|
|
112
113
|
name,
|
|
113
114
|
self._token,
|
|
115
|
+
forge=self.forge.name,
|
|
114
116
|
timeout=self.timeout,
|
|
115
117
|
state_dir=self.state_dir,
|
|
116
118
|
)
|
|
117
119
|
|
|
118
120
|
def _repository_client(self, repository: Repository) -> GitHubForge:
|
|
119
|
-
forge =
|
|
121
|
+
forge = self.forge
|
|
120
122
|
if repository.forge != forge:
|
|
121
123
|
raise ValueError(
|
|
122
124
|
f"repository {repository.id()!r} does not belong to Forge {forge.name!r}"
|
|
@@ -55,9 +55,14 @@ class GitLabForge(Forge):
|
|
|
55
55
|
path: str,
|
|
56
56
|
token: str,
|
|
57
57
|
*,
|
|
58
|
+
forge: str | None = None,
|
|
58
59
|
timeout: int = 10,
|
|
59
60
|
state_dir: str | Path | None = None,
|
|
60
61
|
):
|
|
62
|
+
host = host.lower()
|
|
63
|
+
self.forge = ForgeIdentity(
|
|
64
|
+
forge if forge is not None else ("gitlab" if host == "gitlab.com" else host)
|
|
65
|
+
)
|
|
61
66
|
self.host, self.path, self._token, self.timeout = host, path, token, timeout
|
|
62
67
|
self.state_dir = state_dir
|
|
63
68
|
enc = urllib.parse.quote(path, safe="")
|
|
@@ -77,7 +82,7 @@ class GitLabForge(Forge):
|
|
|
77
82
|
def list_repositories(
|
|
78
83
|
self, *, page_size: int | None = None, cursor: str | None = None
|
|
79
84
|
) -> Page[Repository]:
|
|
80
|
-
scope = f"gitlab:{self.host}:repositories"
|
|
85
|
+
scope = f"gitlab:{self.forge.name}:{self.host}:repositories"
|
|
81
86
|
page, size = page_position(scope, page_size, cursor)
|
|
82
87
|
rows = self._instance.get(
|
|
83
88
|
"projects",
|
|
@@ -97,18 +102,14 @@ class GitLabForge(Forge):
|
|
|
97
102
|
path = row.get("path_with_namespace") if isinstance(row, dict) else None
|
|
98
103
|
if not isinstance(path, str) or any(p in ("", ".", "..") for p in path.split("/")):
|
|
99
104
|
raise ForgeError("Invalid GitLab repository path_with_namespace")
|
|
100
|
-
items.append(
|
|
101
|
-
Repository(
|
|
102
|
-
ForgeIdentity("gitlab" if self.host == "gitlab.com" else self.host), path
|
|
103
|
-
)
|
|
104
|
-
)
|
|
105
|
+
items.append(Repository(self.forge, path))
|
|
105
106
|
return Page(tuple(items), page_continuation(scope, page, size, len(rows) >= size))
|
|
106
107
|
|
|
107
108
|
def repository(self) -> Repository:
|
|
108
109
|
data = self.c.get("")
|
|
109
110
|
parent = (data.get("forked_from_project") or {}).get("path_with_namespace") or None
|
|
110
111
|
return Repository(
|
|
111
|
-
|
|
112
|
+
self.forge,
|
|
112
113
|
data["path_with_namespace"],
|
|
113
114
|
forked_from=parent,
|
|
114
115
|
)
|
|
@@ -117,11 +118,16 @@ class GitLabForge(Forge):
|
|
|
117
118
|
if not path or ":" in path or path.startswith("/"):
|
|
118
119
|
raise ValueError(f"invalid GitLab repository path: {path!r}")
|
|
119
120
|
return GitLabForge(
|
|
120
|
-
self.host,
|
|
121
|
+
self.host,
|
|
122
|
+
path,
|
|
123
|
+
self._token,
|
|
124
|
+
forge=self.forge.name,
|
|
125
|
+
timeout=self.timeout,
|
|
126
|
+
state_dir=self.state_dir,
|
|
121
127
|
)
|
|
122
128
|
|
|
123
129
|
def _repository_client(self, repository: Repository) -> GitLabForge:
|
|
124
|
-
forge =
|
|
130
|
+
forge = self.forge
|
|
125
131
|
if repository.forge != forge:
|
|
126
132
|
raise ValueError(
|
|
127
133
|
f"repository {repository.id()!r} does not belong to Forge {forge.name!r}"
|
|
@@ -37,12 +37,16 @@ def _decode(payload: str, repo: str, checks: Iterable[str]) -> dict[str, Any]:
|
|
|
37
37
|
data.get("diagnostics"), list
|
|
38
38
|
):
|
|
39
39
|
raise ValueError("ValidationReport results missing")
|
|
40
|
-
|
|
40
|
+
requested = set(checks)
|
|
41
|
+
selected = requested - {"format"}
|
|
41
42
|
if not selected <= {"lint", "test"}:
|
|
42
43
|
raise ValueError("invalid requested validation checks")
|
|
43
44
|
seen: set[tuple[str, str]] = set()
|
|
44
45
|
coverage: dict[str, set[str]] = {}
|
|
46
|
+
statuses: list[str] = []
|
|
45
47
|
for group in data["components"]:
|
|
48
|
+
if not isinstance(group["checks"], list) or not group["checks"]:
|
|
49
|
+
raise ValueError("ValidationReport omitted requested checks in Component")
|
|
46
50
|
root = group["component"]["root"]
|
|
47
51
|
if not isinstance(root, str) or not (Path(repo) / root).resolve().is_relative_to(
|
|
48
52
|
Path(repo).resolve()
|
|
@@ -73,6 +77,7 @@ def _decode(payload: str, repo: str, checks: Iterable[str]) -> dict[str, Any]:
|
|
|
73
77
|
"status"
|
|
74
78
|
] not in {"passed", "failed", "skipped", "unavailable", "cancelled", "uncertain"}:
|
|
75
79
|
raise ValueError("invalid validation check status or scope")
|
|
80
|
+
statuses.append(check["status"])
|
|
76
81
|
if type(check["reused"]) is not bool or not isinstance(check["witnesses"], dict):
|
|
77
82
|
raise ValueError("invalid validation evidence")
|
|
78
83
|
if any(
|
|
@@ -96,4 +101,55 @@ def _decode(payload: str, repo: str, checks: Iterable[str]) -> dict[str, Any]:
|
|
|
96
101
|
raise ValueError("invalid validation command or file list")
|
|
97
102
|
if any(checks != selected for checks in coverage.values()):
|
|
98
103
|
raise ValueError("ValidationReport omitted requested checks")
|
|
104
|
+
preparation = data.get("preparation", [])
|
|
105
|
+
if not isinstance(preparation, list):
|
|
106
|
+
raise ValueError("invalid ValidationReport preparation")
|
|
107
|
+
prepared: list[str] = []
|
|
108
|
+
for group in preparation:
|
|
109
|
+
if (
|
|
110
|
+
"format" not in requested
|
|
111
|
+
or not isinstance(group["checks"], list)
|
|
112
|
+
or not group["checks"]
|
|
113
|
+
):
|
|
114
|
+
raise ValueError("invalid ValidationReport preparation checks")
|
|
115
|
+
for check in group["checks"]:
|
|
116
|
+
if check["check"] != "format" or check["status"] not in {
|
|
117
|
+
"passed",
|
|
118
|
+
"failed",
|
|
119
|
+
"skipped",
|
|
120
|
+
"unavailable",
|
|
121
|
+
"cancelled",
|
|
122
|
+
"uncertain",
|
|
123
|
+
}:
|
|
124
|
+
raise ValueError("invalid ValidationReport preparation status")
|
|
125
|
+
prepared.append(check["status"])
|
|
126
|
+
if data["components"] and any(
|
|
127
|
+
status in {"failed", "cancelled", "uncertain"} for status in prepared
|
|
128
|
+
):
|
|
129
|
+
raise ValueError("ValidationReport checks follow unsuccessful preparation")
|
|
130
|
+
# ExecuteValidation can reject a stale plan before producing any check results.
|
|
131
|
+
if (
|
|
132
|
+
not data["components"]
|
|
133
|
+
and not data["complete"]
|
|
134
|
+
and not data["passed"]
|
|
135
|
+
and not data["inputsConsistent"]
|
|
136
|
+
):
|
|
137
|
+
return cast(dict[str, Any], data)
|
|
138
|
+
# Format can stop before lint/test; its resulting snapshot is not validation evidence.
|
|
139
|
+
preparation_only = (
|
|
140
|
+
not data["components"]
|
|
141
|
+
and "format" in requested
|
|
142
|
+
and (not selected or not data["complete"] or any(status == "failed" for status in prepared))
|
|
143
|
+
)
|
|
144
|
+
outcomes = prepared if preparation_only else statuses
|
|
145
|
+
complete = all(status in {"passed", "failed", "skipped", "unavailable"} for status in outcomes)
|
|
146
|
+
passed = all(status in {"passed", "skipped"} for status in outcomes)
|
|
147
|
+
if not preparation_only:
|
|
148
|
+
passed = passed and data["inputsConsistent"]
|
|
149
|
+
if data["complete"] != complete or data["passed"] != passed:
|
|
150
|
+
raise ValueError("ValidationReport outcome contradicts check results")
|
|
151
|
+
elif (data["complete"] and not complete) or (
|
|
152
|
+
data["passed"] and (not passed or not data["complete"])
|
|
153
|
+
):
|
|
154
|
+
raise ValueError("ValidationReport outcome contradicts preparation results")
|
|
99
155
|
return cast(dict[str, Any], data)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from repokit.forge import ForgeConnection, detect_provider
|
|
7
|
+
from repokit.forge.model import ForgeAuthError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_connection_identity_and_context_are_shared_with_typescript(repo, tmp_path):
|
|
11
|
+
"""@case Credentials, provider and endpoint isolate caches; targets stay explicit."""
|
|
12
|
+
fixture = Path(__file__).resolve().parents[3] / "conformance/forge-connection.json"
|
|
13
|
+
for row in json.loads(fixture.read_text()):
|
|
14
|
+
connection = ForgeConnection(
|
|
15
|
+
host=row["host"],
|
|
16
|
+
provider=row["provider"],
|
|
17
|
+
api_host=row["apiHost"],
|
|
18
|
+
token=row["token"],
|
|
19
|
+
state_dir=tmp_path,
|
|
20
|
+
)
|
|
21
|
+
assert connection.name == row["name"]
|
|
22
|
+
assert connection.identity == row["identity"]
|
|
23
|
+
assert row["token"] not in repr(connection)
|
|
24
|
+
context = connection.pull_request_context(repo, source="owner/repo")
|
|
25
|
+
assert context.source == connection.repository("owner/repo")
|
|
26
|
+
assert context.targets == (context.source,)
|
|
27
|
+
assert context.observer.store.root == tmp_path
|
|
28
|
+
assert context.observer.reader.state_dir == tmp_path / "forge-http"
|
|
29
|
+
explicit = connection.pull_request_context(
|
|
30
|
+
repo, source="owner/repo", targets=("upstream/repo",)
|
|
31
|
+
)
|
|
32
|
+
assert explicit.targets == (connection.repository("upstream/repo"),)
|
|
33
|
+
with pytest.raises(ValueError):
|
|
34
|
+
connection.pull_request_context(repo, source="owner/repo", targets=())
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_access_configuration_requires_credentials_and_valid_timeout():
|
|
38
|
+
assert detect_provider("github.example") == "github"
|
|
39
|
+
assert detect_provider("forge.example", "github") == "github"
|
|
40
|
+
assert detect_provider("forge.example") == "gitlab"
|
|
41
|
+
with pytest.raises(ForgeAuthError):
|
|
42
|
+
ForgeConnection("github.com", " ")
|
|
43
|
+
with pytest.raises(ValueError):
|
|
44
|
+
ForgeConnection("github.com", "fixture", timeout=0)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@pytest.mark.parametrize("provider", ["github", "gitlab"])
|
|
48
|
+
def test_endpoint_override_preserves_identity_across_repository_queries(
|
|
49
|
+
tmp_path, monkeypatch, provider
|
|
50
|
+
):
|
|
51
|
+
"""@case Logical Forge identity survives endpoint overrides and nested repository clients."""
|
|
52
|
+
from repokit.forge._rest import RestClient
|
|
53
|
+
|
|
54
|
+
connection = ForgeConnection(
|
|
55
|
+
"Forge.Example", "fixture", provider=provider, api_host="API.Example", state_dir=tmp_path
|
|
56
|
+
)
|
|
57
|
+
calls = []
|
|
58
|
+
|
|
59
|
+
def get(client, path, **params):
|
|
60
|
+
calls.append((client.base_url, path))
|
|
61
|
+
row = {"id": 17, "full_name": "owner/repo", "path_with_namespace": "owner/repo"}
|
|
62
|
+
return [row] if path in {"user/repos", "projects"} else row
|
|
63
|
+
|
|
64
|
+
def get_all(client, path, **params):
|
|
65
|
+
calls.append((client.base_url, path))
|
|
66
|
+
return []
|
|
67
|
+
|
|
68
|
+
monkeypatch.setattr(RestClient, "get", get)
|
|
69
|
+
monkeypatch.setattr(RestClient, "get_all", get_all)
|
|
70
|
+
client = (
|
|
71
|
+
connection.client("owner/repo").for_repository("upstream/repo").for_repository("owner/repo")
|
|
72
|
+
)
|
|
73
|
+
assert client.repository() == connection.repository("owner/repo")
|
|
74
|
+
assert client.list_repositories().items == (connection.repository("owner/repo"),)
|
|
75
|
+
assert (
|
|
76
|
+
client.prs_for_branch("feature", source_repository=connection.repository("owner/repo"))
|
|
77
|
+
== []
|
|
78
|
+
)
|
|
79
|
+
assert calls and all(url.startswith("https://api.example/api/") for url, _ in calls)
|
|
80
|
+
foreign = ForgeConnection("other.example", "fixture", provider=provider)
|
|
81
|
+
with pytest.raises(ValueError, match="does not belong"):
|
|
82
|
+
client.prs_for_branch("feature", source_repository=foreign.repository("owner/repo"))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_mixed_case_public_github_routes_to_public_api(tmp_path, monkeypatch):
|
|
86
|
+
"""@case Host case does not select the Enterprise endpoint."""
|
|
87
|
+
from repokit.forge._rest import RestClient
|
|
88
|
+
|
|
89
|
+
calls = []
|
|
90
|
+
|
|
91
|
+
def get(client, path, **params):
|
|
92
|
+
calls.append(client.base_url)
|
|
93
|
+
return []
|
|
94
|
+
|
|
95
|
+
monkeypatch.setattr(RestClient, "get", get)
|
|
96
|
+
connection = ForgeConnection("GitHub.com", "fixture", state_dir=tmp_path)
|
|
97
|
+
connection.client("owner/repo").list_repositories()
|
|
98
|
+
assert calls == ["https://api.github.com"]
|
|
99
|
+
assert connection.identity == ForgeConnection("github.com", "fixture").identity
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import copy
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from repokit import decode_validation_report
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def report(checkout):
|
|
10
|
+
checks = [
|
|
11
|
+
{
|
|
12
|
+
"check": name,
|
|
13
|
+
"requestedComponents": ["."],
|
|
14
|
+
"status": "passed",
|
|
15
|
+
"scope": "full",
|
|
16
|
+
"command": ["make", name],
|
|
17
|
+
"files": [],
|
|
18
|
+
"reason": "fixture",
|
|
19
|
+
"reused": False,
|
|
20
|
+
"reuseReason": "no previous check",
|
|
21
|
+
"checkedAt": "2026-11-02T00:00:00Z",
|
|
22
|
+
"inputFingerprint": "sha256:inputs",
|
|
23
|
+
"environment": "test",
|
|
24
|
+
"witnesses": {},
|
|
25
|
+
"stdout": "",
|
|
26
|
+
"stderr": "",
|
|
27
|
+
}
|
|
28
|
+
for name in ("lint", "test")
|
|
29
|
+
]
|
|
30
|
+
return {
|
|
31
|
+
"schemaVersion": 1,
|
|
32
|
+
"checkout": str(checkout),
|
|
33
|
+
"snapshot": "sha256:snapshot",
|
|
34
|
+
"inputsConsistent": True,
|
|
35
|
+
"complete": True,
|
|
36
|
+
"passed": True,
|
|
37
|
+
"components": [{"component": {"root": "."}, "checks": checks}],
|
|
38
|
+
"diagnostics": [],
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_failed_report_and_partial_fallback_groups_are_valid(tmp_path):
|
|
43
|
+
value = report(tmp_path)
|
|
44
|
+
lint, test = value["components"][0]["checks"]
|
|
45
|
+
lint["requestedComponents"] = test["requestedComponents"] = ["child"]
|
|
46
|
+
test["status"] = "failed"
|
|
47
|
+
value["passed"] = False
|
|
48
|
+
value["components"] = [
|
|
49
|
+
{"component": {"root": "."}, "checks": [lint]},
|
|
50
|
+
{"component": {"root": "child"}, "checks": [test]},
|
|
51
|
+
]
|
|
52
|
+
assert (
|
|
53
|
+
decode_validation_report(
|
|
54
|
+
json.dumps(value), checkout=tmp_path, checks=("format", "lint", "test")
|
|
55
|
+
)
|
|
56
|
+
== value
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@pytest.mark.parametrize(
|
|
61
|
+
"mutation", ["checkout", "schema", "missing", "duplicate", "escape", "shape", "bool"]
|
|
62
|
+
)
|
|
63
|
+
def test_invalid_report_is_not_validation_evidence(tmp_path, mutation):
|
|
64
|
+
value = report(tmp_path)
|
|
65
|
+
if mutation == "checkout":
|
|
66
|
+
value["checkout"] = str(tmp_path / "other")
|
|
67
|
+
elif mutation == "schema":
|
|
68
|
+
value["schemaVersion"] = 2
|
|
69
|
+
elif mutation == "missing":
|
|
70
|
+
value["components"][0]["checks"].pop()
|
|
71
|
+
elif mutation == "duplicate":
|
|
72
|
+
value["components"] *= 2
|
|
73
|
+
elif mutation == "escape":
|
|
74
|
+
value["components"][0]["component"]["root"] = "../outside"
|
|
75
|
+
elif mutation == "shape":
|
|
76
|
+
value["components"] = [None]
|
|
77
|
+
else:
|
|
78
|
+
value["complete"] = 1
|
|
79
|
+
with pytest.raises(ValueError):
|
|
80
|
+
decode_validation_report(json.dumps(value), checkout=tmp_path)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@pytest.mark.parametrize("sources", [[["child"]], [{"root": "."}], [1], [".", "."], []])
|
|
84
|
+
def test_requested_component_identity_must_be_unique_strings(tmp_path, sources):
|
|
85
|
+
value = copy.deepcopy(report(tmp_path))
|
|
86
|
+
value["components"][0]["checks"][0]["requestedComponents"] = sources
|
|
87
|
+
with pytest.raises(ValueError, match="requestedComponents"):
|
|
88
|
+
decode_validation_report(json.dumps(value), checkout=tmp_path)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@pytest.mark.parametrize(
|
|
92
|
+
"status,complete,passed",
|
|
93
|
+
[
|
|
94
|
+
("passed", True, True),
|
|
95
|
+
("failed", True, False),
|
|
96
|
+
("skipped", True, True),
|
|
97
|
+
("unavailable", True, False),
|
|
98
|
+
("cancelled", False, False),
|
|
99
|
+
("uncertain", False, False),
|
|
100
|
+
],
|
|
101
|
+
)
|
|
102
|
+
def test_aggregate_outcome_matches_checks(tmp_path, status, complete, passed):
|
|
103
|
+
"""@case Known failure is valid evidence; contradictory aggregate flags are not."""
|
|
104
|
+
value = report(tmp_path)
|
|
105
|
+
value["components"][0]["checks"][0]["status"] = status
|
|
106
|
+
value.update(complete=complete, passed=passed)
|
|
107
|
+
assert decode_validation_report(json.dumps(value), checkout=tmp_path) == value
|
|
108
|
+
for flag in ("complete", "passed"):
|
|
109
|
+
invalid = copy.deepcopy(value)
|
|
110
|
+
invalid[flag] = not invalid[flag]
|
|
111
|
+
with pytest.raises(ValueError, match="contradicts"):
|
|
112
|
+
decode_validation_report(json.dumps(invalid), checkout=tmp_path)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_no_selected_tasks_is_distinct_from_empty_component_results(tmp_path):
|
|
116
|
+
value = report(tmp_path)
|
|
117
|
+
value["components"][0]["checks"] = []
|
|
118
|
+
with pytest.raises(ValueError, match="omitted requested checks"):
|
|
119
|
+
decode_validation_report(json.dumps(value), checkout=tmp_path)
|
|
120
|
+
value["components"] = []
|
|
121
|
+
assert decode_validation_report(json.dumps(value), checkout=tmp_path) == value
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_changed_inputs_cannot_claim_validation_passed(tmp_path):
|
|
125
|
+
value = report(tmp_path)
|
|
126
|
+
value["inputsConsistent"] = False
|
|
127
|
+
with pytest.raises(ValueError, match="contradicts"):
|
|
128
|
+
decode_validation_report(json.dumps(value), checkout=tmp_path)
|
|
129
|
+
value["passed"] = False
|
|
130
|
+
assert decode_validation_report(json.dumps(value), checkout=tmp_path) == value
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@pytest.mark.parametrize(
|
|
134
|
+
"status,complete,passed,checks",
|
|
135
|
+
[
|
|
136
|
+
("passed", True, True, ("format",)),
|
|
137
|
+
("unavailable", True, False, ("format",)),
|
|
138
|
+
("failed", True, False, ("format", "lint", "test")),
|
|
139
|
+
("cancelled", False, False, ("format", "lint", "test")),
|
|
140
|
+
("passed", False, False, ("format", "lint", "test")),
|
|
141
|
+
],
|
|
142
|
+
)
|
|
143
|
+
def test_format_only_and_early_termination_are_valid(tmp_path, status, complete, passed, checks):
|
|
144
|
+
"""@case Format can stop before validation and does not promise input consistency."""
|
|
145
|
+
value = report(tmp_path)
|
|
146
|
+
value.update(
|
|
147
|
+
components=[],
|
|
148
|
+
preparation=[{"checks": [{"check": "format", "status": status}]}],
|
|
149
|
+
inputsConsistent=False,
|
|
150
|
+
complete=complete,
|
|
151
|
+
passed=passed,
|
|
152
|
+
)
|
|
153
|
+
assert decode_validation_report(json.dumps(value), checkout=tmp_path, checks=checks) == value
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_failed_preparation_cannot_be_followed_by_successful_checks(tmp_path):
|
|
157
|
+
value = report(tmp_path)
|
|
158
|
+
value["preparation"] = [{"checks": [{"check": "format", "status": "failed"}]}]
|
|
159
|
+
with pytest.raises(ValueError, match="unsuccessful preparation"):
|
|
160
|
+
decode_validation_report(
|
|
161
|
+
json.dumps(value), checkout=tmp_path, checks=("format", "lint", "test")
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@pytest.mark.parametrize("checks", [("lint", "test"), ("format", "lint", "test")])
|
|
166
|
+
def test_stale_plan_can_abort_before_any_checks(tmp_path, checks):
|
|
167
|
+
"""@case Inputs changed after planning produces an incomplete, empty report."""
|
|
168
|
+
value = report(tmp_path)
|
|
169
|
+
value.update(
|
|
170
|
+
components=[],
|
|
171
|
+
inputsConsistent=False,
|
|
172
|
+
complete=False,
|
|
173
|
+
passed=False,
|
|
174
|
+
diagnostics=["inputs changed after planning; no checks executed"],
|
|
175
|
+
)
|
|
176
|
+
assert decode_validation_report(json.dumps(value), checkout=tmp_path, checks=checks) == value
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import json
|
|
2
|
-
from pathlib import Path
|
|
3
|
-
|
|
4
|
-
import pytest
|
|
5
|
-
|
|
6
|
-
from repokit.forge import ForgeConnection, detect_provider
|
|
7
|
-
from repokit.forge.model import ForgeAuthError
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
def test_connection_identity_and_context_are_shared_with_typescript(repo, tmp_path):
|
|
11
|
-
"""@case Credentials, provider and endpoint isolate caches; targets stay explicit."""
|
|
12
|
-
fixture = Path(__file__).resolve().parents[3] / "conformance/forge-connection.json"
|
|
13
|
-
for row in json.loads(fixture.read_text()):
|
|
14
|
-
connection = ForgeConnection(
|
|
15
|
-
host=row["host"],
|
|
16
|
-
provider=row["provider"],
|
|
17
|
-
api_host=row["apiHost"],
|
|
18
|
-
token=row["token"],
|
|
19
|
-
state_dir=tmp_path,
|
|
20
|
-
)
|
|
21
|
-
assert connection.name == row["name"]
|
|
22
|
-
assert connection.identity == row["identity"]
|
|
23
|
-
assert row["token"] not in repr(connection)
|
|
24
|
-
context = connection.pull_request_context(repo, source="owner/repo")
|
|
25
|
-
assert context.source == connection.repository("owner/repo")
|
|
26
|
-
assert context.targets == (context.source,)
|
|
27
|
-
assert context.observer.store.root == tmp_path
|
|
28
|
-
assert context.observer.reader.state_dir == tmp_path / "forge-http"
|
|
29
|
-
explicit = connection.pull_request_context(
|
|
30
|
-
repo, source="owner/repo", targets=("upstream/repo",)
|
|
31
|
-
)
|
|
32
|
-
assert explicit.targets == (connection.repository("upstream/repo"),)
|
|
33
|
-
with pytest.raises(ValueError):
|
|
34
|
-
connection.pull_request_context(repo, source="owner/repo", targets=())
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
def test_access_configuration_requires_credentials_and_valid_timeout():
|
|
38
|
-
assert detect_provider("github.example") == "github"
|
|
39
|
-
assert detect_provider("forge.example", "github") == "github"
|
|
40
|
-
assert detect_provider("forge.example") == "gitlab"
|
|
41
|
-
with pytest.raises(ForgeAuthError):
|
|
42
|
-
ForgeConnection("github.com", " ")
|
|
43
|
-
with pytest.raises(ValueError):
|
|
44
|
-
ForgeConnection("github.com", "fixture", timeout=0)
|
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
import copy
|
|
2
|
-
import json
|
|
3
|
-
|
|
4
|
-
import pytest
|
|
5
|
-
|
|
6
|
-
from repokit import decode_validation_report
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
def report(checkout):
|
|
10
|
-
checks = [
|
|
11
|
-
{
|
|
12
|
-
"check": name,
|
|
13
|
-
"requestedComponents": ["."],
|
|
14
|
-
"status": "passed",
|
|
15
|
-
"scope": "full",
|
|
16
|
-
"command": ["make", name],
|
|
17
|
-
"files": [],
|
|
18
|
-
"reason": "fixture",
|
|
19
|
-
"reused": False,
|
|
20
|
-
"reuseReason": "no previous check",
|
|
21
|
-
"checkedAt": "2026-11-02T00:00:00Z",
|
|
22
|
-
"inputFingerprint": "sha256:inputs",
|
|
23
|
-
"environment": "test",
|
|
24
|
-
"witnesses": {},
|
|
25
|
-
"stdout": "",
|
|
26
|
-
"stderr": "",
|
|
27
|
-
}
|
|
28
|
-
for name in ("lint", "test")
|
|
29
|
-
]
|
|
30
|
-
return {
|
|
31
|
-
"schemaVersion": 1,
|
|
32
|
-
"checkout": str(checkout),
|
|
33
|
-
"snapshot": "sha256:snapshot",
|
|
34
|
-
"inputsConsistent": True,
|
|
35
|
-
"complete": True,
|
|
36
|
-
"passed": True,
|
|
37
|
-
"components": [{"component": {"root": "."}, "checks": checks}],
|
|
38
|
-
"diagnostics": [],
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def test_failed_report_and_partial_fallback_groups_are_valid(tmp_path):
|
|
43
|
-
value = report(tmp_path)
|
|
44
|
-
lint, test = value["components"][0]["checks"]
|
|
45
|
-
lint["requestedComponents"] = test["requestedComponents"] = ["child"]
|
|
46
|
-
test["status"] = "failed"
|
|
47
|
-
value["passed"] = False
|
|
48
|
-
value["components"] = [
|
|
49
|
-
{"component": {"root": "."}, "checks": [lint]},
|
|
50
|
-
{"component": {"root": "child"}, "checks": [test]},
|
|
51
|
-
]
|
|
52
|
-
assert (
|
|
53
|
-
decode_validation_report(
|
|
54
|
-
json.dumps(value), checkout=tmp_path, checks=("format", "lint", "test")
|
|
55
|
-
)
|
|
56
|
-
== value
|
|
57
|
-
)
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
@pytest.mark.parametrize(
|
|
61
|
-
"mutation", ["checkout", "schema", "missing", "duplicate", "escape", "shape", "bool"]
|
|
62
|
-
)
|
|
63
|
-
def test_invalid_report_is_not_validation_evidence(tmp_path, mutation):
|
|
64
|
-
value = report(tmp_path)
|
|
65
|
-
if mutation == "checkout":
|
|
66
|
-
value["checkout"] = str(tmp_path / "other")
|
|
67
|
-
elif mutation == "schema":
|
|
68
|
-
value["schemaVersion"] = 2
|
|
69
|
-
elif mutation == "missing":
|
|
70
|
-
value["components"][0]["checks"].pop()
|
|
71
|
-
elif mutation == "duplicate":
|
|
72
|
-
value["components"] *= 2
|
|
73
|
-
elif mutation == "escape":
|
|
74
|
-
value["components"][0]["component"]["root"] = "../outside"
|
|
75
|
-
elif mutation == "shape":
|
|
76
|
-
value["components"] = [None]
|
|
77
|
-
else:
|
|
78
|
-
value["complete"] = 1
|
|
79
|
-
with pytest.raises(ValueError):
|
|
80
|
-
decode_validation_report(json.dumps(value), checkout=tmp_path)
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
@pytest.mark.parametrize("sources", [[["child"]], [{"root": "."}], [1], [".", "."], []])
|
|
84
|
-
def test_requested_component_identity_must_be_unique_strings(tmp_path, sources):
|
|
85
|
-
value = copy.deepcopy(report(tmp_path))
|
|
86
|
-
value["components"][0]["checks"][0]["requestedComponents"] = sources
|
|
87
|
-
with pytest.raises(ValueError, match="requestedComponents"):
|
|
88
|
-
decode_validation_report(json.dumps(value), checkout=tmp_path)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|