PTSIP 0.3.1__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.
Files changed (66) hide show
  1. ptsip/__init__.py +3 -0
  2. ptsip/__main__.py +4 -0
  3. ptsip/app/__init__.py +5 -0
  4. ptsip/app/client.py +53 -0
  5. ptsip/app/github_client.py +200 -0
  6. ptsip/app/server.py +130 -0
  7. ptsip/app/service.py +285 -0
  8. ptsip/app/store.py +233 -0
  9. ptsip/artifact_evidence.py +150 -0
  10. ptsip/build_resolution.py +298 -0
  11. ptsip/clarification/__init__.py +6 -0
  12. ptsip/clarification/generator.py +120 -0
  13. ptsip/clarification/generator_core.py +98 -0
  14. ptsip/clarification/i18n.py +106 -0
  15. ptsip/clarification/model.py +33 -0
  16. ptsip/clarification/render.py +78 -0
  17. ptsip/clarification/resolution/__init__.py +29 -0
  18. ptsip/clarification/resolution/model.py +33 -0
  19. ptsip/clarification/resolution/parser.py +56 -0
  20. ptsip/clarification/resolution/profile_projection.py +198 -0
  21. ptsip/clarification/resolution/resolver.py +30 -0
  22. ptsip/clarification/transports/__init__.py +1 -0
  23. ptsip/clarification/transports/github_issue.py +162 -0
  24. ptsip/cli.py +402 -0
  25. ptsip/conformance.py +531 -0
  26. ptsip/conformance_audit.py +143 -0
  27. ptsip/conformance_engine.py +396 -0
  28. ptsip/constants.py +9 -0
  29. ptsip/doctor.py +33 -0
  30. ptsip/inspection/__init__.py +1 -0
  31. ptsip/inspection/components.py +74 -0
  32. ptsip/inspection/dependencies.py +496 -0
  33. ptsip/inspection/dependencies_030.py +69 -0
  34. ptsip/inspection/dotnet.py +188 -0
  35. ptsip/inspection/go.py +157 -0
  36. ptsip/inspection/inventory.py +161 -0
  37. ptsip/inspection/javascript.py +376 -0
  38. ptsip/inspection/lexing.py +121 -0
  39. ptsip/inspection/source_adapters.py +60 -0
  40. ptsip/lifecycle_evidence.py +245 -0
  41. ptsip/model.py +105 -0
  42. ptsip/pilot/__init__.py +1 -0
  43. ptsip/pilot/runner.py +170 -0
  44. ptsip/repository/__init__.py +1 -0
  45. ptsip/repository/discover.py +68 -0
  46. ptsip/repository/remote.py +54 -0
  47. ptsip/repository/snapshot.py +173 -0
  48. ptsip/review_evidence.py +406 -0
  49. ptsip/spec_identity.py +22 -0
  50. ptsip/specdata/ptsip-agent-classification.schema.json +35 -0
  51. ptsip/specdata/ptsip-artifact-evidence.schema.json +68 -0
  52. ptsip/specdata/ptsip-diagnostic.schema.json +29 -0
  53. ptsip/specdata/ptsip-profile.schema.json +310 -0
  54. ptsip/specdata/ptsip-registry.yaml +189 -0
  55. ptsip/storage/__init__.py +1 -0
  56. ptsip/storage/local_state.py +32 -0
  57. ptsip/validation/__init__.py +1 -0
  58. ptsip/validation/components.py +164 -0
  59. ptsip/validation/profile.py +172 -0
  60. ptsip/validation/rules.py +163 -0
  61. ptsip-0.3.1.dist-info/METADATA +338 -0
  62. ptsip-0.3.1.dist-info/RECORD +66 -0
  63. ptsip-0.3.1.dist-info/WHEEL +5 -0
  64. ptsip-0.3.1.dist-info/entry_points.txt +3 -0
  65. ptsip-0.3.1.dist-info/licenses/LICENSE +201 -0
  66. ptsip-0.3.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,78 @@
1
+ from __future__ import annotations
2
+
3
+ from .i18n import question, text
4
+ from .model import ClarificationRequest
5
+
6
+
7
+ def request_payload(request: ClarificationRequest, language: str) -> dict[str, object]:
8
+ payload = request.as_dict()
9
+ payload["questions"] = [question(field, language) for field in request.missing_fields]
10
+ return payload
11
+
12
+
13
+ def render_issue(request: ClarificationRequest, language: str, repository_revision: str | None) -> tuple[str, str]:
14
+ title = text(language, "title", component_id=request.component_id)
15
+ paths = "\n".join(f"- `{item}`" for item in request.include) or "- `<unknown>`"
16
+ reasons = "\n".join(f"- `{item}`" for item in request.reason_codes)
17
+ questions: list[str] = []
18
+ for index, field in enumerate(request.missing_fields, start=1):
19
+ item = question(field, language)
20
+ lines = [f"{index}. {item['prompt']}"]
21
+ for option in item.get("options", []):
22
+ lines.append(f" - `{option['value']}` — {option['label']}")
23
+ questions.append("\n".join(lines))
24
+ reply_lines = [
25
+ "format: ptsip-clarification-answer/v1",
26
+ "decision:",
27
+ " classification: PRODUCT|TOOLCHAIN|NEUTRAL_CONTRACT",
28
+ ' purpose: "<short description>"',
29
+ " shipped: YES|NO",
30
+ " runtime_required: YES|NO",
31
+ " lifecycle_owner: PRODUCT|DEVELOPMENT_TOOLING|INDEPENDENT",
32
+ " executable: YES|NO",
33
+ ]
34
+ revision = repository_revision or "UNKNOWN"
35
+ body = f"""{text(language, 'intro')}
36
+
37
+ {text(language, 'no_llm')}
38
+
39
+ ### Component
40
+ - ID: `{request.component_id}`
41
+ - Repository revision: `{revision}`
42
+ - Candidate paths:
43
+ {paths}
44
+
45
+ ### Why PTSIP is asking
46
+ {reasons}
47
+
48
+ ### Questions
49
+
50
+ {chr(10).join(questions)}
51
+
52
+ ### Structured reply
53
+
54
+ {text(language, 'reply')}
55
+
56
+ ```yaml
57
+ {chr(10).join(reply_lines)}
58
+ ```
59
+
60
+ A coding agent may also resolve this decision in an active user chat. The first valid resolution wins; after the decision is resolved, late Issue replies are ignored.
61
+
62
+ <!-- ptsip-clarification-id: {request.id} -->
63
+ """
64
+ return title, body
65
+
66
+
67
+ def render_console(requests: tuple[ClarificationRequest, ...], language: str) -> str:
68
+ if not requests:
69
+ return text(language, "none")
70
+ lines = [f"{text(language, 'required')}: {len(requests)}"]
71
+ for request in requests:
72
+ lines.append("")
73
+ lines.append(f"{request.component_id} [{request.status.value}]")
74
+ for path in request.include:
75
+ lines.append(f" path: {path}")
76
+ for field in request.missing_fields:
77
+ lines.append(f" - {question(field, language)['prompt']}")
78
+ return "\n".join(lines)
@@ -0,0 +1,29 @@
1
+ from .model import DecisionAnswer, ResolutionValidation
2
+ from .parser import ANSWER_FORMAT, parse_answer
3
+ from .profile_projection import (
4
+ PreparedLocalProfile,
5
+ apply_local_profile,
6
+ dump_payload,
7
+ load_profile_text,
8
+ prepare_local_profile,
9
+ project_payload,
10
+ validate_projected_payload,
11
+ write_prepared_local_profile,
12
+ )
13
+ from .resolver import validate_answer
14
+
15
+ __all__ = [
16
+ "ANSWER_FORMAT",
17
+ "DecisionAnswer",
18
+ "PreparedLocalProfile",
19
+ "ResolutionValidation",
20
+ "apply_local_profile",
21
+ "dump_payload",
22
+ "load_profile_text",
23
+ "parse_answer",
24
+ "prepare_local_profile",
25
+ "project_payload",
26
+ "validate_answer",
27
+ "validate_projected_payload",
28
+ "write_prepared_local_profile",
29
+ ]
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, dataclass
4
+
5
+ CLASSIFICATIONS = ("PRODUCT", "TOOLCHAIN", "NEUTRAL_CONTRACT")
6
+ LIFECYCLE_OWNERS = ("PRODUCT", "DEVELOPMENT_TOOLING", "INDEPENDENT")
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class DecisionAnswer:
11
+ classification: str
12
+ purpose: str
13
+ shipped: bool
14
+ runtime_required: bool
15
+ lifecycle_owner: str
16
+ executable: bool
17
+
18
+ def as_dict(self) -> dict[str, object]:
19
+ return asdict(self)
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class ResolutionValidation:
24
+ valid: bool
25
+ status: str
26
+ errors: tuple[str, ...]
27
+
28
+ def as_dict(self) -> dict[str, object]:
29
+ return {
30
+ "valid": self.valid,
31
+ "status": self.status,
32
+ "errors": list(self.errors),
33
+ }
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ import yaml
6
+
7
+ from .model import DecisionAnswer
8
+
9
+ ANSWER_FORMAT = "ptsip-clarification-answer/v1"
10
+
11
+
12
+ def _boolean(value: object, field: str) -> bool:
13
+ if isinstance(value, bool):
14
+ return value
15
+ if isinstance(value, str):
16
+ normalized = value.strip().upper()
17
+ if normalized in {"YES", "TRUE"}:
18
+ return True
19
+ if normalized in {"NO", "FALSE"}:
20
+ return False
21
+ raise ValueError(f"{field} must be YES or NO")
22
+
23
+
24
+ def _mapping(text: str) -> dict[str, object]:
25
+ blocks = re.findall(r"```(?:ya?ml)?\s*\n(.*?)```", text, flags=re.IGNORECASE | re.DOTALL)
26
+ candidates = [*reversed(blocks), text]
27
+ for candidate in candidates:
28
+ try:
29
+ payload = yaml.safe_load(candidate)
30
+ except yaml.YAMLError:
31
+ continue
32
+ if isinstance(payload, dict):
33
+ if payload.get("format") == ANSWER_FORMAT or isinstance(payload.get("decision"), dict):
34
+ return payload
35
+ raise ValueError("No structured PTSIP clarification answer was found")
36
+
37
+
38
+ def parse_answer(text: str) -> DecisionAnswer:
39
+ payload = _mapping(text)
40
+ if payload.get("format") not in (None, ANSWER_FORMAT):
41
+ raise ValueError(f"format must be {ANSWER_FORMAT!r}")
42
+ raw = payload.get("decision", payload)
43
+ if not isinstance(raw, dict):
44
+ raise ValueError("decision must be a mapping")
45
+ classification = str(raw.get("classification", "")).strip().upper()
46
+ purpose = str(raw.get("purpose", "")).strip()
47
+ lifecycle_owner = str(raw.get("lifecycle_owner", "")).strip().upper()
48
+ executable = _boolean(raw.get("executable"), "executable")
49
+ return DecisionAnswer(
50
+ classification=classification,
51
+ purpose=purpose,
52
+ shipped=_boolean(raw.get("shipped"), "shipped"),
53
+ runtime_required=_boolean(raw.get("runtime_required"), "runtime_required"),
54
+ lifecycle_owner=lifecycle_owner,
55
+ executable=executable,
56
+ )
@@ -0,0 +1,198 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import tempfile
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ import yaml
9
+ from jsonschema import Draft202012Validator
10
+
11
+ from ...constants import SPEC_REVISION, SPEC_SOURCE, SPEC_VERSION
12
+ from ...validation.profile import _root_overlap_errors, _schema, validate_profile
13
+ from .model import DecisionAnswer
14
+
15
+ DEFAULT_POLICIES: dict[str, str] = {
16
+ "product_to_toolchain_runtime_dependency": "deny",
17
+ "toolchain_in_product_package": "deny",
18
+ "independent_build_resolution": "required",
19
+ }
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class PreparedLocalProfile:
24
+ path: Path
25
+ content: str
26
+ expected_source: str | None
27
+
28
+
29
+ def _base_profile() -> dict[str, object]:
30
+ return {
31
+ "ptsip": {
32
+ "version": SPEC_VERSION,
33
+ "specification": {
34
+ "source": SPEC_SOURCE,
35
+ "revision": SPEC_REVISION,
36
+ },
37
+ },
38
+ "components": [],
39
+ "policies": dict(DEFAULT_POLICIES),
40
+ }
41
+
42
+
43
+ def _boundary_root(selector: str) -> str:
44
+ normalized = selector.replace("\\", "/").strip().strip("/")
45
+ if normalized.endswith("/**"):
46
+ normalized = normalized[:-3].rstrip("/")
47
+ if not normalized or any(char in normalized for char in "*?[]"):
48
+ raise ValueError(f"Boundary profile cannot safely represent selector {selector!r}")
49
+ return normalized
50
+
51
+
52
+ def _set_missing_or_require_equal(component: dict[str, object], key: str, expected: object) -> None:
53
+ if key not in component or component[key] in (None, ""):
54
+ component[key] = expected
55
+ return
56
+ if component[key] != expected:
57
+ raise ValueError(
58
+ f"Existing component {component.get('id')!r} declares {key}={component[key]!r}, "
59
+ f"which conflicts with the resolved decision value {expected!r}"
60
+ )
61
+
62
+
63
+ def project_payload(
64
+ existing: dict[str, object] | None,
65
+ component_id: str,
66
+ include: tuple[str, ...] | list[str],
67
+ answer: DecisionAnswer,
68
+ ) -> dict[str, object]:
69
+ payload = copy.deepcopy(existing) if existing is not None else _base_profile()
70
+ if not isinstance(payload, dict):
71
+ raise ValueError("PTSIP profile root must be a mapping")
72
+
73
+ if "boundaries" in payload and "components" not in payload:
74
+ boundaries = payload.get("boundaries")
75
+ if not isinstance(boundaries, dict):
76
+ raise ValueError("boundaries must be a mapping")
77
+ key = {
78
+ "PRODUCT": "product",
79
+ "TOOLCHAIN": "toolchain",
80
+ "NEUTRAL_CONTRACT": "neutral_contract",
81
+ }[answer.classification]
82
+ target = boundaries.setdefault(key, {"roots": []})
83
+ if not isinstance(target, dict):
84
+ raise ValueError(f"boundaries.{key} must be a mapping")
85
+ roots = target.setdefault("roots", [])
86
+ if not isinstance(roots, list):
87
+ raise ValueError(f"boundaries.{key}.roots must be a list")
88
+ for selector in include:
89
+ root = _boundary_root(str(selector))
90
+ if root not in roots:
91
+ roots.append(root)
92
+ return payload
93
+
94
+ components = payload.setdefault("components", [])
95
+ if not isinstance(components, list):
96
+ raise ValueError("components must be a list")
97
+ payload.pop("boundaries", None)
98
+
99
+ component: dict[str, object] | None = None
100
+ for item in components:
101
+ if isinstance(item, dict) and str(item.get("id")) == component_id:
102
+ component = item
103
+ break
104
+ if component is None:
105
+ component = {"id": component_id, "include": [str(item) for item in include]}
106
+ components.append(component)
107
+ elif "include" not in component:
108
+ component["include"] = [str(item) for item in include]
109
+
110
+ # Clarification fills missing declaration facts. It must not silently
111
+ # reclassify or rewrite facts the profile already declares. Existing
112
+ # include selectors are preserved so resolving one detected candidate does
113
+ # not accidentally narrow a broader project-owned component boundary.
114
+ _set_missing_or_require_equal(component, "classification", answer.classification)
115
+ _set_missing_or_require_equal(component, "purpose", answer.purpose)
116
+ _set_missing_or_require_equal(component, "shipped", answer.shipped)
117
+ _set_missing_or_require_equal(component, "executable", answer.executable)
118
+ _set_missing_or_require_equal(component, "release_owner", answer.lifecycle_owner)
119
+ return payload
120
+
121
+
122
+ def validate_projected_payload(payload: dict[str, object]) -> tuple[str, ...]:
123
+ errors = [
124
+ f"{'.'.join(str(part) for part in item.absolute_path) or '<root>'}: {item.message}"
125
+ for item in sorted(Draft202012Validator(_schema()).iter_errors(payload), key=lambda value: list(value.absolute_path))
126
+ ]
127
+ errors.extend(_root_overlap_errors(payload))
128
+ return tuple(errors)
129
+
130
+
131
+ def dump_payload(payload: dict[str, object]) -> str:
132
+ return yaml.safe_dump(payload, sort_keys=False, allow_unicode=True)
133
+
134
+
135
+ def load_profile_text(text: str | None) -> dict[str, object] | None:
136
+ if text is None:
137
+ return None
138
+ payload = yaml.safe_load(text)
139
+ if not isinstance(payload, dict):
140
+ raise ValueError("PTSIP profile root must be a mapping")
141
+ return payload
142
+
143
+
144
+ def prepare_local_profile(
145
+ repository_root: str | Path,
146
+ component_id: str,
147
+ include: tuple[str, ...] | list[str],
148
+ answer: DecisionAnswer,
149
+ ) -> PreparedLocalProfile:
150
+ root = Path(repository_root).resolve()
151
+ profile = root / "ptsip.yaml"
152
+ expected_source = profile.read_text(encoding="utf-8-sig") if profile.is_file() else None
153
+ existing = load_profile_text(expected_source)
154
+ projected = project_payload(existing, component_id, include, answer)
155
+ content = dump_payload(projected)
156
+
157
+ with tempfile.NamedTemporaryFile("w", suffix=".yaml", dir=root, delete=False, encoding="utf-8", newline="\n") as handle:
158
+ temp = Path(handle.name)
159
+ handle.write(content)
160
+ try:
161
+ result = validate_profile(root, temp)
162
+ if not result.valid:
163
+ raise ValueError("Projected PTSIP profile is invalid: " + "; ".join(result.errors))
164
+ finally:
165
+ if temp.exists():
166
+ temp.unlink()
167
+ return PreparedLocalProfile(profile, content, expected_source)
168
+
169
+
170
+ def write_prepared_local_profile(prepared: PreparedLocalProfile) -> Path:
171
+ current_source = prepared.path.read_text(encoding="utf-8-sig") if prepared.path.is_file() else None
172
+ if current_source != prepared.expected_source:
173
+ raise RuntimeError("ptsip.yaml changed after decision projection validation; refusing to overwrite concurrent changes")
174
+ with tempfile.NamedTemporaryFile(
175
+ "w",
176
+ suffix=".yaml",
177
+ dir=prepared.path.parent,
178
+ delete=False,
179
+ encoding="utf-8",
180
+ newline="\n",
181
+ ) as handle:
182
+ temp = Path(handle.name)
183
+ handle.write(prepared.content)
184
+ try:
185
+ temp.replace(prepared.path)
186
+ finally:
187
+ if temp.exists():
188
+ temp.unlink()
189
+ return prepared.path
190
+
191
+
192
+ def apply_local_profile(
193
+ repository_root: str | Path,
194
+ component_id: str,
195
+ include: tuple[str, ...] | list[str],
196
+ answer: DecisionAnswer,
197
+ ) -> Path:
198
+ return write_prepared_local_profile(prepare_local_profile(repository_root, component_id, include, answer))
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ from .model import CLASSIFICATIONS, LIFECYCLE_OWNERS, DecisionAnswer, ResolutionValidation
4
+
5
+
6
+ def validate_answer(answer: DecisionAnswer) -> ResolutionValidation:
7
+ errors: list[str] = []
8
+ if answer.classification not in CLASSIFICATIONS:
9
+ errors.append("classification must be PRODUCT, TOOLCHAIN, or NEUTRAL_CONTRACT")
10
+ if not answer.purpose:
11
+ errors.append("purpose must be non-empty")
12
+ if answer.lifecycle_owner not in LIFECYCLE_OWNERS:
13
+ errors.append("lifecycle_owner must be PRODUCT, DEVELOPMENT_TOOLING, or INDEPENDENT")
14
+
15
+ if answer.classification == "PRODUCT" and answer.lifecycle_owner != "PRODUCT":
16
+ errors.append("PRODUCT classification requires PRODUCT lifecycle ownership")
17
+ if answer.classification == "TOOLCHAIN":
18
+ if answer.lifecycle_owner != "DEVELOPMENT_TOOLING":
19
+ errors.append("TOOLCHAIN classification requires DEVELOPMENT_TOOLING lifecycle ownership")
20
+ if answer.shipped:
21
+ errors.append("TOOLCHAIN component cannot be declared as shipped with the Product")
22
+ if answer.runtime_required:
23
+ errors.append("TOOLCHAIN component cannot be required by the Product at runtime")
24
+ if answer.classification == "NEUTRAL_CONTRACT":
25
+ if answer.executable:
26
+ errors.append("NEUTRAL_CONTRACT must be non-executable")
27
+ if answer.lifecycle_owner != "INDEPENDENT":
28
+ errors.append("NEUTRAL_CONTRACT requires INDEPENDENT lifecycle ownership")
29
+
30
+ return ResolutionValidation(not errors, "RESOLVED" if not errors else "CONFLICT", tuple(errors))
@@ -0,0 +1 @@
1
+ """Explicit side-effect transports for PTSIP clarification requests."""
@@ -0,0 +1,162 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ import shutil
6
+ import subprocess
7
+ from dataclasses import asdict, dataclass
8
+ from pathlib import Path
9
+
10
+ from ...repository.remote import RepositoryRemote
11
+ from ...storage.local_state import clarification_directory
12
+ from ..model import ClarificationRequest
13
+ from ..render import render_issue
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class GitHubIssuePublication:
18
+ clarification_id: str
19
+ component_id: str
20
+ repository: str
21
+ status: str
22
+ issue_number: int | None
23
+ issue_url: str
24
+
25
+ def as_dict(self) -> dict[str, object]:
26
+ return asdict(self)
27
+
28
+
29
+ def _valid_repo(value: str) -> bool:
30
+ return bool(re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", value.strip()))
31
+
32
+
33
+ def resolve_repository(remote: RepositoryRemote | None, override: str | None) -> str:
34
+ if override:
35
+ target = override.strip()
36
+ if not _valid_repo(target):
37
+ raise ValueError("--repo must use GitHub owner/repository form")
38
+ return target
39
+ if remote and remote.provider == "github" and remote.repository:
40
+ return remote.repository
41
+ raise RuntimeError(
42
+ "No GitHub origin was detected. Use --repo owner/repository to select the Consumer Repository explicitly."
43
+ )
44
+
45
+
46
+ def _state_path(repository_root: str | Path) -> Path:
47
+ return clarification_directory(repository_root) / "state.json"
48
+
49
+
50
+ def _load_state(repository_root: str | Path) -> dict[str, object]:
51
+ path = _state_path(repository_root)
52
+ if not path.exists():
53
+ return {"format": "ptsip-clarification-state/v1", "issues": {}}
54
+ try:
55
+ payload = json.loads(path.read_text(encoding="utf-8"))
56
+ except (OSError, json.JSONDecodeError) as exc:
57
+ raise RuntimeError(f"Unable to read clarification state: {exc}") from exc
58
+ if not isinstance(payload, dict) or not isinstance(payload.get("issues"), dict):
59
+ raise RuntimeError("Clarification state is not a valid ptsip-clarification-state/v1 document.")
60
+ return payload
61
+
62
+
63
+ def _save_state(repository_root: str | Path, state: dict[str, object]) -> None:
64
+ path = _state_path(repository_root)
65
+ path.parent.mkdir(parents=True, exist_ok=True)
66
+ temp = path.with_suffix(".tmp")
67
+ temp.write_text(json.dumps(state, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
68
+ temp.replace(path)
69
+
70
+
71
+ def _run_gh(args: list[str], input_text: str | None = None) -> subprocess.CompletedProcess[str]:
72
+ return subprocess.run(
73
+ ["gh", *args],
74
+ input=input_text,
75
+ stdout=subprocess.PIPE,
76
+ stderr=subprocess.PIPE,
77
+ text=True,
78
+ check=False,
79
+ )
80
+
81
+
82
+ def _issue_number(url: str) -> int | None:
83
+ match = re.search(r"/issues/(\d+)(?:$|[/?#])", url)
84
+ return int(match.group(1)) if match else None
85
+
86
+
87
+ def publish(
88
+ repository_root: str | Path,
89
+ remote: RepositoryRemote | None,
90
+ repository_revision: str | None,
91
+ requests: tuple[ClarificationRequest, ...],
92
+ language: str,
93
+ repo_override: str | None = None,
94
+ ) -> tuple[GitHubIssuePublication, ...]:
95
+ if not requests:
96
+ return ()
97
+ target = resolve_repository(remote, repo_override)
98
+ state = _load_state(repository_root)
99
+ issues = state["issues"]
100
+ assert isinstance(issues, dict)
101
+ needs_create = any(
102
+ not (
103
+ isinstance(issues.get(request.id), dict)
104
+ and issues[request.id].get("repository") == target
105
+ and issues[request.id].get("url")
106
+ )
107
+ for request in requests
108
+ )
109
+ if needs_create:
110
+ if shutil.which("gh") is None:
111
+ raise RuntimeError("GitHub CLI 'gh' is required for --publish github-issue.")
112
+ auth = _run_gh(["auth", "status", "--hostname", "github.com"])
113
+ if auth.returncode != 0:
114
+ detail = auth.stderr.strip() or auth.stdout.strip() or "authentication failed"
115
+ raise RuntimeError(f"GitHub CLI authentication is not ready: {detail}")
116
+
117
+ results: list[GitHubIssuePublication] = []
118
+ for request in requests:
119
+ existing = issues.get(request.id)
120
+ if isinstance(existing, dict) and existing.get("repository") == target and existing.get("url"):
121
+ results.append(
122
+ GitHubIssuePublication(
123
+ clarification_id=request.id,
124
+ component_id=request.component_id,
125
+ repository=target,
126
+ status="EXISTING",
127
+ issue_number=existing.get("number") if isinstance(existing.get("number"), int) else None,
128
+ issue_url=str(existing["url"]),
129
+ )
130
+ )
131
+ continue
132
+
133
+ title, body = render_issue(request, language, repository_revision)
134
+ created = _run_gh(
135
+ ["issue", "create", "--repo", target, "--title", title, "--body-file", "-"],
136
+ input_text=body,
137
+ )
138
+ if created.returncode != 0:
139
+ detail = created.stderr.strip() or created.stdout.strip() or "issue creation failed"
140
+ raise RuntimeError(f"GitHub issue creation failed for {request.component_id}: {detail}")
141
+ url = next((line.strip() for line in reversed(created.stdout.splitlines()) if line.strip()), "")
142
+ if not url:
143
+ raise RuntimeError(f"GitHub issue creation returned no issue URL for {request.component_id}.")
144
+ number = _issue_number(url)
145
+ issues[request.id] = {
146
+ "repository": target,
147
+ "number": number,
148
+ "url": url,
149
+ "component_id": request.component_id,
150
+ }
151
+ _save_state(repository_root, state)
152
+ results.append(
153
+ GitHubIssuePublication(
154
+ clarification_id=request.id,
155
+ component_id=request.component_id,
156
+ repository=target,
157
+ status="CREATED",
158
+ issue_number=number,
159
+ issue_url=url,
160
+ )
161
+ )
162
+ return tuple(results)