Release 0.2.0: graduation merge race fix + VITE_APP_NAME + versioning infrastructure
- Fix §13.3 step 4 race against Gitea's async mergeability computation: gitea.wait_for_mergeable polls the mergeable field before calling merge, and bot._merge_with_retry rides out the transient 'Please try again later' response. - Frontend now requires VITE_APP_NAME at build time (frontend/vite.config.js fails the build if unset). Header brand, landing H1, and browser title read the deployment's configured name. - Establish framework versioning: VERSION (canonical) + frontend/package.json#version mirror; CHANGELOG.md as the release log with RFC 2119 / 8174 normative-language upgrade-steps; SPEC.md §20 as the binding policy; CLAUDE.md capturing the separation-of-concerns rule; docs/DEPLOYMENTS.md as the downstream operating manual; docs/QUICKSTART-VISION.md staking the future one-command-deploy capability. - Surgical fix to SPEC.md opening so the spec no longer asserts OHM is the corpus the framework produces. - SPEC.md §19.2 gains the 'Deployment-supplied subject framing' candidate that drives the 0.3.0 release.
This commit is contained in:
+78
-7
@@ -22,11 +22,67 @@ anywhere.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
from . import db, notify
|
||||
from .gitea import Gitea
|
||||
from .gitea import Gitea, GiteaError
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_MERGE_TRANSIENT_HINTS = (
|
||||
"please try again later",
|
||||
"is not ready",
|
||||
"not ready to be merged",
|
||||
)
|
||||
|
||||
|
||||
async def _merge_with_retry(
|
||||
gitea: Gitea,
|
||||
org: str,
|
||||
meta_repo: str,
|
||||
pr_number: int,
|
||||
*,
|
||||
merge_message_title: str,
|
||||
merge_message_body: str,
|
||||
attempts: int = 3,
|
||||
delay_seconds: float = 1.0,
|
||||
) -> None:
|
||||
"""Call gitea.merge_pull and retry on Gitea's transient
|
||||
"Please try again later" / "not ready" responses. After
|
||||
`wait_for_mergeable` returns this is rare, but Gitea has been
|
||||
observed to flip back to the unready state momentarily on a
|
||||
write-amplified instance; a small bounded retry closes the gap.
|
||||
|
||||
Any other GiteaError propagates immediately so the §13.3
|
||||
orchestrator can fail the step and run rollback.
|
||||
"""
|
||||
last_error: GiteaError | None = None
|
||||
for attempt in range(1, attempts + 1):
|
||||
try:
|
||||
await gitea.merge_pull(
|
||||
org, meta_repo, pr_number,
|
||||
merge_message_title=merge_message_title,
|
||||
merge_message_body=merge_message_body,
|
||||
style="merge",
|
||||
)
|
||||
return
|
||||
except GiteaError as e:
|
||||
detail = (e.detail or "").lower()
|
||||
if not any(hint in detail for hint in _MERGE_TRANSIENT_HINTS):
|
||||
raise
|
||||
last_error = e
|
||||
log.warning(
|
||||
"merge_pull transient (attempt %d/%d) for %s/%s#%d: %s",
|
||||
attempt, attempts, org, meta_repo, pr_number, e.detail,
|
||||
)
|
||||
if attempt < attempts:
|
||||
await asyncio.sleep(delay_seconds)
|
||||
assert last_error is not None
|
||||
raise last_error
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -823,14 +879,29 @@ class Bot:
|
||||
) -> None:
|
||||
"""§13.3 step 4: auto-merge the graduation PR with the admin as
|
||||
merge actor. Distinct action_kind so the audit log carries the
|
||||
graduation as a linkable sequence per §13.3's transactional shape."""
|
||||
graduation as a linkable sequence per §13.3's transactional shape.
|
||||
|
||||
Gitea computes a PR's mergeability asynchronously in a background
|
||||
job. Step 3 (`open_graduation_pr`) returns the moment the PR is
|
||||
created, which is typically before that job has finished — calling
|
||||
`merge_pull` immediately produces a `405 "Please try again later"`
|
||||
and the §13.3 orchestrator interprets it as a terminal failure.
|
||||
We wait for the mergeability computation to settle before the
|
||||
merge call, then retry the merge a couple of times if Gitea still
|
||||
reports the transient state (belt-and-suspenders against the same
|
||||
race appearing in a different shape).
|
||||
"""
|
||||
subject = f"Graduate {slug} → {rfc_id}"
|
||||
body = _trailer(actor)
|
||||
await self._gitea.merge_pull(
|
||||
org, meta_repo, pr_number,
|
||||
merge_message_title=subject,
|
||||
merge_message_body=body,
|
||||
style="merge",
|
||||
|
||||
try:
|
||||
await self._gitea.wait_for_mergeable(org, meta_repo, pr_number)
|
||||
except TimeoutError as e:
|
||||
raise GiteaError(409, str(e)) from e
|
||||
|
||||
await _merge_with_retry(
|
||||
self._gitea, org, meta_repo, pr_number,
|
||||
merge_message_title=subject, merge_message_body=body,
|
||||
)
|
||||
_log(
|
||||
actor,
|
||||
|
||||
@@ -12,13 +12,17 @@ and never reaches around it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from .config import Config
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GiteaError(Exception):
|
||||
def __init__(self, status: int, detail: str):
|
||||
@@ -247,6 +251,43 @@ class Gitea:
|
||||
},
|
||||
)
|
||||
|
||||
async def wait_for_mergeable(
|
||||
self,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int,
|
||||
*,
|
||||
timeout_seconds: float = 30.0,
|
||||
poll_interval_seconds: float = 0.5,
|
||||
) -> bool:
|
||||
"""Block until Gitea finishes computing the PR's mergeability,
|
||||
or the timeout expires.
|
||||
|
||||
Gitea computes the `mergeable` field in a background job after a
|
||||
PR is opened or its head commit changes. Until that job finishes,
|
||||
`mergeable` is `null` and `POST /pulls/{n}/merge` rejects with
|
||||
`405 "Please try again later"`. Callers about to merge should
|
||||
wait here first so the merge call doesn't race the computation.
|
||||
|
||||
Returns the final `mergeable` value (True / False). Raises
|
||||
TimeoutError if the field never becomes non-null within the
|
||||
budget — the caller can then surface a retryable error to the
|
||||
operator instead of silently failing the merge step.
|
||||
"""
|
||||
deadline = asyncio.get_event_loop().time() + timeout_seconds
|
||||
while True:
|
||||
pr = await self.get_pull(owner, repo, number)
|
||||
if pr is not None:
|
||||
mergeable = pr.get("mergeable")
|
||||
if mergeable is not None:
|
||||
return bool(mergeable)
|
||||
if asyncio.get_event_loop().time() >= deadline:
|
||||
raise TimeoutError(
|
||||
f"Gitea did not compute mergeability for "
|
||||
f"{owner}/{repo}#{number} within {timeout_seconds:.1f}s"
|
||||
)
|
||||
await asyncio.sleep(poll_interval_seconds)
|
||||
|
||||
async def close_pull(self, owner: str, repo: str, number: int) -> None:
|
||||
await self._request(
|
||||
"PATCH",
|
||||
|
||||
Reference in New Issue
Block a user