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.
- gitwise/__init__.py +11 -0
- gitwise/__main__.py +113 -0
- gitwise/_cli_completions.py +88 -0
- gitwise/_cli_dispatch.py +469 -0
- gitwise/_cli_introspection.py +275 -0
- gitwise/_cli_parser.py +345 -0
- gitwise/_cli_setup_agents.py +439 -0
- gitwise/_i18n_data.json +1934 -0
- gitwise/_paths.py +22 -0
- gitwise/_runtime_config.py +246 -0
- gitwise/audit.py +338 -0
- gitwise/branches.py +183 -0
- gitwise/clean.py +197 -0
- gitwise/commit.py +142 -0
- gitwise/conflicts.py +112 -0
- gitwise/context.py +163 -0
- gitwise/design.py +383 -0
- gitwise/diff.py +309 -0
- gitwise/doctor.py +116 -0
- gitwise/git.py +254 -0
- gitwise/health.py +345 -0
- gitwise/i18n.py +99 -0
- gitwise/log.py +329 -0
- gitwise/merge.py +193 -0
- gitwise/optimize.py +212 -0
- gitwise/output.py +652 -0
- gitwise/pick.py +102 -0
- gitwise/pr.py +543 -0
- gitwise/py.typed +0 -0
- gitwise/schema.py +49 -0
- gitwise/setup.py +551 -0
- gitwise/setup_agents/__init__.py +36 -0
- gitwise/setup_agents/adapters/__init__.py +17 -0
- gitwise/setup_agents/adapters/aider.py +5 -0
- gitwise/setup_agents/adapters/base.py +5 -0
- gitwise/setup_agents/adapters/codex.py +5 -0
- gitwise/setup_agents/adapters/continue_adapter.py +5 -0
- gitwise/setup_agents/adapters/cursor.py +5 -0
- gitwise/setup_agents/adapters/opencode.py +5 -0
- gitwise/setup_agents/adapters/pi.py +5 -0
- gitwise/setup_agents/exec.py +449 -0
- gitwise/setup_agents/format.py +164 -0
- gitwise/setup_agents/plan.py +254 -0
- gitwise/setup_agents/plan_gitfiles.py +167 -0
- gitwise/setup_agents/plan_skills.py +256 -0
- gitwise/setup_agents/providers/__init__.py +96 -0
- gitwise/setup_agents/providers/aider.py +11 -0
- gitwise/setup_agents/providers/base.py +79 -0
- gitwise/setup_agents/providers/claude.py +408 -0
- gitwise/setup_agents/providers/codex.py +11 -0
- gitwise/setup_agents/providers/continue_adapter.py +11 -0
- gitwise/setup_agents/providers/cursor.py +11 -0
- gitwise/setup_agents/providers/opencode.py +11 -0
- gitwise/setup_agents/providers/pi.py +11 -0
- gitwise/setup_agents/state.py +141 -0
- gitwise/setup_agents/types.py +48 -0
- gitwise/share/agents/skills/git-audit/SKILL.md +25 -0
- gitwise/share/agents/skills/git-clean/SKILL.md +22 -0
- gitwise/share/agents/skills/git-optimize/SKILL.md +21 -0
- gitwise/share/aider/CONVENTIONS.md.template +8 -0
- gitwise/share/aider/aider.conf.yml.template +4 -0
- gitwise/share/claude/CLAUDE.md.template +9 -0
- gitwise/share/claude/rules/gitwise.md +16 -0
- gitwise/share/claude/settings.json.template +47 -0
- gitwise/share/claude/skills/git-audit/SKILL.md +25 -0
- gitwise/share/claude/skills/git-clean/SKILL.md +22 -0
- gitwise/share/claude/skills/git-optimize/SKILL.md +21 -0
- gitwise/share/codex/agents/gitwise.toml.template +18 -0
- gitwise/share/continue/rules/gitwise.md.template +14 -0
- gitwise/share/cursor/rules/gitwise.mdc.template +16 -0
- gitwise/share/git-config-modern.txt +48 -0
- gitwise/share/hooks/commit-msg +22 -0
- gitwise/share/hooks/pre-commit +19 -0
- gitwise/share/opencode/agents/gitwise.md.template +14 -0
- gitwise/share/pi/skills/gitwise.md.template +14 -0
- gitwise/share/schemas/v1/input/audit.json +40 -0
- gitwise/share/schemas/v1/input/branches.json +51 -0
- gitwise/share/schemas/v1/input/clean.json +52 -0
- gitwise/share/schemas/v1/input/commands.json +36 -0
- gitwise/share/schemas/v1/input/commit.json +63 -0
- gitwise/share/schemas/v1/input/completions.json +51 -0
- gitwise/share/schemas/v1/input/conflicts.json +46 -0
- gitwise/share/schemas/v1/input/context.json +36 -0
- gitwise/share/schemas/v1/input/diff.json +56 -0
- gitwise/share/schemas/v1/input/doctor.json +36 -0
- gitwise/share/schemas/v1/input/health.json +36 -0
- gitwise/share/schemas/v1/input/log.json +71 -0
- gitwise/share/schemas/v1/input/merge.json +63 -0
- gitwise/share/schemas/v1/input/optimize.json +44 -0
- gitwise/share/schemas/v1/input/pick.json +63 -0
- gitwise/share/schemas/v1/input/pr.json +51 -0
- gitwise/share/schemas/v1/input/schema.json +48 -0
- gitwise/share/schemas/v1/input/setup-agents.json +108 -0
- gitwise/share/schemas/v1/input/setup.json +55 -0
- gitwise/share/schemas/v1/input/show.json +46 -0
- gitwise/share/schemas/v1/input/snapshot.json +36 -0
- gitwise/share/schemas/v1/input/stash.json +68 -0
- gitwise/share/schemas/v1/input/status.json +36 -0
- gitwise/share/schemas/v1/input/suggest.json +36 -0
- gitwise/share/schemas/v1/input/summarize.json +44 -0
- gitwise/share/schemas/v1/input/sync.json +55 -0
- gitwise/share/schemas/v1/input/tag.json +73 -0
- gitwise/share/schemas/v1/input/undo.json +60 -0
- gitwise/share/schemas/v1/input/update.json +40 -0
- gitwise/share/schemas/v1/input/worktree.json +50 -0
- gitwise/show.py +118 -0
- gitwise/snapshot.py +110 -0
- gitwise/stash.py +188 -0
- gitwise/status.py +93 -0
- gitwise/suggest.py +148 -0
- gitwise/summarize.py +202 -0
- gitwise/sync.py +257 -0
- gitwise/tag.py +252 -0
- gitwise/undo.py +145 -0
- gitwise/update.py +42 -0
- gitwise/utils/__init__.py +1 -0
- gitwise/utils/git_output.py +51 -0
- gitwise/utils/json_envelope.py +58 -0
- gitwise/utils/parsing.py +34 -0
- gitwise/worktree.py +182 -0
- gitwise_cli-0.24.2.dist-info/METADATA +151 -0
- gitwise_cli-0.24.2.dist-info/RECORD +125 -0
- gitwise_cli-0.24.2.dist-info/WHEEL +4 -0
- gitwise_cli-0.24.2.dist-info/entry_points.txt +2 -0
- 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"))
|