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,577 @@
|
|
|
1
|
+
"""Run one PR feedback round: analyse, rework, verify, publish and reply, resumably."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from shikiru.domain.events import EventKind
|
|
8
|
+
from shikiru.domain.feedback import (
|
|
9
|
+
FeedbackInput,
|
|
10
|
+
FeedbackSourceKind,
|
|
11
|
+
FeedbackStep,
|
|
12
|
+
next_feedback_step,
|
|
13
|
+
operator_questions,
|
|
14
|
+
publishable_replies,
|
|
15
|
+
)
|
|
16
|
+
from shikiru.domain.feedback_rounds import (
|
|
17
|
+
SHORT_SHA_LENGTH,
|
|
18
|
+
Alignment,
|
|
19
|
+
FeedbackReply,
|
|
20
|
+
FeedbackRound,
|
|
21
|
+
FeedbackStage,
|
|
22
|
+
PublicationAction,
|
|
23
|
+
ReplyState,
|
|
24
|
+
align_with_remote,
|
|
25
|
+
describe_checks,
|
|
26
|
+
fixed_source_ids,
|
|
27
|
+
plan_publication,
|
|
28
|
+
reply_body,
|
|
29
|
+
reply_marker,
|
|
30
|
+
)
|
|
31
|
+
from shikiru.domain.reports import (
|
|
32
|
+
DECISION_ACCEPT,
|
|
33
|
+
DECISION_CHANGES_REQUESTED,
|
|
34
|
+
DECISION_ESCALATE,
|
|
35
|
+
STATUS_COMPLETED,
|
|
36
|
+
FeedbackAnalysis,
|
|
37
|
+
FeedbackRework,
|
|
38
|
+
FeedbackVerdict,
|
|
39
|
+
ReportKind,
|
|
40
|
+
)
|
|
41
|
+
from shikiru.domain.tasks import Role
|
|
42
|
+
from shikiru.engine.integration import (
|
|
43
|
+
PUBLISHED_BASE_KEY,
|
|
44
|
+
PUBLISHED_TIP_KEY,
|
|
45
|
+
IntegrationBlocked,
|
|
46
|
+
IntegrationHeld,
|
|
47
|
+
PullRequestClosed,
|
|
48
|
+
)
|
|
49
|
+
from shikiru.errors import InvariantError, ShikiruError
|
|
50
|
+
from shikiru.paths import task_root
|
|
51
|
+
from shikiru.prompts.renderer import PromptName
|
|
52
|
+
from shikiru.workspace.github import PullRequestState
|
|
53
|
+
|
|
54
|
+
if TYPE_CHECKING:
|
|
55
|
+
from collections.abc import Awaitable, Callable
|
|
56
|
+
from pathlib import Path
|
|
57
|
+
|
|
58
|
+
from shikiru.config.settings import OrchestrationConfig
|
|
59
|
+
from shikiru.domain.tasks import Task
|
|
60
|
+
from shikiru.engine.step_runner import StepRunner
|
|
61
|
+
from shikiru.store.events import EventLog
|
|
62
|
+
from shikiru.store.feedback import FeedbackStore
|
|
63
|
+
from shikiru.store.tasks import TaskStore
|
|
64
|
+
from shikiru.workspace.git import Git
|
|
65
|
+
from shikiru.workspace.github import GitHub, PullRequestComment, ReviewThread
|
|
66
|
+
|
|
67
|
+
type PreparePublication = Callable[[Task], Awaitable[Task]]
|
|
68
|
+
|
|
69
|
+
_SOURCE_LINK = "{id}: {url}"
|
|
70
|
+
_SOURCE_LINK_SEPARATOR = "; "
|
|
71
|
+
_QUESTION_SEPARATOR = "\n"
|
|
72
|
+
_EXHAUSTED = "feedback on {url} is not resolved after {attempts} implementation attempts ({links})"
|
|
73
|
+
_REJECTED = "the feedback changes on {url} were rejected: {issues} ({links})"
|
|
74
|
+
_ISSUE_SEPARATOR = "; "
|
|
75
|
+
_IMPLEMENTER_STOPPED = "the feedback rework on {url} stopped: {question} ({links})"
|
|
76
|
+
_NEEDS_OPERATOR = "feedback on {url} needs the Operator: {questions} ({links})"
|
|
77
|
+
_QUESTION_ITEM = "{id}: {question}"
|
|
78
|
+
_ESCALATED_BY_LEADER = "the Leader escalated feedback on {url}: {question} ({links})"
|
|
79
|
+
_DIVERGED = (
|
|
80
|
+
"the remote branch {branch} of {url} holds commits the task branch does not "
|
|
81
|
+
"(remote {remote_sha}, task {local_sha}); both sides are kept"
|
|
82
|
+
)
|
|
83
|
+
_REMOTE_MOVED = (
|
|
84
|
+
"the remote branch {branch} of {url} moved to {remote_sha} while the round expected "
|
|
85
|
+
"{expected}; both sides are kept"
|
|
86
|
+
)
|
|
87
|
+
_PUSH_FAILED = "publishing {branch} for {url} failed: {error}"
|
|
88
|
+
_REPLY_FAILED = "replying to {source} on {url} failed: {error}"
|
|
89
|
+
_ALIGN_FAILED = "reading the remote branch {branch} of {url} failed: {error}"
|
|
90
|
+
_THREAD_MISSING = "the discussion {source} is no longer on {url}; its reply cannot be posted"
|
|
91
|
+
_NOTHING_VERIFIED = "{task} round {round} has no verified tip to publish"
|
|
92
|
+
_ROUND_VANISHED = "{task} lost its feedback round while it ran"
|
|
93
|
+
_NO_ANALYSIS = "{task} round {round} reached {stage} without an analysis"
|
|
94
|
+
_REMOTE_REF = "+refs/heads/{branch}:refs/remotes/{remote}/{branch}"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class FeedbackBlocked(IntegrationBlocked):
|
|
98
|
+
"""A feedback round cannot go on without the Operator."""
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class FeedbackSuperseded(Exception):
|
|
102
|
+
"""The pull request was merged, so its feedback needs no more work."""
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class FeedbackRounds:
|
|
106
|
+
"""Run the persisted feedback round of a task's pull request.
|
|
107
|
+
|
|
108
|
+
The Leader analyses and verifies, the Implementer makes the accepted changes, and this
|
|
109
|
+
class publishes the verified commits and sends the verified replies itself.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
def __init__(
|
|
113
|
+
self,
|
|
114
|
+
*,
|
|
115
|
+
store: FeedbackStore,
|
|
116
|
+
steps: StepRunner,
|
|
117
|
+
github: GitHub,
|
|
118
|
+
git: Git,
|
|
119
|
+
tasks: TaskStore,
|
|
120
|
+
events: EventLog,
|
|
121
|
+
project_root: Path,
|
|
122
|
+
remote: str,
|
|
123
|
+
settings: OrchestrationConfig,
|
|
124
|
+
) -> None:
|
|
125
|
+
"""Run turns through `steps`, publish to `remote` of `project_root`, and reply via `github`.
|
|
126
|
+
|
|
127
|
+
`settings.max_iterations` bounds the implementation attempts of one round.
|
|
128
|
+
"""
|
|
129
|
+
self._store = store
|
|
130
|
+
self._steps = steps
|
|
131
|
+
self._github = github
|
|
132
|
+
self._git = git
|
|
133
|
+
self._tasks = tasks
|
|
134
|
+
self._events = events
|
|
135
|
+
self._project_root = project_root
|
|
136
|
+
self._remote = remote
|
|
137
|
+
self._settings = settings
|
|
138
|
+
|
|
139
|
+
def pending(self, task_id: str) -> bool:
|
|
140
|
+
"""Return whether `task_id` has an unfinished feedback round."""
|
|
141
|
+
return self._store.active(task_id) is not None
|
|
142
|
+
|
|
143
|
+
async def run(self, task: Task, prepare: PreparePublication) -> None:
|
|
144
|
+
"""Carry `task`'s unfinished round as far as it goes.
|
|
145
|
+
|
|
146
|
+
A round that waited for the Operator resumes where it stopped with a fresh attempt
|
|
147
|
+
budget. `prepare` rebases the branch before publication and returns the current task.
|
|
148
|
+
Raises `FeedbackBlocked` when the Operator must decide, `PullRequestClosed` when the
|
|
149
|
+
pull request closed unmerged, and `IntegrationHeld` when the task is held.
|
|
150
|
+
"""
|
|
151
|
+
active = self._store.active(task.id)
|
|
152
|
+
if active is None:
|
|
153
|
+
return
|
|
154
|
+
if active.blocked:
|
|
155
|
+
self._store.reset_after_retry(active.id, active.stage)
|
|
156
|
+
try:
|
|
157
|
+
await self._advance(task, prepare)
|
|
158
|
+
except FeedbackSuperseded:
|
|
159
|
+
return
|
|
160
|
+
|
|
161
|
+
async def _advance(self, task: Task, prepare: PreparePublication) -> None:
|
|
162
|
+
current = task
|
|
163
|
+
while (active := self._store.active(task.id)) is not None:
|
|
164
|
+
self._require_not_held(task.id)
|
|
165
|
+
await self._require_open(active)
|
|
166
|
+
match active.stage:
|
|
167
|
+
case FeedbackStage.ANALYSIS:
|
|
168
|
+
current = await self._analyse(current, active)
|
|
169
|
+
case FeedbackStage.IMPLEMENTATION:
|
|
170
|
+
current = await self._implement(current, active)
|
|
171
|
+
case FeedbackStage.VERIFICATION:
|
|
172
|
+
current = await self._verify(current, active)
|
|
173
|
+
case FeedbackStage.PUBLICATION:
|
|
174
|
+
current = await self._publish(current, active, prepare)
|
|
175
|
+
case FeedbackStage.REPLIES:
|
|
176
|
+
await self._reply(active)
|
|
177
|
+
case _:
|
|
178
|
+
message = f"{task.id} round {active.id} is unfinished at {active.stage}"
|
|
179
|
+
raise InvariantError(message)
|
|
180
|
+
|
|
181
|
+
async def _analyse(self, task: Task, active: FeedbackRound) -> Task:
|
|
182
|
+
current, outcome = await self._steps.run_report_turn(
|
|
183
|
+
task,
|
|
184
|
+
role=Role.LEADER,
|
|
185
|
+
template=PromptName.FEEDBACK_ANALYSIS,
|
|
186
|
+
kind=ReportKind.FEEDBACK_ANALYSIS,
|
|
187
|
+
feedback=FeedbackInput(snapshot=active.snapshot),
|
|
188
|
+
)
|
|
189
|
+
if not isinstance(outcome, FeedbackAnalysis):
|
|
190
|
+
message = f"{task.id} feedback analysis produced no analysis"
|
|
191
|
+
raise InvariantError(message)
|
|
192
|
+
match next_feedback_step(outcome):
|
|
193
|
+
case FeedbackStep.ESCALATE:
|
|
194
|
+
self._store.save_analysis(active.id, outcome, FeedbackStage.ANALYSIS)
|
|
195
|
+
questions = _QUESTION_SEPARATOR.join(
|
|
196
|
+
_QUESTION_ITEM.format(id=source_id, question=question)
|
|
197
|
+
for source_id, question in operator_questions(outcome)
|
|
198
|
+
)
|
|
199
|
+
raise self._blocked(
|
|
200
|
+
active,
|
|
201
|
+
_NEEDS_OPERATOR.format(
|
|
202
|
+
url=active.pull_request_url,
|
|
203
|
+
questions=questions,
|
|
204
|
+
links=self._source_links(active),
|
|
205
|
+
),
|
|
206
|
+
)
|
|
207
|
+
case FeedbackStep.IMPLEMENT:
|
|
208
|
+
await self._align_with_remote(current, active)
|
|
209
|
+
self._store.save_analysis(active.id, outcome, FeedbackStage.IMPLEMENTATION)
|
|
210
|
+
case FeedbackStep.VERIFY:
|
|
211
|
+
self._store.save_analysis(active.id, outcome, FeedbackStage.VERIFICATION)
|
|
212
|
+
return current
|
|
213
|
+
|
|
214
|
+
async def _implement(self, task: Task, active: FeedbackRound) -> Task:
|
|
215
|
+
analysis = self._analysis_of(active)
|
|
216
|
+
limit = self._settings.max_iterations
|
|
217
|
+
if active.attempts >= limit:
|
|
218
|
+
raise self._blocked(
|
|
219
|
+
active,
|
|
220
|
+
_EXHAUSTED.format(
|
|
221
|
+
url=active.pull_request_url,
|
|
222
|
+
attempts=active.attempts,
|
|
223
|
+
links=self._source_links(active),
|
|
224
|
+
),
|
|
225
|
+
)
|
|
226
|
+
current, outcome = await self._steps.run_report_turn(
|
|
227
|
+
task,
|
|
228
|
+
role=Role.IMPLEMENTER,
|
|
229
|
+
template=PromptName.FEEDBACK_REWORK,
|
|
230
|
+
kind=ReportKind.FEEDBACK_REWORK,
|
|
231
|
+
feedback=FeedbackInput(snapshot=active.snapshot, analysis=analysis),
|
|
232
|
+
)
|
|
233
|
+
if not isinstance(outcome, FeedbackRework):
|
|
234
|
+
message = f"{task.id} feedback rework produced no rework report"
|
|
235
|
+
raise InvariantError(message)
|
|
236
|
+
if outcome.status != STATUS_COMPLETED:
|
|
237
|
+
raise self._blocked(
|
|
238
|
+
active,
|
|
239
|
+
_IMPLEMENTER_STOPPED.format(
|
|
240
|
+
url=active.pull_request_url,
|
|
241
|
+
question=outcome.question,
|
|
242
|
+
links=self._source_links(active),
|
|
243
|
+
),
|
|
244
|
+
)
|
|
245
|
+
self._store.complete_implementation(
|
|
246
|
+
active.id, active.attempts + 1, describe_checks(outcome.tests)
|
|
247
|
+
)
|
|
248
|
+
return current
|
|
249
|
+
|
|
250
|
+
async def _verify(self, task: Task, active: FeedbackRound) -> Task:
|
|
251
|
+
analysis = self._analysis_of(active)
|
|
252
|
+
final_round = active.attempts >= self._settings.max_iterations
|
|
253
|
+
tip = await self._git.rev_parse(task_root(self._project_root, task.id), self._branch(task))
|
|
254
|
+
current, outcome = await self._steps.run_report_turn(
|
|
255
|
+
task,
|
|
256
|
+
role=Role.LEADER,
|
|
257
|
+
template=PromptName.FEEDBACK_VERIFY,
|
|
258
|
+
kind=ReportKind.FEEDBACK_VERDICT,
|
|
259
|
+
feedback=FeedbackInput(
|
|
260
|
+
snapshot=active.snapshot, analysis=analysis, final_round=final_round, tip=tip
|
|
261
|
+
),
|
|
262
|
+
)
|
|
263
|
+
if not isinstance(outcome, FeedbackVerdict):
|
|
264
|
+
message = f"{task.id} feedback verification produced no verdict"
|
|
265
|
+
raise InvariantError(message)
|
|
266
|
+
links = self._source_links(active)
|
|
267
|
+
if outcome.decision == DECISION_ACCEPT:
|
|
268
|
+
replies = publishable_replies(active.snapshot, analysis, outcome)
|
|
269
|
+
if analysis.work:
|
|
270
|
+
self._store.save_verified(
|
|
271
|
+
active.id, sha=tip, replies=replies, stage=FeedbackStage.PUBLICATION
|
|
272
|
+
)
|
|
273
|
+
else:
|
|
274
|
+
self._store.save_verified(
|
|
275
|
+
active.id,
|
|
276
|
+
sha=tip,
|
|
277
|
+
replies=replies,
|
|
278
|
+
stage=FeedbackStage.REPLIES,
|
|
279
|
+
planned=self._planned_replies(active, replies, None),
|
|
280
|
+
)
|
|
281
|
+
elif outcome.decision == DECISION_CHANGES_REQUESTED and analysis.work:
|
|
282
|
+
self._store.set_stage(active.id, FeedbackStage.IMPLEMENTATION)
|
|
283
|
+
elif outcome.decision == DECISION_ESCALATE and outcome.escalation is not None:
|
|
284
|
+
raise self._blocked(
|
|
285
|
+
active,
|
|
286
|
+
_ESCALATED_BY_LEADER.format(
|
|
287
|
+
url=active.pull_request_url, question=outcome.escalation.question, links=links
|
|
288
|
+
),
|
|
289
|
+
)
|
|
290
|
+
else:
|
|
291
|
+
issues = _ISSUE_SEPARATOR.join(issue.description for issue in outcome.issues)
|
|
292
|
+
raise self._blocked(
|
|
293
|
+
active,
|
|
294
|
+
_REJECTED.format(
|
|
295
|
+
url=active.pull_request_url, issues=issues or outcome.summary, links=links
|
|
296
|
+
),
|
|
297
|
+
)
|
|
298
|
+
return current
|
|
299
|
+
|
|
300
|
+
async def _publish(
|
|
301
|
+
self, task: Task, active: FeedbackRound, prepare: PreparePublication
|
|
302
|
+
) -> Task:
|
|
303
|
+
verified = active.verified_sha
|
|
304
|
+
if verified is None:
|
|
305
|
+
message = _NOTHING_VERIFIED.format(task=task.id, round=active.id)
|
|
306
|
+
raise InvariantError(message)
|
|
307
|
+
task_dir = task_root(self._project_root, task.id)
|
|
308
|
+
branch = self._branch(task)
|
|
309
|
+
remote_head = await self._observed_head(active)
|
|
310
|
+
local_tip = await self._git.rev_parse(task_dir, branch)
|
|
311
|
+
if remote_head == verified and local_tip == verified:
|
|
312
|
+
await self._published(task, active, verified)
|
|
313
|
+
return task
|
|
314
|
+
current = await prepare(task)
|
|
315
|
+
tip = await self._git.rev_parse(task_dir, branch)
|
|
316
|
+
if tip != verified:
|
|
317
|
+
self._store.set_stage(active.id, FeedbackStage.VERIFICATION)
|
|
318
|
+
return current
|
|
319
|
+
self._require_not_held(task.id)
|
|
320
|
+
remote_head = await self._observed_head(active)
|
|
321
|
+
match plan_publication(
|
|
322
|
+
expected=active.expected_remote_sha, remote_head=remote_head, local_tip=tip
|
|
323
|
+
):
|
|
324
|
+
case PublicationAction.REMOTE_MOVED:
|
|
325
|
+
raise self._blocked(
|
|
326
|
+
active,
|
|
327
|
+
_REMOTE_MOVED.format(
|
|
328
|
+
branch=branch,
|
|
329
|
+
url=active.pull_request_url,
|
|
330
|
+
remote_sha=remote_head,
|
|
331
|
+
expected=active.expected_remote_sha,
|
|
332
|
+
),
|
|
333
|
+
)
|
|
334
|
+
case PublicationAction.PUSH:
|
|
335
|
+
await self._prepared(task, active, tip)
|
|
336
|
+
await self._push(active, branch, active.expected_remote_sha)
|
|
337
|
+
case PublicationAction.ALREADY_PUBLISHED:
|
|
338
|
+
pass
|
|
339
|
+
await self._published(task, active, tip)
|
|
340
|
+
return current
|
|
341
|
+
|
|
342
|
+
async def _push(self, active: FeedbackRound, branch: str, lease: str) -> None:
|
|
343
|
+
try:
|
|
344
|
+
await self._git.push(self._project_root, self._remote, branch, lease=lease)
|
|
345
|
+
except ShikiruError as error:
|
|
346
|
+
raise self._blocked(
|
|
347
|
+
active,
|
|
348
|
+
_PUSH_FAILED.format(branch=branch, url=active.pull_request_url, error=error),
|
|
349
|
+
) from error
|
|
350
|
+
|
|
351
|
+
async def _prepared(self, task: Task, active: FeedbackRound, sha: str) -> None:
|
|
352
|
+
base_sha = await self._git.merge_base(
|
|
353
|
+
task_root(self._project_root, task.id), self._base_ref(task), sha
|
|
354
|
+
)
|
|
355
|
+
self._events.append(
|
|
356
|
+
EventKind.FEEDBACK_PREPARED,
|
|
357
|
+
task_id=active.task_id,
|
|
358
|
+
message=f"{active.task_id} prepared {sha[:SHORT_SHA_LENGTH]} for the feedback",
|
|
359
|
+
data={
|
|
360
|
+
"url": active.pull_request_url,
|
|
361
|
+
"round": active.id,
|
|
362
|
+
PUBLISHED_TIP_KEY: sha,
|
|
363
|
+
PUBLISHED_BASE_KEY: base_sha,
|
|
364
|
+
},
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
async def _published(self, task: Task, active: FeedbackRound, sha: str) -> None:
|
|
368
|
+
base_sha = await self._git.merge_base(
|
|
369
|
+
task_root(self._project_root, task.id), self._base_ref(task), sha
|
|
370
|
+
)
|
|
371
|
+
self._store.save_published(active.id, sha)
|
|
372
|
+
self._events.append(
|
|
373
|
+
EventKind.FEEDBACK_PUBLISHED,
|
|
374
|
+
task_id=active.task_id,
|
|
375
|
+
message=f"{active.task_id} published {sha[:SHORT_SHA_LENGTH]} for the feedback",
|
|
376
|
+
data={
|
|
377
|
+
"url": active.pull_request_url,
|
|
378
|
+
"round": active.id,
|
|
379
|
+
PUBLISHED_TIP_KEY: sha,
|
|
380
|
+
PUBLISHED_BASE_KEY: base_sha,
|
|
381
|
+
},
|
|
382
|
+
)
|
|
383
|
+
self._store.plan_replies(
|
|
384
|
+
active.id, self._planned_replies(active, active.verified_replies, sha)
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
def _planned_replies(
|
|
388
|
+
self, active: FeedbackRound, replies: dict[str, str], fixed_sha: str | None
|
|
389
|
+
) -> list[FeedbackReply]:
|
|
390
|
+
analysis = self._analysis_of(active)
|
|
391
|
+
fixed: set[str] = fixed_source_ids(analysis) if fixed_sha is not None else set()
|
|
392
|
+
planned: list[FeedbackReply] = []
|
|
393
|
+
for source in active.snapshot.sources:
|
|
394
|
+
reply = replies.get(source.id)
|
|
395
|
+
if reply is None:
|
|
396
|
+
continue
|
|
397
|
+
marker = reply_marker(active.task_id, active.id, source.id)
|
|
398
|
+
body = reply_body(
|
|
399
|
+
reply=reply,
|
|
400
|
+
marker=marker,
|
|
401
|
+
review_url=source.url if source.kind is FeedbackSourceKind.REVIEW_BODY else None,
|
|
402
|
+
fixed_sha=fixed_sha if source.id in fixed else None,
|
|
403
|
+
checks=active.checks,
|
|
404
|
+
)
|
|
405
|
+
planned.append(
|
|
406
|
+
FeedbackReply(
|
|
407
|
+
round_id=active.id,
|
|
408
|
+
source_id=source.id,
|
|
409
|
+
kind=source.kind,
|
|
410
|
+
marker=marker,
|
|
411
|
+
body=body,
|
|
412
|
+
)
|
|
413
|
+
)
|
|
414
|
+
return planned
|
|
415
|
+
|
|
416
|
+
async def _reply(self, active: FeedbackRound) -> None:
|
|
417
|
+
for reply in self._store.replies(active.id):
|
|
418
|
+
if reply.state is ReplyState.SENT:
|
|
419
|
+
continue
|
|
420
|
+
self._require_not_held(active.task_id)
|
|
421
|
+
await self._require_open(active)
|
|
422
|
+
await self._send(active, reply)
|
|
423
|
+
self._store.set_stage(active.id, FeedbackStage.HANDLED)
|
|
424
|
+
self._events.append(
|
|
425
|
+
EventKind.FEEDBACK_HANDLED,
|
|
426
|
+
task_id=active.task_id,
|
|
427
|
+
message=f"{active.task_id} answered the feedback on {active.pull_request_url}",
|
|
428
|
+
data={"url": active.pull_request_url, "round": active.id},
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
async def _send(self, active: FeedbackRound, reply: FeedbackReply) -> None:
|
|
432
|
+
url = active.pull_request_url
|
|
433
|
+
try:
|
|
434
|
+
thread = await self._thread_of(active, reply)
|
|
435
|
+
if reply.state is ReplyState.SENDING:
|
|
436
|
+
found = await self._find(url, reply, thread)
|
|
437
|
+
if found is not None:
|
|
438
|
+
self._store.set_reply_state(
|
|
439
|
+
active.id, reply.source_id, ReplyState.SENT, found.url
|
|
440
|
+
)
|
|
441
|
+
return
|
|
442
|
+
self._require_not_held(active.task_id)
|
|
443
|
+
await self._require_open(active)
|
|
444
|
+
self._store.set_reply_state(active.id, reply.source_id, ReplyState.SENDING)
|
|
445
|
+
posted = await self._post(url, reply, thread)
|
|
446
|
+
except PullRequestClosed, FeedbackBlocked:
|
|
447
|
+
raise
|
|
448
|
+
except ShikiruError as error:
|
|
449
|
+
raise self._blocked(
|
|
450
|
+
active,
|
|
451
|
+
_REPLY_FAILED.format(source=reply.source_id, url=url, error=error),
|
|
452
|
+
) from error
|
|
453
|
+
self._store.set_reply_state(active.id, reply.source_id, ReplyState.SENT, posted.url)
|
|
454
|
+
|
|
455
|
+
async def _thread_of(self, active: FeedbackRound, reply: FeedbackReply) -> ReviewThread | None:
|
|
456
|
+
if reply.kind is not FeedbackSourceKind.DISCUSSION:
|
|
457
|
+
return None
|
|
458
|
+
feedback = await self._github.pull_request_feedback(
|
|
459
|
+
self._project_root, active.pull_request_url
|
|
460
|
+
)
|
|
461
|
+
self._end_unless_open(active, feedback.state)
|
|
462
|
+
for thread in feedback.threads:
|
|
463
|
+
if thread.id == reply.source_id:
|
|
464
|
+
return thread
|
|
465
|
+
raise self._blocked(
|
|
466
|
+
active,
|
|
467
|
+
_THREAD_MISSING.format(source=reply.source_id, url=active.pull_request_url),
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
async def _find(
|
|
471
|
+
self, url: str, reply: FeedbackReply, thread: ReviewThread | None
|
|
472
|
+
) -> PullRequestComment | None:
|
|
473
|
+
if thread is not None:
|
|
474
|
+
return await self._github.find_thread_reply(
|
|
475
|
+
self._project_root, url, thread=thread, marker=reply.marker
|
|
476
|
+
)
|
|
477
|
+
return await self._github.find_conversation_comment(
|
|
478
|
+
self._project_root, url, marker=reply.marker
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
async def _post(
|
|
482
|
+
self, url: str, reply: FeedbackReply, thread: ReviewThread | None
|
|
483
|
+
) -> PullRequestComment:
|
|
484
|
+
if thread is not None:
|
|
485
|
+
return await self._github.reply_to_thread(
|
|
486
|
+
self._project_root, url, thread=thread, body=reply.body
|
|
487
|
+
)
|
|
488
|
+
return await self._github.post_conversation_comment(
|
|
489
|
+
self._project_root, url, body=reply.body
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
async def _align_with_remote(self, task: Task, active: FeedbackRound) -> None:
|
|
493
|
+
task_dir = task_root(self._project_root, task.id)
|
|
494
|
+
branch = self._branch(task)
|
|
495
|
+
expected = active.expected_remote_sha
|
|
496
|
+
try:
|
|
497
|
+
await self._git.fetch(
|
|
498
|
+
self._project_root,
|
|
499
|
+
self._remote,
|
|
500
|
+
_REMOTE_REF.format(branch=branch, remote=self._remote),
|
|
501
|
+
timeout=self._settings.network.fetch_timeout_seconds,
|
|
502
|
+
)
|
|
503
|
+
alignment = align_with_remote(
|
|
504
|
+
remote_in_local=await self._git.merge_base_is_ancestor(task_dir, expected, branch),
|
|
505
|
+
local_in_remote=await self._git.merge_base_is_ancestor(task_dir, branch, expected),
|
|
506
|
+
)
|
|
507
|
+
if alignment is Alignment.FAST_FORWARD:
|
|
508
|
+
await self._git.merge_ff_only(task_dir, expected)
|
|
509
|
+
except ShikiruError as error:
|
|
510
|
+
raise self._blocked(
|
|
511
|
+
active,
|
|
512
|
+
_ALIGN_FAILED.format(branch=branch, url=active.pull_request_url, error=error),
|
|
513
|
+
) from error
|
|
514
|
+
if alignment is Alignment.DIVERGED:
|
|
515
|
+
raise self._blocked(
|
|
516
|
+
active,
|
|
517
|
+
_DIVERGED.format(
|
|
518
|
+
branch=branch,
|
|
519
|
+
url=active.pull_request_url,
|
|
520
|
+
remote_sha=expected,
|
|
521
|
+
local_sha=await self._git.rev_parse(task_dir, branch),
|
|
522
|
+
),
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
async def _observed_head(self, active: FeedbackRound) -> str:
|
|
526
|
+
feedback = await self._github.pull_request_feedback(
|
|
527
|
+
self._project_root, active.pull_request_url
|
|
528
|
+
)
|
|
529
|
+
self._end_unless_open(active, feedback.state)
|
|
530
|
+
return feedback.head_sha
|
|
531
|
+
|
|
532
|
+
async def _require_open(self, active: FeedbackRound) -> None:
|
|
533
|
+
status = await self._github.pull_request_status(self._project_root, active.pull_request_url)
|
|
534
|
+
self._end_unless_open(active, status.state)
|
|
535
|
+
|
|
536
|
+
def _end_unless_open(self, active: FeedbackRound, state: PullRequestState) -> None:
|
|
537
|
+
if state is PullRequestState.OPEN:
|
|
538
|
+
return
|
|
539
|
+
self._store.set_stage(active.id, FeedbackStage.ABANDONED)
|
|
540
|
+
if state is PullRequestState.CLOSED:
|
|
541
|
+
raise PullRequestClosed(active.pull_request_url)
|
|
542
|
+
raise FeedbackSuperseded
|
|
543
|
+
|
|
544
|
+
def _require_not_held(self, task_id: str) -> None:
|
|
545
|
+
current = self._tasks.get(task_id)
|
|
546
|
+
if current is None:
|
|
547
|
+
message = _ROUND_VANISHED.format(task=task_id)
|
|
548
|
+
raise InvariantError(message)
|
|
549
|
+
if current.held:
|
|
550
|
+
raise IntegrationHeld
|
|
551
|
+
|
|
552
|
+
def _blocked(self, active: FeedbackRound, question: str) -> FeedbackBlocked:
|
|
553
|
+
self._store.set_blocked(active.id, blocked=True)
|
|
554
|
+
return FeedbackBlocked(question)
|
|
555
|
+
|
|
556
|
+
def _analysis_of(self, active: FeedbackRound) -> FeedbackAnalysis:
|
|
557
|
+
if active.analysis is None:
|
|
558
|
+
message = _NO_ANALYSIS.format(task=active.task_id, round=active.id, stage=active.stage)
|
|
559
|
+
raise InvariantError(message)
|
|
560
|
+
return active.analysis
|
|
561
|
+
|
|
562
|
+
def _source_links(self, active: FeedbackRound) -> str:
|
|
563
|
+
return _SOURCE_LINK_SEPARATOR.join(
|
|
564
|
+
_SOURCE_LINK.format(id=source.id, url=source.url) for source in active.snapshot.sources
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
def _base_ref(self, task: Task) -> str:
|
|
568
|
+
if task.repo is None:
|
|
569
|
+
message = f"{task.id} has no task branch"
|
|
570
|
+
raise InvariantError(message)
|
|
571
|
+
return task.repo.base_ref
|
|
572
|
+
|
|
573
|
+
def _branch(self, task: Task) -> str:
|
|
574
|
+
if task.repo is None:
|
|
575
|
+
message = f"{task.id} has no task branch"
|
|
576
|
+
raise InvariantError(message)
|
|
577
|
+
return task.repo.branch
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Decide whether a merged pull request's task branch holds feedback work nobody published."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from shikiru.domain.events import EventKind
|
|
9
|
+
from shikiru.engine.integration import PUBLISHED_TIP_KEY
|
|
10
|
+
from shikiru.errors import ShikiruError
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from shikiru.domain.tasks import Task
|
|
16
|
+
from shikiru.store.events import EventLog
|
|
17
|
+
from shikiru.store.feedback import FeedbackStore
|
|
18
|
+
from shikiru.workspace.git import Git
|
|
19
|
+
from shikiru.workspace.github import GitHub
|
|
20
|
+
|
|
21
|
+
_OPENED_TIP_KEY = "branch_sha"
|
|
22
|
+
_BRANCH_REF = "refs/heads/{branch}"
|
|
23
|
+
_LOGGER = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class FeedbackWorkAudit:
|
|
27
|
+
"""Tell, from recorded publication evidence, whether a task tip may be discarded."""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
*,
|
|
32
|
+
git: Git,
|
|
33
|
+
github: GitHub,
|
|
34
|
+
events: EventLog,
|
|
35
|
+
store: FeedbackStore,
|
|
36
|
+
project_root: Path,
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Read the task branch of `project_root`, events, feedback rounds and the PR head."""
|
|
39
|
+
self._git = git
|
|
40
|
+
self._github = github
|
|
41
|
+
self._events = events
|
|
42
|
+
self._store = store
|
|
43
|
+
self._project_root = project_root
|
|
44
|
+
|
|
45
|
+
async def holds_unpublished_work(self, task: Task) -> bool:
|
|
46
|
+
"""Return whether the branch of `task` may hold feedback commits the PR never received.
|
|
47
|
+
|
|
48
|
+
Only a task with a persisted feedback round can; the round row, not its event, is the
|
|
49
|
+
evidence that one started. Its current branch tip must be a
|
|
50
|
+
tip the engine recorded as pushed (when the PR opened or a round published), or the
|
|
51
|
+
head the pull request ended with. A merge style that breaks ancestry is therefore
|
|
52
|
+
irrelevant, and a push whose checkpoint was lost is settled by the PR head or else kept.
|
|
53
|
+
"""
|
|
54
|
+
repo = task.repo
|
|
55
|
+
if repo is None:
|
|
56
|
+
return False
|
|
57
|
+
url = self._store.last_pull_request_url(task.id)
|
|
58
|
+
if url is None:
|
|
59
|
+
return False
|
|
60
|
+
recorded = self._events.since(
|
|
61
|
+
0, kinds=[EventKind.PR_OPENED, EventKind.FEEDBACK_PUBLISHED], task_id=task.id
|
|
62
|
+
)
|
|
63
|
+
ref = _BRANCH_REF.format(branch=repo.branch)
|
|
64
|
+
if not await self._git.branch_exists(self._project_root, repo.branch):
|
|
65
|
+
return False
|
|
66
|
+
tip = await self._git.rev_parse(self._project_root, ref)
|
|
67
|
+
pushed = {
|
|
68
|
+
str(event.data[key])
|
|
69
|
+
for event in recorded
|
|
70
|
+
for key in (_OPENED_TIP_KEY, PUBLISHED_TIP_KEY)
|
|
71
|
+
if key in event.data
|
|
72
|
+
}
|
|
73
|
+
if tip in pushed:
|
|
74
|
+
return False
|
|
75
|
+
try:
|
|
76
|
+
feedback = await self._github.pull_request_feedback(self._project_root, url)
|
|
77
|
+
except ShikiruError:
|
|
78
|
+
_LOGGER.warning("keeping %s: the head of %s could not be read", repo.branch, url)
|
|
79
|
+
return True
|
|
80
|
+
return feedback.head_sha != tip
|