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,1234 @@
|
|
|
1
|
+
"""Rebase, conflict hand-off, local merge and pull request orchestration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import dataclasses
|
|
7
|
+
from contextlib import nullcontext
|
|
8
|
+
from dataclasses import dataclass, replace
|
|
9
|
+
from datetime import timedelta
|
|
10
|
+
from typing import TYPE_CHECKING, Protocol, cast
|
|
11
|
+
|
|
12
|
+
from shikiru.config.settings import (
|
|
13
|
+
MERGE_INTEGRATION,
|
|
14
|
+
PULL_REQUEST_INTEGRATION,
|
|
15
|
+
SQUASH_INTEGRATION,
|
|
16
|
+
)
|
|
17
|
+
from shikiru.domain.events import EventKind
|
|
18
|
+
from shikiru.domain.faults import next_backoff
|
|
19
|
+
from shikiru.domain.results import (
|
|
20
|
+
CHANGES_DELETIONS_KEY,
|
|
21
|
+
CHANGES_FILES_KEY,
|
|
22
|
+
CHANGES_INSERTIONS_KEY,
|
|
23
|
+
DONE_CHANGES_KEY,
|
|
24
|
+
DONE_INTEGRATION_KEY,
|
|
25
|
+
landed_changes,
|
|
26
|
+
)
|
|
27
|
+
from shikiru.domain.scheduling import WaitReason
|
|
28
|
+
from shikiru.domain.steps import Transition
|
|
29
|
+
from shikiru.domain.tasks import EscalationReason, RepoState, Status, Task
|
|
30
|
+
from shikiru.errors import CommandFailed, CommandTimeoutError, InvariantError, ShikiruError
|
|
31
|
+
from shikiru.paths import task_root
|
|
32
|
+
from shikiru.workspace.base_refs import BaseFetchFailed, LocalBaseBlocked, MergedBaseUnavailable
|
|
33
|
+
from shikiru.workspace.facts import describe_off_branch
|
|
34
|
+
from shikiru.workspace.github import PullRequestState
|
|
35
|
+
|
|
36
|
+
if TYPE_CHECKING:
|
|
37
|
+
from collections.abc import Awaitable, Callable, Sequence
|
|
38
|
+
from contextlib import AbstractContextManager
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
|
|
41
|
+
from shikiru.clock import Clock
|
|
42
|
+
from shikiru.config.settings import OrchestrationConfig
|
|
43
|
+
from shikiru.engine.fault_reporter import FaultReporter
|
|
44
|
+
from shikiru.store.events import EventLog
|
|
45
|
+
from shikiru.store.tasks import TaskStore
|
|
46
|
+
from shikiru.workspace.base_refs import BaseRefs, FetchedBaseRefs, LocalBaseSync
|
|
47
|
+
from shikiru.workspace.git import Git
|
|
48
|
+
from shikiru.workspace.github import GitHub, PullRequestStatus
|
|
49
|
+
|
|
50
|
+
INTEGRATION_RETRY = timedelta(minutes=2)
|
|
51
|
+
"""Delay between blocked integration pre-checks."""
|
|
52
|
+
|
|
53
|
+
POLL_FAULT_SCOPE = "pull request poll"
|
|
54
|
+
GIT_FAILURE_DETAIL_LIMIT = 4000
|
|
55
|
+
GIT_FAILURE_TRUNCATION = " [truncated]"
|
|
56
|
+
SQUASH_TASK_TRAILER = "Shikiru-Task"
|
|
57
|
+
SQUASH_TIP_TRAILER = "Shikiru-Squashed-Tip"
|
|
58
|
+
_APPLYING_BASE_KEY = "base"
|
|
59
|
+
_APPLYING_BASE_SHA_KEY = "base_sha"
|
|
60
|
+
_APPLYING_BRANCH_KEY = "branch"
|
|
61
|
+
_APPLYING_TASK_SHA_KEY = "task_sha"
|
|
62
|
+
_APPLYING_LANDING_SHA_KEY = "landing_sha"
|
|
63
|
+
_INTEGRATION_COMMITS_KEY = "commits"
|
|
64
|
+
_PR_BASE_SHA_KEY = "base_sha"
|
|
65
|
+
_PR_BRANCH_SHA_KEY = "branch_sha"
|
|
66
|
+
PUBLISHED_BASE_KEY = "base_sha"
|
|
67
|
+
PUBLISHED_TIP_KEY = "sha"
|
|
68
|
+
_MERGED_PR_WITHOUT_COMMIT = "merged pull request has no landing commit"
|
|
69
|
+
_MERGED_PR_COMMIT_WITHOUT_PARENT = "merged pull request has no parent commit"
|
|
70
|
+
_LOCAL_LANDING_WITHOUT_TASK_TIP = "cannot recover a local landing without its task branch tip"
|
|
71
|
+
_LOCAL_TASK_BRANCH_ADVANCED = "task branch advanced after the local landing; preserving its work"
|
|
72
|
+
|
|
73
|
+
_PR_ENDED_BY = frozenset((EscalationReason.PR_CLOSED,))
|
|
74
|
+
_INTEGRATION_RESTARTED_BY = _PR_ENDED_BY | {EscalationReason.INTEGRATION_CONFLICT}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class IntegrationBlocked(ShikiruError):
|
|
78
|
+
"""An integration prerequisite or Git operation could not complete."""
|
|
79
|
+
|
|
80
|
+
reason = EscalationReason.INTEGRATION_BLOCKED
|
|
81
|
+
|
|
82
|
+
def __init__(self, message: str = "integration remained blocked") -> None:
|
|
83
|
+
"""Build the integration-blocked error, saying what stayed blocked."""
|
|
84
|
+
super().__init__(message)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class PullRequestStalled(IntegrationBlocked):
|
|
88
|
+
"""An open pull request cannot land: it conflicts, fails its checks or waited too long."""
|
|
89
|
+
|
|
90
|
+
def __init__(self, url: str, state: str) -> None:
|
|
91
|
+
"""Build the error for the PR at `url`, saying which `state` it is stuck in."""
|
|
92
|
+
super().__init__(f"pull request {url} is {state}")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class IntegrationHeld(Exception):
|
|
96
|
+
"""A task hold was set before integration could start its next external effect."""
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class IntegrationConflict(ShikiruError):
|
|
100
|
+
"""Conflict recovery could not produce an accepted post-rebase result."""
|
|
101
|
+
|
|
102
|
+
reason = EscalationReason.INTEGRATION_CONFLICT
|
|
103
|
+
|
|
104
|
+
def __init__(self, message: str = "integration conflicts could not be resolved") -> None:
|
|
105
|
+
"""Build the integration-conflict error, saying what conflicted."""
|
|
106
|
+
super().__init__(message)
|
|
107
|
+
|
|
108
|
+
@classmethod
|
|
109
|
+
def unresolved_rebase(cls, task_id: str, rounds: int, context: str) -> IntegrationConflict:
|
|
110
|
+
"""Describe paths that remain unresolved after the bounded conflict turns."""
|
|
111
|
+
return cls(
|
|
112
|
+
f"{task_id}: the rebase still has unresolved paths after "
|
|
113
|
+
f"{rounds} conflict turns: {context}"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class RebaseConflict(Exception):
|
|
118
|
+
"""Git still reports unmerged paths after a conflict turn."""
|
|
119
|
+
|
|
120
|
+
def __init__(self, paths: Sequence[str]) -> None:
|
|
121
|
+
self.paths = tuple(paths)
|
|
122
|
+
super().__init__(f"rebase still has unmerged paths: {', '.join(self.paths)}")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class PullRequestConflicting(IntegrationConflict):
|
|
126
|
+
"""An open pull request conflicts with its base: only a new rebase can resolve it."""
|
|
127
|
+
|
|
128
|
+
def __init__(self, url: str) -> None:
|
|
129
|
+
"""Build the error for the PR at `url`."""
|
|
130
|
+
super().__init__(f"pull request {url} is conflicting with its base")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class UncommittedWork(ShikiruError):
|
|
134
|
+
"""The task worktree holds uncommitted changes that integration would discard."""
|
|
135
|
+
|
|
136
|
+
reason = EscalationReason.UNCOMMITTED_WORK
|
|
137
|
+
|
|
138
|
+
def __init__(self, task_id: str, paths: Sequence[str]) -> None:
|
|
139
|
+
"""Build the error for `task_id` listing the porcelain status lines in `paths`."""
|
|
140
|
+
listed = ", ".join(line.strip() for line in paths)
|
|
141
|
+
super().__init__(f"{task_id} has uncommitted changes that cannot be integrated: {listed}")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class OffTaskBranch(ShikiruError):
|
|
145
|
+
"""The task worktree's `HEAD` is off the task branch, so merging the branch loses its work."""
|
|
146
|
+
|
|
147
|
+
reason = EscalationReason.OFF_TASK_BRANCH
|
|
148
|
+
|
|
149
|
+
def __init__(
|
|
150
|
+
self, task_id: str, branch: str, head_branch: str | None, commits: Sequence[str]
|
|
151
|
+
) -> None:
|
|
152
|
+
"""Build the error for `task_id`, naming where `HEAD` is and the commits at risk."""
|
|
153
|
+
super().__init__(f"{task_id}: {describe_off_branch(branch, head_branch, commits)}")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class PullRequestClosed(ShikiruError):
|
|
157
|
+
"""The task's pull request was closed without being merged."""
|
|
158
|
+
|
|
159
|
+
reason = EscalationReason.PR_CLOSED
|
|
160
|
+
|
|
161
|
+
def __init__(self, url: str) -> None:
|
|
162
|
+
"""Build the pull-request-closed error for the PR at `url`."""
|
|
163
|
+
super().__init__(f"pull request closed without merging: {url}")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class FeedbackPending(Exception):
|
|
167
|
+
"""Review feedback awaits a round, so the pull request wait ends to queue for one."""
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@dataclass(frozen=True, slots=True)
|
|
171
|
+
class IntegrationSummary:
|
|
172
|
+
"""Observable result of a successful integration."""
|
|
173
|
+
|
|
174
|
+
summary: str
|
|
175
|
+
commits: tuple[str, ...] = ()
|
|
176
|
+
residual_risks: tuple[str, ...] = ()
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@dataclass(frozen=True, slots=True)
|
|
180
|
+
class LandedChanges:
|
|
181
|
+
"""Final change totals of work as it landed in its base."""
|
|
182
|
+
|
|
183
|
+
files: int
|
|
184
|
+
insertions: int
|
|
185
|
+
deletions: int
|
|
186
|
+
|
|
187
|
+
def as_data(self) -> dict[str, int]:
|
|
188
|
+
"""Return the totals as recorded in event data."""
|
|
189
|
+
return {
|
|
190
|
+
CHANGES_FILES_KEY: self.files,
|
|
191
|
+
CHANGES_INSERTIONS_KEY: self.insertions,
|
|
192
|
+
CHANGES_DELETIONS_KEY: self.deletions,
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@dataclass(frozen=True, slots=True)
|
|
197
|
+
class Landing:
|
|
198
|
+
"""The commits an integration put in its base and the final totals of what they changed.
|
|
199
|
+
|
|
200
|
+
A landing without `changes` is one whose totals are not known.
|
|
201
|
+
"""
|
|
202
|
+
|
|
203
|
+
commits: tuple[str, ...] = ()
|
|
204
|
+
integration: str | None = None
|
|
205
|
+
changes: LandedChanges | None = None
|
|
206
|
+
|
|
207
|
+
def done_data(self) -> dict[str, object]:
|
|
208
|
+
"""Return the evidence `TASK_DONE` records about what landed."""
|
|
209
|
+
data: dict[str, object] = {"commits": list(self.commits)}
|
|
210
|
+
if self.changes is not None:
|
|
211
|
+
data[DONE_INTEGRATION_KEY] = self.integration
|
|
212
|
+
data[DONE_CHANGES_KEY] = self.changes.as_data()
|
|
213
|
+
return data
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@dataclass(frozen=True, slots=True)
|
|
217
|
+
class IntegrationMetadata:
|
|
218
|
+
"""Accepted Leader context carried into the terminal task event and its commit message."""
|
|
219
|
+
|
|
220
|
+
subject: str
|
|
221
|
+
summary: str
|
|
222
|
+
residual_risks: tuple[str, ...] = ()
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@dataclass(frozen=True, slots=True)
|
|
226
|
+
class MergeRequest:
|
|
227
|
+
"""A rebased task branch ready to be applied to its base."""
|
|
228
|
+
|
|
229
|
+
task_id: str
|
|
230
|
+
title: str
|
|
231
|
+
subject: str
|
|
232
|
+
repo: RepoState
|
|
233
|
+
task_root: Path
|
|
234
|
+
summary: str
|
|
235
|
+
held: Callable[[], bool]
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@dataclass(frozen=True, slots=True)
|
|
239
|
+
class _MergeCandidate:
|
|
240
|
+
base_sha: str
|
|
241
|
+
branch_sha: str
|
|
242
|
+
landing_sha: str
|
|
243
|
+
base_checked_out: bool
|
|
244
|
+
landing: Landing
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@dataclass(frozen=True, slots=True)
|
|
248
|
+
class ConflictRecovery:
|
|
249
|
+
"""Durable conflict-turn progress passed from the integration flow to its coordinator."""
|
|
250
|
+
|
|
251
|
+
resumed_report: bool = False
|
|
252
|
+
completed_turns: int = 0
|
|
253
|
+
force_new_turn: bool = False
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
class _PrecheckFailed(Exception):
|
|
257
|
+
pass
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class _CheckoutDirty(_PrecheckFailed):
|
|
261
|
+
def __init__(self, paths: tuple[str, ...]) -> None:
|
|
262
|
+
super().__init__(", ".join(paths))
|
|
263
|
+
self.paths = paths
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
type ContinueRebase = Callable[[], Awaitable[None]]
|
|
267
|
+
type ConflictResolver = Callable[
|
|
268
|
+
[Task, ContinueRebase, ConflictRecovery], Awaitable[tuple[Task, IntegrationMetadata]]
|
|
269
|
+
]
|
|
270
|
+
type ConflictRecoveryLoader = Callable[[Task], ConflictRecovery]
|
|
271
|
+
type TaskFinisher = Callable[[Task], Awaitable[Path | None]]
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@dataclass(frozen=True, slots=True)
|
|
275
|
+
class _OpenPullRequest:
|
|
276
|
+
url: str
|
|
277
|
+
landing: Landing
|
|
278
|
+
base_sha: str | None = None
|
|
279
|
+
branch_sha: str | None = None
|
|
280
|
+
prepared: tuple[tuple[str, str], ...] = ()
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
class IntegrationStrategy(Protocol):
|
|
284
|
+
"""A strategy that applies an already rebased task branch to its base."""
|
|
285
|
+
|
|
286
|
+
def started(self, task_id: str) -> Landing | None:
|
|
287
|
+
"""Return what `task_id`'s started, unsettled integration holds, if there is one."""
|
|
288
|
+
...
|
|
289
|
+
|
|
290
|
+
async def merge(self, request: MergeRequest) -> Landing:
|
|
291
|
+
"""Merge the task branch into its base, or start doing so, and return what landed."""
|
|
292
|
+
...
|
|
293
|
+
|
|
294
|
+
async def applied(self, task_id: str, repo: RepoState, task_root: Path) -> Landing | None:
|
|
295
|
+
"""Return what an earlier, interrupted integration of `repo` landed in its base, if any."""
|
|
296
|
+
...
|
|
297
|
+
|
|
298
|
+
async def settle(
|
|
299
|
+
self, task_id: str, repo: RepoState, held: Callable[[], bool]
|
|
300
|
+
) -> Landing | None:
|
|
301
|
+
"""Wait until `task_id`'s started integration has landed in its base `repo`.
|
|
302
|
+
|
|
303
|
+
Return final landing evidence when settlement obtains it. Raise `IntegrationHeld` instead
|
|
304
|
+
of starting another wait step once `held()` is true.
|
|
305
|
+
"""
|
|
306
|
+
...
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
class FeedbackIntake(Protocol):
|
|
310
|
+
"""Where a waiting pull request learns whether review feedback awaits a round."""
|
|
311
|
+
|
|
312
|
+
async def arrived(self, task_id: str, url: str) -> bool:
|
|
313
|
+
"""Return whether `task_id`'s pull request at `url` has feedback awaiting a round."""
|
|
314
|
+
...
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
class BasePublication(Protocol):
|
|
318
|
+
"""Where a completed local integration registers its base branch for a later push."""
|
|
319
|
+
|
|
320
|
+
def register(self, base: str) -> None:
|
|
321
|
+
"""Record an integration of `base` that must reach the remote."""
|
|
322
|
+
...
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
class IntegrationCoordinator(Protocol):
|
|
326
|
+
"""TaskFlow's required boundary to the integration coordinator."""
|
|
327
|
+
|
|
328
|
+
async def run(
|
|
329
|
+
self,
|
|
330
|
+
task: Task,
|
|
331
|
+
resolve_conflicts: ConflictResolver,
|
|
332
|
+
metadata: IntegrationMetadata,
|
|
333
|
+
conflict_recovery: ConflictRecoveryLoader | None = None,
|
|
334
|
+
) -> IntegrationSummary:
|
|
335
|
+
"""Integrate `task` and finalize it, resolving conflicts through TaskFlow."""
|
|
336
|
+
...
|
|
337
|
+
|
|
338
|
+
async def continue_rebase(self, task: Task) -> None:
|
|
339
|
+
"""Continue `task`'s rebase after its conflicts were resolved, if one is in progress."""
|
|
340
|
+
...
|
|
341
|
+
|
|
342
|
+
async def prepare_publication(
|
|
343
|
+
self,
|
|
344
|
+
task: Task,
|
|
345
|
+
resolve_conflicts: ConflictResolver,
|
|
346
|
+
metadata: IntegrationMetadata,
|
|
347
|
+
conflict_recovery: ConflictRecoveryLoader,
|
|
348
|
+
) -> tuple[Task, IntegrationMetadata]:
|
|
349
|
+
"""Rebase `task` onto its base before a feedback round publishes."""
|
|
350
|
+
...
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
class LocalMerge:
|
|
354
|
+
"""Apply a rebased branch locally using fast-forward or a compare-and-swap ref update."""
|
|
355
|
+
|
|
356
|
+
def __init__(
|
|
357
|
+
self,
|
|
358
|
+
git: Git,
|
|
359
|
+
clock: Clock,
|
|
360
|
+
*,
|
|
361
|
+
events: EventLog,
|
|
362
|
+
project_root: Path,
|
|
363
|
+
integration_wait: int,
|
|
364
|
+
) -> None:
|
|
365
|
+
"""Use `git` on `project_root` for repository operations and `clock` for retry deadlines.
|
|
366
|
+
|
|
367
|
+
A wait for a dirty checkout is recorded once in `events` as `integration_waiting`.
|
|
368
|
+
"""
|
|
369
|
+
self._git = git
|
|
370
|
+
self._clock = clock
|
|
371
|
+
self._events = events
|
|
372
|
+
self._project_root = project_root
|
|
373
|
+
self._integration_wait = integration_wait
|
|
374
|
+
|
|
375
|
+
def started(self, task_id: str) -> Landing | None:
|
|
376
|
+
"""Return `None`: a local merge lands within `merge`, so none is ever left unsettled."""
|
|
377
|
+
del task_id
|
|
378
|
+
|
|
379
|
+
async def applied(self, task_id: str, repo: RepoState, task_root: Path) -> Landing | None:
|
|
380
|
+
"""Recover the exact local landing recorded before its ref update, if it applied."""
|
|
381
|
+
events = self._events.since(0, kinds=[EventKind.INTEGRATION_APPLYING], task_id=task_id)
|
|
382
|
+
if not events:
|
|
383
|
+
return None
|
|
384
|
+
data = events[-1].data
|
|
385
|
+
previous_base = data.get(_APPLYING_BASE_SHA_KEY)
|
|
386
|
+
landing_sha = data.get(_APPLYING_LANDING_SHA_KEY)
|
|
387
|
+
if (
|
|
388
|
+
data.get(_APPLYING_BASE_KEY) != repo.base
|
|
389
|
+
or data.get(_APPLYING_BRANCH_KEY) != repo.branch
|
|
390
|
+
or not isinstance(previous_base, str)
|
|
391
|
+
or not isinstance(landing_sha, str)
|
|
392
|
+
):
|
|
393
|
+
return None
|
|
394
|
+
if not await self._git.merge_base_is_ancestor(
|
|
395
|
+
self._project_root, previous_base, landing_sha
|
|
396
|
+
):
|
|
397
|
+
return None
|
|
398
|
+
base_sha = await self._git.rev_parse(self._project_root, repo.base)
|
|
399
|
+
if not await self._git.merge_base_is_ancestor(self._project_root, landing_sha, base_sha):
|
|
400
|
+
return None
|
|
401
|
+
task_sha = data.get(_APPLYING_TASK_SHA_KEY)
|
|
402
|
+
if not isinstance(task_sha, str):
|
|
403
|
+
raise IntegrationBlocked(_LOCAL_LANDING_WITHOUT_TASK_TIP)
|
|
404
|
+
current_task_sha = await self._git.rev_parse(task_root, repo.branch)
|
|
405
|
+
if current_task_sha != task_sha:
|
|
406
|
+
raise IntegrationBlocked(_LOCAL_TASK_BRANCH_ADVANCED)
|
|
407
|
+
return _recorded_landing(data)
|
|
408
|
+
|
|
409
|
+
async def merge(self, request: MergeRequest) -> Landing:
|
|
410
|
+
"""Pre-check the local base, then apply the merge, retrying while it is blocked."""
|
|
411
|
+
project_root = self._project_root
|
|
412
|
+
repo = request.repo
|
|
413
|
+
deadline = self._clock.now() + timedelta(seconds=self._integration_wait)
|
|
414
|
+
announced = False
|
|
415
|
+
while True:
|
|
416
|
+
try:
|
|
417
|
+
candidate = await self._precheck(project_root, request)
|
|
418
|
+
if candidate is not None:
|
|
419
|
+
if request.held():
|
|
420
|
+
raise IntegrationHeld
|
|
421
|
+
self._record_applying(request, candidate)
|
|
422
|
+
await self._apply(project_root, repo, candidate)
|
|
423
|
+
except (_PrecheckFailed, CommandFailed) as error:
|
|
424
|
+
if isinstance(error, _CheckoutDirty) and not announced:
|
|
425
|
+
announced = True
|
|
426
|
+
self._announce_wait(request.task_id, error.paths)
|
|
427
|
+
if request.held():
|
|
428
|
+
raise IntegrationHeld from None
|
|
429
|
+
remaining = (deadline - self._clock.now()).total_seconds()
|
|
430
|
+
if remaining <= 0:
|
|
431
|
+
raise _blocked_by(request.task_id, error) from error
|
|
432
|
+
await self._clock.sleep(min(INTEGRATION_RETRY.total_seconds(), remaining))
|
|
433
|
+
continue
|
|
434
|
+
break
|
|
435
|
+
return candidate.landing if candidate is not None else self._nothing_landed()
|
|
436
|
+
|
|
437
|
+
def _announce_wait(self, task_id: str, paths: tuple[str, ...]) -> None:
|
|
438
|
+
self._events.append(
|
|
439
|
+
EventKind.INTEGRATION_WAITING,
|
|
440
|
+
task_id=task_id,
|
|
441
|
+
message=f"{task_id} waits to integrate: the project checkout has uncommitted "
|
|
442
|
+
f"changes on {', '.join(paths)}",
|
|
443
|
+
data={"paths": list(paths)},
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
def _record_applying(self, request: MergeRequest, candidate: _MergeCandidate) -> None:
|
|
447
|
+
self._events.append(
|
|
448
|
+
EventKind.INTEGRATION_APPLYING,
|
|
449
|
+
task_id=request.task_id,
|
|
450
|
+
message=f"{request.task_id} integration applying",
|
|
451
|
+
data={
|
|
452
|
+
_APPLYING_BASE_KEY: request.repo.base,
|
|
453
|
+
_APPLYING_BASE_SHA_KEY: candidate.base_sha,
|
|
454
|
+
_APPLYING_BRANCH_KEY: request.repo.branch,
|
|
455
|
+
_APPLYING_TASK_SHA_KEY: candidate.branch_sha,
|
|
456
|
+
_APPLYING_LANDING_SHA_KEY: candidate.landing_sha,
|
|
457
|
+
**candidate.landing.done_data(),
|
|
458
|
+
},
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
async def settle(
|
|
462
|
+
self, task_id: str, repo: RepoState, held: Callable[[], bool]
|
|
463
|
+
) -> Landing | None:
|
|
464
|
+
"""Return at once: `merge` already landed the branch in its base."""
|
|
465
|
+
del task_id, repo, held
|
|
466
|
+
return None
|
|
467
|
+
|
|
468
|
+
async def _apply(self, project_root: Path, repo: RepoState, candidate: _MergeCandidate) -> None:
|
|
469
|
+
if candidate.base_checked_out:
|
|
470
|
+
await self._git.merge_ff_only(project_root, candidate.landing_sha)
|
|
471
|
+
else:
|
|
472
|
+
await self._git.update_ref(
|
|
473
|
+
project_root, repo.base, candidate.landing_sha, candidate.base_sha
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
_integration = MERGE_INTEGRATION
|
|
477
|
+
|
|
478
|
+
def _nothing_landed(self) -> Landing:
|
|
479
|
+
return Landing(integration=self._integration, changes=LandedChanges(0, 0, 0))
|
|
480
|
+
|
|
481
|
+
async def _is_empty(self, project_root: Path, base_sha: str, branch_sha: str) -> bool:
|
|
482
|
+
del project_root
|
|
483
|
+
return branch_sha == base_sha
|
|
484
|
+
|
|
485
|
+
async def _landing_for(
|
|
486
|
+
self, request: MergeRequest, base_sha: str, branch_sha: str
|
|
487
|
+
) -> tuple[str, Landing]:
|
|
488
|
+
commits = tuple(await self._git.log_oneline(request.task_root, f"{base_sha}..{branch_sha}"))
|
|
489
|
+
changes = await _landed_changes(self._git, request.task_root, base_sha, branch_sha)
|
|
490
|
+
return branch_sha, Landing(commits, integration=self._integration, changes=changes)
|
|
491
|
+
|
|
492
|
+
async def _precheck(self, project_root: Path, request: MergeRequest) -> _MergeCandidate | None:
|
|
493
|
+
task_root_path = request.task_root
|
|
494
|
+
repo = request.repo
|
|
495
|
+
base_sha = await self._git.rev_parse(project_root, repo.base)
|
|
496
|
+
branch_sha = await self._git.rev_parse(task_root_path, repo.branch)
|
|
497
|
+
if await self._is_empty(project_root, base_sha, branch_sha):
|
|
498
|
+
return None
|
|
499
|
+
if not await self._git.merge_base_is_ancestor(project_root, repo.base, repo.branch):
|
|
500
|
+
raise _PrecheckFailed
|
|
501
|
+
base_checked_out = await self._git.current_branch(project_root) == repo.base
|
|
502
|
+
if base_checked_out:
|
|
503
|
+
changed = set(
|
|
504
|
+
await self._git.diff_name_only(task_root_path, f"{base_sha}...{branch_sha}")
|
|
505
|
+
)
|
|
506
|
+
dirty = await self._git.dirty_paths(project_root)
|
|
507
|
+
blocking = _blocking_paths(changed, dirty)
|
|
508
|
+
if blocking:
|
|
509
|
+
raise _CheckoutDirty(blocking)
|
|
510
|
+
landing_sha, landing = await self._landing_for(request, base_sha, branch_sha)
|
|
511
|
+
return _MergeCandidate(
|
|
512
|
+
base_sha=base_sha,
|
|
513
|
+
branch_sha=branch_sha,
|
|
514
|
+
landing_sha=landing_sha,
|
|
515
|
+
base_checked_out=base_checked_out,
|
|
516
|
+
landing=landing,
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
class SquashMerge(LocalMerge):
|
|
521
|
+
"""Land a rebased branch as one commit on its base, sharing every local-merge guard.
|
|
522
|
+
|
|
523
|
+
The commit carries the branch's complete tree on top of the current base and trailers naming
|
|
524
|
+
the task and the branch tip it squashed, which is how an interrupted integration is
|
|
525
|
+
recognised afterwards: the original branch is no longer an ancestor of the base.
|
|
526
|
+
"""
|
|
527
|
+
|
|
528
|
+
async def applied(self, task_id: str, repo: RepoState, task_root: Path) -> Landing | None:
|
|
529
|
+
"""Return the squash commit already on the base for the branch's current tip, if any."""
|
|
530
|
+
recorded = await super().applied(task_id, repo, task_root)
|
|
531
|
+
if recorded is not None:
|
|
532
|
+
return recorded
|
|
533
|
+
branch_sha = await self._git.rev_parse(task_root, repo.branch)
|
|
534
|
+
origin = await self._git.merge_base(self._project_root, repo.base, branch_sha)
|
|
535
|
+
landed = await self._git.find_commit(
|
|
536
|
+
self._project_root, f"{origin}..{repo.base}", _tip_trailer(branch_sha)
|
|
537
|
+
)
|
|
538
|
+
return await self._landing_of(landed) if landed is not None else None
|
|
539
|
+
|
|
540
|
+
_integration = SQUASH_INTEGRATION
|
|
541
|
+
|
|
542
|
+
async def _is_empty(self, project_root: Path, base_sha: str, branch_sha: str) -> bool:
|
|
543
|
+
base_tree = await self._git.rev_parse(project_root, f"{base_sha}^{{tree}}")
|
|
544
|
+
return base_tree == await self._git.rev_parse(project_root, f"{branch_sha}^{{tree}}")
|
|
545
|
+
|
|
546
|
+
async def _landing_for(
|
|
547
|
+
self, request: MergeRequest, base_sha: str, branch_sha: str
|
|
548
|
+
) -> tuple[str, Landing]:
|
|
549
|
+
squash_sha = await self._git.commit_tree(
|
|
550
|
+
self._project_root, branch_sha, base_sha, _squash_message(request, branch_sha)
|
|
551
|
+
)
|
|
552
|
+
return squash_sha, await self._landing_of(squash_sha, parent=base_sha)
|
|
553
|
+
|
|
554
|
+
async def _landing_of(self, squash_sha: str, *, parent: str | None = None) -> Landing:
|
|
555
|
+
before = parent if parent is not None else f"{squash_sha}^"
|
|
556
|
+
commits = await self._git.log_oneline(self._project_root, f"{before}..{squash_sha}")
|
|
557
|
+
changes = await _landed_changes(self._git, self._project_root, before, squash_sha)
|
|
558
|
+
return Landing(tuple(commits), integration=SQUASH_INTEGRATION, changes=changes)
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
async def _landed_changes(git: Git, repo_dir: Path, before: str, after: str) -> LandedChanges:
|
|
562
|
+
stats = await git.committed_line_changes(repo_dir, before, after)
|
|
563
|
+
return LandedChanges(stats.changed_paths, stats.added_lines, stats.deleted_lines)
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _recorded_landing(data: dict[str, object]) -> Landing | None:
|
|
567
|
+
changes = landed_changes(data)
|
|
568
|
+
recorded_commits = data.get(_INTEGRATION_COMMITS_KEY)
|
|
569
|
+
integration = data.get(DONE_INTEGRATION_KEY)
|
|
570
|
+
if (
|
|
571
|
+
changes is None
|
|
572
|
+
or not isinstance(recorded_commits, list)
|
|
573
|
+
or not isinstance(integration, str)
|
|
574
|
+
):
|
|
575
|
+
return None
|
|
576
|
+
commits: list[str] = []
|
|
577
|
+
for commit in cast("list[object]", recorded_commits):
|
|
578
|
+
if not isinstance(commit, str):
|
|
579
|
+
return None
|
|
580
|
+
commits.append(commit)
|
|
581
|
+
return Landing(tuple(commits), integration, LandedChanges(*changes))
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _tip_trailer(branch_sha: str) -> str:
|
|
585
|
+
return f"{SQUASH_TIP_TRAILER}: {branch_sha}"
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _squash_message(request: MergeRequest, branch_sha: str) -> str:
|
|
589
|
+
subject = f"{request.subject} ({request.task_id})"
|
|
590
|
+
trailers = f"{SQUASH_TASK_TRAILER}: {request.task_id}\n{_tip_trailer(branch_sha)}"
|
|
591
|
+
body = request.summary.strip()
|
|
592
|
+
return "\n\n".join(part for part in (subject, body, trailers) if part)
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def _blocking_paths(changed: set[str], dirty: set[str]) -> tuple[str, ...]:
|
|
596
|
+
return tuple(
|
|
597
|
+
sorted(right for right in dirty if any(_overlaps(left, right) for left in changed))
|
|
598
|
+
)
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def _overlaps(left: str, right: str) -> bool:
|
|
602
|
+
left, right = left.rstrip("/"), right.rstrip("/")
|
|
603
|
+
return left == right or left.startswith(f"{right}/") or right.startswith(f"{left}/")
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
class PullRequest:
|
|
607
|
+
"""Push a rebased branch, open a pull request for it and poll it until it lands."""
|
|
608
|
+
|
|
609
|
+
def __init__(
|
|
610
|
+
self,
|
|
611
|
+
*,
|
|
612
|
+
git: Git,
|
|
613
|
+
github: GitHub,
|
|
614
|
+
clock: Clock,
|
|
615
|
+
tasks: TaskStore,
|
|
616
|
+
events: EventLog,
|
|
617
|
+
project_root: Path,
|
|
618
|
+
remote: str,
|
|
619
|
+
base_refs: FetchedBaseRefs,
|
|
620
|
+
local_base: LocalBaseSync,
|
|
621
|
+
faults: FaultReporter,
|
|
622
|
+
settings: OrchestrationConfig,
|
|
623
|
+
feedback: FeedbackIntake | None = None,
|
|
624
|
+
) -> None:
|
|
625
|
+
"""Push to `remote` from `project_root`; record the PR in `events`, waits in `tasks`.
|
|
626
|
+
|
|
627
|
+
With `feedback`, an open PR that has review feedback ends its wait with `FeedbackPending`.
|
|
628
|
+
|
|
629
|
+
A merged PR's base is fetched and verified through `base_refs`, and the local base is
|
|
630
|
+
fast-forwarded through `local_base`, before the task is done.
|
|
631
|
+
An open PR that stays unmerged for `timeouts.pr_wait` seconds escalates. A failed poll is
|
|
632
|
+
recorded through `faults` and retried `network.poll_attempts` times on the
|
|
633
|
+
`faults` backoff before it escalates.
|
|
634
|
+
"""
|
|
635
|
+
self._git = git
|
|
636
|
+
self._github = github
|
|
637
|
+
self._clock = clock
|
|
638
|
+
self._tasks = tasks
|
|
639
|
+
self._events = events
|
|
640
|
+
self._project_root = project_root
|
|
641
|
+
self._remote = remote
|
|
642
|
+
self._base_refs = base_refs
|
|
643
|
+
self._local_base = local_base
|
|
644
|
+
self._faults = faults
|
|
645
|
+
self._settings = settings
|
|
646
|
+
self._feedback = feedback
|
|
647
|
+
|
|
648
|
+
def started(self, task_id: str) -> Landing | None:
|
|
649
|
+
"""Return what `task_id`'s open pull request holds, if it has one."""
|
|
650
|
+
opened = self._open_pull_request(task_id)
|
|
651
|
+
return opened.landing if opened is not None else None
|
|
652
|
+
|
|
653
|
+
async def applied(self, task_id: str, repo: RepoState, task_root: Path) -> Landing | None:
|
|
654
|
+
"""Return `None`: only a pull request merged on the remote lands, and `settle` waits."""
|
|
655
|
+
del task_id, repo, task_root
|
|
656
|
+
|
|
657
|
+
async def merge(self, request: MergeRequest) -> Landing:
|
|
658
|
+
"""Push the branch and open its pull request, recorded as `pr_opened`.
|
|
659
|
+
|
|
660
|
+
An open pull request of the branch that was never recorded (the engine died between
|
|
661
|
+
opening and recording it) is adopted instead of opening another.
|
|
662
|
+
"""
|
|
663
|
+
repo = request.repo
|
|
664
|
+
commits = tuple(
|
|
665
|
+
await self._git.log_oneline(request.task_root, f"{repo.base_ref}..{repo.branch}")
|
|
666
|
+
)
|
|
667
|
+
base_sha = await self._git.rev_parse(request.task_root, repo.base_ref)
|
|
668
|
+
branch_sha = await self._git.rev_parse(request.task_root, repo.branch)
|
|
669
|
+
changes = await _landed_changes(self._git, request.task_root, base_sha, branch_sha)
|
|
670
|
+
if not commits:
|
|
671
|
+
return Landing(integration=PULL_REQUEST_INTEGRATION, changes=changes)
|
|
672
|
+
lease = await self._git.remote_branch_sha(self._project_root, self._remote, repo.branch)
|
|
673
|
+
await self._git.push(self._project_root, self._remote, repo.branch, lease=lease)
|
|
674
|
+
url = await self._github.open_pull_request(
|
|
675
|
+
self._project_root, base=repo.base, head=repo.branch
|
|
676
|
+
) or await self._github.create_pull_request(
|
|
677
|
+
self._project_root,
|
|
678
|
+
base=repo.base,
|
|
679
|
+
head=repo.branch,
|
|
680
|
+
title=request.title,
|
|
681
|
+
body=request.summary,
|
|
682
|
+
)
|
|
683
|
+
self._events.append(
|
|
684
|
+
EventKind.PR_OPENED,
|
|
685
|
+
task_id=request.task_id,
|
|
686
|
+
message=f"{request.task_id} opened {url}",
|
|
687
|
+
data={
|
|
688
|
+
"url": url,
|
|
689
|
+
"branch": repo.branch,
|
|
690
|
+
_INTEGRATION_COMMITS_KEY: list(commits),
|
|
691
|
+
DONE_CHANGES_KEY: changes.as_data(),
|
|
692
|
+
_PR_BASE_SHA_KEY: base_sha,
|
|
693
|
+
_PR_BRANCH_SHA_KEY: branch_sha,
|
|
694
|
+
},
|
|
695
|
+
)
|
|
696
|
+
return Landing(commits, integration=PULL_REQUEST_INTEGRATION, changes=changes)
|
|
697
|
+
|
|
698
|
+
async def settle(
|
|
699
|
+
self, task_id: str, repo: RepoState, held: Callable[[], bool]
|
|
700
|
+
) -> Landing | None:
|
|
701
|
+
"""Poll the open pull request until it is merged or closed, then sync the base to it.
|
|
702
|
+
|
|
703
|
+
The remote base is verified to hold the merged commit, and the local base is
|
|
704
|
+
fast-forwarded to the remote tip, before this returns evidence read from the landing commit.
|
|
705
|
+
Raises `PullRequestClosed` for a closed one, `PullRequestConflicting` when the open PR
|
|
706
|
+
conflicts with its base, `IntegrationBlocked` when the merged commit cannot be fetched
|
|
707
|
+
from and verified on the remote base, when the local base cannot fast-forward to it
|
|
708
|
+
without losing work, when the open PR has failing, settled checks, or
|
|
709
|
+
when it stays unmerged for `pr_wait` seconds (counted from the start of each wait), and
|
|
710
|
+
`IntegrationHeld` before any poll once `held()` is true, and `FeedbackPending` when review
|
|
711
|
+
feedback awaits a round.
|
|
712
|
+
"""
|
|
713
|
+
opened = self._open_pull_request(task_id)
|
|
714
|
+
if opened is None:
|
|
715
|
+
return None
|
|
716
|
+
self._tasks.set_slot(task_id, None)
|
|
717
|
+
self._tasks.set_wait_reason(task_id, WaitReason.PR_OPEN)
|
|
718
|
+
deadline = self._clock.now() + timedelta(seconds=self._settings.timeouts.pr_wait)
|
|
719
|
+
while True:
|
|
720
|
+
if held():
|
|
721
|
+
raise IntegrationHeld
|
|
722
|
+
status = await self._status(task_id, opened.url, held)
|
|
723
|
+
if status.state is not PullRequestState.OPEN:
|
|
724
|
+
break
|
|
725
|
+
if status.conflicting:
|
|
726
|
+
self._tasks.set_wait_reason(task_id, None)
|
|
727
|
+
raise PullRequestConflicting(opened.url)
|
|
728
|
+
stalled = _stalled(status, expired=self._clock.now() >= deadline)
|
|
729
|
+
if stalled is not None:
|
|
730
|
+
self._tasks.set_wait_reason(task_id, None)
|
|
731
|
+
raise PullRequestStalled(opened.url, stalled)
|
|
732
|
+
await self._end_wait_for_feedback(task_id, opened.url)
|
|
733
|
+
await self._clock.sleep(self._settings.network.pr_poll_seconds)
|
|
734
|
+
self._tasks.set_wait_reason(task_id, None)
|
|
735
|
+
if status.state is PullRequestState.CLOSED:
|
|
736
|
+
raise PullRequestClosed(opened.url)
|
|
737
|
+
merge_commit = status.merge_commit
|
|
738
|
+
if merge_commit is None:
|
|
739
|
+
raise IntegrationBlocked(_MERGED_PR_WITHOUT_COMMIT)
|
|
740
|
+
base_sha = opened.base_sha or await self._git.rev_parse(self._project_root, repo.base_ref)
|
|
741
|
+
try:
|
|
742
|
+
tip = await self._base_refs.sync(repo.base, merge_commit)
|
|
743
|
+
await self._local_base.advance(repo.base, tip)
|
|
744
|
+
except (BaseFetchFailed, LocalBaseBlocked, MergedBaseUnavailable) as error:
|
|
745
|
+
raise IntegrationBlocked(str(error)) from error
|
|
746
|
+
return await self._landed_with_prepared(opened, merge_commit, base_sha)
|
|
747
|
+
|
|
748
|
+
async def _landed_with_prepared(
|
|
749
|
+
self, opened: _OpenPullRequest, merge_commit: str, base_sha: str
|
|
750
|
+
) -> Landing:
|
|
751
|
+
for tip, base in reversed(opened.prepared):
|
|
752
|
+
if await self._git.merge_base_is_ancestor(self._project_root, tip, merge_commit):
|
|
753
|
+
opened = replace(opened, branch_sha=tip)
|
|
754
|
+
base_sha = base
|
|
755
|
+
break
|
|
756
|
+
return await self._landed_pull_request(opened, merge_commit, base_sha)
|
|
757
|
+
|
|
758
|
+
async def _end_wait_for_feedback(self, task_id: str, url: str) -> None:
|
|
759
|
+
if self._feedback is not None and await self._feedback.arrived(task_id, url):
|
|
760
|
+
self._tasks.set_wait_reason(task_id, None)
|
|
761
|
+
raise FeedbackPending
|
|
762
|
+
|
|
763
|
+
async def _landed_pull_request(
|
|
764
|
+
self, opened: _OpenPullRequest, merge_commit: str, base_sha: str
|
|
765
|
+
) -> Landing:
|
|
766
|
+
parents = list(await self._git.commit_parents(self._project_root, merge_commit))
|
|
767
|
+
if not parents:
|
|
768
|
+
raise IntegrationBlocked(_MERGED_PR_COMMIT_WITHOUT_PARENT)
|
|
769
|
+
if len(parents) > 1:
|
|
770
|
+
before = parents[0]
|
|
771
|
+
elif opened.branch_sha is not None and await self._git.merge_base_is_ancestor(
|
|
772
|
+
self._project_root, opened.branch_sha, merge_commit
|
|
773
|
+
):
|
|
774
|
+
before = base_sha
|
|
775
|
+
else:
|
|
776
|
+
before = parents[0]
|
|
777
|
+
commits = tuple(
|
|
778
|
+
await self._git.log_oneline(self._project_root, f"{before}..{merge_commit}")
|
|
779
|
+
)
|
|
780
|
+
changes = await _landed_changes(self._git, self._project_root, before, merge_commit)
|
|
781
|
+
return Landing(commits, integration=PULL_REQUEST_INTEGRATION, changes=changes)
|
|
782
|
+
|
|
783
|
+
async def _status(self, task_id: str, url: str, held: Callable[[], bool]) -> PullRequestStatus:
|
|
784
|
+
attempts = self._settings.network.poll_attempts
|
|
785
|
+
backoff = self._settings.faults
|
|
786
|
+
delay = 0.0
|
|
787
|
+
attempt = 0
|
|
788
|
+
while True:
|
|
789
|
+
attempt += 1
|
|
790
|
+
try:
|
|
791
|
+
status = await self._github.pull_request_status(self._project_root, url)
|
|
792
|
+
except (CommandFailed, CommandTimeoutError) as error:
|
|
793
|
+
self._faults.report(error, scope=POLL_FAULT_SCOPE, task_id=task_id)
|
|
794
|
+
if attempt >= attempts:
|
|
795
|
+
raise
|
|
796
|
+
delay = next_backoff(
|
|
797
|
+
delay,
|
|
798
|
+
initial=backoff.backoff_initial_seconds,
|
|
799
|
+
maximum=backoff.backoff_max_seconds,
|
|
800
|
+
)
|
|
801
|
+
await self._clock.sleep(delay)
|
|
802
|
+
if held():
|
|
803
|
+
raise IntegrationHeld from None
|
|
804
|
+
else:
|
|
805
|
+
self._faults.resolve(POLL_FAULT_SCOPE, task_id=task_id)
|
|
806
|
+
return status
|
|
807
|
+
|
|
808
|
+
def _open_pull_request(self, task_id: str) -> _OpenPullRequest | None:
|
|
809
|
+
return _open_pull_request(self._events, task_id, _INTEGRATION_RESTARTED_BY)
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def _stalled(status: PullRequestStatus, *, expired: bool) -> str | None:
|
|
813
|
+
if status.checks_failing and not status.checks_pending:
|
|
814
|
+
return "failing checks"
|
|
815
|
+
return "unmerged beyond the wait budget" if expired else None
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
def _open_pull_request(
|
|
819
|
+
events: EventLog, task_id: str, ended_by: frozenset[EscalationReason]
|
|
820
|
+
) -> _OpenPullRequest | None:
|
|
821
|
+
opened: _OpenPullRequest | None = None
|
|
822
|
+
kinds = [
|
|
823
|
+
EventKind.PR_OPENED,
|
|
824
|
+
EventKind.FEEDBACK_PUBLISHED,
|
|
825
|
+
EventKind.FEEDBACK_PREPARED,
|
|
826
|
+
EventKind.PR_WITHDRAWN,
|
|
827
|
+
EventKind.ESCALATION,
|
|
828
|
+
]
|
|
829
|
+
for event in events.since(0, kinds=kinds, task_id=task_id):
|
|
830
|
+
if event.kind is EventKind.PR_OPENED:
|
|
831
|
+
found = landed_changes(event.data)
|
|
832
|
+
opened = _OpenPullRequest(
|
|
833
|
+
event.data["url"],
|
|
834
|
+
Landing(
|
|
835
|
+
tuple(event.data[_INTEGRATION_COMMITS_KEY]),
|
|
836
|
+
integration=PULL_REQUEST_INTEGRATION if found is not None else None,
|
|
837
|
+
changes=LandedChanges(*found) if found is not None else None,
|
|
838
|
+
),
|
|
839
|
+
event.data.get(_PR_BASE_SHA_KEY),
|
|
840
|
+
event.data.get(_PR_BRANCH_SHA_KEY),
|
|
841
|
+
)
|
|
842
|
+
elif event.kind is EventKind.FEEDBACK_PREPARED:
|
|
843
|
+
if opened is not None and event.data["url"] == opened.url:
|
|
844
|
+
opened = replace(
|
|
845
|
+
opened,
|
|
846
|
+
prepared=(
|
|
847
|
+
*opened.prepared,
|
|
848
|
+
(event.data[PUBLISHED_TIP_KEY], event.data[PUBLISHED_BASE_KEY]),
|
|
849
|
+
),
|
|
850
|
+
)
|
|
851
|
+
elif event.kind is EventKind.FEEDBACK_PUBLISHED:
|
|
852
|
+
if opened is not None and event.data["url"] == opened.url:
|
|
853
|
+
opened = replace(
|
|
854
|
+
opened,
|
|
855
|
+
base_sha=event.data[PUBLISHED_BASE_KEY],
|
|
856
|
+
branch_sha=event.data[PUBLISHED_TIP_KEY],
|
|
857
|
+
)
|
|
858
|
+
elif event.kind is EventKind.PR_WITHDRAWN or event.data["reason"] in ended_by:
|
|
859
|
+
opened = None
|
|
860
|
+
return opened
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
class PullRequestCloser:
|
|
864
|
+
"""Close a task's open pull request when the task is cancelled."""
|
|
865
|
+
|
|
866
|
+
def __init__(self, *, github: GitHub, events: EventLog, project_root: Path) -> None:
|
|
867
|
+
"""Close through `github` from `project_root` the PR that `events` record as open."""
|
|
868
|
+
self._github = github
|
|
869
|
+
self._events = events
|
|
870
|
+
self._project_root = project_root
|
|
871
|
+
|
|
872
|
+
def has_open(self, task_id: str) -> bool:
|
|
873
|
+
"""Return whether `task_id` has a pull request that was neither closed nor withdrawn."""
|
|
874
|
+
return _open_pull_request(self._events, task_id, _PR_ENDED_BY) is not None
|
|
875
|
+
|
|
876
|
+
async def close_open(self, task_id: str) -> None:
|
|
877
|
+
"""Close `task_id`'s open pull request, if it has one that is still open.
|
|
878
|
+
|
|
879
|
+
Records `pr_withdrawn` once the pull request is closed or was found not open, so the
|
|
880
|
+
close is never repeated.
|
|
881
|
+
"""
|
|
882
|
+
opened = _open_pull_request(self._events, task_id, _PR_ENDED_BY)
|
|
883
|
+
if opened is None:
|
|
884
|
+
return
|
|
885
|
+
state = (await self._github.pull_request_status(self._project_root, opened.url)).state
|
|
886
|
+
if state is PullRequestState.OPEN:
|
|
887
|
+
await self._github.close_pull_request(self._project_root, opened.url)
|
|
888
|
+
state = PullRequestState.CLOSED
|
|
889
|
+
self._events.append(
|
|
890
|
+
EventKind.PR_WITHDRAWN,
|
|
891
|
+
task_id=task_id,
|
|
892
|
+
message=f"{task_id} withdrew {opened.url}",
|
|
893
|
+
data={"url": opened.url, "state": state.value},
|
|
894
|
+
)
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
class Integrator:
|
|
898
|
+
"""Own the project integration lock, rebase protocol and terminal cleanup."""
|
|
899
|
+
|
|
900
|
+
def __init__(
|
|
901
|
+
self,
|
|
902
|
+
*,
|
|
903
|
+
git: Git,
|
|
904
|
+
strategy: IntegrationStrategy,
|
|
905
|
+
base_refs: BaseRefs,
|
|
906
|
+
lock: asyncio.Lock,
|
|
907
|
+
project_root: Path,
|
|
908
|
+
tasks: TaskStore,
|
|
909
|
+
events: EventLog,
|
|
910
|
+
finish_task: TaskFinisher,
|
|
911
|
+
publication: BasePublication | None = None,
|
|
912
|
+
atomically: Callable[[], AbstractContextManager[object]] = nullcontext,
|
|
913
|
+
) -> None:
|
|
914
|
+
"""Inject every state, cleanup and repository boundary required by integration.
|
|
915
|
+
|
|
916
|
+
The rebase starts from the ref `base_refs` resolves, so a remote base is fetched afresh.
|
|
917
|
+
A completed integration registers its base with `publication` inside the same
|
|
918
|
+
`atomically` transaction that records the task as done, so a crash between the Git
|
|
919
|
+
update and that record is repaired by the integration running again.
|
|
920
|
+
"""
|
|
921
|
+
self._git = git
|
|
922
|
+
self._strategy = strategy
|
|
923
|
+
self._base_refs = base_refs
|
|
924
|
+
self._lock = lock
|
|
925
|
+
self._project_root = project_root
|
|
926
|
+
self._tasks = tasks
|
|
927
|
+
self._events = events
|
|
928
|
+
self._finish_task = finish_task
|
|
929
|
+
self._publication = publication
|
|
930
|
+
self._atomically = atomically
|
|
931
|
+
|
|
932
|
+
async def run(
|
|
933
|
+
self,
|
|
934
|
+
task: Task,
|
|
935
|
+
resolve_conflicts: ConflictResolver,
|
|
936
|
+
metadata: IntegrationMetadata,
|
|
937
|
+
conflict_recovery: ConflictRecoveryLoader | None = None,
|
|
938
|
+
) -> IntegrationSummary:
|
|
939
|
+
"""Rebase and integrate `task`, delegating conflict turns to TaskFlow.
|
|
940
|
+
|
|
941
|
+
An integration the strategy already started (an open PR) is only settled, never
|
|
942
|
+
rebased or applied again.
|
|
943
|
+
"""
|
|
944
|
+
repo = task.repo
|
|
945
|
+
if repo is None:
|
|
946
|
+
message = f"{task.id} has no task root to integrate"
|
|
947
|
+
raise InvariantError(message)
|
|
948
|
+
current, current_metadata = task, metadata
|
|
949
|
+
started = self._strategy.started(task.id)
|
|
950
|
+
if started is None:
|
|
951
|
+
current, current_metadata, landing = await self._apply(
|
|
952
|
+
task, repo, resolve_conflicts, metadata, conflict_recovery
|
|
953
|
+
)
|
|
954
|
+
else:
|
|
955
|
+
landing = started
|
|
956
|
+
settled = await self._strategy.settle(current.id, repo, lambda: self._is_held(current.id))
|
|
957
|
+
if settled is not None:
|
|
958
|
+
landing = settled
|
|
959
|
+
summary = IntegrationSummary(
|
|
960
|
+
summary=current_metadata.summary,
|
|
961
|
+
commits=landing.commits,
|
|
962
|
+
residual_risks=current_metadata.residual_risks,
|
|
963
|
+
)
|
|
964
|
+
self._tasks.set_summary(current.id, summary.summary)
|
|
965
|
+
updated = self._tasks.get(current.id)
|
|
966
|
+
if updated is None:
|
|
967
|
+
message = f"{current.id} vanished before integration could finish"
|
|
968
|
+
raise InvariantError(message)
|
|
969
|
+
with self._atomically():
|
|
970
|
+
self._tasks.finish(
|
|
971
|
+
current.id,
|
|
972
|
+
_done_transition(updated),
|
|
973
|
+
EventKind.TASK_DONE,
|
|
974
|
+
message=summary.summary,
|
|
975
|
+
data={
|
|
976
|
+
"summary": summary.summary,
|
|
977
|
+
**landing.done_data(),
|
|
978
|
+
"residual_risks": list(summary.residual_risks),
|
|
979
|
+
},
|
|
980
|
+
)
|
|
981
|
+
if self._publication is not None:
|
|
982
|
+
self._publication.register(repo.base)
|
|
983
|
+
done = self._tasks.get(current.id)
|
|
984
|
+
if done is None:
|
|
985
|
+
message = f"{current.id} vanished after integration completed"
|
|
986
|
+
raise InvariantError(message)
|
|
987
|
+
await self._finish_task(done)
|
|
988
|
+
return summary
|
|
989
|
+
|
|
990
|
+
async def _apply(
|
|
991
|
+
self,
|
|
992
|
+
task: Task,
|
|
993
|
+
repo: RepoState,
|
|
994
|
+
resolve_conflicts: ConflictResolver,
|
|
995
|
+
metadata: IntegrationMetadata,
|
|
996
|
+
conflict_recovery: ConflictRecoveryLoader | None,
|
|
997
|
+
) -> tuple[Task, IntegrationMetadata, Landing]:
|
|
998
|
+
async with self._lock:
|
|
999
|
+
self._require_not_held(task.id)
|
|
1000
|
+
task_dir = task_root(self._project_root, task.id)
|
|
1001
|
+
if not await self._git.is_rebase_in_progress(task_dir):
|
|
1002
|
+
await self._require_integrable(task, repo, task_dir)
|
|
1003
|
+
landed = await self._strategy.applied(task.id, repo, task_dir)
|
|
1004
|
+
if landed is not None:
|
|
1005
|
+
return task, metadata, landed
|
|
1006
|
+
recovery_state = (
|
|
1007
|
+
conflict_recovery(task) if conflict_recovery is not None else ConflictRecovery()
|
|
1008
|
+
)
|
|
1009
|
+
current = task
|
|
1010
|
+
current_metadata = metadata
|
|
1011
|
+
in_progress = await self._git.is_rebase_in_progress(task_dir)
|
|
1012
|
+
if not in_progress and not recovery_state.resumed_report:
|
|
1013
|
+
self._events.append(
|
|
1014
|
+
EventKind.INTEGRATION_STARTED,
|
|
1015
|
+
task_id=task.id,
|
|
1016
|
+
message=f"{task.id} integration started",
|
|
1017
|
+
data={"branch": repo.branch, "base": repo.base},
|
|
1018
|
+
)
|
|
1019
|
+
recovery_state = ConflictRecovery(force_new_turn=recovery_state.force_new_turn)
|
|
1020
|
+
current, current_metadata = await self._rebase(
|
|
1021
|
+
current,
|
|
1022
|
+
repo,
|
|
1023
|
+
task_dir,
|
|
1024
|
+
resolve_conflicts=resolve_conflicts,
|
|
1025
|
+
metadata=current_metadata,
|
|
1026
|
+
recovery_state=recovery_state,
|
|
1027
|
+
)
|
|
1028
|
+
self._require_not_held(current.id)
|
|
1029
|
+
landing = await self._strategy.merge(
|
|
1030
|
+
MergeRequest(
|
|
1031
|
+
task_id=current.id,
|
|
1032
|
+
title=current.title,
|
|
1033
|
+
subject=current_metadata.subject,
|
|
1034
|
+
repo=repo,
|
|
1035
|
+
task_root=task_dir,
|
|
1036
|
+
summary=current_metadata.summary,
|
|
1037
|
+
held=lambda: self._is_held(current.id),
|
|
1038
|
+
)
|
|
1039
|
+
)
|
|
1040
|
+
return current, current_metadata, landing
|
|
1041
|
+
|
|
1042
|
+
async def prepare_publication(
|
|
1043
|
+
self,
|
|
1044
|
+
task: Task,
|
|
1045
|
+
resolve_conflicts: ConflictResolver,
|
|
1046
|
+
metadata: IntegrationMetadata,
|
|
1047
|
+
conflict_recovery: ConflictRecoveryLoader,
|
|
1048
|
+
) -> tuple[Task, IntegrationMetadata]:
|
|
1049
|
+
"""Rebase `task` onto its base before a feedback round publishes, resolving conflicts.
|
|
1050
|
+
|
|
1051
|
+
Runs under the integration lock and records no integration start.
|
|
1052
|
+
"""
|
|
1053
|
+
repo = task.repo
|
|
1054
|
+
if repo is None:
|
|
1055
|
+
message = f"{task.id} has no task root to publish"
|
|
1056
|
+
raise InvariantError(message)
|
|
1057
|
+
async with self._lock:
|
|
1058
|
+
self._require_not_held(task.id)
|
|
1059
|
+
task_dir = task_root(self._project_root, task.id)
|
|
1060
|
+
recovery = conflict_recovery(task)
|
|
1061
|
+
if not await self._git.is_rebase_in_progress(task_dir) and not recovery.resumed_report:
|
|
1062
|
+
self._events.append(
|
|
1063
|
+
EventKind.FEEDBACK_REBASE_STARTED,
|
|
1064
|
+
task_id=task.id,
|
|
1065
|
+
message=f"{task.id} feedback rebase started",
|
|
1066
|
+
data={"branch": repo.branch, "base": repo.base},
|
|
1067
|
+
)
|
|
1068
|
+
recovery = ConflictRecovery(force_new_turn=recovery.force_new_turn)
|
|
1069
|
+
current, current_metadata = await self._rebase(
|
|
1070
|
+
task,
|
|
1071
|
+
repo,
|
|
1072
|
+
task_dir,
|
|
1073
|
+
resolve_conflicts=resolve_conflicts,
|
|
1074
|
+
metadata=metadata,
|
|
1075
|
+
recovery_state=recovery,
|
|
1076
|
+
)
|
|
1077
|
+
self._require_not_held(current.id)
|
|
1078
|
+
return current, current_metadata
|
|
1079
|
+
|
|
1080
|
+
async def _rebase(
|
|
1081
|
+
self,
|
|
1082
|
+
task: Task,
|
|
1083
|
+
repo: RepoState,
|
|
1084
|
+
task_dir: Path,
|
|
1085
|
+
*,
|
|
1086
|
+
resolve_conflicts: ConflictResolver,
|
|
1087
|
+
metadata: IntegrationMetadata,
|
|
1088
|
+
recovery_state: ConflictRecovery,
|
|
1089
|
+
) -> tuple[Task, IntegrationMetadata]:
|
|
1090
|
+
current = task
|
|
1091
|
+
current_metadata = metadata
|
|
1092
|
+
in_progress = await self._git.is_rebase_in_progress(task_dir)
|
|
1093
|
+
await self._require_integrable(current, repo, task_dir)
|
|
1094
|
+
base_ref = await self._resolve_base(repo)
|
|
1095
|
+
base_sha = await self._git.rev_parse(task_dir, base_ref)
|
|
1096
|
+
branch_sha = await self._git.rev_parse(task_dir, repo.branch)
|
|
1097
|
+
recovery = None
|
|
1098
|
+
if in_progress:
|
|
1099
|
+
recovery = await self._try_rebase(
|
|
1100
|
+
task_dir,
|
|
1101
|
+
base_ref,
|
|
1102
|
+
current,
|
|
1103
|
+
recovery_state,
|
|
1104
|
+
)
|
|
1105
|
+
elif recovery_state.resumed_report and (
|
|
1106
|
+
base_sha == branch_sha
|
|
1107
|
+
or await self._git.merge_base_is_ancestor(task_dir, base_ref, repo.branch)
|
|
1108
|
+
):
|
|
1109
|
+
recovery = recovery_state
|
|
1110
|
+
elif base_sha != branch_sha:
|
|
1111
|
+
recovery = await self._try_rebase(task_dir, base_ref, current, recovery_state)
|
|
1112
|
+
if recovery is None and recovery_state.resumed_report:
|
|
1113
|
+
recovery = recovery_state
|
|
1114
|
+
if recovery is not None:
|
|
1115
|
+
current, current_metadata = await resolve_conflicts(
|
|
1116
|
+
current,
|
|
1117
|
+
lambda: self._continue_rebase(current.id, task_dir),
|
|
1118
|
+
recovery,
|
|
1119
|
+
)
|
|
1120
|
+
await self._continue_rebase(current.id, task_dir)
|
|
1121
|
+
await self._require_integrable(current, repo, task_dir)
|
|
1122
|
+
return current, current_metadata
|
|
1123
|
+
|
|
1124
|
+
async def _resolve_base(self, repo: RepoState) -> str:
|
|
1125
|
+
try:
|
|
1126
|
+
return await self._base_refs.resolve(repo.base)
|
|
1127
|
+
except BaseFetchFailed as error:
|
|
1128
|
+
raise IntegrationBlocked(str(error)) from error
|
|
1129
|
+
|
|
1130
|
+
def _is_held(self, task_id: str) -> bool:
|
|
1131
|
+
current = self._tasks.get(task_id)
|
|
1132
|
+
if current is None:
|
|
1133
|
+
message = f"{task_id} vanished during integration"
|
|
1134
|
+
raise InvariantError(message)
|
|
1135
|
+
return current.held
|
|
1136
|
+
|
|
1137
|
+
def _require_not_held(self, task_id: str) -> None:
|
|
1138
|
+
if self._is_held(task_id):
|
|
1139
|
+
raise IntegrationHeld
|
|
1140
|
+
|
|
1141
|
+
async def _require_integrable(self, task: Task, repo: RepoState, task_dir: Path) -> None:
|
|
1142
|
+
if await self._git.is_rebase_in_progress(task_dir):
|
|
1143
|
+
return
|
|
1144
|
+
head_branch = await self._git.head_branch(task_dir)
|
|
1145
|
+
if head_branch != repo.branch:
|
|
1146
|
+
commits = await self._git.log_oneline(task_dir, f"{repo.base_ref}..HEAD")
|
|
1147
|
+
raise OffTaskBranch(task.id, repo.branch, head_branch, commits)
|
|
1148
|
+
status = await self._git.status_porcelain(task_dir)
|
|
1149
|
+
if status:
|
|
1150
|
+
raise UncommittedWork(task.id, status)
|
|
1151
|
+
|
|
1152
|
+
async def continue_rebase(self, task: Task) -> None:
|
|
1153
|
+
"""Continue `task`'s rebase after its conflicts were resolved, if one is in progress."""
|
|
1154
|
+
await self._continue_rebase(task.id, task_root(self._project_root, task.id))
|
|
1155
|
+
|
|
1156
|
+
async def _try_rebase(
|
|
1157
|
+
self,
|
|
1158
|
+
task_dir: Path,
|
|
1159
|
+
base: str,
|
|
1160
|
+
task: Task,
|
|
1161
|
+
recovery_state: ConflictRecovery,
|
|
1162
|
+
) -> ConflictRecovery | None:
|
|
1163
|
+
if await self._git.is_rebase_in_progress(task_dir):
|
|
1164
|
+
paths = await self._git.unmerged_paths(task_dir)
|
|
1165
|
+
if paths:
|
|
1166
|
+
return dataclasses.replace(recovery_state, resumed_report=False)
|
|
1167
|
+
if recovery_state.resumed_report:
|
|
1168
|
+
return recovery_state
|
|
1169
|
+
await self._git.rebase_abort(task_dir)
|
|
1170
|
+
try:
|
|
1171
|
+
await self._git.rebase(task_dir, base)
|
|
1172
|
+
except CommandTimeoutError, asyncio.CancelledError:
|
|
1173
|
+
await self._abort_interrupted_rebase(task_dir)
|
|
1174
|
+
raise
|
|
1175
|
+
except CommandFailed as error:
|
|
1176
|
+
paths = await self._git.unmerged_paths(task_dir)
|
|
1177
|
+
if paths:
|
|
1178
|
+
return dataclasses.replace(recovery_state, resumed_report=False)
|
|
1179
|
+
raise IntegrationBlocked(_git_failure_message(task.id, "rebase", error)) from error
|
|
1180
|
+
return None
|
|
1181
|
+
|
|
1182
|
+
async def _abort_interrupted_rebase(self, task_dir: Path) -> None:
|
|
1183
|
+
abort = asyncio.create_task(self._abort_rebase(task_dir))
|
|
1184
|
+
try:
|
|
1185
|
+
await asyncio.shield(abort)
|
|
1186
|
+
except asyncio.CancelledError:
|
|
1187
|
+
await abort
|
|
1188
|
+
raise
|
|
1189
|
+
|
|
1190
|
+
async def _abort_rebase(self, task_dir: Path) -> None:
|
|
1191
|
+
if await self._git.is_rebase_in_progress(task_dir):
|
|
1192
|
+
await self._git.rebase_abort(task_dir)
|
|
1193
|
+
|
|
1194
|
+
async def _continue_rebase(self, task_id: str, task_dir: Path) -> None:
|
|
1195
|
+
if not await self._git.is_rebase_in_progress(task_dir):
|
|
1196
|
+
return
|
|
1197
|
+
paths = await self._git.unmerged_paths(task_dir)
|
|
1198
|
+
if paths:
|
|
1199
|
+
raise RebaseConflict(paths)
|
|
1200
|
+
self._require_not_held(task_id)
|
|
1201
|
+
try:
|
|
1202
|
+
await self._git.rebase_continue(task_dir)
|
|
1203
|
+
except CommandFailed as error:
|
|
1204
|
+
paths = await self._git.unmerged_paths(task_dir)
|
|
1205
|
+
if paths:
|
|
1206
|
+
raise RebaseConflict(paths) from error
|
|
1207
|
+
raise IntegrationBlocked(
|
|
1208
|
+
_git_failure_message(task_id, "rebase --continue", error)
|
|
1209
|
+
) from error
|
|
1210
|
+
|
|
1211
|
+
|
|
1212
|
+
def _git_failure_message(task_id: str, command: str, error: CommandFailed) -> str:
|
|
1213
|
+
detail = error.stderr.strip()
|
|
1214
|
+
if len(detail) > GIT_FAILURE_DETAIL_LIMIT:
|
|
1215
|
+
detail = f"{detail[:GIT_FAILURE_DETAIL_LIMIT]}{GIT_FAILURE_TRUNCATION}"
|
|
1216
|
+
if not detail:
|
|
1217
|
+
detail = "Git returned no error details."
|
|
1218
|
+
return f"{task_id}: git {command} failed with exit {error.returncode}: {detail}"
|
|
1219
|
+
|
|
1220
|
+
|
|
1221
|
+
def _blocked_by(task_id: str, blocker: _PrecheckFailed | CommandFailed) -> IntegrationBlocked:
|
|
1222
|
+
if isinstance(blocker, _CheckoutDirty):
|
|
1223
|
+
return IntegrationBlocked(
|
|
1224
|
+
f"{task_id}: the project checkout has uncommitted changes on {', '.join(blocker.paths)}"
|
|
1225
|
+
)
|
|
1226
|
+
if isinstance(blocker, CommandFailed):
|
|
1227
|
+
return IntegrationBlocked(
|
|
1228
|
+
_git_failure_message(task_id, " ".join(blocker.argv[1:]), blocker)
|
|
1229
|
+
)
|
|
1230
|
+
return IntegrationBlocked()
|
|
1231
|
+
|
|
1232
|
+
|
|
1233
|
+
def _done_transition(task: Task) -> Transition:
|
|
1234
|
+
return Transition.stay(task, step=None, status=Status.DONE)
|