quick-status 0.6.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.
- quick_status/__init__.py +7 -0
- quick_status/ci_models.py +181 -0
- quick_status/ci_render.py +197 -0
- quick_status/ci_snapshot.py +955 -0
- quick_status/cli.py +459 -0
- quick_status/commands.py +91 -0
- quick_status/env_render.py +535 -0
- quick_status/env_snapshot.py +383 -0
- quick_status/formatting.py +270 -0
- quick_status/git_snapshot.py +514 -0
- quick_status/github.py +423 -0
- quick_status/models.py +311 -0
- quick_status/py.typed +0 -0
- quick_status/repo_render.py +580 -0
- quick_status-0.6.0.dist-info/METADATA +296 -0
- quick_status-0.6.0.dist-info/RECORD +18 -0
- quick_status-0.6.0.dist-info/WHEEL +4 -0
- quick_status-0.6.0.dist-info/entry_points.txt +2 -0
quick_status/__init__.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Data models for quick_status CI snapshots."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import asdict, dataclass, field
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from quick_status.models import (
|
|
10
|
+
BranchState,
|
|
11
|
+
ChangeSummary,
|
|
12
|
+
CommandRecord,
|
|
13
|
+
RepoIdentity,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
CI_SCHEMA_VERSION = "quick_status_ci_snapshot_v1"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class CiGitHubStatus:
|
|
21
|
+
"""GitHub availability facts for a CI snapshot."""
|
|
22
|
+
|
|
23
|
+
status: str
|
|
24
|
+
repo: str | None
|
|
25
|
+
error: str | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True, slots=True)
|
|
29
|
+
class CiPullRequest:
|
|
30
|
+
"""Pull request facts needed for CI currentness."""
|
|
31
|
+
|
|
32
|
+
number: int
|
|
33
|
+
title: str
|
|
34
|
+
url: str
|
|
35
|
+
state: str
|
|
36
|
+
is_draft: bool
|
|
37
|
+
base_ref: str | None
|
|
38
|
+
base_oid: str | None
|
|
39
|
+
head_ref: str | None
|
|
40
|
+
head_oid: str | None
|
|
41
|
+
review_decision: str | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True, slots=True)
|
|
45
|
+
class CiCommitRefs:
|
|
46
|
+
"""Local, PR, and expected commit facts for CI checks."""
|
|
47
|
+
|
|
48
|
+
local_head: str | None
|
|
49
|
+
local_short: str | None
|
|
50
|
+
branch: str
|
|
51
|
+
upstream: str | None
|
|
52
|
+
upstream_tracking_oid: str | None
|
|
53
|
+
pr_head_oid: str | None
|
|
54
|
+
pr_base_oid: str | None
|
|
55
|
+
expected_oid: str | None
|
|
56
|
+
expected_source: str
|
|
57
|
+
local_matches_pr: bool | None
|
|
58
|
+
local_matches_upstream_tracking: bool | None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True, slots=True)
|
|
62
|
+
class CiCurrentness:
|
|
63
|
+
"""Whether the CI evidence applies to the expected commit."""
|
|
64
|
+
|
|
65
|
+
state: str
|
|
66
|
+
reason: str
|
|
67
|
+
expected_oid: str | None
|
|
68
|
+
checked_oid: str | None
|
|
69
|
+
source: str
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True, slots=True)
|
|
73
|
+
class CiCheck:
|
|
74
|
+
"""One check row from GitHub PR checks."""
|
|
75
|
+
|
|
76
|
+
name: str
|
|
77
|
+
workflow: str | None
|
|
78
|
+
status: str | None
|
|
79
|
+
conclusion: str | None
|
|
80
|
+
bucket: str
|
|
81
|
+
started_at: str | None
|
|
82
|
+
completed_at: str | None
|
|
83
|
+
event: str | None
|
|
84
|
+
url: str | None
|
|
85
|
+
details_url: str | None
|
|
86
|
+
head_sha: str | None
|
|
87
|
+
source: str
|
|
88
|
+
currentness: str
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True, slots=True)
|
|
92
|
+
class CiRun:
|
|
93
|
+
"""One GitHub Actions workflow run row."""
|
|
94
|
+
|
|
95
|
+
database_id: int | None
|
|
96
|
+
workflow_name: str | None
|
|
97
|
+
display_title: str | None
|
|
98
|
+
event: str | None
|
|
99
|
+
status: str | None
|
|
100
|
+
conclusion: str | None
|
|
101
|
+
bucket: str
|
|
102
|
+
head_sha: str | None
|
|
103
|
+
head_branch: str | None
|
|
104
|
+
created_at: str | None
|
|
105
|
+
updated_at: str | None
|
|
106
|
+
url: str | None
|
|
107
|
+
attempt: int | None
|
|
108
|
+
currentness: str
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass(frozen=True, slots=True)
|
|
112
|
+
class CiJob:
|
|
113
|
+
"""One job row from a GitHub Actions workflow run."""
|
|
114
|
+
|
|
115
|
+
database_id: int | None
|
|
116
|
+
run_database_id: int | None
|
|
117
|
+
name: str
|
|
118
|
+
status: str | None
|
|
119
|
+
conclusion: str | None
|
|
120
|
+
bucket: str
|
|
121
|
+
started_at: str | None
|
|
122
|
+
completed_at: str | None
|
|
123
|
+
url: str | None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass(frozen=True, slots=True)
|
|
127
|
+
class CiLogTail:
|
|
128
|
+
"""Bounded tail of failed GitHub Actions log output."""
|
|
129
|
+
|
|
130
|
+
run_database_id: int | None
|
|
131
|
+
status: str
|
|
132
|
+
requested_lines: int
|
|
133
|
+
capped: bool
|
|
134
|
+
lines: list[str] = field(default_factory=list)
|
|
135
|
+
reason: str | None = None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass(frozen=True, slots=True)
|
|
139
|
+
class CiSummary:
|
|
140
|
+
"""Aggregate CI state for compact human output."""
|
|
141
|
+
|
|
142
|
+
ci_state: str
|
|
143
|
+
currentness: str
|
|
144
|
+
total_checks: int
|
|
145
|
+
pass_count: int = 0
|
|
146
|
+
fail_count: int = 0
|
|
147
|
+
pending_count: int = 0
|
|
148
|
+
running_count: int = 0
|
|
149
|
+
skipped_count: int = 0
|
|
150
|
+
cancel_count: int = 0
|
|
151
|
+
unknown_count: int = 0
|
|
152
|
+
failing_runs: int = 0
|
|
153
|
+
failing_jobs: int = 0
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@dataclass(frozen=True, slots=True)
|
|
157
|
+
class CiSnapshot:
|
|
158
|
+
"""Full quick_status CI snapshot."""
|
|
159
|
+
|
|
160
|
+
schema_version: str
|
|
161
|
+
repo: RepoIdentity
|
|
162
|
+
branch: BranchState
|
|
163
|
+
changes: ChangeSummary
|
|
164
|
+
github: CiGitHubStatus
|
|
165
|
+
pull_request: CiPullRequest | None
|
|
166
|
+
commits: CiCommitRefs
|
|
167
|
+
currentness: CiCurrentness
|
|
168
|
+
checks: list[CiCheck] = field(default_factory=list)
|
|
169
|
+
runs: list[CiRun] = field(default_factory=list)
|
|
170
|
+
jobs: list[CiJob] = field(default_factory=list)
|
|
171
|
+
log_tails: list[CiLogTail] = field(default_factory=list)
|
|
172
|
+
summary: CiSummary | None = None
|
|
173
|
+
source_errors: list[str] = field(default_factory=list)
|
|
174
|
+
commands: list[CommandRecord] = field(default_factory=list)
|
|
175
|
+
|
|
176
|
+
def to_dict(self, *, include_commands: bool = False) -> dict[str, object]:
|
|
177
|
+
"""Convert the CI snapshot into stable JSON."""
|
|
178
|
+
payload = asdict(self)
|
|
179
|
+
if not include_commands:
|
|
180
|
+
payload.pop("commands", None)
|
|
181
|
+
return payload
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Human and JSON renderers for quick_status CI snapshots."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from quick_status import formatting as fmt
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from quick_status.ci_models import CiJob, CiRun, CiSnapshot
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def render_ci_json(snapshot: CiSnapshot, *, verbose: bool = False) -> str:
|
|
15
|
+
"""Render a CI snapshot as stable JSON."""
|
|
16
|
+
return json.dumps(
|
|
17
|
+
snapshot.to_dict(include_commands=verbose),
|
|
18
|
+
indent=2,
|
|
19
|
+
sort_keys=True,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def render_ci_human(
|
|
24
|
+
snapshot: CiSnapshot,
|
|
25
|
+
*,
|
|
26
|
+
verbose: bool = False,
|
|
27
|
+
color: bool = False,
|
|
28
|
+
) -> str:
|
|
29
|
+
"""Render a human-readable CI snapshot."""
|
|
30
|
+
lines = [
|
|
31
|
+
_ci_line(snapshot, color=color),
|
|
32
|
+
_branch_line(snapshot, color=color),
|
|
33
|
+
_state_line(snapshot, color=color),
|
|
34
|
+
_pr_line(snapshot, color=color),
|
|
35
|
+
_current_line(snapshot, color=color),
|
|
36
|
+
_summary_line(snapshot, color=color),
|
|
37
|
+
*_run_lines(snapshot.runs, color=color),
|
|
38
|
+
*_job_lines(snapshot.jobs, color=color),
|
|
39
|
+
*_log_tail_lines(snapshot, color=color),
|
|
40
|
+
]
|
|
41
|
+
if snapshot.github.status != "available" and snapshot.github.error:
|
|
42
|
+
lines.append(
|
|
43
|
+
f"{fmt.label('GITHUB', color)} "
|
|
44
|
+
f"{fmt.state(snapshot.github.status, color)} "
|
|
45
|
+
f"reason={snapshot.github.error}"
|
|
46
|
+
)
|
|
47
|
+
if snapshot.source_errors:
|
|
48
|
+
lines.extend(
|
|
49
|
+
f"{fmt.label('SOURCE_ERROR', color)} {error}"
|
|
50
|
+
for error in snapshot.source_errors
|
|
51
|
+
)
|
|
52
|
+
if verbose:
|
|
53
|
+
lines.extend(fmt.command_lines(snapshot.commands, color=color))
|
|
54
|
+
return "\n".join(lines)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _ci_line(snapshot: CiSnapshot, *, color: bool) -> str:
|
|
58
|
+
repo_name = fmt.name(snapshot.repo.name, color)
|
|
59
|
+
github_repo = snapshot.github.repo or snapshot.repo.github_repo or "-"
|
|
60
|
+
return f"{fmt.label('CI', color)} {repo_name} {github_repo}"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _branch_line(snapshot: CiSnapshot, *, color: bool) -> str:
|
|
64
|
+
branch = snapshot.branch
|
|
65
|
+
upstream = branch.upstream or "no-upstream"
|
|
66
|
+
local = _short(snapshot.commits.local_head) or "-"
|
|
67
|
+
parts = [
|
|
68
|
+
f"{fmt.label('BRANCH', color)} {fmt.name(branch.head, color)}",
|
|
69
|
+
f"local={fmt.muted(local, color)}",
|
|
70
|
+
f"upstream={fmt.muted(upstream, color)}",
|
|
71
|
+
fmt.state(branch.sync_state, color),
|
|
72
|
+
]
|
|
73
|
+
return " ".join(parts)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _state_line(snapshot: CiSnapshot, *, color: bool) -> str:
|
|
77
|
+
changes = snapshot.changes
|
|
78
|
+
return (
|
|
79
|
+
f"{fmt.label('STATE', color)} {fmt.state(changes.worktree_state, color)} "
|
|
80
|
+
f"{fmt.kv('staged', changes.staged, color)} "
|
|
81
|
+
f"{fmt.kv('unstaged', changes.unstaged, color)} "
|
|
82
|
+
f"{fmt.kv('untracked', changes.untracked, color)} "
|
|
83
|
+
f"{fmt.kv('conflicts', changes.conflicted, color)}"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _pr_line(snapshot: CiSnapshot, *, color: bool) -> str:
|
|
88
|
+
pull_request = snapshot.pull_request
|
|
89
|
+
if pull_request is None:
|
|
90
|
+
return f"{fmt.label('PR', color)} none"
|
|
91
|
+
head = _short(pull_request.head_oid) or "-"
|
|
92
|
+
base = pull_request.base_ref or "-"
|
|
93
|
+
return (
|
|
94
|
+
f"{fmt.label('PR', color)} #{pull_request.number} "
|
|
95
|
+
f"{fmt.state(pull_request.state, color)} "
|
|
96
|
+
f"head={fmt.muted(head, color)} base={base} url={pull_request.url}"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _current_line(snapshot: CiSnapshot, *, color: bool) -> str:
|
|
101
|
+
currentness = snapshot.currentness
|
|
102
|
+
expected = _short(currentness.expected_oid) or "-"
|
|
103
|
+
checked = _short(currentness.checked_oid) or "-"
|
|
104
|
+
return (
|
|
105
|
+
f"{fmt.label('CURRENT', color)} {fmt.state(currentness.state, color)} "
|
|
106
|
+
f"expected={fmt.muted(expected, color)} checked={fmt.muted(checked, color)} "
|
|
107
|
+
f"source={currentness.source} reason={currentness.reason}"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _summary_line(snapshot: CiSnapshot, *, color: bool) -> str:
|
|
112
|
+
summary = snapshot.summary
|
|
113
|
+
if summary is None:
|
|
114
|
+
return f"{fmt.label('CHECKS', color)} {fmt.state('unknown', color)}"
|
|
115
|
+
label = "CHECKS" if snapshot.checks else "RUNS"
|
|
116
|
+
return (
|
|
117
|
+
f"{fmt.label(label, color)} {fmt.state(summary.ci_state, color)} "
|
|
118
|
+
f"{fmt.kv('total', summary.total_checks, color)} "
|
|
119
|
+
f"{fmt.kv('pass', summary.pass_count, color)} "
|
|
120
|
+
f"{fmt.kv('fail', summary.fail_count, color)} "
|
|
121
|
+
f"{fmt.kv('pending', summary.pending_count, color)} "
|
|
122
|
+
f"{fmt.kv('running', summary.running_count, color)} "
|
|
123
|
+
f"{fmt.kv('skipped', summary.skipped_count, color)} "
|
|
124
|
+
f"{fmt.kv('cancel', summary.cancel_count, color)} "
|
|
125
|
+
f"{fmt.kv('unknown', summary.unknown_count, color)} "
|
|
126
|
+
f"applies_to_head={fmt.state(_applies_to_head(summary.currentness), color)}"
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _run_lines(runs: list[CiRun], *, color: bool) -> list[str]:
|
|
131
|
+
selected = runs[:5]
|
|
132
|
+
lines: list[str] = []
|
|
133
|
+
for run in selected:
|
|
134
|
+
name = run.workflow_name or run.display_title or "run"
|
|
135
|
+
run_id = run.database_id if run.database_id is not None else "-"
|
|
136
|
+
sha = _short(run.head_sha) or "-"
|
|
137
|
+
url = run.url or "-"
|
|
138
|
+
lines.append(
|
|
139
|
+
f"{fmt.label('RUN', color)} {name} "
|
|
140
|
+
f"{fmt.state(_display_bucket(run.bucket), color)} "
|
|
141
|
+
f"id={run_id} sha={fmt.muted(sha, color)} currentness={run.currentness} "
|
|
142
|
+
f"url={url}"
|
|
143
|
+
)
|
|
144
|
+
return lines
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _job_lines(jobs: list[CiJob], *, color: bool) -> list[str]:
|
|
148
|
+
return [
|
|
149
|
+
(
|
|
150
|
+
f"{fmt.label('JOB', color)} {job.name} "
|
|
151
|
+
f"{fmt.state(_display_bucket(job.bucket), color)} "
|
|
152
|
+
f"url={job.url or '-'}"
|
|
153
|
+
)
|
|
154
|
+
for job in jobs
|
|
155
|
+
]
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _log_tail_lines(snapshot: CiSnapshot, *, color: bool) -> list[str]:
|
|
159
|
+
lines: list[str] = []
|
|
160
|
+
for tail in snapshot.log_tails:
|
|
161
|
+
if tail.status != "available":
|
|
162
|
+
reason = tail.reason or "unknown"
|
|
163
|
+
lines.append(
|
|
164
|
+
f"{fmt.label('LOG', color)} unavailable "
|
|
165
|
+
f"run={tail.run_database_id or '-'} reason={reason}"
|
|
166
|
+
)
|
|
167
|
+
continue
|
|
168
|
+
lines.append(
|
|
169
|
+
f"{fmt.label('LOG', color)} tail "
|
|
170
|
+
f"run={tail.run_database_id or '-'} "
|
|
171
|
+
f"lines={len(tail.lines)} capped={'yes' if tail.capped else 'no'}"
|
|
172
|
+
)
|
|
173
|
+
lines.extend(f" {line}" for line in tail.lines)
|
|
174
|
+
return lines
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _short(value: str | None) -> str | None:
|
|
178
|
+
if value is None:
|
|
179
|
+
return None
|
|
180
|
+
return value[:7]
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _display_bucket(bucket: str) -> str:
|
|
184
|
+
return {
|
|
185
|
+
"pass": "success",
|
|
186
|
+
"fail": "failure",
|
|
187
|
+
"cancel": "cancelled",
|
|
188
|
+
"skipping": "skipped",
|
|
189
|
+
}.get(bucket, bucket)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _applies_to_head(currentness: str) -> str:
|
|
193
|
+
if currentness == "current":
|
|
194
|
+
return "yes"
|
|
195
|
+
if currentness == "stale":
|
|
196
|
+
return "no"
|
|
197
|
+
return "unknown"
|