fixdoc 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,268 @@
1
+ """Sync commands for sharing fixes via Git."""
2
+
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+ import click
7
+
8
+ from ..config import ConfigManager
9
+ from ..git import GitOperations, GitError, SyncStatus
10
+ from ..storage import FixRepository
11
+ from ..sync_engine import SyncEngine
12
+
13
+
14
+ def get_sync_context():
15
+ """Get shared objects for sync commands."""
16
+ base_path = Path.home() / ".fixdoc"
17
+ return {
18
+ "repo": FixRepository(base_path),
19
+ "config_manager": ConfigManager(base_path),
20
+ "git": GitOperations(base_path),
21
+ }
22
+
23
+
24
+ @click.group()
25
+ def sync():
26
+ """Sync fixes with a shared Git repository."""
27
+ pass
28
+
29
+
30
+ @sync.command()
31
+ @click.argument("repo_url")
32
+ @click.option("--branch", "-b", default="main", help="Branch to sync with")
33
+ @click.option("--name", "-n", default=None, help="Your name for attribution")
34
+ @click.option("--email", "-e", default=None, help="Your email for attribution")
35
+ def init(repo_url: str, branch: str, name: Optional[str], email: Optional[str]):
36
+ """
37
+ Initialize sync with a shared Git repository.
38
+
39
+ \b
40
+ Example:
41
+ fixdoc sync init git@github.com:mycompany/infra-fixes.git
42
+ fixdoc sync init https://github.com/mycompany/infra-fixes.git
43
+ """
44
+ ctx = get_sync_context()
45
+ config_manager = ctx["config_manager"]
46
+ git = ctx["git"]
47
+
48
+ config = config_manager.load()
49
+ if config.sync.remote_url:
50
+ if not click.confirm(
51
+ f"Sync already configured with {config.sync.remote_url}. Reconfigure?"
52
+ ):
53
+ click.echo("Aborted.")
54
+ return
55
+
56
+ if not name:
57
+ name = click.prompt("Your name (for attribution)")
58
+ if not email:
59
+ email = click.prompt("Your email")
60
+
61
+ try:
62
+ if not git.is_git_repo():
63
+ click.echo("Initializing Git repository...")
64
+ git.init()
65
+
66
+ if git.has_remote("origin"):
67
+ current_url = git.remote_get_url("origin")
68
+ if current_url != repo_url:
69
+ click.echo(f"Updating remote URL from {current_url} to {repo_url}")
70
+ git.remote_set_url("origin", repo_url)
71
+ else:
72
+ click.echo(f"Adding remote: {repo_url}")
73
+ git.remote_add("origin", repo_url)
74
+
75
+ config.sync.remote_url = repo_url
76
+ config.sync.branch = branch
77
+ config.user.name = name
78
+ config.user.email = email
79
+ config_manager.save(config)
80
+
81
+ try:
82
+ click.echo(f"Fetching from remote...")
83
+ git.fetch("origin")
84
+
85
+ status = git.get_status("origin", branch)
86
+ if status.commits_behind > 0:
87
+ click.echo(f"Pulling {status.commits_behind} commits from remote...")
88
+ git.pull("origin", branch)
89
+
90
+ engine = SyncEngine(ctx["repo"], git, config_manager)
91
+ pulled = engine.rebuild_json_from_markdown()
92
+ if pulled:
93
+ click.echo(f"Imported {len(pulled)} fixes from team repository.")
94
+
95
+ except GitError:
96
+ click.echo("Remote repository appears to be empty. Ready for first push.")
97
+
98
+ click.echo("")
99
+ click.echo("Sync initialized successfully!")
100
+ click.echo(f" Repository: {repo_url}")
101
+ click.echo(f" Branch: {branch}")
102
+ click.echo(f" Author: {name} <{email}>")
103
+ click.echo("")
104
+ click.echo("Next steps:")
105
+ click.echo(" fixdoc sync push - Push your fixes to the team repo")
106
+ click.echo(" fixdoc sync pull - Pull fixes from teammates")
107
+ click.echo(" fixdoc sync status - Check sync status")
108
+
109
+ except GitError as e:
110
+ click.echo(f"Error: {e}", err=True)
111
+ raise SystemExit(1)
112
+
113
+
114
+ @sync.command()
115
+ @click.option("--message", "-m", default=None, help="Commit message")
116
+ @click.option("--all", "-a", "push_all", is_flag=True, help="Push all local fixes")
117
+ def push(message: Optional[str], push_all: bool):
118
+ """
119
+ Push local fixes to the shared repository.
120
+
121
+ \b
122
+ Example:
123
+ fixdoc sync push
124
+ fixdoc sync push -m "Added storage account fix"
125
+ """
126
+ ctx = get_sync_context()
127
+ engine = SyncEngine(ctx["repo"], ctx["git"], ctx["config_manager"])
128
+
129
+ if not ctx["config_manager"].is_sync_configured():
130
+ click.echo("Sync not configured. Run 'fixdoc sync init <repo-url>' first.", err=True)
131
+ raise SystemExit(1)
132
+
133
+ config = ctx["config_manager"].load()
134
+ if config.sync.auto_pull:
135
+ click.echo("Auto-pulling latest changes...")
136
+ pull_result = engine.execute_pull()
137
+ if not pull_result.success and pull_result.conflicts:
138
+ click.echo("Conflicts detected. Please resolve with 'fixdoc sync pull' first.", err=True)
139
+ raise SystemExit(1)
140
+
141
+ fixes = engine.prepare_push(push_all=push_all)
142
+
143
+ if not fixes:
144
+ click.echo("No new or modified fixes to push.")
145
+ return
146
+
147
+ click.echo(f"Pushing {len(fixes)} fix(es)...")
148
+ for fix in fixes:
149
+ click.echo(f" {fix.id[:8]} - {fix.issue[:40]}...")
150
+
151
+ result = engine.execute_push(fixes, commit_message=message)
152
+
153
+ if result.success:
154
+ if result.pushed_fixes:
155
+ click.echo(f"Successfully pushed {len(result.pushed_fixes)} fix(es).")
156
+ elif result.error_message:
157
+ click.echo(result.error_message)
158
+ else:
159
+ click.echo(f"Push failed: {result.error_message}", err=True)
160
+ if "rejected" in (result.error_message or "").lower():
161
+ click.echo("Hint: Run 'fixdoc sync pull' first to get the latest changes.")
162
+ raise SystemExit(1)
163
+
164
+
165
+ @sync.command()
166
+ @click.option("--force", "-f", is_flag=True, help="Overwrite local changes on conflict")
167
+ def pull(force: bool):
168
+ """
169
+ Pull fixes from the shared repository.
170
+
171
+ \b
172
+ Example:
173
+ fixdoc sync pull
174
+ fixdoc sync pull --force # Accept all remote changes
175
+ """
176
+ ctx = get_sync_context()
177
+ engine = SyncEngine(ctx["repo"], ctx["git"], ctx["config_manager"])
178
+
179
+ if not ctx["config_manager"].is_sync_configured():
180
+ click.echo("Sync not configured. Run 'fixdoc sync init <repo-url>' first.", err=True)
181
+ raise SystemExit(1)
182
+
183
+ click.echo("Pulling from remote...")
184
+ result = engine.execute_pull(force=force)
185
+
186
+ if result.success:
187
+ if result.pulled_fixes:
188
+ click.echo(f"Successfully pulled/updated {len(result.pulled_fixes)} fix(es).")
189
+ for fix_id in result.pulled_fixes[:5]:
190
+ click.echo(f" {fix_id[:8]}")
191
+ if len(result.pulled_fixes) > 5:
192
+ click.echo(f" ... and {len(result.pulled_fixes) - 5} more")
193
+ else:
194
+ click.echo("Already up to date.")
195
+ elif result.conflicts:
196
+ click.echo(f"Conflicts detected in {len(result.conflicts)} fix(es):", err=True)
197
+ for conflict in result.conflicts:
198
+ click.echo(f" {conflict.fix_id[:8]} - {conflict.conflict_type.value}")
199
+ click.echo("")
200
+ click.echo("To resolve:")
201
+ click.echo(" fixdoc sync pull --force # Accept all remote changes")
202
+ click.echo(" # Or manually edit the conflicting fixes and push again")
203
+ raise SystemExit(1)
204
+ else:
205
+ click.echo(f"Pull failed: {result.error_message}", err=True)
206
+ raise SystemExit(1)
207
+
208
+
209
+ @sync.command()
210
+ def status():
211
+ """
212
+ Show sync status (ahead/behind commits, local changes).
213
+
214
+ \b
215
+ Example:
216
+ fixdoc sync status
217
+ """
218
+ ctx = get_sync_context()
219
+ engine = SyncEngine(ctx["repo"], ctx["git"], ctx["config_manager"])
220
+
221
+ status_info = engine.get_sync_status()
222
+
223
+ if not status_info["configured"]:
224
+ click.echo("Sync not configured.")
225
+ click.echo("")
226
+ click.echo("To set up sync, run:")
227
+ click.echo(" fixdoc sync init <repo-url>")
228
+ return
229
+
230
+ click.echo("Sync Status")
231
+ click.echo("=" * 40)
232
+ click.echo(f"Repository: {status_info['remote_url']}")
233
+ click.echo(f"Branch: {status_info['branch']}")
234
+ click.echo("")
235
+
236
+ status_val = status_info["status"]
237
+ if status_val == SyncStatus.UP_TO_DATE.value:
238
+ click.echo("Status: Up to date")
239
+ elif status_val == SyncStatus.AHEAD.value:
240
+ click.echo(f"Status: {status_info['commits_ahead']} commit(s) ahead")
241
+ elif status_val == SyncStatus.BEHIND.value:
242
+ click.echo(f"Status: {status_info['commits_behind']} commit(s) behind")
243
+ elif status_val == SyncStatus.DIVERGED.value:
244
+ click.echo(
245
+ f"Status: Diverged ({status_info['commits_ahead']} ahead, "
246
+ f"{status_info['commits_behind']} behind)"
247
+ )
248
+ elif status_val == SyncStatus.NO_REMOTE.value:
249
+ click.echo("Status: No remote configured")
250
+
251
+ click.echo("")
252
+ click.echo(f"Total fixes: {status_info['total_fixes']}")
253
+ click.echo(f"Pushable fixes: {status_info['pushable_fixes']}")
254
+ click.echo(f"Private fixes: {status_info['private_fixes']}")
255
+
256
+ if status_info["local_changes"]:
257
+ click.echo("")
258
+ click.echo("Local changes:")
259
+ for change in status_info["local_changes"][:5]:
260
+ click.echo(f" {change}")
261
+ if len(status_info["local_changes"]) > 5:
262
+ click.echo(f" ... and {len(status_info['local_changes']) - 5} more")
263
+
264
+ click.echo("")
265
+ if status_info["pushable_fixes"] > 0:
266
+ click.echo("Run 'fixdoc sync push' to share your fixes.")
267
+ if status_val == SyncStatus.BEHIND.value:
268
+ click.echo("Run 'fixdoc sync pull' to get team updates.")
fixdoc/config.py ADDED
@@ -0,0 +1,113 @@
1
+ """Configuration management for fixdoc sync."""
2
+
3
+ from dataclasses import dataclass, field, asdict
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ import yaml
8
+
9
+
10
+ @dataclass
11
+ class SyncConfig:
12
+ """Configuration for Git sync operations."""
13
+
14
+ remote_url: Optional[str] = None
15
+ branch: str = "main"
16
+ auto_pull: bool = False
17
+
18
+
19
+ @dataclass
20
+ class UserConfig:
21
+ """User identity for attribution."""
22
+
23
+ name: Optional[str] = None
24
+ email: Optional[str] = None
25
+
26
+
27
+ @dataclass
28
+ class FixDocConfig:
29
+ """Root configuration object."""
30
+
31
+ sync: SyncConfig = field(default_factory=SyncConfig)
32
+ user: UserConfig = field(default_factory=UserConfig)
33
+ private_fixes: list[str] = field(default_factory=list)
34
+
35
+ def to_dict(self) -> dict:
36
+ """Convert config to dictionary for YAML serialization."""
37
+ return {
38
+ "sync": asdict(self.sync),
39
+ "user": asdict(self.user),
40
+ "private_fixes": self.private_fixes,
41
+ }
42
+
43
+ @classmethod
44
+ def from_dict(cls, data: dict) -> "FixDocConfig":
45
+ """Create config from dictionary loaded from YAML."""
46
+ sync_data = data.get("sync", {})
47
+ user_data = data.get("user", {})
48
+ private_fixes = data.get("private_fixes", [])
49
+
50
+ return cls(
51
+ sync=SyncConfig(
52
+ remote_url=sync_data.get("remote_url"),
53
+ branch=sync_data.get("branch", "main"),
54
+ auto_pull=sync_data.get("auto_pull", False),
55
+ ),
56
+ user=UserConfig(
57
+ name=user_data.get("name"),
58
+ email=user_data.get("email"),
59
+ ),
60
+ private_fixes=private_fixes,
61
+ )
62
+
63
+
64
+ class ConfigManager:
65
+ """Manages ~/.fixdoc/config.yaml."""
66
+
67
+ CONFIG_FILE = "config.yaml"
68
+
69
+ def __init__(self, base_path: Optional[Path] = None):
70
+ self.base_path = base_path or Path.home() / ".fixdoc"
71
+ self.config_path = self.base_path / self.CONFIG_FILE
72
+
73
+ def load(self) -> FixDocConfig:
74
+ """Load config from YAML, return defaults if not exists."""
75
+ if not self.config_path.exists():
76
+ return FixDocConfig()
77
+
78
+ try:
79
+ with open(self.config_path, "r") as f:
80
+ data = yaml.safe_load(f) or {}
81
+ return FixDocConfig.from_dict(data)
82
+ except (yaml.YAMLError, IOError):
83
+ return FixDocConfig()
84
+
85
+ def save(self, config: FixDocConfig) -> None:
86
+ """Save config to YAML."""
87
+ self.base_path.mkdir(parents=True, exist_ok=True)
88
+ with open(self.config_path, "w") as f:
89
+ yaml.safe_dump(config.to_dict(), f, default_flow_style=False, sort_keys=False)
90
+
91
+ def is_sync_configured(self) -> bool:
92
+ """Check if sync has been initialized."""
93
+ config = self.load()
94
+ return config.sync.remote_url is not None
95
+
96
+ def add_private_fix(self, fix_id: str) -> None:
97
+ """Add a fix ID to the private list."""
98
+ config = self.load()
99
+ if fix_id not in config.private_fixes:
100
+ config.private_fixes.append(fix_id)
101
+ self.save(config)
102
+
103
+ def remove_private_fix(self, fix_id: str) -> None:
104
+ """Remove a fix ID from the private list."""
105
+ config = self.load()
106
+ if fix_id in config.private_fixes:
107
+ config.private_fixes.remove(fix_id)
108
+ self.save(config)
109
+
110
+ def is_fix_private(self, fix_id: str) -> bool:
111
+ """Check if a fix is marked as private."""
112
+ config = self.load()
113
+ return fix_id in config.private_fixes
fixdoc/fix.py ADDED
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ FixDoc - Capture and search infrastructure fixes for cloud engineers.
4
+
5
+ This is the main entry point for the fixdoc CLI tool.
6
+ """
7
+
8
+ import sys
9
+
10
+ from .cli import create_cli
11
+
12
+
13
+ def main():
14
+ cli = create_cli()
15
+ cli()
16
+
17
+
18
+ if __name__ == "__main__":
19
+ sys.exit(main())
fixdoc/formatter.py ADDED
@@ -0,0 +1,62 @@
1
+ """Markdown formatting for fixes."""
2
+
3
+ from .models import Fix
4
+
5
+
6
+ def fix_to_markdown(fix: Fix) -> str:
7
+ """Generate markdown documentation for a fix."""
8
+ lines = [f"# Fix: {fix.id[:8]}","",f"**Created:** {fix.created_at}","",f"**Updated:** {fix.updated_at}","",]
9
+
10
+ if fix.author:
11
+ lines.append(f"**Author:** {fix.author}")
12
+ if fix.author_email:
13
+ lines.append(f"**Author Email:** {fix.author_email}")
14
+
15
+ lines.append("")
16
+
17
+ if fix.author:
18
+ lines.append(f"**Author:** {fix.author}")
19
+ if fix.author_email:
20
+ lines.append(f"**Author Email:** {fix.author_email}")
21
+
22
+ lines.append("")
23
+
24
+ if fix.tags:
25
+ lines.extend([f"**Tags:** `{fix.tags}`", ""])
26
+
27
+ lines.extend(
28
+ [
29
+ "## Issue",
30
+ "",
31
+ fix.issue,
32
+ "",
33
+ "## Resolution",
34
+ "",
35
+ fix.resolution,
36
+ "",
37
+ ]
38
+ )
39
+
40
+ if fix.error_excerpt:
41
+ lines.extend(
42
+ [
43
+ "## Error Excerpt",
44
+ "",
45
+ "```",
46
+ fix.error_excerpt,
47
+ "```",
48
+ "",
49
+ ]
50
+ )
51
+
52
+ if fix.notes:
53
+ lines.extend(
54
+ [
55
+ "## Notes",
56
+ "",
57
+ fix.notes,
58
+ "",
59
+ ]
60
+ )
61
+
62
+ return "\n".join(lines)