gitwise-cli 0.24.2__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.
Files changed (125) hide show
  1. gitwise/__init__.py +11 -0
  2. gitwise/__main__.py +113 -0
  3. gitwise/_cli_completions.py +88 -0
  4. gitwise/_cli_dispatch.py +469 -0
  5. gitwise/_cli_introspection.py +275 -0
  6. gitwise/_cli_parser.py +345 -0
  7. gitwise/_cli_setup_agents.py +439 -0
  8. gitwise/_i18n_data.json +1934 -0
  9. gitwise/_paths.py +22 -0
  10. gitwise/_runtime_config.py +246 -0
  11. gitwise/audit.py +338 -0
  12. gitwise/branches.py +183 -0
  13. gitwise/clean.py +197 -0
  14. gitwise/commit.py +142 -0
  15. gitwise/conflicts.py +112 -0
  16. gitwise/context.py +163 -0
  17. gitwise/design.py +383 -0
  18. gitwise/diff.py +309 -0
  19. gitwise/doctor.py +116 -0
  20. gitwise/git.py +254 -0
  21. gitwise/health.py +345 -0
  22. gitwise/i18n.py +99 -0
  23. gitwise/log.py +329 -0
  24. gitwise/merge.py +193 -0
  25. gitwise/optimize.py +212 -0
  26. gitwise/output.py +652 -0
  27. gitwise/pick.py +102 -0
  28. gitwise/pr.py +543 -0
  29. gitwise/py.typed +0 -0
  30. gitwise/schema.py +49 -0
  31. gitwise/setup.py +551 -0
  32. gitwise/setup_agents/__init__.py +36 -0
  33. gitwise/setup_agents/adapters/__init__.py +17 -0
  34. gitwise/setup_agents/adapters/aider.py +5 -0
  35. gitwise/setup_agents/adapters/base.py +5 -0
  36. gitwise/setup_agents/adapters/codex.py +5 -0
  37. gitwise/setup_agents/adapters/continue_adapter.py +5 -0
  38. gitwise/setup_agents/adapters/cursor.py +5 -0
  39. gitwise/setup_agents/adapters/opencode.py +5 -0
  40. gitwise/setup_agents/adapters/pi.py +5 -0
  41. gitwise/setup_agents/exec.py +449 -0
  42. gitwise/setup_agents/format.py +164 -0
  43. gitwise/setup_agents/plan.py +254 -0
  44. gitwise/setup_agents/plan_gitfiles.py +167 -0
  45. gitwise/setup_agents/plan_skills.py +256 -0
  46. gitwise/setup_agents/providers/__init__.py +96 -0
  47. gitwise/setup_agents/providers/aider.py +11 -0
  48. gitwise/setup_agents/providers/base.py +79 -0
  49. gitwise/setup_agents/providers/claude.py +408 -0
  50. gitwise/setup_agents/providers/codex.py +11 -0
  51. gitwise/setup_agents/providers/continue_adapter.py +11 -0
  52. gitwise/setup_agents/providers/cursor.py +11 -0
  53. gitwise/setup_agents/providers/opencode.py +11 -0
  54. gitwise/setup_agents/providers/pi.py +11 -0
  55. gitwise/setup_agents/state.py +141 -0
  56. gitwise/setup_agents/types.py +48 -0
  57. gitwise/share/agents/skills/git-audit/SKILL.md +25 -0
  58. gitwise/share/agents/skills/git-clean/SKILL.md +22 -0
  59. gitwise/share/agents/skills/git-optimize/SKILL.md +21 -0
  60. gitwise/share/aider/CONVENTIONS.md.template +8 -0
  61. gitwise/share/aider/aider.conf.yml.template +4 -0
  62. gitwise/share/claude/CLAUDE.md.template +9 -0
  63. gitwise/share/claude/rules/gitwise.md +16 -0
  64. gitwise/share/claude/settings.json.template +47 -0
  65. gitwise/share/claude/skills/git-audit/SKILL.md +25 -0
  66. gitwise/share/claude/skills/git-clean/SKILL.md +22 -0
  67. gitwise/share/claude/skills/git-optimize/SKILL.md +21 -0
  68. gitwise/share/codex/agents/gitwise.toml.template +18 -0
  69. gitwise/share/continue/rules/gitwise.md.template +14 -0
  70. gitwise/share/cursor/rules/gitwise.mdc.template +16 -0
  71. gitwise/share/git-config-modern.txt +48 -0
  72. gitwise/share/hooks/commit-msg +22 -0
  73. gitwise/share/hooks/pre-commit +19 -0
  74. gitwise/share/opencode/agents/gitwise.md.template +14 -0
  75. gitwise/share/pi/skills/gitwise.md.template +14 -0
  76. gitwise/share/schemas/v1/input/audit.json +40 -0
  77. gitwise/share/schemas/v1/input/branches.json +51 -0
  78. gitwise/share/schemas/v1/input/clean.json +52 -0
  79. gitwise/share/schemas/v1/input/commands.json +36 -0
  80. gitwise/share/schemas/v1/input/commit.json +63 -0
  81. gitwise/share/schemas/v1/input/completions.json +51 -0
  82. gitwise/share/schemas/v1/input/conflicts.json +46 -0
  83. gitwise/share/schemas/v1/input/context.json +36 -0
  84. gitwise/share/schemas/v1/input/diff.json +56 -0
  85. gitwise/share/schemas/v1/input/doctor.json +36 -0
  86. gitwise/share/schemas/v1/input/health.json +36 -0
  87. gitwise/share/schemas/v1/input/log.json +71 -0
  88. gitwise/share/schemas/v1/input/merge.json +63 -0
  89. gitwise/share/schemas/v1/input/optimize.json +44 -0
  90. gitwise/share/schemas/v1/input/pick.json +63 -0
  91. gitwise/share/schemas/v1/input/pr.json +51 -0
  92. gitwise/share/schemas/v1/input/schema.json +48 -0
  93. gitwise/share/schemas/v1/input/setup-agents.json +108 -0
  94. gitwise/share/schemas/v1/input/setup.json +55 -0
  95. gitwise/share/schemas/v1/input/show.json +46 -0
  96. gitwise/share/schemas/v1/input/snapshot.json +36 -0
  97. gitwise/share/schemas/v1/input/stash.json +68 -0
  98. gitwise/share/schemas/v1/input/status.json +36 -0
  99. gitwise/share/schemas/v1/input/suggest.json +36 -0
  100. gitwise/share/schemas/v1/input/summarize.json +44 -0
  101. gitwise/share/schemas/v1/input/sync.json +55 -0
  102. gitwise/share/schemas/v1/input/tag.json +73 -0
  103. gitwise/share/schemas/v1/input/undo.json +60 -0
  104. gitwise/share/schemas/v1/input/update.json +40 -0
  105. gitwise/share/schemas/v1/input/worktree.json +50 -0
  106. gitwise/show.py +118 -0
  107. gitwise/snapshot.py +110 -0
  108. gitwise/stash.py +188 -0
  109. gitwise/status.py +93 -0
  110. gitwise/suggest.py +148 -0
  111. gitwise/summarize.py +202 -0
  112. gitwise/sync.py +257 -0
  113. gitwise/tag.py +252 -0
  114. gitwise/undo.py +145 -0
  115. gitwise/update.py +42 -0
  116. gitwise/utils/__init__.py +1 -0
  117. gitwise/utils/git_output.py +51 -0
  118. gitwise/utils/json_envelope.py +58 -0
  119. gitwise/utils/parsing.py +34 -0
  120. gitwise/worktree.py +182 -0
  121. gitwise_cli-0.24.2.dist-info/METADATA +151 -0
  122. gitwise_cli-0.24.2.dist-info/RECORD +125 -0
  123. gitwise_cli-0.24.2.dist-info/WHEEL +4 -0
  124. gitwise_cli-0.24.2.dist-info/entry_points.txt +2 -0
  125. gitwise_cli-0.24.2.dist-info/licenses/LICENSE +21 -0
gitwise/pick.py ADDED
@@ -0,0 +1,102 @@
1
+ """gitwise pick — cherry-pick/revert helper."""
2
+
3
+ from .git import require_root, validate_ref
4
+ from .git import run as git_run
5
+ from .i18n import t
6
+ from .output import error, ok, print_json, warn
7
+ from .utils.json_envelope import error_envelope, ok_envelope
8
+
9
+
10
+ def _pick_mode_args(*, revert: bool, continue_: bool, abort: bool) -> list[str] | None:
11
+ base = "revert" if revert else "cherry-pick"
12
+ if continue_:
13
+ return [base, "--continue"]
14
+ if abort:
15
+ return [base, "--abort"]
16
+ return None
17
+
18
+
19
+ def _run_pick_mode(*, root, args: list[str], as_json: bool) -> int:
20
+ result = git_run(args, cwd=root, check=False)
21
+ if result.returncode != 0:
22
+ error(result.stderr.strip())
23
+ return 1
24
+ if as_json:
25
+ if args[-1] == "--continue":
26
+ print_json(ok_envelope(continued=True))
27
+ else:
28
+ print_json(ok_envelope(aborted=True))
29
+ return 0
30
+ if args[-1] == "--continue":
31
+ ok(t("pick_continued"))
32
+ else:
33
+ ok(t("pick_aborted"))
34
+ return 0
35
+
36
+
37
+ def _validate_pick_refs(refs: list[str], *, as_json: bool) -> int:
38
+ if not refs:
39
+ if as_json:
40
+ print_json(error_envelope(error=t("pick_no_refs")))
41
+ return 1
42
+ error(t("pick_no_refs"))
43
+ return 1
44
+ for ref in refs:
45
+ if not validate_ref(ref):
46
+ error(t("invalid_ref", ref=ref))
47
+ return 1
48
+ return 0
49
+
50
+
51
+ def _run_pick_dry_run(*, action: str, refs: list[str], as_json: bool) -> int:
52
+ if as_json:
53
+ print_json(ok_envelope(dry_run=True, action=action, refs=refs))
54
+ return 0
55
+ ok(t("pick_dry", action=action, refs=", ".join(refs)))
56
+ return 0
57
+
58
+
59
+ def _run_pick_execute(*, root, action: str, refs: list[str], as_json: bool) -> int:
60
+ result = git_run([action, "--"] + refs, cwd=root, check=False)
61
+ if result.returncode != 0:
62
+ if "CONFLICT" in result.stdout or "CONFLICT" in result.stderr:
63
+ warn(t("pick_conflicts"))
64
+ else:
65
+ error(result.stderr.strip())
66
+ return 1
67
+ if as_json:
68
+ print_json(ok_envelope(action=action, refs=refs))
69
+ return 0
70
+ ok(t("pick_ok", action=action, refs=", ".join(refs)))
71
+ return 0
72
+
73
+
74
+ def run_pick(
75
+ refs: list[str],
76
+ *,
77
+ revert: bool = False,
78
+ continue_: bool = False,
79
+ abort: bool = False,
80
+ dry_run: bool = False,
81
+ as_json: bool = False,
82
+ ) -> int:
83
+ root, err = require_root()
84
+ if err:
85
+ return err
86
+ if root is None:
87
+ return 1
88
+
89
+ mode_args = _pick_mode_args(revert=revert, continue_=continue_, abort=abort)
90
+ if mode_args is not None:
91
+ return _run_pick_mode(root=root, args=mode_args, as_json=as_json)
92
+
93
+ refs_rc = _validate_pick_refs(refs, as_json=as_json)
94
+ if refs_rc != 0:
95
+ return refs_rc
96
+
97
+ action = "revert" if revert else "cherry-pick"
98
+
99
+ if dry_run:
100
+ return _run_pick_dry_run(action=action, refs=refs, as_json=as_json)
101
+
102
+ return _run_pick_execute(root=root, action=action, refs=refs, as_json=as_json)
gitwise/pr.py ADDED
@@ -0,0 +1,543 @@
1
+ """gitwise pr — GitHub PR wrapper via gh CLI."""
2
+
3
+ import json
4
+ import shutil
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+
8
+ from .git import require_root
9
+ from .i18n import t
10
+ from .output import (
11
+ error,
12
+ info,
13
+ print_blank,
14
+ print_bracket,
15
+ print_dim,
16
+ print_file_status,
17
+ print_header,
18
+ print_json,
19
+ print_table,
20
+ )
21
+ from .utils.json_envelope import error_envelope, ok_envelope
22
+ from .utils.parsing import dict_list, to_int
23
+
24
+ _STATE_LABEL_KEYS: dict[str, str] = {
25
+ "pass": "pr_check_state_pass",
26
+ "fail": "pr_check_state_fail",
27
+ "running": "pr_check_state_running",
28
+ "pending": "pr_check_state_pending",
29
+ "queued": "pr_check_state_queued",
30
+ "cancel": "pr_check_state_cancel",
31
+ "skip": "pr_check_state_skip",
32
+ "other": "pr_check_state_other",
33
+ }
34
+
35
+ _PR_LIST_FIELDS = "number,title,state,headRefName"
36
+ _PR_CHECKS_FIELDS = "name,state,startedAt,completedAt,link,workflow,event"
37
+ _PR_VIEW_FIELDS = (
38
+ "number,title,state,isDraft,author,headRefName,baseRefName,"
39
+ "url,createdAt,updatedAt,mergedAt,closedAt,mergeable,reviewDecision,additions,deletions,"
40
+ "changedFiles,labels,assignees,reviewRequests,body"
41
+ )
42
+ _PR_COMMENTS_FIELDS = "number,title,url,comments"
43
+
44
+
45
+ def _pr_status_code(state: str) -> str:
46
+ normalized = state.strip().upper()
47
+ if normalized in {"OPEN", "DRAFT"}:
48
+ return "M"
49
+ if normalized in {"MERGED"}:
50
+ return "A"
51
+ if normalized in {"CLOSED"}:
52
+ return "D"
53
+ return "M"
54
+
55
+
56
+ def _gh_available() -> bool:
57
+ return bool(shutil.which("gh"))
58
+
59
+
60
+ def _gh(args: list[str], cwd) -> tuple[int, str, str]:
61
+ import subprocess
62
+
63
+ r = subprocess.run(
64
+ ["gh"] + args,
65
+ cwd=cwd,
66
+ capture_output=True,
67
+ text=True,
68
+ check=False,
69
+ timeout=120,
70
+ )
71
+ return r.returncode, r.stdout.strip(), r.stderr.strip()
72
+
73
+
74
+ def _actor_name(actor: object) -> str:
75
+ if isinstance(actor, dict):
76
+ login = actor.get("login")
77
+ if isinstance(login, str) and login.strip():
78
+ return login.strip()
79
+ name = actor.get("name")
80
+ if isinstance(name, str) and name.strip():
81
+ return name.strip()
82
+ return "-"
83
+
84
+
85
+ def _clean_lines(text: str, *, max_lines: int) -> list[str]:
86
+ normalized = _normalize_body_text(text)
87
+ lines = [line.rstrip() for line in normalized.splitlines()]
88
+ compact = [line for line in lines if line.strip()]
89
+ if len(compact) <= max_lines:
90
+ return compact
91
+ return compact[:max_lines]
92
+
93
+
94
+ def _normalize_body_text(text: str) -> str:
95
+ return text
96
+
97
+
98
+ def _json_or_error(out: str) -> tuple[bool, object | None]:
99
+ if not out:
100
+ return True, None
101
+ try:
102
+ parsed = json.loads(out)
103
+ except json.JSONDecodeError:
104
+ return False, None
105
+ return True, parsed
106
+
107
+
108
+ def _selector_args(selector: str | None) -> list[str]:
109
+ if selector is None:
110
+ return []
111
+ cleaned = selector.strip()
112
+ if not cleaned:
113
+ return []
114
+ if cleaned.startswith("-"):
115
+ raise ValueError(t("pr_invalid_selector", selector=selector))
116
+ return [cleaned]
117
+
118
+
119
+ def _parse_iso_datetime(value: str) -> datetime | None:
120
+ if not value:
121
+ return None
122
+ iso = value.strip()
123
+ if not iso:
124
+ return None
125
+ if iso.endswith("Z"):
126
+ iso = iso[:-1] + "+00:00"
127
+ try:
128
+ return datetime.fromisoformat(iso)
129
+ except ValueError:
130
+ return None
131
+
132
+
133
+ def _format_datetime(value: str) -> str:
134
+ parsed = _parse_iso_datetime(value)
135
+ if parsed is None:
136
+ return value or "-"
137
+ return parsed.strftime("%Y-%m-%d %H:%M")
138
+
139
+
140
+ def _non_empty_line_count(text: str) -> int:
141
+ return len([line for line in text.splitlines() if line.strip()])
142
+
143
+
144
+ def _format_duration(seconds: int) -> str:
145
+ if seconds <= 0:
146
+ return "0s"
147
+ minutes, rem = divmod(seconds, 60)
148
+ hours, minutes = divmod(minutes, 60)
149
+ if hours > 0:
150
+ return f"{hours}h{minutes:02d}m"
151
+ if minutes > 0:
152
+ return f"{minutes}m{rem:02d}s"
153
+ return f"{rem}s"
154
+
155
+
156
+ def _state_label(state: str) -> str:
157
+ normalized = state.strip().upper()
158
+ mapping = {
159
+ "SUCCESS": "pass",
160
+ "PASSED": "pass",
161
+ "FAILURE": "fail",
162
+ "FAILED": "fail",
163
+ "CANCELLED": "cancel",
164
+ "SKIPPED": "skip",
165
+ "PENDING": "pending",
166
+ "IN_PROGRESS": "running",
167
+ "QUEUED": "queued",
168
+ }
169
+ return mapping.get(normalized, normalized.lower() if normalized else "-")
170
+
171
+
172
+ def _state_label_human(state: str) -> str:
173
+ return t(_STATE_LABEL_KEYS[state]) if state in _STATE_LABEL_KEYS else state
174
+
175
+
176
+ def _duration_from_check(check: dict[str, object]) -> str:
177
+ started = str(check.get("startedAt") or "")
178
+ completed = str(check.get("completedAt") or "")
179
+ started_dt = _parse_iso_datetime(started)
180
+ completed_dt = _parse_iso_datetime(completed)
181
+ if started_dt is None or completed_dt is None:
182
+ return "-"
183
+ seconds = int((completed_dt - started_dt).total_seconds())
184
+ if seconds < 0:
185
+ return "-"
186
+ return _format_duration(seconds)
187
+
188
+
189
+ def _normalize_checks(payload: object) -> list[dict[str, str]]:
190
+ if not isinstance(payload, list):
191
+ return []
192
+ checks: list[dict[str, str]] = []
193
+ for item in payload:
194
+ if not isinstance(item, dict):
195
+ continue
196
+ name = str(item.get("name") or "-").strip()
197
+ state = _state_label(str(item.get("state") or ""))
198
+ duration = _duration_from_check(item)
199
+ workflow_value = item.get("workflow")
200
+ workflow = "-"
201
+ if isinstance(workflow_value, dict):
202
+ candidate = workflow_value.get("name")
203
+ if isinstance(candidate, str) and candidate.strip():
204
+ workflow = candidate.strip()
205
+ elif isinstance(workflow_value, str) and workflow_value.strip():
206
+ workflow = workflow_value.strip()
207
+ elif isinstance(item.get("event"), str) and str(item.get("event") or "").strip():
208
+ workflow = str(item.get("event") or "").strip()
209
+ link = str(item.get("link") or "").strip()
210
+ checks.append(
211
+ {
212
+ "name": name,
213
+ "state": state,
214
+ "duration": duration,
215
+ "workflow": workflow,
216
+ "link": link,
217
+ }
218
+ )
219
+ return checks
220
+
221
+
222
+ def _checks_summary(checks: list[dict[str, str]]) -> dict[str, int]:
223
+ summary = {"pass": 0, "fail": 0, "running": 0, "other": 0}
224
+ for check in checks:
225
+ state = check.get("state", "")
226
+ if state == "pass":
227
+ summary["pass"] += 1
228
+ elif state == "fail":
229
+ summary["fail"] += 1
230
+ elif state in {"running", "queued", "pending"}:
231
+ summary["running"] += 1
232
+ else:
233
+ summary["other"] += 1
234
+ return summary
235
+
236
+
237
+ def _pr_label_for_selector(selected: list[str]) -> str:
238
+ if not selected:
239
+ return t("pr_current_label")
240
+ return selected[0]
241
+
242
+
243
+ def _render_pr_checks(checks: list[dict[str, str]], *, selector_label: str) -> int:
244
+ print_header(t("pr_checks_title", selector=selector_label))
245
+ if not checks:
246
+ info(t("pr_checks_none"))
247
+ return 0
248
+
249
+ summary = _checks_summary(checks)
250
+ print_bracket(
251
+ t("pr_checks_summary"),
252
+ t(
253
+ "pr_checks_summary_value",
254
+ total=str(len(checks)),
255
+ passed=str(summary["pass"]),
256
+ failed=str(summary["fail"]),
257
+ running=str(summary["running"]),
258
+ other=str(summary["other"]),
259
+ label_pass=t("pr_check_state_pass"),
260
+ label_fail=t("pr_check_state_fail"),
261
+ label_running=t("pr_check_state_running"),
262
+ label_other=t("pr_check_state_other"),
263
+ ),
264
+ )
265
+
266
+ columns = [
267
+ (t("pr_checks_col_name"), "name"),
268
+ (t("pr_checks_col_status"), "state"),
269
+ (t("pr_checks_col_duration"), "duration"),
270
+ (t("pr_checks_col_workflow"), "workflow"),
271
+ ]
272
+ rows = [
273
+ [c["name"], _state_label_human(c["state"]), c["duration"], c["workflow"]] for c in checks
274
+ ]
275
+ print_table(
276
+ title=t("pr_checks_table_title"),
277
+ columns=columns,
278
+ rows=rows,
279
+ no_wrap_columns={1, 2},
280
+ max_widths={0: 42, 1: 10, 2: 10, 3: 28},
281
+ overflow_columns={0: "ellipsis", 3: "ellipsis"},
282
+ column_ratios={0: 3, 3: 2},
283
+ )
284
+
285
+ links = [c["link"] for c in checks if c.get("link")]
286
+ if links:
287
+ print_blank()
288
+ print_header(t("pr_checks_links_title"))
289
+ for idx, link in enumerate(links, start=1):
290
+ print_dim(f" {idx}. {link}")
291
+ return 0
292
+
293
+
294
+ def _render_pr_view(payload: dict[str, object]) -> None:
295
+ number = str(payload.get("number", "-"))
296
+ title = str(payload.get("title") or "")
297
+ state = str(payload.get("state") or "-")
298
+ if payload.get("isDraft") is True:
299
+ state = f"{state} (draft)"
300
+
301
+ head = str(payload.get("headRefName") or "-")
302
+ base = str(payload.get("baseRefName") or "-")
303
+ mergeable = str(payload.get("mergeable") or "-")
304
+ review = str(payload.get("reviewDecision") or "-")
305
+ additions = to_int(payload.get("additions"))
306
+ deletions = to_int(payload.get("deletions"))
307
+ changed_files = to_int(payload.get("changedFiles"))
308
+ url = str(payload.get("url") or "")
309
+ merged_at = str(payload.get("mergedAt") or "")
310
+ closed_at = str(payload.get("closedAt") or "")
311
+ labels = dict_list(payload.get("labels"))
312
+ assignees = dict_list(payload.get("assignees"))
313
+ review_requests = dict_list(payload.get("reviewRequests"))
314
+
315
+ label_names = ", ".join(
316
+ str(item.get("name")).strip() for item in labels if str(item.get("name") or "").strip()
317
+ )
318
+ assignee_names = ", ".join(_actor_name(item) for item in assignees)
319
+ review_request_names = ", ".join(
320
+ _actor_name(item.get("requestedReviewer"))
321
+ for item in review_requests
322
+ if item.get("requestedReviewer")
323
+ )
324
+
325
+ print_header(t("pr_view_title", number=number, title=title or "-"))
326
+ print_bracket(t("pr_field_state"), state)
327
+ print_bracket(t("pr_field_branch"), f"{head} -> {base}")
328
+ print_bracket(t("pr_field_author"), _actor_name(payload.get("author")))
329
+ if state == "MERGED" and merged_at:
330
+ print_bracket(t("pr_field_merged_at"), _format_datetime(merged_at))
331
+ elif state == "CLOSED" and closed_at:
332
+ print_bracket(t("pr_field_closed_at"), _format_datetime(closed_at))
333
+ else:
334
+ print_bracket(t("pr_field_mergeable"), mergeable)
335
+ print_bracket(t("pr_field_review"), review)
336
+ print_bracket(t("pr_field_changes"), f"{changed_files} files, +{additions}/-{deletions}")
337
+ if label_names:
338
+ print_bracket(t("pr_field_labels"), label_names)
339
+ if assignee_names:
340
+ print_bracket(t("pr_field_assignees"), assignee_names)
341
+ if review_request_names:
342
+ print_bracket(t("pr_field_review_requests"), review_request_names)
343
+ if url:
344
+ print_bracket(t("pr_field_url"), url)
345
+
346
+ body = str(payload.get("body") or "").strip()
347
+ if body:
348
+ print_blank()
349
+ print_header(t("pr_body_title"))
350
+ for line in _clean_lines(body, max_lines=12):
351
+ info(f" {line}")
352
+ if _non_empty_line_count(body) > 12:
353
+ print_dim(f" {t('pr_body_truncated')}")
354
+
355
+
356
+ def _render_pr_comments(payload: dict[str, object]) -> int:
357
+ number = str(payload.get("number", "-"))
358
+ title = str(payload.get("title") or "")
359
+ url = str(payload.get("url") or "")
360
+ comments = dict_list(payload.get("comments"))
361
+
362
+ print_header(t("pr_comments_title", number=number, title=title or "-"))
363
+ if url:
364
+ print_bracket(t("pr_field_url"), url)
365
+
366
+ if not comments:
367
+ info(t("pr_comments_none"))
368
+ return 0
369
+
370
+ print_bracket(t("pr_field_comments"), str(len(comments)))
371
+ print_blank()
372
+
373
+ for idx, comment in enumerate(comments, start=1):
374
+ author = _actor_name(comment.get("author"))
375
+ created_at = _format_datetime(str(comment.get("createdAt") or "-"))
376
+ comment_url = str(comment.get("url") or "")
377
+ body = str(comment.get("body") or "").strip()
378
+
379
+ print_header(t("pr_comment_header", index=str(idx), author=author, created=created_at))
380
+ if comment_url:
381
+ print_dim(f" {comment_url}")
382
+ if body:
383
+ for line in _clean_lines(body, max_lines=20):
384
+ info(f" {line}")
385
+ if _non_empty_line_count(body) > 20:
386
+ print_dim(f" {t('pr_comment_truncated')}")
387
+ else:
388
+ print_dim(f" {t('pr_comment_empty')}")
389
+ if idx < len(comments):
390
+ print_blank()
391
+
392
+ return 0
393
+
394
+
395
+ def _invalid_json_response(*, as_json: bool, raw: str) -> int:
396
+ if as_json:
397
+ print_json(error_envelope(error="invalid_gh_json", raw=raw))
398
+ else:
399
+ error(t("pr_invalid_json"))
400
+ return 1
401
+
402
+
403
+ def _run_action_list(*, root: Path, as_json: bool) -> int:
404
+ rc, out, err = _gh(["pr", "list", "--json", _PR_LIST_FIELDS], cwd=root)
405
+ if rc != 0:
406
+ error(err)
407
+ return 1
408
+
409
+ if as_json:
410
+ ok_json, payload = _json_or_error(out)
411
+ if not ok_json:
412
+ print_json(error_envelope(error="invalid_gh_json", raw=out))
413
+ elif payload is None:
414
+ print_json([])
415
+ else:
416
+ print_json(payload)
417
+ return 0
418
+
419
+ prs = json.loads(out) if out else []
420
+ if not prs:
421
+ info(t("pr_none"))
422
+ return 0
423
+
424
+ print_header(t("pr_list_title"))
425
+ for pr in prs:
426
+ if not isinstance(pr, dict):
427
+ continue
428
+ state = str(pr.get("state") or "")
429
+ number = str(pr.get("number") or "-")
430
+ title = str(pr.get("title") or "-")
431
+ head = str(pr.get("headRefName") or "-")
432
+ print_file_status(_pr_status_code(state), f"#{number} {title}")
433
+ info(f" ({state}) <- {head}")
434
+ return 0
435
+
436
+
437
+ def _run_action_checks(*, root: Path, selected: list[str], as_json: bool) -> int:
438
+ rc, out, err = _gh(["pr", "checks", *selected, "--json", _PR_CHECKS_FIELDS], cwd=root)
439
+ if rc != 0:
440
+ error(err)
441
+ return 1
442
+
443
+ ok_json, payload = _json_or_error(out)
444
+ if not ok_json:
445
+ return _invalid_json_response(as_json=as_json, raw=out)
446
+
447
+ checks = _normalize_checks(payload)
448
+ if as_json:
449
+ summary = _checks_summary(checks)
450
+ print_json(
451
+ ok_envelope(
452
+ selector=_pr_label_for_selector(selected),
453
+ checks=checks,
454
+ count=len(checks),
455
+ summary=summary,
456
+ )
457
+ )
458
+ return 0
459
+
460
+ return _render_pr_checks(checks, selector_label=_pr_label_for_selector(selected))
461
+
462
+
463
+ def _run_action_view(*, root: Path, selected: list[str], as_json: bool) -> int:
464
+ rc, out, err = _gh(["pr", "view", *selected, "--json", _PR_VIEW_FIELDS], cwd=root)
465
+ if rc != 0:
466
+ error(err)
467
+ return 1
468
+
469
+ ok_json, payload = _json_or_error(out)
470
+ if not ok_json or not isinstance(payload, dict):
471
+ return _invalid_json_response(as_json=as_json, raw=out)
472
+
473
+ if as_json:
474
+ print_json(ok_envelope(payload=payload))
475
+ return 0
476
+
477
+ _render_pr_view(payload)
478
+ return 0
479
+
480
+
481
+ def _run_action_comments(*, root: Path, selected: list[str], as_json: bool) -> int:
482
+ rc, out, err = _gh(["pr", "view", *selected, "--json", _PR_COMMENTS_FIELDS], cwd=root)
483
+ if rc != 0:
484
+ error(err)
485
+ return 1
486
+
487
+ ok_json, payload = _json_or_error(out)
488
+ if not ok_json or not isinstance(payload, dict):
489
+ return _invalid_json_response(as_json=as_json, raw=out)
490
+
491
+ comments = payload.get("comments")
492
+ if not isinstance(comments, list):
493
+ comments = []
494
+ if as_json:
495
+ print_json(
496
+ ok_envelope(
497
+ number=payload.get("number"),
498
+ title=payload.get("title"),
499
+ url=payload.get("url"),
500
+ comments=comments,
501
+ count=len(comments),
502
+ )
503
+ )
504
+ return 0
505
+
506
+ return _render_pr_comments(payload)
507
+
508
+
509
+ def run_pr(
510
+ *,
511
+ action: str = "list",
512
+ selector: str | None = None,
513
+ as_json: bool = False,
514
+ ) -> int:
515
+ if not _gh_available():
516
+ error(t("pr_gh_required"))
517
+ return 1
518
+ root, err = require_root()
519
+ if err:
520
+ return err
521
+ if root is None:
522
+ return 1
523
+
524
+ if action == "list":
525
+ return _run_action_list(root=root, as_json=as_json)
526
+
527
+ try:
528
+ selected = _selector_args(selector)
529
+ except ValueError as e:
530
+ error(str(e))
531
+ return 1
532
+
533
+ if action == "checks":
534
+ return _run_action_checks(root=root, selected=selected, as_json=as_json)
535
+
536
+ if action == "view":
537
+ return _run_action_view(root=root, selected=selected, as_json=as_json)
538
+
539
+ if action == "comments":
540
+ return _run_action_comments(root=root, selected=selected, as_json=as_json)
541
+
542
+ error(t("pr_unknown_action", action=action))
543
+ return 1
gitwise/py.typed ADDED
File without changes
gitwise/schema.py ADDED
@@ -0,0 +1,49 @@
1
+ """Schema catalog loader for versioned CLI schemas."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from functools import lru_cache
7
+ from pathlib import Path
8
+
9
+ SCHEMA_VERSION_DEFAULT = "v1"
10
+
11
+
12
+ @lru_cache(maxsize=1)
13
+ def _schema_roots() -> tuple[Path, ...]:
14
+ from ._paths import share_dir
15
+
16
+ return (share_dir() / "schemas",)
17
+
18
+
19
+ def schema_root(version: str = SCHEMA_VERSION_DEFAULT) -> Path:
20
+ roots = _schema_roots()
21
+ for candidate in roots:
22
+ version_root = candidate / version
23
+ if version_root.is_dir():
24
+ return version_root
25
+ return roots[0] / version
26
+
27
+
28
+ def command_input_schema_path(*, command: str, version: str = SCHEMA_VERSION_DEFAULT) -> Path:
29
+ return schema_root(version) / "input" / f"{command}.json"
30
+
31
+
32
+ def load_command_input_schema(
33
+ *, command: str, version: str = SCHEMA_VERSION_DEFAULT
34
+ ) -> dict | None:
35
+ path = command_input_schema_path(command=command, version=version)
36
+ if not path.exists():
37
+ return None
38
+ with path.open("r", encoding="utf-8") as f:
39
+ payload = json.load(f)
40
+ if not isinstance(payload, dict):
41
+ raise ValueError(f"schema file is not a JSON object: {path}")
42
+ return payload
43
+
44
+
45
+ def list_command_input_schema_files(*, version: str = SCHEMA_VERSION_DEFAULT) -> list[Path]:
46
+ input_dir = schema_root(version) / "input"
47
+ if not input_dir.is_dir():
48
+ return []
49
+ return sorted(input_dir.glob("*.json"))