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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,2 @@
1
+ [console_scripts]
2
+ github-issue-list = github_issue_list:main
@@ -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()