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.
Files changed (75) hide show
  1. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/PKG-INFO +1 -1
  2. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/pyproject.toml +1 -1
  3. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/forge/connection.py +4 -1
  4. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/forge/github.py +10 -8
  5. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/forge/gitlab.py +15 -9
  6. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/validation/report.py +57 -1
  7. compforge_repokit-0.17.2/tests/test_forge_connection.py +99 -0
  8. compforge_repokit-0.17.2/tests/test_validation_report.py +176 -0
  9. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/uv.lock +1 -1
  10. compforge_repokit-0.17.1/tests/test_forge_connection.py +0 -44
  11. compforge_repokit-0.17.1/tests/test_validation_report.py +0 -88
  12. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/.gitignore +0 -0
  13. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/AGENTS.md +0 -0
  14. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/Makefile +0 -0
  15. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/README.md +0 -0
  16. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/__init__.py +0 -0
  17. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/_inspect.py +0 -0
  18. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/_language.py +0 -0
  19. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/_layout.py +0 -0
  20. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/_process.py +0 -0
  21. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/budget.py +0 -0
  22. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/dependencies.py +0 -0
  23. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/forge/__init__.py +0 -0
  24. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/forge/_http_state.py +0 -0
  25. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/forge/_page.py +0 -0
  26. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/forge/_rest.py +0 -0
  27. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/forge/creation.py +0 -0
  28. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/forge/model.py +0 -0
  29. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/forge/observation/__init__.py +0 -0
  30. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/forge/observation/context.py +0 -0
  31. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/forge/observation/model.py +0 -0
  32. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/forge/observation/service.py +0 -0
  33. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/forge/observation/store.py +0 -0
  34. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/git/__init__.py +0 -0
  35. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/git/_catalog.py +0 -0
  36. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/git/_staging.py +0 -0
  37. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/git/index.py +0 -0
  38. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/git/operations.py +0 -0
  39. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/git/remote.py +0 -0
  40. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/git/stash.py +0 -0
  41. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/git/state.py +0 -0
  42. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/languages.json +0 -0
  43. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/model.py +0 -0
  44. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/py.typed +0 -0
  45. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/repository.py +0 -0
  46. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/snapshot.py +0 -0
  47. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/tree.py +0 -0
  48. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/validation/__init__.py +0 -0
  49. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/repokit/validation/engine.py +0 -0
  50. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/tests/conftest.py +0 -0
  51. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/tests/test_budget.py +0 -0
  52. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/tests/test_checkout_contract.py +0 -0
  53. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/tests/test_dependencies.py +0 -0
  54. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/tests/test_forge.py +0 -0
  55. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/tests/test_forge_http.py +0 -0
  56. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/tests/test_forge_repositories.py +0 -0
  57. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/tests/test_fork.py +0 -0
  58. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/tests/test_git_facts.py +0 -0
  59. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/tests/test_git_helpers.py +0 -0
  60. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/tests/test_git_index.py +0 -0
  61. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/tests/test_inspect.py +0 -0
  62. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/tests/test_metadata_contract.py +0 -0
  63. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/tests/test_operations.py +0 -0
  64. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/tests/test_pr_creation.py +0 -0
  65. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/tests/test_pr_observation.py +0 -0
  66. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/tests/test_process.py +0 -0
  67. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/tests/test_rebase.py +0 -0
  68. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/tests/test_repository_metadata.py +0 -0
  69. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/tests/test_repository_queries.py +0 -0
  70. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/tests/test_scalar_contract.py +0 -0
  71. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/tests/test_snapshot_scopes.py +0 -0
  72. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/tests/test_staging.py +0 -0
  73. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/tests/test_stash.py +0 -0
  74. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/tests/test_tree.py +0 -0
  75. {compforge_repokit-0.17.1 → compforge_repokit-0.17.2}/tests/test_validation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: compforge-repokit
3
- Version: 0.17.1
3
+ Version: 0.17.2
4
4
  Summary: Native repository inspection, content identity, Git and Forge operations
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: harness-common<0.7,>=0.6
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "compforge-repokit"
3
- version = "0.17.1"
3
+ version = "0.17.2"
4
4
  description = "Native repository inspection, content identity, Git and Forge operations"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -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
- ForgeIdentity("github" if self.host == "github.com" else self.host),
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 = ForgeIdentity("github" if self.host == "github.com" else self.host)
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
- ForgeIdentity("gitlab" if self.host == "gitlab.com" else self.host),
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, path, self._token, timeout=self.timeout, state_dir=self.state_dir
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 = ForgeIdentity("gitlab" if self.host == "gitlab.com" else self.host)
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
- selected = set(checks) - {"format"}
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
@@ -17,7 +17,7 @@ wheels = [
17
17
 
18
18
  [[package]]
19
19
  name = "compforge-repokit"
20
- version = "0.17.1"
20
+ version = "0.17.2"
21
21
  source = { editable = "." }
22
22
  dependencies = [
23
23
  { name = "harness-common" },
@@ -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)