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,210 @@
|
|
|
1
|
+
"""Scan story files for unpopulated agent_acus fields and backfill from Devin API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import glob
|
|
6
|
+
import logging
|
|
7
|
+
import re
|
|
8
|
+
import sys
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
import requests
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
_SESSION_URL_RE = re.compile(
|
|
16
|
+
r"agent_session_url:\s*[\"']?https://app\.devin\.ai/sessions/([a-zA-Z0-9_-]+)[\"']?"
|
|
17
|
+
)
|
|
18
|
+
_ACUS_PENDING_RE = re.compile(r"^(\s*)agent_acus:\s*[\"']?pending[\"']?\s*$", re.MULTILINE)
|
|
19
|
+
_ACUS_NUMERIC_RE = re.compile(r"^\s*agent_acus:\s*[0-9]", re.MULTILINE)
|
|
20
|
+
_ACUS_PRESENT_RE = re.compile(r"^(\s*)agent_acus:\s*\S", re.MULTILINE)
|
|
21
|
+
_SESSION_URL_LINE_RE = re.compile(r"^(\s*)agent_session_url:\s*\S", re.MULTILINE)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class PopulateResult:
|
|
26
|
+
"""Result of processing a single story file."""
|
|
27
|
+
|
|
28
|
+
path: str
|
|
29
|
+
session_id: str
|
|
30
|
+
acus: float
|
|
31
|
+
action: str # "replaced" | "inserted"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def extract_session_id(content: str) -> str | None:
|
|
35
|
+
"""Extract the Devin session ID from a story file's agent_session_url field."""
|
|
36
|
+
m = _SESSION_URL_RE.search(content)
|
|
37
|
+
return m.group(1) if m else None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def needs_population(content: str) -> bool:
|
|
41
|
+
"""Return True if the file has agent_acus pending or missing (with session URL present)."""
|
|
42
|
+
if _ACUS_PENDING_RE.search(content):
|
|
43
|
+
return True
|
|
44
|
+
has_session_url = _SESSION_URL_LINE_RE.search(content)
|
|
45
|
+
has_acus = _ACUS_PRESENT_RE.search(content)
|
|
46
|
+
return bool(has_session_url) and not bool(has_acus)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def rewrite_acus(content: str, acus_value: float) -> tuple[str, str]:
|
|
50
|
+
"""Rewrite agent_acus in the content. Returns (new_content, action).
|
|
51
|
+
|
|
52
|
+
action is "replaced" if pending was replaced, "inserted" if the field was added.
|
|
53
|
+
"""
|
|
54
|
+
m = _ACUS_PENDING_RE.search(content)
|
|
55
|
+
if m:
|
|
56
|
+
indent = m.group(1)
|
|
57
|
+
new_content = _ACUS_PENDING_RE.sub(f"{indent}agent_acus: {acus_value}", content)
|
|
58
|
+
return new_content, "replaced"
|
|
59
|
+
|
|
60
|
+
if _ACUS_NUMERIC_RE.search(content):
|
|
61
|
+
return content, ""
|
|
62
|
+
|
|
63
|
+
m = _SESSION_URL_LINE_RE.search(content)
|
|
64
|
+
if m:
|
|
65
|
+
indent = m.group(0).split("agent_session_url")[0]
|
|
66
|
+
line_end = content.find("\n", m.start())
|
|
67
|
+
if line_end == -1:
|
|
68
|
+
insert_pos = len(content)
|
|
69
|
+
new_content = content + f"\n{indent}agent_acus: {acus_value}"
|
|
70
|
+
else:
|
|
71
|
+
insert_pos = line_end + 1
|
|
72
|
+
new_content = (
|
|
73
|
+
content[:insert_pos] + f"{indent}agent_acus: {acus_value}\n" + content[insert_pos:]
|
|
74
|
+
)
|
|
75
|
+
return new_content, "inserted"
|
|
76
|
+
|
|
77
|
+
return content, ""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def fetch_acus_from_devin(session_id: str, api_token: str) -> float | None:
|
|
81
|
+
"""Fetch ACU consumption from Devin API for a given session ID.
|
|
82
|
+
|
|
83
|
+
Returns the ACU value or None on error.
|
|
84
|
+
# Devin API v2: GET /v2/session/{session_id} returns JSON with
|
|
85
|
+
# "total_acus" at the top level of the response object.
|
|
86
|
+
"""
|
|
87
|
+
url = f"https://api.devin.ai/v2/session/{session_id}"
|
|
88
|
+
headers = {"Authorization": f"Bearer {api_token}"}
|
|
89
|
+
try:
|
|
90
|
+
resp = requests.get(url, headers=headers, timeout=30)
|
|
91
|
+
resp.raise_for_status()
|
|
92
|
+
data = resp.json()
|
|
93
|
+
acus = data.get("total_acus")
|
|
94
|
+
if acus is None:
|
|
95
|
+
# Fallback: try nested under "status_enum" or "acus"
|
|
96
|
+
acus = data.get("acus")
|
|
97
|
+
if acus is not None:
|
|
98
|
+
return float(acus)
|
|
99
|
+
logger.warning(
|
|
100
|
+
"Session %s: no ACU field found in response keys: %s",
|
|
101
|
+
session_id,
|
|
102
|
+
list(data.keys()),
|
|
103
|
+
)
|
|
104
|
+
return None
|
|
105
|
+
except requests.HTTPError as exc:
|
|
106
|
+
logger.warning("Devin API error for session %s: %s", session_id, exc)
|
|
107
|
+
return None
|
|
108
|
+
except (requests.ConnectionError, requests.Timeout) as exc:
|
|
109
|
+
logger.warning("Devin API connection error for session %s: %s", session_id, exc)
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def scan_and_populate(
|
|
114
|
+
story_path_pattern: str,
|
|
115
|
+
api_token: str,
|
|
116
|
+
*,
|
|
117
|
+
dry_run: bool = False,
|
|
118
|
+
) -> list[PopulateResult]:
|
|
119
|
+
"""Scan story files and populate agent_acus from Devin API.
|
|
120
|
+
|
|
121
|
+
Returns a list of PopulateResult for each file that was updated.
|
|
122
|
+
"""
|
|
123
|
+
results: list[PopulateResult] = []
|
|
124
|
+
files = sorted(glob.glob(story_path_pattern, recursive=True))
|
|
125
|
+
logger.info("Scanning %d story files matching %s", len(files), story_path_pattern)
|
|
126
|
+
|
|
127
|
+
for filepath in files:
|
|
128
|
+
with open(filepath, encoding="utf-8") as f:
|
|
129
|
+
content = f.read()
|
|
130
|
+
|
|
131
|
+
if not needs_population(content):
|
|
132
|
+
continue
|
|
133
|
+
|
|
134
|
+
session_id = extract_session_id(content)
|
|
135
|
+
if not session_id:
|
|
136
|
+
logger.info("Skipping %s: no agent_session_url found", filepath)
|
|
137
|
+
continue
|
|
138
|
+
|
|
139
|
+
acus = fetch_acus_from_devin(session_id, api_token)
|
|
140
|
+
if acus is None:
|
|
141
|
+
logger.warning(
|
|
142
|
+
"Skipping %s: could not fetch ACUs for session %s",
|
|
143
|
+
filepath,
|
|
144
|
+
session_id,
|
|
145
|
+
)
|
|
146
|
+
continue
|
|
147
|
+
|
|
148
|
+
new_content, action = rewrite_acus(content, acus)
|
|
149
|
+
if not action:
|
|
150
|
+
continue
|
|
151
|
+
|
|
152
|
+
if not dry_run:
|
|
153
|
+
with open(filepath, "w", encoding="utf-8") as f:
|
|
154
|
+
f.write(new_content)
|
|
155
|
+
|
|
156
|
+
results.append(
|
|
157
|
+
PopulateResult(
|
|
158
|
+
path=filepath,
|
|
159
|
+
session_id=session_id,
|
|
160
|
+
acus=acus,
|
|
161
|
+
action=action,
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
logger.info("Updated %s: agent_acus=%s (%s)", filepath, acus, action)
|
|
165
|
+
|
|
166
|
+
return results
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def main() -> int:
|
|
170
|
+
"""CLI entry point for populate-acus."""
|
|
171
|
+
import argparse
|
|
172
|
+
import os
|
|
173
|
+
|
|
174
|
+
parser = argparse.ArgumentParser(
|
|
175
|
+
description="Populate agent_acus in story files from Devin API",
|
|
176
|
+
)
|
|
177
|
+
parser.add_argument(
|
|
178
|
+
"--story-path-pattern",
|
|
179
|
+
default="docs/stories/**/*.md",
|
|
180
|
+
help="Glob pattern for story files (default: docs/stories/**/*.md)",
|
|
181
|
+
)
|
|
182
|
+
parser.add_argument(
|
|
183
|
+
"--dry-run",
|
|
184
|
+
action="store_true",
|
|
185
|
+
help="Print what would be changed without modifying files",
|
|
186
|
+
)
|
|
187
|
+
args = parser.parse_args()
|
|
188
|
+
|
|
189
|
+
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
|
|
190
|
+
|
|
191
|
+
api_token = os.environ.get("DEVIN_API_TOKEN", "")
|
|
192
|
+
if not api_token:
|
|
193
|
+
logger.error("DEVIN_API_TOKEN environment variable is required")
|
|
194
|
+
return 1
|
|
195
|
+
|
|
196
|
+
results = scan_and_populate(args.story_path_pattern, api_token, dry_run=args.dry_run)
|
|
197
|
+
|
|
198
|
+
if not results:
|
|
199
|
+
print("Nothing to do — all agent_acus fields already populated.")
|
|
200
|
+
return 0
|
|
201
|
+
|
|
202
|
+
print(f"\nPopulated agent_acus for {len(results)} story file(s):")
|
|
203
|
+
for r in results:
|
|
204
|
+
print(f" {r.path}: {r.acus} ({r.action})")
|
|
205
|
+
|
|
206
|
+
return 0
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
if __name__ == "__main__":
|
|
210
|
+
sys.exit(main())
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Register a rendered playbook with Devin's API and verify the registration.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
python scripts/register_playbook_with_devin.py \
|
|
6
|
+
--playbook-id scrum-router \
|
|
7
|
+
--rendered-body /path/to/rendered.md \
|
|
8
|
+
--macro '!scrum_pickup'
|
|
9
|
+
|
|
10
|
+
The --playbook-id value must match the ``playbook_id`` field in the source
|
|
11
|
+
body's YAML frontmatter. It is used as the lookup slug against Devin's API.
|
|
12
|
+
|
|
13
|
+
Environment:
|
|
14
|
+
DEVIN_API_TOKEN Devin API key (cog_ prefix). Required.
|
|
15
|
+
|
|
16
|
+
Exit codes:
|
|
17
|
+
0 Registration succeeded and smoke-verify passed.
|
|
18
|
+
1 Any failure (auth, API error, SHA256 mismatch, playbook not found).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import hashlib
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import re
|
|
28
|
+
import sys
|
|
29
|
+
import urllib.error
|
|
30
|
+
import urllib.request
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
MACRO_PATTERN = re.compile(r"^![A-Za-z0-9_]+$")
|
|
34
|
+
|
|
35
|
+
DEVIN_API_BASE = "https://api.devin.ai/v1"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _request(
|
|
39
|
+
method: str,
|
|
40
|
+
path: str,
|
|
41
|
+
token: str,
|
|
42
|
+
data: dict[str, Any] | None = None,
|
|
43
|
+
) -> dict[str, Any]:
|
|
44
|
+
"""Make an authenticated request to the Devin API."""
|
|
45
|
+
url = f"{DEVIN_API_BASE}{path}"
|
|
46
|
+
body = json.dumps(data).encode("utf-8") if data else None
|
|
47
|
+
req = urllib.request.Request(
|
|
48
|
+
url,
|
|
49
|
+
data=body,
|
|
50
|
+
headers={
|
|
51
|
+
"Authorization": f"Bearer {token}",
|
|
52
|
+
"Content-Type": "application/json",
|
|
53
|
+
},
|
|
54
|
+
method=method,
|
|
55
|
+
)
|
|
56
|
+
try:
|
|
57
|
+
with urllib.request.urlopen(req) as resp:
|
|
58
|
+
result: dict[str, Any] = json.loads(resp.read().decode("utf-8"))
|
|
59
|
+
return result
|
|
60
|
+
except urllib.error.HTTPError as e:
|
|
61
|
+
error_body = e.read().decode("utf-8", errors="replace")
|
|
62
|
+
print(
|
|
63
|
+
f"ERROR: Devin API {method} {path} returned {e.code}: {error_body}",
|
|
64
|
+
file=sys.stderr,
|
|
65
|
+
)
|
|
66
|
+
sys.exit(1)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def sha256hex(text: str) -> str:
|
|
70
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def list_playbooks(token: str) -> list[dict[str, Any]]:
|
|
74
|
+
"""List all playbooks accessible to the token's org."""
|
|
75
|
+
resp: Any = _request("GET", "/playbooks", token)
|
|
76
|
+
# v1 returns {"playbooks": [...]} or a bare list
|
|
77
|
+
if isinstance(resp, list):
|
|
78
|
+
return resp
|
|
79
|
+
items: list[dict[str, Any]] = resp.get("playbooks", resp.get("data", []))
|
|
80
|
+
return items
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def find_playbook_by_name(playbooks: list[dict[str, Any]], name: str) -> dict[str, Any] | None:
|
|
84
|
+
"""Find a playbook by its display name (exact match)."""
|
|
85
|
+
for p in playbooks:
|
|
86
|
+
pname = p.get("name") or p.get("title") or ""
|
|
87
|
+
if pname == name:
|
|
88
|
+
return p
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _build_payload(title: str, content: str, macro: str | None = None) -> dict[str, Any]:
|
|
93
|
+
"""Build the JSON payload for CREATE or UPDATE requests.
|
|
94
|
+
|
|
95
|
+
Centralizes field-name choices (REST uses 'body', not 'content').
|
|
96
|
+
Includes 'macro' only when a value is provided.
|
|
97
|
+
"""
|
|
98
|
+
payload: dict[str, Any] = {"title": title, "body": content}
|
|
99
|
+
if macro is not None:
|
|
100
|
+
payload["macro"] = macro
|
|
101
|
+
return payload
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def update_playbook(
|
|
105
|
+
token: str, playbook_id: str, title: str, content: str, macro: str | None = None
|
|
106
|
+
) -> dict[str, Any]:
|
|
107
|
+
"""Update a playbook's title and content (full replace)."""
|
|
108
|
+
payload = _build_payload(title, content, macro)
|
|
109
|
+
return _request("PUT", f"/playbooks/{playbook_id}", token, payload)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def get_playbook(token: str, playbook_id: str) -> dict[str, Any]:
|
|
113
|
+
"""Read back a playbook by ID."""
|
|
114
|
+
return _request("GET", f"/playbooks/{playbook_id}", token)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def create_playbook(
|
|
118
|
+
token: str, slug: str, title: str, content: str, macro: str | None = None
|
|
119
|
+
) -> dict[str, Any]:
|
|
120
|
+
"""Create a new playbook with the given slug, title, and content."""
|
|
121
|
+
payload = _build_payload(title, content, macro)
|
|
122
|
+
payload["playbook_id"] = slug
|
|
123
|
+
return _request("POST", "/playbooks", token, payload)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def main() -> int:
|
|
127
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
128
|
+
parser.add_argument(
|
|
129
|
+
"--playbook-id",
|
|
130
|
+
required=True,
|
|
131
|
+
help="Playbook slug (from source body frontmatter playbook_id:).",
|
|
132
|
+
)
|
|
133
|
+
parser.add_argument(
|
|
134
|
+
"--playbook-name",
|
|
135
|
+
default=None,
|
|
136
|
+
help="Display name / title (used only when creating a new playbook).",
|
|
137
|
+
)
|
|
138
|
+
parser.add_argument(
|
|
139
|
+
"--rendered-body",
|
|
140
|
+
required=True,
|
|
141
|
+
help="Path to the rendered playbook markdown file.",
|
|
142
|
+
)
|
|
143
|
+
parser.add_argument(
|
|
144
|
+
"--macro",
|
|
145
|
+
default=None,
|
|
146
|
+
help=(
|
|
147
|
+
"Macro trigger name (e.g. '!scrum_pickup'). "
|
|
148
|
+
"Included in CREATE/UPDATE payloads when provided."
|
|
149
|
+
),
|
|
150
|
+
)
|
|
151
|
+
parser.add_argument(
|
|
152
|
+
"--dry-run",
|
|
153
|
+
action="store_true",
|
|
154
|
+
help="List playbooks and show what would be updated, but don't write.",
|
|
155
|
+
)
|
|
156
|
+
args = parser.parse_args()
|
|
157
|
+
|
|
158
|
+
if args.macro is not None and not MACRO_PATTERN.match(args.macro):
|
|
159
|
+
print(
|
|
160
|
+
f"ERROR: --macro value must start with '!' followed by word characters "
|
|
161
|
+
f"(letters, digits, underscores). Got: {args.macro!r}",
|
|
162
|
+
file=sys.stderr,
|
|
163
|
+
)
|
|
164
|
+
return 1
|
|
165
|
+
|
|
166
|
+
token = os.environ.get("DEVIN_API_TOKEN", "")
|
|
167
|
+
if not token:
|
|
168
|
+
print("ERROR: DEVIN_API_TOKEN environment variable is not set.", file=sys.stderr)
|
|
169
|
+
return 1
|
|
170
|
+
|
|
171
|
+
rendered_path = args.rendered_body
|
|
172
|
+
try:
|
|
173
|
+
rendered_content = open(rendered_path, encoding="utf-8").read()
|
|
174
|
+
except FileNotFoundError:
|
|
175
|
+
print(f"ERROR: Rendered body file not found: {rendered_path}", file=sys.stderr)
|
|
176
|
+
return 1
|
|
177
|
+
|
|
178
|
+
rendered_sha = sha256hex(rendered_content)
|
|
179
|
+
print(f"Rendered body SHA256: {rendered_sha}", file=sys.stderr)
|
|
180
|
+
print(f"Rendered body length: {len(rendered_content)} chars", file=sys.stderr)
|
|
181
|
+
|
|
182
|
+
slug: str = args.playbook_id
|
|
183
|
+
name: str = args.playbook_name or slug
|
|
184
|
+
|
|
185
|
+
# Step 1: List playbooks and find the target by name
|
|
186
|
+
print("Listing playbooks...", file=sys.stderr)
|
|
187
|
+
playbooks = list_playbooks(token)
|
|
188
|
+
names = [p.get("name") or p.get("title") or "?" for p in playbooks]
|
|
189
|
+
print(f"Found {len(playbooks)} playbook(s); available names: {names}", file=sys.stderr)
|
|
190
|
+
|
|
191
|
+
matches = [p for p in playbooks if (p.get("name") or p.get("title")) == name]
|
|
192
|
+
if len(matches) >= 2:
|
|
193
|
+
ids = [p.get("id") or p.get("playbook_id") for p in matches]
|
|
194
|
+
print(
|
|
195
|
+
f"ERROR: Multiple playbooks found with name '{name}': "
|
|
196
|
+
f"{', '.join(str(i) for i in ids)}.\n"
|
|
197
|
+
f"Operator action required: delete duplicates in Devin's webapp, "
|
|
198
|
+
f"leaving exactly one '{name}' playbook. Then re-run.",
|
|
199
|
+
file=sys.stderr,
|
|
200
|
+
)
|
|
201
|
+
return 1
|
|
202
|
+
target = matches[0] if matches else None
|
|
203
|
+
|
|
204
|
+
if target is None:
|
|
205
|
+
print(
|
|
206
|
+
f"No existing playbook with name '{name}'. Creating new playbook...",
|
|
207
|
+
file=sys.stderr,
|
|
208
|
+
)
|
|
209
|
+
if args.dry_run:
|
|
210
|
+
print("DRY RUN — would create new playbook.", file=sys.stderr)
|
|
211
|
+
return 0
|
|
212
|
+
created = create_playbook(token, slug, name, rendered_content, args.macro)
|
|
213
|
+
created_id: str = created.get("playbook_id") or created.get("id") or ""
|
|
214
|
+
print(f"Created playbook: id={created_id}, title={name}", file=sys.stderr)
|
|
215
|
+
|
|
216
|
+
# Smoke-verify the creation
|
|
217
|
+
print("Smoke-verifying creation...", file=sys.stderr)
|
|
218
|
+
readback = get_playbook(token, created_id)
|
|
219
|
+
readback_content = readback.get("content") or readback.get("body") or ""
|
|
220
|
+
readback_sha = sha256hex(readback_content)
|
|
221
|
+
if readback_sha != rendered_sha:
|
|
222
|
+
print(
|
|
223
|
+
f"ERROR: Smoke-verify FAILED. "
|
|
224
|
+
f"Rendered SHA256: {rendered_sha}, "
|
|
225
|
+
f"Read-back SHA256: {readback_sha}",
|
|
226
|
+
file=sys.stderr,
|
|
227
|
+
)
|
|
228
|
+
return 1
|
|
229
|
+
print(f"Smoke-verify PASSED. SHA256 match: {rendered_sha}", file=sys.stderr)
|
|
230
|
+
return 0
|
|
231
|
+
|
|
232
|
+
playbook_id: str = target.get("playbook_id") or target.get("id") or ""
|
|
233
|
+
playbook_title: str = target.get("title") or target.get("name") or name
|
|
234
|
+
if not playbook_id:
|
|
235
|
+
print("ERROR: Could not determine playbook ID from API response.", file=sys.stderr)
|
|
236
|
+
return 1
|
|
237
|
+
print(
|
|
238
|
+
f"Found playbook: id={playbook_id}, title={playbook_title}; matched '{name}'; updating...",
|
|
239
|
+
file=sys.stderr,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
if args.dry_run:
|
|
243
|
+
print("DRY RUN — skipping update and verify.", file=sys.stderr)
|
|
244
|
+
return 0
|
|
245
|
+
|
|
246
|
+
# Step 2: Check if content or macro has changed
|
|
247
|
+
existing_content: str = target.get("content") or target.get("body") or ""
|
|
248
|
+
existing_sha = sha256hex(existing_content)
|
|
249
|
+
existing_macro: str | None = target.get("macro")
|
|
250
|
+
body_matches = existing_sha == rendered_sha
|
|
251
|
+
macro_matches = existing_macro == args.macro
|
|
252
|
+
if body_matches and macro_matches:
|
|
253
|
+
print(
|
|
254
|
+
"Registered body already matches rendered output (SHA256 identical) "
|
|
255
|
+
"and macro is in sync. Skipping registration.",
|
|
256
|
+
file=sys.stderr,
|
|
257
|
+
)
|
|
258
|
+
return 0
|
|
259
|
+
|
|
260
|
+
reasons: list[str] = []
|
|
261
|
+
if not body_matches:
|
|
262
|
+
reasons.append(f"content differs (existing SHA256: {existing_sha})")
|
|
263
|
+
if not macro_matches:
|
|
264
|
+
reasons.append(f"macro differs (existing: {existing_macro!r}, target: {args.macro!r})")
|
|
265
|
+
print(f"Update needed: {'; '.join(reasons)}. Updating...", file=sys.stderr)
|
|
266
|
+
|
|
267
|
+
# Step 3: Update the playbook
|
|
268
|
+
update_playbook(token, playbook_id, playbook_title, rendered_content, args.macro)
|
|
269
|
+
print("Playbook updated successfully.", file=sys.stderr)
|
|
270
|
+
|
|
271
|
+
# Step 4: Smoke-verify — read back and compare SHA256
|
|
272
|
+
print("Smoke-verifying registration...", file=sys.stderr)
|
|
273
|
+
readback = get_playbook(token, playbook_id)
|
|
274
|
+
readback_content = readback.get("content") or readback.get("body") or ""
|
|
275
|
+
readback_sha = sha256hex(readback_content)
|
|
276
|
+
|
|
277
|
+
if readback_sha != rendered_sha:
|
|
278
|
+
print(
|
|
279
|
+
f"ERROR: Smoke-verify FAILED. "
|
|
280
|
+
f"Rendered SHA256: {rendered_sha}, "
|
|
281
|
+
f"Read-back SHA256: {readback_sha}",
|
|
282
|
+
file=sys.stderr,
|
|
283
|
+
)
|
|
284
|
+
return 1
|
|
285
|
+
|
|
286
|
+
print(
|
|
287
|
+
f"Smoke-verify PASSED. SHA256 match: {rendered_sha}",
|
|
288
|
+
file=sys.stderr,
|
|
289
|
+
)
|
|
290
|
+
return 0
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
if __name__ == "__main__":
|
|
294
|
+
sys.exit(main())
|