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.
- ptsip/__init__.py +3 -0
- ptsip/__main__.py +4 -0
- ptsip/app/__init__.py +5 -0
- ptsip/app/client.py +53 -0
- ptsip/app/github_client.py +200 -0
- ptsip/app/server.py +130 -0
- ptsip/app/service.py +285 -0
- ptsip/app/store.py +233 -0
- ptsip/artifact_evidence.py +150 -0
- ptsip/build_resolution.py +298 -0
- ptsip/clarification/__init__.py +6 -0
- ptsip/clarification/generator.py +120 -0
- ptsip/clarification/generator_core.py +98 -0
- ptsip/clarification/i18n.py +106 -0
- ptsip/clarification/model.py +33 -0
- ptsip/clarification/render.py +78 -0
- ptsip/clarification/resolution/__init__.py +29 -0
- ptsip/clarification/resolution/model.py +33 -0
- ptsip/clarification/resolution/parser.py +56 -0
- ptsip/clarification/resolution/profile_projection.py +198 -0
- ptsip/clarification/resolution/resolver.py +30 -0
- ptsip/clarification/transports/__init__.py +1 -0
- ptsip/clarification/transports/github_issue.py +162 -0
- ptsip/cli.py +402 -0
- ptsip/conformance.py +531 -0
- ptsip/conformance_audit.py +143 -0
- ptsip/conformance_engine.py +396 -0
- ptsip/constants.py +9 -0
- ptsip/doctor.py +33 -0
- ptsip/inspection/__init__.py +1 -0
- ptsip/inspection/components.py +74 -0
- ptsip/inspection/dependencies.py +496 -0
- ptsip/inspection/dependencies_030.py +69 -0
- ptsip/inspection/dotnet.py +188 -0
- ptsip/inspection/go.py +157 -0
- ptsip/inspection/inventory.py +161 -0
- ptsip/inspection/javascript.py +376 -0
- ptsip/inspection/lexing.py +121 -0
- ptsip/inspection/source_adapters.py +60 -0
- ptsip/lifecycle_evidence.py +245 -0
- ptsip/model.py +105 -0
- ptsip/pilot/__init__.py +1 -0
- ptsip/pilot/runner.py +170 -0
- ptsip/repository/__init__.py +1 -0
- ptsip/repository/discover.py +68 -0
- ptsip/repository/remote.py +54 -0
- ptsip/repository/snapshot.py +173 -0
- ptsip/review_evidence.py +406 -0
- ptsip/spec_identity.py +22 -0
- ptsip/specdata/ptsip-agent-classification.schema.json +35 -0
- ptsip/specdata/ptsip-artifact-evidence.schema.json +68 -0
- ptsip/specdata/ptsip-diagnostic.schema.json +29 -0
- ptsip/specdata/ptsip-profile.schema.json +310 -0
- ptsip/specdata/ptsip-registry.yaml +189 -0
- ptsip/storage/__init__.py +1 -0
- ptsip/storage/local_state.py +32 -0
- ptsip/validation/__init__.py +1 -0
- ptsip/validation/components.py +164 -0
- ptsip/validation/profile.py +172 -0
- ptsip/validation/rules.py +163 -0
- ptsip-0.3.1.dist-info/METADATA +338 -0
- ptsip-0.3.1.dist-info/RECORD +66 -0
- ptsip-0.3.1.dist-info/WHEEL +5 -0
- ptsip-0.3.1.dist-info/entry_points.txt +3 -0
- ptsip-0.3.1.dist-info/licenses/LICENSE +201 -0
- 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)
|