github-issue-list 0.0.1__tar.gz
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.
- github_issue_list-0.0.1/PKG-INFO +92 -0
- github_issue_list-0.0.1/README.md +81 -0
- github_issue_list-0.0.1/pyproject.toml +23 -0
- github_issue_list-0.0.1/setup.cfg +4 -0
- github_issue_list-0.0.1/src/github_issue_list.egg-info/PKG-INFO +92 -0
- github_issue_list-0.0.1/src/github_issue_list.egg-info/SOURCES.txt +9 -0
- github_issue_list-0.0.1/src/github_issue_list.egg-info/dependency_links.txt +1 -0
- github_issue_list-0.0.1/src/github_issue_list.egg-info/entry_points.txt +2 -0
- github_issue_list-0.0.1/src/github_issue_list.egg-info/top_level.txt +1 -0
- github_issue_list-0.0.1/src/github_issue_list.py +246 -0
- github_issue_list-0.0.1/tests/test_github_issue_list.py +181 -0
|
@@ -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,81 @@
|
|
|
1
|
+
# GitHub Issue List
|
|
2
|
+
|
|
3
|
+
`github-issue-list` uses the authenticated GitHub CLI account to list open issues assigned to a user in an organization's non-archived repositories.
|
|
4
|
+
|
|
5
|
+
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.
|
|
6
|
+
|
|
7
|
+
## Requirements
|
|
8
|
+
|
|
9
|
+
- Python 3.10 or newer
|
|
10
|
+
- GitHub CLI (`gh`)
|
|
11
|
+
- An authenticated GitHub CLI session:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
gh auth login
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
After installing the package, run:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
github-issue-list
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The organization defaults to `kaizten`, and the username defaults to the active `gh` account. Override either value with named arguments:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
github-issue-list --organization octo --username monalisa
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
From a source checkout, the wrapper exposes the same interface:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
./github-issue-list.py --organization octo --username monalisa
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
```text
|
|
38
|
+
usage: github-issue-list [-h] [--organization ORGANIZATION] [--username USERNAME]
|
|
39
|
+
|
|
40
|
+
List open GitHub issues assigned to a user in an organization.
|
|
41
|
+
|
|
42
|
+
options:
|
|
43
|
+
-h, --help show this help message and exit
|
|
44
|
+
--organization ORGANIZATION
|
|
45
|
+
GitHub organization. Default: kaizten
|
|
46
|
+
--username USERNAME GitHub assignee. Defaults to the authenticated gh user.
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
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.
|
|
50
|
+
|
|
51
|
+
## Development
|
|
52
|
+
|
|
53
|
+
Run the unit tests:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
python3 -m unittest discover -s tests -v
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Build and validate the wheel and source distribution:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
./build-package.sh
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Artifacts are written under `dist/` and checked with Twine.
|
|
66
|
+
|
|
67
|
+
Publish previously built artifacts to PyPI:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
./publish-pypi.sh
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
If PyPI reports that the version already exists, increment the version in `pyproject.toml`, rebuild, and publish again.
|
|
74
|
+
|
|
75
|
+
Install the package in a virtual environment:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
python3 -m venv .venv
|
|
79
|
+
source .venv/bin/activate
|
|
80
|
+
python3 -m pip install .
|
|
81
|
+
```
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=69", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "github-issue-list"
|
|
7
|
+
version = "0.0.1"
|
|
8
|
+
description = "List open GitHub issues assigned to a user in an organization."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "LicenseRef-Proprietary"
|
|
12
|
+
authors = [{ name = "Kaizten Analytics" }]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.scripts]
|
|
19
|
+
github-issue-list = "github_issue_list:main"
|
|
20
|
+
|
|
21
|
+
[tool.setuptools]
|
|
22
|
+
package-dir = { "" = "src" }
|
|
23
|
+
py-modules = ["github_issue_list"]
|
|
@@ -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,9 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/github_issue_list.py
|
|
4
|
+
src/github_issue_list.egg-info/PKG-INFO
|
|
5
|
+
src/github_issue_list.egg-info/SOURCES.txt
|
|
6
|
+
src/github_issue_list.egg-info/dependency_links.txt
|
|
7
|
+
src/github_issue_list.egg-info/entry_points.txt
|
|
8
|
+
src/github_issue_list.egg-info/top_level.txt
|
|
9
|
+
tests/test_github_issue_list.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
github_issue_list
|
|
@@ -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())
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import importlib.util
|
|
2
|
+
import io
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
import unittest
|
|
6
|
+
from contextlib import redirect_stderr, redirect_stdout
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from unittest.mock import patch
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
MODULE_PATH = Path(__file__).parents[1] / "src" / "github_issue_list.py"
|
|
13
|
+
PYPROJECT_PATH = Path(__file__).parents[1] / "pyproject.toml"
|
|
14
|
+
SPEC = importlib.util.spec_from_file_location("github_issue_list", MODULE_PATH)
|
|
15
|
+
issue_list = importlib.util.module_from_spec(SPEC)
|
|
16
|
+
assert SPEC.loader is not None
|
|
17
|
+
sys.modules[SPEC.name] = issue_list
|
|
18
|
+
SPEC.loader.exec_module(issue_list)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class GithubIssueListTests(unittest.TestCase):
|
|
22
|
+
def test_pyproject_installs_command_line_entry_point(self):
|
|
23
|
+
try:
|
|
24
|
+
import tomllib
|
|
25
|
+
except ModuleNotFoundError: # pragma: no cover - Python 3.10 fallback.
|
|
26
|
+
self.skipTest("tomllib is not available")
|
|
27
|
+
|
|
28
|
+
metadata = tomllib.loads(PYPROJECT_PATH.read_text(encoding="utf-8"))
|
|
29
|
+
|
|
30
|
+
self.assertEqual(metadata["project"]["scripts"]["github-issue-list"], "github_issue_list:main")
|
|
31
|
+
|
|
32
|
+
def test_parser_defaults_and_named_arguments(self):
|
|
33
|
+
defaults = issue_list.build_parser().parse_args([])
|
|
34
|
+
selected = issue_list.build_parser().parse_args(["--organization", "octo", "--username", "monalisa"])
|
|
35
|
+
|
|
36
|
+
self.assertEqual(defaults.organization, "kaizten")
|
|
37
|
+
self.assertIsNone(defaults.username)
|
|
38
|
+
self.assertEqual(selected.organization, "octo")
|
|
39
|
+
self.assertEqual(selected.username, "monalisa")
|
|
40
|
+
|
|
41
|
+
def test_parser_rejects_positional_arguments(self):
|
|
42
|
+
with redirect_stderr(io.StringIO()):
|
|
43
|
+
with self.assertRaises(SystemExit):
|
|
44
|
+
issue_list.build_parser().parse_args(["octo"])
|
|
45
|
+
|
|
46
|
+
def test_validate_name_rejects_invalid_value(self):
|
|
47
|
+
with self.assertRaises(issue_list.IssueListError):
|
|
48
|
+
issue_list.validate_name("bad/name", "Organization")
|
|
49
|
+
|
|
50
|
+
def test_current_username_uses_authenticated_user(self):
|
|
51
|
+
with patch.object(issue_list, "run_gh_json", return_value={"login": "monalisa"}) as run_gh:
|
|
52
|
+
self.assertEqual(issue_list.current_username(), "monalisa")
|
|
53
|
+
run_gh.assert_called_once_with(["api", "user"])
|
|
54
|
+
|
|
55
|
+
def test_require_gh_rejects_missing_or_unauthenticated_cli(self):
|
|
56
|
+
with patch.object(issue_list.shutil, "which", return_value=None):
|
|
57
|
+
with self.assertRaisesRegex(issue_list.IssueListError, "required"):
|
|
58
|
+
issue_list.require_gh()
|
|
59
|
+
|
|
60
|
+
result = subprocess.CompletedProcess(["gh", "auth", "status"], 1)
|
|
61
|
+
with patch.object(issue_list.shutil, "which", return_value="/usr/bin/gh"), patch.object(
|
|
62
|
+
issue_list.subprocess, "run", return_value=result
|
|
63
|
+
):
|
|
64
|
+
with self.assertRaisesRegex(issue_list.IssueListError, "not authenticated"):
|
|
65
|
+
issue_list.require_gh()
|
|
66
|
+
|
|
67
|
+
def test_run_gh_json_reports_command_and_json_errors(self):
|
|
68
|
+
failed = subprocess.CompletedProcess(["gh"], 1, stdout="", stderr="failure")
|
|
69
|
+
with patch.object(issue_list.subprocess, "run", return_value=failed):
|
|
70
|
+
with self.assertRaisesRegex(issue_list.IssueListError, "failure"):
|
|
71
|
+
issue_list.run_gh_json(["api", "user"])
|
|
72
|
+
|
|
73
|
+
malformed = subprocess.CompletedProcess(["gh"], 0, stdout="not-json", stderr="")
|
|
74
|
+
with patch.object(issue_list.subprocess, "run", return_value=malformed):
|
|
75
|
+
with self.assertRaisesRegex(issue_list.IssueListError, "invalid JSON"):
|
|
76
|
+
issue_list.run_gh_json(["api", "user"])
|
|
77
|
+
|
|
78
|
+
def test_fetch_issues_uses_expected_search_and_sorts_globally(self):
|
|
79
|
+
response = [
|
|
80
|
+
{
|
|
81
|
+
"repository": {"nameWithOwner": "octo/zeta"},
|
|
82
|
+
"number": 9,
|
|
83
|
+
"title": "newer",
|
|
84
|
+
"createdAt": "2026-10-14T10:00:00Z",
|
|
85
|
+
"isPullRequest": False,
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
"repository": {"nameWithOwner": "octo/alpha"},
|
|
89
|
+
"number": 2,
|
|
90
|
+
"title": "oldest",
|
|
91
|
+
"createdAt": "2026-10-12T10:00:00Z",
|
|
92
|
+
"isPullRequest": False,
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
"repository": {"nameWithOwner": "octo/alpha"},
|
|
96
|
+
"number": 1,
|
|
97
|
+
"title": "same time",
|
|
98
|
+
"createdAt": "2026-10-14T10:00:00Z",
|
|
99
|
+
"isPullRequest": False,
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
"repository": {"nameWithOwner": "octo/alpha"},
|
|
103
|
+
"number": 3,
|
|
104
|
+
"title": "pull request",
|
|
105
|
+
"createdAt": "2026-10-11T10:00:00Z",
|
|
106
|
+
"isPullRequest": True,
|
|
107
|
+
},
|
|
108
|
+
]
|
|
109
|
+
with patch.object(issue_list, "run_gh_json", return_value=response) as run_gh:
|
|
110
|
+
issues = issue_list.fetch_issues("octo", "monalisa")
|
|
111
|
+
|
|
112
|
+
self.assertEqual([(item.repository, item.number) for item in issues], [("octo/alpha", 2), ("octo/alpha", 1), ("octo/zeta", 9)])
|
|
113
|
+
args = run_gh.call_args.args[0]
|
|
114
|
+
self.assertEqual(args[:2], ["search", "issues"])
|
|
115
|
+
self.assertIn("--owner", args)
|
|
116
|
+
self.assertEqual(args[args.index("--owner") + 1], "octo")
|
|
117
|
+
self.assertEqual(args[args.index("--assignee") + 1], "monalisa")
|
|
118
|
+
self.assertEqual(args[args.index("--state") + 1], "open")
|
|
119
|
+
self.assertIn("--archived=false", args)
|
|
120
|
+
self.assertEqual(args[args.index("--sort") + 1], "created")
|
|
121
|
+
self.assertEqual(args[args.index("--order") + 1], "asc")
|
|
122
|
+
|
|
123
|
+
def test_truncate_title_normalizes_whitespace_and_limits_length(self):
|
|
124
|
+
self.assertEqual(issue_list.truncate_title("multi\nline\t title"), "multi line title")
|
|
125
|
+
self.assertEqual(issue_list.truncate_title("x" * 31), ("x" * 27) + "...")
|
|
126
|
+
self.assertEqual(len(issue_list.truncate_title("x" * 31)), 30)
|
|
127
|
+
|
|
128
|
+
def test_datetime_and_elapsed_time(self):
|
|
129
|
+
created = issue_list.parse_github_datetime("2026-10-12T10:30:00Z")
|
|
130
|
+
now = datetime(2026, 10, 14, 12, 0, tzinfo=timezone.utc)
|
|
131
|
+
|
|
132
|
+
self.assertEqual(issue_list.format_github_datetime(created), "2026-10-12T10:30:00Z")
|
|
133
|
+
self.assertEqual(issue_list.elapsed_time(created, now), "2d 1h 30m")
|
|
134
|
+
|
|
135
|
+
def test_render_issue_table_has_required_columns_and_values(self):
|
|
136
|
+
created = datetime(2026, 10, 12, 10, 30, tzinfo=timezone.utc)
|
|
137
|
+
now = datetime(2026, 10, 14, 12, 0, tzinfo=timezone.utc)
|
|
138
|
+
rendered = issue_list.render_issue_table(
|
|
139
|
+
[issue_list.Issue("octo/repo", 7, "An issue title", created)],
|
|
140
|
+
now,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
self.assertIn("Repository", rendered)
|
|
144
|
+
self.assertIn("Issue number", rendered)
|
|
145
|
+
self.assertIn("Issue title", rendered)
|
|
146
|
+
self.assertIn("Creation time", rendered)
|
|
147
|
+
self.assertIn("Time since creation", rendered)
|
|
148
|
+
self.assertIn("octo/repo", rendered)
|
|
149
|
+
self.assertIn("#7", rendered)
|
|
150
|
+
self.assertIn("2026-10-12T10:30:00Z", rendered)
|
|
151
|
+
self.assertIn("2d 1h 30m", rendered)
|
|
152
|
+
self.assertEqual(issue_list.render_issue_table([], now), "(none found)")
|
|
153
|
+
|
|
154
|
+
def test_run_resolves_defaults_and_prints_table(self):
|
|
155
|
+
now = datetime(2026, 10, 14, 12, 0, tzinfo=timezone.utc)
|
|
156
|
+
issue = issue_list.Issue("kaizten/tools", 4, "Old issue", datetime(2026, 10, 13, 12, 0, tzinfo=timezone.utc))
|
|
157
|
+
output = io.StringIO()
|
|
158
|
+
with patch.object(issue_list, "require_gh"), patch.object(
|
|
159
|
+
issue_list, "current_username", return_value="cexposit"
|
|
160
|
+
), patch.object(issue_list, "fetch_issues", return_value=[issue]) as fetch, patch.object(
|
|
161
|
+
issue_list, "utc_now", return_value=now
|
|
162
|
+
), redirect_stdout(output):
|
|
163
|
+
result = issue_list.run([])
|
|
164
|
+
|
|
165
|
+
self.assertEqual(result, 0)
|
|
166
|
+
fetch.assert_called_once_with("kaizten", "cexposit")
|
|
167
|
+
self.assertIn("kaizten/tools", output.getvalue())
|
|
168
|
+
|
|
169
|
+
def test_main_returns_one_for_expected_error(self):
|
|
170
|
+
stderr = io.StringIO()
|
|
171
|
+
with patch.object(issue_list, "run", side_effect=issue_list.IssueListError("broken")), patch.object(
|
|
172
|
+
sys, "argv", ["github-issue-list"]
|
|
173
|
+
), redirect_stderr(stderr):
|
|
174
|
+
result = issue_list.main()
|
|
175
|
+
|
|
176
|
+
self.assertEqual(result, 1)
|
|
177
|
+
self.assertEqual(stderr.getvalue(), "Error: broken\n")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
if __name__ == "__main__":
|
|
181
|
+
unittest.main()
|