github-issue-list 0.0.1__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,92 @@
1
+ Metadata-Version: 2.4
2
+ Name: github-issue-list
3
+ Version: 0.0.1
4
+ Summary: List open GitHub issues assigned to a user in an organization.
5
+ Author: Kaizten Analytics
6
+ License-Expression: LicenseRef-Proprietary
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Programming Language :: Python :: 3 :: Only
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+
12
+ # GitHub Issue List
13
+
14
+ `github-issue-list` uses the authenticated GitHub CLI account to list open issues assigned to a user in an organization's non-archived repositories.
15
+
16
+ Issues are printed in a table sorted globally from the oldest creation time to the most recent. Issue titles are normalized to one line and limited to 30 characters.
17
+
18
+ ## Requirements
19
+
20
+ - Python 3.10 or newer
21
+ - GitHub CLI (`gh`)
22
+ - An authenticated GitHub CLI session:
23
+
24
+ ```bash
25
+ gh auth login
26
+ ```
27
+
28
+ ## Usage
29
+
30
+ After installing the package, run:
31
+
32
+ ```bash
33
+ github-issue-list
34
+ ```
35
+
36
+ The organization defaults to `kaizten`, and the username defaults to the active `gh` account. Override either value with named arguments:
37
+
38
+ ```bash
39
+ github-issue-list --organization octo --username monalisa
40
+ ```
41
+
42
+ From a source checkout, the wrapper exposes the same interface:
43
+
44
+ ```bash
45
+ ./github-issue-list.py --organization octo --username monalisa
46
+ ```
47
+
48
+ ```text
49
+ usage: github-issue-list [-h] [--organization ORGANIZATION] [--username USERNAME]
50
+
51
+ List open GitHub issues assigned to a user in an organization.
52
+
53
+ options:
54
+ -h, --help show this help message and exit
55
+ --organization ORGANIZATION
56
+ GitHub organization. Default: kaizten
57
+ --username USERNAME GitHub assignee. Defaults to the authenticated gh user.
58
+ ```
59
+
60
+ The table contains the full repository name, issue number, truncated title, UTC creation time, and elapsed time since creation. The search returns at most 1,000 issues, matching GitHub's issue-search result limit.
61
+
62
+ ## Development
63
+
64
+ Run the unit tests:
65
+
66
+ ```bash
67
+ python3 -m unittest discover -s tests -v
68
+ ```
69
+
70
+ Build and validate the wheel and source distribution:
71
+
72
+ ```bash
73
+ ./build-package.sh
74
+ ```
75
+
76
+ Artifacts are written under `dist/` and checked with Twine.
77
+
78
+ Publish previously built artifacts to PyPI:
79
+
80
+ ```bash
81
+ ./publish-pypi.sh
82
+ ```
83
+
84
+ If PyPI reports that the version already exists, increment the version in `pyproject.toml`, rebuild, and publish again.
85
+
86
+ Install the package in a virtual environment:
87
+
88
+ ```bash
89
+ python3 -m venv .venv
90
+ source .venv/bin/activate
91
+ python3 -m pip install .
92
+ ```
@@ -0,0 +1,6 @@
1
+ github_issue_list.py,sha256=zuHxskC65-RUp7kB7aXNoVj0ord6F4KO5lgOQ7-HOWs,7948
2
+ github_issue_list-0.0.1.dist-info/METADATA,sha256=UIUBh1fKgFumYD5Lg3m-Fhez9X2gJMeMBlPb4B5LRHk,2444
3
+ github_issue_list-0.0.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
4
+ github_issue_list-0.0.1.dist-info/entry_points.txt,sha256=nwPkRnbyYop3woGXD0ZK18aR4HDVmeDmpaZPlxnwDpU,61
5
+ github_issue_list-0.0.1.dist-info/top_level.txt,sha256=Vi7KdWU5HNKt1jGQCOU94I_CU98ySLou_75x-yqVHeo,18
6
+ github_issue_list-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ github-issue-list = github_issue_list:main
@@ -0,0 +1 @@
1
+ github_issue_list
github_issue_list.py ADDED
@@ -0,0 +1,246 @@
1
+ #!/usr/bin/env python3
2
+ """List open GitHub issues assigned to a user in an organization."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import re
9
+ import shutil
10
+ import subprocess
11
+ import sys
12
+ from dataclasses import dataclass
13
+ from datetime import datetime, timezone
14
+ from typing import Any
15
+
16
+
17
+ DEFAULT_ORGANIZATION = "kaizten"
18
+ SEARCH_LIMIT = 1000
19
+ TITLE_LIMIT = 30
20
+ NAME_RE = re.compile(r"^[A-Za-z0-9_.-]+$")
21
+ REPOSITORY_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
22
+
23
+
24
+ class IssueListError(Exception):
25
+ """Expected user-facing error."""
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class Issue:
30
+ repository: str
31
+ number: int
32
+ title: str
33
+ created_at: datetime
34
+
35
+
36
+ def utc_now() -> datetime:
37
+ return datetime.now(timezone.utc).replace(microsecond=0)
38
+
39
+
40
+ def parse_github_datetime(value: str) -> datetime:
41
+ try:
42
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
43
+ except ValueError as exc:
44
+ raise IssueListError(f"Invalid GitHub datetime: {value}") from exc
45
+ if parsed.tzinfo is None:
46
+ raise IssueListError(f"Invalid GitHub datetime: {value}")
47
+ return parsed.astimezone(timezone.utc)
48
+
49
+
50
+ def format_github_datetime(value: datetime) -> str:
51
+ return value.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
52
+
53
+
54
+ def elapsed_time(created_at: datetime, now: datetime) -> str:
55
+ seconds = max(0, int((now - created_at).total_seconds()))
56
+ days, seconds = divmod(seconds, 24 * 60 * 60)
57
+ hours, seconds = divmod(seconds, 60 * 60)
58
+ minutes = seconds // 60
59
+ return f"{days}d {hours}h {minutes}m"
60
+
61
+
62
+ def validate_name(value: str, label: str) -> str:
63
+ if not NAME_RE.fullmatch(value):
64
+ raise IssueListError(f"{label} must contain only letters, numbers, underscores, dots, or hyphens.")
65
+ return value
66
+
67
+
68
+ def validate_repository(value: str) -> str:
69
+ if not REPOSITORY_RE.fullmatch(value):
70
+ raise IssueListError(f"Invalid repository name returned by GitHub: {value}")
71
+ return value
72
+
73
+
74
+ def require_gh() -> None:
75
+ if shutil.which("gh") is None:
76
+ raise IssueListError("GitHub CLI 'gh' is required but was not found. Install it from https://cli.github.com/.")
77
+ result = subprocess.run(
78
+ ["gh", "auth", "status"],
79
+ stdout=subprocess.DEVNULL,
80
+ stderr=subprocess.DEVNULL,
81
+ text=True,
82
+ )
83
+ if result.returncode != 0:
84
+ raise IssueListError("GitHub CLI is not authenticated. Run: gh auth login")
85
+
86
+
87
+ def run_gh_json(args: list[str]) -> Any:
88
+ result = subprocess.run(["gh", *args], capture_output=True, text=True)
89
+ if result.returncode != 0:
90
+ details = result.stderr.strip() or result.stdout.strip() or f"exit code {result.returncode}"
91
+ raise IssueListError(f"gh command failed: gh {' '.join(args)}\n{details}")
92
+ try:
93
+ return json.loads(result.stdout or "null")
94
+ except json.JSONDecodeError as exc:
95
+ raise IssueListError(f"gh returned invalid JSON for: gh {' '.join(args)}") from exc
96
+
97
+
98
+ def current_username() -> str:
99
+ user = run_gh_json(["api", "user"])
100
+ if not isinstance(user, dict) or not isinstance(user.get("login"), str):
101
+ raise IssueListError("Unable to read authenticated GitHub username.")
102
+ return validate_name(user["login"], "GitHub username")
103
+
104
+
105
+ def repository_name(value: Any) -> str:
106
+ if isinstance(value, str):
107
+ return validate_repository(value)
108
+ if isinstance(value, dict) and isinstance(value.get("nameWithOwner"), str):
109
+ return validate_repository(value["nameWithOwner"])
110
+ raise IssueListError("GitHub returned an issue without a valid repository name.")
111
+
112
+
113
+ def normalize_issue(value: Any) -> Issue | None:
114
+ if not isinstance(value, dict):
115
+ raise IssueListError("GitHub returned an invalid issue entry.")
116
+ if value.get("isPullRequest"):
117
+ return None
118
+
119
+ number = value.get("number")
120
+ title = value.get("title")
121
+ created_at = value.get("createdAt")
122
+ if not isinstance(number, int) or isinstance(number, bool):
123
+ raise IssueListError("GitHub returned an issue without a numeric issue number.")
124
+ if not isinstance(title, str):
125
+ raise IssueListError(f"GitHub returned issue #{number} without a valid title.")
126
+ if not isinstance(created_at, str):
127
+ raise IssueListError(f"GitHub returned issue #{number} without a valid creation time.")
128
+
129
+ return Issue(
130
+ repository=repository_name(value.get("repository")),
131
+ number=number,
132
+ title=title,
133
+ created_at=parse_github_datetime(created_at),
134
+ )
135
+
136
+
137
+ def fetch_issues(organization: str, username: str) -> list[Issue]:
138
+ data = run_gh_json(
139
+ [
140
+ "search",
141
+ "issues",
142
+ "--owner",
143
+ organization,
144
+ "--assignee",
145
+ username,
146
+ "--state",
147
+ "open",
148
+ "--archived=false",
149
+ "--sort",
150
+ "created",
151
+ "--order",
152
+ "asc",
153
+ "--limit",
154
+ str(SEARCH_LIMIT),
155
+ "--json",
156
+ "repository,number,title,createdAt,isPullRequest",
157
+ ]
158
+ )
159
+ if not isinstance(data, list):
160
+ raise IssueListError("Unexpected issue search response from gh.")
161
+
162
+ issues = []
163
+ for value in data:
164
+ issue = normalize_issue(value)
165
+ if issue is not None:
166
+ issues.append(issue)
167
+ return sorted(issues, key=lambda issue: (issue.created_at, issue.repository, issue.number))
168
+
169
+
170
+ def truncate_title(title: str, limit: int = TITLE_LIMIT) -> str:
171
+ clean = " ".join(title.split())
172
+ if len(clean) <= limit:
173
+ return clean
174
+ if limit <= 3:
175
+ return clean[:limit]
176
+ return clean[: limit - 3] + "..."
177
+
178
+
179
+ def table(headers: list[str], rows: list[list[str]]) -> str:
180
+ widths = [len(header) for header in headers]
181
+ for row in rows:
182
+ for index, cell in enumerate(row):
183
+ widths[index] = max(widths[index], len(cell))
184
+
185
+ separator = ["-" * width for width in widths]
186
+ lines = [
187
+ "| " + " | ".join(header.ljust(widths[index]) for index, header in enumerate(headers)) + " |",
188
+ "| " + " | ".join(separator[index] for index in range(len(headers))) + " |",
189
+ ]
190
+ for row in rows:
191
+ lines.append("| " + " | ".join(cell.ljust(widths[index]) for index, cell in enumerate(row)) + " |")
192
+ return "\n".join(lines)
193
+
194
+
195
+ def render_issue_table(issues: list[Issue], now: datetime) -> str:
196
+ if not issues:
197
+ return "(none found)"
198
+ rows = [
199
+ [
200
+ issue.repository,
201
+ f"#{issue.number}",
202
+ truncate_title(issue.title),
203
+ format_github_datetime(issue.created_at),
204
+ elapsed_time(issue.created_at, now),
205
+ ]
206
+ for issue in issues
207
+ ]
208
+ return table(
209
+ ["Repository", "Issue number", "Issue title", "Creation time", "Time since creation"],
210
+ rows,
211
+ )
212
+
213
+
214
+ def build_parser() -> argparse.ArgumentParser:
215
+ parser = argparse.ArgumentParser(description="List open GitHub issues assigned to a user in an organization.")
216
+ parser.add_argument(
217
+ "--organization",
218
+ default=DEFAULT_ORGANIZATION,
219
+ help=f"GitHub organization. Default: {DEFAULT_ORGANIZATION}",
220
+ )
221
+ parser.add_argument("--username", help="GitHub assignee. Defaults to the authenticated gh user.")
222
+ return parser
223
+
224
+
225
+ def run(argv: list[str]) -> int:
226
+ args = build_parser().parse_args(argv)
227
+ organization = validate_name(args.organization, "Organization")
228
+ require_gh()
229
+ username = validate_name(args.username, "Username") if args.username else current_username()
230
+ print(render_issue_table(fetch_issues(organization, username), utc_now()))
231
+ return 0
232
+
233
+
234
+ def main() -> int:
235
+ try:
236
+ return run(sys.argv[1:])
237
+ except IssueListError as exc:
238
+ print(f"Error: {exc}", file=sys.stderr)
239
+ return 1
240
+ except OSError as exc:
241
+ print(f"Error: {exc}", file=sys.stderr)
242
+ return 1
243
+
244
+
245
+ if __name__ == "__main__":
246
+ raise SystemExit(main())