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.
@@ -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())