sddflow 2.7.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.
- sdd/__init__.py +1 -0
- sdd/__main__.py +30 -0
- sdd/commands/__init__.py +0 -0
- sdd/commands/config.py +268 -0
- sdd/commands/confluence.py +376 -0
- sdd/commands/cr.py +257 -0
- sdd/commands/init.py +279 -0
- sdd/commands/jira.py +262 -0
- sdd/commands/pr.py +308 -0
- sdd/commands/review.py +608 -0
- sdd/commands/upgrade.py +100 -0
- sdd/utils/__init__.py +0 -0
- sdd/utils/atlassian_auth.py +108 -0
- sdd/utils/cf_to_md.py +81 -0
- sdd/utils/confluence_client.py +170 -0
- sdd/utils/detect.py +97 -0
- sdd/utils/git_host.py +657 -0
- sdd/utils/integrations.py +165 -0
- sdd/utils/jira_client.py +86 -0
- sdd/utils/manifest.py +44 -0
- sdd/utils/md_to_cf.py +139 -0
- sdd/utils/scaffold.py +85 -0
- sdd/utils/sdd_parser.py +194 -0
- sdd/utils/validate.py +37 -0
- sddflow-2.7.0.dist-info/METADATA +639 -0
- sddflow-2.7.0.dist-info/RECORD +28 -0
- sddflow-2.7.0.dist-info/WHEEL +4 -0
- sddflow-2.7.0.dist-info/entry_points.txt +2 -0
sdd/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "2.7.0"
|
sdd/__main__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import click
|
|
2
|
+
from sdd import __version__
|
|
3
|
+
from sdd.commands.init import init_command
|
|
4
|
+
from sdd.commands.upgrade import upgrade_command
|
|
5
|
+
from sdd.commands.config import config_command
|
|
6
|
+
from sdd.commands.jira import jira_command
|
|
7
|
+
from sdd.commands.confluence import confluence_command
|
|
8
|
+
from sdd.commands.review import review_command
|
|
9
|
+
from sdd.commands.cr import cr_command
|
|
10
|
+
from sdd.commands.pr import pr_command
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@click.group()
|
|
14
|
+
@click.version_option(__version__, prog_name="sdd")
|
|
15
|
+
def cli():
|
|
16
|
+
"""SDD Framework CLI — Spec-Driven Development"""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
cli.add_command(init_command, name="init")
|
|
20
|
+
cli.add_command(upgrade_command, name="upgrade")
|
|
21
|
+
cli.add_command(config_command, name="config")
|
|
22
|
+
cli.add_command(jira_command, name="jira")
|
|
23
|
+
cli.add_command(confluence_command, name="confluence")
|
|
24
|
+
cli.add_command(review_command, name="review")
|
|
25
|
+
cli.add_command(cr_command, name="cr")
|
|
26
|
+
cli.add_command(pr_command, name="pr")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
if __name__ == "__main__":
|
|
30
|
+
cli()
|
sdd/commands/__init__.py
ADDED
|
File without changes
|
sdd/commands/config.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import yaml
|
|
4
|
+
import click
|
|
5
|
+
import requests
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
from sdd.utils.atlassian_auth import load_profile, build_session, save_config, CONFIG_PATH
|
|
9
|
+
from sdd.utils.jira_client import JiraClient
|
|
10
|
+
from sdd.utils.confluence_client import ConfluenceClient
|
|
11
|
+
|
|
12
|
+
console = Console()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@click.group()
|
|
16
|
+
def config_command():
|
|
17
|
+
"""Configure Atlassian credentials and project integration."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@config_command.command("init")
|
|
21
|
+
def config_init():
|
|
22
|
+
"""Interactively create ~/.sdd/config.yml and .specify/integrations.yml."""
|
|
23
|
+
import questionary
|
|
24
|
+
|
|
25
|
+
console.print()
|
|
26
|
+
console.print("[bold]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/bold]")
|
|
27
|
+
console.print(" [bold cyan]SDD Config[/bold cyan] — Atlassian setup")
|
|
28
|
+
console.print("[bold]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/bold]")
|
|
29
|
+
console.print()
|
|
30
|
+
|
|
31
|
+
profile_name = questionary.text("Profile name:", default="default").ask()
|
|
32
|
+
base_url = questionary.text(
|
|
33
|
+
"Atlassian base URL:", default="https://myco.atlassian.net"
|
|
34
|
+
).ask()
|
|
35
|
+
auth_mode = questionary.select(
|
|
36
|
+
"Auth mode:",
|
|
37
|
+
choices=[
|
|
38
|
+
questionary.Choice(
|
|
39
|
+
"basic — Cloud (email + API token)", value="basic"),
|
|
40
|
+
questionary.Choice(
|
|
41
|
+
"pat — Server/DC (Personal Access Token)", value="pat"),
|
|
42
|
+
questionary.Choice(
|
|
43
|
+
"oauth2 — Cloud CI/CD (OAuth 2.0 Bearer token)", value="oauth2"),
|
|
44
|
+
],
|
|
45
|
+
).ask()
|
|
46
|
+
|
|
47
|
+
profile: dict = {"auth_mode": auth_mode, "base_url": base_url}
|
|
48
|
+
|
|
49
|
+
if auth_mode == "basic":
|
|
50
|
+
email = questionary.text("Your Atlassian account email:").ask()
|
|
51
|
+
api_token_env = questionary.text(
|
|
52
|
+
"Name of env var holding your API token:", default="JIRA_API_TOKEN"
|
|
53
|
+
).ask()
|
|
54
|
+
profile["email"] = email
|
|
55
|
+
profile["api_token_env"] = api_token_env
|
|
56
|
+
console.print(f"\n [dim]Export [cyan]{api_token_env}[/cyan] before running sdd commands.[/dim]")
|
|
57
|
+
|
|
58
|
+
elif auth_mode == "pat":
|
|
59
|
+
pat_env = questionary.text(
|
|
60
|
+
"Name of env var holding your PAT:", default="JIRA_PAT"
|
|
61
|
+
).ask()
|
|
62
|
+
profile["pat_env"] = pat_env
|
|
63
|
+
console.print(f"\n [dim]Export [cyan]{pat_env}[/cyan] before running sdd commands.[/dim]")
|
|
64
|
+
|
|
65
|
+
elif auth_mode == "oauth2":
|
|
66
|
+
access_token_env = questionary.text(
|
|
67
|
+
"Name of env var holding your OAuth2 access token:",
|
|
68
|
+
default="JIRA_ACCESS_TOKEN",
|
|
69
|
+
).ask()
|
|
70
|
+
profile["access_token_env"] = access_token_env
|
|
71
|
+
console.print(f"\n [dim]Export [cyan]{access_token_env}[/cyan] before running sdd commands.[/dim]")
|
|
72
|
+
|
|
73
|
+
# Merge into existing config
|
|
74
|
+
if CONFIG_PATH.exists():
|
|
75
|
+
existing = yaml.safe_load(CONFIG_PATH.read_text()) or {}
|
|
76
|
+
else:
|
|
77
|
+
existing = {"version": "1", "profiles": {}}
|
|
78
|
+
|
|
79
|
+
existing.setdefault("profiles", {})[profile_name] = profile
|
|
80
|
+
existing.setdefault("default_profile", profile_name)
|
|
81
|
+
|
|
82
|
+
save_config(existing)
|
|
83
|
+
console.print(f"\n [green]✓[/green] Profile [cyan]{profile_name}[/cyan] saved → {CONFIG_PATH}")
|
|
84
|
+
|
|
85
|
+
# Optionally scaffold .specify/integrations.yml
|
|
86
|
+
if questionary.confirm(
|
|
87
|
+
"\n Set up .specify/integrations.yml for this project?", default=True
|
|
88
|
+
).ask():
|
|
89
|
+
_scaffold_integrations(profile_name)
|
|
90
|
+
|
|
91
|
+
console.print()
|
|
92
|
+
console.print("[bold]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/bold]")
|
|
93
|
+
console.print(
|
|
94
|
+
" [bold green]Config complete![/bold green] "
|
|
95
|
+
"Run [cyan]sdd config test[/cyan] to verify."
|
|
96
|
+
)
|
|
97
|
+
console.print("[bold]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/bold]")
|
|
98
|
+
console.print()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _scaffold_integrations(profile_name: str) -> None:
|
|
102
|
+
import questionary
|
|
103
|
+
from sdd.utils.manifest import read_manifest
|
|
104
|
+
|
|
105
|
+
dest = Path(".specify/integrations.yml")
|
|
106
|
+
if dest.exists():
|
|
107
|
+
if not questionary.confirm(
|
|
108
|
+
f" {dest} already exists — overwrite?", default=False
|
|
109
|
+
).ask():
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
project_key = questionary.text("Jira project key (e.g. MYPROJ):").ask()
|
|
113
|
+
space_key = questionary.text("Confluence space key (e.g. ENG):").ask()
|
|
114
|
+
parent_page_id = questionary.text(
|
|
115
|
+
"Confluence parent page ID (blank = root):", default=""
|
|
116
|
+
).ask().strip()
|
|
117
|
+
|
|
118
|
+
manifest = read_manifest() or {}
|
|
119
|
+
project_name = (manifest.get("project") or {}).get("name", "{project}")
|
|
120
|
+
|
|
121
|
+
dest.write_text(
|
|
122
|
+
_integrations_template(profile_name, project_key, space_key,
|
|
123
|
+
parent_page_id, project_name)
|
|
124
|
+
)
|
|
125
|
+
console.print(f" [green]✓[/green] {dest} created")
|
|
126
|
+
console.print(
|
|
127
|
+
" [dim]Edit [cyan]custom_fields[/cyan] to match your Jira instance. "
|
|
128
|
+
"Run [cyan]sdd config fields[/cyan] to discover IDs.[/dim]"
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@config_command.command("test")
|
|
133
|
+
@click.option("--profile", default=None, help="Profile name from ~/.sdd/config.yml")
|
|
134
|
+
def config_test(profile):
|
|
135
|
+
"""Ping Jira and Confluence to verify credentials."""
|
|
136
|
+
console.print()
|
|
137
|
+
try:
|
|
138
|
+
prof = load_profile(profile)
|
|
139
|
+
session = build_session(prof)
|
|
140
|
+
except Exception as e:
|
|
141
|
+
console.print(f" [red]✗ Config error: {e}[/red]")
|
|
142
|
+
raise SystemExit(1)
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
me = JiraClient(session, prof.base_url).get_myself()
|
|
146
|
+
name = me.get("displayName") or me.get("emailAddress", "?")
|
|
147
|
+
console.print(f" [green]✓[/green] Jira — connected as [cyan]{name}[/cyan]")
|
|
148
|
+
except requests.HTTPError as e:
|
|
149
|
+
console.print(f" [red]✗ Jira — HTTP {e.response.status_code}: "
|
|
150
|
+
f"{e.response.text[:120]}[/red]")
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
me = ConfluenceClient(session, prof.base_url).get_myself()
|
|
154
|
+
name = me.get("displayName") or me.get("username", "?")
|
|
155
|
+
console.print(f" [green]✓[/green] Confluence — connected as [cyan]{name}[/cyan]")
|
|
156
|
+
except requests.HTTPError as e:
|
|
157
|
+
console.print(f" [red]✗ Confluence — HTTP {e.response.status_code}: "
|
|
158
|
+
f"{e.response.text[:120]}[/red]")
|
|
159
|
+
|
|
160
|
+
console.print()
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@config_command.command("fields")
|
|
164
|
+
@click.option("--profile", default=None)
|
|
165
|
+
@click.option("--project", default=None, help="Jira project key")
|
|
166
|
+
def config_fields(profile, project):
|
|
167
|
+
"""List Jira custom fields to help fill integrations.yml custom_fields."""
|
|
168
|
+
try:
|
|
169
|
+
prof = load_profile(profile)
|
|
170
|
+
session = build_session(prof)
|
|
171
|
+
except Exception as e:
|
|
172
|
+
console.print(f" [red]✗ {e}[/red]")
|
|
173
|
+
raise SystemExit(1)
|
|
174
|
+
|
|
175
|
+
if project is None:
|
|
176
|
+
try:
|
|
177
|
+
from sdd.utils.integrations import load_integrations
|
|
178
|
+
cfg = load_integrations()
|
|
179
|
+
project = cfg.jira.project_key if cfg.jira else None
|
|
180
|
+
except FileNotFoundError:
|
|
181
|
+
pass
|
|
182
|
+
if not project:
|
|
183
|
+
console.print(
|
|
184
|
+
" [red]✗ No project key. Use --project KEY or set it in "
|
|
185
|
+
".specify/integrations.yml[/red]"
|
|
186
|
+
)
|
|
187
|
+
raise SystemExit(1)
|
|
188
|
+
|
|
189
|
+
fields = JiraClient(session, prof.base_url).get_fields()
|
|
190
|
+
custom = sorted(
|
|
191
|
+
[f for f in fields if f.get("custom")],
|
|
192
|
+
key=lambda f: f.get("name", ""),
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
console.print(f"\n [bold]Custom fields ({len(custom)} found):[/bold]\n")
|
|
196
|
+
console.print(f" {'ID':<30} {'Name':<40} Type")
|
|
197
|
+
console.print(f" {'─'*30} {'─'*40} {'─'*20}")
|
|
198
|
+
for f in custom:
|
|
199
|
+
ftype = (f.get("schema") or {}).get("type", "")
|
|
200
|
+
console.print(
|
|
201
|
+
f" [cyan]{f['id']:<30}[/cyan] {f.get('name',''):<40} [dim]{ftype}[/dim]"
|
|
202
|
+
)
|
|
203
|
+
console.print()
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _integrations_template(profile: str, project_key: str, space_key: str,
|
|
207
|
+
parent_page_id: str, project_name: str) -> str:
|
|
208
|
+
parent_line = (
|
|
209
|
+
f' parent_page_id: "{parent_page_id}"'
|
|
210
|
+
if parent_page_id
|
|
211
|
+
else ' # parent_page_id: "123456" # optional'
|
|
212
|
+
)
|
|
213
|
+
return f"""\
|
|
214
|
+
# SDD Integrations — project-level config (no secrets here)
|
|
215
|
+
# Credentials live in ~/.sdd/config.yml as env var names
|
|
216
|
+
profile: {profile}
|
|
217
|
+
|
|
218
|
+
jira:
|
|
219
|
+
project_key: {project_key}
|
|
220
|
+
|
|
221
|
+
# Jira issue type names — Feature → Story → Task
|
|
222
|
+
# Use "Epic" instead of "Feature" if your project has no Feature type
|
|
223
|
+
issue_hierarchy:
|
|
224
|
+
feature: Feature
|
|
225
|
+
story: Story
|
|
226
|
+
task: Task
|
|
227
|
+
|
|
228
|
+
# Parent link field:
|
|
229
|
+
# "parent" — Next-gen / team-managed projects
|
|
230
|
+
# "customfield_10014" — Classic projects (Epic Link)
|
|
231
|
+
parent_field: parent
|
|
232
|
+
|
|
233
|
+
base_fields:
|
|
234
|
+
priority_map:
|
|
235
|
+
must-have: High
|
|
236
|
+
should-have: Medium
|
|
237
|
+
could-have: Low
|
|
238
|
+
wont-have: Lowest
|
|
239
|
+
labels: [sdd-generated]
|
|
240
|
+
# fix_version: v1.0 # optional
|
|
241
|
+
|
|
242
|
+
# Custom field IDs — run "sdd config fields" to discover yours
|
|
243
|
+
custom_fields:
|
|
244
|
+
story_points: customfield_10016 # almost universal on Jira Cloud
|
|
245
|
+
# acceptance_criteria: customfield_10021
|
|
246
|
+
# team: customfield_10100
|
|
247
|
+
|
|
248
|
+
confluence:
|
|
249
|
+
space_key: {space_key}
|
|
250
|
+
{parent_line}
|
|
251
|
+
|
|
252
|
+
# Page title templates — {{project}} replaced with project name from manifest
|
|
253
|
+
# design applies in unified plan_mode; arch/hld/adr in separate plan_mode
|
|
254
|
+
page_map:
|
|
255
|
+
brd: "{project_name} — Business Requirements"
|
|
256
|
+
use-cases: "{project_name} — Use Cases"
|
|
257
|
+
srd: "{project_name} — System Requirements"
|
|
258
|
+
design: "{project_name} — Design"
|
|
259
|
+
arch: "{project_name} — Architecture Overview"
|
|
260
|
+
hld: "{project_name} — High-Level Design"
|
|
261
|
+
adr: "{project_name} — Architecture Decisions"
|
|
262
|
+
lld: "{project_name} — Low-Level Design"
|
|
263
|
+
runbook: "{project_name} — Runbook"
|
|
264
|
+
|
|
265
|
+
# For the Jira review workflow (sdd review submit/check/apply), add a
|
|
266
|
+
# document_reviews: section — see .specify/integrations.yml.example for the
|
|
267
|
+
# full plan_mode-aware reference.
|
|
268
|
+
"""
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import json
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import click
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
|
|
7
|
+
from sdd.utils.atlassian_auth import load_profile, build_session
|
|
8
|
+
from sdd.utils.integrations import load_integrations
|
|
9
|
+
from sdd.utils.confluence_client import ConfluenceClient
|
|
10
|
+
from sdd.utils.md_to_cf import md_to_storage
|
|
11
|
+
from sdd.utils.cf_to_md import cf_to_md
|
|
12
|
+
from sdd.utils.manifest import read_manifest
|
|
13
|
+
|
|
14
|
+
console = Console()
|
|
15
|
+
|
|
16
|
+
_DRAFTS_FILE = Path(".specify") / ".confluence-drafts.json"
|
|
17
|
+
|
|
18
|
+
_CONTEXT_PAGE_TITLE = "{project} — Context: {feature}"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _load_drafts() -> dict:
|
|
22
|
+
if _DRAFTS_FILE.exists():
|
|
23
|
+
return json.loads(_DRAFTS_FILE.read_text())
|
|
24
|
+
return {}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _save_drafts(drafts: dict) -> None:
|
|
28
|
+
_DRAFTS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
_DRAFTS_FILE.write_text(json.dumps(drafts, indent=2))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _resolve_doc_path(doc: str, feature: str) -> Path:
|
|
33
|
+
"""Return local file path for a doc key."""
|
|
34
|
+
if doc == "context":
|
|
35
|
+
return Path(".specify") / "contexts" / f"{feature}.md"
|
|
36
|
+
return Path(".specify") / "features" / feature / f"{doc}.md"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _resolve_page_title(doc: str, project_name: str, feature: str,
|
|
40
|
+
page_map: dict) -> str:
|
|
41
|
+
if doc == "context":
|
|
42
|
+
return _CONTEXT_PAGE_TITLE.replace("{project}", project_name).replace("{feature}", feature)
|
|
43
|
+
template = page_map.get(doc, f"{{project}} — {doc.upper()}")
|
|
44
|
+
return template.replace("{project}", project_name)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@click.group()
|
|
48
|
+
def confluence_command():
|
|
49
|
+
"""Push SDD documents to Confluence."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@confluence_command.command("push")
|
|
53
|
+
@click.option("--profile", default=None)
|
|
54
|
+
@click.option("--feature", default=None, help="Feature name (default: from manifest.yml)")
|
|
55
|
+
@click.option("--doc", default=None,
|
|
56
|
+
help="Push a single doc only (e.g. hld, brd, arch, runbook)")
|
|
57
|
+
@click.option("--dry-run", is_flag=True, help="Print page titles without calling the API")
|
|
58
|
+
def confluence_push(profile, feature, doc, dry_run):
|
|
59
|
+
"""Publish SDD documents to Confluence pages (create or update)."""
|
|
60
|
+
console.print()
|
|
61
|
+
console.print("[bold]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/bold]")
|
|
62
|
+
label = " [bold cyan]SDD → Confluence[/bold cyan]"
|
|
63
|
+
if dry_run:
|
|
64
|
+
label += " [yellow](dry run)[/yellow]"
|
|
65
|
+
console.print(label)
|
|
66
|
+
console.print("[bold]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/bold]")
|
|
67
|
+
console.print()
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
cfg = load_integrations()
|
|
71
|
+
except FileNotFoundError as e:
|
|
72
|
+
console.print(f" [red]✗ {e}[/red]")
|
|
73
|
+
raise SystemExit(1)
|
|
74
|
+
|
|
75
|
+
if not cfg.confluence:
|
|
76
|
+
console.print(
|
|
77
|
+
" [red]✗ No confluence: section in .specify/integrations.yml[/red]"
|
|
78
|
+
)
|
|
79
|
+
raise SystemExit(1)
|
|
80
|
+
|
|
81
|
+
cf_cfg = cfg.confluence
|
|
82
|
+
manifest = read_manifest() or {}
|
|
83
|
+
proj = manifest.get("project") or {}
|
|
84
|
+
project_name = proj.get("name", "Project")
|
|
85
|
+
feature_name = feature or proj.get("feature", "")
|
|
86
|
+
|
|
87
|
+
features_dir = Path(".specify") / "features" / feature_name
|
|
88
|
+
if not features_dir.exists():
|
|
89
|
+
console.print(f" [red]✗ Feature directory not found: {features_dir}[/red]")
|
|
90
|
+
raise SystemExit(1)
|
|
91
|
+
|
|
92
|
+
# Resolve which docs to push
|
|
93
|
+
page_map = cf_cfg.page_map
|
|
94
|
+
keys_to_try = [doc] if doc else list(page_map.keys())
|
|
95
|
+
|
|
96
|
+
available: list[tuple[str, Path, str]] = []
|
|
97
|
+
for key in keys_to_try:
|
|
98
|
+
md_path = features_dir / f"{key}.md"
|
|
99
|
+
if not md_path.exists():
|
|
100
|
+
console.print(f" [dim]·[/dim] {key}.md not found — skipped")
|
|
101
|
+
continue
|
|
102
|
+
title = page_map.get(key, f"{project_name} — {key.upper()}")
|
|
103
|
+
title = title.replace("{project}", project_name)
|
|
104
|
+
available.append((key, md_path, title))
|
|
105
|
+
|
|
106
|
+
if not available:
|
|
107
|
+
console.print(" [yellow] No documents found to push.[/yellow]")
|
|
108
|
+
console.print()
|
|
109
|
+
return
|
|
110
|
+
|
|
111
|
+
console.print(f" Space : [cyan]{cf_cfg.space_key}[/cyan]")
|
|
112
|
+
console.print(f" Parent : [cyan]{cf_cfg.parent_page_id or 'root'}[/cyan]")
|
|
113
|
+
console.print(f" Docs : [cyan]{len(available)}[/cyan]")
|
|
114
|
+
console.print()
|
|
115
|
+
|
|
116
|
+
if dry_run:
|
|
117
|
+
for key, md_path, title in available:
|
|
118
|
+
console.print(f" [dim]would push[/dim] [cyan]{title}[/cyan] ← {md_path}")
|
|
119
|
+
console.print()
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
prof = load_profile(profile or cfg.profile)
|
|
124
|
+
session = build_session(prof)
|
|
125
|
+
except Exception as e:
|
|
126
|
+
console.print(f" [red]✗ Auth error: {e}[/red]")
|
|
127
|
+
raise SystemExit(1)
|
|
128
|
+
|
|
129
|
+
client = ConfluenceClient(session, prof.base_url)
|
|
130
|
+
|
|
131
|
+
for key, md_path, title in available:
|
|
132
|
+
body = md_to_storage(md_path.read_text())
|
|
133
|
+
try:
|
|
134
|
+
page, created = client.upsert_page(
|
|
135
|
+
cf_cfg.space_key, title, body, cf_cfg.parent_page_id
|
|
136
|
+
)
|
|
137
|
+
action = "[green]created[/green]" if created else "[dim]updated[/dim]"
|
|
138
|
+
console.print(f" {action} [cyan]{title}[/cyan]")
|
|
139
|
+
if created:
|
|
140
|
+
web_ui = page.get("_links", {}).get("webui", "")
|
|
141
|
+
if web_ui:
|
|
142
|
+
console.print(f" {prof.base_url}/wiki{web_ui}")
|
|
143
|
+
except Exception as e:
|
|
144
|
+
console.print(f" [red]✗ {title} — {e}[/red]")
|
|
145
|
+
|
|
146
|
+
console.print()
|
|
147
|
+
console.print("[bold]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/bold]")
|
|
148
|
+
console.print(" [bold green]Confluence push complete![/bold green]")
|
|
149
|
+
console.print("[bold]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/bold]")
|
|
150
|
+
console.print()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@confluence_command.command("draft")
|
|
154
|
+
@click.option("--doc", required=True,
|
|
155
|
+
help="Document type: context, brd, uc, srd, design, lld, security, ...")
|
|
156
|
+
@click.option("--profile", default=None)
|
|
157
|
+
@click.option("--feature", default=None, help="Feature name (default: from manifest.yml)")
|
|
158
|
+
@click.option("--dry-run", is_flag=True, help="Print title and path without calling the API")
|
|
159
|
+
def confluence_draft(doc, profile, feature, dry_run):
|
|
160
|
+
"""Push a draft SDD document to Confluence and print the edit URL.
|
|
161
|
+
|
|
162
|
+
The page URL is printed so the user can open it, fill in any
|
|
163
|
+
[MISSING] sections or questions, then run `sdd confluence pull`
|
|
164
|
+
to fetch the updated version back.
|
|
165
|
+
"""
|
|
166
|
+
console.print()
|
|
167
|
+
console.print("[bold]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/bold]")
|
|
168
|
+
label = " [bold cyan]SDD → Confluence Draft[/bold cyan]"
|
|
169
|
+
if dry_run:
|
|
170
|
+
label += " [yellow](dry run)[/yellow]"
|
|
171
|
+
console.print(label)
|
|
172
|
+
console.print("[bold]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/bold]")
|
|
173
|
+
console.print()
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
cfg = load_integrations()
|
|
177
|
+
except FileNotFoundError as e:
|
|
178
|
+
console.print(f" [red]✗ {e}[/red]")
|
|
179
|
+
raise SystemExit(1)
|
|
180
|
+
|
|
181
|
+
if not cfg.confluence:
|
|
182
|
+
console.print(" [red]✗ No confluence: section in .specify/integrations.yml[/red]")
|
|
183
|
+
raise SystemExit(1)
|
|
184
|
+
|
|
185
|
+
cf_cfg = cfg.confluence
|
|
186
|
+
manifest = read_manifest() or {}
|
|
187
|
+
proj = manifest.get("project") or {}
|
|
188
|
+
project_name = proj.get("name", "Project")
|
|
189
|
+
feature_name = feature or proj.get("feature", "")
|
|
190
|
+
|
|
191
|
+
doc_path = _resolve_doc_path(doc, feature_name)
|
|
192
|
+
if not doc_path.exists():
|
|
193
|
+
console.print(f" [red]✗ File not found: {doc_path}[/red]")
|
|
194
|
+
console.print(" [dim]Generate the document first, then run this command.[/dim]")
|
|
195
|
+
raise SystemExit(1)
|
|
196
|
+
|
|
197
|
+
title = _resolve_page_title(doc, project_name, feature_name, cf_cfg.page_map)
|
|
198
|
+
|
|
199
|
+
console.print(f" Doc : [cyan]{doc}[/cyan] ({doc_path})")
|
|
200
|
+
console.print(f" Title : [cyan]{title}[/cyan]")
|
|
201
|
+
console.print(f" Space : [cyan]{cf_cfg.space_key}[/cyan]")
|
|
202
|
+
console.print()
|
|
203
|
+
|
|
204
|
+
if dry_run:
|
|
205
|
+
console.print(" [dim]would push draft page to Confluence[/dim]")
|
|
206
|
+
console.print()
|
|
207
|
+
return
|
|
208
|
+
|
|
209
|
+
try:
|
|
210
|
+
prof = load_profile(profile or cfg.profile)
|
|
211
|
+
session = build_session(prof)
|
|
212
|
+
except Exception as e:
|
|
213
|
+
console.print(f" [red]✗ Auth error: {e}[/red]")
|
|
214
|
+
raise SystemExit(1)
|
|
215
|
+
|
|
216
|
+
client = ConfluenceClient(session, prof.base_url)
|
|
217
|
+
body = md_to_storage(doc_path.read_text())
|
|
218
|
+
|
|
219
|
+
try:
|
|
220
|
+
page, created = client.upsert_page(
|
|
221
|
+
cf_cfg.space_key, title, body, cf_cfg.parent_page_id
|
|
222
|
+
)
|
|
223
|
+
except Exception as e:
|
|
224
|
+
console.print(f" [red]✗ Confluence error: {e}[/red]")
|
|
225
|
+
raise SystemExit(1)
|
|
226
|
+
|
|
227
|
+
page_id = page.get("id", "")
|
|
228
|
+
web_ui = page.get("_links", {}).get("webui", "")
|
|
229
|
+
edit_url = f"{prof.base_url}/wiki{web_ui}" if web_ui else ""
|
|
230
|
+
|
|
231
|
+
# Persist page_id so `sdd confluence pull` can find it later
|
|
232
|
+
drafts = _load_drafts()
|
|
233
|
+
drafts[doc] = {"page_id": page_id, "title": title}
|
|
234
|
+
_save_drafts(drafts)
|
|
235
|
+
|
|
236
|
+
action = "[green]created[/green]" if created else "[dim]updated[/dim]"
|
|
237
|
+
console.print(f" {action} [bold]{title}[/bold]")
|
|
238
|
+
if edit_url:
|
|
239
|
+
console.print(f" URL : [underline cyan]{edit_url}[/underline cyan]")
|
|
240
|
+
console.print()
|
|
241
|
+
console.print("[bold]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/bold]")
|
|
242
|
+
console.print(" [bold green]Draft pushed![/bold green] Open the URL above, fill in any")
|
|
243
|
+
console.print(" [MISSING] sections or answer the questions, then run:")
|
|
244
|
+
console.print()
|
|
245
|
+
console.print(f" [bold]sdd confluence pull --doc {doc}[/bold]")
|
|
246
|
+
console.print()
|
|
247
|
+
console.print(" to pull your edits back into the local file.")
|
|
248
|
+
console.print("[bold]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/bold]")
|
|
249
|
+
console.print()
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@confluence_command.command("pull")
|
|
253
|
+
@click.option("--doc", required=True,
|
|
254
|
+
help="Document type: context, brd, uc, srd, design, lld, security, ...")
|
|
255
|
+
@click.option("--profile", default=None)
|
|
256
|
+
@click.option("--feature", default=None, help="Feature name (default: from manifest.yml)")
|
|
257
|
+
@click.option("--page-id", default=None, help="Confluence page ID (overrides saved value)")
|
|
258
|
+
def confluence_pull(doc, profile, feature, page_id):
|
|
259
|
+
"""Pull the latest Confluence page content back to the local SDD file.
|
|
260
|
+
|
|
261
|
+
Run this after editing the draft page in Confluence to pull your
|
|
262
|
+
changes back so the AI can continue the workflow from the updated doc.
|
|
263
|
+
"""
|
|
264
|
+
console.print()
|
|
265
|
+
console.print("[bold]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/bold]")
|
|
266
|
+
console.print(" [bold cyan]Confluence → SDD Pull[/bold cyan]")
|
|
267
|
+
console.print("[bold]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/bold]")
|
|
268
|
+
console.print()
|
|
269
|
+
|
|
270
|
+
try:
|
|
271
|
+
cfg = load_integrations()
|
|
272
|
+
except FileNotFoundError as e:
|
|
273
|
+
console.print(f" [red]✗ {e}[/red]")
|
|
274
|
+
raise SystemExit(1)
|
|
275
|
+
|
|
276
|
+
if not cfg.confluence:
|
|
277
|
+
console.print(" [red]✗ No confluence: section in .specify/integrations.yml[/red]")
|
|
278
|
+
raise SystemExit(1)
|
|
279
|
+
|
|
280
|
+
manifest = read_manifest() or {}
|
|
281
|
+
proj = manifest.get("project") or {}
|
|
282
|
+
feature_name = feature or proj.get("feature", "")
|
|
283
|
+
|
|
284
|
+
# Resolve page ID: explicit flag > saved drafts file
|
|
285
|
+
resolved_page_id = page_id
|
|
286
|
+
if not resolved_page_id:
|
|
287
|
+
drafts = _load_drafts()
|
|
288
|
+
entry = drafts.get(doc)
|
|
289
|
+
if entry:
|
|
290
|
+
resolved_page_id = entry.get("page_id")
|
|
291
|
+
if not resolved_page_id:
|
|
292
|
+
console.print(
|
|
293
|
+
f" [red]✗ No page ID for '{doc}'.[/red]\n"
|
|
294
|
+
" Run [bold]sdd confluence draft --doc {doc}[/bold] first, "
|
|
295
|
+
"or pass [bold]--page-id[/bold] directly."
|
|
296
|
+
)
|
|
297
|
+
raise SystemExit(1)
|
|
298
|
+
|
|
299
|
+
try:
|
|
300
|
+
prof = load_profile(profile or cfg.profile)
|
|
301
|
+
session = build_session(prof)
|
|
302
|
+
except Exception as e:
|
|
303
|
+
console.print(f" [red]✗ Auth error: {e}[/red]")
|
|
304
|
+
raise SystemExit(1)
|
|
305
|
+
|
|
306
|
+
client = ConfluenceClient(session, prof.base_url)
|
|
307
|
+
|
|
308
|
+
console.print(f" Fetching page [cyan]{resolved_page_id}[/cyan] from Confluence...")
|
|
309
|
+
try:
|
|
310
|
+
page = client.get_page_with_body(resolved_page_id)
|
|
311
|
+
except Exception as e:
|
|
312
|
+
console.print(f" [red]✗ Confluence error: {e}[/red]")
|
|
313
|
+
raise SystemExit(1)
|
|
314
|
+
|
|
315
|
+
storage_body = (
|
|
316
|
+
page.get("body", {}).get("storage", {}).get("value", "")
|
|
317
|
+
)
|
|
318
|
+
if not storage_body:
|
|
319
|
+
console.print(" [red]✗ Page body is empty.[/red]")
|
|
320
|
+
raise SystemExit(1)
|
|
321
|
+
|
|
322
|
+
markdown = cf_to_md(storage_body)
|
|
323
|
+
|
|
324
|
+
# Fetch comments (footer + inline) and append as a section the AI can read
|
|
325
|
+
console.print(" Fetching comments...")
|
|
326
|
+
try:
|
|
327
|
+
footer_comments = client.get_page_comments(resolved_page_id)
|
|
328
|
+
inline_comments = client.get_inline_comments(resolved_page_id)
|
|
329
|
+
all_comments = footer_comments + inline_comments
|
|
330
|
+
except Exception:
|
|
331
|
+
all_comments = []
|
|
332
|
+
|
|
333
|
+
if all_comments:
|
|
334
|
+
lines = [
|
|
335
|
+
"",
|
|
336
|
+
"---",
|
|
337
|
+
"",
|
|
338
|
+
"## Confluence Comments",
|
|
339
|
+
"",
|
|
340
|
+
"> These comments were added in Confluence by reviewers or stakeholders.",
|
|
341
|
+
"> The AI will incorporate them into the document — do not edit this section manually.",
|
|
342
|
+
"",
|
|
343
|
+
]
|
|
344
|
+
for i, c in enumerate(all_comments, 1):
|
|
345
|
+
kind = "inline" if c["type"] == "inline" else "comment"
|
|
346
|
+
lines.append(f"### {kind.capitalize()} {i} — {c['author']} ({c['created']})")
|
|
347
|
+
lines.append("")
|
|
348
|
+
lines.append(c["text"])
|
|
349
|
+
lines.append("")
|
|
350
|
+
markdown = markdown + "\n" + "\n".join(lines)
|
|
351
|
+
|
|
352
|
+
doc_path = _resolve_doc_path(doc, feature_name)
|
|
353
|
+
doc_path.parent.mkdir(parents=True, exist_ok=True)
|
|
354
|
+
|
|
355
|
+
old_text = doc_path.read_text() if doc_path.exists() else ""
|
|
356
|
+
doc_path.write_text(markdown + "\n")
|
|
357
|
+
|
|
358
|
+
body_lines = len(cf_to_md(storage_body).splitlines())
|
|
359
|
+
comment_count = len(all_comments)
|
|
360
|
+
console.print(f" [green]✓[/green] Saved to [bold]{doc_path}[/bold]")
|
|
361
|
+
console.print(f" Body : {body_lines} lines")
|
|
362
|
+
if comment_count:
|
|
363
|
+
console.print(f" Comments : [yellow]{comment_count}[/yellow] (included for AI review)")
|
|
364
|
+
console.print()
|
|
365
|
+
console.print("[bold]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/bold]")
|
|
366
|
+
console.print(" [bold green]Pull complete![/bold green] The local file is now up to date.")
|
|
367
|
+
if comment_count:
|
|
368
|
+
console.print(f" [yellow]{comment_count} Confluence comment(s)[/yellow] included —")
|
|
369
|
+
console.print(" tell the AI 'done' and it will incorporate them.")
|
|
370
|
+
if doc == "context":
|
|
371
|
+
console.print(" Say [bold]'done'[/bold] in chat — the AI will read the updates")
|
|
372
|
+
console.print(" and comments, then continue.")
|
|
373
|
+
else:
|
|
374
|
+
console.print(f" Say [bold]'done'[/bold] in chat to resume the SDD workflow.")
|
|
375
|
+
console.print("[bold]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/bold]")
|
|
376
|
+
console.print()
|