qodev-gitlab-cli 0.1.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.
- qodev_gitlab_cli/__init__.py +1 -0
- qodev_gitlab_cli/__main__.py +5 -0
- qodev_gitlab_cli/app.py +93 -0
- qodev_gitlab_cli/commands/__init__.py +1 -0
- qodev_gitlab_cli/commands/issues.py +102 -0
- qodev_gitlab_cli/commands/jobs.py +52 -0
- qodev_gitlab_cli/commands/mrs.py +202 -0
- qodev_gitlab_cli/commands/pipelines.py +63 -0
- qodev_gitlab_cli/commands/projects.py +35 -0
- qodev_gitlab_cli/commands/releases.py +48 -0
- qodev_gitlab_cli/commands/variables.py +61 -0
- qodev_gitlab_cli/context.py +62 -0
- qodev_gitlab_cli/formatters/__init__.py +1 -0
- qodev_gitlab_cli/formatters/generic.py +94 -0
- qodev_gitlab_cli/formatters/issues.py +60 -0
- qodev_gitlab_cli/formatters/jobs.py +39 -0
- qodev_gitlab_cli/formatters/mrs.py +101 -0
- qodev_gitlab_cli/formatters/pipelines.py +67 -0
- qodev_gitlab_cli/formatters/projects.py +39 -0
- qodev_gitlab_cli/formatters/releases.py +33 -0
- qodev_gitlab_cli/formatters/variables.py +34 -0
- qodev_gitlab_cli/output.py +116 -0
- qodev_gitlab_cli/project.py +102 -0
- qodev_gitlab_cli-0.1.0.dist-info/METADATA +14 -0
- qodev_gitlab_cli-0.1.0.dist-info/RECORD +27 -0
- qodev_gitlab_cli-0.1.0.dist-info/WHEEL +4 -0
- qodev_gitlab_cli-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Global context shared across all commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from qodev_gitlab_api import GitLabClient
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class Context:
|
|
12
|
+
"""Shared state passed from the meta launcher to every command."""
|
|
13
|
+
|
|
14
|
+
json_mode: bool = False
|
|
15
|
+
token: str | None = None
|
|
16
|
+
base_url: str | None = None
|
|
17
|
+
project: str | None = None
|
|
18
|
+
limit: int = 25
|
|
19
|
+
page: int = 1
|
|
20
|
+
|
|
21
|
+
def client(self) -> GitLabClient:
|
|
22
|
+
kwargs: dict = {}
|
|
23
|
+
if self.token:
|
|
24
|
+
kwargs["token"] = self.token
|
|
25
|
+
if self.base_url:
|
|
26
|
+
kwargs["base_url"] = self.base_url
|
|
27
|
+
return GitLabClient(**kwargs)
|
|
28
|
+
|
|
29
|
+
def resolve_project(self) -> str:
|
|
30
|
+
if self.project:
|
|
31
|
+
return self.project
|
|
32
|
+
from qodev_gitlab_cli.project import detect_project_from_git
|
|
33
|
+
|
|
34
|
+
path = detect_project_from_git(self.base_url)
|
|
35
|
+
if not path:
|
|
36
|
+
from qodev_gitlab_api.exceptions import ConfigurationError
|
|
37
|
+
|
|
38
|
+
raise ConfigurationError(
|
|
39
|
+
"Could not detect project. Use --project/-p or run from a git repo with a GitLab remote."
|
|
40
|
+
)
|
|
41
|
+
return path
|
|
42
|
+
|
|
43
|
+
def configure(
|
|
44
|
+
self,
|
|
45
|
+
*,
|
|
46
|
+
json_mode: bool,
|
|
47
|
+
token: str | None,
|
|
48
|
+
base_url: str | None,
|
|
49
|
+
project: str | None,
|
|
50
|
+
limit: int,
|
|
51
|
+
page: int,
|
|
52
|
+
) -> None:
|
|
53
|
+
self.json_mode = json_mode
|
|
54
|
+
self.token = token
|
|
55
|
+
self.base_url = base_url
|
|
56
|
+
self.project = project
|
|
57
|
+
self.limit = limit
|
|
58
|
+
self.page = page
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# Module-level singleton
|
|
62
|
+
ctx = Context()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Formatters for CLI output."""
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Generic formatters for dicts and lists."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from qodev_gitlab_cli.output import md_table
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def detail_table(data: Any, fields: list[tuple[str, str]], *, title: str | None = None) -> str:
|
|
11
|
+
"""Render a single record as a Field/Value markdown table.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
data: dict
|
|
15
|
+
fields: List of (label, key) — key supports dot notation.
|
|
16
|
+
title: Optional heading.
|
|
17
|
+
"""
|
|
18
|
+
d = data
|
|
19
|
+
|
|
20
|
+
lines: list[str] = []
|
|
21
|
+
if title:
|
|
22
|
+
lines.append(f"# {title}")
|
|
23
|
+
lines.append("")
|
|
24
|
+
|
|
25
|
+
lines.append("| Field | Value |")
|
|
26
|
+
lines.append("|-------|-------|")
|
|
27
|
+
for label, key in fields:
|
|
28
|
+
value = _get(d, key)
|
|
29
|
+
if value is None or value == "" or value == []:
|
|
30
|
+
continue
|
|
31
|
+
lines.append(f"| {label} | {_fmt(value)} |")
|
|
32
|
+
return "\n".join(lines)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def list_table(
|
|
36
|
+
items: list[Any],
|
|
37
|
+
columns: list[tuple[str, str]],
|
|
38
|
+
*,
|
|
39
|
+
title: str = "Results",
|
|
40
|
+
total: int = 0,
|
|
41
|
+
page: int = 1,
|
|
42
|
+
) -> str:
|
|
43
|
+
"""Render a list of records as a markdown table."""
|
|
44
|
+
if not items:
|
|
45
|
+
return f"# {title}\n\n_No results found._"
|
|
46
|
+
|
|
47
|
+
header = f"# {title}"
|
|
48
|
+
if total:
|
|
49
|
+
header += f" (page {page}, {total} total)"
|
|
50
|
+
|
|
51
|
+
rows: list[dict[str, str]] = []
|
|
52
|
+
for item in items:
|
|
53
|
+
d = item
|
|
54
|
+
row = {}
|
|
55
|
+
for _, key in columns:
|
|
56
|
+
row[key] = _fmt(_get(d, key))
|
|
57
|
+
rows.append(row)
|
|
58
|
+
|
|
59
|
+
return f"{header}\n\n{md_table(rows, columns)}"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _get(d: dict, key: str) -> Any:
|
|
63
|
+
"""Get a value from a dict, supporting dot notation."""
|
|
64
|
+
parts = key.split(".")
|
|
65
|
+
current: Any = d
|
|
66
|
+
for part in parts:
|
|
67
|
+
if isinstance(current, dict):
|
|
68
|
+
current = current.get(part)
|
|
69
|
+
else:
|
|
70
|
+
return None
|
|
71
|
+
if current is None:
|
|
72
|
+
return None
|
|
73
|
+
return current
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _fmt(value: Any) -> str:
|
|
77
|
+
"""Format a value for display in a markdown table cell."""
|
|
78
|
+
if value is None:
|
|
79
|
+
return ""
|
|
80
|
+
if isinstance(value, bool):
|
|
81
|
+
return "Yes" if value else "No"
|
|
82
|
+
if isinstance(value, list):
|
|
83
|
+
if not value:
|
|
84
|
+
return ""
|
|
85
|
+
# Extract names from list of user dicts (common GitLab pattern)
|
|
86
|
+
if value and isinstance(value[0], dict) and "name" in value[0]:
|
|
87
|
+
return ", ".join(v.get("name", "?") for v in value)
|
|
88
|
+
return ", ".join(str(v) for v in value)
|
|
89
|
+
if isinstance(value, dict):
|
|
90
|
+
# Extract name from user dict
|
|
91
|
+
if "name" in value:
|
|
92
|
+
return str(value["name"])
|
|
93
|
+
return str(value)
|
|
94
|
+
return str(value)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Issue formatters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from qodev_gitlab_cli.formatters.generic import detail_table, list_table
|
|
8
|
+
|
|
9
|
+
ISSUE_DETAIL_FIELDS = [
|
|
10
|
+
("IID", "iid"),
|
|
11
|
+
("Title", "title"),
|
|
12
|
+
("State", "state"),
|
|
13
|
+
("Author", "author.name"),
|
|
14
|
+
("Assignees", "assignees"),
|
|
15
|
+
("Labels", "labels"),
|
|
16
|
+
("Milestone", "milestone.title"),
|
|
17
|
+
("URL", "web_url"),
|
|
18
|
+
("Created", "created_at"),
|
|
19
|
+
("Updated", "updated_at"),
|
|
20
|
+
("Closed", "closed_at"),
|
|
21
|
+
("Description", "description"),
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
ISSUE_LIST_COLUMNS = [
|
|
25
|
+
("IID", "iid"),
|
|
26
|
+
("Title", "title"),
|
|
27
|
+
("Author", "author.name"),
|
|
28
|
+
("Labels", "labels"),
|
|
29
|
+
("State", "state"),
|
|
30
|
+
("Updated", "updated_at"),
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
NOTE_LIST_COLUMNS = [
|
|
34
|
+
("ID", "id"),
|
|
35
|
+
("Author", "author.name"),
|
|
36
|
+
("Body", "_body"),
|
|
37
|
+
("Created", "created_at"),
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def format_issue_detail(data: Any) -> str:
|
|
42
|
+
iid = data.get("iid", "?") if isinstance(data, dict) else "?"
|
|
43
|
+
return detail_table(data, ISSUE_DETAIL_FIELDS, title=f"Issue #{iid}")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def format_issue_list(items: list[Any], *, total: int = 0, page: int = 1) -> str:
|
|
47
|
+
return list_table(items, ISSUE_LIST_COLUMNS, title="Issues", total=total, page=page)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def format_note_list(items: list[Any], *, total: int = 0, page: int = 1) -> str:
|
|
51
|
+
rows = []
|
|
52
|
+
for n in items:
|
|
53
|
+
body = n.get("body", "")
|
|
54
|
+
rows.append(
|
|
55
|
+
{
|
|
56
|
+
**n,
|
|
57
|
+
"_body": (body[:80] + "...") if len(body) > 80 else body,
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
return list_table(rows, NOTE_LIST_COLUMNS, title="Notes", total=total, page=page)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Job formatters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from qodev_gitlab_cli.formatters.generic import detail_table, list_table
|
|
8
|
+
|
|
9
|
+
JOB_DETAIL_FIELDS = [
|
|
10
|
+
("ID", "id"),
|
|
11
|
+
("Name", "name"),
|
|
12
|
+
("Status", "status"),
|
|
13
|
+
("Stage", "stage"),
|
|
14
|
+
("Ref", "ref"),
|
|
15
|
+
("Pipeline", "pipeline.id"),
|
|
16
|
+
("URL", "web_url"),
|
|
17
|
+
("Duration", "duration"),
|
|
18
|
+
("Created", "created_at"),
|
|
19
|
+
("Started", "started_at"),
|
|
20
|
+
("Finished", "finished_at"),
|
|
21
|
+
("Runner", "runner.description"),
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
JOB_LIST_COLUMNS = [
|
|
25
|
+
("ID", "id"),
|
|
26
|
+
("Name", "name"),
|
|
27
|
+
("Stage", "stage"),
|
|
28
|
+
("Status", "status"),
|
|
29
|
+
("Duration", "duration"),
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def format_job_detail(data: Any) -> str:
|
|
34
|
+
name = data.get("name", "?") if isinstance(data, dict) else "?"
|
|
35
|
+
return detail_table(data, JOB_DETAIL_FIELDS, title=f"Job: {name}")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def format_job_list(items: list[Any], *, total: int = 0, page: int = 1) -> str:
|
|
39
|
+
return list_table(items, JOB_LIST_COLUMNS, title="Jobs", total=total, page=page)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Merge request formatters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from qodev_gitlab_cli.formatters.generic import detail_table, list_table
|
|
8
|
+
|
|
9
|
+
MR_DETAIL_FIELDS = [
|
|
10
|
+
("IID", "iid"),
|
|
11
|
+
("Title", "title"),
|
|
12
|
+
("State", "state"),
|
|
13
|
+
("Author", "author.name"),
|
|
14
|
+
("Source", "source_branch"),
|
|
15
|
+
("Target", "target_branch"),
|
|
16
|
+
("URL", "web_url"),
|
|
17
|
+
("Labels", "labels"),
|
|
18
|
+
("Assignees", "assignees"),
|
|
19
|
+
("Reviewers", "reviewers"),
|
|
20
|
+
("Created", "created_at"),
|
|
21
|
+
("Updated", "updated_at"),
|
|
22
|
+
("Merged By", "merged_by.name"),
|
|
23
|
+
("Merge Status", "merge_status"),
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
MR_LIST_COLUMNS = [
|
|
27
|
+
("IID", "iid"),
|
|
28
|
+
("Title", "title"),
|
|
29
|
+
("Author", "author.name"),
|
|
30
|
+
("Source", "source_branch"),
|
|
31
|
+
("Target", "target_branch"),
|
|
32
|
+
("State", "state"),
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
DISCUSSION_LIST_COLUMNS = [
|
|
36
|
+
("ID", "id"),
|
|
37
|
+
("Author", "_author"),
|
|
38
|
+
("Resolved", "_resolved"),
|
|
39
|
+
("Body", "_body"),
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
COMMIT_LIST_COLUMNS = [
|
|
43
|
+
("Short ID", "short_id"),
|
|
44
|
+
("Title", "title"),
|
|
45
|
+
("Author", "author_name"),
|
|
46
|
+
("Date", "created_at"),
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def format_mr_detail(data: Any) -> str:
|
|
51
|
+
iid = data.get("iid", "?") if isinstance(data, dict) else "?"
|
|
52
|
+
return detail_table(data, MR_DETAIL_FIELDS, title=f"Merge Request !{iid}")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def format_mr_list(items: list[Any], *, total: int = 0, page: int = 1) -> str:
|
|
56
|
+
return list_table(items, MR_LIST_COLUMNS, title="Merge Requests", total=total, page=page)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def format_discussion_list(items: list[Any], *, total: int = 0, page: int = 1) -> str:
|
|
60
|
+
"""Format discussions with first note content."""
|
|
61
|
+
rows = []
|
|
62
|
+
for d in items:
|
|
63
|
+
notes = d.get("notes", [])
|
|
64
|
+
first_note = notes[0] if notes else {}
|
|
65
|
+
rows.append(
|
|
66
|
+
{
|
|
67
|
+
"id": d.get("id", ""),
|
|
68
|
+
"_author": first_note.get("author", {}).get("name", ""),
|
|
69
|
+
"_resolved": "Yes"
|
|
70
|
+
if d.get("individual_note") is False
|
|
71
|
+
and all(n.get("resolved", False) for n in notes if n.get("resolvable"))
|
|
72
|
+
else "No"
|
|
73
|
+
if any(n.get("resolvable") for n in notes)
|
|
74
|
+
else "-",
|
|
75
|
+
"_body": (first_note.get("body", "")[:80] + "...")
|
|
76
|
+
if len(first_note.get("body", "")) > 80
|
|
77
|
+
else first_note.get("body", ""),
|
|
78
|
+
}
|
|
79
|
+
)
|
|
80
|
+
return list_table(rows, DISCUSSION_LIST_COLUMNS, title="Discussions", total=total, page=page)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def format_commit_list(items: list[Any], *, total: int = 0, page: int = 1) -> str:
|
|
84
|
+
return list_table(items, COMMIT_LIST_COLUMNS, title="Commits", total=total, page=page)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def format_approval_detail(data: Any) -> str:
|
|
88
|
+
lines = ["# Approval Status"]
|
|
89
|
+
approved_by = data.get("approved_by", [])
|
|
90
|
+
if approved_by:
|
|
91
|
+
lines.append(f"\nApproved by: {', '.join(a.get('user', {}).get('name', '?') for a in approved_by)}")
|
|
92
|
+
else:
|
|
93
|
+
lines.append("\n_Not yet approved._")
|
|
94
|
+
|
|
95
|
+
rules = data.get("approval_rules_left", [])
|
|
96
|
+
if rules:
|
|
97
|
+
lines.append("\n## Remaining Rules")
|
|
98
|
+
for rule in rules:
|
|
99
|
+
lines.append(f"- {rule.get('name', '?')} (needs {rule.get('approvals_required', '?')} approvals)")
|
|
100
|
+
|
|
101
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Pipeline formatters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from qodev_gitlab_cli.formatters.generic import detail_table, list_table
|
|
8
|
+
|
|
9
|
+
PIPELINE_DETAIL_FIELDS = [
|
|
10
|
+
("ID", "id"),
|
|
11
|
+
("Status", "status"),
|
|
12
|
+
("Ref", "ref"),
|
|
13
|
+
("SHA", "sha"),
|
|
14
|
+
("Source", "source"),
|
|
15
|
+
("URL", "web_url"),
|
|
16
|
+
("Created", "created_at"),
|
|
17
|
+
("Updated", "updated_at"),
|
|
18
|
+
("Duration", "duration"),
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
PIPELINE_LIST_COLUMNS = [
|
|
22
|
+
("ID", "id"),
|
|
23
|
+
("Status", "status"),
|
|
24
|
+
("Ref", "ref"),
|
|
25
|
+
("Source", "source"),
|
|
26
|
+
("Created", "created_at"),
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def format_pipeline_detail(data: Any) -> str:
|
|
31
|
+
pid = data.get("id", "?") if isinstance(data, dict) else "?"
|
|
32
|
+
return detail_table(data, PIPELINE_DETAIL_FIELDS, title=f"Pipeline #{pid}")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def format_pipeline_list(items: list[Any], *, total: int = 0, page: int = 1) -> str:
|
|
36
|
+
return list_table(items, PIPELINE_LIST_COLUMNS, title="Pipelines", total=total, page=page)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def format_wait_result(data: Any) -> str:
|
|
40
|
+
lines = [f"# Pipeline #{data.get('pipeline_id', '?')}"]
|
|
41
|
+
lines.append("")
|
|
42
|
+
lines.append(f"**Status:** {data.get('final_status', '?')}")
|
|
43
|
+
lines.append(f"**Duration:** {data.get('total_duration', '?')}s")
|
|
44
|
+
lines.append(f"**Checks:** {data.get('checks_performed', '?')}")
|
|
45
|
+
|
|
46
|
+
if data.get("pipeline_url"):
|
|
47
|
+
lines.append(f"**URL:** {data['pipeline_url']}")
|
|
48
|
+
|
|
49
|
+
summary = data.get("job_summary")
|
|
50
|
+
if summary:
|
|
51
|
+
lines.append("")
|
|
52
|
+
lines.append(
|
|
53
|
+
f"**Jobs:** {summary.get('total', 0)} total, {summary.get('success', 0)} success, {summary.get('failed', 0)} failed"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
failed_jobs = data.get("failed_jobs", [])
|
|
57
|
+
if failed_jobs:
|
|
58
|
+
lines.append("")
|
|
59
|
+
lines.append("## Failed Jobs")
|
|
60
|
+
for job in failed_jobs:
|
|
61
|
+
lines.append(f"\n### {job.get('name', '?')} (#{job.get('id', '?')})")
|
|
62
|
+
if job.get("web_url"):
|
|
63
|
+
lines.append(f"URL: {job['web_url']}")
|
|
64
|
+
if job.get("last_log_lines"):
|
|
65
|
+
lines.append(f"\n```\n{job['last_log_lines']}\n```")
|
|
66
|
+
|
|
67
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Project formatters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from qodev_gitlab_cli.formatters.generic import detail_table, list_table
|
|
8
|
+
|
|
9
|
+
PROJECT_DETAIL_FIELDS = [
|
|
10
|
+
("ID", "id"),
|
|
11
|
+
("Name", "name"),
|
|
12
|
+
("Path", "path_with_namespace"),
|
|
13
|
+
("Description", "description"),
|
|
14
|
+
("URL", "web_url"),
|
|
15
|
+
("Default Branch", "default_branch"),
|
|
16
|
+
("Visibility", "visibility"),
|
|
17
|
+
("Stars", "star_count"),
|
|
18
|
+
("Forks", "forks_count"),
|
|
19
|
+
("Open Issues", "open_issues_count"),
|
|
20
|
+
("Created", "created_at"),
|
|
21
|
+
("Updated", "last_activity_at"),
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
PROJECT_LIST_COLUMNS = [
|
|
25
|
+
("ID", "id"),
|
|
26
|
+
("Name", "name"),
|
|
27
|
+
("Path", "path_with_namespace"),
|
|
28
|
+
("Stars", "star_count"),
|
|
29
|
+
("Updated", "last_activity_at"),
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def format_project_detail(data: Any) -> str:
|
|
34
|
+
name = data.get("name", "Unknown") if isinstance(data, dict) else "Unknown"
|
|
35
|
+
return detail_table(data, PROJECT_DETAIL_FIELDS, title=f"Project: {name}")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def format_project_list(items: list[Any], *, total: int = 0, page: int = 1) -> str:
|
|
39
|
+
return list_table(items, PROJECT_LIST_COLUMNS, title="Projects", total=total, page=page)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Release formatters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from qodev_gitlab_cli.formatters.generic import detail_table, list_table
|
|
8
|
+
|
|
9
|
+
RELEASE_DETAIL_FIELDS = [
|
|
10
|
+
("Tag", "tag_name"),
|
|
11
|
+
("Name", "name"),
|
|
12
|
+
("Description", "description"),
|
|
13
|
+
("Author", "author.name"),
|
|
14
|
+
("Created", "created_at"),
|
|
15
|
+
("Released", "released_at"),
|
|
16
|
+
("Commit", "commit.short_id"),
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
RELEASE_LIST_COLUMNS = [
|
|
20
|
+
("Tag", "tag_name"),
|
|
21
|
+
("Name", "name"),
|
|
22
|
+
("Author", "author.name"),
|
|
23
|
+
("Released", "released_at"),
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def format_release_detail(data: Any) -> str:
|
|
28
|
+
tag = data.get("tag_name", "?") if isinstance(data, dict) else "?"
|
|
29
|
+
return detail_table(data, RELEASE_DETAIL_FIELDS, title=f"Release: {tag}")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def format_release_list(items: list[Any], *, total: int = 0, page: int = 1) -> str:
|
|
33
|
+
return list_table(items, RELEASE_LIST_COLUMNS, title="Releases", total=total, page=page)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""CI/CD variable formatters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from qodev_gitlab_cli.formatters.generic import detail_table, list_table
|
|
8
|
+
|
|
9
|
+
VARIABLE_DETAIL_FIELDS = [
|
|
10
|
+
("Key", "key"),
|
|
11
|
+
("Type", "variable_type"),
|
|
12
|
+
("Protected", "protected"),
|
|
13
|
+
("Masked", "masked"),
|
|
14
|
+
("Raw", "raw"),
|
|
15
|
+
("Environment", "environment_scope"),
|
|
16
|
+
("Description", "description"),
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
VARIABLE_LIST_COLUMNS = [
|
|
20
|
+
("Key", "key"),
|
|
21
|
+
("Type", "variable_type"),
|
|
22
|
+
("Protected", "protected"),
|
|
23
|
+
("Masked", "masked"),
|
|
24
|
+
("Environment", "environment_scope"),
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def format_variable_detail(data: Any) -> str:
|
|
29
|
+
key = data.get("key", "?") if isinstance(data, dict) else "?"
|
|
30
|
+
return detail_table(data, VARIABLE_DETAIL_FIELDS, title=f"Variable: {key}")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def format_variable_list(items: list[Any], *, total: int = 0, page: int = 1) -> str:
|
|
34
|
+
return list_table(items, VARIABLE_LIST_COLUMNS, title="CI/CD Variables", total=total, page=page)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Output formatting — JSON and Markdown modes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.markdown import Markdown
|
|
12
|
+
|
|
13
|
+
from qodev_gitlab_cli.context import Context
|
|
14
|
+
|
|
15
|
+
console = Console(stderr=True)
|
|
16
|
+
stdout_console = Console()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def serialize(obj: Any) -> Any:
|
|
20
|
+
"""Convert an object to a JSON-serializable structure."""
|
|
21
|
+
if isinstance(obj, datetime):
|
|
22
|
+
return obj.isoformat()
|
|
23
|
+
if isinstance(obj, list):
|
|
24
|
+
return [serialize(item) for item in obj]
|
|
25
|
+
if isinstance(obj, dict):
|
|
26
|
+
return {k: serialize(v) for k, v in obj.items()}
|
|
27
|
+
return obj
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def output_json(data: Any) -> None:
|
|
31
|
+
print(json.dumps(serialize(data), indent=2, default=str))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def output_markdown(text: str) -> None:
|
|
35
|
+
stdout_console.print(Markdown(text))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def output(data: Any, *, ctx: Context, format_fn: Any = None) -> None:
|
|
39
|
+
"""Route output through the correct formatter."""
|
|
40
|
+
if ctx.json_mode:
|
|
41
|
+
output_json(data)
|
|
42
|
+
else:
|
|
43
|
+
md = format_fn(data) if format_fn else generic_markdown(data)
|
|
44
|
+
output_markdown(md)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def output_list(
|
|
48
|
+
*,
|
|
49
|
+
items: list[Any],
|
|
50
|
+
total: int | None = None,
|
|
51
|
+
page: int = 1,
|
|
52
|
+
limit: int = 25,
|
|
53
|
+
ctx: Context,
|
|
54
|
+
format_fn: Any,
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Output a paginated list."""
|
|
57
|
+
if total is None:
|
|
58
|
+
total = len(items)
|
|
59
|
+
|
|
60
|
+
if ctx.json_mode:
|
|
61
|
+
output_json({"items": serialize(items), "total": total, "page": page, "limit": limit})
|
|
62
|
+
else:
|
|
63
|
+
md = format_fn(items, total=total, page=page)
|
|
64
|
+
if total > page * limit:
|
|
65
|
+
md += f"\n\n*Showing {len(items)} of {total} results. Use `--page {page + 1}` for next page.*"
|
|
66
|
+
elif total > 0:
|
|
67
|
+
md += f"\n\n*Showing {len(items)} of {total} results.*"
|
|
68
|
+
output_markdown(md)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def error(message: str, *, ctx: Context | None = None, code: str = "error", exit_code: int = 1) -> None:
|
|
72
|
+
"""Output an error and exit."""
|
|
73
|
+
if ctx and ctx.json_mode:
|
|
74
|
+
print(json.dumps({"error": message, "code": code}))
|
|
75
|
+
else:
|
|
76
|
+
console.print(f"[red]Error:[/red] {message}")
|
|
77
|
+
sys.exit(exit_code)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
# Generic markdown helpers
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def generic_markdown(data: Any) -> str:
|
|
86
|
+
if isinstance(data, dict):
|
|
87
|
+
return _dict_to_md(data)
|
|
88
|
+
if isinstance(data, list):
|
|
89
|
+
if not data:
|
|
90
|
+
return "_No results._"
|
|
91
|
+
return "\n\n".join(generic_markdown(item) for item in data)
|
|
92
|
+
return str(data)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _dict_to_md(d: dict) -> str:
|
|
96
|
+
lines = ["| Field | Value |", "|-------|-------|"]
|
|
97
|
+
for key, value in d.items():
|
|
98
|
+
if isinstance(value, (dict, list)):
|
|
99
|
+
continue
|
|
100
|
+
display_key = key.replace("_", " ").title()
|
|
101
|
+
lines.append(f"| {display_key} | {value} |")
|
|
102
|
+
return "\n".join(lines)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def md_table(rows: list[dict[str, str]], headers: list[tuple[str, str]]) -> str:
|
|
106
|
+
"""Build a markdown table from rows."""
|
|
107
|
+
if not rows:
|
|
108
|
+
return "_No results._"
|
|
109
|
+
|
|
110
|
+
header_line = "| " + " | ".join(h for h, _ in headers) + " |"
|
|
111
|
+
sep_line = "| " + " | ".join("---" for _ in headers) + " |"
|
|
112
|
+
lines = [header_line, sep_line]
|
|
113
|
+
for row in rows:
|
|
114
|
+
cells = [str(row.get(key, "")) for _, key in headers]
|
|
115
|
+
lines.append("| " + " | ".join(cells) + " |")
|
|
116
|
+
return "\n".join(lines)
|