shikiru-cli 0.2.0__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.
Files changed (319) hide show
  1. shikiru/__init__.py +1 -0
  2. shikiru/__main__.py +8 -0
  3. shikiru/agents/__init__.py +3 -0
  4. shikiru/agents/contracts.py +590 -0
  5. shikiru/agents/registry.py +87 -0
  6. shikiru/agents/sdk/__init__.py +17 -0
  7. shikiru/agents/sdk/reset_time.py +137 -0
  8. shikiru/agents/sdk/turn.py +335 -0
  9. shikiru/agents/settings.py +151 -0
  10. shikiru/agents/testing/__init__.py +26 -0
  11. shikiru/agents/testing/contract_suite.py +653 -0
  12. shikiru/agents/testing/resume.py +15 -0
  13. shikiru/bootstrap.py +1277 -0
  14. shikiru/cli/__init__.py +1 -0
  15. shikiru/cli/app.py +770 -0
  16. shikiru/cli/context.py +63 -0
  17. shikiru/cli/render.py +1475 -0
  18. shikiru/cli/report.py +156 -0
  19. shikiru/cli/session_prompt.py +63 -0
  20. shikiru/cli/view.py +131 -0
  21. shikiru/clock.py +41 -0
  22. shikiru/config/__init__.py +8 -0
  23. shikiru/config/defaults.toml +131 -0
  24. shikiru/config/loader.py +178 -0
  25. shikiru/config/settings.py +392 -0
  26. shikiru/dashboard/__init__.py +3 -0
  27. shikiru/dashboard/actions_list.py +799 -0
  28. shikiru/dashboard/activity.py +150 -0
  29. shikiru/dashboard/app.py +1765 -0
  30. shikiru/dashboard/blocker_evidence.py +122 -0
  31. shikiru/dashboard/blocker_rows.py +84 -0
  32. shikiru/dashboard/briefing_view.py +871 -0
  33. shikiru/dashboard/contract.py +1086 -0
  34. shikiru/dashboard/covered.py +32 -0
  35. shikiru/dashboard/detail.py +1209 -0
  36. shikiru/dashboard/detail_fold.py +244 -0
  37. shikiru/dashboard/detail_hint.py +68 -0
  38. shikiru/dashboard/details_list_view.py +150 -0
  39. shikiru/dashboard/diagnostics.py +80 -0
  40. shikiru/dashboard/diagnostics_sections.py +186 -0
  41. shikiru/dashboard/evidence_table.py +123 -0
  42. shikiru/dashboard/extra_details.py +214 -0
  43. shikiru/dashboard/fault_guidance.py +216 -0
  44. shikiru/dashboard/fold_bodies.py +276 -0
  45. shikiru/dashboard/help_keys.py +166 -0
  46. shikiru/dashboard/help_screen.py +204 -0
  47. shikiru/dashboard/interaction.py +17 -0
  48. shikiru/dashboard/ladder_editor.py +544 -0
  49. shikiru/dashboard/live_settings.py +62 -0
  50. shikiru/dashboard/motion.py +368 -0
  51. shikiru/dashboard/orchestration_fields.py +201 -0
  52. shikiru/dashboard/palette.py +1606 -0
  53. shikiru/dashboard/palette_app.py +89 -0
  54. shikiru/dashboard/palette_drafts.py +33 -0
  55. shikiru/dashboard/resolution.py +573 -0
  56. shikiru/dashboard/resolution_view.py +302 -0
  57. shikiru/dashboard/row_list.py +256 -0
  58. shikiru/dashboard/section_rows.py +173 -0
  59. shikiru/dashboard/services.py +665 -0
  60. shikiru/dashboard/session_history.py +402 -0
  61. shikiru/dashboard/shikiru_init_ascii_art.txt +40 -0
  62. shikiru/dashboard/task_picker.py +358 -0
  63. shikiru/dashboard/tick.py +59 -0
  64. shikiru/dashboard/timeline_filter.py +129 -0
  65. shikiru/dashboard/timeline_section.py +385 -0
  66. shikiru/dashboard/usage_body.py +269 -0
  67. shikiru/dashboard/views.py +2314 -0
  68. shikiru/dashboard/wizard.py +1389 -0
  69. shikiru/domain/__init__.py +1 -0
  70. shikiru/domain/acceptance.py +130 -0
  71. shikiru/domain/activity.py +28 -0
  72. shikiru/domain/attempts.py +333 -0
  73. shikiru/domain/briefing.py +257 -0
  74. shikiru/domain/commands.py +219 -0
  75. shikiru/domain/decisions.py +231 -0
  76. shikiru/domain/docker.py +262 -0
  77. shikiru/domain/engine_uptime.py +87 -0
  78. shikiru/domain/escalation.py +194 -0
  79. shikiru/domain/escalation_evidence.py +186 -0
  80. shikiru/domain/escalation_panel.py +144 -0
  81. shikiru/domain/events.py +71 -0
  82. shikiru/domain/evidence_export.py +89 -0
  83. shikiru/domain/faults.py +86 -0
  84. shikiru/domain/feedback.py +180 -0
  85. shikiru/domain/feedback_rounds.py +172 -0
  86. shikiru/domain/fencing.py +31 -0
  87. shikiru/domain/ladder.py +102 -0
  88. shikiru/domain/ladder_text.py +44 -0
  89. shikiru/domain/levels.py +44 -0
  90. shikiru/domain/motion.py +199 -0
  91. shikiru/domain/packet_evidence.py +377 -0
  92. shikiru/domain/pushes.py +47 -0
  93. shikiru/domain/quota_blocks.py +44 -0
  94. shikiru/domain/quota_waits.py +219 -0
  95. shikiru/domain/recovery.py +194 -0
  96. shikiru/domain/reports.py +498 -0
  97. shikiru/domain/results.py +217 -0
  98. shikiru/domain/runs.py +85 -0
  99. shikiru/domain/scheduling.py +231 -0
  100. shikiru/domain/status_tasks.py +40 -0
  101. shikiru/domain/steps.py +325 -0
  102. shikiru/domain/tasks.py +174 -0
  103. shikiru/domain/timeline.py +208 -0
  104. shikiru/domain/timeline_phrases.py +271 -0
  105. shikiru/domain/versions.py +18 -0
  106. shikiru/domain/workflow.py +372 -0
  107. shikiru/engine/__init__.py +0 -0
  108. shikiru/engine/acceptance.py +90 -0
  109. shikiru/engine/agents.py +61 -0
  110. shikiru/engine/cosmetic_decorator.py +99 -0
  111. shikiru/engine/daemon.py +409 -0
  112. shikiru/engine/dependency.py +37 -0
  113. shikiru/engine/escalation_filing.py +110 -0
  114. shikiru/engine/escalations.py +401 -0
  115. shikiru/engine/events.py +285 -0
  116. shikiru/engine/evidence.py +81 -0
  117. shikiru/engine/fault_reporter.py +144 -0
  118. shikiru/engine/feedback.py +577 -0
  119. shikiru/engine/feedback_audit.py +80 -0
  120. shikiru/engine/feedback_intake.py +95 -0
  121. shikiru/engine/feedback_snapshot.py +148 -0
  122. shikiru/engine/flow.py +87 -0
  123. shikiru/engine/handlers/__init__.py +33 -0
  124. shikiru/engine/handlers/base.py +148 -0
  125. shikiru/engine/handlers/engine.py +24 -0
  126. shikiru/engine/handlers/escalations.py +174 -0
  127. shikiru/engine/handlers/operator.py +81 -0
  128. shikiru/engine/handlers/tasks.py +237 -0
  129. shikiru/engine/history.py +128 -0
  130. shikiru/engine/integration.py +1234 -0
  131. shikiru/engine/integration_turns.py +337 -0
  132. shikiru/engine/layout_guard.py +362 -0
  133. shikiru/engine/lifecycle.py +60 -0
  134. shikiru/engine/monitor.py +317 -0
  135. shikiru/engine/notification_delivery.py +124 -0
  136. shikiru/engine/notification_feed.py +57 -0
  137. shikiru/engine/notifier.py +91 -0
  138. shikiru/engine/pull_request_closing.py +117 -0
  139. shikiru/engine/push_service.py +177 -0
  140. shikiru/engine/quota_probe_threads.py +79 -0
  141. shikiru/engine/quota_reconciler.py +210 -0
  142. shikiru/engine/quota_schedule.py +24 -0
  143. shikiru/engine/quota_waits.py +102 -0
  144. shikiru/engine/reconcile.py +91 -0
  145. shikiru/engine/recovery.py +284 -0
  146. shikiru/engine/retention.py +102 -0
  147. shikiru/engine/root_preparation.py +86 -0
  148. shikiru/engine/scheduler.py +320 -0
  149. shikiru/engine/session_guard.py +46 -0
  150. shikiru/engine/sessions.py +95 -0
  151. shikiru/engine/signalled.py +75 -0
  152. shikiru/engine/step_runner.py +140 -0
  153. shikiru/engine/supervisor.py +101 -0
  154. shikiru/engine/takeover.py +223 -0
  155. shikiru/engine/task_facts.py +46 -0
  156. shikiru/engine/tick_guard.py +95 -0
  157. shikiru/engine/titles.py +297 -0
  158. shikiru/engine/transitions.py +79 -0
  159. shikiru/engine/turn_context.py +206 -0
  160. shikiru/engine/turn_execution.py +30 -0
  161. shikiru/engine/turn_loop.py +230 -0
  162. shikiru/engine/turn_processes.py +79 -0
  163. shikiru/engine/turns.py +457 -0
  164. shikiru/errors.py +144 -0
  165. shikiru/helper/__init__.py +3 -0
  166. shikiru/helper/helper.py +182 -0
  167. shikiru/helper/models.py +11 -0
  168. shikiru/mcp/__init__.py +1 -0
  169. shikiru/mcp/server.py +196 -0
  170. shikiru/operator/__init__.py +33 -0
  171. shikiru/operator/attachment.py +24 -0
  172. shikiru/operator/commands.py +319 -0
  173. shikiru/operator/detectors.py +374 -0
  174. shikiru/operator/doctor.py +351 -0
  175. shikiru/operator/events.py +57 -0
  176. shikiru/operator/evidence_export.py +498 -0
  177. shikiru/operator/evidence_writer.py +78 -0
  178. shikiru/operator/lifecycle.py +294 -0
  179. shikiru/operator/plugin_checks.py +194 -0
  180. shikiru/operator/probe.py +287 -0
  181. shikiru/operator/queries.py +1601 -0
  182. shikiru/operator/services.py +36 -0
  183. shikiru/operator/sessions.py +378 -0
  184. shikiru/operator/strip_views.py +162 -0
  185. shikiru/operator/timeline.py +416 -0
  186. shikiru/operator/toml_text.py +62 -0
  187. shikiru/palette.py +18 -0
  188. shikiru/paths.py +91 -0
  189. shikiru/plugins/__init__.py +0 -0
  190. shikiru/plugins/agents/__init__.py +1 -0
  191. shikiru/plugins/agents/claude/__init__.py +5 -0
  192. shikiru/plugins/agents/claude/agent.py +251 -0
  193. shikiru/plugins/agents/claude/context.py +116 -0
  194. shikiru/plugins/agents/claude/headless.py +92 -0
  195. shikiru/plugins/agents/claude/hook.py +57 -0
  196. shikiru/plugins/agents/claude/model_catalog.py +105 -0
  197. shikiru/plugins/agents/claude/options.py +62 -0
  198. shikiru/plugins/agents/claude/turn.py +1018 -0
  199. shikiru/plugins/agents/codex/__init__.py +5 -0
  200. shikiru/plugins/agents/codex/agent.py +486 -0
  201. shikiru/plugins/agents/codex/headless.py +166 -0
  202. shikiru/plugins/agents/codex/home.py +14 -0
  203. shikiru/plugins/agents/codex/model_catalog.py +128 -0
  204. shikiru/plugins/agents/codex/options.py +94 -0
  205. shikiru/plugins/agents/codex/quota.py +195 -0
  206. shikiru/plugins/agents/codex/sandbox.py +31 -0
  207. shikiru/plugins/agents/codex/sandbox_probe.py +97 -0
  208. shikiru/plugins/agents/codex/turn.py +767 -0
  209. shikiru/plugins/agents/codex/usage.py +106 -0
  210. shikiru/plugins/agents/shared_settings.py +62 -0
  211. shikiru/plugins/widgets/__init__.py +3 -0
  212. shikiru/plugins/widgets/dialogue.py +144 -0
  213. shikiru/plugins/widgets/disk.py +123 -0
  214. shikiru/plugins/widgets/docker.py +225 -0
  215. shikiru/plugins/widgets/escalations.py +402 -0
  216. shikiru/plugins/widgets/faults.py +204 -0
  217. shikiru/plugins/widgets/header.py +321 -0
  218. shikiru/plugins/widgets/queue.py +1140 -0
  219. shikiru/plugins/widgets/queue_hint.py +93 -0
  220. shikiru/plugins/widgets/resources.py +105 -0
  221. shikiru/plugins/widgets/strips.py +82 -0
  222. shikiru/plugins/widgets/usage.py +176 -0
  223. shikiru/private.py +94 -0
  224. shikiru/process.py +535 -0
  225. shikiru/prompts/__init__.py +3 -0
  226. shikiru/prompts/renderer.py +85 -0
  227. shikiru/prompts/templates/_base.md.j2 +17 -0
  228. shikiru/prompts/templates/_macros.md.j2 +61 -0
  229. shikiru/prompts/templates/conflicts.md.j2 +22 -0
  230. shikiru/prompts/templates/consult.md.j2 +5 -0
  231. shikiru/prompts/templates/continue.md.j2 +4 -0
  232. shikiru/prompts/templates/feedback_analysis.md.j2 +29 -0
  233. shikiru/prompts/templates/feedback_rework.md.j2 +33 -0
  234. shikiru/prompts/templates/feedback_verify.md.j2 +33 -0
  235. shikiru/prompts/templates/handoff.md.j2 +23 -0
  236. shikiru/prompts/templates/implement.md.j2 +13 -0
  237. shikiru/prompts/templates/plan.md.j2 +30 -0
  238. shikiru/prompts/templates/rework.md.j2 +13 -0
  239. shikiru/prompts/templates/triage.md.j2 +20 -0
  240. shikiru/prompts/templates/verify.md.j2 +29 -0
  241. shikiru/store/__init__.py +1 -0
  242. shikiru/store/commands.py +242 -0
  243. shikiru/store/database.py +297 -0
  244. shikiru/store/escalations.py +240 -0
  245. shikiru/store/events.py +213 -0
  246. shikiru/store/feedback.py +266 -0
  247. shikiru/store/migrations/001_initial.sql +63 -0
  248. shikiru/store/migrations/002_headless_turns.sql +31 -0
  249. shikiru/store/migrations/003_drop_command_handled_at.sql +1 -0
  250. shikiru/store/migrations/004_repo_base_ref.sql +3 -0
  251. shikiru/store/migrations/005_complexity_explicit.sql +3 -0
  252. shikiru/store/migrations/006_events_kind_index.sql +1 -0
  253. shikiru/store/migrations/007_notification_cursor.sql +1 -0
  254. shikiru/store/migrations/008_reported_usage.sql +1 -0
  255. shikiru/store/migrations/009_billing_declarations.sql +2 -0
  256. shikiru/store/migrations/010_canonical_quota_windows.sql +3 -0
  257. shikiru/store/migrations/012_drop_task_quota_evidence.sql +2 -0
  258. shikiru/store/migrations/013_push_targets.sql +7 -0
  259. shikiru/store/migrations/014_agent_block_time.sql +6 -0
  260. shikiru/store/migrations/015_repair_orphan_agent_blocks.sql +3 -0
  261. shikiru/store/migrations/016_feedback_rounds.sql +29 -0
  262. shikiru/store/migrations/017_feedback_round_comments.sql +5 -0
  263. shikiru/store/migrations/__init__.py +1 -0
  264. shikiru/store/notification_cursor.py +29 -0
  265. shikiru/store/pushes.py +117 -0
  266. shikiru/store/quotas.py +153 -0
  267. shikiru/store/reader.py +310 -0
  268. shikiru/store/retention.py +92 -0
  269. shikiru/store/runs.py +227 -0
  270. shikiru/store/tasks.py +949 -0
  271. shikiru/telemetry/__init__.py +1 -0
  272. shikiru/telemetry/disk.py +167 -0
  273. shikiru/telemetry/docker.py +164 -0
  274. shikiru/telemetry/resources.py +296 -0
  275. shikiru/tmux/__init__.py +3 -0
  276. shikiru/tmux/client.py +637 -0
  277. shikiru/tmux/decorator.py +225 -0
  278. shikiru/tmux/layout.py +200 -0
  279. shikiru/tmux/session.py +381 -0
  280. shikiru/ui/__init__.py +9 -0
  281. shikiru/ui/age.py +45 -0
  282. shikiru/ui/bubbles.py +41 -0
  283. shikiru/ui/compact.py +1209 -0
  284. shikiru/ui/console.py +49 -0
  285. shikiru/ui/fold.py +50 -0
  286. shikiru/ui/gated_static.py +117 -0
  287. shikiru/ui/gauge.py +40 -0
  288. shikiru/ui/glyphs.py +109 -0
  289. shikiru/ui/keys.py +57 -0
  290. shikiru/ui/live_interval.py +42 -0
  291. shikiru/ui/popup.py +311 -0
  292. shikiru/ui/quota_status.py +26 -0
  293. shikiru/ui/recent_window.py +78 -0
  294. shikiru/ui/screen.py +57 -0
  295. shikiru/ui/section.py +193 -0
  296. shikiru/ui/session.py +65 -0
  297. shikiru/ui/size.py +17 -0
  298. shikiru/ui/splash.py +42 -0
  299. shikiru/ui/status.py +47 -0
  300. shikiru/ui/status_strip.py +149 -0
  301. shikiru/ui/stream.py +152 -0
  302. shikiru/ui/strip.py +332 -0
  303. shikiru/ui/theme.py +83 -0
  304. shikiru/ui/tokens.py +51 -0
  305. shikiru/view_log.py +126 -0
  306. shikiru/workspace/__init__.py +3 -0
  307. shikiru/workspace/base_refs.py +160 -0
  308. shikiru/workspace/caches.py +136 -0
  309. shikiru/workspace/facts.py +118 -0
  310. shikiru/workspace/git.py +601 -0
  311. shikiru/workspace/github.py +744 -0
  312. shikiru/workspace/messages.py +107 -0
  313. shikiru/workspace/reports.py +145 -0
  314. shikiru/workspace/roots.py +180 -0
  315. shikiru_cli-0.2.0.dist-info/METADATA +428 -0
  316. shikiru_cli-0.2.0.dist-info/RECORD +319 -0
  317. shikiru_cli-0.2.0.dist-info/WHEEL +4 -0
  318. shikiru_cli-0.2.0.dist-info/entry_points.txt +18 -0
  319. shikiru_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,744 @@
1
+ """The only place gh argv is built: pull requests for `integration = "pr"`, and their feedback."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from dataclasses import dataclass
8
+ from datetime import datetime
9
+ from enum import StrEnum
10
+ from typing import TYPE_CHECKING, Any, cast
11
+
12
+ from shikiru.errors import ShikiruError
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Awaitable, Callable, Iterable
16
+ from pathlib import Path
17
+
18
+ from shikiru.process import ProcessResult, SubprocessRunner
19
+
20
+ _GH_BIN = "gh"
21
+ _COMMAND_TIMEOUT_SECONDS = 60.0
22
+ _OPEN_STATE_FILTER = "open"
23
+ _CONFLICTING = "CONFLICTING"
24
+ _CHECK_COMPLETED = "COMPLETED"
25
+ _FAILING_CHECK_RESULTS = frozenset(
26
+ ("FAILURE", "ERROR", "TIMED_OUT", "CANCELLED", "STARTUP_FAILURE", "ACTION_REQUIRED")
27
+ )
28
+ _PENDING_CHECK_STATES = frozenset(("PENDING", "EXPECTED"))
29
+ _GITHUB_MAX_PAGE_SIZE = 100
30
+ _DECIMAL_DIGITS = re.compile(r"[0-9]+")
31
+ _PULL_REQUEST_URL = re.compile(
32
+ r"https://(?P<host>[A-Za-z0-9.-]+)/(?P<owner>[A-Za-z0-9_.-]+)/(?P<repo>[A-Za-z0-9_.-]+)"
33
+ r"/pull/(?P<number>[0-9]+)"
34
+ )
35
+ _MALFORMED_RESPONSE = "gh returned an unexpected response"
36
+ _NO_COMMENT_TO_REPLY_TO = "review thread has no comment to reply to"
37
+ _GRAPHQL_ERRORS = "GitHub GraphQL returned errors"
38
+ _REST_POST_METHOD = "POST"
39
+ _NAMED_FIELD_FLAG = "-f"
40
+ _TYPED_FIELD_FLAG = "-F"
41
+
42
+ _REVIEW_COMMENT_FRAGMENT = """
43
+ fragment ReviewComment on PullRequestReviewComment {
44
+ id
45
+ fullDatabaseId
46
+ url
47
+ body
48
+ createdAt
49
+ author { login }
50
+ pullRequestReview { id }
51
+ }
52
+ """
53
+
54
+ _HEAD_QUERY = """
55
+ query PullRequestHead($owner: String!, $repo: String!, $number: Int!) {
56
+ repository(owner: $owner, name: $repo) {
57
+ pullRequest(number: $number) {
58
+ state
59
+ headRefOid
60
+ }
61
+ }
62
+ }
63
+ """
64
+
65
+ _REVIEWS_QUERY = """
66
+ query PullRequestReviews(
67
+ $owner: String!, $repo: String!, $number: Int!, $pageSize: Int!, $cursor: String
68
+ ) {
69
+ repository(owner: $owner, name: $repo) {
70
+ pullRequest(number: $number) {
71
+ reviews(first: $pageSize, after: $cursor) {
72
+ pageInfo { hasNextPage endCursor }
73
+ nodes { id url state body submittedAt author { login } }
74
+ }
75
+ }
76
+ }
77
+ }
78
+ """
79
+
80
+ _THREADS_QUERY = (
81
+ _REVIEW_COMMENT_FRAGMENT
82
+ + """
83
+ query PullRequestReviewThreads(
84
+ $owner: String!, $repo: String!, $number: Int!, $pageSize: Int!, $cursor: String
85
+ ) {
86
+ repository(owner: $owner, name: $repo) {
87
+ pullRequest(number: $number) {
88
+ reviewThreads(first: $pageSize, after: $cursor) {
89
+ pageInfo { hasNextPage endCursor }
90
+ nodes {
91
+ id
92
+ path
93
+ line
94
+ isOutdated
95
+ isResolved
96
+ comments(first: $pageSize) {
97
+ pageInfo { hasNextPage endCursor }
98
+ nodes { ...ReviewComment }
99
+ }
100
+ }
101
+ }
102
+ }
103
+ }
104
+ }
105
+ """
106
+ )
107
+
108
+ _THREAD_COMMENTS_QUERY = (
109
+ _REVIEW_COMMENT_FRAGMENT
110
+ + """
111
+ query ReviewThreadComments($threadId: ID!, $pageSize: Int!, $cursor: String) {
112
+ node(id: $threadId) {
113
+ ... on PullRequestReviewThread {
114
+ comments(first: $pageSize, after: $cursor) {
115
+ pageInfo { hasNextPage endCursor }
116
+ nodes { ...ReviewComment }
117
+ }
118
+ }
119
+ }
120
+ }
121
+ """
122
+ )
123
+
124
+ _CONVERSATION_QUERY = """
125
+ query PullRequestConversation(
126
+ $owner: String!, $repo: String!, $number: Int!, $pageSize: Int!, $cursor: String
127
+ ) {
128
+ repository(owner: $owner, name: $repo) {
129
+ pullRequest(number: $number) {
130
+ comments(first: $pageSize, after: $cursor) {
131
+ pageInfo { hasNextPage endCursor }
132
+ nodes { id fullDatabaseId url body createdAt author { login } }
133
+ }
134
+ }
135
+ }
136
+ }
137
+ """
138
+
139
+
140
+ class _Key(StrEnum):
141
+ """The JSON keys gh and GitHub responses use, named once."""
142
+
143
+ AUTHOR = "author"
144
+ BODY = "body"
145
+ CHECKS = "statusCheckRollup"
146
+ COMMENTS = "comments"
147
+ CONCLUSION = "conclusion"
148
+ CREATED_AT = "createdAt"
149
+ DATA = "data"
150
+ END_CURSOR = "endCursor"
151
+ ERRORS = "errors"
152
+ FULL_DATABASE_ID = "fullDatabaseId"
153
+ HAS_NEXT_PAGE = "hasNextPage"
154
+ HEAD_SHA = "headRefOid"
155
+ HTML_URL = "html_url"
156
+ ID = "id"
157
+ IS_OUTDATED = "isOutdated"
158
+ IS_RESOLVED = "isResolved"
159
+ LINE = "line"
160
+ LOGIN = "login"
161
+ MERGE_COMMIT = "mergeCommit"
162
+ MERGEABLE = "mergeable"
163
+ NODE = "node"
164
+ NODE_ID = "node_id"
165
+ NODES = "nodes"
166
+ OID = "oid"
167
+ PAGE_INFO = "pageInfo"
168
+ PATH = "path"
169
+ PULL_REQUEST = "pullRequest"
170
+ REPOSITORY = "repository"
171
+ REST_CREATED_AT = "created_at"
172
+ REVIEW = "pullRequestReview"
173
+ REVIEW_THREADS = "reviewThreads"
174
+ REVIEWS = "reviews"
175
+ STATE = "state"
176
+ STATUS = "status"
177
+ SUBMITTED_AT = "submittedAt"
178
+ URL = "url"
179
+ USER = "user"
180
+
181
+
182
+ class PullRequestState(StrEnum):
183
+ """The pull request states `gh pr view --json state` reports."""
184
+
185
+ OPEN = "OPEN"
186
+ CLOSED = "CLOSED"
187
+ MERGED = "MERGED"
188
+
189
+
190
+ class ReviewState(StrEnum):
191
+ """The states GitHub reports for a submitted or unsubmitted pull request review."""
192
+
193
+ PENDING = "PENDING"
194
+ COMMENTED = "COMMENTED"
195
+ APPROVED = "APPROVED"
196
+ CHANGES_REQUESTED = "CHANGES_REQUESTED"
197
+ DISMISSED = "DISMISSED"
198
+
199
+
200
+ @dataclass(frozen=True, slots=True)
201
+ class PullRequestStatus:
202
+ """A pull request's state, whether it can land, and the commit it landed as once merged.
203
+
204
+ `checks_failing` is true when any check failed; `checks_pending` when any check has not
205
+ completed yet, so a failure is final only while nothing is pending.
206
+ """
207
+
208
+ state: PullRequestState
209
+ merge_commit: str | None
210
+ conflicting: bool
211
+ checks_failing: bool
212
+ checks_pending: bool
213
+
214
+
215
+ @dataclass(frozen=True, slots=True)
216
+ class Review:
217
+ """A pull request review: its state, author, submission time and body, and its page URL."""
218
+
219
+ id: str
220
+ url: str
221
+ state: ReviewState
222
+ author: str | None
223
+ submitted_at: datetime | None
224
+ body: str
225
+
226
+
227
+ @dataclass(frozen=True, slots=True)
228
+ class PullRequestComment:
229
+ """A comment in an inline discussion or the PR conversation, with the IDs needed to reply.
230
+
231
+ `review_id` is the review an inline comment was submitted in. It is None for conversation
232
+ comments and for replies this engine posts, which are not part of a review.
233
+ """
234
+
235
+ id: str
236
+ database_id: int
237
+ review_id: str | None
238
+ url: str
239
+ author: str | None
240
+ body: str
241
+ created_at: datetime
242
+
243
+
244
+ @dataclass(frozen=True, slots=True)
245
+ class ReviewThread:
246
+ """An inline discussion: where it sits, whether it is outdated or resolved, and its comments.
247
+
248
+ Each comment carries the review it was submitted in, so a discussion reopened by a later
249
+ review shows which comments that review added. Outdated and resolved are separate facts:
250
+ an outdated discussion may still be unresolved.
251
+ """
252
+
253
+ id: str
254
+ path: str
255
+ line: int | None
256
+ is_outdated: bool
257
+ is_resolved: bool
258
+ comments: tuple[PullRequestComment, ...]
259
+
260
+ @property
261
+ def reply_to_id(self) -> int:
262
+ """The database ID a reply to this discussion must name: its first comment's."""
263
+ if not self.comments:
264
+ raise ShikiruError(_NO_COMMENT_TO_REPLY_TO)
265
+ return self.comments[0].database_id
266
+
267
+
268
+ @dataclass(frozen=True, slots=True)
269
+ class PullRequestFeedback:
270
+ """The PR's state, head commit, submitted reviews and their inline discussions."""
271
+
272
+ url: str
273
+ state: PullRequestState
274
+ head_sha: str
275
+ reviews: tuple[Review, ...]
276
+ threads: tuple[ReviewThread, ...]
277
+
278
+
279
+ @dataclass(frozen=True, slots=True)
280
+ class _PullRequestAddress:
281
+ host: str
282
+ owner: str
283
+ repo: str
284
+ number: int
285
+
286
+ @classmethod
287
+ def parse(cls, url: str) -> _PullRequestAddress:
288
+ match = _PULL_REQUEST_URL.fullmatch(url)
289
+ if match is None:
290
+ message = f"not a pull request URL: {url}"
291
+ raise ShikiruError(message)
292
+ return cls(
293
+ host=match.group("host"),
294
+ owner=match.group("owner"),
295
+ repo=match.group("repo"),
296
+ number=int(match.group("number")),
297
+ )
298
+
299
+
300
+ @dataclass(frozen=True, slots=True)
301
+ class _Page:
302
+ nodes: tuple[dict[str, Any], ...]
303
+ next_cursor: str | None
304
+
305
+
306
+ def _check_pending(check: dict[str, Any]) -> bool:
307
+ if _Key.STATUS in check:
308
+ return check[_Key.STATUS] != _CHECK_COMPLETED
309
+ return check.get(_Key.STATE) in _PENDING_CHECK_STATES
310
+
311
+
312
+ def _malformed(key: _Key) -> ShikiruError:
313
+ return ShikiruError(f"{_MALFORMED_RESPONSE}: {key.value}")
314
+
315
+
316
+ def _json(text: str) -> object:
317
+ try:
318
+ return json.loads(text)
319
+ except json.JSONDecodeError as error:
320
+ raise ShikiruError(_MALFORMED_RESPONSE) from error
321
+
322
+
323
+ def _object(value: object) -> dict[str, Any]:
324
+ if not isinstance(value, dict):
325
+ raise ShikiruError(_MALFORMED_RESPONSE)
326
+ return cast("dict[str, Any]", value)
327
+
328
+
329
+ def _field(node: dict[str, Any], key: _Key) -> object:
330
+ if key not in node:
331
+ raise _malformed(key)
332
+ return node[key]
333
+
334
+
335
+ def _text(node: dict[str, Any], key: _Key) -> str:
336
+ value = _field(node, key)
337
+ if not isinstance(value, str):
338
+ raise _malformed(key)
339
+ return value
340
+
341
+
342
+ def _optional_text(node: dict[str, Any], key: _Key) -> str | None:
343
+ value = _field(node, key)
344
+ if value is None or isinstance(value, str):
345
+ return value
346
+ raise _malformed(key)
347
+
348
+
349
+ def _integer(node: dict[str, Any], key: _Key) -> int:
350
+ value = _field(node, key)
351
+ if isinstance(value, int) and not isinstance(value, bool):
352
+ return value
353
+ raise _malformed(key)
354
+
355
+
356
+ def _optional_integer(node: dict[str, Any], key: _Key) -> int | None:
357
+ if _field(node, key) is None:
358
+ return None
359
+ return _integer(node, key)
360
+
361
+
362
+ def _big_integer(node: dict[str, Any], key: _Key) -> int:
363
+ value = _text(node, key)
364
+ if not _DECIMAL_DIGITS.fullmatch(value):
365
+ raise _malformed(key)
366
+ return int(value)
367
+
368
+
369
+ def _flag(node: dict[str, Any], key: _Key) -> bool:
370
+ value = _field(node, key)
371
+ if isinstance(value, bool):
372
+ return value
373
+ raise _malformed(key)
374
+
375
+
376
+ def _list_field(node: dict[str, Any], key: _Key) -> list[Any]:
377
+ value = _field(node, key)
378
+ if isinstance(value, list):
379
+ return cast("list[Any]", value)
380
+ raise _malformed(key)
381
+
382
+
383
+ def _object_field(node: dict[str, Any], key: _Key) -> dict[str, Any] | None:
384
+ value = _field(node, key)
385
+ return None if value is None else _object(value)
386
+
387
+
388
+ def _moment(node: dict[str, Any], key: _Key) -> datetime:
389
+ text = _text(node, key)
390
+ try:
391
+ return datetime.fromisoformat(text)
392
+ except ValueError as error:
393
+ raise _malformed(key) from error
394
+
395
+
396
+ def _optional_moment(node: dict[str, Any], key: _Key) -> datetime | None:
397
+ if _field(node, key) is None:
398
+ return None
399
+ return _moment(node, key)
400
+
401
+
402
+ def _enum[E: StrEnum](enum_type: type[E], node: dict[str, Any], key: _Key) -> E:
403
+ try:
404
+ return enum_type(_text(node, key))
405
+ except ValueError as error:
406
+ raise _malformed(key) from error
407
+
408
+
409
+ def _author(node: dict[str, Any]) -> str | None:
410
+ author = _object_field(node, _Key.AUTHOR)
411
+ return None if author is None else _text(author, _Key.LOGIN)
412
+
413
+
414
+ def _review(node: dict[str, Any]) -> Review:
415
+ return Review(
416
+ id=_text(node, _Key.ID),
417
+ url=_text(node, _Key.URL),
418
+ state=_enum(ReviewState, node, _Key.STATE),
419
+ author=_author(node),
420
+ submitted_at=_optional_moment(node, _Key.SUBMITTED_AT),
421
+ body=_text(node, _Key.BODY),
422
+ )
423
+
424
+
425
+ def _comment(node: dict[str, Any], review_id: str | None) -> PullRequestComment:
426
+ return PullRequestComment(
427
+ id=_text(node, _Key.ID),
428
+ database_id=_big_integer(node, _Key.FULL_DATABASE_ID),
429
+ review_id=review_id,
430
+ url=_text(node, _Key.URL),
431
+ author=_author(node),
432
+ body=_text(node, _Key.BODY),
433
+ created_at=_moment(node, _Key.CREATED_AT),
434
+ )
435
+
436
+
437
+ def _review_comment(node: dict[str, Any]) -> PullRequestComment:
438
+ review = _object_field(node, _Key.REVIEW)
439
+ return _comment(node, None if review is None else _text(review, _Key.ID))
440
+
441
+
442
+ def _conversation_comment(node: dict[str, Any]) -> PullRequestComment:
443
+ return _comment(node, None)
444
+
445
+
446
+ def _rest_comment(payload: dict[str, Any]) -> PullRequestComment:
447
+ user = _object_field(payload, _Key.USER)
448
+ return PullRequestComment(
449
+ id=_text(payload, _Key.NODE_ID),
450
+ database_id=_integer(payload, _Key.ID),
451
+ review_id=None,
452
+ url=_text(payload, _Key.HTML_URL),
453
+ author=None if user is None else _text(user, _Key.LOGIN),
454
+ body=_text(payload, _Key.BODY),
455
+ created_at=_moment(payload, _Key.REST_CREATED_AT),
456
+ )
457
+
458
+
459
+ def _page(connection: object) -> _Page:
460
+ container = _object(connection)
461
+ info = _object(_field(container, _Key.PAGE_INFO))
462
+ nodes = tuple(_object(node) for node in _list_field(container, _Key.NODES))
463
+ has_next = _flag(info, _Key.HAS_NEXT_PAGE)
464
+ cursor = _optional_text(info, _Key.END_CURSOR)
465
+ if has_next and cursor is None:
466
+ raise _malformed(_Key.END_CURSOR)
467
+ return _Page(nodes, cursor if has_next else None)
468
+
469
+
470
+ async def _collect(
471
+ first: _Page, fetch: Callable[[str], Awaitable[_Page]]
472
+ ) -> tuple[dict[str, Any], ...]:
473
+ nodes = list(first.nodes)
474
+ page = first
475
+ while page.next_cursor is not None:
476
+ page = await fetch(page.next_cursor)
477
+ nodes.extend(page.nodes)
478
+ return tuple(nodes)
479
+
480
+
481
+ def _pull_request_node(data: dict[str, Any]) -> dict[str, Any]:
482
+ repository = _object(_field(data, _Key.REPOSITORY))
483
+ return _object(_field(repository, _Key.PULL_REQUEST))
484
+
485
+
486
+ def _marked(comments: Iterable[PullRequestComment], marker: str) -> PullRequestComment | None:
487
+ return next((comment for comment in comments if marker in comment.body), None)
488
+
489
+
490
+ def _address_variables(address: _PullRequestAddress) -> list[str]:
491
+ return [
492
+ _NAMED_FIELD_FLAG,
493
+ f"owner={address.owner}",
494
+ _NAMED_FIELD_FLAG,
495
+ f"repo={address.repo}",
496
+ _TYPED_FIELD_FLAG,
497
+ f"number={address.number}",
498
+ ]
499
+
500
+
501
+ def _page_variables(cursor: str | None) -> list[str]:
502
+ variables = [_TYPED_FIELD_FLAG, f"pageSize={_GITHUB_MAX_PAGE_SIZE}"]
503
+ if cursor is not None:
504
+ variables += [_NAMED_FIELD_FLAG, f"cursor={cursor}"]
505
+ return variables
506
+
507
+
508
+ def _thread_variables(thread_id: str) -> list[str]:
509
+ return [_NAMED_FIELD_FLAG, f"threadId={thread_id}"]
510
+
511
+
512
+ class GitHub:
513
+ """A small wrapper over the gh CLI, always through `SubprocessRunner`."""
514
+
515
+ def __init__(self, runner: SubprocessRunner) -> None:
516
+ """Run every gh command through `runner`, never a shell."""
517
+ self._runner = runner
518
+
519
+ async def open_pull_request(self, repo_dir: Path, *, base: str, head: str) -> str | None:
520
+ """Return the URL of an open pull request of `head` into `base`, if there is one."""
521
+ result = await self._run(
522
+ repo_dir,
523
+ "pr",
524
+ "list",
525
+ "--head",
526
+ head,
527
+ "--base",
528
+ base,
529
+ "--state",
530
+ _OPEN_STATE_FILTER,
531
+ "--json",
532
+ _Key.URL,
533
+ )
534
+ pulls = json.loads(result.stdout)
535
+ return pulls[0][_Key.URL] if pulls else None
536
+
537
+ async def create_pull_request(
538
+ self, repo_dir: Path, *, base: str, head: str, title: str, body: str
539
+ ) -> str:
540
+ """Open a pull request of pushed branch `head` into `base` and return its URL."""
541
+ result = await self._run(
542
+ repo_dir,
543
+ "pr",
544
+ "create",
545
+ "--base",
546
+ base,
547
+ "--head",
548
+ head,
549
+ "--title",
550
+ title,
551
+ "--body",
552
+ body,
553
+ )
554
+ lines = result.stdout.split()
555
+ if not lines:
556
+ message = f"{_GH_BIN} pr create printed no pull request URL"
557
+ raise ShikiruError(message)
558
+ return lines[-1]
559
+
560
+ async def pull_request_status(self, repo_dir: Path, url: str) -> PullRequestStatus:
561
+ """Return the state, merge commit, conflict and check results of the PR at `url`."""
562
+ fields = (_Key.STATE, _Key.MERGE_COMMIT, _Key.MERGEABLE, _Key.CHECKS)
563
+ result = await self._run(repo_dir, "pr", "view", url, "--json", ",".join(fields))
564
+ view = json.loads(result.stdout)
565
+ merge_commit = view[_Key.MERGE_COMMIT]
566
+ return PullRequestStatus(
567
+ state=PullRequestState(view[_Key.STATE]),
568
+ merge_commit=merge_commit[_Key.OID] if merge_commit is not None else None,
569
+ conflicting=view[_Key.MERGEABLE] == _CONFLICTING,
570
+ checks_failing=any(
571
+ {check.get(_Key.CONCLUSION), check.get(_Key.STATE)} & _FAILING_CHECK_RESULTS
572
+ for check in view[_Key.CHECKS]
573
+ ),
574
+ checks_pending=any(_check_pending(check) for check in view[_Key.CHECKS]),
575
+ )
576
+
577
+ async def close_pull_request(self, repo_dir: Path, url: str) -> None:
578
+ """Close the open pull request at `url` without merging it."""
579
+ await self._run(repo_dir, "pr", "close", url)
580
+
581
+ async def pull_request_feedback(self, repo_dir: Path, pr_url: str) -> PullRequestFeedback:
582
+ """Return the PR's state, head commit, every submitted review and every inline discussion.
583
+
584
+ Every page of reviews, discussions and discussion replies is read. Anything GitHub
585
+ reports that cannot be parsed raises `ShikiruError` rather than reading as no feedback.
586
+ """
587
+ address = _PullRequestAddress.parse(pr_url)
588
+ head = _pull_request_node(
589
+ await self._graphql(repo_dir, address.host, _HEAD_QUERY, _address_variables(address))
590
+ )
591
+ raw_reviews = await self._all_nodes(repo_dir, address, _REVIEWS_QUERY, _Key.REVIEWS)
592
+ raw_threads = await self._all_nodes(repo_dir, address, _THREADS_QUERY, _Key.REVIEW_THREADS)
593
+ threads = [await self._thread(repo_dir, address.host, raw) for raw in raw_threads]
594
+ return PullRequestFeedback(
595
+ url=pr_url,
596
+ state=_enum(PullRequestState, head, _Key.STATE),
597
+ head_sha=_text(head, _Key.HEAD_SHA),
598
+ reviews=tuple(_review(raw) for raw in raw_reviews),
599
+ threads=tuple(threads),
600
+ )
601
+
602
+ async def reply_to_thread(
603
+ self, repo_dir: Path, pr_url: str, *, thread: ReviewThread, body: str
604
+ ) -> PullRequestComment:
605
+ """Post `body` as a reply in `thread`, which stays attached even if its code is outdated."""
606
+ address = _PullRequestAddress.parse(pr_url)
607
+ return await self._post(
608
+ repo_dir,
609
+ address,
610
+ f"pulls/{address.number}/comments",
611
+ body,
612
+ _TYPED_FIELD_FLAG,
613
+ f"in_reply_to={thread.reply_to_id}",
614
+ )
615
+
616
+ async def post_conversation_comment(
617
+ self, repo_dir: Path, pr_url: str, *, body: str
618
+ ) -> PullRequestComment:
619
+ """Post `body` as a comment in the PR conversation and return the posted comment."""
620
+ address = _PullRequestAddress.parse(pr_url)
621
+ return await self._post(repo_dir, address, f"issues/{address.number}/comments", body)
622
+
623
+ async def find_thread_reply(
624
+ self, repo_dir: Path, pr_url: str, *, thread: ReviewThread, marker: str
625
+ ) -> PullRequestComment | None:
626
+ """Return the comment in `thread` whose body carries `marker`, or None if there is none."""
627
+ address = _PullRequestAddress.parse(pr_url)
628
+ first = await self._thread_comment_page(repo_dir, address.host, thread.id, None)
629
+ nodes = await _collect(
630
+ first,
631
+ lambda cursor: self._thread_comment_page(repo_dir, address.host, thread.id, cursor),
632
+ )
633
+ return _marked((_review_comment(node) for node in nodes), marker)
634
+
635
+ async def find_conversation_comment(
636
+ self, repo_dir: Path, pr_url: str, *, marker: str
637
+ ) -> PullRequestComment | None:
638
+ """Return the PR conversation comment whose body carries `marker`, or None if none does."""
639
+ address = _PullRequestAddress.parse(pr_url)
640
+ nodes = await self._all_nodes(repo_dir, address, _CONVERSATION_QUERY, _Key.COMMENTS)
641
+ return _marked((_conversation_comment(node) for node in nodes), marker)
642
+
643
+ async def _all_nodes(
644
+ self,
645
+ repo_dir: Path,
646
+ address: _PullRequestAddress,
647
+ query: str,
648
+ connection: _Key,
649
+ ) -> tuple[dict[str, Any], ...]:
650
+ first = await self._pull_request_page(repo_dir, address, query, connection, None)
651
+ return await _collect(
652
+ first,
653
+ lambda cursor: self._pull_request_page(repo_dir, address, query, connection, cursor),
654
+ )
655
+
656
+ async def _pull_request_page(
657
+ self,
658
+ repo_dir: Path,
659
+ address: _PullRequestAddress,
660
+ query: str,
661
+ connection: _Key,
662
+ cursor: str | None,
663
+ ) -> _Page:
664
+ data = await self._graphql(
665
+ repo_dir,
666
+ address.host,
667
+ query,
668
+ [*_address_variables(address), *_page_variables(cursor)],
669
+ )
670
+ return _page(_field(_pull_request_node(data), connection))
671
+
672
+ async def _thread(self, repo_dir: Path, host: str, node: dict[str, Any]) -> ReviewThread:
673
+ thread_id = _text(node, _Key.ID)
674
+ first = _page(_field(node, _Key.COMMENTS))
675
+ raw_comments = await _collect(
676
+ first,
677
+ lambda cursor: self._thread_comment_page(repo_dir, host, thread_id, cursor),
678
+ )
679
+ return ReviewThread(
680
+ id=thread_id,
681
+ path=_text(node, _Key.PATH),
682
+ line=_optional_integer(node, _Key.LINE),
683
+ is_outdated=_flag(node, _Key.IS_OUTDATED),
684
+ is_resolved=_flag(node, _Key.IS_RESOLVED),
685
+ comments=tuple(_review_comment(raw) for raw in raw_comments),
686
+ )
687
+
688
+ async def _thread_comment_page(
689
+ self, repo_dir: Path, host: str, thread_id: str, cursor: str | None
690
+ ) -> _Page:
691
+ data = await self._graphql(
692
+ repo_dir,
693
+ host,
694
+ _THREAD_COMMENTS_QUERY,
695
+ [*_thread_variables(thread_id), *_page_variables(cursor)],
696
+ )
697
+ return _page(_field(_object(_field(data, _Key.NODE)), _Key.COMMENTS))
698
+
699
+ async def _post(
700
+ self,
701
+ repo_dir: Path,
702
+ address: _PullRequestAddress,
703
+ resource: str,
704
+ body: str,
705
+ *typed_fields: str,
706
+ ) -> PullRequestComment:
707
+ endpoint = f"repos/{address.owner}/{address.repo}/{resource}"
708
+ result = await self._run(
709
+ repo_dir,
710
+ "api",
711
+ "--method",
712
+ _REST_POST_METHOD,
713
+ "--hostname",
714
+ address.host,
715
+ endpoint,
716
+ _NAMED_FIELD_FLAG,
717
+ f"body={body}",
718
+ *typed_fields,
719
+ )
720
+ return _rest_comment(_object(_json(result.stdout)))
721
+
722
+ async def _graphql(
723
+ self, repo_dir: Path, host: str, query: str, variables: list[str]
724
+ ) -> dict[str, Any]:
725
+ result = await self._run(
726
+ repo_dir,
727
+ "api",
728
+ "--hostname",
729
+ host,
730
+ "graphql",
731
+ _NAMED_FIELD_FLAG,
732
+ f"query={query}",
733
+ *variables,
734
+ )
735
+ response = _object(_json(result.stdout))
736
+ if _Key.ERRORS in response:
737
+ message = f"{_GRAPHQL_ERRORS}: {response[_Key.ERRORS]}"
738
+ raise ShikiruError(message)
739
+ return _object(_field(response, _Key.DATA))
740
+
741
+ async def _run(self, repo_dir: Path, *args: str) -> ProcessResult:
742
+ return await self._runner.run(
743
+ [_GH_BIN, *args], timeout=_COMMAND_TIMEOUT_SECONDS, cwd=repo_dir, check=True
744
+ )