ergane-cli 0.1.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.
- ergane_cli-0.1.0.dist-info/METADATA +148 -0
- ergane_cli-0.1.0.dist-info/RECORD +121 -0
- ergane_cli-0.1.0.dist-info/WHEEL +4 -0
- ergane_cli-0.1.0.dist-info/entry_points.txt +2 -0
- ergane_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
- ergane_cli-0.1.0.dist-info/licenses/NOTICE +27 -0
- factory/__init__.py +0 -0
- factory/activities/__init__.py +0 -0
- factory/activities/agent_activities.py +745 -0
- factory/activities/merge_activities.py +654 -0
- factory/activities/notify_activities.py +1078 -0
- factory/activities/roadmap_activities.py +577 -0
- factory/activities/usage_activities.py +593 -0
- factory/activities/verify_activities.py +617 -0
- factory/cli/__init__.py +0 -0
- factory/cli/completion.py +150 -0
- factory/cli/doctor.py +484 -0
- factory/cli/env.py +247 -0
- factory/cli/errors.py +77 -0
- factory/cli/init.py +1057 -0
- factory/cli/install.py +679 -0
- factory/cli/main.py +212 -0
- factory/cli/nouns/__init__.py +59 -0
- factory/cli/nouns/answer.py +134 -0
- factory/cli/nouns/build.py +1222 -0
- factory/cli/nouns/completion.py +13 -0
- factory/cli/nouns/doctor.py +13 -0
- factory/cli/nouns/env.py +13 -0
- factory/cli/nouns/escalations.py +93 -0
- factory/cli/nouns/findings.py +13 -0
- factory/cli/nouns/init.py +13 -0
- factory/cli/nouns/install.py +99 -0
- factory/cli/nouns/repo.py +13 -0
- factory/cli/nouns/roadmap.py +13 -0
- factory/cli/nouns/spec.py +511 -0
- factory/cli/nouns/status.py +18 -0
- factory/cli/nouns/usage.py +13 -0
- factory/cli/nouns/worker.py +69 -0
- factory/cli/repo.py +569 -0
- factory/cli/repo_export.py +282 -0
- factory/cli/roadmap.py +434 -0
- factory/cli/status.py +844 -0
- factory/cli/usage.py +129 -0
- factory/config.py +262 -0
- factory/controlplane/__init__.py +0 -0
- factory/controlplane/config.py +863 -0
- factory/controlplane/resolve.py +434 -0
- factory/controlplane/verify.py +827 -0
- factory/discovery/__init__.py +3 -0
- factory/discovery/llm_scanner.py +176 -0
- factory/doctor/__init__.py +0 -0
- factory/doctor/cli.py +392 -0
- factory/doctor/models.py +165 -0
- factory/doctor/probes.py +522 -0
- factory/doctor/scaffold.py +184 -0
- factory/doctor/store.py +449 -0
- factory/env.py +131 -0
- factory/escalation/__init__.py +0 -0
- factory/escalation/client.py +168 -0
- factory/escalation/question.py +161 -0
- factory/escalation/workflow.py +470 -0
- factory/locking.py +85 -0
- factory/mergequeue/__init__.py +21 -0
- factory/mergequeue/classify.py +133 -0
- factory/mergequeue/forge.py +401 -0
- factory/mergequeue/gh.py +484 -0
- factory/mergequeue/github_forge.py +349 -0
- factory/mergequeue/merge_queue_ruleset.json +32 -0
- factory/mergequeue/messages.py +141 -0
- factory/mergequeue/models.py +324 -0
- factory/mergequeue/onboard.py +537 -0
- factory/mergequeue/wiring.py +466 -0
- factory/notify/__init__.py +0 -0
- factory/notify/adapter.py +263 -0
- factory/notify/messages.py +474 -0
- factory/notify/service.py +809 -0
- factory/notify/webhook.py +151 -0
- factory/personas.yaml +161 -0
- factory/registry.py +583 -0
- factory/roadmap/__init__.py +8 -0
- factory/roadmap/cli.py +125 -0
- factory/roadmap/discovery.py +286 -0
- factory/roadmap/models.py +583 -0
- factory/roadmap/schedule.py +377 -0
- factory/roadmap/workflow.py +1364 -0
- factory/supervision/__init__.py +0 -0
- factory/supervision/alert.py +239 -0
- factory/supervision/probe.py +467 -0
- factory/supervision/temporal_server.py +121 -0
- factory/supervision/units.py +649 -0
- factory/usage/__init__.py +0 -0
- factory/usage/aggregate.py +113 -0
- factory/usage/cli.py +223 -0
- factory/usage/ledger.py +295 -0
- factory/usage/litellm_client.py +486 -0
- factory/usage/models.py +149 -0
- factory/verify/__init__.py +0 -0
- factory/verify/criteria.py +488 -0
- factory/verify/diffbounds.py +159 -0
- factory/verify/diffcheck.py +506 -0
- factory/verify/factory_yaml.py +842 -0
- factory/verify/gates.py +1341 -0
- factory/verify/judge.py +775 -0
- factory/verify/ladder.py +145 -0
- factory/verify/models.py +745 -0
- factory/verify/question.py +167 -0
- factory/verify/store.py +1366 -0
- factory/verify/toolchain.py +370 -0
- factory/worker.py +296 -0
- factory/workgraph/__init__.py +0 -0
- factory/workgraph/adapter.py +1333 -0
- factory/workgraph/cli.py +801 -0
- factory/workgraph/delta.py +278 -0
- factory/workgraph/derive.py +607 -0
- factory/workgraph/detector.py +509 -0
- factory/workgraph/landed.py +496 -0
- factory/workgraph/models.py +494 -0
- factory/workgraph/preflight.py +651 -0
- factory/workgraph/prompt.py +796 -0
- factory/workgraph/workflow.py +2733 -0
- factory/workgraph/worktree.py +1322 -0
|
@@ -0,0 +1,654 @@
|
|
|
1
|
+
"""US1's landing surface: push, open, enqueue, poll, disable — and nothing else.
|
|
2
|
+
|
|
3
|
+
`factory/mergequeue/` is a library — a data model, a classifier, a client, a PR
|
|
4
|
+
renderer. This module turns those into things the workflow can call, which means
|
|
5
|
+
it owns the two concerns the library deliberately does not (the same split
|
|
6
|
+
`factory/activities/verify_activities.py` draws for verification): reading the
|
|
7
|
+
world at a known moment, and turning a library exception into a refusal the
|
|
8
|
+
interpreter can route without reading prose.
|
|
9
|
+
|
|
10
|
+
The activities are one landing's life, in the plan's order:
|
|
11
|
+
|
|
12
|
+
- `prepare_landing_pr` — renders the PASS node's PR body to a scratch file via
|
|
13
|
+
the pure `render_pr_body`, reading the renderer's secret inputs from the worker
|
|
14
|
+
environment so they never cross a workflow boundary (constitution V).
|
|
15
|
+
- `open_landing_pr` — salvage has already happened (the workflow salvages before
|
|
16
|
+
it ever calls this); this *pushes* the node branch to the target clone's
|
|
17
|
+
`origin` (FR-001 — the forge reads that clone, so the branch has to exist on
|
|
18
|
+
that remote), then offers it. Offered ready, and idempotent: an existing
|
|
19
|
+
proposal for the branch is reused — a forge holds one landing per head.
|
|
20
|
+
- `enqueue_landing` — asks the forge to land the proposal once its gates pass;
|
|
21
|
+
the factory never merges (D-024, FR-002). A refusal (the target stopped
|
|
22
|
+
accepting landings mid-flight, the spec edge case) is returned as rejection
|
|
23
|
+
data, never raised — the workflow routes it to escalation.
|
|
24
|
+
- `poll_landing` — one observation → a `PrSnapshot`, the classifier's input.
|
|
25
|
+
- `disable_auto_merge` — the kill-cleanup path: best-effort, so a killed epic's
|
|
26
|
+
landing stops trying to land even when the call fails.
|
|
27
|
+
|
|
28
|
+
No activity ever removes a branch (FR-008): the branch is the target's to land,
|
|
29
|
+
and this module never issues a branch-removal command — the string the
|
|
30
|
+
structural guard greps for must never appear in its command surface.
|
|
31
|
+
|
|
32
|
+
`_client_factory` is the seam in the same sense as `open_bot` and
|
|
33
|
+
`judge_transport`: since 049's US1 it resolves the *forge* the target repository
|
|
34
|
+
is on — since US5, the one that repository's own manifest declares — and since
|
|
35
|
+
US3 every landing activity speaks to that forge and nothing beneath it: none
|
|
36
|
+
constructs a forge-native client or names one (FR-009). It
|
|
37
|
+
keeps its name deliberately: twenty-one call sites in the landing suite bind it,
|
|
38
|
+
and US3-S1 requires that suite to pass with no assertion changed, so a rename
|
|
39
|
+
would rewrite the one file whose stillness is the evidence (trap 14).
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import asyncio
|
|
45
|
+
import os
|
|
46
|
+
from dataclasses import dataclass
|
|
47
|
+
from pathlib import Path
|
|
48
|
+
from typing import Callable
|
|
49
|
+
|
|
50
|
+
from temporalio import activity
|
|
51
|
+
from temporalio.exceptions import ApplicationError
|
|
52
|
+
|
|
53
|
+
from factory.mergequeue.forge import Forge, ForgeError, resolve_forge_for_repo
|
|
54
|
+
from factory.mergequeue.messages import pr_title, render_pr_body
|
|
55
|
+
from factory.mergequeue.models import CheckFailure, PrSnapshot, TargetRepoProfile
|
|
56
|
+
from factory.mergequeue.onboard import InitFacts, evaluate_init_facts, evaluate_repo
|
|
57
|
+
from factory.usage.litellm_client import MASTER_KEY_ENV, PROXY_URL_ENV
|
|
58
|
+
from factory.verify.factory_yaml import FactoryConfigError, load_factory_config, resolve_manifest_path
|
|
59
|
+
from factory.verify.models import VerificationResult
|
|
60
|
+
from factory.workgraph import worktree as worktrees
|
|
61
|
+
from factory.workgraph.adapter import transcript_dir
|
|
62
|
+
|
|
63
|
+
#: Where the worker host keeps worktrees — one override, one default, the same
|
|
64
|
+
#: resolver agent_activities uses. The merge surface must hand `push_branch` the
|
|
65
|
+
#: *same* root the worktree was prepared under: git resolves a relative worktree
|
|
66
|
+
#: path against the target clone, while Python resolves it against the worker's
|
|
67
|
+
#: cwd, and a relative root silently splits the two (see the absolute-root rule
|
|
68
|
+
#: in scripts/ergane-env.sh).
|
|
69
|
+
FACTORY_ROOT_ENV = "FACTORY_ROOT"
|
|
70
|
+
|
|
71
|
+
#: The activity error type for a landing that must not proceed — an enqueue the
|
|
72
|
+
#: queue will not accept for a reason that is not an outage. Non-retryable: the
|
|
73
|
+
#: rejection is data the workflow routes to escalation, not a transient fault.
|
|
74
|
+
#: (The client already classifies; this re-raises as data via `EnqueueResult`.)
|
|
75
|
+
LANDING_REFUSED = "LANDING_REFUSED"
|
|
76
|
+
|
|
77
|
+
#: The activity error type for a git push that failed. Retryable, like the
|
|
78
|
+
#: worktree operations: a lock or a slow filesystem is what a second attempt
|
|
79
|
+
#: fixes.
|
|
80
|
+
PUSH_FAILED = "PUSH_FAILED"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True)
|
|
84
|
+
class PrepareLandingPrInput:
|
|
85
|
+
"""The non-secret facts the PR body is rendered from (constitution V).
|
|
86
|
+
|
|
87
|
+
`result` is the passing attempt's `VerificationResult` — its per-gate
|
|
88
|
+
results and judge word are what the body quotes. The secrets
|
|
89
|
+
`render_pr_body` also demands (`proxy_url`, `master_key`, `telegram_token`,
|
|
90
|
+
`transcript_path`) are deliberately *absent*: they live only in the worker
|
|
91
|
+
host environment and are read inside this activity, never carried in an
|
|
92
|
+
orchestration payload (architecture §10).
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
epic_id: str
|
|
96
|
+
node_id: str
|
|
97
|
+
branch: str
|
|
98
|
+
attempt: int
|
|
99
|
+
feature: str
|
|
100
|
+
requirement_keys: tuple[str, ...]
|
|
101
|
+
result: VerificationResult
|
|
102
|
+
story_title: str
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True)
|
|
106
|
+
class PrepareLandingPrResult:
|
|
107
|
+
"""The body file and title `open_landing_pr` needs — the workflow's side is pure."""
|
|
108
|
+
|
|
109
|
+
body_file: str
|
|
110
|
+
title: str
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(frozen=True)
|
|
114
|
+
class OpenLandingPrInput:
|
|
115
|
+
"""What the workflow knows about a PASS node that must now land.
|
|
116
|
+
|
|
117
|
+
`body_file` is a path `prepare_landing_pr` wrote (rendered by
|
|
118
|
+
`factory/mergequeue/messages.py`); the activity passes it to
|
|
119
|
+
`gh pr create --body-file` so the body's quoting needs no shell care.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
epic_id: str
|
|
123
|
+
node_id: str
|
|
124
|
+
target_repo: str
|
|
125
|
+
base: str
|
|
126
|
+
branch: str
|
|
127
|
+
title: str
|
|
128
|
+
body_file: str
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass(frozen=True)
|
|
132
|
+
class OpenLandingPrResult:
|
|
133
|
+
"""The PR the landing will ride: number, URL, and pushed sha.
|
|
134
|
+
|
|
135
|
+
US3: `pushed_sha` records the commit the branch was pushed to origin with,
|
|
136
|
+
so a later recovery can compare the tree the queue rejected against the tree
|
|
137
|
+
it is about to re-enqueue (FR-009). Default `None` keeps pre-spec histories
|
|
138
|
+
replayable.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
number: int
|
|
142
|
+
url: str
|
|
143
|
+
pushed_sha: str | None = None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass(frozen=True)
|
|
147
|
+
class CompareTreesInput:
|
|
148
|
+
"""US3: which two refs to compare for tree identity in a node's worktree.
|
|
149
|
+
|
|
150
|
+
`rejected_tip` is the recorded `enqueued_tip` — what the queue tested.
|
|
151
|
+
`current_head` is the worktree HEAD after the recovery sync and attempt.
|
|
152
|
+
The activity runs `git rev-parse <ref>^{tree}` for both and reports whether
|
|
153
|
+
the tree ids match.
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
epic_id: str
|
|
157
|
+
node_id: str
|
|
158
|
+
target_repo: str
|
|
159
|
+
rejected_tip: str
|
|
160
|
+
current_head: str
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@dataclass(frozen=True)
|
|
164
|
+
class EnqueueLandingInput:
|
|
165
|
+
"""Which PR to enqueue, how the queue should land it, and against which clone.
|
|
166
|
+
|
|
167
|
+
`target_repo` is the target clone path — `gh` runs with `cwd` = the clone
|
|
168
|
+
(FR-001), so the queue command is issued against the same repository the
|
|
169
|
+
branch was pushed to.
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
pr_number: int
|
|
173
|
+
merge_method: str
|
|
174
|
+
target_repo: str
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@dataclass(frozen=True)
|
|
178
|
+
class EnqueueResult:
|
|
179
|
+
"""The enqueue's outcome — success or a refusal an interpreter can route.
|
|
180
|
+
|
|
181
|
+
`None`-shaped on success is deliberately avoided: this dataclass always
|
|
182
|
+
exists, and `rejected=False` is the success case, so the workflow reads
|
|
183
|
+
one type whether the queue accepted the PR or refused it.
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
rejected: bool
|
|
187
|
+
reason: str
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@dataclass(frozen=True)
|
|
191
|
+
class PollLandingInput:
|
|
192
|
+
"""The PR to poll, and against which clone."""
|
|
193
|
+
|
|
194
|
+
pr_number: int
|
|
195
|
+
target_repo: str
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@dataclass(frozen=True)
|
|
199
|
+
class DisableAutoMergeInput:
|
|
200
|
+
"""The PR to take out of the queue — the kill-cleanup path."""
|
|
201
|
+
|
|
202
|
+
pr_number: int
|
|
203
|
+
target_repo: str
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@dataclass(frozen=True)
|
|
207
|
+
class DisableResult:
|
|
208
|
+
"""Best-effort outcome: whether disabling failed, and why, if it did."""
|
|
209
|
+
|
|
210
|
+
failed: bool
|
|
211
|
+
reason: str
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@dataclass(frozen=True)
|
|
215
|
+
class FetchCheckFailureInput:
|
|
216
|
+
"""US2: which PR's failing checks to fetch evidence for (FR-005/006/007).
|
|
217
|
+
|
|
218
|
+
`check_names` is the set of required checks the classifier named as failing;
|
|
219
|
+
the activity resolves their run links, fetches the per-run failing-step log,
|
|
220
|
+
and returns a frozen record per check. A `gh` failure anywhere in the chain
|
|
221
|
+
returns degraded evidence with the absence stated, never a raise — the cycle
|
|
222
|
+
must not be lost to its own evidence-gathering.
|
|
223
|
+
"""
|
|
224
|
+
|
|
225
|
+
epic_id: str
|
|
226
|
+
node_id: str
|
|
227
|
+
pr_number: int
|
|
228
|
+
check_names: tuple[str, ...]
|
|
229
|
+
target_repo: str
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@dataclass(frozen=True)
|
|
233
|
+
class SyncLandingBranchInput:
|
|
234
|
+
"""Which node's branch to sync onto the target head (US2 recovery, FR-005).
|
|
235
|
+
|
|
236
|
+
A `CHECKS_FAILED` rejection means the target branch moved under the node; the
|
|
237
|
+
recovery cycle's first move is to sync the node branch onto the new target
|
|
238
|
+
head inside its worktree. `target_repo` is the clone whose `origin` is the
|
|
239
|
+
remote the queue operates against — the branch is pushed there, so the sync
|
|
240
|
+
fetches and merges from it.
|
|
241
|
+
"""
|
|
242
|
+
|
|
243
|
+
epic_id: str
|
|
244
|
+
node_id: str
|
|
245
|
+
target_repo: str
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@dataclass(frozen=True)
|
|
249
|
+
class SyncLandingBranchResult:
|
|
250
|
+
"""The sync's outcome, as data the workflow can route (FR-005).
|
|
251
|
+
|
|
252
|
+
`clean` mirrors `worktree.SyncResult`: True when the target head merged in
|
|
253
|
+
without a conflict, with `base_ref` the merged-in target head (the new branch
|
|
254
|
+
point re-verification's diff is measured from, D-027 extended). `refused`
|
|
255
|
+
covers a recovery that could not run — a missing worktree or a wedged git —
|
|
256
|
+
surfaced as data with the reason, never a silent pass that would read as a
|
|
257
|
+
successful sync and re-enqueue work that was not actually synced.
|
|
258
|
+
"""
|
|
259
|
+
|
|
260
|
+
clean: bool
|
|
261
|
+
base_ref: str | None
|
|
262
|
+
conflicted_files: tuple[str, ...]
|
|
263
|
+
refused: bool
|
|
264
|
+
reason: str
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@dataclass(frozen=True)
|
|
268
|
+
class ValidateTargetRepoInput:
|
|
269
|
+
"""Which target clone to validate — US3's preflight (FR-010).
|
|
270
|
+
|
|
271
|
+
`validate_target_repo` runs against the clone itself: `gh` resolves the
|
|
272
|
+
owner/repo from `origin`, and the repo's committed `factory.yaml` is read from
|
|
273
|
+
the same clone. There is nothing else to say — the repo path is the whole
|
|
274
|
+
input, which is what makes onboarding a property of a repo, not of a payload.
|
|
275
|
+
"""
|
|
276
|
+
|
|
277
|
+
target_repo: str
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
#: The seam — a factory `(repo_path: str) -> Forge`. Production resolves the
|
|
281
|
+
#: forge the repository's own manifest declares (049-US5); tests replace this
|
|
282
|
+
#: with one over a scripted `gh` or a modelled repository. One factory for this
|
|
283
|
+
#: boundary, and no second.
|
|
284
|
+
_client_factory: Callable[..., Forge] = lambda *, repo_path: resolve_forge_for_repo(
|
|
285
|
+
repo_path=repo_path
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _forge(*, repo_path: str) -> Forge:
|
|
290
|
+
return _client_factory(repo_path=repo_path)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _landing_body_dir() -> Path:
|
|
294
|
+
"""Where prepared PR bodies are written, under the worker's state directory.
|
|
295
|
+
|
|
296
|
+
The same `FACTORY_ROOT` the worktree ops use, so one override locates all of
|
|
297
|
+
the worker host's state. Body files are scratch — the PR create reads them
|
|
298
|
+
once — so a fixed name is fine and cleanup is not this activity's job.
|
|
299
|
+
"""
|
|
300
|
+
root, _choice, _source = worktrees.resolve_factory_root(FACTORY_ROOT_ENV)
|
|
301
|
+
return root
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
# --- the activities -----------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
@activity.defn
|
|
308
|
+
async def prepare_landing_pr(request: PrepareLandingPrInput) -> PrepareLandingPrResult:
|
|
309
|
+
"""Render a PASS node's PR body to a file — the one landing side effect that is a write.
|
|
310
|
+
|
|
311
|
+
The body is rendered by the pure `render_pr_body` (deterministic, secret-free)
|
|
312
|
+
and written to a scratch file for `gh pr create --body-file`. The renderer's
|
|
313
|
+
secret inputs — proxy URL, master key, telegram token, transcript path — are
|
|
314
|
+
read from the worker host environment *here*, inside the activity, so they
|
|
315
|
+
never cross a workflow boundary (constitution V, architecture §10).
|
|
316
|
+
"""
|
|
317
|
+
factory_root = _landing_body_dir()
|
|
318
|
+
transcript = str(
|
|
319
|
+
transcript_dir(factory_root, request.epic_id, request.node_id, request.attempt)
|
|
320
|
+
)
|
|
321
|
+
body = render_pr_body(
|
|
322
|
+
epic_id=request.epic_id,
|
|
323
|
+
node_id=request.node_id,
|
|
324
|
+
branch=request.branch,
|
|
325
|
+
attempt=request.attempt,
|
|
326
|
+
feature=request.feature,
|
|
327
|
+
requirement_keys=request.requirement_keys,
|
|
328
|
+
result=request.result,
|
|
329
|
+
proxy_url=os.environ.get(PROXY_URL_ENV, ""),
|
|
330
|
+
master_key=os.environ.get(MASTER_KEY_ENV, ""),
|
|
331
|
+
telegram_token=os.environ.get("TELEGRAM_BOT_TOKEN", ""),
|
|
332
|
+
transcript_path=transcript,
|
|
333
|
+
)
|
|
334
|
+
title = pr_title(
|
|
335
|
+
epic_id=request.epic_id,
|
|
336
|
+
node_id=request.node_id,
|
|
337
|
+
story_title=request.story_title,
|
|
338
|
+
)
|
|
339
|
+
body_dir = factory_root / "landing" / request.epic_id / request.node_id
|
|
340
|
+
body_dir.mkdir(parents=True, exist_ok=True)
|
|
341
|
+
body_file = body_dir / f"attempt-{request.attempt}.md"
|
|
342
|
+
body_file.write_text(body, encoding="utf-8")
|
|
343
|
+
return PrepareLandingPrResult(body_file=str(body_file), title=title)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
@activity.defn
|
|
347
|
+
async def open_landing_pr(request: OpenLandingPrInput) -> OpenLandingPrResult:
|
|
348
|
+
"""Push the node branch, then offer it to the target (FR-001, FR-009).
|
|
349
|
+
|
|
350
|
+
Salvage has already happened; the branch holds the durable work. Pushing to
|
|
351
|
+
the target clone's `origin` is what makes the PR's head exist on the remote
|
|
352
|
+
the forge operates against, so the push comes first. Idempotent: an open PR
|
|
353
|
+
for the branch is reused, so a retry after an unrecorded success does not
|
|
354
|
+
open a second PR for one head.
|
|
355
|
+
|
|
356
|
+
Raises `PUSH_FAILED` (retryable) when git refused, and lets the forge's own
|
|
357
|
+
`ForgeError` through when it refused the offer.
|
|
358
|
+
"""
|
|
359
|
+
try:
|
|
360
|
+
pushed_sha = await asyncio.to_thread(
|
|
361
|
+
worktrees.push_branch,
|
|
362
|
+
request.target_repo,
|
|
363
|
+
request.epic_id,
|
|
364
|
+
request.node_id,
|
|
365
|
+
factory_root=worktrees.resolve_factory_root(FACTORY_ROOT_ENV)[0],
|
|
366
|
+
)
|
|
367
|
+
except worktrees.WorktreeError as exc:
|
|
368
|
+
raise ApplicationError(str(exc), type=PUSH_FAILED) from exc
|
|
369
|
+
|
|
370
|
+
forge = _forge(repo_path=request.target_repo)
|
|
371
|
+
|
|
372
|
+
existing = forge.find_proposal(request.branch)
|
|
373
|
+
if existing is not None:
|
|
374
|
+
return OpenLandingPrResult(
|
|
375
|
+
number=existing.number, url=existing.url, pushed_sha=pushed_sha
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
created = forge.open_proposal(
|
|
379
|
+
base=request.base,
|
|
380
|
+
head=request.branch,
|
|
381
|
+
title=request.title,
|
|
382
|
+
body_file=request.body_file,
|
|
383
|
+
)
|
|
384
|
+
return OpenLandingPrResult(
|
|
385
|
+
number=created.number, url=created.url, pushed_sha=pushed_sha
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
@activity.defn
|
|
390
|
+
async def enqueue_landing(request: EnqueueLandingInput) -> EnqueueResult:
|
|
391
|
+
"""Ask the forge to land the proposal once its gates pass (FR-002, FR-009).
|
|
392
|
+
|
|
393
|
+
The factory never merges: it asks (D-024). The operator's declared method
|
|
394
|
+
travels as stated intent; a branch whose landing policy owns the method
|
|
395
|
+
ignores it. A refusal — the target stopped accepting landings mid-flight,
|
|
396
|
+
the spec edge case — comes back as `EnqueueResult(rejected=True, reason=…)`,
|
|
397
|
+
never as a raised crash, so the workflow routes it to escalation.
|
|
398
|
+
"""
|
|
399
|
+
forge = _forge(repo_path=request.target_repo)
|
|
400
|
+
try:
|
|
401
|
+
forge.request_landing(
|
|
402
|
+
request.pr_number, declared_method=request.merge_method
|
|
403
|
+
)
|
|
404
|
+
except ForgeError as exc:
|
|
405
|
+
return EnqueueResult(rejected=True, reason=exc.detail or str(exc))
|
|
406
|
+
return EnqueueResult(rejected=False, reason="")
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
@activity.defn
|
|
410
|
+
async def poll_landing(request: PollLandingInput) -> PrSnapshot:
|
|
411
|
+
"""One observation of the proposal — the classifier's input (FR-009)."""
|
|
412
|
+
return _forge(repo_path=request.target_repo).observe_proposal(request.pr_number)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
@activity.defn
|
|
416
|
+
async def disable_auto_merge(request: DisableAutoMergeInput) -> DisableResult:
|
|
417
|
+
"""Withdraw the landing request — best-effort (FR-008, FR-009).
|
|
418
|
+
|
|
419
|
+
Called on the epic/node kill path so a killed epic does not keep landing. A
|
|
420
|
+
failure is reported, never raised: the kill sequence must not be blocked on
|
|
421
|
+
the forge's availability, and a landing that could not be withdrawn is a fact
|
|
422
|
+
the workflow surfaces, not a crash it dies on.
|
|
423
|
+
"""
|
|
424
|
+
forge = _forge(repo_path=request.target_repo)
|
|
425
|
+
try:
|
|
426
|
+
forge.withdraw_landing(request.pr_number)
|
|
427
|
+
except ForgeError as exc:
|
|
428
|
+
return DisableResult(failed=True, reason=exc.detail or str(exc))
|
|
429
|
+
return DisableResult(failed=False, reason="")
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
@activity.defn
|
|
433
|
+
async def compare_trees(request: CompareTreesInput) -> bool:
|
|
434
|
+
"""Compare the rejected tip's tree with the current worktree HEAD (US3).
|
|
435
|
+
|
|
436
|
+
Runs on a thread via `asyncio.to_thread` so the git subprocess never blocks the
|
|
437
|
+
worker's event loop (trap 4). Returns True when the two refs resolve to the
|
|
438
|
+
same tree id, False otherwise. A missing worktree or an unresolvable ref is
|
|
439
|
+
a `WorktreeError` surfaced as `ApplicationError(type=PUSH_FAILED)` so the
|
|
440
|
+
workflow escalates rather than guessing.
|
|
441
|
+
"""
|
|
442
|
+
|
|
443
|
+
def _compare() -> bool:
|
|
444
|
+
return worktrees.trees_identical(
|
|
445
|
+
request.target_repo, request.rejected_tip, request.current_head
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
try:
|
|
449
|
+
return await asyncio.to_thread(_compare)
|
|
450
|
+
except worktrees.WorktreeError as exc:
|
|
451
|
+
raise ApplicationError(str(exc), type=PUSH_FAILED) from exc
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
@activity.defn
|
|
455
|
+
async def sync_landing_branch(request: SyncLandingBranchInput) -> SyncLandingBranchResult:
|
|
456
|
+
"""Sync a rejected node's branch onto the target head (US2 recovery, FR-005).
|
|
457
|
+
|
|
458
|
+
The recovery cycle's first move after a `CHECKS_FAILED` rejection: the
|
|
459
|
+
`worktree.sync_with_target` helper fetches origin and merges the target head
|
|
460
|
+
into the node branch inside its worktree (merge, never rebase — the branch is
|
|
461
|
+
pushed, history stays fast-forward). The activity turns the helper's two
|
|
462
|
+
outcomes into the data the workflow routes:
|
|
463
|
+
|
|
464
|
+
- a clean merge → `clean=True` with the merged-in target head as `base_ref`;
|
|
465
|
+
- a conflict → `clean=False` with the conflicted file list, the markers left
|
|
466
|
+
in the tree for the debugger persona (FR-006);
|
|
467
|
+
- a helper failure (missing worktree, wedged git) → `refused=True` with the
|
|
468
|
+
reason, so the workflow escalates rather than re-enqueueing work that was
|
|
469
|
+
not synced. Never a silent pass.
|
|
470
|
+
"""
|
|
471
|
+
try:
|
|
472
|
+
result = await asyncio.to_thread(
|
|
473
|
+
worktrees.sync_with_target,
|
|
474
|
+
request.target_repo,
|
|
475
|
+
request.epic_id,
|
|
476
|
+
request.node_id,
|
|
477
|
+
factory_root=worktrees.resolve_factory_root(FACTORY_ROOT_ENV)[0],
|
|
478
|
+
)
|
|
479
|
+
except worktrees.WorktreeError as exc:
|
|
480
|
+
return SyncLandingBranchResult(
|
|
481
|
+
clean=False,
|
|
482
|
+
base_ref=None,
|
|
483
|
+
conflicted_files=(),
|
|
484
|
+
refused=True,
|
|
485
|
+
reason=str(exc),
|
|
486
|
+
)
|
|
487
|
+
return SyncLandingBranchResult(
|
|
488
|
+
clean=result.clean,
|
|
489
|
+
base_ref=result.base_ref,
|
|
490
|
+
conflicted_files=result.conflicted_files,
|
|
491
|
+
refused=False,
|
|
492
|
+
reason="",
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
@activity.defn
|
|
497
|
+
async def fetch_check_failure(request: FetchCheckFailureInput) -> tuple[CheckFailure, ...]:
|
|
498
|
+
"""Fetch each named failing check's evidence (US2, FR-005/006/007, FR-009).
|
|
499
|
+
|
|
500
|
+
Runs on a thread via `asyncio.to_thread` so the forge's I/O never blocks the
|
|
501
|
+
worker's event loop (trap 4). How the evidence is gathered was GitHub detail
|
|
502
|
+
living in an activity and is the forge's since US3; what survives here is the
|
|
503
|
+
promise the workflow depends on — one record per requested name, degraded
|
|
504
|
+
with the absence stated rather than raised.
|
|
505
|
+
"""
|
|
506
|
+
|
|
507
|
+
def _fetch() -> tuple[CheckFailure, ...]:
|
|
508
|
+
forge = _forge(repo_path=request.target_repo)
|
|
509
|
+
return forge.failing_check_evidence(request.pr_number, request.check_names)
|
|
510
|
+
|
|
511
|
+
return await asyncio.to_thread(_fetch)
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def onboard_target_repo(
|
|
515
|
+
forge: Forge,
|
|
516
|
+
target_repo: str,
|
|
517
|
+
*,
|
|
518
|
+
init_facts: "InitFacts | None" = None,
|
|
519
|
+
) -> TargetRepoProfile:
|
|
520
|
+
"""US3's preflight: gather a repo's facts through `forge` and judge it (FR-010).
|
|
521
|
+
|
|
522
|
+
The fact-gathering half of onboarding — `evaluate_repo` (in
|
|
523
|
+
`factory/mergequeue/onboard.py`) is the pure judgment. This reads the world
|
|
524
|
+
at one moment, and since 049's US1 it reads it through a forge rather than
|
|
525
|
+
by naming `gh`:
|
|
526
|
+
|
|
527
|
+
- the repository's address, default branch and forge-authored findings;
|
|
528
|
+
- the landing branch's policy — does it gate on named checks, which checks;
|
|
529
|
+
- the clone's committed `factory.yaml` via the 002 loader.
|
|
530
|
+
|
|
531
|
+
Every forge failure is returned as a failed validation with a finding — never
|
|
532
|
+
a pass — and a malformed manifest is a failing `factory_yaml` finding
|
|
533
|
+
carrying the loader's error, never a shrug (FR-010, spec US3 AS2). The
|
|
534
|
+
profile's `passed` is the conjunction of its findings; a failing profile
|
|
535
|
+
blocks dispatch before any key is issued or worktree created (SC-005).
|
|
536
|
+
|
|
537
|
+
`forge` is injected so both the activity (via the `_client_factory` seam) and
|
|
538
|
+
the offline CLI (`ergane repo onboard`) can drive the same logic against
|
|
539
|
+
whichever forge their caller resolved.
|
|
540
|
+
|
|
541
|
+
`init_facts` is 034 US4's second door: `ergane init --check` gathers what init
|
|
542
|
+
created (runtime root, registry entry, landing branch, control plane) and
|
|
543
|
+
passes it straight through to `evaluate_repo`, so terminal and dispatch
|
|
544
|
+
render the *same* parity findings rather than two implementations that agree
|
|
545
|
+
today. It travels the read-failure path too: a repo with no remote at all is
|
|
546
|
+
precisely the repo whose local findings must still render.
|
|
547
|
+
"""
|
|
548
|
+
manifest_path, _ = resolve_manifest_path(target_repo)
|
|
549
|
+
|
|
550
|
+
try:
|
|
551
|
+
config = load_factory_config(manifest_path)
|
|
552
|
+
declared_gates = tuple(config.gates.keys())
|
|
553
|
+
manifest_error = None
|
|
554
|
+
except FactoryConfigError as error:
|
|
555
|
+
declared_gates = ()
|
|
556
|
+
manifest_error = str(error)
|
|
557
|
+
|
|
558
|
+
try:
|
|
559
|
+
repository = forge.describe_repository()
|
|
560
|
+
except ForgeError as error:
|
|
561
|
+
return _profile_from_forge_failure(
|
|
562
|
+
target_repo, visibility="", default_branch="", address="",
|
|
563
|
+
manifest_error=manifest_error, declared_gates=declared_gates,
|
|
564
|
+
error=error, init_facts=init_facts,
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
try:
|
|
568
|
+
policy = forge.landing_policy(repository.default_branch)
|
|
569
|
+
except ForgeError as error:
|
|
570
|
+
# A repo the factory cannot read is not dispatchable.
|
|
571
|
+
return _profile_from_forge_failure(
|
|
572
|
+
target_repo, visibility=repository.visibility,
|
|
573
|
+
default_branch=repository.default_branch,
|
|
574
|
+
address=repository.address, manifest_error=manifest_error,
|
|
575
|
+
declared_gates=declared_gates, error=error, init_facts=init_facts,
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
return evaluate_repo(
|
|
579
|
+
repo=repository.address or target_repo,
|
|
580
|
+
reading=repository,
|
|
581
|
+
policy=policy,
|
|
582
|
+
declared_gates=declared_gates,
|
|
583
|
+
factory_yaml_error=manifest_error,
|
|
584
|
+
init_facts=init_facts,
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _profile_from_forge_failure(
|
|
589
|
+
target_repo: str,
|
|
590
|
+
*,
|
|
591
|
+
visibility: str,
|
|
592
|
+
default_branch: str,
|
|
593
|
+
address: str,
|
|
594
|
+
manifest_error: str | None,
|
|
595
|
+
declared_gates: tuple[str, ...],
|
|
596
|
+
error: ForgeError,
|
|
597
|
+
init_facts: "InitFacts | None" = None,
|
|
598
|
+
) -> TargetRepoProfile:
|
|
599
|
+
"""A failed validation from a forge refusal — never a pass (FR-010).
|
|
600
|
+
|
|
601
|
+
A repo the factory cannot read is a repo the factory must not dispatch
|
|
602
|
+
against. The findings carry the refusal and name the remedy; `queue_enabled`
|
|
603
|
+
is False and the required checks are unknown, so `evaluate_repo` would report
|
|
604
|
+
them as failing — but the primary finding is the read failure itself.
|
|
605
|
+
"""
|
|
606
|
+
# The read failure dominates: visibility, queue and checks are all unknown,
|
|
607
|
+
# so every check a known-value check would need is not judgeable. We return a
|
|
608
|
+
# profile whose single dominant finding is the refusal, plus the manifest's
|
|
609
|
+
# own finding if the manifest also failed.
|
|
610
|
+
from factory.mergequeue.models import Finding
|
|
611
|
+
|
|
612
|
+
# US3 left this naming `gh` and called it US2's; US2 left it too, and no
|
|
613
|
+
# per-module check either story wrote was pointed here. US6's package-wide
|
|
614
|
+
# sweep is what found it. `error.kind` already carries the forge's own
|
|
615
|
+
# taxonomy term, so the operator loses nothing.
|
|
616
|
+
detail = (
|
|
617
|
+
f"could not read the repo via its forge ({error.kind}): "
|
|
618
|
+
f"{error.detail or str(error)}"
|
|
619
|
+
)
|
|
620
|
+
findings = [Finding("repo_read", False, detail)]
|
|
621
|
+
if manifest_error is not None:
|
|
622
|
+
findings.append(
|
|
623
|
+
Finding(
|
|
624
|
+
"factory_yaml",
|
|
625
|
+
False,
|
|
626
|
+
f"manifest failed to load: {manifest_error}",
|
|
627
|
+
)
|
|
628
|
+
)
|
|
629
|
+
# A forge refusal is not a reason to stop judging the repo's own tree: init's
|
|
630
|
+
# findings come from the same function `evaluate_repo` calls, so the
|
|
631
|
+
# gitignore and registry checks survive a repo with no remote at all.
|
|
632
|
+
findings.extend(evaluate_init_facts(init_facts))
|
|
633
|
+
return TargetRepoProfile(
|
|
634
|
+
repo=address or target_repo,
|
|
635
|
+
default_branch=default_branch,
|
|
636
|
+
visibility=visibility,
|
|
637
|
+
queue_enabled=False,
|
|
638
|
+
required_checks=(),
|
|
639
|
+
declared_gates=declared_gates,
|
|
640
|
+
findings=tuple(findings),
|
|
641
|
+
passed=False,
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
@activity.defn
|
|
646
|
+
async def validate_target_repo(request: ValidateTargetRepoInput) -> TargetRepoProfile:
|
|
647
|
+
"""US3's preflight activity: gather facts through the seam and judge (FR-010).
|
|
648
|
+
|
|
649
|
+
The thin wrapper: resolves the forge through the injectable `_client_factory`
|
|
650
|
+
seam and hands it to `onboard_target_repo`, which both this activity and the
|
|
651
|
+
offline CLI share. Tests replace the seam with one over a `FakeGh`; the CLI
|
|
652
|
+
resolves a forge against the clone the operator points at.
|
|
653
|
+
"""
|
|
654
|
+
return onboard_target_repo(_forge(repo_path=request.target_repo), request.target_repo)
|