alissa-tools-github-orcloop 0.3.9__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.
Files changed (23) hide show
  1. {alissa_tools_github_orcloop-0.3.9/src/main/alissa_tools_github_orcloop.egg-info → alissa_tools_github_orcloop-0.3.10}/PKG-INFO +1 -1
  2. {alissa_tools_github_orcloop-0.3.9 → alissa_tools_github_orcloop-0.3.10}/src/main/alissa/tools/github/orcloop/__main__.py +13 -0
  3. {alissa_tools_github_orcloop-0.3.9 → alissa_tools_github_orcloop-0.3.10}/src/main/alissa/tools/github/orcloop/config.py +35 -0
  4. {alissa_tools_github_orcloop-0.3.9 → alissa_tools_github_orcloop-0.3.10}/src/main/alissa/tools/github/orcloop/ghclient.py +207 -20
  5. {alissa_tools_github_orcloop-0.3.9 → alissa_tools_github_orcloop-0.3.10}/src/main/alissa/tools/github/orcloop/loop.py +332 -5
  6. alissa_tools_github_orcloop-0.3.10/src/main/alissa/tools/github/orcloop/version +1 -0
  7. {alissa_tools_github_orcloop-0.3.9 → alissa_tools_github_orcloop-0.3.10/src/main/alissa_tools_github_orcloop.egg-info}/PKG-INFO +1 -1
  8. alissa_tools_github_orcloop-0.3.9/src/main/alissa/tools/github/orcloop/version +0 -1
  9. {alissa_tools_github_orcloop-0.3.9 → alissa_tools_github_orcloop-0.3.10}/MANIFEST.in +0 -0
  10. {alissa_tools_github_orcloop-0.3.9 → alissa_tools_github_orcloop-0.3.10}/README.md +0 -0
  11. {alissa_tools_github_orcloop-0.3.9 → alissa_tools_github_orcloop-0.3.10}/requirements.txt +0 -0
  12. {alissa_tools_github_orcloop-0.3.9 → alissa_tools_github_orcloop-0.3.10}/setup.cfg +0 -0
  13. {alissa_tools_github_orcloop-0.3.9 → alissa_tools_github_orcloop-0.3.10}/setup.py +0 -0
  14. {alissa_tools_github_orcloop-0.3.9 → alissa_tools_github_orcloop-0.3.10}/src/main/alissa/tools/github/orcloop/__init__.py +0 -0
  15. {alissa_tools_github_orcloop-0.3.9 → alissa_tools_github_orcloop-0.3.10}/src/main/alissa/tools/github/orcloop/alissa_client.py +0 -0
  16. {alissa_tools_github_orcloop-0.3.9 → alissa_tools_github_orcloop-0.3.10}/src/main/alissa/tools/github/orcloop/markers.py +0 -0
  17. {alissa_tools_github_orcloop-0.3.9 → alissa_tools_github_orcloop-0.3.10}/src/main/alissa/tools/github/orcloop/proc.py +0 -0
  18. {alissa_tools_github_orcloop-0.3.9 → alissa_tools_github_orcloop-0.3.10}/src/main/alissa/tools/github/orcloop/state.py +0 -0
  19. {alissa_tools_github_orcloop-0.3.9 → alissa_tools_github_orcloop-0.3.10}/src/main/alissa/tools/github/orcloop/version.py +0 -0
  20. {alissa_tools_github_orcloop-0.3.9 → alissa_tools_github_orcloop-0.3.10}/src/main/alissa_tools_github_orcloop.egg-info/SOURCES.txt +0 -0
  21. {alissa_tools_github_orcloop-0.3.9 → alissa_tools_github_orcloop-0.3.10}/src/main/alissa_tools_github_orcloop.egg-info/dependency_links.txt +0 -0
  22. {alissa_tools_github_orcloop-0.3.9 → alissa_tools_github_orcloop-0.3.10}/src/main/alissa_tools_github_orcloop.egg-info/entry_points.txt +0 -0
  23. {alissa_tools_github_orcloop-0.3.9 → alissa_tools_github_orcloop-0.3.10}/src/main/alissa_tools_github_orcloop.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-orcloop
3
- Version: 0.3.9
3
+ Version: 0.3.10
4
4
  Summary: ALISSA-TOOLS-GITHUB-ORCLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -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, and --
5
- for the close-loop -- find the MERGED pull request that closed a released issue.
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:
@@ -132,6 +177,20 @@ class PullRequest:
132
177
  # meaningful only on a merged PR, and a payload that omits it must degrade
133
178
  # to "unknown" in the evidence rather than fail the close walk.
134
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 = ""
135
194
 
136
195
  @property
137
196
  def full_name(self) -> str:
@@ -141,6 +200,31 @@ class PullRequest:
141
200
  def pr_slug(self) -> str:
142
201
  return f"{self.owner}/{self.repo}#{self.number}"
143
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
+
144
228
 
145
229
  class RateLimited(RuntimeError):
146
230
  """A `gh` rate-limit signal -- a retry-later condition, distinct from a
@@ -155,6 +239,11 @@ def _is_rate_limited(exc: proc.CommandError) -> bool:
155
239
  class GitHub:
156
240
  SEARCH_PER_PAGE = 100
157
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
+
158
247
  def _api(self, *args: str, timeout: int = 60):
159
248
  try:
160
249
  return proc.run_json(["gh", "api", *args], timeout=timeout)
@@ -348,6 +437,38 @@ class GitHub:
348
437
  f"repos/{owner}/{repo}/issues/{number}/comments", "-f", f"body={body}"
349
438
  )
350
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
+
351
472
  # --- the close-loop: the merged-PR walk --------------------------------
352
473
 
353
474
  def closing_merged_pr(self, owner: str, repo: str, number: int) -> PullRequest | None:
@@ -372,15 +493,55 @@ class GitHub:
372
493
  wedge the close decision. Newest-referenced PR wins ties -- the last
373
494
  cross-reference is the one most likely to be the real closer.
374
495
  """
375
- candidates = self._referenced_pr_numbers(owner, repo, number)
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)
376
515
  pattern = _closes_re(number)
377
516
  for pr_number in candidates[:MAX_PR_CHECKS]:
378
517
  pr = self.pull_request(owner, repo, pr_number)
379
518
  if pr is None or not pr.merged:
380
519
  continue
381
520
  if pattern.search(pr.body):
382
- return pr
383
- 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
384
545
 
385
546
  def pull_request(self, owner: str, repo: str, number: int) -> PullRequest | None:
386
547
  """One PR's close-loop-relevant fields, or None if it cannot be read.
@@ -388,12 +549,12 @@ class GitHub:
388
549
  -- the close-loop treats "unreadable" as "not the closer, keep
389
550
  looking", never as a merge.
390
551
 
391
- `body` and `merge_sha` come from this ONE payload, which is why the
392
- close edge's delivery-record evidence (O15) costs no extra round-trip:
393
- the PR the close walk already resolved carries everything the evidence
394
- needs. A field the payload omits degrades to the empty string here and
395
- is reported as missing at the render site -- never fabricated, never
396
- fetched a second time."""
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."""
397
558
  data = self._api(f"repos/{owner}/{repo}/pulls/{number}")
398
559
  if not isinstance(data, dict) or not data:
399
560
  return None
@@ -407,13 +568,29 @@ class GitHub:
407
568
  merged=bool(data.get("merged") or data.get("merged_at")),
408
569
  url=data.get("html_url") or "",
409
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 ""),
410
574
  )
411
575
 
412
- def _referenced_pr_numbers(self, owner: str, repo: str, number: int) -> list[int]:
413
- """Same-repo PR numbers cross-referenced by the issue timeline, newest
414
- last, deduped -- the candidate set for `closing_merged_pr`."""
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`."""
415
591
  same_repo_pull = f"https://github.com/{owner}/{repo}/pull/"
416
592
  numbers: list[int] = []
593
+ ever_closed = False
417
594
  for page in range(1, TIMELINE_MAX_PAGES + 1):
418
595
  events = self._api(
419
596
  "-X", "GET", f"repos/{owner}/{repo}/issues/{number}/timeline",
@@ -422,7 +599,11 @@ class GitHub:
422
599
  if not isinstance(events, list):
423
600
  break
424
601
  for event in events:
425
- if not isinstance(event, dict) or event.get("event") != "cross-referenced":
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":
426
607
  continue
427
608
  source = event.get("source")
428
609
  src = source.get("issue") if isinstance(source, dict) else None
@@ -438,19 +619,24 @@ class GitHub:
438
619
  if len(events) < TIMELINE_PAGE_SIZE:
439
620
  break # short page: the timeline is exhausted
440
621
  # Dedupe keeping LAST occurrence: the most recent cross-reference is the
441
- # likeliest closer, and closing_merged_pr checks candidates in order.
442
- 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
443
624
 
444
625
  # --- helpers -----------------------------------------------------------
445
626
 
446
627
  def _post_json(self, path: str, payload: dict):
447
- """POST a JSON body via `--input <tempfile>`, cleaning the file up on
448
- every path (success or raise)."""
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."""
449
635
  fd, body_path = tempfile.mkstemp(prefix="orcloop-", suffix=".json")
450
636
  try:
451
637
  with os.fdopen(fd, "w") as handle:
452
638
  handle.write(json.dumps(payload))
453
- return self._api("-X", "POST", path, "--input", body_path)
639
+ return self._api("-X", method, path, "--input", body_path)
454
640
  finally:
455
641
  os.unlink(body_path)
456
642
 
@@ -469,6 +655,7 @@ class GitHub:
469
655
  (lb.get("name") or "") if isinstance(lb, dict) else str(lb)
470
656
  for lb in (data.get("labels") or [])
471
657
  ),
658
+ state_reason=str(data.get("state_reason") or ""),
472
659
  )
473
660
 
474
661
 
@@ -213,7 +213,19 @@ 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, nothing to do.
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 ->
@@ -285,7 +297,7 @@ import logging
285
297
  import re
286
298
  import time
287
299
  from collections import Counter
288
- from dataclasses import dataclass
300
+ from dataclasses import dataclass, replace
289
301
  from enum import Enum
290
302
  from functools import lru_cache
291
303
  from typing import Callable, Sequence
@@ -468,6 +480,15 @@ CLOSELOOP_DONE = "closeloop-done"
468
480
  # only after `add_evidence` succeeds, so a failed write retries next poll.
469
481
  CLOSELOOP_EVIDENCE_DONE = "closeloop-evidence-done"
470
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
+
471
492
  # The origin status the close-loop mutation itself hands off to. Seeing the task
472
493
  # already here (before CLOSELOOP_DONE was recorded) means a prior pass completed
473
494
  # the mutation but died before the completion marker -- orcloop's OWN transition,
@@ -613,6 +634,30 @@ CLOSELOOP_FAILED_ISSUE_COMMENT = (
613
634
  "Retrying; operator help may be needed."
614
635
  )
615
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
+
616
661
  DEGRADED_DISCOVERY_REASON = (
617
662
  "release discovery was incomplete this pass ({count} feed body(ies) of "
618
663
  "work unreadable), so the two-body-of-work ambiguity check could not run "
@@ -2596,9 +2641,7 @@ class Orchestrator:
2596
2641
  f"{slug}: label removed by operator — parked, never re-labeling",
2597
2642
  task_number,
2598
2643
  ))
2599
- return self._logged(ref, Decision(
2600
- Action.SKIPPED, f"{slug}: released issue still open — in flight", task_number,
2601
- ))
2644
+ return self._evaluate_open_released(task_number, ref, slug, issue)
2602
2645
 
2603
2646
  try:
2604
2647
  pr = self.github.closing_merged_pr(owner, repo, number)
@@ -2624,6 +2667,290 @@ class Orchestrator:
2624
2667
 
2625
2668
  return self._close_loop(task_number, ref, slug, issue, pr)
2626
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
+
2627
2954
  def _close_loop(
2628
2955
  self, task_number: int, ref: str, slug: str, issue: Issue, pr: PullRequest
2629
2956
  ) -> tuple[str, Decision]:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-orcloop
3
- Version: 0.3.9
3
+ Version: 0.3.10
4
4
  Summary: ALISSA-TOOLS-GITHUB-ORCLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera