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.
@@ -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)