Pin approved head in Gitea merge request
This commit is contained in:
+3
-2
@@ -1,7 +1,7 @@
|
|||||||
# NestQuest controller
|
# NestQuest controller
|
||||||
|
|
||||||
OpenCode 1.18.31 loads the three named agents from `.opencode/agents/`. Their
|
OpenCode 1.18.31 loads the three named agents from `.opencode/agents/`. Their
|
||||||
models match the current NestQuest CTXD prompt (version 6): DeepSeek V4.1
|
models match the current NestQuest CTXD prompt: DeepSeek V4.1
|
||||||
Flash for controller and approver, GLM 5.3 Flash for development. The
|
Flash for controller and approver, GLM 5.3 Flash for development. The
|
||||||
controller runs one OpenCode turn at a time, then starts a fresh turn based on
|
controller runs one OpenCode turn at a time, then starts a fresh turn based on
|
||||||
Maestro, CTXD, Git, and pull-request state. Its state file is
|
Maestro, CTXD, Git, and pull-request state. Its state file is
|
||||||
@@ -16,7 +16,8 @@ The approver's shell permission permits only the installed
|
|||||||
`nq-approve-merge` broker for Gitea. That broker requires exact-head
|
`nq-approve-merge` broker for Gitea. That broker requires exact-head
|
||||||
Claude approval evidence, an exact-head Gitea approval by `review_agent`,
|
Claude approval evidence, an exact-head Gitea approval by `review_agent`,
|
||||||
the `dev` target, a mergeable PR, and passing configured checks before it
|
the `dev` target, a mergeable PR, and passing configured checks before it
|
||||||
requests a squash merge. `scripts/nq-review` launches the independent
|
requests a squash merge with Gitea's atomic `head_commit_id` guard.
|
||||||
|
`scripts/nq-review` launches the independent
|
||||||
Claude review in a noninteractive read-only session in the task worktree,
|
Claude review in a noninteractive read-only session in the task worktree,
|
||||||
checks the head again afterward, saves
|
checks the head again afterward, saves
|
||||||
the output under `/tmp/opencode`, and posts the Gitea approval.
|
the output under `/tmp/opencode`, and posts the Gitea approval.
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ class MergeGateTests(unittest.TestCase):
|
|||||||
with self.assertRaises(RuntimeError):
|
with self.assertRaises(RuntimeError):
|
||||||
APPROVE["main"]()
|
APPROVE["main"]()
|
||||||
self.assertEqual(merged, problem == "none")
|
self.assertEqual(merged, problem == "none")
|
||||||
|
if problem == "none":
|
||||||
|
merge_calls = [payload for path, payload in calls if path.endswith("/merge")]
|
||||||
|
self.assertEqual(merge_calls,
|
||||||
|
[{"do": "squash", "head_commit_id": HEAD}])
|
||||||
|
|
||||||
def test_review_evidence_must_match_exact_head_and_approval(self):
|
def test_review_evidence_must_match_exact_head_and_approval(self):
|
||||||
root = Path("/tmp/opencode")
|
root = Path("/tmp/opencode")
|
||||||
|
|||||||
@@ -32,6 +32,18 @@ class ControllerProtocolTests(unittest.TestCase):
|
|||||||
record["project_id"] = "wrong"
|
record["project_id"] = "wrong"
|
||||||
with self.assertRaisesRegex(RuntimeError, "wrong NestQuest prompt"):
|
with self.assertRaisesRegex(RuntimeError, "wrong NestQuest prompt"):
|
||||||
CONTROL["check_ctxd"]()
|
CONTROL["check_ctxd"]()
|
||||||
|
record["project_id"] = CONTROL["CTXD_PROJECT"]
|
||||||
|
config.write_text(json.dumps({"mcp": {"ctxd": {
|
||||||
|
"url": "http://localhost:9091/mcp/",
|
||||||
|
"headers": {"Authorization": "Bearer test"},
|
||||||
|
}}}))
|
||||||
|
self.assertEqual(CONTROL["check_ctxd"](), 6)
|
||||||
|
config.write_text(json.dumps({"mcp": {"ctxd": {
|
||||||
|
"url": "http://localhost:9091/invalid",
|
||||||
|
"headers": {"Authorization": "Bearer test"},
|
||||||
|
}}}))
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "must end in /mcp"):
|
||||||
|
CONTROL["check_ctxd"]()
|
||||||
|
|
||||||
def test_installed_opencode_text_event(self):
|
def test_installed_opencode_text_event(self):
|
||||||
event = {"type": "text", "sessionID": "ses_test", "part": {"type": "text", "text": "Next task queued.\nORCHESTRATOR_STATE: CONTINUE\n"}}
|
event = {"type": "text", "sessionID": "ses_test", "part": {"type": "text", "text": "Next task queued.\nORCHESTRATOR_STATE: CONTINUE\n"}}
|
||||||
@@ -73,6 +85,14 @@ class ControllerProtocolTests(unittest.TestCase):
|
|||||||
db.execute("INSERT INTO session VALUES (?, ?)", ("ses_test", str(project)))
|
db.execute("INSERT INTO session VALUES (?, ?)", ("ses_test", str(project)))
|
||||||
(entry / "cmdline").write_bytes(b"opencode\0--dir\0" + str(project).encode() + b"\0")
|
(entry / "cmdline").write_bytes(b"opencode\0--dir\0" + str(project).encode() + b"\0")
|
||||||
self.assertTrue(CONTROL["interactive_nestquest_session"](project, proc, database))
|
self.assertTrue(CONTROL["interactive_nestquest_session"](project, proc, database))
|
||||||
|
(entry / "cmdline").write_bytes(b"opencode\0web\0--dir\0" + str(project).encode() + b"\0")
|
||||||
|
self.assertTrue(CONTROL["interactive_nestquest_session"](project, proc, database))
|
||||||
|
(entry / "cmdline").write_bytes(b"opencode\0serve\0")
|
||||||
|
(entry / "cwd").unlink()
|
||||||
|
(entry / "cwd").symlink_to(project)
|
||||||
|
self.assertTrue(CONTROL["interactive_nestquest_session"](project, proc, database))
|
||||||
|
(entry / "cwd").unlink()
|
||||||
|
(entry / "cwd").symlink_to(root)
|
||||||
(entry / "cmdline").write_bytes(b"opencode\0-s\0ses_test\0")
|
(entry / "cmdline").write_bytes(b"opencode\0-s\0ses_test\0")
|
||||||
self.assertTrue(CONTROL["interactive_nestquest_session"](project, proc, database))
|
self.assertTrue(CONTROL["interactive_nestquest_session"](project, proc, database))
|
||||||
(entry / "cmdline").write_bytes(b"opencode\0--dir\0" + str(root).encode() + b"\0")
|
(entry / "cmdline").write_bytes(b"opencode\0--dir\0" + str(root).encode() + b"\0")
|
||||||
|
|||||||
@@ -113,10 +113,12 @@ def main() -> int:
|
|||||||
raise RuntimeError("a configured commit check has not passed")
|
raise RuntimeError("a configured commit check has not passed")
|
||||||
# Gitea rechecks branch protection at the merge endpoint. The caller must
|
# Gitea rechecks branch protection at the merge endpoint. The caller must
|
||||||
# independently verify Maestro and task-level evidence before invoking us.
|
# independently verify Maestro and task-level evidence before invoking us.
|
||||||
request(f"/pulls/{args.pr}/merge", token, {"Do": "squash"})
|
# Gitea checks head_commit_id atomically while processing the merge.
|
||||||
|
request(f"/pulls/{args.pr}/merge", token,
|
||||||
|
{"do": "squash", "head_commit_id": args.reviewed_head})
|
||||||
after = inspect(args.pr, token)
|
after = inspect(args.pr, token)
|
||||||
if after["merged"] is not True:
|
if after["merged"] is not True or after["head"] != args.reviewed_head:
|
||||||
raise RuntimeError("merge request returned, but the PR is not merged")
|
raise RuntimeError("merge result is missing or differs from the reviewed head")
|
||||||
print(json.dumps(after, sort_keys=True))
|
print(json.dumps(after, sort_keys=True))
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,10 @@ def check_ctxd() -> int:
|
|||||||
"""Use OpenCode's configured endpoint, never an unrelated host DB."""
|
"""Use OpenCode's configured endpoint, never an unrelated host DB."""
|
||||||
config = json.loads((Path.home() / ".config/opencode/opencode.json").read_text())
|
config = json.loads((Path.home() / ".config/opencode/opencode.json").read_text())
|
||||||
mcp = config["mcp"]["ctxd"]
|
mcp = config["mcp"]["ctxd"]
|
||||||
endpoint = mcp["url"].removesuffix("/mcp")
|
configured_url = mcp["url"].rstrip("/")
|
||||||
|
if not configured_url.endswith("/mcp"):
|
||||||
|
raise RuntimeError("CTXD MCP URL must end in /mcp")
|
||||||
|
endpoint = configured_url.removesuffix("/mcp")
|
||||||
url = f"{endpoint}/projects/{CTXD_PROJECT}/files/{CTXD_FILE}"
|
url = f"{endpoint}/projects/{CTXD_PROJECT}/files/{CTXD_FILE}"
|
||||||
with urllib.request.urlopen(
|
with urllib.request.urlopen(
|
||||||
urllib.request.Request(url, headers=mcp["headers"]), timeout=8
|
urllib.request.Request(url, headers=mcp["headers"]), timeout=8
|
||||||
@@ -65,7 +68,7 @@ def interactive_nestquest_session(
|
|||||||
args = [a.decode(errors="replace") for a in args if a]
|
args = [a.decode(errors="replace") for a in args if a]
|
||||||
if not args or Path(args[0]).name != "opencode":
|
if not args or Path(args[0]).name != "opencode":
|
||||||
continue
|
continue
|
||||||
if len(args) > 1 and args[1] in ("run", "debug", "serve", "web"):
|
if len(args) > 1 and args[1] in ("run", "debug"):
|
||||||
continue
|
continue
|
||||||
session = None
|
session = None
|
||||||
for flag in ("-s", "--session"):
|
for flag in ("-s", "--session"):
|
||||||
|
|||||||
Reference in New Issue
Block a user