alissa-tools-github-orcloop 0.3.8__tar.gz → 0.3.10__tar.gz
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.
- {alissa_tools_github_orcloop-0.3.8/src/main/alissa_tools_github_orcloop.egg-info → alissa_tools_github_orcloop-0.3.10}/PKG-INFO +1 -1
- {alissa_tools_github_orcloop-0.3.8 → alissa_tools_github_orcloop-0.3.10}/src/main/alissa/tools/github/orcloop/__main__.py +13 -0
- {alissa_tools_github_orcloop-0.3.8 → alissa_tools_github_orcloop-0.3.10}/src/main/alissa/tools/github/orcloop/config.py +35 -0
- {alissa_tools_github_orcloop-0.3.8 → alissa_tools_github_orcloop-0.3.10}/src/main/alissa/tools/github/orcloop/ghclient.py +219 -15
- {alissa_tools_github_orcloop-0.3.8 → alissa_tools_github_orcloop-0.3.10}/src/main/alissa/tools/github/orcloop/loop.py +716 -6
- alissa_tools_github_orcloop-0.3.10/src/main/alissa/tools/github/orcloop/version +1 -0
- {alissa_tools_github_orcloop-0.3.8 → alissa_tools_github_orcloop-0.3.10/src/main/alissa_tools_github_orcloop.egg-info}/PKG-INFO +1 -1
- alissa_tools_github_orcloop-0.3.8/src/main/alissa/tools/github/orcloop/version +0 -1
- {alissa_tools_github_orcloop-0.3.8 → alissa_tools_github_orcloop-0.3.10}/MANIFEST.in +0 -0
- {alissa_tools_github_orcloop-0.3.8 → alissa_tools_github_orcloop-0.3.10}/README.md +0 -0
- {alissa_tools_github_orcloop-0.3.8 → alissa_tools_github_orcloop-0.3.10}/requirements.txt +0 -0
- {alissa_tools_github_orcloop-0.3.8 → alissa_tools_github_orcloop-0.3.10}/setup.cfg +0 -0
- {alissa_tools_github_orcloop-0.3.8 → alissa_tools_github_orcloop-0.3.10}/setup.py +0 -0
- {alissa_tools_github_orcloop-0.3.8 → alissa_tools_github_orcloop-0.3.10}/src/main/alissa/tools/github/orcloop/__init__.py +0 -0
- {alissa_tools_github_orcloop-0.3.8 → alissa_tools_github_orcloop-0.3.10}/src/main/alissa/tools/github/orcloop/alissa_client.py +0 -0
- {alissa_tools_github_orcloop-0.3.8 → alissa_tools_github_orcloop-0.3.10}/src/main/alissa/tools/github/orcloop/markers.py +0 -0
- {alissa_tools_github_orcloop-0.3.8 → alissa_tools_github_orcloop-0.3.10}/src/main/alissa/tools/github/orcloop/proc.py +0 -0
- {alissa_tools_github_orcloop-0.3.8 → alissa_tools_github_orcloop-0.3.10}/src/main/alissa/tools/github/orcloop/state.py +0 -0
- {alissa_tools_github_orcloop-0.3.8 → alissa_tools_github_orcloop-0.3.10}/src/main/alissa/tools/github/orcloop/version.py +0 -0
- {alissa_tools_github_orcloop-0.3.8 → alissa_tools_github_orcloop-0.3.10}/src/main/alissa_tools_github_orcloop.egg-info/SOURCES.txt +0 -0
- {alissa_tools_github_orcloop-0.3.8 → alissa_tools_github_orcloop-0.3.10}/src/main/alissa_tools_github_orcloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_orcloop-0.3.8 → alissa_tools_github_orcloop-0.3.10}/src/main/alissa_tools_github_orcloop.egg-info/entry_points.txt +0 -0
- {alissa_tools_github_orcloop-0.3.8 → alissa_tools_github_orcloop-0.3.10}/src/main/alissa_tools_github_orcloop.egg-info/top_level.txt +0 -0
|
@@ -145,6 +145,18 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
145
145
|
"disables it). The sweep reports, it never releases.",
|
|
146
146
|
)
|
|
147
147
|
|
|
148
|
+
over.add_argument(
|
|
149
|
+
"--autoclose-grace-seconds",
|
|
150
|
+
type=int,
|
|
151
|
+
metavar="SECONDS",
|
|
152
|
+
help="close edge only: how long to let GitHub close a released issue "
|
|
153
|
+
"itself, measured from the closing PR's mergedAt, before the daemon "
|
|
154
|
+
"closes it mechanically (default 300; 0 = no grace). GitHub's "
|
|
155
|
+
"`Closes #n` auto-close is best-effort and can silently never fire, "
|
|
156
|
+
"which would otherwise leave the issue open and its origin task stuck "
|
|
157
|
+
"forever.",
|
|
158
|
+
)
|
|
159
|
+
|
|
148
160
|
close = over.add_mutually_exclusive_group()
|
|
149
161
|
close.add_argument(
|
|
150
162
|
"--close-loop",
|
|
@@ -191,6 +203,7 @@ def overrides_from(args: argparse.Namespace) -> dict:
|
|
|
191
203
|
"alissa_endpoint": args.alissa_endpoint,
|
|
192
204
|
"ledger_path": args.ledger_path,
|
|
193
205
|
"close_loop_enabled": args.close_loop_enabled,
|
|
206
|
+
"autoclose_grace_seconds": args.autoclose_grace_seconds,
|
|
194
207
|
"dry_run": args.dry_run,
|
|
195
208
|
"release_feed": args.release_feed,
|
|
196
209
|
"bow_prefix": args.bow_prefix,
|
|
@@ -121,6 +121,7 @@ CONFIG_KEYS = (
|
|
|
121
121
|
"dry_run",
|
|
122
122
|
"ledger_path",
|
|
123
123
|
"close_loop_enabled",
|
|
124
|
+
"autoclose_grace_seconds",
|
|
124
125
|
"release_feed",
|
|
125
126
|
"bow_prefix",
|
|
126
127
|
"bow_owners",
|
|
@@ -499,6 +500,25 @@ class Config:
|
|
|
499
500
|
# half; every side effect still honors dry_run. Off reverts to release-only.
|
|
500
501
|
close_loop_enabled: bool = True
|
|
501
502
|
|
|
503
|
+
# How long to let GitHub close a released issue itself, measured from the
|
|
504
|
+
# closing PR's `mergedAt`, before the close edge closes it mechanically.
|
|
505
|
+
#
|
|
506
|
+
# GitHub's `Closes #n` auto-close is best-effort, not a contract: it has been
|
|
507
|
+
# observed to silently never fire on a properly-linked PR while a sibling
|
|
508
|
+
# merge seconds earlier auto-closed normally. Past this window an issue whose
|
|
509
|
+
# closing PR has MERGED is treated as a flaked platform step and completed by
|
|
510
|
+
# the daemon (issue #40). Within it, nothing happens -- the daemon never
|
|
511
|
+
# races the platform for a step the platform usually performs.
|
|
512
|
+
#
|
|
513
|
+
# Minutes, not seconds or hours: auto-close normally lands within seconds, so
|
|
514
|
+
# a few minutes is far outside the healthy distribution, while an hour would
|
|
515
|
+
# leave the released work's lane occupied for an hour on every flake. `0`
|
|
516
|
+
# means "no grace" (close on the first pass that sees the merge) and is legal
|
|
517
|
+
# -- it is a race with the platform, not a correctness problem, since the
|
|
518
|
+
# close is idempotent -- but it makes a normal auto-close look like an
|
|
519
|
+
# anomaly, so it is not the default.
|
|
520
|
+
autoclose_grace_seconds: int = 300
|
|
521
|
+
|
|
502
522
|
dry_run: bool = False
|
|
503
523
|
|
|
504
524
|
# Where release candidates come from: `scan` (all committed tasks, filtered
|
|
@@ -651,6 +671,20 @@ class Config:
|
|
|
651
671
|
f"release_blocked_floor_days must be >= 1, got {floor_days}"
|
|
652
672
|
)
|
|
653
673
|
|
|
674
|
+
grace = int(raw.get("autoclose_grace_seconds", cls.autoclose_grace_seconds))
|
|
675
|
+
if grace < 0:
|
|
676
|
+
# No upper bound and no MIN_POLL_INTERVAL floor, unlike the sweep:
|
|
677
|
+
# this is a wait, not a cadence, so a small value only makes the
|
|
678
|
+
# daemon act sooner (`0` = act on the first pass that sees the
|
|
679
|
+
# merge), and a large one only makes it wait longer. Neither can
|
|
680
|
+
# produce the runaway a sub-poll INTERVAL can. Negative is the one
|
|
681
|
+
# value with no reading at all -- it would say "close it before the
|
|
682
|
+
# PR merged" -- so it is rejected rather than clamped.
|
|
683
|
+
raise ValueError(
|
|
684
|
+
f"autoclose_grace_seconds must be >= 0 (0 = no grace, close as "
|
|
685
|
+
f"soon as the merge is seen), got {grace}"
|
|
686
|
+
)
|
|
687
|
+
|
|
654
688
|
marker_key = str(raw.get("marker_key", cls.marker_key))
|
|
655
689
|
scope_key = str(raw.get("scope_key", cls.scope_key))
|
|
656
690
|
for name, value in (("marker_key", marker_key), ("scope_key", scope_key)):
|
|
@@ -721,6 +755,7 @@ class Config:
|
|
|
721
755
|
close_loop_enabled=bool(
|
|
722
756
|
raw.get("close_loop_enabled", cls.close_loop_enabled)
|
|
723
757
|
),
|
|
758
|
+
autoclose_grace_seconds=grace,
|
|
724
759
|
dry_run=bool(raw.get("dry_run", cls.dry_run)),
|
|
725
760
|
release_feed=release_feed,
|
|
726
761
|
bow_prefix=bow_prefix,
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
"""GitHub access via `gh api`.
|
|
2
2
|
|
|
3
3
|
orcloop's GitHub side: create the `alissa:develop` issue for a released task,
|
|
4
|
-
manage its label, read issues back, search the watched repos, comment,
|
|
5
|
-
|
|
4
|
+
manage its label, read issues back, search the watched repos, comment, close an
|
|
5
|
+
issue the platform should have closed itself, and -- for the close-loop -- find
|
|
6
|
+
the MERGED pull request that closed a released issue.
|
|
6
7
|
|
|
7
8
|
Targets gh 2.4.0, which predates `gh search` and `gh issue`-level JSON we can
|
|
8
9
|
rely on: every call goes through `gh api` against REST v3. The gh-2.4.0-safe
|
|
@@ -24,6 +25,7 @@ import os
|
|
|
24
25
|
import re
|
|
25
26
|
import tempfile
|
|
26
27
|
import urllib.parse
|
|
28
|
+
from datetime import datetime, timezone
|
|
27
29
|
from dataclasses import dataclass
|
|
28
30
|
|
|
29
31
|
from . import proc
|
|
@@ -97,6 +99,33 @@ def parse_task_ref(body: str) -> int | None:
|
|
|
97
99
|
return int(match.group(1)) if match else None
|
|
98
100
|
|
|
99
101
|
|
|
102
|
+
def parse_timestamp(value: str | None) -> float | None:
|
|
103
|
+
"""A GitHub RFC 3339 timestamp (`2026-07-28T04:42:55Z`) as unix seconds, or
|
|
104
|
+
None when it is missing or unparseable.
|
|
105
|
+
|
|
106
|
+
`fromisoformat` handles the `Z` suffix from 3.11 on, which `setup.py`
|
|
107
|
+
already requires. A timestamp carrying no zone is read as UTC rather than as
|
|
108
|
+
the daemon's local time: every timestamp GitHub sends is UTC, and a
|
|
109
|
+
container running in a non-UTC zone would otherwise shift an age by hours --
|
|
110
|
+
silently, and in whichever direction the deployment happens to sit.
|
|
111
|
+
|
|
112
|
+
None rather than an exception, for the same reason `pull_request` degrades a
|
|
113
|
+
malformed payload to None: the callers here are decisions, not parsers, and
|
|
114
|
+
a field GitHub omitted must not be able to crash a poll. What "unknown"
|
|
115
|
+
MEANS is the caller's to decide, which is why it is a distinct answer and
|
|
116
|
+
not, say, 0.
|
|
117
|
+
"""
|
|
118
|
+
if not value:
|
|
119
|
+
return None
|
|
120
|
+
try:
|
|
121
|
+
parsed = datetime.fromisoformat(str(value).strip())
|
|
122
|
+
except ValueError:
|
|
123
|
+
return None
|
|
124
|
+
if parsed.tzinfo is None:
|
|
125
|
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
126
|
+
return parsed.timestamp()
|
|
127
|
+
|
|
128
|
+
|
|
100
129
|
@dataclass(frozen=True)
|
|
101
130
|
class Issue:
|
|
102
131
|
owner: str
|
|
@@ -107,6 +136,17 @@ class Issue:
|
|
|
107
136
|
body: str
|
|
108
137
|
url: str
|
|
109
138
|
labels: tuple[str, ...]
|
|
139
|
+
# `state_reason`, verbatim: `completed` / `not_planned` / `reopened`, or ""
|
|
140
|
+
# when GitHub did not state one. Defaulted like the PullRequest fields below
|
|
141
|
+
# -- it is a late addition to the API and an issue reopened long ago (or
|
|
142
|
+
# through an unusual path) can carry nothing here.
|
|
143
|
+
#
|
|
144
|
+
# `reopened` is the one value the close edge acts on, and it is the cheapest
|
|
145
|
+
# answer to "has this issue ever been closed?" -- a question `state` cannot
|
|
146
|
+
# answer and, verified against a real reopened issue (golang/go#79623),
|
|
147
|
+
# `closed_at` cannot either: GitHub CLEARS `closed_at` on reopen, so it is
|
|
148
|
+
# null on exactly the issues that matter. See `_evaluate_open_released`.
|
|
149
|
+
state_reason: str = ""
|
|
110
150
|
|
|
111
151
|
@property
|
|
112
152
|
def full_name(self) -> str:
|
|
@@ -116,6 +156,11 @@ class Issue:
|
|
|
116
156
|
def issue_slug(self) -> str:
|
|
117
157
|
return f"{self.owner}/{self.repo}#{self.number}"
|
|
118
158
|
|
|
159
|
+
@property
|
|
160
|
+
def reopened(self) -> bool:
|
|
161
|
+
"""Whether GitHub says this issue was re-opened after being closed."""
|
|
162
|
+
return self.state_reason == "reopened"
|
|
163
|
+
|
|
119
164
|
|
|
120
165
|
@dataclass(frozen=True)
|
|
121
166
|
class PullRequest:
|
|
@@ -127,11 +172,59 @@ class PullRequest:
|
|
|
127
172
|
body: str
|
|
128
173
|
merged: bool
|
|
129
174
|
url: str
|
|
175
|
+
# The merge commit's SHA (`merge_commit_sha`), for anchoring the close
|
|
176
|
+
# evidence to a point in history. Defaulted rather than required: it is
|
|
177
|
+
# meaningful only on a merged PR, and a payload that omits it must degrade
|
|
178
|
+
# to "unknown" in the evidence rather than fail the close walk.
|
|
179
|
+
merge_sha: str = ""
|
|
180
|
+
# The branch this PR merged INTO (`base.ref`), verbatim. GitHub only
|
|
181
|
+
# auto-closes a linked issue when the PR merges into the repository's
|
|
182
|
+
# DEFAULT branch, so this is what separates "the auto-close flaked" from
|
|
183
|
+
# "the platform correctly declined to auto-close a merge into `develop`".
|
|
184
|
+
# Defaulted like the fields below: a payload that omits it says nothing, and
|
|
185
|
+
# the close edge refuses to repair on a base it cannot read.
|
|
186
|
+
base_ref: str = ""
|
|
187
|
+
# `merged_at`, verbatim as GitHub sent it (RFC 3339, `2026-07-28T04:42:55Z`).
|
|
188
|
+
# Kept as the RAW string rather than a parsed timestamp for the same reason
|
|
189
|
+
# `merge_sha` is defaulted: this dataclass mirrors a payload, and a field the
|
|
190
|
+
# payload omitted must read as "not stated" rather than as an epoch the
|
|
191
|
+
# daemon invented. `merged_epoch` below is the parsed view, and it answers
|
|
192
|
+
# None on exactly the payloads that omit or malform it.
|
|
193
|
+
merged_at: str = ""
|
|
130
194
|
|
|
131
195
|
@property
|
|
132
196
|
def full_name(self) -> str:
|
|
133
197
|
return f"{self.owner}/{self.repo}"
|
|
134
198
|
|
|
199
|
+
@property
|
|
200
|
+
def pr_slug(self) -> str:
|
|
201
|
+
return f"{self.owner}/{self.repo}#{self.number}"
|
|
202
|
+
|
|
203
|
+
@property
|
|
204
|
+
def merged_epoch(self) -> float | None:
|
|
205
|
+
"""`merged_at` as unix seconds, or None when it is absent/unparseable.
|
|
206
|
+
|
|
207
|
+
The close edge measures GitHub's auto-close grace window from this, so
|
|
208
|
+
"unknown" must be distinguishable from "long ago" -- see
|
|
209
|
+
`Orchestrator._evaluate_open_released`, which is what decides how an
|
|
210
|
+
unknown merge time behaves."""
|
|
211
|
+
return parse_timestamp(self.merged_at)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@dataclass(frozen=True)
|
|
215
|
+
class CloseWalk:
|
|
216
|
+
"""What ONE walk of an issue's timeline establishes about how it closed --
|
|
217
|
+
or failed to.
|
|
218
|
+
|
|
219
|
+
Two facts, deliberately together: the merged PR that declares it closes the
|
|
220
|
+
issue (None if there is none), and whether the issue has ever been closed at
|
|
221
|
+
all. The close edge's repair needs both and must not pay for two walks to
|
|
222
|
+
get them; keeping them in one record is also what stops a caller from
|
|
223
|
+
answering "was it closed?" with the cheaper, wrong `state == "closed"`."""
|
|
224
|
+
|
|
225
|
+
pr: "PullRequest | None"
|
|
226
|
+
ever_closed: bool
|
|
227
|
+
|
|
135
228
|
|
|
136
229
|
class RateLimited(RuntimeError):
|
|
137
230
|
"""A `gh` rate-limit signal -- a retry-later condition, distinct from a
|
|
@@ -146,6 +239,11 @@ def _is_rate_limited(exc: proc.CommandError) -> bool:
|
|
|
146
239
|
class GitHub:
|
|
147
240
|
SEARCH_PER_PAGE = 100
|
|
148
241
|
|
|
242
|
+
def __init__(self) -> None:
|
|
243
|
+
# Per-repo default branch, resolved lazily and kept for the process.
|
|
244
|
+
# See `default_branch`; only successful reads land here.
|
|
245
|
+
self._default_branches: dict[str, str] = {}
|
|
246
|
+
|
|
149
247
|
def _api(self, *args: str, timeout: int = 60):
|
|
150
248
|
try:
|
|
151
249
|
return proc.run_json(["gh", "api", *args], timeout=timeout)
|
|
@@ -339,6 +437,38 @@ class GitHub:
|
|
|
339
437
|
f"repos/{owner}/{repo}/issues/{number}/comments", "-f", f"body={body}"
|
|
340
438
|
)
|
|
341
439
|
|
|
440
|
+
def close_issue(
|
|
441
|
+
self, owner: str, repo: str, number: int, reason: str = "completed"
|
|
442
|
+
) -> Issue:
|
|
443
|
+
"""Close an issue, stating WHY, and return it as GitHub now reports it.
|
|
444
|
+
|
|
445
|
+
The only issue-state mutation orcloop performs, and it exists for one
|
|
446
|
+
anomaly: GitHub's `Closes #n` auto-close is best-effort, so a merged
|
|
447
|
+
closing PR can leave its issue open forever (see the close edge's
|
|
448
|
+
`_evaluate_open_released`). `reason` maps to `state_reason`, which is
|
|
449
|
+
what distinguishes a completed issue from an abandoned one in GitHub's
|
|
450
|
+
own UI -- the daemon only ever completes work the platform confirmed
|
|
451
|
+
merged, so the default is `completed` and no caller passes anything else
|
|
452
|
+
today.
|
|
453
|
+
|
|
454
|
+
PATCH, through the same `--input <tempfile>` path `_post_json` uses:
|
|
455
|
+
gh 2.4.0's `-f` sends everything as a string, and `state_reason` is a
|
|
456
|
+
closed enum whose values would still be strings but whose siblings on
|
|
457
|
+
this endpoint (`labels`, `assignees`) are arrays -- one JSON body is
|
|
458
|
+
both simpler and the pattern the rest of this file already follows.
|
|
459
|
+
|
|
460
|
+
Idempotent on GitHub's side: closing an already-closed issue is a 200
|
|
461
|
+
that leaves it closed, so a retry after a lost response is harmless.
|
|
462
|
+
"""
|
|
463
|
+
data = self._send_json(
|
|
464
|
+
"PATCH",
|
|
465
|
+
f"repos/{owner}/{repo}/issues/{number}",
|
|
466
|
+
{"state": "closed", "state_reason": reason},
|
|
467
|
+
)
|
|
468
|
+
return self._issue_from_payload(
|
|
469
|
+
owner, repo, data if isinstance(data, dict) else {}
|
|
470
|
+
)
|
|
471
|
+
|
|
342
472
|
# --- the close-loop: the merged-PR walk --------------------------------
|
|
343
473
|
|
|
344
474
|
def closing_merged_pr(self, owner: str, repo: str, number: int) -> PullRequest | None:
|
|
@@ -363,21 +493,68 @@ class GitHub:
|
|
|
363
493
|
wedge the close decision. Newest-referenced PR wins ties -- the last
|
|
364
494
|
cross-reference is the one most likely to be the real closer.
|
|
365
495
|
"""
|
|
366
|
-
|
|
496
|
+
return self.close_walk(owner, repo, number).pr
|
|
497
|
+
|
|
498
|
+
def close_walk(self, owner: str, repo: str, number: int) -> "CloseWalk":
|
|
499
|
+
"""`closing_merged_pr`'s answer PLUS whether the issue was ever closed,
|
|
500
|
+
both from the SAME timeline walk.
|
|
501
|
+
|
|
502
|
+
The close edge's flaked-auto-close repair (O16) needs both facts, and
|
|
503
|
+
needs them not to cost two walks: "is there a merged closer?" decides
|
|
504
|
+
whether there is anything to repair, and "was this issue ever closed?"
|
|
505
|
+
decides whether there is anything WRONG -- an issue that was closed once
|
|
506
|
+
and is open again was re-opened by a human, which is indistinguishable
|
|
507
|
+
from a flake on every other signal (same open state, same merged PR,
|
|
508
|
+
same elapsed time since `mergedAt`).
|
|
509
|
+
|
|
510
|
+
Split out from `closing_merged_pr` rather than folded into it so the
|
|
511
|
+
closed-issue path keeps its exact signature and semantics: that caller
|
|
512
|
+
wants a PR, has no use for the history, and must not have to learn a new
|
|
513
|
+
return shape for a question it never asks."""
|
|
514
|
+
candidates, ever_closed = self._timeline_facts(owner, repo, number)
|
|
367
515
|
pattern = _closes_re(number)
|
|
368
516
|
for pr_number in candidates[:MAX_PR_CHECKS]:
|
|
369
517
|
pr = self.pull_request(owner, repo, pr_number)
|
|
370
518
|
if pr is None or not pr.merged:
|
|
371
519
|
continue
|
|
372
520
|
if pattern.search(pr.body):
|
|
373
|
-
return pr
|
|
374
|
-
return None
|
|
521
|
+
return CloseWalk(pr=pr, ever_closed=ever_closed)
|
|
522
|
+
return CloseWalk(pr=None, ever_closed=ever_closed)
|
|
523
|
+
|
|
524
|
+
def default_branch(self, owner: str, repo: str) -> str:
|
|
525
|
+
"""The repository's default branch, memoised for this process.
|
|
526
|
+
|
|
527
|
+
GitHub auto-closes a linked issue only on a merge into this branch, so
|
|
528
|
+
the close edge needs it to tell a flaked auto-close from a merge the
|
|
529
|
+
platform correctly declined to act on. Memoised because a default branch
|
|
530
|
+
does not move within a poll (or most weeks) and the daemon watches a
|
|
531
|
+
handful of repos: one read per repo per process, never per poll. A
|
|
532
|
+
failure is NOT cached -- it must be retried, not turned into a permanent
|
|
533
|
+
empty answer that would look like an unreadable base forever."""
|
|
534
|
+
key = f"{owner}/{repo}"
|
|
535
|
+
cached = self._default_branches.get(key)
|
|
536
|
+
if cached is not None:
|
|
537
|
+
return cached
|
|
538
|
+
data = self._api(f"repos/{owner}/{repo}")
|
|
539
|
+
branch = str((data or {}).get("default_branch") or "") if isinstance(
|
|
540
|
+
data, dict
|
|
541
|
+
) else ""
|
|
542
|
+
if branch:
|
|
543
|
+
self._default_branches[key] = branch
|
|
544
|
+
return branch
|
|
375
545
|
|
|
376
546
|
def pull_request(self, owner: str, repo: str, number: int) -> PullRequest | None:
|
|
377
547
|
"""One PR's close-loop-relevant fields, or None if it cannot be read.
|
|
378
548
|
A malformed/empty payload degrades to None rather than a fabricated PR
|
|
379
549
|
-- the close-loop treats "unreadable" as "not the closer, keep
|
|
380
|
-
looking", never as a merge.
|
|
550
|
+
looking", never as a merge.
|
|
551
|
+
|
|
552
|
+
`body`, `merge_sha` and `merged_at` all come from this ONE payload,
|
|
553
|
+
which is why the close edge's delivery-record evidence (O15) and its
|
|
554
|
+
auto-close grace window (O16) cost no extra round-trip: the PR the close
|
|
555
|
+
walk already resolved carries everything both need. A field the payload
|
|
556
|
+
omits degrades to the empty string here and is reported as missing at
|
|
557
|
+
the use site -- never fabricated, never fetched a second time."""
|
|
381
558
|
data = self._api(f"repos/{owner}/{repo}/pulls/{number}")
|
|
382
559
|
if not isinstance(data, dict) or not data:
|
|
383
560
|
return None
|
|
@@ -390,13 +567,30 @@ class GitHub:
|
|
|
390
567
|
body=data.get("body") or "",
|
|
391
568
|
merged=bool(data.get("merged") or data.get("merged_at")),
|
|
392
569
|
url=data.get("html_url") or "",
|
|
570
|
+
merge_sha=str(data.get("merge_commit_sha") or ""),
|
|
571
|
+
merged_at=str(data.get("merged_at") or ""),
|
|
572
|
+
base_ref=str(((data.get("base") or {}) if isinstance(
|
|
573
|
+
data.get("base"), dict) else {}).get("ref") or ""),
|
|
393
574
|
)
|
|
394
575
|
|
|
395
|
-
def
|
|
396
|
-
|
|
397
|
-
|
|
576
|
+
def _timeline_facts(
|
|
577
|
+
self, owner: str, repo: str, number: int
|
|
578
|
+
) -> tuple[list[int], bool]:
|
|
579
|
+
"""One timeline walk, two answers: the same-repo PR numbers it
|
|
580
|
+
cross-references (newest first, deduped -- the candidate set for
|
|
581
|
+
`close_walk`) and whether it carries a `closed` event.
|
|
582
|
+
|
|
583
|
+
The `closed` event is what makes "has this issue EVER been closed?"
|
|
584
|
+
answerable. It is durable in a way the issue's own fields are not: a
|
|
585
|
+
reopen clears `closed_at` and leaves `state` at `open`, so the timeline
|
|
586
|
+
is the only place the history survives (`golang/go#79623`, a real
|
|
587
|
+
reopened issue, reports `closed_at: null` while its timeline still
|
|
588
|
+
carries `closed`). Bounded by the same TIMELINE_MAX_PAGES page cap as
|
|
589
|
+
the cross-reference scan, which is why the caller does not rely on it
|
|
590
|
+
alone -- see `Issue.reopened`."""
|
|
398
591
|
same_repo_pull = f"https://github.com/{owner}/{repo}/pull/"
|
|
399
592
|
numbers: list[int] = []
|
|
593
|
+
ever_closed = False
|
|
400
594
|
for page in range(1, TIMELINE_MAX_PAGES + 1):
|
|
401
595
|
events = self._api(
|
|
402
596
|
"-X", "GET", f"repos/{owner}/{repo}/issues/{number}/timeline",
|
|
@@ -405,7 +599,11 @@ class GitHub:
|
|
|
405
599
|
if not isinstance(events, list):
|
|
406
600
|
break
|
|
407
601
|
for event in events:
|
|
408
|
-
if not isinstance(event, dict)
|
|
602
|
+
if not isinstance(event, dict):
|
|
603
|
+
continue
|
|
604
|
+
if event.get("event") == "closed":
|
|
605
|
+
ever_closed = True
|
|
606
|
+
if event.get("event") != "cross-referenced":
|
|
409
607
|
continue
|
|
410
608
|
source = event.get("source")
|
|
411
609
|
src = source.get("issue") if isinstance(source, dict) else None
|
|
@@ -421,19 +619,24 @@ class GitHub:
|
|
|
421
619
|
if len(events) < TIMELINE_PAGE_SIZE:
|
|
422
620
|
break # short page: the timeline is exhausted
|
|
423
621
|
# Dedupe keeping LAST occurrence: the most recent cross-reference is the
|
|
424
|
-
# likeliest closer, and
|
|
425
|
-
return list(dict.fromkeys(reversed(numbers)))
|
|
622
|
+
# likeliest closer, and close_walk checks candidates in order.
|
|
623
|
+
return list(dict.fromkeys(reversed(numbers))), ever_closed
|
|
426
624
|
|
|
427
625
|
# --- helpers -----------------------------------------------------------
|
|
428
626
|
|
|
429
627
|
def _post_json(self, path: str, payload: dict):
|
|
430
|
-
"""POST a JSON body via `--input <tempfile
|
|
431
|
-
|
|
628
|
+
"""POST a JSON body via `--input <tempfile>`."""
|
|
629
|
+
return self._send_json("POST", path, payload)
|
|
630
|
+
|
|
631
|
+
def _send_json(self, method: str, path: str, payload: dict):
|
|
632
|
+
"""Send a JSON body via `--input <tempfile>`, cleaning the file up on
|
|
633
|
+
every path (success or raise). One implementation for every verb: the
|
|
634
|
+
tempfile lifetime is the part worth having exactly once."""
|
|
432
635
|
fd, body_path = tempfile.mkstemp(prefix="orcloop-", suffix=".json")
|
|
433
636
|
try:
|
|
434
637
|
with os.fdopen(fd, "w") as handle:
|
|
435
638
|
handle.write(json.dumps(payload))
|
|
436
|
-
return self._api("-X",
|
|
639
|
+
return self._api("-X", method, path, "--input", body_path)
|
|
437
640
|
finally:
|
|
438
641
|
os.unlink(body_path)
|
|
439
642
|
|
|
@@ -452,6 +655,7 @@ class GitHub:
|
|
|
452
655
|
(lb.get("name") or "") if isinstance(lb, dict) else str(lb)
|
|
453
656
|
for lb in (data.get("labels") or [])
|
|
454
657
|
),
|
|
658
|
+
state_reason=str(data.get("state_reason") or ""),
|
|
455
659
|
)
|
|
456
660
|
|
|
457
661
|
|
|
@@ -213,12 +213,31 @@ so it behaves identically under `scan` and `bow` (and keeps driving issues
|
|
|
213
213
|
released before a feed switch):
|
|
214
214
|
|
|
215
215
|
* Poll each released issue (the ledger is the set to re-check; GitHub is the
|
|
216
|
-
truth for its state). Still open -> in flight,
|
|
216
|
+
truth for its state). Still open with no merged closing PR -> in flight,
|
|
217
|
+
nothing to do.
|
|
218
|
+
* Still OPEN but a merged pull request already declares `Closes #n` (O16, issue
|
|
219
|
+
#40) -> GitHub's auto-close is best-effort and has been observed to silently
|
|
220
|
+
never fire, leaving a phantom "in-flight" issue that holds its lane and an
|
|
221
|
+
origin task stuck at `in_progress` with nothing escalated. Inside
|
|
222
|
+
`autoclose_grace_seconds` of the merge, nothing happens -- the daemon does not
|
|
223
|
+
race the platform. Past it, the daemon closes the issue itself (`completed`,
|
|
224
|
+
commented, WARNING-logged) and falls into the close walk below in the SAME
|
|
225
|
+
pass. A close that FAILS holds the row untouched; an open issue with no merged
|
|
226
|
+
closer is never touched; and the repair runs EXACTLY once, so an issue an
|
|
227
|
+
operator re-opens afterwards is left alone for good. See
|
|
228
|
+
`_evaluate_open_released`.
|
|
217
229
|
* Closed with a MERGED pull request that declares `Closes #n` -> close the
|
|
218
230
|
loop on the ORIGIN task: satisfy every validation criterion, attach the
|
|
219
231
|
merged-PR evidence, and move the task `committed -> in_progress ->
|
|
220
232
|
pending_validation` (the two-step transition the status matrix requires;
|
|
221
233
|
orcloop hands off at pending_validation, never validates for the operator).
|
|
234
|
+
That evidence carries the merged PR's DELIVERY RECORD -- its `## Delivery
|
|
235
|
+
notes` section, or the whole body when there is none, anchored by PR title
|
|
236
|
+
and merge SHA (O15, `render_close_evidence`). A task arriving at
|
|
237
|
+
`pending_validation` has to be validatable from what is attached to it; a
|
|
238
|
+
bare "closed by <url>" makes the reader re-derive every criterion from
|
|
239
|
+
GitHub. An unreadable write-up degrades loudly and never blocks the walk --
|
|
240
|
+
the merge already happened, so holding the close would only strand the task.
|
|
222
241
|
* Closed with NO merged closing PR -> the `closed-unmerged` escalation only;
|
|
223
242
|
the task is NOT mutated (a human closed it, or the work was abandoned).
|
|
224
243
|
* The origin task already moved on (validated/cancelled/blocked/... -- the
|
|
@@ -278,7 +297,7 @@ import logging
|
|
|
278
297
|
import re
|
|
279
298
|
import time
|
|
280
299
|
from collections import Counter
|
|
281
|
-
from dataclasses import dataclass
|
|
300
|
+
from dataclasses import dataclass, replace
|
|
282
301
|
from enum import Enum
|
|
283
302
|
from functools import lru_cache
|
|
284
303
|
from typing import Callable, Sequence
|
|
@@ -461,6 +480,15 @@ CLOSELOOP_DONE = "closeloop-done"
|
|
|
461
480
|
# only after `add_evidence` succeeds, so a failed write retries next poll.
|
|
462
481
|
CLOSELOOP_EVIDENCE_DONE = "closeloop-evidence-done"
|
|
463
482
|
|
|
483
|
+
# NOT an operator page: an idempotency marker recording that the daemon closed a
|
|
484
|
+
# released issue GitHub's `Closes #n` auto-close never closed (O16, issue #40).
|
|
485
|
+
# It is what makes that repair happen EXACTLY once: an issue seen open again
|
|
486
|
+
# with this marker set was re-opened by an operator -- the very escape hatch the
|
|
487
|
+
# posted comment offers -- and is left alone for good, the close-edge twin of
|
|
488
|
+
# park's "never re-labeling". Recorded only after the close succeeds, so a
|
|
489
|
+
# failed close retries next poll.
|
|
490
|
+
AUTOCLOSE_HEALED = "autoclose-healed"
|
|
491
|
+
|
|
464
492
|
# The origin status the close-loop mutation itself hands off to. Seeing the task
|
|
465
493
|
# already here (before CLOSELOOP_DONE was recorded) means a prior pass completed
|
|
466
494
|
# the mutation but died before the completion marker -- orcloop's OWN transition,
|
|
@@ -606,6 +634,30 @@ CLOSELOOP_FAILED_ISSUE_COMMENT = (
|
|
|
606
634
|
"Retrying; operator help may be needed."
|
|
607
635
|
)
|
|
608
636
|
|
|
637
|
+
# Posted on the issue the daemon closes on GitHub's behalf (O16, issue #40). It
|
|
638
|
+
# says what was true, what the daemon did, and what it did NOT do -- a reader
|
|
639
|
+
# arriving at a mechanically-closed issue must be able to tell this apart from
|
|
640
|
+
# an operator closing it by hand, which is the whole reason it is commented and
|
|
641
|
+
# not just logged. It names the merged PR because that PR is the entire
|
|
642
|
+
# justification: without one this path is unreachable.
|
|
643
|
+
AUTOCLOSE_HEALED_ISSUE_COMMENT = (
|
|
644
|
+
"**Orchestrator: completed a flaked auto-close** — this issue was closed by "
|
|
645
|
+
"the orchestrator, not by GitHub. Pull request {pr_url} merged {merged} and "
|
|
646
|
+
"declares that it closes this issue, but GitHub's own `Closes #n` "
|
|
647
|
+
"auto-close never fired (best-effort, not a contract), and the issue was "
|
|
648
|
+
"still open more than {grace}s later. The work is merged, so the "
|
|
649
|
+
"orchestrator completed the step the platform skipped and is now driving "
|
|
650
|
+
"the origin Alissa task forward. Nothing was reverted and no code changed; "
|
|
651
|
+
"if this issue was genuinely NOT finished by that pull request, reopen it "
|
|
652
|
+
"and correct the PR's closing keyword."
|
|
653
|
+
)
|
|
654
|
+
|
|
655
|
+
# The merge time as the comment above states it when the payload did not carry
|
|
656
|
+
# one. Spelled out rather than left blank: "merged " followed by nothing reads
|
|
657
|
+
# as a rendering bug, and this path is exactly the one a reader should treat
|
|
658
|
+
# with suspicion.
|
|
659
|
+
AUTOCLOSE_MERGED_AT_UNKNOWN = "at an unreported time"
|
|
660
|
+
|
|
609
661
|
DEGRADED_DISCOVERY_REASON = (
|
|
610
662
|
"release discovery was incomplete this pass ({count} feed body(ies) of "
|
|
611
663
|
"work unreadable), so the two-body-of-work ambiguity check could not run "
|
|
@@ -637,6 +689,116 @@ CLOSE_EVIDENCE_BODY = (
|
|
|
637
689
|
"operator validation."
|
|
638
690
|
)
|
|
639
691
|
|
|
692
|
+
# O15: the confirmation sentence above is the CLAIM; everything below is the
|
|
693
|
+
# delivery record that lets a validator check it without leaving the task. The
|
|
694
|
+
# two anchor lines are always present, on every path -- a bare slug and URL is
|
|
695
|
+
# what the operator's soft validator rejected on studio TASK-1156925553.
|
|
696
|
+
CLOSE_EVIDENCE_PR_LINE = "**Pull request:** {pr_slug} — {title}"
|
|
697
|
+
CLOSE_EVIDENCE_SHA_LINE = "**Merge commit:** `{sha}`"
|
|
698
|
+
CLOSE_EVIDENCE_TITLE_MISSING = "_(the pull request payload carried no title)_"
|
|
699
|
+
CLOSE_EVIDENCE_SHA_MISSING = "unknown"
|
|
700
|
+
|
|
701
|
+
# The heading over the included record, in its two shapes. Each names WHICH of
|
|
702
|
+
# the two it is, so the reader never has to guess whether they are looking at a
|
|
703
|
+
# curated section or the whole body.
|
|
704
|
+
CLOSE_EVIDENCE_NOTES_HEADING = "## Delivery notes (from the merged pull request)"
|
|
705
|
+
# "no write-up" rather than "no section": a heading with nothing under it is one
|
|
706
|
+
# of the shapes that lands here, and this heading must not deny it exists when
|
|
707
|
+
# the body below plainly shows it.
|
|
708
|
+
CLOSE_EVIDENCE_FULL_HEADING = (
|
|
709
|
+
"## Pull request body (no `## Delivery notes` write-up — full body included)"
|
|
710
|
+
)
|
|
711
|
+
|
|
712
|
+
# The heading the delivery record is written under. Matched line-anchored and
|
|
713
|
+
# case-insensitively, but otherwise EXACTLY: see `extract_delivery_notes` for
|
|
714
|
+
# why this one is strict where O14's context anchors are lenient.
|
|
715
|
+
#
|
|
716
|
+
# WHO EMITS IT: nothing in THIS repo. The producer is the develop daemon's
|
|
717
|
+
# worker directive -- a companion task in the develop-daemon lane, fed the same
|
|
718
|
+
# day as issue #36 and explicitly out of scope for it -- which tells an
|
|
719
|
+
# implementing session to write this section into its PR body. So until that
|
|
720
|
+
# lands, every close legitimately takes the full-body branch; the fallback is
|
|
721
|
+
# the norm today and the section is the arriving case, not the other way round.
|
|
722
|
+
# Both branches are load-bearing and neither is dead code: a PR authored by a
|
|
723
|
+
# human, or by any daemon that never adopts the directive, will always want the
|
|
724
|
+
# fallback.
|
|
725
|
+
DELIVERY_NOTES_HEADING = "## Delivery notes"
|
|
726
|
+
|
|
727
|
+
# The cap on the included record. Generous on purpose -- a delivery write-up is
|
|
728
|
+
# thousands of chars, and evidence is markdown on a task rather than a GitHub
|
|
729
|
+
# body, so there is no 65,536-char cliff to fit under. It exists so one
|
|
730
|
+
# pathological PR body cannot turn every close into a megabyte write, not to
|
|
731
|
+
# shape the common case. A constant, not a config key, for the reason O14's
|
|
732
|
+
# context caps are: documented caps are a promise, tunable ones are a
|
|
733
|
+
# compatibility commitment.
|
|
734
|
+
#
|
|
735
|
+
# THE HEADROOM IS MEASURED, not assumed, because the size of this write is the
|
|
736
|
+
# one way this feature could newly BLOCK a close: `add_evidence` is not wrapped
|
|
737
|
+
# in `_mutate_close`, `_close_loop` routes its `AlissaError` to
|
|
738
|
+
# `_closeloop_failed`, and `set_status(pending_validation)` comes after it -- so
|
|
739
|
+
# a rejection that is deterministic rather than transient would strand the
|
|
740
|
+
# origin task at `in_progress` on every future poll. `AddEvidenceRequest`
|
|
741
|
+
# declares `markdownContent` with a `minLength` and NO `maxLength`, so the
|
|
742
|
+
# ceiling was probed directly against the live endpoint (2026-07-28, disposable
|
|
743
|
+
# task, since cancelled):
|
|
744
|
+
#
|
|
745
|
+
# 32,300 -> 201 131,072 -> 201 524,288 -> 201
|
|
746
|
+
# 65,536 -> 201 262,144 -> 201 1,048,576 -> 500
|
|
747
|
+
#
|
|
748
|
+
# Worst case here is this cap plus the confirmation line and the two anchors,
|
|
749
|
+
# ~32.3k -- sixteen times under the largest write the endpoint accepted. Raising
|
|
750
|
+
# this constant is therefore NOT a free edit: past ~512k the write starts
|
|
751
|
+
# failing, and it fails in the one direction this feature promises never to
|
|
752
|
+
# fail. Re-probe before widening it.
|
|
753
|
+
CLOSE_EVIDENCE_RECORD_MAX_CHARS = 32000
|
|
754
|
+
# Appended verbatim (issue #36's own wording). No silent caps.
|
|
755
|
+
#
|
|
756
|
+
# The marker ALONE is not the "was anything cut" signal, and the counterexample
|
|
757
|
+
# is this repo's own PR #37: the record is the PR body verbatim, that body
|
|
758
|
+
# mentions the marker inside the very section that gets extracted, and so the
|
|
759
|
+
# first evidence item this feature ever produced would answer a bare-literal
|
|
760
|
+
# grep wrong. `CLOSE_EVIDENCE_TRUNCATED_RE` below is the signal -- deliberately
|
|
761
|
+
# the only place the pattern is written down, because a comment restating it is
|
|
762
|
+
# a second copy to drift from (and, found the hard way, a second copy for a
|
|
763
|
+
# counterfactual to patch instead of the code).
|
|
764
|
+
CLOSE_EVIDENCE_TRUNCATED_MARKER = "[PR body truncated — read the PR]"
|
|
765
|
+
# The signal as a PATTERN rather than as prose: whoever greps an evidence item
|
|
766
|
+
# matches the same thing `_truncate_record` emits, and tests pin both directions
|
|
767
|
+
# so the two cannot drift apart. A promise about a diagnostic is worth exactly as
|
|
768
|
+
# much as the mechanism that keeps it true.
|
|
769
|
+
#
|
|
770
|
+
# ANCHORED at the end, which is what makes the ceiling below exactly true rather
|
|
771
|
+
# than merely nearly so. A real truncation is always LAST in the evidence:
|
|
772
|
+
# `_truncate_record` appends the marker after the record it cut, and
|
|
773
|
+
# `render_close_evidence` appends that record after everything else. Unanchored,
|
|
774
|
+
# a forged line ANYWHERE in a body would read as truncated; anchored, only a body
|
|
775
|
+
# whose own end forges it can. `\s*` before `\Z` so a consumer that normalises
|
|
776
|
+
# trailing whitespace still matches -- trailing blanks cannot forge anything.
|
|
777
|
+
# The end-position invariant this depends on is pinned by a test, so a future
|
|
778
|
+
# change that appends anything after the record fails loudly instead of silently
|
|
779
|
+
# turning a real truncation undetectable. A false NEGATIVE is the worse failure
|
|
780
|
+
# here, exactly as it is for O13's warning.
|
|
781
|
+
#
|
|
782
|
+
# THE CEILING, stated so nobody over-trusts it: a body whose end is a forged
|
|
783
|
+
# marker-and-counts line is indistinguishable from a real truncation. That needs
|
|
784
|
+
# merge access to the target repo, which is the same trust boundary
|
|
785
|
+
# `render_close_evidence` scopes for the included text as a whole -- so it bounds
|
|
786
|
+
# the diagnostic rather than opening a hole to escape.
|
|
787
|
+
CLOSE_EVIDENCE_TRUNCATED_RE = re.compile(
|
|
788
|
+
re.escape(CLOSE_EVIDENCE_TRUNCATED_MARKER) + r" \(\d+ of \d+ chars included\)\s*\Z"
|
|
789
|
+
)
|
|
790
|
+
|
|
791
|
+
# The loud degradation. The close walk NEVER stops for this: the merge already
|
|
792
|
+
# happened, and holding the close would strand the origin task in `in_progress`
|
|
793
|
+
# for a missing write-up. (The opposite trade from O14's context hold, where
|
|
794
|
+
# nothing has happened yet and the release can safely wait.)
|
|
795
|
+
CLOSE_EVIDENCE_UNRETRIEVABLE = (
|
|
796
|
+
"**The pull request body could not be retrieved**, so this evidence carries "
|
|
797
|
+
"the merge confirmation only — open the pull request for the delivery "
|
|
798
|
+
"record. The close-loop hand-off proceeded regardless: the merge is a fact "
|
|
799
|
+
"whether or not its write-up can be read."
|
|
800
|
+
)
|
|
801
|
+
|
|
640
802
|
|
|
641
803
|
class Action(str, Enum):
|
|
642
804
|
RELEASED = "released"
|
|
@@ -2479,9 +2641,7 @@ class Orchestrator:
|
|
|
2479
2641
|
f"{slug}: label removed by operator — parked, never re-labeling",
|
|
2480
2642
|
task_number,
|
|
2481
2643
|
))
|
|
2482
|
-
return self.
|
|
2483
|
-
Action.SKIPPED, f"{slug}: released issue still open — in flight", task_number,
|
|
2484
|
-
))
|
|
2644
|
+
return self._evaluate_open_released(task_number, ref, slug, issue)
|
|
2485
2645
|
|
|
2486
2646
|
try:
|
|
2487
2647
|
pr = self.github.closing_merged_pr(owner, repo, number)
|
|
@@ -2507,6 +2667,290 @@ class Orchestrator:
|
|
|
2507
2667
|
|
|
2508
2668
|
return self._close_loop(task_number, ref, slug, issue, pr)
|
|
2509
2669
|
|
|
2670
|
+
def _evaluate_open_released(
|
|
2671
|
+
self, task_number: int, ref: str, slug: str, issue: Issue
|
|
2672
|
+
) -> tuple[str, Decision]:
|
|
2673
|
+
"""A released issue that is still OPEN: genuinely in flight, or a
|
|
2674
|
+
platform step that flaked and must be completed on GitHub's behalf.
|
|
2675
|
+
|
|
2676
|
+
Through 0.3.9 `open` was terminal for the pass -- "still open" meant
|
|
2677
|
+
"still being worked on", and the closing PR was resolved only AFTER the
|
|
2678
|
+
issue closed. That reading is wrong, and it cost a live incident
|
|
2679
|
+
(studio#262 / PR #265, 2026-07-28): the PR merged with a properly-linked
|
|
2680
|
+
`Closes #262` and GitHub's auto-close silently never fired, while the
|
|
2681
|
+
sibling merge 21 seconds earlier auto-closed normally. **GitHub's
|
|
2682
|
+
`Closes #n` auto-close is best-effort, not a contract.** Nothing
|
|
2683
|
+
distinguished that issue from real work: the origin task sat
|
|
2684
|
+
`in_progress` with no evidence, the phantom in-flight issue held its
|
|
2685
|
+
scope in the repo lane against a queued task, and nothing escalated.
|
|
2686
|
+
|
|
2687
|
+
So the merged-closer read moves ahead of the open/closed split, and this
|
|
2688
|
+
branch answers one of three ways:
|
|
2689
|
+
|
|
2690
|
+
* no merged closing PR -> in flight, exactly as before. This is the
|
|
2691
|
+
common case and it is untouched: the daemon never closes an issue it
|
|
2692
|
+
cannot point at a merged PR for.
|
|
2693
|
+
* merged, inside the grace window -> SKIPPED, with a reason that says
|
|
2694
|
+
which condition it is. The window exists so the daemon never RACES
|
|
2695
|
+
GitHub for a step GitHub usually performs; the healthy case lands in
|
|
2696
|
+
seconds, so a few minutes is well outside it.
|
|
2697
|
+
* merged, past the window -> complete the step: close the issue
|
|
2698
|
+
(`completed`, commented, WARNING-logged) and fall straight through
|
|
2699
|
+
into the same close walk a natural auto-close would have produced --
|
|
2700
|
+
in THIS pass, because an extra poll of latency is the thing the origin
|
|
2701
|
+
task was already waiting on.
|
|
2702
|
+
|
|
2703
|
+
THE COST, stated as measured rather than as hoped. `repo_parallelism`
|
|
2704
|
+
bounds how MANY issues reach here at once (1 in-flight released issue
|
|
2705
|
+
per repo at the default), but it says nothing about how OFTEN, and this
|
|
2706
|
+
walk now runs on every poll for as long as the issue stays open. Note
|
|
2707
|
+
the shape: the healthy, common case -- genuinely in flight, no merged
|
|
2708
|
+
closer -- is the EXPENSIVE one, because it never short-circuits and
|
|
2709
|
+
fetches every cross-referenced candidate, so the per-poll cost grows
|
|
2710
|
+
with an issue's discussion rather than with its work. Measured: 2 `gh`
|
|
2711
|
+
calls per poll with one cross-referenced PR, 11 at the MAX_PR_CHECKS
|
|
2712
|
+
cap, ~+120 calls/hour/repo at `poll_interval=60` and the default
|
|
2713
|
+
parallelism -- small against a 5000/hour REST budget, but not zero, and
|
|
2714
|
+
it was zero before.
|
|
2715
|
+
|
|
2716
|
+
There is deliberately no damper here yet. The obvious one (probe every
|
|
2717
|
+
Nth poll) needs a design this change should not smuggle in: N interacts
|
|
2718
|
+
with `poll_interval` to stretch the effective grace window, it needs
|
|
2719
|
+
somewhere to survive a restart, and a throttled poll needs its own
|
|
2720
|
+
decision reason so it cannot be mistaken for a healthy in-flight one.
|
|
2721
|
+
The nearby `ESCALATION_CLOSED_UNMERGED` short-circuit exists for exactly
|
|
2722
|
+
this reason on a TERMINAL row, whose cost is unbounded in time; this
|
|
2723
|
+
one ends when the issue closes.
|
|
2724
|
+
|
|
2725
|
+
This is the devloop self-heal doctrine transposed to the close edge --
|
|
2726
|
+
complete the dead protocol step on the platform's behalf, exactly once,
|
|
2727
|
+
loudly -- the same family as the pushed-but-never-re-requested fix.
|
|
2728
|
+
|
|
2729
|
+
Park detection runs BEFORE this, in `_evaluate_close`, and stays there:
|
|
2730
|
+
an operator who removed the label has said "stop acting on this", which
|
|
2731
|
+
outranks any repair the daemon might otherwise perform.
|
|
2732
|
+
|
|
2733
|
+
"Exactly once" is enforced by the AUTOCLOSE_HEALED marker, recorded
|
|
2734
|
+
after a successful close. Seeing an issue OPEN again with that marker
|
|
2735
|
+
already set means the close landed and someone re-opened it -- which is
|
|
2736
|
+
precisely what the comment the daemon posted invites a reader to do if
|
|
2737
|
+
the PR did not really finish the work. Re-closing there would fight the
|
|
2738
|
+
operator and make that invitation a lie, so the repair is refused for
|
|
2739
|
+
good, exactly like park's "never re-labeling". It also covers the
|
|
2740
|
+
cheaper case of a read-after-write that still reports the issue open:
|
|
2741
|
+
one close, one comment, whatever the API's timing.
|
|
2742
|
+
"""
|
|
2743
|
+
if self.state.escalated(task_number, AUTOCLOSE_HEALED):
|
|
2744
|
+
return self._logged(ref, Decision(
|
|
2745
|
+
Action.SKIPPED,
|
|
2746
|
+
f"{slug}: auto-close already completed once and the issue is "
|
|
2747
|
+
f"open again — operator reopened it, never re-closing",
|
|
2748
|
+
task_number,
|
|
2749
|
+
))
|
|
2750
|
+
|
|
2751
|
+
try:
|
|
2752
|
+
walk = self.github.close_walk(issue.owner, issue.repo, issue.number)
|
|
2753
|
+
except (CommandError, RateLimited) as exc:
|
|
2754
|
+
return self._logged(ref, Decision(
|
|
2755
|
+
Action.HELD,
|
|
2756
|
+
f"{slug}: could not resolve closing PR: {exc}",
|
|
2757
|
+
task_number,
|
|
2758
|
+
))
|
|
2759
|
+
|
|
2760
|
+
pr = walk.pr
|
|
2761
|
+
if pr is None:
|
|
2762
|
+
return self._logged(ref, Decision(
|
|
2763
|
+
Action.SKIPPED,
|
|
2764
|
+
f"{slug}: released issue still open — in flight",
|
|
2765
|
+
task_number,
|
|
2766
|
+
))
|
|
2767
|
+
|
|
2768
|
+
# HAS THIS ISSUE EVER BEEN CLOSED? -- the question the open/closed state
|
|
2769
|
+
# cannot answer and the one this branch actually turns on. A flaked
|
|
2770
|
+
# auto-close and a human's deliberate re-open are identical on every
|
|
2771
|
+
# other signal: same open state, same merged PR declaring `Closes #n`,
|
|
2772
|
+
# same elapsed time since the merge. Repairing the second is not a
|
|
2773
|
+
# missed opportunity, it is REVERTING AN OPERATOR -- and posting a
|
|
2774
|
+
# comment claiming the auto-close never fired when it did.
|
|
2775
|
+
#
|
|
2776
|
+
# Two independent signals, because each covers the other's blind spot:
|
|
2777
|
+
# `state_reason` is free (it rides the issue payload already fetched)
|
|
2778
|
+
# and exact, but it is a late API addition that can be empty on an issue
|
|
2779
|
+
# re-opened long ago; the timeline's `closed` event is durable history
|
|
2780
|
+
# but can fall past TIMELINE_MAX_PAGES on a very chatty issue. Either
|
|
2781
|
+
# one refuses the repair, for good.
|
|
2782
|
+
#
|
|
2783
|
+
# `closed_at` is deliberately NOT among them: GitHub CLEARS it on
|
|
2784
|
+
# reopen, so it is null on precisely the issues this must catch
|
|
2785
|
+
# (verified against golang/go#79623, a real reopened issue).
|
|
2786
|
+
#
|
|
2787
|
+
# The accepted cost is a false NEGATIVE: an issue an operator closed and
|
|
2788
|
+
# re-opened earlier in its life is never repaired, even by a genuinely
|
|
2789
|
+
# flaked later merge. That fails toward leaving an operator's issue
|
|
2790
|
+
# alone, which is the trade park already makes.
|
|
2791
|
+
if issue.reopened or walk.ever_closed:
|
|
2792
|
+
return self._logged(ref, Decision(
|
|
2793
|
+
Action.SKIPPED,
|
|
2794
|
+
f"{slug}: closed once already and open again — reopened, not a "
|
|
2795
|
+
f"flaked auto-close; never re-closing",
|
|
2796
|
+
task_number,
|
|
2797
|
+
))
|
|
2798
|
+
|
|
2799
|
+
# DID GITHUB EVEN OWE US AN AUTO-CLOSE? It fires only for a merge into
|
|
2800
|
+
# the repository's DEFAULT branch: a PR carrying `Closes #n` that merges
|
|
2801
|
+
# into `develop`, a release branch, or a stacked base leaves the issue
|
|
2802
|
+
# open ON PURPOSE, and that is the platform working correctly.
|
|
2803
|
+
#
|
|
2804
|
+
# Through 0.3.9 the merged-closer predicate (merged + a closing keyword)
|
|
2805
|
+
# only supplied ATTRIBUTION for a decision GitHub had already made, so
|
|
2806
|
+
# being loose was harmless. Promoting it to the TRIGGER for a state
|
|
2807
|
+
# mutation is what makes the missing check matter.
|
|
2808
|
+
try:
|
|
2809
|
+
default_branch = self.github.default_branch(issue.owner, issue.repo)
|
|
2810
|
+
except (CommandError, RateLimited) as exc:
|
|
2811
|
+
# Same posture as the closing-PR failure above: an unreadable input
|
|
2812
|
+
# is not permission to close someone's issue.
|
|
2813
|
+
return self._logged(ref, Decision(
|
|
2814
|
+
Action.HELD,
|
|
2815
|
+
f"{slug}: could not resolve the default branch of "
|
|
2816
|
+
f"{issue.full_name}: {exc}",
|
|
2817
|
+
task_number,
|
|
2818
|
+
))
|
|
2819
|
+
|
|
2820
|
+
if not default_branch:
|
|
2821
|
+
# A read that SUCCEEDED but answered nothing is the same species of
|
|
2822
|
+
# unknown as one that raised, and it must not fall through to the
|
|
2823
|
+
# comparison below: with a non-empty `base_ref` that mismatch would
|
|
2824
|
+
# be reported as "GitHub declined to auto-close a non-default
|
|
2825
|
+
# merge", asserting a fact not in evidence -- the base may well have
|
|
2826
|
+
# BEEN the default branch; the daemon just could not read it. Held,
|
|
2827
|
+
# not skipped, so the row retries instead of silently never
|
|
2828
|
+
# repairing. Recoverable because `default_branch` deliberately does
|
|
2829
|
+
# not cache an empty answer.
|
|
2830
|
+
return self._logged(ref, Decision(
|
|
2831
|
+
Action.HELD,
|
|
2832
|
+
f"{slug}: the default branch of {issue.full_name} could not be "
|
|
2833
|
+
f"read (empty answer) — holding rather than guessing whether "
|
|
2834
|
+
f"merged {pr.pr_slug} targeted it",
|
|
2835
|
+
task_number,
|
|
2836
|
+
))
|
|
2837
|
+
|
|
2838
|
+
if not pr.base_ref or pr.base_ref != default_branch:
|
|
2839
|
+
if not pr.base_ref:
|
|
2840
|
+
# Unknown provenance cannot authorise a state mutation. Unlike
|
|
2841
|
+
# an unmeasurable grace window -- which could only be granted
|
|
2842
|
+
# FOREVER, the very hang this path exists to end -- refusing
|
|
2843
|
+
# here hangs nothing: the issue stays exactly as GitHub left it.
|
|
2844
|
+
log.warning(
|
|
2845
|
+
"%s: %s reports no base branch, so it cannot be confirmed "
|
|
2846
|
+
"to have merged into %s — refusing to close the issue on a "
|
|
2847
|
+
"merge whose target is unknown",
|
|
2848
|
+
slug, pr.pr_slug, default_branch,
|
|
2849
|
+
)
|
|
2850
|
+
return self._logged(ref, Decision(
|
|
2851
|
+
Action.SKIPPED,
|
|
2852
|
+
f"{slug}: merged {pr.pr_slug} targeted "
|
|
2853
|
+
f"{pr.base_ref or '(unreported)'}, not the default branch "
|
|
2854
|
+
f"{default_branch!r} — GitHub does not auto-close on a "
|
|
2855
|
+
f"non-default merge, so there is nothing to repair",
|
|
2856
|
+
task_number,
|
|
2857
|
+
))
|
|
2858
|
+
|
|
2859
|
+
grace = self.config.autoclose_grace_seconds
|
|
2860
|
+
merged_epoch = pr.merged_epoch
|
|
2861
|
+
if merged_epoch is not None and self._now() - merged_epoch < grace:
|
|
2862
|
+
# Deliberately NOT a WARNING and not an escalation: for the first
|
|
2863
|
+
# few minutes after a merge this state is what a perfectly healthy
|
|
2864
|
+
# repo looks like. Only outliving the window makes it an anomaly.
|
|
2865
|
+
return self._logged(ref, Decision(
|
|
2866
|
+
Action.SKIPPED,
|
|
2867
|
+
f"{slug}: closed by merged {pr.pr_slug} but still open — "
|
|
2868
|
+
f"GitHub auto-close pending, within the {grace}s grace window",
|
|
2869
|
+
task_number,
|
|
2870
|
+
))
|
|
2871
|
+
|
|
2872
|
+
# An unreadable/absent `mergedAt` lands HERE rather than in the branch
|
|
2873
|
+
# above, i.e. it heals. The window is a courtesy extended to a platform
|
|
2874
|
+
# step whose age we can measure; when the payload does not say when the
|
|
2875
|
+
# merge happened, the courtesy cannot be granted without granting it
|
|
2876
|
+
# FOREVER -- which is precisely the indefinite hang this whole change
|
|
2877
|
+
# exists to end. The downside is bounded and self-announcing: at worst
|
|
2878
|
+
# the daemon completes a close GitHub was about to perform anyway (the
|
|
2879
|
+
# close is idempotent) and says so in a comment that names the merge
|
|
2880
|
+
# time as unreported, so a spurious anomaly report is visible as one.
|
|
2881
|
+
merged = (
|
|
2882
|
+
f"at {pr.merged_at}" if merged_epoch is not None
|
|
2883
|
+
else AUTOCLOSE_MERGED_AT_UNKNOWN
|
|
2884
|
+
)
|
|
2885
|
+
if self.config.dry_run:
|
|
2886
|
+
# Sequenced here rather than with the real one below because there
|
|
2887
|
+
# is no close to sequence it against, and its prefix already cannot
|
|
2888
|
+
# be misread as a completed repair.
|
|
2889
|
+
log.warning(
|
|
2890
|
+
"[dry-run] would complete a flaked GitHub auto-close: %s is "
|
|
2891
|
+
"still open but %s merged %s declaring it closed "
|
|
2892
|
+
"(grace %ds elapsed)",
|
|
2893
|
+
slug, pr.pr_slug, merged, grace,
|
|
2894
|
+
)
|
|
2895
|
+
else:
|
|
2896
|
+
try:
|
|
2897
|
+
self.github.close_issue(issue.owner, issue.repo, issue.number)
|
|
2898
|
+
except (CommandError, RateLimited) as exc:
|
|
2899
|
+
# HOLD, never walk: `_close_loop` and everything downstream of
|
|
2900
|
+
# it assume the issue is closed -- that is what makes moving the
|
|
2901
|
+
# origin task to `pending_validation` an honest report. An
|
|
2902
|
+
# unclosed issue with a validated task is a worse state than one
|
|
2903
|
+
# more poll of waiting, and the next poll re-enters here with
|
|
2904
|
+
# the same inputs (no marker was recorded, nothing was mutated).
|
|
2905
|
+
log.error("could not close %s on GitHub's behalf: %s", slug, exc)
|
|
2906
|
+
return self._logged(ref, Decision(
|
|
2907
|
+
Action.HELD,
|
|
2908
|
+
f"{slug}: merged {pr.pr_slug} never auto-closed the issue "
|
|
2909
|
+
f"and closing it failed: {exc} — holding, no task mutation",
|
|
2910
|
+
task_number,
|
|
2911
|
+
))
|
|
2912
|
+
# AFTER the close, never before it: this line is the anomaly report,
|
|
2913
|
+
# and "completing" is the one word a log-scraper or a half-read
|
|
2914
|
+
# alert picks up. Emitted ahead of the attempt it would announce a
|
|
2915
|
+
# repair that a following ERROR then has to retract -- the anomaly
|
|
2916
|
+
# stays just as visible logged here, and is never claimed falsely.
|
|
2917
|
+
log.warning(
|
|
2918
|
+
"completed a flaked GitHub auto-close: %s was still open but "
|
|
2919
|
+
"%s merged %s declaring it closed (grace %ds elapsed)",
|
|
2920
|
+
slug, pr.pr_slug, merged, grace,
|
|
2921
|
+
)
|
|
2922
|
+
# Recorded the moment the close lands, BEFORE the best-effort
|
|
2923
|
+
# comment and before the walk: from here on the repair is done and
|
|
2924
|
+
# must never be performed twice, whatever any later step does. A
|
|
2925
|
+
# marker written after the walk would be lost on a walk that raises,
|
|
2926
|
+
# which is the one path that re-enters this branch.
|
|
2927
|
+
self.state.record_escalation(task_number, AUTOCLOSE_HEALED)
|
|
2928
|
+
# Best-effort, and AFTER the close for idempotency: a comment that
|
|
2929
|
+
# posted before a close which then failed would post again on the
|
|
2930
|
+
# retry, so the audit trail would grow one copy per failure. Losing
|
|
2931
|
+
# the comment costs an explanation the WARNING above still carries;
|
|
2932
|
+
# losing the close would cost the fix.
|
|
2933
|
+
try:
|
|
2934
|
+
self.github.comment(
|
|
2935
|
+
issue.owner, issue.repo, issue.number,
|
|
2936
|
+
AUTOCLOSE_HEALED_ISSUE_COMMENT.format(
|
|
2937
|
+
pr_url=pr.url or pr.pr_slug, merged=merged, grace=grace,
|
|
2938
|
+
),
|
|
2939
|
+
)
|
|
2940
|
+
except (CommandError, RateLimited) as exc:
|
|
2941
|
+
log.warning(
|
|
2942
|
+
"best-effort auto-close explanation comment failed on %s: %s",
|
|
2943
|
+
slug, exc,
|
|
2944
|
+
)
|
|
2945
|
+
|
|
2946
|
+
# Walk in the SAME pass, with the issue's state corrected to what the
|
|
2947
|
+
# daemon just made true. `_close_loop` only reads the issue to address
|
|
2948
|
+
# its escalation comments, but handing a stale `state="open"` to
|
|
2949
|
+
# anything downstream is a trap for whatever reads it next.
|
|
2950
|
+
return self._close_loop(
|
|
2951
|
+
task_number, ref, slug, replace(issue, state="closed"), pr
|
|
2952
|
+
)
|
|
2953
|
+
|
|
2510
2954
|
def _close_loop(
|
|
2511
2955
|
self, task_number: int, ref: str, slug: str, issue: Issue, pr: PullRequest
|
|
2512
2956
|
) -> tuple[str, Decision]:
|
|
@@ -2589,6 +3033,12 @@ class Orchestrator:
|
|
|
2589
3033
|
record). A retry after the write succeeded finds the marker and skips,
|
|
2590
3034
|
so evidence is never duplicated.
|
|
2591
3035
|
|
|
3036
|
+
The evidence body itself is the merged PR's DELIVERY RECORD, not a bare
|
|
3037
|
+
merge confirmation -- see `render_close_evidence`. It is rendered from
|
|
3038
|
+
the `pr` object this method was handed, so it adds no call and no
|
|
3039
|
+
failure mode to the walk: a PR whose write-up cannot be read still
|
|
3040
|
+
reaches `pending_validation`, carrying an explicit line saying so.
|
|
3041
|
+
|
|
2592
3042
|
Criteria satisfaction is naturally idempotent (already-satisfied ones are
|
|
2593
3043
|
skipped), so it needs no gate."""
|
|
2594
3044
|
if task.status == TASK_TODO_STATE:
|
|
@@ -2600,7 +3050,7 @@ class Orchestrator:
|
|
|
2600
3050
|
self.alissa.add_evidence(
|
|
2601
3051
|
ref,
|
|
2602
3052
|
CLOSE_EVIDENCE_TITLE,
|
|
2603
|
-
|
|
3053
|
+
render_close_evidence(slug, pr),
|
|
2604
3054
|
)
|
|
2605
3055
|
self.state.record_escalation(task_number, CLOSELOOP_EVIDENCE_DONE)
|
|
2606
3056
|
self.alissa.set_status(ref, "pending_validation")
|
|
@@ -3117,3 +3567,263 @@ def render_issue_body(
|
|
|
3117
3567
|
"""`render_issue(...).body` -- the body alone, for callers with nothing to
|
|
3118
3568
|
report about how it was rendered."""
|
|
3119
3569
|
return render_issue(task, target, scope, config, context).body
|
|
3570
|
+
|
|
3571
|
+
|
|
3572
|
+
# --- the close edge's delivery-record evidence (O15) -----------------------
|
|
3573
|
+
|
|
3574
|
+
# POSSESSIVE quantifiers (`*+`, python >= 3.11, which setup.py already requires)
|
|
3575
|
+
# on all three whitespace runs. Greedy `*` makes the tail CUBIC on a line that
|
|
3576
|
+
# starts with the heading and then fails: the engine tries every way to split
|
|
3577
|
+
# the trailing whitespace across `[ \t]* … [ \t]* … [ \t]*`. Measured on
|
|
3578
|
+
# `"## Delivery notes" + " "*n + "X"`: 20ms at n=200, 2.7s at n=800, 24.7s at
|
|
3579
|
+
# n=1600 -- and this regex runs inside `_mutate_close`, on a single-threaded
|
|
3580
|
+
# poll loop with no timeout, so a crafted body would stall the WHOLE daemon,
|
|
3581
|
+
# release edge included, on every poll forever. Possessive quantifiers cannot
|
|
3582
|
+
# backtrack, which takes the same n=65,000 input from days to 0.045ms. They are
|
|
3583
|
+
# safe here precisely because the three runs are DISJOINT character classes
|
|
3584
|
+
# (whitespace / `:` / `#`), so refusing to give text back can never starve a
|
|
3585
|
+
# later token: every tolerance case below still matches and every rejection
|
|
3586
|
+
# still fails.
|
|
3587
|
+
_DELIVERY_NOTES_RE = re.compile(
|
|
3588
|
+
rf"^[ ]{{0,3}}{re.escape(DELIVERY_NOTES_HEADING)}[ \t]*+:?[ \t]*+#*+[ \t]*+\r?$",
|
|
3589
|
+
re.IGNORECASE,
|
|
3590
|
+
)
|
|
3591
|
+
# A sibling-or-parent heading: `# ` or `## `. A `### ` subsection belongs to the
|
|
3592
|
+
# delivery notes and must NOT end them.
|
|
3593
|
+
_SECTION_END_RE = re.compile(r"^[ ]{0,3}#{1,2}[ \t]")
|
|
3594
|
+
# The delimiter and whatever follows it on the line (the "info string"), which
|
|
3595
|
+
# decides whether a fence line can CLOSE a block.
|
|
3596
|
+
_FENCE_RE = re.compile(r"^[ ]{0,3}(`{3,}|~{3,})(.*)$")
|
|
3597
|
+
|
|
3598
|
+
|
|
3599
|
+
def _fence_scan(lines: Sequence[str]) -> tuple[list[bool], str]:
|
|
3600
|
+
"""`(mask, open_fence)` for a markdown document: which lines sit in (or
|
|
3601
|
+
delimit) a fenced code block, and the delimiter still open at the end.
|
|
3602
|
+
|
|
3603
|
+
Both halves come from ONE pass on purpose. A caller that needs to close a
|
|
3604
|
+
block must close it with the delimiter that opened it, and re-deriving that
|
|
3605
|
+
by scanning backwards for "a fence line" is a guess -- the nearest fence
|
|
3606
|
+
line may be an inner one of the other character, or a shorter one, neither
|
|
3607
|
+
of which opened anything. The scan already knows; it hands the answer back
|
|
3608
|
+
rather than making the caller reconstruct it.
|
|
3609
|
+
|
|
3610
|
+
CommonMark's three closing rules, all of which matter here:
|
|
3611
|
+
|
|
3612
|
+
* **Same character.** A `~~~` inside a ``` block is content. Conflating the
|
|
3613
|
+
two ends the block early and hands the code after it back to the heading
|
|
3614
|
+
scan -- the failure the fence tracking exists to prevent.
|
|
3615
|
+
* **At least as long.** ``` cannot close a ```` block, so an inner shorter
|
|
3616
|
+
fence is content too.
|
|
3617
|
+
* **No info string.** ```` ```python ```` opens a block; it can never close
|
|
3618
|
+
one. Without this rule a `## Delivery notes` heading sitting inside an
|
|
3619
|
+
unclosed block reads as a real heading, and because the scan takes the
|
|
3620
|
+
FIRST candidate with content, a fake section early in a body would outrank
|
|
3621
|
+
the real one later -- the one direction this module promises not to err.
|
|
3622
|
+
|
|
3623
|
+
An unclosed fence runs to the end of the document, which errs toward
|
|
3624
|
+
treating more text as code and so toward including more of the body: the
|
|
3625
|
+
same direction as every other tie-break here.
|
|
3626
|
+
"""
|
|
3627
|
+
fenced = [False] * len(lines)
|
|
3628
|
+
open_fence = ""
|
|
3629
|
+
for index, line in enumerate(lines):
|
|
3630
|
+
match = _FENCE_RE.match(line)
|
|
3631
|
+
if match:
|
|
3632
|
+
delimiter, info = match.group(1), match.group(2).strip()
|
|
3633
|
+
closes = (
|
|
3634
|
+
open_fence
|
|
3635
|
+
and delimiter[0] == open_fence[0]
|
|
3636
|
+
and len(delimiter) >= len(open_fence)
|
|
3637
|
+
and not info
|
|
3638
|
+
)
|
|
3639
|
+
if closes or not open_fence:
|
|
3640
|
+
# A delimiter line is not content either way; marking it keeps
|
|
3641
|
+
# the line that OPENS a block from reading as a heading.
|
|
3642
|
+
fenced[index] = True
|
|
3643
|
+
open_fence = "" if closes else delimiter
|
|
3644
|
+
continue
|
|
3645
|
+
# A fence line that cannot close the open block IS content.
|
|
3646
|
+
fenced[index] = bool(open_fence)
|
|
3647
|
+
return fenced, open_fence
|
|
3648
|
+
|
|
3649
|
+
|
|
3650
|
+
def extract_delivery_notes(body: str) -> str | None:
|
|
3651
|
+
"""The `## Delivery notes` section of a pull-request body, or None when the
|
|
3652
|
+
body has no such section with anything under it.
|
|
3653
|
+
|
|
3654
|
+
THE TIE-BREAK, and why it is the opposite of O14's. Both this and
|
|
3655
|
+
`strip_context_section` locate a section by an ambiguous heading, but the
|
|
3656
|
+
costs are mirrored. There, a wrong START anchor SUPPRESSED a diagnostic, so
|
|
3657
|
+
every tie broke toward doing nothing. Here, "do nothing" means falling back
|
|
3658
|
+
to the FULL body -- a strict superset of any section this could have found.
|
|
3659
|
+
Guessing wrong and matching a section costs the reader the rest of the
|
|
3660
|
+
write-up; guessing wrong and matching nothing costs them some scrolling. So
|
|
3661
|
+
the heading match is STRICT: the line, stripped of up to three leading
|
|
3662
|
+
spaces and an optional trailing colon, must be exactly the heading, differing
|
|
3663
|
+
at most in case. `## Delivery notes are still missing` is prose about the
|
|
3664
|
+
notes, not the notes, and reads as absent.
|
|
3665
|
+
|
|
3666
|
+
Strict about the PROSE, tolerant about the TYPOGRAPHY, and the CRLF case is
|
|
3667
|
+
why the distinction matters. This is the only end-of-line-anchored regex in
|
|
3668
|
+
the package -- every other line-anchored parser (`TASK_TRAILER_RE`, the
|
|
3669
|
+
`markers.py` templates, `_SECTION_END_RE`, `_FENCE_RE`) is a prefix match and
|
|
3670
|
+
so already tolerates a trailing `\r`. A PR body edited in GitHub's web UI
|
|
3671
|
+
comes back CRLF-normalised, which would have made every heading read as
|
|
3672
|
+
absent and sent a PR that plainly HAS delivery notes down the full-body
|
|
3673
|
+
branch, under a heading asserting it has none. So `\r` is admitted, as is a
|
|
3674
|
+
closed ATX heading (`## Delivery notes ##`) -- both are the same class of
|
|
3675
|
+
concession as the leading spaces and the case-fold, and neither can turn
|
|
3676
|
+
prose into a match. The scan still splits on `"\n"` and nothing else, for the
|
|
3677
|
+
reason `escape_control_lines` documents: `str.splitlines()` breaks on
|
|
3678
|
+
characters `^` does not recognise and this function JOINS its result back
|
|
3679
|
+
together, so it must not be able to invent a line boundary.
|
|
3680
|
+
|
|
3681
|
+
THE TERMINATOR is the next `# ` or `## ` -- a sibling or parent section.
|
|
3682
|
+
`### ` subsections are kept: a write-up structured as `### What shipped` /
|
|
3683
|
+
`### Verification` under the notes heading is the shape the devloop
|
|
3684
|
+
directive asks for, and ending on the first `###` would take the first
|
|
3685
|
+
paragraph and drop the substance.
|
|
3686
|
+
|
|
3687
|
+
FENCED CODE IS SKIPPED, for both the heading scan and the terminator scan
|
|
3688
|
+
(see `_fence_scan`). A delivery write-up quotes shell and diffs, where a
|
|
3689
|
+
line can legitimately begin with `## `, and a code fence is the one context
|
|
3690
|
+
in markdown where a hash is not a heading.
|
|
3691
|
+
|
|
3692
|
+
EMPTY SECTIONS DO NOT COUNT. A heading with nothing under it is not a
|
|
3693
|
+
delivery record, and returning it would suppress the full-body fallback --
|
|
3694
|
+
the one failure here that actually loses information. The scan therefore
|
|
3695
|
+
continues past an empty candidate to a later one, and answers None only if
|
|
3696
|
+
no candidate has content.
|
|
3697
|
+
"""
|
|
3698
|
+
lines = (body or "").split("\n")
|
|
3699
|
+
fenced, _ = _fence_scan(lines)
|
|
3700
|
+
|
|
3701
|
+
for start, line in enumerate(lines):
|
|
3702
|
+
if fenced[start] or not _DELIVERY_NOTES_RE.match(line):
|
|
3703
|
+
continue
|
|
3704
|
+
end = len(lines)
|
|
3705
|
+
for index in range(start + 1, len(lines)):
|
|
3706
|
+
if not fenced[index] and _SECTION_END_RE.match(lines[index]):
|
|
3707
|
+
end = index
|
|
3708
|
+
break
|
|
3709
|
+
section = "\n".join(lines[start + 1:end]).strip()
|
|
3710
|
+
if section:
|
|
3711
|
+
return section
|
|
3712
|
+
return None
|
|
3713
|
+
|
|
3714
|
+
|
|
3715
|
+
def _open_fence(text: str) -> str:
|
|
3716
|
+
"""The code fence `text` leaves open, or `""` -- the delimiter itself, so
|
|
3717
|
+
the caller can close it with the characters that opened it. Taken from
|
|
3718
|
+
`_fence_scan`'s own tracking rather than re-derived: see there."""
|
|
3719
|
+
return _fence_scan(text.split("\n"))[1]
|
|
3720
|
+
|
|
3721
|
+
|
|
3722
|
+
def _truncate_record(record: str) -> str:
|
|
3723
|
+
"""`record` capped at `CLOSE_EVIDENCE_RECORD_MAX_CHARS`, with the cut marked
|
|
3724
|
+
where it happened. The counts are appended after the marker rather than
|
|
3725
|
+
inside it so the marker stays greppable as the literal string issue #36
|
|
3726
|
+
named -- and `CLOSE_EVIDENCE_TRUNCATED_RE` is the actual "something was cut"
|
|
3727
|
+
signal, since a PR body may quote the bare literal (see the constant).
|
|
3728
|
+
|
|
3729
|
+
The marker is appended LAST, after the record and after any fence this
|
|
3730
|
+
function had to close. `CLOSE_EVIDENCE_TRUNCATED_RE` is anchored to the end
|
|
3731
|
+
of the evidence on the strength of that, so anything appended after the
|
|
3732
|
+
record -- here or in `render_close_evidence` -- would make a real truncation
|
|
3733
|
+
undetectable. `test_a_real_cut_ends_the_evidence_item` pins it.
|
|
3734
|
+
|
|
3735
|
+
The counts describe what is REALLY there. The slice is `.rstrip()`ed, so
|
|
3736
|
+
reporting the cap would overstate by the whitespace dropped -- a record cut
|
|
3737
|
+
mid-whitespace-run could claim 32,000 chars included and carry seven. Only
|
|
3738
|
+
whitespace is ever lost that way, so this is about the number being exactly
|
|
3739
|
+
true rather than about content. The re-added fence below is NOT counted: it
|
|
3740
|
+
is this function's own text, not the record's.
|
|
3741
|
+
|
|
3742
|
+
A cut that lands INSIDE a fenced code block closes the fence first. The cut
|
|
3743
|
+
is a character slice, so a record whose fence opens before the cap and
|
|
3744
|
+
closes after it would otherwise leave the block open -- and the marker,
|
|
3745
|
+
appended after the cut, would render as program output inside that block.
|
|
3746
|
+
The text is present and greppable either way, so this is about the reader
|
|
3747
|
+
NOTICING, which is the whole claim "no silent caps" makes.
|
|
3748
|
+
"""
|
|
3749
|
+
original = len(record)
|
|
3750
|
+
if original <= CLOSE_EVIDENCE_RECORD_MAX_CHARS:
|
|
3751
|
+
return record
|
|
3752
|
+
kept = record[:CLOSE_EVIDENCE_RECORD_MAX_CHARS].rstrip()
|
|
3753
|
+
cut = kept
|
|
3754
|
+
fence = _open_fence(kept)
|
|
3755
|
+
if fence:
|
|
3756
|
+
cut += f"\n{fence}"
|
|
3757
|
+
return (
|
|
3758
|
+
cut
|
|
3759
|
+
+ f"\n\n{CLOSE_EVIDENCE_TRUNCATED_MARKER} "
|
|
3760
|
+
+ f"({len(kept)} of {original} chars included)"
|
|
3761
|
+
)
|
|
3762
|
+
|
|
3763
|
+
|
|
3764
|
+
def render_close_evidence(slug: str, pr: PullRequest) -> str:
|
|
3765
|
+
"""The close edge's evidence markdown: the merge confirmation, the two
|
|
3766
|
+
anchor lines, and the merged PR's delivery record.
|
|
3767
|
+
|
|
3768
|
+
Costs no round-trip. `GitHub.closing_merged_pr` already re-fetched the PR to
|
|
3769
|
+
decide it was the closer, and that payload carries the body and the merge
|
|
3770
|
+
SHA -- so the record is assembled from the object the close walk is holding,
|
|
3771
|
+
not fetched again. A second fetch would add a failure mode to a path whose
|
|
3772
|
+
whole point is that it must not acquire one.
|
|
3773
|
+
|
|
3774
|
+
DEGRADE, NEVER BLOCK. Every field is rendered from what the PR object
|
|
3775
|
+
actually has: a missing title and a missing merge SHA each say so in place,
|
|
3776
|
+
and a missing BODY drops the record for
|
|
3777
|
+
`CLOSE_EVIDENCE_UNRETRIEVABLE` -- today's evidence plus an explicit line.
|
|
3778
|
+
None of the three raises, because the caller is mid-`_mutate_close`: the
|
|
3779
|
+
merge has happened, the criteria are satisfied, and an exception here would
|
|
3780
|
+
strand the origin task short of `pending_validation` over a write-up.
|
|
3781
|
+
|
|
3782
|
+
THE INCLUDED BODY IS NOT ESCAPED, and the reason is narrower than "nothing
|
|
3783
|
+
parses evidence". O14 blockquotes control lines because an ISSUE body is
|
|
3784
|
+
read back by orcloop's own line-anchored trailer parsers; task evidence is
|
|
3785
|
+
not -- `add_evidence` is write-only and nothing in this package reads
|
|
3786
|
+
evidence back, so no text arriving here can reach a parser that decides
|
|
3787
|
+
anything. That is the claim, and it is about ORCLOOP. The evidence IS read
|
|
3788
|
+
by the operator's soft validator, which is an LLM, so this text is parsed --
|
|
3789
|
+
just not by anything whose answer moves a task. Anyone adding a machine
|
|
3790
|
+
consumer of evidence inherits no guarantee from this function. Note also
|
|
3791
|
+
what reaching this text costs an attacker: the close edge only fires on
|
|
3792
|
+
issues orcloop itself released, and the body must belong to a PR MERGED into
|
|
3793
|
+
that repo.
|
|
3794
|
+
|
|
3795
|
+
On the empty-body branch being defensive: through today's resolution path a
|
|
3796
|
+
resolved closer cannot have an empty body, because `closing_merged_pr`
|
|
3797
|
+
matched a closing keyword IN that body. That is a guarantee held by another
|
|
3798
|
+
function, and this one's contract -- evidence that never lies about what it
|
|
3799
|
+
contains -- must not depend on it. `GitHub.pull_request` degrades an absent
|
|
3800
|
+
`body` field to `""`, so a payload that omits it, or any future resolution
|
|
3801
|
+
rule that does not read the body, lands here and says so rather than
|
|
3802
|
+
emitting a heading with nothing under it.
|
|
3803
|
+
"""
|
|
3804
|
+
parts = [
|
|
3805
|
+
CLOSE_EVIDENCE_BODY.format(slug=slug, pr_url=pr.url),
|
|
3806
|
+
CLOSE_EVIDENCE_PR_LINE.format(
|
|
3807
|
+
pr_slug=pr.pr_slug, title=pr.title.strip() or CLOSE_EVIDENCE_TITLE_MISSING
|
|
3808
|
+
),
|
|
3809
|
+
CLOSE_EVIDENCE_SHA_LINE.format(
|
|
3810
|
+
sha=pr.merge_sha.strip() or CLOSE_EVIDENCE_SHA_MISSING
|
|
3811
|
+
),
|
|
3812
|
+
]
|
|
3813
|
+
body = (pr.body or "").strip()
|
|
3814
|
+
if not body:
|
|
3815
|
+
log.warning(
|
|
3816
|
+
"%s: the merged pull request %s carries no readable body — close "
|
|
3817
|
+
"evidence attached WITHOUT a delivery record (the close-loop "
|
|
3818
|
+
"hand-off is unaffected)",
|
|
3819
|
+
slug, pr.url or pr.pr_slug,
|
|
3820
|
+
)
|
|
3821
|
+
parts.append(CLOSE_EVIDENCE_UNRETRIEVABLE)
|
|
3822
|
+
return "\n\n".join(parts)
|
|
3823
|
+
|
|
3824
|
+
notes = extract_delivery_notes(body)
|
|
3825
|
+
heading = (
|
|
3826
|
+
CLOSE_EVIDENCE_NOTES_HEADING if notes is not None else CLOSE_EVIDENCE_FULL_HEADING
|
|
3827
|
+
)
|
|
3828
|
+
parts.append(f"{heading}\n\n{_truncate_record(notes if notes is not None else body)}")
|
|
3829
|
+
return "\n\n".join(parts)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.3.10
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.3.8
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|