methodology-framework 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- methodology_framework/__init__.py +17 -0
- methodology_framework/__main__.py +28 -0
- methodology_framework/bootstrap_jira.py +427 -0
- methodology_framework/build_playbook.py +172 -0
- methodology_framework/jira_shapes/__init__.py +0 -0
- methodology_framework/jira_shapes/automation_rules.yaml +85 -0
- methodology_framework/jira_shapes/custom_fields.yaml +44 -0
- methodology_framework/jira_shapes/workflow.yaml +135 -0
- methodology_framework/playbooks/scrum-router.body.md +217 -0
- methodology_framework/populate_acus.py +210 -0
- methodology_framework/register_playbook_with_devin.py +294 -0
- methodology_framework/specs/devin-story-format.md +536 -0
- methodology_framework/sync_stories_to_jira.py +1087 -0
- methodology_framework/templates/github_workflows/populate-story-acus-caller.yml +29 -0
- methodology_framework/templates/story.md +152 -0
- methodology_framework-0.1.0.dist-info/METADATA +264 -0
- methodology_framework-0.1.0.dist-info/RECORD +20 -0
- methodology_framework-0.1.0.dist-info/WHEEL +5 -0
- methodology_framework-0.1.0.dist-info/licenses/LICENSE +21 -0
- methodology_framework-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""methodology-framework — portable process tooling for agent-driven delivery."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
from methodology_framework import (
|
|
6
|
+
bootstrap_jira,
|
|
7
|
+
build_playbook,
|
|
8
|
+
register_playbook_with_devin,
|
|
9
|
+
sync_stories_to_jira,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"bootstrap_jira",
|
|
14
|
+
"build_playbook",
|
|
15
|
+
"register_playbook_with_devin",
|
|
16
|
+
"sync_stories_to_jira",
|
|
17
|
+
]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Entry point for ``python -m methodology_framework <subcommand>``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main() -> None:
|
|
9
|
+
"""Route subcommands to their implementations."""
|
|
10
|
+
if len(sys.argv) < 2:
|
|
11
|
+
print("Usage: python -m methodology_framework <subcommand>", file=sys.stderr)
|
|
12
|
+
print("Available subcommands: bootstrap-jira", file=sys.stderr)
|
|
13
|
+
sys.exit(1)
|
|
14
|
+
|
|
15
|
+
subcommand = sys.argv[1]
|
|
16
|
+
|
|
17
|
+
if subcommand == "bootstrap-jira":
|
|
18
|
+
from methodology_framework.bootstrap_jira import main as bootstrap_main
|
|
19
|
+
|
|
20
|
+
sys.exit(bootstrap_main(sys.argv[2:]))
|
|
21
|
+
else:
|
|
22
|
+
print(f"Unknown subcommand: {subcommand}", file=sys.stderr)
|
|
23
|
+
print("Available subcommands: bootstrap-jira", file=sys.stderr)
|
|
24
|
+
sys.exit(1)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
if __name__ == "__main__":
|
|
28
|
+
main()
|
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
"""Bootstrap a Jira project with the methodology-framework's canonical shape.
|
|
2
|
+
|
|
3
|
+
CLI entry point: ``python -m methodology_framework bootstrap-jira``
|
|
4
|
+
|
|
5
|
+
Three operating modes:
|
|
6
|
+
--dry-run (default) Print what would be applied; no side-effects.
|
|
7
|
+
--apply POST/PUT to Jira admin API (needs JIRA_ADMIN_TOKEN).
|
|
8
|
+
--export <path> Emit an importable config bundle at <path>; no API calls.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import importlib.resources
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import sys
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
import requests
|
|
22
|
+
import yaml
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
# Shape-def loading
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
_SHAPE_FILES = ("workflow.yaml", "custom_fields.yaml", "automation_rules.yaml")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _load_shape_file(name: str) -> dict[str, Any]:
|
|
34
|
+
"""Load a YAML shape-def from package data and return as a dict."""
|
|
35
|
+
ref = importlib.resources.files("methodology_framework") / "jira_shapes" / name
|
|
36
|
+
text = ref.read_text(encoding="utf-8")
|
|
37
|
+
data: dict[str, Any] = yaml.safe_load(text)
|
|
38
|
+
return data
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def load_all_shapes() -> dict[str, dict[str, Any]]:
|
|
42
|
+
"""Load all three shape-def files and return keyed by stem name."""
|
|
43
|
+
result: dict[str, dict[str, Any]] = {}
|
|
44
|
+
for name in _SHAPE_FILES:
|
|
45
|
+
stem = name.removesuffix(".yaml")
|
|
46
|
+
result[stem] = _load_shape_file(name)
|
|
47
|
+
return result
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
# {{PROJECT_KEY}} substitution
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
_PLACEHOLDER_RE = re.compile(r"\{\{PROJECT_KEY\}\}")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def substitute_project_key(
|
|
58
|
+
shapes: dict[str, dict[str, Any]], project_key: str
|
|
59
|
+
) -> dict[str, dict[str, Any]]:
|
|
60
|
+
"""Deep-substitute ``{{PROJECT_KEY}}`` with *project_key* in all shapes."""
|
|
61
|
+
|
|
62
|
+
def _walk(obj: Any) -> Any:
|
|
63
|
+
if isinstance(obj, str):
|
|
64
|
+
return _PLACEHOLDER_RE.sub(project_key, obj)
|
|
65
|
+
if isinstance(obj, dict):
|
|
66
|
+
return {k: _walk(v) for k, v in obj.items()}
|
|
67
|
+
if isinstance(obj, list):
|
|
68
|
+
return [_walk(item) for item in obj]
|
|
69
|
+
return obj
|
|
70
|
+
|
|
71
|
+
substituted: dict[str, dict[str, Any]] = {}
|
|
72
|
+
for key, val in shapes.items():
|
|
73
|
+
walked = _walk(val)
|
|
74
|
+
assert isinstance(walked, dict)
|
|
75
|
+
substituted[key] = walked
|
|
76
|
+
return substituted
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
# JiraAdminClient — thin wrapper for Jira Cloud REST API v3
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class JiraAdminClient:
|
|
85
|
+
"""Minimal Jira Cloud admin client for bootstrap operations."""
|
|
86
|
+
|
|
87
|
+
def __init__(self, jira_host: str, token: str) -> None:
|
|
88
|
+
self._base = f"https://{jira_host}/rest/api/3"
|
|
89
|
+
self._session = requests.Session()
|
|
90
|
+
self._session.headers.update(
|
|
91
|
+
{
|
|
92
|
+
"Authorization": f"Bearer {token}",
|
|
93
|
+
"Content-Type": "application/json",
|
|
94
|
+
"Accept": "application/json",
|
|
95
|
+
}
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
# -- connectivity check --------------------------------------------------
|
|
99
|
+
|
|
100
|
+
def check_connectivity(self) -> None:
|
|
101
|
+
"""Validate reachability via GET /rest/api/3/myself."""
|
|
102
|
+
resp = self._session.get(f"{self._base}/myself")
|
|
103
|
+
resp.raise_for_status()
|
|
104
|
+
logger.info("Connected as %s", resp.json().get("displayName", "unknown"))
|
|
105
|
+
|
|
106
|
+
# -- workflow operations -------------------------------------------------
|
|
107
|
+
|
|
108
|
+
def get_statuses(self, project_key: str) -> list[dict[str, Any]]:
|
|
109
|
+
"""Return the current statuses for *project_key*."""
|
|
110
|
+
resp = self._session.get(f"{self._base}/project/{project_key}/statuses")
|
|
111
|
+
resp.raise_for_status()
|
|
112
|
+
raw: list[dict[str, Any]] = resp.json()
|
|
113
|
+
statuses: list[dict[str, Any]] = []
|
|
114
|
+
for issue_type in raw:
|
|
115
|
+
for s in issue_type.get("statuses", []):
|
|
116
|
+
if not any(existing["id"] == s["id"] for existing in statuses):
|
|
117
|
+
statuses.append(s)
|
|
118
|
+
return statuses
|
|
119
|
+
|
|
120
|
+
def create_status(self, spec: dict[str, Any]) -> None:
|
|
121
|
+
"""Create a workflow status from *spec*."""
|
|
122
|
+
resp = self._session.post(f"{self._base}/statuses", json=spec)
|
|
123
|
+
resp.raise_for_status()
|
|
124
|
+
|
|
125
|
+
# -- custom field operations ---------------------------------------------
|
|
126
|
+
|
|
127
|
+
def get_fields(self) -> list[dict[str, Any]]:
|
|
128
|
+
"""Return all fields (system + custom)."""
|
|
129
|
+
resp = self._session.get(f"{self._base}/field")
|
|
130
|
+
resp.raise_for_status()
|
|
131
|
+
result: list[dict[str, Any]] = resp.json()
|
|
132
|
+
return result
|
|
133
|
+
|
|
134
|
+
def create_custom_field(self, spec: dict[str, Any]) -> dict[str, Any]:
|
|
135
|
+
"""Create a custom field from *spec*."""
|
|
136
|
+
resp = self._session.post(f"{self._base}/field", json=spec)
|
|
137
|
+
resp.raise_for_status()
|
|
138
|
+
result: dict[str, Any] = resp.json()
|
|
139
|
+
return result
|
|
140
|
+
|
|
141
|
+
# -- automation rule operations ------------------------------------------
|
|
142
|
+
|
|
143
|
+
def get_automation_rules(self, project_key: str) -> list[dict[str, Any]]:
|
|
144
|
+
"""Return automation rules for *project_key*."""
|
|
145
|
+
resp = self._session.get(f"{self._base}/project/{project_key}/automation/rule")
|
|
146
|
+
if resp.status_code == 404:
|
|
147
|
+
logger.warning(
|
|
148
|
+
"Automation rules API not available (404). "
|
|
149
|
+
"Use --export mode and import rules via the Jira admin UI."
|
|
150
|
+
)
|
|
151
|
+
return []
|
|
152
|
+
resp.raise_for_status()
|
|
153
|
+
result: list[dict[str, Any]] = resp.json()
|
|
154
|
+
return result
|
|
155
|
+
|
|
156
|
+
def create_automation_rule(self, project_key: str, spec: dict[str, Any]) -> dict[str, Any]:
|
|
157
|
+
"""Create an automation rule from *spec*."""
|
|
158
|
+
resp = self._session.post(
|
|
159
|
+
f"{self._base}/project/{project_key}/automation/rule",
|
|
160
|
+
json=spec,
|
|
161
|
+
)
|
|
162
|
+
resp.raise_for_status()
|
|
163
|
+
result: dict[str, Any] = resp.json()
|
|
164
|
+
return result
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# ---------------------------------------------------------------------------
|
|
168
|
+
# Diff / display helpers
|
|
169
|
+
# ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _format_diff(shapes: dict[str, dict[str, Any]]) -> str:
|
|
173
|
+
"""Return a human-readable summary of the shape definitions."""
|
|
174
|
+
lines: list[str] = []
|
|
175
|
+
workflow = shapes.get("workflow", {})
|
|
176
|
+
for status in workflow.get("statuses", []):
|
|
177
|
+
name = status.get("name", "?")
|
|
178
|
+
desc = status.get("description", "").strip().split("\n")[0]
|
|
179
|
+
transitions = status.get("transitions", [])
|
|
180
|
+
lines.append(f" + Status: {name}")
|
|
181
|
+
lines.append(f" {desc}")
|
|
182
|
+
for t in transitions:
|
|
183
|
+
actors = ", ".join(t.get("allowed_actors", []))
|
|
184
|
+
lines.append(f" -> {t['target']} (actors: {actors})")
|
|
185
|
+
lines.append("")
|
|
186
|
+
|
|
187
|
+
custom_fields = shapes.get("custom_fields", {})
|
|
188
|
+
for field in custom_fields.get("custom_fields", []):
|
|
189
|
+
name = field.get("name", "?")
|
|
190
|
+
ftype = field.get("type", "?")
|
|
191
|
+
lines.append(f" + Custom field: {name} (type: {ftype})")
|
|
192
|
+
lines.append("")
|
|
193
|
+
|
|
194
|
+
automation = shapes.get("automation_rules", {})
|
|
195
|
+
for rule in automation.get("automation_rules", []):
|
|
196
|
+
name = rule.get("name", "?")
|
|
197
|
+
lines.append(f" + Automation rule: {name}")
|
|
198
|
+
return "\n".join(lines)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# ---------------------------------------------------------------------------
|
|
202
|
+
# Mode handlers
|
|
203
|
+
# ---------------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _handle_dry_run(shapes: dict[str, dict[str, Any]]) -> int:
|
|
207
|
+
"""Print what would be applied; no side-effects."""
|
|
208
|
+
print("=== Methodology Framework — Jira Bootstrap (dry-run) ===\n")
|
|
209
|
+
print("The following Jira shape would be applied:\n")
|
|
210
|
+
print("[Workflow statuses + transitions]")
|
|
211
|
+
print(_format_diff(shapes))
|
|
212
|
+
print("[Custom fields]")
|
|
213
|
+
for field in shapes.get("custom_fields", {}).get("custom_fields", []):
|
|
214
|
+
print(f" {field['name']} ({field['type']}): {field.get('description', '')[:80]}")
|
|
215
|
+
print()
|
|
216
|
+
print("[Automation rules]")
|
|
217
|
+
for rule in shapes.get("automation_rules", {}).get("automation_rules", []):
|
|
218
|
+
print(f" {rule['name']}: {rule.get('description', '')[:80]}")
|
|
219
|
+
print("\nNo changes applied (dry-run mode).")
|
|
220
|
+
return 0
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _handle_export(shapes: dict[str, dict[str, Any]], export_path: str) -> int:
|
|
224
|
+
"""Emit an importable config bundle at *export_path*."""
|
|
225
|
+
bundle = {
|
|
226
|
+
"schema_version": "1.0",
|
|
227
|
+
"description": "Jira project bootstrap bundle generated by methodology-framework.",
|
|
228
|
+
"workflow": shapes.get("workflow", {}),
|
|
229
|
+
"custom_fields": shapes.get("custom_fields", {}),
|
|
230
|
+
"automation_rules": shapes.get("automation_rules", {}),
|
|
231
|
+
}
|
|
232
|
+
with open(export_path, "w", encoding="utf-8") as fh:
|
|
233
|
+
yaml.dump(bundle, fh, default_flow_style=False, sort_keys=False, allow_unicode=True)
|
|
234
|
+
print(export_path)
|
|
235
|
+
return 0
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _handle_apply(
|
|
239
|
+
shapes: dict[str, dict[str, Any]],
|
|
240
|
+
project_key: str,
|
|
241
|
+
jira_host: str,
|
|
242
|
+
token: str,
|
|
243
|
+
force: bool = False,
|
|
244
|
+
) -> int:
|
|
245
|
+
"""Apply the shape definitions to a Jira project via the admin API."""
|
|
246
|
+
client = JiraAdminClient(jira_host, token)
|
|
247
|
+
|
|
248
|
+
logger.info("Validating connectivity to %s ...", jira_host)
|
|
249
|
+
try:
|
|
250
|
+
client.check_connectivity()
|
|
251
|
+
except requests.HTTPError as exc:
|
|
252
|
+
logger.error("Connectivity check failed: %s", exc)
|
|
253
|
+
return 1
|
|
254
|
+
|
|
255
|
+
# -- statuses -----------------------------------------------------------
|
|
256
|
+
logger.info("Checking workflow statuses ...")
|
|
257
|
+
existing_statuses = client.get_statuses(project_key)
|
|
258
|
+
existing_names = {s.get("name", "").lower() for s in existing_statuses}
|
|
259
|
+
desired = shapes.get("workflow", {}).get("statuses", [])
|
|
260
|
+
statuses_created = 0
|
|
261
|
+
for status_spec in desired:
|
|
262
|
+
if status_spec["name"].lower() in existing_names:
|
|
263
|
+
logger.info(" Status '%s' already exists — skipping.", status_spec["name"])
|
|
264
|
+
else:
|
|
265
|
+
if not force:
|
|
266
|
+
answer = input(f" Create status '{status_spec['name']}'? [y/N] ").strip().lower()
|
|
267
|
+
if answer != "y":
|
|
268
|
+
logger.info(" Skipped '%s'.", status_spec["name"])
|
|
269
|
+
continue
|
|
270
|
+
logger.info(" Creating status '%s' ...", status_spec["name"])
|
|
271
|
+
client.create_status(
|
|
272
|
+
{"name": status_spec["name"], "description": status_spec.get("description", "")}
|
|
273
|
+
)
|
|
274
|
+
statuses_created += 1
|
|
275
|
+
|
|
276
|
+
# -- custom fields ------------------------------------------------------
|
|
277
|
+
logger.info("Checking custom fields ...")
|
|
278
|
+
existing_fields = client.get_fields()
|
|
279
|
+
existing_field_names = {f.get("name", "").lower() for f in existing_fields}
|
|
280
|
+
desired_fields = shapes.get("custom_fields", {}).get("custom_fields", [])
|
|
281
|
+
fields_created = 0
|
|
282
|
+
for field_spec in desired_fields:
|
|
283
|
+
if field_spec["name"].lower() in existing_field_names:
|
|
284
|
+
logger.info(" Field '%s' already exists — skipping.", field_spec["name"])
|
|
285
|
+
else:
|
|
286
|
+
if not force:
|
|
287
|
+
answer = (
|
|
288
|
+
input(f" Create custom field '{field_spec['name']}'? [y/N] ").strip().lower()
|
|
289
|
+
)
|
|
290
|
+
if answer != "y":
|
|
291
|
+
logger.info(" Skipped '%s'.", field_spec["name"])
|
|
292
|
+
continue
|
|
293
|
+
logger.info(" Creating field '%s' ...", field_spec["name"])
|
|
294
|
+
client.create_custom_field(
|
|
295
|
+
{
|
|
296
|
+
"name": field_spec["name"],
|
|
297
|
+
"type": field_spec.get("type", "string"),
|
|
298
|
+
"description": field_spec.get("description", ""),
|
|
299
|
+
}
|
|
300
|
+
)
|
|
301
|
+
fields_created += 1
|
|
302
|
+
|
|
303
|
+
# -- automation rules ---------------------------------------------------
|
|
304
|
+
logger.info("Checking automation rules ...")
|
|
305
|
+
existing_rules = client.get_automation_rules(project_key)
|
|
306
|
+
existing_rule_names = {r.get("name", "").lower() for r in existing_rules}
|
|
307
|
+
desired_rules = shapes.get("automation_rules", {}).get("automation_rules", [])
|
|
308
|
+
rules_created = 0
|
|
309
|
+
for rule_spec in desired_rules:
|
|
310
|
+
if rule_spec["name"].lower() in existing_rule_names:
|
|
311
|
+
logger.info(" Rule '%s' already exists — skipping.", rule_spec["name"])
|
|
312
|
+
else:
|
|
313
|
+
if not force:
|
|
314
|
+
answer = (
|
|
315
|
+
input(f" Create automation rule '{rule_spec['name']}'? [y/N] ")
|
|
316
|
+
.strip()
|
|
317
|
+
.lower()
|
|
318
|
+
)
|
|
319
|
+
if answer != "y":
|
|
320
|
+
logger.info(" Skipped '%s'.", rule_spec["name"])
|
|
321
|
+
continue
|
|
322
|
+
logger.info(" Creating rule '%s' ...", rule_spec["name"])
|
|
323
|
+
client.create_automation_rule(project_key, rule_spec)
|
|
324
|
+
rules_created += 1
|
|
325
|
+
|
|
326
|
+
logger.info(
|
|
327
|
+
"Apply complete: %d statuses, %d fields, %d rules created.",
|
|
328
|
+
statuses_created,
|
|
329
|
+
fields_created,
|
|
330
|
+
rules_created,
|
|
331
|
+
)
|
|
332
|
+
if statuses_created == 0 and fields_created == 0 and rules_created == 0:
|
|
333
|
+
logger.info("Already up to date.")
|
|
334
|
+
return 0
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
# ---------------------------------------------------------------------------
|
|
338
|
+
# CLI entry point
|
|
339
|
+
# ---------------------------------------------------------------------------
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
343
|
+
"""Construct the argument parser for ``bootstrap-jira``."""
|
|
344
|
+
parser = argparse.ArgumentParser(
|
|
345
|
+
prog="bootstrap-jira",
|
|
346
|
+
description="Bootstrap a Jira project with methodology-framework's canonical shape.",
|
|
347
|
+
)
|
|
348
|
+
parser.add_argument(
|
|
349
|
+
"--project-key",
|
|
350
|
+
required=True,
|
|
351
|
+
help="Jira project key (e.g. SCRUM). Used for {{PROJECT_KEY}} substitution.",
|
|
352
|
+
)
|
|
353
|
+
parser.add_argument(
|
|
354
|
+
"--jira-host",
|
|
355
|
+
required=True,
|
|
356
|
+
help="Jira Cloud host (e.g. myorg.atlassian.net). No https:// prefix.",
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
mode_group = parser.add_mutually_exclusive_group()
|
|
360
|
+
mode_group.add_argument(
|
|
361
|
+
"--dry-run",
|
|
362
|
+
action="store_true",
|
|
363
|
+
default=False,
|
|
364
|
+
help="Print what would be applied; no side-effects (default).",
|
|
365
|
+
)
|
|
366
|
+
mode_group.add_argument(
|
|
367
|
+
"--apply",
|
|
368
|
+
action="store_true",
|
|
369
|
+
default=False,
|
|
370
|
+
help="Apply shape defs to the Jira project via admin API.",
|
|
371
|
+
)
|
|
372
|
+
mode_group.add_argument(
|
|
373
|
+
"--export",
|
|
374
|
+
metavar="PATH",
|
|
375
|
+
default=None,
|
|
376
|
+
help="Emit an importable config bundle at PATH; no API calls.",
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
parser.add_argument(
|
|
380
|
+
"--force",
|
|
381
|
+
action="store_true",
|
|
382
|
+
default=False,
|
|
383
|
+
help="Skip confirmation prompts during --apply.",
|
|
384
|
+
)
|
|
385
|
+
return parser
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def main(argv: list[str] | None = None) -> int:
|
|
389
|
+
"""CLI entry point. Returns exit code."""
|
|
390
|
+
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
|
|
391
|
+
|
|
392
|
+
parser = build_parser()
|
|
393
|
+
args = parser.parse_args(argv)
|
|
394
|
+
|
|
395
|
+
# Load and substitute shape defs
|
|
396
|
+
try:
|
|
397
|
+
shapes = load_all_shapes()
|
|
398
|
+
except (yaml.YAMLError, FileNotFoundError) as exc:
|
|
399
|
+
logger.error("Failed to load shape definitions: %s", exc)
|
|
400
|
+
return 1
|
|
401
|
+
|
|
402
|
+
shapes = substitute_project_key(shapes, args.project_key)
|
|
403
|
+
|
|
404
|
+
# Determine mode (default to dry-run)
|
|
405
|
+
if args.apply:
|
|
406
|
+
token = os.environ.get("JIRA_ADMIN_TOKEN", "")
|
|
407
|
+
if not token:
|
|
408
|
+
logger.error(
|
|
409
|
+
"JIRA_ADMIN_TOKEN env var required for --apply mode. "
|
|
410
|
+
"Set it to a Jira admin API token or OAuth Bearer token."
|
|
411
|
+
)
|
|
412
|
+
return 1
|
|
413
|
+
return _handle_apply(
|
|
414
|
+
shapes,
|
|
415
|
+
project_key=args.project_key,
|
|
416
|
+
jira_host=args.jira_host,
|
|
417
|
+
token=token,
|
|
418
|
+
force=args.force,
|
|
419
|
+
)
|
|
420
|
+
elif args.export is not None:
|
|
421
|
+
return _handle_export(shapes, args.export)
|
|
422
|
+
else:
|
|
423
|
+
return _handle_dry_run(shapes)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
if __name__ == "__main__":
|
|
427
|
+
sys.exit(main())
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Build a concrete, project-bound playbook from the parameterized body + bindings.
|
|
3
|
+
|
|
4
|
+
Reads the parameterized playbook body from methodology/playbooks/scrum-router.body.md
|
|
5
|
+
and substitutes {{PLACEHOLDER}} tokens with the project-specific bindings defined in
|
|
6
|
+
docs/jira-pickup-config.md § 5.1 (the playbook_bindings YAML block).
|
|
7
|
+
|
|
8
|
+
The '## Variables required' section is preserved verbatim so placeholder
|
|
9
|
+
documentation remains legible in the rendered output. The frontmatter
|
|
10
|
+
``version:`` value is injected as the ``{{VERSION}}`` binding automatically.
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
python scripts/build_playbook.py [--output PATH] [--check]
|
|
14
|
+
|
|
15
|
+
Options:
|
|
16
|
+
--output PATH Write the built playbook to PATH (default: stdout).
|
|
17
|
+
--check Validate only: exit 0 if no unreplaced tokens, exit 1 otherwise.
|
|
18
|
+
Does not write output.
|
|
19
|
+
|
|
20
|
+
The script is designed to be run from the repo root. It uses relative paths to locate
|
|
21
|
+
the body and bindings files.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import argparse
|
|
27
|
+
import re
|
|
28
|
+
import sys
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
32
|
+
BODY_PATH = REPO_ROOT / "methodology" / "playbooks" / "scrum-router.body.md"
|
|
33
|
+
CONFIG_PATH = REPO_ROOT / "docs" / "jira-pickup-config.md"
|
|
34
|
+
|
|
35
|
+
PLACEHOLDER_RE = re.compile(r"\{\{(\w+)\}\}")
|
|
36
|
+
VARS_SECTION_RE = re.compile(
|
|
37
|
+
r"(^## Variables required$\n.*?)(?=^## |\Z)",
|
|
38
|
+
re.MULTILINE | re.DOTALL,
|
|
39
|
+
)
|
|
40
|
+
FRONTMATTER_RE = re.compile(r"\A---\n(.*?)\n---\n", re.DOTALL)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def extract_bindings(config_text: str) -> dict[str, str]:
|
|
44
|
+
"""Extract playbook_bindings from the config doc's YAML code block."""
|
|
45
|
+
in_block = False
|
|
46
|
+
bindings: dict[str, str] = {}
|
|
47
|
+
for line in config_text.splitlines():
|
|
48
|
+
if line.strip() == "playbook_bindings:":
|
|
49
|
+
in_block = True
|
|
50
|
+
continue
|
|
51
|
+
if in_block:
|
|
52
|
+
if line.strip() == "```" or (not line.startswith(" ") and line.strip()):
|
|
53
|
+
break
|
|
54
|
+
match = re.match(r'\s+(\w+):\s*"([^"]*)"', line)
|
|
55
|
+
if match:
|
|
56
|
+
bindings[match.group(1)] = match.group(2)
|
|
57
|
+
return bindings
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def extract_version(body: str) -> str:
|
|
61
|
+
"""Extract the ``version:`` value from the body's YAML frontmatter."""
|
|
62
|
+
fm_match = FRONTMATTER_RE.match(body)
|
|
63
|
+
if not fm_match:
|
|
64
|
+
return ""
|
|
65
|
+
for line in fm_match.group(1).splitlines():
|
|
66
|
+
stripped = line.strip()
|
|
67
|
+
if stripped.startswith("version:"):
|
|
68
|
+
return stripped.split(":", 1)[1].strip().strip('"').strip("'")
|
|
69
|
+
return ""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def substitute(body: str, bindings: dict[str, str]) -> str:
|
|
73
|
+
"""Replace all {{PLACEHOLDER}} tokens with their bound values.
|
|
74
|
+
|
|
75
|
+
The ``## Variables required`` section is excluded from substitution so
|
|
76
|
+
that the placeholder-documentation table remains legible in the rendered
|
|
77
|
+
output.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def replacer(m: re.Match[str]) -> str:
|
|
81
|
+
key = m.group(1)
|
|
82
|
+
if key in bindings:
|
|
83
|
+
return bindings[key]
|
|
84
|
+
return m.group(0)
|
|
85
|
+
|
|
86
|
+
preserved_sections: list[tuple[int, int, str]] = []
|
|
87
|
+
for sec_match in VARS_SECTION_RE.finditer(body):
|
|
88
|
+
preserved_sections.append((sec_match.start(), sec_match.end(), sec_match.group(0)))
|
|
89
|
+
|
|
90
|
+
if not preserved_sections:
|
|
91
|
+
return PLACEHOLDER_RE.sub(replacer, body)
|
|
92
|
+
|
|
93
|
+
parts: list[str] = []
|
|
94
|
+
prev_end = 0
|
|
95
|
+
for start, end, original in preserved_sections:
|
|
96
|
+
parts.append(PLACEHOLDER_RE.sub(replacer, body[prev_end:start]))
|
|
97
|
+
parts.append(original)
|
|
98
|
+
prev_end = end
|
|
99
|
+
parts.append(PLACEHOLDER_RE.sub(replacer, body[prev_end:]))
|
|
100
|
+
return "".join(parts)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def find_unreplaced(text: str) -> list[str]:
|
|
104
|
+
"""Return a list of any remaining {{PLACEHOLDER}} tokens.
|
|
105
|
+
|
|
106
|
+
Tokens inside the preserved ``## Variables required`` section are
|
|
107
|
+
expected and excluded from the check.
|
|
108
|
+
"""
|
|
109
|
+
stripped = VARS_SECTION_RE.sub("", text)
|
|
110
|
+
return PLACEHOLDER_RE.findall(stripped)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def main() -> int:
|
|
114
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
115
|
+
parser.add_argument(
|
|
116
|
+
"--output",
|
|
117
|
+
type=str,
|
|
118
|
+
default=None,
|
|
119
|
+
help="Write built playbook to this path (default: stdout)",
|
|
120
|
+
)
|
|
121
|
+
parser.add_argument(
|
|
122
|
+
"--check", action="store_true", help="Validate only; exit 1 if unreplaced tokens remain"
|
|
123
|
+
)
|
|
124
|
+
args = parser.parse_args()
|
|
125
|
+
|
|
126
|
+
if not BODY_PATH.exists():
|
|
127
|
+
print(f"ERROR: Body file not found: {BODY_PATH}", file=sys.stderr)
|
|
128
|
+
return 1
|
|
129
|
+
|
|
130
|
+
if not CONFIG_PATH.exists():
|
|
131
|
+
print(f"ERROR: Config file not found: {CONFIG_PATH}", file=sys.stderr)
|
|
132
|
+
return 1
|
|
133
|
+
|
|
134
|
+
body = BODY_PATH.read_text()
|
|
135
|
+
config = CONFIG_PATH.read_text()
|
|
136
|
+
|
|
137
|
+
bindings = extract_bindings(config)
|
|
138
|
+
if not bindings:
|
|
139
|
+
print("ERROR: No playbook_bindings found in config doc.", file=sys.stderr)
|
|
140
|
+
return 1
|
|
141
|
+
|
|
142
|
+
version = extract_version(body)
|
|
143
|
+
if version:
|
|
144
|
+
bindings["VERSION"] = version
|
|
145
|
+
print(f"Version from frontmatter: {version}", file=sys.stderr)
|
|
146
|
+
else:
|
|
147
|
+
print("WARNING: No version found in body frontmatter.", file=sys.stderr)
|
|
148
|
+
|
|
149
|
+
print(f"Bindings loaded: {bindings}", file=sys.stderr)
|
|
150
|
+
|
|
151
|
+
result = substitute(body, bindings)
|
|
152
|
+
|
|
153
|
+
unreplaced = find_unreplaced(result)
|
|
154
|
+
if unreplaced:
|
|
155
|
+
print(f"ERROR: Unreplaced tokens: {unreplaced}", file=sys.stderr)
|
|
156
|
+
return 1
|
|
157
|
+
|
|
158
|
+
if args.check:
|
|
159
|
+
print("OK: All tokens substituted.", file=sys.stderr)
|
|
160
|
+
return 0
|
|
161
|
+
|
|
162
|
+
if args.output:
|
|
163
|
+
Path(args.output).write_text(result)
|
|
164
|
+
print(f"Built playbook written to: {args.output}", file=sys.stderr)
|
|
165
|
+
else:
|
|
166
|
+
sys.stdout.write(result)
|
|
167
|
+
|
|
168
|
+
return 0
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
if __name__ == "__main__":
|
|
172
|
+
sys.exit(main())
|
|
File without changes
|