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