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,1087 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Sync story .md files from docs/stories/ to Jira tickets.
|
|
3
|
+
|
|
4
|
+
One-way sync: repo -> Jira. Creates or updates Jira tickets based on
|
|
5
|
+
story frontmatter and body content. Writes back assigned jira_key to
|
|
6
|
+
the story file's frontmatter on first sync.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
python scripts/sync_stories_to_jira.py --dry-run --since-ref HEAD~1
|
|
10
|
+
python scripts/sync_stories_to_jira.py --since-ref HEAD~3
|
|
11
|
+
python scripts/sync_stories_to_jira.py --all
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import logging
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
import frontmatter
|
|
27
|
+
import requests
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
# Constants
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
36
|
+
STORIES_DIR = REPO_ROOT / "docs" / "stories"
|
|
37
|
+
GITHUB_REPO_URL = "https://github.com/whiteout59/centralized-pipeline-ui"
|
|
38
|
+
|
|
39
|
+
# Jira custom field IDs (discovered from the SCRUM project)
|
|
40
|
+
CF_REQUIREMENT_IDS = "customfield_10141"
|
|
41
|
+
CF_STORY_FILE = "customfield_10073"
|
|
42
|
+
CF_AGENT_ESTIMATE = "customfield_10074"
|
|
43
|
+
|
|
44
|
+
# Jira issue link type for "Blocks" (inward = "is blocked by")
|
|
45
|
+
LINK_TYPE_BLOCKS = "10000"
|
|
46
|
+
|
|
47
|
+
# Jira transition IDs (from the SCRUM workflow)
|
|
48
|
+
TRANSITION_TO_DO = "11"
|
|
49
|
+
TRANSITION_READY_FOR_AGENT = "2"
|
|
50
|
+
TRANSITION_BLOCKED = "3"
|
|
51
|
+
TRANSITION_WONT_DO = "4"
|
|
52
|
+
TRANSITION_WAITING = "5"
|
|
53
|
+
|
|
54
|
+
# Jira status IDs
|
|
55
|
+
STATUS_DONE = "10003"
|
|
56
|
+
STATUS_WONT_DO = "10104"
|
|
57
|
+
STATUS_WAITING = "10137"
|
|
58
|
+
STATUS_BLOCKED = "10103"
|
|
59
|
+
|
|
60
|
+
# Statuses the sync must NOT auto-transition out of. Done / Won't Do are
|
|
61
|
+
# truly terminal. Blocked is non-terminal in the workflow sense but is
|
|
62
|
+
# the "human attention required" lane (ambiguity escalations, file-overlap
|
|
63
|
+
# halts, security review pending, etc.) — auto-resolving Blocked back to
|
|
64
|
+
# Ready would defeat the lifecycle-status-hygiene design that distinguishes
|
|
65
|
+
# Waiting (dep-block, safe to auto-resolve) from Blocked (manual gate).
|
|
66
|
+
PROTECTED_STATUSES = {STATUS_DONE, STATUS_WONT_DO, STATUS_BLOCKED}
|
|
67
|
+
TERMINAL_STATUSES = PROTECTED_STATUSES # backward-compat alias
|
|
68
|
+
|
|
69
|
+
# Jira issue type IDs
|
|
70
|
+
ISSUE_TYPE_STORY = "10004"
|
|
71
|
+
ISSUE_TYPE_SUBTASK = "10002"
|
|
72
|
+
|
|
73
|
+
PROJECT_KEY = "SCRUM"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
# Data classes
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class StoryMeta:
|
|
83
|
+
"""Parsed frontmatter + rendered body of a story .md file."""
|
|
84
|
+
|
|
85
|
+
slug: str
|
|
86
|
+
path: Path
|
|
87
|
+
title: str
|
|
88
|
+
story_type: str
|
|
89
|
+
labels: list[str]
|
|
90
|
+
requirements: list[str]
|
|
91
|
+
depends_on: list[str]
|
|
92
|
+
parent: str | None
|
|
93
|
+
tasks: list[str]
|
|
94
|
+
estimate: str
|
|
95
|
+
jira_key: str | None
|
|
96
|
+
body: str
|
|
97
|
+
is_canceled: bool = False
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass
|
|
101
|
+
class SyncAction:
|
|
102
|
+
"""A planned create/update/transition operation."""
|
|
103
|
+
|
|
104
|
+
action: str # "create", "update", "transition", "link", "cancel"
|
|
105
|
+
slug: str
|
|
106
|
+
jira_key: str | None = None
|
|
107
|
+
details: dict[str, Any] = field(default_factory=dict)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# ---------------------------------------------------------------------------
|
|
111
|
+
# Frontmatter parsing
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def parse_story_file(path: Path) -> StoryMeta:
|
|
116
|
+
"""Parse a story .md file and return structured metadata."""
|
|
117
|
+
post = frontmatter.load(str(path))
|
|
118
|
+
meta = post.metadata
|
|
119
|
+
|
|
120
|
+
slug = path.stem
|
|
121
|
+
is_canceled = slug.startswith("_")
|
|
122
|
+
if is_canceled:
|
|
123
|
+
slug = slug.lstrip("_")
|
|
124
|
+
|
|
125
|
+
title_raw = meta.get("title", "")
|
|
126
|
+
if not title_raw:
|
|
127
|
+
raise ValueError(f"Missing required 'title' in frontmatter: {path}")
|
|
128
|
+
title = str(title_raw)
|
|
129
|
+
|
|
130
|
+
story_type = str(meta.get("type", "task"))
|
|
131
|
+
|
|
132
|
+
labels_raw = meta.get("labels", [])
|
|
133
|
+
labels: list[str] = (
|
|
134
|
+
[str(x) for x in labels_raw] if isinstance(labels_raw, list) else [str(labels_raw)]
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
requirements_raw = meta.get("requirements", [])
|
|
138
|
+
requirements: list[str] = (
|
|
139
|
+
[str(x) for x in requirements_raw]
|
|
140
|
+
if isinstance(requirements_raw, list)
|
|
141
|
+
else [str(requirements_raw)]
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
depends_on_raw = meta.get("depends_on", [])
|
|
145
|
+
depends_on: list[str] = (
|
|
146
|
+
[str(x) for x in depends_on_raw]
|
|
147
|
+
if isinstance(depends_on_raw, list)
|
|
148
|
+
else [str(depends_on_raw)]
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
parent_raw = meta.get("parent")
|
|
152
|
+
parent: str | None = None
|
|
153
|
+
if parent_raw is not None:
|
|
154
|
+
parent_str = str(parent_raw)
|
|
155
|
+
if parent_str.lower() != "null" and parent_str != "":
|
|
156
|
+
parent = parent_str
|
|
157
|
+
|
|
158
|
+
tasks_raw = meta.get("tasks", [])
|
|
159
|
+
tasks: list[str] = (
|
|
160
|
+
[str(x) for x in tasks_raw] if isinstance(tasks_raw, list) else [str(tasks_raw)]
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
estimate = str(meta.get("estimate", "0h"))
|
|
164
|
+
jira_key_raw = meta.get("jira_key")
|
|
165
|
+
jira_key: str | None = None
|
|
166
|
+
if jira_key_raw is not None and str(jira_key_raw).lower() != "null":
|
|
167
|
+
jira_key = str(jira_key_raw)
|
|
168
|
+
|
|
169
|
+
body = render_body(post.content)
|
|
170
|
+
|
|
171
|
+
return StoryMeta(
|
|
172
|
+
slug=slug,
|
|
173
|
+
path=path,
|
|
174
|
+
title=title,
|
|
175
|
+
story_type=story_type,
|
|
176
|
+
labels=labels,
|
|
177
|
+
requirements=requirements,
|
|
178
|
+
depends_on=depends_on,
|
|
179
|
+
parent=parent,
|
|
180
|
+
tasks=tasks,
|
|
181
|
+
estimate=estimate,
|
|
182
|
+
jira_key=jira_key,
|
|
183
|
+
body=body,
|
|
184
|
+
is_canceled=is_canceled,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def render_body(content: str) -> str:
|
|
189
|
+
"""Render the story body for Jira.
|
|
190
|
+
|
|
191
|
+
- Strips HTML comments (<!-- ... -->)
|
|
192
|
+
- Converts AC bullets to markdown checkboxes
|
|
193
|
+
"""
|
|
194
|
+
# Strip HTML comments (single-line and multi-line)
|
|
195
|
+
rendered = re.sub(r"<!--.*?-->", "", content, flags=re.DOTALL)
|
|
196
|
+
|
|
197
|
+
# Convert AC section bullets to checkboxes
|
|
198
|
+
rendered = _convert_ac_to_checkboxes(rendered)
|
|
199
|
+
|
|
200
|
+
# Clean up excessive blank lines
|
|
201
|
+
rendered = re.sub(r"\n{3,}", "\n\n", rendered)
|
|
202
|
+
|
|
203
|
+
return rendered.strip()
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _convert_ac_to_checkboxes(content: str) -> str:
|
|
207
|
+
"""Convert bullets under '## Acceptance criteria' to markdown checkboxes."""
|
|
208
|
+
lines = content.split("\n")
|
|
209
|
+
result: list[str] = []
|
|
210
|
+
in_ac_section = False
|
|
211
|
+
|
|
212
|
+
for line in lines:
|
|
213
|
+
stripped = line.strip()
|
|
214
|
+
|
|
215
|
+
# Detect AC section start
|
|
216
|
+
if re.match(r"^##\s+Acceptance\s+criteria", stripped, re.IGNORECASE):
|
|
217
|
+
in_ac_section = True
|
|
218
|
+
result.append(line)
|
|
219
|
+
continue
|
|
220
|
+
|
|
221
|
+
# Detect next section (exits AC)
|
|
222
|
+
if in_ac_section and re.match(r"^##\s+", stripped):
|
|
223
|
+
in_ac_section = False
|
|
224
|
+
|
|
225
|
+
# Convert bullets to checkboxes inside AC section
|
|
226
|
+
if in_ac_section and re.match(r"^-\s+(?!\[[ x]\])", stripped):
|
|
227
|
+
line = re.sub(r"^(\s*)-\s+", r"\1- [ ] ", line)
|
|
228
|
+
|
|
229
|
+
result.append(line)
|
|
230
|
+
|
|
231
|
+
return "\n".join(result)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def parse_estimate_hours(estimate: str) -> float | None:
|
|
235
|
+
"""Parse an estimate string like '8h' or '24h' into a float."""
|
|
236
|
+
match = re.match(r"^(\d+(?:\.\d+)?)\s*h$", estimate.strip(), re.IGNORECASE)
|
|
237
|
+
if match:
|
|
238
|
+
return float(match.group(1))
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# ---------------------------------------------------------------------------
|
|
243
|
+
# Git helpers
|
|
244
|
+
# ---------------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def get_changed_story_files(since_ref: str) -> list[Path]:
|
|
248
|
+
"""Get story .md files changed since the given git ref."""
|
|
249
|
+
result = subprocess.run(
|
|
250
|
+
["git", "diff", "--name-only", "--diff-filter=ACDMR", since_ref, "HEAD"],
|
|
251
|
+
capture_output=True,
|
|
252
|
+
text=True,
|
|
253
|
+
cwd=str(REPO_ROOT),
|
|
254
|
+
check=True,
|
|
255
|
+
)
|
|
256
|
+
changed: list[Path] = []
|
|
257
|
+
for line in result.stdout.strip().split("\n"):
|
|
258
|
+
line = line.strip()
|
|
259
|
+
if not line:
|
|
260
|
+
continue
|
|
261
|
+
if line.startswith("docs/stories/") and line.endswith(".md"):
|
|
262
|
+
full_path = REPO_ROOT / line
|
|
263
|
+
# File might have been deleted (rename to _ prefix)
|
|
264
|
+
if full_path.exists() or line.split("/")[-1].startswith("_"):
|
|
265
|
+
changed.append(full_path)
|
|
266
|
+
return changed
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def get_all_story_files() -> list[Path]:
|
|
270
|
+
"""Get all story .md files under docs/stories/."""
|
|
271
|
+
files: list[Path] = []
|
|
272
|
+
for md_file in sorted(STORIES_DIR.rglob("*.md")):
|
|
273
|
+
if md_file.name == "README.md":
|
|
274
|
+
continue
|
|
275
|
+
files.append(md_file)
|
|
276
|
+
return files
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def get_renamed_files(since_ref: str) -> dict[str, str]:
|
|
280
|
+
"""Detect renames (old path -> new path) for underscore-prefix detection."""
|
|
281
|
+
result = subprocess.run(
|
|
282
|
+
["git", "diff", "--name-status", "--diff-filter=R", "-M", since_ref, "HEAD"],
|
|
283
|
+
capture_output=True,
|
|
284
|
+
text=True,
|
|
285
|
+
cwd=str(REPO_ROOT),
|
|
286
|
+
check=True,
|
|
287
|
+
)
|
|
288
|
+
renames: dict[str, str] = {}
|
|
289
|
+
for line in result.stdout.strip().split("\n"):
|
|
290
|
+
if not line:
|
|
291
|
+
continue
|
|
292
|
+
parts = line.split("\t")
|
|
293
|
+
if len(parts) >= 3 and parts[0].startswith("R"):
|
|
294
|
+
old_path = parts[1]
|
|
295
|
+
new_path = parts[2]
|
|
296
|
+
if old_path.startswith("docs/stories/") and old_path.endswith(".md"):
|
|
297
|
+
renames[old_path] = new_path
|
|
298
|
+
return renames
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
# ---------------------------------------------------------------------------
|
|
302
|
+
# Jira API client
|
|
303
|
+
# ---------------------------------------------------------------------------
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
class JiraClient:
|
|
307
|
+
"""Thin wrapper around the Jira REST API v3."""
|
|
308
|
+
|
|
309
|
+
def __init__(
|
|
310
|
+
self,
|
|
311
|
+
base_url: str,
|
|
312
|
+
email: str,
|
|
313
|
+
api_token: str,
|
|
314
|
+
project_key: str = PROJECT_KEY,
|
|
315
|
+
dry_run: bool = False,
|
|
316
|
+
) -> None:
|
|
317
|
+
self.base_url = base_url.rstrip("/")
|
|
318
|
+
self.project_key = project_key
|
|
319
|
+
self.dry_run = dry_run
|
|
320
|
+
self.session = requests.Session()
|
|
321
|
+
self.session.auth = (email, api_token)
|
|
322
|
+
self.session.headers.update(
|
|
323
|
+
{
|
|
324
|
+
"Content-Type": "application/json",
|
|
325
|
+
"Accept": "application/json",
|
|
326
|
+
}
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
def _url(self, path: str) -> str:
|
|
330
|
+
return f"{self.base_url}/rest/api/3{path}"
|
|
331
|
+
|
|
332
|
+
def _get(self, path: str, params: dict[str, str] | None = None) -> dict[str, Any]:
|
|
333
|
+
resp = self.session.get(self._url(path), params=params)
|
|
334
|
+
if not resp.ok:
|
|
335
|
+
logger.error("Jira %s %s returned %d: %s", "GET", path, resp.status_code, resp.text)
|
|
336
|
+
resp.raise_for_status()
|
|
337
|
+
data: dict[str, Any] = resp.json()
|
|
338
|
+
return data
|
|
339
|
+
|
|
340
|
+
def _post(self, path: str, json_data: dict[str, Any]) -> dict[str, Any]:
|
|
341
|
+
if self.dry_run:
|
|
342
|
+
logger.info("[DRY RUN] POST %s: %s", path, json_data)
|
|
343
|
+
return {}
|
|
344
|
+
resp = self.session.post(self._url(path), json=json_data)
|
|
345
|
+
if not resp.ok:
|
|
346
|
+
logger.error(
|
|
347
|
+
"Jira %s %s returned %d: %s\nRequest payload: %s",
|
|
348
|
+
"POST",
|
|
349
|
+
path,
|
|
350
|
+
resp.status_code,
|
|
351
|
+
resp.text,
|
|
352
|
+
json_data,
|
|
353
|
+
)
|
|
354
|
+
resp.raise_for_status()
|
|
355
|
+
if resp.content:
|
|
356
|
+
data: dict[str, Any] = resp.json()
|
|
357
|
+
return data
|
|
358
|
+
return {}
|
|
359
|
+
|
|
360
|
+
def _put(self, path: str, json_data: dict[str, Any]) -> dict[str, Any]:
|
|
361
|
+
if self.dry_run:
|
|
362
|
+
logger.info("[DRY RUN] PUT %s: %s", path, json_data)
|
|
363
|
+
return {}
|
|
364
|
+
resp = self.session.put(self._url(path), json=json_data)
|
|
365
|
+
resp.raise_for_status()
|
|
366
|
+
if resp.content:
|
|
367
|
+
data: dict[str, Any] = resp.json()
|
|
368
|
+
return data
|
|
369
|
+
return {}
|
|
370
|
+
|
|
371
|
+
def create_issue(self, fields: dict[str, Any]) -> dict[str, Any]:
|
|
372
|
+
"""Create a new Jira issue. Returns the created issue data."""
|
|
373
|
+
payload: dict[str, Any] = {"fields": fields}
|
|
374
|
+
return self._post("/issue", payload)
|
|
375
|
+
|
|
376
|
+
def update_issue(self, key: str, fields: dict[str, Any]) -> None:
|
|
377
|
+
"""Update an existing Jira issue's fields."""
|
|
378
|
+
payload: dict[str, Any] = {"fields": fields}
|
|
379
|
+
self._put(f"/issue/{key}", payload)
|
|
380
|
+
|
|
381
|
+
def transition_issue(self, key: str, transition_id: str) -> None:
|
|
382
|
+
"""Transition an issue to a new status."""
|
|
383
|
+
payload: dict[str, Any] = {"transition": {"id": transition_id}}
|
|
384
|
+
self._post(f"/issue/{key}/transitions", payload)
|
|
385
|
+
|
|
386
|
+
def add_comment(self, key: str, body: str) -> None:
|
|
387
|
+
"""Add a comment to an issue."""
|
|
388
|
+
payload: dict[str, Any] = {
|
|
389
|
+
"body": {
|
|
390
|
+
"version": 1,
|
|
391
|
+
"type": "doc",
|
|
392
|
+
"content": [
|
|
393
|
+
{
|
|
394
|
+
"type": "paragraph",
|
|
395
|
+
"content": [{"type": "text", "text": body}],
|
|
396
|
+
}
|
|
397
|
+
],
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
self._post(f"/issue/{key}/comment", payload)
|
|
401
|
+
|
|
402
|
+
def create_issue_link(
|
|
403
|
+
self,
|
|
404
|
+
inward_key: str,
|
|
405
|
+
outward_key: str,
|
|
406
|
+
link_type_id: str = LINK_TYPE_BLOCKS,
|
|
407
|
+
) -> None:
|
|
408
|
+
"""Create an issue link (inward 'is blocked by' outward)."""
|
|
409
|
+
payload: dict[str, Any] = {
|
|
410
|
+
"type": {"id": link_type_id},
|
|
411
|
+
"inwardIssue": {"key": inward_key},
|
|
412
|
+
"outwardIssue": {"key": outward_key},
|
|
413
|
+
}
|
|
414
|
+
self._post("/issueLink", payload)
|
|
415
|
+
|
|
416
|
+
def get_issue(self, key: str) -> dict[str, Any]:
|
|
417
|
+
"""Get full issue data."""
|
|
418
|
+
return self._get(f"/issue/{key}")
|
|
419
|
+
|
|
420
|
+
def get_issue_status(self, key: str) -> str:
|
|
421
|
+
"""Get the status ID of an issue."""
|
|
422
|
+
issue = self._get(f"/issue/{key}", params={"fields": "status"})
|
|
423
|
+
return str(issue["fields"]["status"]["id"])
|
|
424
|
+
|
|
425
|
+
def get_existing_links(self, key: str) -> list[dict[str, Any]]:
|
|
426
|
+
"""Get existing issue links for an issue."""
|
|
427
|
+
issue = self._get(f"/issue/{key}", params={"fields": "issuelinks"})
|
|
428
|
+
return list(issue["fields"].get("issuelinks", []))
|
|
429
|
+
|
|
430
|
+
def get_transitions(self, key: str) -> list[dict[str, Any]]:
|
|
431
|
+
"""Get available transitions for an issue."""
|
|
432
|
+
data = self._get(f"/issue/{key}/transitions")
|
|
433
|
+
return list(data.get("transitions", []))
|
|
434
|
+
|
|
435
|
+
def search_by_story_file_url(self, url: str) -> str | None:
|
|
436
|
+
"""Search for an existing issue by its Story file URL custom field.
|
|
437
|
+
|
|
438
|
+
Returns the issue key if found, None otherwise. Used to dedupe
|
|
439
|
+
against tickets created manually before the sync existed.
|
|
440
|
+
"""
|
|
441
|
+
if self.dry_run:
|
|
442
|
+
logger.info("[DRY RUN] Would search JQL: %s ~ %r", CF_STORY_FILE, url)
|
|
443
|
+
return None
|
|
444
|
+
jql = f'{CF_STORY_FILE} = "{url}" AND project = {self.project_key}'
|
|
445
|
+
# Atlassian deprecated /rest/api/3/search (GET) — returns 410 Gone after May 2025.
|
|
446
|
+
# Replacement: /rest/api/3/search/jql (POST with JSON body).
|
|
447
|
+
# Docs: https://developer.atlassian.com/cloud/jira/platform/changelog/
|
|
448
|
+
data = self._post("/search/jql", {"jql": jql, "fields": ["key"], "maxResults": 1})
|
|
449
|
+
issues: list[dict[str, Any]] = data.get("issues", [])
|
|
450
|
+
if issues:
|
|
451
|
+
found_key = str(issues[0]["key"])
|
|
452
|
+
logger.info(
|
|
453
|
+
"Found existing ticket %s for story file URL %s",
|
|
454
|
+
found_key,
|
|
455
|
+
url,
|
|
456
|
+
)
|
|
457
|
+
return found_key
|
|
458
|
+
return None
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
# ---------------------------------------------------------------------------
|
|
462
|
+
# Sync logic
|
|
463
|
+
# ---------------------------------------------------------------------------
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def build_story_file_url(story: StoryMeta) -> str:
|
|
467
|
+
"""Build the GitHub URL for a story file."""
|
|
468
|
+
rel_path = story.path.relative_to(REPO_ROOT)
|
|
469
|
+
return f"{GITHUB_REPO_URL}/blob/main/{rel_path}"
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def derive_phase(story: StoryMeta) -> str | None:
|
|
473
|
+
"""Derive the phase from the story file's directory path."""
|
|
474
|
+
rel = story.path.relative_to(STORIES_DIR)
|
|
475
|
+
parts = rel.parts
|
|
476
|
+
if parts:
|
|
477
|
+
return parts[0] # e.g. "phase1", "phase2"
|
|
478
|
+
return None
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def build_jira_fields(
|
|
482
|
+
story: StoryMeta,
|
|
483
|
+
all_stories: dict[str, StoryMeta] | None = None,
|
|
484
|
+
) -> dict[str, Any]:
|
|
485
|
+
"""Build the Jira fields dict from a StoryMeta."""
|
|
486
|
+
# Determine issue type
|
|
487
|
+
is_subtask = story.parent is not None
|
|
488
|
+
issue_type_id = ISSUE_TYPE_SUBTASK if is_subtask else ISSUE_TYPE_STORY
|
|
489
|
+
|
|
490
|
+
# Parse estimate
|
|
491
|
+
estimate_hours = parse_estimate_hours(story.estimate)
|
|
492
|
+
|
|
493
|
+
# Build labels: include story labels + phase label
|
|
494
|
+
jira_labels = list(story.labels)
|
|
495
|
+
phase = derive_phase(story)
|
|
496
|
+
if phase and phase not in jira_labels:
|
|
497
|
+
jira_labels.append(phase)
|
|
498
|
+
|
|
499
|
+
story_file_url = build_story_file_url(story)
|
|
500
|
+
# Belt-and-suspenders: strip leading/trailing underscores from the URL.
|
|
501
|
+
# Investigation (SCRUM-26): the '__...__' wrapping reported in the first
|
|
502
|
+
# backfill run was traced to a GH Actions log rendering artifact — the
|
|
503
|
+
# Python dict repr logged by _post() was displayed in a markdown-aware
|
|
504
|
+
# context (Actions log viewer or the Jira ticket where it was pasted),
|
|
505
|
+
# causing surrounding text to appear italic-wrapped. No code path in
|
|
506
|
+
# build_story_file_url() or build_jira_fields() adds underscores; the
|
|
507
|
+
# value sent to Jira was always clean. The strip is kept as defense
|
|
508
|
+
# against any future upstream change that might introduce markers.
|
|
509
|
+
story_file_url = story_file_url.strip("_")
|
|
510
|
+
|
|
511
|
+
fields: dict[str, Any] = {
|
|
512
|
+
"project": {"key": PROJECT_KEY},
|
|
513
|
+
"summary": story.title,
|
|
514
|
+
"description": _text_to_adf(story.body),
|
|
515
|
+
"issuetype": {"id": issue_type_id},
|
|
516
|
+
"labels": jira_labels,
|
|
517
|
+
CF_REQUIREMENT_IDS: ", ".join(story.requirements) if story.requirements else None,
|
|
518
|
+
CF_STORY_FILE: story_file_url,
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
if estimate_hours is not None:
|
|
522
|
+
fields[CF_AGENT_ESTIMATE] = estimate_hours
|
|
523
|
+
|
|
524
|
+
# Include parent reference at create time for sub-tasks
|
|
525
|
+
if is_subtask and story.parent and all_stories is not None:
|
|
526
|
+
parent_key = resolve_slug_to_key(story.parent, all_stories)
|
|
527
|
+
if parent_key:
|
|
528
|
+
fields["parent"] = {"key": parent_key}
|
|
529
|
+
|
|
530
|
+
return fields
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _text_to_adf(text: str) -> dict[str, Any]:
|
|
534
|
+
"""Convert plain markdown text to Atlassian Document Format.
|
|
535
|
+
|
|
536
|
+
Uses a simple paragraph-per-block approach. Jira renders markdown
|
|
537
|
+
within ADF text nodes for basic formatting.
|
|
538
|
+
"""
|
|
539
|
+
paragraphs: list[dict[str, Any]] = []
|
|
540
|
+
for block in text.split("\n\n"):
|
|
541
|
+
block = block.strip()
|
|
542
|
+
if not block:
|
|
543
|
+
continue
|
|
544
|
+
paragraphs.append(
|
|
545
|
+
{
|
|
546
|
+
"type": "paragraph",
|
|
547
|
+
"content": [{"type": "text", "text": block}],
|
|
548
|
+
}
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
if not paragraphs:
|
|
552
|
+
paragraphs.append(
|
|
553
|
+
{
|
|
554
|
+
"type": "paragraph",
|
|
555
|
+
"content": [{"type": "text", "text": " "}],
|
|
556
|
+
}
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
return {
|
|
560
|
+
"version": 1,
|
|
561
|
+
"type": "doc",
|
|
562
|
+
"content": paragraphs,
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def resolve_slug_to_key(slug: str, all_stories: dict[str, StoryMeta]) -> str | None:
|
|
567
|
+
"""Resolve a story slug to its jira_key (if known)."""
|
|
568
|
+
story = all_stories.get(slug)
|
|
569
|
+
if story and story.jira_key:
|
|
570
|
+
return story.jira_key
|
|
571
|
+
return None
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def determine_transition(
|
|
575
|
+
story: StoryMeta,
|
|
576
|
+
all_stories: dict[str, StoryMeta],
|
|
577
|
+
client: JiraClient | None,
|
|
578
|
+
) -> str | None:
|
|
579
|
+
"""Determine the target transition for a story.
|
|
580
|
+
|
|
581
|
+
Returns the transition ID based on depends_on resolution and manual-gate,
|
|
582
|
+
or None if the ticket is already in a terminal state (DONE / Won't Do).
|
|
583
|
+
"""
|
|
584
|
+
# Terminal-state guard: never re-transition a ticket that is already
|
|
585
|
+
# in DONE or WON'T DO.
|
|
586
|
+
if story.jira_key and client:
|
|
587
|
+
if client.dry_run:
|
|
588
|
+
logger.info(
|
|
589
|
+
"[DRY RUN] Would check terminal status for %s (%s)",
|
|
590
|
+
story.jira_key,
|
|
591
|
+
story.slug,
|
|
592
|
+
)
|
|
593
|
+
else:
|
|
594
|
+
current_status = client.get_issue_status(story.jira_key)
|
|
595
|
+
if current_status in PROTECTED_STATUSES:
|
|
596
|
+
status_name = {
|
|
597
|
+
STATUS_DONE: "DONE",
|
|
598
|
+
STATUS_WONT_DO: "WON'T DO",
|
|
599
|
+
STATUS_BLOCKED: "BLOCKED",
|
|
600
|
+
}.get(current_status, current_status)
|
|
601
|
+
logger.info(
|
|
602
|
+
"Skipping transition for %s — current status is "
|
|
603
|
+
"protected (%s); only human action can move it.",
|
|
604
|
+
story.jira_key,
|
|
605
|
+
status_name,
|
|
606
|
+
)
|
|
607
|
+
return None
|
|
608
|
+
|
|
609
|
+
has_manual_gate = "manual-gate" in story.labels
|
|
610
|
+
|
|
611
|
+
# If manual-gate, land in Backlog (To Do)
|
|
612
|
+
if has_manual_gate:
|
|
613
|
+
return TRANSITION_TO_DO
|
|
614
|
+
|
|
615
|
+
# Check depends_on resolution
|
|
616
|
+
if story.depends_on:
|
|
617
|
+
all_resolved = True
|
|
618
|
+
for dep_slug in story.depends_on:
|
|
619
|
+
dep_key = resolve_slug_to_key(dep_slug, all_stories)
|
|
620
|
+
if not dep_key:
|
|
621
|
+
all_resolved = False
|
|
622
|
+
break
|
|
623
|
+
if client and not client.dry_run:
|
|
624
|
+
dep_status = client.get_issue_status(dep_key)
|
|
625
|
+
if dep_status != STATUS_DONE:
|
|
626
|
+
all_resolved = False
|
|
627
|
+
break
|
|
628
|
+
else:
|
|
629
|
+
# In dry-run, assume not resolved unless we can check
|
|
630
|
+
all_resolved = False
|
|
631
|
+
break
|
|
632
|
+
|
|
633
|
+
if not all_resolved:
|
|
634
|
+
return TRANSITION_WAITING
|
|
635
|
+
|
|
636
|
+
return TRANSITION_READY_FOR_AGENT
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def write_jira_key_to_frontmatter(path: Path, jira_key: str) -> None:
|
|
640
|
+
"""Write the jira_key back to the story file's frontmatter."""
|
|
641
|
+
post = frontmatter.load(str(path))
|
|
642
|
+
post.metadata["jira_key"] = jira_key
|
|
643
|
+
frontmatter.dump(post, str(path))
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def sync_story(
|
|
647
|
+
story: StoryMeta,
|
|
648
|
+
all_stories: dict[str, StoryMeta],
|
|
649
|
+
client: JiraClient,
|
|
650
|
+
actions: list[SyncAction],
|
|
651
|
+
) -> str | None:
|
|
652
|
+
"""Sync a single story to Jira. Returns the jira_key if created/updated."""
|
|
653
|
+
# Handle canceled stories (underscore-prefix rename)
|
|
654
|
+
if story.is_canceled:
|
|
655
|
+
if story.jira_key:
|
|
656
|
+
actions.append(
|
|
657
|
+
SyncAction(
|
|
658
|
+
action="cancel",
|
|
659
|
+
slug=story.slug,
|
|
660
|
+
jira_key=story.jira_key,
|
|
661
|
+
details={"transition": "Won't Do"},
|
|
662
|
+
)
|
|
663
|
+
)
|
|
664
|
+
if not client.dry_run:
|
|
665
|
+
client.transition_issue(story.jira_key, TRANSITION_WONT_DO)
|
|
666
|
+
client.add_comment(
|
|
667
|
+
story.jira_key,
|
|
668
|
+
"Story canceled: file renamed with underscore prefix. "
|
|
669
|
+
"See git history for details.",
|
|
670
|
+
)
|
|
671
|
+
return story.jira_key
|
|
672
|
+
return None
|
|
673
|
+
|
|
674
|
+
fields = build_jira_fields(story, all_stories)
|
|
675
|
+
|
|
676
|
+
if story.jira_key:
|
|
677
|
+
# Update existing ticket
|
|
678
|
+
update_fields = {k: v for k, v in fields.items() if k != "project" and k != "issuetype"}
|
|
679
|
+
actions.append(
|
|
680
|
+
SyncAction(
|
|
681
|
+
action="update",
|
|
682
|
+
slug=story.slug,
|
|
683
|
+
jira_key=story.jira_key,
|
|
684
|
+
details={"fields": update_fields},
|
|
685
|
+
)
|
|
686
|
+
)
|
|
687
|
+
if not client.dry_run:
|
|
688
|
+
try:
|
|
689
|
+
client.update_issue(story.jira_key, update_fields)
|
|
690
|
+
except requests.HTTPError as exc:
|
|
691
|
+
# Stale-or-inaccessible-ticket guard: under --all sync mode the
|
|
692
|
+
# script walks every story file, and any story whose jira_key
|
|
693
|
+
# frontmatter references a ticket the current sync auth context
|
|
694
|
+
# cannot edit (deleted, archived, or permission-scoped out)
|
|
695
|
+
# would otherwise halt the entire sync run. Log loud, record a
|
|
696
|
+
# skip action, and continue with remaining stories. Operator
|
|
697
|
+
# investigates the named key separately — either restore
|
|
698
|
+
# permissions, rotate auth, or clear the stale frontmatter.
|
|
699
|
+
if exc.response is not None and exc.response.status_code == 404:
|
|
700
|
+
logger.warning(
|
|
701
|
+
"Skipping update for %s — Jira returned 404. The "
|
|
702
|
+
"issue is either deleted/archived or the sync auth "
|
|
703
|
+
"context (JIRA_USER_EMAIL) lacks edit permission on "
|
|
704
|
+
"it. Continuing with remaining stories. Action: "
|
|
705
|
+
"verify the issue exists and the sync user has "
|
|
706
|
+
"EDIT_ISSUES permission, OR clear the jira_key "
|
|
707
|
+
"frontmatter on the story file.",
|
|
708
|
+
story.jira_key,
|
|
709
|
+
)
|
|
710
|
+
actions.append(
|
|
711
|
+
SyncAction(
|
|
712
|
+
action="update_skipped",
|
|
713
|
+
slug=story.slug,
|
|
714
|
+
jira_key=story.jira_key,
|
|
715
|
+
details={
|
|
716
|
+
"reason": "http_404_not_accessible",
|
|
717
|
+
"remediation": "verify_permissions_or_clear_frontmatter",
|
|
718
|
+
},
|
|
719
|
+
)
|
|
720
|
+
)
|
|
721
|
+
# Don't attempt the transition either — same auth issue
|
|
722
|
+
# would just re-trip. Return the (stale) key so caller
|
|
723
|
+
# state stays consistent.
|
|
724
|
+
return story.jira_key
|
|
725
|
+
raise
|
|
726
|
+
|
|
727
|
+
# Re-evaluate transition
|
|
728
|
+
transition_id = determine_transition(story, all_stories, client)
|
|
729
|
+
if transition_id is None:
|
|
730
|
+
actions.append(
|
|
731
|
+
SyncAction(
|
|
732
|
+
action="transition_skipped",
|
|
733
|
+
slug=story.slug,
|
|
734
|
+
jira_key=story.jira_key,
|
|
735
|
+
details={"reason": "terminal_state"},
|
|
736
|
+
)
|
|
737
|
+
)
|
|
738
|
+
else:
|
|
739
|
+
actions.append(
|
|
740
|
+
SyncAction(
|
|
741
|
+
action="transition",
|
|
742
|
+
slug=story.slug,
|
|
743
|
+
jira_key=story.jira_key,
|
|
744
|
+
details={"transition_id": transition_id},
|
|
745
|
+
)
|
|
746
|
+
)
|
|
747
|
+
if not client.dry_run:
|
|
748
|
+
client.transition_issue(story.jira_key, transition_id)
|
|
749
|
+
|
|
750
|
+
return story.jira_key
|
|
751
|
+
else:
|
|
752
|
+
# Before creating, check if a ticket already exists (dedupe)
|
|
753
|
+
story_file_url = build_story_file_url(story)
|
|
754
|
+
existing_key = client.search_by_story_file_url(story_file_url)
|
|
755
|
+
if existing_key:
|
|
756
|
+
logger.info(
|
|
757
|
+
"Dedupe: found existing ticket %s for %s via JQL lookup",
|
|
758
|
+
existing_key,
|
|
759
|
+
story.slug,
|
|
760
|
+
)
|
|
761
|
+
story.jira_key = existing_key
|
|
762
|
+
write_jira_key_to_frontmatter(story.path, existing_key)
|
|
763
|
+
# Treat as update from here on
|
|
764
|
+
update_fields = {
|
|
765
|
+
k: v for k, v in fields.items() if k != "project" and k != "issuetype"
|
|
766
|
+
}
|
|
767
|
+
actions.append(
|
|
768
|
+
SyncAction(
|
|
769
|
+
action="update",
|
|
770
|
+
slug=story.slug,
|
|
771
|
+
jira_key=existing_key,
|
|
772
|
+
details={"fields": update_fields, "dedupe": True},
|
|
773
|
+
)
|
|
774
|
+
)
|
|
775
|
+
if not client.dry_run:
|
|
776
|
+
client.update_issue(existing_key, update_fields)
|
|
777
|
+
transition_id = determine_transition(story, all_stories, client)
|
|
778
|
+
if transition_id is None:
|
|
779
|
+
actions.append(
|
|
780
|
+
SyncAction(
|
|
781
|
+
action="transition_skipped",
|
|
782
|
+
slug=story.slug,
|
|
783
|
+
jira_key=existing_key,
|
|
784
|
+
details={"reason": "terminal_state"},
|
|
785
|
+
)
|
|
786
|
+
)
|
|
787
|
+
else:
|
|
788
|
+
actions.append(
|
|
789
|
+
SyncAction(
|
|
790
|
+
action="transition",
|
|
791
|
+
slug=story.slug,
|
|
792
|
+
jira_key=existing_key,
|
|
793
|
+
details={"transition_id": transition_id},
|
|
794
|
+
)
|
|
795
|
+
)
|
|
796
|
+
client.transition_issue(existing_key, transition_id)
|
|
797
|
+
return existing_key
|
|
798
|
+
|
|
799
|
+
# Create new ticket
|
|
800
|
+
actions.append(
|
|
801
|
+
SyncAction(
|
|
802
|
+
action="create",
|
|
803
|
+
slug=story.slug,
|
|
804
|
+
details={"fields": fields},
|
|
805
|
+
)
|
|
806
|
+
)
|
|
807
|
+
jira_key: str | None = None
|
|
808
|
+
if not client.dry_run:
|
|
809
|
+
result = client.create_issue(fields)
|
|
810
|
+
jira_key = result.get("key")
|
|
811
|
+
if jira_key:
|
|
812
|
+
story.jira_key = jira_key
|
|
813
|
+
# Propagate the freshly-assigned key into all_stories so any
|
|
814
|
+
# later-processed child story whose parent is THIS story can
|
|
815
|
+
# resolve the parent reference via resolve_slug_to_key().
|
|
816
|
+
# Without this, target_stories and all_stories hold different
|
|
817
|
+
# StoryMeta instances (target_stories is re-parsed from disk
|
|
818
|
+
# in main()), so the mutation on `story.jira_key` above does
|
|
819
|
+
# not reach the dict the child lookup consults — and sub-task
|
|
820
|
+
# creation 400s with "parent issue key or id not specified."
|
|
821
|
+
all_stories[story.slug] = story
|
|
822
|
+
# Write key back to frontmatter
|
|
823
|
+
write_jira_key_to_frontmatter(story.path, jira_key)
|
|
824
|
+
logger.info("Created %s for %s", jira_key, story.slug)
|
|
825
|
+
else:
|
|
826
|
+
jira_key = f"DRY-RUN-{story.slug}"
|
|
827
|
+
logger.info("[DRY RUN] Would create ticket for %s", story.slug)
|
|
828
|
+
|
|
829
|
+
# Transition the new ticket
|
|
830
|
+
if jira_key and not client.dry_run:
|
|
831
|
+
transition_id = determine_transition(story, all_stories, client)
|
|
832
|
+
if transition_id is None:
|
|
833
|
+
actions.append(
|
|
834
|
+
SyncAction(
|
|
835
|
+
action="transition_skipped",
|
|
836
|
+
slug=story.slug,
|
|
837
|
+
jira_key=jira_key,
|
|
838
|
+
details={"reason": "terminal_state"},
|
|
839
|
+
)
|
|
840
|
+
)
|
|
841
|
+
else:
|
|
842
|
+
actions.append(
|
|
843
|
+
SyncAction(
|
|
844
|
+
action="transition",
|
|
845
|
+
slug=story.slug,
|
|
846
|
+
jira_key=jira_key,
|
|
847
|
+
details={"transition_id": transition_id},
|
|
848
|
+
)
|
|
849
|
+
)
|
|
850
|
+
client.transition_issue(jira_key, transition_id)
|
|
851
|
+
|
|
852
|
+
# Create depends_on links
|
|
853
|
+
if story.depends_on and jira_key:
|
|
854
|
+
for dep_slug in story.depends_on:
|
|
855
|
+
dep_key = resolve_slug_to_key(dep_slug, all_stories)
|
|
856
|
+
if dep_key:
|
|
857
|
+
actions.append(
|
|
858
|
+
SyncAction(
|
|
859
|
+
action="link",
|
|
860
|
+
slug=story.slug,
|
|
861
|
+
jira_key=jira_key,
|
|
862
|
+
details={
|
|
863
|
+
"link_type": "is blocked by",
|
|
864
|
+
"target_key": dep_key,
|
|
865
|
+
},
|
|
866
|
+
)
|
|
867
|
+
)
|
|
868
|
+
if not client.dry_run:
|
|
869
|
+
client.create_issue_link(jira_key, dep_key)
|
|
870
|
+
|
|
871
|
+
return jira_key
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def sort_stories_for_processing(stories: list[StoryMeta]) -> list[StoryMeta]:
|
|
875
|
+
"""Sort stories so parents are processed before their children.
|
|
876
|
+
|
|
877
|
+
Returns a new list with parent stories (parent is None) first,
|
|
878
|
+
then children grouped by parent slug. Within each group the
|
|
879
|
+
original relative order is preserved.
|
|
880
|
+
"""
|
|
881
|
+
parents: list[StoryMeta] = []
|
|
882
|
+
children: list[StoryMeta] = []
|
|
883
|
+
for story in stories:
|
|
884
|
+
if story.parent is None:
|
|
885
|
+
parents.append(story)
|
|
886
|
+
else:
|
|
887
|
+
children.append(story)
|
|
888
|
+
return parents + children
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
def load_all_stories() -> dict[str, StoryMeta]:
|
|
892
|
+
"""Load all story files and return a slug -> StoryMeta mapping."""
|
|
893
|
+
stories: dict[str, StoryMeta] = {}
|
|
894
|
+
for path in get_all_story_files():
|
|
895
|
+
try:
|
|
896
|
+
story = parse_story_file(path)
|
|
897
|
+
# Use the non-canceled slug for lookup
|
|
898
|
+
slug = story.slug
|
|
899
|
+
stories[slug] = story
|
|
900
|
+
except Exception:
|
|
901
|
+
logger.warning("Failed to parse story file: %s", path, exc_info=True)
|
|
902
|
+
return stories
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
def print_actions(actions: list[SyncAction]) -> None:
|
|
906
|
+
"""Print planned sync actions in a human-readable format."""
|
|
907
|
+
if not actions:
|
|
908
|
+
print("No sync actions to perform.")
|
|
909
|
+
return
|
|
910
|
+
|
|
911
|
+
print(f"\n{'=' * 60}")
|
|
912
|
+
print(f"Sync plan: {len(actions)} action(s)")
|
|
913
|
+
print(f"{'=' * 60}\n")
|
|
914
|
+
|
|
915
|
+
for i, action in enumerate(actions, 1):
|
|
916
|
+
print(f" [{i}] {action.action.upper()}: {action.slug}")
|
|
917
|
+
if action.jira_key:
|
|
918
|
+
print(f" Jira key: {action.jira_key}")
|
|
919
|
+
for k, v in action.details.items():
|
|
920
|
+
if k == "fields":
|
|
921
|
+
print(f" Fields: {list(v.keys())}")
|
|
922
|
+
else:
|
|
923
|
+
print(f" {k}: {v}")
|
|
924
|
+
print()
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
# ---------------------------------------------------------------------------
|
|
928
|
+
# Main
|
|
929
|
+
# ---------------------------------------------------------------------------
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
def main(argv: list[str] | None = None) -> int:
|
|
933
|
+
"""Main entry point."""
|
|
934
|
+
parser = argparse.ArgumentParser(description="Sync story .md files to Jira tickets.")
|
|
935
|
+
parser.add_argument(
|
|
936
|
+
"--dry-run",
|
|
937
|
+
action="store_true",
|
|
938
|
+
help="Print planned actions without making Jira API calls.",
|
|
939
|
+
)
|
|
940
|
+
parser.add_argument(
|
|
941
|
+
"--since-ref",
|
|
942
|
+
type=str,
|
|
943
|
+
help="Git ref to diff against (e.g. HEAD~1, main~3).",
|
|
944
|
+
)
|
|
945
|
+
parser.add_argument(
|
|
946
|
+
"--all",
|
|
947
|
+
action="store_true",
|
|
948
|
+
help="Sync all story files, not just changed ones.",
|
|
949
|
+
)
|
|
950
|
+
parser.add_argument(
|
|
951
|
+
"--verbose",
|
|
952
|
+
action="store_true",
|
|
953
|
+
help="Enable verbose logging.",
|
|
954
|
+
)
|
|
955
|
+
args = parser.parse_args(argv)
|
|
956
|
+
|
|
957
|
+
logging.basicConfig(
|
|
958
|
+
level=logging.DEBUG if args.verbose else logging.INFO,
|
|
959
|
+
format="%(levelname)s: %(message)s",
|
|
960
|
+
)
|
|
961
|
+
|
|
962
|
+
if not args.all and not args.since_ref:
|
|
963
|
+
parser.error("Either --all or --since-ref is required.")
|
|
964
|
+
|
|
965
|
+
# Load Jira credentials from environment
|
|
966
|
+
jira_base_url = os.environ.get("JIRA_BASE_URL", "")
|
|
967
|
+
jira_email = os.environ.get("JIRA_USER_EMAIL", "")
|
|
968
|
+
jira_token = os.environ.get("JIRA_API_TOKEN", "")
|
|
969
|
+
|
|
970
|
+
if not args.dry_run and (not jira_base_url or not jira_email or not jira_token):
|
|
971
|
+
logger.error("JIRA_BASE_URL, JIRA_USER_EMAIL, and JIRA_API_TOKEN must be set.")
|
|
972
|
+
return 1
|
|
973
|
+
|
|
974
|
+
# Initialize Jira client
|
|
975
|
+
client = JiraClient(
|
|
976
|
+
base_url=jira_base_url or "https://example.atlassian.net",
|
|
977
|
+
email=jira_email or "noop@example.com",
|
|
978
|
+
api_token=jira_token or "noop",
|
|
979
|
+
dry_run=args.dry_run,
|
|
980
|
+
)
|
|
981
|
+
|
|
982
|
+
# Load all stories for slug -> key resolution
|
|
983
|
+
all_stories = load_all_stories()
|
|
984
|
+
logger.info("Loaded %d story files.", len(all_stories))
|
|
985
|
+
|
|
986
|
+
# Scale guard: the --all sync mode (used on every push by the default
|
|
987
|
+
# workflow) issues one Jira GET per story for the terminal-state guard
|
|
988
|
+
# plus one per dep for determine_transition(). At ~250 stories this
|
|
989
|
+
# starts pressing Jira rate limits and pushing single-run latency
|
|
990
|
+
# past ~2 minutes. The durable fix is documented in
|
|
991
|
+
# methodology/followups.md § "Dep reconciliation skips unchanged
|
|
992
|
+
# stories — JQL-prefetch is the durable fix at scale": replace
|
|
993
|
+
# per-story GETs with one JQL pre-fetch of the relevant project
|
|
994
|
+
# tickets, then walk stories against the in-memory state map.
|
|
995
|
+
# Until that lands, fail loudly rather than degrade silently.
|
|
996
|
+
MAX_STORY_CORPUS = 250
|
|
997
|
+
if len(all_stories) > MAX_STORY_CORPUS:
|
|
998
|
+
logger.error(
|
|
999
|
+
"Story corpus has %d files, exceeding the %d-story threshold "
|
|
1000
|
+
"at which the current --all sync strategy starts hitting "
|
|
1001
|
+
"Jira rate limits. Apply the durable fix documented in "
|
|
1002
|
+
"methodology/followups.md § 'Dep reconciliation skips "
|
|
1003
|
+
"unchanged stories — JQL-prefetch is the durable fix at "
|
|
1004
|
+
"scale' (bulk-read Jira ticket states via one JQL query, "
|
|
1005
|
+
"then walk stories locally against the in-memory state map) "
|
|
1006
|
+
"before this workflow can complete.",
|
|
1007
|
+
len(all_stories),
|
|
1008
|
+
MAX_STORY_CORPUS,
|
|
1009
|
+
)
|
|
1010
|
+
return 2
|
|
1011
|
+
|
|
1012
|
+
# Determine which files to sync
|
|
1013
|
+
if args.all:
|
|
1014
|
+
target_files = get_all_story_files()
|
|
1015
|
+
else:
|
|
1016
|
+
target_files = get_changed_story_files(args.since_ref)
|
|
1017
|
+
|
|
1018
|
+
if not target_files:
|
|
1019
|
+
logger.info("No story files to sync.")
|
|
1020
|
+
return 0
|
|
1021
|
+
|
|
1022
|
+
logger.info("Syncing %d story file(s).", len(target_files))
|
|
1023
|
+
|
|
1024
|
+
# Parse target stories
|
|
1025
|
+
target_stories: list[StoryMeta] = []
|
|
1026
|
+
for path in target_files:
|
|
1027
|
+
if not path.exists():
|
|
1028
|
+
# Check if this was a rename to underscore prefix
|
|
1029
|
+
continue
|
|
1030
|
+
try:
|
|
1031
|
+
story = parse_story_file(path)
|
|
1032
|
+
target_stories.append(story)
|
|
1033
|
+
except Exception:
|
|
1034
|
+
logger.error("Failed to parse %s", path, exc_info=True)
|
|
1035
|
+
return 1
|
|
1036
|
+
|
|
1037
|
+
# Also check for renamed (canceled) files
|
|
1038
|
+
if args.since_ref:
|
|
1039
|
+
renames = get_renamed_files(args.since_ref)
|
|
1040
|
+
for old_path, new_path in renames.items():
|
|
1041
|
+
new_name = Path(new_path).name
|
|
1042
|
+
if new_name.startswith("_"):
|
|
1043
|
+
full_new = REPO_ROOT / new_path
|
|
1044
|
+
if full_new.exists():
|
|
1045
|
+
try:
|
|
1046
|
+
story = parse_story_file(full_new)
|
|
1047
|
+
# Only add if not already in target list
|
|
1048
|
+
if not any(s.slug == story.slug for s in target_stories):
|
|
1049
|
+
target_stories.append(story)
|
|
1050
|
+
except Exception:
|
|
1051
|
+
logger.error(
|
|
1052
|
+
"Failed to parse renamed file %s",
|
|
1053
|
+
full_new,
|
|
1054
|
+
exc_info=True,
|
|
1055
|
+
)
|
|
1056
|
+
return 1
|
|
1057
|
+
|
|
1058
|
+
# Sort stories so parents are processed before children
|
|
1059
|
+
target_stories = sort_stories_for_processing(target_stories)
|
|
1060
|
+
|
|
1061
|
+
# Execute sync
|
|
1062
|
+
actions: list[SyncAction] = []
|
|
1063
|
+
writeback_paths: list[tuple[Path, str]] = []
|
|
1064
|
+
|
|
1065
|
+
for story in target_stories:
|
|
1066
|
+
jira_key = sync_story(story, all_stories, client, actions)
|
|
1067
|
+
# Track files that need jira_key writeback
|
|
1068
|
+
if jira_key and not story.is_canceled:
|
|
1069
|
+
# Check if the key was newly assigned (was None before sync)
|
|
1070
|
+
original = all_stories.get(story.slug)
|
|
1071
|
+
if original and original.jira_key is None and jira_key:
|
|
1072
|
+
writeback_paths.append((story.path, jira_key))
|
|
1073
|
+
|
|
1074
|
+
print_actions(actions)
|
|
1075
|
+
|
|
1076
|
+
if writeback_paths and not args.dry_run:
|
|
1077
|
+
logger.info(
|
|
1078
|
+
"jira_key writeback needed for %d file(s): %s",
|
|
1079
|
+
len(writeback_paths),
|
|
1080
|
+
[str(p) for p, _ in writeback_paths],
|
|
1081
|
+
)
|
|
1082
|
+
|
|
1083
|
+
return 0
|
|
1084
|
+
|
|
1085
|
+
|
|
1086
|
+
if __name__ == "__main__":
|
|
1087
|
+
sys.exit(main())
|