@laitszkin/apollo-toolkit 3.13.2 → 3.14.1

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.
Files changed (154) hide show
  1. package/AGENTS.md +7 -7
  2. package/CHANGELOG.md +36 -0
  3. package/CLAUDE.md +8 -8
  4. package/analyse-app-logs/SKILL.md +3 -3
  5. package/bin/apollo-toolkit.ts +7 -0
  6. package/codex/codex-memory-manager/SKILL.md +2 -2
  7. package/codex/learn-skill-from-conversations/SKILL.md +3 -3
  8. package/dist/bin/apollo-toolkit.d.ts +2 -0
  9. package/dist/bin/apollo-toolkit.js +7 -0
  10. package/dist/lib/cli.d.ts +41 -0
  11. package/dist/lib/cli.js +655 -0
  12. package/dist/lib/installer.d.ts +59 -0
  13. package/dist/lib/installer.js +404 -0
  14. package/dist/lib/tool-runner.d.ts +19 -0
  15. package/dist/lib/tool-runner.js +536 -0
  16. package/dist/lib/tools/architecture.d.ts +2 -0
  17. package/dist/lib/tools/architecture.js +23 -0
  18. package/dist/lib/tools/create-specs.d.ts +2 -0
  19. package/dist/lib/tools/create-specs.js +175 -0
  20. package/dist/lib/tools/docs-to-voice.d.ts +2 -0
  21. package/dist/lib/tools/docs-to-voice.js +705 -0
  22. package/dist/lib/tools/enforce-video-aspect-ratio.d.ts +2 -0
  23. package/dist/lib/tools/enforce-video-aspect-ratio.js +312 -0
  24. package/dist/lib/tools/extract-conversations.d.ts +2 -0
  25. package/dist/lib/tools/extract-conversations.js +105 -0
  26. package/dist/lib/tools/extract-pdf-text.d.ts +2 -0
  27. package/dist/lib/tools/extract-pdf-text.js +92 -0
  28. package/dist/lib/tools/filter-logs.d.ts +2 -0
  29. package/dist/lib/tools/filter-logs.js +94 -0
  30. package/dist/lib/tools/find-github-issues.d.ts +2 -0
  31. package/dist/lib/tools/find-github-issues.js +176 -0
  32. package/dist/lib/tools/generate-storyboard-images.d.ts +2 -0
  33. package/dist/lib/tools/generate-storyboard-images.js +419 -0
  34. package/dist/lib/tools/log-cli-utils.d.ts +35 -0
  35. package/dist/lib/tools/log-cli-utils.js +233 -0
  36. package/dist/lib/tools/open-github-issue.d.ts +2 -0
  37. package/dist/lib/tools/open-github-issue.js +750 -0
  38. package/dist/lib/tools/read-github-issue.d.ts +2 -0
  39. package/dist/lib/tools/read-github-issue.js +134 -0
  40. package/dist/lib/tools/render-error-book.d.ts +2 -0
  41. package/dist/lib/tools/render-error-book.js +265 -0
  42. package/dist/lib/tools/render-katex.d.ts +2 -0
  43. package/dist/lib/tools/render-katex.js +294 -0
  44. package/dist/lib/tools/review-threads.d.ts +2 -0
  45. package/dist/lib/tools/review-threads.js +491 -0
  46. package/dist/lib/tools/search-logs.d.ts +2 -0
  47. package/dist/lib/tools/search-logs.js +164 -0
  48. package/dist/lib/tools/sync-memory-index.d.ts +2 -0
  49. package/dist/lib/tools/sync-memory-index.js +113 -0
  50. package/dist/lib/tools/validate-openai-agent-config.d.ts +2 -0
  51. package/dist/lib/tools/validate-openai-agent-config.js +190 -0
  52. package/dist/lib/tools/validate-skill-frontmatter.d.ts +2 -0
  53. package/dist/lib/tools/validate-skill-frontmatter.js +118 -0
  54. package/dist/lib/types.d.ts +82 -0
  55. package/dist/lib/types.js +2 -0
  56. package/dist/lib/updater.d.ts +34 -0
  57. package/dist/lib/updater.js +112 -0
  58. package/dist/lib/utils/format.d.ts +2 -0
  59. package/dist/lib/utils/format.js +6 -0
  60. package/dist/lib/utils/terminal.d.ts +12 -0
  61. package/dist/lib/utils/terminal.js +26 -0
  62. package/docs-to-voice/SKILL.md +0 -1
  63. package/generate-spec/SKILL.md +1 -1
  64. package/katex/SKILL.md +1 -2
  65. package/lib/cli.ts +780 -0
  66. package/lib/installer.ts +466 -0
  67. package/lib/tool-runner.ts +561 -0
  68. package/lib/tools/architecture.ts +20 -0
  69. package/lib/tools/create-specs.ts +204 -0
  70. package/lib/tools/docs-to-voice.ts +799 -0
  71. package/lib/tools/enforce-video-aspect-ratio.ts +368 -0
  72. package/lib/tools/extract-conversations.ts +114 -0
  73. package/lib/tools/extract-pdf-text.ts +99 -0
  74. package/lib/tools/filter-logs.ts +118 -0
  75. package/lib/tools/find-github-issues.ts +211 -0
  76. package/lib/tools/generate-storyboard-images.ts +455 -0
  77. package/lib/tools/log-cli-utils.ts +262 -0
  78. package/lib/tools/open-github-issue.ts +930 -0
  79. package/lib/tools/read-github-issue.ts +179 -0
  80. package/lib/tools/render-error-book.ts +300 -0
  81. package/lib/tools/render-katex.ts +325 -0
  82. package/lib/tools/review-threads.ts +590 -0
  83. package/lib/tools/search-logs.ts +200 -0
  84. package/lib/tools/sync-memory-index.ts +114 -0
  85. package/lib/tools/validate-openai-agent-config.ts +213 -0
  86. package/lib/tools/validate-skill-frontmatter.ts +124 -0
  87. package/lib/types.ts +90 -0
  88. package/lib/updater.ts +165 -0
  89. package/lib/utils/format.ts +7 -0
  90. package/lib/utils/terminal.ts +22 -0
  91. package/open-github-issue/SKILL.md +2 -2
  92. package/optimise-skill/SKILL.md +1 -1
  93. package/package.json +13 -4
  94. package/resources/project-architecture/assets/architecture.css +764 -0
  95. package/resources/project-architecture/assets/viewer.client.js +144 -0
  96. package/resources/project-architecture/index.html +42 -0
  97. package/review-spec-related-changes/SKILL.md +1 -1
  98. package/solve-issues-found-during-review/SKILL.md +2 -1
  99. package/tsconfig.json +28 -0
  100. package/analyse-app-logs/scripts/__pycache__/filter_logs_by_time.cpython-312.pyc +0 -0
  101. package/analyse-app-logs/scripts/__pycache__/log_cli_utils.cpython-312.pyc +0 -0
  102. package/analyse-app-logs/scripts/__pycache__/search_logs.cpython-312.pyc +0 -0
  103. package/analyse-app-logs/scripts/filter_logs_by_time.py +0 -64
  104. package/analyse-app-logs/scripts/log_cli_utils.py +0 -112
  105. package/analyse-app-logs/scripts/search_logs.py +0 -137
  106. package/analyse-app-logs/tests/test_filter_logs_by_time.py +0 -95
  107. package/analyse-app-logs/tests/test_search_logs.py +0 -100
  108. package/codex/codex-memory-manager/scripts/extract_recent_conversations.py +0 -369
  109. package/codex/codex-memory-manager/scripts/sync_memory_index.py +0 -130
  110. package/codex/codex-memory-manager/tests/test_extract_recent_conversations.py +0 -177
  111. package/codex/codex-memory-manager/tests/test_memory_template.py +0 -37
  112. package/codex/codex-memory-manager/tests/test_sync_memory_index.py +0 -84
  113. package/codex/learn-skill-from-conversations/scripts/extract_recent_conversations.py +0 -369
  114. package/codex/learn-skill-from-conversations/tests/test_extract_recent_conversations.py +0 -177
  115. package/docs-to-voice/scripts/__pycache__/docs_to_voice.cpython-312.pyc +0 -0
  116. package/docs-to-voice/scripts/docs_to_voice.py +0 -1385
  117. package/docs-to-voice/scripts/docs_to_voice.sh +0 -11
  118. package/docs-to-voice/tests/test_docs_to_voice_api_max_chars.py +0 -210
  119. package/docs-to-voice/tests/test_docs_to_voice_sentence_timeline.py +0 -115
  120. package/docs-to-voice/tests/test_docs_to_voice_settings.py +0 -43
  121. package/docs-to-voice/tests/test_docs_to_voice_shell_wrapper.py +0 -51
  122. package/docs-to-voice/tests/test_docs_to_voice_speech_rate.py +0 -57
  123. package/generate-spec/scripts/__pycache__/create-specscpython-312.pyc +0 -0
  124. package/generate-spec/scripts/create-specs +0 -215
  125. package/generate-spec/tests/test_create_specs.py +0 -200
  126. package/init-project-html/scripts/architecture-bootstrap-render.js +0 -16
  127. package/init-project-html/scripts/architecture.js +0 -296
  128. package/katex/scripts/__pycache__/render_katex.cpython-312.pyc +0 -0
  129. package/katex/scripts/render_katex.py +0 -247
  130. package/katex/scripts/render_katex.sh +0 -11
  131. package/katex/tests/test_render_katex.py +0 -174
  132. package/learning-error-book/scripts/render_error_book_json_to_pdf.py +0 -590
  133. package/learning-error-book/tests/test_render_error_book_json_to_pdf.py +0 -134
  134. package/open-github-issue/scripts/__pycache__/open_github_issue.cpython-312.pyc +0 -0
  135. package/open-github-issue/scripts/open_github_issue.py +0 -705
  136. package/open-github-issue/tests/test_open_github_issue.py +0 -381
  137. package/openai-text-to-image-storyboard/scripts/generate_storyboard_images.py +0 -763
  138. package/openai-text-to-image-storyboard/tests/test_generate_storyboard_images.py +0 -177
  139. package/read-github-issue/scripts/__pycache__/find_issues.cpython-312.pyc +0 -0
  140. package/read-github-issue/scripts/__pycache__/read_issue.cpython-312.pyc +0 -0
  141. package/read-github-issue/scripts/find_issues.py +0 -148
  142. package/read-github-issue/scripts/read_issue.py +0 -108
  143. package/read-github-issue/tests/test_find_issues.py +0 -127
  144. package/read-github-issue/tests/test_read_issue.py +0 -109
  145. package/resolve-review-comments/scripts/__pycache__/review_threads.cpython-312.pyc +0 -0
  146. package/resolve-review-comments/scripts/review_threads.py +0 -425
  147. package/resolve-review-comments/tests/test_review_threads.py +0 -74
  148. package/scripts/validate_openai_agent_config.py +0 -209
  149. package/scripts/validate_skill_frontmatter.py +0 -131
  150. package/text-to-short-video/scripts/__pycache__/enforce_video_aspect_ratio.cpython-312.pyc +0 -0
  151. package/text-to-short-video/scripts/enforce_video_aspect_ratio.py +0 -350
  152. package/text-to-short-video/tests/test_enforce_video_aspect_ratio.py +0 -194
  153. package/weekly-financial-event-report/scripts/extract_pdf_text_pdfkit.swift +0 -99
  154. package/weekly-financial-event-report/tests/test_extract_pdf_text_pdfkit.py +0 -64
@@ -1,177 +0,0 @@
1
- #!/usr/bin/env python3
2
-
3
- from __future__ import annotations
4
-
5
- import importlib.util
6
- import io
7
- import json
8
- import random
9
- import string
10
- import tempfile
11
- import types
12
- import unittest
13
- from pathlib import Path
14
- from unittest.mock import patch
15
-
16
- PIL_AVAILABLE = importlib.util.find_spec("PIL") is not None
17
-
18
- if PIL_AVAILABLE:
19
- from PIL import Image
20
- SCRIPT_PATH = Path(__file__).resolve().parents[1] / "scripts" / "generate_storyboard_images.py"
21
- SPEC = importlib.util.spec_from_file_location("generate_storyboard_images", SCRIPT_PATH)
22
- MODULE = importlib.util.module_from_spec(SPEC)
23
- SPEC.loader.exec_module(MODULE)
24
- else:
25
- Image = None
26
- MODULE = None
27
-
28
-
29
- def png_bytes(width: int, height: int, color: tuple[int, int, int] = (64, 128, 255)) -> bytes:
30
- buffer = io.BytesIO()
31
- Image.new("RGB", (width, height), color).save(buffer, format="PNG")
32
- return buffer.getvalue()
33
-
34
-
35
- @unittest.skipUnless(PIL_AVAILABLE, "Pillow is required for storyboard image tests")
36
- class GenerateStoryboardImagesTests(unittest.TestCase):
37
- def test_sanitize_component_property_removes_invalid_path_characters(self) -> None:
38
- generator = random.Random(20260418)
39
- alphabet = string.ascii_letters + string.digits + string.punctuation + " \t中文"
40
-
41
- for _ in range(200):
42
- raw = "".join(generator.choice(alphabet) for _ in range(generator.randint(0, 30)))
43
- sanitized = MODULE.sanitize_component(raw, "fallback")
44
- with self.subTest(raw=raw, sanitized=sanitized):
45
- self.assertTrue(sanitized)
46
- self.assertNotRegex(sanitized, MODULE.INVALID_PATH_CHARS)
47
- self.assertNotIn(" ", sanitized)
48
-
49
- def test_parse_image_dimensions_supports_png_and_jpeg(self) -> None:
50
- png = png_bytes(320, 180)
51
- self.assertEqual(MODULE.parse_image_dimensions(png), (320, 180))
52
-
53
- buffer = io.BytesIO()
54
- Image.new("RGB", (160, 90), (0, 0, 0)).save(buffer, format="JPEG")
55
- self.assertEqual(MODULE.parse_image_dimensions(buffer.getvalue()), (160, 90))
56
-
57
- def test_parse_prompts_file_supports_structured_scene_mode(self) -> None:
58
- payload = {
59
- "characters": [
60
- {
61
- "id": "hero",
62
- "name": "Kai",
63
- "appearance": "short black hair",
64
- "outfit": "blue jacket",
65
- "description": "determined teenage inventor",
66
- }
67
- ],
68
- "scenes": [
69
- {
70
- "title": "Workshop",
71
- "description": "Kai repairs a flickering lantern inside a cramped workshop.",
72
- "character_ids": ["hero"],
73
- "character_descriptions": {"hero": "focused and covered with grease"},
74
- "camera": "medium shot",
75
- }
76
- ],
77
- }
78
-
79
- with tempfile.TemporaryDirectory() as temp_dir:
80
- prompts_file = Path(temp_dir) / "prompts.json"
81
- prompts_file.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
82
- items = MODULE.parse_prompts_file(prompts_file)
83
-
84
- self.assertEqual(len(items), 1)
85
- self.assertEqual(items[0]["title"], "Workshop")
86
- prompt_payload = json.loads(items[0]["prompt"])
87
- self.assertEqual(prompt_payload["characters"][0]["description"], "focused and covered with grease")
88
- self.assertEqual(prompt_payload["camera"], "medium shot")
89
-
90
- def test_build_scene_json_prompt_rejects_unknown_override_character(self) -> None:
91
- scene = {
92
- "title": "Workshop",
93
- "description": "Lantern repair",
94
- "character_ids": ["hero"],
95
- "character_descriptions": {"ghost": "not listed"},
96
- }
97
-
98
- with self.assertRaises(SystemExit) as context:
99
- MODULE.build_scene_json_prompt(
100
- scene=scene,
101
- scene_index=1,
102
- prompts_file=Path("prompts.json"),
103
- character_profiles={
104
- "hero": {
105
- "id": "hero",
106
- "name": "Kai",
107
- "appearance": "short black hair",
108
- "outfit": "blue jacket",
109
- "description": "determined teenage inventor",
110
- }
111
- },
112
- )
113
-
114
- self.assertIn("ids not listed in character_ids", str(context.exception))
115
-
116
- def test_generate_image_supports_b64_payload(self) -> None:
117
- raw = png_bytes(100, 60)
118
-
119
- with patch.object(
120
- MODULE,
121
- "post_json",
122
- return_value={"data": [{"b64_json": MODULE.base64.b64encode(raw).decode("ascii"), "revised_prompt": "ok"}]},
123
- ):
124
- image_bytes, revised_prompt = MODULE.generate_image(
125
- base_url="https://example.com",
126
- api_key="token",
127
- image_model="gpt-image-1",
128
- prompt="scene",
129
- aspect_ratio=None,
130
- image_size=None,
131
- quality=None,
132
- style=None,
133
- )
134
-
135
- self.assertEqual(image_bytes, raw)
136
- self.assertEqual(revised_prompt, "ok")
137
-
138
- def test_main_generates_cropped_images_and_summary(self) -> None:
139
- with tempfile.TemporaryDirectory() as temp_dir:
140
- project_dir = Path(temp_dir)
141
- args = types.SimpleNamespace(
142
- content_name="Lantern / Story",
143
- project_dir=str(project_dir),
144
- env_file=str(project_dir / ".env"),
145
- api_url="https://example.com",
146
- api_key="token",
147
- prompts_file=None,
148
- prompt=["Lantern workshop at dusk"],
149
- image_model="gpt-image-1",
150
- aspect_ratio="1:1",
151
- image_size=None,
152
- quality=None,
153
- style=None,
154
- )
155
-
156
- with patch.object(MODULE, "parse_args", return_value=args), patch.object(
157
- MODULE, "load_dotenv_file", return_value=False
158
- ), patch.object(
159
- MODULE,
160
- "generate_image",
161
- return_value=(png_bytes(200, 100), "Lantern workshop at dusk, improved"),
162
- ):
163
- exit_code = MODULE.main()
164
-
165
- self.assertEqual(exit_code, 0)
166
- output_dir = project_dir / "pictures" / "Lantern_Story"
167
- summary = json.loads((output_dir / "storyboard.json").read_text(encoding="utf-8"))
168
- self.assertEqual(summary["aspect_ratio"], "1:1")
169
- self.assertEqual(summary["images"][0]["width"], 100)
170
- self.assertEqual(summary["images"][0]["height"], 100)
171
- self.assertEqual(summary["images"][0]["source_width"], 200)
172
- self.assertEqual(summary["images"][0]["source_height"], 100)
173
- self.assertEqual(summary["images"][0]["revised_prompt"], "Lantern workshop at dusk, improved")
174
-
175
-
176
- if __name__ == "__main__":
177
- unittest.main()
@@ -1,148 +0,0 @@
1
- #!/usr/bin/env python3
2
- import argparse
3
- import json
4
- import subprocess
5
- import sys
6
- from typing import Any
7
-
8
- ISSUE_FIELDS = "number,title,state,updatedAt,url,labels,assignees"
9
-
10
-
11
- def positive_int(raw: str) -> int:
12
- value = int(raw)
13
- if value <= 0:
14
- raise argparse.ArgumentTypeError("limit must be a positive integer")
15
- return value
16
-
17
-
18
- def parse_args() -> argparse.Namespace:
19
- parser = argparse.ArgumentParser(
20
- description="Find remote GitHub issues with gh CLI and display table or JSON output."
21
- )
22
- parser.add_argument("--repo", help="Target repository in owner/name format.")
23
- parser.add_argument("--state", default="open", choices=["open", "closed", "all"])
24
- parser.add_argument("--limit", type=positive_int, default=50)
25
- parser.add_argument(
26
- "--label",
27
- action="append",
28
- default=[],
29
- help="Label filter (repeat for multiple labels).",
30
- )
31
- parser.add_argument("--search", help="Search query for issue list filtering.")
32
- parser.add_argument(
33
- "--output",
34
- choices=["table", "json"],
35
- default="table",
36
- help="Output format.",
37
- )
38
- return parser.parse_args()
39
-
40
-
41
- def build_command(args: argparse.Namespace) -> list[str]:
42
- cmd = [
43
- "gh",
44
- "issue",
45
- "list",
46
- "--state",
47
- args.state,
48
- "--limit",
49
- str(args.limit),
50
- "--json",
51
- ISSUE_FIELDS,
52
- ]
53
-
54
- if args.repo:
55
- cmd.extend(["--repo", args.repo])
56
- for label in args.label:
57
- cmd.extend(["--label", label])
58
- if args.search:
59
- cmd.extend(["--search", args.search])
60
-
61
- return cmd
62
-
63
-
64
- def truncate(text: str, width: int) -> str:
65
- if len(text) <= width:
66
- return text
67
- if width <= 3:
68
- return text[:width]
69
- return text[: width - 3] + "..."
70
-
71
-
72
- def format_labels(issue: dict[str, Any]) -> str:
73
- labels = issue.get("labels", [])
74
- names = [item.get("name", "") for item in labels if item.get("name")]
75
- return ",".join(names)
76
-
77
-
78
- def format_assignees(issue: dict[str, Any]) -> str:
79
- assignees = issue.get("assignees", [])
80
- logins = [item.get("login", "") for item in assignees if item.get("login")]
81
- return ",".join(logins) if logins else "-"
82
-
83
-
84
- def print_table(issues: list[dict[str, Any]]) -> None:
85
- columns = {
86
- "number": 7,
87
- "title": 54,
88
- "labels": 22,
89
- "assignees": 18,
90
- "updated": 20,
91
- }
92
-
93
- header = (
94
- f"{'NUMBER':<{columns['number']}} "
95
- f"{'TITLE':<{columns['title']}} "
96
- f"{'LABELS':<{columns['labels']}} "
97
- f"{'ASSIGNEES':<{columns['assignees']}} "
98
- f"{'UPDATED':<{columns['updated']}}"
99
- )
100
- print(header)
101
- print("-" * len(header))
102
-
103
- for issue in issues:
104
- number = f"#{issue.get('number', '')}"
105
- title = truncate(str(issue.get("title", "")), columns["title"])
106
- labels = truncate(format_labels(issue), columns["labels"])
107
- assignees = truncate(format_assignees(issue), columns["assignees"])
108
- updated = truncate(str(issue.get("updatedAt", "")), columns["updated"])
109
-
110
- row = (
111
- f"{number:<{columns['number']}} "
112
- f"{title:<{columns['title']}} "
113
- f"{labels:<{columns['labels']}} "
114
- f"{assignees:<{columns['assignees']}} "
115
- f"{updated:<{columns['updated']}}"
116
- )
117
- print(row)
118
-
119
-
120
- def main() -> int:
121
- args = parse_args()
122
- cmd = build_command(args)
123
-
124
- try:
125
- result = subprocess.run(cmd, check=True, capture_output=True, text=True)
126
- except FileNotFoundError:
127
- print("Error: gh CLI is not installed or not in PATH.", file=sys.stderr)
128
- return 1
129
- except subprocess.CalledProcessError as exc:
130
- print(exc.stderr.strip() or "gh issue list failed.", file=sys.stderr)
131
- return exc.returncode
132
-
133
- try:
134
- issues = json.loads(result.stdout)
135
- except json.JSONDecodeError:
136
- print("Error: unable to parse gh output as JSON.", file=sys.stderr)
137
- return 1
138
-
139
- if args.output == "json":
140
- print(json.dumps(issues, indent=2))
141
- return 0
142
-
143
- print_table(issues)
144
- return 0
145
-
146
-
147
- if __name__ == "__main__":
148
- sys.exit(main())
@@ -1,108 +0,0 @@
1
- #!/usr/bin/env python3
2
- from __future__ import annotations
3
-
4
- import argparse
5
- import json
6
- import subprocess
7
- import sys
8
-
9
-
10
- ISSUE_FIELDS = "number,title,body,state,author,labels,assignees,comments,createdAt,updatedAt,closedAt,url"
11
-
12
-
13
- def parse_args() -> argparse.Namespace:
14
- parser = argparse.ArgumentParser(
15
- description="Read a remote GitHub issue with gh CLI and display summary or JSON output."
16
- )
17
- parser.add_argument("issue", help="Issue number or URL.")
18
- parser.add_argument("--repo", help="Target repository in owner/name format.")
19
- parser.add_argument(
20
- "--comments",
21
- action="store_true",
22
- help="Keep issue comments in formatted output.",
23
- )
24
- parser.add_argument(
25
- "--json",
26
- action="store_true",
27
- help="Print raw JSON output.",
28
- )
29
- return parser.parse_args()
30
-
31
-
32
- def build_command(args: argparse.Namespace) -> list[str]:
33
- cmd = [
34
- "gh",
35
- "issue",
36
- "view",
37
- args.issue,
38
- "--json",
39
- ISSUE_FIELDS,
40
- ]
41
- if args.repo:
42
- cmd.extend(["--repo", args.repo])
43
- return cmd
44
-
45
-
46
- def join_names(items: list[dict], key: str) -> str:
47
- values = [item.get(key, "") for item in items if item.get(key)]
48
- return ", ".join(values) if values else "-"
49
-
50
-
51
- def print_summary(issue: dict, include_comments: bool) -> None:
52
- print(f"Number: #{issue.get('number', '')}")
53
- print(f"Title: {issue.get('title', '')}")
54
- print(f"State: {issue.get('state', '')}")
55
- print(f"URL: {issue.get('url', '')}")
56
- print(f"Author: {(issue.get('author') or {}).get('login', '-')}")
57
- print(f"Labels: {join_names(issue.get('labels', []), 'name')}")
58
- print(f"Assignees: {join_names(issue.get('assignees', []), 'login')}")
59
- print(f"Created: {issue.get('createdAt', '')}")
60
- print(f"Updated: {issue.get('updatedAt', '')}")
61
- print(f"Closed: {issue.get('closedAt', '') or '-'}")
62
- print("")
63
- print("Body:")
64
- print(issue.get("body", "") or "-")
65
-
66
- if include_comments:
67
- comments = issue.get("comments", [])
68
- print("")
69
- print(f"Comments ({len(comments)}):")
70
- if not comments:
71
- print("-")
72
- return
73
- for comment in comments:
74
- author = (comment.get("author") or {}).get("login", "-")
75
- created = comment.get("createdAt", "")
76
- body = comment.get("body", "") or "-"
77
- print(f"- [{created}] {author}: {body}")
78
-
79
-
80
- def main() -> int:
81
- args = parse_args()
82
- cmd = build_command(args)
83
-
84
- try:
85
- result = subprocess.run(cmd, check=True, capture_output=True, text=True)
86
- except FileNotFoundError:
87
- print("Error: gh CLI is not installed or not in PATH.", file=sys.stderr)
88
- return 1
89
- except subprocess.CalledProcessError as exc:
90
- print(exc.stderr.strip() or "gh issue view failed.", file=sys.stderr)
91
- return exc.returncode
92
-
93
- try:
94
- issue = json.loads(result.stdout)
95
- except json.JSONDecodeError:
96
- print("Error: unable to parse gh output as JSON.", file=sys.stderr)
97
- return 1
98
-
99
- if args.json:
100
- print(json.dumps(issue, indent=2))
101
- return 0
102
-
103
- print_summary(issue, include_comments=args.comments)
104
- return 0
105
-
106
-
107
- if __name__ == "__main__":
108
- sys.exit(main())
@@ -1,127 +0,0 @@
1
- #!/usr/bin/env python3
2
-
3
- from __future__ import annotations
4
-
5
- import argparse
6
- import importlib.util
7
- import io
8
- import json
9
- import unittest
10
- from argparse import Namespace
11
- from pathlib import Path
12
- from unittest.mock import patch
13
-
14
-
15
- SCRIPT_PATH = Path(__file__).resolve().parents[1] / "scripts" / "find_issues.py"
16
- SPEC = importlib.util.spec_from_file_location("find_issues", SCRIPT_PATH)
17
- MODULE = importlib.util.module_from_spec(SPEC)
18
- SPEC.loader.exec_module(MODULE)
19
-
20
-
21
- class FindIssuesTests(unittest.TestCase):
22
- def test_build_command_with_filters(self) -> None:
23
- args = Namespace(
24
- repo="owner/repo",
25
- state="open",
26
- limit=25,
27
- label=["bug", "needs-triage"],
28
- search="panic parser",
29
- output="table",
30
- )
31
-
32
- cmd = MODULE.build_command(args)
33
-
34
- self.assertEqual(
35
- cmd,
36
- [
37
- "gh",
38
- "issue",
39
- "list",
40
- "--state",
41
- "open",
42
- "--limit",
43
- "25",
44
- "--json",
45
- MODULE.ISSUE_FIELDS,
46
- "--repo",
47
- "owner/repo",
48
- "--label",
49
- "bug",
50
- "--label",
51
- "needs-triage",
52
- "--search",
53
- "panic parser",
54
- ],
55
- )
56
-
57
- def test_positive_int_rejects_zero(self) -> None:
58
- with self.assertRaises(argparse.ArgumentTypeError):
59
- MODULE.positive_int("0")
60
-
61
- def test_truncate_handles_small_width(self) -> None:
62
- self.assertEqual(MODULE.truncate("abcdef", 2), "ab")
63
-
64
- def test_print_table_handles_missing_fields(self) -> None:
65
- with patch("sys.stdout", new_callable=io.StringIO) as stdout:
66
- MODULE.print_table([
67
- {
68
- "number": 42,
69
- "title": "Fix formatter fallback",
70
- }
71
- ])
72
-
73
- output = stdout.getvalue()
74
- self.assertIn("NUMBER", output)
75
- self.assertIn("#42", output)
76
-
77
- def test_main_returns_error_when_gh_missing(self) -> None:
78
- args = Namespace(
79
- repo=None,
80
- state="open",
81
- limit=50,
82
- label=[],
83
- search=None,
84
- output="table",
85
- )
86
-
87
- with patch.object(MODULE, "parse_args", return_value=args), patch(
88
- "subprocess.run", side_effect=FileNotFoundError
89
- ), patch("sys.stderr", new_callable=io.StringIO) as stderr:
90
- code = MODULE.main()
91
-
92
- self.assertEqual(code, 1)
93
- self.assertIn("not in PATH", stderr.getvalue())
94
-
95
- def test_main_json_output(self) -> None:
96
- args = Namespace(
97
- repo=None,
98
- state="open",
99
- limit=50,
100
- label=[],
101
- search=None,
102
- output="json",
103
- )
104
- payload = [
105
- {
106
- "number": 1,
107
- "title": "Fix bug",
108
- "labels": [],
109
- "assignees": [],
110
- "updatedAt": "2026-02-26T00:00:00Z",
111
- }
112
- ]
113
-
114
- class Result:
115
- stdout = json.dumps(payload)
116
-
117
- with patch.object(MODULE, "parse_args", return_value=args), patch(
118
- "subprocess.run", return_value=Result()
119
- ), patch("sys.stdout", new_callable=io.StringIO) as stdout:
120
- code = MODULE.main()
121
-
122
- self.assertEqual(code, 0)
123
- self.assertEqual(json.loads(stdout.getvalue()), payload)
124
-
125
-
126
- if __name__ == "__main__":
127
- unittest.main()
@@ -1,109 +0,0 @@
1
- #!/usr/bin/env python3
2
-
3
- from __future__ import annotations
4
-
5
- import importlib.util
6
- import io
7
- import json
8
- import unittest
9
- from argparse import Namespace
10
- from pathlib import Path
11
- from unittest.mock import patch
12
-
13
-
14
- SCRIPT_PATH = Path(__file__).resolve().parents[1] / "scripts" / "read_issue.py"
15
- SPEC = importlib.util.spec_from_file_location("read_issue", SCRIPT_PATH)
16
- MODULE = importlib.util.module_from_spec(SPEC)
17
- SPEC.loader.exec_module(MODULE)
18
-
19
-
20
- class ReadIssueTests(unittest.TestCase):
21
- def test_build_command_with_repo(self) -> None:
22
- args = Namespace(issue="123", repo="owner/repo", comments=False, json=False)
23
-
24
- self.assertEqual(
25
- MODULE.build_command(args),
26
- [
27
- "gh",
28
- "issue",
29
- "view",
30
- "123",
31
- "--json",
32
- MODULE.ISSUE_FIELDS,
33
- "--repo",
34
- "owner/repo",
35
- ],
36
- )
37
-
38
- def test_main_returns_error_when_gh_missing(self) -> None:
39
- args = Namespace(issue="123", repo=None, comments=False, json=False)
40
-
41
- with patch.object(MODULE, "parse_args", return_value=args), patch(
42
- "subprocess.run", side_effect=FileNotFoundError
43
- ), patch("sys.stderr", new_callable=io.StringIO) as stderr:
44
- code = MODULE.main()
45
-
46
- self.assertEqual(code, 1)
47
- self.assertIn("not in PATH", stderr.getvalue())
48
-
49
- def test_main_json_output(self) -> None:
50
- args = Namespace(issue="123", repo=None, comments=False, json=True)
51
- payload = {
52
- "number": 123,
53
- "title": "Need better retries",
54
- "body": "detail",
55
- "state": "OPEN",
56
- "author": {"login": "octocat"},
57
- "labels": [],
58
- "assignees": [],
59
- "comments": [],
60
- "createdAt": "2026-03-24T00:00:00Z",
61
- "updatedAt": "2026-03-24T01:00:00Z",
62
- "closedAt": None,
63
- "url": "https://github.com/owner/repo/issues/123",
64
- }
65
-
66
- class Result:
67
- stdout = json.dumps(payload)
68
-
69
- with patch.object(MODULE, "parse_args", return_value=args), patch(
70
- "subprocess.run", return_value=Result()
71
- ), patch("sys.stdout", new_callable=io.StringIO) as stdout:
72
- code = MODULE.main()
73
-
74
- self.assertEqual(code, 0)
75
- self.assertEqual(json.loads(stdout.getvalue()), payload)
76
-
77
- def test_print_summary_includes_comments(self) -> None:
78
- issue = {
79
- "number": 123,
80
- "title": "Need better retries",
81
- "body": "detail",
82
- "state": "OPEN",
83
- "author": {"login": "octocat"},
84
- "labels": [{"name": "bug"}],
85
- "assignees": [{"login": "alice"}],
86
- "comments": [
87
- {
88
- "author": {"login": "bob"},
89
- "createdAt": "2026-03-24T02:00:00Z",
90
- "body": "extra context",
91
- }
92
- ],
93
- "createdAt": "2026-03-24T00:00:00Z",
94
- "updatedAt": "2026-03-24T01:00:00Z",
95
- "closedAt": None,
96
- "url": "https://github.com/owner/repo/issues/123",
97
- }
98
-
99
- with patch("sys.stdout", new_callable=io.StringIO) as stdout:
100
- MODULE.print_summary(issue, include_comments=True)
101
-
102
- output = stdout.getvalue()
103
- self.assertIn("#123", output)
104
- self.assertIn("bug", output)
105
- self.assertIn("extra context", output)
106
-
107
-
108
- if __name__ == "__main__":
109
- unittest.main()