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.
- shikiru/__init__.py +1 -0
- shikiru/__main__.py +8 -0
- shikiru/agents/__init__.py +3 -0
- shikiru/agents/contracts.py +590 -0
- shikiru/agents/registry.py +87 -0
- shikiru/agents/sdk/__init__.py +17 -0
- shikiru/agents/sdk/reset_time.py +137 -0
- shikiru/agents/sdk/turn.py +335 -0
- shikiru/agents/settings.py +151 -0
- shikiru/agents/testing/__init__.py +26 -0
- shikiru/agents/testing/contract_suite.py +653 -0
- shikiru/agents/testing/resume.py +15 -0
- shikiru/bootstrap.py +1277 -0
- shikiru/cli/__init__.py +1 -0
- shikiru/cli/app.py +770 -0
- shikiru/cli/context.py +63 -0
- shikiru/cli/render.py +1475 -0
- shikiru/cli/report.py +156 -0
- shikiru/cli/session_prompt.py +63 -0
- shikiru/cli/view.py +131 -0
- shikiru/clock.py +41 -0
- shikiru/config/__init__.py +8 -0
- shikiru/config/defaults.toml +131 -0
- shikiru/config/loader.py +178 -0
- shikiru/config/settings.py +392 -0
- shikiru/dashboard/__init__.py +3 -0
- shikiru/dashboard/actions_list.py +799 -0
- shikiru/dashboard/activity.py +150 -0
- shikiru/dashboard/app.py +1765 -0
- shikiru/dashboard/blocker_evidence.py +122 -0
- shikiru/dashboard/blocker_rows.py +84 -0
- shikiru/dashboard/briefing_view.py +871 -0
- shikiru/dashboard/contract.py +1086 -0
- shikiru/dashboard/covered.py +32 -0
- shikiru/dashboard/detail.py +1209 -0
- shikiru/dashboard/detail_fold.py +244 -0
- shikiru/dashboard/detail_hint.py +68 -0
- shikiru/dashboard/details_list_view.py +150 -0
- shikiru/dashboard/diagnostics.py +80 -0
- shikiru/dashboard/diagnostics_sections.py +186 -0
- shikiru/dashboard/evidence_table.py +123 -0
- shikiru/dashboard/extra_details.py +214 -0
- shikiru/dashboard/fault_guidance.py +216 -0
- shikiru/dashboard/fold_bodies.py +276 -0
- shikiru/dashboard/help_keys.py +166 -0
- shikiru/dashboard/help_screen.py +204 -0
- shikiru/dashboard/interaction.py +17 -0
- shikiru/dashboard/ladder_editor.py +544 -0
- shikiru/dashboard/live_settings.py +62 -0
- shikiru/dashboard/motion.py +368 -0
- shikiru/dashboard/orchestration_fields.py +201 -0
- shikiru/dashboard/palette.py +1606 -0
- shikiru/dashboard/palette_app.py +89 -0
- shikiru/dashboard/palette_drafts.py +33 -0
- shikiru/dashboard/resolution.py +573 -0
- shikiru/dashboard/resolution_view.py +302 -0
- shikiru/dashboard/row_list.py +256 -0
- shikiru/dashboard/section_rows.py +173 -0
- shikiru/dashboard/services.py +665 -0
- shikiru/dashboard/session_history.py +402 -0
- shikiru/dashboard/shikiru_init_ascii_art.txt +40 -0
- shikiru/dashboard/task_picker.py +358 -0
- shikiru/dashboard/tick.py +59 -0
- shikiru/dashboard/timeline_filter.py +129 -0
- shikiru/dashboard/timeline_section.py +385 -0
- shikiru/dashboard/usage_body.py +269 -0
- shikiru/dashboard/views.py +2314 -0
- shikiru/dashboard/wizard.py +1389 -0
- shikiru/domain/__init__.py +1 -0
- shikiru/domain/acceptance.py +130 -0
- shikiru/domain/activity.py +28 -0
- shikiru/domain/attempts.py +333 -0
- shikiru/domain/briefing.py +257 -0
- shikiru/domain/commands.py +219 -0
- shikiru/domain/decisions.py +231 -0
- shikiru/domain/docker.py +262 -0
- shikiru/domain/engine_uptime.py +87 -0
- shikiru/domain/escalation.py +194 -0
- shikiru/domain/escalation_evidence.py +186 -0
- shikiru/domain/escalation_panel.py +144 -0
- shikiru/domain/events.py +71 -0
- shikiru/domain/evidence_export.py +89 -0
- shikiru/domain/faults.py +86 -0
- shikiru/domain/feedback.py +180 -0
- shikiru/domain/feedback_rounds.py +172 -0
- shikiru/domain/fencing.py +31 -0
- shikiru/domain/ladder.py +102 -0
- shikiru/domain/ladder_text.py +44 -0
- shikiru/domain/levels.py +44 -0
- shikiru/domain/motion.py +199 -0
- shikiru/domain/packet_evidence.py +377 -0
- shikiru/domain/pushes.py +47 -0
- shikiru/domain/quota_blocks.py +44 -0
- shikiru/domain/quota_waits.py +219 -0
- shikiru/domain/recovery.py +194 -0
- shikiru/domain/reports.py +498 -0
- shikiru/domain/results.py +217 -0
- shikiru/domain/runs.py +85 -0
- shikiru/domain/scheduling.py +231 -0
- shikiru/domain/status_tasks.py +40 -0
- shikiru/domain/steps.py +325 -0
- shikiru/domain/tasks.py +174 -0
- shikiru/domain/timeline.py +208 -0
- shikiru/domain/timeline_phrases.py +271 -0
- shikiru/domain/versions.py +18 -0
- shikiru/domain/workflow.py +372 -0
- shikiru/engine/__init__.py +0 -0
- shikiru/engine/acceptance.py +90 -0
- shikiru/engine/agents.py +61 -0
- shikiru/engine/cosmetic_decorator.py +99 -0
- shikiru/engine/daemon.py +409 -0
- shikiru/engine/dependency.py +37 -0
- shikiru/engine/escalation_filing.py +110 -0
- shikiru/engine/escalations.py +401 -0
- shikiru/engine/events.py +285 -0
- shikiru/engine/evidence.py +81 -0
- shikiru/engine/fault_reporter.py +144 -0
- shikiru/engine/feedback.py +577 -0
- shikiru/engine/feedback_audit.py +80 -0
- shikiru/engine/feedback_intake.py +95 -0
- shikiru/engine/feedback_snapshot.py +148 -0
- shikiru/engine/flow.py +87 -0
- shikiru/engine/handlers/__init__.py +33 -0
- shikiru/engine/handlers/base.py +148 -0
- shikiru/engine/handlers/engine.py +24 -0
- shikiru/engine/handlers/escalations.py +174 -0
- shikiru/engine/handlers/operator.py +81 -0
- shikiru/engine/handlers/tasks.py +237 -0
- shikiru/engine/history.py +128 -0
- shikiru/engine/integration.py +1234 -0
- shikiru/engine/integration_turns.py +337 -0
- shikiru/engine/layout_guard.py +362 -0
- shikiru/engine/lifecycle.py +60 -0
- shikiru/engine/monitor.py +317 -0
- shikiru/engine/notification_delivery.py +124 -0
- shikiru/engine/notification_feed.py +57 -0
- shikiru/engine/notifier.py +91 -0
- shikiru/engine/pull_request_closing.py +117 -0
- shikiru/engine/push_service.py +177 -0
- shikiru/engine/quota_probe_threads.py +79 -0
- shikiru/engine/quota_reconciler.py +210 -0
- shikiru/engine/quota_schedule.py +24 -0
- shikiru/engine/quota_waits.py +102 -0
- shikiru/engine/reconcile.py +91 -0
- shikiru/engine/recovery.py +284 -0
- shikiru/engine/retention.py +102 -0
- shikiru/engine/root_preparation.py +86 -0
- shikiru/engine/scheduler.py +320 -0
- shikiru/engine/session_guard.py +46 -0
- shikiru/engine/sessions.py +95 -0
- shikiru/engine/signalled.py +75 -0
- shikiru/engine/step_runner.py +140 -0
- shikiru/engine/supervisor.py +101 -0
- shikiru/engine/takeover.py +223 -0
- shikiru/engine/task_facts.py +46 -0
- shikiru/engine/tick_guard.py +95 -0
- shikiru/engine/titles.py +297 -0
- shikiru/engine/transitions.py +79 -0
- shikiru/engine/turn_context.py +206 -0
- shikiru/engine/turn_execution.py +30 -0
- shikiru/engine/turn_loop.py +230 -0
- shikiru/engine/turn_processes.py +79 -0
- shikiru/engine/turns.py +457 -0
- shikiru/errors.py +144 -0
- shikiru/helper/__init__.py +3 -0
- shikiru/helper/helper.py +182 -0
- shikiru/helper/models.py +11 -0
- shikiru/mcp/__init__.py +1 -0
- shikiru/mcp/server.py +196 -0
- shikiru/operator/__init__.py +33 -0
- shikiru/operator/attachment.py +24 -0
- shikiru/operator/commands.py +319 -0
- shikiru/operator/detectors.py +374 -0
- shikiru/operator/doctor.py +351 -0
- shikiru/operator/events.py +57 -0
- shikiru/operator/evidence_export.py +498 -0
- shikiru/operator/evidence_writer.py +78 -0
- shikiru/operator/lifecycle.py +294 -0
- shikiru/operator/plugin_checks.py +194 -0
- shikiru/operator/probe.py +287 -0
- shikiru/operator/queries.py +1601 -0
- shikiru/operator/services.py +36 -0
- shikiru/operator/sessions.py +378 -0
- shikiru/operator/strip_views.py +162 -0
- shikiru/operator/timeline.py +416 -0
- shikiru/operator/toml_text.py +62 -0
- shikiru/palette.py +18 -0
- shikiru/paths.py +91 -0
- shikiru/plugins/__init__.py +0 -0
- shikiru/plugins/agents/__init__.py +1 -0
- shikiru/plugins/agents/claude/__init__.py +5 -0
- shikiru/plugins/agents/claude/agent.py +251 -0
- shikiru/plugins/agents/claude/context.py +116 -0
- shikiru/plugins/agents/claude/headless.py +92 -0
- shikiru/plugins/agents/claude/hook.py +57 -0
- shikiru/plugins/agents/claude/model_catalog.py +105 -0
- shikiru/plugins/agents/claude/options.py +62 -0
- shikiru/plugins/agents/claude/turn.py +1018 -0
- shikiru/plugins/agents/codex/__init__.py +5 -0
- shikiru/plugins/agents/codex/agent.py +486 -0
- shikiru/plugins/agents/codex/headless.py +166 -0
- shikiru/plugins/agents/codex/home.py +14 -0
- shikiru/plugins/agents/codex/model_catalog.py +128 -0
- shikiru/plugins/agents/codex/options.py +94 -0
- shikiru/plugins/agents/codex/quota.py +195 -0
- shikiru/plugins/agents/codex/sandbox.py +31 -0
- shikiru/plugins/agents/codex/sandbox_probe.py +97 -0
- shikiru/plugins/agents/codex/turn.py +767 -0
- shikiru/plugins/agents/codex/usage.py +106 -0
- shikiru/plugins/agents/shared_settings.py +62 -0
- shikiru/plugins/widgets/__init__.py +3 -0
- shikiru/plugins/widgets/dialogue.py +144 -0
- shikiru/plugins/widgets/disk.py +123 -0
- shikiru/plugins/widgets/docker.py +225 -0
- shikiru/plugins/widgets/escalations.py +402 -0
- shikiru/plugins/widgets/faults.py +204 -0
- shikiru/plugins/widgets/header.py +321 -0
- shikiru/plugins/widgets/queue.py +1140 -0
- shikiru/plugins/widgets/queue_hint.py +93 -0
- shikiru/plugins/widgets/resources.py +105 -0
- shikiru/plugins/widgets/strips.py +82 -0
- shikiru/plugins/widgets/usage.py +176 -0
- shikiru/private.py +94 -0
- shikiru/process.py +535 -0
- shikiru/prompts/__init__.py +3 -0
- shikiru/prompts/renderer.py +85 -0
- shikiru/prompts/templates/_base.md.j2 +17 -0
- shikiru/prompts/templates/_macros.md.j2 +61 -0
- shikiru/prompts/templates/conflicts.md.j2 +22 -0
- shikiru/prompts/templates/consult.md.j2 +5 -0
- shikiru/prompts/templates/continue.md.j2 +4 -0
- shikiru/prompts/templates/feedback_analysis.md.j2 +29 -0
- shikiru/prompts/templates/feedback_rework.md.j2 +33 -0
- shikiru/prompts/templates/feedback_verify.md.j2 +33 -0
- shikiru/prompts/templates/handoff.md.j2 +23 -0
- shikiru/prompts/templates/implement.md.j2 +13 -0
- shikiru/prompts/templates/plan.md.j2 +30 -0
- shikiru/prompts/templates/rework.md.j2 +13 -0
- shikiru/prompts/templates/triage.md.j2 +20 -0
- shikiru/prompts/templates/verify.md.j2 +29 -0
- shikiru/store/__init__.py +1 -0
- shikiru/store/commands.py +242 -0
- shikiru/store/database.py +297 -0
- shikiru/store/escalations.py +240 -0
- shikiru/store/events.py +213 -0
- shikiru/store/feedback.py +266 -0
- shikiru/store/migrations/001_initial.sql +63 -0
- shikiru/store/migrations/002_headless_turns.sql +31 -0
- shikiru/store/migrations/003_drop_command_handled_at.sql +1 -0
- shikiru/store/migrations/004_repo_base_ref.sql +3 -0
- shikiru/store/migrations/005_complexity_explicit.sql +3 -0
- shikiru/store/migrations/006_events_kind_index.sql +1 -0
- shikiru/store/migrations/007_notification_cursor.sql +1 -0
- shikiru/store/migrations/008_reported_usage.sql +1 -0
- shikiru/store/migrations/009_billing_declarations.sql +2 -0
- shikiru/store/migrations/010_canonical_quota_windows.sql +3 -0
- shikiru/store/migrations/012_drop_task_quota_evidence.sql +2 -0
- shikiru/store/migrations/013_push_targets.sql +7 -0
- shikiru/store/migrations/014_agent_block_time.sql +6 -0
- shikiru/store/migrations/015_repair_orphan_agent_blocks.sql +3 -0
- shikiru/store/migrations/016_feedback_rounds.sql +29 -0
- shikiru/store/migrations/017_feedback_round_comments.sql +5 -0
- shikiru/store/migrations/__init__.py +1 -0
- shikiru/store/notification_cursor.py +29 -0
- shikiru/store/pushes.py +117 -0
- shikiru/store/quotas.py +153 -0
- shikiru/store/reader.py +310 -0
- shikiru/store/retention.py +92 -0
- shikiru/store/runs.py +227 -0
- shikiru/store/tasks.py +949 -0
- shikiru/telemetry/__init__.py +1 -0
- shikiru/telemetry/disk.py +167 -0
- shikiru/telemetry/docker.py +164 -0
- shikiru/telemetry/resources.py +296 -0
- shikiru/tmux/__init__.py +3 -0
- shikiru/tmux/client.py +637 -0
- shikiru/tmux/decorator.py +225 -0
- shikiru/tmux/layout.py +200 -0
- shikiru/tmux/session.py +381 -0
- shikiru/ui/__init__.py +9 -0
- shikiru/ui/age.py +45 -0
- shikiru/ui/bubbles.py +41 -0
- shikiru/ui/compact.py +1209 -0
- shikiru/ui/console.py +49 -0
- shikiru/ui/fold.py +50 -0
- shikiru/ui/gated_static.py +117 -0
- shikiru/ui/gauge.py +40 -0
- shikiru/ui/glyphs.py +109 -0
- shikiru/ui/keys.py +57 -0
- shikiru/ui/live_interval.py +42 -0
- shikiru/ui/popup.py +311 -0
- shikiru/ui/quota_status.py +26 -0
- shikiru/ui/recent_window.py +78 -0
- shikiru/ui/screen.py +57 -0
- shikiru/ui/section.py +193 -0
- shikiru/ui/session.py +65 -0
- shikiru/ui/size.py +17 -0
- shikiru/ui/splash.py +42 -0
- shikiru/ui/status.py +47 -0
- shikiru/ui/status_strip.py +149 -0
- shikiru/ui/stream.py +152 -0
- shikiru/ui/strip.py +332 -0
- shikiru/ui/theme.py +83 -0
- shikiru/ui/tokens.py +51 -0
- shikiru/view_log.py +126 -0
- shikiru/workspace/__init__.py +3 -0
- shikiru/workspace/base_refs.py +160 -0
- shikiru/workspace/caches.py +136 -0
- shikiru/workspace/facts.py +118 -0
- shikiru/workspace/git.py +601 -0
- shikiru/workspace/github.py +744 -0
- shikiru/workspace/messages.py +107 -0
- shikiru/workspace/reports.py +145 -0
- shikiru/workspace/roots.py +180 -0
- shikiru_cli-0.2.0.dist-info/METADATA +428 -0
- shikiru_cli-0.2.0.dist-info/RECORD +319 -0
- shikiru_cli-0.2.0.dist-info/WHEEL +4 -0
- shikiru_cli-0.2.0.dist-info/entry_points.txt +18 -0
- 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
|
+
)
|