@pennyfarthing/core 7.8.1 → 7.8.4

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.
Files changed (178) hide show
  1. package/README.md +1 -1
  2. package/package.json +2 -1
  3. package/packages/core/dist/cli/commands/init.d.ts.map +1 -1
  4. package/packages/core/dist/cli/commands/init.js +7 -6
  5. package/packages/core/dist/cli/commands/init.js.map +1 -1
  6. package/packages/core/dist/cli/utils/symlinks.d.ts +7 -0
  7. package/packages/core/dist/cli/utils/symlinks.d.ts.map +1 -1
  8. package/packages/core/dist/cli/utils/symlinks.js +25 -0
  9. package/packages/core/dist/cli/utils/symlinks.js.map +1 -1
  10. package/pennyfarthing-dist/scripts/core/phase-check-start.sh +1 -1
  11. package/pennyfarthing-dist/scripts/core/prime.sh +23 -0
  12. package/pennyfarthing-dist/scripts/core/run.sh +5 -5
  13. package/pennyfarthing-dist/scripts/git/install-git-hooks.sh +2 -2
  14. package/pennyfarthing-dist/scripts/git/release.sh +2 -2
  15. package/pennyfarthing-dist/scripts/health/drift-detection.sh +1 -1
  16. package/pennyfarthing-dist/scripts/hooks/post-merge.sh +2 -2
  17. package/pennyfarthing-dist/scripts/hooks/pre-push.sh +2 -2
  18. package/pennyfarthing-dist/scripts/hooks/session-stop.sh +1 -1
  19. package/pennyfarthing-dist/scripts/jira/create-jira-epic.sh +1 -1
  20. package/pennyfarthing-dist/scripts/jira/create-jira-story.sh +1 -1
  21. package/pennyfarthing-dist/scripts/jira/jira-reconcile.sh +1 -1
  22. package/pennyfarthing-dist/scripts/lib/common.sh +1 -1
  23. package/pennyfarthing-dist/scripts/lib/find-root.sh +4 -4
  24. package/pennyfarthing-dist/scripts/maintenance/sidecar-health.sh +1 -1
  25. package/pennyfarthing-dist/scripts/misc/add_short_names.py +2 -2
  26. package/pennyfarthing-dist/scripts/misc/backlog.sh +2 -2
  27. package/pennyfarthing-dist/scripts/misc/deploy.sh +2 -2
  28. package/pennyfarthing-dist/scripts/misc/generate-skill-docs.sh +4 -4
  29. package/pennyfarthing-dist/scripts/misc/log-skill-usage.sh +2 -2
  30. package/pennyfarthing-dist/scripts/misc/run-ci.sh +1 -1
  31. package/pennyfarthing-dist/scripts/misc/skill-usage-report.sh +2 -2
  32. package/pennyfarthing-dist/scripts/sprint/archive-story.sh +1 -1
  33. package/pennyfarthing-dist/scripts/sprint/available-stories.sh +1 -1
  34. package/pennyfarthing-dist/scripts/sprint/check-story.sh +1 -1
  35. package/pennyfarthing-dist/scripts/sprint/get-epic-field.sh +1 -1
  36. package/pennyfarthing-dist/scripts/sprint/get-story-field.sh +1 -1
  37. package/pennyfarthing-dist/scripts/sprint/import_epic_to_future.py +2 -2
  38. package/pennyfarthing-dist/scripts/sprint/list-future.sh +1 -1
  39. package/pennyfarthing-dist/scripts/sprint/new-sprint.sh +1 -1
  40. package/pennyfarthing-dist/scripts/sprint/promote-epic.sh +1 -1
  41. package/pennyfarthing-dist/scripts/sprint/sprint-common.sh +3 -3
  42. package/pennyfarthing-dist/scripts/sprint/sprint-info.sh +1 -1
  43. package/pennyfarthing-dist/scripts/sprint/sprint-metrics.sh +2 -2
  44. package/pennyfarthing-dist/scripts/theme/compute_theme_tiers.py +2 -2
  45. package/pennyfarthing-dist/scripts/workflow/check.py +2 -2
  46. package/pennyfarthing-dist/scripts/workflow/finish-story.sh +1 -1
  47. package/pennyfarthing-dist/scripts/workflow/fix-session-phase.sh +1 -1
  48. package/pennyfarthing-dist/scripts/workflow/get-workflow-type.py +2 -2
  49. package/pennyfarthing-dist/scripts/workflow/list-workflows.sh +1 -1
  50. package/pennyfarthing-dist/scripts/workflow/phase-owner.sh +1 -1
  51. package/pennyfarthing-dist/scripts/workflow/resume-workflow.sh +1 -1
  52. package/pennyfarthing-dist/scripts/workflow/show-workflow.sh +1 -1
  53. package/pennyfarthing-dist/scripts/workflow/start-workflow.sh +1 -1
  54. package/pennyfarthing-dist/scripts/workflow/workflow-status.sh +1 -1
  55. package/pennyfarthing_scripts/__init__.py +17 -0
  56. package/pennyfarthing_scripts/__pycache__/__init__.cpython-311.pyc +0 -0
  57. package/pennyfarthing_scripts/__pycache__/__init__.cpython-314.pyc +0 -0
  58. package/pennyfarthing_scripts/__pycache__/config.cpython-314.pyc +0 -0
  59. package/pennyfarthing_scripts/__pycache__/jira.cpython-314.pyc +0 -0
  60. package/pennyfarthing_scripts/__pycache__/jira_epic_creation.cpython-314.pyc +0 -0
  61. package/pennyfarthing_scripts/__pycache__/jira_sync.cpython-314.pyc +0 -0
  62. package/pennyfarthing_scripts/__pycache__/jira_sync_story.cpython-314.pyc +0 -0
  63. package/pennyfarthing_scripts/__pycache__/sprint.cpython-314.pyc +0 -0
  64. package/pennyfarthing_scripts/__pycache__/workflow.cpython-311.pyc +0 -0
  65. package/pennyfarthing_scripts/__pycache__/workflow.cpython-314.pyc +0 -0
  66. package/pennyfarthing_scripts/bellmode_hook.py +154 -0
  67. package/pennyfarthing_scripts/brownfield/__init__.py +35 -0
  68. package/pennyfarthing_scripts/brownfield/__main__.py +7 -0
  69. package/pennyfarthing_scripts/brownfield/__pycache__/__init__.cpython-314.pyc +0 -0
  70. package/pennyfarthing_scripts/brownfield/__pycache__/__main__.cpython-314.pyc +0 -0
  71. package/pennyfarthing_scripts/brownfield/__pycache__/cli.cpython-314.pyc +0 -0
  72. package/pennyfarthing_scripts/brownfield/__pycache__/discover.cpython-314.pyc +0 -0
  73. package/pennyfarthing_scripts/brownfield/cli.py +131 -0
  74. package/pennyfarthing_scripts/brownfield/discover.py +753 -0
  75. package/pennyfarthing_scripts/common/__init__.py +49 -0
  76. package/pennyfarthing_scripts/common/__pycache__/__init__.cpython-314.pyc +0 -0
  77. package/pennyfarthing_scripts/common/__pycache__/config.cpython-314.pyc +0 -0
  78. package/pennyfarthing_scripts/common/__pycache__/output.cpython-314.pyc +0 -0
  79. package/pennyfarthing_scripts/common/config.py +65 -0
  80. package/pennyfarthing_scripts/common/output.py +180 -0
  81. package/pennyfarthing_scripts/config.py +21 -0
  82. package/pennyfarthing_scripts/git/__init__.py +29 -0
  83. package/pennyfarthing_scripts/git/__pycache__/__init__.cpython-314.pyc +0 -0
  84. package/pennyfarthing_scripts/git/__pycache__/create_branches.cpython-314.pyc +0 -0
  85. package/pennyfarthing_scripts/git/__pycache__/status_all.cpython-314.pyc +0 -0
  86. package/pennyfarthing_scripts/git/create_branches.py +439 -0
  87. package/pennyfarthing_scripts/git/status_all.py +310 -0
  88. package/pennyfarthing_scripts/hooks.py +455 -0
  89. package/pennyfarthing_scripts/jira/__init__.py +93 -0
  90. package/pennyfarthing_scripts/jira/__main__.py +10 -0
  91. package/pennyfarthing_scripts/jira/__pycache__/__init__.cpython-314.pyc +0 -0
  92. package/pennyfarthing_scripts/jira/__pycache__/__main__.cpython-314.pyc +0 -0
  93. package/pennyfarthing_scripts/jira/__pycache__/bidirectional.cpython-314.pyc +0 -0
  94. package/pennyfarthing_scripts/jira/__pycache__/claim.cpython-314.pyc +0 -0
  95. package/pennyfarthing_scripts/jira/__pycache__/cli.cpython-314.pyc +0 -0
  96. package/pennyfarthing_scripts/jira/__pycache__/client.cpython-314.pyc +0 -0
  97. package/pennyfarthing_scripts/jira/__pycache__/compat.cpython-314.pyc +0 -0
  98. package/pennyfarthing_scripts/jira/__pycache__/epic.cpython-314.pyc +0 -0
  99. package/pennyfarthing_scripts/jira/__pycache__/mappings.cpython-314.pyc +0 -0
  100. package/pennyfarthing_scripts/jira/__pycache__/models.cpython-314.pyc +0 -0
  101. package/pennyfarthing_scripts/jira/__pycache__/story.cpython-314.pyc +0 -0
  102. package/pennyfarthing_scripts/jira/__pycache__/sync.cpython-314.pyc +0 -0
  103. package/pennyfarthing_scripts/jira/bidirectional.py +561 -0
  104. package/pennyfarthing_scripts/jira/claim.py +211 -0
  105. package/pennyfarthing_scripts/jira/cli.py +150 -0
  106. package/pennyfarthing_scripts/jira/client.py +613 -0
  107. package/pennyfarthing_scripts/jira/epic.py +176 -0
  108. package/pennyfarthing_scripts/jira/story.py +219 -0
  109. package/pennyfarthing_scripts/jira/sync.py +350 -0
  110. package/pennyfarthing_scripts/jira_bidirectional_sync.py +37 -0
  111. package/pennyfarthing_scripts/jira_epic_creation.py +30 -0
  112. package/pennyfarthing_scripts/jira_sync.py +36 -0
  113. package/pennyfarthing_scripts/jira_sync_story.py +30 -0
  114. package/pennyfarthing_scripts/output.py +37 -0
  115. package/pennyfarthing_scripts/preflight/__init__.py +17 -0
  116. package/pennyfarthing_scripts/preflight/__main__.py +10 -0
  117. package/pennyfarthing_scripts/preflight/__pycache__/__init__.cpython-314.pyc +0 -0
  118. package/pennyfarthing_scripts/preflight/__pycache__/__main__.cpython-314.pyc +0 -0
  119. package/pennyfarthing_scripts/preflight/__pycache__/cli.cpython-314.pyc +0 -0
  120. package/pennyfarthing_scripts/preflight/__pycache__/finish.cpython-314.pyc +0 -0
  121. package/pennyfarthing_scripts/preflight/cli.py +141 -0
  122. package/pennyfarthing_scripts/preflight/finish.py +382 -0
  123. package/pennyfarthing_scripts/pretooluse_hook.py +142 -0
  124. package/pennyfarthing_scripts/prime/__init__.py +38 -0
  125. package/pennyfarthing_scripts/prime/__main__.py +8 -0
  126. package/pennyfarthing_scripts/prime/__pycache__/__init__.cpython-314.pyc +0 -0
  127. package/pennyfarthing_scripts/prime/__pycache__/__main__.cpython-314.pyc +0 -0
  128. package/pennyfarthing_scripts/prime/__pycache__/cli.cpython-314.pyc +0 -0
  129. package/pennyfarthing_scripts/prime/__pycache__/loader.cpython-314.pyc +0 -0
  130. package/pennyfarthing_scripts/prime/__pycache__/models.cpython-314.pyc +0 -0
  131. package/pennyfarthing_scripts/prime/__pycache__/persona.cpython-314.pyc +0 -0
  132. package/pennyfarthing_scripts/prime/__pycache__/session.cpython-314.pyc +0 -0
  133. package/pennyfarthing_scripts/prime/__pycache__/workflow.cpython-314.pyc +0 -0
  134. package/pennyfarthing_scripts/prime/cli.py +220 -0
  135. package/pennyfarthing_scripts/prime/loader.py +239 -0
  136. package/pennyfarthing_scripts/sprint/__init__.py +66 -0
  137. package/pennyfarthing_scripts/sprint/__main__.py +10 -0
  138. package/pennyfarthing_scripts/sprint/__pycache__/__init__.cpython-314.pyc +0 -0
  139. package/pennyfarthing_scripts/sprint/__pycache__/__main__.cpython-314.pyc +0 -0
  140. package/pennyfarthing_scripts/sprint/__pycache__/archive.cpython-314.pyc +0 -0
  141. package/pennyfarthing_scripts/sprint/__pycache__/cli.cpython-314.pyc +0 -0
  142. package/pennyfarthing_scripts/sprint/__pycache__/loader.cpython-314.pyc +0 -0
  143. package/pennyfarthing_scripts/sprint/__pycache__/status.cpython-314.pyc +0 -0
  144. package/pennyfarthing_scripts/sprint/__pycache__/validator.cpython-314.pyc +0 -0
  145. package/pennyfarthing_scripts/sprint/__pycache__/work.cpython-314.pyc +0 -0
  146. package/pennyfarthing_scripts/sprint/archive.py +108 -0
  147. package/pennyfarthing_scripts/sprint/cli.py +124 -0
  148. package/pennyfarthing_scripts/sprint/loader.py +193 -0
  149. package/pennyfarthing_scripts/sprint/status.py +122 -0
  150. package/pennyfarthing_scripts/sprint/validator.py +405 -0
  151. package/pennyfarthing_scripts/sprint/work.py +192 -0
  152. package/pennyfarthing_scripts/story/__init__.py +67 -0
  153. package/pennyfarthing_scripts/story/__main__.py +10 -0
  154. package/pennyfarthing_scripts/story/cli.py +105 -0
  155. package/pennyfarthing_scripts/story/create.py +167 -0
  156. package/pennyfarthing_scripts/story/size.py +113 -0
  157. package/pennyfarthing_scripts/story/template.py +151 -0
  158. package/pennyfarthing_scripts/swebench.py +216 -0
  159. package/pennyfarthing_scripts/tests/__init__.py +1 -0
  160. package/pennyfarthing_scripts/tests/__pycache__/__init__.cpython-314.pyc +0 -0
  161. package/pennyfarthing_scripts/tests/__pycache__/conftest.cpython-314-pytest-9.0.2.pyc +0 -0
  162. package/pennyfarthing_scripts/tests/__pycache__/test_brownfield.cpython-314-pytest-9.0.2.pyc +0 -0
  163. package/pennyfarthing_scripts/tests/__pycache__/test_git_utils.cpython-314-pytest-9.0.2.pyc +0 -0
  164. package/pennyfarthing_scripts/tests/__pycache__/test_prime.cpython-314-pytest-9.0.2.pyc +0 -0
  165. package/pennyfarthing_scripts/tests/__pycache__/test_sprint_validator.cpython-314-pytest-9.0.2.pyc +0 -0
  166. package/pennyfarthing_scripts/tests/conftest.py +106 -0
  167. package/pennyfarthing_scripts/tests/test_brownfield.py +842 -0
  168. package/pennyfarthing_scripts/tests/test_cli_modules.py +245 -0
  169. package/pennyfarthing_scripts/tests/test_common.py +180 -0
  170. package/pennyfarthing_scripts/tests/test_git_utils.py +866 -0
  171. package/pennyfarthing_scripts/tests/test_jira_package.py +334 -0
  172. package/pennyfarthing_scripts/tests/test_package_structure.py +372 -0
  173. package/pennyfarthing_scripts/tests/test_prime.py +397 -0
  174. package/pennyfarthing_scripts/tests/test_sprint_package.py +236 -0
  175. package/pennyfarthing_scripts/tests/test_sprint_validator.py +675 -0
  176. package/pennyfarthing_scripts/tests/test_story_package.py +156 -0
  177. package/pennyfarthing_scripts/welcome_hook.py +157 -0
  178. package/pennyfarthing_scripts/workflow.py +183 -0
@@ -0,0 +1,561 @@
1
+ """
2
+ Bidirectional sync between sprint YAML and Jira.
3
+
4
+ Story: MSSCI-12400 (Port from jira-bidirectional-sync.mjs)
5
+
6
+ Usage: python -m pennyfarthing_scripts.jira bidirectional [options]
7
+
8
+ Options:
9
+ --dry-run Show changes without applying
10
+ --yaml-wins Prefer YAML values on conflict (default: Jira wins)
11
+ --status Sync status field
12
+ --points Sync story points
13
+ --all Sync all fields (status + points)
14
+ --sprint <id> Target specific sprint (default: current)
15
+
16
+ Features over JS version:
17
+ - Async parallel Jira API calls
18
+ - Structured colored output
19
+ - Better conflict detection
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import argparse
25
+ import asyncio
26
+ import sys
27
+ from dataclasses import dataclass, field
28
+ from pathlib import Path
29
+ from typing import Any, Literal
30
+
31
+ from pennyfarthing_scripts.jira.client import JiraClient, map_jira_to_status, map_status_to_jira
32
+ from pennyfarthing_scripts.common.output import error, info, success, warn
33
+ from pennyfarthing_scripts.sprint.loader import get_all_stories, load_sprint
34
+
35
+
36
+ # =============================================================================
37
+ # Data Classes
38
+ # =============================================================================
39
+
40
+
41
+ @dataclass
42
+ class SyncChange:
43
+ """Represents a single sync change to apply."""
44
+
45
+ key: str
46
+ field: Literal["status", "points"]
47
+ action: Literal["update-yaml", "update-jira"]
48
+ yaml_value: Any
49
+ jira_value: Any
50
+ target_value: Any
51
+
52
+
53
+ @dataclass
54
+ class SyncPlan:
55
+ """Result of comparing YAML and Jira stories."""
56
+
57
+ changes: list[SyncChange] = field(default_factory=list)
58
+ yaml_only: list[str] = field(default_factory=list)
59
+ jira_only: list[str] = field(default_factory=list)
60
+ both: list[str] = field(default_factory=list)
61
+ conflicts: list[dict[str, Any]] = field(default_factory=list)
62
+
63
+
64
+ @dataclass
65
+ class SyncResult:
66
+ """Result of executing a sync plan."""
67
+
68
+ dry_run: bool
69
+ changes_planned: int
70
+ changes_applied: int
71
+ yaml_modified: bool
72
+ jira_api_calls: int
73
+ errors: list[str] = field(default_factory=list)
74
+
75
+
76
+ # =============================================================================
77
+ # CLI Argument Parsing
78
+ # =============================================================================
79
+
80
+
81
+ def parse_cli_args(argv: list[str] | None = None) -> argparse.Namespace:
82
+ """Parse command line arguments.
83
+
84
+ Args:
85
+ argv: Command line arguments (defaults to sys.argv[1:])
86
+
87
+ Returns:
88
+ Parsed arguments namespace
89
+ """
90
+ parser = argparse.ArgumentParser(
91
+ description="Bidirectional sync between sprint YAML and Jira",
92
+ formatter_class=argparse.RawDescriptionHelpFormatter,
93
+ epilog="""
94
+ Examples:
95
+ # Preview status sync (dry run)
96
+ python -m pennyfarthing_scripts.jira bidirectional --status --dry-run
97
+
98
+ # Sync all fields, YAML wins conflicts
99
+ python -m pennyfarthing_scripts.jira bidirectional --all --yaml-wins
100
+
101
+ # Sync only points to Jira
102
+ python -m pennyfarthing_scripts.jira bidirectional --points
103
+ """,
104
+ )
105
+
106
+ parser.add_argument(
107
+ "--dry-run",
108
+ action="store_true",
109
+ help="Show changes without applying",
110
+ )
111
+ parser.add_argument(
112
+ "--yaml-wins",
113
+ action="store_true",
114
+ help="Prefer YAML values on conflict (default: Jira wins)",
115
+ )
116
+ parser.add_argument(
117
+ "--status",
118
+ action="store_true",
119
+ help="Sync status field",
120
+ )
121
+ parser.add_argument(
122
+ "--points",
123
+ action="store_true",
124
+ help="Sync story points",
125
+ )
126
+ parser.add_argument(
127
+ "--all",
128
+ action="store_true",
129
+ help="Sync all fields (status + points)",
130
+ )
131
+ parser.add_argument(
132
+ "--sprint",
133
+ type=str,
134
+ default=None,
135
+ help="Target specific sprint ID (default: current)",
136
+ )
137
+
138
+ args = parser.parse_args(argv)
139
+
140
+ # --all implies both --status and --points
141
+ if args.all:
142
+ args.status = True
143
+ args.points = True
144
+
145
+ return args
146
+
147
+
148
+ # =============================================================================
149
+ # Sync Plan Generation
150
+ # =============================================================================
151
+
152
+
153
+ def generate_sync_plan(
154
+ yaml_stories: list[dict[str, Any]],
155
+ jira_stories: list[dict[str, Any]],
156
+ *,
157
+ sync_status: bool = False,
158
+ sync_points: bool = False,
159
+ yaml_wins: bool = False,
160
+ ) -> SyncPlan:
161
+ """Generate a sync plan comparing YAML and Jira stories.
162
+
163
+ Args:
164
+ yaml_stories: Stories from sprint YAML [{id, jira, status, points, ...}]
165
+ jira_stories: Stories from Jira [{key, fields: {status: {name}, customfield_10031, ...}}]
166
+ sync_status: Whether to sync status field
167
+ sync_points: Whether to sync points field
168
+ yaml_wins: If True, YAML wins conflicts (default: Jira wins)
169
+
170
+ Returns:
171
+ SyncPlan with changes, categorized stories, and conflicts
172
+ """
173
+ plan = SyncPlan()
174
+
175
+ # Build lookup maps - use Jira key as the common identifier
176
+ yaml_by_jira_key: dict[str, dict[str, Any]] = {}
177
+ for story in yaml_stories:
178
+ jira_key = story.get("jira")
179
+ if jira_key:
180
+ yaml_by_jira_key[jira_key] = story
181
+
182
+ jira_by_key: dict[str, dict[str, Any]] = {}
183
+ for story in jira_stories:
184
+ key = story.get("key")
185
+ if key:
186
+ jira_by_key[key] = story
187
+
188
+ # Categorize stories
189
+ yaml_keys = set(yaml_by_jira_key.keys())
190
+ jira_keys = set(jira_by_key.keys())
191
+
192
+ plan.yaml_only = sorted(yaml_keys - jira_keys)
193
+ plan.jira_only = sorted(jira_keys - yaml_keys)
194
+ plan.both = sorted(yaml_keys & jira_keys)
195
+
196
+ # Generate changes for stories in both systems
197
+ for key in plan.both:
198
+ yaml_story = yaml_by_jira_key[key]
199
+ jira_story = jira_by_key[key]
200
+
201
+ yaml_status = yaml_story.get("status")
202
+ jira_status_raw = jira_story.get("fields", {}).get("status", {}).get("name")
203
+ yaml_points = yaml_story.get("points")
204
+ # customfield_10031 is Story Points for 1898andco Jira
205
+ jira_points = jira_story.get("fields", {}).get("customfield_10031")
206
+
207
+ # Normalize statuses for comparison
208
+ normalized_yaml_status = map_status_to_jira(yaml_status)
209
+ normalized_jira_status = jira_status_raw
210
+
211
+ # Check status differences
212
+ if sync_status and normalized_yaml_status != normalized_jira_status:
213
+ if yaml_wins:
214
+ action: Literal["update-yaml", "update-jira"] = "update-jira"
215
+ target_status = normalized_yaml_status
216
+ else:
217
+ action = "update-yaml"
218
+ target_status = map_jira_to_status(jira_status_raw)
219
+
220
+ plan.changes.append(
221
+ SyncChange(
222
+ key=key,
223
+ field="status",
224
+ action=action,
225
+ yaml_value=yaml_status,
226
+ jira_value=jira_status_raw,
227
+ target_value=target_status,
228
+ )
229
+ )
230
+
231
+ # Check points differences
232
+ if sync_points and yaml_points != jira_points:
233
+ if yaml_wins:
234
+ action = "update-jira"
235
+ target_points = yaml_points
236
+ else:
237
+ action = "update-yaml"
238
+ target_points = jira_points
239
+
240
+ plan.changes.append(
241
+ SyncChange(
242
+ key=key,
243
+ field="points",
244
+ action=action,
245
+ yaml_value=yaml_points,
246
+ jira_value=jira_points,
247
+ target_value=target_points,
248
+ )
249
+ )
250
+
251
+ return plan
252
+
253
+
254
+ # =============================================================================
255
+ # Sync Plan Execution
256
+ # =============================================================================
257
+
258
+
259
+ async def execute_sync_plan(
260
+ plan: SyncPlan,
261
+ *,
262
+ dry_run: bool = False,
263
+ client: JiraClient | None = None,
264
+ sprint_path: Path | None = None,
265
+ ) -> SyncResult:
266
+ """Execute a sync plan, applying changes to YAML and/or Jira.
267
+
268
+ Args:
269
+ plan: Sync plan from generate_sync_plan
270
+ dry_run: If True, don't apply changes
271
+ client: JiraClient instance (created if not provided)
272
+ sprint_path: Path to sprint YAML (for YAML updates)
273
+
274
+ Returns:
275
+ SyncResult with execution details
276
+ """
277
+ result = SyncResult(
278
+ dry_run=dry_run,
279
+ changes_planned=len(plan.changes),
280
+ changes_applied=0,
281
+ yaml_modified=False,
282
+ jira_api_calls=0,
283
+ )
284
+
285
+ if dry_run:
286
+ return result
287
+
288
+ if client is None:
289
+ client = JiraClient()
290
+
291
+ # Group changes by action type for parallel execution
292
+ jira_updates: list[SyncChange] = []
293
+ yaml_updates: list[SyncChange] = []
294
+
295
+ for change in plan.changes:
296
+ if change.action == "update-jira":
297
+ jira_updates.append(change)
298
+ else:
299
+ yaml_updates.append(change)
300
+
301
+ # Execute Jira updates in parallel
302
+ if jira_updates:
303
+ tasks = []
304
+ for change in jira_updates:
305
+ if change.field == "status":
306
+ tasks.append(
307
+ client.transition_async(change.key, change.target_value)
308
+ )
309
+ elif change.field == "points":
310
+ tasks.append(
311
+ client.sync_story_points_async(change.key, change.target_value)
312
+ )
313
+
314
+ results = await asyncio.gather(*tasks, return_exceptions=True)
315
+
316
+ for i, res in enumerate(results):
317
+ result.jira_api_calls += 1
318
+ if isinstance(res, Exception):
319
+ result.errors.append(f"{jira_updates[i].key}: {res}")
320
+ elif isinstance(res, dict) and res.get("success"):
321
+ result.changes_applied += 1
322
+ elif isinstance(res, dict):
323
+ result.errors.append(
324
+ f"{jira_updates[i].key}: {res.get('reason', 'Unknown error')}"
325
+ )
326
+
327
+ # Execute YAML updates (sequential, file-based)
328
+ if yaml_updates:
329
+ # Load current sprint data
330
+ from pennyfarthing_scripts.common.config import get_project_root
331
+
332
+ root = get_project_root()
333
+ sprint_file = sprint_path or (root / "sprint" / "current-sprint.yaml")
334
+
335
+ if sprint_file.exists():
336
+ import yaml
337
+
338
+ with open(sprint_file) as f:
339
+ sprint_data = yaml.safe_load(f)
340
+
341
+ # Apply YAML updates
342
+ for change in yaml_updates:
343
+ # Find and update the story in sprint data
344
+ updated = _update_story_in_sprint(
345
+ sprint_data, change.key, change.field, change.target_value
346
+ )
347
+ if updated:
348
+ result.changes_applied += 1
349
+ result.yaml_modified = True
350
+
351
+ # Write back if modified
352
+ if result.yaml_modified:
353
+ with open(sprint_file, "w") as f:
354
+ yaml.dump(sprint_data, f, default_flow_style=False, sort_keys=False)
355
+
356
+ return result
357
+
358
+
359
+ def _update_story_in_sprint(
360
+ sprint_data: dict[str, Any],
361
+ jira_key: str,
362
+ field: str,
363
+ value: Any,
364
+ ) -> bool:
365
+ """Update a story field in sprint data by Jira key.
366
+
367
+ Args:
368
+ sprint_data: Sprint YAML data
369
+ jira_key: Jira issue key (e.g., MSSCI-12400)
370
+ field: Field to update (status, points)
371
+ value: New value
372
+
373
+ Returns:
374
+ True if story was found and updated
375
+ """
376
+ if not sprint_data or "epics" not in sprint_data:
377
+ return False
378
+
379
+ for epic in sprint_data.get("epics", []):
380
+ for story in epic.get("stories", []):
381
+ if story.get("jira") == jira_key:
382
+ story[field] = value
383
+ return True
384
+
385
+ return False
386
+
387
+
388
+ # =============================================================================
389
+ # Sync Plan Formatting
390
+ # =============================================================================
391
+
392
+
393
+ def format_sync_plan(plan: SyncPlan) -> str:
394
+ """Format sync plan as human-readable string.
395
+
396
+ Args:
397
+ plan: Sync plan from generate_sync_plan
398
+
399
+ Returns:
400
+ Human-readable output string
401
+ """
402
+ lines = []
403
+
404
+ lines.append("=" * 60)
405
+ lines.append("Bidirectional Sync Plan")
406
+ lines.append("=" * 60)
407
+ lines.append("")
408
+
409
+ # Summary
410
+ lines.append(f"Stories in YAML only: {len(plan.yaml_only)}")
411
+ lines.append(f"Stories in Jira only: {len(plan.jira_only)}")
412
+ lines.append(f"Stories in both: {len(plan.both)}")
413
+ lines.append(f"Changes to apply: {len(plan.changes)}")
414
+ lines.append("")
415
+
416
+ # YAML-only stories
417
+ if plan.yaml_only:
418
+ lines.append("--- YAML Only (not in Jira) ---")
419
+ for key in plan.yaml_only:
420
+ lines.append(f" {key}")
421
+ lines.append("")
422
+
423
+ # Jira-only stories
424
+ if plan.jira_only:
425
+ lines.append("--- Jira Only (not in YAML) ---")
426
+ for key in plan.jira_only:
427
+ lines.append(f" {key}")
428
+ lines.append("")
429
+
430
+ # Changes
431
+ if plan.changes:
432
+ lines.append("--- Changes ---")
433
+ for change in plan.changes:
434
+ direction = "Jira -> YAML" if change.action == "update-yaml" else "YAML -> Jira"
435
+ lines.append(f" {change.key}: {change.field} {direction}")
436
+ lines.append(
437
+ f" YAML: {change.yaml_value} | Jira: {change.jira_value} -> {change.target_value}"
438
+ )
439
+ lines.append("")
440
+
441
+ # Conflicts
442
+ if plan.conflicts:
443
+ lines.append("--- Conflicts (manual resolution needed) ---")
444
+ for conflict in plan.conflicts:
445
+ lines.append(f" {conflict.get('key')}: {conflict.get('field')}")
446
+ lines.append("")
447
+
448
+ lines.append("=" * 60)
449
+
450
+ return "\n".join(lines)
451
+
452
+
453
+ # =============================================================================
454
+ # Main Entry Point
455
+ # =============================================================================
456
+
457
+
458
+ async def async_main(args: argparse.Namespace) -> int:
459
+ """Async main entry point.
460
+
461
+ Args:
462
+ args: Parsed command line arguments
463
+
464
+ Returns:
465
+ Exit code (0 for success, 1 for error)
466
+ """
467
+ # Validate at least one field is selected
468
+ if not args.status and not args.points:
469
+ error("Must specify at least one field to sync: --status, --points, or --all")
470
+ return 1
471
+
472
+ # Load sprint data
473
+ sprint_data = load_sprint()
474
+ if not sprint_data:
475
+ error("Could not load sprint data")
476
+ return 1
477
+
478
+ yaml_stories = get_all_stories()
479
+ if not yaml_stories:
480
+ warn("No stories found in sprint YAML")
481
+
482
+ # Filter to stories with Jira keys
483
+ yaml_stories_with_jira = [s for s in yaml_stories if s.get("jira")]
484
+ info(f"Found {len(yaml_stories_with_jira)} stories with Jira keys in YAML")
485
+
486
+ # Fetch Jira stories
487
+ client = JiraClient()
488
+ jira_keys = [s["jira"] for s in yaml_stories_with_jira]
489
+
490
+ info(f"Fetching {len(jira_keys)} issues from Jira...")
491
+ jira_stories = []
492
+
493
+ # Fetch in parallel
494
+ tasks = [client.get_issue_async(key) for key in jira_keys]
495
+ results = await asyncio.gather(*tasks, return_exceptions=True)
496
+
497
+ for key, result in zip(jira_keys, results):
498
+ if isinstance(result, Exception):
499
+ warn(f"Failed to fetch {key}: {result}")
500
+ elif result:
501
+ jira_stories.append(result)
502
+ else:
503
+ warn(f"Issue not found: {key}")
504
+
505
+ info(f"Fetched {len(jira_stories)} issues from Jira")
506
+
507
+ # Generate sync plan
508
+ plan = generate_sync_plan(
509
+ yaml_stories_with_jira,
510
+ jira_stories,
511
+ sync_status=args.status,
512
+ sync_points=args.points,
513
+ yaml_wins=args.yaml_wins,
514
+ )
515
+
516
+ # Display plan
517
+ print(format_sync_plan(plan), file=sys.stderr)
518
+
519
+ if args.dry_run:
520
+ info("Dry run - no changes applied")
521
+ return 0
522
+
523
+ if not plan.changes:
524
+ success("No changes needed - already in sync")
525
+ return 0
526
+
527
+ # Execute plan
528
+ info(f"Applying {len(plan.changes)} changes...")
529
+ result = await execute_sync_plan(plan, dry_run=False, client=client)
530
+
531
+ # Report results
532
+ if result.errors:
533
+ for err in result.errors:
534
+ error(err)
535
+
536
+ if result.yaml_modified:
537
+ success("Updated sprint YAML")
538
+
539
+ if result.jira_api_calls > 0:
540
+ success(f"Made {result.jira_api_calls} Jira API calls")
541
+
542
+ success(f"Applied {result.changes_applied}/{result.changes_planned} changes")
543
+
544
+ return 0 if not result.errors else 1
545
+
546
+
547
+ def main(args: list[str] | None = None) -> int:
548
+ """Main entry point.
549
+
550
+ Args:
551
+ args: Command line arguments (defaults to sys.argv[1:])
552
+
553
+ Returns:
554
+ Exit code
555
+ """
556
+ parsed_args = parse_cli_args(args)
557
+ return asyncio.run(async_main(parsed_args))
558
+
559
+
560
+ if __name__ == "__main__":
561
+ sys.exit(main())