@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.
- package/README.md +1 -1
- package/package.json +2 -1
- package/packages/core/dist/cli/commands/init.d.ts.map +1 -1
- package/packages/core/dist/cli/commands/init.js +7 -6
- package/packages/core/dist/cli/commands/init.js.map +1 -1
- package/packages/core/dist/cli/utils/symlinks.d.ts +7 -0
- package/packages/core/dist/cli/utils/symlinks.d.ts.map +1 -1
- package/packages/core/dist/cli/utils/symlinks.js +25 -0
- package/packages/core/dist/cli/utils/symlinks.js.map +1 -1
- package/pennyfarthing-dist/scripts/core/phase-check-start.sh +1 -1
- package/pennyfarthing-dist/scripts/core/prime.sh +23 -0
- package/pennyfarthing-dist/scripts/core/run.sh +5 -5
- package/pennyfarthing-dist/scripts/git/install-git-hooks.sh +2 -2
- package/pennyfarthing-dist/scripts/git/release.sh +2 -2
- package/pennyfarthing-dist/scripts/health/drift-detection.sh +1 -1
- package/pennyfarthing-dist/scripts/hooks/post-merge.sh +2 -2
- package/pennyfarthing-dist/scripts/hooks/pre-push.sh +2 -2
- package/pennyfarthing-dist/scripts/hooks/session-stop.sh +1 -1
- package/pennyfarthing-dist/scripts/jira/create-jira-epic.sh +1 -1
- package/pennyfarthing-dist/scripts/jira/create-jira-story.sh +1 -1
- package/pennyfarthing-dist/scripts/jira/jira-reconcile.sh +1 -1
- package/pennyfarthing-dist/scripts/lib/common.sh +1 -1
- package/pennyfarthing-dist/scripts/lib/find-root.sh +4 -4
- package/pennyfarthing-dist/scripts/maintenance/sidecar-health.sh +1 -1
- package/pennyfarthing-dist/scripts/misc/add_short_names.py +2 -2
- package/pennyfarthing-dist/scripts/misc/backlog.sh +2 -2
- package/pennyfarthing-dist/scripts/misc/deploy.sh +2 -2
- package/pennyfarthing-dist/scripts/misc/generate-skill-docs.sh +4 -4
- package/pennyfarthing-dist/scripts/misc/log-skill-usage.sh +2 -2
- package/pennyfarthing-dist/scripts/misc/run-ci.sh +1 -1
- package/pennyfarthing-dist/scripts/misc/skill-usage-report.sh +2 -2
- package/pennyfarthing-dist/scripts/sprint/archive-story.sh +1 -1
- package/pennyfarthing-dist/scripts/sprint/available-stories.sh +1 -1
- package/pennyfarthing-dist/scripts/sprint/check-story.sh +1 -1
- package/pennyfarthing-dist/scripts/sprint/get-epic-field.sh +1 -1
- package/pennyfarthing-dist/scripts/sprint/get-story-field.sh +1 -1
- package/pennyfarthing-dist/scripts/sprint/import_epic_to_future.py +2 -2
- package/pennyfarthing-dist/scripts/sprint/list-future.sh +1 -1
- package/pennyfarthing-dist/scripts/sprint/new-sprint.sh +1 -1
- package/pennyfarthing-dist/scripts/sprint/promote-epic.sh +1 -1
- package/pennyfarthing-dist/scripts/sprint/sprint-common.sh +3 -3
- package/pennyfarthing-dist/scripts/sprint/sprint-info.sh +1 -1
- package/pennyfarthing-dist/scripts/sprint/sprint-metrics.sh +2 -2
- package/pennyfarthing-dist/scripts/theme/compute_theme_tiers.py +2 -2
- package/pennyfarthing-dist/scripts/workflow/check.py +2 -2
- package/pennyfarthing-dist/scripts/workflow/finish-story.sh +1 -1
- package/pennyfarthing-dist/scripts/workflow/fix-session-phase.sh +1 -1
- package/pennyfarthing-dist/scripts/workflow/get-workflow-type.py +2 -2
- package/pennyfarthing-dist/scripts/workflow/list-workflows.sh +1 -1
- package/pennyfarthing-dist/scripts/workflow/phase-owner.sh +1 -1
- package/pennyfarthing-dist/scripts/workflow/resume-workflow.sh +1 -1
- package/pennyfarthing-dist/scripts/workflow/show-workflow.sh +1 -1
- package/pennyfarthing-dist/scripts/workflow/start-workflow.sh +1 -1
- package/pennyfarthing-dist/scripts/workflow/workflow-status.sh +1 -1
- package/pennyfarthing_scripts/__init__.py +17 -0
- package/pennyfarthing_scripts/__pycache__/__init__.cpython-311.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/config.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/jira.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/jira_epic_creation.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/jira_sync.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/jira_sync_story.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/sprint.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/workflow.cpython-311.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/workflow.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bellmode_hook.py +154 -0
- package/pennyfarthing_scripts/brownfield/__init__.py +35 -0
- package/pennyfarthing_scripts/brownfield/__main__.py +7 -0
- package/pennyfarthing_scripts/brownfield/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/brownfield/__pycache__/__main__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/brownfield/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/brownfield/__pycache__/discover.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/brownfield/cli.py +131 -0
- package/pennyfarthing_scripts/brownfield/discover.py +753 -0
- package/pennyfarthing_scripts/common/__init__.py +49 -0
- package/pennyfarthing_scripts/common/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/common/__pycache__/config.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/common/__pycache__/output.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/common/config.py +65 -0
- package/pennyfarthing_scripts/common/output.py +180 -0
- package/pennyfarthing_scripts/config.py +21 -0
- package/pennyfarthing_scripts/git/__init__.py +29 -0
- package/pennyfarthing_scripts/git/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/git/__pycache__/create_branches.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/git/__pycache__/status_all.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/git/create_branches.py +439 -0
- package/pennyfarthing_scripts/git/status_all.py +310 -0
- package/pennyfarthing_scripts/hooks.py +455 -0
- package/pennyfarthing_scripts/jira/__init__.py +93 -0
- package/pennyfarthing_scripts/jira/__main__.py +10 -0
- package/pennyfarthing_scripts/jira/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/__main__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/bidirectional.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/claim.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/client.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/compat.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/epic.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/mappings.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/models.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/story.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/sync.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/bidirectional.py +561 -0
- package/pennyfarthing_scripts/jira/claim.py +211 -0
- package/pennyfarthing_scripts/jira/cli.py +150 -0
- package/pennyfarthing_scripts/jira/client.py +613 -0
- package/pennyfarthing_scripts/jira/epic.py +176 -0
- package/pennyfarthing_scripts/jira/story.py +219 -0
- package/pennyfarthing_scripts/jira/sync.py +350 -0
- package/pennyfarthing_scripts/jira_bidirectional_sync.py +37 -0
- package/pennyfarthing_scripts/jira_epic_creation.py +30 -0
- package/pennyfarthing_scripts/jira_sync.py +36 -0
- package/pennyfarthing_scripts/jira_sync_story.py +30 -0
- package/pennyfarthing_scripts/output.py +37 -0
- package/pennyfarthing_scripts/preflight/__init__.py +17 -0
- package/pennyfarthing_scripts/preflight/__main__.py +10 -0
- package/pennyfarthing_scripts/preflight/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/preflight/__pycache__/__main__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/preflight/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/preflight/__pycache__/finish.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/preflight/cli.py +141 -0
- package/pennyfarthing_scripts/preflight/finish.py +382 -0
- package/pennyfarthing_scripts/pretooluse_hook.py +142 -0
- package/pennyfarthing_scripts/prime/__init__.py +38 -0
- package/pennyfarthing_scripts/prime/__main__.py +8 -0
- package/pennyfarthing_scripts/prime/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/__pycache__/__main__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/__pycache__/loader.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/__pycache__/models.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/__pycache__/persona.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/__pycache__/session.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/__pycache__/workflow.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/cli.py +220 -0
- package/pennyfarthing_scripts/prime/loader.py +239 -0
- package/pennyfarthing_scripts/sprint/__init__.py +66 -0
- package/pennyfarthing_scripts/sprint/__main__.py +10 -0
- package/pennyfarthing_scripts/sprint/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/__main__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/archive.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/loader.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/status.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/validator.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/work.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/archive.py +108 -0
- package/pennyfarthing_scripts/sprint/cli.py +124 -0
- package/pennyfarthing_scripts/sprint/loader.py +193 -0
- package/pennyfarthing_scripts/sprint/status.py +122 -0
- package/pennyfarthing_scripts/sprint/validator.py +405 -0
- package/pennyfarthing_scripts/sprint/work.py +192 -0
- package/pennyfarthing_scripts/story/__init__.py +67 -0
- package/pennyfarthing_scripts/story/__main__.py +10 -0
- package/pennyfarthing_scripts/story/cli.py +105 -0
- package/pennyfarthing_scripts/story/create.py +167 -0
- package/pennyfarthing_scripts/story/size.py +113 -0
- package/pennyfarthing_scripts/story/template.py +151 -0
- package/pennyfarthing_scripts/swebench.py +216 -0
- package/pennyfarthing_scripts/tests/__init__.py +1 -0
- package/pennyfarthing_scripts/tests/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/conftest.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_brownfield.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_git_utils.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_prime.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_sprint_validator.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/conftest.py +106 -0
- package/pennyfarthing_scripts/tests/test_brownfield.py +842 -0
- package/pennyfarthing_scripts/tests/test_cli_modules.py +245 -0
- package/pennyfarthing_scripts/tests/test_common.py +180 -0
- package/pennyfarthing_scripts/tests/test_git_utils.py +866 -0
- package/pennyfarthing_scripts/tests/test_jira_package.py +334 -0
- package/pennyfarthing_scripts/tests/test_package_structure.py +372 -0
- package/pennyfarthing_scripts/tests/test_prime.py +397 -0
- package/pennyfarthing_scripts/tests/test_sprint_package.py +236 -0
- package/pennyfarthing_scripts/tests/test_sprint_validator.py +675 -0
- package/pennyfarthing_scripts/tests/test_story_package.py +156 -0
- package/pennyfarthing_scripts/welcome_hook.py +157 -0
- 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())
|